packages feed

beam-duckdb 0.3.0.0 → 0.3.1.0

raw patch · 20 files changed

+4291/−546 lines, 20 filesdep +aesondep +hashabledep +temporarydep ~beam-coredep ~beam-migratedep ~bytestringPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: aeson, hashable, temporary

Dependency ranges changed: beam-core, beam-migrate, bytestring, uuid-types

API changes (from Hackage documentation)

- Database.Beam.DuckDB: instance Database.Beam.Backend.SQL.MonadBeam Database.Beam.DuckDB.Backend.DuckDB Database.Beam.DuckDB.DuckDBM
- Database.Beam.DuckDB: instance Database.Beam.Backend.SQL.Row.FromBackendRow Database.Beam.DuckDB.Backend.DuckDB a => Database.DuckDB.Simple.FromRow.FromRow (Database.Beam.DuckDB.BeamDuckDBRow a)
- Database.Beam.DuckDB: instance Database.DuckDB.Simple.ToRow.ToRow Database.Beam.DuckDB.BeamDuckDBParams
- Database.Beam.DuckDB: instance GHC.Internal.Base.Applicative Database.Beam.DuckDB.DuckDBM
- Database.Beam.DuckDB: instance GHC.Internal.Base.Functor Database.Beam.DuckDB.DuckDBM
- Database.Beam.DuckDB: instance GHC.Internal.Base.Monad Database.Beam.DuckDB.DuckDBM
- Database.Beam.DuckDB: instance GHC.Internal.Control.Monad.Fail.MonadFail Database.Beam.DuckDB.DuckDBM
- Database.Beam.DuckDB: instance GHC.Internal.Control.Monad.IO.Class.MonadIO Database.Beam.DuckDB.DuckDBM
+ Database.Beam.DuckDB: DuckDBCSVCopyFromOptions :: Maybe Bool -> Maybe Text -> Maybe Char -> Maybe Char -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Bool -> Maybe Bool -> Maybe Int -> Maybe Bool -> DuckDBCSVCopyFromOptions
+ Database.Beam.DuckDB: DuckDBCSVCopyToOptions :: Maybe Bool -> Maybe Text -> Maybe Char -> Maybe Char -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Bool -> DuckDBCSVCopyToOptions
+ Database.Beam.DuckDB: DuckDBJSONCopyFromOptions :: Maybe Bool -> Maybe JSONCompression -> DuckDBJSONCopyFromOptions
+ Database.Beam.DuckDB: DuckDBJSONCopyToOptions :: Maybe JSONCompression -> Maybe Bool -> DuckDBJSONCopyToOptions
+ Database.Beam.DuckDB: DuckDBParquetCopyFromOptions :: DuckDBParquetCopyFromOptions
+ Database.Beam.DuckDB: DuckDBParquetCopyToOptions :: Maybe ParquetCompression -> Maybe Bool -> DuckDBParquetCopyToOptions
+ Database.Beam.DuckDB: JSONGzip :: JSONCompression
+ Database.Beam.DuckDB: JSONUncompressed :: JSONCompression
+ Database.Beam.DuckDB: JSONZstd :: JSONCompression
+ Database.Beam.DuckDB: ParquetGzip :: ParquetCompression
+ Database.Beam.DuckDB: ParquetSnappy :: ParquetCompression
+ Database.Beam.DuckDB: ParquetUncompressed :: ParquetCompression
+ Database.Beam.DuckDB: ParquetZstd :: ParquetCompression
+ Database.Beam.DuckDB: [csvCopyFromAllVarchar] :: DuckDBCSVCopyFromOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyFromAutoDetect] :: DuckDBCSVCopyFromOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyFromDateformat] :: DuckDBCSVCopyFromOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyFromDelimiter] :: DuckDBCSVCopyFromOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyFromEscape] :: DuckDBCSVCopyFromOptions -> Maybe Char
+ Database.Beam.DuckDB: [csvCopyFromHeader] :: DuckDBCSVCopyFromOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyFromIgnoreErrors] :: DuckDBCSVCopyFromOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyFromNullStr] :: DuckDBCSVCopyFromOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyFromQuote] :: DuckDBCSVCopyFromOptions -> Maybe Char
+ Database.Beam.DuckDB: [csvCopyFromSkip] :: DuckDBCSVCopyFromOptions -> Maybe Int
+ Database.Beam.DuckDB: [csvCopyFromTimestampformat] :: DuckDBCSVCopyFromOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyToDateformat] :: DuckDBCSVCopyToOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyToDelimiter] :: DuckDBCSVCopyToOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyToEscape] :: DuckDBCSVCopyToOptions -> Maybe Char
+ Database.Beam.DuckDB: [csvCopyToHeader] :: DuckDBCSVCopyToOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyToNullStr] :: DuckDBCSVCopyToOptions -> Maybe Text
+ Database.Beam.DuckDB: [csvCopyToOverwriteOrIgnore] :: DuckDBCSVCopyToOptions -> Maybe Bool
+ Database.Beam.DuckDB: [csvCopyToQuote] :: DuckDBCSVCopyToOptions -> Maybe Char
+ Database.Beam.DuckDB: [csvCopyToTimestampformat] :: DuckDBCSVCopyToOptions -> Maybe Text
+ Database.Beam.DuckDB: [jsonCopyFromAutoDetect] :: DuckDBJSONCopyFromOptions -> Maybe Bool
+ Database.Beam.DuckDB: [jsonCopyFromCompression] :: DuckDBJSONCopyFromOptions -> Maybe JSONCompression
+ Database.Beam.DuckDB: [jsonCopyToCompression] :: DuckDBJSONCopyToOptions -> Maybe JSONCompression
+ Database.Beam.DuckDB: [jsonCopyToOverwriteOrIgnore] :: DuckDBJSONCopyToOptions -> Maybe Bool
+ Database.Beam.DuckDB: [parquetCopyToCompression] :: DuckDBParquetCopyToOptions -> Maybe ParquetCompression
+ Database.Beam.DuckDB: [parquetCopyToOverwriteOrIgnore] :: DuckDBParquetCopyToOptions -> Maybe Bool
+ Database.Beam.DuckDB: anyConflict :: forall (table :: (Type -> Type) -> Type). BeamHasInsertOnConflict be => SqlConflictTarget be table
+ Database.Beam.DuckDB: class BeamSqlBackend be => BeamHasInsertOnConflict be where {
+ Database.Beam.DuckDB: class MonadBeam be m => MonadBeamDeleteReturning be (m :: Type -> Type) | m -> be
+ Database.Beam.DuckDB: class MonadBeam be m => MonadBeamInsertReturning be (m :: Type -> Type) | m -> be
+ Database.Beam.DuckDB: class MonadBeam be m => MonadBeamUpdateReturning be (m :: Type -> Type) | m -> be
+ Database.Beam.DuckDB: class UnsupportedConflictingFieldsWhereOnDuckDB
+ Database.Beam.DuckDB: conflictingFields :: (BeamHasInsertOnConflict be, Projectible be proj) => (table (QExpr be QInternal) -> proj) -> SqlConflictTarget be table
+ Database.Beam.DuckDB: conflictingFieldsWhere :: forall table proj. (UnsupportedConflictingFieldsWhereOnDuckDB, Projectible DuckDB proj, Beamable table) => (table (QExpr DuckDB QInternal) -> proj) -> (forall s. () => table (QExpr DuckDB s) -> QExpr DuckDB s Bool) -> SqlConflictTarget DuckDB table
+ Database.Beam.DuckDB: copyFromCSV :: FilePath -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyFromCSVWith :: FilePath -> DuckDBCSVCopyFromOptions -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyFromJSON :: FilePath -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyFromJSONWith :: FilePath -> DuckDBJSONCopyFromOptions -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyFromParquet :: FilePath -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyFromParquetWith :: FilePath -> DuckDBParquetCopyFromOptions -> DuckDBCopyFromOptions
+ Database.Beam.DuckDB: copyToCSV :: FilePath -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: copyToCSVWith :: FilePath -> DuckDBCSVCopyToOptions -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: copyToJSON :: FilePath -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: copyToJSONWith :: FilePath -> DuckDBJSONCopyToOptions -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: copyToParquet :: FilePath -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: copyToParquetWith :: FilePath -> DuckDBParquetCopyToOptions -> DuckDBCopyToOptions
+ Database.Beam.DuckDB: data DuckDBCSVCopyFromOptions
+ Database.Beam.DuckDB: data DuckDBCSVCopyToOptions
+ Database.Beam.DuckDB: data DuckDBCopyFromOptions
+ Database.Beam.DuckDB: data DuckDBCopyToOptions
+ Database.Beam.DuckDB: data DuckDBJSONCopyFromOptions
+ Database.Beam.DuckDB: data DuckDBJSONCopyToOptions
+ Database.Beam.DuckDB: data DuckDBParquetCopyFromOptions
+ Database.Beam.DuckDB: data DuckDBParquetCopyToOptions
+ Database.Beam.DuckDB: data JSONCompression
+ Database.Beam.DuckDB: data ParquetCompression
+ Database.Beam.DuckDB: data SqlConflictAction be (table :: Type -> Type -> Type);
+ Database.Beam.DuckDB: data SqlConflictTarget be (table :: Type -> Type -> Type);
+ Database.Beam.DuckDB: data family SqlConflictTarget be (table :: Type -> Type -> Type)
+ Database.Beam.DuckDB: defaultDuckDBCSVCopyFromOptions :: DuckDBCSVCopyFromOptions
+ Database.Beam.DuckDB: defaultDuckDBCSVCopyToOptions :: DuckDBCSVCopyToOptions
+ Database.Beam.DuckDB: defaultDuckDBJSONCopyFromOptions :: DuckDBJSONCopyFromOptions
+ Database.Beam.DuckDB: defaultDuckDBJSONCopyToOptions :: DuckDBJSONCopyToOptions
+ Database.Beam.DuckDB: defaultDuckDBParquetCopyFromOptions :: DuckDBParquetCopyFromOptions
+ Database.Beam.DuckDB: defaultDuckDBParquetCopyToOptions :: DuckDBParquetCopyToOptions
+ Database.Beam.DuckDB: insertOnConflict :: forall table (db :: (Type -> Type) -> Type) s. (BeamHasInsertOnConflict be, Beamable table) => DatabaseEntity be db (TableEntity table) -> SqlInsertValues be (table (QExpr be s)) -> SqlConflictTarget be table -> SqlConflictAction be table -> SqlInsert be table
+ Database.Beam.DuckDB: onConflictDoNothing :: forall (table :: (Type -> Type) -> Type). BeamHasInsertOnConflict be => SqlConflictAction be table
+ Database.Beam.DuckDB: onConflictUpdateAll :: forall be (table :: (Type -> Type) -> Type). (BeamHasInsertOnConflict be, Beamable table) => SqlConflictAction be table
+ Database.Beam.DuckDB: onConflictUpdateInstead :: (BeamHasInsertOnConflict be, Beamable table, ProjectibleWithPredicate AnyType () (InaccessibleQAssignment be) proj) => (table (Const (InaccessibleQAssignment be) :: Type -> Type) -> proj) -> SqlConflictAction be table
+ Database.Beam.DuckDB: onConflictUpdateSet :: (BeamHasInsertOnConflict be, Beamable table) => (forall s. () => table (QField s) -> table (QExpr be s) -> QAssignment be s) -> SqlConflictAction be table
+ Database.Beam.DuckDB: onConflictUpdateSetWhere :: (BeamHasInsertOnConflict be, Beamable table) => (forall s. () => table (QField s) -> table (QExpr be s) -> QAssignment be s) -> (forall s. () => table (QField s) -> table (QExpr be s) -> QExpr be s Bool) -> SqlConflictAction be table
+ Database.Beam.DuckDB: runDeleteReturningList :: (MonadBeamDeleteReturning be m, Beamable table, Projectible be (table (QExpr be ())), FromBackendRow be (table Identity)) => SqlDelete be table -> m [table Identity]
+ Database.Beam.DuckDB: runDeleteReturningListWith :: (MonadBeamDeleteReturning be m, Beamable table, Projectible be a, FromBackendRow be (QExprToIdentity a)) => SqlDelete be table -> (table (QExpr be ()) -> a) -> m [QExprToIdentity a]
+ Database.Beam.DuckDB: runInsertReturningList :: (MonadBeamInsertReturning be m, Beamable table, Projectible be (table (QExpr be ())), FromBackendRow be (table Identity)) => SqlInsert be table -> m [table Identity]
+ Database.Beam.DuckDB: runInsertReturningListWith :: (MonadBeamInsertReturning be m, Beamable table, Projectible be a, FromBackendRow be (QExprToIdentity a)) => SqlInsert be table -> (table (QExpr be ()) -> a) -> m [QExprToIdentity a]
+ Database.Beam.DuckDB: runUpdateReturningList :: (MonadBeamUpdateReturning be m, Beamable table, Projectible be (table (QExpr be ())), FromBackendRow be (table Identity)) => SqlUpdate be table -> m [table Identity]
+ Database.Beam.DuckDB: runUpdateReturningListWith :: (MonadBeamUpdateReturning be m, Beamable table, Projectible be a, FromBackendRow be (QExprToIdentity a)) => SqlUpdate be table -> (table (QExpr be ()) -> a) -> m [QExprToIdentity a]
+ Database.Beam.DuckDB: }
+ Database.Beam.DuckDB.Migrate: blob :: DataType DuckDB ByteString
+ Database.Beam.DuckDB.Migrate: data DuckDBCommandSyntax
+ Database.Beam.DuckDB.Migrate: getDbConstraints :: DuckDBM [SomeDatabasePredicate]
+ Database.Beam.DuckDB.Migrate: getDbConstraintsForSchemas :: Maybe [Text] -> DuckDBM [SomeDatabasePredicate]
+ Database.Beam.DuckDB.Migrate: migrateScript :: MigrationSteps DuckDB () a -> [ByteString]
+ Database.Beam.DuckDB.Migrate: migrationBackend :: BeamMigrationBackend DuckDB DuckDBM
+ Database.Beam.DuckDB.Migrate: text :: DataType DuckDB Text
+ Database.Beam.DuckDB.Migrate: tinyint :: DataType DuckDB Int8
+ Database.Beam.DuckDB.Migrate: ubigint :: DataType DuckDB Word64
+ Database.Beam.DuckDB.Migrate: uinteger :: DataType DuckDB Word32
+ Database.Beam.DuckDB.Migrate: usmallint :: DataType DuckDB Word16
+ Database.Beam.DuckDB.Migrate: utinyint :: DataType DuckDB Word8
+ Database.Beam.DuckDB.Migrate: uuid :: DataType DuckDB UUID

Files

