packages feed

beam-postgres 0.6.3.0 → 0.6.4.0

raw patch · 6 files changed

+497/−54 lines, 6 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Database.Beam.Postgres.Full: cteInsertCommand :: forall (table :: (Type -> Type) -> Type) (db :: (Type -> Type) -> Type). SqlInsert Postgres table -> PgWith db 'PgCteTopLevelOnly ()
+ Database.Beam.Postgres.Full: cteInsertCommandReturning :: forall table a (db :: (Type -> Type) -> Type). (Beamable table, Projectible Postgres a, ThreadRewritable PostgresInaccessible a, Projectible Postgres (WithRewrittenThread PostgresInaccessible QAnyScope a), ThreadRewritable QAnyScope (WithRewrittenThread PostgresInaccessible QAnyScope a)) => SqlInsert Postgres table -> (table (QExpr Postgres PostgresInaccessible) -> a) -> PgWith db 'PgCteTopLevelOnly (Maybe (ReusableQ Postgres db (WithRewrittenThread PostgresInaccessible QAnyScope a)))
+ Database.Beam.Postgres.Full: pgInsertOnly :: forall r (db :: (Type -> Type) -> Type) table s. ProjectibleWithPredicate AnyType () Text (QExprToField r) => DatabaseEntity Postgres db (TableEntity table) -> (table (QField s) -> QExprToField r) -> SqlInsertValues Postgres r -> PgInsertOnConflict table -> SqlInsert Postgres table

Files

