packages feed

valiant-0.1.0.0: src/Valiant/Statement.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-simplifiable-class-constraints #-}

-- | The 'Statement' type: a compile-time validated SQL query.
--
-- @p@ is the parameter type, @r@ is the result type. The GHC source plugin
-- verifies that these types match the SQL query's parameters and columns.
--
-- Create statements with 'queryFile' (validated by the plugin) or
-- 'mkStatement' (for manual\/test construction).
module Valiant.Statement
  ( Statement (..)
  , query
  , queryFile
  , queryFileAs
  , mkStatement
  ) where

import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.TypeLits (ErrorMessage (..), TypeError)
import Valiant.FromRow (FromRow (..))
import PgWire.Protocol.Oid (Oid (..))
import Valiant.ToParams (ToParams (..))

-- | A compile-time validated SQL statement.
--
-- @p@ is the parameter type (e.g., @Int32@ or @(Text, Maybe Text)@).
-- @r@ is the result type (e.g., @(Int32, Text)@ or @()@ for commands).
data Statement p r = Statement
  { stmtSQL :: ByteString
  -- ^ The raw SQL text.
  , stmtFile :: FilePath
  -- ^ The source @.sql@ file path (for error messages).
  , stmtParamOids :: Vector Oid
  -- ^ PostgreSQL type OIDs for each parameter.
  , stmtEncode :: p -> Vector (Maybe ByteString)
  -- ^ Encode parameters to binary format.
  , stmtDecode :: Vector (Maybe ByteString) -> Either String r
  -- ^ Decode a result row from binary format.
  , stmtColumns :: Vector ByteString
  -- ^ Column names from the query result.
  }

-- | Constraint that fires a helpful 'TypeError' if the valiant plugin is
-- not loaded. The plugin removes this constraint during the parse-phase
-- rewrite (by replacing @queryFile@ with @mkStatement@). If the plugin
-- isn't loaded, GHC tries to solve this constraint and hits the
-- 'TypeError' instance.
class ValiantPluginRequired

instance
  TypeError
    ( 'Text "queryFile/queryFileAs requires the Valiant.Plugin GHC source plugin."
      ':$$: 'Text ""
      ':$$: 'Text "Add this to your module:"
      ':$$: 'Text "  {-# OPTIONS_GHC -fplugin=Valiant.Plugin #-}"
      ':$$: 'Text ""
      ':$$: 'Text "Or add to your .cabal file:"
      ':$$: 'Text "  ghc-options: -fplugin=Valiant.Plugin"
    )
  => ValiantPluginRequired

-- | Reference a @.sql@ file for compile-time validation.
--
-- The GHC source plugin intercepts this call and rewrites it to
-- 'mkStatement' with embedded SQL, OIDs, column names, and file path.
--
-- Without the plugin, this produces a compile-time type error with
-- instructions on how to enable the plugin.
queryFile :: ValiantPluginRequired => FilePath -> Statement p r
queryFile _ = error "unreachable: valiant plugin not loaded"

-- | Like 'queryFile' but for named result types with 'FromRow'.
queryFileAs :: ValiantPluginRequired => FilePath -> Statement p r
queryFileAs _ = error "unreachable: valiant plugin not loaded"

-- | Inline SQL with compile-time validation.
--
-- Write SQL directly in Haskell source. The GHC plugin intercepts this
-- call, hashes the SQL text, looks up the type metadata in @.valiant\/@,
-- and rewrites to 'mkStatement' with the correct types.
--
-- Requires running @valiant prepare@ with the same SQL text present in a
-- @.sql@ file or registered via @valiant prepare --inline@.
--
-- @
-- findById :: Statement Int32 (Int32, Text, Maybe Text)
-- findById = query "SELECT id, name, email FROM users WHERE id = $1"
-- @
query :: ValiantPluginRequired => String -> Statement p r
query _ = error "unreachable: valiant plugin not loaded"

-- | Construct a 'Statement' from literal data embedded by the plugin.
-- The plugin rewrites @queryFile "path.sql"@ into a call to this function
-- with the SQL text, parameter OIDs, column names, and file path baked in.
-- Type class constraints resolve the encoder and decoder at compile time.
mkStatement
  :: (ToParams p, FromRow r)
  => String
  -> [Int]
  -> [String]
  -> String
  -> Statement p r
mkStatement sqlStr oids colNames path =
  Statement
    { stmtSQL = BS8.pack sqlStr
    , stmtFile = path
    , stmtParamOids = V.fromList (map (Oid . fromIntegral) oids)
    , stmtEncode = toParams
    , stmtDecode = fromRow
    , stmtColumns = V.fromList (map BS8.pack colNames)
    }