packages feed

pg-migrate-import-codd-1.1.0.0: src/Database/PostgreSQL/Migrate/History/Codd/Ledger.hs

module Database.PostgreSQL.Migrate.History.Codd.Ledger
  ( readCoddHistory,
    withLockedCoddHistory,
    SourceUnlockIssue,
    coddUnlockFailure,
    sourceUnlockCleanupIssue,
    readCoddHistoryOnConnection,
    classifyCoddSchema,
    validateCoddRows,
  )
where

import Control.Exception qualified as Exception
import Data.Bifunctor (first)
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Database.PostgreSQL.Migrate (CleanupIssue (..))
import Database.PostgreSQL.Migrate.History.Codd.Types
import Database.PostgreSQL.Migrate.Internal (useConnectionProvider)
import Hasql.Connection qualified as Connection
import Hasql.Decoders qualified as Decoders
import Hasql.Encoders qualified as Encoders
import Hasql.Errors qualified as Errors
import Hasql.Session qualified as Session
import Hasql.Statement (Statement)
import Hasql.Statement qualified as Statement
import PgMigrate.History.Codd.Prelude

-- | Read and verify selected legacy rows without mutating source objects.
readCoddHistory :: CoddSourceConfig -> IO (Either CoddImportError CoddHistory)
readCoddHistory config@CoddSourceConfig {sourceLockKey} = do
  (result, unlockIssues) <-
    withLockedCoddHistory config (readCoddHistoryOnConnection config)
  pure $ case (result, unlockIssues) of
    (Left primary, issue : _) -> Left (coddUnlockFailure sourceLockKey (Just primary) issue)
    (Left primary, []) -> Left primary
    (Right _, issue : _) -> Left (coddUnlockFailure sourceLockKey Nothing issue)
    (Right history, []) -> Right history

data SourceUnlockIssue
  = SourceUnlockReturnedFalse
  | SourceUnlockSessionFailed !Errors.SessionError

withLockedCoddHistory ::
  CoddSourceConfig ->
  (Connection.Connection -> IO (Either CoddImportError value)) ->
  IO (Either CoddImportError value, [SourceUnlockIssue])
withLockedCoddHistory CoddSourceConfig {sourceProvider, sourceLockKey} action = do
  acquired <-
    useConnectionProvider sourceProvider $ \connection ->
      Exception.mask $ \restore -> do
        locked <- runSession connection (Session.statement sourceLockKey tryLockStatement)
        case locked of
          Left err -> pure (Left err, [])
          Right False -> pure (Left (CoddLockUnavailable sourceLockKey), [])
          Right True -> do
            captured <- Exception.try @Exception.SomeException (restore (action connection))
            unlockIssues <- unlockSource connection sourceLockKey
            case captured of
              Left exception -> Exception.throwIO exception
              Right primary -> pure (primary, unlockIssues)
  pure $ case acquired of
    Left connectionError -> (Left (CoddConnectionFailed connectionError), [])
    Right result -> result

unlockSource :: Connection.Connection -> Int64 -> IO [SourceUnlockIssue]
unlockSource connection lockKey = do
  unlocked <- Connection.use connection (Session.statement lockKey unlockStatement)
  pure $ case unlocked of
    Left sessionError -> [SourceUnlockSessionFailed sessionError]
    Right False -> [SourceUnlockReturnedFalse]
    Right True -> []

coddUnlockFailure :: Int64 -> Maybe CoddImportError -> SourceUnlockIssue -> CoddImportError
coddUnlockFailure lockKey primary unlockIssue =
  case unlockIssue of
    SourceUnlockReturnedFalse -> CoddUnlockFailed lockKey primary Nothing
    SourceUnlockSessionFailed sessionError ->
      CoddUnlockFailed lockKey primary (Just (CoddSessionFailed sessionError))

sourceUnlockCleanupIssue :: SourceUnlockIssue -> CleanupIssue
sourceUnlockCleanupIssue unlockIssue =
  case unlockIssue of
    SourceUnlockReturnedFalse -> AdvisoryUnlockReturnedFalse
    SourceUnlockSessionFailed sessionError -> AdvisoryUnlockFailed sessionError

readCoddHistoryOnConnection ::
  CoddSourceConfig ->
  Connection.Connection ->
  IO (Either CoddImportError CoddHistory)
readCoddHistoryOnConnection config connection = do
  detected <- runSession connection (Session.statement () detectColumnsStatement)
  case detected >>= classifyCoddSchema of
    Left err -> pure (Left err)
    Right (version, schemaName) -> do
      loaded <- runSession connection (Session.statement () (loadRowsStatement version schemaName))
      pure (loaded >>= validateCoddRows config version)

