packages feed

valiant-0.1.0.0: src/Valiant/NamedParams.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Named parameter support for query execution.
--
-- This module provides 'ToNamedParams', a type class for encoding
-- Haskell records as named query parameters. Field names in the record
-- correspond to @:name@ parameters in the SQL file.
--
-- @
-- data FindParams = FindParams
--   { orgId  :: Int32
--   , role   :: Text
--   , active :: Bool
--   } deriving (Generic, ToNamedParams)
--
-- findByRole :: NamedStatement FindParams [(Int32, Text)]
-- findByRole = mkStatementNamed
--   "SELECT id, name FROM users WHERE org_id = $1 AND role = $2 AND active = $3"
--   [23, 25, 16]
--   ["id", "name"]
--   ["orgId", "role", "active"]  -- maps $1=orgId, $2=role, $3=active
--   "users/find_by_role.sql"
-- @
module Valiant.NamedParams
  ( ToNamedParams (..)
  , NamedStatement
  , mkStatementNamed
  ) where

import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as BS8
import Data.List (intercalate)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Generics
import Valiant.FromRow (FromRow (..))
import Valiant.Statement (Statement (..))
import Valiant.ToParams (EncodeField (..))
import PgWire.Protocol.Oid (Oid (..))

-- | A statement with named parameters. This is just a type alias —
-- at the wire level it's the same as a positional 'Statement'.
type NamedStatement p r = Statement p r

-- | Encode a record's fields as named parameters.
--
-- The default 'Generic' implementation extracts field names and values,
-- producing an association list of @(fieldName, encodedValue)@.
--
-- Derive with:
--
-- @
-- data MyParams = MyParams { userId :: Int32, role :: Text }
--   deriving stock (Generic)
--   deriving anyclass (ToNamedParams)
-- @
class ToNamedParams a where
  toNamedParamList :: a -> [(Text, Maybe ByteString)]

  default toNamedParamList :: (Generic a, GToNamedParams (Rep a)) => a -> [(Text, Maybe ByteString)]
  toNamedParamList = gToNamedParams . from
  {-# INLINE toNamedParamList #-}

-- | Generic implementation of 'ToNamedParams'.
class GToNamedParams f where
  gToNamedParams :: f p -> [(Text, Maybe ByteString)]

-- Datatype metadata: pass through
instance (GToNamedParams f) => GToNamedParams (D1 c f) where
  gToNamedParams (M1 x) = gToNamedParams x
  {-# INLINE gToNamedParams #-}

-- Constructor metadata: pass through
instance (GToNamedParams f) => GToNamedParams (C1 c f) where
  gToNamedParams (M1 x) = gToNamedParams x
  {-# INLINE gToNamedParams #-}

-- Product: concatenate fields
instance (GToNamedParams f, GToNamedParams g) => GToNamedParams (f :*: g) where
  gToNamedParams (f :*: g) = gToNamedParams f ++ gToNamedParams g
  {-# INLINE gToNamedParams #-}

-- Selector (record field): extract name and encode value
instance (Selector s, EncodeField a) => GToNamedParams (S1 s (Rec0 a)) where
  gToNamedParams m@(M1 (K1 x)) =
    let name = T.pack (selName m)
     in [(name, encodeField x)]
  {-# INLINE gToNamedParams #-}

-- Unit: no fields
instance GToNamedParams U1 where
  gToNamedParams U1 = []
  {-# INLINE gToNamedParams #-}

-- | Construct a 'Statement' that accepts named parameters via a record type.
--
-- The @paramNames@ list defines the mapping: @paramNames !! 0@ is the
-- name for @$1@, @paramNames !! 1@ for @$2@, etc. At runtime,
-- 'ToNamedParams' extracts the record's fields by name and reorders
-- them to match the positional indices.
--
-- If a record field name doesn't match any expected parameter name,
-- or an expected parameter has no matching field, a runtime error
-- is thrown with a clear message.
mkStatementNamed
  :: (ToNamedParams p, FromRow r)
  => String
  -- ^ SQL text (with positional $1, $2, ... — already preprocessed)
  -> [Int]
  -- ^ Parameter OIDs
  -> [String]
  -- ^ Result column names
  -> [String]
  -- ^ Parameter names in positional order ($1=first, $2=second, ...)
  -> String
  -- ^ Source .sql file path
  -> Statement p r
mkStatementNamed sqlStr oids colNames paramNames path =
  Statement
    { stmtSQL = BS8.pack sqlStr
    , stmtFile = path
    , stmtParamOids = V.fromList (map (Oid . fromIntegral) oids)
    , stmtEncode = encodeNamed (map T.pack paramNames)
    , stmtDecode = fromRow
    , stmtColumns = V.fromList (map BS8.pack colNames)
    }

-- | Build the encoder that reorders named params to positional order.
-- Validates at first use that all SQL param names have matching record
-- fields and vice versa, with clear error messages on mismatch.
encodeNamed :: (ToNamedParams p) => [Text] -> p -> Vector (Maybe ByteString)
encodeNamed paramNames p =
  let pairs = toNamedParamList p
      pairMap = Map.fromList pairs
      expectedNames = Set.fromList paramNames
      actualNames = Set.fromList (map fst pairs)
      missing = Set.toList (Set.difference expectedNames actualNames)
      extra = Set.toList (Set.difference actualNames expectedNames)
      n = length paramNames
   in case (missing, extra) of
        ([], []) ->
          V.fromListN n (map (\name -> Map.findWithDefault Nothing name pairMap) paramNames)
        _ ->
          error $ unlines $ concatMap (filter (not . null))
            [ [ "valiant: named parameter mismatch"
              , ""
              , "  SQL parameters:   " <> showNames paramNames
              , "  Record fields:    " <> showNames (map fst pairs)
              ]
            , if null missing then []
              else [ ""
                   , "  Missing fields (SQL expects these but the record doesn't have them):"
                   , "    " <> intercalate ", " (map (\n' -> ":" <> T.unpack n') missing)
                   ]
            , if null extra then []
              else [ ""
                   , "  Extra fields (record has these but the SQL doesn't use them):"
                   , "    " <> intercalate ", " (map T.unpack extra)
                   , ""
                   , "  Hint: remove unused fields from the record, or add"
                   , "  corresponding :name parameters to the SQL query."
                   ]
            ]
  where
    showNames ns = intercalate ", " (map T.unpack ns)
{-# INLINE encodeNamed #-}