From fd1dd6d63ed94c06acc3a4e8620c224d9ea509b3 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Jan 2026 18:08:55 +0800 Subject: [PATCH] impl 013-logseq-cli-datascript-query.md (2) --- .../013-logseq-cli-datascript-query.md | 91 +++++++++ src/main/logseq/cli/command/add.cljs | 106 +++++++++-- src/main/logseq/cli/command/query.cljs | 177 +++++++++++++++++- src/main/logseq/cli/commands.cljs | 12 +- src/main/logseq/cli/format.cljs | 13 ++ src/main/logseq/cli/main.cljs | 9 +- src/test/logseq/cli/command/query_test.cljs | 109 ++++++++++- src/test/logseq/cli/commands_test.cljs | 17 +- src/test/logseq/cli/format_test.cljs | 14 ++ src/test/logseq/cli/integration_test.cljs | 69 +++++++ 10 files changed, 582 insertions(+), 35 deletions(-) diff --git a/docs/agent-guide/013-logseq-cli-datascript-query.md b/docs/agent-guide/013-logseq-cli-datascript-query.md index 2bd4bc730b..9fb2903dc3 100644 --- a/docs/agent-guide/013-logseq-cli-datascript-query.md +++ b/docs/agent-guide/013-logseq-cli-datascript-query.md @@ -57,7 +57,98 @@ The unit tests will assert that parsing rejects invalid EDN for --inputs, that a - Return datascript-query results without transformation. - Keep all argument parsing and validation inside query command module using --query and --inputs. - Keep db-worker-node changes to zero unless a new worker API is required. +- Add `custom-queries` to cli.edn for storing pre-defined Datascript queries that the CLI can list and run by name. +- Add built-in queries that appear in the query list alongside custom queries. +- Optional inputs should support default values in cli.edn, and built-in queries should ship with reasonable defaults for their optional inputs (required inputs can omit defaults). + - `block-search` (search-title) + ``` + [:find [?e ...] + :in $ ?search-title + :where + [?e :block/title ?title] + [(clojure.string/lower-case ?title) ?title-lower-case] + [(clojure.string/include? ?title-lower-case ?search-title)]] + ``` + - `task-search` (search-status, ?search-title, ?recent-days) + ``` + ;; Modify this query so search-title and recent-days are optional parameters. + ;; ?now-ms is injected by the CLI so users don't need to pass it (and should be hidden in query list output). + ;; Example: logseq query --name task-search --inputs '["doing"]' + [:find [?e ...] + :in $ ?search-status ?search-title ?recent-days ?now-ms + :where + [?e :block/title ?title] + [?e :logseq.property/status ?s] + [?s :db/ident ?status-ident] + [(= ?status-ident ?search-status)] + [(clojure.string/lower-case ?title) ?title-lower-case] + (or-join [?search-title ?title-lower-case] + [(nil? ?search-title)] + [(clojure.string/blank? ?search-title)] + (and [(clojure.string/lower-case ?search-title) ?search-title-lower-case] + [(clojure.string/include? ?title-lower-case ?search-title-lower-case)])) + [(get-else $ ?e :block/updated-at 0) ?updated-at] + (or + [(nil? ?recent-days)] + (and [(number? ?recent-days)] + [(<= ?recent-days 0)]) + (and [(number? ?recent-days)] + [(>= ?updated-at (- ?now-ms (* ?recent-days 86400000)))]) )] + ``` +## cli.edn query shape + +Represent queries as a map keyed by query name. Keep the query form as EDN data (not a string) so it can be read directly. Optional fields like `:doc` and `:inputs` are included to help with listing and UX. `:inputs` should allow optional inputs to declare default values that are used when the CLI caller omits them. Internal inputs like `?now-ms` should be hidden from `query list` output. + +Suggested `:inputs` shapes: +- `["search-status" "?search-title" "?recent-days"]` (legacy string-only form) +- `[{:name "search-status"} {:name "?search-title" :default nil} {:name "?recent-days" :default nil}]` (explicit defaults) + +Example: +``` +{:custom-queries + {"block-search" + {:doc "Find blocks by title substring (case-insensitive)." + :inputs ["search-title"] + :query [:find [?e ...] + :in $ ?search-title + :where + [?e :block/title ?title] + [(clojure.string/lower-case ?title) ?title-lower-case] + [(clojure.string/include? ?title-lower-case ?search-title)]]} + + "task-search" + {:doc "Find tasks by status, optional title substring, optional recent-days." + :inputs [{:name "search-status"} + {:name "?search-title" :default nil} + {:name "?recent-days" :default nil} + ;; ?now-ms is internal; CLI fills it with current ms and query list should hide it. + {:name "?now-ms" :default :now-ms}] + :query [:find [?e ...] + :in $ ?search-status ?search-title ?recent-days ?now-ms + :where + [?e :block/title ?title] + [?e :logseq.property/status ?s] + [?s :db/ident ?status-ident] + [(= ?status-ident ?search-status)] + [(clojure.string/lower-case ?title) ?title-lower-case] + (or-join [?search-title ?title-lower-case] + [(nil? ?search-title)] + [(clojure.string/blank? ?search-title)] + (and [(clojure.string/lower-case ?search-title) ?search-title-lower-case] + [(clojure.string/include? ?title-lower-case ?search-title-lower-case)])) + [(get-else $ ?e :block/updated-at 0) ?updated-at] + (or + [(nil? ?recent-days)] + (and [(number? ?recent-days)] + [(<= ?recent-days 0)]) + (and [(number? ?recent-days)] + [(>= ?updated-at (- ?now-ms (* ?recent-days 86400000)))]) )]}}} +``` + +Notes: +- Built-in queries live in code but should be merged into the same map shape when listing or resolving by name. +- `:inputs` is optional metadata for CLI help. It does not affect execution. ## Question Use --query and --inputs options for the query subcommand. diff --git a/src/main/logseq/cli/command/add.cljs b/src/main/logseq/cli/command/add.cljs index f9c31566f7..105188dd8d 100644 --- a/src/main/logseq/cli/command/add.cljs +++ b/src/main/logseq/cli/command/add.cljs @@ -9,6 +9,7 @@ [logseq.cli.transport :as transport] [logseq.common.util :as common-util] [logseq.common.util.date-time :as date-time-util] + [logseq.common.uuid :as common-uuid] [promesa.core :as p])) (def ^:private content-add-spec @@ -19,7 +20,8 @@ :coerce :long} :target-uuid {:desc "Target block UUID"} :target-page-name {:desc "Target page name"} - :pos {:desc "Position (first-child, last-child, sibling)"}}) + :pos {:desc "Position (first-child, last-child, sibling)"} + :status {:desc "Task status (todo, doing, done, etc.)"}}) (def ^:private add-page-spec {:page {:desc "Page name"}}) @@ -50,6 +52,57 @@ (def ^:private add-positions #{"first-child" "last-child" "sibling"}) +(def ^:private status-aliases + {"todo" :logseq.property/status.todo + "doing" :logseq.property/status.doing + "done" :logseq.property/status.done + "now" :logseq.property/status.doing + "later" :logseq.property/status.todo + "wait" :logseq.property/status.backlog + "waiting" :logseq.property/status.backlog + "backlog" :logseq.property/status.backlog + "canceled" :logseq.property/status.canceled + "cancelled" :logseq.property/status.canceled + "in-review" :logseq.property/status.in-review + "in_review" :logseq.property/status.in-review + "inreview" :logseq.property/status.in-review + "in-progress" :logseq.property/status.doing + "in progress" :logseq.property/status.doing + "inprogress" :logseq.property/status.doing}) + +(defn- normalize-status + [value] + (let [text (some-> value string/trim) + parsed (when (and (seq text) (string/starts-with? text ":")) + (common-util/safe-read-string {:log-error? false} text)) + normalized (cond + (qualified-keyword? parsed) + parsed + + (keyword? parsed) + (get status-aliases (name parsed)) + + (seq text) + (get status-aliases (string/lower-case text)) + + :else nil)] + normalized)) + +(defn- ensure-block-uuids + [blocks] + (mapv (fn [block] + (let [current (:block/uuid block)] + (cond + (some? current) + (update block :block/uuid (fn [value] + (if (and (string? value) (common-util/uuid-string? value)) + (uuid value) + value))) + + :else + (assoc block :block/uuid (common-uuid/gen-uuid))))) + blocks)) + (defn invalid-options? [opts] (let [pos (some-> (:pos opts) string/trim string/lower-case) @@ -130,21 +183,34 @@ {:ok? false :error {:code :missing-repo :message "repo is required for add"}} - (let [blocks-result (read-blocks options args)] + (let [blocks-result (read-blocks options args) + status-text (some-> (:status options) string/trim) + status (when (seq status-text) (normalize-status status-text))] + (cond + (and (seq status-text) (nil? status)) + {:ok? false + :error {:code :invalid-options + :message (str "invalid status: " status-text)}} + + :else (if-not (:ok? blocks-result) blocks-result (let [vector-result (ensure-blocks (:value blocks-result))] (if-not (:ok? vector-result) vector-result - {:ok? true - :action {:type :add-block - :repo repo - :graph (core/repo->graph repo) - :target-id (:target-id options) - :target-uuid (some-> (:target-uuid options) string/trim) - :target-page-name (some-> (:target-page-name options) string/trim) - :pos (or (some-> (:pos options) string/trim string/lower-case) "last-child") - :blocks (:value vector-result)}})))))) + (let [blocks (cond-> (:value vector-result) + status + ensure-block-uuids)] + {:ok? true + :action {:type :add-block + :repo repo + :graph (core/repo->graph repo) + :target-id (:target-id options) + :target-uuid (some-> (:target-uuid options) string/trim) + :target-page-name (some-> (:target-page-name options) string/trim) + :pos (or (some-> (:pos options) string/trim string/lower-case) "last-child") + :status status + :blocks blocks}})))))))) (defn build-add-page-action [options repo] @@ -167,17 +233,31 @@ [action config] (-> (p/let [cfg (cli-server/ensure-server! config (:repo action)) target-id (resolve-add-target cfg action) + status (:status action) pos (:pos action) opts (case pos "last-child" {:sibling? false :bottom? true} "sibling" {:sibling? true} {:sibling? false}) + opts (cond-> opts + status + (assoc :keep-uuid? true)) ops [[:insert-blocks [(:blocks action) target-id (assoc opts :outliner-op :insert-blocks)]]] - result (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}])] + _ (transport/invoke cfg :thread-api/apply-outliner-ops false [(:repo action) ops {}]) + _ (when status + (let [block-ids (->> (:blocks action) + (map :block/uuid) + (remove nil?) + vec)] + (when (seq block-ids) + (transport/invoke cfg :thread-api/apply-outliner-ops false + [(:repo action) + [[:batch-set-property [block-ids :logseq.property/status status {}]]] + {}]))))] {:status :ok - :data {:result result}}))) + :data {:result nil}}))) (defn execute-add-page [action config] diff --git a/src/main/logseq/cli/command/query.cljs b/src/main/logseq/cli/command/query.cljs index 42ef0f7fc0..6b1a8c2fc1 100644 --- a/src/main/logseq/cli/command/query.cljs +++ b/src/main/logseq/cli/command/query.cljs @@ -9,10 +9,54 @@ (def ^:private query-spec {:query {:desc "Datascript query EDN"} + :name {:desc "Query name from cli.edn custom-queries or built-ins"} :inputs {:desc "EDN vector of query inputs"}}) +(def ^:private query-list-spec + {}) + (def entries - [(core/command-entry ["query"] :query "Run a Datascript query" query-spec)]) + [(core/command-entry ["query"] :query "Run a Datascript query" query-spec) + (core/command-entry ["query" "list"] :query-list "List available queries" query-list-spec)]) + +(def ^:private built-in-query-specs + {"block-search" + {:doc "Find blocks by title substring (case-insensitive)." + :inputs ["search-title"] + :query '[:find [?e ...] + :in $ ?search-title + :where + [?e :block/title ?title] + [(clojure.string/lower-case ?title) ?title-lower-case] + [(clojure.string/lower-case ?search-title) ?search-title-lower-case] + [(clojure.string/includes? ?title-lower-case ?search-title-lower-case)]]} + + "task-search" + {:doc "Find tasks by status, optional title substring, optional recent-days." + :inputs [{:name "search-status"} + {:name "?search-title" :default ""} + {:name "?recent-days" :default 0} + {:name "?now-ms" :default :now-ms}] + :query '[:find [?e ...] + :in $ ?search-status ?search-title ?recent-days ?now-ms + :where + [?e :block/title ?title] + [?e :logseq.property/status ?status] + [?status :db/ident ?status-ident] + [(= ?status-ident ?search-status)] + [(clojure.string/lower-case ?title) ?title-lower-case] + [(str ?search-title) ?search-title-string] + [(clojure.string/lower-case ?search-title-string) ?search-title-lower-case] + [(clojure.string/includes? ?title-lower-case ?search-title-lower-case)] + [(get-else $ ?e :block/updated-at 0) ?updated-at] + (or-join [?recent-days ?updated-at ?now-ms ?days-ago] + (and [(nil? ?recent-days)] + [(identity 0) ?days-ago]) + (and [(<= ?recent-days 0)] + [(identity 0) ?days-ago]) + (and [(* ?recent-days 86400000) ?recent-days-ms] + [(- ?now-ms ?recent-days-ms) ?days-ago] + [(>= ?updated-at ?days-ago)]))]}}) (defn- parse-edn [label value] @@ -23,27 +67,134 @@ :message (str "invalid " label " edn")}} {:ok? true :value parsed}))) +(defn- normalize-query-name + [name] + (when (some? name) + (let [raw (if (keyword? name) (name name) (str name)) + text (string/trim raw)] + (when (seq text) text)))) + +(defn- normalize-query-entry + [name source spec] + (let [spec (cond + (vector? spec) {:query spec} + (map? spec) spec + :else nil) + query (:query spec) + name (normalize-query-name name)] + (when (and name query) + (cond-> (assoc spec :name name :source source) + (nil? (:inputs spec)) (assoc :inputs []))))) + +(defn- hide-internal-inputs + [entry] + (let [inputs (vec (remove (fn [input] + (let [name (cond + (string? (:name input)) (:name input) + (keyword? (:name input)) (name (:name input)) + :else nil)] + (= "?now-ms" name))) + (or (:inputs entry) [])))] + (assoc entry :inputs inputs))) + +(defn list-queries + [config] + (let [built-ins (mapv (fn [[name spec]] + (normalize-query-entry name :built-in spec)) + built-in-query-specs) + custom-queries (or (:custom-queries config) {}) + customs (mapv (fn [[name spec]] + (normalize-query-entry name :custom spec)) + custom-queries) + merged (reduce (fn [acc entry] + (if entry + (assoc acc (:name entry) entry) + acc)) + {} + (concat built-ins customs))] + (->> (vals merged) + (sort-by :name) + vec))) + +(defn- find-query + [config name] + (some #(when (= name (:name %)) %) (list-queries config))) + +(defn- optional-input? + [input] + (let [name (cond + (string? input) input + (keyword? input) (name input) + (map? input) (some-> (:name input) str) + :else nil)] + (and (string? name) (string/starts-with? name "?")))) + +(defn- input-default + [input] + (when (map? input) + (if (contains? input :default) + (let [value (:default input)] + (if (= :now-ms value) + (js/Date.now) + value)) + nil))) + +(defn- normalize-named-inputs + [entry inputs] + (let [spec-inputs (or (:inputs entry) []) + required-count (count (remove optional-input? spec-inputs)) + inputs (or inputs [])] + (if (< (count inputs) required-count) + {:ok? false + :error {:code :invalid-options + :message "inputs missing required values"}} + {:ok? true + :value (if (< (count inputs) (count spec-inputs)) + (let [missing (subvec (vec spec-inputs) (count inputs))] + (into (vec inputs) (map input-default missing))) + inputs)}))) + (defn build-action - [options repo] + [options repo config] (if-not (seq repo) {:ok? false :error {:code :missing-repo :message "repo is required for query"}} - (let [query-text (some-> (:query options) string/trim)] - (if-not (seq query-text) + (let [query-text (some-> (:query options) string/trim) + query-name (normalize-query-name (:name options))] + (cond + (and (seq query-text) (seq query-name)) + {:ok? false + :error {:code :invalid-options + :message "use either --query or --name, not both"}} + + (and (not (seq query-text)) (not (seq query-name))) {:ok? false :error {:code :missing-query :message "query is required"}} - (let [query-result (parse-edn "query" query-text)] + + :else + (let [query-result (if (seq query-text) + (parse-edn "query" query-text) + (if-let [entry (find-query config query-name)] + {:ok? true :value (:query entry) :entry entry} + {:ok? false + :error {:code :unknown-query + :message (str "unknown query: " query-name)}}))] (if-not (:ok? query-result) query-result (let [inputs-text (some-> (:inputs options) string/trim) inputs-result (when (seq inputs-text) - (parse-edn "inputs" inputs-text))] + (parse-edn "inputs" inputs-text)) + named-inputs (when-let [entry (:entry query-result)] + (normalize-named-inputs entry (or (:value inputs-result) [])))] (cond (and inputs-result (not (:ok? inputs-result))) inputs-result + (and named-inputs (not (:ok? named-inputs))) + named-inputs + (and inputs-result (not (vector? (:value inputs-result)))) {:ok? false :error {:code :invalid-options @@ -55,7 +206,14 @@ :repo repo :graph (core/repo->graph repo) :query (:value query-result) - :inputs (or (:value inputs-result) [])}})))))))) + :inputs (or (:value named-inputs) + (:value inputs-result) + [])}})))))))) + +(defn build-list-action + [_options _repo] + {:ok? true + :action {:type :query-list}}) (defn execute-query [action config] @@ -64,3 +222,8 @@ results (transport/invoke cfg :thread-api/q false [(:repo action) args])] {:status :ok :data {:result results}}))) + +(defn execute-query-list + [_action config] + (p/resolved {:status :ok + :data {:queries (mapv hide-internal-inputs (list-queries config))}})) diff --git a/src/main/logseq/cli/commands.cljs b/src/main/logseq/cli/commands.cljs index a1f9b79af0..208b749d14 100644 --- a/src/main/logseq/cli/commands.cljs +++ b/src/main/logseq/cli/commands.cljs @@ -176,7 +176,9 @@ (and (= command :search) (not has-args?)) (missing-search-result summary) - (and (= command :query) (not (seq (some-> (:query opts) string/trim)))) + (and (= command :query) + (not (seq (some-> (:query opts) string/trim))) + (not (seq (some-> (:name opts) string/trim)))) (missing-query-result summary) (and (#{:list-page :list-tag :list-property} command) @@ -237,7 +239,7 @@ :error {:code :missing-command :message "missing command"} :summary summary}) - (if (and (= 1 (count args)) (#{"graph" "server" "list" "add" "remove"} (first args))) + (if (and (= 1 (count args)) (#{"graph" "server" "list" "add" "remove" "query"} (first args))) (command-core/help-result (command-core/group-summary (first args) table)) (try (let [result (cli/dispatch table args {:spec global-spec})] @@ -352,7 +354,10 @@ (search-command/build-action options args repo) :query - (query-command/build-action options repo) + (query-command/build-action options repo config) + + :query-list + (query-command/build-list-action options repo) :show (show-command/build-action options repo) @@ -392,6 +397,7 @@ :remove-page (remove-command/execute-remove action config) :search (search-command/execute-search action config) :query (query-command/execute-query action config) + :query-list (query-command/execute-query-list action config) :show (show-command/execute-show action config) :server-list (server-command/execute-list action config) :server-status (server-command/execute-status action config) diff --git a/src/main/logseq/cli/format.cljs b/src/main/logseq/cli/format.cljs index 988d983a83..d773b198f8 100644 --- a/src/main/logseq/cli/format.cljs +++ b/src/main/logseq/cli/format.cljs @@ -87,6 +87,7 @@ :missing-content "Use --content or pass content as args" :missing-search-text "Provide search text as a positional argument" :missing-query "Use --query " + :unknown-query "Use `logseq query list` to see available queries" nil)) (defn- format-error @@ -191,6 +192,17 @@ [result] (pr-str result)) +(defn- format-query-list + [queries] + (format-counted-table + ["NAME" "INPUTS" "SOURCE" "DOC"] + (mapv (fn [{:keys [name inputs source doc]}] + [name + (if (seq inputs) (string/join ", " inputs) "-") + (clojure.core/name (or source :custom)) + (or doc "-")]) + (or queries [])))) + (defn- format-graph-info [{:keys [graph logseq.kv/graph-created-at logseq.kv/schema-version]} now-ms] (string/join "\n" @@ -281,6 +293,7 @@ :graph-import (format-graph-import context) :search (format-search-results (:results data)) :query (format-query-results (:result data)) + :query-list (format-query-list (:queries data)) :show (or (:message data) (pr-str data)) (if (and (map? data) (contains? data :message)) (:message data) diff --git a/src/main/logseq/cli/main.cljs b/src/main/logseq/cli/main.cljs index 1af322508c..dcba210339 100644 --- a/src/main/logseq/cli/main.cljs +++ b/src/main/logseq/cli/main.cljs @@ -12,7 +12,7 @@ (string/join "\n" ["logseq [options]" "" - "Commands: list page, list tag, list property, add block, add page, move, remove block, remove page, search, query, show, graph list, graph create, graph switch, graph remove, graph validate, graph info, graph export, graph import, server list, server status, server start, server stop, server restart" + "Commands: list page, list tag, list property, add block, add page, move, remove block, remove page, search, query, query list, show, graph list, graph create, graph switch, graph remove, graph validate, graph info, graph export, graph import, server list, server status, server start, server stop, server restart" "" "Options:" summary])) @@ -47,8 +47,8 @@ (-> (commands/execute (:action action-result) cfg) (p/then (fn [result] (let [opts (cond-> cfg - (:output-format result) - (assoc :output-format (:output-format result)))] + (:output-format result) + (assoc :output-format (:output-format result)))] {:exit-code 0 :output (format/format-result result opts)}))) (p/catch (fn [error] @@ -74,4 +74,5 @@ (p/then (fn [{:keys [exit-code output]}] (when (seq output) (println output)) - (.exit js/process exit-code))))) + (when-not (zero? exit-code) + (.exit js/process exit-code)))))) diff --git a/src/test/logseq/cli/command/query_test.cljs b/src/test/logseq/cli/command/query_test.cljs index 54a032f547..a6becea914 100644 --- a/src/test/logseq/cli/command/query_test.cljs +++ b/src/test/logseq/cli/command/query_test.cljs @@ -7,7 +7,8 @@ (testing "query parses query and inputs" (let [result (query-command/build-action {:query "[:find ?e :in $ ?title :where [?e :block/title ?title]]" :inputs "[\"Hello\"]"} - "logseq_db_demo")] + "logseq_db_demo" + {})] (is (true? (:ok? result))) (is (= :query (get-in result [:action :type]))) (is (= "logseq_db_demo" (get-in result [:action :repo]))) @@ -18,7 +19,8 @@ (deftest test-build-action-invalid-edn (testing "invalid query edn returns invalid-options" (let [result (query-command/build-action {:query "[:find ?e"} - "logseq_db_demo")] + "logseq_db_demo" + {})] (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))) (is (string/includes? (get-in result [:error :message]) "query")))) @@ -26,7 +28,108 @@ (testing "invalid inputs edn returns invalid-options" (let [result (query-command/build-action {:query "[:find ?e :where [?e :block/title \"Hello\"]]" :inputs "[\"Hello"} - "logseq_db_demo")] + "logseq_db_demo" + {})] (is (false? (:ok? result))) (is (= :invalid-options (get-in result [:error :code]))) (is (string/includes? (get-in result [:error :message]) "inputs"))))) + +(deftest test-build-action-named-query + (testing "named query resolves from config" + (let [config {:custom-queries {"my-query" + {:doc "Custom query" + :inputs ["title"] + :query '[:find ?e + :in $ ?title + :where + [?e :block/title ?title]]}}} + result (query-command/build-action {:name "my-query" + :inputs "[\"Alpha\"]"} + "logseq_db_demo" + config)] + (is (true? (:ok? result))) + (is (= '[:find ?e :in $ ?title :where [?e :block/title ?title]] + (get-in result [:action :query]))) + (is (= ["Alpha"] (get-in result [:action :inputs]))))) + + (testing "unknown named query returns unknown-query error" + (let [result (query-command/build-action {:name "missing"} + "logseq_db_demo" + {:custom-queries {}})] + (is (false? (:ok? result))) + (is (= :unknown-query (get-in result [:error :code]))))) + + (testing "rejects both name and query" + (let [result (query-command/build-action {:name "my-query" + :query "[:find ?e :where [?e :block/title ?title]]"} + "logseq_db_demo" + {:custom-queries {"my-query" {:query '[:find ?e]}}})] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "optional inputs are padded with nils" + (let [config {:custom-queries {"task-search" + {:inputs ["search-status" "?search-title" "?recent-days"] + :query '[:find [?e ...] + :in $ ?search-status ?search-title ?recent-days + :where + [?e :block/title ?title]]}}} + result (query-command/build-action {:name "task-search" + :inputs "[\"doing\"]"} + "logseq_db_demo" + config)] + (is (true? (:ok? result))) + (is (= ["doing" nil nil] (get-in result [:action :inputs]))))) + + (testing "missing required inputs returns invalid-options" + (let [config {:custom-queries {"task-search" + {:inputs ["search-status" "?search-title"] + :query '[:find [?e ...] + :in $ ?search-status ?search-title + :where + [?e :block/title ?title]]}}} + result (query-command/build-action {:name "task-search" + :inputs "[]"} + "logseq_db_demo" + config)] + (is (false? (:ok? result))) + (is (= :invalid-options (get-in result [:error :code]))))) + + (testing "optional inputs can define defaults in cli.edn" + (let [config {:custom-queries {"task-search" + {:inputs [{:name "search-status"} + {:name "?search-title" :default "fallback-title"} + {:name "?recent-days" :default 7}] + :query '[:find [?e ...] + :in $ ?search-status ?search-title ?recent-days + :where + [?e :block/title ?title]]}}} + result (query-command/build-action {:name "task-search" + :inputs "[\"doing\"]"} + "logseq_db_demo" + config)] + (is (true? (:ok? result))) + (is (= ["doing" "fallback-title" 7] (get-in result [:action :inputs]))))) + + (testing "built-in task-search uses defaults for optional inputs" + (let [result (query-command/build-action {:name "task-search" + :inputs "[\"doing\"]"} + "logseq_db_demo" + {})] + (is (true? (:ok? result))) + (let [inputs (get-in result [:action :inputs])] + (is (= ["doing" "" 0] (subvec inputs 0 3))) + (is (number? (nth inputs 3))))))) + +(deftest test-query-list-merges-built-in-and-custom + (testing "built-in and custom queries are both listed" + (let [queries (query-command/list-queries {:custom-queries {"custom-q" {:query '[:find ?e]}}}) + names (set (map :name queries))] + (is (contains? names "block-search")) + (is (contains? names "task-search")) + (is (contains? names "custom-q")))) + + (testing "custom query overrides built-in name" + (let [queries (query-command/list-queries {:custom-queries {"block-search" {:query '[:find ?e]}}}) + block-search (first (filter #(= "block-search" (:name %)) queries))] + (is (= :custom (:source block-search)))))) diff --git a/src/test/logseq/cli/commands_test.cljs b/src/test/logseq/cli/commands_test.cljs index c6196177ea..e8533c24b1 100644 --- a/src/test/logseq/cli/commands_test.cljs +++ b/src/test/logseq/cli/commands_test.cljs @@ -75,7 +75,14 @@ summary (:summary result)] (is (true? (:help? result))) (is (string/includes? summary "server list")) - (is (string/includes? summary "server start"))))) + (is (string/includes? summary "server start")))) + + (testing "query group shows subcommands" + (let [result (commands/parse-args ["query"]) + summary (:summary result)] + (is (true? (:help? result))) + (is (string/includes? summary "query list")) + (is (string/includes? summary "query [options]"))))) (deftest test-parse-args-help-alignment (testing "graph group aligns subcommand columns" @@ -112,7 +119,7 @@ (testing "rejects legacy commands" (doseq [command ["graph-list" "graph-create" "graph-switch" "graph-remove" "graph-validate" "graph-info" "block" "tree" - "ping" "status" "query" "export"]] + "ping" "status" "export"]] (let [result (commands/parse-args [command])] (is (false? (:ok? result))) (is (= :unknown-command (get-in result [:error :code])))))) @@ -484,10 +491,10 @@ (is (= "Home" (get-in result [:options :page-name])))))) (deftest test-verb-subcommand-parse-query - (testing "query requires query option" + (testing "query shows group help" (let [result (commands/parse-args ["query"])] - (is (false? (:ok? result))) - (is (= :missing-query (get-in result [:error :code]))))) + (is (true? (:help? result))) + (is (string/includes? (:summary result) "query list")))) (testing "query parses with query and inputs" (let [result (commands/parse-args ["query" diff --git a/src/test/logseq/cli/format_test.cljs b/src/test/logseq/cli/format_test.cljs index b82ca0b0b7..4e630e11ac 100644 --- a/src/test/logseq/cli/format_test.cljs +++ b/src/test/logseq/cli/format_test.cljs @@ -208,6 +208,20 @@ {:output-format nil})] (is (= "[[1] [2] [3]]" result))))) +(deftest test-human-output-query-list + (testing "query list renders a table with count" + (let [result (format/format-result {:status :ok + :command :query-list + :data {:queries [{:name "block-search" + :inputs ["search-title"] + :doc "Find blocks" + :source :built-in}]}} + {:output-format nil})] + (is (= (str "NAME INPUTS SOURCE DOC\n" + "block-search search-title built-in Find blocks\n" + "Count: 1") + result))))) + (deftest test-human-output-error-formatting (testing "errors include code and hint when available" (let [result (format/format-result {:status :error diff --git a/src/test/logseq/cli/integration_test.cljs b/src/test/logseq/cli/integration_test.cljs index c1a1f8dab8..c7ecb41c06 100644 --- a/src/test/logseq/cli/integration_test.cljs +++ b/src/test/logseq/cli/integration_test.cljs @@ -168,6 +168,75 @@ (is false (str "unexpected error: " e)) (done))))))) +(deftest test-cli-query-task-search + (async done + (let [data-dir (node-helper/create-tmp-dir "db-worker-task-query")] + (-> (p/let [cfg-path (node-path/join (node-helper/create-tmp-dir "cli") "cli.edn") + _ (fs/writeFileSync cfg-path "{:output-format :json}") + create-result (run-cli ["graph" "create" "--repo" "task-query-graph"] data-dir cfg-path) + create-payload (parse-json-output create-result) + _ (run-cli ["--repo" "task-query-graph" "add" "page" "--page" "Tasks"] data-dir cfg-path) + _ (run-cli ["--repo" "task-query-graph" + "add" "block" + "--target-page-name" "Tasks" + "--content" "Task one" + "--status" "doing"] + data-dir cfg-path) + _ (run-cli ["--repo" "task-query-graph" + "add" "block" + "--target-page-name" "Tasks" + "--content" "Task two" + "--status" "doing"] + data-dir cfg-path) + _ (run-cli ["--repo" "task-query-graph" + "add" "block" + "--target-page-name" "Tasks" + "--content" "Task three" + "--status" "todo"] + data-dir cfg-path) + _ (p/delay 100) + list-result (run-cli ["query" "list"] data-dir cfg-path) + list-payload (parse-json-output list-result) + task-entry (some (fn [entry] + (when (= "task-search" (:name entry)) entry)) + (get-in list-payload [:data :queries])) + query-result (run-cli ["--repo" "task-query-graph" + "query" + "--name" "task-search" + "--inputs" "[:logseq.property/status.doing]"] + data-dir cfg-path) + query-payload (parse-json-output query-result) + query-nil-result (run-cli ["--repo" "task-query-graph" + "query" + "--name" "task-search" + "--inputs" "[:logseq.property/status.doing nil 1]"] + data-dir cfg-path) + query-nil-payload (parse-json-output query-nil-result) + _ (prn :xxxx query-payload) + result (get-in query-payload [:data :result]) + nil-result (get-in query-nil-payload [:data :result]) + stop-result (run-cli ["server" "stop" "--repo" "task-query-graph"] data-dir cfg-path) + stop-payload (parse-json-output stop-result)] + (is (= "ok" (:status create-payload))) + (is (= "ok" (:status list-payload))) + (is (= [{:name "search-status"} + {:name "?search-title" :default ""} + {:name "?recent-days" :default 0}] + (:inputs task-entry))) + (is (= 0 (:exit-code query-result))) + (is (= "ok" (:status query-payload))) + (is (vector? result)) + (is (= 2 (count result))) + (is (= 0 (:exit-code query-nil-result))) + (is (= "ok" (:status query-nil-payload))) + (is (vector? nil-result)) + (is (= 2 (count nil-result))) + (is (= "ok" (:status stop-payload))) + (done)) + (p/catch (fn [e] + (is false (str "unexpected error: " e)) + (done))))))) + (deftest test-cli-show-search-resolve-nested-uuid-refs (async done (let [data-dir (node-helper/create-tmp-dir "db-worker-nested-refs")]