|
| 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