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