Skip to content

Commit 8d9fa50

Browse files
committed
feat(sql/ast): Add converter for SQL WHERE to AST with comprehensive tests
- Implement `chrondb.api.sql.execution.ast-converter` to translate SQL WHERE condition maps to unified AST query clauses used by Lucene. - Support FTS, standard operators (=, !=, <>, >, <, >=, <=), LIKE (wildcards), IS [NOT] NULL, and number parsing for range queries. - Gracefully handle unknown types and operators, always preserving immutability and fast failure semantics. - Add unit tests for all major conversion and edge cases to `test/chrondb/api/sql/execution/ast_converter_test.clj`. - Document AST composition and query helper contract in `docs/ast-queries.md`. - Cross-link property-based test location in AGENT.md for better developer guidance. This supports full protocol-level parity and reproducible query planning across ChronDB HTTP, Redis, and SQL endpoints. Signed-off-by: Avelino <[email protected]>
1 parent 5780a93 commit 8d9fa50

File tree

5 files changed

+345
-3
lines changed

5 files changed

+345
-3
lines changed

AGENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ ChronDB is a chronological key/value database implemented in Clojure and backed
5454
- Include integration tests that exercise Git-backed storage
5555
- Add regression tests for concurrency and conflict resolution
5656
- Prefer property-based tests when sequence ordering matters
57+
- Unit coverage for query AST helpers lives in `test/chrondb/query/ast_test.clj`
5758

5859
### Git Architecture Integration
5960