classifyCoddSchema :: [(Text, Text)] -> Either CoddImportError (CoddSchemaVersion, Text)
classifyCoddSchema qualifiedColumns =
  case (Map.lookup "codd_schema" grouped, Map.lookup "codd" grouped) of
    (Nothing, Nothing) -> Left CoddLedgerMissing
    (Just _, Just _) -> Left CoddBothSchemasPresent
    (Just columns, Nothing) -> classifyLegacy columns
    (Nothing, Just columns)
      | columns == v4Columns -> Right (CoddV5, "codd")
      | otherwise -> Left (CoddUnsupportedShape "codd" columns)
  where
    grouped = Map.fromListWith (flip (<>)) [(schema, [column]) | (schema, column) <- qualifiedColumns]
    classifyLegacy columns
      | columns == v1Columns = Right (CoddV1, "codd_schema")
      | columns == v2Columns = Right (CoddV2, "codd_schema")
      | columns == v3Columns = Right (CoddV3, "codd_schema")
      | columns == v4Columns = Right (CoddV4, "codd_schema")
      | otherwise = Left (CoddUnsupportedShape "codd_schema" columns)

validateCoddRows ::
  CoddSourceConfig ->
  CoddSchemaVersion ->
  [CoddHistoryRow] ->
  Either CoddImportError CoddHistory
validateCoddRows CoddSourceConfig {selectedFilenames, strictSource} version rows = do
  case firstDuplicate (filename <$> rows) of
    Just duplicate -> Left (CoddDuplicateLedgerFilename duplicate)
    Nothing -> pure ()
  case List.find (\row -> isJust (noTransactionFailedAt row) || appliedAt row == Nothing) rows of
    Just partial -> Left (CoddPartialMigration (filename partial))
    Nothing -> pure ()
  selected <- traverse selectRow selectedFilenames
  let selectedSet = Set.fromList (toList selectedFilenames)
      unselectedRows = filter ((`Set.notMember` selectedSet) . filename) rows
  if strictSource && not (null unselectedRows)
    then Left (CoddStrictSourceHasUnselected (filename <$> unselectedRows))
    else Right CoddHistory {schemaVersion = version, selectedRows = selected, unselectedRows}
  where
    rowsByFilename = Map.fromList [(filename row, row) | row <- rows]
    selectRow selected =
      maybe (Left (CoddSelectedFilenameMissing selected)) Right (Map.lookup selected rowsByFilename)

firstDuplicate :: (Ord value) => [value] -> Maybe value
firstDuplicate = go Set.empty
  where
    go _ [] = Nothing
    go seen (value : rest)
      | Set.member value seen = Just value
      | otherwise = go (Set.insert value seen) rest

detectColumnsStatement :: Statement () [(Text, Text)]
detectColumnsStatement =
  Statement.preparable
    "SELECT n.nspname::text, a.attname::text FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = 'sql_migrations' AND n.nspname IN ('codd_schema', 'codd') AND a.attnum >= 1 AND NOT a.attisdropped ORDER BY n.nspname, a.attnum"
    Encoders.noParams
    (Decoders.rowList ((,) <$> required Decoders.text <*> required Decoders.text))
  where
    required = Decoders.column . Decoders.nonNullable

loadRowsStatement :: CoddSchemaVersion -> Text -> Statement () [CoddHistoryRow]
loadRowsStatement version schemaName =
  Statement.unpreparable
    ( Text.unwords
        [ "SELECT name, migration_timestamp, applied_at,",
          if version >= CoddV3 then "COALESCE(num_applied_statements, 0)::int4," else "0::int4,",
          if version >= CoddV3 then "no_txn_failed_at" else "NULL::timestamptz",
          "FROM " <> schemaName <> ".sql_migrations ORDER BY id"
        ]
    )
    Encoders.noParams
    (Decoders.rowList rowDecoder)
  where
    rowDecoder =
      CoddHistoryRow
        <$> (Text.unpack <$> required Decoders.text)
        <*> required Decoders.timestamptz
        <*> nullableColumn Decoders.timestamptz
        <*> required Decoders.int4
        <*> nullableColumn Decoders.timestamptz
    required = Decoders.column . Decoders.nonNullable
    nullableColumn = Decoders.column . Decoders.nullable

tryLockStatement :: Statement Int64 Bool
tryLockStatement =
  Statement.preparable
    "SELECT pg_try_advisory_lock($1)"
    (Encoders.param (Encoders.nonNullable Encoders.int8))
    (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.bool)))

unlockStatement :: Statement Int64 Bool
unlockStatement =
  Statement.preparable
    "SELECT pg_advisory_unlock($1)"
    (Encoders.param (Encoders.nonNullable Encoders.int8))
    (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.bool)))

runSession :: Connection.Connection -> Session.Session value -> IO (Either CoddImportError value)
runSession connection session =
  first CoddSessionFailed <$> Connection.use connection session

v1Columns :: [Text]
v1Columns = ["id", "migration_timestamp", "applied_at", "name"]

v2Columns :: [Text]
v2Columns = v1Columns <> ["application_duration"]

v3Columns :: [Text]
v3Columns = v2Columns <> ["num_applied_statements", "no_txn_failed_at"]

v4Columns :: [Text]
v4Columns = v3Columns <> ["txnid", "connid"]