packages feed

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

{-# LANGUAGE TemplateHaskell #-}

-- |
-- Module      : Hasql.Check.Inferred.TH
-- Copyright   : (c) 2026 Kronor.io
--
-- License     : BSD-3-Clause
-- Maintainer  : lorenzo@kronor.io
-- Stability   : experimental
-- Portability : GHC
--
-- Quoters for SQL without annotations: the Haskell types a statement is used
-- with decide its parameter and result types.
--
-- > profile :: Statement Int64 (Maybe (Text, Maybe Text))
-- > profile = [maybeStatement| select email, nickname from customer where id = $1 |]
--
-- sends
--
-- > WITH hasql_check_result (c1, c2) AS (SELECT email, nickname FROM customer WHERE id = $1 :: int8)
-- > SELECT c1 :: text, c2 :: text FROM hasql_check_result
--
-- GHC can only tell whether those types fit the schema with a database; see
-- the README for checking them with @hasql-check --inferred@.
module Hasql.Check.Inferred.TH (
    singletonStatement,
    maybeStatement,
    vectorStatement,
    resultlessStatement,
    template,
) where

import Data.Char (isAlphaNum)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Hasql.Check (parsePlainStmt, renderStmt)
import Hasql.Check.Annotate (annotate, placeholder)
import Hasql.Check.Inferred (Piece (..), Template (..), Wrapped (..), resultless, statement)
import Hasql.Decoders qualified as D
import Language.Haskell.TH (Exp, Q)
import Language.Haskell.TH.Quote (QuasiQuoter (..))
import Language.Haskell.TH.Syntax (lift)
import PostgresqlSyntax qualified as Ast
import Text.Read (readMaybe)
import Prelude


quoter :: (Q Exp -> Q Exp) -> QuasiQuoter
quoter make =
    QuasiQuoter
        { quoteExp = either fail (make . lift) . template . Text.pack
        , quotePat = const (fail "Not supported in patterns")
        , quoteType = const (fail "Not supported in types")
        , quoteDec = const (fail "Not supported in declarations")
        }


singletonStatement, maybeStatement, vectorStatement, resultlessStatement :: QuasiQuoter
singletonStatement = quoter \t -> [|statement $t D.singleRow|]
maybeStatement = quoter \t -> [|statement $t D.rowMaybe|]
vectorStatement = quoter \t -> [|statement $t D.rowVector|]
resultlessStatement = quoter \t -> [|resultless $t|]


-- | The statement, rendered as hasql-th would render it, cut where its
-- parameters' annotations go, and split where its result columns' cast
-- goes around it.
template :: Text -> Either String Template
template sql = do
    stmt <- parsePlainStmt False sql
    -- Names the statement does not already contain, in a string literal or
    -- as a table, say.
    let plain = Text.toLower (Text.decodeUtf8Lenient (renderStmt stmt))
        unused base = until (not . (`Text.isInfixOf` plain)) (<> "_") base
        prefix = unused "hasql_check_slot_"
        annotated = annotate (\n -> Just (placeholder (prefix <> Text.pack (show n)))) stmt
        cut = cutWith prefix
    pure
        Template
            { whole = cut (Text.decodeUtf8Lenient (renderStmt annotated))
            , wrapped =
                ( \(with, body) ->
                    Wrapped
                        { with = cut . Ast.toText mempty <$> with
                        , body = cut (Text.decodeUtf8Lenient (renderStmt body))
                        , name = unused "hasql_check_result"
                        }
                )
                    <$> splitWith annotated
            }


-- | The statement's own WITH list, and the rest of it.
splitWith :: Ast.PreparableStmt -> Maybe (Maybe Ast.WithClause, Ast.PreparableStmt)
splitWith = \case
    -- "(WITH ... SELECT ...)" is a parenthesised select clause.
    Ast.SelectPreparableStmt (Ast.NoParensSelectStmt (Ast.SelectNoParens Nothing (Ast.WithParensSelectClause p) s l f)) ->
        let (w, p') = inParens p
         in Just (w, Ast.SelectPreparableStmt (Ast.NoParensSelectStmt (Ast.SelectNoParens Nothing (Ast.WithParensSelectClause p') s l f)))
    Ast.SelectPreparableStmt (Ast.NoParensSelectStmt (Ast.SelectNoParens w c s l f)) ->
        Just (w, Ast.SelectPreparableStmt (Ast.NoParensSelectStmt (Ast.SelectNoParens Nothing c s l f)))
    Ast.SelectPreparableStmt (Ast.WithParensSelectStmt s) -> Just (Ast.SelectPreparableStmt . Ast.WithParensSelectStmt <$> inParens s)
    Ast.InsertPreparableStmt (Ast.InsertStmt w a b c d) -> Just (w, Ast.InsertPreparableStmt (Ast.InsertStmt Nothing a b c d))
    Ast.UpdatePreparableStmt (Ast.UpdateStmt w a b c d e) -> Just (w, Ast.UpdatePreparableStmt (Ast.UpdateStmt Nothing a b c d e))
    Ast.DeletePreparableStmt (Ast.DeleteStmt w a b c d) -> Just (w, Ast.DeletePreparableStmt (Ast.DeleteStmt Nothing a b c d))
    Ast.CallPreparableStmt _ -> Nothing


-- | A parenthesised SELECT's WITH list, and the rest of it: the list can go
-- outside the parentheses, and must, if it has a data-modifying statement.
inParens :: Ast.SelectWithParens -> (Maybe Ast.WithClause, Ast.SelectWithParens)
inParens = \case
    Ast.NoParensSelectWithParens (Ast.SelectNoParens w c s l f) -> (w, Ast.NoParensSelectWithParens (Ast.SelectNoParens Nothing c s l f))
    Ast.WithParensSelectWithParens s -> Ast.WithParensSelectWithParens <$> inParens s


cutWith :: Text -> Text -> [Piece]
cutWith prefix = cut
  where
    cut t = case Text.breakOn (" :: " <> prefix) t of
        (before, "") -> [Sql before | not (Text.null before)]
        (before, rest) ->
            let (slot, after) = Text.span isAlphaNum (Text.drop (Text.length (" :: " <> prefix)) rest)
             in [Sql before | not (Text.null before)] <> piece slot <> cut after

    piece slot = case readMaybe (Text.unpack slot) of
        Just i -> [ParamType i]
        Nothing -> [Sql (" :: " <> prefix <> slot)]