git.iko.soy/rowtype-yoga/purescript-yoga-postgres
on main help sign in
Add compile-fail tests for window functions
commit5550cd64c767dc6a69b445a9b9b3e978c0bb6870 authorMark <mark.eibes@gmail.com> dateFri 13 Feb 2026, 22:13 (8 months ago) parent2f0d408
6 files changed+403
compile-fail-tests/GroupByAfterUnion.purs+20 compile-fail-tests/LimitParamDuplicateName.purs+20 compile-fail-tests/UnionWithoutSelect.purs+20 compile-fail-tests/WhereAfterUnion.purs+20 src/Yoga/Postgres/Schema.purs+104 test/Schema.purs+219
compile-fail-tests/GroupByAfterUnion.pursnew file+20
@@ -0,0 +1,20 @@
1+-- EXPECT: Lacks
2+module Test.CompileFail.GroupByAfterUnion where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Data.Tuple.Nested (type (/\))
7+import Type.Proxy (Proxy(..))
8+import Yoga.Postgres.Schema
9+
10+type UsersTable = Table "users"
11+ ( id :: Column Int (PrimaryKey /\ AutoIncrement)
12+ , name :: Column String None
13+ , email :: Column String Unique
14+ , age :: Column (Maybe Int) None
15+ )
16+
17+usersTable :: Proxy UsersTable
18+usersTable = Proxy
19+
20+bad = (from usersTable # select @"name") `union` (from usersTable # select @"name") # groupBy @"name"
compile-fail-tests/LimitParamDuplicateName.pursnew file+20
@@ -0,0 +1,20 @@
1+-- EXPECT: Lacks
2+module Test.CompileFail.LimitParamDuplicateName where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Data.Tuple.Nested (type (/\))
7+import Type.Proxy (Proxy(..))
8+import Yoga.Postgres.Schema
9+
10+type UsersTable = Table "users"
11+ ( id :: Column Int (PrimaryKey /\ AutoIncrement)
12+ , name :: Column String None
13+ , email :: Column String Unique
14+ , age :: Column (Maybe Int) None
15+ )
16+
17+usersTable :: Proxy UsersTable
18+usersTable = Proxy
19+
20+bad = from usersTable # selectAll # where_ @"age > $n" # limitParam @"n"
compile-fail-tests/UnionWithoutSelect.pursnew file+20
@@ -0,0 +1,20 @@
1+-- EXPECT: Cons
2+module Test.CompileFail.UnionWithoutSelect where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Data.Tuple.Nested (type (/\))
7+import Type.Proxy (Proxy(..))
8+import Yoga.Postgres.Schema
9+
10+type UsersTable = Table "users"
11+ ( id :: Column Int (PrimaryKey /\ AutoIncrement)
12+ , name :: Column String None
13+ , email :: Column String Unique
14+ , age :: Column (Maybe Int) None
15+ )
16+
17+usersTable :: Proxy UsersTable
18+usersTable = Proxy
19+
20+bad = (from usersTable) `union` (from usersTable)
compile-fail-tests/WhereAfterUnion.pursnew file+20
@@ -0,0 +1,20 @@
1+-- EXPECT: Lacks
2+module Test.CompileFail.WhereAfterUnion where
3+
4+import Prelude
5+import Data.Maybe (Maybe(..))
6+import Data.Tuple.Nested (type (/\))
7+import Type.Proxy (Proxy(..))
8+import Yoga.Postgres.Schema
9+
10+type UsersTable = Table "users"
11+ ( id :: Column Int (PrimaryKey /\ AutoIncrement)
12+ , name :: Column String None
13+ , email :: Column String Unique
14+ , age :: Column (Maybe Int) None
15+ )
16+
17+usersTable :: Proxy UsersTable
18+usersTable = Proxy
19+
20+bad = (from usersTable # select @"name") `union` (from usersTable # select @"name") # where_ @"name = $name"
src/Yoga/Postgres/Schema.purs+104
@@ -1838,6 +1838,18 @@ limit
18381838 -> Q tables result params stage'
18391839limit n (Q q) = Q (q { sql = q.sql <> " LIMIT " <> show n })
18401840
1841+limitParam
1842+ :: forall @name tables result params params' stage stage'
1843+ . IsSymbol name
1844+ => Row.Lacks name params
1845+ => Row.Cons name Int params params'
1846+ => HasClause "select" stage
1847+ => Row.Lacks "limit" stage
1848+ => Row.Cons "limit" Unit stage stage'
1849+ => Q tables result params stage
1850+ -> Q tables result params' stage'
1851+limitParam (Q q) = Q (q { sql = q.sql <> " LIMIT $" <> reflectSymbol (Proxy :: Proxy name) })
1852+
18411853offset
18421854 :: forall tables result params stage stage'
18431855 . HasClause "select" stage
@@ -1848,6 +1860,18 @@ offset
18481860 -> Q tables result params stage'
18491861offset n (Q q) = Q (q { sql = q.sql <> " OFFSET " <> show n })
18501862
1863+offsetParam
1864+ :: forall @name tables result params params' stage stage'
1865+ . IsSymbol name
1866+ => Row.Lacks name params
1867+ => Row.Cons name Int params params'
1868+ => HasClause "select" stage
1869+ => Row.Lacks "offset" stage
1870+ => Row.Cons "offset" Unit stage stage'
1871+ => Q tables result params stage
1872+ -> Q tables result params' stage'
1873+offsetParam (Q q) = Q (q { sql = q.sql <> " OFFSET $" <> reflectSymbol (Proxy :: Proxy name) })
1874+
18511875-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18521876-- INSERT builder
18531877-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -2135,6 +2159,86 @@ leftJoinAs _ (Q q) = Q
21352159 , values: q.values
21362160 }
21372161
2162+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2163+-- Set operations: UNION, INTERSECT, EXCEPT
2164+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2165+
2166+type SetOpStage =
2167+ ( select :: Unit
2168+ , "where" :: Unit
2169+ , groupBy :: Unit
2170+ , having :: Unit
2171+ , insert :: Unit
2172+ , "set" :: Unit
2173+ , "delete" :: Unit
2174+ )
2175+
2176+union
2177+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2178+ . HasClause "select" stage1
2179+ => HasClause "select" stage2
2180+ => Row.Union params1 params2 params
2181+ => Row.Nub params params
2182+ => Q tables1 result params1 stage1
2183+ -> Q tables2 result params2 stage2
2184+ -> Q tables1 result params SetOpStage
2185+union (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") UNION (" <> q2.sql <> ")", values: q1.values <> q2.values }
2186+
2187+unionAll
2188+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2189+ . HasClause "select" stage1
2190+ => HasClause "select" stage2
2191+ => Row.Union params1 params2 params
2192+ => Row.Nub params params
2193+ => Q tables1 result params1 stage1
2194+ -> Q tables2 result params2 stage2
2195+ -> Q tables1 result params SetOpStage
2196+unionAll (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") UNION ALL (" <> q2.sql <> ")", values: q1.values <> q2.values }
2197+
2198+intersect
2199+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2200+ . HasClause "select" stage1
2201+ => HasClause "select" stage2
2202+ => Row.Union params1 params2 params
2203+ => Row.Nub params params
2204+ => Q tables1 result params1 stage1
2205+ -> Q tables2 result params2 stage2
2206+ -> Q tables1 result params SetOpStage
2207+intersect (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") INTERSECT (" <> q2.sql <> ")", values: q1.values <> q2.values }
2208+
2209+intersectAll
2210+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2211+ . HasClause "select" stage1
2212+ => HasClause "select" stage2
2213+ => Row.Union params1 params2 params
2214+ => Row.Nub params params
2215+ => Q tables1 result params1 stage1
2216+ -> Q tables2 result params2 stage2
2217+ -> Q tables1 result params SetOpStage
2218+intersectAll (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") INTERSECT ALL (" <> q2.sql <> ")", values: q1.values <> q2.values }
2219+
2220+except_
2221+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2222+ . HasClause "select" stage1
2223+ => HasClause "select" stage2
2224+ => Row.Union params1 params2 params
2225+ => Row.Nub params params
2226+ => Q tables1 result params1 stage1
2227+ -> Q tables2 result params2 stage2
2228+ -> Q tables1 result params SetOpStage
2229+except_ (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") EXCEPT (" <> q2.sql <> ")", values: q1.values <> q2.values }
2230+
2231+exceptAll
2232+ :: forall tables1 tables2 result params1 params2 params stage1 stage2
2233+ . HasClause "select" stage1
2234+ => HasClause "select" stage2
2235+ => Row.Union params1 params2 params
2236+ => Row.Nub params params
2237+ => Q tables1 result params1 stage1
2238+ -> Q tables2 result params2 stage2
2239+ -> Q tables1 result params SetOpStage
2240+exceptAll (Q q1) (Q q2) = Q { sql: "(" <> q1.sql <> ") EXCEPT ALL (" <> q2.sql <> ")", values: q1.values <> q2.values }
2241+
21382242-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21392243-- Query execution
21402244-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test/Schema.purs+219
@@ -530,6 +530,84 @@ typedNtile
530530typedNtile = from usersTable
531531 # select @"name, NTILE(4) OVER (ORDER BY age) AS bucket"
532532
533+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
534+-- Parameterized LIMIT / OFFSET
535+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
536+
537+typedLimitParam
538+ :: Q _ (name :: String) (n :: Int) _
539+typedLimitParam = from usersTable # select @"name" # limitParam @"n"
540+
541+typedOffsetParam
542+ :: Q _ (name :: String) (n :: Int) _
543+typedOffsetParam = from usersTable # select @"name" # offsetParam @"n"
544+
545+typedLimitParamWithWhere
546+ :: Q _ (name :: String) (age :: Int, n :: Int) _
547+typedLimitParamWithWhere = from usersTable
548+ # select @"name"
549+ # where_ @"age > $age"
550+ # limitParam @"n"
551+
552+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
553+-- UNION / INTERSECT / EXCEPT
554+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
555+
556+typedUnion
557+ :: Q _ (name :: String) () _
558+typedUnion = (from usersTable # select @"name")
559+ `union` (from usersTable # select @"name")
560+
561+typedUnionAll
562+ :: Q _ (name :: String) () _
563+typedUnionAll = (from usersTable # select @"name")
564+ `unionAll` (from usersTable # select @"name")
565+
566+typedUnionWithParams
567+ :: Q _ (name :: String) (a :: Int, b :: Int) _
568+typedUnionWithParams =
569+ (from usersTable # select @"name" # where_ @"age > $a")
570+ `union` (from usersTable # select @"name" # where_ @"age < $b")
571+
572+typedUnionOrderByLimit
573+ :: Q _ (name :: String) () _
574+typedUnionOrderByLimit =
575+ (from usersTable # select @"name")
576+ `union` (from usersTable # select @"name")
577+ # orderBy @"name"
578+ # limit 10
579+
580+typedIntersect
581+ :: Q _ (name :: String) () _
582+typedIntersect = (from usersTable # select @"name")
583+ `intersect` (from usersTable # select @"name")
584+
585+typedExcept
586+ :: Q _ (name :: String) () _
587+typedExcept = (from usersTable # select @"name")
588+ `except_` (from usersTable # select @"name")
589+
590+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
591+-- WHERE features
592+-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
593+
594+typedBetween
595+ :: Q _ _ (lo :: Int, hi :: Int) _
596+typedBetween = from usersTable # selectAll # where_ @"age BETWEEN $lo AND $hi"
597+
598+typedIsNull
599+ :: Q _ _ () _
600+typedIsNull = from usersTable # selectAll # where_ @"age IS NULL"
601+
602+typedIsNotNull
603+ :: Q _ _ () _
604+typedIsNotNull = from usersTable # selectAll # where_ @"age IS NOT NULL"
605+
606+typedNestedParens
607+ :: Q _ _ (a :: Int, b :: Int, name :: String) _
608+typedNestedParens = from usersTable # selectAll
609+ # where_ @"(age > $a OR age < $b) AND name = $name"
610+
533611-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
534612-- Spec
535613-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -671,6 +749,38 @@ spec = do
671749 it "builds NTILE(n) OVER" do
672750 (typedNtile # toSQL) `shouldEqual` "SELECT name, NTILE(4) OVER (ORDER BY age) AS bucket FROM users"
673751
752+ describe "Builder parameterized LIMIT/OFFSET" do
753+ it "builds LIMIT with param" do
754+ (typedLimitParam # toSQL) `shouldEqual` "SELECT name FROM users LIMIT $n"
755+ it "builds OFFSET with param" do
756+ (typedOffsetParam # toSQL) `shouldEqual` "SELECT name FROM users OFFSET $n"
757+ it "combines WHERE params with LIMIT param" do
758+ (typedLimitParamWithWhere # toSQL) `shouldEqual` "SELECT name FROM users WHERE age > $age LIMIT $n"
759+
760+ describe "Builder UNION / INTERSECT / EXCEPT" do
761+ it "builds UNION" do
762+ (typedUnion # toSQL) `shouldEqual` "(SELECT name FROM users) UNION (SELECT name FROM users)"
763+ it "builds UNION ALL" do
764+ (typedUnionAll # toSQL) `shouldEqual` "(SELECT name FROM users) UNION ALL (SELECT name FROM users)"
765+ it "builds UNION with params from both sides" do
766+ (typedUnionWithParams # toSQL) `shouldEqual` "(SELECT name FROM users WHERE age > $a) UNION (SELECT name FROM users WHERE age < $b)"
767+ it "builds UNION with ORDER BY and LIMIT" do
768+ (typedUnionOrderByLimit # toSQL) `shouldEqual` "(SELECT name FROM users) UNION (SELECT name FROM users) ORDER BY name LIMIT 10"
769+ it "builds INTERSECT" do
770+ (typedIntersect # toSQL) `shouldEqual` "(SELECT name FROM users) INTERSECT (SELECT name FROM users)"
771+ it "builds EXCEPT" do
772+ (typedExcept # toSQL) `shouldEqual` "(SELECT name FROM users) EXCEPT (SELECT name FROM users)"
773+
774+ describe "Builder WHERE features" do
775+ it "builds BETWEEN" do
776+ (typedBetween # toSQL) `shouldEqual` "SELECT * FROM users WHERE age BETWEEN $lo AND $hi"
777+ it "builds IS NULL" do
778+ (typedIsNull # toSQL) `shouldEqual` "SELECT * FROM users WHERE age IS NULL"
779+ it "builds IS NOT NULL" do
780+ (typedIsNotNull # toSQL) `shouldEqual` "SELECT * FROM users WHERE age IS NOT NULL"
781+ it "builds nested parentheses" do
782+ (typedNestedParens # toSQL) `shouldEqual` "SELECT * FROM users WHERE (age > $a OR age < $b) AND name = $name"
783+
674784integrationSpec :: PG.Connection -> Spec Unit
675785integrationSpec conn = do
676786 describe "Builder query execution" do
@@ -941,3 +1051,112 @@ integrationSpec conn = do
9411051 case result of
9421052 Just r -> r.title `shouldEqual` "Alice's Post"
9431053 Nothing -> shouldEqual "found" "nothing"
1054+
1055+ describe "Parameterized LIMIT/OFFSET execution" do
1056+ it "parameterized LIMIT restricts rows" do
1057+ rows <- from usersTable # select @"name" # orderBy @"name"
1058+ # limitParam @"n"
1059+ # runQuery conn { n: 1 }
1060+ Array.length rows `shouldEqual` 1
1061+
1062+ it "parameterized OFFSET skips rows" do
1063+ rows <- from usersTable # select @"name" # orderBy @"name"
1064+ # limitParam @"n"
1065+ # offsetParam @"off"
1066+ # runQuery conn { n: 1, off: 1 }
1067+ Array.length rows `shouldEqual` 1
1068+
1069+ it "parameterized LIMIT with WHERE" do
1070+ rows <- from usersTable # select @"name"
1071+ # where_ @"age > $age"
1072+ # orderBy @"name"
1073+ # limitParam @"n"
1074+ # runQuery conn { age: 0, n: 1 }
1075+ Array.length rows `shouldEqual` 1
1076+
1077+ describe "UNION / INTERSECT / EXCEPT execution" do
1078+ it "UNION deduplicates" do
1079+ rows <-
1080+ (from usersTable # select @"name")
1081+ `union` (from usersTable # select @"name")
1082+ # runQuery conn {}
1083+ Array.length rows `shouldEqual` 2
1084+
1085+ it "UNION ALL keeps duplicates" do
1086+ rows <-
1087+ (from usersTable # select @"name")
1088+ `unionAll` (from usersTable # select @"name")
1089+ # runQuery conn {}
1090+ Array.length rows `shouldEqual` 4
1091+
1092+ it "UNION with params from both sides" do
1093+ rows <-
1094+ (from usersTable # select @"name" # where_ @"age > $a")
1095+ `union` (from usersTable # select @"name" # where_ @"age < $b")
1096+ # runQuery conn { a: 100, b: 0 }
1097+ Array.length rows `shouldEqual` 0
1098+
1099+ it "INTERSECT returns common rows" do
1100+ rows <-
1101+ (from usersTable # select @"name")
1102+ `intersect` (from usersTable # select @"name")
1103+ # runQuery conn {}
1104+ Array.length rows `shouldEqual` 2
1105+
1106+ it "EXCEPT removes matching rows" do
1107+ rows <-
1108+ (from usersTable # select @"name")
1109+ `except_` (from usersTable # select @"name" # where_ @"name = $name")
1110+ # runQuery conn { name: "Alice" }
1111+ Array.length rows `shouldEqual` 1
1112+ (map _.name rows) `shouldEqual` [ "Bob" ]
1113+
1114+ it "UNION with ORDER BY and LIMIT" do
1115+ rows <-
1116+ (from usersTable # select @"name")
1117+ `union` (from usersTable # select @"name")
1118+ # orderBy @"name"
1119+ # limit 1
1120+ # runQuery conn {}
1121+ Array.length rows `shouldEqual` 1
1122+ (map _.name rows) `shouldEqual` [ "Alice" ]
1123+
1124+ describe "WHERE features execution" do
1125+ it "BETWEEN filters range" do
1126+ rows <- from usersTable # select @"name"
1127+ # where_ @"age BETWEEN $lo AND $hi"
1128+ # runQuery conn { lo: 20, hi: 26 }
1129+ Array.length rows `shouldEqual` 1
1130+ (map _.name rows) `shouldEqual` [ "Alice" ]
1131+
1132+ it "IS NULL matches null values" do
1133+ _ <- from usersTable
1134+ # insert { name: "NullAge", email: "nullage@example.com" }
1135+ # runExecute conn {}
1136+ rows <- from usersTable # select @"name"
1137+ # where_ @"age IS NULL"
1138+ # runQuery conn {}
1139+ (map _.name rows) `shouldSatisfy` Array.elem "NullAge"
1140+ _ <- PG.execute (PG.SQL "DELETE FROM users WHERE name = $1")
1141+ [ PG.toPGValue "NullAge" ]
1142+ conn
1143+ pure unit
1144+
1145+ it "IS NOT NULL excludes null values" do
1146+ _ <- from usersTable
1147+ # insert { name: "NullAge2", email: "nullage2@example.com" }
1148+ # runExecute conn {}
1149+ rows <- from usersTable # select @"name"
1150+ # where_ @"age IS NOT NULL"
1151+ # runQuery conn {}
1152+ (map _.name rows) `shouldSatisfy` (not <<< Array.elem "NullAge2")
1153+ _ <- PG.execute (PG.SQL "DELETE FROM users WHERE name = $1")
1154+ [ PG.toPGValue "NullAge2" ]
1155+ conn
1156+ pure unit
1157+
1158+ it "nested parentheses" do
1159+ rows <- from usersTable # select @"name"
1160+ # where_ @"(age > $a OR age < $b) AND name = $name"
1161+ # runQuery conn { a: 100, b: 0, name: "Alice" }
1162+ Array.length rows `shouldEqual` 0