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)