packages feed

valiant-cli-0.1.0.0: src/Valiant/CLI/Command/Prepare.hs

module Valiant.CLI.Command.Prepare
  ( runPrepare
  ) where

import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Word (Word32)
import PgWire.Connection (Connection)
import PgWire.Protocol.Oid (Oid (..))
import Valiant.CLI.Cache
import Valiant.CLI.Config (AppEnv (..))
import Valiant.CLI.Describe (ColumnMeta (..), DescribeError (..), ParamMeta (..), QueryMeta (..), describeQuery, withPgConnection)
import Valiant.CLI.Discover (SqlFile (..), discoverSqlFiles, discoverInlineSql)
import System.FilePath.Glob qualified as Glob
import Valiant.CLI.Error (ValiantCliError (..), dieWithError)
import Valiant.CLI.Hash (sha256Hex)
import Valiant.CLI.Nullability (resolveNullability)
import Valiant.CLI.Output
import Valiant.CLI.CustomTypes (CustomTypeMap, customTypesFile, loadCustomTypes)
import Valiant.CLI.NamedParams (NamedParamMapping, preprocessNamedParams)
import Valiant.CLI.TypeMap (HaskellType (..), ResolvedType (..), oidToHaskellTypeWith, oidToTypeNameWith, resolveType, resolveUnknownOid)
import System.Exit (ExitCode (..))

runPrepare :: AppEnv -> [FilePath] -> IO ExitCode
runPrepare env inlineDirs = do
  dbUrl <- case appDatabaseUrl env of
    Nothing -> dieWithError ErrNoDatabaseUrl
    Just url -> pure url

  printHeader $ "Using database " <> maskPassword (TE.decodeUtf8 dbUrl)
  printHeader $ "Scanning " <> T.pack (appSqlDir env) <> " for .sql files..."

  sqlFiles <- discoverSqlFiles (appSqlDir env)

  -- Discover inline SQL from Haskell source if --inline dirs provided
  inlineFiles <- case inlineDirs of
    [] -> pure []
    dirs -> do
      hsPaths <- concat <$> mapM discoverHsFiles dirs
      inlines <- discoverInlineSql hsPaths
      case inlines of
        [] -> pure []
        _ -> do
          printLn $ "  Found " <> T.pack (show (length inlines)) <> " inline queries in .hs files"
          pure inlines

  let files = sqlFiles <> inlineFiles
  case files of
    [] -> dieWithError (ErrNoSqlFiles (appSqlDir env))
    _ -> printLn $ "  Found " <> T.pack (show (length files)) <> " queries total"

  printLn ""

  customs <- loadCustomTypes customTypesFile
  let total = length files

  withPgConnection dbUrl $ \conn -> do
    results <- mapM (processFile env customs conn total) (zip [1 ..] files)
    let okCount = length (filter id results)
    printSummary okCount total
    printLn $ "  Wrote " <> T.pack (show okCount) <> " cache files to " <> T.pack (appCacheDir env) <> "/"
    pure $
      if okCount == total
        then ExitSuccess
        else ExitFailure 1

-- | Discover all .hs files in a directory tree.
discoverHsFiles :: FilePath -> IO [FilePath]
discoverHsFiles dir = do
  let pat = Glob.compile "**/*.hs"
  matched <- Glob.globDir [pat] dir
  pure (concat matched)

processFile :: AppEnv -> CustomTypeMap -> Connection -> Int -> (Int, SqlFile) -> IO Bool
processFile env customs conn total (idx, sqlFile) = do
  printProgress idx total (sqlRelPath sqlFile)

  -- Check if cache is already current
  existing <- findCacheFile (appCacheDir env) (sqlRelPath sqlFile) (sqlHash sqlFile)
  case existing of
    Just _ -> do
      printOk
      pure True
    Nothing -> do
      -- Preprocess :name → $N before sending to Postgres
      let (rewrittenSql, nameMapping) = preprocessNamedParams (sqlContent sqlFile)
          prepFile = sqlFile {sqlContent = rewrittenSql}
      result <- describeQuery conn prepFile
      case result of
        Left err -> do
          printFailed (deMessage err)
          pure False
        Right meta -> do
          nullabilities <- resolveNullability conn (qmColumns meta)
          now <- getCurrentTime
          buildResult <- buildCacheEntry env customs conn sqlFile meta nullabilities nameMapping now
          case buildResult of
            Left errMsg -> do
              printFailed errMsg
              pure False
            Right entry -> do
              writeCacheEntry (appCacheDir env) entry
              printOk
              pure True

buildCacheEntry
  :: AppEnv
  -> CustomTypeMap
  -> Connection
  -> SqlFile
  -> QueryMeta
  -> [Bool]
  -> NamedParamMapping
  -> UTCTime
  -> IO (Either Text CacheEntry)