CHANGELOG.md view
@@ -1,5 +1,33 @@ # Revision history for beam-duckdb +## 0.3.1.0 -- unreleased++* Added support for `beam-migrate`: the new `Database.Beam.DuckDB.Migrate`+  module exposes a `migrationBackend :: BeamMigrationBackend DuckDB DuckDBM`+  value, along with other utilities. This brings DuckDB on par with `beam-postgres`+  and `beam-sqlite` for schema verification and migration script generation.+* Added file-mode `COPY ... TO 'file'` / `COPY ... FROM 'file'` support+  via the new `MonadBeamCopyTo` / `MonadBeamCopyFrom` instances on+  `DuckDBM`. Smart constructors `copyToCSV` / `copyToParquet` /+  `copyToJSON` (and the symmetric `copyFrom*` plus `*With` variants)+  build the per-format options records. See+  `Database.Beam.DuckDB.Syntax.Extensions.Copy`.+* Added instances for `MonadBeamInsertReturning` / `MonadBeamUpdateReturning` / `MonadBeamDeleteReturning`;+* Added a `BeamHasInsertOnConflict DuckDB` instance, exposing+  `insertOnConflict` (and the `anyConflict` / `conflictingFields` /+  `onConflictDoNothing` / `onConflictUpdateSet` /+  `onConflictUpdateSetWhere` / `onConflictUpdateAll` /+  `onConflictUpdateInstead` combinators) for `INSERT ... ON CONFLICT`+  on DuckDB. DuckDB does not support partial-index conflict targets+  ([INSERT docs](https://duckdb.org/docs/stable/sql/statements/insert),+  so `conflictingFieldsWhere` is *not* re-exported from+  `Database.Beam.DuckDB`; the `conflictingFieldsWhere` in that module+  is a shadowing shim that produces a compile-time `TypeError`+  pointing users at `onConflictUpdateSetWhere`. Importing+  `conflictingFieldsWhere` directly from+  `Database.Beam.Backend.SQL.BeamExtensions` still type-checks but+  raises an error at runtime.+ ## 0.3.0.0 -- 2026-04-28  * Added a `weekField` definition to the `IsSql92ExtractFieldSyntax` instance
beam-duckdb.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               beam-duckdb-version:            0.3.0.0+version:            0.3.1.0 synopsis:           DuckDB backend for Beam description:        Beam driver for DuckDB, an analytics-focused open-source in-process database. license:            MIT@@ -36,15 +36,22 @@                  -Wredundant-constraints                  -fhide-source-paths                  -Wpartial-fields+                 -Wunused-packages library     import:           common     exposed-modules:  Database.Beam.DuckDB+                      Database.Beam.DuckDB.Migrate     other-modules:    Database.Beam.DuckDB.Backend+                      Database.Beam.DuckDB.Connection                       Database.Beam.DuckDB.Syntax                       Database.Beam.DuckDB.Syntax.Builder+                      Database.Beam.DuckDB.Syntax.Extensions+                      Database.Beam.DuckDB.Syntax.Extensions.Copy                       Database.Beam.DuckDB.Syntax.Extensions.DataSource-    build-depends:    base >=4.11 && <5-                    , beam-core >=0.11 && <0.12+                      Database.Beam.DuckDB.Syntax.Extensions.InsertOnConflict+    build-depends:    aeson >=1.0 && <2.3+                    , base >=4.11 && <5+                    , beam-core >=0.11.1 && <0.12                     , beam-migrate ^>=0.6                     , bytestring >=0.10 && <0.13                     -- Version 0.1.5.0 contained a broken change (RowParser constructor not exposed)@@ -52,9 +59,10 @@                     , duckdb-simple (>=0.1.2 && <0.1.5) || (>=0.1.5.1 && <0.1.6)                     , dlist >=0.8 && <1.1                     , free >=4.12 && <5.3+                    , hashable >=1.1 && <1.6                     , scientific ^>=0.3                     , text >=1.0 && <2.2-                    , time >=1.6 && <1.16+                    , time >=1.6 && <1.17                     , transformers >=0.3 && <0.7                     , uuid-types ^>=1.0     hs-source-dirs:   src@@ -62,6 +70,11 @@ test-suite beam-duckdb-test     import:           common     other-modules:    Database.Beam.DuckDB.Test.Extensions+                      Database.Beam.DuckDB.Test.Extensions.Copy+                      Database.Beam.DuckDB.Test.Extensions.DataSource+                      Database.Beam.DuckDB.Test.Extensions.InsertOnConflict+                      Database.Beam.DuckDB.Test.Extensions.Returning+                      Database.Beam.DuckDB.Test.Migrate                       Database.Beam.DuckDB.Test.Query     type:             exitcode-stdio-1.0     hs-source-dirs:   tests@@ -69,10 +82,14 @@     build-depends:    base >=4.11 && <5                     , beam-core                     , beam-duckdb+                    , beam-migrate+                    , bytestring                     , duckdb-simple                     , hedgehog                     , tasty                     , tasty-hedgehog                     , tasty-hunit+                    , temporary                     , text                     , time+                    , uuid-types
src/Database/Beam/DuckDB.hs view
@@ -1,13 +1,7 @@-{-# LANGUAGE DerivingStrategies #-}-{-# LANGUAGE ExplicitForAll #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-}-{-# LANGUAGE UndecidableInstances #-}  -- | DuckDB is a powerful in-process database specialized in analytical processing workloads. --@@ -29,6 +23,35 @@     DuckDB,     DuckDBM, +    -- * @RETURNING@ support++    -- | The 'MonadBeamInsertReturning', 'MonadBeamUpdateReturning', and+    -- 'MonadBeamDeleteReturning' instances let DuckDB users capture the+    -- newly-inserted, updated, or deleted rows of a DML statement, which is+    -- useful for retrieving defaulted/serial column values.+    MonadBeamInsertReturning (..),+    runInsertReturningList,+    MonadBeamUpdateReturning (..),+    runUpdateReturningList,+    MonadBeamDeleteReturning (..),+    runDeleteReturningList,++    -- * @INSERT ... ON CONFLICT@ support++    -- | The 'BeamHasInsertOnConflict' instance for DuckDB exposes+    -- @INSERT ... ON CONFLICT@ via 'insertOnConflict', allowing rows that+    -- violate a uniqueness constraint to be ignored+    -- ('onConflictDoNothing') or replaced with new values+    -- ('onConflictUpdateSet', 'onConflictUpdateAll',+    -- 'onConflictUpdateInstead').+    --+    -- DuckDB does not support partial-index conflict targets, so the+    -- 'Database.Beam.Backend.SQL.BeamExtensions.conflictingFieldsWhere'+    -- class method is /not/ re-exported here. The 'conflictingFieldsWhere'+    -- in scope is a compile-time-blocked shim that produces a 'TypeError'+    -- when used — pointing users at 'onConflictUpdateSetWhere' instead.+    module Database.Beam.DuckDB.Syntax.Extensions.InsertOnConflict,+     -- * DuckDB-specific functionality      -- ** Data sources@@ -52,175 +75,90 @@     csvWith,     CSVOptions (..),     defaultCSVOptions,++    -- ** COPY support+    DuckDBCopyToOptions,+    DuckDBCopyFromOptions,++    -- *** CSV+    copyToCSV,+    copyToCSVWith,+    DuckDBCSVCopyToOptions (..),+    defaultDuckDBCSVCopyToOptions,+    copyFromCSV,+    copyFromCSVWith,+    DuckDBCSVCopyFromOptions (..),+    defaultDuckDBCSVCopyFromOptions,++    -- *** Parquet+    copyToParquet,+    copyToParquetWith,+    DuckDBParquetCopyToOptions (..),+    ParquetCompression (..),+    defaultDuckDBParquetCopyToOptions,+    copyFromParquet,+    copyFromParquetWith,+    DuckDBParquetCopyFromOptions (..),+    defaultDuckDBParquetCopyFromOptions,++    -- *** JSON+    copyToJSON,+    copyToJSONWith,+    DuckDBJSONCopyToOptions (..),+    JSONCompression (..),+    defaultDuckDBJSONCopyToOptions,+    copyFromJSON,+    copyFromJSONWith,+    DuckDBJSONCopyFromOptions (..),+    defaultDuckDBJSONCopyFromOptions,   ) where -import Control.Exception (SomeException (..))-import Control.Monad (void)-import Control.Monad.Free.Church (runF)-import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Class (lift)-import Control.Monad.Trans.Reader (ReaderT (..), ask)-import Control.Monad.Trans.State.Strict (StateT (..), get, put)-import qualified Data.DList as DL-import Data.Data (cast)-import Data.Maybe (mapMaybe)-import Data.Text (Text)-import qualified Data.Text as Text-import qualified Data.Text.Lazy as Text.Lazy-import qualified Data.Text.Lazy.Builder as Builder-import Database.Beam.Backend-  ( BeamRowReadError (..),-    ColumnParseError (..),-    FromBackendRow,-    FromBackendRowF (..),-    FromBackendRowM (..),-    MonadBeam,+import Database.Beam.Backend.SQL.BeamExtensions+  ( MonadBeamDeleteReturning (..),+    MonadBeamInsertReturning (..),+    MonadBeamUpdateReturning (..),+    runDeleteReturningList,+    runInsertReturningList,+    runUpdateReturningList,   )-import Database.Beam.Backend.SQL (FromBackendRow (..), MonadBeam (..)) import Database.Beam.DuckDB.Backend (DuckDB)-import Database.Beam.DuckDB.Syntax-  ( DuckDBCommandSyntax (..),+import Database.Beam.DuckDB.Connection+  ( DuckDBM,+    runBeamDuckDB,+    runBeamDuckDBDebug,+    runBeamDuckDBDebugString,   )-import Database.Beam.DuckDB.Syntax.Builder-  ( DuckDBSyntax (..),-    SomeField (..),-    withPlaceholder,+import Database.Beam.DuckDB.Syntax.Extensions ()+import Database.Beam.DuckDB.Syntax.Extensions.Copy+  ( DuckDBCSVCopyFromOptions (..),+    DuckDBCSVCopyToOptions (..),+    DuckDBCopyFromOptions,+    DuckDBCopyToOptions,+    DuckDBJSONCopyFromOptions (..),+    DuckDBJSONCopyToOptions (..),+    DuckDBParquetCopyFromOptions (..),+    DuckDBParquetCopyToOptions (..),+    JSONCompression (..),+    ParquetCompression (..),+    copyFromCSV,+    copyFromCSVWith,+    copyFromJSON,+    copyFromJSONWith,+    copyFromParquet,+    copyFromParquetWith,+    copyToCSV,+    copyToCSVWith,+    copyToJSON,+    copyToJSONWith,+    copyToParquet,+    copyToParquetWith,+    defaultDuckDBCSVCopyFromOptions,+    defaultDuckDBCSVCopyToOptions,+    defaultDuckDBJSONCopyFromOptions,+    defaultDuckDBJSONCopyToOptions,+    defaultDuckDBParquetCopyFromOptions,+    defaultDuckDBParquetCopyToOptions,   ) import Database.Beam.DuckDB.Syntax.Extensions.DataSource-import Database.DuckDB.Simple (Connection, FromRow, Query (Query), ResultError (..), RowParser, ToField (toField), ToRow (toRow), bind, execute, nextRow, withStatement)-import Database.DuckDB.Simple.FromRow (FromRow (..), RowParser (..), field)-import Database.DuckDB.Simple.Ok (Ok (..))---- | 'MonadBeam' instance inside which DuckDB queries are run. See the--- <https://haskell-beam.github.io/beam/ user guide> for more information-newtype DuckDBM a-  = DuckDBM-  { -- | Run an IO action with access to a DuckDB connection and a debug logging-    --  function, called or each query submitted on the connection.-    runDuckDBM :: ReaderT (Text -> IO (), Connection) IO a-  }-  deriving (Monad, Functor, Applicative, MonadIO, MonadFail)---- | Execute queries on a DuckDB 'Connection'.------ To trace the exact SQL statements being generated,--- use 'runBeamDuckDBDebug' instead.-runBeamDuckDB :: Connection -> DuckDBM a -> IO a-runBeamDuckDB = runBeamDuckDBDebug (\_ -> pure ())--runBeamDuckDBDebug :: (Text -> IO ()) -> Connection -> DuckDBM a -> IO a-runBeamDuckDBDebug debug conn action =-  runReaderT (runDuckDBM action) (debug, conn)---- | Like 'runBeamDuckDBDebug', but accepts a 'String' argument instead of 'Text'.------ This is provided for compatibility with other backends-runBeamDuckDBDebugString :: (String -> IO ()) -> Connection -> DuckDBM a -> IO a-runBeamDuckDBDebugString debug = runBeamDuckDBDebug (debug . Text.unpack)--newtype BeamDuckDBParams = BeamDuckDBParams [SomeField]--instance ToRow BeamDuckDBParams where-  toRow (BeamDuckDBParams x) = map (\(SomeField f) -> toField f) x--instance MonadBeam DuckDB DuckDBM where-  runNoReturn (DuckDBCommandSyntax (DuckDBSyntax cmd vals)) =-    DuckDBM $ do-      (logger, conn) <- ask-      let cmdString = Text.Lazy.toStrict (Builder.toLazyText (withPlaceholder cmd))-      liftIO (logger (cmdString <> ";\n-- With values: " <> Text.pack (show (DL.toList vals))))-      liftIO (void $ execute conn (Query cmdString) (BeamDuckDBParams (DL.toList vals)))--  runReturningMany (DuckDBCommandSyntax (DuckDBSyntax cmd vals)) action =-    DuckDBM $ do-      (logger, conn) <- ask-      let cmdString = Text.Lazy.toStrict (Builder.toLazyText (withPlaceholder cmd))-      liftIO $ do-        logger (cmdString <> ";\n-- With values: " <> Text.pack (show (DL.toList vals)))-        withStatement conn (Query cmdString) $ \stmt ->-          do-            bind stmt (map toField (DL.toList vals))-            let nextRow' =-                  liftIO (nextRow stmt) >>= \case-                    Nothing -> pure Nothing-                    Just (BeamDuckDBRow row) -> pure row-            runReaderT (runDuckDBM (action nextRow')) (logger, conn)--newtype BeamDuckDBRow a = BeamDuckDBRow a--instance (FromBackendRow DuckDB a) => FromRow (BeamDuckDBRow a) where-  fromRow = BeamDuckDBRow <$> runF fromBackendRow' finish step-    where-      FromBackendRowM fromBackendRow' = fromBackendRow--      translateErrors :: Maybe Int -> SomeException -> Maybe SomeException-      translateErrors col (SomeException e) =-        case cast e of-          Just-            ( ConversionFailed-                { errSQLType = typeString,-                  errHaskellType = hsString,-                  errMessage = msg-                }-              ) ->-              Just-                ( SomeException-                    ( BeamRowReadError-                        col-                        ( ColumnTypeMismatch-                            (Text.unpack hsString)-                            (Text.unpack typeString)-                            ("conversion failed: " ++ Text.unpack msg)-                        )-                    )-                )-          Just (UnexpectedNull {}) ->-            Just (SomeException (BeamRowReadError {brreColumn = col, brreError = ColumnUnexpectedNull}))-          Just-            ( Incompatible-                { errSQLType = typeString,-                  errHaskellType = hsString,-                  errMessage = msg-                }-              ) ->-              Just-                ( SomeException-                    ( BeamRowReadError-                        col-                        ( ColumnTypeMismatch-                            (Text.unpack hsString)-                            (Text.unpack typeString)-                            ("incompatible: " ++ Text.unpack msg)-                        )-                    )-                )-          Nothing -> Nothing--      finish = pure--      step :: forall a'. FromBackendRowF DuckDB (RowParser a') -> RowParser a'-      step (ParseOneField next) =-        RowParser $ ReaderT $ \ro -> StateT $ \st@(col, _) ->-          case runStateT (runReaderT (runRowParser field) ro) st of-            Ok (x, st') -> runStateT (runReaderT (runRowParser (next x)) ro) st'-            Errors errs -> Errors (mapMaybe (translateErrors (Just col)) errs)-      step (Alt (FromBackendRowM a) (FromBackendRowM b) next) = do-        RowParser $ do-          let RowParser a' = runF a finish step-              RowParser b' = runF b finish step--          st <- lift get-          ro <- ask-          case runStateT (runReaderT a' ro) st of-            Ok (ra, st') -> do-              lift $ put st'-              runRowParser (next ra)-            Errors aErrs ->-              case runStateT (runReaderT b' ro) st of-                Ok (rb, st') -> do-                  lift $ put st'-                  runRowParser (next rb)-                Errors bErrs ->-                  lift (lift (Errors (aErrs ++ bErrs)))-      step (FailParseWith err) = RowParser (lift (lift (Errors [SomeException err])))+import Database.Beam.DuckDB.Syntax.Extensions.InsertOnConflict
src/Database/Beam/DuckDB/Backend.hs view
@@ -17,6 +17,7 @@ import Data.Int (Int16, Int32, Int64, Int8) import Data.Scientific (Scientific, scientific) import Data.Text (Text)+import qualified Data.Text as Text import Data.Time (Day, LocalTime, TimeOfDay, UTCTime) import Data.UUID.Types (UUID) import Data.Word (Word16, Word32, Word64, Word8)@@ -31,6 +32,7 @@     parseOneField,   ) import Database.Beam.Backend.SQL (FromBackendRow (..))+import Database.Beam.Backend.SQL.BeamExtensions (BeamSqlBackendCopyFromSyntax, BeamSqlBackendCopyToSyntax) import Database.Beam.DuckDB.Syntax   ( DuckDBCommandSyntax,     DuckDBExpressionSyntax (..),@@ -40,6 +42,8 @@     emit,     parens,   )+import Database.Beam.DuckDB.Syntax.Extensions.Copy (DuckDBCopyFromSyntax, DuckDBCopyToSyntax)+import Database.Beam.Migrate.SQL (BeamMigrateOnlySqlBackend) import Database.Beam.Query.SQL92 (buildSql92Query') import Database.Beam.Query.Types (HasQBuilder (..)) import Database.DuckDB.Simple (Null)@@ -51,6 +55,12 @@  instance BeamSqlBackend DuckDB +instance BeamMigrateOnlySqlBackend DuckDB++type instance BeamSqlBackendCopyToSyntax DuckDB = DuckDBCopyToSyntax++type instance BeamSqlBackendCopyFromSyntax DuckDB = DuckDBCopyFromSyntax+ instance HasQBuilder DuckDB where   buildSqlQuery = buildSql92Query' True @@ -102,6 +112,13 @@ instance FromBackendRow DuckDB Word64  instance FromBackendRow DuckDB Text++instance FromBackendRow DuckDB Char where+  fromBackendRow = do+    t <- fromBackendRow @DuckDB @Text+    case Text.uncons t of+      Just (c, _) -> pure c+      Nothing -> fail "Need string of size one to parse Char"  instance FromBackendRow DuckDB ByteString 
+ src/Database/Beam/DuckDB/Connection.hs view
@@ -0,0 +1,210 @@+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE ExplicitForAll #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Internal module providing the 'DuckDBM' monad and core 'MonadBeam'+-- instances for the DuckDB backend. Re-exported from "Database.Beam.DuckDB".+module Database.Beam.DuckDB.Connection+  ( DuckDBM (..),+    runBeamDuckDB,+    runBeamDuckDBDebug,+    runBeamDuckDBDebugString,+    liftIOWithHandle,+    BeamDuckDBParams (..),+    BeamDuckDBRow (..),+  )+where++import Control.Exception (SomeException (..))+import Control.Monad (void)+import Control.Monad.Free.Church (runF)+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.Trans.Class (lift)+import Control.Monad.Trans.Reader (ReaderT (..), ask)+import Control.Monad.Trans.State.Strict (StateT (..), get, put)+import Data.Coerce (coerce)+import qualified Data.DList as DL+import Data.Data (cast)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Lazy as Text.Lazy+import qualified Data.Text.Lazy.Builder as Builder+import Database.Beam.Backend+  ( BeamRowReadError (..),+    ColumnParseError (..),+    FromBackendRow,+    FromBackendRowF (..),+    FromBackendRowM (..),+    MonadBeam,+  )+import Database.Beam.Backend.SQL (FromBackendRow (..), MonadBeam (..))+import Database.Beam.Backend.SQL.BeamExtensions (MonadBeamCopyFrom (..), MonadBeamCopyTo (..), SqlCopyFrom (..), SqlCopyTo (..))+import Database.Beam.DuckDB.Backend (DuckDB)+import Database.Beam.DuckDB.Syntax (DuckDBCommandSyntax (..))+import Database.Beam.DuckDB.Syntax.Builder+  ( DuckDBSyntax (..),+    SomeField (..),+    withPlaceholder,+  )+import Database.Beam.DuckDB.Syntax.Extensions.Copy+  ( DuckDBCopyFromSyntax (..),+    DuckDBCopyToSyntax (..),+  )+import Database.DuckDB.Simple (Connection, FromRow, Query (Query), ResultError (..), RowParser, ToField (toField), ToRow (toRow), bind, execute, nextRow, withStatement)+import Database.DuckDB.Simple.FromRow (FromRow (..), RowParser (..), field)+import Database.DuckDB.Simple.Ok (Ok (..))++-- | 'MonadBeam' instance inside which DuckDB queries are run. See the+-- <https://haskell-beam.github.io/beam/ user guide> for more information+newtype DuckDBM a+  = DuckDBM+  { -- | Run an IO action with access to a DuckDB connection and a debug logging+    --  function, called or each query submitted on the connection.+    runDuckDBM :: ReaderT (Text -> IO (), Connection) IO a+  }+  deriving (Monad, Functor, Applicative, MonadIO, MonadFail)++-- | Execute queries on a DuckDB 'Connection'.+--+-- To trace the exact SQL statements being generated,+-- use 'runBeamDuckDBDebug' instead.+runBeamDuckDB :: Connection -> DuckDBM a -> IO a+runBeamDuckDB = runBeamDuckDBDebug (\_ -> pure ())++runBeamDuckDBDebug :: (Text -> IO ()) -> Connection -> DuckDBM a -> IO a+runBeamDuckDBDebug debug conn action =+  runReaderT (runDuckDBM action) (debug, conn)++-- | Like 'runBeamDuckDBDebug', but accepts a 'String' argument instead of 'Text'.+--+-- This is provided for compatibility with other backends+runBeamDuckDBDebugString :: (String -> IO ()) -> Connection -> DuckDBM a -> IO a+runBeamDuckDBDebugString debug = runBeamDuckDBDebug (debug . Text.unpack)++-- | Run an IO action with access to the underlying DuckDB 'Connection'.+-- This is mostly useful for migrate-backend implementors who need to issue+-- raw queries against the connection.+liftIOWithHandle :: (Connection -> IO a) -> DuckDBM a+liftIOWithHandle f = DuckDBM $ ReaderT $ \(_, conn) -> f conn++newtype BeamDuckDBParams = BeamDuckDBParams [SomeField]++instance ToRow BeamDuckDBParams where+  toRow (BeamDuckDBParams x) = map (\(SomeField f) -> toField f) x++instance MonadBeam DuckDB DuckDBM where+  runNoReturn (DuckDBCommandSyntax (DuckDBSyntax cmd vals)) =+    DuckDBM $ do+      (logger, conn) <- ask+      let cmdString = Text.Lazy.toStrict (Builder.toLazyText (withPlaceholder cmd))+      liftIO (logger (cmdString <> ";\n-- With values: " <> Text.pack (show (DL.toList vals))))+      liftIO (void $ execute conn (Query cmdString) (BeamDuckDBParams (DL.toList vals)))++  runReturningMany (DuckDBCommandSyntax (DuckDBSyntax cmd vals)) action =+    DuckDBM $ do+      (logger, conn) <- ask+      let cmdString = Text.Lazy.toStrict (Builder.toLazyText (withPlaceholder cmd))+      liftIO $ do+        logger (cmdString <> ";\n-- With values: " <> Text.pack (show (DL.toList vals)))+        withStatement conn (Query cmdString) $ \stmt ->+          do+            bind stmt (map toField (DL.toList vals))+            let nextRow' =+                  liftIO (nextRow stmt) >>= \case+                    Nothing -> pure Nothing+                    Just (BeamDuckDBRow row) -> pure row+            runReaderT (runDuckDBM (action nextRow')) (logger, conn)++instance MonadBeamCopyTo DuckDB DuckDBM where+  runCopyTo SqlCopyToNoColumns = pure ()+  runCopyTo (SqlCopyTo syntax) = runNoReturn (coerce syntax)++instance MonadBeamCopyFrom DuckDB DuckDBM where+  runCopyFrom SqlCopyFromNoColumns = pure ()+  runCopyFrom (SqlCopyFrom syntax) = runNoReturn (coerce syntax)++newtype BeamDuckDBRow a = BeamDuckDBRow a++instance (FromBackendRow DuckDB a) => FromRow (BeamDuckDBRow a) where+  fromRow = BeamDuckDBRow <$> runF fromBackendRow' finish step+    where+      FromBackendRowM fromBackendRow' = fromBackendRow++      translateErrors :: Maybe Int -> SomeException -> Maybe SomeException+      translateErrors col (SomeException e) =+        case cast e of+          Just+            ( ConversionFailed+                { errSQLType = typeString,+                  errHaskellType = hsString,+                  errMessage = msg+                }+              ) ->+              Just+                ( SomeException+                    ( BeamRowReadError+                        col+                        ( ColumnTypeMismatch+                            (Text.unpack hsString)+                            (Text.unpack typeString)+                            ("conversion failed: " ++ Text.unpack msg)+                        )+                    )+                )+          Just (UnexpectedNull {}) ->+            Just (SomeException (BeamRowReadError {brreColumn = col, brreError = ColumnUnexpectedNull}))+          Just+            ( Incompatible+                { errSQLType = typeString,+                  errHaskellType = hsString,+                  errMessage = msg+                }+              ) ->+              Just+                ( SomeException+                    ( BeamRowReadError+                        col+                        ( ColumnTypeMismatch+                            (Text.unpack hsString)+                            (Text.unpack typeString)+                            ("incompatible: " ++ Text.unpack msg)+                        )+                    )+                )+          Nothing -> Nothing++      finish = pure++      step :: forall a'. FromBackendRowF DuckDB (RowParser a') -> RowParser a'+      step (ParseOneField next) =+        RowParser $ ReaderT $ \ro -> StateT $ \st@(col, _) ->+          case runStateT (runReaderT (runRowParser field) ro) st of+            Ok (x, st') -> runStateT (runReaderT (runRowParser (next x)) ro) st'+            Errors errs -> Errors (mapMaybe (translateErrors (Just col)) errs)+      step (Alt (FromBackendRowM a) (FromBackendRowM b) next) = do+        RowParser $ do+          let RowParser a' = runF a finish step+              RowParser b' = runF b finish step++          st <- lift get+          ro <- ask+          case runStateT (runReaderT a' ro) st of+            Ok (ra, st') -> do+              lift $ put st'+              runRowParser (next ra)+            Errors aErrs ->+              case runStateT (runReaderT b' ro) st of+                Ok (rb, st') -> do+                  lift $ put st'+                  runRowParser (next rb)+                Errors bErrs ->+                  lift (lift (Errors (aErrs ++ bErrs)))+      step (FailParseWith err) = RowParser (lift (lift (Errors [SomeException err])))
+ src/Database/Beam/DuckDB/Migrate.hs view
@@ -0,0 +1,621 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE TypeApplications #-}++-- | Migrations support for @beam-duckdb@. See "Database.Beam.Migrate" for+-- more information on beam migrations.+module Database.Beam.DuckDB.Migrate+  ( -- * Top-level @beam-migrate@ backend+    migrationBackend,+    DuckDBCommandSyntax,++    -- * DuckDB-specific data types++    --+    -- DuckDB has a number of data types that aren't part of standard SQL or+    -- that DuckDB names differently. These smart constructors produce+    -- 'Db.DataType's tagged with @DuckDB@ so they can be used in+    -- @beam-migrate@ schemas just like 'Database.Beam.Migrate.int' or+    -- 'Database.Beam.Migrate.smallint'.+    text,+    blob,+    tinyint,+    uuid,+    utinyint,+    usmallint,+    uinteger,+    ubigint,++    -- * Conversions and utilities+    getDbConstraints,+    getDbConstraintsForSchemas,+    migrateScript,+  )+where++import Control.Exception (SomeException (..), catch)+import Control.Monad (forM, void)+import Control.Monad.Trans.Reader (ReaderT (..))+import Data.Aeson (object, withText, (.=))+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as BL+import Data.Char (isDigit)+import Data.Int (Int8)+import qualified Data.List.NonEmpty as NE+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Monoid (Endo (..))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.UUID.Types (UUID)+import Data.Word (Word16, Word32, Word64, Word8)+import Database.Beam.Backend.SQL+import Database.Beam.DuckDB.Backend (DuckDB)+import Database.Beam.DuckDB.Connection+  ( DuckDBM (..),+    liftIOWithHandle,+  )+import Database.Beam.DuckDB.Syntax+  ( DuckDBCommandSyntax (..),+    DuckDBDataTypeSyntax (..),+  )+import Database.Beam.DuckDB.Syntax.Builder+  ( duckDBRenderSyntaxScript,+    emit,+  )+import Database.Beam.Haskell.Syntax+import Database.Beam.Migrate.Actions+  ( createIndexActionProvider,+    defaultActionProvider,+    defaultSchemaActionProvider,+    dropIndexActionProvider,+  )+import qualified Database.Beam.Migrate.Backend as Tool+import qualified Database.Beam.Migrate.Checks as Db+import qualified Database.Beam.Migrate.SQL as Db+import qualified Database.Beam.Migrate.Serialization as Db+import qualified Database.Beam.Migrate.Types as Db+import qualified Database.Beam.Query.DataTypes as Db+import qualified Database.DuckDB.Simple as DuckDB+import Database.DuckDB.Simple.Types (Only (..))++-- | Top-level migration backend for use by @beam-migrate@ tools.+--+-- @since 0.3.1.0+migrationBackend :: Tool.BeamMigrationBackend DuckDB DuckDBM+migrationBackend =+  Tool.BeamMigrationBackend+    { Tool.backendName = "duckdb",+      Tool.backendConnStringExplanation =+        unlines+          [ "For beam-duckdb, this is the path to a DuckDB database file, or :memory: for an in-memory database.",+            "",+            "See <https://duckdb.org/docs/connect.html> for more information."+          ],+      Tool.backendGetDbConstraints = getDbConstraints,+      Tool.backendPredicateParsers =+        Db.sql92Deserializers+          <> Db.sql99DataTypeDeserializers+          <> Db.sql2008BigIntDataTypeDeserializers+          <> duckDBDataTypeDeserializers+          <> Db.beamCheckDeserializers,+      Tool.backendRenderSyntax = renderDuckDBCommand,+      Tool.backendFileExtension = "duckdb.sql",+      Tool.backendConvertToHaskell = duckDBPredConverter,+      Tool.backendActionProvider =+        mconcat+          [ defaultActionProvider,+            defaultSchemaActionProvider,+            createIndexActionProvider,+            dropIndexActionProvider+          ],+      Tool.backendRunSqlScript = \t -> liftIOWithHandle $ \conn ->+        void $ DuckDB.execute_ conn (DuckDB.Query t),+      Tool.backendWithTransaction = duckDBMWithTransaction,+      Tool.backendConnect = \options -> do+        conn <- DuckDB.open options+        pure+          Tool.BeamMigrateConnection+            { Tool.backendRun = \(DuckDBM action) ->+                catch+                  (Right <$> runReaderT action (\_ -> pure (), conn))+                  (\e -> pure (Left (show (e :: SomeException)))),+              Tool.backendClose = DuckDB.close conn+            }+    }+  where+    duckDBMWithTransaction :: forall a. DuckDBM a -> DuckDBM a+    duckDBMWithTransaction (DuckDBM (ReaderT k)) =+      DuckDBM $ ReaderT $ \ctx@(_, conn) ->+        DuckDB.withTransaction conn (k ctx)++    renderDuckDBCommand :: DuckDBCommandSyntax -> String+    renderDuckDBCommand (DuckDBCommandSyntax inner) =+      T.unpack (duckDBRenderSyntaxScript inner) ++ ";"++    duckDBPredConverter :: Tool.HaskellPredicateConverter+    duckDBPredConverter =+      Tool.sql92HsPredicateConverters @DuckDB duckDBTypeToHs+        <> Tool.hsPredicateConverter duckDBHasColumnConstraint+      where+        duckDBHasColumnConstraint+          ( Db.TableColumnHasConstraint tblNm colNm c ::+              Db.TableColumnHasConstraint DuckDB+            )+            | c == Db.constraintDefinitionSyntax Nothing Db.notNullConstraintSyntax Nothing =+                Just+                  ( Db.SomeDatabasePredicate+                      ( Db.TableColumnHasConstraint+                          tblNm+                          colNm+                          (Db.constraintDefinitionSyntax Nothing Db.notNullConstraintSyntax Nothing) ::+                          Db.TableColumnHasConstraint HsMigrateBackend+                      )+                  )+            | otherwise = Nothing++        duckDBTypeToHs :: DuckDBDataTypeSyntax -> Maybe HsDataType+        duckDBTypeToHs = Just . hsErrorType . T.unpack . duckDBRenderSyntaxScript . duckDBDataType++-- | Build a backend-specific 'Db.BeamSerializedDataType' for DuckDB, tagged+-- with the given keyword. Used to give DuckDB-only types a stable serialized+-- representation that round-trips through @beam-migrate@'s predicate cache.+duckDBDataTypeJSON :: Text -> Db.BeamSerializedDataType+duckDBDataTypeJSON tag =+  Db.BeamSerializedDataType (Db.beamSerializeJSON "duckdb" (object ["data-type" .= tag]))++duckDBTinyIntType :: DuckDBDataTypeSyntax+duckDBTinyIntType = DuckDBDataTypeSyntax (emit "TINYINT") (duckDBDataTypeJSON "tinyint")++duckDBHugeIntType :: DuckDBDataTypeSyntax+duckDBHugeIntType = DuckDBDataTypeSyntax (emit "HUGEINT") (duckDBDataTypeJSON "hugeint")++duckDBUuidType :: DuckDBDataTypeSyntax+duckDBUuidType = DuckDBDataTypeSyntax (emit "UUID") (duckDBDataTypeJSON "uuid")++duckDBUTinyIntType :: DuckDBDataTypeSyntax+duckDBUTinyIntType = DuckDBDataTypeSyntax (emit "UTINYINT") (duckDBDataTypeJSON "utinyint")++duckDBUSmallIntType :: DuckDBDataTypeSyntax+duckDBUSmallIntType = DuckDBDataTypeSyntax (emit "USMALLINT") (duckDBDataTypeJSON "usmallint")++duckDBUIntegerType :: DuckDBDataTypeSyntax+duckDBUIntegerType = DuckDBDataTypeSyntax (emit "UINTEGER") (duckDBDataTypeJSON "uinteger")++duckDBUBigIntType :: DuckDBDataTypeSyntax+duckDBUBigIntType = DuckDBDataTypeSyntax (emit "UBIGINT") (duckDBDataTypeJSON "ubigint")++duckDBUHugeIntType :: DuckDBDataTypeSyntax+duckDBUHugeIntType = DuckDBDataTypeSyntax (emit "UHUGEINT") (duckDBDataTypeJSON "uhugeint")++-- | DuckDB @TEXT@. Equivalent to 'Database.Beam.Migrate.characterLargeObject'+-- and to @VARCHAR@ without a length limit; DuckDB reports all three as+-- @VARCHAR@ in @information_schema@.+--+-- @since 0.3.1.0+text :: Db.DataType DuckDB Text+text = Db.DataType (varCharType Nothing Nothing)++-- | DuckDB @BLOB@ for arbitrary binary data. Equivalent to+-- 'Database.Beam.Migrate.binaryLargeObject' (DuckDB accepts both @BLOB@ and+-- @BYTEA@ in DDL but reports @BLOB@ in @information_schema@).+--+-- @since 0.3.1.0+blob :: Db.DataType DuckDB ByteString+blob = Db.DataType binaryLargeObjectType++-- | DuckDB @TINYINT@ — a signed 8-bit integer. Not part of SQL standard+-- nor of beam-core (which only exposes 'smallint' upwards).+--+-- @since 0.3.1.0+tinyint :: Db.DataType DuckDB Int8+tinyint = Db.DataType duckDBTinyIntType++-- | DuckDB @UUID@. Maps to 'Data.UUID.Types.UUID' from @uuid-types@.+--+-- @since 0.3.1.0+uuid :: Db.DataType DuckDB UUID+uuid = Db.DataType duckDBUuidType++-- | DuckDB @UTINYINT@ — an unsigned 8-bit integer.+--+-- @since 0.3.1.0+utinyint :: Db.DataType DuckDB Word8+utinyint = Db.DataType duckDBUTinyIntType++-- | DuckDB @USMALLINT@ — an unsigned 16-bit integer.+--+-- @since 0.3.1.0+usmallint :: Db.DataType DuckDB Word16+usmallint = Db.DataType duckDBUSmallIntType++-- | DuckDB @UINTEGER@ — an unsigned 32-bit integer.+--+-- @since 0.3.1.0+uinteger :: Db.DataType DuckDB Word32+uinteger = Db.DataType duckDBUIntegerType++-- | DuckDB @UBIGINT@ — an unsigned 64-bit integer.+--+-- @since 0.3.1.0+ubigint :: Db.DataType DuckDB Word64+ubigint = Db.DataType duckDBUBigIntType++-- | 'Db.BeamDeserializers' for DuckDB-specific data types not covered by+-- the standard SQL92/SQL99/SQL2008 deserializers. Wired into+-- 'migrationBackend' by default.+--+-- @since 0.3.1.0+duckDBDataTypeDeserializers :: Db.BeamDeserializers DuckDB+duckDBDataTypeDeserializers =+  Db.beamDeserializer $ \_ ->+    withText "DuckDB data type" $ \tag ->+      case tag of+        "tinyint" -> pure duckDBTinyIntType+        "hugeint" -> pure duckDBHugeIntType+        "uuid" -> pure duckDBUuidType+        "utinyint" -> pure duckDBUTinyIntType+        "usmallint" -> pure duckDBUSmallIntType+        "uinteger" -> pure duckDBUIntegerType+        "ubigint" -> pure duckDBUBigIntType+        "uhugeint" -> pure duckDBUHugeIntType+        _ -> fail $ "Unknown DuckDB data type with tag " <> T.unpack tag++-- | Render a series of 'Db.MigrationSteps' for DuckDB as a list of lazy+-- 'BL.ByteString's suitable for assembling a migration script.+--+-- @since 0.3.1.0+migrateScript :: Db.MigrationSteps DuckDB () a -> [BL.ByteString]+migrateScript steps =+  "-- Generated by beam-duckdb beam-migrate backend\n"+    : "\n"+    : appEndo (Db.migrateScript renderHeader renderCommand steps) []+  where+    renderHeader nm =+      Endo (("-- " <> BL.fromStrict (TE.encodeUtf8 nm) <> "\n") :)+    renderCommand (DuckDBCommandSyntax inner) =+      Endo ((BL.fromStrict (TE.encodeUtf8 (duckDBRenderSyntaxScript inner)) <> ";\n") :)++-- | Convert a DuckDB type name (as reported by @information_schema@) into a+-- 'DuckDBDataTypeSyntax', or 'Nothing' if the name is not recognised.+--+-- Handles common DuckDB-printed forms such as @INTEGER@, @VARCHAR(10)@,+-- @DECIMAL(18,3)@, @TIMESTAMP WITH TIME ZONE@, etc.+duckDBDataTypeFromText :: Text -> Maybe DuckDBDataTypeSyntax+duckDBDataTypeFromText t0 =+  let t = T.toUpper (T.strip t0)+      (base, parens) = splitParens t+   in case base of+        "BOOLEAN" -> Just booleanType+        "BOOL" -> Just booleanType+        "TINYINT" -> Just duckDBTinyIntType+        "INT1" -> Just duckDBTinyIntType+        "SMALLINT" -> Just smallIntType+        "INT2" -> Just smallIntType+        "INTEGER" -> Just intType+        "INT" -> Just intType+        "INT4" -> Just intType+        "BIGINT" -> Just bigIntType+        "INT8" -> Just bigIntType+        "HUGEINT" -> Just duckDBHugeIntType+        "UTINYINT" -> Just duckDBUTinyIntType+        "USMALLINT" -> Just duckDBUSmallIntType+        "UINTEGER" -> Just duckDBUIntegerType+        "UBIGINT" -> Just duckDBUBigIntType+        "UHUGEINT" -> Just duckDBUHugeIntType+        "REAL" -> Just realType+        "FLOAT" -> Just (floatType (parsePrec parens))+        "FLOAT4" -> Just realType+        "DOUBLE" -> Just doubleType+        "FLOAT8" -> Just doubleType+        "DOUBLE PRECISION" -> Just doubleType+        "NUMERIC" -> Just (numericType (parseNumericPrec parens))+        "DECIMAL" -> Just (decimalType (parseNumericPrec parens))+        "VARCHAR" -> Just (varCharType (parsePrec parens) Nothing)+        "CHAR" -> Just (charType (parsePrec parens) Nothing)+        "CHARACTER" -> Just (charType (parsePrec parens) Nothing)+        "CHARACTER VARYING" -> Just (varCharType (parsePrec parens) Nothing)+        "TEXT" -> Just (varCharType Nothing Nothing)+        "STRING" -> Just (varCharType Nothing Nothing)+        "BLOB" -> Just binaryLargeObjectType+        "BYTEA" -> Just binaryLargeObjectType+        "UUID" -> Just duckDBUuidType+        "DATE" -> Just dateType+        "TIME" -> Just (timeType (parsePrec parens) False)+        "TIME WITH TIME ZONE" -> Just (timeType (parsePrec parens) True)+        "TIMETZ" -> Just (timeType (parsePrec parens) True)+        "TIMESTAMP" -> Just (timestampType (parsePrec parens) False)+        "TIMESTAMP WITH TIME ZONE" -> Just (timestampType (parsePrec parens) True)+        "TIMESTAMPTZ" -> Just (timestampType (parsePrec parens) True)+        _ -> Nothing+  where+    splitParens :: Text -> (Text, Maybe Text)+    splitParens t =+      case T.breakOn "(" t of+        (base, "") -> (T.strip base, Nothing)+        (base, rest) ->+          let inside = T.dropWhile (== '(') (T.dropWhileEnd (== ')') rest)+           in (T.strip base, Just (T.strip inside))++    parsePrec :: Maybe Text -> Maybe Word+    parsePrec Nothing = Nothing+    parsePrec (Just t)+      | T.null digits = Nothing+      | otherwise = Just (read (T.unpack digits))+      where+        digits = T.takeWhile isDigit (T.strip t)++    parseNumericPrec :: Maybe Text -> Maybe (Word, Maybe Word)+    parseNumericPrec Nothing = Nothing+    parseNumericPrec (Just t) =+      case T.splitOn "," t of+        [p] -> fmap (,Nothing) (readMaybeWord (T.strip p))+        [p, d] -> case (readMaybeWord (T.strip p), readMaybeWord (T.strip d)) of+          (Just pw, Just dw) -> Just (pw, Just dw)+          (Just pw, Nothing) -> Just (pw, Nothing)+          _ -> Nothing+        _ -> Nothing++    readMaybeWord :: Text -> Maybe Word+    readMaybeWord t+      | T.null t || T.any (not . isDigit) t = Nothing+      | otherwise = Just (read (T.unpack t))++-- | Discover the set of 'Db.SomeDatabasePredicate's describing the current+-- DuckDB database. Includes schema, table, column, primary-key, secondary+-- index, and foreign-key constraint predicates.+--+-- @since 0.3.1.0+getDbConstraints :: DuckDBM [Db.SomeDatabasePredicate]+getDbConstraints = do+  schemas <-+    liftIOWithHandle $ \conn ->+      DuckDB.query_+        conn+        "SELECT schema_name FROM information_schema.schemata \+        \WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'main') \+        \AND schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'"+  let userSchemas = map (\(Only s) -> s) schemas :: [Text]+  case userSchemas of+    [] -> getDbConstraintsForSchemas Nothing+    ss ->+      (++)+        <$> getDbConstraintsForSchemas (Just ss)+        <*> getDbConstraintsForSchemas Nothing++-- | Like 'getDbConstraints', but optionally restricted to a list of schemas.+-- If 'Nothing', the @main@ schema (the DuckDB default) is queried.+--+-- @since 0.3.1.0+getDbConstraintsForSchemas :: Maybe [Text] -> DuckDBM [Db.SomeDatabasePredicate]+getDbConstraintsForSchemas mSchemas = do+  let schemaFilter = case mSchemas of+        Nothing -> "table_schema = '" <> implicitSchema <> "'"+        Just ss ->+          "table_schema IN ("+            <> T.intercalate+              ","+              (map (\s -> "'" <> s <> "'") ss)+            <> ")"+      schemaPreds =+        case mSchemas of+          Nothing -> []+          Just ss -> map (Db.SomeDatabasePredicate . Db.SchemaExistsPredicate) ss++  tbls <-+    liftIOWithHandle $ \conn ->+      DuckDB.query_ conn $+        DuckDB.Query $+          T.unlines+            [ "SELECT table_schema, table_name FROM information_schema.tables",+              "WHERE table_type='BASE TABLE' AND " <> schemaFilter+            ]+  let tblExistsPreds =+        map+          ( \(s, t) ->+              Db.SomeDatabasePredicate+                ( Db.TableExistsPredicate+                    (Db.QualifiedName (qualifySchema s mSchemas) t)+                )+          )+          tbls++  columnsAndConstraints <-+    fmap mconcat . forM tbls $ \(s, t) -> collectColumnInfo s t mSchemas++  primaryKeyPreds <- collectPrimaryKeys schemaFilter mSchemas+  fkPreds <- collectForeignKeys schemaFilter mSchemas++  pure $+    concat+      [ schemaPreds,+        tblExistsPreds,+        columnsAndConstraints,+        primaryKeyPreds,+        fkPreds+      ]++-- | The implicit schema in case it's not specified+--+-- https://duckdb.org/docs/lts/sql/statements/use+implicitSchema :: Text+implicitSchema = "main"++-- | If we are looking at the 'main' schema, qualify columns/tables with+-- 'Nothing' (the implicit default). Otherwise qualify with the actual schema.+qualifySchema :: Text -> Maybe [Text] -> Maybe Text+qualifySchema s Nothing+  | s == implicitSchema = Nothing+  | otherwise = Just s+qualifySchema s _ = Just s++collectColumnInfo ::+  Text ->+  Text ->+  Maybe [Text] ->+  DuckDBM [Db.SomeDatabasePredicate]+collectColumnInfo schemaNm tblNm mSchemas = do+  columns <-+    liftIOWithHandle $ \conn ->+      DuckDB.query+        conn+        "SELECT column_name, data_type, is_nullable \+        \FROM information_schema.columns \+        \WHERE table_schema = ? AND table_name = ? \+        \ORDER BY ordinal_position"+        (schemaNm, tblNm)+  let qual = Db.QualifiedName (qualifySchema schemaNm mSchemas) tblNm+      mkPreds (colNm, typ, isNullable) =+        let dt = fromMaybe (unknownDuckDBType typ) (duckDBDataTypeFromText typ)+            colPred =+              Db.SomeDatabasePredicate+                ( Db.TableHasColumn qual colNm dt ::+                    Db.TableHasColumn DuckDB+                )+            notNullPred =+              ( [ Db.SomeDatabasePredicate+                    ( Db.TableColumnHasConstraint+                        qual+                        colNm+                        ( Db.constraintDefinitionSyntax+                            Nothing+                            Db.notNullConstraintSyntax+                            Nothing+                        ) ::+                        Db.TableColumnHasConstraint DuckDB+                    )+                | isNullable == ("NO" :: Text)+                ]+              )+         in colPred : notNullPred+  pure $ concatMap mkPreds (columns :: [(Text, Text, Text)])+  where+    unknownDuckDBType :: Text -> DuckDBDataTypeSyntax+    unknownDuckDBType = domainType++collectPrimaryKeys ::+  Text ->+  Maybe [Text] ->+  DuckDBM [Db.SomeDatabasePredicate]+collectPrimaryKeys schemaFilter mSchemas = do+  rows <-+    liftIOWithHandle $ \conn ->+      DuckDB.query_ conn $+        DuckDB.Query $+          T.unlines+            [ "SELECT tc.table_schema, tc.table_name, kcu.column_name, kcu.ordinal_position",+              "FROM information_schema.table_constraints tc",+              "JOIN information_schema.key_column_usage kcu",+              "  ON tc.constraint_name = kcu.constraint_name",+              " AND tc.constraint_schema = kcu.constraint_schema",+              " AND tc.table_name = kcu.table_name",+              "WHERE tc.constraint_type = 'PRIMARY KEY' AND tc." <> schemaFilter,+              "ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position"+            ]+  let grouped = groupByPk (rows :: [(Text, Text, Text, Int)])+  pure $+    map+      ( \((sch, tbl), cols) ->+          Db.SomeDatabasePredicate+            ( Db.TableHasPrimaryKey+                (Db.QualifiedName (qualifySchema sch mSchemas) tbl)+                cols+            )+      )+      grouped++groupByPk ::+  [(Text, Text, Text, Int)] ->+  [((Text, Text), [Text])]+groupByPk = go []+  where+    go acc [] = reverse acc+    go acc rs@((s, t, _, _) : _) =+      let (here, rest) = span (\(s', t', _, _) -> s == s' && t == t') rs+          cols = map (\(_, _, c, _) -> c) here+       in go (((s, t), cols) : acc) rest++collectForeignKeys ::+  Text ->+  Maybe [Text] ->+  DuckDBM [Db.SomeDatabasePredicate]+collectForeignKeys schemaFilter mSchemas = do+  rows <-+    liftIOWithHandle $ \conn ->+      DuckDB.query_ conn $+        DuckDB.Query $+          T.unlines+            [ "SELECT tc.table_schema, tc.table_name, kcu.column_name,",+              "       ccu.table_schema, ccu.table_name, ccu.column_name,",+              "       rc.update_rule, rc.delete_rule,",+              "       kcu.ordinal_position",+              "FROM information_schema.table_constraints tc",+              "JOIN information_schema.key_column_usage kcu",+              "  ON tc.constraint_name = kcu.constraint_name",+              " AND tc.constraint_schema = kcu.constraint_schema",+              "JOIN information_schema.referential_constraints rc",+              "  ON rc.constraint_name = tc.constraint_name",+              " AND rc.constraint_schema = tc.constraint_schema",+              "JOIN information_schema.constraint_column_usage ccu",+              "  ON ccu.constraint_name = rc.unique_constraint_name",+              " AND ccu.constraint_schema = rc.unique_constraint_schema",+              "WHERE tc.constraint_type = 'FOREIGN KEY' AND tc." <> schemaFilter,+              "ORDER BY tc.constraint_schema, tc.constraint_name, kcu.ordinal_position"+            ]+  pure $+    mapMaybe mkFk $+      groupByFk+        ( rows ::+            [ ( Text,+                Text,+                Text,+                Text,+                Text,+                Text,+                Text,+                Text,+                Int+              )+            ]+        )+  where+    groupByFk = go []+      where+        go acc [] = reverse acc+        go acc rs@((s, t, _, fs, ft, _, _, _, _) : _) =+          let (here, rest) =+                span+                  ( \(s', t', _, fs', ft', _, _, _, _) ->+                      s == s' && t == t' && fs == fs' && ft == ft'+                  )+                  rs+           in go (here : acc) rest++    mkFk [] = Nothing+    mkFk rows@((srcSchema, srcTbl, _, refSchema, refTbl, _, upd, del, _) : _) =+      let localCols = map (\(_, _, c, _, _, _, _, _, _) -> c) rows+          refCols = map (\(_, _, _, _, _, c, _, _, _) -> c) rows+       in case (NE.nonEmpty localCols, NE.nonEmpty refCols) of+            (Just lc, Just rc) ->+              Just $+                Db.SomeDatabasePredicate+                  ( Db.TableHasForeignKey+                      (Db.QualifiedName (qualifySchema srcSchema mSchemas) srcTbl)+                      lc+                      (Db.QualifiedName (qualifySchema refSchema mSchemas) refTbl)+                      rc+                      (parseDuckDBForeignKeyAction upd)+                      (parseDuckDBForeignKeyAction del)+                  )+            _ -> Nothing++    parseDuckDBForeignKeyAction :: Text -> Db.ForeignKeyAction+    parseDuckDBForeignKeyAction t =+      case T.toUpper t of+        "CASCADE" -> Db.ForeignKeyActionCascade+        "SET NULL" -> Db.ForeignKeyActionSetNull+        "SET DEFAULT" -> Db.ForeignKeyActionSetDefault+        "RESTRICT" -> Db.ForeignKeyActionRestrict+        "NO ACTION" -> Db.ForeignKeyNoAction+        _ -> Db.ForeignKeyNoAction
src/Database/Beam/DuckDB/Syntax.hs view
@@ -1,10 +1,10 @@ {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-}--- TODO: clean up unused top binds {-# OPTIONS_GHC -Wno-unused-top-binds #-}  module Database.Beam.DuckDB.Syntax@@ -20,12 +20,35 @@     DuckDBDeleteSyntax (..),     DuckDBTableNameSyntax (..),     DuckDBOnConflictSyntax (..),+    DuckDBFieldNameSyntax (..),++    -- * DDL syntaxes+    DuckDBDataTypeSyntax (..),+    DuckDBCreateTableSyntax (..),+    DuckDBDropTableSyntax (..),+    DuckDBAlterTableSyntax (..),+    DuckDBAlterTableActionSyntax (..),+    DuckDBAlterColumnActionSyntax (..),+    DuckDBCreateSchemaSyntax (..),+    DuckDBDropSchemaSyntax (..),+    DuckDBSchemaNameSyntax (..),+    DuckDBColumnSchemaSyntax (..),+    DuckDBColumnConstraintDefinitionSyntax (..),+    DuckDBColumnConstraintSyntax (..),+    DuckDBTableConstraintSyntax (..),+    DuckDBMatchTypeSyntax (..),+    DuckDBReferentialActionSyntax (..),+    DuckDBTableOptionsSyntax (..),+    DuckDBIndexOptions (..),   ) where +import Data.Aeson (object, withObject, (.:), (.=)) import Data.Bifunctor (second) import Data.Coerce (coerce)+import Data.Hashable (Hashable (..)) import Data.Int (Int16, Int32, Int64, Int8)+import qualified Data.List.NonEmpty as NE import Data.Scientific (Scientific) import Data.String (fromString) import Data.Text (Text)@@ -63,6 +86,7 @@     IsSql92OrderingSyntax (..),     IsSql92ProjectionSyntax (..),     IsSql92QuantifierSyntax (..),+    IsSql92SchemaNameSyntax (..),     IsSql92SelectSyntax (..),     IsSql92SelectTableSyntax (..),     IsSql92Syntax (..),@@ -79,8 +103,46 @@     IsSql99RecursiveCommonTableExpressionSelectSyntax (..),     SqlNull (..),   )-import Database.Beam.DuckDB.Syntax.Builder (DuckDBSyntax, commas, emit, emitChar, emitIntegral, emitRealFloat, emitScientific, emitValue, parens, quotedIdentifier, sepBy, spaces)-import Database.Beam.Migrate.Serialization (BeamSerializedDataType)+import Database.Beam.Backend.SQL+  ( IsSql2003BinaryAndVarBinaryDataTypeSyntax (..),+    IsSql2008BigIntDataTypeSyntax (..),+    Sql92DisplaySyntax (..),+  )+import Database.Beam.DuckDB.Syntax.Builder (DuckDBSyntax, commas, duckDBRenderSyntaxScript, emit, emitChar, emitIntegral, emitRealFloat, emitScientific, emitValue, parens, quotedIdentifier, sepBy, spaces)+import Database.Beam.Migrate.Checks (HasDataTypeCreatedCheck (..))+import Database.Beam.Migrate.SQL+  ( IsSql92AlterColumnActionSyntax (..),+    IsSql92AlterTableActionSyntax (..),+    IsSql92AlterTableSyntax (..),+    IsSql92ColumnConstraintDefinitionSyntax (..),+    IsSql92ColumnConstraintSyntax (..),+    IsSql92ColumnSchemaSyntax (..),+    IsSql92CreateDropIndexSyntax (..),+    IsSql92CreateSchemaSyntax (..),+    IsSql92CreateTableSyntax (..),+    IsSql92DdlCommandSyntax (..),+    IsSql92DdlSchemaCommandSyntax (..),+    IsSql92DropSchemaSyntax (..),+    IsSql92DropTableSyntax (..),+    IsSql92MatchTypeSyntax (..),+    IsSql92ReferentialActionSyntax (..),+    IsSql92TableConstraintSyntax (..),+    IsSql92UniqueIndexSyntax (..),+    Sql92SerializableConstraintDefinitionSyntax (..),+    Sql92SerializableDataTypeSyntax (..),+  )+import Database.Beam.Migrate.SQL.Builder (ConstraintAttributeTiming (..), SqlConstraintAttributesBuilder (..), sqlConstraintAttributesSerialized)+import Database.Beam.Migrate.SQL.SQL92 (ForeignKeyAction (..))+import Database.Beam.Migrate.Serialization+  ( BeamSerializedConstraint,+    BeamSerializedConstraintDefinition,+    BeamSerializedDataType (..),+    BeamSerializedExpression (..),+    BeamSerializedMatchType,+    BeamSerializedReferentialAction,+    fromBeamSerializedConstraintDefinition,+    fromBeamSerializedDataType,+  ) import Database.DuckDB.Simple (Null (Null))  newtype DuckDBCommandSyntax = DuckDBCommandSyntax {fromDuckDBSyntax :: DuckDBSyntax}@@ -97,6 +159,22 @@   updateCmd = DuckDBCommandSyntax . fromDuckDBUpdate   deleteCmd = DuckDBCommandSyntax . fromDuckDBDelete +instance IsSql92DdlCommandSyntax DuckDBCommandSyntax where+  type Sql92DdlCommandCreateTableSyntax DuckDBCommandSyntax = DuckDBCreateTableSyntax+  type Sql92DdlCommandDropTableSyntax DuckDBCommandSyntax = DuckDBDropTableSyntax+  type Sql92DdlCommandAlterTableSyntax DuckDBCommandSyntax = DuckDBAlterTableSyntax++  createTableCmd = DuckDBCommandSyntax . fromDuckDBCreateTable+  dropTableCmd = DuckDBCommandSyntax . fromDuckDBDropTable+  alterTableCmd = DuckDBCommandSyntax . fromDuckDBAlterTable++instance IsSql92DdlSchemaCommandSyntax DuckDBCommandSyntax where+  type Sql92DdlCommandCreateSchemaSyntax DuckDBCommandSyntax = DuckDBCreateSchemaSyntax+  type Sql92DdlCommandDropSchemaSyntax DuckDBCommandSyntax = DuckDBDropSchemaSyntax++  createSchemaCmd = DuckDBCommandSyntax . fromDuckDBCreateSchema+  dropSchemaCmd = DuckDBCommandSyntax . fromDuckDBDropSchema+ newtype DuckDBTableNameSyntax = DuckDBTableNameSyntax {fromDuckDBTableName :: DuckDBSyntax}  -- | DuckDB @SELECT@ syntax@@ -124,7 +202,58 @@   { duckDBDataType :: DuckDBSyntax,     duckDBDataTypeSerialized :: BeamSerializedDataType   }+  deriving (Show) +-- DDL syntax newtypes used by 'IsSql92DdlCommandSyntax' and friends.++newtype DuckDBCreateTableSyntax = DuckDBCreateTableSyntax {fromDuckDBCreateTable :: DuckDBSyntax}++data DuckDBTableOptionsSyntax = DuckDBTableOptionsSyntax DuckDBSyntax DuckDBSyntax++newtype DuckDBDropTableSyntax = DuckDBDropTableSyntax {fromDuckDBDropTable :: DuckDBSyntax}++newtype DuckDBAlterTableSyntax = DuckDBAlterTableSyntax {fromDuckDBAlterTable :: DuckDBSyntax}++newtype DuckDBAlterTableActionSyntax = DuckDBAlterTableActionSyntax {fromDuckDBAlterTableAction :: DuckDBSyntax}++newtype DuckDBAlterColumnActionSyntax = DuckDBAlterColumnActionSyntax {fromDuckDBAlterColumnAction :: DuckDBSyntax}++newtype DuckDBCreateSchemaSyntax = DuckDBCreateSchemaSyntax {fromDuckDBCreateSchema :: DuckDBSyntax}++newtype DuckDBDropSchemaSyntax = DuckDBDropSchemaSyntax {fromDuckDBDropSchema :: DuckDBSyntax}++newtype DuckDBSchemaNameSyntax = DuckDBSchemaNameSyntax {fromDuckDBSchemaName :: DuckDBSyntax}++newtype DuckDBColumnSchemaSyntax = DuckDBColumnSchemaSyntax {fromDuckDBColumnSchema :: DuckDBSyntax}+  deriving (Show, Eq)++data DuckDBColumnConstraintDefinitionSyntax = DuckDBColumnConstraintDefinitionSyntax+  { fromDuckDBColumnConstraintDefinition :: DuckDBSyntax,+    duckDBColumnConstraintDefinitionSerialized :: BeamSerializedConstraintDefinition+  }+  deriving (Show)++data DuckDBColumnConstraintSyntax = DuckDBColumnConstraintSyntax+  { fromDuckDBColumnConstraint :: DuckDBSyntax,+    duckDBColumnConstraintSerialized :: BeamSerializedConstraint+  }++newtype DuckDBTableConstraintSyntax = DuckDBTableConstraintSyntax {fromDuckDBTableConstraint :: DuckDBSyntax}++data DuckDBMatchTypeSyntax = DuckDBMatchTypeSyntax+  { fromDuckDBMatchType :: DuckDBSyntax,+    duckDBMatchTypeSerialized :: BeamSerializedMatchType+  }++data DuckDBReferentialActionSyntax = DuckDBReferentialActionSyntax+  { fromDuckDBReferentialAction :: DuckDBSyntax,+    duckDBReferentialActionSerialized :: BeamSerializedReferentialAction+  }++newtype DuckDBIndexOptions = DuckDBIndexOptions {duckDBIndexUnique :: Bool}+  deriving stock (Show, Eq)+  deriving newtype (Hashable)+ newtype DuckDBExtractFieldSyntax = DuckDBExtractFieldSyntax {fromDuckDBExtractField :: DuckDBSyntax}  newtype DuckDBGroupingSyntax = DuckDBGroupingSyntax {fromDuckDBGrouping :: DuckDBSyntax}@@ -349,6 +478,293 @@             <> emitChar ')',         duckDBDataTypeSerialized = rowType (map (second duckDBDataTypeSerialized) fields)       }++instance IsSql2008BigIntDataTypeSyntax DuckDBDataTypeSyntax where+  bigIntType = DuckDBDataTypeSyntax (emit "BIGINT") bigIntType++instance IsSql2003BinaryAndVarBinaryDataTypeSyntax DuckDBDataTypeSyntax where+  binaryType sz =+    DuckDBDataTypeSyntax+      (emit "BLOB" <> optPrec sz)+      (binaryType sz)+  varBinaryType sz =+    DuckDBDataTypeSyntax+      (emit "BLOB" <> optPrec sz)+      (varBinaryType sz)++-- | Two 'DuckDBDataTypeSyntax' values are equal when their rendered SQL is+-- equal. This is good enough for migration purposes, where the serialized+-- JSON form is used as the canonical comparison key by @beam-migrate@.+instance Eq DuckDBDataTypeSyntax where+  DuckDBDataTypeSyntax a _ == DuckDBDataTypeSyntax b _ = a == b++instance Hashable DuckDBDataTypeSyntax where+  hashWithSalt salt (DuckDBDataTypeSyntax a _) = hashWithSalt salt a++instance Sql92DisplaySyntax DuckDBDataTypeSyntax where+  displaySyntax = displaySyntax . duckDBDataType++instance Sql92SerializableDataTypeSyntax DuckDBDataTypeSyntax where+  serializeDataType = fromBeamSerializedDataType . duckDBDataTypeSerialized++-- | DuckDB doesn't allow user-defined domain types, so every data type is+-- always available.+instance HasDataTypeCreatedCheck DuckDBDataTypeSyntax where+  dataTypeHasBeenCreated _ _ = True++instance Sql92DisplaySyntax DuckDBColumnSchemaSyntax where+  displaySyntax = displaySyntax . fromDuckDBColumnSchema++instance Hashable DuckDBColumnSchemaSyntax where+  hashWithSalt salt = hashWithSalt salt . fromDuckDBColumnSchema++instance Eq DuckDBColumnConstraintDefinitionSyntax where+  DuckDBColumnConstraintDefinitionSyntax a _ == DuckDBColumnConstraintDefinitionSyntax b _ = a == b++instance Hashable DuckDBColumnConstraintDefinitionSyntax where+  hashWithSalt salt = hashWithSalt salt . fromDuckDBColumnConstraintDefinition++instance Sql92DisplaySyntax DuckDBColumnConstraintDefinitionSyntax where+  displaySyntax = displaySyntax . fromDuckDBColumnConstraintDefinition++instance Sql92SerializableConstraintDefinitionSyntax DuckDBColumnConstraintDefinitionSyntax where+  serializeConstraint = fromBeamSerializedConstraintDefinition . duckDBColumnConstraintDefinitionSerialized++instance IsSql92SchemaNameSyntax DuckDBSchemaNameSyntax where+  schemaName s = DuckDBSchemaNameSyntax (quotedIdentifier s)++instance IsSql92CreateSchemaSyntax DuckDBCreateSchemaSyntax where+  type Sql92CreateSchemaSchemaNameSyntax DuckDBCreateSchemaSyntax = DuckDBSchemaNameSyntax++  createSchemaSyntax sNm =+    DuckDBCreateSchemaSyntax (emit "CREATE SCHEMA " <> fromDuckDBSchemaName sNm)++instance IsSql92DropSchemaSyntax DuckDBDropSchemaSyntax where+  type Sql92DropSchemaSchemaNameSyntax DuckDBDropSchemaSyntax = DuckDBSchemaNameSyntax++  dropSchemaSyntax sNm =+    DuckDBDropSchemaSyntax (emit "DROP SCHEMA " <> fromDuckDBSchemaName sNm)++instance IsSql92DropTableSyntax DuckDBDropTableSyntax where+  type Sql92DropTableTableNameSyntax DuckDBDropTableSyntax = DuckDBTableNameSyntax++  dropTableSyntax tblNm =+    DuckDBDropTableSyntax (emit "DROP TABLE " <> fromDuckDBTableName tblNm)++instance IsSql92AlterTableSyntax DuckDBAlterTableSyntax where+  type Sql92AlterTableAlterTableActionSyntax DuckDBAlterTableSyntax = DuckDBAlterTableActionSyntax+  type Sql92AlterTableTableNameSyntax DuckDBAlterTableSyntax = DuckDBTableNameSyntax++  alterTableSyntax tblNm action =+    DuckDBAlterTableSyntax $+      emit "ALTER TABLE "+        <> fromDuckDBTableName tblNm+        <> emit " "+        <> fromDuckDBAlterTableAction action++instance IsSql92AlterTableActionSyntax DuckDBAlterTableActionSyntax where+  type Sql92AlterTableAlterColumnActionSyntax DuckDBAlterTableActionSyntax = DuckDBAlterColumnActionSyntax+  type Sql92AlterTableColumnSchemaSyntax DuckDBAlterTableActionSyntax = DuckDBColumnSchemaSyntax++  alterColumnSyntax colNm action =+    DuckDBAlterTableActionSyntax $+      emit "ALTER COLUMN "+        <> quotedIdentifier colNm+        <> emit " "+        <> fromDuckDBAlterColumnAction action++  addColumnSyntax colNm schema =+    DuckDBAlterTableActionSyntax $+      emit "ADD COLUMN "+        <> quotedIdentifier colNm+        <> emit " "+        <> fromDuckDBColumnSchema schema++  dropColumnSyntax colNm =+    DuckDBAlterTableActionSyntax (emit "DROP COLUMN " <> quotedIdentifier colNm)++  renameTableToSyntax newNm =+    DuckDBAlterTableActionSyntax (emit "RENAME TO " <> quotedIdentifier newNm)++  renameColumnToSyntax oldNm newNm =+    DuckDBAlterTableActionSyntax $+      emit "RENAME COLUMN "+        <> quotedIdentifier oldNm+        <> emit " TO "+        <> quotedIdentifier newNm++instance IsSql92AlterColumnActionSyntax DuckDBAlterColumnActionSyntax where+  setNullSyntax = DuckDBAlterColumnActionSyntax (emit "DROP NOT NULL")+  setNotNullSyntax = DuckDBAlterColumnActionSyntax (emit "SET NOT NULL")++instance IsSql92CreateTableSyntax DuckDBCreateTableSyntax where+  type Sql92CreateTableTableNameSyntax DuckDBCreateTableSyntax = DuckDBTableNameSyntax+  type Sql92CreateTableColumnSchemaSyntax DuckDBCreateTableSyntax = DuckDBColumnSchemaSyntax+  type Sql92CreateTableTableConstraintSyntax DuckDBCreateTableSyntax = DuckDBTableConstraintSyntax+  type Sql92CreateTableOptionsSyntax DuckDBCreateTableSyntax = DuckDBTableOptionsSyntax++  createTableSyntax options tblNm fieldTypes constraints =+    let (beforeOptions, afterOptions) =+          case options of+            Nothing -> (emit " ", emit " ")+            Just (DuckDBTableOptionsSyntax before after) ->+              (emit " " <> before <> emit " ", emit " " <> after <> emit " ")+     in DuckDBCreateTableSyntax $+          emit "CREATE"+            <> beforeOptions+            <> emit "TABLE "+            <> fromDuckDBTableName tblNm+            <> emit " ("+            <> sepBy+              (emit ", ")+              ( map (\(nm, ty) -> quotedIdentifier nm <> emit " " <> fromDuckDBColumnSchema ty) fieldTypes+                  <> map fromDuckDBTableConstraint constraints+              )+            <> emit ")"+            <> afterOptions++duckDBForeignKeyAction :: ForeignKeyAction -> DuckDBSyntax+duckDBForeignKeyAction ForeignKeyActionCascade = emit "CASCADE"+duckDBForeignKeyAction ForeignKeyActionSetNull = emit "SET NULL"+duckDBForeignKeyAction ForeignKeyActionSetDefault = emit "SET DEFAULT"+duckDBForeignKeyAction ForeignKeyActionRestrict = emit "RESTRICT"+duckDBForeignKeyAction ForeignKeyNoAction = emit "NO ACTION"++instance IsSql92TableConstraintSyntax DuckDBTableConstraintSyntax where+  primaryKeyConstraintSyntax fieldNames =+    DuckDBTableConstraintSyntax $+      emit "PRIMARY KEY("+        <> sepBy (emit ", ") (map quotedIdentifier (NE.toList fieldNames))+        <> emit ")"+  foreignKeyConstraintSyntax localCols refTbl refCols onUpdate onDelete =+    DuckDBTableConstraintSyntax $+      emit "FOREIGN KEY("+        <> sepBy (emit ", ") (map quotedIdentifier (NE.toList localCols))+        <> emit ") REFERENCES "+        <> quotedIdentifier refTbl+        <> emit "("+        <> sepBy (emit ", ") (map quotedIdentifier (NE.toList refCols))+        <> emit ")"+        <> emit " ON UPDATE "+        <> duckDBForeignKeyAction onUpdate+        <> emit " ON DELETE "+        <> duckDBForeignKeyAction onDelete++instance IsSql92ColumnSchemaSyntax DuckDBColumnSchemaSyntax where+  type Sql92ColumnSchemaColumnTypeSyntax DuckDBColumnSchemaSyntax = DuckDBDataTypeSyntax+  type Sql92ColumnSchemaExpressionSyntax DuckDBColumnSchemaSyntax = DuckDBExpressionSyntax+  type Sql92ColumnSchemaColumnConstraintDefinitionSyntax DuckDBColumnSchemaSyntax = DuckDBColumnConstraintDefinitionSyntax++  columnSchemaSyntax colType defaultClause constraints collation =+    DuckDBColumnSchemaSyntax $+      duckDBDataType colType+        <> maybe mempty (\d -> emit " DEFAULT " <> fromDuckDBExpression d) defaultClause+        <> ( case constraints of+               [] -> mempty+               _ -> foldMap (\c -> emit " " <> fromDuckDBColumnConstraintDefinition c) constraints+           )+        <> maybe mempty (\nm -> emit " COLLATE " <> quotedIdentifier nm) collation++instance IsSql92MatchTypeSyntax DuckDBMatchTypeSyntax where+  fullMatchSyntax = DuckDBMatchTypeSyntax (emit "FULL") fullMatchSyntax+  partialMatchSyntax = DuckDBMatchTypeSyntax (emit "PARTIAL") partialMatchSyntax++instance IsSql92ReferentialActionSyntax DuckDBReferentialActionSyntax where+  referentialActionCascadeSyntax = DuckDBReferentialActionSyntax (emit "CASCADE") referentialActionCascadeSyntax+  referentialActionNoActionSyntax = DuckDBReferentialActionSyntax (emit "NO ACTION") referentialActionNoActionSyntax+  referentialActionSetDefaultSyntax = DuckDBReferentialActionSyntax (emit "SET DEFAULT") referentialActionSetDefaultSyntax+  referentialActionSetNullSyntax = DuckDBReferentialActionSyntax (emit "SET NULL") referentialActionSetNullSyntax++instance IsSql92ColumnConstraintDefinitionSyntax DuckDBColumnConstraintDefinitionSyntax where+  type Sql92ColumnConstraintDefinitionConstraintSyntax DuckDBColumnConstraintDefinitionSyntax = DuckDBColumnConstraintSyntax+  type Sql92ColumnConstraintDefinitionAttributesSyntax DuckDBColumnConstraintDefinitionSyntax = SqlConstraintAttributesBuilder++  constraintDefinitionSyntax nm constraint attrs =+    DuckDBColumnConstraintDefinitionSyntax syntax serialized+    where+      syntax =+        maybe mempty (\n -> emit "CONSTRAINT " <> quotedIdentifier n <> emit " ") nm+          <> fromDuckDBColumnConstraint constraint+          <> maybe mempty (\a -> emit " " <> duckDBSqlConstraintAttributes a) attrs+      serialized =+        constraintDefinitionSyntax nm (duckDBColumnConstraintSerialized constraint) (fmap sqlConstraintAttributesSerialized attrs)++-- | Emit a 'SqlConstraintAttributesBuilder' as DuckDB syntax. DuckDB does not+-- support @DEFERRABLE@/@INITIALLY DEFERRED@ at the time of writing, but we+-- still emit the corresponding SQL keywords so that user-supplied constraint+-- attributes are not silently dropped. If DuckDB rejects them, the user will+-- see an obvious error rather than a subtle behavioural difference.+duckDBSqlConstraintAttributes :: SqlConstraintAttributesBuilder -> DuckDBSyntax+duckDBSqlConstraintAttributes (SqlConstraintAttributesBuilder timing deferrable) =+  maybe mempty timingBuilder timing <> maybe mempty deferrableBuilder deferrable+  where+    timingBuilder InitiallyDeferred = emit "INITIALLY DEFERRED"+    timingBuilder InitiallyImmediate = emit "INITIALLY IMMEDIATE"+    deferrableBuilder False = emit "NOT DEFERRABLE"+    deferrableBuilder True = emit "DEFERRABLE"++instance IsSql92ColumnConstraintSyntax DuckDBColumnConstraintSyntax where+  type Sql92ColumnConstraintMatchTypeSyntax DuckDBColumnConstraintSyntax = DuckDBMatchTypeSyntax+  type Sql92ColumnConstraintReferentialActionSyntax DuckDBColumnConstraintSyntax = DuckDBReferentialActionSyntax+  type Sql92ColumnConstraintExpressionSyntax DuckDBColumnConstraintSyntax = DuckDBExpressionSyntax++  notNullConstraintSyntax = DuckDBColumnConstraintSyntax (emit "NOT NULL") notNullConstraintSyntax+  uniqueColumnConstraintSyntax = DuckDBColumnConstraintSyntax (emit "UNIQUE") uniqueColumnConstraintSyntax+  primaryKeyColumnConstraintSyntax = DuckDBColumnConstraintSyntax (emit "PRIMARY KEY") primaryKeyColumnConstraintSyntax+  checkColumnConstraintSyntax expr =+    DuckDBColumnConstraintSyntax+      (emit "CHECK(" <> fromDuckDBExpression expr <> emit ")")+      ( checkColumnConstraintSyntax+          ( BeamSerializedExpression+              (duckDBRenderSyntaxScript (fromDuckDBExpression expr))+          )+      )+  referencesConstraintSyntax tbl fields matchType onUpdate onDelete =+    DuckDBColumnConstraintSyntax syntax serialized+    where+      syntax =+        emit "REFERENCES "+          <> quotedIdentifier tbl+          <> emit "("+          <> sepBy (emit ", ") (map quotedIdentifier fields)+          <> emit ")"+          <> maybe mempty (\m -> emit " " <> fromDuckDBMatchType m) matchType+          <> maybe mempty (\a -> emit " ON UPDATE " <> fromDuckDBReferentialAction a) onUpdate+          <> maybe mempty (\a -> emit " ON DELETE " <> fromDuckDBReferentialAction a) onDelete+      serialized =+        referencesConstraintSyntax+          tbl+          fields+          (fmap duckDBMatchTypeSerialized matchType)+          (fmap duckDBReferentialActionSerialized onUpdate)+          (fmap duckDBReferentialActionSerialized onDelete)++instance IsSql92CreateDropIndexSyntax DuckDBCommandSyntax where+  type Sql92CreateIndexOptionsSyntax DuckDBCommandSyntax = DuckDBIndexOptions++  defaultIndexOptions = DuckDBIndexOptions {duckDBIndexUnique = False}++  createIndexCmd idxNm tblNm cols opts =+    DuckDBCommandSyntax $+      emit (if duckDBIndexUnique opts then "CREATE UNIQUE INDEX " else "CREATE INDEX ")+        <> quotedIdentifier idxNm+        <> emit " ON "+        <> fromDuckDBTableName tblNm+        <> parens (sepBy (emit ", ") (NE.toList (fmap quotedIdentifier cols)))++  dropIndexCmd idxNm =+    DuckDBCommandSyntax (emit "DROP INDEX " <> quotedIdentifier idxNm)++  serializeIndexOptions opts =+    object ["unique" .= duckDBIndexUnique opts]++  deserializeIndexOptions =+    withObject "DuckDBIndexOptions" $ \v ->+      DuckDBIndexOptions <$> v .: "unique"++instance IsSql92UniqueIndexSyntax DuckDBCommandSyntax where+  setUniqueIndexOptions u opts = opts {duckDBIndexUnique = u}+  indexIsUnique = duckDBIndexUnique  optPrec :: Maybe Word -> DuckDBSyntax optPrec Nothing = mempty
src/Database/Beam/DuckDB/Syntax/Builder.hs view
@@ -5,11 +5,13 @@   ( DuckDBSyntax (..),     SomeField (..),     emitChar,+    emitCharLit,     emit,     emitIntegral,     emitRealFloat,     emitScientific,     emit',+    emitTextLit,     emitValue,     spaces,     parens,@@ -17,11 +19,14 @@     commas,     quotedIdentifier,     withPlaceholder,+    duckDBRenderSyntaxScript,   ) where  import Data.DList (DList) import qualified Data.DList as DL+import Data.Hashable (Hashable (..))+import Data.Scientific (Scientific) import Data.Text (Text) import qualified Data.Text as Text import qualified Data.Text.Lazy as Text.Lazy@@ -29,11 +34,10 @@ import qualified Data.Text.Lazy.Builder as Builder import qualified Data.Text.Lazy.Builder.Int as Builder import qualified Data.Text.Lazy.Builder.RealFloat as Builder+import qualified Data.Text.Lazy.Builder.Scientific as Builder.Scientific import Database.Beam.Backend (Sql92DisplaySyntax (..)) import Database.DuckDB.Simple (ToField (toField)) import Database.DuckDB.Simple.ToField (renderFieldBinding)-import Data.Scientific (Scientific)-import qualified Data.Text.Lazy.Builder.Scientific as Builder.Scientific  data SomeField = forall a. (ToField a, Eq a) => SomeField a @@ -66,6 +70,22 @@ emit' :: (Show a) => a -> DuckDBSyntax emit' s = DuckDBSyntax (const (Builder.fromString (show s))) mempty +-- | Render a 'Text' as a single-quoted SQL string literal, e.g. @|@ -> @'|'@.+-- DuckDB requires this for option values like @DELIMITER@, @NULLSTR@, etc.+emitTextLit :: Text -> DuckDBSyntax+emitTextLit t = emitChar '\'' <> emit t <> emitChar '\''++-- | Render a 'Char' as a single-quoted SQL string literal, e.g. @"@ -> @'"'@.+-- The apostrophe character is escaped per the SQL standard by doubling,+-- so @\'@ renders as @''''@.+--+-- The emitter places no constraint on the input 'Char'; format-specific+-- runtime constraints (e.g. DuckDB's CSV @QUOTE@ option requiring exactly+-- 1 byte in UTF-8) are enforced by DuckDB at execute time, not here.+emitCharLit :: Char -> DuckDBSyntax+emitCharLit '\'' = emit "''''"+emitCharLit c = emitChar '\'' <> emitChar c <> emitChar '\''+ -- | Emit a properly escaped value into the syntax emitValue :: (ToField a, Eq a) => a -> DuckDBSyntax emitValue v =@@ -104,6 +124,10 @@     -- TODO: is there a cleaner way to do this? Is it even important to have this instance?     show vx == show vy       && withPlaceholder sx == withPlaceholder sy++instance Hashable DuckDBSyntax where+  hashWithSalt salt (DuckDBSyntax s vs) =+    hashWithSalt salt (Builder.toLazyText (withPlaceholder s), show vs)  instance Semigroup DuckDBSyntax where   DuckDBSyntax sx vx <> DuckDBSyntax sy vy =
+ src/Database/Beam/DuckDB/Syntax/Extensions.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Database.Beam.DuckDB.Syntax.Extensions () where++import Data.Proxy (Proxy (..))+import Database.Beam (Beamable, TableSettings)+import Database.Beam.Backend.SQL+  ( IsSql92ExpressionSyntax (..),+    IsSql92FieldNameSyntax (..),+    MonadBeam (runReturningList),+  )+import Database.Beam.Backend.SQL.BeamExtensions+  ( MonadBeamDeleteReturning (..),+    MonadBeamInsertReturning (..),+    MonadBeamUpdateReturning (..),+  )+import Database.Beam.DuckDB.Backend (DuckDB)+import Database.Beam.DuckDB.Connection (DuckDBM)+import Database.Beam.DuckDB.Syntax+  ( DuckDBCommandSyntax (..),+    DuckDBDeleteSyntax (..),+    DuckDBExpressionSyntax (..),+    DuckDBInsertSyntax (..),+    DuckDBUpdateSyntax (..),+  )+import Database.Beam.DuckDB.Syntax.Builder (DuckDBSyntax, commas, emit)+import Database.Beam.DuckDB.Syntax.Extensions.InsertOnConflict ()+import Database.Beam.Query (SqlDelete (..), SqlInsert (..), SqlUpdate (..))+import Database.Beam.Query.Internal (Projectible, QExpr, QGenExpr (..), project)+import Database.Beam.Schema.Tables (Columnar' (..), changeBeamRep, _fieldName)++instance MonadBeamInsertReturning DuckDB DuckDBM where+  -- \| @since 0.3.1.0+  runInsertReturningListWith SqlInsertNoRows _ = pure []+  runInsertReturningListWith (SqlInsert tblSettings (DuckDBInsertSyntax syntax)) mkProjection =+    runReturningList+      (DuckDBCommandSyntax (syntax <> returningClause tblSettings mkProjection))++instance MonadBeamUpdateReturning DuckDB DuckDBM where+  -- \| @since 0.3.1.0+  runUpdateReturningListWith SqlIdentityUpdate _ = pure []+  runUpdateReturningListWith (SqlUpdate tblSettings (DuckDBUpdateSyntax syntax)) mkProjection =+    runReturningList+      (DuckDBCommandSyntax (syntax <> returningClause tblSettings mkProjection))++instance MonadBeamDeleteReturning DuckDB DuckDBM where+  -- \| @since 0.3.1.0+  runDeleteReturningListWith (SqlDelete tblSettings (DuckDBDeleteSyntax syntax)) mkProjection =+    runReturningList+      (DuckDBCommandSyntax (syntax <> returningClause tblSettings mkProjection))++-- | Build @RETURNING e1, e2, ...@ for a projection over the given table's+-- fields, using unqualified field references (DuckDB does not require a+-- table prefix in @RETURNING@ projections).+returningClause ::+  forall table a.+  (Beamable table, Projectible DuckDB a) =>+  TableSettings table ->+  (table (QExpr DuckDB ()) -> a) ->+  DuckDBSyntax+returningClause tblSettings mkProjection =+  emit " RETURNING "+    <> commas+      ( map+          fromDuckDBExpression+          (project (Proxy @DuckDB) (mkProjection tblQ) "t")+      )+  where+    tblQ :: table (QExpr DuckDB ())+    tblQ =+      changeBeamRep+        ( \(Columnar' f) ->+            Columnar' (QExpr (\_ -> fieldE (unqualifiedField (_fieldName f))))+        )+        tblSettings
+ src/Database/Beam/DuckDB/Syntax/Extensions/Copy.hs view
@@ -0,0 +1,500 @@+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+{-# OPTIONS_GHC -Wno-unused-top-binds #-}++module Database.Beam.DuckDB.Syntax.Extensions.Copy+  ( DuckDBCopyToSyntax (..),+    DuckDBCopyFromSyntax (..),++    -- * COPY options+    DuckDBCopyToOptions,+    DuckDBCopyFromOptions,++    -- ** CSV+    copyToCSV,+    copyToCSVWith,+    DuckDBCSVCopyToOptions (..),+    defaultDuckDBCSVCopyToOptions,+    copyFromCSV,+    copyFromCSVWith,+    DuckDBCSVCopyFromOptions (..),+    defaultDuckDBCSVCopyFromOptions,++    -- ** Parquet+    copyToParquet,+    copyToParquetWith,+    DuckDBParquetCopyToOptions (..),+    ParquetCompression (..),+    defaultDuckDBParquetCopyToOptions,+    copyFromParquet,+    copyFromParquetWith,+    DuckDBParquetCopyFromOptions (..),+    defaultDuckDBParquetCopyFromOptions,++    -- ** JSON+    copyToJSON,+    copyToJSONWith,+    DuckDBJSONCopyToOptions (..),+    JSONCompression (..),+    defaultDuckDBJSONCopyToOptions,+    copyFromJSON,+    copyFromJSONWith,+    DuckDBJSONCopyFromOptions (..),+    defaultDuckDBJSONCopyFromOptions,+  )+where++import Data.Coerce (coerce)+import Data.List.NonEmpty (NonEmpty)+import qualified Data.List.NonEmpty as NE+import Data.Maybe (catMaybes)+import Data.Text (Text)+import qualified Data.Text as Text+import Database.Beam.Backend.SQL.BeamExtensions+import Database.Beam.DuckDB.Syntax (DuckDBSelectSyntax (..))+import Database.Beam.DuckDB.Syntax.Builder (DuckDBSyntax, commas, emit, emit', emitChar, emitCharLit, emitIntegral, emitTextLit, parens, quotedIdentifier, sepBy)++-- | DuckDB @COPY ... TO@ source syntax. Wraps the table name (with+-- optional column list) or a @SELECT@ subquery.+newtype DuckDBCopyToSourceSyntax = DuckDBCopyToSourceSyntax {fromDuckDBCopyToSource :: DuckDBSyntax}++-- | DuckDB @COPY ... TO@ statement syntax.+newtype DuckDBCopyToSyntax = DuckDBCopyToSyntax {fromDuckDBCopyTo :: DuckDBSyntax}++instance IsSqlCopyToSourceSyntax DuckDBCopyToSourceSyntax where+  type SqlCopyToSourceSelectSyntax DuckDBCopyToSourceSyntax = DuckDBSelectSyntax++  copyTableToSyntax mSchema tableName' mColumns =+    DuckDBCopyToSourceSyntax $+      maybe (emit tableName') (\schema -> emit schema <> emitChar '.' <> emit tableName') mSchema+        <> case mColumns of+          Nothing -> mempty+          Just cols -> parens $ commas (map quotedIdentifier (NE.toList cols))++  copySelectToSyntax (DuckDBSelectSyntax select) = DuckDBCopyToSourceSyntax $ parens select++instance IsSqlCopyToSyntax DuckDBCopyToSyntax where+  type SqlCopyToSourceSyntax DuckDBCopyToSyntax = DuckDBCopyToSourceSyntax+  type SqlCopyToParams DuckDBCopyToSyntax = DuckDBCopyToOptions++  copyToStmt source options =+    let (filepath, optionsSyntax) = emitDuckDBCopyToOptions options+     in DuckDBCopyToSyntax $+          mconcat+            [ emit "COPY ",+              coerce source,+              emit " TO ",+              emitChar '\'',+              emit (Text.pack filepath),+              emitChar '\'',+              optionsSyntax+            ]++-- | DuckDB @COPY ... FROM@ source syntax. Just the table name plus optional+-- column list.+newtype DuckDBCopyFromSourceSyntax = DuckDBCopyFromSourceSyntax {fromDuckDBCopyFromSource :: DuckDBSyntax}++-- | DuckDB @COPY ... FROM@ statement syntax.+newtype DuckDBCopyFromSyntax = DuckDBCopyFromSyntax {fromDuckDBCopyFrom :: DuckDBSyntax}++instance IsSqlCopyFromSourceSyntax DuckDBCopyFromSourceSyntax where+  copyTableFromSyntax mSchema tableName' mColumns =+    DuckDBCopyFromSourceSyntax $+      maybe (emit tableName') (\schema -> emit schema <> emitChar '.' <> emit tableName') mSchema+        <> case mColumns of+          Nothing -> mempty+          Just cols -> parens $ commas (map quotedIdentifier (NE.toList cols))++instance IsSqlCopyFromSyntax DuckDBCopyFromSyntax where+  type SqlCopyFromSourceSyntax DuckDBCopyFromSyntax = DuckDBCopyFromSourceSyntax+  type SqlCopyFromParams DuckDBCopyFromSyntax = DuckDBCopyFromOptions++  copyFromStmt source options =+    let (filepath, optionsSyntax) = emitDuckDBCopyFromOptions options+     in DuckDBCopyFromSyntax $+          mconcat+            [ emit "COPY ",+              coerce source,+              emit " FROM ",+              emitChar '\'',+              emit (Text.pack filepath),+              emitChar '\'',+              optionsSyntax+            ]++-- | Options for DuckDB's @COPY ... TO@ statement, tagged by the output format.+--+-- Use 'copyToCSV', 'copyToParquet', and 'copyToJSON' to construct values of type 'DuckDBCopyToOptions'.+--+-- See <https://duckdb.org/docs/sql/statements/copy.html the DuckDB COPY documentation>+-- for the full set of options each format supports.+--+-- @since 0.3.1.0+data DuckDBCopyToOptions+  = DuckDBCopyToCSV !FilePath !DuckDBCSVCopyToOptions+  | DuckDBCopyToParquet !FilePath !DuckDBParquetCopyToOptions+  | DuckDBCopyToJSON !FilePath !DuckDBJSONCopyToOptions+  deriving (Eq, Show)++-- | Copy to a CSV file at the given path with default options.+-- Use 'copyToCSVWith' to override.+--+-- @since 0.3.1.0+copyToCSV :: FilePath -> DuckDBCopyToOptions+copyToCSV path = DuckDBCopyToCSV path defaultDuckDBCSVCopyToOptions++-- | Copy to a CSV file at the given path with the given options.+--+-- @since 0.3.1.0+copyToCSVWith :: FilePath -> DuckDBCSVCopyToOptions -> DuckDBCopyToOptions+copyToCSVWith = DuckDBCopyToCSV++-- | Copy to a Parquet file at the given path with default options.+-- Use 'copyToParquetWith' to override.+--+-- @since 0.3.1.0+copyToParquet :: FilePath -> DuckDBCopyToOptions+copyToParquet path = DuckDBCopyToParquet path defaultDuckDBParquetCopyToOptions++-- | Copy to a Parquet file at the given path with the given options.+--+-- @since 0.3.1.0+copyToParquetWith :: FilePath -> DuckDBParquetCopyToOptions -> DuckDBCopyToOptions+copyToParquetWith = DuckDBCopyToParquet++-- | Copy to a JSON file at the given path with default options.+-- Use 'copyToJSONWith' to override.+--+-- @since 0.3.1.0+copyToJSON :: FilePath -> DuckDBCopyToOptions+copyToJSON path = DuckDBCopyToJSON path defaultDuckDBJSONCopyToOptions++-- | Copy to a JSON file at the given path with the given options.+--+-- @since 0.3.1.0+copyToJSONWith :: FilePath -> DuckDBJSONCopyToOptions -> DuckDBCopyToOptions+copyToJSONWith = DuckDBCopyToJSON++-- | CSV-specific options for @COPY ... TO@.+--+-- @since 0.3.1.0+data DuckDBCSVCopyToOptions = DuckDBCSVCopyToOptions+  { csvCopyToHeader :: Maybe Bool,+    csvCopyToDelimiter :: Maybe Text,+    csvCopyToQuote :: Maybe Char,+    csvCopyToEscape :: Maybe Char,+    csvCopyToNullStr :: Maybe Text,+    csvCopyToDateformat :: Maybe Text,+    csvCopyToTimestampformat :: Maybe Text,+    csvCopyToOverwriteOrIgnore :: Maybe Bool+  }+  deriving (Eq, Show)++-- | All-'Nothing' 'DuckDBCSVCopyToOptions'; a sensible starting point for+-- record-update overrides.+--+-- @since 0.3.1.0+defaultDuckDBCSVCopyToOptions :: DuckDBCSVCopyToOptions+defaultDuckDBCSVCopyToOptions =+  DuckDBCSVCopyToOptions+    { csvCopyToHeader = Nothing,+      csvCopyToDelimiter = Nothing,+      csvCopyToQuote = Nothing,+      csvCopyToEscape = Nothing,+      csvCopyToNullStr = Nothing,+      csvCopyToDateformat = Nothing,+      csvCopyToTimestampformat = Nothing,+      csvCopyToOverwriteOrIgnore = Nothing+    }++-- | Parquet-specific options for @COPY ... TO@.+--+-- @since 0.3.1.0+data DuckDBParquetCopyToOptions = DuckDBParquetCopyToOptions+  { parquetCopyToCompression :: Maybe ParquetCompression,+    parquetCopyToOverwriteOrIgnore :: Maybe Bool+  }+  deriving (Eq, Show)++-- | All-'Nothing' 'DuckDBParquetCopyToOptions'; a sensible starting point+-- for record-update overrides.+--+-- @since 0.3.1.0+defaultDuckDBParquetCopyToOptions :: DuckDBParquetCopyToOptions+defaultDuckDBParquetCopyToOptions =+  DuckDBParquetCopyToOptions+    { parquetCopyToCompression = Nothing,+      parquetCopyToOverwriteOrIgnore = Nothing+    }++-- | JSON-specific options for @COPY ... TO@.+--+-- @since 0.3.1.0+data DuckDBJSONCopyToOptions = DuckDBJSONCopyToOptions+  { jsonCopyToCompression :: Maybe JSONCompression,+    jsonCopyToOverwriteOrIgnore :: Maybe Bool+  }+  deriving (Eq, Show)++-- | All-'Nothing' 'DuckDBJSONCopyToOptions'; a sensible starting point for+-- record-update overrides.+--+-- @since 0.3.1.0+defaultDuckDBJSONCopyToOptions :: DuckDBJSONCopyToOptions+defaultDuckDBJSONCopyToOptions =+  DuckDBJSONCopyToOptions+    { jsonCopyToCompression = Nothing,+      jsonCopyToOverwriteOrIgnore = Nothing+    }++-- | Options for DuckDB's @COPY ... FROM@ statement, tagged by the input format.+--+-- Use 'copyFromCSV', 'copyFromParquet', and 'copyFromJSON' to construct values of type 'DuckDBCopyFromOptions'.+--+-- See <https://duckdb.org/docs/sql/statements/copy.html the DuckDB COPY documentation>+-- for the full set of options each format supports.+--+-- @since 0.3.1.0+data DuckDBCopyFromOptions+  = DuckDBCopyFromCSV !FilePath !DuckDBCSVCopyFromOptions+  | DuckDBCopyFromParquet !FilePath !DuckDBParquetCopyFromOptions+  | DuckDBCopyFromJSON !FilePath !DuckDBJSONCopyFromOptions+  deriving (Eq, Show)++-- | Copy from a CSV file at the given path with default options.+-- Use 'copyFromCSVWith' to override.+--+-- @since 0.3.1.0+copyFromCSV :: FilePath -> DuckDBCopyFromOptions+copyFromCSV path = DuckDBCopyFromCSV path defaultDuckDBCSVCopyFromOptions++-- | Copy from a CSV file at the given path with the given options.+--+-- @since 0.3.1.0+copyFromCSVWith :: FilePath -> DuckDBCSVCopyFromOptions -> DuckDBCopyFromOptions+copyFromCSVWith = DuckDBCopyFromCSV++-- | Copy from a Parquet file at the given path with default options.+-- Use 'copyFromParquetWith' to override.+--+-- @since 0.3.1.0+copyFromParquet :: FilePath -> DuckDBCopyFromOptions+copyFromParquet path = DuckDBCopyFromParquet path defaultDuckDBParquetCopyFromOptions++-- | Copy from a Parquet file at the given path with the given options.+--+-- @since 0.3.1.0+copyFromParquetWith :: FilePath -> DuckDBParquetCopyFromOptions -> DuckDBCopyFromOptions+copyFromParquetWith = DuckDBCopyFromParquet++-- | Copy from a JSON file at the given path with default options.+-- Use 'copyFromJSONWith' to override.+--+-- @since 0.3.1.0+copyFromJSON :: FilePath -> DuckDBCopyFromOptions+copyFromJSON path = DuckDBCopyFromJSON path defaultDuckDBJSONCopyFromOptions++-- | Copy from a JSON file at the given path with the given options.+--+-- @since 0.3.1.0+copyFromJSONWith :: FilePath -> DuckDBJSONCopyFromOptions -> DuckDBCopyFromOptions+copyFromJSONWith = DuckDBCopyFromJSON++-- | CSV-specific options for @COPY ... FROM@.+--+-- @since 0.3.1.0+data DuckDBCSVCopyFromOptions = DuckDBCSVCopyFromOptions+  { csvCopyFromHeader :: Maybe Bool,+    csvCopyFromDelimiter :: Maybe Text,+    csvCopyFromQuote :: Maybe Char,+    csvCopyFromEscape :: Maybe Char,+    csvCopyFromNullStr :: Maybe Text,+    csvCopyFromDateformat :: Maybe Text,+    csvCopyFromTimestampformat :: Maybe Text,+    csvCopyFromAutoDetect :: Maybe Bool,+    csvCopyFromIgnoreErrors :: Maybe Bool,+    csvCopyFromSkip :: Maybe Int,+    csvCopyFromAllVarchar :: Maybe Bool+  }+  deriving (Eq, Show)++-- | All-'Nothing' 'DuckDBCSVCopyFromOptions'; a sensible starting point for+-- record-update overrides.+--+-- @since 0.3.1.0+defaultDuckDBCSVCopyFromOptions :: DuckDBCSVCopyFromOptions+defaultDuckDBCSVCopyFromOptions =+  DuckDBCSVCopyFromOptions+    { csvCopyFromHeader = Nothing,+      csvCopyFromDelimiter = Nothing,+      csvCopyFromQuote = Nothing,+      csvCopyFromEscape = Nothing,+      csvCopyFromNullStr = Nothing,+      csvCopyFromDateformat = Nothing,+      csvCopyFromTimestampformat = Nothing,+      csvCopyFromAutoDetect = Nothing,+      csvCopyFromIgnoreErrors = Nothing,+      csvCopyFromSkip = Nothing,+      csvCopyFromAllVarchar = Nothing+    }++-- | Parquet-specific options for @COPY ... FROM@. DuckDB's Parquet reader has+-- almost no read-time options; this is here so the Parquet format can be+-- selected with consistent defaults.+--+-- @since 0.3.1.0+data DuckDBParquetCopyFromOptions = DuckDBParquetCopyFromOptions+  {+  }+  deriving (Eq, Show)++-- | The (only) 'DuckDBParquetCopyFromOptions' value.+--+-- @since 0.3.1.0+defaultDuckDBParquetCopyFromOptions :: DuckDBParquetCopyFromOptions+defaultDuckDBParquetCopyFromOptions = DuckDBParquetCopyFromOptions++-- | JSON-specific options for @COPY ... FROM@.+--+-- @since 0.3.1.0+data DuckDBJSONCopyFromOptions = DuckDBJSONCopyFromOptions+  { jsonCopyFromAutoDetect :: Maybe Bool,+    -- | @COMPRESSION@: how the input file is compressed. DuckDB's JSON+    -- reader normally auto-detects this from the file extension, but+    -- when the path doesn't carry the usual @.gz@ / @.zst@ suffix the+    -- compression has to be specified explicitly so it can be+    -- decompressed on read. Round-tripping a compressed JSON write+    -- requires this to match the matching @TO@-side compression.+    jsonCopyFromCompression :: Maybe JSONCompression+  }+  deriving (Eq, Show)++-- | All-'Nothing' 'DuckDBJSONCopyFromOptions'; a sensible starting point for+-- record-update overrides.+--+-- @since 0.3.1.0+defaultDuckDBJSONCopyFromOptions :: DuckDBJSONCopyFromOptions+defaultDuckDBJSONCopyFromOptions =+  DuckDBJSONCopyFromOptions+    { jsonCopyFromAutoDetect = Nothing,+      jsonCopyFromCompression = Nothing+    }++-- | Compression algorithms accepted by DuckDB's Parquet writer (and reader).+--+-- See <https://duckdb.org/docs/current/sql/statements/copy DuckDB's COPY documentation>;+-- the JSON format accepts a strictly smaller set of values, modelled+-- separately by 'JSONCompression'.+--+-- @since 0.3.1.0+data ParquetCompression+  = ParquetUncompressed+  | ParquetSnappy+  | ParquetGzip+  | ParquetZstd+  deriving (Eq, Show, Ord, Enum, Bounded)++-- | Compression algorithms accepted by DuckDB's JSON writer (and reader).+--+-- DuckDB's JSON format does not support @SNAPPY@, so 'JSONCompression'+-- omits it. See <https://duckdb.org/docs/current/sql/statements/copy DuckDB's COPY documentation>.+--+-- @since 0.3.1.0+data JSONCompression+  = JSONUncompressed+  | JSONGzip+  | JSONZstd+  deriving (Eq, Show, Ord, Enum, Bounded)++emitDuckDBCopyToOptions :: DuckDBCopyToOptions -> (FilePath, DuckDBSyntax)+emitDuckDBCopyToOptions (DuckDBCopyToCSV fp o) = (fp, emitOptionList (emit "CSV") (csvCopyToFields o))+emitDuckDBCopyToOptions (DuckDBCopyToParquet fp o) = (fp, emitOptionList (emit "PARQUET") (parquetCopyToFields o))+emitDuckDBCopyToOptions (DuckDBCopyToJSON fp o) = (fp, emitOptionList (emit "JSON") (jsonCopyToFields o))++emitDuckDBCopyFromOptions :: DuckDBCopyFromOptions -> (FilePath, DuckDBSyntax)+emitDuckDBCopyFromOptions (DuckDBCopyFromCSV fp o) = (fp, emitOptionList (emit "CSV") (csvCopyFromFields o))+emitDuckDBCopyFromOptions (DuckDBCopyFromParquet fp o) = (fp, emitOptionList (emit "PARQUET") (parquetCopyFromFields o))+emitDuckDBCopyFromOptions (DuckDBCopyFromJSON fp o) = (fp, emitOptionList (emit "JSON") (jsonCopyFromFields o))++-- | Render a non-empty list of options like @ (FORMAT FMT, OPT1 …, OPT2 …)@.+-- The @FORMAT@ entry is always present, since the constructor of the options+-- value pins it.+emitOptionList :: DuckDBSyntax -> [DuckDBSyntax] -> DuckDBSyntax+emitOptionList fmt items =+  emit " ("+    <> sepBy (emit ", ") ((emit "FORMAT " <> fmt) : items)+    <> emitChar ')'++csvCopyToFields :: DuckDBCSVCopyToOptions -> [DuckDBSyntax]+csvCopyToFields opts =+  catMaybes+    [ fmap (\x -> emit "HEADER " <> emit' x) (csvCopyToHeader opts),+      fmap (\x -> emit "DELIMITER " <> emitTextLit x) (csvCopyToDelimiter opts),+      fmap (\x -> emit "QUOTE " <> emitCharLit x) (csvCopyToQuote opts),+      fmap (\x -> emit "ESCAPE " <> emitCharLit x) (csvCopyToEscape opts),+      fmap (\x -> emit "NULLSTR " <> emitTextLit x) (csvCopyToNullStr opts),+      fmap (\x -> emit "DATEFORMAT " <> emitTextLit x) (csvCopyToDateformat opts),+      fmap (\x -> emit "TIMESTAMPFORMAT " <> emitTextLit x) (csvCopyToTimestampformat opts),+      fmap (\x -> emit "OVERWRITE_OR_IGNORE " <> emit' x) (csvCopyToOverwriteOrIgnore opts)+    ]++parquetCopyToFields :: DuckDBParquetCopyToOptions -> [DuckDBSyntax]+parquetCopyToFields opts =+  catMaybes+    [ fmap (\x -> emit "COMPRESSION " <> emitParquetCompression x) (parquetCopyToCompression opts),+      fmap (\x -> emit "OVERWRITE_OR_IGNORE " <> emit' x) (parquetCopyToOverwriteOrIgnore opts)+    ]++jsonCopyToFields :: DuckDBJSONCopyToOptions -> [DuckDBSyntax]+jsonCopyToFields opts =+  catMaybes+    [ fmap (\x -> emit "COMPRESSION " <> emitJSONCompression x) (jsonCopyToCompression opts),+      fmap (\x -> emit "OVERWRITE_OR_IGNORE " <> emit' x) (jsonCopyToOverwriteOrIgnore opts)+    ]++csvCopyFromFields :: DuckDBCSVCopyFromOptions -> [DuckDBSyntax]+csvCopyFromFields opts =+  catMaybes+    [ fmap (\x -> emit "HEADER " <> emit' x) (csvCopyFromHeader opts),+      fmap (\x -> emit "DELIMITER " <> emitTextLit x) (csvCopyFromDelimiter opts),+      fmap (\x -> emit "QUOTE " <> emitCharLit x) (csvCopyFromQuote opts),+      fmap (\x -> emit "ESCAPE " <> emitCharLit x) (csvCopyFromEscape opts),+      fmap (\x -> emit "NULLSTR " <> emitTextLit x) (csvCopyFromNullStr opts),+      fmap (\x -> emit "DATEFORMAT " <> emitTextLit x) (csvCopyFromDateformat opts),+      fmap (\x -> emit "TIMESTAMPFORMAT " <> emitTextLit x) (csvCopyFromTimestampformat opts),+      fmap (\x -> emit "AUTO_DETECT " <> emit' x) (csvCopyFromAutoDetect opts),+      fmap (\x -> emit "IGNORE_ERRORS " <> emit' x) (csvCopyFromIgnoreErrors opts),+      fmap (\x -> emit "SKIP " <> emitIntegral x) (csvCopyFromSkip opts),+      fmap (\x -> emit "ALL_VARCHAR " <> emit' x) (csvCopyFromAllVarchar opts)+    ]++parquetCopyFromFields :: DuckDBParquetCopyFromOptions -> [DuckDBSyntax]+parquetCopyFromFields _ = []++jsonCopyFromFields :: DuckDBJSONCopyFromOptions -> [DuckDBSyntax]+jsonCopyFromFields opts =+  catMaybes+    [ fmap (\x -> emit "AUTO_DETECT " <> emit' x) (jsonCopyFromAutoDetect opts),+      fmap (\x -> emit "COMPRESSION " <> emitJSONCompression x) (jsonCopyFromCompression opts)+    ]++emitPartitionBy :: NonEmpty Text -> DuckDBSyntax+emitPartitionBy cols =+  emit "PARTITION_BY ("+    <> commas (map quotedIdentifier (NE.toList cols))+    <> emitChar ')'++emitParquetCompression :: ParquetCompression -> DuckDBSyntax+emitParquetCompression ParquetUncompressed = emit "UNCOMPRESSED"+emitParquetCompression ParquetSnappy = emit "SNAPPY"+emitParquetCompression ParquetGzip = emit "GZIP"+emitParquetCompression ParquetZstd = emit "ZSTD"++emitJSONCompression :: JSONCompression -> DuckDBSyntax+emitJSONCompression JSONUncompressed = emit "UNCOMPRESSED"+emitJSONCompression JSONGzip = emit "GZIP"+emitJSONCompression JSONZstd = emit "ZSTD"
src/Database/Beam/DuckDB/Syntax/Extensions/DataSource.hs view
@@ -96,7 +96,7 @@ -- | Declare a CSV file (or files) as the source of data for a database. -- -- See 'csv' if you want to use default options.--- +-- -- Note: Be careful to sanitize any user input that is then used as -- a data source. csvWith ::@@ -138,7 +138,7 @@ -- | Define an Apache Iceberg table with default table options. -- -- See 'icebergTableWith' if you want to change the default options.--- +-- -- Note: Be careful to sanitize any user input that is then used as -- a data source. icebergTable ::@@ -150,7 +150,7 @@ -- | Define an Apache Iceberg table with options. -- -- See 'icebergTable' if you want to use default options.--- +-- -- Note: Be careful to sanitize any user input that is then used as -- a data source. icebergTableWith ::@@ -176,7 +176,7 @@ defaultIcebergTableOptions = IcebergTableOptions Nothing Nothing  -- | Declare a Parquet file(s) or glob(s) as the source of data for a database.--- +-- -- Note: Be careful to sanitize any user input that is then used as -- a data source. parquet ::
+ src/Database/Beam/DuckDB/Syntax/Extensions/InsertOnConflict.hs view
@@ -0,0 +1,227 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE InstanceSigs #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Database.Beam.DuckDB.Syntax.Extensions.InsertOnConflict+  ( BeamHasInsertOnConflict+      ( SqlConflictTarget,+        SqlConflictAction,+        insertOnConflict,+        anyConflict,+        conflictingFields,+        onConflictDoNothing,+        onConflictUpdateSet,+        onConflictUpdateSetWhere+      ),+    conflictingFieldsWhere,+    onConflictUpdateAll,+    onConflictUpdateInstead,+    UnsupportedConflictingFieldsWhereOnDuckDB,+  )+where++import Data.Proxy (Proxy (..))+import Database.Beam (Beamable, TableEntity)+import Database.Beam.Backend.SQL+  ( IsSql92ExpressionSyntax (..),+    IsSql92FieldNameSyntax (..),+    IsSql92InsertSyntax (..),+    IsSql92TableNameSyntax (..),+  )+import Database.Beam.Backend.SQL.BeamExtensions+  ( BeamHasInsertOnConflict (SqlConflictAction, SqlConflictTarget),+    onConflictUpdateAll,+    onConflictUpdateInstead,+  )+import qualified Database.Beam.Backend.SQL.BeamExtensions as Beam+import Database.Beam.DuckDB.Backend (DuckDB)+import Database.Beam.DuckDB.Syntax+  ( DuckDBExpressionSyntax (..),+    DuckDBFieldNameSyntax (..),+    DuckDBInsertSyntax (..),+  )+import Database.Beam.DuckDB.Syntax.Builder+  ( DuckDBSyntax,+    commas,+    emit,+    parens,+  )+import Database.Beam.Query (SqlInsert (..), SqlInsertValues (..))+import Database.Beam.Query.Internal+  ( Projectible,+    QAssignment (..),+    QExpr,+    QField (..),+    QGenExpr (..),+    QInternal,+    project,+  )+import Database.Beam.Schema.Tables+  ( Columnar' (..),+    DatabaseEntity (..),+    DatabaseEntityDescriptor (..),+    TableField,+    allBeamValues,+    changeBeamRep,+    _fieldName,+  )+import GHC.TypeLits (ErrorMessage (..), TypeError)++-- | @since 0.3.1.0+instance Beam.BeamHasInsertOnConflict DuckDB where+  newtype SqlConflictTarget DuckDB table = DuckDBConflictTarget+    {unDuckDBConflictTarget :: table (QExpr DuckDB QInternal) -> DuckDBSyntax}+  newtype SqlConflictAction DuckDB table = DuckDBConflictAction+    {unDuckDBConflictAction :: forall s. table (QField s) -> DuckDBSyntax}++  insertOnConflict ::+    forall table db s.+    (Beamable table) =>+    DatabaseEntity DuckDB db (TableEntity table) ->+    SqlInsertValues DuckDB (table (QExpr DuckDB s)) ->+    SqlConflictTarget DuckDB table ->+    SqlConflictAction DuckDB table ->+    SqlInsert DuckDB table+  insertOnConflict (DatabaseEntity dt) values target action = case values of+    SqlInsertValuesEmpty -> SqlInsertNoRows+    SqlInsertValues vs ->+      let tblSettings = dbTableSettings dt+          mkField ::+            forall a.+            Columnar' (TableField table) a ->+            Columnar' (QField QInternal) a+          mkField (Columnar' fd) = Columnar' $ QField False (dbTableCurrentName dt) (_fieldName fd)+          tblFields = changeBeamRep mkField tblSettings+          tblExprs =+            changeBeamRep+              ( \(Columnar' (QField _ _ nm)) ->+                  Columnar' (QExpr (\_ -> fieldE (unqualifiedField nm)))+              )+              tblFields+          fieldList = allBeamValues (\(Columnar' f) -> _fieldName f) tblSettings+          DuckDBInsertSyntax baseInsert =+            insertStmt (tableName (dbTableSchema dt) (dbTableCurrentName dt)) fieldList vs+       in SqlInsert tblSettings $+            DuckDBInsertSyntax $+              baseInsert+                <> emit " ON CONFLICT "+                <> unDuckDBConflictTarget target tblExprs+                <> unDuckDBConflictAction action tblFields++  anyConflict = DuckDBConflictTarget (const mempty)++  conflictingFields makeProjection =+    DuckDBConflictTarget $ \tbl ->+      parens+        ( commas $+            map fromDuckDBExpression $+              project (Proxy @DuckDB) (makeProjection tbl) "t"+        )+        <> emit " "++  -- \| DuckDB does not support partial-index conflict targets, so calling this is always a bug.+  -- The supported way to invoke this method from @Database.Beam.DuckDB@ goes through the shadowing+  -- 'conflictingFieldsWhere' below, which produces a /compile-time/ error pointing users at 'onConflictUpdateSetWhere'.+  conflictingFieldsWhere _ _ =+    error+      "Database.Beam.DuckDB: `conflictingFieldsWhere` is not supported on DuckDB. \+      \DuckDB does not support partial-index conflict targets — use \+      \`onConflictUpdateSetWhere` to filter the rows that are updated instead."++  onConflictDoNothing = DuckDBConflictAction (const (emit "DO NOTHING"))++  onConflictUpdateSet mkAssignments =+    DuckDBConflictAction $ \tbl ->+      let QAssignment assignments = mkAssignments tbl (excluded tbl)+          assignmentSyntaxes =+            [ fromDuckDBFieldName fieldNm+                <> emit "="+                <> parens (fromDuckDBExpression expr)+            | (fieldNm, expr) <- assignments+            ]+       in emit "DO UPDATE SET " <> commas assignmentSyntaxes++  onConflictUpdateSetWhere mkAssignments where_ =+    DuckDBConflictAction $ \tbl ->+      let QAssignment assignments = mkAssignments tbl (excluded tbl)+          QExpr where_' = where_ tbl (excluded tbl)+          assignmentSyntaxes =+            [ fromDuckDBFieldName fieldNm+                <> emit "="+                <> parens (fromDuckDBExpression expr)+            | (fieldNm, expr) <- assignments+            ]+       in emit "DO UPDATE SET "+            <> commas assignmentSyntaxes+            <> emit " WHERE "+            <> fromDuckDBExpression (where_' "t")++-- | Build a table reference whose fields point to the special @excluded@+-- pseudo-table available inside DuckDB's @ON CONFLICT DO UPDATE@ — i.e. the+-- row that would have been inserted had there been no conflict.+excluded ::+  (Beamable table) =>+  table (QField s) ->+  table (QExpr DuckDB s)+excluded =+  changeBeamRep+    ( \(Columnar' (QField _ _ nm)) ->+        Columnar' (QExpr (\_ -> fieldE (qualifiedField "excluded" nm)))+    )++-- | A class with a single 'TypeError'-guarded instance, used purely to+-- defer firing of the type error until a /use site/ of+-- 'conflictingFieldsWhere'. The instance context is only discharged+-- when GHC actually selects this instance, which means defining a+-- method body in terms of it (as @'conflictingFieldsWhere' = …@) does+-- not trip the error at module load time.+--+-- = References+--+-- DuckDB's binder unconditionally rejects @ON CONFLICT (cols) WHERE …@:+-- see @GenerateMergeInto@ in+-- <https://github.com/duckdb/duckdb/blob/main/src/planner/binder/statement/bind_insert.cpp duckdb/duckdb \@ src\/planner\/binder\/statement\/bind_insert.cpp>,+-- which throws @"ON CONFLICT WHERE clause is only supported in DO UPDATE+-- SET ... WHERE ... The WHERE clause after the conflict columns is used+-- for partial indexes which are not supported."@. The user-facing+-- <https://duckdb.org/docs/stable/sql/statements/insert INSERT docs>+-- describe both @WHERE@ positions, but only the trailing-update form+-- actually binds; the index-predicate form (used in PostgreSQL to+-- arbitrate between /partial/ unique indexes) has nothing to match+-- against in DuckDB.+--+-- @since 0.3.1.0+class UnsupportedConflictingFieldsWhereOnDuckDB where+  -- | A 'conflictingFieldsWhere' that always fails to compile when+  -- used against DuckDB. Re-exported from "Database.Beam.DuckDB" so+  -- that it shadows the class method of the same name from+  -- "Database.Beam.Backend.SQL.BeamExtensions".+  --+  -- @since 0.3.1.0+  conflictingFieldsWhere ::+    forall table proj.+    (Projectible DuckDB proj, Beamable table) =>+    (table (QExpr DuckDB QInternal) -> proj) ->+    (forall s. table (QExpr DuckDB s) -> QExpr DuckDB s Bool) ->+    SqlConflictTarget DuckDB table++instance+  (TypeError DuckDBConflictingFieldsWhereError) =>+  UnsupportedConflictingFieldsWhereOnDuckDB+  where+  conflictingFieldsWhere _ _ = error "unreachable: TypeError fires first"++type DuckDBConflictingFieldsWhereError =+  'Text "`conflictingFieldsWhere` is not supported on the DuckDB backend."+    ':$$: 'Text "DuckDB does not support partial-index conflict targets"+    ':$$: 'Text "(the WHERE clause between the conflict columns and DO)."+    ':$$: 'Text "Use `onConflictUpdateSetWhere` to filter the rows that are updated instead."
tests/Database/Beam/DuckDB/Test/Extensions.hs view
@@ -1,365 +1,17 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE ExplicitForAll #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE StandaloneDeriving #-}-{-# LANGUAGE TypeApplications #-}-{-# LANGUAGE TypeFamilies #-}- module Database.Beam.DuckDB.Test.Extensions (tests) where -import Data.Int (Int32)-import Data.List.NonEmpty (NonEmpty ((:|)))-import Data.Text (Text)-import Data.Time (Day, fromGregorian)-import Database.Beam-  ( Beamable,-    Columnar,-    Database,-    DatabaseSettings,-    Generic,-    Identity,-    Table (..),-    aggregate_,-    as_,-    countAll_,-    dbModification,-    defaultDbSettings,-    guard_,-    min_,-    runSelectReturningList,-    runSelectReturningOne,-    select,-    tableModification,-    val_,-    withDbModification,-    (==.),-  )-import Database.Beam.DuckDB-  ( CSVOptions (..),-    DataSourceEntity,-    DuckDB,-    allFromDataSource_,-    allowMovedPaths,-    csvWith,-    dataSource,-    defaultCSVOptions,-    defaultIcebergTableOptions,-    icebergTableWith,-    modifyDataSourceFields,-    parquet,-    runBeamDuckDB,-  )-import Database.DuckDB.Simple (Connection, withConnection)+import qualified Database.Beam.DuckDB.Test.Extensions.Copy as Copy+import qualified Database.Beam.DuckDB.Test.Extensions.DataSource as DataSource+import qualified Database.Beam.DuckDB.Test.Extensions.InsertOnConflict as InsertOnConflict+import qualified Database.Beam.DuckDB.Test.Extensions.Returning as Returning import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))  tests :: TestTree tests =   testGroup     "Extensions"-    [ testGroup-        "Parquet"-        [ testCountFromParquet,-          testQueryFromParquet,-          testCountFromMultipleParquetFiles,-          testQueryFromMultipleParquetFiles-        ],-      testGroup-        "Apache Iceberg"-        [ testCountFromIceberg,-          testParseDateFromIceberg-        ],-      testGroup-        "CSV"-        [ testCountFromCSV,-          testQueryFromCSV-        ]+    [ Copy.tests,+      DataSource.tests,+      InsertOnConflict.tests,+      Returning.tests     ]--testCountFromParquet :: TestTree-testCountFromParquet = testCase "Counting records from a Parquet file" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningList $-        select $ do-          exam <- allFromDataSource_ (_dbExams testDb)-          pure (_examName exam)-  results @?= ["alice", "bob", "carol", "dave"]--testQueryFromParquet :: TestTree-testQueryFromParquet = testCase "Query from a Parquet file" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $ do-          exam <- allFromDataSource_ (_dbExams testDb)-          guard_ (_examId exam ==. 1)-          pure (_examName exam)-  results @?= Just "alice"--testCountFromMultipleParquetFiles :: TestTree-testCountFromMultipleParquetFiles = testCase "Counting records from multiple Parquet file" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningList $-        select $ do-          exam <- allFromDataSource_ (_dbExamsMulti testDb)-          pure (_examName exam)-  results @?= ["alice", "bob", "carol", "dave", "erika", "francis", "genevieve", "hugo"]--testQueryFromMultipleParquetFiles :: TestTree-testQueryFromMultipleParquetFiles = testCase "Query from multiple Parquet files" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $ do-          exam <- allFromDataSource_ (_dbExamsMulti testDb)-          guard_ (_examId exam ==. 5)-          pure (_examName exam)-  results @?= Just "erika"--testCountFromIceberg :: TestTree-testCountFromIceberg = testCase "Counting records from an Apache Iceberg table" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $-          aggregate_ (\_ -> as_ @Int32 countAll_) (allFromDataSource_ (_dbLineItems testDb))-  results @?= Just 51793 -- From DuckDB's documentation--testParseDateFromIceberg :: TestTree-testParseDateFromIceberg = testCase "Test parsing date columns from Apache Iceberg table" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $-          aggregate_ (min_ . _lineitemShipdate) (allFromDataSource_ (_dbLineItems testDb))--  results @?= Just (Just (fromGregorian 1992 01 04))--testCountFromCSV :: TestTree-testCountFromCSV = testCase "Counting records from a CSV file" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $-          aggregate_ (\_ -> as_ @Int32 countAll_) (allFromDataSource_ (_dbFlights testDb))-  results @?= Just 3--testQueryFromCSV :: TestTree-testQueryFromCSV = testCase "Query from a CSV file" $ do-  results <- withTestDb $ \conn ->-    runBeamDuckDB conn $-      runSelectReturningOne $-        select $ do-          flight <- allFromDataSource_ (_dbFlights testDb)-          guard_ (_flightDate flight ==. val_ (fromGregorian 1988 01 01))-          pure flight-  results-    @?= Just-      ( Flight-          { _flightDate = fromGregorian 1988 01 01,-            _flightUniqueCarrier = "AA",-            _flightOriginCity = "New York, NY",-            _flightDestCity = "Los Angeles, CA"-          }-      )--data ExamT f = Exam-  { _examId :: Columnar f Int32,-    _examName :: Columnar f Text,-    _examScore :: Columnar f Double,-    _examDate :: Columnar f Day-  }-  deriving (Generic)--type Exam = ExamT Identity--type ExamId = PrimaryKey ExamT Identity--deriving instance Show ExamId--deriving instance Eq ExamId--deriving instance Ord ExamId--deriving instance Show Exam--deriving instance Eq Exam--deriving instance Ord Exam--instance Beamable ExamT--instance Table ExamT where-  data PrimaryKey ExamT f = ExamId (Columnar f Int32)-    deriving (Generic)-  primaryKey = ExamId . _examId--instance Beamable (PrimaryKey ExamT)--data LineItemT f = Lineitem-  { _lineitemOrderkey :: Columnar f Int32,-    _lineitemPartkey :: Columnar f Int32,-    _lineitemSuppkey :: Columnar f Int32,-    _lineitemLinenumber :: Columnar f Int32,-    _lineitemQuantity :: Columnar f Double,-    _lineitemExtendedprice :: Columnar f Double,-    _lineitemDiscount :: Columnar f Double,-    _lineitemTax :: Columnar f Double,-    _lineitemReturnflag :: Columnar f Text,-    _lineitemLinestatus :: Columnar f Text,-    _lineitemShipdate :: Columnar f Day,-    _lineitemCommitdate :: Columnar f Day,-    _lineitemReceiptdate :: Columnar f Day,-    _lineitemShipinstruct :: Columnar f Text,-    _lineitemShipmode :: Columnar f Text,-    _lineitemComment :: Columnar f Text-  }-  deriving (Generic)--type Lineitem = LineItemT Identity--deriving instance Show Lineitem--deriving instance Eq Lineitem--instance Beamable LineItemT--instance Table LineItemT where-  data PrimaryKey LineItemT f-    = LineItemKey (Columnar f Int32) (Columnar f Int32)-    deriving (Generic)-  primaryKey li =-    LineItemKey (_lineitemOrderkey li) (_lineitemLinenumber li)--instance Beamable (PrimaryKey LineItemT)--deriving instance Show (PrimaryKey LineItemT Identity)--deriving instance Eq (PrimaryKey LineItemT Identity)--data FlightT f = Flight-  { _flightDate :: Columnar f Day,-    _flightUniqueCarrier :: Columnar f Text,-    _flightOriginCity :: Columnar f Text,-    _flightDestCity :: Columnar f Text-  }-  deriving (Generic)--type Flight = FlightT Identity--deriving instance Show Flight--deriving instance Eq Flight--instance Beamable FlightT--instance Table FlightT where-  data PrimaryKey FlightT f-    = FlightKey-        (Columnar f Day)-        (Columnar f Text)-        (Columnar f Text)-        (Columnar f Text)-    deriving (Generic)-  primaryKey fl =-    FlightKey-      (_flightDate fl)-      (_flightUniqueCarrier fl)-      (_flightOriginCity fl)-      (_flightDestCity fl)--instance Beamable (PrimaryKey FlightT)--deriving instance Show (PrimaryKey FlightT Identity)--deriving instance Eq (PrimaryKey FlightT Identity)--data TestDB f = TestDB-  { _dbExams :: f (DataSourceEntity ExamT),-    _dbExamsMulti :: f (DataSourceEntity ExamT), -- set up with multiple parquet files-    _dbLineItems :: f (DataSourceEntity LineItemT),-    _dbFlights :: f (DataSourceEntity FlightT)-  }-  deriving (Generic, Database DuckDB)--testDb :: DatabaseSettings DuckDB TestDB-testDb =-  defaultDbSettings-    `withDbModification` (dbModification @_ @DuckDB)-      { _dbExams =-          dataSource-            (parquet ("tests/data/test1.parquet" :| []))-            <> modifyDataSourceFields-              tableModification-                { _examId = "id",-                  _examName = "name",-                  _examScore = "score",-                  _examDate = "exam_date"-                },-        _dbExamsMulti =-          dataSource-            ( parquet-                ("tests/data/test1.parquet" :| ["tests/data/test2.parquet"])-            )-            <> modifyDataSourceFields-              tableModification-                { _examId = "id",-                  _examName = "name",-                  _examScore = "score",-                  _examDate = "exam_date"-                },-        _dbLineItems =-          dataSource-            ( icebergTableWith-                "tests/data/lineitem_iceberg"-                (defaultIcebergTableOptions {allowMovedPaths = Just True})-            )-            <> modifyDataSourceFields-              tableModification-                { _lineitemOrderkey = "l_orderkey",-                  _lineitemPartkey = "l_partkey",-                  _lineitemSuppkey = "l_suppkey",-                  _lineitemLinenumber = "l_linenumber",-                  _lineitemQuantity = "l_quantity",-                  _lineitemExtendedprice = "l_extendedprice",-                  _lineitemDiscount = "l_discount",-                  _lineitemTax = "l_tax",-                  _lineitemReturnflag = "l_returnflag",-                  _lineitemLinestatus = "l_linestatus",-                  _lineitemShipdate = "l_shipdate",-                  _lineitemCommitdate = "l_commitdate",-                  _lineitemReceiptdate = "l_receiptdate",-                  _lineitemShipinstruct = "l_shipinstruct",-                  _lineitemShipmode = "l_shipmode",-                  _lineitemComment = "l_comment"-                },-        _dbFlights =-          dataSource-            ( csvWith-                ("tests/data/flights.csv" :| [])-                ( defaultCSVOptions-                    { header = Just True,-                      comment = Just '#',-                      delim = Just "|",-                      ignoreErrors = Just False-                    }-                )-            )-            <> modifyDataSourceFields-              tableModification-                { _flightDate = "FlightDate",-                  _flightUniqueCarrier = "UniqueCarrier",-                  _flightOriginCity = "OriginCityName",-                  _flightDestCity = "DestCityName"-                }-      }--withTestDb ::-  (Connection -> IO a) ->-  IO a-withTestDb =-  withConnection ":memory:"
+ tests/Database/Beam/DuckDB/Test/Extensions/Copy.hs view
@@ -0,0 +1,586 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeFamilies #-}++module Database.Beam.DuckDB.Test.Extensions.Copy (tests) where++import Control.Monad (guard, void)+import Control.Monad.IO.Class (liftIO)+import qualified Data.Char as Char+import Data.Int (Int32)+import qualified Data.List as List+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text.IO+import Database.Beam+  ( Beamable,+    Columnar,+    Database,+    DatabaseSettings,+    Generic,+    Identity,+    Table (..),+    TableEntity,+    all_,+    asc_,+    dbModification,+    defaultDbSettings,+    guard_,+    insert,+    insertValues,+    modifyTableFields,+    orderBy_,+    runInsert,+    runSelectReturningList,+    select,+    tableModification,+    val_,+    withDbModification,+    (>.),+  )+import Database.Beam.Backend.SQL.BeamExtensions+  ( copySelectTo,+    copyTableFrom,+    copyTableTo,+    runCopyFrom,+    runCopyTo,+  )+import Database.Beam.DuckDB+  ( DuckDB,+    DuckDBCSVCopyFromOptions (..),+    DuckDBCSVCopyToOptions (..),+    DuckDBJSONCopyFromOptions (..),+    DuckDBJSONCopyToOptions (..),+    DuckDBParquetCopyToOptions (..),+    JSONCompression (..),+    ParquetCompression (..),+    copyFromCSV,+    copyFromCSVWith,+    copyFromJSONWith,+    copyFromParquet,+    copyFromParquetWith,+    copyToCSV,+    copyToCSVWith,+    copyToJSONWith,+    copyToParquetWith,+    defaultDuckDBCSVCopyFromOptions,+    defaultDuckDBCSVCopyToOptions,+    defaultDuckDBJSONCopyFromOptions,+    defaultDuckDBJSONCopyToOptions,+    defaultDuckDBParquetCopyFromOptions,+    defaultDuckDBParquetCopyToOptions,+    runBeamDuckDB,+  )+import Database.DuckDB.Simple (Connection, execute_, withConnection)+import Hedgehog (Gen, forAll, property, (===))+import qualified Hedgehog.Gen as Gen+import System.IO (hClose)+import System.IO.Temp (withSystemTempFile)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.Hedgehog (testProperty)++tests :: TestTree+tests =+  testGroup+    "COPY statements"+    [ testGroup+        "COPY ... TO"+        [ testCopyAllColumnsToCsv,+          testCopyMultiColumnProjectionToCsv,+          testCopySingleColumnProjectionToCsv,+          testCopySelectToCsv+        ],+      testGroup+        "COPY ... FROM"+        [ testCopyFromCsvAllColumns,+          testCopyFromCsvMultiColumnProjection,+          testCopyFromCsvSingleColumnProjection+        ],+      testGroup+        "options"+        [ testCopyToWithCustomDelimiterAndNoHeader,+          testCopyFromWithCustomDelimiter,+          testRoundTripParquetWithCompression+        ],+      testGroup+        "property-based round-trip"+        [ propRoundTripCsv,+          propRoundTripParquet,+          propRoundTripJson+        ]+    ]++testCopyAllColumnsToCsv :: TestTree+testCopyAllColumnsToCsv = testCase "Copy all columns of a table to a CSV file" $+  withCopyTarget $ \conn path -> do+    runBeamDuckDB conn $+      runCopyTo $+        copyTableTo (_dbWidgets testDb) id (copyToCSV path)++    (header, rows) <- readCsv path+    header @?= "id,name,price"+    rows+      @?= List.sort+        [ "1,Widget,9.99",+          "2,Sprocket,4.5",+          "3,Cog,1.25"+        ]++testCopyMultiColumnProjectionToCsv :: TestTree+testCopyMultiColumnProjectionToCsv =+  testCase "Copy a multi-column projection of a table to a CSV file" $+    withCopyTarget $ \conn path -> do+      runBeamDuckDB conn $+        runCopyTo $+          copyTableTo+            (_dbWidgets testDb)+            (\w -> (_widgetId w, _widgetName w))+            (copyToCSV path)++      (header, rows) <- readCsv path+      header @?= "id,name"+      rows+        @?= List.sort+          [ "1,Widget",+            "2,Sprocket",+            "3,Cog"+          ]++testCopySingleColumnProjectionToCsv :: TestTree+testCopySingleColumnProjectionToCsv =+  testCase "Copy a single column of a table to a CSV file" $+    withCopyTarget $ \conn path -> do+      runBeamDuckDB conn $+        runCopyTo $+          copyTableTo (_dbWidgets testDb) _widgetName (copyToCSV path)++      (header, rows) <- readCsv path+      header @?= "name"+      rows @?= List.sort ["Widget", "Sprocket", "Cog"]++testCopySelectToCsv :: TestTree+testCopySelectToCsv =+  testCase "Copy the result of a SELECT query to a CSV file" $+    withCopyTarget $ \conn path -> do+      runBeamDuckDB conn $+        runCopyTo $+          copySelectTo+            ( select $ do+                w <- all_ (_dbWidgets testDb)+                guard_ (_widgetPrice w >. val_ 4.0)+                pure (_widgetName w)+            )+            (copyToCSV path)++      (_header, rows) <- readCsv path+      -- Only Widget (9.99) and Sprocket (4.5) have price > 4.0; Cog (1.25) is excluded.+      rows @?= List.sort ["Widget", "Sprocket"]++testCopyFromCsvAllColumns :: TestTree+testCopyFromCsvAllColumns =+  testCase "Copy all columns of a table from a CSV file" $+    withCopyFromSource csvAllColumns $ \conn path -> do+      runBeamDuckDB conn $+        runCopyFrom $+          copyTableFrom (_dbWidgets testDb) id (copyFromCSV path)++      rows <- queryAllWidgets conn+      rows+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket" 4.5,+              Widget 3 "Cog" 1.25+            ]+  where+    csvAllColumns =+      "id,name,price\n\+      \1,Widget,9.99\n\+      \2,Sprocket,4.5\n\+      \3,Cog,1.25\n"++testCopyFromCsvMultiColumnProjection :: TestTree+testCopyFromCsvMultiColumnProjection =+  testCase "Copy a multi-column projection of a table from a CSV file" $+    withCopyFromSource csvIdName $ \conn path -> do+      runBeamDuckDB conn $+        runCopyFrom $+          copyTableFrom+            (_dbWidgets testDb)+            (\w -> (_widgetId w, _widgetName w))+            (copyFromCSV path)++      rows <- queryAllWidgets conn+      -- 'price' was not in the CSV, so it gets the table's DEFAULT value.+      rows+        @?= [ Widget 1 "Widget" 0,+              Widget 2 "Sprocket" 0,+              Widget 3 "Cog" 0+            ]+  where+    csvIdName =+      "id,name\n\+      \1,Widget\n\+      \2,Sprocket\n\+      \3,Cog\n"++testCopyFromCsvSingleColumnProjection :: TestTree+testCopyFromCsvSingleColumnProjection =+  testCase "Copy a single column of a table from a CSV file" $+    withCopyFromSource csvIdOnly $ \conn path -> do+      runBeamDuckDB conn $+        runCopyFrom $+          copyTableFrom (_dbWidgets testDb) _widgetId (copyFromCSV path)++      rows <- queryAllWidgets conn+      -- Only 'id' was in the CSV, so 'name' and 'price' get table DEFAULTs.+      rows+        @?= [ Widget 1 "" 0,+              Widget 2 "" 0,+              Widget 3 "" 0+            ]+  where+    csvIdOnly =+      "id\n\+      \1\n\+      \2\n\+      \3\n"++testCopyToWithCustomDelimiterAndNoHeader :: TestTree+testCopyToWithCustomDelimiterAndNoHeader =+  testCase "Copy a table to a CSV file with custom delimiter and no header" $+    withCopyTarget $ \conn path -> do+      let opts =+            copyToCSVWith+              path+              defaultDuckDBCSVCopyToOptions+                { csvCopyToHeader = Just False,+                  csvCopyToDelimiter = Just "|"+                }+      runBeamDuckDB conn $+        runCopyTo $+          copyTableTo (_dbWidgets testDb) id opts++      contents <- Text.IO.readFile path+      let dataLines = List.sort (filter (not . Text.null) (Text.lines contents))+      dataLines+        @?= List.sort+          [ "1|Widget|9.99",+            "2|Sprocket|4.5",+            "3|Cog|1.25"+          ]++testCopyFromWithCustomDelimiter :: TestTree+testCopyFromWithCustomDelimiter =+  testCase "Copy from a CSV file with a custom delimiter" $+    withCopyFromSource pipeDelimited $ \conn path -> do+      let opts =+            copyFromCSVWith+              path+              defaultDuckDBCSVCopyFromOptions+                { csvCopyFromDelimiter = Just "|"+                }+      runBeamDuckDB conn $+        runCopyFrom $+          copyTableFrom (_dbWidgets testDb) id opts++      rows <- queryAllWidgets conn+      rows+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket" 4.5,+              Widget 3 "Cog" 1.25+            ]+  where+    pipeDelimited =+      "id|name|price\n\+      \1|Widget|9.99\n\+      \2|Sprocket|4.5\n\+      \3|Cog|1.25\n"++testRoundTripParquetWithCompression :: TestTree+testRoundTripParquetWithCompression =+  testCase "Round-trip data through a ZSTD-compressed Parquet file" $+    withTestDb widgetData $ \conn ->+      withSystemTempFile "beam-duckdb-copy-.parquet" $ \path h -> do+        hClose h+        let toOpts =+              copyToParquetWith+                path+                defaultDuckDBParquetCopyToOptions+                  { parquetCopyToCompression = Just ParquetZstd+                  }+            fromOpts = copyFromParquet path++        runBeamDuckDB conn $+          runCopyTo $+            copyTableTo (_dbWidgets testDb) id toOpts++        -- Wipe the seeded rows, then re-import from the Parquet file we wrote.+        void $ execute_ conn "DELETE FROM widgets"++        runBeamDuckDB conn $+          runCopyFrom $+            copyTableFrom (_dbWidgets testDb) id fromOpts++        rows <- queryAllWidgets conn+        rows+          @?= [ Widget 1 "Widget" 9.99,+                Widget 2 "Sprocket" 4.5,+                Widget 3 "Cog" 1.25+              ]++propRoundTripCsv :: TestTree+propRoundTripCsv =+  testProperty "CSV options round-trip" $+    property $ do+      toOpts <- forAll genCsvToOpts+      let fromOpts = matchedCsvFromOpts toOpts+      rows <- liftIO $ csvRoundTrip toOpts fromOpts+      rows === widgetData+  where+    csvRoundTrip toOpts fromOpts =+      withTestDb widgetData $ \conn ->+        withSystemTempFile "beam-duckdb-prop-csv-.csv" $ \path h -> do+          hClose h+          runBeamDuckDB conn $+            runCopyTo $+              copyTableTo (_dbWidgets testDb) id (copyToCSVWith path toOpts)+          void $ execute_ conn "DELETE FROM widgets"+          runBeamDuckDB conn $+            runCopyFrom $+              copyTableFrom (_dbWidgets testDb) id (copyFromCSVWith path fromOpts)+          queryAllWidgets conn++    genCsvToOpts :: Hedgehog.Gen DuckDBCSVCopyToOptions+    genCsvToOpts = do+      hdr <- Gen.maybe Gen.bool+      delim <- Gen.maybe (Gen.element [",", ";", "|", "\t"])+      nullStr <- Gen.maybe (Gen.element ["NULL", "~~~"])+      quote <- Gen.maybe genCsvSpecialChar+      escape <- Gen.maybe genCsvSpecialChar++      -- quote and escape can't overlap with nullStr and delim,+      -- otherwise the CSV data may be unparseable.+      guard (maybe True not $ Text.elem <$> quote <*> delim)+      guard (quote /= Just ',') -- the default delimiter+      guard (maybe True not $ Text.elem <$> quote <*> nullStr)+      guard (maybe True not $ Text.elem <$> escape <*> delim)+      guard (escape /= Just ',') -- the default delimiter+      guard (maybe True not $ Text.elem <$> escape <*> nullStr)++      pure+        defaultDuckDBCSVCopyToOptions+          { csvCopyToHeader = hdr,+            csvCopyToDelimiter = delim,+            csvCopyToQuote = quote,+            csvCopyToEscape = escape,+            csvCopyToNullStr = nullStr,+            -- The TO-side @overwrite_or_ignore@ field is intentionally fixed to+            -- @Just True@: a property that replays through the same temp file 25+            -- times needs the second iteration onwards to overwrite, and the field+            -- itself is independent of the readable contents we round-trip.+            csvCopyToOverwriteOrIgnore = Just True+          }++    matchedCsvFromOpts :: DuckDBCSVCopyToOptions -> DuckDBCSVCopyFromOptions+    matchedCsvFromOpts o =+      defaultDuckDBCSVCopyFromOptions+        { csvCopyFromHeader = csvCopyToHeader o,+          csvCopyFromDelimiter = csvCopyToDelimiter o,+          csvCopyFromQuote = csvCopyToQuote o,+          csvCopyFromEscape = csvCopyToEscape o,+          csvCopyFromNullStr = csvCopyToNullStr o+        }++    -- Printable single-byte ASCII characters that don't collide with+    -- the seeded 'widgetData' (which is alphanumeric plus '.'), and the delimiter+    genCsvSpecialChar :: Hedgehog.Gen Char+    genCsvSpecialChar =+      Gen.element+        [ c+        | c <- enumFromTo minBound maxBound,+          Char.isPrint c,+          Char.isAscii c,+          not (Char.isAlphaNum c),+          c /= '.'+        ]++propRoundTripParquet :: TestTree+propRoundTripParquet =+  testProperty "Parquet options round-trip" $+    property $ do+      toOpts <- forAll genParquetToOpts+      let fromOpts = defaultDuckDBParquetCopyFromOptions+      rows <- liftIO $ parquetRoundTrip toOpts fromOpts+      rows === widgetData+  where+    parquetRoundTrip toOpts fromOpts =+      withTestDb widgetData $ \conn ->+        withSystemTempFile "beam-duckdb-prop-parquet-.parquet" $ \path h -> do+          hClose h+          runBeamDuckDB conn $+            runCopyTo $+              copyTableTo (_dbWidgets testDb) id (copyToParquetWith path toOpts)+          void $ execute_ conn "DELETE FROM widgets"+          runBeamDuckDB conn $+            runCopyFrom $+              copyTableFrom (_dbWidgets testDb) id (copyFromParquetWith path fromOpts)+          queryAllWidgets conn++    genParquetToOpts :: Hedgehog.Gen DuckDBParquetCopyToOptions+    genParquetToOpts = do+      comp <- Gen.maybe Gen.enumBounded+      pure+        defaultDuckDBParquetCopyToOptions+          { parquetCopyToCompression = comp,+            parquetCopyToOverwriteOrIgnore = Just True+          }++propRoundTripJson :: TestTree+propRoundTripJson =+  testProperty "JSON options round-trip" $+    property $ do+      toOpts <- forAll genJsonToOpts+      fromOpts <- forAll (genJsonFromOpts (jsonCopyToCompression toOpts))+      rows <- liftIO $ jsonRoundTrip toOpts fromOpts+      rows === widgetData+  where+    jsonRoundTrip toOpts fromOpts =+      withTestDb widgetData $ \conn ->+        withSystemTempFile "beam-duckdb-prop-json-.json" $ \path h -> do+          hClose h+          runBeamDuckDB conn $+            runCopyTo $+              copyTableTo (_dbWidgets testDb) id (copyToJSONWith path toOpts)+          void $ execute_ conn "DELETE FROM widgets"+          runBeamDuckDB conn $+            runCopyFrom $+              copyTableFrom (_dbWidgets testDb) id (copyFromJSONWith path fromOpts)+          queryAllWidgets conn++    genJsonToOpts :: Hedgehog.Gen DuckDBJSONCopyToOptions+    genJsonToOpts = do+      comp <- Gen.maybe Gen.enumBounded+      pure+        defaultDuckDBJSONCopyToOptions+          { jsonCopyToCompression = comp,+            jsonCopyToOverwriteOrIgnore = Just True+          }++    genJsonFromOpts :: Maybe JSONCompression -> Hedgehog.Gen DuckDBJSONCopyFromOptions+    genJsonFromOpts comp = do+      auto <- Gen.maybe Gen.bool+      pure+        defaultDuckDBJSONCopyFromOptions+          { jsonCopyFromAutoDetect = auto,+            jsonCopyFromCompression = comp+          }++withCopyTarget :: (Connection -> FilePath -> IO a) -> IO a+withCopyTarget action =+  withTestDb widgetData $ \conn ->+    withSystemTempFile "beam-duckdb-copy-.csv" $ \path h -> do+      -- DuckDB's COPY TO opens the file itself; close our handle first+      -- so it isn't held open while DuckDB writes.+      hClose h+      action conn path++withCopyFromSource :: Text -> (Connection -> FilePath -> IO a) -> IO a+withCopyFromSource csvContent action =+  withTestDb [] $ \conn ->+    withSystemTempFile "beam-duckdb-copy-from-.csv" $ \path h -> do+      Text.IO.hPutStr h csvContent+      hClose h+      action conn path++readCsv :: FilePath -> IO (Text, [Text])+readCsv path = do+  contents <- Text.IO.readFile path+  case Text.lines contents of+    [] -> fail ("expected at least a header line in " <> path)+    (h : ds) -> pure (h, List.sort ds)++queryAllWidgets :: Connection -> IO [Widget]+queryAllWidgets conn =+  runBeamDuckDB conn $+    runSelectReturningList $+      select $+        orderBy_ (asc_ . _widgetId) $+          all_ (_dbWidgets testDb)++widgetData :: [Widget]+widgetData =+  [ Widget 1 "Widget" 9.99,+    Widget 2 "Sprocket" 4.50,+    Widget 3 "Cog" 1.25+  ]++data WidgetT f = Widget+  { _widgetId :: Columnar f Int32,+    _widgetName :: Columnar f Text,+    _widgetPrice :: Columnar f Double+  }+  deriving (Generic)++type Widget = WidgetT Identity++deriving instance Show Widget++deriving instance Eq Widget++instance Beamable WidgetT++instance Table WidgetT where+  data PrimaryKey WidgetT f = WidgetId (Columnar f Int32)+    deriving (Generic)+  primaryKey = WidgetId . _widgetId++instance Beamable (PrimaryKey WidgetT)++newtype TestDB f = TestDB+  { _dbWidgets :: f (TableEntity WidgetT)+  }+  deriving (Generic, Database be)++testDb :: DatabaseSettings DuckDB TestDB+testDb =+  defaultDbSettings+    `withDbModification` dbModification+      { _dbWidgets =+          modifyTableFields+            tableModification+              { _widgetId = "id",+                _widgetName = "name",+                _widgetPrice = "price"+              }+      }++createTables :: Connection -> IO ()+createTables conn =+  void $+    execute_+      conn+      "CREATE TABLE widgets (\+      \  id INTEGER PRIMARY KEY,\+      \  name TEXT NOT NULL DEFAULT '',\+      \  price DOUBLE NOT NULL DEFAULT 0\+      \)"++seedData :: Connection -> [Widget] -> IO ()+seedData conn widgets =+  runBeamDuckDB conn $+    runInsert $+      insert (_dbWidgets testDb) $+        insertValues widgets++withTestDb ::+  [Widget] ->+  (Connection -> IO a) ->+  IO a+withTestDb widgets action =+  withConnection ":memory:" $ \conn -> do+    createTables conn+    seedData conn widgets+    action conn
+ tests/Database/Beam/DuckDB/Test/Extensions/DataSource.hs view
@@ -0,0 +1,365 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE ExplicitForAll #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}++module Database.Beam.DuckDB.Test.Extensions.DataSource (tests) where++import Data.Int (Int32)+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text (Text)+import Data.Time (Day, fromGregorian)+import Database.Beam+  ( Beamable,+    Columnar,+    Database,+    DatabaseSettings,+    Generic,+    Identity,+    Table (..),+    aggregate_,+    as_,+    countAll_,+    dbModification,+    defaultDbSettings,+    guard_,+    min_,+    runSelectReturningList,+    runSelectReturningOne,+    select,+    tableModification,+    val_,+    withDbModification,+    (==.),+  )+import Database.Beam.DuckDB+  ( CSVOptions (..),+    DataSourceEntity,+    DuckDB,+    allFromDataSource_,+    allowMovedPaths,+    csvWith,+    dataSource,+    defaultCSVOptions,+    defaultIcebergTableOptions,+    icebergTableWith,+    modifyDataSourceFields,+    parquet,+    runBeamDuckDB,+  )+import Database.DuckDB.Simple (Connection, withConnection)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "DataSource"+    [ testGroup+        "Parquet"+        [ testCountFromParquet,+          testQueryFromParquet,+          testCountFromMultipleParquetFiles,+          testQueryFromMultipleParquetFiles+        ],+      testGroup+        "Apache Iceberg"+        [ testCountFromIceberg,+          testParseDateFromIceberg+        ],+      testGroup+        "CSV"+        [ testCountFromCSV,+          testQueryFromCSV+        ]+    ]++testCountFromParquet :: TestTree+testCountFromParquet = testCase "Counting records from a Parquet file" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningList $+        select $ do+          exam <- allFromDataSource_ (_dbExams testDb)+          pure (_examName exam)+  results @?= ["alice", "bob", "carol", "dave"]++testQueryFromParquet :: TestTree+testQueryFromParquet = testCase "Query from a Parquet file" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $ do+          exam <- allFromDataSource_ (_dbExams testDb)+          guard_ (_examId exam ==. 1)+          pure (_examName exam)+  results @?= Just "alice"++testCountFromMultipleParquetFiles :: TestTree+testCountFromMultipleParquetFiles = testCase "Counting records from multiple Parquet file" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningList $+        select $ do+          exam <- allFromDataSource_ (_dbExamsMulti testDb)+          pure (_examName exam)+  results @?= ["alice", "bob", "carol", "dave", "erika", "francis", "genevieve", "hugo"]++testQueryFromMultipleParquetFiles :: TestTree+testQueryFromMultipleParquetFiles = testCase "Query from multiple Parquet files" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $ do+          exam <- allFromDataSource_ (_dbExamsMulti testDb)+          guard_ (_examId exam ==. 5)+          pure (_examName exam)+  results @?= Just "erika"++testCountFromIceberg :: TestTree+testCountFromIceberg = testCase "Counting records from an Apache Iceberg table" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $+          aggregate_ (\_ -> as_ @Int32 countAll_) (allFromDataSource_ (_dbLineItems testDb))+  results @?= Just 51793 -- From DuckDB's documentation++testParseDateFromIceberg :: TestTree+testParseDateFromIceberg = testCase "Test parsing date columns from Apache Iceberg table" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $+          aggregate_ (min_ . _lineitemShipdate) (allFromDataSource_ (_dbLineItems testDb))++  results @?= Just (Just (fromGregorian 1992 01 04))++testCountFromCSV :: TestTree+testCountFromCSV = testCase "Counting records from a CSV file" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $+          aggregate_ (\_ -> as_ @Int32 countAll_) (allFromDataSource_ (_dbFlights testDb))+  results @?= Just 3++testQueryFromCSV :: TestTree+testQueryFromCSV = testCase "Query from a CSV file" $ do+  results <- withTestDb $ \conn ->+    runBeamDuckDB conn $+      runSelectReturningOne $+        select $ do+          flight <- allFromDataSource_ (_dbFlights testDb)+          guard_ (_flightDate flight ==. val_ (fromGregorian 1988 01 01))+          pure flight+  results+    @?= Just+      ( Flight+          { _flightDate = fromGregorian 1988 01 01,+            _flightUniqueCarrier = "AA",+            _flightOriginCity = "New York, NY",+            _flightDestCity = "Los Angeles, CA"+          }+      )++data ExamT f = Exam+  { _examId :: Columnar f Int32,+    _examName :: Columnar f Text,+    _examScore :: Columnar f Double,+    _examDate :: Columnar f Day+  }+  deriving (Generic)++type Exam = ExamT Identity++type ExamId = PrimaryKey ExamT Identity++deriving instance Show ExamId++deriving instance Eq ExamId++deriving instance Ord ExamId++deriving instance Show Exam++deriving instance Eq Exam++deriving instance Ord Exam++instance Beamable ExamT++instance Table ExamT where+  data PrimaryKey ExamT f = ExamId (Columnar f Int32)+    deriving (Generic)+  primaryKey = ExamId . _examId++instance Beamable (PrimaryKey ExamT)++data LineItemT f = Lineitem+  { _lineitemOrderkey :: Columnar f Int32,+    _lineitemPartkey :: Columnar f Int32,+    _lineitemSuppkey :: Columnar f Int32,+    _lineitemLinenumber :: Columnar f Int32,+    _lineitemQuantity :: Columnar f Double,+    _lineitemExtendedprice :: Columnar f Double,+    _lineitemDiscount :: Columnar f Double,+    _lineitemTax :: Columnar f Double,+    _lineitemReturnflag :: Columnar f Text,+    _lineitemLinestatus :: Columnar f Text,+    _lineitemShipdate :: Columnar f Day,+    _lineitemCommitdate :: Columnar f Day,+    _lineitemReceiptdate :: Columnar f Day,+    _lineitemShipinstruct :: Columnar f Text,+    _lineitemShipmode :: Columnar f Text,+    _lineitemComment :: Columnar f Text+  }+  deriving (Generic)++type Lineitem = LineItemT Identity++deriving instance Show Lineitem++deriving instance Eq Lineitem++instance Beamable LineItemT++instance Table LineItemT where+  data PrimaryKey LineItemT f+    = LineItemKey (Columnar f Int32) (Columnar f Int32)+    deriving (Generic)+  primaryKey li =+    LineItemKey (_lineitemOrderkey li) (_lineitemLinenumber li)++instance Beamable (PrimaryKey LineItemT)++deriving instance Show (PrimaryKey LineItemT Identity)++deriving instance Eq (PrimaryKey LineItemT Identity)++data FlightT f = Flight+  { _flightDate :: Columnar f Day,+    _flightUniqueCarrier :: Columnar f Text,+    _flightOriginCity :: Columnar f Text,+    _flightDestCity :: Columnar f Text+  }+  deriving (Generic)++type Flight = FlightT Identity++deriving instance Show Flight++deriving instance Eq Flight++instance Beamable FlightT++instance Table FlightT where+  data PrimaryKey FlightT f+    = FlightKey+        (Columnar f Day)+        (Columnar f Text)+        (Columnar f Text)+        (Columnar f Text)+    deriving (Generic)+  primaryKey fl =+    FlightKey+      (_flightDate fl)+      (_flightUniqueCarrier fl)+      (_flightOriginCity fl)+      (_flightDestCity fl)++instance Beamable (PrimaryKey FlightT)++deriving instance Show (PrimaryKey FlightT Identity)++deriving instance Eq (PrimaryKey FlightT Identity)++data TestDB f = TestDB+  { _dbExams :: f (DataSourceEntity ExamT),+    _dbExamsMulti :: f (DataSourceEntity ExamT), -- set up with multiple parquet files+    _dbLineItems :: f (DataSourceEntity LineItemT),+    _dbFlights :: f (DataSourceEntity FlightT)+  }+  deriving (Generic, Database DuckDB)++testDb :: DatabaseSettings DuckDB TestDB+testDb =+  defaultDbSettings+    `withDbModification` (dbModification @_ @DuckDB)+      { _dbExams =+          dataSource+            (parquet ("tests/data/test1.parquet" :| []))+            <> modifyDataSourceFields+              tableModification+                { _examId = "id",+                  _examName = "name",+                  _examScore = "score",+                  _examDate = "exam_date"+                },+        _dbExamsMulti =+          dataSource+            ( parquet+                ("tests/data/test1.parquet" :| ["tests/data/test2.parquet"])+            )+            <> modifyDataSourceFields+              tableModification+                { _examId = "id",+                  _examName = "name",+                  _examScore = "score",+                  _examDate = "exam_date"+                },+        _dbLineItems =+          dataSource+            ( icebergTableWith+                "tests/data/lineitem_iceberg"+                (defaultIcebergTableOptions {allowMovedPaths = Just True})+            )+            <> modifyDataSourceFields+              tableModification+                { _lineitemOrderkey = "l_orderkey",+                  _lineitemPartkey = "l_partkey",+                  _lineitemSuppkey = "l_suppkey",+                  _lineitemLinenumber = "l_linenumber",+                  _lineitemQuantity = "l_quantity",+                  _lineitemExtendedprice = "l_extendedprice",+                  _lineitemDiscount = "l_discount",+                  _lineitemTax = "l_tax",+                  _lineitemReturnflag = "l_returnflag",+                  _lineitemLinestatus = "l_linestatus",+                  _lineitemShipdate = "l_shipdate",+                  _lineitemCommitdate = "l_commitdate",+                  _lineitemReceiptdate = "l_receiptdate",+                  _lineitemShipinstruct = "l_shipinstruct",+                  _lineitemShipmode = "l_shipmode",+                  _lineitemComment = "l_comment"+                },+        _dbFlights =+          dataSource+            ( csvWith+                ("tests/data/flights.csv" :| [])+                ( defaultCSVOptions+                    { header = Just True,+                      comment = Just '#',+                      delim = Just "|",+                      ignoreErrors = Just False+                    }+                )+            )+            <> modifyDataSourceFields+              tableModification+                { _flightDate = "FlightDate",+                  _flightUniqueCarrier = "UniqueCarrier",+                  _flightOriginCity = "OriginCityName",+                  _flightDestCity = "DestCityName"+                }+      }++withTestDb ::+  (Connection -> IO a) ->+  IO a+withTestDb =+  withConnection ":memory:"
+ tests/Database/Beam/DuckDB/Test/Extensions/InsertOnConflict.hs view
@@ -0,0 +1,306 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeFamilies #-}++module Database.Beam.DuckDB.Test.Extensions.InsertOnConflict (tests) where++import Control.Monad (void)+import Data.Int (Int32)+import qualified Data.List as List+import Data.Text (Text)+import Database.Beam+  ( Beamable,+    Columnar,+    Database,+    DatabaseSettings,+    Generic,+    Identity,+    SqlOrd ((>.)),+    Table (..),+    TableEntity,+    all_,+    asc_,+    current_,+    dbModification,+    defaultDbSettings,+    insert,+    insertValues,+    modifyTableFields,+    orderBy_,+    runInsert,+    runSelectReturningList,+    select,+    tableModification,+    val_,+    withDbModification,+    (<-.),+  )+import Database.Beam.DuckDB+  ( BeamHasInsertOnConflict+      ( anyConflict,+        conflictingFields,+        insertOnConflict,+        onConflictDoNothing,+        onConflictUpdateSet,+        onConflictUpdateSetWhere+      ),+    DuckDB,+    onConflictUpdateAll,+    onConflictUpdateInstead,+    runBeamDuckDB,+    runInsertReturningList,+  )+import Database.DuckDB.Simple (Connection, execute_, withConnection)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "INSERT ... ON CONFLICT"+    [ testAnyConflictDoNothing,+      testConflictingFieldsDoNothing,+      testConflictingFieldsUpdateAll,+      testConflictingFieldsUpdateInstead,+      testConflictingFieldsUpdateSet,+      testConflictingFieldsUpdateSetWhere,+      testInsertOnConflictReturning+    ]++testAnyConflictDoNothing :: TestTree+testAnyConflictDoNothing =+  testCase "anyConflict + onConflictDoNothing leaves existing rows untouched" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            (insertValues [Widget 2 "Sprocket-collision" 0.0])+            anyConflict+            onConflictDoNothing+      remaining <- queryAllWidgets conn+      remaining @?= List.sortOn _widgetId widgetData++testConflictingFieldsDoNothing :: TestTree+testConflictingFieldsDoNothing =+  testCase "conflictingFields + onConflictDoNothing inserts non-conflicting rows" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            ( insertValues+                [ Widget 2 "Sprocket-collision" 0.0,+                  Widget 4 "Gear" 7.75+                ]+            )+            (conflictingFields _widgetId)+            onConflictDoNothing+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket" 4.50,+              Widget 3 "Cog" 1.25,+              Widget 4 "Gear" 7.75+            ]++testConflictingFieldsUpdateAll :: TestTree+testConflictingFieldsUpdateAll =+  testCase "conflictingFields + onConflictUpdateAll overwrites every column" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            ( insertValues+                [ Widget 2 "Sprocket-v2" 6.00,+                  Widget 4 "Gear" 7.75+                ]+            )+            (conflictingFields _widgetId)+            onConflictUpdateAll+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket-v2" 6.00,+              Widget 3 "Cog" 1.25,+              Widget 4 "Gear" 7.75+            ]++testConflictingFieldsUpdateInstead :: TestTree+testConflictingFieldsUpdateInstead =+  testCase "conflictingFields + onConflictUpdateInstead overwrites a subset" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            (insertValues [Widget 2 "Sprocket-v3" 100.0])+            (conflictingFields _widgetId)+            (onConflictUpdateInstead _widgetName)+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket-v3" 4.50,+              Widget 3 "Cog" 1.25+            ]++testConflictingFieldsUpdateSet :: TestTree+testConflictingFieldsUpdateSet =+  testCase "conflictingFields + onConflictUpdateSet applies a custom assignment" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            (insertValues [Widget 2 "Sprocket-discount" 0.0])+            (conflictingFields _widgetId)+            ( onConflictUpdateSet+                ( \fields _excluded ->+                    _widgetPrice fields <-. current_ (_widgetPrice fields) - val_ 0.50+                )+            )+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket" 4.00,+              Widget 3 "Cog" 1.25+            ]++testConflictingFieldsUpdateSetWhere :: TestTree+testConflictingFieldsUpdateSetWhere =+  testCase "conflictingFields + onConflictUpdateSetWhere only updates when predicate holds" $+    withTestDb widgetData $ \conn -> do+      runBeamDuckDB conn $+        runInsert $+          insertOnConflict+            (_dbWidgets testDb)+            ( insertValues+                [ Widget 1 "Widget-replacement" 1.00,+                  Widget 3 "Cog-replacement" 50.00+                ]+            )+            (conflictingFields _widgetId)+            ( onConflictUpdateSetWhere+                ( \fields excludedRow ->+                    _widgetName fields <-. _widgetName excludedRow+                )+                -- Only overwrite when the incoming price is higher than the existing one+                ( \fields excludedRow ->+                    _widgetPrice excludedRow >. current_ (_widgetPrice fields)+                )+            )+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket" 4.50,+              Widget 3 "Cog-replacement" 1.25+            ]++testInsertOnConflictReturning :: TestTree+testInsertOnConflictReturning =+  testCase "INSERT ON CONFLICT ... RETURNING returns inserted-or-updated rows" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runInsertReturningList $+            insertOnConflict+              (_dbWidgets testDb)+              ( insertValues+                  [ Widget 2 "Sprocket-v2" 6.00,+                    Widget 4 "Gear" 7.75+                  ]+              )+              (conflictingFields _widgetId)+              onConflictUpdateAll+      List.sort returned+        @?= List.sort+          [ Widget 2 "Sprocket-v2" 6.00,+            Widget 4 "Gear" 7.75+          ]++data WidgetT f = Widget+  { _widgetId :: Columnar f Int32,+    _widgetName :: Columnar f Text,+    _widgetPrice :: Columnar f Double+  }+  deriving (Generic)++type Widget = WidgetT Identity++deriving instance Show Widget++deriving instance Eq Widget++deriving instance Ord Widget++instance Beamable WidgetT++instance Table WidgetT where+  data PrimaryKey WidgetT f = WidgetId (Columnar f Int32)+    deriving (Generic)+  primaryKey = WidgetId . _widgetId++instance Beamable (PrimaryKey WidgetT)++newtype TestDB f = TestDB+  { _dbWidgets :: f (TableEntity WidgetT)+  }+  deriving (Generic, Database be)++testDb :: DatabaseSettings DuckDB TestDB+testDb =+  defaultDbSettings+    `withDbModification` dbModification+      { _dbWidgets =+          modifyTableFields+            tableModification+              { _widgetId = "id",+                _widgetName = "name",+                _widgetPrice = "price"+              }+      }++createTables :: Connection -> IO ()+createTables conn =+  void $+    execute_+      conn+      "CREATE TABLE widgets (\+      \  id INTEGER PRIMARY KEY,\+      \  name TEXT NOT NULL DEFAULT '',\+      \  price DOUBLE NOT NULL DEFAULT 0\+      \)"++seedData :: Connection -> [Widget] -> IO ()+seedData _ [] = pure ()+seedData conn widgets =+  void $+    runBeamDuckDB conn $+      runInsertReturningList $+        insert (_dbWidgets testDb) (insertValues widgets)++withTestDb :: [Widget] -> (Connection -> IO a) -> IO a+withTestDb widgets action =+  withConnection ":memory:" $ \conn -> do+    createTables conn+    seedData conn widgets+    action conn++queryAllWidgets :: Connection -> IO [Widget]+queryAllWidgets conn =+  runBeamDuckDB conn $+    runSelectReturningList $+      select $+        orderBy_ (asc_ . _widgetId) $+          all_ (_dbWidgets testDb)++widgetData :: [Widget]+widgetData =+  [ Widget 1 "Widget" 9.99,+    Widget 2 "Sprocket" 4.50,+    Widget 3 "Cog" 1.25+  ]
+ tests/Database/Beam/DuckDB/Test/Extensions/Returning.hs view
@@ -0,0 +1,239 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeFamilies #-}++module Database.Beam.DuckDB.Test.Extensions.Returning (tests) where++import Control.Monad (void)+import Data.Int (Int32)+import qualified Data.List as List+import Data.Text (Text)+import Database.Beam+  ( Beamable,+    Columnar,+    Database,+    DatabaseSettings,+    Generic,+    Identity,+    Table (..),+    TableEntity,+    all_,+    asc_,+    current_,+    dbModification,+    defaultDbSettings,+    delete,+    insert,+    insertValues,+    modifyTableFields,+    orderBy_,+    runSelectReturningList,+    save,+    select,+    tableModification,+    update,+    val_,+    withDbModification,+    (<-.),+    (==.),+  )+import Database.Beam.DuckDB+  ( DuckDB,+    runBeamDuckDB,+    runDeleteReturningList,+    runInsertReturningList,+    runUpdateReturningList,+  )+import Database.DuckDB.Simple (Connection, execute_, withConnection)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "RETURNING"+    [ testGroup+        "INSERT ... RETURNING"+        [ testInsertReturningAll,+          testInsertReturningEmpty+        ],+      testGroup+        "UPDATE ... RETURNING"+        [ testUpdateReturningAll,+          testUpdateReturningOnlyMatched+        ],+      testGroup+        "DELETE ... RETURNING"+        [ testDeleteReturningAll,+          testDeleteReturningOnlyMatched+        ]+    ]++testInsertReturningAll :: TestTree+testInsertReturningAll =+  testCase "INSERT returns the inserted rows" $+    withTestDb [] $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runInsertReturningList $+            insert (_dbWidgets testDb) (insertValues widgetData)+      List.sort returned @?= List.sort widgetData++testInsertReturningEmpty :: TestTree+testInsertReturningEmpty =+  testCase "INSERT with no rows returns []" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runInsertReturningList $+            insert (_dbWidgets testDb) (insertValues [])+      returned @?= []+      remaining <- queryAllWidgets conn+      remaining @?= List.sortOn _widgetId widgetData++testUpdateReturningAll :: TestTree+testUpdateReturningAll =+  testCase "UPDATE returns the post-update rows" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runUpdateReturningList $+            update+              (_dbWidgets testDb)+              (\w -> _widgetPrice w <-. current_ (_widgetPrice w) + val_ 1.0)+              (\_ -> val_ True)+      List.sort returned+        @?= List.sort+          [ Widget 1 "Widget" 10.99,+            Widget 2 "Sprocket" 5.50,+            Widget 3 "Cog" 2.25+          ]++testUpdateReturningOnlyMatched :: TestTree+testUpdateReturningOnlyMatched =+  testCase "UPDATE with WHERE returns only the matched rows" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runUpdateReturningList $+            save (_dbWidgets testDb) (Widget 2 "Sprocket-v2" 4.50)+      returned @?= [Widget 2 "Sprocket-v2" 4.50]+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 2 "Sprocket-v2" 4.50,+              Widget 3 "Cog" 1.25+            ]++testDeleteReturningAll :: TestTree+testDeleteReturningAll =+  testCase "DELETE returns the deleted rows" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runDeleteReturningList $+            delete (_dbWidgets testDb) (\_ -> val_ True)+      List.sort returned @?= List.sort widgetData+      remaining <- queryAllWidgets conn+      remaining @?= []++testDeleteReturningOnlyMatched :: TestTree+testDeleteReturningOnlyMatched =+  testCase "DELETE with WHERE returns only the matched rows" $+    withTestDb widgetData $ \conn -> do+      returned <-+        runBeamDuckDB conn $+          runDeleteReturningList $+            delete (_dbWidgets testDb) (\w -> _widgetId w ==. val_ 2)+      returned @?= [Widget 2 "Sprocket" 4.50]+      remaining <- queryAllWidgets conn+      remaining+        @?= [ Widget 1 "Widget" 9.99,+              Widget 3 "Cog" 1.25+            ]++data WidgetT f = Widget+  { _widgetId :: Columnar f Int32,+    _widgetName :: Columnar f Text,+    _widgetPrice :: Columnar f Double+  }+  deriving (Generic)++type Widget = WidgetT Identity++deriving instance Show Widget++deriving instance Eq Widget++deriving instance Ord Widget++instance Beamable WidgetT++instance Table WidgetT where+  data PrimaryKey WidgetT f = WidgetId (Columnar f Int32)+    deriving (Generic)+  primaryKey = WidgetId . _widgetId++instance Beamable (PrimaryKey WidgetT)++newtype TestDB f = TestDB+  { _dbWidgets :: f (TableEntity WidgetT)+  }+  deriving (Generic, Database be)++testDb :: DatabaseSettings DuckDB TestDB+testDb =+  defaultDbSettings+    `withDbModification` dbModification+      { _dbWidgets =+          modifyTableFields+            tableModification+              { _widgetId = "id",+                _widgetName = "name",+                _widgetPrice = "price"+              }+      }++createTables :: Connection -> IO ()+createTables conn =+  void $+    execute_+      conn+      "CREATE TABLE widgets (\+      \  id INTEGER PRIMARY KEY,\+      \  name TEXT NOT NULL DEFAULT '',\+      \  price DOUBLE NOT NULL DEFAULT 0\+      \)"++seedData :: Connection -> [Widget] -> IO ()+seedData _ [] = pure ()+seedData conn widgets =+  void $+    runBeamDuckDB conn $+      runInsertReturningList $+        insert (_dbWidgets testDb) (insertValues widgets)++withTestDb :: [Widget] -> (Connection -> IO a) -> IO a+withTestDb widgets action =+  withConnection ":memory:" $ \conn -> do+    createTables conn+    seedData conn widgets+    action conn++queryAllWidgets :: Connection -> IO [Widget]+queryAllWidgets conn =+  runBeamDuckDB conn $+    runSelectReturningList $+      select $+        orderBy_ (asc_ . _widgetId) $+          all_ (_dbWidgets testDb)++widgetData :: [Widget]+widgetData =+  [ Widget 1 "Widget" 9.99,+    Widget 2 "Sprocket" 4.50,+    Widget 3 "Cog" 1.25+  ]
+ tests/Database/Beam/DuckDB/Test/Migrate.hs view
@@ -0,0 +1,483 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}++module Database.Beam.DuckDB.Test.Migrate (tests) where++import Control.Arrow ((>>>))+import Control.Exception (bracket)+import Control.Monad (void)+import Data.ByteString (ByteString)+import Data.Functor ((<&>))+import Data.Int (Int32, Int8)+import qualified Data.Text as T+import Data.UUID.Types (UUID)+import Data.Word (Word16, Word32, Word64, Word8)+import Database.Beam+  ( Beamable,+    C,+    Database,+    Generic,+    MonadBeam (runNoReturn),+    Table (..),+    TableEntity,+  )+import Database.Beam.DuckDB (DuckDB, runBeamDuckDB)+import Database.Beam.DuckDB.Migrate+  ( blob,+    getDbConstraints,+    migrationBackend,+    text,+    tinyint,+    ubigint,+    uinteger,+    usmallint,+    utinyint,+    uuid,+  )+import Database.Beam.Migrate+  ( CheckedDatabaseSettings,+    DatabasePredicate (..),+    Migration,+    SomeDatabasePredicate (..),+    addColumn,+    alterTable,+    createTable,+    defaultMigratableDbSettings,+    dropColumn,+    executeMigration,+    field,+    migrationStep,+    notNull,+    runMigrationSteps,+  )+import Database.Beam.Migrate.Simple+  ( VerificationResult (..),+    autoMigrate,+    verifySchema,+  )+import Database.Beam.Query.DataTypes (double, int, maybeType, varchar)+import Database.DuckDB.Simple (Connection, execute_)+import qualified Database.DuckDB.Simple as DuckDB+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase, testCaseSteps)++tests :: TestTree+tests =+  testGroup+    "Migration tests"+    [ verifiesPrimaryKey,+      verifiesNotNull,+      verifiesForeignKey,+      autoMigrateCreatesSchema,+      autoMigrateIsIdempotent,+      multiStepMigration,+      testGroup+        "Data types"+        [ duckDBSpecificTypesRoundTrip,+          duckDBUnsignedIntegersRoundTrip+        ]+    ]++withInMemory :: (Connection -> IO a) -> IO a+withInMemory = bracket (DuckDB.open ":memory:") DuckDB.close++newtype WithPkT f = WithPkT+  { _with_pk_value :: C f Int32+  }+  deriving (Generic, Beamable)++instance Table WithPkT where+  newtype PrimaryKey WithPkT f = WithPkPk (C f Int32)+    deriving (Generic, Beamable)++  primaryKey = WithPkPk . _with_pk_value++newtype WithPkDb entity = WithPkDb+  { _with_pk :: entity (TableEntity WithPkT)+  }+  deriving (Generic, Database DuckDB)++withPkDbChecked :: CheckedDatabaseSettings DuckDB WithPkDb+withPkDbChecked = defaultMigratableDbSettings++verifiesPrimaryKey :: TestTree+verifiesPrimaryKey =+  testCase "verifySchema correctly detects primary key" $+    withInMemory $ \conn -> do+      void $ execute_ conn "CREATE TABLE with_pk (with_pk_value INTEGER NOT NULL PRIMARY KEY)"+      runBeamDuckDB conn (verifySchema migrationBackend withPkDbChecked)+        >>= \case+          VerificationSucceeded -> pure ()+          VerificationFailed failures ->+            assertFailure $ "Verification failed: " ++ show failures++newtype NotNullT f = NotNullT+  { _nn_value :: C f T.Text+  }+  deriving (Generic, Beamable)++instance Table NotNullT where+  newtype PrimaryKey NotNullT f = NotNullPk (C f T.Text)+    deriving (Generic, Beamable)++  primaryKey = NotNullPk . _nn_value++newtype NotNullDb entity = NotNullDb+  { _nn_tbl :: entity (TableEntity NotNullT)+  }+  deriving (Generic, Database DuckDB)++notNullDbChecked :: CheckedDatabaseSettings DuckDB NotNullDb+notNullDbChecked = defaultMigratableDbSettings++verifiesNotNull :: TestTree+verifiesNotNull =+  testCase "verifySchema correctly detects NOT NULL columns" $+    withInMemory $ \conn -> do+      void $+        execute_+          conn+          "CREATE TABLE nn_tbl (nn_value VARCHAR NOT NULL PRIMARY KEY)"+      runBeamDuckDB conn (verifySchema migrationBackend notNullDbChecked)+        >>= \case+          VerificationSucceeded -> pure ()+          VerificationFailed failures ->+            assertFailure $ "Verification failed: " ++ show failures++newtype FkParentT f = FkParentT+  { _fk_parent_id :: C f Int32+  }+  deriving (Generic, Beamable)++instance Table FkParentT where+  newtype PrimaryKey FkParentT f = FkParentPk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = FkParentPk . _fk_parent_id++data FkChildT f = FkChildT+  { _fk_child_id :: C f Int32,+    _fk_child_parent_id :: PrimaryKey FkParentT f+  }+  deriving (Generic, Beamable)++instance Table FkChildT where+  newtype PrimaryKey FkChildT f = FkChildPk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = FkChildPk . _fk_child_id++data FkDb entity = FkDb+  { _fk_parent :: entity (TableEntity FkParentT),+    _fk_child :: entity (TableEntity FkChildT)+  }+  deriving (Generic, Database DuckDB)++verifiesForeignKey :: TestTree+verifiesForeignKey =+  testCase "getDbConstraints sees both parent and child tables" $+    withInMemory $ \conn -> do+      void $+        execute_+          conn+          "CREATE TABLE fk_parent (fk_parent_id INTEGER NOT NULL PRIMARY KEY)"+      void $+        execute_+          conn+          "CREATE TABLE fk_child (\+          \  fk_child_id INTEGER NOT NULL PRIMARY KEY,\+          \  fk_child_parent_id INTEGER NOT NULL,\+          \  FOREIGN KEY (fk_child_parent_id) REFERENCES fk_parent (fk_parent_id))"+      preds <- runBeamDuckDB conn getDbConstraints+      assertBool "should see fk_parent in predicates" $+        any (predicateNamesTable "fk_parent") preds+      assertBool "should see fk_child in predicates" $+        any (predicateNamesTable "fk_child") preds+  where+    predicateNamesTable :: T.Text -> SomeDatabasePredicate -> Bool+    predicateNamesTable name (SomeDatabasePredicate p) =+      name `T.isInfixOf` T.pack (englishDescription p)++autoMigrateCreatesSchema :: TestTree+autoMigrateCreatesSchema =+  testCase "autoMigrate creates a schema that round-trips through verifySchema" $+    withInMemory $ \conn -> do+      runBeamDuckDB conn $ autoMigrate migrationBackend withPkDbChecked+      runBeamDuckDB conn (verifySchema migrationBackend withPkDbChecked)+        >>= \case+          VerificationSucceeded -> pure ()+          VerificationFailed failures ->+            assertFailure $ "Verification failed: " ++ show failures+      preds <- runBeamDuckDB conn getDbConstraints+      assertBool "should see with_pk table after autoMigrate" $+        any (predicateNamesTable "with_pk") preds+  where+    predicateNamesTable :: T.Text -> SomeDatabasePredicate -> Bool+    predicateNamesTable name (SomeDatabasePredicate p) =+      name `T.isInfixOf` T.pack (englishDescription p)++autoMigrateIsIdempotent :: TestTree+autoMigrateIsIdempotent =+  testCase "autoMigrate is idempotent" $+    withInMemory $ \conn -> do+      runBeamDuckDB conn $ autoMigrate migrationBackend withPkDbChecked+      runBeamDuckDB conn $ autoMigrate migrationBackend withPkDbChecked+      runBeamDuckDB conn (verifySchema migrationBackend withPkDbChecked) >>= \case+        VerificationSucceeded -> pure ()+        VerificationFailed failures ->+          assertFailure $ "Verification failed: " ++ show failures++data TypesT f = TypesT+  { _types_pk :: C f Int32,+    _types_text :: C f T.Text,+    _types_blob :: C f ByteString,+    _types_tinyint :: C f Int8,+    _types_uuid :: C f UUID+  }+  deriving (Generic, Beamable)++instance Table TypesT where+  newtype PrimaryKey TypesT f = TypesPk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = TypesPk . _types_pk++newtype TypesDb entity = TypesDb+  { _types_tbl :: entity (TableEntity TypesT)+  }+  deriving (Generic, Database DuckDB)++duckDBSpecificTypesRoundTrip :: TestTree+duckDBSpecificTypesRoundTrip =+  testCase "verifySchema round-trips schemas that use text/blob/tinyint/uuid" $+    withInMemory $ \conn -> do+      -- Create the table directly with the DuckDB-specific keywords. The+      -- schema we'll verify against, defined below via 'createTable' + 'field',+      -- should round-trip cleanly against information_schema.+      void $+        execute_+          conn+          "CREATE TABLE types_tbl (\+          \ types_pk INTEGER NOT NULL PRIMARY KEY,\+          \ types_text TEXT NOT NULL,\+          \ types_blob BLOB NOT NULL,\+          \ types_tinyint TINYINT NOT NULL,\+          \ types_uuid UUID NOT NULL)"+      let dsl = do+            typesTbl <-+              createTable+                "types_tbl"+                TypesT+                  { _types_pk = field "types_pk" int notNull,+                    _types_text = field "types_text" text notNull,+                    _types_blob = field "types_blob" blob notNull,+                    _types_tinyint = field "types_tinyint" tinyint notNull,+                    _types_uuid = field "types_uuid" uuid notNull+                  }+            pure (TypesDb typesTbl)+      -- Drop the table so executeMigration can re-create it from the DSL,+      -- then verify it matches. This is the standard 'beam-migrate' flow.+      void $ execute_ conn "DROP TABLE types_tbl"+      result <- runBeamDuckDB conn $ do+        db <- executeMigration runNoReturn dsl+        verifySchema migrationBackend db+      case result of+        VerificationSucceeded -> pure ()+        VerificationFailed failures ->+          assertFailure $ "Verification failed: " ++ show failures++data UIntsT f = UIntsT+  { _uints_pk :: C f Int32,+    _uints_utiny :: C f Word8,+    _uints_usmall :: C f Word16,+    _uints_uint :: C f Word32,+    _uints_ubig :: C f Word64+  }+  deriving (Generic, Beamable)++instance Table UIntsT where+  newtype PrimaryKey UIntsT f = UIntsPk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = UIntsPk . _uints_pk++newtype UIntsDb entity = UIntsDb+  { _uints_tbl :: entity (TableEntity UIntsT)+  }+  deriving (Generic, Database DuckDB)++duckDBUnsignedIntegersRoundTrip :: TestTree+duckDBUnsignedIntegersRoundTrip =+  testCase "verifySchema round-trips schemas that use utinyint/usmallint/uinteger/ubigint" $+    withInMemory $ \conn -> do+      let dsl = do+            uintsTbl <-+              createTable+                "uints_tbl"+                UIntsT+                  { _uints_pk = field "uints_pk" int notNull,+                    _uints_utiny = field "uints_utiny" utinyint notNull,+                    _uints_usmall = field "uints_usmall" usmallint notNull,+                    _uints_uint = field "uints_uint" uinteger notNull,+                    _uints_ubig = field "uints_ubig" ubigint notNull+                  }+            pure (UIntsDb uintsTbl)+      result <- runBeamDuckDB conn $ do+        db <- executeMigration runNoReturn dsl+        verifySchema migrationBackend db+      case result of+        VerificationSucceeded -> pure ()+        VerificationFailed failures ->+          assertFailure $ "Verification failed: " ++ show failures++data WidgetsV1T f = WidgetsV1T+  { _v1_id :: C f Int32,+    _v1_name :: C f T.Text+  }+  deriving (Generic, Beamable)++instance Table WidgetsV1T where+  newtype PrimaryKey WidgetsV1T f = WidgetsV1Pk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = WidgetsV1Pk . _v1_id++data WidgetsV2T f = WidgetsV2T+  { _v2_id :: C f Int32,+    _v2_name :: C f T.Text,+    -- widgets gains a nullable 'price' column. DuckDB rejects+    -- @ALTER TABLE ... ADD COLUMN ... <constraint>@ with the parser error+    -- "Adding columns with constraints not yet supported", so the added+    -- column is nullable here.+    _v2_price :: C f (Maybe Double)+  }+  deriving (Generic, Beamable)++instance Table WidgetsV2T where+  newtype PrimaryKey WidgetsV2T f = WidgetsV2Pk (C f Int32)+    deriving (Generic, Beamable)+  primaryKey = WidgetsV2Pk . _v2_id++newtype WidgetsV1Db entity = WidgetsV1Db+  { _v1_widgets :: entity (TableEntity WidgetsV1T)+  }+  deriving (Generic, Database DuckDB)++newtype WidgetsV2Db entity = WidgetsV2Db+  { _v2_widgets :: entity (TableEntity WidgetsV2T)+  }+  deriving (Generic, Database DuckDB)++stepV1 :: () -> Migration DuckDB (CheckedDatabaseSettings DuckDB WidgetsV1Db)+stepV1 () =+  WidgetsV1Db+    <$> createTable+      "widgets"+      WidgetsV1T+        { _v1_id = field "id" int notNull,+          _v1_name = field "name" (varchar Nothing) notNull+        }++stepV2 ::+  CheckedDatabaseSettings DuckDB WidgetsV1Db ->+  Migration DuckDB (CheckedDatabaseSettings DuckDB WidgetsV2Db)+stepV2 oldDb =+  WidgetsV2Db+    <$> alterTable+      (_v1_widgets oldDb)+      ( \oldTbl -> do+          price <- addColumn (field "price" (maybeType double))+          pure+            WidgetsV2T+              { _v2_id = _v1_id oldTbl,+                _v2_name = _v1_name oldTbl,+                _v2_price = price+              }+      )++stepV3 ::+  CheckedDatabaseSettings DuckDB WidgetsV2Db ->+  Migration DuckDB (CheckedDatabaseSettings DuckDB WidgetsV1Db)+stepV3 (WidgetsV2Db v2Table) =+  WidgetsV1Db+    <$> alterTable+      v2Table+      ( \v2Table' -> do+          dropColumn (_v2_price v2Table')+          pure (WidgetsV1T (_v2_id v2Table') (_v2_name v2Table'))+      )++runVerifySchema ::+  (Database DuckDB db) =>+  Connection ->+  CheckedDatabaseSettings DuckDB db ->+  Assertion+runVerifySchema conn db =+  runBeamDuckDB conn (verifySchema migrationBackend db) >>= \case+    VerificationSucceeded -> pure ()+    VerificationFailed failures ->+      assertFailure $ "Verification failed: " ++ show failures++multiStepMigration :: TestTree+multiStepMigration =+  testCaseSteps "two-step migration: create + alter table verifies against final schema" $ \testStep ->+    withInMemory $ \conn -> do+      let getColumns =+            ( DuckDB.query_+                conn+                "SELECT column_name FROM information_schema.columns \+                \WHERE table_name = 'widgets' \+                \ORDER BY ordinal_position" ::+                IO [DuckDB.Only T.Text]+            )+              <&> map (\(DuckDB.Only c) -> c)++      testStep "Initial migration"+      v1Db <- runBeamDuckDB conn $+        runMigrationSteps 0 Nothing (migrationStep "V1" stepV1) $ \_ _ step ->+          executeMigration runNoReturn step+      runVerifySchema conn v1Db++      getColumns >>= \cols ->+        assertEqual+          ("expected 'price' in columns " ++ show cols)+          ["id", "name"]+          cols++      testStep "Migration across V1 and V2"+      v2Db <- runBeamDuckDB conn+        $ runMigrationSteps+          1+          Nothing+          ( migrationStep "V1" stepV1+              >>> migrationStep "V2" stepV2+          )+        $ \_ _ step ->+          executeMigration runNoReturn step+      runVerifySchema conn v2Db++      getColumns >>= \cols ->+        assertEqual+          ("expected 'price' in columns " ++ show cols)+          ["id", "name", "price"]+          cols++      testStep "Migration back to V1"+      v3Db <- runBeamDuckDB conn+        $ runMigrationSteps+          2+          Nothing+          ( migrationStep "V1" stepV1+              >>> migrationStep "V2" stepV2+              >>> migrationStep "V3" stepV3+          )+        $ \_ _ step ->+          executeMigration runNoReturn step+      runVerifySchema conn v3Db++      getColumns >>= \cols ->+        assertEqual+          ("expected price column dropped, in columns " ++ show cols)+          ["id", "name"]+          cols
tests/Database/Beam/DuckDB/Test/Query.hs view
@@ -55,12 +55,14 @@     isTrue_,     lagWithDefault_,     lastValue_,+    lead1_,     leftJoin_,     modifyTableFields,     noBounds_,     noOrder_,     noPartition_,     nrows_,+    nub_,     orderBy_,     orderPartitionBy_,     over_,@@ -148,7 +150,10 @@             ],           testGroup             "LAG/LEAD"-            [testLag, testLead],+            [ testLag,+              testLead,+              testLeadOverNub+            ],           testGroup             "FILTER"             [ testFilterWhereCountAll,@@ -779,6 +784,34 @@       -- Second leads to third's qty (1)       -- Third has no successor → default 0       rows @?= [0, 2, 1]++-- Regression test for #746+testLeadOverNub :: TestTree+testLeadOverNub = testCase "LEAD over nub_ (issue #746)" $ do+  let users = [User 1 "Alice" 30]+      products = [Product 1 "Widget" 999]+      orders =+        [ Order 1 (UserId 1) (ProductId 1) 10,+          Order 2 (UserId 1) (ProductId 1) 10,+          Order 3 (UserId 1) (ProductId 1) 20,+          Order 4 (UserId 1) (ProductId 1) 20,+          Order 5 (UserId 1) (ProductId 1) 30,+          Order 6 (UserId 1) (ProductId 1) 30+        ]+  withTestDb users products orders $ \conn -> do+    rows <-+      runBeamDuckDB conn $+        runSelectReturningList $+          select $+            withWindow_+              (\q -> frame_ noPartition_ (orderPartitionBy_ (asc_ q)) noBounds_)+              (\q w -> (q, lead1_ q `over_` w))+              (nub_ (_orderQuantity <$> all_ (_dbOrders testDb)))+    rows+      @?= [ (10 :: Int32, Just 20),+            (20, Just 30),+            (30, Nothing)+          ]  testFilterWhereCountAll :: TestTree testFilterWhereCountAll = testCase "COUNT(*) FILTER (WHERE ... )" $ do
tests/Main.hs view
@@ -1,14 +1,16 @@ module Main (main) where  import qualified Database.Beam.DuckDB.Test.Extensions (tests)+import qualified Database.Beam.DuckDB.Test.Migrate (tests) import qualified Database.Beam.DuckDB.Test.Query (tests) import Test.Tasty (defaultMain, testGroup)  main :: IO () main =-    defaultMain $-        testGroup-            "beam-duckdb tests"-            [ Database.Beam.DuckDB.Test.Query.tests-            , Database.Beam.DuckDB.Test.Extensions.tests-            ]+  defaultMain $+    testGroup+      "beam-duckdb tests"+      [ Database.Beam.DuckDB.Test.Query.tests,+        Database.Beam.DuckDB.Test.Extensions.tests,+        Database.Beam.DuckDB.Test.Migrate.tests+      ]