git.iko.soy/rowtype-yoga/purescript-yoga-postgres-om
on main help sign in
.psc-ide-port+1−1
since splitsnapshots
unifiedsplit
@@ -1 +1 @@
1−15826
1+15453
spago.lock+31−23
@@ -6,7 +6,7 @@
6 "core": {
6 "core": {
7 "dependencies": [
7 "dependencies": [
8 {
8 {
9− "aff": ">=7.1.0 <8.0.0"
9+ "aff": ">=8.0.0 <9.0.0"
10 },
10 },
11 {
11 {
12 "arrays": ">=7.3.0 <8.0.0"
12 "arrays": ">=7.3.0 <8.0.0"
@@ -20,6 +20,9 @@
20 {
20 {
21 "either": ">=6.1.0 <7.0.0"
21 "either": ">=6.1.0 <7.0.0"
22 },
22 },
23+ {
24+ "exceptions": ">=6.1.0 <7.0.0"
25+ },
23 {
26 {
24 "foldable-traversable": ">=6.0.0 <7.0.0"
27 "foldable-traversable": ">=6.0.0 <7.0.0"
25 },
28 },
@@ -27,11 +30,14 @@
27 "foreign": ">=7.0.0 <8.0.0"
30 "foreign": ">=7.0.0 <8.0.0"
28 },
31 },
29 {
32 {
30− "heterogeneous": ">=0.6.0 <0.7.0"
33+ "heterogeneous": ">=0.7.0 <0.8.0"
31 },
34 },
32 {
35 {
33 "maybe": ">=6.0.0 <7.0.0"
36 "maybe": ">=6.0.0 <7.0.0"
34 },
37 },
38+ {
39+ "nullable": ">=6.0.0 <7.0.0"
40+ },
35 {
41 {
36 "ordered-collections": ">=3.2.0 <4.0.0"
42 "ordered-collections": ">=3.2.0 <4.0.0"
37 },
43 },
@@ -57,21 +63,24 @@
57 "yoga-json": ">=5.2.1 <6.0.0"
63 "yoga-json": ">=5.2.1 <6.0.0"
58 },
64 },
59 {
65 {
60− "yoga-om": ">=1.5.2 <2.0.0"
66+ "yoga-om": ">=2.2.1 <3.0.0"
61 },
67 },
62 {
68 {
63− "yoga-om-layer": ">=1.2.2 <2.0.0"
69+ "yoga-om-layer": ">=2.1.0 <3.0.0"
64 },
70 },
65 {
71 {
66− "yoga-postgres": "*"
72+ "yoga-postgres": ">=0.3.2 <0.4.0"
67 },
73 },
68 {
74 {
69− "yoga-sql-types": ">=0.1.0 <0.2.0"
75+ "yoga-sql-types": ">=0.2.0 <0.3.0"
70 }
76 }
71 ]
77 ]
72 },
78 },
73 "test": {
79 "test": {
74 "dependencies": [
80 "dependencies": [
81+ {
82+ "avar": ">=5.0.1 <6.0.0"
83+ },
75 {
84 {
76 "console": ">=6.0.0 <7.0.0"
85 "console": ">=6.0.0 <7.0.0"
77 },
86 },
@@ -85,17 +94,13 @@
85 }
94 }
86 }
95 }
87 },
96 },
88− "extra_packages": {
97+ "extra_packages": {}
89− "yoga-postgres": {
90− "path": "../purescript-yoga-postgres"
91− }
92− }
93 },
98 },
94 "packages": {
99 "packages": {
95 "aff": {
100 "aff": {
96 "type": "registry",
101 "type": "registry",
97− "version": "7.1.0",
102+ "version": "8.0.0",
98− "integrity": "sha256-/nOpDQVnKIoP1pQ1ZZBYpadK0TuMlG4WwufGzJpsfD0=",
103+ "integrity": "sha256-9BRIrSdRoYybfiUmBqgKJ66cPqxYHb277bVWQMn/9sg=",
99 "dependencies": [
104 "dependencies": [
100 "control",
105 "control",
101 "datetime",
106 "datetime",
@@ -466,8 +471,8 @@
466 },
471 },
467 "heterogeneous": {
472 "heterogeneous": {
468 "type": "registry",
473 "type": "registry",
469− "version": "0.6.0",
474+ "version": "0.7.0",
470− "integrity": "sha256-nQgk2iHYe5LIi6ZzREgWaCU3y8oCwdhDSukT6xVMWdk=",
475+ "integrity": "sha256-pQwcFvhWL8noktxaoD7FlLs2F2lYf3QHoyO0VOWO3I0=",
471 "dependencies": [
476 "dependencies": [
472 "either",
477 "either",
473 "foldable-traversable",
478 "foldable-traversable",
@@ -1053,8 +1058,8 @@
1053 },
1058 },
1054 "yoga-om": {
1059 "yoga-om": {
1055 "type": "registry",
1060 "type": "registry",
1056− "version": "1.5.2",
1061+ "version": "2.2.1",
1057− "integrity": "sha256-2ys14BF6NPgzFDFUIFk0EDxmqkA8+yghIA9TXHlOmE0=",
1062+ "integrity": "sha256-nahYX6UD7/7chFM5hxGGVITSbiLXKb0gbP3PV8CvsXY=",
1058 "dependencies": [
1063 "dependencies": [
1059 "aff",
1064 "aff",
1060 "aff-retry",
1065 "aff-retry",
@@ -1085,12 +1090,13 @@
1085 },
1090 },
1086 "yoga-om-layer": {
1091 "yoga-om-layer": {
1087 "type": "registry",
1092 "type": "registry",
1088− "version": "1.2.2",
1093+ "version": "2.1.0",
1089− "integrity": "sha256-Usw2ZysteSYNkEG3CDmVVXUk7cgr1mntkmhbR33nF6M=",
1094+ "integrity": "sha256-4XxwLT/1JJQovIkH/qog5YRa4WcP8SbfKFrWs214/sQ=",
1090 "dependencies": [
1095 "dependencies": [
1091 "aff",
1096 "aff",
1092 "aff-retry",
1097 "aff-retry",
1093 "arrays",
1098 "arrays",
1099+ "control",
1094 "effect",
1100 "effect",
1095 "exceptions",
1101 "exceptions",
1096 "foldable-traversable",
1102 "foldable-traversable",
@@ -1102,6 +1108,7 @@
1102 "record",
1108 "record",
1103 "record-studio",
1109 "record-studio",
1104 "refs",
1110 "refs",
1111+ "tailrec",
1105 "transformers",
1112 "transformers",
1106 "type-equality",
1113 "type-equality",
1107 "typelevel-prelude",
1114 "typelevel-prelude",
@@ -1111,8 +1118,9 @@
1111 ]
1118 ]
1112 },
1119 },
1113 "yoga-postgres": {
1120 "yoga-postgres": {
1114− "type": "local",
1121+ "type": "registry",
1115− "path": "../purescript-yoga-postgres",
1122+ "version": "0.3.2",
1123+ "integrity": "sha256-3xDBcPLrIRYbrj2k7Y+V6WcxD+I7yls90AjBCQS1NCk=",
1116 "dependencies": [
1124 "dependencies": [
1117 "aff",
1125 "aff",
1118 "arrays",
1126 "arrays",
@@ -1148,8 +1156,8 @@
1148 },
1156 },
1149 "yoga-sql-types": {
1157 "yoga-sql-types": {
1150 "type": "registry",
1158 "type": "registry",
1151− "version": "0.1.0",
1159+ "version": "0.2.0",
1152− "integrity": "sha256-vxOgnj3HK8F5gRHCNkIPNirORQtCBEKozG+CnrHtB84=",
1160+ "integrity": "sha256-/fiSiEmSQ8UH2N7vp/E02Eo+Krbs6iGcre9haDV34Q8=",
1153 "dependencies": [
1161 "dependencies": [
1154 "arrays",
1162 "arrays",
1155 "bifunctors",
1163 "bifunctors",
spago.yaml+11−11
@@ -1,26 +1,25 @@
1−workspace:
1+workspace: {}
2− extraPackages:
3− yoga-postgres:
4− path: ../purescript-yoga-postgres
5
2
6package:
3package:
7 name: yoga-postgres-om
4 name: yoga-postgres-om
8 publish:
5 publish:
9− version: 0.1.0
6+ version: 0.1.1
10 license: MIT
7 license: MIT
11 location:
8 location:
12 githubOwner: rowtype-yoga
9 githubOwner: rowtype-yoga
13 githubRepo: purescript-yoga-postgres-om
10 githubRepo: purescript-yoga-postgres-om
14 dependencies:
11 dependencies:
15− - aff: ">=7.1.0 <8.0.0"
12+ - aff: ">=8.0.0 <9.0.0"
16 - arrays: ">=7.3.0 <8.0.0"
13 - arrays: ">=7.3.0 <8.0.0"
17 - console: ">=6.1.0 <7.0.0"
14 - console: ">=6.1.0 <7.0.0"
18 - effect: ">=4.0.0 <5.0.0"
15 - effect: ">=4.0.0 <5.0.0"
19 - either: ">=6.1.0 <7.0.0"
16 - either: ">=6.1.0 <7.0.0"
17+ - exceptions: ">=6.1.0 <7.0.0"
20 - foldable-traversable: ">=6.0.0 <7.0.0"
18 - foldable-traversable: ">=6.0.0 <7.0.0"
21 - foreign: ">=7.0.0 <8.0.0"
19 - foreign: ">=7.0.0 <8.0.0"
22− - heterogeneous: ">=0.6.0 <0.7.0"
20+ - heterogeneous: ">=0.7.0 <0.8.0"
23 - maybe: ">=6.0.0 <7.0.0"
21 - maybe: ">=6.0.0 <7.0.0"
22+ - nullable: ">=6.0.0 <7.0.0"
24 - ordered-collections: ">=3.2.0 <4.0.0"
23 - ordered-collections: ">=3.2.0 <4.0.0"
25 - prelude: ">=6.0.2 <7.0.0"
24 - prelude: ">=6.0.2 <7.0.0"
26 - record: ">=4.0.0 <5.0.0"
25 - record: ">=4.0.0 <5.0.0"
@@ -29,13 +28,14 @@ package:
29 - typelevel-prelude: ">=7.0.0 <8.0.0"
28 - typelevel-prelude: ">=7.0.0 <8.0.0"
30 - unsafe-coerce: ">=6.0.0 <7.0.0"
29 - unsafe-coerce: ">=6.0.0 <7.0.0"
31 - yoga-json: ">=5.2.1 <6.0.0"
30 - yoga-json: ">=5.2.1 <6.0.0"
32− - yoga-om: ">=1.5.2 <2.0.0"
31+ - yoga-om: ">=2.2.1 <3.0.0"
33− - yoga-om-layer: ">=1.2.2 <2.0.0"
32+ - yoga-om-layer: ">=2.1.0 <3.0.0"
34− - yoga-postgres: "*"
33+ - yoga-postgres: ">=0.3.2 <0.4.0"
35− - yoga-sql-types: ">=0.1.0 <0.2.0"
34+ - yoga-sql-types: ">=0.2.0 <0.3.0"
36 test:
35 test:
37 main: Test.Main
36 main: Test.Main
38 dependencies:
37 dependencies:
38+ - avar: ">=5.0.1 <6.0.0"
39 - console: ">=6.0.0 <7.0.0"
39 - console: ">=6.0.0 <7.0.0"
40 - exceptions: ">=6.0.0 <7.0.0"
40 - exceptions: ">=6.0.0 <7.0.0"
41 - spec: ">=7.0.0 <8.0.0"
41 - spec: ">=7.0.0 <8.0.0"
src/Yoga/Postgres/ClientOm.js+21−1
@@ -1 +1,21 @@
1+const transactionConnections = new WeakSet();
2+
3+const transactionFailure = Symbol("Yoga.Postgres.transactionFailure");
4+
5+export const transactionErrorImpl = (failure) => {
6+ const error = new Error("Yoga.Postgres transaction aborted");
7+ error[transactionFailure] = failure;
8+ return error;
9+};
10+
11+export const transactionFailureImpl = (error) =>
12+ error?.[transactionFailure] ?? null;
13+
1−export const txToConnection = (tx) => tx._sql || tx._reserved || tx;
14+export const txToConnection = (transaction) => {
15+ const connection = transaction._sql ?? transaction;
16+ transactionConnections.add(connection);
17+ return connection;
18+};
19+
20+export const isTransactionConnection = (connection) =>
21+ transactionConnections.has(connection);
src/Yoga/Postgres/ClientOm.purs+47−19
@@ -5,13 +5,15 @@ import Prelude
5import Data.Array (filter, intercalate, length, mapWithIndex, take)
5import Data.Array (filter, intercalate, length, mapWithIndex, take)
6import Data.Tuple.Nested (type (/\), (/\))
6import Data.Tuple.Nested (type (/\), (/\))
7import Data.Either (Either(..))
7import Data.Either (Either(..))
8−import Data.Maybe (Maybe)
8+import Data.Maybe (Maybe(..))
9+import Data.Nullable as Nullable
9import Record (get) as Record
10import Record (get) as Record
10import Data.Symbol (class IsSymbol, reflectSymbol)
11import Data.Symbol (class IsSymbol, reflectSymbol)
11import Data.Traversable (traverse)
12import Data.Traversable (traverse)
12−import Control.Monad.Reader.Class (local)
13+import Control.Monad.Error.Class (throwError, try)
13import Effect.Aff.Class (liftAff)
14import Effect.Aff.Class (liftAff)
15+import Effect.Exception (Error, error)
14−import Foreign (Foreign, MultipleErrors)
16+import Foreign (Foreign, MultipleErrors, unsafeFromForeign, unsafeToForeign)
15import Prim.Boolean (True, False)
17import Prim.Boolean (True, False)
16import Prim.Row (class Cons, class Lacks, class Union) as Row
18import Prim.Row (class Cons, class Lacks, class Union) as Row
17import Prim.RowList as RL
19import Prim.RowList as RL
@@ -30,14 +32,42 @@ import Yoga.Postgres.Schema
30 , class FieldToPGValue, class InsertableColumnsRL, class RecordValuesRL
32 , class FieldToPGValue, class InsertableColumnsRL, class RecordValuesRL
31 , class RequiredColumnsRL, class SetClauseRL, class StripColumnsRL
33 , class RequiredColumnsRL, class SetClauseRL, class StripColumnsRL
32 , class ValidateSetColumnsRL
34 , class ValidateSetColumnsRL
33− , Table, PrimaryKey, AutoIncrement, Unique, Default, DefaultExpr
35+ , Table, PrimaryKey, AutoIncrement, Unique, Default
34 , ForeignKey, References, Nullable
36 , ForeignKey, References, Nullable
35 , columnCountRL, columnNamesRL, fieldToPGValue, recordValuesRL, setClauseRL
37 , columnCountRL, columnNamesRL, fieldToPGValue, recordValuesRL, setClauseRL
36− , class ExtractWord, class SkipSpaces, class SplitOnDot
38+ , class ExtractWord, class SkipSpaces
37 )
39 )
38import Yoga.Postgres.Schema as Schema
40import Yoga.Postgres.Schema as Schema
39
41
40
42
43+
44+foreign import transactionErrorImpl :: Foreign -> Error
45+foreign import transactionFailureImpl :: Error -> Nullable.Nullable Foreign
46+foreign import isTransactionConnection :: PG.Connection -> Boolean
47+
48+transactionally
49+ :: forall r err a
50+ . Om.Om { postgresConnection :: PG.Connection | r } err a
51+ -> Om.Om { postgresConnection :: PG.Connection | r } err a
52+transactionally action = do
53+ context@{ postgresConnection } <- Om.ask
54+ Om.throwWhen
55+ { exception: error "Yoga.Postgres does not support nested transactions" }
56+ (isTransactionConnection postgresConnection)
57+ outcome <- liftAff $ try $ PG.withTransaction
58+ (\transaction -> do
59+ let transactionContext = context { postgresConnection = txToConnection transaction }
60+ Om.runReader transactionContext action >>= case _ of
61+ Left failure -> throwError (transactionErrorImpl (unsafeToForeign failure))
62+ Right value -> pure value
63+ )
64+ postgresConnection
65+ case outcome of
66+ Right value -> pure value
67+ Left cause -> case Nullable.toMaybe (transactionFailureImpl cause) of
68+ Just failure -> throwError (unsafeFromForeign failure)
69+ Nothing -> Om.throw { exception: cause }
70+
41-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
71-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42-- Query builder bridge (Schema.Q -> Om)
72-- Query builder bridge (Schema.Q -> Om)
43-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -66,17 +96,27 @@ runOne params q = do
66 { postgresConnection } <- Om.ask
96 { postgresConnection } <- Om.ask
67 Schema.runQueryOne postgresConnection params q # liftAff
97 Schema.runQueryOne postgresConnection params q # liftAff
68
98
69−runExec
99+exec
100+ :: forall tables params paramsRL stage r err
101+ . RowToList params paramsRL
102+ => Schema.ParamsToArray paramsRL params
103+ => { | params }
104+ -> Schema.Q tables () params stage
105+ -> Om.Om { postgresConnection :: PG.Connection | r } err Unit
106+exec params q = void (execCount params q)
107+
108+execCount
70 :: forall tables params paramsRL stage r err
109 :: forall tables params paramsRL stage r err
71 . RowToList params paramsRL
110 . RowToList params paramsRL
72 => Schema.ParamsToArray paramsRL params
111 => Schema.ParamsToArray paramsRL params
73 => { | params }
112 => { | params }
74 -> Schema.Q tables () params stage
113 -> Schema.Q tables () params stage
75 -> Om.Om { postgresConnection :: PG.Connection | r } err Int
114 -> Om.Om { postgresConnection :: PG.Connection | r } err Int
76−runExec params q = do
115+execCount params q = do
77 { postgresConnection } <- Om.ask
116 { postgresConnection } <- Om.ask
78 Schema.runExecute postgresConnection params q # liftAff
117 Schema.runExecute postgresConnection params q # liftAff
79
118
119+
80-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
81-- WHERE operators
121-- WHERE operators
82-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
122-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -299,7 +339,6 @@ instance HasPrimaryKeyConstraint (PrimaryKey a) True
299else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (AutoIncrement a) result
339else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (AutoIncrement a) result
300else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Unique a) result
340else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Unique a) result
301else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Default s a) result
341else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Default s a) result
302−else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (DefaultExpr s a) result
303else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (ForeignKey t r c a) result
342else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (ForeignKey t r c a) result
304else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Nullable a) result
343else instance HasPrimaryKeyConstraint a result => HasPrimaryKeyConstraint (Nullable a) result
305else instance HasPrimaryKeyConstraint a False
344else instance HasPrimaryKeyConstraint a False
@@ -1823,17 +1862,6 @@ deleteWhereReturning whereRec _ = do
1823-- Transaction
1862-- Transaction
1824-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1863-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1825
1864
1826−withTransaction
1827− :: forall r err a
1828− . Om.Om { postgresConnection :: PG.Connection | r } err a
1829− -> Om.Om { postgresConnection :: PG.Connection | r } err a
1830−withTransaction action = do
1831− { postgresConnection } <- Om.ask
1832− tx <- PG.begin postgresConnection # liftAff
1833− let txConn = txToConnection tx
1834− result <- local (_ { postgresConnection = txConn }) action
1835− PG.commit tx # liftAff
1836− pure result
1837
1865
1838foreign import txToConnection :: PG.Transaction -> PG.Connection
1866foreign import txToConnection :: PG.Transaction -> PG.Connection
1839
1867
test/Test/ClientOm.purs+4−4
@@ -31,9 +31,9 @@ import Yoga.Postgres.ClientOm
31 , gt, gte, ilike, inArray, join, like_, lt, lte
31 , gt, gte, ilike, inArray, join, like_, lt, lte
32 , maxOf, minOf, minOfWhere, ne, not_, or_
32 , maxOf, minOf, minOfWhere, ne, not_, or_
33 , selectFrom, selectFromWhere, selectOneFrom, selectOneFromWhere
33 , selectFrom, selectFromWhere, selectOneFrom, selectOneFromWhere
34− , sumOf, sumOfWhere, updateWhere, upsert, upsertMany, withTransaction
34+ , sumOf, sumOfWhere, transactionally, updateWhere, upsert, upsertMany
35 )
35 )
36−import Yoga.Postgres.Schema (Table, PrimaryKey, AutoIncrement, Unique, Default, DefaultExpr, ForeignKey, References, PGUUID)
36+import Yoga.Postgres.Schema (Table, PrimaryKey, AutoIncrement, Unique, Default, ForeignKey, References, PGUUID)
37
37
38spec :: Spec Unit
38spec :: Spec Unit
39spec = describe "ClientOm" do
39spec = describe "ClientOm" do
@@ -541,7 +541,7 @@ spec = describe "ClientOm" do
541
541
542 describe "Transactions" do
542 describe "Transactions" do
543 it "commits multiple inserts atomically" $ withUsersTable do
543 it "commits multiple inserts atomically" $ withUsersTable do
544− withTransaction do
544+ transactionally do
545 _ <- usersTable # create { name: "Alice", email: "a@t.com", role: "admin" }
545 _ <- usersTable # create { name: "Alice", email: "a@t.com", role: "admin" }
546 _ <- usersTable # create { name: "Bob", email: "b@t.com", role: "user" }
546 _ <- usersTable # create { name: "Bob", email: "b@t.com", role: "user" }
547 pure unit
547 pure unit
@@ -588,7 +588,7 @@ settingsTable :: Proxy SettingsTable
588settingsTable = Proxy
588settingsTable = Proxy
589
589
590type UuidTable = Table "test_om_uuid_things"
590type UuidTable = Table "test_om_uuid_things"
591− ( id :: PGUUID # PrimaryKey # DefaultExpr "gen_random_uuid()"
591+ ( id :: PGUUID # PrimaryKey # Default "gen_random_uuid()"
592 , label :: String
592 , label :: String
593 )
593 )
594
594
test/Test/SchemaQ.purs+546−62
@@ -2,102 +2,550 @@ module Test.SchemaQ where
2
2
3import Prelude
3import Prelude
4
4
5−import Control.Monad.Error.Class (throwError)
5+import Control.Monad.Error.Class (throwError, try)
6+import Control.Monad.Except (throwError) as Except
7+import Data.Either (Either(..), isLeft)
6import Data.Array as Array
8import Data.Array as Array
7import Data.Maybe (Maybe(..))
9import Data.Maybe (Maybe(..))
8−import Effect.Aff (Aff)
10+import JS.BigInt as BigInt
11+import Data.Time.Duration (Milliseconds(..))
12+import Data.Newtype (class Newtype)
13+import Effect.Aff (Aff, delay, forkAff, joinFiber, killFiber)
14+import Effect.Aff.Class (liftAff)
15+import Effect.Aff.AVar as AVar
9import Effect.Class (class MonadEffect, liftEffect)
16import Effect.Class (class MonadEffect, liftEffect)
10import Effect.Exception as Exception
17import Effect.Exception as Exception
18+import Effect.Ref as Ref
11−import Foreign (MultipleErrors)
19+import Foreign (ForeignError(..), MultipleErrors)
12import Test.Spec (Spec, describe, it)
20import Test.Spec (Spec, describe, it)
13import Type.Proxy (Proxy(..))
21import Type.Proxy (Proxy(..))
22+import Yoga.JSON (class ReadForeign, class WriteForeign, readImpl, writeImpl)
14import Yoga.Om as Om
23import Yoga.Om as Om
15import Yoga.Postgres as PG
24import Yoga.Postgres as PG
16−import Yoga.Postgres.ClientOm (run, runOne, runExec)
25+import Yoga.Postgres.ClientOm (create, exec, run, runOne, transactionally)
17−import Yoga.Postgres.Schema (Table, PrimaryKey, AutoIncrement, Unique, Default, DefaultExpr, TimestampTZ, from, into, select, where_, orderBy, limit, groupBy, insert, delete, returning, union)
26+import Yoga.Postgres.Schema (class PGTypeName, AutoIncrement, Default, ForeignKey, PrimaryKey, References, Table, TimestampTZ, Unique, andCTE, cte, createTableDDL, delete, except_, from, fromCTE, groupBy, having, innerJoin, innerJoinAs, insert, intersect, into, leftJoin, leftJoinAs, limit, offset, onConflict, onConflictDoNothing, orderBy, returning, returningAll, select, set, union, unionAll, update, where_, with_)
18
27
19spec :: Spec Unit
28spec :: Spec Unit
20spec = describe "Query builder" do
29spec = describe "Query builder" do
21
30
31+ -- ── SELECT ──────────────────────────────────────────────────
32+
22 it "selects columns with a WHERE clause" $ withUsersTable do
33 it "selects columns with a WHERE clause" $ withUsersTable do
23− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
34+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
24− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
35+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
25− rows <- from usersTable # select @"name, email" # where_ @"role = $role" # run { role: "admin" }
36+ rows <- from usersTable
37+ # select @"name, email"
38+ # where_ @"role = $role"
39+ # run { role: Admin }
26− Array.length rows `shouldEqual` 1
40+ rows `shouldEqual` [ { name: "Alice", email: Email "a@t.com" } ]
27− let
28− r = case Array.head rows of
29− Just x -> x
30− Nothing -> { name: "", email: "" }
31− r.name `shouldEqual` "Alice"
32
41
33 it "orders and limits results" $ withUsersTable do
42 it "orders and limits results" $ withUsersTable do
34− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
43+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
35− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
44+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
36− _ <- into usersTable # insert { name: "Carol", email: "c@t.com", role: "user" } # runExec {}
45+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: User } # exec {}
37 rows <- from usersTable # select @"name" # orderBy @"name" # limit @"2" # run {}
46 rows <- from usersTable # select @"name" # orderBy @"name" # limit @"2" # run {}
38− Array.length rows `shouldEqual` 2
47+ rows `shouldEqual` [ { name: "Alice" }, { name: "Bob" } ]
48+
49+ it "skips rows with offset" $ withUsersTable do
50+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
51+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
52+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: Mod } # exec {}
53+ rows <- from usersTable # select @"name" # orderBy @"name" # limit @"10" # offset @"1" # run {}
39− (map _.name rows) `shouldEqual` [ "Alice", "Bob" ]
54+ rows `shouldEqual` [ { name: "Bob" }, { name: "Carol" } ]
40
55
41 it "groups results" $ withUsersTable do
56 it "groups results" $ withUsersTable do
42− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
57+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
43− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
58+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
44− _ <- into usersTable # insert { name: "Carol", email: "c@t.com", role: "user" } # runExec {}
59+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: User } # exec {}
45 rows <- from usersTable # select @"role" # groupBy @"role" # orderBy @"role" # run {}
60 rows <- from usersTable # select @"role" # groupBy @"role" # orderBy @"role" # run {}
46− (rows <#> _.role) `shouldEqual` [ "admin", "user" ]
61+ rows `shouldEqual` [ { role: Admin }, { role: User } ]
62+
63+ it "filters with HAVING after GROUP BY" $ withUsersTable do
64+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
65+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
66+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: User } # exec {}
67+ rows <- from usersTable
68+ # select @"role, COUNT(*) AS count"
69+ # groupBy @"role"
70+ # having @"COUNT(*) > $min"
71+ # orderBy @"role"
72+ # run { min: 1 }
73+ rows `shouldEqual` [ { role: User, count: BigInt.fromInt 2 } ]
47
74
48 it "filters with a BETWEEN range" $ withUsersTable do
75 it "filters with a BETWEEN range" $ withUsersTable do
49− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
76+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
50− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
77+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
51− _ <- into usersTable # insert { name: "Carol", email: "c@t.com", role: "mod" } # runExec {}
78+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: Mod } # exec {}
52 rows <- from usersTable # select @"name" # where_ @"id BETWEEN $lo AND $hi" # orderBy @"id" # run { lo: 2, hi: 3 }
79 rows <- from usersTable # select @"name" # where_ @"id BETWEEN $lo AND $hi" # orderBy @"id" # run { lo: 2, hi: 3 }
53 rows `shouldEqual` [ { name: "Bob" }, { name: "Carol" } ]
80 rows `shouldEqual` [ { name: "Bob" }, { name: "Carol" } ]
54
81
82+ -- ── INSERT ──────────────────────────────────────────────────
83+
55 it "inserts a row and returns specific columns" $ withUsersTable do
84 it "inserts a row and returns specific columns" $ withUsersTable do
56− row <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # returning @"id, name" # runOne {}
85+ rows <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"name" # run {}
57− r <- shouldBeJust row
58− r.name `shouldEqual` "Alice"
86+ rows `shouldEqual` [ { name: "Alice" } ]
59− (r.id > 0) `shouldEqual` true
60
87
61 it "inserts a row with default created_at and updated_at" $ withUsersTable do
88 it "inserts a row with default created_at and updated_at" $ withUsersTable do
62− row <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # returning @"created_at, updated_at" # runOne {}
89+ row <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"created_at, updated_at" # runOne {}
63 r <- shouldBeJust row
90 r <- shouldBeJust row
64 r.created_at `shouldEqual` r.updated_at
91 r.created_at `shouldEqual` r.updated_at
65
92
66− it "deletes with a condition and returns the deleted rows" $ withUsersTable do
93+ it "returns all columns with returningAll" $ withUsersTable do
67− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
94+ rows <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returningAll # run {}
68− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
95+ let row = Array.head rows
69− rows <- from usersTable # delete # where_ @"role = $role" # returning @"name" # run { role: "admin" }
70− Array.length rows `shouldEqual` 1
71− r <- shouldBeJust (Array.head rows)
96+ r <- shouldBeJust row
72 r.name `shouldEqual` "Alice"
97 r.name `shouldEqual` "Alice"
98+ r.email `shouldEqual` Email "a@t.com"
99+ r.role `shouldEqual` Admin
100+
101+ it "skips duplicate on ON CONFLICT DO NOTHING" $ withUsersTable do
102+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
103+ _ <- into usersTable # insert { name: "Alice2", email: Email "a@t.com", role: User } # onConflictDoNothing @"email" # exec {}
104+ rows <- from usersTable # select @"name" # run {}
105+ rows `shouldEqual` [ { name: "Alice" } ]
106+
107+ it "upserts with ON CONFLICT DO UPDATE" $ withUsersTable do
108+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
109+ _ <- into usersTable # insert { name: "Alice2", email: Email "a@t.com", role: User }
110+ # onConflict @"email" @"DO UPDATE SET name = EXCLUDED.name"
111+ # exec {}
112+ rows <- from usersTable # select @"name" # run {}
113+ rows `shouldEqual` [ { name: "Alice2" } ]
114+
115+ describe "transactionally" do
116+ it "runs ordinary schema operations against the transaction and commits" $ withUsersTable do
117+ transactionally do
118+ into usersTable
119+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
120+ # exec {}
121+
122+ rows <- from usersTable # select @"name" # run {}
123+ rows `shouldEqual` [ { name: "Alice" } ]
124+
125+ it "reads its own writes before commit" $ withUsersTable do
126+ rows <- transactionally do
127+ _ <- into usersTable
128+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
129+ # exec {}
130+ from usersTable
131+ # select @"name, role"
132+ # where_ @"email = $email"
133+ # run { email: Email "a@t.com" }
134+
135+ rows `shouldEqual` [ { name: "Alice", role: Admin } ]
136+
137+ it "shares one transaction across ClientOm and schema operations" $ withUsersTable do
138+ rows <- transactionally do
139+ _ <- usersTable
140+ # create { name: "Alice", email: Email "a@t.com", role: Admin }
141+ from usersTable
142+ # select @"name, role"
143+ # where_ @"email = $email"
144+ # run { email: Email "a@t.com" }
145+
146+ rows `shouldEqual` [ { name: "Alice", role: Admin } ]
147+
148+ it "returns the application value after commit" $ withUsersTable do
149+ result <- transactionally do
150+ _ <- into usersTable
151+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
152+ # exec {}
153+ pure { status: "created", count: 1 }
154+
155+ result `shouldEqual` { status: "created", count: 1 }
156+ rows <- from usersTable # select @"name" # run {}
157+ rows `shouldEqual` [ { name: "Alice" } ]
158+
159+ it "rolls back typed Om failures" $ withUsersTable do
160+ result <- try $ transactionally do
161+ _ <- into usersTable
162+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
163+ # exec {}
164+ Om.fatal "abort transaction"
165+
166+ isLeft result `shouldEqual` true
167+ rows <- from usersTable # select @"name" # run {}
168+ rows `shouldEqual` []
169+
170+ it "rolls back writes made by composed helpers" $ withUsersTable do
171+ let
172+ createUser name email role =
173+ into usersTable
174+ # insert { name, email, role }
175+ # exec {}
176+ rejectBatch = Om.fatal "inventory unavailable"
177+
178+ result <- try $ transactionally do
179+ createUser "Alice" (Email "a@t.com") Admin
180+ createUser "Bob" (Email "b@t.com") User
181+ rejectBatch
182+
183+ isLeft result `shouldEqual` true
184+ rows <- from usersTable # select @"name" # run {}
185+ rows `shouldEqual` []
186+
187+ it "rolls back when an Om transaction fiber is cancelled" $ withUsersTable do
188+ wrote <- liftEffect $ Ref.new false
189+ transaction <- Om.unliftAff $ transactionally do
190+ _ <- into usersTable
191+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
192+ # exec {}
193+ liftEffect $ Ref.write true wrote
194+ liftAff $ delay (Milliseconds 10000.0)
195+
196+ fiber <- liftAff $ forkAff transaction
197+ liftAff $ delay (Milliseconds 50.0)
198+ didWrite <- liftEffect $ Ref.read wrote
199+ didWrite `shouldEqual` true
200+ liftAff $ killFiber (Exception.error "cancel transaction") fiber
201+
202+ rows <- from usersTable # select @"name" # run {}
203+ rows `shouldEqual` []
204+
205+ it "reuses a one-connection pool after commit and rollback" $ withSingleConnectionUsersTable do
206+ transactionally do
207+ _ <- into usersTable
208+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
209+ # exec {}
210+ pure unit
211+
212+ failed <- try $ transactionally do
213+ _ <- into usersTable
214+ # insert { name: "Bob", email: Email "b@t.com", role: User }
215+ # exec {}
216+ Om.fatal "roll back Bob"
217+ isLeft failed `shouldEqual` true
218+
219+ transactionally do
220+ _ <- into usersTable
221+ # insert { name: "Carol", email: Email "c@t.com", role: User }
222+ # exec {}
223+ pure unit
224+
225+ rows <- from usersTable # select @"name" # orderBy @"name" # run {}
226+ rows `shouldEqual` [ { name: "Alice" }, { name: "Carol" } ]
227+
228+ it "isolates concurrent transactions until each commits" $ withUsersTable do
229+ inserted <- liftAff AVar.empty
230+ readRows <- liftAff AVar.empty
231+ allowRead <- liftAff AVar.empty
232+ allowCommit <- liftAff AVar.empty
233+ let
234+ isolatedInsert name email role = transactionally do
235+ _ <- into usersTable
236+ # insert { name, email, role }
237+ # exec {}
238+ liftAff $ AVar.put unit inserted
239+ liftAff $ AVar.read allowRead
240+ rows <- from usersTable # select @"name" # run {}
241+ liftAff $ AVar.put unit readRows
242+ liftAff $ AVar.read allowCommit
243+ pure (Array.length rows)
244+
245+ alice <- Om.unliftAff $ isolatedInsert "Alice" (Email "a@t.com") Admin
246+ bob <- Om.unliftAff $ isolatedInsert "Bob" (Email "b@t.com") User
247+ aliceFiber <- liftAff $ forkAff alice
248+ bobFiber <- liftAff $ forkAff bob
249+
250+ liftAff $ AVar.take inserted
251+ liftAff $ AVar.take inserted
252+ liftAff $ AVar.put unit allowRead
253+ liftAff $ AVar.take readRows
254+ liftAff $ AVar.take readRows
255+ liftAff $ AVar.put unit allowCommit
256+
257+ aliceResult <- liftAff $ joinFiber aliceFiber
258+ bobResult <- liftAff $ joinFiber bobFiber
259+ case aliceResult, bobResult of
260+ Right aliceCount, Right bobCount ->
261+ [ aliceCount, bobCount ] `shouldEqual` [ 1, 1 ]
262+ _, _ ->
263+ liftEffect $ Exception.throwException $ Exception.error "concurrent transaction failed"
264+
265+ rows <- from usersTable # select @"name" # orderBy @"name" # run {}
266+ rows `shouldEqual` [ { name: "Alice" }, { name: "Bob" } ]
267+
268+ it "rejects nested transactions and rolls back the outer transaction" $ withUsersTable do
269+ failure <- transactionally
270+ (do
271+ _ <- into usersTable
272+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
273+ # exec {}
274+ transactionally do
275+ _ <- into usersTable
276+ # insert { name: "Bob", email: Email "b@t.com", role: User }
277+ # exec {}
278+ pure "nested transaction unexpectedly succeeded"
279+ )
280+ # Om.handleErrors
281+ { exception: \cause -> pure (Exception.message cause)
282+ , parseError: \_ -> pure "unexpected parse error"
283+ }
284+
285+ failure `shouldEqual` "Yoga.Postgres does not support nested transactions"
286+ rows <- from usersTable # select @"name" # run {}
287+ rows `shouldEqual` []
288+
289+ it "routes begin failures through the exception channel without running the action" do
290+ conn <- testConnection
291+ PG.end conn
292+ ran <- liftEffect $ Ref.new false
293+ result <- try $ Om.runOm { postgresConnection: conn }
294+ { exception: throwError }
295+ (transactionally do
296+ liftEffect $ Ref.write true ran
297+ )
298+
299+ isLeft result `shouldEqual` true
300+ liftEffect (Ref.read ran) >>= (_ `shouldEqual` false)
301+
302+ it "preserves the exact typed Om error payload" $ withUsersTable do
303+ let expected = { available: 10, requested: 20 }
304+ actual <- transactionally
305+ (Om.throw { insufficientFunds: expected })
306+ # Om.handleErrors
307+ { insufficientFunds: pure
308+ }
309+ actual `shouldEqual` expected
310+
311+ it "rolls back SQL exceptions and keeps the connection usable" $ withUsersTable do
312+ result <- try $ transactionally do
313+ _ <- into usersTable
314+ # insert { name: "Alice", email: Email "a@t.com", role: Admin }
315+ # exec {}
316+ { postgresConnection } <- Om.ask
317+ _ <- PG.executeSimple
318+ (PG.SQL "INSERT INTO missing_transaction_table VALUES (1)")
319+ postgresConnection
320+ # liftAff
321+ pure unit
322+
323+ isLeft result `shouldEqual` true
324+ rows <- from usersTable # select @"name" # run {}
325+ rows `shouldEqual` []
326+
327+ -- ── DELETE ──────────────────────────────────────────────────
328+
329+ it "deletes with a condition and returns the deleted rows" $ withUsersTable do
330+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
331+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
332+ rows <- from usersTable # delete # where_ @"role = $role" # returning @"name" # run { role: Admin }
333+ rows `shouldEqual` [ { name: "Alice" } ]
334+
335+ -- ── UPDATE ──────────────────────────────────────────────────
336+
337+ it "updates a row with SET and WHERE" $ withUsersTable do
338+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
339+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
340+ _ <- update usersTable # set { role: Mod } # where_ @"name = $name" # exec { name: "Bob" }
341+ rows <- from usersTable # select @"name, role" # orderBy @"name" # run {}
342+ rows `shouldEqual` [ { name: "Alice", role: Admin }, { name: "Bob", role: Mod } ]
343+
344+ it "updates multiple columns" $ withUsersTable do
345+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
346+ _ <- update usersTable # set { name: "Alicia", role: Mod } # where_ @"email = $email" # exec { email: Email "a@t.com" }
347+ rows <- from usersTable # select @"name, role" # run {}
348+ rows `shouldEqual` [ { name: "Alicia", role: Mod } ]
349+
350+ it "updates and returns the modified row" $ withUsersTable do
351+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
352+ rows <- update usersTable
353+ # set { role: Mod }
354+ # where_ @"email = $email"
355+ # returning @"name, role"
356+ # run { email: Email "a@t.com" }
357+ rows `shouldEqual` [ { name: "Alice", role: Mod } ]
358+
359+ -- ── SET OPERATIONS ──────────────────────────────────────────
73
360
74 it "combines two queries with UNION" $ withUsersTable do
361 it "combines two queries with UNION" $ withUsersTable do
75− _ <- into usersTable # insert { name: "Alice", email: "a@t.com", role: "admin" } # runExec {}
362+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
76− _ <- into usersTable # insert { name: "Bob", email: "b@t.com", role: "user" } # runExec {}
363+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
77− _ <- into usersTable # insert { name: "Carol", email: "c@t.com", role: "mod" } # runExec {}
364+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: Mod } # exec {}
78− rows <-
79− from usersTable # select @"name" # where_ @"role = $a"
365+ rows <- from usersTable # select @"name" # where_ @"role = $a"
80− # union (from usersTable # select @"name" # where_ @"role = $b")
366+ # union (from usersTable # select @"name" # where_ @"role = $b")
81− # run { a: "admin", b: "mod" }
367+ # run { a: Admin, b: Mod }
368+ Array.sort rows `shouldEqual` [ { name: "Alice" }, { name: "Carol" } ]
369+
370+ it "combines with UNION ALL including duplicates" $ withUsersTable do
371+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
372+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
373+ rows <- from usersTable # select @"name" # where_ @"role = $r"
374+ # unionAll (from usersTable # select @"name" # where_ @"role = $r2")
375+ # run { r: Admin, r2: Admin }
376+ Array.sort rows `shouldEqual` [ { name: "Alice" }, { name: "Alice" } ]
377+
378+ it "returns common rows with INTERSECT" $ withUsersTable do
379+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
380+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
381+ rows <- from usersTable # select @"name" # where_ @"role = $a"
382+ # intersect (from usersTable # select @"name" # where_ @"name = $n")
383+ # run { a: Admin, n: "Alice" }
384+ rows `shouldEqual` [ { name: "Alice" } ]
385+
386+ it "excludes rows with EXCEPT" $ withUsersTable do
387+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
388+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
389+ _ <- into usersTable # insert { name: "Carol", email: Email "c@t.com", role: Mod } # exec {}
390+ rows <- (from usersTable # select @"name")
391+ `except_` (from usersTable # select @"name" # where_ @"role = $r")
392+ # run { r: User }
82− Array.sort (rows <#> _.name) `shouldEqual` [ "Alice", "Carol" ]
393+ Array.sort rows `shouldEqual` [ { name: "Alice" }, { name: "Carol" } ]
394+
395+ -- ── JOINs ───────────────────────────────────────────────────
396+
397+ it "inner joins posts to users" $ withUsersAndPostsTable do
398+ alice <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"id" # runOne {}
399+ { id: aliceId } <- shouldBeJust alice
400+ _ <- into postsTable # insert { user_id: aliceId, title: "Hello" } # exec {}
401+ rows <- from usersTable
402+ # innerJoin @"test_om_users.id = test_om_posts.user_id" postsTable
403+ # select @"test_om_users.name, test_om_posts.title"
404+ # run {}
405+ rows `shouldEqual` [ { name: "Alice", title: "Hello" } ]
406+
407+ it "inner join excludes users without posts" $ withUsersAndPostsTable do
408+ alice <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"id" # runOne {}
409+ { id: aliceId } <- shouldBeJust alice
410+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
411+ _ <- into postsTable # insert { user_id: aliceId, title: "Hello" } # exec {}
412+ rows <- from usersTable
413+ # innerJoin @"test_om_users.id = test_om_posts.user_id" postsTable
414+ # select @"test_om_users.name"
415+ # run {}
416+ rows `shouldEqual` [ { name: "Alice" } ]
417+
418+ it "left join includes users without posts as Nothing" $ withUsersAndPostsTable do
419+ alice <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"id" # runOne {}
420+ { id: aliceId } <- shouldBeJust alice
421+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
422+ _ <- into postsTable # insert { user_id: aliceId, title: "Hello" } # exec {}
423+ rows <- from usersTable
424+ # leftJoin @"test_om_users.id = test_om_posts.user_id" postsTable
425+ # select @"test_om_users.name, test_om_posts.title"
426+ # orderBy @"test_om_users.name"
427+ # run {}
428+ rows `shouldEqual`
429+ [ { name: "Alice", title: Just "Hello" }
430+ , { name: "Bob", title: Nothing }
431+ ]
432+
433+ it "inner join with alias" $ withUsersAndPostsTable do
434+ alice <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"id" # runOne {}
435+ { id: aliceId } <- shouldBeJust alice
436+ _ <- into postsTable # insert { user_id: aliceId, title: "Hello" } # exec {}
437+ rows <- from usersTable
438+ # innerJoinAs @"p" @"test_om_users.id = p.user_id" postsTable
439+ # select @"test_om_users.name, p.title"
440+ # run {}
441+ rows `shouldEqual` [ { name: "Alice", title: "Hello" } ]
442+
443+ it "left join with alias yields Nothing for unmatched" $ withUsersAndPostsTable do
444+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
445+ rows <- from usersTable
446+ # leftJoinAs @"p" @"test_om_users.id = p.user_id" postsTable
447+ # select @"test_om_users.name, p.title"
448+ # run {}
449+ rows `shouldEqual` [ { name: "Alice", title: Nothing } ]
450+
451+ -- ── CTEs ────────────────────────────────────────────────────
452+
453+ it "selects from a CTE" $ withUsersTable do
454+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
455+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: User } # exec {}
456+ let ctes = from usersTable # select @"name" # where_ @"role = $role" # cte @"active_users"
457+ rows <- with_ ctes
458+ (fromCTE @"active_users" ctes # select @"active_users.name")
459+ # run { role: Admin }
460+ rows `shouldEqual` [ { name: "Alice" } ]
461+
462+ it "CTE can be further filtered in the main query" $ withUsersTable do
463+ _ <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # exec {}
464+ _ <- into usersTable # insert { name: "Bob", email: Email "b@t.com", role: Admin } # exec {}
465+ let ctes = from usersTable # select @"name" # where_ @"role = $role" # cte @"admins"
466+ rows <- with_ ctes
467+ (fromCTE @"admins" ctes # select @"admins.name" # where_ @"admins.name = $name")
468+ # run { role: Admin, name: "Alice" }
469+ rows `shouldEqual` [ { name: "Alice" } ]
470+
471+ it "two CTEs with andCTE" $ withUsersAndPostsTable do
472+ alice <- into usersTable # insert { name: "Alice", email: Email "a@t.com", role: Admin } # returning @"id" # runOne {}
473+ { id: aliceId } <- shouldBeJust alice
474+ _ <- into postsTable # insert { user_id: aliceId, title: "Hello" } # exec {}
475+ let adminsCTE = from usersTable # select @"name" # where_ @"role = $role" # cte @"admins"
476+ let ctes = andCTE @"posts" (from postsTable # select @"title") adminsCTE
477+ rows <- with_ ctes
478+ (fromCTE @"admins" ctes # select @"admins.name")
479+ # run { role: Admin }
480+ rows `shouldEqual` [ { name: "Alice" } ]
83
481
84-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
482-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85-- Test schema
483-- Test schema
86-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
484-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
87
485
486+newtype Email = Email String
487+
488+derive instance Newtype Email _
489+derive instance Eq Email
490+derive instance Ord Email
491+derive newtype instance Show Email
492+derive newtype instance ReadForeign Email
493+derive newtype instance WriteForeign Email
494+
495+data Role = Admin | User | Mod
496+
497+derive instance Eq Role
498+derive instance Ord Role
499+
500+instance Show Role where
501+ show = roleToString
502+
503+instance WriteForeign Role where
504+ writeImpl = roleToString >>> writeImpl
505+
506+instance ReadForeign Role where
507+ readImpl f = do
508+ s :: String <- readImpl f
509+ case s of
510+ "admin" -> pure Admin
511+ "user" -> pure User
512+ "mod" -> pure Mod
513+ _ -> Except.throwError $ pure $ ForeignError $ "Unknown role: " <> s
514+
515+roleToString :: Role -> String
516+roleToString = case _ of
517+ Admin -> "admin"
518+ User -> "user"
519+ Mod -> "mod"
520+
521+instance PGTypeName Email where
522+ pgTypeName _ = "TEXT"
523+
524+instance PGTypeName Role where
525+ pgTypeName _ = "TEXT"
526+
88type UsersTable = Table "test_om_users"
527type UsersTable = Table "test_om_users"
89 ( id :: PrimaryKey (AutoIncrement Int)
528 ( id :: PrimaryKey (AutoIncrement Int)
90 , name :: String
529 , name :: String
91− , email :: Unique String
530+ , email :: Unique Email
92 , active :: Default "true" Boolean
531 , active :: Default "true" Boolean
93− , role :: String
532+ , role :: Role
94− , created_at :: DefaultExpr "now()" TimestampTZ
533+ , created_at :: Default "now()" TimestampTZ
95− , updated_at :: DefaultExpr "now()" TimestampTZ
534+ , updated_at :: Default "now()" TimestampTZ
96 )
535 )
97
536
98usersTable :: Proxy UsersTable
537usersTable :: Proxy UsersTable
99usersTable = Proxy
538usersTable = Proxy
100
539
540+type PostsTable = Table "test_om_posts"
541+ ( id :: PrimaryKey (AutoIncrement Int)
542+ , user_id :: ForeignKey "test_om_users" References "id" Int
543+ , title :: String
544+ )
545+
546+postsTable :: Proxy PostsTable
547+postsTable = Proxy
548+
101-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
549-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
102-- Helpers
550-- Helpers
103-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
551-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -105,7 +553,8 @@ usersTable = Proxy
105type TestOm = Om.Om { postgresConnection :: PG.Connection } (parseError :: MultipleErrors) Unit
553type TestOm = Om.Om { postgresConnection :: PG.Connection } (parseError :: MultipleErrors) Unit
106
554
107shouldEqual :: forall m t. MonadEffect m => Show t => Eq t => t -> t -> m Unit
555shouldEqual :: forall m t. MonadEffect m => Show t => Eq t => t -> t -> m Unit
556+shouldEqual v1 v2 = liftEffect $ when (v1 /= v2) do
108−shouldEqual v1 v2 = when (v1 /= v2) $ liftEffect $ Exception.throwException $ Exception.error $ show v1 <> " ≠ " <> show v2
557+ Exception.throwException $ Exception.error $ show v1 <> " ≠ " <> show v2
109
558
110shouldBeJust :: forall m a. MonadEffect m => Maybe a -> m a
559shouldBeJust :: forall m a. MonadEffect m => Maybe a -> m a
111shouldBeJust (Just a) = pure a
560shouldBeJust (Just a) = pure a
@@ -133,15 +582,50 @@ withUsersTable test = do
133 _ <- PG.executeSimple dropDDL conn
582 _ <- PG.executeSimple dropDDL conn
134 PG.end conn
583 PG.end conn
135 where
584 where
136− dropDDL = PG.SQL "DROP TABLE IF EXISTS test_om_users CASCADE"
585+ dropStr = "DROP TABLE IF EXISTS test_om_users CASCADE"
586+ dropDDL = PG.SQL dropStr
137− createDDL = PG.SQL
587+ createDDL = PG.SQL (dropStr <> "; " <> createTableDDL @UsersTable)
138− """DROP TABLE IF EXISTS test_om_users CASCADE;
588+
139− CREATE TABLE test_om_users (
589+withSingleConnectionUsersTable :: TestOm -> Aff Unit
140− id SERIAL PRIMARY KEY,
590+withSingleConnectionUsersTable test = do
141− name TEXT NOT NULL,
591+ conn <- PG.postgres
142− email TEXT UNIQUE NOT NULL,
592+ { host: PG.PostgresHost "localhost"
143− active BOOLEAN DEFAULT true NOT NULL,
593+ , port: PG.PostgresPort 5433
144− role TEXT NOT NULL,
594+ , database: PG.PostgresDatabase "test_playground"
145− created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
595+ , username: PG.PostgresUsername "postgres"
146− updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
596+ , password: PG.PostgresPassword "postgres"
147− )"""
597+ , max: PG.MaxConnections 1
598+ } # liftEffect
599+ _ <- PG.executeSimple createDDL conn
600+ Om.runOm { postgresConnection: conn }
601+ { exception: throwError
602+ , parseError: \errs -> throwError (Exception.error ("Parse error: " <> show errs))
603+ }
604+ test
605+ _ <- PG.executeSimple dropDDL conn
606+ PG.end conn
607+ where
608+ dropStr = "DROP TABLE IF EXISTS test_om_users CASCADE"
609+ dropDDL = PG.SQL dropStr
610+ createDDL = PG.SQL (dropStr <> "; " <> createTableDDL @UsersTable)
611+
612+withUsersAndPostsTable :: TestOm -> Aff Unit
613+withUsersAndPostsTable test = do
614+ conn <- testConnection
615+ _ <- PG.executeSimple setupDDL conn
616+ Om.runOm { postgresConnection: conn }
617+ { exception: throwError
618+ , parseError: \errs -> throwError (Exception.error ("Parse error: " <> show errs))
619+ }
620+ test
621+ _ <- PG.executeSimple teardownDDL conn
622+ PG.end conn
623+ where
624+ teardownDDL = PG.SQL "DROP TABLE IF EXISTS test_om_posts CASCADE; DROP TABLE IF EXISTS test_om_users CASCADE"
625+ setupDDL = PG.SQL
626+ ( "DROP TABLE IF EXISTS test_om_posts CASCADE; "
627+ <> "DROP TABLE IF EXISTS test_om_users CASCADE; "
628+ <> createTableDDL @UsersTable
629+ <> "; "
630+ <> createTableDDL @PostsTable
631+ )