packages feed

hasql-generate (empty) → 1.0.0

raw patch · 13 files changed

+3655/−0 lines, 13 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, data-default-class, hasql, hasql-generate, postgresql-libpq, scientific, template-haskell, text, time, uuid, vector

Files

+ CHANGELOG.md view
@@ -0,0 +1,3 @@+# Changelog++1.0.0: Hello, world! The README is probably a better source for what this is
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2025 dneaves++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,298 @@+# Hasql.Generate+A library for compile-time generation of datatypes from Postgres introspection. Inspired by the relational-query-HDBC library's defineTableFromDB functions, but expanded to use Hasql, support more than just tables, and using the features of Postgres.+Aims to eliminate most of the boilerplate with using Hasql, the duplicate-definitions and need to ensure database-definitions match code-definitions, and be simple enough to use and understand. ++---++## Developing Hasql.Generate++All the tools I use are available in the `nix-shell`.+That will give you `just`, and `justfile` (run with `just help`) will have all the commands you would need.+To run the tests, you will need to have postgres running, which again there's a `just` recipe for (`just pg-start`), since the tests revolve around the generation of types and functions. The `just test` command will setup the database, run the tests, and clean itself up afterwards. When done, just stop the database (`just pg-stop`).+Please format and lint your work (`just format`/`just lint`).++## Using Hasql.Generate++### BYOD (Bring Your Own Data/Database)+To start with this library, you'll need a pre-existing PostgreSQL database with tables, views, or types.++### Config+From there, you should make a `Config` that all the TH splices will generate with. This config should be made in a file that you will _import_ into files that generate types (this is a TemplateHaskell requirement, not mine).+```haskell+data Config+    = Config+      { connection                 :: ConnectionInfo+      , allowDuplicateRecordFields :: Bool+      , newtypePrimaryKeys         :: Bool+      , globalOverrides            :: [(String, Name)]+      }+```+You will need to make a `ConnectionInfo`, with either the `PgSimpleInfo` for "simple" info that's then formed into the connection string, or you can make the libpq connection string yourself (with all the advanced options) with `PgConnectionString` for full control, but you should know what you're doing for this case.+```haskell+data ConnectionInfo+    = PgSimpleInfo+      { pgHost     :: Maybe String+      , pgPort     :: Maybe String+      , pgUser     :: Maybe String+      , pgPassword :: Maybe String+      , pgDatabase :: Maybe String+      }+    | PgConnectionString String+```+If you set any `PgSimpleInfo` field as `Nothing` it will use the libpq defaults. If you will be using the default libpq connection details for all of it, you can use `Data.Default.def` for the `ConnectionInfo`. If you will not be using DuplicateRecordFields, are fine with the primary keys being "regular" types (ex.: `Int` instead of a wrapped-newtype `TypeNamePk {getTypeNamePk :: Int}`), and have no global type-overrides, you can use `Data.Default.def` for the entire `Config`.++### Generate Haskell Datatypes+Once that's setup, we can use that to generate our data. In a file where you want this type to be generated:+```haskell+module MyApp.MySqlDatatypes where++import Hasql.Generate (generate, fromTable, fromView, fromType)+-- You made this in the last step!+import MyApp.MyHasqlGenerateConfig (config)++$(generate config $ fromType "public" "user_type")++$(generate config $ fromTable "public" "users")++$(generate config $ fromView "public" "users_view")+```+If you need to override types on a per-table level:+```haskell+import Data.Function ((&))+import Hasql.Generate (generate, fromTable, withOverrides)+import MyApp.MyHasqlGenerateConfig (config)++$( generate config+    ( fromTable "public" "users"+      & withOverrides [ ("text", ''String) ]+    )+ )+```+Or if you want to generically-derive types:+```haskell+import Data.Aeson ( FromJSON, ToJSON)+import Data.Function ((&))+import GHC.Generics (Generic)+import Hasql.Generate (generate, fromTable, withDerivations)+import MyApp.MyHasqlGenerateConfig (config)++$( generate config+    ( fromTable "public" "users"+      & withDerivations [''Show, ''Eq, ''Generic, ''ToJSON, ''FromJSON] +    )+ )+```+(or you can do both, just chain them)++### Use Generated Tables+Given the following SQL:+```sql+CREATE TYPE public.user_role AS ENUM ('admin', 'important', 'regular');++CREATE TABLE public.users+  ( id     UUID      NOT NULL PRIMARY KEY DEFAULT uuidv7()+  , name   TEXT      NOT NULL+  , "role" user_role NOT NULL DEFAULT 'regular'+  , email  TEXT+  , age    INT4+  );+```+`$( generate def $ fromTable "public" "users")` will generate the following Haskell datatype:+```haskell+data Users+  = Users+    { usersId    :: !UUID+    , usersName  :: !Text+    , usersRole  :: !UserRole+    , usersEmail :: !(Maybe Text)+    , usersAge   :: !(Maybe Int32)+    }+```+\*you are responsible for adding an unqualified import for all types for your database. In the above example, you will need+```haskell+import Data.Int (Int32)+import Data.Text (Text)+import Data.UUID (UUID)+-- See important note on Postgres types below, in "Use Generated Types"+import MyApp.MyUserRoleLocation (UserRole)+```++It will also generate:+- `usersDecoder`+- `usersEncoder`+- `insertUsers`+- `insertManyUsers`+- a `HasInsert` instance++And when it has a Primary Key defined, it will also generate:+- `selectUsers`+- `selectManyUsers`+- `updateUsers`+- `updateManyUsers`+- `deleteUsers`+- `deleteManyUsers`+- a `HasPrimaryKey` instance+- a `HasSelect` instance+- a `HasUpdate` instance+- a `HasDelete` instance++If your Primary Key has a DEFAULT and you want to defer to the database to generate PK values on INSERT, add `withholdPk` to the `fromTable` generator. Without `withholdPk`, the primary key you supply in Haskell will be included in the INSERT, and the Postgres DEFAULT will not be used. You still need to create the record with a primary key value, but it can be a dummy value, like `0` for any Int-based type, or `UUID.nil` for a `UUID`:+```haskell+import Data.Function ((&))+import Hasql.Generate (generate, fromTable, withholdPk)+import MyApp.MyHasqlGenerateConfig (config)++$(generate config (fromTable "public" "users" & withholdPk))+```++### Use Generated Views+Given the following SQL:+```sql+CREATE VIEW public.users_view AS+  SELECT (name, "role", email, age)+  FROM public.users+  WHERE "role" = 'regular'+;+```+`$( generate def $ fromView "public" "users_view")` will generate the following Haskell datatype:+```haskell+data UsersView+  = UsersView+    { usersViewName  :: !(Maybe Text)+    , usersViewRole  :: !(Maybe UserRole)+    , usersViewEmail :: !(Maybe Text)+    , usersViewAge   :: !(Maybe Int32)+    }+```+\*All field types are Maybes in views because that's how Postgres reports the types on-introspection, even if the underlying table's column is `NOT NULL`.++It will also generate:+- `usersViewDecoder`+- `selectUsersView`+- a `HasView` instance++### Use Generated Types+Given the following SQL:+```sql+CREATE TYPE public.user_role+  AS ENUM ('admin', 'important', 'regular');+```+`$( generate def $ fromType "public" "user_role")` will generate the following Haskell datatype:+```haskell+data UserRole+  = Admin+  | Important+  | Regular+```+It will also generate:+- a `PgCodec` instance+- a `PgColumn` instance+- a `HasEnum` instance++IMPORTANT: If you define a type in Postgres, then use that type in a table you generate with `fromTable`, you must supply a matching type to the file containing the table's generator splice. You can either generate it with the `fromType` function as outlined above, or you can make it yourself. If you choose to write your own, you _must_ define `PgCodec` and `PgColumn` instances for the type written yourself, and the file generating the table mentioned previously _must_ have those instances in-scope.++The generated `PgColumn` instance triggers `-Worphans` because the functional+dependency's determining types are `Symbol` literals. Suppress with:+```haskell+{-# OPTIONS_GHC -Wno-orphans #-}+```++### Config Options++#### allowDuplicateRecordFields+Pairs with the DuplicateRecordFields Haskell pragma/language-extension. When active, we drop the camelCase table-name from the front of fields of generated table and view datatypes.++So this:+```haskell+data Users+  = Users+    { usersId    :: !UUID+    , usersName  :: !Text+    , usersRole  :: !UserRole+    , usersEmail :: !(Maybe Text)+    , usersAge   :: !(Maybe Int32)+    }+```+...would instead be this (with `allowDuplicateRecordFields = True`):+```haskell+data Users+  = Users+    { id    :: !UUID+    , name  :: !Text+    , role' :: !UserRole+    , email :: !(Maybe Text)+    , age   :: !(Maybe Int32)+    }+```+If you noticed, in this second example, there is an `id` field. This may conflict with the `id` function in Prelude, so you may wish to hide the `id` from Prelude, or name your columns accordingly. The `role` column is also named `role'` in Haskell, since `role` is a reserved keyword (`role` is also a reserved word in Postgres, hence why we've been double-quoting it in this README). Any Haskell-keywords will append an apostrophe as such.++#### newtypePrimaryKeys+When active, we generate newtype-wrappers for primary keys of tables.++So this:+```haskell+data Users+  = Users+    { usersId    :: !UUID+    , usersName  :: !Text+    , usersRole  :: !UserRole+    , usersEmail :: !(Maybe Text)+    , usersAge   :: !(Maybe Int32)+    }+```+...would instead be this (with `newtypePrimaryKeys = True`):+```haskell+newtype UsersPk = UsersPk { getUsersPk :: UUID }++data Users+  = Users+    { usersId    :: !UsersPk+    , usersName  :: !Text+    , usersRole  :: !UserRole+    , usersEmail :: !(Maybe Text)+    , usersAge   :: !(Maybe Int32)+    }+```++We also support composite primary keys:+```sql+CREATE TABLE public.user_items (+    user_id     UUID     NOT NULL,+    item_id     INT4     NOT NULL,+    json_data   JSONB    NOT NULL,+    PRIMARY KEY (user_id, item_id)+);+```+```haskell+import Data.Aeson (Value)++data UserItemsPk+  = UserItemsPk+    { userItemsPkUserId :: !UUID+    , userItemsPkItemId :: !Int32+    }+  deriving stock (Show, Eq)++data UserItems+  = UserItems+    { userItemsUserId   :: !UUID+    , userItemsItemId   :: !Int32+    , userItemsJsonData :: !Value+    }+```+but note we don't replace a field's type with a newtype wrapper, since it's multiple fields and it gets a little weird.++#### globalOverrides+This allows overrides for all generators using the config. For example, with the Postgres `TEXT` type, we generate `Data.Text.Text` by default. If you wanted to use `String` instead, you would add to the globalOverrides:+```haskell+import Data.Default (Default(def))+import Hasql.Generate (Config(..))++{- In `("text", ''String)`: `"text"` is the PG type (must be lowercase),+   and `''String` is obviously the type you want it to map to+-}+myConfig :: Config+myConfig = def { globalOverrides = [("text", ''String)] }+```++If you only want to override a type just for for a table/view generator, see `withOverrides`, as these take precedence over the global ones.
+ hasql-generate.cabal view
@@ -0,0 +1,93 @@+cabal-version: 3.4+name:          hasql-generate+version:       1.0.0+synopsis:      Compile-time PostgreSQL data generation for hasql+description:+  Connects to a live PostgreSQL database at compile time via TemplateHaskell,+  introspects table schemas from pg_catalog, and generates types, hasql+  decoders\/encoders, and CRUD statements targeting hasql's binary protocol.++author:           dneaves+maintainer:       dneavesdev@pm.me+homepage:         https://code.dneaves.com/dneaves/hasql-generate+bug-reports:      https://code.dneaves.com/dneaves/hasql-generate/issues/1+license:          MIT+license-file:     LICENSE+copyright:        © 2025 dneaves+category:         Database+build-type:       Simple+tested-with:      GHC ==9.8.4 || ==9.10.3 || ==9.12.2+extra-doc-files:  CHANGELOG.md, README.md++source-repository head+    type: git+    location: https://code.dneaves.com/dneaves/hasql-generate++common warnings+  ghc-options: -Wall++common extensions+  default-extensions:+    NoImplicitPrelude+    AllowAmbiguousTypes+    DataKinds+    DeriveAnyClass+    DerivingStrategies+    DuplicateRecordFields+    FlexibleInstances+    FunctionalDependencies+    InstanceSigs+    LambdaCase+    MultiParamTypeClasses+    MultiWayIf+    OverloadedStrings+    OverloadedRecordDot+    ScopedTypeVariables+    TemplateHaskell+    TypeFamilies++library+  import:           warnings, extensions+  default-language: GHC2021+  hs-source-dirs:   src+  exposed-modules:+    Hasql.Generate+    Hasql.Generate.Codec+    Hasql.Generate.Column+    Hasql.Generate.Config+    Hasql.Generate.Class+    Hasql.Generate.Connection+    Hasql.Generate.TH+  other-modules:+    Hasql.Generate.Internal.Introspect+  build-depends:+    , aeson              >=2.1     && <2.3+    , base               >=4.19    && <4.22+    , bytestring         >=0.11.5  && <0.13+    , data-default-class >=0.1     && <0.2+    , hasql              >=1.6     && <1.9+    , postgresql-libpq   >=0.10    && <0.12+    , scientific         >=0.3.7   && <0.4+    , template-haskell   >=2.21    && <2.24+    , text               >=2.0.2   && <2.2+    , time               >=1.12    && <1.15+    , uuid               >=1.3     && <1.4+    , vector             >=0.13    && <0.14++test-suite hasql-generate-test+  import:           warnings, extensions+  default-language: GHC2021+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          Main.hs+  build-depends:+    , aeson              >=2.1     && <2.3+    , base               >=4.19    && <4.22+    , bytestring         >=0.11.5  && <0.13+    , data-default-class >=0.1     && <0.2+    , hasql              >=1.6     && <1.9+    , hasql-generate+    , scientific         >=0.3.7   && <0.4+    , text               >=2.0.2   && <2.2+    , time               >=1.12    && <1.15+    , uuid               >=1.3     && <1.4
+ src/Hasql/Generate.hs view
@@ -0,0 +1,49 @@+module Hasql.Generate+    ( Config (..)+    , ConnectionInfo (..)+    , GenerateConfig+    , HasDelete (..)+    , HasEnum (..)+    , HasInsert (..)+    , HasPrimaryKey (..)+    , HasSelect (..)+    , HasUpdate (..)+    , HasView (..)+    , PgCodec (..)+    , PgColumn+    , fromTable+    , fromType+    , fromView+    , generate+    , libpqDefaults+    , withDerivations+    , withOverrides+    , withholdPk+    ) where++import           Hasql.Generate.Class+    ( HasDelete (..)+    , HasEnum (..)+    , HasInsert (..)+    , HasPrimaryKey (..)+    , HasSelect (..)+    , HasUpdate (..)+    , HasView (..)+    )+import           Hasql.Generate.Codec      ( PgCodec (..) )+import           Hasql.Generate.Column     ( PgColumn )+import           Hasql.Generate.Config     ( Config (..) )+import           Hasql.Generate.Connection+    ( ConnectionInfo (..)+    , libpqDefaults+    )+import           Hasql.Generate.TH+    ( GenerateConfig+    , fromTable+    , fromType+    , fromView+    , generate+    , withDerivations+    , withOverrides+    , withholdPk+    )
+ src/Hasql/Generate/Class.hs view
@@ -0,0 +1,97 @@+module Hasql.Generate.Class+    ( HasDelete (..)+    , HasEnum (..)+    , HasInsert (..)+    , HasPrimaryKey (..)+    , HasSelect (..)+    , HasUpdate (..)+    , HasView (..)+    ) where++----------------------------------------------------------------------------------------------------++import           Data.Maybe    ( Maybe )+import           Data.Text     ( Text )++import qualified Hasql.Session++----------------------------------------------------------------------------------------------------++{-  Typeclass providing utility functions for PostgreSQL enum types generated+    by @fromType@.++    * @allValues@ — all constructors in declaration order (matching PostgreSQL+      sort order).+    * @toText@ — convert a constructor to its PostgreSQL label as 'Text'.+    * @fromText@ — parse a PostgreSQL label back to a constructor, returning+      'Nothing' on unrecognised input.+-}+class HasEnum a where+  allValues :: [a]+  toText :: a -> Text+  fromText :: Text -> Maybe a++----------------------------------------------------------------------------------------------------++{-  Typeclass for tables with a primary key that support SELECT-by-PK queries.+    The associated type @SelectKey@ is the PK type (single value or tuple for+    composite keys). Returns @Nothing@ when the key is not found.++    @selectMany@ takes a list of keys and returns all matching rows. Unmatched+    keys are silently omitted. An empty input list returns an empty result.+-}+class HasSelect a where+  type SelectKey a+  select :: SelectKey a -> Hasql.Session.Session (Maybe a)+  selectMany :: [SelectKey a] -> Hasql.Session.Session [a]++{-  Typeclass for tables that support INSERT with RETURNING. All tables get an+    instance; the insert returns the full record (including any server-generated+    columns like serial PKs).++    @insertMany@ inserts multiple rows in a single round-trip using @unnest@-based+    array parameters and returns all inserted rows via @RETURNING@.+-}+class HasInsert a where+  insert :: a -> Hasql.Session.Session a+  insertMany :: [a] -> Hasql.Session.Session [a]++{-  Typeclass for tables with a primary key that support UPDATE-by-PK with+    RETURNING. Returns @Nothing@ if the PK was not found.++    @updateMany@ updates multiple rows in a single round-trip using an @unnest@+    subquery join. Returns only the rows that were actually updated; unmatched+    PKs are silently omitted.+-}+class HasUpdate a where+  update :: a -> Hasql.Session.Session (Maybe a)+  updateMany :: [a] -> Hasql.Session.Session [a]++{-  Typeclass for tables with a primary key that support DELETE-by-PK.+    The associated type @DeleteKey@ is the PK type.++    @deleteMany@ deletes multiple rows in a single round-trip. Unmatched+    keys are silently ignored.+-}+class HasDelete a where+  type DeleteKey a+  delete :: DeleteKey a -> Hasql.Session.Session ()+  deleteMany :: [DeleteKey a] -> Hasql.Session.Session ()++-- | Typeclass for views — read-only relations with no primary key.+class HasView a where+  selectView :: Hasql.Session.Session [a]++{-  Typeclass for tables with a primary key, providing generic PK operations.+    Generated for all tables with PKs regardless of 'newtypePrimaryKeys'.++    @PkOf@ is the PK type (newtype wrapper when enabled, raw type otherwise).+    @RawPkOf@ is always the unwrapped PK type (e.g. @UUID@ or @(UUID, Int32)@).+-}+class HasPrimaryKey a where+  type PkOf a+  type RawPkOf a+  toPk :: a -> PkOf a+  wrapPk :: RawPkOf a -> PkOf a+  unwrapPk :: PkOf a -> RawPkOf a+  rawPk :: a -> RawPkOf a
+ src/Hasql/Generate/Codec.hs view
@@ -0,0 +1,158 @@+module Hasql.Generate.Codec+    ( PgCodec (..)+    ) where++----------------------------------------------------------------------------------------------------++import qualified Data.Aeson                 as Aeson+import           Data.Bool                  ( Bool )+import           Data.ByteString            ( ByteString )+import           Data.Char                  ( Char )+import           Data.Functor               ( fmap )+import qualified Data.Functor.Contravariant as Contravariant+import           Data.Int                   ( Int16, Int32, Int64 )+import           Data.Scientific            ( Scientific )+import           Data.String                ( String )+import           Data.Text                  ( Text )+import qualified Data.Text+import           Data.Time+    ( Day+    , DiffTime+    , LocalTime+    , TimeOfDay+    , TimeZone+    , UTCTime+    )+import           Data.UUID                  ( UUID )++import qualified Hasql.Decoders             as Decoders+import qualified Hasql.Encoders             as Encoders++import           Prelude                    ( Double, Float )++----------------------------------------------------------------------------------------------------++{-  Typeclass mapping a Haskell type to its hasql binary-protocol encoder and+    decoder. Each instance pairs a type with the corresponding hasql 'Value'+    encoder and decoder, enabling the TH code generator to resolve codecs by+    type inference alone.+-}+class PgCodec a where+  pgDecode :: Decoders.Value a+  pgEncode :: Encoders.Value a++----------------------------------------------------------------------------------------------------++instance PgCodec Bool where+  pgDecode :: Decoders.Value Bool+  pgDecode = Decoders.bool+  pgEncode :: Encoders.Value Bool+  pgEncode = Encoders.bool++instance PgCodec Int16 where+  pgDecode :: Decoders.Value Int16+  pgDecode = Decoders.int2+  pgEncode :: Encoders.Value Int16+  pgEncode = Encoders.int2++instance PgCodec Int32 where+  pgDecode :: Decoders.Value Int32+  pgDecode = Decoders.int4+  pgEncode :: Encoders.Value Int32+  pgEncode = Encoders.int4++instance PgCodec Int64 where+  pgDecode :: Decoders.Value Int64+  pgDecode = Decoders.int8+  pgEncode :: Encoders.Value Int64+  pgEncode = Encoders.int8++instance PgCodec Float where+  pgDecode :: Decoders.Value Float+  pgDecode = Decoders.float4+  pgEncode :: Encoders.Value Float+  pgEncode = Encoders.float4++instance PgCodec Double where+  pgDecode :: Decoders.Value Double+  pgDecode = Decoders.float8+  pgEncode :: Encoders.Value Double+  pgEncode = Encoders.float8++instance PgCodec Scientific where+  pgDecode :: Decoders.Value Scientific+  pgDecode = Decoders.numeric+  pgEncode :: Encoders.Value Scientific+  pgEncode = Encoders.numeric++instance PgCodec Char where+  pgDecode :: Decoders.Value Char+  pgDecode = Decoders.char+  pgEncode :: Encoders.Value Char+  pgEncode = Encoders.char++instance PgCodec Text where+  pgDecode :: Decoders.Value Text+  pgDecode = Decoders.text+  pgEncode :: Encoders.Value Text+  pgEncode = Encoders.text++instance PgCodec String where+  pgDecode :: Decoders.Value String+  pgDecode = fmap Data.Text.unpack Decoders.text+  pgEncode :: Encoders.Value String+  pgEncode = Contravariant.contramap Data.Text.pack Encoders.text++instance PgCodec ByteString where+  pgDecode :: Decoders.Value ByteString+  pgDecode = Decoders.bytea+  pgEncode :: Encoders.Value ByteString+  pgEncode = Encoders.bytea++instance PgCodec Day where+  pgDecode :: Decoders.Value Day+  pgDecode = Decoders.date+  pgEncode :: Encoders.Value Day+  pgEncode = Encoders.date++instance PgCodec LocalTime where+  pgDecode :: Decoders.Value LocalTime+  pgDecode = Decoders.timestamp+  pgEncode :: Encoders.Value LocalTime+  pgEncode = Encoders.timestamp++instance PgCodec UTCTime where+  pgDecode :: Decoders.Value UTCTime+  pgDecode = Decoders.timestamptz+  pgEncode :: Encoders.Value UTCTime+  pgEncode = Encoders.timestamptz++instance PgCodec TimeOfDay where+  pgDecode :: Decoders.Value TimeOfDay+  pgDecode = Decoders.time+  pgEncode :: Encoders.Value TimeOfDay+  pgEncode = Encoders.time++instance PgCodec (TimeOfDay, TimeZone) where+  pgDecode :: Decoders.Value (TimeOfDay, TimeZone)+  pgDecode = Decoders.timetz+  pgEncode :: Encoders.Value (TimeOfDay, TimeZone)+  pgEncode = Encoders.timetz++instance PgCodec DiffTime where+  pgDecode :: Decoders.Value DiffTime+  pgDecode = Decoders.interval+  pgEncode :: Encoders.Value DiffTime+  pgEncode = Encoders.interval++instance PgCodec UUID where+  pgDecode :: Decoders.Value UUID+  pgDecode = Decoders.uuid+  pgEncode :: Encoders.Value UUID+  pgEncode = Encoders.uuid++instance PgCodec Aeson.Value where+  pgDecode :: Decoders.Value Aeson.Value+  pgDecode = Decoders.jsonb+  pgEncode :: Encoders.Value Aeson.Value+  pgEncode = Encoders.jsonb
+ src/Hasql/Generate/Column.hs view
@@ -0,0 +1,68 @@+module Hasql.Generate.Column+    ( PgColumn+    ) where++----------------------------------------------------------------------------------------------------++import qualified Data.Aeson           as Aeson+import           Data.Bool            ( Bool )+import           Data.ByteString      ( ByteString )+import           Data.Char            ( Char )+import           Data.Int             ( Int16, Int32, Int64 )+import           Data.Scientific      ( Scientific )+import           Data.Text            ( Text )+import           Data.Time+    ( Day+    , DiffTime+    , LocalTime+    , TimeOfDay+    , TimeZone+    , UTCTime+    )+import           Data.UUID            ( UUID )++import           GHC.TypeLits         ( Symbol )++import           Hasql.Generate.Codec ( PgCodec )++import           Prelude              ( Double, Float )++----------------------------------------------------------------------------------------------------++{-  Typeclass mapping a schema-qualified PostgreSQL type name to a Haskell type.+    The functional dependency @pgSchema pgName -> a@ ensures each+    (schema, type name) pair resolves to exactly one Haskell type.++    Built-in types use @\"pg_catalog\"@ as their schema. User-defined types+    (e.g. enums created with @fromType@) use their actual schema name.++    The TH code generator uses @reifyInstances@ on this class to discover the+    correct Haskell type for each column at compile time. Users can define+    additional instances for custom PostgreSQL types (e.g. enums).+-}+class (PgCodec a) => PgColumn (pgSchema :: Symbol) (pgName :: Symbol) a | pgSchema pgName -> a++----------------------------------------------------------------------------------------------------++instance PgColumn "pg_catalog" "bool" Bool+instance PgColumn "pg_catalog" "int2" Int16+instance PgColumn "pg_catalog" "int4" Int32+instance PgColumn "pg_catalog" "int8" Int64+instance PgColumn "pg_catalog" "float4" Float+instance PgColumn "pg_catalog" "float8" Double+instance PgColumn "pg_catalog" "numeric" Scientific+instance PgColumn "pg_catalog" "char" Char+instance PgColumn "pg_catalog" "text" Text+instance PgColumn "pg_catalog" "varchar" Text+instance PgColumn "pg_catalog" "bpchar" Text+instance PgColumn "pg_catalog" "name" Text+instance PgColumn "pg_catalog" "bytea" ByteString+instance PgColumn "pg_catalog" "date" Day+instance PgColumn "pg_catalog" "timestamp" LocalTime+instance PgColumn "pg_catalog" "timestamptz" UTCTime+instance PgColumn "pg_catalog" "time" TimeOfDay+instance PgColumn "pg_catalog" "timetz" (TimeOfDay, TimeZone)+instance PgColumn "pg_catalog" "interval" DiffTime+instance PgColumn "pg_catalog" "uuid" UUID+instance PgColumn "pg_catalog" "json" Aeson.Value+instance PgColumn "pg_catalog" "jsonb" Aeson.Value
+ src/Hasql/Generate/Config.hs view
@@ -0,0 +1,53 @@+module Hasql.Generate.Config+    ( Config (..)+    ) where++----------------------------------------------------------------------------------------------------++import           Data.Bool                 ( Bool (..) )+import           Data.Default.Class        ( Default (..) )+import           Data.String               ( String )++import           Hasql.Generate.Connection ( ConnectionInfo, libpqDefaults )++import           Language.Haskell.TH       ( Name )++----------------------------------------------------------------------------------------------------++{-  Top-level configuration for compile-time code generation.++    Embeds the database connection parameters ('ConnectionInfo') together with+    behavioral flags that control the shape of the generated code.++    Use @def@ for the default configuration, which reads connection details+    from standard PostgreSQL environment variables, sets False to allowDuplicateRecordFields+    and newtypePrimaryKeys, and has an emply override list.+-}+data Config+    = Config+      { connection                 :: ConnectionInfo+      , allowDuplicateRecordFields :: Bool+      , newtypePrimaryKeys         :: Bool+      , globalOverrides            :: [(String, Name)]+      }++{-  Default configuration:++    * Connection via libpq environment variables (@PGHOST@, @PGPORT@, etc.)+    * @allowDuplicateRecordFields = False@ — field names are prefixed with the+      type name so the generated code compiles without @DuplicateRecordFields@+    * @newtypePrimaryKeys = False@ — when @True@, generates newtype (single PK)+      or data record (composite PK) wrappers for primary keys, plus @HasPrimaryKey@+      instances for all tables with PKs+    * @globalOverrides = []@ — PG type overrides applied to all tables. Per-table+      'withOverrides' take precedence over global ones.+-}+instance Default Config where+  def :: Config+  def =+    Config+      { connection = libpqDefaults+      , allowDuplicateRecordFields = False+      , newtypePrimaryKeys = False+      , globalOverrides = []+      }
+ src/Hasql/Generate/Connection.hs view
@@ -0,0 +1,134 @@+module Hasql.Generate.Connection+    ( ConnectionInfo (..)+    , libpqDefaults+    , toConnString+    , withCompileTimeConnection+    ) where++----------------------------------------------------------------------------------------------------++import           Control.Exception         ( bracket, throwIO )++import           Data.Bool                 ( Bool (..), otherwise, (||) )+import qualified Data.ByteString           as BS+import qualified Data.ByteString.Char8     as BS8+import           Data.Char                 ( Char )+import           Data.Default.Class        ( Default (..) )+import           Data.Eq                   ( (/=), (==) )+import           Data.Functor              ( (<$>) )+import           Data.List+    ( any+    , concatMap+    , null+    , unwords+    , (++)+    )+import           Data.Maybe                ( Maybe (..), catMaybes, maybe )+import           Data.Semigroup            ( (<>) )+import           Data.String               ( String )++import qualified Database.PostgreSQL.LibPQ as PQ++import           Prelude                   ( Applicative (pure), userError )++import           System.IO                 ( IO )++----------------------------------------------------------------------------------------------------++{-  Acquire a libpq connection for compile-time schema introspection, run the+    provided action, and ensure the connection is closed afterward.++    The 'Data.ByteString.ByteString' argument is passed directly to+    @PQ.connectdb@. An empty string causes libpq to read connection+    parameters from standard PostgreSQL environment variables (@PGHOST@,+    @PGPORT@, @PGUSER@, @PGPASSWORD@, @PGDATABASE@, etc.).++    Throws an 'IOError' if the connection cannot be established.+-}+withCompileTimeConnection :: BS8.ByteString -> (PQ.Connection -> IO a) -> IO a+withCompileTimeConnection connStr =+  bracket acquire release+  where+    acquire :: IO PQ.Connection+    acquire = do+      conn <- PQ.connectdb connStr+      status <- PQ.status conn+      if status /= PQ.ConnectionOk+        then do+          msg <- maybe "unknown error" BS8.unpack <$> PQ.errorMessage conn+          PQ.finish conn+          throwIO (userError ("hasql-generate: compile-time DB connection failed: " <> msg))+        else+          pure conn++    release :: PQ.Connection -> IO ()+    release = PQ.finish++----------------------------------------------------------------------------------------------------++{-  Connection configuration for compile-time PostgreSQL introspection.++    @PgSimpleInfo@ lets you specify individual connection fields. Any field+    left as @Nothing@ is omitted from the connection string, so libpq falls+    back to its standard environment variables (@PGHOST@, @PGPORT@, etc.).++    @PgConnectionString@ passes a raw libpq connection string through as-is,+    useful for advanced options like @sslmode=verify-full@.+-}+data ConnectionInfo+    = PgSimpleInfo+      { pgHost     :: Maybe String+      , pgPort     :: Maybe String+      , pgUser     :: Maybe String+      , pgPassword :: Maybe String+      , pgDatabase :: Maybe String+      }+    | PgConnectionString String++{-  All @Nothing@ — produces an empty connection string so libpq reads+    standard environment variables.+-}+instance Default ConnectionInfo where+  def :: ConnectionInfo+  def = libpqDefaults++libpqDefaults :: ConnectionInfo+libpqDefaults = PgSimpleInfo Nothing Nothing Nothing Nothing Nothing++{-  Assemble a 'ConnectionInfo' into a libpq connection 'BS.ByteString'.++    For @PgSimpleInfo@, builds a @key=value@ string from the fields that+    are @Just@, omitting @Nothing@ fields. For @PgConnectionString@, passes+    the raw string through.+-}+toConnString :: ConnectionInfo -> BS.ByteString+toConnString (PgConnectionString s) = BS8.pack s+toConnString (PgSimpleInfo host port user pass db) =+  let pairs =+        catMaybes+          [ kvPair "host" host+          , kvPair "port" port+          , kvPair "user" user+          , kvPair "password" pass+          , kvPair "dbname" db+          ]+   in if null pairs+        then BS.empty+        else BS8.pack (unwords pairs)+  where+    kvPair :: String -> Maybe String -> Maybe String+    kvPair _key Nothing   = Nothing+    kvPair key (Just val) = Just (key <> "=" <> escapeValue val)++    escapeValue :: String -> String+    escapeValue v+      | needsQuoting v = "'" ++ concatMap escapeChar v ++ "'"+      | otherwise = v++    needsQuoting :: String -> Bool+    needsQuoting = any (\c -> c == ' ' || c == '\'' || c == '\\')++    escapeChar :: Char -> String+    escapeChar '\'' = "\\'"+    escapeChar '\\' = "\\\\"+    escapeChar c    = [c]
+ src/Hasql/Generate/Internal/Introspect.hs view
@@ -0,0 +1,189 @@+module Hasql.Generate.Internal.Introspect+    ( ColumnInfo (..)+    , introspectColumns+    , introspectEnumLabels+    , introspectPrimaryKey+    ) where++----------------------------------------------------------------------------------------------------++import           Control.Exception         ( throwIO )+import           Control.Monad             ( return )++import           Data.Bool                 ( Bool (..), otherwise )+import qualified Data.ByteString           as BS+import qualified Data.ByteString.Char8     as BS8+import           Data.Eq                   ( (==) )+import           Data.Function             ( ($) )+import           Data.Functor              ( (<$>) )+import           Data.Maybe                ( Maybe (..), maybe )+import           Data.Semigroup            ( (<>) )+import           Data.String               ( String )++import qualified Database.PostgreSQL.LibPQ as PQ++import           Prelude+    ( Applicative (pure)+    , userError+    , (+)+    )++import           System.IO                 ( IO )++----------------------------------------------------------------------------------------------------++data ColumnInfo+    = ColumnInfo+      { colName       :: String+      , colPgSchema   :: String+      , colPgType     :: String+      , colIsEnum     :: Bool+      , colNotNull    :: Bool+      , colHasDefault :: Bool+      }++----------------------------------------------------------------------------------------------------++{-  Query @pg_catalog@ for all visible user columns of the given table, including+    both base types (@typtype = 'b'@) and enum types (@typtype = 'e'@), excluding+    composite, pseudo, and range types. Results are ordered by column position.+-}+introspectColumns :: PQ.Connection -> String -> String -> IO [ColumnInfo]+introspectColumns conn schema table = do+  result <- PQ.execParams conn columnSQL [textParam schema, textParam table] PQ.Text+  withQueryResult "column" result parseColumnRows++----------------------------------------------------------------------------------------------------++{-  Query @pg_catalog@ for the primary key column names of the given table,+    ordered by their position within the index.+-}+introspectPrimaryKey :: PQ.Connection -> String -> String -> IO [String]+introspectPrimaryKey conn schema table = do+  result <- PQ.execParams conn primaryKeySQL [textParam schema, textParam table] PQ.Text+  withQueryResult "primary key" result parsePkRows++----------------------------------------------------------------------------------------------------++{-  Query @pg_catalog@ for the labels of a PostgreSQL enum type in the given+    schema, ordered by their sort position (@enumsortorder@).+-}+introspectEnumLabels :: PQ.Connection -> String -> String -> IO [String]+introspectEnumLabels conn schema typeName = do+  result <- PQ.execParams conn enumLabelSQL [textParam schema, textParam typeName] PQ.Text+  withQueryResult "enum label" result parseEnumRows++----------------------------------------------------------------------------------------------------++columnSQL :: BS.ByteString+columnSQL =+  "SELECT tn.nspname, a.attname, t.typname, t.typtype, a.attnotnull, a.atthasdef \+  \FROM pg_catalog.pg_attribute a \+  \JOIN pg_catalog.pg_class c ON c.oid = a.attrelid \+  \JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \+  \JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \+  \JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace \+  \WHERE n.nspname = $1 \+  \  AND c.relname = $2 \+  \  AND a.attnum > 0 \+  \  AND NOT a.attisdropped \+  \  AND t.typtype IN ('b', 'e') \+  \  AND t.typcategory NOT IN ('C', 'P', 'X') \+  \ORDER BY a.attnum"++primaryKeySQL :: BS.ByteString+primaryKeySQL =+  "SELECT a.attname \+  \FROM pg_catalog.pg_index i \+  \JOIN pg_catalog.pg_class c ON c.oid = i.indrelid \+  \JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \+  \JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid \+  \  AND a.attnum = ANY(i.indkey) \+  \WHERE n.nspname = $1 \+  \  AND c.relname = $2 \+  \  AND i.indisprimary \+  \ORDER BY array_position(i.indkey, a.attnum)"++enumLabelSQL :: BS.ByteString+enumLabelSQL =+  "SELECT e.enumlabel \+  \FROM pg_catalog.pg_enum e \+  \JOIN pg_catalog.pg_type t ON t.oid = e.enumtypid \+  \JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace \+  \WHERE n.nspname = $1 \+  \  AND t.typname = $2 \+  \ORDER BY e.enumsortorder"++----------------------------------------------------------------------------------------------------++parseColumnRows :: PQ.Result -> IO [ColumnInfo]+parseColumnRows res = do+  nRows <- PQ.ntuples res+  mapRows nRows $ \row -> do+    schemaVal <- PQ.getvalue res row 0+    nameVal <- PQ.getvalue res row 1+    typeVal <- PQ.getvalue res row 2+    typtypeVal <- PQ.getvalue res row 3+    notNullVal <- PQ.getvalue res row 4+    defVal <- PQ.getvalue res row 5+    let schema = maybe "" BS8.unpack schemaVal+        name = maybe "" BS8.unpack nameVal+        typ = maybe "" BS8.unpack typeVal+        isEnum = Just "e" == typtypeVal+        notNull = maybe False parseBool notNullVal+        hasDef = maybe False parseBool defVal+    return (ColumnInfo name schema typ isEnum notNull hasDef)++parsePkRows :: PQ.Result -> IO [String]+parsePkRows res = do+  nRows <- PQ.ntuples res+  mapRows nRows $ \row -> do+    nameVal <- PQ.getvalue res row 0+    return (maybe "" BS8.unpack nameVal)++parseEnumRows :: PQ.Result -> IO [String]+parseEnumRows res = do+  nRows <- PQ.ntuples res+  mapRows nRows $ \row -> do+    labelVal <- PQ.getvalue res row 0+    return (maybe "" BS8.unpack labelVal)++parseBool :: BS.ByteString -> Bool+parseBool bs+  | bs == "t" = True+  | otherwise = False++----------------------------------------------------------------------------------------------------++-- | Wrap a 'String' as a libpq text parameter (oid 25 = @text@).+textParam :: String -> Maybe (PQ.Oid, BS.ByteString, PQ.Format)+textParam str = Just (PQ.Oid 25, BS8.pack str, PQ.Text)++{-  Unwrap and validate a libpq query result, passing it to a row-parser on+    success. The @label@ argument names the introspection step for error+    messages.+-}+withQueryResult :: String -> Maybe PQ.Result -> (PQ.Result -> IO a) -> IO a+withQueryResult label result parse = case result of+  Nothing -> throwIO (userError ("hasql-generate: " <> label <> " introspection query returned no result"))+  Just res -> do+    status <- PQ.resultStatus res+    if status == PQ.TuplesOk+      then parse res+      else do+        msg <- maybe "unknown error" BS8.unpack <$> PQ.resultErrorMessage res+        throwIO (userError ("hasql-generate: " <> label <> " introspection failed: " <> msg))++-- |  Iterate over row indices [0 .. nRows-1] collecting results.+mapRows :: PQ.Row -> (PQ.Row -> IO a) -> IO [a]+mapRows nRows fn = go (PQ.Row 0)+  where+    go i+      | i == nRows = pure []+      | otherwise = do+          x <- fn i+          xs <- go (nextRow i)+          pure (x : xs)++    nextRow :: PQ.Row -> PQ.Row+    nextRow (PQ.Row n) = PQ.Row (n + 1)
+ src/Hasql/Generate/TH.hs view
@@ -0,0 +1,1710 @@+module Hasql.Generate.TH+    ( GenerateConfig+    , fromTable+    , fromType+    , fromView+    , generate+    , withDerivations+    , withOverrides+    , withholdPk+    ) where++----------------------------------------------------------------------------------------------------++import           Control.Applicative                ( (<*>) )+import           Control.Monad                      ( mapM, return )+import           Control.Monad.Fail                 ( MonadFail (fail) )++import           Data.Bool+    ( Bool (..)+    , not+    , otherwise+    , (&&)+    )+import           Data.Char                          ( toLower, toUpper )+import           Data.Eq                            ( (==) )+import           Data.Foldable                      ( foldl )+import           Data.Function                      ( flip, ($), (.) )+import           Data.Functor                       ( (<$>) )+import qualified Data.Functor.Contravariant         as Contravariant+import           Data.Int                           ( Int )+import           Data.List+    ( all+    , break+    , concatMap+    , elem+    , filter+    , intercalate+    , length+    , lookup+    , map+    , notElem+    , null+    , partition+    , zip+    , zipWith+    , (!!)+    )+import           Data.Maybe                         ( Maybe (..) )+import           Data.Semigroup                     ( (<>) )+import           Data.String                        ( String )+import qualified Data.String+import qualified Data.Text+import qualified Data.Tuple++import qualified Hasql.Decoders+import qualified Hasql.Encoders+import qualified Hasql.Generate.Class+import qualified Hasql.Generate.Codec+import qualified Hasql.Generate.Column+import           Hasql.Generate.Config              ( Config (..) )+import           Hasql.Generate.Connection+    ( toConnString+    , withCompileTimeConnection+    )+import           Hasql.Generate.Internal.Introspect+    ( ColumnInfo (..)+    , introspectColumns+    , introspectEnumLabels+    , introspectPrimaryKey+    )+import qualified Hasql.Session+import qualified Hasql.Statement++import           Language.Haskell.TH++import           Prelude+    ( enumFromTo+    , mconcat+    , show+    , (+)+    , (-)+    )++----------------------------------------------------------------------------------------------------++-- | Whether the target relation is a table (full CRUD), a view (read-only), or a type (enum).+data RelationKind = Table | View | Type++{-  Configuration for a table, view, or type code-generation request.++    Use 'fromTable', 'fromView', or 'fromType' to create a default config,+    then chain modifier functions with @(&)@ to customise it:++    @+    fromTable \"public\" \"users\"+        & withDerivations [''Show, ''Eq, ''Generic]+        & withOverrides [(\"timestamptz\", ''UTCTime)]+        & withholdPk+    @+-}+data GenerateConfig+    = GenerateConfig+      { tcSchema      :: String+      , tcTable       :: String+      , tcKind        :: RelationKind+      , tcDerivations :: [Name]+      , tcOverrides   :: [(String, Name)]+      , tcWithholdPk  :: Bool+      }++{-  Create a 'GenerateConfig' for the given schema and table with no derivations+    and no type overrides. Generates full CRUD code.+-}+fromTable :: String -> String -> GenerateConfig+fromTable schema table =+  GenerateConfig+    { tcSchema = schema+    , tcTable = table+    , tcKind = Table+    , tcDerivations = []+    , tcOverrides = []+    , tcWithholdPk = False+    }++{-  Create a 'GenerateConfig' for the given schema and view with no derivations+    and no type overrides. Generates read-only code: a record type, a decoder,+    a SELECT statement, and a 'HasView' instance.+-}+fromView :: String -> String -> GenerateConfig+fromView schema view =+  GenerateConfig+    { tcSchema = schema+    , tcTable = view+    , tcKind = View+    , tcDerivations = []+    , tcOverrides = []+    , tcWithholdPk = False+    }++{-  Create a 'GenerateConfig' for the given schema and enum type with no+    derivations. Generates a Haskell sum type, a 'PgCodec' instance,+    and a 'PgColumn' instance. Overrides are ignored for types.++    The splice must appear before any @fromTable@ whose table has a column of+    this enum type, since TH processes splices top to bottom.++    __Orphan instance warning:__ The generated @PgColumn@ instance will trigger+    GHC's @-Worphans@ warning because the functional dependency's determining+    types are @Symbol@ literals, which GHC never considers local to any user+    module. This is expected and harmless. Add the following pragma to any+    module that uses @fromType@:++    @+    \{\-\# OPTIONS_GHC -Wno-orphans \#\-\}+    @+-}+fromType :: String -> String -> GenerateConfig+fromType schema typeName =+  GenerateConfig+    { tcSchema = schema+    , tcTable = typeName+    , tcKind = Type+    , tcDerivations = []+    , tcOverrides = []+    , tcWithholdPk = False+    }++-- | Append derivation class 'Name's to the config.+withDerivations :: [Name] -> GenerateConfig -> GenerateConfig+withDerivations names cfg = cfg {tcDerivations = tcDerivations cfg <> names}++{-  Append per-table PG type overrides. Each pair maps a PostgreSQL type name+    (e.g. @\"timestamptz\"@) to a Haskell type 'Name' (e.g. @''UTCTime@).+    The override type must still have a 'PgCodec' instance.+-}+withOverrides :: [(String, Name)] -> GenerateConfig -> GenerateConfig+withOverrides ovs cfg = cfg {tcOverrides = tcOverrides cfg <> ovs}++{-  Opt in to excluding PK columns from INSERT when all PK columns have+    database defaults. Without this, INSERT always includes all columns.++    @+    fromTable \"public\" \"users\" & withholdPk+    @+-}+withholdPk :: GenerateConfig -> GenerateConfig+withholdPk cfg = cfg {tcWithholdPk = True}++{-  Terminal step that introspects a PostgreSQL database at compile time and+    generates the provided data construct:++    Usage:+    @+    \$(generate def (fromTable \"public\" \"users\" & withDerivations [''Show, ''Eq]))+    @+-}+generate :: Config -> GenerateConfig -> Q [Dec]+generate config genConfig =+  case kind of+    Type -> do+      labels <- runIO $ withCompileTimeConnection connStr $ \conn ->+        introspectEnumLabels conn schema table+      if null labels+        then fail ("hasql-generate: no enum labels found for " <> schema <> "." <> table)+        else generateTypeDecs schema table (pascalCase table) derivNames labels+    Table -> do+      (typeName, resolvedCols, pkCols) <- getPgData+      let pkInfo = buildPkInfo resolvedCols pkCols+      generateAllDecs config withhold schema table typeName derivNames resolvedCols pkInfo+    View -> do+      (typeName, resolvedCols, _) <- getPgData+      generateViewDecs schema table typeName derivNames resolvedCols+  where+    connStr = toConnString (connection config)+    schema = tcSchema genConfig+    table = tcTable genConfig+    kind = tcKind genConfig+    derivNames = tcDerivations genConfig+    withhold = tcWithholdPk genConfig+    mergedOverrides = tcOverrides genConfig <> globalOverrides config+    getPgData = do+      (columns, pkCols') <- runIO $ withCompileTimeConnection connStr $ \conn -> do+        cols <- introspectColumns conn schema table+        pks <- case kind of+          Table -> introspectPrimaryKey conn schema table+          _     -> return []+        return (cols, pks)+      if null columns+        then fail ("hasql-generate: no columns found for " <> schema <> "." <> table)+        else do+          resolvedCols <- mapM (resolveColumnWithOverrides mergedOverrides) columns+          let typName = pascalCase table+              resolvedCols' =+                if allowDuplicateRecordFields config+                  then resolvedCols+                  else map (prefixFieldName typName) resolvedCols+          return (typName, resolvedCols', pkCols')++----------------------------------------------------------------------------------------------------++data ResolvedColumn+    = ResolvedColumn+      { rcColName    :: String+      , rcFieldName  :: String+      , rcType       :: Type+      , rcNotNull    :: Bool+      , rcHasDefault :: Bool+      , rcPgCast     :: Maybe String+      }++data PkInfo+    = NoPrimaryKey+    | SinglePk ResolvedColumn+    | CompositePk [ResolvedColumn]++----------------------------------------------------------------------------------------------------++{-  Resolve a column's Haskell type. If the column's PG type name appears in+    the overrides list, use the override 'Name' directly (as @ConT@). Otherwise+    fall back to 'PgColumn' instance resolution via 'reifyInstances'.+-}+resolveColumnWithOverrides :: [(String, Name)] -> ColumnInfo -> Q ResolvedColumn+resolveColumnWithOverrides overrides col = do+  hsType <- case lookup (colPgType col) overrides of+    Just overrideName -> return (ConT overrideName)+    Nothing -> do+      a <- newName "a"+      insts <-+        reifyInstances+          ''Hasql.Generate.Column.PgColumn+          [ LitT (StrTyLit (colPgSchema col))+          , LitT (StrTyLit (colPgType col))+          , VarT a+          ]+      case insts of+        [InstanceD _ _ (AppT (AppT (AppT _ _) _) ty) _] -> return ty+        [] ->+          fail+            ( "hasql-generate: no PgColumn instance for pg type '"+                <> colPgSchema col+                <> "."+                <> colPgType col+                <> "' (column '"+                <> colName col+                <> "')"+            )+        _ ->+          fail+            ( "hasql-generate: multiple PgColumn instances for pg type '"+                <> colPgSchema col+                <> "."+                <> colPgType col+                <> "' (column '"+                <> colName col+                <> "') — expected exactly one"+            )+  let pgCast =+        if colIsEnum col+          then Just (quoteIdent (colPgSchema col) <> "." <> quoteIdent (colPgType col))+          else Nothing+  return+    ResolvedColumn+      { rcColName = colName col+      , rcFieldName = sanitizeField (camelCase (colName col))+      , rcType = hsType+      , rcNotNull = colNotNull col+      , rcHasDefault = colHasDefault col+      , rcPgCast = pgCast+      }++----------------------------------------------------------------------------------------------------++buildPkInfo :: [ResolvedColumn] -> [String] -> PkInfo+buildPkInfo _ [] = NoPrimaryKey+buildPkInfo resolvedCols [pkName] =+  case filter (\rc -> rcColName rc == pkName) resolvedCols of+    [rc] -> SinglePk rc+    _    -> NoPrimaryKey+buildPkInfo resolvedCols pkNames =+  let lookupPk pkn = filter (\rc -> rcColName rc == pkn) resolvedCols+      pkCols = concatMap lookupPk pkNames+   in if length pkCols == length pkNames+        then CompositePk pkCols+        else NoPrimaryKey++----------------------------------------------------------------------------------------------------++generateAllDecs+  :: Config -> Bool -> String -> String -> String -> [Name] -> [ResolvedColumn] -> PkInfo -> Q [Dec]+generateAllDecs config withhold schema table typName derivNames resolvedCols pkInfo = do+  let useNtPk = newtypePrimaryKeys config+      pkTypName = typName <> "Pk"+      allowDupFields = allowDuplicateRecordFields config++  -- Generate PK wrapper type declarations (before main record so newtype is in scope)+  pkTypeDecs <-+    if useNtPk+      then case pkInfo of+        SinglePk rc -> do+          ntDec <- genPkNewtype pkTypName derivNames rc+          codecDec <- genPkNewtypeCodec pkTypName+          return (ntDec <> codecDec)+        CompositePk rcs -> do+          let pkRcs = map (pkRecordFieldName pkTypName allowDupFields) rcs+          recDec <- genPkRecord pkTypName derivNames pkRcs+          encDec <- genPkRecordEncoder pkTypName pkRcs+          return (recDec <> encDec)+        NoPrimaryKey -> return []+      else return []++  -- For single PK with newtypes, rewrite the PK column's type to the newtype+  let (resolvedCols', pkInfo') =+        if useNtPk+          then case pkInfo of+            SinglePk rc ->+              let rc' = rc {rcType = ConT (mkName pkTypName)}+                  cols' = map (\c -> if rcColName c == rcColName rc then rc' else c) resolvedCols+               in (cols', SinglePk rc')+            _ -> (resolvedCols, pkInfo)+          else (resolvedCols, pkInfo)++  -- Compute pkTypOverride for composite PK with newtypes+  let pkTypOverride = case (useNtPk, pkInfo) of+        (True, CompositePk _) -> Just pkTypName+        _                     -> Nothing++  -- Generate main record, decoder, encoder, insert (single + batch)+  dataDec <- genDataType typName derivNames resolvedCols'+  decoderDec <- genDecoder typName resolvedCols'+  encoderDec <- genEncoder typName resolvedCols'+  let insertCols = computeInsertCols withhold resolvedCols' pkInfo'+  insDec <- genInsert schema table typName resolvedCols' insertCols+  insMany <- genInsertMany schema table typName resolvedCols' insertCols+  hasInsInst <- genHasInsertInstance typName++  -- Generate PK-dependent statements and instances (single + batch)+  pkDecs <- case pkInfo' of+    NoPrimaryKey -> return []+    _ -> do+      sel <- genSelectByPk schema table typName resolvedCols' pkInfo' pkTypOverride+      selMany <- genSelectMany schema table typName resolvedCols' pkInfo' pkTypOverride+      upd <- genUpdate schema table typName resolvedCols' pkInfo'+      updMany <- genUpdateMany schema table typName resolvedCols' pkInfo'+      del <- genDeleteByPk schema table typName pkInfo' pkTypOverride+      delMany <- genDeleteMany schema table typName pkInfo' pkTypOverride+      hasSelInst <- genHasSelectInstance typName pkInfo' pkTypOverride+      hasUpdInst <- genHasUpdateInstance typName+      hasDelInst <- genHasDeleteInstance typName pkInfo' pkTypOverride+      return (sel <> selMany <> upd <> updMany <> del <> delMany <> hasSelInst <> hasUpdInst <> hasDelInst)++  -- Generate HasPrimaryKey instance (for ALL tables with PKs)+  hasPkInst <- case pkInfo of+    NoPrimaryKey -> return []+    _ -> genHasPrimaryKeyInstance typName pkInfo pkInfo' useNtPk pkTypOverride allowDupFields++  return (pkTypeDecs <> dataDec <> decoderDec <> encoderDec <> insDec <> insMany <> hasInsInst <> pkDecs <> hasPkInst)++{-  Generate read-only declarations for a view: data type, decoder, SELECT+    statement, and 'HasView' instance.+-}+generateViewDecs+  :: String -> String -> String -> [Name] -> [ResolvedColumn] -> Q [Dec]+generateViewDecs schema table typName derivNames resolvedCols = do+  dataDec <- genDataType typName derivNames resolvedCols+  decoderDec <- genDecoder typName resolvedCols+  selectDec <- genSelectView schema table typName resolvedCols+  hasViewInst <- genHasViewInstance typName+  return (dataDec <> decoderDec <> selectDec <> hasViewInst)++{-  Generate declarations for a PostgreSQL enum type: a Haskell sum type,+    a 'PgCodec' instance (using @Decoders.enum@ / @Encoders.enum@), and+    a 'PgColumn' instance mapping the schema-qualified PG type name to the+    generated Haskell type.+-}+generateTypeDecs+  :: String -> String -> String -> [Name] -> [String] -> Q [Dec]+generateTypeDecs schema pgTypeName hsTypeName derivNames labels = do+  sumType <- genSumType hsTypeName derivNames labels+  codecInst <- genPgCodecInstance hsTypeName labels+  columnInst <- genPgColumnInstance schema pgTypeName hsTypeName+  enumInst <- genHasEnumInstance hsTypeName labels+  return (sumType <> codecInst <> columnInst <> enumInst)++genSumType :: String -> [Name] -> [String] -> Q [Dec]+genSumType hsTypeName derivNames labels = do+  let tName = mkName hsTypeName+      cons = map (\lbl -> NormalC (mkName (pascalCase lbl)) []) labels+      derivClauses = mkDerivClauses derivNames+  return [DataD [] tName [] Nothing cons derivClauses]++genPgCodecInstance :: String -> [String] -> Q [Dec]+genPgCodecInstance hsTypeName labels = do+  let tName = mkName hsTypeName+      -- pgDecode: Decoders.enum (\t -> case Data.Text.unpack t of "lbl" -> Just Con; ... _ -> Nothing)+      t = mkName "t"+      decoderMatches =+        map+          ( \lbl ->+              Match+                (LitP (StringL lbl))+                (NormalB (AppE (ConE 'Just) (ConE (mkName (pascalCase lbl)))))+                []+          )+          labels+          <> [Match WildP (NormalB (ConE 'Nothing)) []]+      decoderLam =+        LamE+          [VarP t]+          (CaseE (AppE (VarE 'Data.Text.unpack) (VarE t)) decoderMatches)+      decoderBody = AppE (VarE 'Hasql.Decoders.enum) decoderLam+      decoderDec = FunD 'Hasql.Generate.Codec.pgDecode [Clause [] (NormalB decoderBody) []]+      -- pgEncode: Encoders.enum (\x -> case x of Con -> "lbl"; ...)+      x = mkName "x"+      encoderMatches =+        map+          ( \lbl ->+              Match+                (ConP (mkName (pascalCase lbl)) [] [])+                (NormalB (textLit lbl))+                []+          )+          labels+      encoderLam = LamE [VarP x] (CaseE (VarE x) encoderMatches)+      encoderBody = AppE (VarE 'Hasql.Encoders.enum) encoderLam+      encoderDec = FunD 'Hasql.Generate.Codec.pgEncode [Clause [] (NormalB encoderBody) []]+      instanceType = AppT (ConT ''Hasql.Generate.Codec.PgCodec) (ConT tName)+  return [InstanceD Nothing [] instanceType [decoderDec, encoderDec]]++genPgColumnInstance :: String -> String -> String -> Q [Dec]+genPgColumnInstance schema pgTypeName hsTypeName = do+  let tName = mkName hsTypeName+      instanceType =+        AppT+          ( AppT+              (AppT (ConT ''Hasql.Generate.Column.PgColumn) (LitT (StrTyLit schema)))+              (LitT (StrTyLit pgTypeName))+          )+          (ConT tName)+  return [InstanceD Nothing [] instanceType []]++{-  Generate a @HasEnum@ instance for the given sum type.++    @allValues@ is a list of all constructors. @toText@ is a multi-clause+    function mapping each constructor to its PostgreSQL label. @fromText@+    unpacks the 'Text' to a 'String' and matches on string literals.+-}+genHasEnumInstance :: String -> [String] -> Q [Dec]+genHasEnumInstance hsTypeName labels = do+  let tName = mkName hsTypeName+      -- allValues = [Con1, Con2, ...]+      allValuesDec =+        FunD+          'Hasql.Generate.Class.allValues+          [Clause [] (NormalB (ListE (map (ConE . mkName . pascalCase) labels))) []]+      -- toText Con1 = "label1"; toText Con2 = "label2"; ...+      toTextClauses =+        map+          ( \lbl ->+              Clause+                [ConP (mkName (pascalCase lbl)) [] []]+                (NormalB (textLit lbl))+                []+          )+          labels+      toTextDec = FunD 'Hasql.Generate.Class.toText toTextClauses+      -- fromText t = case Data.Text.unpack t of "label1" -> Just Con1; ... _ -> Nothing+      t = mkName "t"+      fromTextMatches =+        map+          ( \lbl ->+              Match+                (LitP (StringL lbl))+                (NormalB (AppE (ConE 'Just) (ConE (mkName (pascalCase lbl)))))+                []+          )+          labels+          <> [Match WildP (NormalB (ConE 'Nothing)) []]+      fromTextBody =+        LamE+          [VarP t]+          (CaseE (AppE (VarE 'Data.Text.unpack) (VarE t)) fromTextMatches)+      fromTextDec =+        FunD+          'Hasql.Generate.Class.fromText+          [Clause [] (NormalB fromTextBody) []]+      instanceType = AppT (ConT ''Hasql.Generate.Class.HasEnum) (ConT tName)+  return [InstanceD Nothing [] instanceType [allValuesDec, toTextDec, fromTextDec]]++----------------------------------------------------------------------------------------------------+-- Data type generation+----------------------------------------------------------------------------------------------------++genDataType :: String -> [Name] -> [ResolvedColumn] -> Q [Dec]+genDataType typName derivNames cols = do+  let tName = mkName typName+      fields = map mkField cols+      con = RecC tName fields+      derivClauses = mkDerivClauses derivNames+  return [DataD [] tName [] Nothing [con] derivClauses]+  where+    mkField :: ResolvedColumn -> VarBangType+    mkField resCol =+      let fName = mkName (rcFieldName resCol)+          fieldBang = Bang NoSourceUnpackedness SourceStrict+          typ = fieldType resCol+       in (fName, fieldBang, typ)++fieldType :: ResolvedColumn -> Type+fieldType resCol+  | rcNotNull resCol = rcType resCol+  | otherwise = AppT (ConT ''Maybe) (rcType resCol)++{-  Partition deriving class 'Name's into stock and anyclass 'DerivClause's.+    Known stock-derivable classes get @deriving stock@; everything else gets+    @deriving anyclass@. Empty groups are omitted.+-}+mkDerivClauses :: [Name] -> [DerivClause]+mkDerivClauses names =+  let (stock, anyclass) = partition isStockDerivable names+   in clauseFor StockStrategy stock <> clauseFor AnyclassStrategy anyclass+  where+    clauseFor _ []      = []+    clauseFor strat nms = [DerivClause (Just strat) (map ConT nms)]++isStockDerivable :: Name -> Bool+isStockDerivable nm =+  nameBase nm+    `elem` [ "Show"+           , "Read"+           , "Eq"+           , "Ord"+           , "Bounded"+           , "Enum"+           , "Ix"+           , "Generic"+           , "Generic1"+           , "Data"+           , "Typeable"+           , "Lift"+           , "Functor"+           , "Foldable"+           , "Traversable"+           ]++----------------------------------------------------------------------------------------------------+-- Decoder generation+----------------------------------------------------------------------------------------------------++genDecoder :: String -> [ResolvedColumn] -> Q [Dec]+genDecoder typName cols = do+  let decName = mkName (camelCase typName <> "Decoder")+      tName = mkName typName+      sigTy = AppT (ConT ''Hasql.Decoders.Row) (ConT tName)+  sig <- sigD decName (return sigTy)+  body <- genDecoderBody tName cols+  let dec = ValD (VarP decName) (NormalB body) []+  return [sig, dec]++genDecoderBody :: Name -> [ResolvedColumn] -> Q Exp+genDecoderBody tName cols =+  case cols of+    [] -> fail "hasql-generate: cannot generate decoder for table with no columns"+    (x : xs) -> do+      let first = applyFmap (ConE tName) (columnDecodeExp x)+      foldl applyAp (return first) (map (return . columnDecodeExp) xs)++columnDecodeExp :: ResolvedColumn -> Exp+columnDecodeExp resCol =+  let nullability =+        if rcNotNull resCol+          then VarE 'Hasql.Decoders.nonNullable+          else VarE 'Hasql.Decoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgDecode+   in AppE (VarE 'Hasql.Decoders.column) (AppE nullability codec)++applyFmap :: Exp -> Exp -> Exp+applyFmap f x = InfixE (Just f) (VarE '(<$>)) (Just x)++applyAp :: Q Exp -> Q Exp -> Q Exp+applyAp qf qx = do+  f <- qf+  InfixE (Just f) (VarE '(<*>)) . Just <$> qx++----------------------------------------------------------------------------------------------------+-- Encoder generation+----------------------------------------------------------------------------------------------------++genEncoder :: String -> [ResolvedColumn] -> Q [Dec]+genEncoder typName cols = do+  let encName = mkName (camelCase typName <> "Encoder")+      tName = mkName typName+      sigTy = AppT (ConT ''Hasql.Encoders.Params) (ConT tName)+  sig <- sigD encName (return sigTy)+  let body = AppE (VarE 'mconcat) (ListE (map (columnEncodeExp tName) cols))+      dec = ValD (VarP encName) (NormalB body) []+  return [sig, dec]++{-  Generate a single encoder term:+    @contramap fieldSelector (Hasql.Encoders.param (Hasql.Encoders.nonNullable pgEncode))@++    Uses a record pattern match lambda to extract the field, which works+    regardless of @DuplicateRecordFields@ or @NoFieldSelectors@ settings.+-}+columnEncodeExp :: Name -> ResolvedColumn -> Exp+columnEncodeExp tName rc =+  let nullability =+        if rcNotNull rc+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+      paramEnc = AppE (VarE 'Hasql.Encoders.param) (AppE nullability codec)+      x = mkName "x"+      selector =+        LamE+          [RecP tName [(mkName (rcFieldName rc), VarP x)]]+          (VarE x)+   in AppE (AppE (VarE 'Contravariant.contramap) selector) paramEnc++{-  Generate a single array encoder term for batch operations:++    @contramap (map (\\TypeName{field = x} -> x))+      (Encoders.param (Encoders.nonNullable+        (Encoders.foldableArray (nonNullable\/nullable pgEncode))))@++    The outer @param@ is always @nonNullable@ (the array itself is always present).+    The inner @foldableArray@ element nullability follows 'rcNotNull'.+    The selector uses @map@ over a @RecP@ lambda pattern.+-}+columnArrayEncodeExp :: Name -> ResolvedColumn -> Exp+columnArrayEncodeExp tName rc =+  let elementNullability =+        if rcNotNull rc+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+      arrayEnc =+        AppE+          (VarE 'Hasql.Encoders.foldableArray)+          (AppE elementNullability codec)+      paramEnc =+        AppE+          (VarE 'Hasql.Encoders.param)+          (AppE (VarE 'Hasql.Encoders.nonNullable) arrayEnc)+      x = mkName "x"+      fieldExtractor =+        LamE+          [RecP tName [(mkName (rcFieldName rc), VarP x)]]+          (VarE x)+      mapSelector = AppE (VarE 'map) fieldExtractor+   in AppE (AppE (VarE 'Contravariant.contramap) mapSelector) paramEnc++{-  Generate an array encoder for a single PK value list:++    @Encoders.param (Encoders.nonNullable+      (Encoders.foldableArray (nonNullable\/nullable pgEncode)))@+-}+singlePkArrayEncoder :: ResolvedColumn -> Exp+singlePkArrayEncoder resCol =+  let elementNullability =+        if rcNotNull resCol+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+      arrayEnc =+        AppE+          (VarE 'Hasql.Encoders.foldableArray)+          (AppE elementNullability codec)+   in AppE+        (VarE 'Hasql.Encoders.param)+        (AppE (VarE 'Hasql.Encoders.nonNullable) arrayEnc)++{-  Generate an array encoder for batch PK operations over @[PkType]@ input.++    * @NoPrimaryKey@: @noParams@+    * @SinglePk@: 'singlePkArrayEncoder'+    * @CompositePk@ without newtypes: @mconcat@ of tuple-field array encoders+    * @CompositePk@ with newtypes: @mconcat@ of RecP-based array encoders+      over the PK record type+-}+pkArrayEncoder :: Maybe String -> PkInfo -> Q Exp+pkArrayEncoder (Just pkTypName) (CompositePk rcs) = do+  let tName = mkName pkTypName+  return (AppE (VarE 'mconcat) (ListE (map (columnArrayEncodeExp tName) rcs)))+pkArrayEncoder _ NoPrimaryKey = return (VarE 'Hasql.Encoders.noParams)+pkArrayEncoder _ (SinglePk rc) = return (singlePkArrayEncoder rc)+pkArrayEncoder _ (CompositePk rcs) = do+  let n = length rcs+      encoders =+        zipWith+          (flip (tupleFieldArrayEncoder n))+          rcs+          (enumFromTo 0 (n - 1))+  return (AppE (VarE 'mconcat) (ListE encoders))++{-  Generate an array encoder for one field of a tuple PK:++    @contramap (map (\\(_, x, _) -> x))+      (Encoders.param (Encoders.nonNullable (Encoders.foldableArray ...)))@+-}+tupleFieldArrayEncoder :: Int -> Int -> ResolvedColumn -> Exp+tupleFieldArrayEncoder tupleSize idx resCol =+  let elementNullability =+        if rcNotNull resCol+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+      arrayEnc =+        AppE+          (VarE 'Hasql.Encoders.foldableArray)+          (AppE elementNullability codec)+      paramEnc =+        AppE+          (VarE 'Hasql.Encoders.param)+          (AppE (VarE 'Hasql.Encoders.nonNullable) arrayEnc)+      accessor = tupleAccessor tupleSize idx+      mapSelector = AppE (VarE 'map) accessor+   in AppE (AppE (VarE 'Contravariant.contramap) mapSelector) paramEnc++----------------------------------------------------------------------------------------------------+-- CRUD statement generation+----------------------------------------------------------------------------------------------------++{-  Assemble a typed top-level @Statement@ binding from its parts: name, type+    signature, SQL literal, encoder expression, and decoder expression.+    Every statement generator delegates to this for the final assembly.+-}+genStatement :: Name -> Type -> Exp -> Exp -> Exp -> Q [Dec]+genStatement stmtName sigTy sql enc dec = do+  sig <- sigD stmtName (return sigTy)+  let body = applyStatement sql enc dec+      valDec = ValD (VarP stmtName) (NormalB body) []+  return [sig, valDec]++genSelectByPk+  :: String -> String -> String -> [ResolvedColumn] -> PkInfo -> Maybe String -> Q [Dec]+genSelectByPk schema table typName cols pkInfo pkTypOverride = do+  let stmtName = mkName ("select" <> typName)+      decName = mkName (camelCase typName <> "Decoder")+      pkType = pkParamType pkTypOverride pkInfo+      sql = selectByPkSql schema table cols pkInfo+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) pkType)+          (AppT (ConT ''Maybe) (ConT (mkName typName)))+  pkEnc <- pkEncoder pkTypOverride pkInfo+  genStatement stmtName sigTy sql pkEnc (AppE (VarE 'Hasql.Decoders.rowMaybe) (VarE decName))++{-  Compute the columns to include in INSERT. When 'withholdPk' is active+    and all PK columns have database defaults, the PK columns are excluded+    from the insert column list (the DB generates them). Without 'withholdPk',+    all columns are always included.+-}+computeInsertCols :: Bool -> [ResolvedColumn] -> PkInfo -> [ResolvedColumn]+computeInsertCols withhold allCols pkInfo =+  let pkNames = pkColumnNames pkInfo+      pkCols = filter (\rc -> rcColName rc `elem` pkNames) allCols+      allPkDefaulted = not (null pkCols) && all rcHasDefault pkCols+   in if withhold && allPkDefaulted+        then filter (\rc -> rcColName rc `notElem` pkNames) allCols+        else allCols++{-  Generate the INSERT statement. @allCols@ is the full record column list+    (used for RETURNING and the decoder), @insertCols@ is the subset actually+    included in the INSERT column list and VALUES params. When PK columns have+    defaults, @insertCols@ excludes them.+-}+genInsert+  :: String -> String -> String -> [ResolvedColumn] -> [ResolvedColumn] -> Q [Dec]+genInsert schema table typName allCols insertCols =+  let stmtName = mkName ("insert" <> typName)+      tName = mkName typName+      decName = mkName (camelCase typName <> "Decoder")+      sql = insertSql schema table allCols insertCols+      sigTy = AppT (AppT (ConT ''Hasql.Statement.Statement) (ConT tName)) (ConT tName)+      enc = insertEncoder tName insertCols+   in genStatement stmtName sigTy sql enc (AppE (VarE 'Hasql.Decoders.singleRow) (VarE decName))++{-  Generate an inline encoder for the INSERT that only encodes the insert+    columns (which may be a subset of all columns when PK columns are excluded).+-}+insertEncoder :: Name -> [ResolvedColumn] -> Exp+insertEncoder tName cols =+  AppE (VarE 'mconcat) (ListE (map (columnEncodeExp tName) cols))++{-  The UPDATE uses the full record encoder, so SQL parameter positions match+    the record field order. SET covers non-PK columns and WHERE covers PK+    columns, each referencing the correct @$N@ from the encoder.+-}+genUpdate+  :: String -> String -> String -> [ResolvedColumn] -> PkInfo -> Q [Dec]+genUpdate schema table typName cols pkInfo =+  let stmtName = mkName ("update" <> typName)+      tName = mkName typName+      encName = mkName (camelCase typName <> "Encoder")+      decName = mkName (camelCase typName <> "Decoder")+      sql = updateSql schema table cols pkInfo+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) (ConT tName))+          (AppT (ConT ''Maybe) (ConT tName))+   in genStatement stmtName sigTy sql (VarE encName) (AppE (VarE 'Hasql.Decoders.rowMaybe) (VarE decName))++genDeleteByPk :: String -> String -> String -> PkInfo -> Maybe String -> Q [Dec]+genDeleteByPk schema table _typName pkInfo pkTypOverride = do+  let stmtName = mkName ("delete" <> pascalCase table)+      pkType = pkParamType pkTypOverride pkInfo+      sql = deleteSql schema table pkInfo+      sigTy = AppT (AppT (ConT ''Hasql.Statement.Statement) pkType) (TupleT 0)+  pkEnc <- pkEncoder pkTypOverride pkInfo+  genStatement stmtName sigTy sql pkEnc (VarE 'Hasql.Decoders.noResult)++{-  Generate @selectMany\<TypeName\> :: Statement [PkType] [TypeName]@.++    Uses 'selectManySql' and 'pkArrayEncoder' for the input encoding.+-}+genSelectMany+  :: String -> String -> String -> [ResolvedColumn] -> PkInfo -> Maybe String -> Q [Dec]+genSelectMany schema table typName cols pkInfo pkTypOverride = do+  let stmtName = mkName ("selectMany" <> typName)+      tName = mkName typName+      decName = mkName (camelCase typName <> "Decoder")+      pkType = pkParamType pkTypOverride pkInfo+      sql = selectManySql schema table cols pkInfo+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) (AppT ListT pkType))+          (AppT ListT (ConT tName))+  pkEnc <- pkArrayEncoder pkTypOverride pkInfo+  genStatement stmtName sigTy sql pkEnc (AppE (VarE 'Hasql.Decoders.rowList) (VarE decName))++{-  Generate @deleteMany\<TypeName\> :: Statement [PkType] ()@.++    Uses 'deleteManySql' and 'pkArrayEncoder'.+-}+genDeleteMany+  :: String -> String -> String -> PkInfo -> Maybe String -> Q [Dec]+genDeleteMany schema table _typName pkInfo pkTypOverride = do+  let stmtName = mkName ("deleteMany" <> pascalCase table)+      pkType = pkParamType pkTypOverride pkInfo+      sql = deleteManySql schema table pkInfo+      sigTy = AppT (AppT (ConT ''Hasql.Statement.Statement) (AppT ListT pkType)) (TupleT 0)+  pkEnc <- pkArrayEncoder pkTypOverride pkInfo+  genStatement stmtName sigTy sql pkEnc (VarE 'Hasql.Decoders.noResult)++{-  Generate @insertMany\<TypeName\> :: Statement [TypeName] [TypeName]@.++    Uses 'insertManySql' and @mconcat@ of 'columnArrayEncodeExp' for+    each insert column. Respects 'computeInsertCols' (excludes defaulted+    PK columns).+-}+genInsertMany+  :: String -> String -> String -> [ResolvedColumn] -> [ResolvedColumn] -> Q [Dec]+genInsertMany schema table typName allCols insertCols =+  let stmtName = mkName ("insertMany" <> typName)+      tName = mkName typName+      decName = mkName (camelCase typName <> "Decoder")+      sql = insertManySql schema table allCols insertCols+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) (AppT ListT (ConT tName)))+          (AppT ListT (ConT tName))+      enc = AppE (VarE 'mconcat) (ListE (map (columnArrayEncodeExp tName) insertCols))+   in genStatement stmtName sigTy sql enc (AppE (VarE 'Hasql.Decoders.rowList) (VarE decName))++{-  Generate @updateMany\<TypeName\> :: Statement [TypeName] [TypeName]@.++    Uses 'updateManySql' and @mconcat@ of 'columnArrayEncodeExp' for ALL+    columns (both PK and non-PK appear in the unnest subquery). Only generated+    when the table has a primary key.+-}+genUpdateMany+  :: String -> String -> String -> [ResolvedColumn] -> PkInfo -> Q [Dec]+genUpdateMany schema table typName cols pkInfo =+  let stmtName = mkName ("updateMany" <> typName)+      tName = mkName typName+      decName = mkName (camelCase typName <> "Decoder")+      sql = updateManySql schema table cols pkInfo+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) (AppT ListT (ConT tName)))+          (AppT ListT (ConT tName))+      enc = AppE (VarE 'mconcat) (ListE (map (columnArrayEncodeExp tName) cols))+   in genStatement stmtName sigTy sql enc (AppE (VarE 'Hasql.Decoders.rowList) (VarE decName))++----------------------------------------------------------------------------------------------------+-- Typeclass instance generation+----------------------------------------------------------------------------------------------------++-- | Build @\\param -> Hasql.Session.statement param stmtName@.+sessionLambda :: String -> Name -> Exp+sessionLambda paramName stmtName =+  LamE+    [VarP (mkName paramName)]+    ( AppE+        (AppE (VarE 'Hasql.Session.statement) (VarE (mkName paramName)))+        (VarE stmtName)+    )++{-  Generate an instance with two session-wrapper methods (single + batch) and+    no associated types. Used for 'HasInsert' and 'HasUpdate'.+-}+genSimpleCrudInstance :: Name -> Name -> Name -> String -> String -> Q [Dec]+genSimpleCrudInstance className singleMethod batchMethod prefix typName = do+  let tName = mkName typName+      singleDec = FunD singleMethod [Clause [] (NormalB (sessionLambda "x" (mkName (prefix <> typName)))) []]+      batchDec = FunD batchMethod [Clause [] (NormalB (sessionLambda "xs" (mkName (prefix <> "Many" <> typName)))) []]+  return [InstanceD Nothing [] (AppT (ConT className) (ConT tName)) [singleDec, batchDec]]++{-  Generate an instance with two session-wrapper methods (single + batch) and+    one associated type for the key. Used for 'HasSelect' and 'HasDelete'.+-}+genKeyCrudInstance :: Name -> Name -> Name -> Name -> String -> String -> PkInfo -> Maybe String -> Q [Dec]+genKeyCrudInstance className singleMethod batchMethod assocType prefix typName pkInfo pkTypOverride = do+  let tName = mkName typName+      keyType = pkParamType pkTypOverride pkInfo+      singleDec = FunD singleMethod [Clause [] (NormalB (sessionLambda "k" (mkName (prefix <> typName)))) []]+      batchDec = FunD batchMethod [Clause [] (NormalB (sessionLambda "ks" (mkName (prefix <> "Many" <> typName)))) []]+      keyTySynInst = TySynInstD (TySynEqn Nothing (AppT (ConT assocType) (ConT tName)) keyType)+  return [InstanceD Nothing [] (AppT (ConT className) (ConT tName)) [keyTySynInst, singleDec, batchDec]]++genHasInsertInstance :: String -> Q [Dec]+genHasInsertInstance =+  genSimpleCrudInstance ''Hasql.Generate.Class.HasInsert 'Hasql.Generate.Class.insert 'Hasql.Generate.Class.insertMany "insert"++genHasSelectInstance :: String -> PkInfo -> Maybe String -> Q [Dec]+genHasSelectInstance =+  genKeyCrudInstance ''Hasql.Generate.Class.HasSelect 'Hasql.Generate.Class.select 'Hasql.Generate.Class.selectMany ''Hasql.Generate.Class.SelectKey "select"++genHasUpdateInstance :: String -> Q [Dec]+genHasUpdateInstance =+  genSimpleCrudInstance ''Hasql.Generate.Class.HasUpdate 'Hasql.Generate.Class.update 'Hasql.Generate.Class.updateMany "update"++genHasDeleteInstance :: String -> PkInfo -> Maybe String -> Q [Dec]+genHasDeleteInstance =+  genKeyCrudInstance ''Hasql.Generate.Class.HasDelete 'Hasql.Generate.Class.delete 'Hasql.Generate.Class.deleteMany ''Hasql.Generate.Class.DeleteKey "delete"++----------------------------------------------------------------------------------------------------+-- View-specific generators+----------------------------------------------------------------------------------------------------++{-  Generate a @select<TypeName> :: Statement () [TypeName]@ that selects+    all rows from the view with no parameters.+-}+genSelectView+  :: String -> String -> String -> [ResolvedColumn] -> Q [Dec]+genSelectView schema table typName cols =+  let stmtName = mkName ("select" <> typName)+      tName = mkName typName+      decName = mkName (camelCase typName <> "Decoder")+      sql = selectViewSql schema table cols+      sigTy =+        AppT+          (AppT (ConT ''Hasql.Statement.Statement) (TupleT 0))+          (AppT ListT (ConT tName))+   in genStatement stmtName sigTy sql (VarE 'Hasql.Encoders.noParams) (AppE (VarE 'Hasql.Decoders.rowList) (VarE decName))++selectViewSql :: String -> String -> [ResolvedColumn] -> Exp+selectViewSql schema table cols =+  sqlLit+    ( "SELECT "+        <> columnList cols+        <> " FROM "+        <> qualifiedName schema table+    )++-- | Generate a @HasView@ instance for a view.+genHasViewInstance :: String -> Q [Dec]+genHasViewInstance typName = do+  let tName = mkName typName+      stmtName = mkName ("select" <> typName)+      body =+        AppE+          (AppE (VarE 'Hasql.Session.statement) (TupE []))+          (VarE stmtName)+      selectViewMethod = FunD 'Hasql.Generate.Class.selectView [Clause [] (NormalB body) []]+  return [InstanceD Nothing [] (AppT (ConT ''Hasql.Generate.Class.HasView) (ConT tName)) [selectViewMethod]]++----------------------------------------------------------------------------------------------------+-- SQL generation helpers+----------------------------------------------------------------------------------------------------++selectByPkSql :: String -> String -> [ResolvedColumn] -> PkInfo -> Exp+selectByPkSql schema table cols pkInfo =+  let pkWhere = pkWhereClause pkInfo 1+   in sqlLit+        ( "SELECT "+            <> columnList cols+            <> " FROM "+            <> qualifiedName schema table+            <> " WHERE "+            <> pkWhere+        )++insertSql :: String -> String -> [ResolvedColumn] -> [ResolvedColumn] -> Exp+insertSql schema table allCols insertCols =+  let insertColNames = columnList insertCols+      returnColNames = columnList allCols+      params = typedParamList 1 insertCols+   in sqlLit+        ( "INSERT INTO "+            <> qualifiedName schema table+            <> " ("+            <> insertColNames+            <> ")"+            <> " VALUES ("+            <> params+            <> ")"+            <> " RETURNING "+            <> returnColNames+        )++updateSql :: String -> String -> [ResolvedColumn] -> PkInfo -> Exp+updateSql schema table cols pkInfo =+  let pkNames = pkColumnNames pkInfo+      indexed = zip cols (enumFromTo 1 (length cols))+      nonPkSet = filter (\(rc, _) -> rcColName rc `notElem` pkNames) indexed+      pkWhere = filter (\(rc, _) -> rcColName rc `elem` pkNames) indexed+      setClauses =+        intercalate+          ", "+          (map (\(rc, i) -> quoteIdent (rcColName rc) <> " = " <> paramRef i rc) nonPkSet)+      whereClauses =+        intercalate+          " AND "+          (map (\(rc, i) -> quoteIdent (rcColName rc) <> " = " <> paramRef i rc) pkWhere)+   in sqlLit+        ( "UPDATE "+            <> qualifiedName schema table+            <> " SET "+            <> setClauses+            <> " WHERE "+            <> whereClauses+            <> " RETURNING "+            <> columnList cols+        )++deleteSql :: String -> String -> PkInfo -> Exp+deleteSql schema table pkInfo =+  let pkWhere = pkWhereClause pkInfo 1+   in sqlLit+        ( "DELETE FROM "+            <> qualifiedName schema table+            <> " WHERE "+            <> pkWhere+        )++----------------------------------------------------------------------------------------------------+-- Batch SQL generation helpers+----------------------------------------------------------------------------------------------------++{-  Generate SQL for batch select: @SELECT cols FROM s.t WHERE pk = ANY($1)@+    for single PK, or @WHERE (c1, c2) IN (SELECT unnest($1), unnest($2))@+    for composite PK.+-}+selectManySql :: String -> String -> [ResolvedColumn] -> PkInfo -> Exp+selectManySql schema table cols pkInfo =+  sqlLit+    ( "SELECT "+        <> columnList cols+        <> " FROM "+        <> qualifiedName schema table+        <> " WHERE "+        <> batchPkWhereClause pkInfo+    )++{-  Generate SQL for batch delete: same WHERE clause logic as 'selectManySql'.+-}+deleteManySql :: String -> String -> PkInfo -> Exp+deleteManySql schema table pkInfo =+  sqlLit+    ( "DELETE FROM "+        <> qualifiedName schema table+        <> " WHERE "+        <> batchPkWhereClause pkInfo+    )++{-  Generate SQL for batch insert using @unnest@-based array parameters:++    @INSERT INTO s.t (cols) SELECT * FROM unnest($1, $2, ...) RETURNING allCols@+-}+insertManySql :: String -> String -> [ResolvedColumn] -> [ResolvedColumn] -> Exp+insertManySql schema table allCols insertCols =+  let insertColNames = columnList insertCols+      returnColNames = columnList allCols+      params = typedArrayParamList 1 insertCols+   in sqlLit+        ( "INSERT INTO "+            <> qualifiedName schema table+            <> " ("+            <> insertColNames+            <> ") SELECT * FROM unnest("+            <> params+            <> ") RETURNING "+            <> returnColNames+        )++{-  Generate SQL for batch update using an @unnest@ subquery join:++    @UPDATE s.t SET c1 = d.c1, c2 = d.c2+    FROM (SELECT unnest($1) AS pk, unnest($2) AS c1, unnest($3) AS c2) d+    WHERE s.t.pk = d.pk+    RETURNING s.t.*@++    All columns (PK and non-PK) appear in the unnest subquery. SET covers+    non-PK columns. WHERE joins on PK columns.+-}+updateManySql :: String -> String -> [ResolvedColumn] -> PkInfo -> Exp+updateManySql schema table cols pkInfo =+  let pkNames = pkColumnNames pkInfo+      nonPkCols = filter (\rc -> rcColName rc `notElem` pkNames) cols+      setClauses =+        intercalate+          ", "+          (map (\rc -> quoteIdent (rcColName rc) <> " = d." <> quoteIdent (rcColName rc)) nonPkCols)+      -- All columns in the unnest subquery+      unnestParams =+        intercalate+          ", "+          ( zipWith+              (\rc i -> "unnest(" <> arrayParamRef i rc <> ") AS " <> quoteIdent (rcColName rc))+              cols+              (enumFromTo 1 (length cols))+          )+      -- WHERE join on PK columns+      pkJoin =+        intercalate+          " AND "+          ( map+              (\pkn -> qualifiedName schema table <> "." <> quoteIdent pkn <> " = d." <> quoteIdent pkn)+              pkNames+          )+   in sqlLit+        ( "UPDATE "+            <> qualifiedName schema table+            <> " SET "+            <> setClauses+            <> " FROM (SELECT "+            <> unnestParams+            <> ") d WHERE "+            <> pkJoin+            <> " RETURNING "+            <> qualifiedName schema table+            <> ".*"+        )++{-  Generate the WHERE clause for batch PK operations.++    Single PK: @pk = ANY($1)@ — uses @arrayParamRef@ for enum cast support.+    Composite PK: @(c1, c2) IN (SELECT unnest($1), unnest($2))@.+-}+batchPkWhereClause :: PkInfo -> String+batchPkWhereClause NoPrimaryKey = "true"+batchPkWhereClause (SinglePk rc) =+  quoteIdent (rcColName rc) <> " = ANY(" <> arrayParamRef 1 rc <> ")"+batchPkWhereClause (CompositePk rcs) =+  let pkTuple = "(" <> intercalate ", " (map (quoteIdent . rcColName) rcs) <> ")"+      unnests =+        intercalate+          ", "+          ( zipWith+              (\rc i -> "unnest(" <> arrayParamRef i rc <> ")")+              rcs+              (enumFromTo 1 (length rcs))+          )+   in pkTuple <> " IN (SELECT " <> unnests <> ")"++{-  Generate a comma-separated array parameter list with per-column type casts,+    using 'arrayParamRef' so enum types get @[]@ appended.+-}+typedArrayParamList :: Int -> [ResolvedColumn] -> String+typedArrayParamList start cols =+  intercalate ", " (zipWith arrayParamRef (enumFromTo start (start + length cols - 1)) cols)++textLit :: String -> Exp+textLit s = AppE (VarE 'Data.String.fromString) (LitE (StringL s))++sqlLit :: String -> Exp+sqlLit = textLit++applyStatement :: Exp -> Exp -> Exp -> Exp+applyStatement sql enc dec =+  foldl AppE (ConE 'Hasql.Statement.Statement) [sql, enc, dec, ConE 'True]++columnList :: [ResolvedColumn] -> String+columnList cols = intercalate ", " (map (quoteIdent . rcColName) cols)++{-  Format a single parameter reference, appending a @::type@ cast when the+    column has 'rcPgCast' set (e.g. for enum types).++    @paramRef 3 rc@  →  @\"$3\"@ or @\"$3::hg_test.user_role\"@+-}+paramRef :: Int -> ResolvedColumn -> String+paramRef i rc = case rcPgCast rc of+  Nothing   -> "$" <> show i+  Just cast -> "$" <> show i <> "::" <> cast++{-  Format an array parameter reference for batch operations. Appends @[]@ to+    the cast suffix for enum types so that PostgreSQL receives the correct+    array type annotation.++    @arrayParamRef 1 rcEnum@  produces @\"$1::hg_test.user_role[]\"@+    @arrayParamRef 2 rcPlain@ produces @\"$2\"@+-}+arrayParamRef :: Int -> ResolvedColumn -> String+arrayParamRef i rc = case rcPgCast rc of+  Nothing   -> "$" <> show i+  Just cast -> "$" <> show i <> "::" <> cast <> "[]"++{-  Generate a comma-separated parameter list with per-column type casts.++    @typedParamList 1 [rcPlain, rcEnum]@  →  @\"$1, $2::hg_test.user_role\"@+-}+typedParamList :: Int -> [ResolvedColumn] -> String+typedParamList start cols =+  intercalate ", " (zipWith paramRef (enumFromTo start (start + length cols - 1)) cols)++pkWhereClause :: PkInfo -> Int -> String+pkWhereClause NoPrimaryKey _ = "true"+pkWhereClause (SinglePk rc) startIdx =+  quoteIdent (rcColName rc) <> " = " <> paramRef startIdx rc+pkWhereClause (CompositePk rcs) startIdx =+  intercalate+    " AND "+    ( zipWith+        (\rc i -> quoteIdent (rcColName rc) <> " = " <> paramRef i rc)+        rcs+        (enumFromTo startIdx (startIdx + length rcs - 1))+    )++pkColumnNames :: PkInfo -> [String]+pkColumnNames NoPrimaryKey      = []+pkColumnNames (SinglePk rc)     = [rcColName rc]+pkColumnNames (CompositePk rcs) = map rcColName rcs++----------------------------------------------------------------------------------------------------+-- PK type and encoder helpers+----------------------------------------------------------------------------------------------------++pkParamType :: Maybe String -> PkInfo -> Type+pkParamType (Just pkTypName) (CompositePk _) = ConT (mkName pkTypName)+pkParamType _ NoPrimaryKey = TupleT 0+pkParamType _ (SinglePk rc) = fieldType rc+pkParamType _ (CompositePk rcs) =+  foldl AppT (TupleT (length rcs)) (map fieldType rcs)++pkEncoder :: Maybe String -> PkInfo -> Q Exp+pkEncoder (Just pkTypName) (CompositePk _) =+  return (VarE (mkName (camelCase pkTypName <> "Encoder")))+pkEncoder _ NoPrimaryKey = return (VarE 'Hasql.Encoders.noParams)+pkEncoder _ (SinglePk rc) = return (singleParamEncoder rc)+pkEncoder _ (CompositePk rcs) = return (compositeEncoder rcs)++singleParamEncoder :: ResolvedColumn -> Exp+singleParamEncoder rc =+  let nullability =+        if rcNotNull rc+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+   in AppE (VarE 'Hasql.Encoders.param) (AppE nullability codec)++compositeEncoder :: [ResolvedColumn] -> Exp+compositeEncoder rcs =+  let n = length rcs+      encoders =+        zipWith+          (flip (tupleFieldEncoder n))+          rcs+          (enumFromTo 0 (n - 1))+   in AppE (VarE 'mconcat) (ListE encoders)++tupleFieldEncoder :: Int -> Int -> ResolvedColumn -> Exp+tupleFieldEncoder tupleSize idx rc =+  let nullability =+        if rcNotNull rc+          then VarE 'Hasql.Encoders.nonNullable+          else VarE 'Hasql.Encoders.nullable+      codec = VarE 'Hasql.Generate.Codec.pgEncode+      paramEnc = AppE (VarE 'Hasql.Encoders.param) (AppE nullability codec)+      accessor = tupleAccessor tupleSize idx+   in AppE (AppE (VarE 'Contravariant.contramap) accessor) paramEnc++{-  Build a lambda extracting the Nth element from a tuple of known size.+    For pairs, uses @fst@ and @snd@. For larger tuples, generates a pattern match:+    @\\(_, _, x, _) -> x@+-}+tupleAccessor :: Int -> Int -> Exp+tupleAccessor 2 0 = VarE 'Data.Tuple.fst+tupleAccessor 2 1 = VarE 'Data.Tuple.snd+tupleAccessor size idx =+  let names = map (\i -> mkName ("t" <> show i)) (enumFromTo 0 (size - 1))+      pats = map VarP names+      target = names !! idx+   in LamE [TupP pats] (VarE target)++----------------------------------------------------------------------------------------------------+-- Newtype PK generation+----------------------------------------------------------------------------------------------------++{-  Generate a newtype wrapper for a single-column primary key.++    @genPkNewtype \"UsersPk\" [''Show, ''Eq] rc@  where @rc@ has type @UUID@++    produces:++    @newtype UsersPk = UsersPk { getUsersPk :: !UUID } deriving stock (Show, Eq)@+-}+genPkNewtype :: String -> [Name] -> ResolvedColumn -> Q [Dec]+genPkNewtype pkTypName derivNames rc = do+  let tName = mkName pkTypName+      unwrapperName = mkName ("get" <> pkTypName)+      fieldBang = Bang NoSourceUnpackedness NoSourceStrictness+      field = (unwrapperName, fieldBang, rcType rc)+      con = RecC tName [field]+      derivClauses = mkDerivClauses derivNames+  return [NewtypeD [] tName [] Nothing con derivClauses]++{-  Generate a @PgCodec@ instance for a single-column PK newtype.++    @genPkNewtypeCodec \"UsersPk\"@  produces:++    @+    instance PgCodec UsersPk where+      pgDecode = UsersPk \<$\> pgDecode+      pgEncode = contramap getUsersPk pgEncode+    @+-}+genPkNewtypeCodec :: String -> Q [Dec]+genPkNewtypeCodec pkTypName = do+  let tName = mkName pkTypName+      conName = mkName pkTypName+      unwrapperName = mkName ("get" <> pkTypName)+      -- pgDecode = Con <$> pgDecode+      decoderBody =+        InfixE+          (Just (ConE conName))+          (VarE '(<$>))+          (Just (VarE 'Hasql.Generate.Codec.pgDecode))+      decoderDec = FunD 'Hasql.Generate.Codec.pgDecode [Clause [] (NormalB decoderBody) []]+      -- pgEncode = contramap unwrapper pgEncode+      encoderBody =+        AppE+          (AppE (VarE 'Contravariant.contramap) (VarE unwrapperName))+          (VarE 'Hasql.Generate.Codec.pgEncode)+      encoderDec = FunD 'Hasql.Generate.Codec.pgEncode [Clause [] (NormalB encoderBody) []]+      instanceType = AppT (ConT ''Hasql.Generate.Codec.PgCodec) (ConT tName)+  return [InstanceD Nothing [] instanceType [decoderDec, encoderDec]]++{-  Generate a data record for a composite primary key.++    @genPkRecord \"CompositePkPk\" [''Show, ''Eq] pkRcs@  produces a record with+    fields matching the PK columns.+-}+genPkRecord :: String -> [Name] -> [ResolvedColumn] -> Q [Dec]+genPkRecord pkTypName derivNames pkRcs = do+  let tName = mkName pkTypName+      fields = map mkPkField pkRcs+      con = RecC tName fields+      derivClauses = mkDerivClauses derivNames+  return [DataD [] tName [] Nothing [con] derivClauses]+  where+    mkPkField :: ResolvedColumn -> VarBangType+    mkPkField rc =+      let fName = mkName (rcFieldName rc)+          fieldBang = Bang NoSourceUnpackedness SourceStrict+       in (fName, fieldBang, fieldType rc)++{-  Generate an encoder for a composite PK record type.++    @genPkRecordEncoder \"CompositePkPk\" pkRcs@  produces:++    @+    compositePkPkEncoder :: Encoders.Params CompositePkPk+    compositePkPkEncoder = mconcat [...]+    @+-}+genPkRecordEncoder :: String -> [ResolvedColumn] -> Q [Dec]+genPkRecordEncoder pkTypName pkRcs = do+  let encName = mkName (camelCase pkTypName <> "Encoder")+      tName = mkName pkTypName+      sigTy = AppT (ConT ''Hasql.Encoders.Params) (ConT tName)+  sig <- sigD encName (return sigTy)+  let body = AppE (VarE 'mconcat) (ListE (map (columnEncodeExp tName) pkRcs))+      dec = ValD (VarP encName) (NormalB body) []+  return [sig, dec]++{-  Compute the field name for a PK record column. Uses @rcColName@ (the raw PG+    column name) rather than the main record's potentially-prefixed @rcFieldName@.+-}+pkRecordFieldName :: String -> Bool -> ResolvedColumn -> ResolvedColumn+pkRecordFieldName pkTypName allowDupFields rc =+  if allowDupFields+    then rc {rcFieldName = sanitizeField (camelCase (rcColName rc))}+    else rc {rcFieldName = sanitizeField (camelCase pkTypName <> pascalCase (rcColName rc))}++----------------------------------------------------------------------------------------------------+-- HasPrimaryKey instance generation+----------------------------------------------------------------------------------------------------++{-  Generate a @HasPrimaryKey@ instance for a table.++    Handles all four cases: single/composite × newtypes/no-newtypes.+    @rawPk@ is generated explicitly since the type families are non-injective+    and GHC cannot resolve a default @unwrapPk . toPk@ composition.+-}+genHasPrimaryKeyInstance+  :: String -> PkInfo -> PkInfo -> Bool -> Maybe String -> Bool -> Q [Dec]+genHasPrimaryKeyInstance typName origPkInfo pkInfo' useNtPk pkTypOverride allowDupFields = do+  let tName = mkName typName+      pkOfType = pkParamType pkTypOverride pkInfo'+      rawPkOfType = pkParamType Nothing origPkInfo+      pkOfTySynInst =+        TySynInstD+          (TySynEqn Nothing (AppT (ConT ''Hasql.Generate.Class.PkOf) (ConT tName)) pkOfType)+      rawPkOfTySynInst =+        TySynInstD+          (TySynEqn Nothing (AppT (ConT ''Hasql.Generate.Class.RawPkOf) (ConT tName)) rawPkOfType)+  toPkBody <- genToPk typName pkInfo' pkTypOverride allowDupFields+  wrapPkBody <- genWrapPk origPkInfo pkInfo' useNtPk pkTypOverride allowDupFields+  unwrapPkBody <- genUnwrapPk origPkInfo pkInfo' useNtPk pkTypOverride allowDupFields+  rawPkBody <- genRawPk typName origPkInfo pkInfo' useNtPk pkTypOverride allowDupFields+  let toPkDec = FunD 'Hasql.Generate.Class.toPk [Clause [] (NormalB toPkBody) []]+      wrapPkDec = FunD 'Hasql.Generate.Class.wrapPk [Clause [] (NormalB wrapPkBody) []]+      unwrapPkDec = FunD 'Hasql.Generate.Class.unwrapPk [Clause [] (NormalB unwrapPkBody) []]+      rawPkDec = FunD 'Hasql.Generate.Class.rawPk [Clause [] (NormalB rawPkBody) []]+      instanceType = AppT (ConT ''Hasql.Generate.Class.HasPrimaryKey) (ConT tName)+  return+    [ InstanceD+        Nothing+        []+        instanceType+        [pkOfTySynInst, rawPkOfTySynInst, toPkDec, wrapPkDec, unwrapPkDec, rawPkDec]+    ]++{-  Generate the @toPk@ method body: extracts PK field(s) from the main record.++    Single PK: @\\Rec{field = x} -> x@+    Composite, no newtypes: @\\Rec{f1 = x0, f2 = x1} -> (x0, x1)@+    Composite, with newtypes: @\\Rec{f1 = x0, f2 = x1} -> PkRec{pf1 = x0, pf2 = x1}@+-}+genToPk :: String -> PkInfo -> Maybe String -> Bool -> Q Exp+genToPk typName pkInfo' pkTypOverride allowDupFields = do+  let tName = mkName typName+  case pkInfo' of+    NoPrimaryKey -> fail "hasql-generate: genToPk called with NoPrimaryKey"+    SinglePk rc -> do+      let x = mkName "x"+      return (LamE [RecP tName [(mkName (rcFieldName rc), VarP x)]] (VarE x))+    CompositePk rcs -> do+      let xs = map (\i -> mkName ("x" <> show i)) (enumFromTo 0 (length rcs - 1))+          pat = RecP tName (zipWith (\rc x -> (mkName (rcFieldName rc), VarP x)) rcs xs)+      case pkTypOverride of+        Just pkTypName -> do+          let pkCon = mkName pkTypName+              pkRcs = map (pkRecordFieldName pkTypName allowDupFields) rcs+              body = RecConE pkCon (zipWith (\prc x -> (mkName (rcFieldName prc), VarE x)) pkRcs xs)+          return (LamE [pat] body)+        Nothing -> do+          let body = TupE (map (Just . VarE) xs)+          return (LamE [pat] body)++{-  Generate the @wrapPk@ method body: converts raw PK value(s) to the PK type.++    No newtypes: @\\x -> x@ (identity)+    Single + newtypes: @ConE pkTypName@ (newtype constructor)+    Composite + newtypes: @\\(x0, x1) -> PkRec{pf1 = x0, pf2 = x1}@+-}+genWrapPk :: PkInfo -> PkInfo -> Bool -> Maybe String -> Bool -> Q Exp+genWrapPk _origPkInfo _pkInfo' useNtPk pkTypOverride allowDupFields+  | not useNtPk = return identityLam+  | otherwise = case (_origPkInfo, pkTypOverride) of+      (SinglePk _, _) -> do+        let pkTypName = case _pkInfo' of+              SinglePk rc -> case rcType rc of+                ConT n -> nameBase n+                _      -> ""+              _ -> ""+        return (ConE (mkName pkTypName))+      (CompositePk rcs, Just pkTypName) -> do+        let n = length rcs+            xs = map (\i -> mkName ("x" <> show i)) (enumFromTo 0 (n - 1))+            pat = TupP (map VarP xs)+            pkRcs = map (pkRecordFieldName pkTypName allowDupFields) rcs+            body = RecConE (mkName pkTypName) (zipWith (\prc x -> (mkName (rcFieldName prc), VarE x)) pkRcs xs)+        return (LamE [pat] body)+      _ -> return identityLam++{-  Generate the @unwrapPk@ method body: converts PK type to raw value(s).++    No newtypes: @\\x -> x@ (identity)+    Single + newtypes: @VarE getterName@ (newtype field accessor)+    Composite + newtypes: @\\PkRec{pf1 = x0, pf2 = x1} -> (x0, x1)@+-}+genUnwrapPk :: PkInfo -> PkInfo -> Bool -> Maybe String -> Bool -> Q Exp+genUnwrapPk _origPkInfo _pkInfo' useNtPk pkTypOverride allowDupFields+  | not useNtPk = return identityLam+  | otherwise = case (_origPkInfo, pkTypOverride) of+      (SinglePk _, _) -> do+        let pkTypName = case _pkInfo' of+              SinglePk rc -> case rcType rc of+                ConT n -> nameBase n+                _      -> ""+              _ -> ""+        return (VarE (mkName ("get" <> pkTypName)))+      (CompositePk rcs, Just pkTypName) -> do+        let n = length rcs+            xs = map (\i -> mkName ("x" <> show i)) (enumFromTo 0 (n - 1))+            pkRcs = map (pkRecordFieldName pkTypName allowDupFields) rcs+            pat = RecP (mkName pkTypName) (zipWith (\prc x -> (mkName (rcFieldName prc), VarP x)) pkRcs xs)+            body = TupE (map (Just . VarE) xs)+        return (LamE [pat] body)+      _ -> return identityLam++{-  Generate the @rawPk@ method body: extracts raw PK value(s) directly from the+    main record.++    Without newtypes: same as @toPk@ — the PK type is already the raw type.+    With newtypes, single PK: pattern matches through the newtype to extract+    the raw value (e.g. @\\Rec{id = NtSinglePk x} -> x@).+    With newtypes, composite PK: extracts raw fields into a tuple (the record+    fields are already raw types, only the PK wrapper is a record).+-}+genRawPk :: String -> PkInfo -> PkInfo -> Bool -> Maybe String -> Bool -> Q Exp+genRawPk typName origPkInfo pkInfo' useNtPk _pkTypOverride allowDupFields+  | not useNtPk = genToPk typName origPkInfo Nothing allowDupFields+  | otherwise = do+      let tName = mkName typName+      case origPkInfo of+        NoPrimaryKey -> fail "hasql-generate: genRawPk called with NoPrimaryKey"+        SinglePk _rc -> do+          -- The record field is the newtype; pattern match through it+          case pkInfo' of+            SinglePk rc' -> do+              let pkTypName = case rcType rc' of+                    ConT n -> nameBase n+                    _      -> ""+                  x = mkName "x"+                  innerPat = ConP (mkName pkTypName) [] [VarP x]+                  pat = RecP tName [(mkName (rcFieldName rc'), innerPat)]+              return (LamE [pat] (VarE x))+            _ -> fail "hasql-generate: genRawPk single PK mismatch"+        CompositePk rcs -> do+          -- Composite PK fields in the main record are raw types (not rewritten)+          let xs = map (\i -> mkName ("x" <> show i)) (enumFromTo 0 (length rcs - 1))+              -- Use pkInfo' field names since those are what the main record uses+              rcs' = case pkInfo' of+                CompositePk cs -> cs+                _              -> rcs+              pat = RecP tName (zipWith (\rc x -> (mkName (rcFieldName rc), VarP x)) rcs' xs)+              body = TupE (map (Just . VarE) xs)+          return (LamE [pat] body)++identityLam :: Exp+identityLam =+  let x = mkName "x"+   in LamE [VarP x] (VarE x)++----------------------------------------------------------------------------------------------------+-- Utility functions+----------------------------------------------------------------------------------------------------++{-  Wrap a single PostgreSQL identifier in double-quotes, escaping any+    internal double-quote characters per the SQL standard (@\"\"@ → @\"\"\"\"@).+-}+quoteIdent :: String -> String+quoteIdent s = "\"" <> concatMap escDQ s <> "\""+  where+    escDQ '"' = "\"\""+    escDQ c   = [c]++{-  Build a schema-qualified, double-quoted identifier pair.++    @qualifiedName \"public\" \"users\"@  →  @\"\\\"public\\\".\\\"users\\\"\"@+-}+qualifiedName :: String -> String -> String+qualifiedName schema name = quoteIdent schema <> "." <> quoteIdent name++{-  Prefix a resolved column's field name with the type name in camelCase.++    @prefixFieldName \"Users\" rc{rcColName=\"tenant_id\"}@  gives+    @rc{rcFieldName=\"usersTenantId\"}@+-}+prefixFieldName :: String -> ResolvedColumn -> ResolvedColumn+prefixFieldName typName rc =+  rc {rcFieldName = sanitizeField (camelCase typName <> pascalCase (rcColName rc))}++{-  Append an apostrophe to a camelCase identifier if it collides with a+    Haskell reserved keyword. This is idiomatic Haskell (e.g. @type'@, @class'@).+-}+sanitizeField :: String -> String+sanitizeField s+  | s `elem` haskellKeywords = s <> "'"+  | otherwise = s++-- | Haskell reserved keywords that may collide with generated camelCase identifiers.+haskellKeywords :: [String]+haskellKeywords =+  [ "case"+  , "class"+  , "data"+  , "default"+  , "deriving"+  , "do"+  , "else"+  , "forall"+  , "foreign"+  , "hiding"+  , "if"+  , "import"+  , "in"+  , "infix"+  , "infixl"+  , "infixr"+  , "instance"+  , "let"+  , "module"+  , "newtype"+  , "of"+  , "qualified"+  , "then"+  , "type"+  , "where"+  , "mdo"+  , "rec"+  , "proc"+  , "pattern"+  , "role"+  , "family"+  , "stock"+  , "anyclass"+  , "via"+  ]++{-  Convert a snake_case or plain identifier to PascalCase.++    @\"user_emails\"  →  \"UserEmails\"@+    @\"users\"        →  \"Users\"@+-}+pascalCase :: String -> String+pascalCase = concatMap titleWord . splitSnake++{-  Convert a snake_case or PascalCase identifier to camelCase.++    @\"tenant_id\"    →  \"tenantId\"@+    @\"UserEmails\"   →  \"userEmails\"@+    @\"name\"         →  \"name\"@+-}+camelCase :: String -> String+camelCase s = case splitSnake s of+  []       -> []+  (w : ws) -> lowerFirst w <> concatMap titleWord ws++titleWord :: String -> String+titleWord []       = []+titleWord (c : cs) = toUpper c : cs++lowerFirst :: String -> String+lowerFirst []       = []+lowerFirst (c : cs) = toLower c : cs++{-  Split a string on underscores.++    @\"tenant_id\"  →  [\"tenant\", \"id\"]@+    @\"name\"       →  [\"name\"]@+-}+splitSnake :: String -> [String]+splitSnake [] = []+splitSnake s = case break (== '_') s of+  (word, [])       -> [word]+  (word, _ : rest) -> word : splitSnake rest
+ test/Main.hs view
@@ -0,0 +1,782 @@+module Main+    ( main+    ) where++----------------------------------------------------------------------------------------------------++import           Control.Exception    ( bracket )++import           Data.Aeson           ( FromJSON, ToJSON, eitherDecode, encode )+import           Data.Default.Class   ( Default (def) )+import           Data.Either          ( Either (..) )+import           Data.Eq              ( Eq, (/=), (==) )+import           Data.Function        ( ($), (&) )+import           Data.Int             ( Int32 )+import           Data.List            ( any, length, null )+import           Data.Maybe           ( Maybe (..) )+import           Data.Ord             ( Ord )+import           Data.Semigroup       ( (<>) )+import           Data.String          ( String )+import           Data.Text            ( Text )+import           Data.UUID            ( UUID )+import qualified Data.UUID+import           Data.UUID.V4         ( nextRandom )++import           GHC.Generics         ( Generic )+import           GHC.Show             ( Show, show )++import qualified Hasql.Connection+import qualified Hasql.Decoders+import qualified Hasql.Encoders+import           Hasql.Generate+    ( Config (..)+    , HasEnum (..)+    , HasPrimaryKey (..)+    , fromTable+    , fromType+    , fromView+    , generate+    , withDerivations+    , withholdPk+    )+import qualified Hasql.Generate.Class as Q+import qualified Hasql.Session+import qualified Hasql.Statement++import           Prelude+    ( Bool (..)+    , IO+    , error+    , fmap+    , not+    , otherwise+    , pure+    , putStrLn+    , seq+    , (>>)+    )++import           System.Exit          ( die, exitSuccess )++----------------------------------------------------------------------------------------------------++-- Enum type: generates sum type + PgCodec + PgColumn instances.+-- Must appear before any fromTable whose columns reference this enum.+$(generate (def {allowDuplicateRecordFields = True}) (fromType "hg_test" "user_role" & withDerivations [''Show, ''Eq, ''Ord]))++-- Table with enum column: exercises the PgColumn instance generated by fromType above.+$(generate (def {allowDuplicateRecordFields = True}) (fromTable "hg_test" "with_role" & withDerivations [''Show, ''Eq]))++-- Functional test table: CRUD + derived instances + global type override exercised at runtime+$( generate+     (def {allowDuplicateRecordFields = True, globalOverrides = [("text", ''String)]})+     ( fromTable "hg_test" "users"+         & withDerivations [''Show, ''Eq, ''Generic, ''ToJSON, ''FromJSON]+         & withholdPk+     )+ )++-- Single UUID PK: generates data type, decoder, encoder, select, insert, update, delete+$(generate (def {allowDuplicateRecordFields = True}) (fromTable "hg_test" "with_pk"))++-- No PK: generates data type, decoder, encoder only+$(generate (def {allowDuplicateRecordFields = True}) (fromTable "hg_test" "no_pk"))++-- Composite PK: generates all CRUD with tuple PK type+$(generate (def {allowDuplicateRecordFields = True}) (fromTable "hg_test" "composite_pk"))++-- View: generates read-only data type, decoder, SELECT, and HasView instance+$(generate (def {allowDuplicateRecordFields = True}) (fromView "hg_test" "user_emails" & withDerivations [''Show, ''Eq]))++-- Newtype PK (single): generates newtype wrapper, PgCodec, HasPrimaryKey+$( generate+     (def {allowDuplicateRecordFields = True, newtypePrimaryKeys = True})+     (fromTable "hg_test" "nt_single" & withDerivations [''Show, ''Eq])+ )++-- Newtype PK (composite): generates data record wrapper, encoder, HasPrimaryKey+$( generate+     (def {allowDuplicateRecordFields = True, newtypePrimaryKeys = True})+     (fromTable "hg_test" "nt_composite" & withDerivations [''Show, ''Eq])+ )++-- Prefixed fields (default config): field names are prefixed with the type name+$(generate def (fromTable "hg_test" "prefix_demo"))++-- Reserved keyword columns: tests that field names colliding with Haskell keywords+-- get an apostrophe suffix (e.g. "type" -> "type'", "class" -> "class'", "data" -> "data'")+$(generate (def {allowDuplicateRecordFields = True}) (fromTable "hg_test" "keyword_cols" & withDerivations [''Show, ''Eq]))++----------------------------------------------------------------------------------------------------++main :: IO ()+main = do+  testGeneratorsGenerate+  testPrefixedFields+  testKeywordSanitization+  testUsersCrud+  testUsersDerivation+  testEnumUtilities+  testEnumRoundTrip+  testViewSelect+  testNewtypePkCrud+  testHasPrimaryKey+  testBatchCrud+  testBatchTypeclasses+  exitSuccess++----------------------------------------------------------------------------------------------------+-- Compile-time generation test+----------------------------------------------------------------------------------------------------++{-  If this module compiles, the TH splices produced valid, well-typed code.+    We reference every generated binding to ensure nothing was silently omitted.+-}+testGeneratorsGenerate :: IO ()+testGeneratorsGenerate =+  _urAdmin+    .> _urModerator+    .> _urMember+    .> _wrDec+    .> _wrEnc+    .> _wrSel+    .> _wrIns+    .> _wrUpd+    .> _wrDel+    .> _wpDec+    .> _wpEnc+    .> _wpSel+    .> _wpIns+    .> _wpUpd+    .> _wpDel+    .> _npDec+    .> _npEnc+    .> _npIns+    .> _cpDec+    .> _cpEnc+    .> _cpSel+    .> _cpIns+    .> _cpUpd+    .> _cpDel+    .> _ueDec+    .> _ueSel+    .> _nsSel+    .> _nsDel+    .> _nsIns+    .> _ncSel+    .> _ncDel+    .> _ncIns+    .> _urAll+    .> _urToText+    .> _urFromText+    .> _pdDec+    .> _pdEnc+    .> _pdIns+    .> _kwDec+    .> _kwEnc+    .> _kwIns+    .> _smWp+    .> _dmWp+    .> _imWp+    .> _umWp+    .> _smCp+    .> _dmCp+    .> _imNp+    .> _imU+    .> _smNs+    .> _smNc+    .> _imCp+    .> _umCp+    .> _wrSmany+    .> _wrDmany+    .> _wrImany+    .> _wrUmany+    .> pure ()+    >> putStrLn "Generator test passed ✔"+  where+    a .> b = a `seq` b++    _urAdmin :: UserRole+    _urAdmin = Admin++    _urModerator :: UserRole+    _urModerator = Moderator++    _urMember :: UserRole+    _urMember = Member++    _wrDec :: Hasql.Decoders.Row WithRole+    _wrDec = withRoleDecoder++    _wrEnc :: Hasql.Encoders.Params WithRole+    _wrEnc = withRoleEncoder++    _wrSel :: Hasql.Statement.Statement UUID (Maybe WithRole)+    _wrSel = selectWithRole++    _wrIns :: Hasql.Statement.Statement WithRole WithRole+    _wrIns = insertWithRole++    _wrUpd :: Hasql.Statement.Statement WithRole (Maybe WithRole)+    _wrUpd = updateWithRole++    _wrDel :: Hasql.Statement.Statement UUID ()+    _wrDel = deleteWithRole++    _wpDec :: Hasql.Decoders.Row WithPk+    _wpDec = withPkDecoder++    _wpEnc :: Hasql.Encoders.Params WithPk+    _wpEnc = withPkEncoder++    _wpSel :: Hasql.Statement.Statement UUID (Maybe WithPk)+    _wpSel = selectWithPk++    _wpIns :: Hasql.Statement.Statement WithPk WithPk+    _wpIns = insertWithPk++    _wpUpd :: Hasql.Statement.Statement WithPk (Maybe WithPk)+    _wpUpd = updateWithPk++    _wpDel :: Hasql.Statement.Statement UUID ()+    _wpDel = deleteWithPk++    _npDec :: Hasql.Decoders.Row NoPk+    _npDec = noPkDecoder++    _npEnc :: Hasql.Encoders.Params NoPk+    _npEnc = noPkEncoder++    _npIns :: Hasql.Statement.Statement NoPk NoPk+    _npIns = insertNoPk++    _cpDec :: Hasql.Decoders.Row CompositePk+    _cpDec = compositePkDecoder++    _cpEnc :: Hasql.Encoders.Params CompositePk+    _cpEnc = compositePkEncoder++    _cpSel :: Hasql.Statement.Statement (UUID, Int32) (Maybe CompositePk)+    _cpSel = selectCompositePk++    _cpIns :: Hasql.Statement.Statement CompositePk CompositePk+    _cpIns = insertCompositePk++    _cpUpd :: Hasql.Statement.Statement CompositePk (Maybe CompositePk)+    _cpUpd = updateCompositePk++    _cpDel :: Hasql.Statement.Statement (UUID, Int32) ()+    _cpDel = deleteCompositePk++    _ueDec :: Hasql.Decoders.Row UserEmails+    _ueDec = userEmailsDecoder++    _ueSel :: Hasql.Statement.Statement () [UserEmails]+    _ueSel = selectUserEmails++    _urAll :: [UserRole]+    _urAll = allValues++    _urToText :: UserRole -> Text+    _urToText = toText++    _urFromText :: Text -> Maybe UserRole+    _urFromText = fromText++    -- Newtype PK (single) — PK param is NtSinglePk, not raw UUID+    _nsSel :: Hasql.Statement.Statement NtSinglePk (Maybe NtSingle)+    _nsSel = selectNtSingle++    _nsDel :: Hasql.Statement.Statement NtSinglePk ()+    _nsDel = deleteNtSingle++    _nsIns :: Hasql.Statement.Statement NtSingle NtSingle+    _nsIns = insertNtSingle++    -- Newtype PK (composite) — PK param is NtCompositePk record, not raw tuple+    _ncSel :: Hasql.Statement.Statement NtCompositePk (Maybe NtComposite)+    _ncSel = selectNtComposite++    _ncDel :: Hasql.Statement.Statement NtCompositePk ()+    _ncDel = deleteNtComposite++    _ncIns :: Hasql.Statement.Statement NtComposite NtComposite+    _ncIns = insertNtComposite++    _pdDec :: Hasql.Decoders.Row PrefixDemo+    _pdDec = prefixDemoDecoder++    _pdEnc :: Hasql.Encoders.Params PrefixDemo+    _pdEnc = prefixDemoEncoder++    _pdIns :: Hasql.Statement.Statement PrefixDemo PrefixDemo+    _pdIns = insertPrefixDemo++    _kwDec :: Hasql.Decoders.Row KeywordCols+    _kwDec = keywordColsDecoder++    _kwEnc :: Hasql.Encoders.Params KeywordCols+    _kwEnc = keywordColsEncoder++    _kwIns :: Hasql.Statement.Statement KeywordCols KeywordCols+    _kwIns = insertKeywordCols++    -- Batch with single PK+    _smWp :: Hasql.Statement.Statement [UUID] [WithPk]+    _smWp = selectManyWithPk++    _dmWp :: Hasql.Statement.Statement [UUID] ()+    _dmWp = deleteManyWithPk++    _imWp :: Hasql.Statement.Statement [WithPk] [WithPk]+    _imWp = insertManyWithPk++    _umWp :: Hasql.Statement.Statement [WithPk] [WithPk]+    _umWp = updateManyWithPk++    -- Batch with composite PK+    _smCp :: Hasql.Statement.Statement [(UUID, Int32)] [CompositePk]+    _smCp = selectManyCompositePk++    _dmCp :: Hasql.Statement.Statement [(UUID, Int32)] ()+    _dmCp = deleteManyCompositePk++    _imCp :: Hasql.Statement.Statement [CompositePk] [CompositePk]+    _imCp = insertManyCompositePk++    _umCp :: Hasql.Statement.Statement [CompositePk] [CompositePk]+    _umCp = updateManyCompositePk++    -- Batch insert (always generated, even no-PK tables)+    _imNp :: Hasql.Statement.Statement [NoPk] [NoPk]+    _imNp = insertManyNoPk++    -- Batch with defaulted PK (insert excludes PK columns)+    _imU :: Hasql.Statement.Statement [Users] [Users]+    _imU = insertManyUsers++    -- Batch with newtype PKs+    _smNs :: Hasql.Statement.Statement [NtSinglePk] [NtSingle]+    _smNs = selectManyNtSingle++    _smNc :: Hasql.Statement.Statement [NtCompositePk] [NtComposite]+    _smNc = selectManyNtComposite++    -- Batch with enum column+    _wrSmany :: Hasql.Statement.Statement [UUID] [WithRole]+    _wrSmany = selectManyWithRole++    _wrDmany :: Hasql.Statement.Statement [UUID] ()+    _wrDmany = deleteManyWithRole++    _wrImany :: Hasql.Statement.Statement [WithRole] [WithRole]+    _wrImany = insertManyWithRole++    _wrUmany :: Hasql.Statement.Statement [WithRole] [WithRole]+    _wrUmany = updateManyWithRole++----------------------------------------------------------------------------------------------------+-- Prefixed field name test+----------------------------------------------------------------------------------------------------++testPrefixedFields :: IO ()+testPrefixedFields = do+  let pd = PrefixDemo {prefixDemoVal = 42}+  assertEqual "prefixed field named \"val\"" 42 (prefixDemoVal pd)+  putStrLn "Prefixed fields test passed ✔"++----------------------------------------------------------------------------------------------------+-- Keyword sanitization test+----------------------------------------------------------------------------------------------------++{-  Verify that PostgreSQL columns named after Haskell reserved keywords get+    an apostrophe suffix in the generated record fields. If this function+    compiles and the field names are accessible, the sanitization works.+-}+testKeywordSanitization :: IO ()+testKeywordSanitization = withTestConnection $ \conn -> do+  let kw =+        KeywordCols+          { id = Data.UUID.nil+          , type' = "something" :: Text+          , class' = Just ("someclass" :: Text)+          , data' = 42 :: Int32+          }++  -- Insert via typeclass — exercises encoder with sanitized field names+  inserted <- runSession conn (Q.insert kw)+  assertEqual "keyword sanitization > type'" kw.type' inserted.type'+  assertEqual "keyword sanitization > class'" kw.class' inserted.class'+  assertEqual "keyword sanitization > data'" kw.data' inserted.data'++  -- Clean up+  runSession conn (Q.delete @KeywordCols inserted.id)++  putStrLn "Keyword sanitization test passed ✔"++----------------------------------------------------------------------------------------------------+-- CRUD functional test+----------------------------------------------------------------------------------------------------++testUsersCrud :: IO ()+testUsersCrud = withTestConnection $ \conn -> do+  let testUuid = Data.UUID.nil+  let user =+        Users+          { id = testUuid+          , name = "Alice"+          , email = Just "alice@example.com"+          }++  -- Insert via typeclass+  inserted <- runSession conn (Q.insert user)+  assertNotEqual "insert roundtrip > id" user.id inserted.id+  assertEqual "insert roundtrip > name" user.name inserted.name+  assertEqual "insert roundtrip > email" user.email inserted.email++  -- Select by PK via typeclass+  found <- runSession conn (Q.select @Users inserted.id)+  assertEqual "select by PK" (Just inserted) found+  assertEqual "select by PK > id" (Just inserted.id) (fmap (.id) found)++  -- Update via typeclass+  let updatedUser =+        Users+          { id = inserted.id+          , name = "Bob"+          , email = Just "bob@example.com"+          }+  updated <- runSession conn (Q.update updatedUser)+  assertEqual "update roundtrip" (Just updatedUser) updated++  -- Verify update persisted+  found2 <- runSession conn (Q.select @Users inserted.id)+  assertEqual "select after update" (Just updatedUser) found2++  -- Delete via typeclass+  runSession conn (Q.delete @Users inserted.id)++  -- Verify deletion+  gone <- runSession conn (Q.select @Users inserted.id)+  assertEqual "select after delete" (Nothing :: Maybe Users) gone++  putStrLn "CRUD test passed ✔"++----------------------------------------------------------------------------------------------------+-- Derivation tests+----------------------------------------------------------------------------------------------------++testUsersDerivation :: IO ()+testUsersDerivation = do+  testUuid <- nextRandom+  let user =+        Users+          { id = testUuid+          , name = "Alice"+          , email = Just "alice@example.com"+          }+      encoded = encode user+  case eitherDecode encoded of+    Left err      -> error ("JSON roundtrip failed: " <> err)+    Right decoded -> assertEqual "JSON roundtrip" user decoded++  -- Also test with a Nothing field+  let userNoEmail =+        Users+          { id = testUuid+          , name = "Bob"+          , email = Nothing+          }+      encodedNoEmail = encode userNoEmail+  case eitherDecode encodedNoEmail of+    Left err -> error ("JSON roundtrip (null email) failed: " <> err)+    Right decoded -> assertEqual "JSON roundtrip (null email)" userNoEmail decoded++  assertEqual+    "show derivation"+    ("Users {id = " <> show testUuid <> ", name = \"Alice\", email = Just \"alice@example.com\"}")+    (show user)++  putStrLn "Derivation test passed ✔"++----------------------------------------------------------------------------------------------------+-- HasEnum utility test+----------------------------------------------------------------------------------------------------++testEnumUtilities :: IO ()+testEnumUtilities = do+  assertEqual "allValues length" 3 (length (allValues :: [UserRole]))+  assertEqual "allValues order" [Admin, Moderator, Member] (allValues :: [UserRole])+  assertEqual "toText Admin" "admin" (toText Admin)+  assertEqual "toText Moderator" "moderator" (toText Moderator)+  assertEqual "toText Member" "member" (toText Member)+  assertEqual "fromText admin" (Just Admin) (fromText "admin")+  assertEqual "fromText moderator" (Just Moderator) (fromText "moderator")+  assertEqual "fromText member" (Just Member) (fromText "member")+  assertEqual "fromText bogus" (Nothing :: Maybe UserRole) (fromText "bogus")+  putStrLn "Enum utilities test passed ✔"++----------------------------------------------------------------------------------------------------+-- Enum round-trip test+----------------------------------------------------------------------------------------------------++testEnumRoundTrip :: IO ()+testEnumRoundTrip = withTestConnection $ \conn -> do+  let wr =+        WithRole+          { id = Data.UUID.nil+          , name = "EnumUser"+          , role' = Admin+          }++  -- Insert via typeclass+  inserted <- runSession conn (Q.insert wr)+  assertEqual "enum roundtrip > role'" Admin inserted.role'+  assertEqual "enum roundtrip > name" "EnumUser" inserted.name++  -- Select by PK via typeclass+  found <- runSession conn (Q.select @WithRole inserted.id)+  assertEqual "enum select" (Just inserted) found++  -- Update to a different role+  let updated = inserted {role' = Member}+  result <- runSession conn (Q.update updated)+  assertEqual "enum update > role'" (Just Member) (fmap (.role') result)++  -- Clean up+  runSession conn (Q.delete @WithRole inserted.id)++  -- Verify deletion+  gone <- runSession conn (Q.select @WithRole inserted.id)+  assertEqual "enum delete" (Nothing :: Maybe WithRole) gone++  putStrLn "Enum roundtrip test passed ✔"++----------------------------------------------------------------------------------------------------+-- View functional test+----------------------------------------------------------------------------------------------------++testViewSelect :: IO ()+testViewSelect = withTestConnection $ \conn -> do+  -- Insert a user with email (appears in user_emails view)+  let user =+        Users+          { id = Data.UUID.nil+          , name = "ViewUser"+          , email = Just "view@example.com"+          }+  inserted <- runSession conn (Q.insert user)++  -- Select all from the view via typeclass+  rows <- runSession conn (Q.selectView @UserEmails)+  assertTrue "view returns rows" (not (null rows))+  assertTrue "view contains inserted user" (any (\ue -> ue.id == Just inserted.id) rows)++  -- Clean up+  runSession conn (Q.delete @Users inserted.id)++  putStrLn "View test passed ✔"++----------------------------------------------------------------------------------------------------+-- Newtype PK CRUD test+----------------------------------------------------------------------------------------------------++testNewtypePkCrud :: IO ()+testNewtypePkCrud = withTestConnection $ \conn -> do+  testUuid <- nextRandom+  let user =+        NtSingle+          { id = NtSinglePk testUuid+          , name = "NtAlice"+          , email = Just "nt@example.com"+          }++  -- Insert+  inserted <- runSession conn (Hasql.Session.statement user insertNtSingle)+  assertEqual "nt insert > name" "NtAlice" inserted.name+  assertEqual "nt insert > email" (Just "nt@example.com") inserted.email++  -- Select by newtype PK+  found <- runSession conn (Hasql.Session.statement inserted.id selectNtSingle)+  assertEqual "nt select by PK" (Just inserted) found++  -- Update+  let updatedUser =+        NtSingle+          { id = inserted.id+          , name = "NtBob"+          , email = Just "ntbob@example.com"+          }+  updated <- runSession conn (Hasql.Session.statement updatedUser updateNtSingle)+  assertEqual "nt update" (Just updatedUser) updated++  -- Delete by newtype PK+  runSession conn (Hasql.Session.statement inserted.id deleteNtSingle)+  gone <- runSession conn (Hasql.Session.statement inserted.id selectNtSingle)+  assertEqual "nt delete" (Nothing :: Maybe NtSingle) gone++  putStrLn "Newtype PK CRUD test passed ✔"++----------------------------------------------------------------------------------------------------+-- HasPrimaryKey test+----------------------------------------------------------------------------------------------------++testHasPrimaryKey :: IO ()+testHasPrimaryKey = do+  testUuid <- nextRandom++  -- Test HasPrimaryKey on existing table with raw UUID PK (no newtypes)+  let wp = WithPk {pkid = testUuid, name = "Test", email = Nothing, age = Nothing}+  assertEqual "withPk toPk" testUuid (toPk wp)+  assertEqual "withPk wrapPk" testUuid (wrapPk @WithPk testUuid)+  assertEqual "withPk unwrapPk" testUuid (unwrapPk @WithPk testUuid)+  assertEqual "withPk rawPk" testUuid (rawPk wp)++  -- Test HasPrimaryKey on newtype single PK table+  let ns = NtSingle {id = NtSinglePk testUuid, name = "NtTest", email = Nothing}+  assertEqual "ntSingle toPk" (NtSinglePk testUuid) (toPk ns)+  assertEqual "ntSingle wrapPk" (NtSinglePk testUuid) (wrapPk @NtSingle testUuid)+  assertEqual "ntSingle unwrapPk" testUuid (unwrapPk @NtSingle (NtSinglePk testUuid))+  assertEqual "ntSingle rawPk" testUuid (rawPk ns)++  -- Test wrapPk / unwrapPk roundtrip+  let pk = NtSinglePk testUuid+  assertEqual "ntSingle wrap/unwrap roundtrip" pk (wrapPk @NtSingle (unwrapPk @NtSingle pk))++  -- Test HasPrimaryKey on composite PK table (no newtypes)+  let cp = CompositePk {tenantId = testUuid, itemId = 42, payload = "test"}+  assertEqual "compositePk toPk" (testUuid, 42) (toPk cp)+  assertEqual "compositePk rawPk" (testUuid, 42) (rawPk cp)++  -- Test HasPrimaryKey on newtype composite PK table+  let nc = NtComposite {tenantId = testUuid, itemId = 99, payload = "nctest"}+      ncPk = NtCompositePk {tenantId = testUuid, itemId = 99}+  assertEqual "ntComposite toPk" ncPk (toPk nc)+  assertEqual "ntComposite wrapPk" ncPk (wrapPk @NtComposite (testUuid, 99))+  assertEqual "ntComposite unwrapPk" (testUuid, 99) (unwrapPk @NtComposite ncPk)+  assertEqual "ntComposite rawPk" (testUuid, 99) (rawPk nc)++  putStrLn "HasPrimaryKey test passed ✔"++----------------------------------------------------------------------------------------------------+-- Batch CRUD functional test+----------------------------------------------------------------------------------------------------++testBatchCrud :: IO ()+testBatchCrud = withTestConnection $ \conn -> do+  uuid1 <- nextRandom+  uuid2 <- nextRandom+  uuid3 <- nextRandom++  let rec1 = WithPk {pkid = uuid1, name = "Batch1", email = Just "b1@test.com", age = Just 10}+      rec2 = WithPk {pkid = uuid2, name = "Batch2", email = Nothing, age = Just 20}+      rec3 = WithPk {pkid = uuid3, name = "Batch3", email = Just "b3@test.com", age = Nothing}++  -- insertMany+  inserted <- runSession conn (Hasql.Session.statement [rec1, rec2, rec3] insertManyWithPk)+  assertEqual "insertMany count" 3 (length inserted)++  -- selectMany all 3+  found <- runSession conn (Hasql.Session.statement [uuid1, uuid2, uuid3] selectManyWithPk)+  assertEqual "selectMany count" 3 (length found)+  assertTrue "selectMany contains uuid1" (any (\r -> r.pkid == uuid1) found)+  assertTrue "selectMany contains uuid2" (any (\r -> r.pkid == uuid2) found)+  assertTrue "selectMany contains uuid3" (any (\r -> r.pkid == uuid3) found)++  -- updateMany (use full construction to avoid ambiguous record update)+  let upd1 = WithPk {pkid = uuid1, name = "Updated1", email = rec1.email, age = rec1.age}+      upd2 = WithPk {pkid = uuid2, name = "Updated2", email = rec2.email, age = rec2.age}+      upd3 = WithPk {pkid = uuid3, name = "Updated3", email = rec3.email, age = rec3.age}+  updated <- runSession conn (Hasql.Session.statement [upd1, upd2, upd3] updateManyWithPk)+  assertEqual "updateMany count" 3 (length updated)+  assertTrue "updateMany name1" (any (\(r :: WithPk) -> r.name == "Updated1") updated)+  assertTrue "updateMany name2" (any (\(r :: WithPk) -> r.name == "Updated2") updated)+  assertTrue "updateMany name3" (any (\(r :: WithPk) -> r.name == "Updated3") updated)++  -- deleteMany first 2+  runSession conn (Hasql.Session.statement [uuid1, uuid2] deleteManyWithPk)++  -- selectMany all 3 — only the third should remain+  remaining <- runSession conn (Hasql.Session.statement [uuid1, uuid2, uuid3] selectManyWithPk)+  assertEqual "after deleteMany count" 1 (length remaining)+  assertTrue "after deleteMany only uuid3 remains" (any (\r -> r.pkid == uuid3) remaining)++  -- Clean up+  runSession conn (Hasql.Session.statement [uuid3] deleteManyWithPk)++  putStrLn "Batch CRUD test passed ✔"++----------------------------------------------------------------------------------------------------+-- Batch typeclass test+----------------------------------------------------------------------------------------------------++testBatchTypeclasses :: IO ()+testBatchTypeclasses = withTestConnection $ \conn -> do+  uuid1 <- nextRandom+  uuid2 <- nextRandom++  let rec1 = WithPk {pkid = uuid1, name = "TC1", email = Just "tc1@test.com", age = Just 30}+      rec2 = WithPk {pkid = uuid2, name = "TC2", email = Nothing, age = Nothing}++  -- insertMany via typeclass+  inserted <- runSession conn (Q.insertMany [rec1, rec2])+  assertEqual "typeclass insertMany count" 2 (length inserted)++  -- selectMany via typeclass+  found <- runSession conn (Q.selectMany @WithPk [uuid1, uuid2])+  assertEqual "typeclass selectMany count" 2 (length found)++  -- updateMany via typeclass (use full construction to avoid ambiguous record update)+  let upd1 = WithPk {pkid = uuid1, name = "TC1-updated", email = rec1.email, age = rec1.age}+      upd2 = WithPk {pkid = uuid2, name = "TC2-updated", email = rec2.email, age = rec2.age}+  updated <- runSession conn (Q.updateMany [upd1, upd2])+  assertEqual "typeclass updateMany count" 2 (length updated)+  assertTrue "typeclass updateMany name" (any (\(r :: WithPk) -> r.name == "TC1-updated") updated)++  -- deleteMany via typeclass+  runSession conn (Q.deleteMany @WithPk [uuid1, uuid2])++  -- Verify deletion+  gone <- runSession conn (Q.selectMany @WithPk [uuid1, uuid2])+  assertEqual "typeclass deleteMany" 0 (length gone)++  putStrLn "Batch typeclass test passed ✔"++----------------------------------------------------------------------------------------------------+-- Test helpers+----------------------------------------------------------------------------------------------------++withTestConnection :: (Hasql.Connection.Connection -> IO a) -> IO a+withTestConnection =+  bracket acquire Hasql.Connection.release+  where+    acquire :: IO Hasql.Connection.Connection+    acquire = do+      result <- Hasql.Connection.acquire ""+      case result of+        Left err   -> error ("Failed to connect: " <> show err)+        Right conn -> pure conn++runSession :: Hasql.Connection.Connection -> Hasql.Session.Session a -> IO a+runSession conn session = do+  result <- Hasql.Session.run session conn+  case result of+    Left err -> error ("Session failed: " <> show err)+    Right a  -> pure a++assertEqual :: (Eq a, Show a) => String -> a -> a -> IO ()+assertEqual label expected actual+  | expected == actual = pure ()+  | otherwise =+      die $ "Assertion failed (" <> label <> "): expected " <> show expected <> " but got " <> show actual++assertTrue :: String -> Bool -> IO ()+assertTrue label condition+  | condition = pure ()+  | otherwise = die ("Assertion failed (" <> label <> "): expected True but got False")++assertNotEqual :: (Eq a, Show a) => String -> a -> a -> IO ()+assertNotEqual label unexpected actual+  | unexpected /= actual = pure ()+  | otherwise =+      die $ "Assertion failed (" <> label <> "): expected NOT " <> show unexpected <> " but got " <> show actual