pg-migrate-import-codd-1.0.0.0: src/Database/PostgreSQL/Migrate/History/Codd/Ledger.hs
module Database.PostgreSQL.Migrate.History.Codd.Ledger
( readCoddHistory,
withLockedCoddHistory,
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.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.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 = withLockedCoddHistory config (readCoddHistoryOnConnection config)
withLockedCoddHistory ::
CoddSourceConfig ->
(Connection.Connection -> IO (Either CoddImportError value)) ->
IO (Either CoddImportError value)
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
primary <-
restore (action connection)
`Exception.onException` releaseIgnoringFailure connection sourceLockKey
unlocked <- runSession connection (Session.statement sourceLockKey unlockStatement)
pure $ case unlocked of
Right True -> primary
Right False -> Left (CoddUnlockFailed sourceLockKey (either Just (const Nothing) primary) Nothing)
Left cleanupFailure ->
Left
( CoddUnlockFailed
sourceLockKey
(either Just (const Nothing) primary)
(Just cleanupFailure)
)
pure $ case acquired of
Left connectionError -> Left (CoddConnectionFailed connectionError)
Right result -> result
releaseIgnoringFailure :: Connection.Connection -> Int64 -> IO ()
releaseIgnoringFailure connection lockKey = do
_ <- Connection.use connection (Session.statement lockKey unlockStatement)
pure ()
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"]