Skip to content
Prev Previous commit
fix: store query dsl page refs as uuids and resolve them at parse time
Query DSL strings that reference pages by title (e.g. (tags [[Project"]]))
break cljs.reader when a title contains special characters like " or \\.
Instead of hardening the title-form reader path, keep page refs in their
stored [[uuid]] form end to end:

- execution reads :block/raw-title (the stored form) instead of the
  display-substituted :block/title (query-result, flashcards, query builder)
- new query value blocks write [[uuid]] refs: pipeline tag-query sync,
  create-property-text-block!, and the file-graph exporter
- dsl clauses resolve [[uuid]] args via get-page (tags/page/between/property)
- display still shows titles via canonical-block's substituted :block/title
- pre-transform keeps a simplified scanner for legacy title-form queries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
  • Loading branch information
megayu and devin-ai-integration[bot] committed Oct 1, 2026
commit 9ccb4782d996d8c09cf5a9ee69694b20fbc8151b
44 changes: 9 additions & 35 deletions deps/db/src/logseq/db/frontend/query_dsl.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -39,73 +39,47 @@
(recur (inc i))
i))))

(defn- next-form-start?
"True when text after a `)` is a sibling DSL form or the end of input,
not more page-title text. A following `[[` is a page-ref argument, not
more of the current title."
[s i]
(let [i (skip-ws s i)
n (count s)]
(or (>= i n)
(= \( (nth s i))
(= \# (nth s i))
(and (= \[ (nth s i))
(< (inc i) n)
(= \[ (nth s (inc i)))))))

(defn- unmatched-page-ref-close?
"True when `s` from `start` still contains a `]]` that is not paired with a
later `[[` in the current list. A `)` ends this form only when the next token
is a sibling list, so a title such as `A]] B) C` keeps its trailing `]]`."
later `[[` before the current form's `)`."
[s start]
(let [n (count s)]
(loop [i start
open 0
paren 0]
open 0]
(cond
(>= i n)
false

(= \" (nth s i))
(if-let [end (quoted-string-end s i)]
(recur end open paren)
(recur end open)
false)

(= \( (nth s i))
(recur (inc i) open (inc paren))

(= \) (nth s i))
(cond
(pos? paren)
(recur (inc i) open (dec paren))

(next-form-start? s (inc i))
false

:else
(recur (inc i) open paren))
false
Comment on lines +58 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Parenthesized page titles break query parsing

For (tags [[A]] B) C]]), unmatched-page-ref-close? stops at the title's ) before seeing its final ]]. The query splits the page title at A, leaving invalid trailing text instead of matching A]] B) C.

Learn more

The scanner decides whether a candidate ]] closes a page reference by searching ahead for another unmatched ]]. Stopping that search at every ) assumes parentheses cannot occur in page titles, even when a later closing pair is part of the same title. Such page names are valid, and queries using their rendered references no longer parse.

Example: A tag titled A]] B) C appears as (tags [[A]] B) C]]). The scanner quotes only [[A]] and leaves B) C]]) as query syntax rather than matching the tag.

Recommended fix: Distinguish a parenthesis inside a reference title from a DSL form closer during lookahead, and restore coverage for ]] followed by ) and more title text.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


(and (< (inc i) n)
(= \[ (nth s i))
(= \[ (nth s (inc i))))
(recur (+ i 2) (inc open) paren)
(recur (+ i 2) (inc open))

(and (< (inc i) n)
(= \] (nth s i))
(= \] (nth s (inc i))))
(if (zero? open)
true
(recur (+ i 2) (dec open) paren))
(recur (+ i 2) (dec open)))
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

:else
(recur (inc i) open paren)))))
(recur (inc i) open)))))

