pg-schema 0.7.0.1 → 0.7.1.0
raw patch · 14 files changed
+557/−115 lines, 14 filesdep +postgresql-libpq
Dependencies added: postgresql-libpq
Files
- ChangeLog.md +13/−0
- pg-schema.cabal +8/−2
- src/PgSchema/Ann.hs +32/−21
- src/PgSchema/DML.hs +9/−6
- src/PgSchema/DML/Insert/Types.hs +5/−3
- src/PgSchema/DML/InsertJSON.hs +110/−65
- src/PgSchema/DML/Select/Types.hs +12/−2
- src/PgSchema/Schema.hs +19/−7
- src/PgSchema/Types.hs +63/−2
- test-gen/Main.hs +10/−0
- test-pgs/Main.hs +22/−0
- test-pgs/Sch.hs +28/−7
- test-pgs/Tests/InsertJSONTransaction.hs +102/−0
- test-pgs/Tests/UpsertUniqueKey.hs +124/−0
ChangeLog.md view
@@ -1,5 +1,18 @@ # Changelog for pg-schema +## 0.7.1.0+- Condition DSL: `(=??)` on nullable columns generates SQL+ `IS NOT DISTINCT FROM` (same null-safe key equality as upsertJSON)+- Improve transaction behavior for insertJSON/upsertJSON+- Add tests for insertJSON/upsertJSON with transactions+- `upsertJSON` / `upsertJSON_`: optional `ON CONFLICT` targets include NOT NULL+ unique keys (primary key still first), then nullable unique keys when the table+ has no NOT NULL UK; `UPDATE` by key uses `IS NOT DISTINCT FROM` for key columns;+ `IdentityCandidates` (compile-time) lists every PK/UK from the schema+- `insertJSON` / `insertJSON_`: insert-only (plain `INSERT`, no `ON CONFLICT` or+ `UPDATE`; duplicate keys fail at the database)+- Rename `AllMandatoryOrHasPKTree` to `AllMandatoryOrHasKeyTree`+ ## 0.7.0.1 - Bug fixing (upsertJSON_ without change fields: on conflict do nothing)
pg-schema.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.12 name: pg-schema-version: 0.7.0.1+version: 0.7.1.0 category: Database author: Dmitry Olshansky maintainer: olshanskydr@gmail.com@@ -41,7 +41,7 @@ data Order = Order { num :: Text, createdAt :: Day, items :: [OrdPos] } deriving Generic data OrdPos = OrdPos { id :: Int32, num :: Int32, article :: Article, price :: Double } deriving Generic data Article = Article { id :: Int32, name :: Text } deriving Generic- type MyAnn tabName = 'Ann 5 CamelToSnake MySch ("dbSchema" ->> tabName)+ type MyAnn tabName = 'Ann CamelToSnake MySch 5 ("dbSchema" ->> tabName) ... do -- insert an order with order positions@@ -155,6 +155,7 @@ , directory >= 1.3 && < 1.5 , exceptions >= 0.9 && < 0.11 , mtl >= 2.0 && < 2.4+ , postgresql-libpq >= 0.11 && < 0.12 , postgresql-simple >= 0.6 && < 0.8 , scientific >= 0.2 && < 0.4 , singletons >= 3.0.3 && < 3.1@@ -227,6 +228,7 @@ , aeson , bytestring , pg-schema+ , postgresql-libpq >= 0.11 && < 0.12 , postgresql-simple >= 0.6 && < 0.8 , text default-language: GHC2021@@ -240,6 +242,7 @@ ghc-options: -Wall test-suite test-gen+ type: exitcode-stdio-1.0 main-is: Main.hs other-modules: -- Paths and PackageInfo@@ -253,6 +256,7 @@ , bytestring >= 0.12.2 && < 0.13 , directory >= 1.3.9 && < 1.4 , pg-schema+ , postgresql-libpq >= 0.11 && < 0.12 , postgresql-simple >= 0.6 && < 0.8 hs-source-dirs: test-gen default-language: GHC2021@@ -266,6 +270,8 @@ Tests.BaseConverts Tests.Conditions Tests.Hierarchy+ Tests.InsertJSONTransaction+ Tests.UpsertUniqueKey Tests.Path Utils -- Paths and PackageInfo
src/PgSchema/Ann.hs view
@@ -357,10 +357,10 @@ => FromRow (PgTag ann r) where fromRow = PgTag <$> fromRowCols @ann @colsCase @cols --- >>> type AnnRel = 'Ann RenamerId PgCatalog (PGC "pg_constraint")+-- >>> type AnnRel = 'Ann RenamerId PgCatalog 1 (PGC "pg_constraint") -- >>> (r1 :: [PgTag AnnRel ( ("conkey" := Int16))]) <- query_ conn "select 1::int2" -- >>> r1--- [PgTag {unPgTag = PgTag {unPgTag = 1}}]+-- [PgTag {unPgTag = "conkey" =: 1}] class ToRowCols (ann :: Ann) (colsCase :: ColsCase) (cols :: [ColInfo NameNSK]) r where@@ -644,7 +644,7 @@ :$$: Text "" ) CheckRef ann fldDbName b b = () ----------------------------------------------------------------------------------- Node-level checks for Mandatory / PK (analogue of CheckNodeAll*)+-- Node-level checks for Mandatory / upsert keys (CheckNodeAll* analogue) -------------------------------------------------------------------------------- -- | One-table check that all mandatory fields are present@@ -660,23 +660,34 @@ :$$: Text "Table: " :<>: ShowType tab :$$: Text "Missing mandatory fields: " :<>: ShowType rest ) --- | One-table check that all mandatory fields are present--- or all PK fields are present-type family CheckAllMandatoryOrHasPK (ann :: Ann) (rs :: [Symbol]) :: Constraint where- CheckAllMandatoryOrHasPK ('Ann ren sch d tab) rs = CheckAllMandatoryOrHasPK'- (RestMandatory sch tab rs) (RestPK sch tab rs) ('Ann ren sch d tab) rs+-- | All mandatory fields in @rs@, or a full primary key, or a full eligible+-- unique key (see 'IdentityCandidates').+type family CheckAllMandatoryOrHasKey (ann :: Ann) (rs :: [Symbol]) :: Constraint where+ CheckAllMandatoryOrHasKey ('Ann ren sch d tab) rs = CheckAllMandatoryOrHasKey'+ (RestMandatory sch tab rs) (IdentityCandidates sch tab)+ ('Ann ren sch d tab) rs -type family CheckAllMandatoryOrHasPK' (restMandatory :: [Symbol]) (restPK :: [Symbol]) (ann :: Ann) (rs :: [Symbol]) :: Constraint where- CheckAllMandatoryOrHasPK' '[] rpk ann rs = ()- CheckAllMandatoryOrHasPK' rm '[] ann rs = ()- CheckAllMandatoryOrHasPK' rm rpk ('Ann ren sch d tab) rs = TypeError- ( Text "We can't upsert data because for table " :<>: ShowType tab- :$$: Text "either not all mandatory fields or not all PK fields are present."- :$$: Text "Missing mandatory fields: " :<>: ShowType rm- :$$: Text "Missing PK fields: " :<>: ShowType rpk )+type family CheckAllMandatoryOrHasKey' (restMandatory :: [Symbol]) (cands :: [[Symbol]]) (ann :: Ann) (rs :: [Symbol]) :: Constraint where+ CheckAllMandatoryOrHasKey' '[] _ ann rs = ()+ CheckAllMandatoryOrHasKey' rm cands ann rs = HasAnyFullIdentity rs cands ann -genDefunSymbols [ ''CheckAllMandatory, ''CheckAllMandatoryOrHasPK]+type family HasAnyFullIdentity (rs :: [Symbol]) (cands :: [[Symbol]]) ann :: Constraint where+ HasAnyFullIdentity rs '[] ('Ann _ sch _ tab) =+ TypeError+ ( Text "We can't upsert data because for table " :<>: ShowType tab+ :$$: Text "not all mandatory fields are present and no full primary or"+ :$$: Text "unique key is covered by record fields."+ :$$: Text "Missing mandatory fields: " :<>: ShowType (RestMandatory sch tab rs)+ :$$: Text "Possible keys: " :<>: ShowType (IdentityCandidates sch tab) )+ HasAnyFullIdentity rs (k ': ks) ann =+ HasAnyFullIdentity' (RestKey rs k) rs ks ann +type family HasAnyFullIdentity' (restKey :: [Symbol]) (rs :: [Symbol]) (ks :: [[Symbol]]) ann :: Constraint where+ HasAnyFullIdentity' '[] rs ks ann = ()+ HasAnyFullIdentity' (_ ': _) rs ks ann = HasAnyFullIdentity rs ks ann++genDefunSymbols [ ''CheckAllMandatory, ''CheckAllMandatoryOrHasKey]+ -------------------------------------------------------------------------------- -- Recursive AllMandatory / PK for tree (JSON insert / upsert) --------------------------------------------------------------------------------@@ -698,11 +709,11 @@ AllMandatoryTree ann r rFlds = WalkLevelAnn CheckAllMandatorySym0 ann (TRecordInfo ann r) rFlds -type family AllMandatoryOrHasPKTree (ann :: Ann) (r :: Type) (rFlds :: [Symbol])+type family AllMandatoryOrHasKeyTree (ann :: Ann) (r :: Type) (rFlds :: [Symbol]) :: Constraint where- AllMandatoryOrHasPKTree ann [r] rFlds = AllMandatoryOrHasPKTree ann r rFlds- AllMandatoryOrHasPKTree ann r rFlds =- WalkLevelAnn CheckAllMandatoryOrHasPKSym0 ann (TRecordInfo ann r) rFlds+ AllMandatoryOrHasKeyTree ann [r] rFlds = AllMandatoryOrHasKeyTree ann r rFlds+ AllMandatoryOrHasKeyTree ann r rFlds =+ WalkLevelAnn CheckAllMandatoryOrHasKeySym0 ann (TRecordInfo ann r) rFlds -------------------------------------------------------------------------------- -- Returning tree must be subtree of input tree (with path)
src/PgSchema/DML.hs view
@@ -16,7 +16,7 @@ -- data Order = Order { num :: Text, createdAt :: Day, items :: [OrdPos] } deriving Generic -- data OrdPos = OrdPos { id :: Int32, num :: Int32, article :: Article, price :: Double } deriving Generic -- data Article = Article { id :: Int32, name :: Text } deriving Generic--- type MyAnn tabName = 'Ann 5 CamelToSnake MySch ("dbSchema" ->> tabName)+-- type MyAnn tabName = 'Ann CamelToSnake MySch 5 ("dbSchema" ->> tabName) -- ... -- do -- void $ insertJSON_ (MyAnn "orders") conn@@ -43,10 +43,10 @@ -- JSON internally. Child data is carried in list fields: the field’s name (after 'Renamer') names -- the FK constraint in the database and thus selects the child table and link; -- each list element supplies one child row’s columns, with nested lists for further--- children in the same way. For strict inserts,--- 'insertJSON' and 'insertJSON_' require every mandatory column at each node;--- 'upsertJSON' / 'upsertJSON_' relax that so each row can be resolved by keys and--- optional columns (see their Haddock). Plain 'insertSch' / 'insertSch_' follow the+-- children in the same way. 'insertJSON' / 'insertJSON_' require every mandatory+-- column and emit plain @INSERT@ only (duplicates fail in the database).+-- 'upsertJSON' / 'upsertJSON_' relax mandatory requirements and may @UPDATE@ or+-- @INSERT … ON CONFLICT …@ (see their Haddock). Plain 'insertSch' / 'insertSch_' follow the -- same safety story for flat (non-tree) rows. -- -- 'selectSch' decodes each row into a Haskell type @r@ whose fields describe the@@ -82,6 +82,9 @@ -- *** Conditions -- | Example: @"name" =? "John"@ , (<?),(>?),(<=?),(>=?),(=?)+ -- | Null-safe equality on nullable columns (@IS NOT DISTINCT FROM@).+ -- | Example: @"suffix" =?? (Just "a")@+ , (=??) -- | Example: @"name" ~~? "%joh%"@ ,(~=?),(~~?) , (|||), (&&&), pnot, pnull, pin, pinArr@@ -101,7 +104,7 @@ , UpdateReturning, UpdateNonReturning, CRecInfo(..) -- ** Tree-base Insert/Upsert , insertJSON, insertJSON_, upsertJSON, upsertJSON_, insertJSONText, insertJSONText_- , TreeIn, TreeOut, AllMandatoryTree, AllMandatoryOrHasPKTree, TreeSch+ , TreeIn, TreeOut, AllMandatoryTree, AllMandatoryOrHasKeyTree, TreeSch , InsertTreeNonReturning, InsertTreeReturning , UpsertTreeNonReturning, UpsertTreeReturning, TRecordInfo -- ** Delete
src/PgSchema/DML/Insert/Types.hs view
@@ -60,14 +60,16 @@ -- | Upsert tree without @RETURNING@. ----- Check that all mandatory fields or primary keys are present in all tables in tree.+-- Check that all mandatory fields or a full primary / eligible unique key are+-- present at each tree node. -- Reference fields in the child tables are not checked - they are inserted automatically. type UpsertTreeNonReturning ann r =- (TreeSch ann, TreeIn ann r, AllMandatoryOrHasPKTree ann r '[])+ (TreeSch ann, TreeIn ann r, AllMandatoryOrHasKeyTree ann r '[]) -- | Upsert tree with @RETURNING@. ----- Check that all mandatory fields or primary keys are present in all tables in tree.+-- Check that all mandatory fields or a full primary / eligible unique key are+-- present at each tree node. -- Reference fields in the child tables are not checked - they are inserted automatically. -- -- It also checks that we get returnings only from the tables we upserted into.
src/PgSchema/DML/InsertJSON.hs view
@@ -18,30 +18,35 @@ import Data.Traversable import PgSchema.Ann import PgSchema.DML.Insert.Types+import Database.PostgreSQL.LibPQ qualified as PQ import Database.PostgreSQL.Simple+import Database.PostgreSQL.Simple.Internal (withConnection) import PgSchema.Schema import PgSchema.Types import Data.String-import GHC.Int import PgSchema.Utils.Internal import Prelude as P -- | Insert records into a table and its children using JSON data internally. ----- Like 'upsertJSON', but requires all mandatory columns at every node (insert-only constraint).+-- Uses the same SQL pipeline as 'upsertJSON', but each row is a plain @INSERT@+-- (no @UPDATE@, no @INSERT … ON CONFLICT …@). Any duplicate primary or unique key+-- raises a PostgreSQL error.+--+-- Requires all mandatory columns at every node (see 'InsertTreeReturning'). insertJSON :: forall ann -> forall r r'. InsertTreeReturning ann r r' => Connection -> [r] -> IO ([r'], Text)-insertJSON ann @r @r' = insertJSONImpl ann @r @r'+insertJSON ann @r @r' conn rs = insertJSONImpl ann @r @r' conn rs True -- | Like 'insertJSON', but does not return rows. -- insertJSON_ :: forall ann -> forall r. InsertTreeNonReturning ann r => Connection -> [r] -> IO Text-insertJSON_ ann @r = insertJSONImpl_ ann @r+insertJSON_ ann @r conn rs = insertJSONImpl_ ann @r conn rs True -- | Upsert a forest of rows into the root table and its /child/ tables in one -- round-trip, using JSON inside PostgreSQL (same pipeline as 'insertJSON').@@ -59,37 +64,41 @@ -- -- __What to supply at each node:__ at every level, each row must either include -- all mandatory columns (for columns that are mandatory in the schema /sense of--- this API/) or, alternatively, enough primary-key columns to identify an existing--- row. Foreign-key columns that are filled in by the parent level (for example+-- this API/) or, alternatively, enough columns to identify an existing row via+-- the primary key or a unique constraint (including constraints with nullable+-- columns when the table has no @NOT NULL@ unique constraint besides PK).+-- If a nullable key column is @null@ in the payload, conflict/update semantics+-- follow PostgreSQL (see haddock on 'upsertJSON').+-- Foreign-key columns that are filled in by the parent level (for example -- after an auto-generated id on insert) do /not/ need to be present on the child -- payload. -- -- __Insert vs update vs upsert per row:__ the engine picks one of @INSERT@, -- @UPDATE@, or @UPSERT@ from the keys and mandatory fields you provide: ----- * all mandatory fields present and /no/ primary key → @INSERT@--- * primary key present, not all mandatory fields → @UPDATE@--- * primary key present /and/ all mandatory fields → @UPSERT@+-- * all mandatory fields present and no full identity key → @INSERT@+-- * identity (PK or first eligible unique whose columns are all in the node), not all mandatory → @UPDATE@+-- * identity present /and/ all mandatory fields → @UPSERT@ (@INSERT … ON CONFLICT …@) ----- 'insertJSON' is the same execution path but adds a stricter type-level--- constraint: /every/ mandatory field must be present (pure inserts). 'upsertJSON'--- relaxes that so updates and upserts are expressible as in the rules above.+-- 'insertJSON' shares this execution path but is insert-only at runtime and+-- requires /every/ mandatory field at compile time. 'upsertJSON' relaxes both. upsertJSON :: forall ann -> forall r r'. UpsertTreeReturning ann r r' => Connection -> [r] -> IO ([r'], Text)-upsertJSON ann @r @r' = insertJSONImpl ann @r @r'+upsertJSON ann @r @r' conn rs = insertJSONImpl ann @r @r' conn rs False -- | Like 'upsertJSON', but does not return rows. -- upsertJSON_ :: forall ann -> forall r. UpsertTreeNonReturning ann r => Connection -> [r] -> IO Text-upsertJSON_ ann @r = insertJSONImpl_ ann @r+upsertJSON_ ann @r conn rs = insertJSONImpl_ ann @r conn rs False insertJSONImpl :: forall ann -> forall r r'. (TreeSch ann, TreeIn ann r, TreeOut ann r')- => Connection -> [r] -> IO ([r'], Text)-insertJSONImpl ann @r @r' conn rs = withTransactionIfNot conn do+ => Connection -> [r] -> Bool -> IO ([r'], Text)+ -- ^ @Bool@ is @isInsertOnly@.+insertJSONImpl ann @r @r' conn rs isInsertOnly = withTransactionIfNot conn do let sql' = T.unpack sql in trace' sql' $ void $ execute_ conn $ fromString sql' [Only res] <- let q = "select pg_temp.__ins(?)" in traceShow' q@@ -98,40 +107,54 @@ void $ execute_ conn "drop function pg_temp.__ins" pure (unPgTag @ann @r' <$> res, sql) where- sql = insertJSONText ann @r @r'+ sql = insertJSONText' isInsertOnly (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))+ (getRecordInfo @ann @r) (getRecordInfo @ann @r').fields withTransactionIfNot :: Connection -> IO a -> IO a withTransactionIfNot conn act = do- isInTrans <- any (isJust @Int64 . fromOnly)- <$> query_ conn "SELECT txid_current_if_assigned()"- (if isInTrans then id else withTransaction conn) act+ stat <- withConnection conn PQ.transactionStatus+ case stat of+ PQ.TransIdle -> withTransaction conn act+ PQ.TransInTrans -> act+ PQ.TransInError -> act+ PQ.TransActive ->+ error "PgSchema.DML.InsertJSON.withTransactionIfNot: connection is active"+ PQ.TransUnknown ->+ error "PgSchema.DML.InsertJSON.withTransactionIfNot: \+ \unknown transaction status" insertJSONImpl_ :: forall ann -> forall r. (TreeSch ann, TreeIn ann r)- => Connection -> [r] -> IO Text-insertJSONImpl_ ann @r conn rs = withTransactionIfNot conn do+ => Connection -> [r] -> Bool -> IO Text+insertJSONImpl_ ann @r conn rs isInsertOnly = withTransactionIfNot conn do void $ trace' (T.unpack sql) $ execute_ conn $ fromString $ T.unpack sql void $ execute conn "call pg_temp.__ins(?)" $ Only $ PgTag @ann @r <$> rs sql <$ execute_ conn "drop procedure pg_temp.__ins" where- sql = insertJSONText_ ann @r+ sql = insertJSONText' isInsertOnly (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))+ (getRecordInfo @ann @r) [] insertJSONText_ :: forall ann -> forall r s. (IsString s, Monoid s, Ord s, TreeSch ann, CRecInfo ann r) => s-insertJSONText_ ann @r = insertJSONText' (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))- (getRecordInfo @ann @r) []+insertJSONText_ ann @r =+ insertJSONText' True (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))+ (getRecordInfo @ann @r) [] insertJSONText :: forall ann -> forall r r'. ( TreeSch ann, CRecInfo ann r, CRecInfo ann r' , IsString s, Monoid s, Ord s ) => s insertJSONText ann @r @r' =- insertJSONText' (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann))+ insertJSONText' True (typDefMap @(AnnSch ann)) (tabInfoMap @(AnnSch ann)) (getRecordInfo @ann @r) (getRecordInfo @ann @r').fields +-- | @isInsertOnly@: plain @INSERT@ only ('insertJSON'); 'False' enables upsert+-- key resolution ('upsertJSON'). insertJSONText' :: forall s. (IsString s, Monoid s, Ord s)- => M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text -> [FieldInfo Text] -> s-insertJSONText' mapTypes mapTabs ir qfs = unlines'+ => Bool+ -> M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text+ -> [FieldInfo Text] -> s+insertJSONText' isInsertOnly mapTypes mapTabs ir qfs = unlines' [ maybe "create or replace procedure pg_temp.__ins(data_0 jsonb) as $$" (const "create or replace function pg_temp.__ins(data_0 jsonb) returns jsonb as $$")@@ -145,7 +168,8 @@ , "$$ language plpgsql;" ] where (mbRes, (decl, body)) =- evalRWS (insertJSONTextM mapTypes mapTabs ir qfs [] []) (" ",0) 0+ evalRWS (insertJSONTextM isInsertOnly mapTypes mapTabs ir qfs [] [])+ (" ",0) 0 type MonadInsert s = RWS (s, Int) ([s],[s]) Int -- R: (leading spaces to format code, number of table in tree)@@ -160,11 +184,26 @@ , info :: v } deriving (Eq, Show) +-- | Runtime conflict/update targets: PK, then @NOT NULL@ unique constraints,+-- then nullable unique constraints only when the table has no @NOT NULL@ UK.+-- Compile-time checks use all keys from 'IdentityCandidates'.+identityCandidatesFromTab :: TabInfo -> [[Text]]+identityCandidatesFromTab ti =+ P.filter (not . P.null) [ti.tiDef.tdKey] <> notNullUks <> nullUks+ where+ isNullable = (== Just True) . fmap (.fdNullable) . (`M.lookup` ti.tiFlds)+ (nullUks, notNullUks) = L.partition (P.any isNullable) ti.tiDef.tdUniq++mandatoryDbNames :: TabInfo -> [Text]+mandatoryDbNames ti =+ M.keys $ M.filter (\fd -> not $ fd.fdNullable || fd.fdHasDefault) ti.tiFlds+ insertJSONTextM :: forall s. (IsString s, Monoid s, Ord s)- => M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text+ => Bool+ -> M.Map NameNS TypDef -> M.Map NameNS TabInfo -> RecordInfo Text -> [FieldInfo Text] -> [s] -> [s] -> MonadInsert s (Maybe s)-insertJSONTextM mapTypes mapTabs ri qfs fromFields toVars = do+insertJSONTextM isInsertOnly mapTypes mapTabs ri qfs fromFields toVars = do (spaces, n) <- ask let sn = show' n@@ -179,13 +218,21 @@ startLoop = ["for " <> rowN <> " in select * from jsonb_array_elements(" <> dataN <> ")", "loop"]- (ins, op) = case mbKeyMand of- Just (pk, mflds)- | not $ P.null $ mflds L.\\ (fromFields <> (fst <$> plains)) ->- (addSemiColon (upd0 <> rets), UPD)- | P.null $ pk L.\\ (fromFields <> (fst <$> plainsPK)) -> (ups0 pk, UPS)- _ -> (addSemiColon (ins0 <> rets), INS)+ (ins, op) = resolve where+ jsonFld ip = case mapTypes M.!? ip.info.fdType of+ Just (TypDef "A" (Just t) _) ->+ "case when jsonb_typeof(" <> rowN <> ".value->'" <> fld <> "') = 'array'"+ <> " then (select coalesce(array_agg(__x)::" <> fromText (qualName t) <> "[], '{}')"+ <> " from jsonb_array_elements_text(" <> rowN <> ".value->'" <> fld <> "') __x) else null end"+ _+ | tn == "json" || tn == "jsonb" -> "(" <> rowN <> ".value->'" <> fld <> "')"+ | otherwise -> "(" <> rowN <> ".value->>'" <> fld <> "')::" <> ty+ where+ fld = fromText ip.jsonName+ ty = fromText (qualName ip.info.fdType)+ tn = ip.info.fdType.nnsName+ plains = iplains <&> \ip -> (fromText ip.dbName, jsonFld ip) srcFlds = fromFields <> (fst <$> plains) srcVars = toVars <> (jsonFld <$> iplains) srcMap = M.fromList $ P.zip srcFlds srcVars@@ -198,13 +245,27 @@ ins0 = [ " insert into " <> qualTabName <> "(" <> intercalate' ", " srcFlds <> ")" , " values (" <> intercalate' ", " srcVars <> ")"]+ mbTab = mapTabs M.!? ri.tabName+ mflds = foldMap (fmap fromText . mandatoryDbNames) mbTab+ keyNames+ | isInsertOnly = []+ | otherwise = F.fold $ L.find (P.null . (L.\\ srcFlds))+ $ fmap fromText <$> foldMap identityCandidatesFromTab mbTab+ (plainsKey, plainsOthers) =+ L.partition ((`L.elem` keyNames) . fst) plains sWhere = "where " <> intercalate' " and "- ( L.zipWith nameVal fromFields toVars <> (uncurry nameVal <$> plainsPK))+ ( L.zipWith nameVal fromFields toVars+ <> (uncurry keyValDistinct <$> plainsKey)) upd0 = [ " update " <> qualTabName , " set " <> intercalate' ", " (uncurry nameVal <$> plains) , " " <> sWhere]- ups0 pk+ qretVars = (<> sn) <$> qretFlds+ rets+ | noRets = []+ | otherwise = [" returning " <> intercalate' ", " qretFlds+ <> " into " <> intercalate' ", " qretVars]+ ups0 conflictCols | L.null plainsOthers = case mbSetVars of Just xs -> ins0 <> [ " on conflict do nothing;"] <> [" " <> intercalate' " " xs]@@ -214,31 +275,17 @@ , " from " <> qualTabName , " " <> sWhere <> ";" , " end if;"]- | L.null pk = addSemiColon ins0 -- support for tables without PK+ | P.null conflictCols = addSemiColon ins0 | otherwise = addSemiColon $ ins0- <> [ " on conflict (" <> intercalate' ", " pk <> ")"+ <> [ " on conflict (" <> intercalate' ", " conflictCols <> ")" , " do update set " <> intercalate' ", " (plainsOthers <&> \(name, _) -> name <> " = " <> "EXCLUDED." <> name) ] <> rets- qretVars = (<> sn) <$> qretFlds- plains = iplains <&> \ip -> (fromText ip.dbName, jsonFld ip)- (plainsPK, plainsOthers) = L.partition ((`L.elem` foldMap fst mbKeyMand) . fst) plains- jsonFld ip = case mapTypes M.!? ip.info.fdType of- Just (TypDef "A" (Just t) _) ->- "case when jsonb_typeof(" <> rowN <> ".value->'" <> fld <> "') = 'array'"- <> " then (select coalesce(array_agg(__x)::" <> fromText (qualName t) <> "[], '{}')"- <> " from jsonb_array_elements_text(" <> rowN <> ".value->'" <> fld <> "') __x) else null end"- _- | tn == "json" || tn == "jsonb" -> "(" <> rowN <> ".value->'" <> fld <> "')"- | otherwise -> "(" <> rowN <> ".value->>'" <> fld <> "')::" <> ty- where- fld = fromText ip.jsonName- ty = fromText (qualName ip.info.fdType)- tn = ip.info.fdType.nnsName- rets- | noRets = []- | otherwise = [" returning " <> intercalate' ", " qretFlds- <> " into " <> intercalate' ", " qretVars]+ resolve+ | not isInsertOnly, not $ P.null $ mflds L.\\ srcFlds =+ (addSemiColon (upd0 <> rets), UPD)+ | not isInsertOnly, not $ P.null keyNames = (ups0 keyNames, UPS)+ | otherwise = (addSemiColon (ins0 <> rets), INS) endLoop = "end loop;" processChildren = do (spaces', _) <- ask@@ -250,7 +297,8 @@ let qfs' = foldMap ((.fields) . snd) $ L.find (\(qc, _) -> qc.jsonName == child.jsonName) qchildren- mbArr <- local (second $ const n') $ insertJSONTextM mapTypes mapTabs+ mbArr <- local (second $ const n') $ insertJSONTextM isInsertOnly+ mapTypes mapTabs childRi qfs' (fromText . (.fromName) <$> child.info) ((<> sn) . fromText . (.toName) <$> child.info) pure (fromText child.jsonName, mbArr)@@ -279,10 +327,6 @@ _ -> P.id) mempty (iplains, ichildren) = splitFields ri.fields (qplains, qchildren) = splitFields qfs- mbKeyMand = mapTabs M.!? ri.tabName <&> ((,)- <$> fmap fromText . (.tiDef.tdKey)- <*> fmap fromText . M.keys- . M.filter (\fd -> not $ fd.fdNullable || fd.fdHasDefault) . (.tiFlds)) qualTabName = fromText (qualName ri.tabName) qcFlds = fmap ((,) <$> (.toName) <*> qualName . (.toDef.fdType)) $ nubBy ((==) `on` (.toName)) $ ichildren >>= (\x -> (fst x).info)@@ -290,6 +334,7 @@ qretPairs = fmap (bimap fromText fromText) $ nubBy ((==) `on` fst) $ qcFlds <> qpFlds nameVal name val = name <> " = " <> val+ keyValDistinct name val = name <> " IS NOT DISTINCT FROM " <> val noRets = P.null qplains && P.null ichildren qretFlds = fst <$> qretPairs addSemiColon = \case
src/PgSchema/DML/Select/Types.hs view
@@ -233,7 +233,7 @@ -- | Comparison constructors; each is paired with its corresponding operator -- (e.g. '(:=)' with '(=?)').-data Cmp = (:=) | (:<=) | (:>=) | (:>) | (:<) | Like | ILike+data Cmp = (:=) | (:<=) | (:>=) | (:>) | (:<) | Like | ILike | NotDistinctFrom deriving (Show, Eq, Generic) -- | Just boolean operations@@ -248,6 +248,7 @@ (:>) -> ">" Like -> "like" ILike -> "ilike"+ NotDistinctFrom -> "is not distinct from" {- | RWS-Monad to generate condition. * Read: Stack of numbers of parent tables. The top is "current table"@@ -419,6 +420,7 @@ {-# INLINE (<=?) #-} {-# INLINE (>=?) #-} {-# INLINE (=?) #-}+{-# INLINE (=??) #-} {-# INLINE (~=?) #-} {-# INLINE (~~?) #-} (<?),(>?),(<=?),(>=?),(=?)@@ -428,11 +430,19 @@ x <=? b = Cmp @x (:<=) b x >=? b = Cmp @x (:>=) b x =? b = Cmp @x (:=) b+-- | Null-safe equality (@IS NOT DISTINCT FROM@) for nullable columns only.+(=??)+ :: forall fld -> forall ren sch tab v+ . ( CDBFieldNullable sch tab (ApplyRenamer ren fld)+ , CDBValue sch tab (ApplyRenamer ren fld) v+ )+ => v -> Cond ren sch tab+x =?? b = Cmp @x NotDistinctFrom b (~=?),(~~?) :: forall fld -> forall ren sch tab v. CDBValue sch tab (ApplyRenamer ren fld) v => v -> Cond ren sch tab x ~=? b = Cmp @x Like b x ~~? b = Cmp @x ILike b-infix 4 <?, >?, <=?, >=?, =?, ~=?, ~~?+infix 4 <?, >?, <=?, >=?, =?, =??, ~=?, ~~? data OrdDirection = Asc | Desc deriving Show
src/PgSchema/Schema.hs view
@@ -136,15 +136,27 @@ type RestMandatory sch t rs = RestMandatory' sch t rs (TdFlds (TTabDef sch t)) '[] -type family RestPK' (rs :: [Symbol]) (fs :: [Symbol]) (res :: [Symbol]) :: [Symbol] where- RestPK' rs '[] res = res- RestPK' rs (fld ': fs) res = RestPK'' rs fs fld res (Elem' fld rs)+type family RestKey' (rs :: [Symbol]) (fs :: [Symbol]) (res :: [Symbol]) :: [Symbol] where+ RestKey' rs '[] res = res+ RestKey' rs (fld ': fs) res = RestKey'' rs fs fld res (Elem' fld rs) -type family RestPK'' rs fs fld (res :: [Symbol]) (b :: Bool) :: [Symbol] where- RestPK'' rs fs fld res 'True = RestPK' rs fs res- RestPK'' rs fs fld res 'False = RestPK' rs fs (fld ': res)+type family RestKey'' rs fs fld (res :: [Symbol]) (b :: Bool) :: [Symbol] where+ RestKey'' rs fs fld res 'True = RestKey' rs fs res+ RestKey'' rs fs fld res 'False = RestKey' rs fs (fld ': res) -type RestPK sch t rs = RestPK' rs (TdKey (TTabDef sch t)) '[]+-- | Columns still missing from @rs@ to complete candidate key @keyCols@.+type RestKey rs keyCols = RestKey' rs keyCols '[]++type family AppendPK (pk :: [Symbol]) (uks :: [[Symbol]]) :: [[Symbol]] where+ AppendPK '[] uks = uks+ AppendPK pk uks = pk ': uks++-- | Keys accepted by 'CheckAllMandatoryOrHasKey': PK plus every unique+-- constraint from the schema (catalog order). Runtime key choice uses+-- 'PgSchema.DML.InsertJSON.identityCandidatesFromTab' and its priority rules.+type family IdentityCandidates sch tab :: [[Symbol]] where+ IdentityCandidates sch tab =+ AppendPK (TdKey (TTabDef sch tab)) (TdUniq (TTabDef sch tab)) simpleType :: Text -> TypDef simpleType c = TypDef c Nothing []
src/PgSchema/Types.hs view
@@ -397,9 +397,71 @@ -- synonym below). -- newtype PgTag s t = PgTag { unPgTag :: t }- deriving stock (Show, Read, Eq, Ord, Functor, Foldable, Traversable)+ deriving stock (Eq, Ord, Functor, Foldable, Traversable) deriving newtype (Semigroup, Monoid) +-- | Parse legacy @PgTag { unPgTag = … }@ (and minor spacing variants).+--+-- >>> P.show (fst (P.head (readsPgTagLegacy "PgTag { unPgTag = 9 }" :: [(PgTag "z" Int, String)])))+-- "\"z\" =: 9"+readsPgTagLegacy :: Read t => ReadS (PgTag s t)+readsPgTagLegacy r =+ [ (PgTag v, z)+ | ("PgTag", r1) <- lex r+ , ("{", r2) <- lex r1+ , ("unPgTag", r3) <- lex r2+ , ("=", r4) <- lex r3+ , (v, r5) <- reads r4+ , ("}", z) <- lex r5+ ]++-- | When @s :: 'Symbol'@ and @t@ is 'Show', render like @\"field\" =: value@:+--+-- >>> P.show ("ownerId" =: (1 :: Int))+-- "\"ownerId\" =: 1"+instance {-# OVERLAPPING #-} (KnownSymbol s, Show t) =>+ Show (PgTag (s :: Symbol) t) where+ showsPrec p (PgTag x) =+ showParen (p > 5) $+ showString (P.show (TL.symbolVal (Proxy @s) :: String))+ . showString " =: "+ . showsPrec 6 x++-- | Accept @\"field\" =: value@ or the legacy @PgTag { unPgTag = … }@ form:+--+-- >>> P.show (read "\"x\" =: 42" :: PgTag "x" Int)+-- "\"x\" =: 42"+-- >>> P.show (read "PgTag { unPgTag = 42 }" :: PgTag "x" Int)+-- "\"x\" =: 42"+instance {-# OVERLAPPING #-} (KnownSymbol s, Read t) =>+ Read (PgTag (s :: Symbol) t) where+ readsPrec p r =+ readParen (p > 5) tryEqColon r P.++ readParen (p > 11) readsPgTagLegacy r+ where+ tryEqColon s =+ [ (PgTag a, u)+ | (expected, t) <- reads s+ , expected == (TL.symbolVal (Proxy @s) :: String)+ , ("=:", u0) <- lex t+ , (a, u) <- readsPrec 6 u0+ ]++-- | If @s@ is not a 'Symbol', fall back to record-style 'Show':+--+-- >>> P.show (PgTag @Bool (42 :: Int))+-- "PgTag {unPgTag = 42}"+instance {-# OVERLAPPABLE #-} Show t => Show (PgTag s t) where+ showsPrec p (PgTag x) =+ showParen (p > 11) $+ showString "PgTag {unPgTag = " . shows x . showString "}"++-- | Uses only 'readsPgTagLegacy' (see there for a doctest).+--+-- >>> P.show (read "PgTag { unPgTag = 42 }" :: PgTag Bool Int)+-- "PgTag {unPgTag = 42}"+instance {-# OVERLAPPABLE #-} Read t => Read (PgTag s t) where+ readsPrec p = readParen (p > 11) readsPgTagLegacy+ type s := t = PgTag s t infixr 5 := @@ -409,7 +471,6 @@ newtype UnsafeCol (flds :: [Symbol]) (expr :: Symbol) res = UnsafeCol { getUnsafeCol :: res }- -- deriving newtype (FromField, FromJSON) instance (FromField res, AllFields sch tab flds, CheckExpr flds expr) => FromField (PgTag '(sch, tab :: NameNSK) (UnsafeCol flds expr res)) where
test-gen/Main.hs view
@@ -90,6 +90,16 @@ constraint leaf_mid2_fk foreign key (root_id, seq) references mid2 (root_id, seq) on delete cascade ); + -- Upsert by nullable unique key when table has no NOT NULL UK.+ create table nullable_uq_row (+ id serial primary key,+ code text not null,+ suffix text,+ name text not null,+ note text,+ constraint nullable_uq_row_code_suffix_uq unique (code, suffix)+ );+ -- Table for array types (nullable elements, no 2D arrays). create table arrays ( id serial primary key,
test-pgs/Main.hs view
@@ -10,6 +10,8 @@ import Tests.BaseConverts import Tests.Hierarchy+import Tests.InsertJSONTransaction+import Tests.UpsertUniqueKey import Tests.Conditions prop_not_implemented :: Property@@ -34,6 +36,26 @@ , testProperty "Duplicate field names in root and nested structure" $ prop_hier_duplicate_names_root_nested pool -- , testProperty "Optional parent FK on root (dim_a_id)" $ -- prop_hier_insert_optional_parent_dim_a pool+ ]+ , testGroup "InsertJSON transactions (test_dml)"+ [ testProperty "Standalone call commits" $+ prop_insertJSON_standalone_commits pool+ , testProperty "Outer transaction is not committed" $+ prop_insertJSON_respects_outer_transaction pool+ , testProperty "BEGIN without prior writes rolls back" $+ prop_insertJSON_begin_without_prior_writes pool+ , testProperty "Failure rolls back standalone transaction" $+ prop_insertJSON_rolls_back_on_failure pool+ ]+ , testGroup "Upsert by unique key (test_dml)"+ [ testProperty "upsertJSON on composite unique without PK in payload" $+ prop_upsert_json_by_composite_unique pool+ , testProperty "upsertJSON on nullable unique without NOT NULL UK" $+ prop_upsert_json_by_nullable_unique pool+ , testProperty "upsertJSON UPDATE by nullable unique key" $+ prop_upsert_json_update_by_nullable_unique pool+ , testProperty "upsertJSON null in nullable key inserts again" $+ prop_upsert_json_nullable_key_null_inserts_twice pool ] , testGroup "Query (test_dml)" [ testProperty "'Simple' queries" $ prop_cond_query pool
test-pgs/Sch.hs view
@@ -71,11 +71,12 @@ TTabDefSch ( "test_pgs" ->> "leaf" ) = 'TabDef '[ "root_id","seq","leaf_no","value","category","created_at" ] '[ "root_id","seq","leaf_no" ] '[ ] TTabDefSch ( "test_pgs" ->> "mid1" ) = 'TabDef '[ "id","root_id","pos","flag","sort_key","payload" ] '[ "id" ] '[ ] TTabDefSch ( "test_pgs" ->> "mid2" ) = 'TabDef '[ "root_id","seq","kind","flag","priority","payload" ] '[ "root_id","seq" ] '[ ]+ TTabDefSch ( "test_pgs" ->> "nullable_uq_row" ) = 'TabDef '[ "id","code","suffix","name","note" ] '[ "id" ] '[ '[ "code","suffix" ] ] TTabDefSch ( "test_pgs" ->> "root" ) = 'TabDef '[ "id","code","grp","name","created_at","dim_a_id","dim_b_id" ] '[ "id" ] '[ '[ "code","grp" ] ] TTabDefSch name = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "table " TE.:<>: TE.ShowType name TE.:<>: TE.Text " is not defined." TE.:$$: TE.Text "" TE.:$$: TE.Text "Valid values are:"- TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."+ TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root." TE.:$$: TE.Text "") instance (ToStar (TTabDef Sch name), ToStar name) => CTabDef Sch name where type TTabDef Sch name = TTabDefSch name@@ -117,13 +118,15 @@ TFromSch ( "test_pgs" ->> "mid2" ) = '[ ( "test_pgs" ->> "mid2_root_fk" ) ] + TFromSch ( "test_pgs" ->> "nullable_uq_row" ) = '[ ]+ TFromSch ( "test_pgs" ->> "root" ) = '[ ( "test_pgs" ->> "root_dim_a_fk" ) ,( "test_pgs" ->> "root_dim_b_fk" ) ] TFromSch tab = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "TFrom for table " TE.:<>: TE.ShowType tab TE.:<>: TE.Text " is not defined." TE.:$$: TE.Text "" TE.:$$: TE.Text "Valid values are:"- TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."+ TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root." TE.:$$: TE.Text "") type family TToSch (tab :: NameNSK) :: [NameNSK] where@@ -146,13 +149,15 @@ TToSch ( "test_pgs" ->> "mid2" ) = '[ ( "test_pgs" ->> "leaf_mid2_fk" ) ] + TToSch ( "test_pgs" ->> "nullable_uq_row" ) = '[ ]+ TToSch ( "test_pgs" ->> "root" ) = '[ ( "test_pgs" ->> "arrays_root_fk" ) ,( "test_pgs" ->> "mid1_root_fk" ),( "test_pgs" ->> "mid2_root_fk" ) ] TToSch tab = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "TTo for table " TE.:<>: TE.ShowType tab TE.:<>: TE.Text " is not defined." TE.:$$: TE.Text "" TE.:$$: TE.Text "Valid values are:"- TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.root."+ TE.:$$: TE.Text " Tables: test_pgs.arrays, test_pgs.base_arr_converts, test_pgs.base_converts, test_pgs.dim, test_pgs.ext_arr_converts, test_pgs.ext_converts, test_pgs.leaf, test_pgs.mid1, test_pgs.mid2, test_pgs.nullable_uq_row, test_pgs.root." TE.:$$: TE.Text "") instance CTabRels Sch tab where type TFrom Sch tab = TFromSch tab@@ -313,6 +318,21 @@ TE.:$$: TE.Text "" TE.:$$: TE.Text "Your source or target type or renaimer is probably invalid." TE.:$$: TE.Text "")+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "code" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int4" ) 'False 'True)+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "name" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "note" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'True 'False)+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) "suffix" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'True 'False)+ TDBFieldInfoSch ( "test_pgs" ->> "nullable_uq_row" ) f = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch+ TE.:$$: TE.Text "for table " TE.:<>: TE.ShowType ( "test_pgs" ->> "nullable_uq_row" )+ TE.:$$: TE.Text "name " TE.:<>: TE.ShowType f TE.:<>: TE.Text " is not defined."+ TE.:$$: TE.Text ""+ TE.:$$: TE.Text "Valid values are:"+ TE.:$$: TE.Text " Fields: id, code, suffix, name, note."+ TE.:$$: TE.Text " Foreign key constraints: ."+ TE.:$$: TE.Text ""+ TE.:$$: TE.Text "Your source or target type or renaimer is probably invalid."+ TE.:$$: TE.Text "") TDBFieldInfoSch ( "test_pgs" ->> "root" ) "code" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False) TDBFieldInfoSch ( "test_pgs" ->> "root" ) "created_at" = 'RFPlain ('FldDef ( "pg_catalog" ->> "timestamptz" ) 'False 'True) TDBFieldInfoSch ( "test_pgs" ->> "root" ) "dim_a_id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int4" ) 'True 'False)@@ -348,10 +368,11 @@ instance CSchema Sch where type TTabs Sch = '[ ( "test_pgs" ->> "arrays" ),( "test_pgs" ->> "base_arr_converts" )- ,( "test_pgs" ->> "base_converts" ),( "test_pgs" ->> "dim" )- ,( "test_pgs" ->> "ext_arr_converts" ),( "test_pgs" ->> "ext_converts" )- ,( "test_pgs" ->> "leaf" ),( "test_pgs" ->> "mid1" )- ,( "test_pgs" ->> "mid2" ),( "test_pgs" ->> "root" ) ]+ ,( "test_pgs" ->> "base_converts" )+ ,( "test_pgs" ->> "dim" ),( "test_pgs" ->> "ext_arr_converts" )+ ,( "test_pgs" ->> "ext_converts" ),( "test_pgs" ->> "leaf" )+ ,( "test_pgs" ->> "mid1" ),( "test_pgs" ->> "mid2" )+ ,( "test_pgs" ->> "nullable_uq_row" ),( "test_pgs" ->> "root" ) ] type TTypes Sch = '[ ( "pg_catalog" ->> "_bool" ) ,( "pg_catalog" ->> "_bytea" ),( "pg_catalog" ->> "_date" )
+ test-pgs/Tests/InsertJSONTransaction.hs view
@@ -0,0 +1,102 @@+{-# LANGUAGE BlockArguments #-}+module Tests.InsertJSONTransaction where++import Control.Exception (SomeException, try)+import Control.Monad (void, when)+import Data.Functor+import Data.Int (Int32, Int64)+import Data.List qualified as L+import Data.Pool as Pool+import Data.Text (Text)+import Database.PostgreSQL.Simple+import Hedgehog+import Utils+import PgSchema.DML++type RootRec = "code" := Text :. "grp" := Int32 :. "name" := Text :. "someEmpty" := ()++type RootSel = "code" := Text :. "grp" := Int32 :. "name" := Text++eqRoot :: RootRec -> RootRec -> Bool+eqRoot (a1 :. b1 :. c1) (a2 :. b2 :. c2) = (a1, b1) == (a2, b2)++countRoots :: Connection -> IO Int64+countRoots conn = do+ [cnt] <- fst <$> selSch "root" conn qpEmpty+ pure $ unAggr @ACount $ unPgTag @"cnt" cnt++countRootsCode :: Connection -> Text -> IO Int64+countRootsCode conn code = do+ [cnt] <- fst <$> selSch "root" conn (qRoot $ qWhere $ "code" =? code)+ pure $ unAggr @ACount $ unPgTag @"cnt" cnt++clearRoots :: Connection -> IO ()+clearRoots conn = void $ delByCond "root" conn mempty++prop_insertJSON_standalone_commits :: Pool Connection -> Property+prop_insertJSON_standalone_commits pool = property do+ rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)+ evalIO do+ Pool.withResource pool clearRoots+ Pool.withResource pool \conn -> void $ insJSON_ "root" conn rootsIn+ Pool.withResource pool \conn -> do+ n <- countRoots conn+ when (n /= fromIntegral (length rootsIn)) $+ fail $ "expected " <> show (length rootsIn) <> " rows, got " <> show n++prop_insertJSON_respects_outer_transaction :: Pool Connection -> Property+prop_insertJSON_respects_outer_transaction pool = property do+ rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)+ evalIO do+ Pool.withResource pool clearRoots+ Pool.withResource pool \conn -> do+ execute_ conn "begin"+ void $ insJSON_ "root" conn rootsIn+ n <- countRoots conn+ when (n /= fromIntegral (length rootsIn)) $+ fail $ "expected " <> show (length rootsIn) <> " rows in session, got "+ <> show n+ Pool.withResource pool \conn2 -> do+ n2 <- countRoots conn2+ when (n2 /= 0) $+ fail $ "uncommitted rows visible in other session: " <> show n2+ rollback conn+ Pool.withResource pool \conn -> do+ n <- countRoots conn+ when (n /= 0) $+ fail $ "rows remained after rollback: " <> show n++prop_insertJSON_begin_without_prior_writes :: Pool Connection -> Property+prop_insertJSON_begin_without_prior_writes pool = property do+ rootsIn <- forAll (L.nubBy eqRoot <$> genData' RootRec 1 5)+ evalIO do+ Pool.withResource pool clearRoots+ Pool.withResource pool \conn -> do+ execute_ conn "begin"+ void $ insJSON_ "root" conn rootsIn+ rollback conn+ Pool.withResource pool \conn -> do+ n <- countRoots conn+ when (n /= 0) $+ fail $ "rows remained after rollback from empty begin: " <> show n++prop_insertJSON_rolls_back_on_failure :: Pool Connection -> Property+prop_insertJSON_rolls_back_on_failure pool = property do+ evalIO do+ Pool.withResource pool clearRoots+ let+ row :: RootRec+ row = "code" =: "dup_tx" :. "grp" =: (1 :: Int32)+ :. "name" =: "x" :. "someEmpty" =: ()+ Pool.withResource pool \conn -> do+ res <- try @SomeException $ insJSON_ "root" conn [row, row]+ case res of+ Left _ -> pure ()+ Right _ -> fail "expected unique violation"+ n <- countRootsCode conn "dup_tx"+ when (n /= 0) $+ fail $ "partial insert remained after failure: " <> show n+ Pool.withResource pool \conn -> do+ n <- countRootsCode conn "dup_tx"+ when (n /= 0) $+ fail $ "failed insert visible in other session: " <> show n
+ test-pgs/Tests/UpsertUniqueKey.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE BlockArguments #-}+module Tests.UpsertUniqueKey where++import Control.Monad (void, when)+import Data.Int (Int32)+import Data.Maybe (Maybe(..))+import Data.List qualified as L+import Data.Pool as Pool+import Data.Text (Text)+import Database.PostgreSQL.Simple (Connection)+import Hedgehog (Property, discard, evalIO, forAll, property, withTests)+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import PgSchema.DML+import Utils++type RootRec =+ "code" := Text :. "grp" := Int32 :. "name" := Text :. "someEmpty" := ()++type RootSel = "code" := Text :. "grp" := Int32 :. "name" := Text++-- | Upsert by composite unique @(code, grp)@ when PK @id@ is omitted; second call+-- updates the same row.+prop_upsert_json_by_composite_unique :: Pool Connection -> Property+prop_upsert_json_by_composite_unique pool = withTests 5 $ property do+ code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)+ name1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ name2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ when (name1 == name2) discard+ let grp = 4242 :: Int32+ evalIO $ Pool.withResource pool \conn -> do+ void $ delByCond "root" conn ("code" =? code)+ let+ row1 :: RootRec+ row1 = "code" =: code :. "grp" =: grp :. "name" =: name1 :. "someEmpty" =: ()+ row2 :: RootRec+ row2 = "code" =: code :. "grp" =: grp :. "name" =: name2 :. "someEmpty" =: ()+ void $ upsJSON_ "root" conn [row1]+ void $ upsJSON_ "root" conn [row2]+ (rows :: [RootSel], _) <- selSch "root" conn $+ qRoot $ qWhere $ "code" =? code &&& "grp" =? grp+ case rows of+ [_ :. _ :. n] ->+ when (unPgTag n /= name2) $+ fail $ "expected updated name " <> show name2 <> ", got " <> show (unPgTag n)+ _ -> fail $ "expected 1 row, got " <> show (L.length rows)++-- | Upsert by nullable unique @(code, suffix)@ when table has no NOT NULL UK.+prop_upsert_json_by_nullable_unique :: Pool Connection -> Property+prop_upsert_json_by_nullable_unique pool = withTests 5 $ property do+ code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)+ suffix <- forAll (Gen.text (Range.linear 1 12) Gen.alphaNum)+ name1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ name2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ when (name1 == name2) discard+ let sfx = Just suffix+ evalIO $ Pool.withResource pool \conn -> do+ void $ delByCond "nullable_uq_row" conn ("code" =? code)+ let+ row1 = "code" =: code :. "suffix" =: sfx :. "name" =: name1+ :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()+ row2 = "code" =: code :. "suffix" =: sfx :. "name" =: name2+ :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()+ void $ upsJSON_ "nullable_uq_row" conn [row1]+ void $ upsJSON_ "nullable_uq_row" conn [row2]+ (rows :: ["name" := Text], _) <- selSch "nullable_uq_row" conn $+ qRoot $ qWhere $ "code" =? code &&& "suffix" =?? sfx+ case rows of+ [n] -> when (unPgTag n /= name2) $ fail+ $ "expected updated name " <> show name2 <> ", got " <> show (unPgTag n)+ _ -> fail $ "expected 1 row, got " <> show (L.length rows)++-- | UPDATE branch: key only + optional field, mandatory @name@ omitted.+prop_upsert_json_update_by_nullable_unique :: Pool Connection -> Property+prop_upsert_json_update_by_nullable_unique pool = withTests 5 $ property do+ code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)+ suffix <- forAll (Gen.text (Range.linear 1 12) Gen.alphaNum)+ note1 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ note2 <- forAll (Gen.text (Range.linear 1 24) Gen.alphaNum)+ when (note1 == note2) discard+ let+ sfx = Just suffix+ fullName = "seed-name"+ evalIO $ Pool.withResource pool \conn -> do+ void $ delByCond "nullable_uq_row" conn ("code" =? code)+ let+ seed =+ "code" =: code :. "suffix" =: sfx :. "name" =: fullName+ :. "note" =: Just note1 :. "someEmpty" =: ()+ patch =+ "code" =: code :. "suffix" =: sfx :. "note" =: Just note2+ :. "someEmpty" =: ()+ void $ upsJSON_ "nullable_uq_row" conn [seed]+ void $ upsJSON_ "nullable_uq_row" conn [patch]+ (rows :: ["name" := Text :. "note" := Maybe Text], _) <-+ selSch "nullable_uq_row" conn+ $ qRoot $ qWhere $ "code" =? code &&& "suffix" =?? sfx+ case rows of+ [nm :. note] -> do+ when (unPgTag note /= Just note2) $+ fail $ "expected note " <> show note2 <> ", got " <> show (unPgTag note)+ when (unPgTag nm /= fullName) $+ fail $ "name should be unchanged, got " <> show (unPgTag nm)+ _ -> fail $ "expected 1 row, got " <> show (L.length rows)++-- | NULL in nullable key column: classic UNIQUE allows duplicate keys.+prop_upsert_json_nullable_key_null_inserts_twice :: Pool Connection -> Property+prop_upsert_json_nullable_key_null_inserts_twice pool = withTests 3 $ property do+ code <- forAll (Gen.text (Range.linear 3 24) Gen.alphaNum)+ name1 <- forAll (Gen.text (Range.linear 1 16) Gen.alphaNum)+ name2 <- forAll (Gen.text (Range.linear 1 16) Gen.alphaNum)+ when (name1 == name2) discard+ evalIO $ Pool.withResource pool \conn -> do+ void $ delByCond "nullable_uq_row" conn ("code" =? code)+ let+ row nm =+ "code" =: code :. "suffix" =: (Nothing :: Maybe Text) :. "name" =: nm+ :. "note" =: (Nothing :: Maybe Text) :. "someEmpty" =: ()+ void $ upsJSON_ "nullable_uq_row" conn [row name1]+ void $ upsJSON_ "nullable_uq_row" conn [row name2]+ (rows :: ["code" := Text], _) <- selSch "nullable_uq_row" conn $+ qRoot $ qWhere $ "code" =? code &&& pnull "suffix"+ when (L.length rows /= 2) $+ fail $ "expected 2 rows with null suffix, got " <> show (L.length rows)