packages feed

sqel-0.0.1.0: lib/Sqel/Migration/Consistency.hs

module Sqel.Migration.Consistency where

import qualified Control.Exception as Base
import Control.Monad.Trans.Except (ExceptT (ExceptT), runExceptT, throwE, withExceptT)
import qualified Data.Aeson as Aeson
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Lazy as LByteString
import Data.List.Extra (zipWithLongest)
import qualified Data.Map.Strict as Map
import Exon (exon)
import Generics.SOP (NP (Nil, (:*)))
import Lens.Micro ((.~), (^.))
import Path (Abs, Dir, File, Path, parseRelFile, toFilePath, (</>))
import Path.IO (createDirIfMissing, doesFileExist)
import qualified Sqel.Data.Migration as Migration
import Sqel.Data.Migration (Migration (Migration), Migrations (Migrations))
import qualified Sqel.Data.PgType as PgType
import Sqel.Data.PgType (
  ColumnType (ColumnComp, ColumnPrim),
  PgColumn (PgColumn),
  PgColumns (PgColumns),
  PgComposite (PgComposite),
  PgPrimName (PgPrimName),
  PgTable (PgTable),
  PgTypeRef (PgTypeRef),
  )
import Sqel.Data.PgTypeName (PgTableName, pattern PgTypeName)
import Sqel.Data.Sql (Sql)
import qualified Sqel.Sql.Type as Sql
import Sqel.Text.Quote (squote)
import System.IO.Error (IOError)

import Sqel.Migration.Statement (migrationStatementSql, migrationStatements)

tryIO :: MonadIO m => IO a -> m (Either Text a)
tryIO =
  liftIO . fmap (first show) . Base.try @IOError