ChangeLog.md view
@@ -1,3 +1,12 @@+# 0.6.4.0++## Added features++* Added `pgInsertOnly` for partial-column PostgreSQL inserts with `ON CONFLICT`+  support. The resulting `SqlInsert` composes with `returning`, `cteInsertCommand`,+  and `cteInsertCommandReturning`, allowing generated and defaulted columns to be+  exposed by a data-modifying CTE without duplicating insert builders.+ # 0.6.3.0  ## Added features
Database/Beam/Postgres/Full.hs view
@@ -38,7 +38,8 @@   , lateral_    -- * @INSERT@ and @INSERT RETURNING@-  , insert, insertReturning, cteInsert, cteInsertReturning+  , insert, pgInsertOnly, insertReturning+  , cteInsert, cteInsertReturning, cteInsertCommand, cteInsertCommandReturning   , insertDefaults   , runPgInsertReturningList @@ -69,6 +70,7 @@   ) where  import           Database.Beam hiding (insert, insertValues)+import qualified Database.Beam as Beam import           Database.Beam.Backend.SQL import           Database.Beam.Backend.SQL.BeamExtensions import qualified Database.Beam.Query.CTE as CTE@@ -397,19 +399,47 @@ -- | A @beam-postgres@-specific version of 'Database.Beam.Query.insert', which -- provides fuller support for the much richer Postgres @INSERT@ syntax. This -- allows you to specify @ON CONFLICT@ actions. For even more complete support,--- see 'insertReturning'.+-- see 'insertReturning'. For a partial target-column projection, see 'pgInsertOnly'. insert :: DatabaseEntity Postgres db (TableEntity table)-       -> SqlInsertValues Postgres (table (QExpr Postgres s)) -- TODO arbitrary projectibles+       -> SqlInsertValues Postgres (table (QExpr Postgres s))        -> PgInsertOnConflict table        -> SqlInsert Postgres table-insert tbl@(DatabaseEntity dt@(DatabaseTable {})) values onConflict_ =-  case insertReturning tbl values onConflict_-         (Nothing :: Maybe (table (QExpr Postgres PostgresInaccessible) -> QExpr Postgres PostgresInaccessible Int)) of-    PgInsertReturning a ->-      SqlInsert (dbTableSettings dt) (PgInsertSyntax a)-    PgInsertReturningEmpty ->-      SqlInsertNoRows+insert tbl@(DatabaseEntity (DatabaseTable {})) values =+  pgInsertOnly tbl id values +-- | Build a PostgreSQL insert over a caller-selected subset of table fields,+-- with support for 'PgInsertOnConflict'. Beam's backend-independent+-- 'Beam.insertOnly' owns the typed field projection and base @INSERT@+-- rendering; this function adds PostgreSQL's @ON CONFLICT@ extension.+--+-- Beam also provides backend-independent conflict handling through+-- 'BeamHasInsertOnConflict'. Its 'insertOnConflict' method accepts full-table+-- values; 'pgInsertOnly' adds partial-target selection with conflict handling.+-- This matters for @INSERT ... SELECT@: 'default_' can appear in an @INSERT@+-- @VALUES@ list, but cannot stand in for an omitted column in a @SELECT@.+--+-- @since 0.6.4.0+pgInsertOnly+  :: ProjectibleWithPredicate AnyType () Text (QExprToField r)+  => DatabaseEntity Postgres db (TableEntity table)+  -> (table (QField s) -> QExprToField r)+  -> SqlInsertValues Postgres r+  -> PgInsertOnConflict table+  -> SqlInsert Postgres table+pgInsertOnly tbl@(DatabaseEntity dt@(DatabaseTable {})) mkProjection values+             (PgInsertOnConflict mkOnConflict) =+  case Beam.insertOnly tbl mkProjection values of+    SqlInsertNoRows -> SqlInsertNoRows+    SqlInsert settings (PgInsertSyntax syntax) ->+      SqlInsert settings . PgInsertSyntax $+        syntax <> emit " " <> fromPgInsertOnConflict (mkOnConflict tblFields)+  where+    tblFields =+      changeBeamRep+        (\(Columnar' f) ->+          Columnar' (QField True (dbTableCurrentName dt) (_fieldName f)))+        (dbTableSettings dt)+ -- | The most general kind of @INSERT@ that postgres can perform data PgInsertReturning a   = PgInsertReturning PgSyntax@@ -429,25 +459,14 @@                 -> Maybe (table (QExpr Postgres PostgresInaccessible) -> a)                 -> PgInsertReturning (QExprToIdentity a) -insertReturning _ SqlInsertValuesEmpty _ _ = PgInsertReturningEmpty-insertReturning (DatabaseEntity tbl@(DatabaseTable {}))-                (SqlInsertValues (PgInsertValuesSyntax insertValues_))-                (PgInsertOnConflict mkOnConflict)+insertReturning table@(DatabaseEntity (DatabaseTable {})) values onConflict_                 mMkProjection =-  PgInsertReturning $-  emit "INSERT INTO " <> fromPgTableName (tableName (dbTableSchema tbl) (dbTableCurrentName tbl)) <>-  emit "(" <> pgSepBy (emit ", ") (allBeamValues (\(Columnar' f) -> pgQuotedIdentifier (_fieldName f)) tblSettings) <> emit ") " <>-  insertValues_ <> emit " " <> fromPgInsertOnConflict (mkOnConflict tblFields) <>-  (case mMkProjection of-     Nothing -> mempty-     Just mkProjection ->-         emit " RETURNING " <>-         pgSepBy (emit ", ") (map fromPgExpression (project (Proxy @Postgres) (mkProjection tblQ) "t")))-   where-     tblQ = changeBeamRep (\(Columnar' f) -> Columnar' (QExpr (\_ -> fieldE (unqualifiedField (_fieldName f))))) tblSettings-     tblFields = changeBeamRep (\(Columnar' f) -> Columnar' (QField True (dbTableCurrentName tbl) (_fieldName f))) tblSettings--     tblSettings = dbTableSettings tbl+  case insert table values onConflict_ of+    SqlInsertNoRows -> PgInsertReturningEmpty+    statement@(SqlInsert _ (PgInsertSyntax syntax)) ->+      case mMkProjection of+        Nothing -> PgInsertReturning syntax+        Just mkProjection -> returning statement mkProjection  -- | Introduce a PostgreSQL @INSERT@ statement as a side-effect-only CTE. --@@ -477,10 +496,28 @@   -> PgInsertOnConflict table   -> PgWith db 'PgCteTopLevelOnly () cteInsert table values onConflict_ =-  case insert table values onConflict_ of-    SqlInsertNoRows -> pure ()-    SqlInsert _ (PgInsertSyntax syntax) -> pgDataModifyingCte_ syntax+  cteInsertCommand (insert table values onConflict_) +-- | Introduce an already-built PostgreSQL @INSERT@ statement as a+-- side-effect-only CTE. This is the command-level counterpart to 'cteInsert':+-- it composes with any insert builder which produces 'SqlInsert', including+-- 'pgInsertOnly'.+--+-- 'SqlInsert' does not carry the target table's database type, so that type is+-- not tied to the enclosing 'PgWith' block.+--+-- Empty inserts register no CTE. The result remains conservatively indexed as+-- 'PgCteTopLevelOnly', because the placement index cannot vary with the+-- supplied command.+--+-- @since 0.6.4.0+cteInsertCommand+  :: SqlInsert Postgres table+  -> PgWith db 'PgCteTopLevelOnly ()+cteInsertCommand SqlInsertNoRows = pure ()+cteInsertCommand (SqlInsert _ (PgInsertSyntax syntax)) =+  pgDataModifyingCte_ syntax+ -- | Introduce a PostgreSQL @INSERT ... RETURNING@ statement as a -- data-modifying common table expression. The returned value can be used in a -- subsequent query with 'reuse'.@@ -538,8 +575,37 @@   -> PgInsertOnConflict table   -> (table (QExpr Postgres PostgresInaccessible) -> a)   -> PgWith db 'PgCteTopLevelOnly (Maybe (ReusableQ Postgres db (WithRewrittenThread PostgresInaccessible CTE.QAnyScope a)))-cteInsertReturning table values onConflict_ mkProjection =-  case insertReturning table values onConflict_ (Just mkProjection) of+cteInsertReturning table@(DatabaseEntity (DatabaseTable {}))+                   values onConflict_ mkProjection =+  cteInsertCommandReturning+    (insert table values onConflict_)+    mkProjection++-- | Attach a @RETURNING@ projection to an already-built PostgreSQL @INSERT@+-- and introduce it as a reusable data-modifying CTE. Keeping insert+-- construction separate lets this function compose with both full-row+-- 'insert' and partial-row 'pgInsertOnly' commands.+--+-- 'SqlInsert' does not carry the target table's database type, so that type is+-- not tied to the enclosing 'PgWith' block.+--+-- Returns 'Nothing' for 'SqlInsertNoRows'. A real command which affects no+-- rows, such as @ON CONFLICT DO NOTHING@, still returns a reusable relation;+-- that relation simply produces no rows when the statement executes.+--+-- @since 0.6.4.0+cteInsertCommandReturning+  :: ( Beamable table+     , Projectible Postgres a+     , ThreadRewritable PostgresInaccessible a+     , Projectible Postgres (WithRewrittenThread PostgresInaccessible CTE.QAnyScope a)+     , ThreadRewritable CTE.QAnyScope (WithRewrittenThread PostgresInaccessible CTE.QAnyScope a)+     )+  => SqlInsert Postgres table+  -> (table (QExpr Postgres PostgresInaccessible) -> a)+  -> PgWith db 'PgCteTopLevelOnly (Maybe (ReusableQ Postgres db (WithRewrittenThread PostgresInaccessible CTE.QAnyScope a)))+cteInsertCommandReturning statement mkProjection =+  case returning statement mkProjection of     PgInsertReturningEmpty -> pure Nothing     PgInsertReturning syntax ->       Just <$> pgDataModifyingCte syntax
beam-postgres.cabal view
@@ -1,5 +1,5 @@ name:                 beam-postgres-version:              0.6.3.0+version:              0.6.4.0 synopsis:             Connection layer between beam and postgres description:          Beam driver for <https://www.postgresql.org/ PostgreSQL>, an advanced open-source RDBMS homepage:             https://haskell-beam.github.io/beam/user-guide/backends/beam-postgres@@ -60,7 +60,7 @@                       vector               >=0.11 && <0.14,                       network-uri          >=2.6  && <2.7,                       unordered-containers >= 0.2 && <0.3,-                      tagged               >=0.8  && <0.9,+                      tagged               >=0.8  && <0.10,                       transformers-base    >=0.4  && <0.5    default-language:   Haskell2010
test/Database/Beam/Postgres/Test/CTE.hs view
@@ -70,6 +70,29 @@ cteDb :: DatabaseSettings Postgres CteDb cteDb = defaultDbSettings +partialCteRowValues+  :: Text+  -> Int32+  -> SqlInsertValues Postgres+       (QExpr Postgres s Text, QExpr Postgres s Int32)+partialCteRowValues value key =+  insertData [(val_ value, val_ key)]++partialCteValueValues+  :: Text+  -> SqlInsertValues Postgres (QExpr Postgres s Text)+partialCteValueValues value = insertData [val_ value]++partialCteIdValues+  :: Int32+  -> SqlInsertValues Postgres (QExpr Postgres s Int32)+partialCteIdValues key = insertData [val_ key]++emptyPartialCteRowValues+  :: SqlInsertValues Postgres+       (QExpr Postgres s Text, QExpr Postgres s Int32)+emptyPartialCteRowValues = SqlInsertValuesEmpty+ unitTests :: TestTree unitTests = testGroup "Common table expression tests"   [ renderingTests@@ -78,7 +101,8 @@  integrationTests :: IO ByteString -> TestTree integrationTests getConn = testGroup "Common table expression integration tests"-  [ testMixedCteBodies getConn+  [ testPartialInsertCommandCtes getConn+  , testMixedCteBodies getConn   , testSideEffectOnlyCtes getConn   , testMaterializationExecution getConn   , testLiftedWithExecution getConn@@ -95,7 +119,14 @@  renderingTests :: TestTree renderingTests = testGroup "Common table expression rendering tests"-  [ testMixedCteRendering+  [ testPgInsertOnlyRendering+  , testPgInsertOnlyFromAndEmptyRendering+  , testCteInsertCommandRendering+  , testCteInsertCommandReturningRendering+  , testCteInsertCommandNoOps+  , testCteInsertCommandReturningZeroProjection+  , testLegacyInsertCteCompatibility+  , testMixedCteRendering   , testMaterializationRendering   , testNestedMaterializedCteRendering   , testSideEffectOnlyRendering@@ -113,6 +144,147 @@   , testDegreeZeroDataModifyingRendering   ] +testPgInsertOnlyRendering :: TestTree+testPgInsertOnlyRendering = testCase "renders partial PostgreSQL inserts with ON CONFLICT" $ do+  sql <- requireRenderedStatement . renderInsert $+    Pg.pgInsertOnly+      (dbCteRows cteDb)+      (\row -> (cteValue row, cteId row))+      (partialCteRowValues "partial" 7)+      (Pg.onConflict+        (Pg.conflictingFields cteId)+        Pg.onConflictDoNothing)+  assertBool ("renders only the selected columns in projection order:\n" ++ sql)+    ("(\"value\", \"id\") VALUES" `isInfixOf` sql)+  assertBool "renders the PostgreSQL conflict action"+    ("ON CONFLICT (\"id\") DO NOTHING" `isInfixOf` sql)++testPgInsertOnlyFromAndEmptyRendering :: TestTree+testPgInsertOnlyFromAndEmptyRendering = testCase "renders partial INSERT FROM and preserves empty inserts" $ do+  let fromStatement = Pg.pgInsertOnly+        (dbCteRows cteDb)+        (\row -> (cteValue row, cteId row))+        (insertFrom $ do+          row <- all_ (dbCteRows cteDb)+          pure (cteValue row, cteId row))+        Pg.onConflictDefault+      emptyStatement = Pg.pgInsertOnly+        (dbCteRows cteDb)+        (\row -> (cteValue row, cteId row))+        emptyPartialCteRowValues+        Pg.onConflictDefault+  fromSql <- requireRenderedStatement (renderInsert fromStatement)+  assertBool ("renders the selected target columns before INSERT FROM:\n" ++ fromSql)+    ("(\"value\", \"id\") SELECT" `isInfixOf` fromSql)+  assertEqual "empty projected values still produce no statement"+    Nothing+    (renderInsert emptyStatement)++testCteInsertCommandRendering :: TestTree+testCteInsertCommandRendering = testCase "lifts a partial insert command into a side-effect-only CTE" $ do+  let sql = renderSelect $ Pg.pgSelectWithTopLevel $ do+        Pg.cteInsertCommand $ Pg.pgInsertOnly+          (dbCteRows cteDb)+          (\row -> (cteValue row, cteId row))+          (partialCteRowValues "side-effect" 8)+          Pg.onConflictDefault+        pure $ do+          guard_ (val_ False)+          pure (as_ @Int32 (val_ 0))+  assertBool "renders the command as a top-level CTE"+    ("WITH " `isPrefixOf` sql)+  assertBool ("retains the partial target projection:\n" ++ sql)+    ("INSERT INTO \"cte_rows\"(\"value\", \"id\") VALUES" `isInfixOf` sql)+  assertBool "does not add RETURNING to a side-effect-only CTE"+    (not (" RETURNING " `isInfixOf` sql))++testCteInsertCommandReturningRendering :: TestTree+testCteInsertCommandReturningRendering = testCase "adds RETURNING when lifting a partial insert command" $ do+  let sql = renderSelect $ Pg.pgSelectWithTopLevel $ do+        inserted <- Pg.cteInsertCommandReturning+          (Pg.pgInsertOnly+            (dbCteRows cteDb)+            (\row -> (cteValue row, cteId row))+            (partialCteRowValues "returning" 9)+            (Pg.onConflict+              (Pg.conflictingFields cteId)+              Pg.onConflictDoNothing))+          id+        pure $ case inserted of+          Nothing -> do+            guard_ (val_ False)+            pure (CteRow (val_ 0) (val_ ""))+          Just rows -> reuse rows+  assertBool "renders the command as a top-level CTE"+    ("WITH " `isPrefixOf` sql)+  assertBool ("retains selected columns and ON CONFLICT:\n" ++ sql)+    ("INSERT INTO \"cte_rows\"(\"value\", \"id\") VALUES" `isInfixOf` sql &&+     "ON CONFLICT (\"id\") DO NOTHING" `isInfixOf` sql)+  assertBool "adds a full-row RETURNING projection"+    (" RETURNING \"id\", \"value\"" `isInfixOf` sql)++testCteInsertCommandNoOps :: TestTree+testCteInsertCommandNoOps = testCase "command-level CTE adapters preserve empty inserts" $ do+  let emptyCommand = Pg.pgInsertOnly+        (dbCteRows cteDb)+        (\row -> (cteValue row, cteId row))+        emptyPartialCteRowValues+        Pg.onConflictDefault+      sql = renderSelect $ Pg.pgSelectWithTopLevel $ do+        Pg.cteInsertCommand emptyCommand+        inserted <- Pg.cteInsertCommandReturning emptyCommand id+        pure $ case inserted of+          Nothing -> pure (as_ @Int32 (val_ 1))+          Just rows -> cteId <$> reuse rows+  assertBool ("does not render an empty WITH block:\n" ++ sql)+    (not ("WITH " `isPrefixOf` sql))+  assertBool "renders the fallback query after receiving Nothing"+    ("SELECT 1" `isPrefixOf` sql)++testCteInsertCommandReturningZeroProjection :: TestTree+testCteInsertCommandReturningZeroProjection = testCase "command-level returning CTEs preserve zero-field rows" $ do+  let sql = renderSelect $ Pg.pgSelectWithTopLevel $ do+        inserted <- Pg.cteInsertCommandReturning+          (Pg.pgInsertOnly+            (dbCteRows cteDb)+            (\row -> (cteValue row, cteId row))+            (partialCteRowValues "zero" 10)+            Pg.onConflictDefault)+          (const (EmptyCte :: EmptyCteT (QExpr Postgres PostgresInaccessible)))+        pure $ case inserted of+          Nothing -> pure EmptyCte+          Just rows -> reuse rows+  assertBool "adds the private sentinel to an empty RETURNING projection"+    ("RETURNING NULL::boolean" `isInfixOf` sql)+  assertBool ("does not expose the private sentinel in the terminal projection:\n" ++ sql)+    ("SELECT  FROM" `isInfixOf` sql || "SELECT FROM" `isInfixOf` sql)++testLegacyInsertCteCompatibility :: TestTree+testLegacyInsertCteCompatibility = testCase "legacy insert CTE wrappers match command composition" $ do+  let finish inserted = case inserted of+        Nothing -> do+          guard_ (val_ False)+          pure (CteRow (val_ 0) (val_ ""))+        Just rows -> reuse rows+      legacy = Pg.pgSelectWithTopLevel $ do+        inserted <- Pg.cteInsertReturning+          (dbCteRows cteDb)+          (insertValues [CteRow 11 "legacy"])+          Pg.onConflictDefault+          id+        pure (finish inserted)+      composed = Pg.pgSelectWithTopLevel $ do+        inserted <- Pg.cteInsertCommandReturning+          (Pg.insert+            (dbCteRows cteDb)+            (insertValues [CteRow 11 "legacy"])+            Pg.onConflictDefault)+          id+        pure (finish inserted)+  assertEqual "legacy and command-composed SQL"+    (renderSelect legacy)+    (renderSelect composed)+ -- These tests force expressions compiled with deferred type errors in the -- isolated negative-fixture module. Checking fragments of GHC's error ensures -- an unrelated deferred error cannot make a test pass accidentally.@@ -134,6 +306,10 @@       assertPlacementTypeError Negative.invalidNestedIdentityUpdate   , testCase "rejects a side-effect-only DELETE inside pgSelectWithNested" $       assertPlacementTypeError Negative.invalidNestedSideEffectDelete+  , testCase "rejects a command-level INSERT inside pgSelectWithNested" $+      assertPlacementTypeError Negative.invalidNestedCommandInsert+  , testCase "rejects a command-level returning INSERT inside pgSelectWithNested" $+      assertPlacementTypeError Negative.invalidNestedCommandInsertReturning   , testCase "placement cannot be bypassed with coerce" $       assertPlacementTypeError Negative.invalidCoercedPlacement   , testCase "rejects a recursively self-referencing INSERT CTE" $@@ -144,6 +320,10 @@       assertDeferredTypeErrorContaining         ["ReusableQ"]         Negative.invalidReuseSideEffect+  , testCase "rejects values which do not match the selected insert columns" $+      assertDeferredInsertTypeErrorContaining+        ["Couldn't match", "QExprToField"]+        Negative.invalidMismatchedPgInsertOnly   ]  assertPlacementTypeError :: SqlSelect Postgres a -> Assertion@@ -168,6 +348,24 @@         ("mentions " ++ fragment ++ "\nDeferred error was:\n" ++ message)         (fragment `isInfixOf` message) +assertDeferredInsertTypeErrorContaining+  :: [String]+  -> SqlInsert Postgres table+  -> Assertion+assertDeferredInsertTypeErrorContaining expectedFragments statement = do+  result <- try (evaluate (maybe 0 length (renderInsert statement)))+  case result of+    Left (err :: TypeError) ->+      let message = show err+      in mapM_ (assertFragment message) expectedFragments+    Right _ ->+      assertFailure "expected the insert to contain a deferred type error"+  where+    assertFragment message fragment =+      assertBool+        ("mentions " ++ fragment ++ "\nDeferred error was:\n" ++ message)+        (fragment `isInfixOf` message)+ -- A single top-level WITH block may freely mix SELECT and data-modifying CTE -- bodies. Besides checking the individual keywords, this guards against -- accidentally nesting a second WITH while combining the syntax fragments.@@ -363,6 +561,129 @@       assertBool (command ++ " does not expose the sentinel")         ("SELECT  FROM \"cte0\"" `isInfixOf` sql ||          "SELECT FROM \"cte0\"" `isInfixOf` sql)++-- Exercise the command-composition path against PostgreSQL itself. Generated+-- and defaulted columns must remain visible to RETURNING even though they are+-- absent from the insert projection, and both conflict actions must retain+-- their normal PostgreSQL semantics.+testPartialInsertCommandCtes :: IO ByteString -> TestTree+testPartialInsertCommandCtes getConn = testCase "partial insert commands preserve defaults and conflicts in CTEs" $+  withTestPostgres "partial_insert_command_ctes" getConn $ \conn -> do+    execute_ conn+      "CREATE TABLE cte_rows (id SERIAL PRIMARY KEY, value TEXT UNIQUE NOT NULL DEFAULT 'db-default')"++    generated <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "generated" Pg.onConflictDefault+    assertEqual "returns an omitted generated identity"+      [CteRow 1 "generated"]+      generated++    execute_ conn "TRUNCATE TABLE cte_rows RESTART IDENTITY"+    defaulted <- runBeamPostgres conn $ runSelectReturningList $+      partialIdReturning 40 Pg.onConflictDefault+    assertEqual "returns an omitted database-defaulted value"+      [CteRow 40 "db-default"]+      defaulted++    execute_ conn "TRUNCATE TABLE cte_rows RESTART IDENTITY"+    _ <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "conflict" Pg.onConflictDefault+    ignored <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "conflict" $+        Pg.onConflict+          (Pg.conflictingFields cteValue)+          Pg.onConflictDoNothing+    assertEqual "DO NOTHING exposes a real but empty reusable relation"+      []+      ignored++    updated <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "conflict" $+        Pg.onConflict+          (Pg.conflictingFields cteValue)+          (Pg.onConflictUpdateSet $ \target excluded ->+            cteId target <-. cteId excluded)+    case updated of+      [row] -> do+        assertEqual "DO UPDATE returns the conflicting value"+          "conflict"+          (cteValue row)+        assertBool "DO UPDATE can use the generated excluded identity"+          (cteId row > 1)+      rows -> assertFailure ("expected one updated row, got " ++ show rows)++    storedAfterUpdate <- runBeamPostgres conn $ runSelectReturningList $ select $+      all_ (dbCteRows cteDb)+    assertEqual "the reusable row agrees with durable table state"+      updated+      storedAfterUpdate++    execute_ conn "TRUNCATE TABLE cte_rows RESTART IDENTITY"+    _ <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "parameter-order" Pg.onConflictDefault+    parameterizedUpdate <- runBeamPostgres conn $ runSelectReturningList $+      partialValueReturning "parameter-order" $+        Pg.onConflict+          (Pg.conflictingFields cteValue)+          (Pg.onConflictUpdateSet $ \target _ ->+            cteId target <-. val_ (77 :: Int32))+    assertEqual "parameters remain ordered from insert values into the conflict action"+      [CteRow 77 "parameter-order"]+      parameterizedUpdate++    execute_ conn "TRUNCATE TABLE cte_rows RESTART IDENTITY"+    marker <- runBeamPostgres conn $ runSelectReturningOne $+      Pg.pgSelectWithTopLevel $ do+        Pg.cteInsertCommand $ Pg.pgInsertOnly+          (dbCteRows cteDb)+          cteValue+          (partialCteValueValues "side-effect")+          Pg.onConflictDefault+        pure (pure (as_ @Int32 (val_ 1)))+    assertEqual "side-effect-only command CTE executes"+      (Just 1)+      marker+    storedSideEffect <- runBeamPostgres conn $ runSelectReturningList $ select $+      all_ (dbCteRows cteDb)+    assertEqual "side-effect-only partial insert uses the generated identity"+      [CteRow 1 "side-effect"]+      storedSideEffect++partialValueReturning+  :: Text+  -> Pg.PgInsertOnConflict CteRowT+  -> SqlSelect Postgres (CteRowT Identity)+partialValueReturning value conflict = Pg.pgSelectWithTopLevel $ do+  inserted <- Pg.cteInsertCommandReturning+    (Pg.pgInsertOnly+      (dbCteRows cteDb)+      cteValue+      (partialCteValueValues value)+      conflict)+    id+  pure $ case inserted of+    Nothing -> do+      guard_ (val_ False)+      pure (CteRow (val_ 0) (val_ ""))+    Just rows -> reuse rows++partialIdReturning+  :: Int32+  -> Pg.PgInsertOnConflict CteRowT+  -> SqlSelect Postgres (CteRowT Identity)+partialIdReturning key conflict = Pg.pgSelectWithTopLevel $ do+  inserted <- Pg.cteInsertCommandReturning+    (Pg.pgInsertOnly+      (dbCteRows cteDb)+      cteId+      (partialCteIdValues key)+      conflict)+    id+  pure $ case inserted of+    Nothing -> do+      guard_ (val_ False)+      pure (CteRow (val_ 0) (val_ ""))+    Just rows -> reuse rows  -- Rendering alone cannot verify PostgreSQL's execution and snapshot semantics. -- This integration case checks both the RETURNING rows and the final table
test/Database/Beam/Postgres/Test/CTENegative.hs view
@@ -15,9 +15,12 @@   , invalidNestedEmptyInsert   , invalidNestedIdentityUpdate   , invalidNestedSideEffectDelete+  , invalidNestedCommandInsert+  , invalidNestedCommandInsertReturning   , invalidCoercedPlacement   , invalidRecursiveInsert   , invalidReuseSideEffect+  , invalidMismatchedPgInsertOnly   ) where  import qualified Data.Coerce as Coerce@@ -128,6 +131,28 @@     (\row -> negativeCteId row ==. val_ 1)   pure $ all_ (negativeCteRows negativeCteDb) +invalidNestedCommandInsert :: SqlSelect Postgres (NegativeCteRowT Identity)+invalidNestedCommandInsert = select $ Pg.pgSelectWithNested $ do+  Pg.cteInsertCommand $ Pg.pgInsertOnly+    (negativeCteRows negativeCteDb)+    id+    (insertValues [NegativeCteRow 2 "inserted"])+    Pg.onConflictDefault+  pure $ all_ (negativeCteRows negativeCteDb)++invalidNestedCommandInsertReturning :: SqlSelect Postgres (NegativeCteRowT Identity)+invalidNestedCommandInsertReturning = select $ Pg.pgSelectWithNested $ do+  inserted <- Pg.cteInsertCommandReturning+    (Pg.pgInsertOnly+      (negativeCteRows negativeCteDb)+      id+      (insertValues [NegativeCteRow 2 "inserted"])+      Pg.onConflictDefault)+    id+  case inserted of+    Nothing -> pure $ all_ (negativeCteRows negativeCteDb)+    Just inserted' -> pure (reuse inserted')+ -- PgWith has nominal roles and an abstract constructor, so Data.Coerce cannot -- be used to relabel a top-level-only block as nested-safe. invalidCoercedPlacement :: SqlSelect Postgres (NegativeCteRowT Identity)@@ -158,6 +183,17 @@              (NegativeCteRowT (QExpr Postgres CTE.QAnyScope))       impossible = deleted   pure (reuse impossible)++invalidMismatchedPgInsertOnly :: SqlInsert Postgres NegativeCteRowT+invalidMismatchedPgInsertOnly = Pg.pgInsertOnly+  (negativeCteRows negativeCteDb)+  (\row -> (negativeCteId row, negativeCteValue row))+  singleIdValue+  Pg.onConflictDefault++singleIdValue+  :: SqlInsertValues Postgres (QExpr Postgres s Int32)+singleIdValue = insertData [val_ (1 :: Int32)]  coercePlacement   :: Pg.PgWith NegativeCteDb 'Pg.PgCteTopLevelOnly a
test/Main.hs view
@@ -1,8 +1,10 @@ module Main where  import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as BS import Data.Text (unpack) import qualified Data.Text.Lazy as TL+import System.Environment (lookupEnv)  import Test.Tasty import qualified TestContainers.Tasty as TC@@ -20,23 +22,32 @@ import qualified Database.PostgreSQL.Simple as Postgres  main :: IO ()-main = defaultMain $ testGroup "beam-postgres tests"-  -- Rendering and compile-negative tests do not need Docker, so keep them-  -- outside the Testcontainers resource and available as fast unit tests.-  [ CTE.unitTests-  , TC.withContainers setupTempPostgresDB $ \getConnStr ->-      testGroup "PostgreSQL integration tests"-        [ Marshal.tests getConnStr-        , CTE.integrationTests getConnStr-        , Select.tests getConnStr-        , Select.PgNubBy.tests getConnStr-        , DataType.tests getConnStr-        , Migrate.tests getConnStr-        , TempTable.tests getConnStr-        , Windowing.tests getConnStr-        , Copy.tests getConnStr-        ]-  ]+main = do+  -- CI normally uses Testcontainers. An explicit connection string also makes+  -- the suite usable with an externally managed disposable PostgreSQL server.+  externalConnStr <- lookupEnv "BEAM_POSTGRES_TEST_CONNSTR"+  defaultMain $ testGroup "beam-postgres tests"+    -- Rendering and compile-negative tests do not need Docker, so keep them+    -- outside the Testcontainers resource and available as fast unit tests.+    [ CTE.unitTests+    , case externalConnStr of+        Just connStr -> integrationTests (pure (BS.pack connStr))+        Nothing -> TC.withContainers setupTempPostgresDB integrationTests+    ]++integrationTests :: IO ByteString -> TestTree+integrationTests getConnStr =+  testGroup "PostgreSQL integration tests"+    [ Marshal.tests getConnStr+    , CTE.integrationTests getConnStr+    , Select.tests getConnStr+    , Select.PgNubBy.tests getConnStr+    , DataType.tests getConnStr+    , Migrate.tests getConnStr+    , TempTable.tests getConnStr+    , Windowing.tests getConnStr+    , Copy.tests getConnStr+    ]   setupTempPostgresDB :: TC.MonadDocker m => m ByteString