packages feed

valiant-0.1.0.1: src/Valiant/Error.hs

-- | Structured error inspection and constraint violation helpers.
--
-- PostgreSQL errors carry a SQLSTATE code (5 ASCII characters) that
-- categorifies the error. This module provides helpers for extracting
-- the SQLSTATE, identifying constraint violations, and catching
-- specific error classes.
--
-- See <https://www.postgresql.org/docs/current/errcodes-appendix.html>
-- for the full list of SQLSTATE codes.
--
-- @
-- result <- try (execute conn insertStmt params)
-- case result of
--   Left err
--     | Just (UniqueViolation name) <- constraintViolation err ->
--         handleDuplicate name
--     | isSerializationError err ->
--         retry
--   Right n -> pure n
-- @
module Valiant.Error
  ( -- * SQLSTATE access
    sqlState
  , pgErrorOf

    -- * Constraint violations
  , ConstraintViolation (..)
  , constraintViolation
  , catchConstraintViolation

    -- * Error predicates
  , isUniqueViolation
  , isForeignKeyViolation
  , isNotNullViolation
  , isCheckViolation
  , isExclusionViolation
  , isSerializationError
  , isDeadlockError
  , isNoActiveTransactionError
  , isFailedTransactionError
  , isInvalidTextRepresentation
  , isUndefinedTable
  , isUndefinedColumn
  , isSyntaxError

    -- * Connection fatality
  , isFatal
  ) where

import Control.Exception (catch, throwIO)
import Data.Maybe (fromMaybe)
import Data.ByteString (ByteString)
import PgWire.Error (PgWireError (..), isFatal)
import PgWire.Protocol.Backend (PgError (..))

-- | Extract the SQLSTATE code from a 'PgWireError', if it wraps a
-- server-side 'QueryError'.
--
-- Returns 'Nothing' for non-query errors (connection errors, decode
-- errors, pool errors, etc.).
sqlState :: PgWireError -> Maybe ByteString
sqlState (QueryError err) = Just (pgCode err)
sqlState _ = Nothing

-- | Extract a field from the underlying 'PgError', if present.
--
-- @
-- mDetail <- pgErrorOf err pgDetail
-- mTable  <- pgErrorOf err pgTable
-- @
pgErrorOf :: PgWireError -> (PgError -> a) -> Maybe a
pgErrorOf (QueryError err) f = Just (f err)
pgErrorOf _ _ = Nothing

------------------------------------------------------------------------
-- Constraint violations
------------------------------------------------------------------------

-- | A structured constraint violation, extracted from the SQLSTATE code
-- and constraint name of a PostgreSQL error.
--
-- The 'ByteString' payload is the constraint name (from the @C@ error
-- field), which may be empty if PostgreSQL did not report it.
data ConstraintViolation
  = NotNullViolation !ByteString
  -- ^ @23502@ — a NOT NULL constraint was violated.
  | ForeignKeyViolation !ByteString
  -- ^ @23503@ — a foreign key constraint was violated.
  | UniqueViolation !ByteString
  -- ^ @23505@ — a unique constraint or unique index was violated.
  | CheckViolation !ByteString
  -- ^ @23514@ — a CHECK constraint was violated.
  | ExclusionViolation !ByteString
  -- ^ @23P01@ — an exclusion constraint was violated.
  deriving stock (Show, Eq, Ord)

-- | Extract a 'ConstraintViolation' from a 'PgWireError', if the error
-- is a constraint violation (SQLSTATE class 23).
--
-- Returns 'Nothing' for non-query errors or query errors that are not
-- constraint violations.
constraintViolation :: PgWireError -> Maybe ConstraintViolation
constraintViolation (QueryError err) =
  let code = pgCode err
      name = fromMaybe "" (pgConstraint err)
   in case code of
        "23502" -> Just (NotNullViolation name)
        "23503" -> Just (ForeignKeyViolation name)
        "23505" -> Just (UniqueViolation name)
        "23514" -> Just (CheckViolation name)
        "23P01" -> Just (ExclusionViolation name)
        _ -> Nothing
constraintViolation _ = Nothing

-- | Catch a constraint violation and handle it with a callback.
-- Non-constraint errors are re-thrown.
--
-- @
-- catchConstraintViolation
--   (\\err cv -> case cv of
--       UniqueViolation _ -> pure fallbackValue
--       _ -> throwIO err)
--   (execute conn insertStmt params)
-- @
catchConstraintViolation
  :: (PgWireError -> ConstraintViolation -> IO a)
  -> IO a
  -> IO a
catchConstraintViolation handler action =
  action `catch` \err ->
    case constraintViolation err of
      Just cv -> handler err cv
      Nothing -> throwIO err

------------------------------------------------------------------------
-- Error predicates
------------------------------------------------------------------------

-- | @23505@ — unique constraint or unique index violation.
isUniqueViolation :: PgWireError -> Bool
isUniqueViolation = hasState "23505"

-- | @23503@ — foreign key constraint violation.
isForeignKeyViolation :: PgWireError -> Bool
isForeignKeyViolation = hasState "23503"

-- | @23502@ — NOT NULL constraint violation.
isNotNullViolation :: PgWireError -> Bool
isNotNullViolation = hasState "23502"

-- | @23514@ — CHECK constraint violation.
isCheckViolation :: PgWireError -> Bool
isCheckViolation = hasState "23514"

-- | @23P01@ — exclusion constraint violation.
isExclusionViolation :: PgWireError -> Bool
isExclusionViolation = hasState "23P01"

-- | @40001@ — serialization failure. Retry the transaction.
isSerializationError :: PgWireError -> Bool
isSerializationError = hasState "40001"

-- | @40P01@ — deadlock detected. Retry the transaction.
isDeadlockError :: PgWireError -> Bool
isDeadlockError = hasState "40P01"

-- | @25P01@ — no active transaction (e.g., COMMIT outside a transaction).
isNoActiveTransactionError :: PgWireError -> Bool
isNoActiveTransactionError = hasState "25P01"

-- | @25P02@ — current transaction is aborted, commands ignored until
-- end of transaction block.
isFailedTransactionError :: PgWireError -> Bool
isFailedTransactionError = hasState "25P02"

-- | @22P02@ — invalid input syntax for type (e.g., passing @\"abc\"@ for an integer).
isInvalidTextRepresentation :: PgWireError -> Bool
isInvalidTextRepresentation = hasState "22P02"

-- | @42P01@ — undefined table.
isUndefinedTable :: PgWireError -> Bool
isUndefinedTable = hasState "42P01"

-- | @42703@ — undefined column.
isUndefinedColumn :: PgWireError -> Bool
isUndefinedColumn = hasState "42703"

-- | @42601@ — syntax error in SQL.
isSyntaxError :: PgWireError -> Bool
isSyntaxError = hasState "42601"

-- Internal: check if an error has a specific SQLSTATE code.
hasState :: ByteString -> PgWireError -> Bool
hasState expected err = sqlState err == Just expected