diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,12 @@
+# v0.5
+
+- (Breaking) Removed `Hasql.DynamicStatements.Session` module
+- (Breaking) Removed `Hasql.DynamicStatements.Statement` module
+- Added `toStatement`, `toSession`, `toPipeline` functions to the `Hasql.DynamicStatements.Snippet` module instead
+
+# v0.4
+
+- Migrated to Hasql 1.10
+- (Breaking) Redefined `Snippet.sql` to use `Text` instead of `ByteString`
+- (Breaking) Dropped the preparability flags assuming that all dynamic statements should not be
+  prepared
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,72 @@
+# hasql-dynamic-statements
+
+[![Hackage](https://img.shields.io/hackage/v/hasql-dynamic-statements.svg)](https://hackage.haskell.org/package/hasql-dynamic-statements)
+[![Continuous Haddock](https://img.shields.io/badge/haddock-master-blue)](https://nikita-volkov.github.io/hasql-dynamic-statements/)
+
+Hasql extension for composable, dynamic construction of SQL statements.
+
+## Overview
+
+This library provides a **Snippet** API that simplifies building SQL statements where the structure depends on runtime parameters. It abstracts over placeholder management and encoder matching, eliminating boilerplate and reducing bugs.
+
+## Key Features
+
+- **Composable**: Build statements using `Semigroup`/`Monoid` operations
+- **Type-safe**: Automatic parameter encoding with type-driven encoder derivation
+- **Dynamic**: Construct SQL conditionally based on parameters
+- **Clean API**: No manual placeholder numbering or encoder alignment
+
+## Quick Example
+
+```haskell
+import Hasql.DynamicStatements.Snippet
+
+-- Build a dynamic substring query
+selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> Snippet
+selectSubstring string from to =
+  "select substring(" <> param string <>
+  foldMap (\x -> " from " <> param x) from <>
+  foldMap (\x -> " for " <> param x) to <>
+  ")"
+
+-- Execute in a session
+result <- toSession (selectSubstring "hello" (Just 2) Nothing) 
+  (singleRow $ column $ nonNullable text)
+```
+
+## Without Snippet API
+
+Compare the above to manual construction:
+
+```haskell
+selectSubstring' :: Text -> Maybe Int32 -> Maybe Int32 -> Statement () Text
+selectSubstring' string from to = 
+  let sql = case (from, to) of
+        (Just _, Just _)  -> "select substring($1 from $2 to $3)"
+        (Just _, Nothing) -> "select substring($1 from $2)"
+        (Nothing, Just _) -> "select substring($1 to $2)"
+        (Nothing, Nothing) -> "select substring($1)"
+      encoder = 
+        Encoders.param (string >$ Encoders.text) <>
+        foldMap (\x -> Encoders.param (x >$ Encoders.int8)) from <>
+        foldMap (\x -> Encoders.param (x >$ Encoders.int8)) to
+      decoder = singleRow $ column $ nonNullable text
+  in Statement.preparable sql encoder decoder
+```
+
+The Snippet API eliminates placeholder numbering, pattern matching on parameter presence, and manual encoder sequencing.
+
+## Core API
+
+### Construction
+
+- **`sql`** - Raw SQL text
+- **`param`** - Parameter with implicit encoder
+- **`encoderAndParam`** - Parameter with explicit encoder
+
+### Execution
+
+- **`toSql`** - Compile to SQL text with placeholders
+- **`toStatement`** - Create a `Statement`
+- **`toSession`** - Execute directly in `Session`
+- **`toPipeline`** - Execute in `Pipeline`
diff --git a/Setup.hs b/Setup.hs
deleted file mode 100644
--- a/Setup.hs
+++ /dev/null
@@ -1,2 +0,0 @@
-import Distribution.Simple
-main = defaultMain
diff --git a/hasql-dynamic-statements.cabal b/hasql-dynamic-statements.cabal
--- a/hasql-dynamic-statements.cabal
+++ b/hasql-dynamic-statements.cabal
@@ -1,57 +1,138 @@
+cabal-version: 3.0
 name: hasql-dynamic-statements
-version: 0.3.1
-synopsis: Toolkit for constructing Hasql statements dynamically
+version: 0.5.1
+synopsis: Hasql extension for dynamic construction of statements
 description:
-  This library introduces into the Hasql ecosystem a new abstraction named Snippet,
-  which makes it trivial to construct SQL, while injecting values.
-  It is intended to be used when the SQL of your statement depends on the parameters,
-  that you want to pass in.
+  Library extending Hasql with a composable API for building SQL statements dynamically.
+
+  The core abstraction is 'Snippet', which allows you to construct SQL statements with parameters
+  injected in-place, automatically handling placeholders and encoder matching. This is particularly
+  useful when the structure of your SQL depends on runtime parameters.
+
+  Key features:
+
+  * Composable SQL snippets using 'Semigroup' and 'Monoid' instances
+  * Automatic placeholder generation and parameter encoding
+  * Type-safe parameter injection with implicit encoder derivation
+  * Protection against common SQL construction bugs
+  * Support for conditional SQL structure based on parameters
+
+  Example:
+
+  > selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> Snippet
+  > selectSubstring string from to =
+  >   "select substring(" <> param string <>
+  >   foldMap (\x -> " from " <> param x) from <>
+  >   foldMap (\x -> " for " <> param x) to <>
+  >   ")"
+
 homepage: https://github.com/nikita-volkov/hasql-dynamic-statements
-bug-reports: https://github.com/nikita-volkov/hasql-dynamic-statements/issues
+bug-reports:
+  https://github.com/nikita-volkov/hasql-dynamic-statements/issues
+
 author: Nikita Volkov <nikita.y.volkov@mail.ru>
 maintainer: Nikita Volkov <nikita.y.volkov@mail.ru>
 copyright: (c) 2019, Nikita Volkov
 license: MIT
 license-file: LICENSE
-build-type: Simple
-cabal-version: >=1.10
+extra-doc-files:
+  CHANGELOG.md
+  README.md
 
 source-repository head
   type: git
-  location: git://github.com/nikita-volkov/hasql-dynamic-statements.git
+  location: https://github.com/nikita-volkov/hasql-dynamic-statements
 
-library
-  hs-source-dirs: library
-  default-extensions: BangPatterns, DeriveDataTypeable, DeriveGeneric, DeriveFunctor, DeriveTraversable, GeneralizedNewtypeDeriving, FlexibleContexts, FlexibleInstances, LambdaCase, NoImplicitPrelude, OverloadedStrings, RankNTypes, ScopedTypeVariables, StandaloneDeriving, TypeApplications, TypeFamilies
-  ghc-options: -funbox-strict-fields
+common base
   default-language: Haskell2010
+  default-extensions:
+    ApplicativeDo
+    BangPatterns
+    BlockArguments
+    DefaultSignatures
+    DeriveDataTypeable
+    DeriveFoldable
+    DeriveFunctor
+    DeriveGeneric
+    DeriveTraversable
+    DerivingVia
+    EmptyDataDecls
+    FlexibleContexts
+    FlexibleInstances
+    FunctionalDependencies
+    GADTs
+    GeneralizedNewtypeDeriving
+    ImportQualifiedPost
+    LambdaCase
+    LiberalTypeSynonyms
+    MultiParamTypeClasses
+    MultiWayIf
+    NamedFieldPuns
+    NoImplicitPrelude
+    NoMonomorphismRestriction
+    OverloadedStrings
+    PatternGuards
+    QuasiQuotes
+    RankNTypes
+    RecordWildCards
+    RoleAnnotations
+    ScopedTypeVariables
+    StandaloneDeriving
+    StrictData
+    TupleSections
+    TypeApplications
+    TypeFamilies
+    TypeOperators
+
+common executable
+  import: base
+  ghc-options:
+    -O2
+    -threaded
+    -with-rtsopts=-N
+    -rtsopts
+    -funbox-strict-fields
+
+common test
+  import: base
+  ghc-options:
+    -threaded
+    -with-rtsopts=-N
+
+library
+  import: base
+  hs-source-dirs: src/library
   exposed-modules:
-    Hasql.DynamicStatements.Session
     Hasql.DynamicStatements.Snippet
-    Hasql.DynamicStatements.Statement
+
   other-modules:
     Hasql.DynamicStatements.Prelude
-    Hasql.DynamicStatements.Snippet.Defs
+
   build-depends:
-    base >=4.12 && <5,
-    bytestring >=0.10 && <0.12,
-    containers >=0.6 && <0.7,
-    hasql >=1.4 && <1.5,
-    hasql-implicits >=0.1 && <0.2,
-    ptr >=0.16.7.2 && <0.17
+    base >=4.14 && <5,
+    bytestring >=0.10 && <0.13,
+    containers >=0.6 && <0.8,
+    hasql >=1.10 && <1.11,
+    hasql-implicits >=0.2.0.2 && <0.3,
+    text >=1 && <3,
+    text-builder >=1.0 && <1.1,
 
 test-suite test
+  import: test
   type: exitcode-stdio-1.0
-  hs-source-dirs: test
+  hs-source-dirs: src/test
   main-is: Main.hs
-  default-extensions: Arrows, BangPatterns, ConstraintKinds, DataKinds, DefaultSignatures, DeriveDataTypeable, DeriveFoldable, DeriveFunctor, DeriveGeneric, DeriveTraversable, EmptyDataDecls, FlexibleContexts, FlexibleInstances, FunctionalDependencies, GADTs, GeneralizedNewtypeDeriving, LambdaCase, LiberalTypeSynonyms, MagicHash, MultiParamTypeClasses, MultiWayIf, NoImplicitPrelude, NoMonomorphismRestriction, OverloadedStrings, PatternGuards, ParallelListComp, QuasiQuotes, RankNTypes, RecordWildCards, ScopedTypeVariables, StandaloneDeriving, TemplateHaskell, TupleSections, TypeApplications, TypeFamilies, TypeOperators, UnboxedTuples
-  default-language: Haskell2010
+  other-modules:
+    Regressions.Issue2Spec
+    SpecHook
+    Units.SnippetSpec
+
+  build-tool-depends:
+    hspec-discover:hspec-discover ^>=2.11.12
+
   build-depends:
     hasql,
     hasql-dynamic-statements,
-    QuickCheck >=2.8.1 && <3,
-    quickcheck-instances >=0.3.11 && <0.4,
+    hspec ^>=2.11.12,
     rerebase <2,
-    tasty >=0.12 && <2,
-    tasty-hunit >=0.9 && <0.11,
-    tasty-quickcheck >=0.9 && <0.11
+    testcontainers-postgresql >=0.2 && <0.3,
diff --git a/library/Hasql/DynamicStatements/Prelude.hs b/library/Hasql/DynamicStatements/Prelude.hs
deleted file mode 100644
--- a/library/Hasql/DynamicStatements/Prelude.hs
+++ /dev/null
@@ -1,82 +0,0 @@
-module Hasql.DynamicStatements.Prelude
-(
-  module Exports,
-)
-where
-
-
--- base
--------------------------
-import Control.Applicative as Exports
-import Control.Arrow as Exports
-import Control.Category as Exports
-import Control.Concurrent as Exports
-import Control.Exception as Exports
-import Control.Monad as Exports hiding (fail, mapM_, sequence_, forM_, msum, mapM, sequence, forM)
-import Control.Monad.IO.Class as Exports
-import Control.Monad.Fail as Exports
-import Control.Monad.Fix as Exports hiding (fix)
-import Control.Monad.ST as Exports
-import Data.Bits as Exports
-import Data.Bool as Exports
-import Data.Char as Exports
-import Data.Coerce as Exports
-import Data.Complex as Exports
-import Data.Data as Exports
-import Data.Dynamic as Exports
-import Data.Either as Exports
-import Data.Fixed as Exports
-import Data.Foldable as Exports hiding (toList)
-import Data.Function as Exports hiding (id, (.))
-import Data.Functor as Exports
-import Data.Functor.Contravariant as Exports
-import Data.Functor.Identity as Exports
-import Data.Int as Exports
-import Data.IORef as Exports
-import Data.Ix as Exports
-import Data.List as Exports hiding (sortOn, isSubsequenceOf, uncons, concat, foldr, foldl1, maximum, minimum, product, sum, all, and, any, concatMap, elem, foldl, foldr1, notElem, or, find, maximumBy, minimumBy, mapAccumL, mapAccumR, foldl')
-import Data.Maybe as Exports
-import Data.Monoid as Exports hiding (Last(..), First(..), (<>))
-import Data.Ord as Exports
-import Data.Proxy as Exports
-import Data.Ratio as Exports
-import Data.Semigroup as Exports
-import Data.STRef as Exports
-import Data.String as Exports
-import Data.Traversable as Exports
-import Data.Tuple as Exports
-import Data.Unique as Exports
-import Data.Version as Exports
-import Data.Word as Exports
-import Debug.Trace as Exports
-import Foreign.ForeignPtr as Exports
-import Foreign.Ptr as Exports
-import Foreign.StablePtr as Exports
-import Foreign.Storable as Exports hiding (sizeOf, alignment)
-import GHC.Conc as Exports hiding (withMVar, threadWaitWriteSTM, threadWaitWrite, threadWaitReadSTM, threadWaitRead)
-import GHC.Exts as Exports (lazy, inline, sortWith, groupWith, IsList(..))
-import GHC.Generics as Exports (Generic, Generic1)
-import GHC.IO.Exception as Exports
-import Numeric as Exports
-import Prelude as Exports hiding (fail, concat, foldr, mapM_, sequence_, foldl1, maximum, minimum, product, sum, all, and, any, concatMap, elem, foldl, foldr1, notElem, or, mapM, sequence, id, (.))
-import System.Environment as Exports
-import System.Exit as Exports
-import System.IO as Exports
-import System.IO.Error as Exports
-import System.IO.Unsafe as Exports
-import System.Mem as Exports
-import System.Mem.StableName as Exports
-import System.Timeout as Exports
-import Text.ParserCombinators.ReadP as Exports (ReadP, ReadS, readP_to_S, readS_to_P)
-import Text.ParserCombinators.ReadPrec as Exports (ReadPrec, readPrec_to_P, readP_to_Prec, readPrec_to_S, readS_to_Prec)
-import Text.Printf as Exports (printf, hPrintf)
-import Text.Read as Exports (Read(..), readMaybe, readEither)
-import Unsafe.Coerce as Exports
-
--- containers
--------------------------
-import Data.Sequence as Exports (Seq)
-
--- bytestring
--------------------------
-import Data.ByteString as Exports (ByteString)
diff --git a/library/Hasql/DynamicStatements/Session.hs b/library/Hasql/DynamicStatements/Session.hs
deleted file mode 100644
--- a/library/Hasql/DynamicStatements/Session.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-module Hasql.DynamicStatements.Session where
-
-import Hasql.DynamicStatements.Prelude
-import Hasql.Session
-import qualified Hasql.DynamicStatements.Snippet.Defs as SnippetDefs
-import qualified Hasql.DynamicStatements.Statement as Statement
-import qualified Hasql.Decoders as Decoders
-import qualified Hasql.Session as Session
-
-{-|
-Execute a dynamically parameterized statement, providing a result decoder.
-
-This is merely a shortcut, which immediately embeds
-@Hasql.DynamicStatements.Statement.'Statement.dynamicallyParameterized'@
-in @Session@.
-For details see the docs on that function.
--}
-dynamicallyParameterizedStatement :: SnippetDefs.Snippet -> Decoders.Result result -> Bool -> Session result
-dynamicallyParameterizedStatement snippet decoder prepared =
-  Session.statement () (Statement.dynamicallyParameterized snippet decoder prepared)
diff --git a/library/Hasql/DynamicStatements/Snippet.hs b/library/Hasql/DynamicStatements/Snippet.hs
deleted file mode 100644
--- a/library/Hasql/DynamicStatements/Snippet.hs
+++ /dev/null
@@ -1,10 +0,0 @@
-module Hasql.DynamicStatements.Snippet
-(
-  Snippet,
-  param,
-  encoderAndParam,
-  sql,
-)
-where
-
-import Hasql.DynamicStatements.Snippet.Defs
diff --git a/library/Hasql/DynamicStatements/Snippet/Defs.hs b/library/Hasql/DynamicStatements/Snippet/Defs.hs
deleted file mode 100644
--- a/library/Hasql/DynamicStatements/Snippet/Defs.hs
+++ /dev/null
@@ -1,59 +0,0 @@
-module Hasql.DynamicStatements.Snippet.Defs where
-
-import Hasql.DynamicStatements.Prelude
-import qualified Hasql.Encoders as Encoders
-import qualified Hasql.Implicits.Encoders as Encoders
-
-
-{-|
-Composable SQL snippet with parameters injected.
-Abstracts over placeholders and matching of encoders.
-
-It has an instance of `IsString`, so having the @OverloadedStrings@ extension enabled
-you can construct it directly from string literals.
-
-Here's an example:
-
-@
-selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> 'Snippet'
-selectSubstring string from to =
-  "select substring(" <> 'param' string <>
-  'foldMap' (\\ x -> " from " <> 'param' x) from <>
-  'foldMap' (\\ x -> " for " <> 'param' x) to <>
-  ")"
-@
-
-Having a decoder you can lift it into 'Hasql.Statement.Statement' using
-'Hasql.DynamicStatements.Statement.dynamicallyParameterized' or directly execute it in
-'Hasql.Session.Session' using
-'Hasql.DynamicStatements.Session.dynamicallyParameterizedStatement'.
--}
-newtype Snippet = Snippet (Seq SnippetChunk)
-
-data SnippetChunk =
-  StringSnippetChunk ByteString |
-  ParamSnippetChunk (Encoders.Params ())
-
-deriving instance Semigroup Snippet
-deriving instance Monoid Snippet
-
-instance IsString Snippet where
-  fromString x = Snippet (pure (StringSnippetChunk (fromString x)))
-
-{-|
-SQL chunk in ASCII.
--}
-sql :: ByteString -> Snippet
-sql x = Snippet (pure (StringSnippetChunk x))
-
-{-|
-Parameter encoded using an implicitly derived encoder from the type.
--}
-param :: Encoders.DefaultParamEncoder param => param -> Snippet
-param = encoderAndParam Encoders.defaultParam
-
-{-|
-Parameter with an explicitly defined encoder.
--}
-encoderAndParam :: Encoders.NullableOrNot Encoders.Value param -> param -> Snippet
-encoderAndParam encoder param = Snippet (pure (ParamSnippetChunk (param >$ Encoders.param encoder)))
diff --git a/library/Hasql/DynamicStatements/Statement.hs b/library/Hasql/DynamicStatements/Statement.hs
deleted file mode 100644
--- a/library/Hasql/DynamicStatements/Statement.hs
+++ /dev/null
@@ -1,66 +0,0 @@
-module Hasql.DynamicStatements.Statement where
-
-import Hasql.DynamicStatements.Prelude
-import Hasql.Statement
-import qualified Hasql.DynamicStatements.Snippet.Defs as SnippetDefs
-import qualified Hasql.Decoders as Decoders
-import qualified Hasql.Encoders as Encoders
-import qualified Ptr.Poking as Poking
-import qualified Ptr.ByteString as ByteString
-
-
-{-|
-Construct a statement dynamically, specifying the parameters in-place
-in the declaration of snippet and providing a result decoder and
-specifying whether the statement should be prepared.
-
-The injection of the parameters is handled automatically,
-generating parametric statements with binary encoders under the hood.
-
-This is useful when the SQL of your statement depends on the parameters.
-Here's an example:
-
-@
-selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> 'Statement' () Text
-selectSubstring string from to = let
-  snippet =
-    "select substring(" <> Snippet.'SnippetDefs.param' string <>
-    foldMap (mappend " from " . Snippet.'SnippetDefs.param') from <>
-    foldMap (mappend " for " . Snippet.'SnippetDefs.param') to <>
-    ")"
-  decoder = Decoders.'Decoders.singleRow' (Decoders.'Decoders.column' (Decoders.'Decoders.nonNullable' Decoders.'Decoders.text'))
-  in 'dynamicallyParameterized' snippet decoder True
-@
-
-Without the Snippet API you would have had to implement the same functionality thus:
-
-@
-selectSubstring' :: Text -> Maybe Int32 -> Maybe Int32 -> 'Statement' () Text
-selectSubstring' string from to = let
-  sql = case (from, to) of
-    (Just _, Just _) -> "select substring($1 from $2 to $3)"
-    (Just _, Nothing) -> "select substring($1 from $2)"
-    (Nothing, Just _) -> "select substring($1 to $2)"
-    (Nothing, Nothing) -> "select substring($1)"
-  encoder = 
-    Encoders.'Encoders.param' (string '>$' Encoders.'Encoders.text') <>
-    foldMap (\\ x -> Encoders.'Encoders.param' (x '>$' Encoders.'Encoders.int8')) from <>
-    foldMap (\\ x -> Encoders.'Encoders.param' (x '>$' Encoders.'Encoders.int8')) to
-  decoder = Decoders.'Decoders.singleRow' (Decoders.'Decoders.column' Decoders.'Decoders.text')
-  in Statement sql encoder decoder True
-@
-
-As you can see, the Snippet API abstracts over placeholders and
-matching encoder generation, thus also protecting you from all sorts of related bugs.
--}
-dynamicallyParameterized :: SnippetDefs.Snippet -> Decoders.Result result -> Bool -> Statement () result
-dynamicallyParameterized (SnippetDefs.Snippet chunks) decoder prepared = let
-  step (!paramId, !poking, !encoder) = \ case
-    SnippetDefs.StringSnippetChunk sql -> (paramId, poking <> Poking.bytes sql, encoder)
-    SnippetDefs.ParamSnippetChunk paramEncoder -> let
-      newParamId = paramId + 1
-      newPoking = poking <> Poking.word8 36 <> Poking.asciiIntegral paramId
-      newEncoder = encoder <> paramEncoder
-      in (newParamId, newPoking, newEncoder)
-  in case foldl' step (1, mempty, mempty) chunks of
-    (_, poking, encoder) -> Statement (ByteString.poking poking) encoder decoder prepared
diff --git a/src/library/Hasql/DynamicStatements/Prelude.hs b/src/library/Hasql/DynamicStatements/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/library/Hasql/DynamicStatements/Prelude.hs
@@ -0,0 +1,71 @@
+module Hasql.DynamicStatements.Prelude
+  ( module Exports,
+  )
+where
+
+import Control.Applicative as Exports
+import Control.Arrow as Exports
+import Control.Category as Exports
+import Control.Concurrent as Exports
+import Control.Exception as Exports
+import Control.Monad as Exports hiding (fail, forM, forM_, mapM, mapM_, msum, sequence, sequence_)
+import Control.Monad.Fail as Exports
+import Control.Monad.Fix as Exports hiding (fix)
+import Control.Monad.IO.Class as Exports
+import Control.Monad.ST as Exports
+import Data.Bits as Exports
+import Data.Bool as Exports
+import Data.ByteString as Exports (ByteString)
+import Data.Char as Exports
+import Data.Coerce as Exports
+import Data.Complex as Exports
+import Data.Data as Exports
+import Data.Dynamic as Exports
+import Data.Either as Exports
+import Data.Fixed as Exports
+import Data.Foldable as Exports hiding (toList)
+import Data.Function as Exports hiding (id, (.))
+import Data.Functor as Exports hiding (unzip)
+import Data.Functor.Contravariant as Exports
+import Data.Functor.Identity as Exports
+import Data.IORef as Exports
+import Data.Int as Exports
+import Data.Ix as Exports
+import Data.List as Exports hiding (all, and, any, concat, concatMap, elem, find, foldl, foldl', foldl1, foldr, foldr1, isSubsequenceOf, mapAccumL, mapAccumR, maximum, maximumBy, minimum, minimumBy, notElem, or, product, sortOn, sum, uncons)
+import Data.Maybe as Exports
+import Data.Monoid as Exports hiding (First (..), Last (..), (<>))
+import Data.Ord as Exports
+import Data.Proxy as Exports
+import Data.Ratio as Exports
+import Data.STRef as Exports
+import Data.Semigroup as Exports
+import Data.Sequence as Exports (Seq)
+import Data.String as Exports
+import Data.Text as Exports (Text)
+import Data.Traversable as Exports
+import Data.Tuple as Exports
+import Data.Unique as Exports
+import Data.Version as Exports
+import Data.Word as Exports
+import Debug.Trace as Exports
+import Foreign.ForeignPtr as Exports
+import Foreign.Ptr as Exports
+import Foreign.StablePtr as Exports
+import Foreign.Storable as Exports hiding (alignment, sizeOf)
+import GHC.Conc as Exports hiding (threadWaitRead, threadWaitReadSTM, threadWaitWrite, threadWaitWriteSTM, withMVar)
+import GHC.Exts as Exports (IsList (..), groupWith, inline, lazy, sortWith)
+import GHC.Generics as Exports (Generic, Generic1)
+import GHC.IO.Exception as Exports
+import Numeric as Exports
+import System.Environment as Exports
+import System.Exit as Exports
+import System.IO as Exports
+import System.IO.Error as Exports
+import System.IO.Unsafe as Exports
+import System.Mem as Exports
+import System.Mem.StableName as Exports
+import System.Timeout as Exports
+import Text.Printf as Exports (hPrintf, printf)
+import Text.Read as Exports (Read (..), readEither, readMaybe)
+import Unsafe.Coerce as Exports
+import Prelude as Exports hiding (all, and, any, concat, concatMap, elem, fail, foldl, foldl1, foldr, foldr1, id, mapM, mapM_, maximum, minimum, notElem, or, product, sequence, sequence_, sum, (.))
diff --git a/src/library/Hasql/DynamicStatements/Snippet.hs b/src/library/Hasql/DynamicStatements/Snippet.hs
new file mode 100644
--- /dev/null
+++ b/src/library/Hasql/DynamicStatements/Snippet.hs
@@ -0,0 +1,170 @@
+module Hasql.DynamicStatements.Snippet
+  ( Snippet,
+
+    -- * Execution
+    toSql,
+    toStatement,
+    toPreparableStatement,
+    toSession,
+    toPipeline,
+
+    -- * Construction
+    param,
+    encoderAndParam,
+    sql,
+  )
+where
+
+import Hasql.Decoders qualified as Decoders
+import Hasql.DynamicStatements.Prelude
+import Hasql.Encoders qualified as Encoders
+import Hasql.Implicits.Encoders qualified as Encoders
+import Hasql.Pipeline qualified as Pipeline
+import Hasql.Session qualified as Session
+import Hasql.Statement qualified as Statement
+import TextBuilder qualified
+
+-- |
+-- Composable SQL snippet with parameters injected.
+-- Abstracts over placeholders and matching of encoders.
+--
+-- It has an instance of `IsString`, so having the @OverloadedStrings@ extension enabled
+-- you can construct it directly from string literals.
+--
+-- Here's an example:
+--
+-- @
+-- selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> 'Snippet'
+-- selectSubstring string from to =
+--   "select substring(" <> 'param' string <>
+--   'foldMap' (\\ x -> " from " <> 'param' x) from <>
+--   'foldMap' (\\ x -> " for " <> 'param' x) to <>
+--   ")"
+-- @
+--
+-- Having a decoder you can lift it into 'Hasql.Statement.Statement' using
+-- 'Hasql.DynamicStatements.Snippet.toStatement' or directly execute it in
+-- 'Hasql.Session.Session' using
+-- 'Hasql.DynamicStatements.Snippet.toSession'.
+data Snippet
+  = Snippet
+      (Int -> TextBuilder.TextBuilder)
+      Int
+      (Encoders.Params ())
+
+instance Semigroup Snippet where
+  Snippet sql1 paramCount1 encoder1 <> Snippet sql2 paramCount2 encoder2 =
+    Snippet
+      (\paramId -> sql1 paramId <> sql2 (paramId + paramCount1))
+      (paramCount1 + paramCount2)
+      (encoder1 <> encoder2)
+
+instance Monoid Snippet where
+  mempty = Snippet (\_ -> mempty) 0 mempty
+
+instance IsString Snippet where
+  fromString = sql . fromString
+
+-- |
+-- SQL chunk.
+sql :: Text -> Snippet
+sql x = Snippet (const (TextBuilder.text x)) 0 mempty
+
+-- |
+-- Parameter encoded using an implicitly derived encoder from the type.
+param :: (Encoders.DefaultParamEncoder param) => param -> Snippet
+param = encoderAndParam Encoders.defaultParam
+
+-- |
+-- Parameter with an explicitly defined encoder.
+encoderAndParam :: Encoders.NullableOrNot Encoders.Value param -> param -> Snippet
+encoderAndParam encoder param =
+  Snippet
+    (\paramId -> "$" <> TextBuilder.decimal paramId)
+    1
+    (param >$ Encoders.param encoder)
+
+-- |
+-- Compile a snippet into SQL text with placeholders.
+toSql :: Snippet -> Text
+toSql (Snippet sql _ _) =
+  TextBuilder.toText (sql 1)
+
+-- |
+-- Construct a statement dynamically, specifying the parameters in-place
+-- in the declaration of snippet and providing a result decoder and
+-- specifying whether the statement should be prepared.
+--
+-- The injection of the parameters is handled automatically,
+-- generating parametric statements with binary encoders under the hood.
+--
+-- This is useful when the SQL of your statement depends on the parameters.
+-- Here's an example:
+--
+-- @
+-- selectSubstring :: Text -> Maybe Int32 -> Maybe Int32 -> 'Statement' () Text
+-- selectSubstring string from to = let
+--   snippet =
+--     "select substring(" <> Snippet.'param' string <>
+--     foldMap (mappend " from " . Snippet.'param') from <>
+--     foldMap (mappend " for " . Snippet.'param') to <>
+--     ")"
+--   decoder = Decoders.'Decoders.singleRow' (Decoders.'Decoders.column' (Decoders.'Decoders.nonNullable' Decoders.'Decoders.text'))
+--   in 'toStatement' snippet decoder
+-- @
+--
+-- Without the Snippet API you would have had to implement the same functionality thus:
+--
+-- @
+-- selectSubstring' :: Text -> Maybe Int32 -> Maybe Int32 -> 'Statement' () Text
+-- selectSubstring' string from to = let
+--   sql = case (from, to) of
+--     (Just _, Just _) -> "select substring($1 from $2 to $3)"
+--     (Just _, Nothing) -> "select substring($1 from $2)"
+--     (Nothing, Just _) -> "select substring($1 to $2)"
+--     (Nothing, Nothing) -> "select substring($1)"
+--   encoder =
+--     Encoders.'Encoders.param' (string '>$' Encoders.'Encoders.text') '<>'
+--     foldMap (\\ x -> Encoders.'Encoders.param' (x '>$' Encoders.'Encoders.int8')) from '<>'
+--     foldMap (\\ x -> Encoders.'Encoders.param' (x '>$' Encoders.'Encoders.int8')) to
+--   decoder = Decoders.'Decoders.singleRow' (Decoders.'Decoders.column' (Decoders.'Decoders.nonNullable' Decoders.'Decoders.text'))
+--   in Statement.'Statement.unpreparable' sql encoder decoder
+-- @
+--
+-- As you can see, the Snippet API abstracts over placeholders and
+-- matching encoder generation, thus also protecting you from all sorts of related bugs.
+toStatement :: Snippet -> Decoders.Result result -> Statement.Statement () result
+toStatement = toStatement' Statement.unpreparable
+
+toPreparableStatement :: Snippet -> Decoders.Result result -> Statement.Statement () result
+toPreparableStatement = toStatement' Statement.preparable
+
+toStatement' :: (Text -> Encoders.Params () -> t) -> Snippet -> t
+toStatement' stmt (Snippet sql _ encoder) =
+  stmt (TextBuilder.toText (sql 1)) encoder
+
+-- |
+-- Execute in @Session.Session@, providing a result decoder.
+--
+-- This is merely a shortcut defined thus:
+--
+-- @
+-- toSession snippet decoder =
+--   Session.statement () (Snippet.toStatement snippet decoder)
+-- @
+toSession :: Snippet -> Decoders.Result result -> Session.Session result
+toSession snippet decoder =
+  Session.statement () (toStatement snippet decoder)
+
+-- |
+-- Execute in @Pipeline.Pipeline@, providing a result decoder.
+--
+-- This is merely a shortcut defined thus:
+--
+-- @
+-- toPipeline snippet decoder =
+--   Pipeline.statement () (Snippet.toStatement snippet decoder)
+-- @
+toPipeline :: Snippet -> Decoders.Result result -> Pipeline.Pipeline result
+toPipeline snippet decoder =
+  Pipeline.statement () (toStatement snippet decoder)
diff --git a/src/test/Main.hs b/src/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/test/Main.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/src/test/Regressions/Issue2Spec.hs b/src/test/Regressions/Issue2Spec.hs
new file mode 100644
--- /dev/null
+++ b/src/test/Regressions/Issue2Spec.hs
@@ -0,0 +1,19 @@
+module Regressions.Issue2Spec where
+
+import Hasql.Connection qualified as Connection
+import Hasql.Decoders qualified as Decoders
+import Hasql.DynamicStatements.Snippet qualified as Snippet
+import Hasql.Session qualified as Session
+import Test.Hspec
+import Prelude
+
+spec :: SpecWith Connection.Connection
+spec =
+  describe "Missing $ for 1000th parameter string error" do
+    it "Doesn't happen" \connection -> do
+      let snippet =
+            "SELECT 1 " <> (foldMap @[] ("," <>) (replicate 1001 (Snippet.param (10 :: Int64))))
+      result <- Connection.use connection (Session.statement () (Snippet.toStatement snippet Decoders.noResult))
+      case result of
+        Right () -> pure ()
+        _ -> expectationFailure "Statement execution failed"
diff --git a/src/test/SpecHook.hs b/src/test/SpecHook.hs
new file mode 100644
--- /dev/null
+++ b/src/test/SpecHook.hs
@@ -0,0 +1,45 @@
+-- Docs: https://hspec.github.io/hspec-discover.html
+module SpecHook where
+
+import Hasql.Connection qualified as Connection
+import Hasql.Connection.Settings qualified as Settings
+import Test.Hspec
+import TestcontainersPostgresql qualified
+import Prelude
+
+type HookedSpec = SpecWith Connection.Connection
+
+hook :: HookedSpec -> Spec
+hook hookedSpec = parallel do
+  byDistro "postgres:10"
+  byDistro "postgres:17"
+  where
+    byDistro tagName =
+      describe (toList tagName) do
+        aroundAll
+          ( \onConnection ->
+              TestcontainersPostgresql.run
+                TestcontainersPostgresql.Config
+                  { tagName,
+                    auth = TestcontainersPostgresql.TrustAuth,
+                    forwardLogs = False
+                  }
+                ( \(host, port) -> do
+                    let settings =
+                          mconcat
+                            [ Settings.hostAndPort host port,
+                              Settings.user "postgres",
+                              Settings.dbname "postgres"
+                            ]
+                    bracket
+                      ( do
+                          res <- Connection.acquire settings
+                          case res of
+                            Left err -> fail ("Connection failed: " <> show err)
+                            Right conn -> pure conn
+                      )
+                      Connection.release
+                      onConnection
+                )
+          )
+          (parallel hookedSpec)
diff --git a/src/test/Units/SnippetSpec.hs b/src/test/Units/SnippetSpec.hs
new file mode 100644
--- /dev/null
+++ b/src/test/Units/SnippetSpec.hs
@@ -0,0 +1,27 @@
+module Units.SnippetSpec where
+
+import Hasql.Connection qualified as Connection
+import Hasql.Decoders qualified as Decoders
+import Hasql.DynamicStatements.Snippet qualified as Snippet
+import Hasql.Session qualified as Session
+import Test.Hspec
+import Prelude
+
+spec :: SpecWith Connection.Connection
+spec = do
+  describe "Select substring" do
+    it "Works" \connection -> do
+      let sample string from to =
+            let snippet =
+                  "select substring("
+                    <> Snippet.param @Text string
+                    <> foldMap (mappend " from " . Snippet.param @Int32) from
+                    <> foldMap (mappend " for " . Snippet.param @Int32) to
+                    <> ")"
+                decoder = Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.text))
+             in Connection.use connection (Session.statement () (Snippet.toStatement snippet decoder))
+       in do
+            shouldBe (Right "bc") =<< sample "abcd" (Just 2) (Just 2)
+            shouldBe (Right "bcd") =<< sample "abcd" (Just 2) (Just 3)
+            shouldBe (Right "abc") =<< sample "abcd" Nothing (Just 3)
+            shouldBe (Right "bcd") =<< sample "abcd" (Just 2) Nothing
diff --git a/test/Main.hs b/test/Main.hs
deleted file mode 100644
--- a/test/Main.hs
+++ /dev/null
@@ -1,78 +0,0 @@
-module Main where
-
-import Prelude hiding (assert)
-import Test.QuickCheck.Instances
-import Test.Tasty
-import Test.Tasty.Runners
-import Test.Tasty.HUnit
-import Test.Tasty.QuickCheck
-import qualified Test.QuickCheck as QuickCheck
-import qualified Hasql.Statement as Statement
-import qualified Hasql.Encoders as Encoders
-import qualified Hasql.Decoders as Decoders
-import qualified Hasql.Session as Session
-import qualified Hasql.Connection as Connection
-import qualified Hasql.DynamicStatements.Snippet as Snippet
-import qualified Hasql.DynamicStatements.Session as Session
-import qualified Hasql.DynamicStatements.Statement as Statement
-import qualified Data.ByteString as ByteString
-import qualified Data.ByteString.Char8 as ByteStringChar8
-
-
-main =
-  defaultMain tree
-
-tree =
-  testGroup "All tests"
-  [
-    testCase "Select substring" $ let
-      sample string from to = let
-        snippet =
-          "select substring(" <> Snippet.param @Text string <>
-          foldMap (mappend " from " . Snippet.param @Int32) from <>
-          foldMap (mappend " for " . Snippet.param @Int32) to <>
-          ")"
-        decoder = Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.text))
-        in runSession (Session.dynamicallyParameterizedStatement snippet decoder True)
-      in do
-        assertEqual "" (Right (Right "bc")) =<< sample "abcd" (Just 2) (Just 2)
-        assertEqual "" (Right (Right "bcd")) =<< sample "abcd" (Just 2) (Just 3)
-        assertEqual "" (Right (Right "abc")) =<< sample "abcd" Nothing (Just 3)
-        assertEqual "" (Right (Right "bcd")) =<< sample "abcd" (Just 2) Nothing
-    ,
-    testGroup "Regression" [
-        testCase "Missing $ for 1000th parameter string #2" $ let
-          snippet =
-            "SELECT 1 " <> (foldMap @[] ("," <>) $ replicate 1001 $ Snippet.param (10::Int64))
-          statement =
-            Statement.dynamicallyParameterized snippet Decoders.noResult True
-          sql =
-            case statement of
-              Statement.Statement x _ _ _ -> x
-          in do
-            assertBool (ByteStringChar8.unpack sql) (ByteString.isInfixOf "$1000" sql)
-      ]
-  ]
-
-runSession :: Session.Session a -> IO (Either Connection.ConnectionError (Either Session.QueryError a))
-runSession = withConnection . Session.run
-
-withConnection :: (Connection.Connection -> IO a) -> IO (Either Connection.ConnectionError a)
-withConnection handler =
-  runExceptT $ acquire >>= \connection -> use connection <* release connection
-  where
-    acquire =
-      ExceptT $ Connection.acquire settings
-      where
-        settings =
-          Connection.settings host port user password database
-          where
-            host = "localhost"
-            port = 5432
-            user = "postgres"
-            password = ""
-            database = "postgres"
-    use connection =
-      lift $ handler connection
-    release connection =
-      lift $ Connection.release connection
