packages feed

pg-migrate-1.0.0.0: src/Database/PostgreSQL/Migrate/Ledger.hs

module Database.PostgreSQL.Migrate.Ledger
  ( comparePlanWithLedger,
    loadLedger,
    statusFromSnapshot,
    statusFromSnapshotWith,
    verifyFromSnapshot,
    loadStatus,
    loadVerification,
  )
where

import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Database.PostgreSQL.Migrate.Ledger.Sql
import Database.PostgreSQL.Migrate.Ledger.Types
import Database.PostgreSQL.Migrate.Plan
import Database.PostgreSQL.Migrate.Types
import Hasql.Session (Session)
import Hasql.Session qualified as Session
import PgMigrate.Prelude

comparePlanWithLedger ::
  UnknownMigrationsPolicy ->
  PlanDescription ->
  [StoredMigration] ->
  VerificationReport
comparePlanWithLedger unknownPolicy description storedInput =
  VerificationReport
    { issues =
        duplicateIdentityIssues
          <> duplicatePositionIssues
          <> statusIssues
          <> metadataIssues
          <> prefixIssues
          <> unknownIssues,
      appliedMigrations,
      pendingMigrations,
      unknownMigrations
    }
  where
    target = flattenPlan description
    targetById = Map.fromList ((\migration -> (migrationId migration, migration)) <$> target)
    stored = List.sortOn storedSortKey storedInput
    storedById = firstBy storedMigrationId stored

    duplicateIdentityIssues =
      DuplicateStoredMigration <$> duplicates (storedMigrationId <$> stored)
    duplicatePositionIssues =
      (\(component, position) -> DuplicateStoredPosition component position)
        <$> duplicates (storedPositionKey <$> stored)
    statusIssues = concatMap storedStatusIssues stored
    metadataIssues = concatMap (migrationMetadataIssues storedById) target
    prefixIssues = concatMap (componentPrefixIssues storedById) (planComponents description)

    unknownMigrations =
      filter ((`Map.notMember` targetById) . storedMigrationId) stored
    unknownIssues =
      case unknownPolicy of
        RejectUnknownMigrations -> UnknownStoredMigration . storedMigrationId <$> unknownMigrations
        AllowUnknownMigrations -> []

    appliedMigrations =
      mapMaybe (targetApplied storedById) target
    pendingMigrations =
      mapMaybe (targetPending storedById) target

loadLedger :: LedgerConfig -> Session LedgerSnapshot
loadLedger config = do
  ledgerExists <-
    Session.statement
      (ledgerSchemaText config)
      ledgerMetadataExistsStatement
  if ledgerExists
    then do
      metadata <- Session.statement () (loadLedgerMetadataStatement config)
      storedMigrations <- Session.statement () (loadStoredMigrationsStatement config)
      pure LedgerSnapshot {metadata = Just metadata, storedMigrations}
    else pure LedgerSnapshot {metadata = Nothing, storedMigrations = []}

statusFromSnapshot :: MigrationPlan -> LedgerSnapshot -> StatusReport
statusFromSnapshot = statusFromSnapshotWith AllowUnknownMigrations

statusFromSnapshotWith ::
  UnknownMigrationsPolicy ->
  MigrationPlan ->
  LedgerSnapshot ->
  StatusReport
statusFromSnapshotWith unknownPolicy plan LedgerSnapshot {storedMigrations} =
  case comparePlanWithLedger unknownPolicy (planDescription plan) storedMigrations of
    VerificationReport
      { issues,
        appliedMigrations,
        pendingMigrations,
        unknownMigrations
      } ->
        StatusReport
          { issues,
            appliedMigrations,
            pendingMigrations,
            unknownMigrations
          }

verifyFromSnapshot :: MigrationPlan -> LedgerSnapshot -> VerificationReport
verifyFromSnapshot plan LedgerSnapshot {storedMigrations} =
  case comparePlanWithLedger RejectUnknownMigrations (planDescription plan) storedMigrations of
    VerificationReport
      { issues,
        appliedMigrations,
        pendingMigrations,
        unknownMigrations
      } ->
        VerificationReport
          { issues = issues <> (PendingMigration <$> pendingMigrations),
            appliedMigrations,
            pendingMigrations,
            unknownMigrations
          }

loadStatus ::
  LedgerConfig ->
  UnknownMigrationsPolicy ->
  MigrationPlan ->
  Session StatusReport
