packages feed

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

-- |
-- Module      : Hasql.Check.Compile
-- Copyright   : (c) 2026 Kronor.io
--
-- License     : BSD-3-Clause
-- Maintainer  : lorenzo@kronor.io
-- Stability   : experimental
-- Portability : GHC
--
-- Checking statements while GHC compiles them, from "Hasql.Check.TH"'s
-- quoters or from a compiler plugin: the configuration, read from the
-- environment, one connection for the whole compilation, and warnings that
-- GHC's output and an editor pick up.
--
-- * @HASQL_CHECK_DATABASE_URL@: a libpq connection string or URI. Unset,
--   nothing is checked.
-- * @HASQL_CHECK_SCHEMA_FILE@: a file that changes whenever the schema does,
--   such as a migration plan.
-- * @HASQL_CHECK_NULLABILITY@, @HASQL_CHECK_TYPES@: when set, the warnings of
--   @hasql-check --nullability@ and @--types@ too.
-- * @HASQL_CHECK_BASELINE@: a baseline file, as @--write-baseline@ writes it.
module Hasql.Check.Compile (
    -- * Configuration
    Config (..),
    readConfig,

    -- * The connection
    Session (..),
    withSession,
    reportUnavailable,

    -- * Warnings
    warnings,
    warning,
) where