buildCacheEntry env customs conn sqlFile meta nullabilities nameMapping now = do
  let nameMap = Map.fromList [(idx, name) | (name, idx) <- nameMapping]
  eParams <- resolveParams customs conn nameMap (qmParams meta)
  case eParams of
    Left err -> pure (Left err)
    Right params -> do
      eColumns <- resolveColumns customs conn (zip (qmColumns meta) nullabilities)
      case eColumns of
        Left err -> pure (Left err)
        Right columns -> do
          let sqlText = TE.decodeUtf8 (sqlContent sqlFile)
          pure . Right $
            CacheEntry
              { ceVersion = "0.1.0"
              , ceFile = sqlRelPath sqlFile
              , ceSqlHash = sqlHash sqlFile
              , ceSql = sqlText
              , ceDbUrlHash = maybe "" sha256Hex (appDatabaseUrl env)
              , cePreparedAt = now
              , ceStatementType = statementTypeFromSql sqlText
              , ceParams = params
              , ceColumns = columns
              }

resolveParams :: CustomTypeMap -> Connection -> Map.Map Int Text -> [ParamMeta] -> IO (Either Text [CacheParam])
resolveParams customs conn nameMap = go []
  where
    go !acc [] = pure (Right (reverse acc))
    go !acc (pm : pms) = do
      result <- resolveParam customs conn nameMap pm
      case result of
        Left err -> pure (Left err)
        Right cp -> go (cp : acc) pms

resolveParam :: CustomTypeMap -> Connection -> Map.Map Int Text -> ParamMeta -> IO (Either Text CacheParam)
resolveParam customs conn nameMap ParamMeta {..} =
  let Oid oid = pmOid
      paramName = Map.lookup pmIndex nameMap
      paramLabel = case paramName of
        Just n -> ":" <> n
        Nothing -> "$" <> T.pack (show pmIndex)
   in case oidToHaskellTypeWith customs pmOid of
        Just ht ->
          pure . Right $
            CacheParam
              { cpIndex = pmIndex
              , cpName = paramName
              , cpPgOid = fromIntegral oid
              , cpPgTypeName = fromMaybe "unknown" (oidToTypeNameWith mempty pmOid)
              , cpHaskellType = htType ht
              , cpHaskellModule = htModule ht
              , cpPgTypeCategory = Nothing
              , cpPgEnumLabels = Nothing
              }
        Nothing -> do
          -- Auto-discover via pg_type
          resolved <- resolveUnknownOid conn customs pmOid
          case resolved of
            Left err ->
              pure . Left $ err <> " for parameter " <> paramLabel
            Right rt ->
              pure . Right $
                CacheParam
                  { cpIndex = pmIndex
                  , cpName = paramName
                  , cpPgOid = fromIntegral oid
                  , cpPgTypeName = rtPgTypeName rt
                  , cpHaskellType = htType (rtHaskellType rt)
                  , cpHaskellModule = htModule (rtHaskellType rt)
                  , cpPgTypeCategory = rtCategory rt
                  , cpPgEnumLabels = rtEnumLabels rt
                  }

resolveColumns :: CustomTypeMap -> Connection -> [(ColumnMeta, Bool)] -> IO (Either Text [CacheColumn])
resolveColumns customs conn = go []
  where
    go !acc [] = pure (Right (reverse acc))
    go !acc ((cm, nullable) : rest) = do
      result <- resolveColumn customs conn cm nullable
      case result of
        Left err -> pure (Left err)
        Right cc -> go (cc : acc) rest

resolveColumn :: CustomTypeMap -> Connection -> ColumnMeta -> Bool -> IO (Either Text CacheColumn)
resolveColumn customs conn ColumnMeta {..} nullable =
  let Oid oid = cmOid
      Oid tOid = cmTableOid
      tOidW32 = fromIntegral tOid :: Word32
      mkColumn ht cat labels =
        CacheColumn
          { ccName = cmName
          , ccPgOid = fromIntegral oid
          , ccPgTypeName = fromMaybe "unknown" (oidToTypeNameWith mempty cmOid)
          , ccNullable = nullable
          , ccHaskellType = htType (resolveType nullable ht)
          , ccHaskellModule = htModule ht
          , ccSourceTableOid = if tOidW32 == 0 then Nothing else Just tOidW32
          , ccSourceColumnNum = if cmColumnNumber == 0 then Nothing else Just cmColumnNumber
          , ccPgTypeCategory = cat
          , ccPgEnumLabels = labels
          }
   in case oidToHaskellTypeWith customs cmOid of
        Just baseHt ->
          pure . Right $ mkColumn baseHt Nothing Nothing
        Nothing -> do
          resolved <- resolveUnknownOid conn customs cmOid
          case resolved of
            Left err ->
              pure . Left $ err <> " for column \"" <> cmName <> "\""
            Right rt ->
              pure . Right $
                (mkColumn (rtHaskellType rt) (rtCategory rt) (rtEnumLabels rt))
                  { ccPgTypeName = rtPgTypeName rt
                  }

-- | Mask the password in a connection URL for display.
maskPassword :: Text -> Text
maskPassword url =
  case T.breakOn "://" url of
    (scheme, rest)
      | T.null rest -> url
      | otherwise ->
          let afterScheme = T.drop 3 rest
           in case T.breakOn "@" afterScheme of
                (_, hostPart)
                  | T.null hostPart -> url
                  | otherwise ->
                      case T.breakOn ":" afterScheme of
                        (user, passAndHost)
                          | T.null passAndHost -> url
                          | otherwise ->
                              scheme <> "://" <> user <> ":***" <> T.dropWhile (/= '@') passAndHost