packages feed

valiant-0.1.0.0: test/Valiant/NamedParamsSpec.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}

module Valiant.NamedParamsSpec (spec) where

import Control.Exception (ErrorCall (..), evaluate, try)
import Data.Int (Int32)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Vector qualified as V
import GHC.Generics (Generic)
import Valiant.NamedParams
import Valiant.Statement (Statement (..))
import PgWire.Binary.Types (PgEncode (..))
import Test.Hspec

-- Test record types
data SimpleParams = SimpleParams
  { userId :: Int32
  }
  deriving stock (Generic)
  deriving anyclass (ToNamedParams)

data MultiParams = MultiParams
  { orgId :: Int32
  , role :: Text
  , active :: Bool
  }
  deriving stock (Generic)
  deriving anyclass (ToNamedParams)

data NullableParams = NullableParams
  { name :: Text
  , email :: Maybe Text
  }
  deriving stock (Generic)
  deriving anyclass (ToNamedParams)

-- Record with fields that don't match any SQL param
data ExtraFieldParams = ExtraFieldParams
  { knownField :: Int32
  , unknownField :: Text
  }
  deriving stock (Generic)
  deriving anyclass (ToNamedParams)

spec :: Spec
spec = do
  describe "ToNamedParams (Generic)" $ do
    it "encodes a single-field record" $ do
      let pairs = toNamedParamList (SimpleParams 42)
      length pairs `shouldBe` 1
      case pairs of
        [(n, val)] -> do
          n `shouldBe` "userId"
          val `shouldBe` Just (pgEncode (42 :: Int32))
        _ -> expectationFailure "expected exactly one pair"

    it "encodes a multi-field record with correct names" $ do
      let pairs = toNamedParamList (MultiParams 1 "admin" True)
          names = map fst pairs
      names `shouldBe` ["orgId", "role", "active"]

    it "encodes a multi-field record with correct values" $ do
      let pairs = toNamedParamList (MultiParams 1 "admin" True)
      snd (pairs !! 0) `shouldBe` Just (pgEncode (1 :: Int32))
      snd (pairs !! 1) `shouldBe` Just (TE.encodeUtf8 "admin")
      snd (pairs !! 2) `shouldBe` Just (pgEncode True)

    it "encodes Nothing as NULL" $ do
      let pairs = toNamedParamList (NullableParams "Alice" Nothing)
      snd (pairs !! 0) `shouldBe` Just (TE.encodeUtf8 "Alice")
      snd (pairs !! 1) `shouldBe` Nothing

    it "encodes Just value correctly" $ do
      let pairs = toNamedParamList (NullableParams "Alice" (Just "alice@example.com"))
      snd (pairs !! 1) `shouldBe` Just (TE.encodeUtf8 "alice@example.com")

  describe "mkStatementNamed" $ do
    it "reorders fields to match positional parameter order" $ do
      let stmt = mkStatementNamed @MultiParams @()
            "SELECT 1 WHERE org_id = $1 AND active = $2 AND role = $3"
            [23, 16, 25]
            []
            ["orgId", "active", "role"]
            "test.sql"
          -- orgId=$1, active=$2, role=$3
          encoded = stmtEncode stmt (MultiParams 42 "admin" True)
      -- $1 = orgId = 42
      (V.!) encoded 0 `shouldBe` Just (pgEncode (42 :: Int32))
      -- $2 = active = True
      (V.!) encoded 1 `shouldBe` Just (pgEncode True)
      -- $3 = role = "admin"
      (V.!) encoded 2 `shouldBe` Just (TE.encodeUtf8 "admin")

    it "handles duplicate param names (record field used once)" $ do
      let stmt = mkStatementNamed @SimpleParams @()
            "SELECT 1 WHERE a = $1 OR b = $1"
            [23]
            []
            ["userId"]
            "test.sql"
          encoded = stmtEncode stmt (SimpleParams 99)
      V.length encoded `shouldBe` 1
      (V.!) encoded 0 `shouldBe` Just (pgEncode (99 :: Int32))

    it "preserves original field order in same-order case" $ do
      let stmt = mkStatementNamed @MultiParams @()
            "SELECT 1"
            [23, 25, 16]
            []
            ["orgId", "role", "active"]
            "test.sql"
          encoded = stmtEncode stmt (MultiParams 7 "viewer" False)
      (V.!) encoded 0 `shouldBe` Just (pgEncode (7 :: Int32))
      (V.!) encoded 1 `shouldBe` Just (TE.encodeUtf8 "viewer")
      (V.!) encoded 2 `shouldBe` Just (pgEncode False)

    it "handles nullable params in named mode" $ do
      let stmt = mkStatementNamed @NullableParams @()
            "INSERT INTO t (name, email) VALUES ($1, $2)"
            [25, 25]
            []
            ["name", "email"]
            "test.sql"
          encoded = stmtEncode stmt (NullableParams "Bob" Nothing)
      (V.!) encoded 0 `shouldBe` Just (TE.encodeUtf8 "Bob")
      (V.!) encoded 1 `shouldBe` Nothing

  describe "error messages" $ do
    it "reports missing record fields with helpful message" $ do
      let stmt = mkStatementNamed @SimpleParams @()
            "SELECT 1 WHERE org = $1 AND role = $2"
            [23, 25]
            []
            ["orgId", "role"]  -- record only has userId, not orgId or role
            "test.sql"
      result <- try @ErrorCall $ evaluate (stmtEncode stmt (SimpleParams 1))
      case result of
        Left (ErrorCall msg) -> do
          msg `shouldContain` "named parameter mismatch"
          msg `shouldContain` "Missing fields"
          msg `shouldContain` ":orgId"
          msg `shouldContain` ":role"
          msg `shouldContain` "userId"  -- listed as available
        Right _ -> expectationFailure "expected error for missing fields"

    it "reports extra record fields with helpful message" $ do
      let stmt = mkStatementNamed @ExtraFieldParams @()
            "SELECT 1 WHERE id = $1"
            [23]
            []
            ["knownField"]  -- SQL only uses knownField, not unknownField
            "test.sql"
      result <- try @ErrorCall $ evaluate (stmtEncode stmt (ExtraFieldParams 1 "extra"))
      case result of
        Left (ErrorCall msg) -> do
          msg `shouldContain` "named parameter mismatch"
          msg `shouldContain` "Extra fields"
          msg `shouldContain` "unknownField"
          msg `shouldContain` "Hint"
        Right _ -> expectationFailure "expected error for extra fields"

    it "reports both missing and extra fields together" $ do
      let stmt = mkStatementNamed @SimpleParams @()
            "SELECT 1 WHERE name = $1"
            [25]
            []
            ["name"]  -- SQL expects "name" but record has "userId"
            "test.sql"
      result <- try @ErrorCall $ evaluate (stmtEncode stmt (SimpleParams 1))
      case result of
        Left (ErrorCall msg) -> do
          msg `shouldContain` "Missing fields"
          msg `shouldContain` ":name"
          msg `shouldContain` "Extra fields"
          msg `shouldContain` "userId"
        Right _ -> expectationFailure "expected error for mismatched fields"

-- Use hspec's shouldContain (works on [Char] = String)