(defn- page-ref-terminator?
"True when the text after a candidate `]]` is the next DSL token or closer,
not more title text. A lone `]` is a terminator only inside an EDN vector so
`(tags [ [[foo]]])` keeps the vector close, while `(tags [ [[foo]]]])` can
keep a title that ends with `]`. A following symbol such as `tomorrow` is a
terminator unless the current form still has a dangling `]]`."
terminator unless the current form still has a dangling `]]`. Titles that
contain `)` are not supported: a `)` always ends the form."
[s i vector-depth]
(let [i (skip-ws s i)
n (count s)]
Expand Down
38 changes: 7 additions & 31 deletions deps/db/test/logseq/db/frontend/query_dsl_test.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,12 @@
(is (= '(tags "[[Project]]]]")
(read-query "(tags [[Project]]]])"))))

(testing "title that is itself a page ref"
(is (= (quoted-tags-query "[[Gremlin Home]]")
(query-dsl/pre-transform "(tags [[[[Gremlin Home]]]])")))
(is (= '(tags "[[[[Gremlin Home]]]]")
(read-query "(tags [[[[Gremlin Home]]]])"))))

(testing "multiple page refs with special characters stay independent"
(is (= (str "(and " (pr-str "[[foo\"]]") " " (pr-str "[[bar\\]]") ")")
(query-dsl/pre-transform "(and [[foo\"]] [[bar\\]])")))
(is (= (list 'and "[[foo\"]]" "[[bar\\]]")
(read-query "(and [[foo\"]] [[bar\\]])"))))

(testing "]] in the middle of a page title"
(is (= (quoted-tags-query "Project]] Garden")
(query-dsl/pre-transform "(tags [[Project]] Garden]])")))
(is (= '(tags "[[Project]] Garden]]")
(read-query "(tags [[Project]] Garden]])"))))

(testing "nested [[...]] followed by more title text"
(is (= (quoted-tags-query "Project[[Gremlin]] extra")
(query-dsl/pre-transform "(tags [[Project[[Gremlin]] extra]])")))
(is (= '(tags "[[Project[[Gremlin]] extra]]")
(read-query "(tags [[Project[[Gremlin]] extra]])"))))

(testing "page ref immediately before a tags vector close"
(is (= (str "(tags [ " (pr-str "[[foo]]") "])")
(query-dsl/pre-transform "(tags [ [[foo]]])")))
Expand All @@ -93,12 +75,6 @@
(is (= '(tags ["[[foo]]]"])
(read-query "(tags [ [[foo]]]])"))))

(testing "title ending with ]] inside a tags vector"
(is (= (str "(tags [ " (pr-str "[[foo]]]]") "])")
(query-dsl/pre-transform "(tags [ [[foo]]]]])")))
(is (= '(tags ["[[foo]]]]"])
(read-query "(tags [ [[foo]]]]])"))))

(testing "later tag ending with ]] does not swallow an earlier between date"
(is (= (str "(and (between " (pr-str "[[Dec 26th, 2020]]") " tomorrow) (tags "
(pr-str "[[bar]]]]") "))")
Expand All @@ -107,16 +83,16 @@
(is (= (list 'and (list 'between "[[Dec 26th, 2020]]" 'tomorrow) (list 'tags "[[bar]]]]"))
(read-query "(and (between [[Dec 26th, 2020]] tomorrow) (tags [[bar]]]]))"))))

(testing "title can contain ]] then a paren then more text"
(is (= (quoted-tags-query "A]] B) C")
(query-dsl/pre-transform "(tags [[A]] B) C]])")))
(is (= '(tags "[[A]] B) C]]")
(read-query "(tags [[A]] B) C]])"))))

(testing "later page-ref sibling ending with ]] does not swallow a between date"
(is (= (str "(and (between " (pr-str "[[Dec 26th, 2020]]") " tomorrow) "
(pr-str "[[foo]]]]") ")")
(query-dsl/pre-transform
"(and (between [[Dec 26th, 2020]] tomorrow) [[foo]]]])")))
(is (= (list 'and (list 'between "[[Dec 26th, 2020]]" 'tomorrow) "[[foo]]]]")
(read-query "(and (between [[Dec 26th, 2020]] tomorrow) [[foo]]]])")))))
(read-query "(and (between [[Dec 26th, 2020]] tomorrow) [[foo]]]])"))))

(testing "uuid page refs pass through"
(is (= (str "(tags " (pr-str "[[6a8ead3b-a450-4916-a7e2-d16d0d2b59fd]]") ")")
(query-dsl/pre-transform "(tags [[6a8ead3b-a450-4916-a7e2-d16d0d2b59fd]])")))
(is (= '(tags "[[6a8ead3b-a450-4916-a7e2-d16d0d2b59fd]]")
(read-query "(tags [[6a8ead3b-a450-4916-a7e2-d16d0d2b59fd]])")))))
24 changes: 21 additions & 3 deletions deps/graph-parser/src/logseq/graph_parser/exporter.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -1388,14 +1388,30 @@
ast-blocks)
@results))