data MigrationMetadata =
  MigrationMetadata {
    name :: PgTableName,
    table :: PgColumns,
    types :: [PgComposite],
    statementsTable :: [Sql],
    statementsMigration :: [Sql]
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

tableStatements :: PgTable a -> [Sql]
tableStatements table =
  Sql.createTable table : (Sql.createProdType <$> types)
  where
    types = snd <$> Map.toAscList (table ^. #types)

tableMetadata :: PgTable a -> MigrationMetadata
tableMetadata table =
  MigrationMetadata {
    name = table ^. #name,
    table = table ^. #columns,
    types,
    statementsTable = tableStatements table,
    statementsMigration = []
  }
  where
    types = snd <$> Map.toAscList (table ^. #types)

migrationMetadata :: Migration mig -> MigrationMetadata
migrationMetadata Migration {tableFrom, actions} =
  tableMetadata tableFrom & #statementsMigration .~
  (migrationStatementSql <$> migrationStatements (tableFrom ^. #name) actions)

currentMetadata :: Migration mig -> MigrationMetadata
currentMetadata Migration {tableTo} =
  tableMetadata tableTo

migrationMetadatas :: NP Migration migs -> [MigrationMetadata]
migrationMetadatas = \case
  Nil -> []
  m :* ms -> migrationMetadata m : migrationMetadatas ms

headMigrationMetadata :: NP Migration migs -> Maybe MigrationMetadata
headMigrationMetadata = \case
  Nil -> Nothing
  mig :* _ -> Just (currentMetadata mig)

migrationsMetadata :: Migrations m migs -> [MigrationMetadata]
migrationsMetadata (Migrations migs) =
  reverse (maybeToList (headMigrationMetadata migs) <> migrationMetadatas migs)

jsonFile :: PgTable a -> String
jsonFile PgTable {name = PgTypeName name} =
  [exon|##{name}.json|]

jsonPath ::
  Monad m =>
  Path Abs Dir ->
  PgTable a ->
  ExceptT Text m (Path Abs File)
jsonPath dir table = do
  name <- ExceptT (pure (first pathError (parseRelFile (jsonFile table))))
  pure (dir </> name)
  where
    pathError _ = [exon|Table name couldn't be converted to a path: #{toText tname}|]
    tname = jsonFile table

writeMigrationMetadata ::
  MonadIO m =>
  Path Abs Dir ->
  Migrations m migs ->
  ExceptT Text m ()
writeMigrationMetadata dir migs@(Migrations (Migration {tableFrom} :* _)) = do
  path <- jsonPath dir tableFrom
  let
    write = LByteString.writeFile (toFilePath path) (Aeson.encode (migrationsMetadata migs))
    writeError e = [exon|Couldn't write migration metadata to '#{show path}': #{e}|]
  ExceptT (first writeError <$> tryIO (createDirIfMissing True dir))
  ExceptT (first writeError <$> tryIO write)
writeMigrationMetadata _ (Migrations Nil) =
  unit

readError :: Path Abs File -> Text -> Text
readError path e =
  [exon|Couldn't read migration metadata from #{show path}: #{e}|]

decodeError :: Path Abs File -> String -> Text
decodeError path e =
  [exon|Migration metadata in '#{show path}' has invalid json format: ##{e}|]

readMigrationMetadata ::
  MonadIO m =>
  Path Abs Dir ->
  Migrations m migs ->
  ExceptT Text m (Maybe [MigrationMetadata])
readMigrationMetadata dir (Migrations (Migration {tableFrom} :* _)) = do
  path <- jsonPath dir tableFrom
  liftIO (fromRight False <$> tryIO (doesFileExist path)) >>= \case
    False ->
      pure Nothing
    True -> do
      j <- ExceptT (first (readError path) <$> tryIO (ByteString.readFile (toFilePath path)))
      ExceptT (pure (first (decodeError path) (Aeson.eitherDecodeStrict' j)))
readMigrationMetadata _ (Migrations Nil) =
  throwE "Cannot test empty migrations"

indent ::
  Functor t =>
  t Text ->
  t Text
indent =
  fmap (" • " <>)

showType :: ColumnType -> Text
showType =
  squote . \case
    ColumnPrim {name = PgPrimName name} -> name
    ColumnComp { pgType = PgTypeRef name } -> name

columnMismatch :: Maybe PgColumn -> Maybe PgColumn -> Text
columnMismatch Nothing (Just (PgColumn name tpe)) =
  [exon|A column '##{name}' with type #{showType tpe} was added.|]
columnMismatch (Just (PgColumn name tpe)) Nothing =
  [exon|The column '##{name}' with type #{showType tpe} was removed.|]
columnMismatch (Just (PgColumn gname gtpe)) (Just (PgColumn cname ctpe))
  | gname == cname =
    [exon|The type of the column '##{gname}' was changed from #{showType gtpe} to #{showType ctpe}.|]
  | otherwise =
    [exon|The column '##{gname}' with type #{showType gtpe} was replaced with the column '##{cname}' with type #{showType ctpe}.|]
columnMismatch Nothing Nothing =
  "Internal error"

compareType :: Text -> PgColumns -> PgColumns -> Maybe (NonEmpty Text)
compareType desc (PgColumns golden) (PgColumns current) =
  mismatches <$> nonEmpty (filter (uncurry (/=)) (zipWithLongest (,) golden current))
  where
    mismatches cols = [exon|#{desc} has mismatched columns:|] :| (indent (uncurry columnMismatch <$> toList cols))

compareComp :: Maybe PgComposite -> Maybe PgComposite -> Maybe (NonEmpty Text)
compareComp Nothing Nothing =
  Nothing
compareComp Nothing (Just (PgComposite (PgTypeName name) _)) =
  Just [[exon|The type '#{name}' was added.|]]
compareComp (Just (PgComposite (PgTypeName name) _)) Nothing =
  Just [[exon|The type '#{name}' was removed.|]]
compareComp (Just (PgComposite (PgTypeName gname) gcols)) (Just (PgComposite (PgTypeName cname) ccols))
  | gname == cname =
    compareType [exon|The composite type '#{gname}'|] gcols ccols
  | otherwise =
    Just [[exon|The type '#{gname}' was replaced with a type named '#{cname}'.|]]

compareStep :: MigrationMetadata -> MigrationMetadata -> Maybe (NonEmpty Text)
compareStep golden current =
  join <$> nonEmpty (catMaybes mismatches)
  where
    mismatches =
      compareType [exon|The migration table '#{name}'|] (golden ^. #table) (current ^. #table) :
      zipWithLongest (compareComp) (golden ^. #types) (current ^. #types)
    PgTypeName name = golden ^. #name

checkStep :: Maybe MigrationMetadata -> Maybe MigrationMetadata -> Maybe (NonEmpty Text)
checkStep Nothing _ =
  Nothing
checkStep (Just golden) Nothing =
  let (PgTypeName name) = golden ^. #name
  in Just (pure [exon|A migration for #{name} was removed.|])
checkStep (Just golden) (Just current) =
  compareStep golden current

checkMigrationConsistency :: [MigrationMetadata] -> [MigrationMetadata] -> Either (NonEmpty Text) ()
checkMigrationConsistency golden current =
  maybeToLeft () (join <$> (nonEmpty (catMaybes (zipWithLongest checkStep golden current))))

single ::
  Functor m =>
  ExceptT Text m a ->
  ExceptT (NonEmpty Text) m a
single =
  withExceptT pure

result ::
  Functor m =>
  ExceptT e m () ->
  m (Maybe e)
result =
  runExceptT >>> fmap \case
    Left e -> Just e
    Right () -> Nothing

migrationConsistency ::
  MonadIO m =>
  Path Abs Dir ->
  Migrations m migs ->
  Bool ->
  m (Maybe (NonEmpty Text))
migrationConsistency dir migs =
  result . \case
    True ->
      single (writeMigrationMetadata dir migs)
    False ->
      single (readMigrationMetadata dir migs) >>= \case
        Just golden ->
          ExceptT (pure (checkMigrationConsistency golden (migrationsMetadata migs)))
        Nothing -> single (writeMigrationMetadata dir migs)