Skip to content

Commit 88e11b9

Browse files
committed
test: add e2e black-box scenarios at tests/ root
tests/e2e-test.phel sits next to the per-domain suite under tests/sql/. Each deftest is a realistic, application-shaped query that exercises many subsystems together: a regression here means the cross-cutting behaviour broke even if the focused tests still pass. Scenarios: - paginated user dashboard (CTE + alias + join + filter + order + limit) - analytics rollup (group-by + having + agg FILTER + window ranking) - Postgres upsert with EXCLUDED + returning - MySQL upsert with VALUES(col) reference - soft-delete by UPDATE + NOT EXISTS correlated subquery - bulk delete with RETURNING - recursive CTE walking a parent/child tree - DISTINCT ON for latest-per-group - LATERAL join for top-N per group - UNION ALL of two filtered queries with literal projection tags - CASE in select to bucket rows + sort by bucket - determinism + pure-function invariants (string + vector shape)
1 parent 6c336b5 commit 88e11b9

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

tests/e2e-test.phel

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
(ns phel-sql-tests.e2e-test
2+
(:require phel-sql.sql :as sql)
3+
(:require phel.test :refer [deftest is testing]))
4+
5+
;; ===========================================================================
6+
;; End-to-end scenarios.
7+
;;
8+
;; The per-domain tests under tests/sql/ each focus on one clause or
9+
;; expression form. This file does the opposite: each deftest is a
10+
;; realistic, application-shaped query that exercises many subsystems
11+
;; together. If the per-domain suite passes but one of these fails, a
12+
;; cross-cutting regression has slipped in.
13+
;; ===========================================================================
14+
15+
(deftest e2e-user-dashboard-page
16+
(testing "paginated dashboard: CTE + alias + join + filter + order + limit"
17+
(is (= [(str "WITH recent_orders AS (SELECT user_id, count(*) AS n FROM orders "
18+
"WHERE created_at >= ? GROUP BY user_id) "
19+
"SELECT u.id, u.name, r.n "
20+
"FROM users AS u "
21+
"JOIN recent_orders AS r ON u.id = r.user_id "
22+
"WHERE u.status = ? "
23+
"ORDER BY r.n DESC "
24+
"LIMIT 25 OFFSET 50")
25+
["2026-01-01" "active"]]
26+
(sql/format
27+
{:with [[:recent_orders
28+
{:select [:user_id [[:fn :count :*] :n]]
29+
:from [:orders]
30+
:where [:>= :created_at "2026-01-01"]
31+
:group-by [:user_id]}]]
32+
:select [:u/id :u/name :r/n]
33+
:from [[:users :u]]
34+
:join [[:recent_orders :r] [:= :u/id :r/user_id]]
35+
:where [:= :u/status "active"]
36+
:order-by [[:r/n :desc]]
37+
:limit 25
38+
:offset 50})))))
39+
40+
(deftest e2e-analytics-rollup
41+
(testing "aggregation: group + having + filter aggregate + window for ranking"
42+
(is (= [(str "SELECT country, "
43+
"count(*) FILTER (WHERE active = ?) AS active_users, "
44+
"row_number() OVER (ORDER BY count(*) DESC) AS rk "
45+
"FROM users "
46+
"GROUP BY country "
47+
"HAVING count(*) >= ?")
48+
[true 100]]
49+
(sql/format
50+
{:select [:country
51+
[[:filter [:fn :count :*] [:= :active true]] :active_users]
52+
[[:over [:fn :row_number] {:order-by [[[:fn :count :*] :desc]]}] :rk]]
53+
:from [:users]
54+
:group-by [:country]
55+
:having [:>= [:fn :count :*] 100]})))))
56+
57+
(deftest e2e-postgres-upsert
58+
(testing "PG upsert with EXCLUDED-style update + returning"
59+
(is (= [(str "INSERT INTO users (email, name) VALUES (?, ?) "
60+
"ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name "
61+
"RETURNING id, email")
62+
["a@x.com" "Alice"]]
63+
(sql/format
64+
{:insert-into :users
65+
:values [{:email "a@x.com" :name "Alice"}]
66+
:on-conflict [:email]
67+
:do-update-set {:name [:raw "EXCLUDED.name"]}
68+
:returning [:id :email]})))))
69+
70+
(deftest e2e-mysql-upsert
71+
(testing "MySQL upsert with VALUES(col) reference"
72+
(is (= [(str "INSERT INTO counters (key, n) VALUES (?, ?) "
73+
"ON DUPLICATE KEY UPDATE n = n + VALUES(n)")
74+
["visits" 1]]
75+
(sql/format
76+
{:insert-into :counters
77+
:values [{:key "visits" :n 1}]
78+
:on-duplicate-key-update {:n [:raw "n + VALUES(n)"]}})))))
79+
80+
(deftest e2e-soft-delete-with-exists
81+
(testing "filter users who do not have a deletion record"
82+
(is (= [(str "UPDATE users SET status = ?, updated_at = NOW() "
83+
"WHERE (id = ?) AND (NOT EXISTS (SELECT 1 FROM deletions "
84+
"WHERE user_id = users.id))")
85+
["archived" 42]]
86+
(sql/format
87+
{:update :users
88+
:set {:status "archived"
89+
:updated_at [:raw "NOW()"]}
90+
:where [:and
91+
[:= :id 42]
92+
[:not-exists {:select ["1"]
93+
:from [:deletions]
94+
:where [:= :user_id :users/id]}]]})))))
95+
96+
(deftest e2e-bulk-delete-with-returning
97+
(testing "bulk delete by IN list + RETURNING the removed rows"
98+
(is (= ["DELETE FROM orders WHERE id IN (?, ?, ?) RETURNING id, user_id"
99+
[1 2 3]]
100+
(sql/format
101+
{:delete-from :orders
102+
:where [:in :id [1 2 3]]
103+
:returning [:id :user_id]})))))
104+
105+
(deftest e2e-recursive-cte-graph-walk
106+
(testing "recursive CTE traversing a parent/child tree"
107+
(is (= [(str "WITH RECURSIVE tree AS ((SELECT id, parent_id FROM nodes WHERE id = ?) "
108+
"UNION ALL "
109+
"(SELECT n.id, n.parent_id FROM nodes AS n "
110+
"JOIN tree AS t ON n.parent_id = t.id)) "
111+
"SELECT id FROM tree")
112+
[1]]
113+
(sql/format
114+
{:with-recursive
115+
[[:tree
116+
{:union-all
117+
[{:select [:id :parent_id]
118+
:from [:nodes]
119+
:where [:= :id 1]}
120+
{:select [:n/id :n/parent_id]
121+
:from [[:nodes :n]]
122+
:join [[:tree :t] [:= :n/parent_id :t/id]]}]}]]
123+
:select [:id]
124+
:from [:tree]})))))
125+
126+
(deftest e2e-distinct-on-latest-per-group
127+
(testing "Postgres DISTINCT ON for the latest row per user"
128+
(is (= [(str "SELECT DISTINCT ON (user_id) user_id, id, created_at "
129+
"FROM events "
130+
"ORDER BY user_id ASC, created_at DESC")
131+
[]]
132+
(sql/format
133+
{:select-distinct-on {:on [:user_id]
134+
:select [:user_id :id :created_at]}
135+
:from [:events]
136+
:order-by [[:user_id :asc] [:created_at :desc]]})))))
137+
138+
(deftest e2e-lateral-join-top-n-per-group
139+
(testing "top-N orders per user via LATERAL subquery"
140+
(is (= [(str "SELECT u.id, o.id, o.amount "
141+
"FROM users AS u "
142+
"JOIN LATERAL (SELECT id, amount FROM orders WHERE user_id = u.id "
143+
"ORDER BY amount DESC LIMIT 3) AS o ON TRUE")
144+
[]]
145+
(sql/format
146+
{:select [:u/id :o/id :o/amount]
147+
:from [[:users :u]]
148+
:join [[[:lateral {:select [:id :amount]
149+
:from [:orders]
150+
:where [:= :user_id :u/id]
151+
:order-by [[:amount :desc]]
152+
:limit 3}] :o]
153+
[:raw "TRUE"]]})))))
154+
155+
(deftest e2e-union-of-filtered-queries
156+
(testing "union of two filtered queries with their own params"
157+
(is (= [(str "(SELECT id, 'customer' AS source FROM customers WHERE country = ?) "
158+
"UNION ALL "
159+
"(SELECT id, 'lead' AS source FROM leads WHERE country = ?)")
160+
["ES" "ES"]]
161+
(sql/format
162+
{:union-all [{:select [:id [[:raw "'customer'"] :source]]
163+
:from [:customers]
164+
:where [:= :country "ES"]}
165+
{:select [:id [[:raw "'lead'"] :source]]
166+
:from [:leads]
167+
:where [:= :country "ES"]}]})))))
168+
169+
(deftest e2e-conditional-projection
170+
(testing "CASE in select to bucket rows + ordering by bucket"
171+
(is (= [(str "SELECT id, "
172+
"CASE WHEN amount > ? THEN ? WHEN amount > ? THEN ? ELSE ? END AS tier "
173+
"FROM orders "
174+
"ORDER BY tier ASC")
175+
[1000 "gold" 100 "silver" "bronze"]]
176+
(sql/format
177+
{:select [:id
178+
[[:case
179+
[:> :amount 1000] "gold"
180+
[:> :amount 100] "silver"
181+
:else "bronze"]
182+
:tier]]
183+
:from [:orders]
184+
:order-by [[:tier :asc]]})))))
185+
186+
(deftest e2e-pure-and-deterministic
187+
(testing "same input always produces same output"
188+
(let [q {:select [:id :name :email]
189+
:from [:users]
190+
:where [:= :status "active"]
191+
:order-by [[:created_at :desc]]
192+
:limit 10}]
193+
(is (= (sql/format q) (sql/format q)))
194+
(is (= (sql/format q) (sql/format q)))))
195+
196+
(testing "no I/O leaks: params come back as a vector, sql as a string"
197+
(let [[s p] (sql/format {:select [:id] :from [:t] :where [:= :a 1]})]
198+
(is (string? s))
199+
(is (vector? p))
200+
(is (= [1] p)))))

0 commit comments

Comments
 (0)