loadStatus config unknownPolicy plan =
  statusFromSnapshotWith unknownPolicy plan <$> loadLedger config

loadVerification :: LedgerConfig -> MigrationPlan -> Session VerificationReport
loadVerification config plan = verifyFromSnapshot plan <$> loadLedger config

flattenPlan :: PlanDescription -> [MigrationDescription]
flattenPlan description =
  concatMap (toList . componentMigrations) (planComponents description)

planComponents :: PlanDescription -> [ComponentDescription]
planComponents (PlanDescription components) = toList components

componentMigrations :: ComponentDescription -> NonEmpty MigrationDescription
componentMigrations ComponentDescription {migrations} = migrations

storedSortKey :: StoredMigration -> (Text, Int, Text)
storedSortKey StoredMigration {storedMigrationId, position = storedPosition} =
  ( componentNameText (migrationIdComponent storedMigrationId),
    storedPosition,
    migrationNameText (migrationIdName storedMigrationId)
  )

storedPositionKey :: StoredMigration -> (ComponentName, Int)
storedPositionKey StoredMigration {storedMigrationId, position = storedPosition} =
  (migrationIdComponent storedMigrationId, storedPosition)

firstBy :: (Ord key) => (value -> key) -> [value] -> Map key value
firstBy keyOf =
  foldl
    (\values value -> Map.insertWith (\_ previous -> previous) (keyOf value) value values)
    Map.empty

duplicates :: (Ord value) => [value] -> [value]
duplicates = go Set.empty Set.empty
  where
    go _ _ [] = []
    go seen reported (value : rest)
      | value `Set.member` reported = go seen reported rest
      | value `Set.member` seen = value : go seen (Set.insert value reported) rest
      | otherwise = go (Set.insert value seen) reported rest

storedStatusIssues :: StoredMigration -> [VerificationIssue]
storedStatusIssues stored =
  case status stored of
    Running -> [StoredMigrationRunning (storedMigrationId stored)]
    Applied -> []
    Failed -> [StoredMigrationFailed (storedMigrationId stored)]

migrationMetadataIssues ::
  Map MigrationId StoredMigration ->
  MigrationDescription ->
  [VerificationIssue]
migrationMetadataIssues storedById expected =
  case Map.lookup expectedId storedById of
    Nothing -> []
    Just
      StoredMigration
        { position = actualPosition,
          checksum = actualChecksum,
          kind = actualKind,
          transactionMode = actualMode
        } ->
        concat
          [ [ MigrationPositionMismatch expectedId expectedPosition actualPosition
            | actualPosition /= expectedPosition
            ],
            [ MigrationChecksumMismatch expectedId expectedChecksum actualChecksum
            | actualChecksum /= expectedChecksum
            ],
            [ MigrationKindMismatch expectedId expectedKind actualKind
            | actualKind /= expectedKind
            ],
            [ MigrationTransactionModeMismatch expectedId expectedMode actualMode
            | actualMode /= expectedMode
            ]
          ]
  where
    MigrationDescription
      { migrationId = expectedId,
        position = expectedPosition,
        checksum = expectedChecksum,
        kind = expectedKind,
        transactionMode = expectedMode
      } = expected

componentPrefixIssues ::
  Map MigrationId StoredMigration ->
  ComponentDescription ->
  [VerificationIssue]
componentPrefixIssues storedById ComponentDescription {migrations} =
  snd (foldl step (Nothing, []) (toList migrations))
  where
    step (firstGap, accumulated) MigrationDescription {migrationId}
      | Map.member migrationId storedById =
          case firstGap of
            Nothing -> (Nothing, accumulated)
            Just missing ->
              (Just missing, accumulated <> [AppliedMigrationAfterGap migrationId missing])
      | otherwise = (Just (fromMaybe migrationId firstGap), accumulated)

targetApplied ::
  Map MigrationId StoredMigration ->
  MigrationDescription ->
  Maybe MigrationId
targetApplied storedById MigrationDescription {migrationId} = do
  stored <- Map.lookup migrationId storedById
  case status stored of
    Applied -> Just migrationId
    Running -> Nothing
    Failed -> Nothing

targetPending ::
  Map MigrationId StoredMigration ->
  MigrationDescription ->
  Maybe MigrationId
targetPending storedById MigrationDescription {migrationId} =
  case Map.lookup migrationId storedById of
    Just stored | status stored == Applied -> Nothing
    _ -> Just migrationId