packages feed

hasql-check-0.5.0.0: src/Hasql/Check/TH.hs

-- |
-- Module      : Hasql.Check.TH
-- Copyright   : (c) 2026 Kronor.io
--
-- License     : BSD-3-Clause
-- Maintainer  : lorenzo@kronor.io
-- Stability   : experimental
-- Portability : GHC
--
-- hasql-th's quasiquoters, checking their statements against a live database
-- while they compile. Import this module instead of "Hasql.TH".
--
-- Nothing is checked unless @HASQL_CHECK_DATABASE_URL@ is set, so builds
-- without a database - CI, nix - behave exactly as with "Hasql.TH". When it
-- is set, a statement that does not @PREPARE@ against that database is a
-- compile error at its quote.
--
-- * @HASQL_CHECK_DATABASE_URL@: a libpq connection string or URI.
-- * @HASQL_CHECK_SCHEMA_FILE@: a file that changes whenever the schema does,
--   such as a migration plan. Every module with a checked quote depends on
--   it, so a schema change rechecks them. Cabal only notices it if it is also
--   listed in the package's @extra-source-files@; GHCi notices it regardless.
-- * @HASQL_CHECK_NULLABILITY@: when set, the warnings of @hasql-check
--   --nullability@ become compiler warnings.
-- * @HASQL_CHECK_TYPES@: when set, so do the warnings of @hasql-check
--   --types@.
--
-- GHC runs a quote only when it compiles the module, so a module compiled
-- before the database was set, or before the schema changed, is not checked
-- until it recompiles. Run the @hasql-check@ executable in CI for a check
-- of everything.
module Hasql.Check.TH (
    -- * Statements
    singletonStatement,
    maybeStatement,
    vectorStatement,
    foldStatement,
    resultlessStatement,
    rowsAffectedStatement,
    uncheckedSql,
    uncheckedSqlFile,

    -- * Checking any quoter
    checked,
) where

import Control.Monad (forM_)
import Data.Text qualified as Text
import Hasql.Check (parseStmt, prepareStmt, renderStmt)
import Hasql.Check.Compile (Config (..), Session (..), readConfig, reportUnavailable, warning, warnings, withSession)
import Hasql.Check.Finding (Rule (..), statementHash)
import Hasql.Check.Nullability qualified as Nullability
import Hasql.Check.Types qualified as Types
import Hasql.TH (uncheckedSql, uncheckedSqlFile)
import Hasql.TH qualified as TH
import Language.Haskell.TH (Loc (..), Q, location, runIO)
import Language.Haskell.TH.Quote (QuasiQuoter (..))
import Language.Haskell.TH.Syntax (addDependentFile)
import Prelude


singletonStatement :: QuasiQuoter
singletonStatement = withRows TH.singletonStatement


maybeStatement :: QuasiQuoter
maybeStatement = withRows TH.maybeStatement


vectorStatement :: QuasiQuoter
vectorStatement = withRows TH.vectorStatement


foldStatement :: QuasiQuoter
foldStatement = withRows TH.foldStatement


resultlessStatement :: QuasiQuoter
resultlessStatement = checked TH.resultlessStatement


rowsAffectedStatement :: QuasiQuoter
rowsAffectedStatement = checked TH.rowsAffectedStatement


-- | Check a quoter's statements, then run it. For quoters built on hasql-th's
-- statement syntax.
checked :: QuasiQuoter -> QuasiQuoter
checked = wrap False


-- | 'checked', also checking nullability when asked to.
withRows :: QuasiQuoter -> QuasiQuoter
withRows = wrap True


wrap :: Bool -> QuasiQuoter -> QuasiQuoter
wrap rows quoter = quoter{quoteExp = \sql -> check rows sql *> quoteExp quoter sql}


-- * Checking


check :: Bool -> String -> Q ()
check rows sql = do
    config <- runIO readConfig
    forM_ config.schemaFile addDependentFile
    forM_ config.databaseUrl \url -> case parseStmt (Text.pack sql) of
        -- hasql-th reports the parse error itself.
        Left _ -> pure ()
        Right stmt -> do
            let rules = [Nullable | rows && config.nullability] <> [Narrowing | config.types]
            outcome <- runIO $ withSession config url (not (null rules)) \s ->
                prepareStmt s.connection "" stmt >>= \case
                    Left err -> pure (Left err)
                    Right () -> case s.catalogue of
                        Just (_, cat) | not (null rules) -> do
                            nullable <- if Nullable `elem` rules then Nullability.check s.connection cat stmt else pure []
                            narrowing <- if Narrowing `elem` rules then Types.check s.connection cat stmt else pure []
                            pure (Right (nullable <> narrowing))
                        _ -> pure (Right [])
            case outcome of
                Left why -> runIO (reportUnavailable why)
                Right (Left err) -> fail ("hasql-check: the statement does not prepare against HASQL_CHECK_DATABASE_URL:\n" <> err)
                -- Without warnings asked for, no location is needed: there is
                -- none outside GHC, under runQ.
                Right (Right _) | null rules -> pure ()
                Right (Right found) -> do
                    loc <- location
                    messages <- runIO (warnings config rules loc.loc_filename (fst loc.loc_start) (statementHash (renderStmt stmt)) found)
                    forM_ messages (runIO . warning loc.loc_filename loc.loc_start)