docs/ast-queries.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,4 @@ The AST system replaces the legacy query format:
219219
```
220220

221221
Both are supported during migration, but new code should use AST.
222+

src/chrondb/api/sql/execution/ast_converter.clj

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,3 @@
8989
(if (= (count clauses) 1)
9090
(first clauses)
9191
(apply ast/and clauses))))))
92-
93-
94-
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
(ns chrondb.api.sql.execution.ast-converter-test
2+
(:require [clojure.test :refer [deftest is testing]]
3+
[chrondb.api.sql.execution.ast-converter :as converter]
4+
[chrondb.query.ast :as ast]))
5+
6+
(deftest condition->ast-clause-fts-test
7+
(is (= (ast/fts :content "time travel")
8+
(converter/condition->ast-clause
9+
{:type :fts-match
10+
:field "content"
11+
:op "MATCH"
12+
:query "time travel"}))))
13+
14+
(deftest condition->ast-clause-standard-test
15+
(testing "Equality removes surrounding quotes"
16+
(let [condition {:type :standard
17+
:field "name"
18+
:op "="
19+
:value "'Alice'"}
20+
expected (ast/term :name "Alice")]
21+
(is (= expected (converter/condition->ast-clause condition)))))
22+
23+
(testing "Not equal delegates to ast/not"
24+
(let [condition {:type :standard
25+
:field "status"
26+
:op "!="
27+
:value "archived"}
28+
expected (ast/not (ast/term :status "archived"))]
29+
(is (= expected (converter/condition->ast-clause condition)))))
30+
31+
(testing "Alternate not equal operator <> behaves the same"
32+
(let [condition {:type :standard
33+
:field "status"
34+
:op "<>"
35+
:value "archived"}
36+
expected (ast/not (ast/term :status "archived"))]
37+
(is (= expected (converter/condition->ast-clause condition)))))
38+
39+
(testing "Greater than parses longs"
40+
(let [condition {:type :standard
41+
:field "age"
42+
:op ">"
43+
:value "30"}
44+
expected (ast/range-long :age 30 nil {:include-lower? false})]
45+
(is (= expected (converter/condition->ast-clause condition)))))
46+
47+
(testing "Greater than parses doubles"
48+
(let [condition {:type :standard
49+
:field "price"
50+
:op ">"
51+
:value "42.5"}
52+
expected (ast/range-double :price 42.5 nil {:include-lower? false})]
53+
(is (= expected (converter/condition->ast-clause condition)))))
54+
55+
(testing "Greater than falls back to string range when numeric parse fails"
56+
(let [condition {:type :standard
57+
:field "code"
58+
:op ">"
59+
:value "'A'"}
60+
expected (ast/range :code "A" nil {:include-lower? false})]
61+
(is (= expected (converter/condition->ast-clause condition)))))
62+
63+
(testing "Less-than-or-equal builds inclusive upper bound"
64+
(let [condition {:type :standard
65+
:field "age"
66+
:op "<="
67+
:value "65"}
68+
expected (ast/range-long :age nil 65 {:include-upper? true})]
69+
(is (= expected (converter/condition->ast-clause condition)))))
70+
71+
(testing "LIKE converts SQL wildcards to Lucene wildcards"
72+
(let [condition {:type :standard
73+
:field "title"
74+
:op "LIKE"
75+
:value "report-%Q1%"}
76+
expected (ast/wildcard :title "report-*Q1*")]
77+
(is (= expected (converter/condition->ast-clause condition)))))
78+
79+
(testing "IS NULL returns missing clause"
80+
(let [condition {:type :standard
81+
:field "deleted_at"
82+
:op "IS NULL"}
83+
expected (ast/missing :deleted_at)]
84+
(is (= expected (converter/condition->ast-clause condition)))))
85+
86+
(testing "IS NOT NULL returns exists clause"
87+
(let [condition {:type :standard
88+
:field "deleted_at"
89+
:op "IS NOT NULL"}
90+
expected (ast/exists :deleted_at)]
91+
(is (= expected (converter/condition->ast-clause condition)))))
92+
93+
(testing "Unknown condition type returns nil"
94+
(is (nil? (converter/condition->ast-clause
95+
{:type :unsupported
96+
:field "name"
97+
:op "="
98+
:value "Alice"})))))
99+
100+
(deftest conditions->ast-clauses-test
101+
(testing "Empty conditions produce nil"
102+
(is (nil? (converter/conditions->ast-clauses []))))
103+
104+
(testing "Single condition returns clause without wrapping"
105+
(let [condition {:type :standard :field "age" :op ">" :value "30"}
106+
expected (converter/condition->ast-clause condition)]
107+
(is (= expected (converter/conditions->ast-clauses [condition])))))
108+
109+
(testing "Multiple conditions combine with AND and drop nil clauses"
110+
(let [cond-a {:type :standard :field "age" :op ">" :value "30"}
111+
cond-b {:type :standard :field "status" :op "=" :value "active"}
112+
cond-nil {:type :unsupported :field "ignored" :op "=" :value "nope"}
113+
expected (ast/and (converter/condition->ast-clause cond-a)
114+
(converter/condition->ast-clause cond-b))]
115+
(is (= expected
116+
(converter/conditions->ast-clauses [cond-a cond-b cond-nil]))))))
117+

test/chrondb/query/ast_test.clj

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
(ns chrondb.query.ast-test
2+
(:require [clojure.test :refer [deftest is testing]]
3+
[chrondb.query.ast :as ast]))
4+
5+
(deftest match-and-term-clauses-test
6+
(testing "match-all returns the expected clause"
7+
(is (= {:type :match-all}
8+
(ast/match-all))))
9+
10+
(testing "term clauses require both field and value"
11+
(is (= {:type :term :field "status" :value "active"}
12+
(ast/term :status "active")))
13+
(is (= {:type :term :field "score" :value "42"}
14+
(ast/term :score 42)))
15+
(is (nil? (ast/term nil "active")))
16+
(is (nil? (ast/term :status nil))))
17+
18+
(testing "wildcard clauses normalize field and value"
19+
(is (= {:type :wildcard :field "name" :value "foo"}
20+
(ast/wildcard :name "foo")))
21+
(is (nil? (ast/wildcard :name nil))))
22+
23+
(testing "prefix delegates to wildcard with appended asterisk"
24+
(is (= (ast/wildcard :path "logs*")
25+
(ast/prefix :path "logs")))
26+
(is (nil? (ast/prefix :path nil))))
27+
28+
(testing "full-text search clauses include analyzer metadata"
29+
(is (= {:type :fts :field "content" :value "history" :analyzer :fts}
30+
(ast/fts :content "history")))
31+
(is (nil? (ast/fts :content nil))))
32+
33+
(testing "exists and missing clauses reject nil fields"
34+
(is (= {:type :exists :field "updated_at"}
35+
(ast/exists :updated_at)))
36+
(is (nil? (ast/exists nil)))
37+
(is (= {:type :missing :field "deleted_at"}
38+
(ast/missing :deleted_at)))
39+
(is (nil? (ast/missing nil)))))
40+
41+
(deftest range-clauses-test
42+
(testing "default range is inclusive with stringified bounds"
43+
(is (= {:type :range
44+
:field "timestamp"
45+
:lower "10"
46+
:upper "20"
47+
:include-lower? true
48+
:include-upper? true
49+
:value-type nil}
50+
(ast/range :timestamp 10 20))))
51+
52+
(testing "range supports open bounds and option overrides"
53+
(is (= {:type :range
54+
:field "created_at"
55+
:lower nil
56+
:upper "100"
57+
:include-lower? false
58+
:include-upper? false
59+
:value-type :string}
60+
(ast/range :created_at nil 100
61+
{:include-lower? false
62+
:include-upper? false
63+
:type :string}))))
64+
65+
(testing "numeric helper ranges set the value type"
66+
(is (= {:type :range
67+
:field "age"
68+
:lower "18"
69+
:upper "65"
70+
:include-lower? true
71+
:include-upper? false
72+
:value-type :long}
73+
(ast/range-long :age 18 65 {:include-upper? false})))
74+
(is (= {:type :range
75+
:field "score"
76+
:lower "0.1"
77+
:upper "0.9"
78+
:include-lower? true
79+
:include-upper? true
80+
:value-type :double}
81+
(ast/range-double :score 0.1 0.9 {})))))
82+
83+
(deftest boolean-node-test
84+
(testing "empty boolean collapses to match-all"
85+
(is (= (ast/match-all)
86+
(ast/boolean {}))))
87+
88+
(testing "single should clause collapses to the clause itself"
89+
(let [clause (ast/term :status "active")]
90+
(is (= clause
91+
(ast/boolean {:should [clause]})))))
92+
93+
(testing "must-not clauses receive an implicit match-all must"
94+
(let [clause (ast/term :status "inactive")]
95+
(is (= {:type :boolean
96+
:must [(ast/match-all)]
97+
:should []
98+
:must-not [clause]
99+
:filter []}
100+
(ast/boolean {:must []
101+
:should []
102+
:must-not [clause]
103+
:filter []})))))
104+
105+
(testing "general boolean vectorizes inputs and removes nils"
106+
(let [must-a (ast/term :status "active")
107+
should-a (ast/term :role "admin")
108+
result (ast/boolean {:must [must-a nil]
109+
:should [nil should-a]
110+
:must-not [nil]
111+
:filter nil})]
112+
(is (= {:type :boolean
113+
:must [must-a]
114+
:should [should-a]
115+
:must-not []
116+
:filter []}
117+
result)))))
118+
119+
(deftest logical-combinators-test
120+
(testing "and collapses edge cases"
121+
(let [clause (ast/term :status "active")]
122+
(is (= (ast/match-all) (ast/and)))
123+
(is (= clause (ast/and clause)))
124+
(is (= {:type :boolean
125+
:must [clause (ast/match-all)]
126+
:should []
127+
:must-not []
128+
:filter []}
129+
(ast/and clause nil (ast/match-all))))))
130+
131+
(testing "or collapses edge cases"
132+
(let [clause (ast/term :role "admin")]
133+
(is (= (ast/match-all) (ast/or)))
134+
(is (= clause (ast/or clause)))
135+
(is (= {:type :boolean
136+
:must []
137+
:should [clause (ast/match-all)]
138+
:must-not []
139+
:filter []}
140+
(ast/or clause nil (ast/match-all))))))
141+
142+
(testing "not wraps the clause with must-not and implicit must"
143+
(let [clause (ast/term :status "inactive")]
144+
(is (= {:type :boolean
145+
:must [(ast/match-all)]
146+
:should []
147+
:must-not [clause]
148+
:filter []}
149+
(ast/not clause))))
150+
151+
(testing "not defaults to match-all when clause is nil"
152+
(is (= {:type :boolean
153+
:must [(ast/match-all)]
154+
:should []
155+
:must-not [(ast/match-all)]
156+
:filter []}
157+
(ast/not nil)))))
158+
159+
(deftest sort-and-query-metadata-test
160+
(testing "sort descriptors infer defaults"
161+
(is (= {:field "timestamp" :direction :asc :type :string}
162+
(ast/sort-by :timestamp)))
163+
(is (= {:field "timestamp" :direction :desc :type :string}
164+
(ast/sort-by :timestamp :desc)))
165+
(is (= {:field "timestamp" :direction :desc :type :long}
166+
(ast/sort-by :timestamp :desc :long))))
167+
168+
(testing "query wraps clauses and optional metadata"
169+
(let [clause (ast/term :status "active")
170+
sort-desc (ast/sort-by :timestamp :desc :long)
171+
query (ast/query [clause]
172+
{:sort [sort-desc]
173+
:limit 25
174+
:offset 10
175+
:branch "feature"
176+
:hints {:refresh true}
177+
:after {:doc 42}})]
178+
(is (= {:clauses [clause]
179+
:sort [sort-desc]
180+
:limit 25
181+
:offset 10
182+
:branch "feature"
183+
:hints {:refresh true}
184+
:after {:doc 42}}
185+
query))))
186+
187+
(testing "query vectorizes sort descriptors when provided"
188+
(let [clause (ast/match-all)
189+
sort-desc (ast/sort-by :timestamp)
190+
query (ast/query [clause] {:sort [sort-desc]})]
191+
(is (= [sort-desc] (:sort query))))))
192+
193+
(deftest query-transformers-test
194+
(let [base-query (ast/query [(ast/match-all)])
195+
sort-desc (ast/sort-by :timestamp :desc :long)]
196+
(testing "with-branch replaces the branch"
197+
(is (= "main"
198+
(:branch (ast/with-branch base-query "main")))))
199+
200+
(testing "with-hints merges successive hint maps"
201+
(is (= {:refresh true :track-total-hits true}
202+
(:hints (-> base-query
203+
(ast/with-hints {:refresh true})
204+
(ast/with-hints {:track-total-hits true})))))
205+
(is (nil? (:hints (ast/with-hints base-query nil)))))
206+
207+
(testing "with-search-after attaches the cursor"
208+
(is (= {:doc 9 :score 0.42}
209+
(:after (ast/with-search-after base-query {:doc 9 :score 0.42})))))
210+
211+
(testing "with-pagination conditionally sets pagination keys"
212+
(is (= {:limit 100 :offset 20 :after {:doc 1}}
213+
(select-keys (ast/with-pagination base-query
214+
{:limit 100
215+
:offset 20
216+
:after {:doc 1}})
217+
[:limit :offset :after])))
218+
(is (= base-query (ast/with-pagination base-query {}))))
219+
220+
(testing "with-sort vectorizes descriptors"
221+
(is (= [sort-desc]
222+
(:sort (ast/with-sort base-query sort-desc))))
223+
(is (= [sort-desc]
224+
(:sort (ast/with-sort base-query [sort-desc]))))))
225+
))
226+

0 commit comments

Comments
 (0)