import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)
import Control.Exception (SomeException, try)
import Control.Monad (unless)
import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as ByteString.Char8
import Data.Either (fromRight)
import Data.IORef (IORef, atomicModifyIORef', newIORef)
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Hasql.Check.Finding (Baseline, Finding, Rule, directivesAbove, readBaseline, settle)
import Hasql.Check.Nullability qualified as Nullability
import Pqi qualified
import Pqi.Ffi qualified
import System.Environment (lookupEnv)
import System.IO (hPutStrLn, stderr)
import System.IO.Unsafe (unsafePerformIO)
import Prelude


-- * Configuration


data Config = Config
    { databaseUrl :: Maybe String
    , schemaFile :: Maybe FilePath
    , nullability :: Bool
    , types :: Bool
    , baseline :: Maybe FilePath
    }


readConfig :: IO Config
readConfig =
    Config
        <$> nonEmpty "HASQL_CHECK_DATABASE_URL"
        <*> nonEmpty "HASQL_CHECK_SCHEMA_FILE"
        <*> (isJust <$> nonEmpty "HASQL_CHECK_NULLABILITY")
        <*> (isJust <$> nonEmpty "HASQL_CHECK_TYPES")
        <*> nonEmpty "HASQL_CHECK_BASELINE"
  where
    nonEmpty name =
        lookupEnv name >>= \case
            Just "" -> pure Nothing
            other -> pure other


-- * The connection


data Session = Session
    { url :: String
    -- ^ What the connection was made to. A long GHCi session can outlive a
    -- change of HASQL_CHECK_DATABASE_URL, and must follow it.
    , connection :: Pqi.Connection
    , catalogue :: Maybe (ByteString.ByteString, Nullability.Catalogue)
    -- ^ Loaded for nullability and types, keyed by the schema file's contents.
    }


-- | One connection for every statement the compiler checks, created on first
-- use. The lock also keeps the statements of one check together, which
-- nullability needs: it prepares, explains and deallocates a named
-- statement.

{- HLINT ignore session "Avoid restricted function" -}
-- A process-wide variable is the point: it outlives every splice.
session :: MVar (Maybe Session)
session = unsafePerformIO (newMVar Nothing)
{-# NOINLINE session #-}


-- | Run a check on the connection to the given database, with the catalogue
-- if asked for; or why the database cannot be reached. A connection that
-- breaks during a check - a restarted database under a long GHCi session -
-- is replaced, and the check run once more on the new one.
withSession :: Config -> String -> Bool -> (Session -> IO a) -> IO (Either String a)
withSession config url withCatalogue action = modifyMVar session \current -> do
    fingerprint <-
        if withCatalogue
            then maybe (pure ByteString.empty) readFileOrEmpty config.schemaFile
            else pure ByteString.empty
    let attempt existing =
            open fingerprint existing >>= \case
                Left why -> pure (Nothing, Left why)
                Right s -> do
                    result <- action s
                    status <- s.connection.status
                    pure (if status == Pqi.ConnectionOk then Just s else Nothing, Right result)
    attempt current >>= \case
        (Nothing, Right _) | Just _ <- current -> attempt Nothing
        result -> pure result
  where
    open fingerprint = \case
        Just s
            | s.url /= url -> do
                s.connection.finish
                open fingerprint Nothing
            | not withCatalogue || fmap fst s.catalogue == Just fingerprint -> pure (Right s)
            | otherwise -> loadCatalogue fingerprint s.connection
        Nothing -> do
            conn <- Pqi.Ffi.adapter.connectdb (ByteString.Char8.pack url)
            status <- conn.status
            if status /= Pqi.ConnectionOk
                then Left . maybe "cannot connect" ByteString.Char8.unpack <$> conn.errorMessage
                else
                    if withCatalogue
                        then loadCatalogue fingerprint conn
                        else pure (Right (Session url conn Nothing))

    loadCatalogue fingerprint conn =
        Nullability.prepareSession conn >>= \case
            Left err -> pure (Left err)
            Right cat -> pure (Right (Session url conn (Just (fingerprint, cat))))

    readFileOrEmpty path = fromRight ByteString.empty <$> (try (ByteString.readFile path) :: IO (Either SomeException ByteString.ByteString))


-- | Whether the database being unavailable has already been reported.

{- HLINT ignore reportedUnavailable "Avoid restricted function" -}
reportedUnavailable :: IORef Bool
reportedUnavailable = unsafePerformIO (newIORef False)
{-# NOINLINE reportedUnavailable #-}


-- | Say, once a compilation, that the database cannot be reached: not about
-- any one statement, so with no location.
reportUnavailable :: String -> IO ()
reportUnavailable why = do
    reported <- atomicModifyIORef' reportedUnavailable (True,)
    unless reported (hPutStrLn stderr ("hasql-check: not checking statements: " <> why))


-- * Warnings


-- | The warnings to report for a statement at a line of a file, after its
-- directives and the baseline, if any.
warnings :: Config -> [Rule] -> FilePath -> Int -> Text -> [Finding] -> IO [String]
warnings config rules file line fingerprint found
    | null rules = pure []
    | otherwise = do
        fileLines <- readLines file
        known <- traverse readBaselineFile config.baseline
        let (_, messages) = settle rules (directivesAbove fileLines line) (either (const Nothing) Just =<< known) fingerprint found
        pure (maybe [] (\err -> ["cannot read HASQL_CHECK_BASELINE: " <> err]) (known >>= either Just (const Nothing)) <> messages)


-- | A warning in GHC's format, so that tools reading GHC's output pick it up,
-- but written to stderr rather than reported to GHC: under -Werror GHC would
-- fail the build on it, and these are advice.
warning :: FilePath -> (Int, Int) -> String -> IO ()
warning file (line, column) message =
    hPutStrLn stderr (file <> ":" <> show line <> ":" <> show column <> ": warning: hasql-check: " <> message)


-- | A file's lines, or none if it cannot be read.
readLines :: FilePath -> IO [Text]
readLines path = either (const []) (Text.lines . Text.decodeUtf8Lenient) <$> (try (ByteString.readFile path) :: IO (Either SomeException ByteString.ByteString))


readBaselineFile :: FilePath -> IO (Either String Baseline)
readBaselineFile path =
    (try (ByteString.readFile path) :: IO (Either SomeException ByteString.ByteString)) >>= \case
        Left e -> pure (Left (show e))
        Right bytes -> pure (readBaseline (Text.decodeUtf8Lenient bytes))