git.iko.soy/rowtype-yoga/purescript-yoga-postgres
on main help sign in
commit436c535ba6f2bd18400330550fccd58936a6fd47 authorMark <mark.eibes@gmail.com> dateFri 13 Feb 2026, 23:35 (8 months ago) parentc537b14
4 files changed+211−33
compile-fail-tests/HavingParamDuplicateName.purs+24 src/Yoga/Postgres/Schema.purs+21−31 test/EdgeCases.purs+143 test/Schema.purs+23−2
compile-fail-tests/HavingParamDuplicateName.pursnew file+24
@@ -0,0 +1,24 @@
1+-- EXPECT: Nub
2+module Test.CompileFail.HavingParamDuplicateName where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Type.Function (type (#))
7+import Type.Proxy (Proxy(..))
8+import Yoga.Postgres.Schema
9+
10+type UsersTable = Table "users"
11+ ( id :: Int # PrimaryKey # AutoIncrement
12+ , name :: String
13+ , email :: String # Unique
14+ , age :: Maybe Int
15+ )
16+
17+usersTable :: Proxy UsersTable
18+usersTable = Proxy
19+
20+bad = from usersTable
21+ # select @"name, COUNT(*) AS cnt"
22+ # where_ @"name = $x"
23+ # groupBy @"name"
24+ # having @"COUNT(*) > $x"
src/Yoga/Postgres/Schema.purs+21−31
@@ -55,14 +55,8 @@ data AutoIncrement a
5555data Unique :: Type -> Type
5656data Unique a
5757
58−data Default :: Symbol -> Type -> Type
59−data Default sym a
60−
61−data DefaultInt :: Int -> Type -> Type
62−data DefaultInt val a
63−
64−data DefaultBool :: Boolean -> Type -> Type
65−data DefaultBool val a
58+data Default :: forall k. k -> Type -> Type
59+data Default val a
6660
6761data References
6862
@@ -106,9 +100,7 @@ class ExtractType wrapped typ | wrapped -> typ
106100instance ExtractType a typ => ExtractType (PrimaryKey a) typ
107101else instance ExtractType a typ => ExtractType (AutoIncrement a) typ
108102else instance ExtractType a typ => ExtractType (Unique a) typ
109−else instance ExtractType a typ => ExtractType (Default sym a) typ
110−else instance ExtractType a typ => ExtractType (DefaultInt val a) typ
111−else instance ExtractType a typ => ExtractType (DefaultBool val a) typ
103+else instance ExtractType a typ => ExtractType (Default val a) typ
112104else instance ExtractType a typ => ExtractType (ForeignKey t r c a) typ
113105else instance ExtractType a typ => ExtractType (Nullable a) (Maybe typ)
114106else instance ExtractType a a
@@ -151,6 +143,16 @@ instance PGTypeName a => PGTypeName (Maybe a) where
151143-- Constraint -> DDL fragment
152144-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
153145
146+class RenderDefault val where
147+ renderDefault :: Proxy val -> String
148+
149+instance Reflectable sym String => RenderDefault sym where
150+ renderDefault _ = "DEFAULT '" <> reflectType (Proxy :: Proxy sym) <> "'"
151+else instance Reflectable val Int => RenderDefault val where
152+ renderDefault _ = "DEFAULT " <> show (reflectType (Proxy :: Proxy val))
153+else instance Reflectable val Boolean => RenderDefault val where
154+ renderDefault _ = if reflectType (Proxy :: Proxy val) then "DEFAULT true" else "DEFAULT false"
155+
154156class RenderConstraint a where
155157 renderConstraint :: Proxy a -> String
156158
@@ -163,16 +165,8 @@ else instance RenderConstraint a => RenderConstraint (AutoIncrement a) where
163165else instance RenderConstraint a => RenderConstraint (Unique a) where
164166 renderConstraint _ = joinConstraints "UNIQUE" (renderConstraint (Proxy :: Proxy a))
165167
166−else instance (Reflectable sym String, RenderConstraint a) => RenderConstraint (Default sym a) where
167− renderConstraint _ = joinConstraints ("DEFAULT '" <> reflectType (Proxy :: Proxy sym) <> "'") (renderConstraint (Proxy :: Proxy a))
168−
169−else instance (Reflectable val Int, RenderConstraint a) => RenderConstraint (DefaultInt val a) where
170− renderConstraint _ = joinConstraints ("DEFAULT " <> show (reflectType (Proxy :: Proxy val))) (renderConstraint (Proxy :: Proxy a))
171−
172−else instance (Reflectable val Boolean, RenderConstraint a) => RenderConstraint (DefaultBool val a) where
173− renderConstraint _ = joinConstraints
174− (if reflectType (Proxy :: Proxy val) then "DEFAULT true" else "DEFAULT false")
175− (renderConstraint (Proxy :: Proxy a))
168+else instance (RenderDefault val, RenderConstraint a) => RenderConstraint (Default val a) where
169+ renderConstraint _ = joinConstraints (renderDefault (Proxy :: Proxy val)) (renderConstraint (Proxy :: Proxy a))
176170
177171else instance (IsSymbol table, IsSymbol col, RenderConstraint a) => RenderConstraint (ForeignKey table References col a) where
178172 renderConstraint _ = joinConstraints
@@ -243,11 +237,7 @@ class IsAutoGenerated a where
243237
244238instance IsAutoGenerated (AutoIncrement a) where
245239 isAutoGenerated _ = true
246−else instance IsAutoGenerated (Default s a) where
247− isAutoGenerated _ = true
248−else instance IsAutoGenerated (DefaultInt v a) where
249− isAutoGenerated _ = true
250−else instance IsAutoGenerated (DefaultBool v a) where
240+else instance IsAutoGenerated (Default v a) where
251241 isAutoGenerated _ = true
252242else instance IsAutoGenerated a => IsAutoGenerated (PrimaryKey a) where
253243 isAutoGenerated _ = isAutoGenerated (Proxy :: Proxy a)
@@ -1637,8 +1627,6 @@ class IsAutoGeneratedTC constraints result | constraints -> result
16371627
16381628instance IsAutoGeneratedTC (AutoIncrement a) True
16391629else instance IsAutoGeneratedTC (Default s a) True
1640−else instance IsAutoGeneratedTC (DefaultInt v a) True
1641−else instance IsAutoGeneratedTC (DefaultBool v a) True
16421630else instance IsAutoGeneratedTC a result => IsAutoGeneratedTC (PrimaryKey a) result
16431631else instance IsAutoGeneratedTC a result => IsAutoGeneratedTC (Unique a) result
16441632else instance IsAutoGeneratedTC a result => IsAutoGeneratedTC (ForeignKey t r c a) result
@@ -1877,9 +1865,11 @@ groupBy
18771865groupBy (Q q) = Q (q { sql = q.sql <> " GROUP BY " <> reflectSymbol (Proxy :: Proxy cols) })
18781866
18791867having
1880− :: forall @cond tables result params params' stage stage'
1868+ :: forall @cond tables result params havingParams allParams stage stage'
18811869 . IsSymbol cond
1882− => ParseWhere cond tables params'
1870+ => ParseWhere cond tables havingParams
1871+ => Row.Union params havingParams allParams
1872+ => Row.Nub allParams allParams
18831873 => HasClause "groupBy" stage
18841874 => Row.Lacks "having" stage
18851875 => Row.Lacks "orderBy" stage
@@ -1887,7 +1877,7 @@ having
18871877 => Row.Lacks "offset" stage
18881878 => Row.Cons "having" Unit stage stage'
18891879 => Q tables result params stage
1890− -> Q tables result params' stage'
1880+ -> Q tables result allParams stage'
18911881having (Q q) = Q (q { sql = q.sql <> " HAVING " <> reflectSymbol (Proxy :: Proxy cond) })
18921882
18931883class IsLimitParam :: Symbol -> Symbol -> Boolean -> Constraint
test/EdgeCases.pursnew file+143
@@ -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 {}
test/Schema.purs+23−2
@@ -63,9 +63,9 @@ selectWhereSQL = selectWhereSQLFor @UsersTable @(id :: Int)
6363
6464type ConfigTable = Table "config"
6565 ( id :: Int # PrimaryKey # AutoIncrement
66− , active :: Boolean # DefaultBool True
66+ , active :: Boolean # Default True
6767 , role :: String # Default "user"
68− , score :: Int # DefaultInt 0
68+ , score :: Int # Default 0
6969 )
7070
7171configDDL :: String
@@ -464,6 +464,14 @@ typedFullAggregate = from usersTable
464464 # orderBy @"name"
465465 # limit @"10"
466466
467+typedWhereGroupByHaving
468+ :: Q _ (name :: String, cnt :: Int) (age :: Int, minCount :: Int) _
469+typedWhereGroupByHaving = from usersTable
470+ # select @"name, COUNT(*) AS cnt"
471+ # where_ @"age > $age"
472+ # groupBy @"name"
473+ # having @"COUNT(*) > $minCount"
474+
467475-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
468476-- Table aliases
469477-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -731,6 +739,8 @@ spec = do
731739 (typedHaving # toSQL) `shouldEqual` "SELECT name, COUNT(*) AS cnt FROM users GROUP BY name HAVING COUNT(*) > $minCount"
732740 it "builds full aggregate chain" do
733741 (typedFullAggregate # toSQL) `shouldEqual` "SELECT name, COUNT(*) AS cnt FROM users GROUP BY name HAVING COUNT(*) > $min ORDER BY name LIMIT 10"
742+ it "builds WHERE + GROUP BY + HAVING" do
743+ (typedWhereGroupByHaving # toSQL) `shouldEqual` "SELECT name, COUNT(*) AS cnt FROM users WHERE age > $age GROUP BY name HAVING COUNT(*) > $minCount"
734744
735745 describe "Builder window functions" do
736746 it "builds ROW_NUMBER() OVER" do
@@ -1170,3 +1180,14 @@ integrationSpec conn = do
11701180 # where_ @"(age > $a OR age < $b) AND name = $name"
11711181 # runQuery conn { a: 100, b: 0, name: "Alice" }
11721182 rows `shouldEqual` ([] :: Array { name :: String })
1183+
1184+ describe "HAVING with WHERE params" do
1185+ it "WHERE + GROUP BY + HAVING merges params" do
1186+ resetUsers conn
1187+ rows <- from usersTable
1188+ # select @"name, COUNT(*) AS cnt"
1189+ # where_ @"age > $age"
1190+ # groupBy @"name"
1191+ # having @"COUNT(*) > $minCount"
1192+ # runQuery conn { age: 10, minCount: 0 }
1193+ rows `shouldEqual` [ { name: "Alice", cnt: 1 }, { name: "Bob", cnt: 1 } ]