(defn- query-refs->id-refs
"Replaces `[[title]]` refs in a query string with `[[uuid]]` for pages that
resolve in page-names-to-uuids"
[query page-names-to-uuids]
(if (string/includes? query "[[")
(let [ref-pages (keep (fn [[_ page-name]]
(when-let [id (or (get @page-names-to-uuids page-name)
(get @page-names-to-uuids
(common-util/page-name-sanity-lc page-name)))]
{:block/title page-name :block/uuid id}))
(re-seq page-ref/page-ref-re query))]
(if (seq ref-pages)
(db-content/title-ref->id-ref query ref-pages {:replace-tag? false})
Comment on lines +1395 to +1403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Quoted query text changes during import

When an imported query contains a quoted [[page]] literal, query-refs->id-refs replaces it with [[uuid]]. Text searches and advanced query string predicates then search for different content, changing their results.

Learn more

The importer now converts all regex-matched page references in simple, advanced, and cards query text to IDs. This includes matches inside quoted query strings. Quoted strings in DSL property or content searches, and string constants in advanced Datalog queries, can be literal text rather than page arguments. Replacing those strings changes the stored query's meaning.

Example: An advanced query whose predicate compares a title against "[[Foo]]" is imported while a Foo page exists. The string becomes "[[<Foo's UUID>]]", so the predicate no longer matches the original title.

Recommended fix: Parse the query format and convert only syntactic page-reference operands, preserving quoted string literals. Add tests for quoted refs in advanced queries and literal-content searches.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

query))
query))

