packages feed

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

module Database.PostgreSQL.Migrate.Plan
  ( PlanError (..),
    MigrationDescription (..),
    ComponentDescription (..),
    PlanDescription (..),
    migrationPlan,
    resolveMigrationPlan,
    planDescription,
  )
where

import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Database.PostgreSQL.Migrate.Types
import PgMigrate.Prelude

-- | Structural reason a component composition is not a valid plan.
data PlanError
  = DuplicateComponentName !ComponentName
  | DuplicateMigrationName !ComponentName !MigrationName
  | MissingComponentDependency !ComponentName !ComponentName
  | DependencyPlacedAfterConsumer !ComponentName !ComponentName
  | ComponentDependencyCycle !(NonEmpty ComponentName)
  deriving stock (Generic, Eq, Show)

data MigrationDescription = MigrationDescription
  { migrationId :: !MigrationId,
    position :: !Int,
    checksum :: !MigrationChecksum,
    kind :: !MigrationKind,
    transactionMode :: !TransactionMode
  }
  deriving stock (Generic, Eq, Show)

data ComponentDescription = ComponentDescription
  { name :: !ComponentName,
    position :: !Int,
    dependencies :: !(Set ComponentName),
    migrations :: !(NonEmpty MigrationDescription)
  }
  deriving stock (Generic, Eq, Show)

newtype PlanDescription = PlanDescription
  { components :: NonEmpty ComponentDescription
  }
  deriving stock (Generic, Eq, Show)

-- | Validate an already dependency-ordered component sequence.
migrationPlan :: NonEmpty MigrationComponent -> Either PlanError MigrationPlan
migrationPlan components = do
  validateUniqueNames components
  validateDependenciesExist components
  _ <- stableTopologicalOrder components
  validateExplicitOrder components
  pure (MigrationPlan components)

-- | Stably topologically order and validate a component sequence.
resolveMigrationPlan :: NonEmpty MigrationComponent -> Either PlanError MigrationPlan
resolveMigrationPlan components = do
  validateUniqueNames components
  validateDependenciesExist components
  MigrationPlan <$> stableTopologicalOrder components

planDescription :: MigrationPlan -> PlanDescription
planDescription (MigrationPlan components) =
  PlanDescription
    ( NonEmpty.zipWith
        describeComponent
        (1 :| [2 ..])
        components
    )

describeComponent :: Int -> MigrationComponent -> ComponentDescription
describeComponent componentPosition component =
  ComponentDescription
    { name = componentNameOf component,
      position = componentPosition,
      dependencies = componentDependenciesOf component,
      migrations =
        NonEmpty.zipWith
          (describeMigration (componentNameOf component))
          (1 :| [2 ..])
          (componentMigrationsOf component)
    }

describeMigration :: ComponentName -> Int -> Migration -> MigrationDescription
describeMigration component migrationPosition migration =
  MigrationDescription
    { migrationId = MigrationId component (migrationNameOf migration),
      position = migrationPosition,
      checksum = migrationChecksumOf migration,
      kind = migrationKindOf migration,
      transactionMode = migrationModeOf migration
    }

validateUniqueNames :: NonEmpty MigrationComponent -> Either PlanError ()
validateUniqueNames components = do
  case firstDuplicate (componentNameOf <$> toList components) of
    Just duplicate -> Left (DuplicateComponentName duplicate)
    Nothing -> pure ()
  traverse_ validateMigrationNames components

validateMigrationNames :: MigrationComponent -> Either PlanError ()
validateMigrationNames component =
  case firstDuplicate (migrationNameOf <$> toList (componentMigrationsOf component)) of
    Just duplicate -> Left (DuplicateMigrationName (componentNameOf component) duplicate)
    Nothing -> pure ()

validateDependenciesExist :: NonEmpty MigrationComponent -> Either PlanError ()
validateDependenciesExist components =
  traverse_ validateComponent components
  where
    knownNames = Set.fromList (componentNameOf <$> toList components)
    validateComponent component =
      case find (`Set.notMember` knownNames) (Set.toAscList (componentDependenciesOf component)) of
        Just missing -> Left (MissingComponentDependency (componentNameOf component) missing)
        Nothing -> pure ()

validateExplicitOrder :: NonEmpty MigrationComponent -> Either PlanError ()
validateExplicitOrder components =
  traverse_ validateComponent (toList components)
  where
    positions = Map.fromList (zip (componentNameOf <$> toList components) [1 :: Int ..])
    validateComponent component =
      case find (isAfter component) (Set.toAscList (componentDependenciesOf component)) of
        Just dependency -> Left (DependencyPlacedAfterConsumer (componentNameOf component) dependency)
        Nothing -> pure ()
    isAfter component dependency =
      Map.lookup dependency positions > Map.lookup (componentNameOf component) positions

stableTopologicalOrder ::
  NonEmpty MigrationComponent ->
  Either PlanError (NonEmpty MigrationComponent)
stableTopologicalOrder components =
  go Set.empty [] (toList components)
  where
    go _ ordered [] =
      case ordered of
        firstComponent : remainingComponents -> Right (firstComponent :| remainingComponents)
        [] -> error "stableTopologicalOrder: NonEmpty input produced empty output"
    go resolved ordered remaining =
      case removeFirst (dependenciesResolved resolved) remaining of
        Just (next, rest) ->
          go
            (Set.insert (componentNameOf next) resolved)
            (ordered <> [next])
            rest
        Nothing ->
          case componentNameOf <$> remaining of
            firstName : remainingNames -> Left (ComponentDependencyCycle (firstName :| remainingNames))
            [] -> error "stableTopologicalOrder: unresolved set unexpectedly empty"

dependenciesResolved :: Set ComponentName -> MigrationComponent -> Bool
dependenciesResolved resolved component =
  componentDependenciesOf component `Set.isSubsetOf` resolved

removeFirst :: (a -> Bool) -> [a] -> Maybe (a, [a])
removeFirst predicate = go []
  where
    go _ [] = Nothing
    go before (candidate : after)
      | predicate candidate = Just (candidate, reverse before <> after)
      | otherwise = go (candidate : before) after

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