@@ -0,0 +1,143 @@
1+module Test.Postgres.EdgeCases where
2+
3+import Prelude
4+
5+import Data.Maybe (Maybe)
6+import Prim.Boolean (True)
7+import Type.Function (type (#))
8+import Type.Proxy (Proxy(..))
9+import Yoga.Postgres.Schema
10+
11+-- Same table definitions as Schema tests
12+type UsersTable = Table "users"
13+ ( id :: Int # PrimaryKey # AutoIncrement
14+ , name :: String
15+ , email :: String # Unique
16+ , age :: Maybe Int
17+ )
18+
19+usersTable :: Proxy UsersTable
20+usersTable = Proxy
21+
22+type PostsTable = Table "posts"
23+ ( id :: Int # PrimaryKey # AutoIncrement
24+ , title :: String
25+ , body :: String
26+ , user_id :: Int
27+ )
28+
29+postsTable :: Proxy PostsTable
30+postsTable = Proxy
31+
32+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33+-- EDGE CASE 1: HAVING merges WHERE params (FIXED)
34+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
35+
36+havingMergesWhereParams
37+ :: Q _ (name :: String, cnt :: Int) (age :: Int, minCount :: Int) _
38+havingMergesWhereParams = from usersTable
39+ # select @"name, COUNT(*) AS cnt"
40+ # where_ @"age > $age"
41+ # groupBy @"name"
42+ # having @"COUNT(*) > $minCount"
43+
44+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45+-- EDGE CASE 2: WHERE without any DML operation
46+-- from table # where_ compiles, producing invalid SQL like
47+-- "users WHERE id = $id" (missing SELECT/DELETE/UPDATE)
48+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
49+
50+whereWithoutOperation :: Q _ _ (id :: Int) _
51+whereWithoutOperation = from usersTable # where_ @"id = $id"
52+
53+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54+-- EDGE CASE 3: GROUP BY doesn't enforce aggregation rules
55+-- Non-aggregated, non-grouped columns in SELECT should be
56+-- rejected when GROUP BY is present.
57+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
58+
59+-- This produces invalid SQL: SELECT name, email FROM users GROUP BY name
60+-- 'email' is not in GROUP BY and not aggregated.
61+-- PostgreSQL would reject this at runtime.
62+groupByMissingColumn
63+ :: Q _ (name :: String, email :: String) () _
64+groupByMissingColumn = from usersTable
65+ # select @"name, email"
66+ # groupBy @"name"
67+
68+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
69+-- EDGE CASE 4: OVER clause content is not validated
70+-- Invalid SQL inside OVER() parens is silently accepted.
71+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72+
73+-- OVER clause content is completely skipped by ExtractUntilParen
74+-- "INVALID SQL HERE" is not validated at all
75+overClauseNotValidated
76+ :: Q _ (name :: String, rn :: Int) () _
77+overClauseNotValidated = from usersTable
78+ # select @"name, ROW_NUMBER() OVER (THIS IS TOTALLY INVALID) AS rn"
79+
80+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
81+-- EDGE CASE 5: WHERE keywords are case-sensitive (uppercase only)
82+-- "IS NULL" works but "is null" would fail (tries to resolve "is" as column)
83+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84+
85+-- This works:
86+uppercaseIsNull :: Q _ _ () _
87+uppercaseIsNull = from usersTable # selectAll # where_ @"age IS NULL"
88+
89+-- This would FAIL to compile (uncomment to verify):
90+-- lowercaseIsNull :: Q _ _ () _
91+-- lowercaseIsNull = from usersTable # selectAll # where_ @"age is null"
92+
93+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
94+-- EDGE CASE 6: DISTINCT ORDER BY on non-selected column
95+-- PostgreSQL: "ORDER BY items must appear in the select list"
96+-- when using SELECT DISTINCT
97+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
98+
99+-- Produces: SELECT DISTINCT name FROM users ORDER BY age
100+-- PostgreSQL rejects this but it compiles
101+distinctOrderByNonSelected
102+ :: Q _ (name :: String) () _
103+distinctOrderByNonSelected = from usersTable
104+ # selectDistinct @"name"
105+ # orderBy @"age"
106+
107+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
108+-- EDGE CASE 7: Aggregate function on nonexistent column in OVER
109+-- OVER content is skipped so "ORDER BY nonexistent" compiles
110+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
111+
112+overNonexistentColumn
113+ :: Q _ (name :: String, rn :: Int) () _
114+overNonexistentColumn = from usersTable
115+ # select @"name, ROW_NUMBER() OVER (ORDER BY nonexistent_column) AS rn"
116+
117+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
118+-- EDGE CASE 8: Nested parens in aggregate args
119+-- ExtractUntilParen stops at the FIRST ), not matching brackets
120+-- COALESCE(NULLIF(x, 0), 1) would break parsing
121+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
122+
123+-- Uncomment to test:
124+-- nestedParensInAggregate
125+-- :: Q _ (name :: String, val :: Int) () _
126+-- nestedParensInAggregate = from usersTable
127+-- # select @"name, COALESCE(age, 0) AS val"
128+
129+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
130+-- EDGE CASE 9: INSERT on table with all auto-generated columns
131+-- Produces: INSERT INTO config () VALUES ()
132+-- This is invalid PostgreSQL syntax (should be DEFAULT VALUES)
133+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
134+
135+type AllDefaultTable = Table "all_default"
136+ ( id :: Int # PrimaryKey # AutoIncrement
137+ , active :: Boolean # Default True
138+ , score :: Int # Default 0
139+ )
140+
141+allDefaultInsert :: Q _ () () _
142+allDefaultInsert = from (Proxy :: Proxy AllDefaultTable)
143+ # insert {}