(defn- handle-queries
"If a block contains a simple or advanced queries, converts block to a #Query node. If a block
contains a cards query converts to a #Cards node"
[{:block/keys [title] :as block} db page-names-to-uuids walked-ast-blocks options]
(if-let [query (some-> (first (:simple-queries walked-ast-blocks))
(ast->text (select-keys options [:log-fn]))
string/trim)]
(let [props {:logseq.property/query query}
(let [props {:logseq.property/query (query-refs->id-refs query page-names-to-uuids)}
{:keys [block-properties pvalues-tx]}
(build-properties-and-values props db page-names-to-uuids
(select-keys block [:block/properties-text-values :block/name :block/title :block/uuid])
Expand All @@ -1409,7 +1425,9 @@
(if-let [advanced-query (some-> (first (filter #(= ["Custom" "query"] (take 2 %)) (:block.temp/ast-blocks block)))
(ast->text (select-keys options [:log-fn]))
string/trim)]
(let [props {:logseq.property/query (migrate-advanced-query-string advanced-query)}
(let [props {:logseq.property/query
(query-refs->id-refs (migrate-advanced-query-string advanced-query)
page-names-to-uuids)}
{:keys [block-properties pvalues-tx]}
(build-properties-and-values props db page-names-to-uuids
(select-keys block [:block/properties-text-values :block/name :block/title :block/uuid])
Expand All @@ -1435,7 +1453,7 @@
:pvalues-tx pvalues-tx'})
(if-let [cards-macro (first (:cards walked-ast-blocks))]
(if-let [query (some-> cards-macro second :arguments first string/trim not-empty)]
(let [props {:logseq.property/query query}
(let [props {:logseq.property/query (query-refs->id-refs query page-names-to-uuids)}
{:keys [block-properties pvalues-tx]}
(build-properties-and-values props db page-names-to-uuids
(select-keys block [:block/properties-text-values :block/name :block/title :block/uuid])
Expand Down
13 changes: 13 additions & 0 deletions deps/outliner/src/logseq/outliner/property.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
[datascript.core :as d]
[datascript.impl.entity :as de]
[logseq.common.util :as common-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.db-ident :as db-ident]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.malli-schema :as db-malli-schema]
Expand Down Expand Up @@ -438,6 +440,17 @@
_ (assert (some? property) (str "Property " property-id " doesn't exist yet"))
value' (convert-property-input-string (:logseq.property/type block)
property value)
value' (if (and (= property-id :logseq.property/query)
(string? value')
(string/includes? value' "[["))
(let [ref-pages (->> (re-seq page-ref/page-ref-re value')
(map second)
distinct
(keep #(ldb/get-page @conn %)))]
(if (seq ref-pages)
(db-content/title-ref->id-ref value' ref-pages {:replace-tag? false})
value'))
value')
_ (when (and (not= (:logseq.property/type property) :number)
(not (string? value')))
(throw (ex-info "value should be a string" {:block-id block-id
Expand Down
13 changes: 13 additions & 0 deletions deps/outliner/test/logseq/outliner/property_test.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,19 @@
(outliner-property/create-property-text-block! conn (:db/id block) :user.property/num "Not a number" {}))
"Wrong value isn't transacted")))

(testing "Query property value stores [[uuid]] refs"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1 [[Foo]]"}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
_ (outliner-property/create-property-text-block! conn (:db/id block) :logseq.property/query "(tags [[Foo]])" {})
query-value (:logseq.property/query (db-test/find-block-by-content @conn "b2"))
foo-uuid (:block/uuid (ldb/get-page @conn "Foo"))]
(is (= (str "(tags [[" foo-uuid "]])")
(db-property/property-value-content query-value))
"[[title]] refs in a query property are stored as [[uuid]]")))

(testing "Create new :many :number property values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
Expand Down
8 changes: 6 additions & 2 deletions src/main/frontend/components/block.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -3902,7 +3902,10 @@
[config block query-block]
(let [query-block-uuid (:block/uuid query-block)
current-query-block (db-hooks/use-block query-block-uuid)
query (:block/title current-query-block)
;; Execute the stored title (id refs like [[uuid]]); :block/title is
;; already display-substituted on the wire
query (or (:block/raw-title current-query-block)
(:block/title current-query-block))
result (common-util/safe-read-string {:log-error? false} query)
advanced-query? (map? result)]
(when current-query-block
Expand All @@ -3911,7 +3914,8 @@
:dsl-query? (not advanced-query?)
:cards? (cards-block? block)))
(if advanced-query? result {:builder nil
:query (query-builder-component/sanitize-q query)}))])))
:query (query-builder-component/sanitize-q query)
:query-display (:block/title current-query-block)}))])))

(defn- build-block
[config block* {:keys [navigated?]}]
Expand Down
2 changes: 1 addition & 1 deletion src/main/frontend/components/query.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
(if (:custom-query? config)
;; Don't display recursive results when query blocks are a query result
[:code (if dsl-query?
(t :query/results-for (pr-str query))
(t :query/results-for (pr-str (or (:query-display q) query)))
(t :query/advanced-results))]
(when-not (and built-in-query? (empty? result))
[:div.custom-query (get config :attr {})
Expand Down
1 change: 1 addition & 0 deletions src/main/frontend/components/query/builder.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@
(defn- get-q
[block]
(sanitize-q (or (:file-version/query-macro-title block)
(:block/raw-title block)
(:block/title block)
"")))

Expand Down
4 changes: 2 additions & 2 deletions src/main/frontend/worker/handler/flashcard.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
(d/entity db cards-id))
query (when cards
(when-let [query (:logseq.property/query cards)]
(when-not (string/blank? (:block/title query))
(:block/title query))))
(when-not (string/blank? (:block/raw-title query))
(:block/raw-title query))))
result (query-dsl/parse query db {})
card-tag-id (:db/id (d/entity db :logseq.class/Card))
card-tag-children-ids (db-class/get-structured-children db card-tag-id)
Expand Down
9 changes: 9 additions & 0 deletions src/main/frontend/worker/pipeline.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.property.build :as db-property-build]
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
[logseq.db.sqlite.export :as sqlite-export]
Expand Down Expand Up @@ -388,6 +389,14 @@
(let [query-entity (:logseq.property/query block)]
(when-not (and query-entity (:block/uuid query-entity))
(let [query-text (if (string? query-entity) query-entity "")
ref-pages (when (string/includes? query-text "[[")
(->> (re-seq page-ref/page-ref-re query-text)
(map second)
distinct
(keep #(ldb/get-page db-after %))))
query-text (if (seq ref-pages)
(db-content/title-ref->id-ref query-text ref-pages {:replace-tag? false})
query-text)
value-block (db-property-build/build-property-value-block
block
query-property
Expand Down
Loading
Loading