poppy-codegen-1.0.0: src/Poppy/Codegen/Drift.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Compare a Schema to live Postgres. Poppy does not generate migrations.
module Poppy.Codegen.Drift
( DbCatalog (..),
DbTable (..),
DbColumn (..),
DbForeignKey (..),
DriftError (..),
emptyCatalog,
checkSchema,
formatDriftError,
)
where
import Data.List (find, nub, sort)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Poppy.Codegen.IR hiding (field, variant)
import Poppy.Codegen.TextUtil (lowerFirst)
data DbCatalog = DbCatalog
{ dbTables :: Map Text DbTable,
dbEnums :: Map Text [Text],
dbForeignKeys :: [DbForeignKey]
}
deriving (Show, Eq)
data DbTable = DbTable
{ dbTableName :: Text,
dbColumns :: Map Text DbColumn,
dbPrimaryKey :: [Text],
dbUniques :: [Set Text]
}
deriving (Show, Eq)
data DbColumn = DbColumn
{ dbColName :: Text,
dbColType :: Text,
dbColNullable :: Bool,
dbColDefault :: Maybe Text
}
deriving (Show, Eq)
data DbForeignKey = DbForeignKey
{ dbFkFromTable :: Text,
dbFkFromColumn :: Text,
dbFkToTable :: Text,
dbFkToColumn :: Text
}
deriving (Show, Eq)
data DriftError
= DriftMissingTable Text Text
| DriftMissingColumn Text Text
| DriftExtraColumn Text Text
| DriftNullability Text Text Bool Bool
| DriftType Text Text Text Text
| DriftPrimaryKey Text [Text] [Text]
| DriftMissingUnique Text [Text]
| DriftUnexpectedUnique Text [Text]
| DriftMissingEnum Text Text
| DriftEnumLabels Text [Text] [Text]
| DriftMissingDefault Text Text FieldDefault
| DriftDefaultMismatch Text Text FieldDefault Text
| DriftMissingForeignKey Text Text Text Text
deriving (Show, Eq)
emptyCatalog :: DbCatalog
emptyCatalog =
DbCatalog
{ dbTables = Map.empty,
dbEnums = Map.empty,
dbForeignKeys = []
}
checkSchema :: Schema -> DbCatalog -> [DriftError]
checkSchema schema catalog =
concatMap (checkModel catalog) (schemaModels schema)
++ concatMap (checkEnum catalog) (schemaEnums schema)
++ concatMap (checkMissingUnique catalog schema) (schemaUniques schema)
++ concatMap (checkUnexpectedUniques catalog schema) (schemaModels schema)
++ checkForeignKeys catalog schema
formatDriftError :: DriftError -> Text
formatDriftError = \case
DriftMissingTable model table ->
"IR model " <> model <> " table " <> table <> " is missing from the database"
DriftMissingColumn table col ->
"IR column " <> table <> "." <> col <> " is missing from the database"
DriftExtraColumn table col ->
"database column " <> table <> "." <> col <> " is not in the IR"
DriftNullability table col irNull dbNull ->
"nullability drift on "
<> table
<> "."
<> col
<> ": IR nullable="
<> showBool irNull
<> " database nullable="
<> showBool dbNull
DriftType table col expected actual ->
"type drift on " <> table <> "." <> col <> ": IR " <> expected <> " database " <> actual
DriftPrimaryKey table expected actual ->
"primary key drift on " <> table <> ": IR " <> csv expected <> " database " <> csv actual
DriftMissingUnique table cols ->
"IR unique (" <> csv cols <> ") is missing from " <> table
DriftUnexpectedUnique table cols ->
"database unique (" <> csv cols <> ") on " <> table <> " is not in the IR"
DriftMissingEnum name dbName ->
"IR enum " <> name <> " (database type " <> dbName <> ") is missing"
DriftEnumLabels name expected actual ->
"enum " <> name <> " labels: IR " <> csv expected <> " database " <> csv actual
DriftMissingDefault table col expected ->
"IR column "
<> table
<> "."
<> col
<> " declares DEFAULT "
<> defaultLabel expected
<> " but the database has none"
DriftDefaultMismatch table col expected actual ->
"default drift on "
<> table
<> "."
<> col
<> ": IR "
<> defaultLabel expected
<> " database "
<> actual
DriftMissingForeignKey fromTable fromCol toTable toCol ->
"IR foreign key "
<> fromTable
<> "."
<> fromCol
<> " → "
<> toTable
<> "."
<> toCol
<> " is missing from the database"
showBool :: Bool -> Text
showBool True = "true"
showBool False = "false"
csv :: [Text] -> Text
csv = T.intercalate ", "
checkModel :: DbCatalog -> Model -> [DriftError]
checkModel catalog model =
case Map.lookup (modelTable model) (dbTables catalog) of
Nothing ->
[DriftMissingTable (modelName model) (modelTable model)]
Just table ->
concatMap (checkColumn table) (modelFields model)
++ extraColumns table (modelFields model)
++ checkPrimaryKey table model
checkColumn :: DbTable -> FieldSpec -> [DriftError]
checkColumn table spec =
case Map.lookup (fieldColumn spec) (dbColumns table) of
Nothing ->
[DriftMissingColumn (dbTableName table) (fieldColumn spec)]
Just col ->
[ DriftNullability (dbTableName table) (fieldColumn spec) (fieldNullable spec) (dbColNullable col)
| fieldNullable spec /= dbColNullable col
]
++ [ DriftType (dbTableName table) (fieldColumn spec) expected (dbColType col)
| dbColType col /= expected
]
++ checkDefault (dbTableName table) (fieldColumn spec) (fieldDefault spec) (dbColDefault col)
where
expected = irType spec
checkDefault :: Text -> Text -> Maybe FieldDefault -> Maybe Text -> [DriftError]
checkDefault _ _ Nothing _ = []
checkDefault table col (Just expected) Nothing =
[DriftMissingDefault table col expected]
checkDefault table col (Just expected) (Just actual) =
[ DriftDefaultMismatch table col expected actual
| canonicalizeDefault actual /= Just expected
]
canonicalizeDefault :: Text -> Maybe FieldDefault
canonicalizeDefault raw
| mentions ["uuid_generate_v4", "gen_random_uuid"] = Just DefaultUuidV4
| mentions ["now()", "current_timestamp"] = Just DefaultNow
| otherwise = Nothing
where
normalized = T.toLower (T.strip raw)
mentions = any (`T.isInfixOf` normalized)
defaultLabel :: FieldDefault -> Text
defaultLabel DefaultUuidV4 = "uuid_generate_v4()"
defaultLabel DefaultNow = "now()"
extraColumns :: DbTable -> [FieldSpec] -> [DriftError]
extraColumns table fields =
[ DriftExtraColumn (dbTableName table) col
| col <- Map.keys (dbColumns table),
col `notElem` map fieldColumn fields
]
checkPrimaryKey :: DbTable -> Model -> [DriftError]
checkPrimaryKey table model =
[ DriftPrimaryKey (modelTable model) expected actual
| expected /= actual
]
where
expected = map fieldColumn (filter fieldIsPrimaryKey (modelFields model))
actual = dbPrimaryKey table
checkMissingUnique :: DbCatalog -> Schema -> UniqueConstraint -> [DriftError]
checkMissingUnique catalog schema UniqueConstraint {uniqueModel, uniqueFields} =
case find ((== uniqueModel) . modelName) (schemaModels schema) of
Nothing -> []
Just model ->
case Map.lookup (modelTable model) (dbTables catalog) of
Nothing -> []
Just table ->
let irCols = map (fieldColumnFor model) uniqueFields
irSet = Set.fromList irCols
in [ DriftMissingUnique (modelTable model) (sort irCols)
| irSet `notElem` dbUniques table
]
checkUnexpectedUniques :: DbCatalog -> Schema -> Model -> [DriftError]
checkUnexpectedUniques catalog schema model =
case Map.lookup (modelTable model) (dbTables catalog) of
Nothing -> []
Just table ->
[ DriftUnexpectedUnique (dbTableName table) (sort (Set.toList dbSet))
| dbSet <- dbUniques table,
dbSet `notElem` irSets
]
where
irSets =
[ Set.fromList (map (fieldColumnFor model) uniqueFields)
| UniqueConstraint {uniqueModel, uniqueFields} <- schemaUniques schema,
uniqueModel == modelName model
]
fieldColumnFor :: Model -> Text -> Text
fieldColumnFor model wanted =
maybe
wanted
fieldColumn
(find ((== wanted) . fieldName) (modelFields model))
checkEnum :: DbCatalog -> EnumSpec -> [DriftError]
checkEnum catalog enumSpec =
case Map.lookup dbName (dbEnums catalog) of
Nothing ->
[DriftMissingEnum (enumName enumSpec) dbName]
Just labels ->
[ DriftEnumLabels (enumName enumSpec) expected (sort labels)
| sort labels /= expected
]
where
dbName = T.toLower (enumName enumSpec)
expected = sort (map variantSqlValue (enumVariants enumSpec))
variantSqlValue :: EnumVariant -> Text
variantSqlValue spec =
case variantDbValue spec of
Just value -> value
Nothing -> lowerFirst (variantName spec)
irType :: FieldSpec -> Text
irType spec =
case fieldType spec of
TyText -> "text"
TyUuid -> "uuid"
TyInt -> "int"
TyNumeric -> "numeric"
TyJsonb -> "jsonb"
TyTimestamptz -> "timestamptz"
TyBool -> "boolean"
TyEnum name -> "enum:" <> T.toLower name
checkForeignKeys :: DbCatalog -> Schema -> [DriftError]
checkForeignKeys catalog schema =
[ DriftMissingForeignKey fromTable fromCol toTable toCol
| DbForeignKey fromTable fromCol toTable toCol <- impliedForeignKeys schema,
Map.member fromTable (dbTables catalog),
DbForeignKey fromTable fromCol toTable toCol `notElem` dbForeignKeys catalog
]
impliedForeignKeys :: Schema -> [DbForeignKey]
impliedForeignKeys schema =
nub
[ fk
| model <- schemaModels schema,
rel <- modelRelations model,
Just fk <- [relationForeignKey schema rel]
]
relationForeignKey :: Schema -> RelationSpec -> Maybe DbForeignKey
relationForeignKey schema rel =
case (findModel (relFromModel rel), findModel (relToModel rel)) of
(Just fromModel, Just toModel) ->
Just $
case relKind rel of
RelHasMany ->
DbForeignKey
{ dbFkFromTable = modelTable toModel,
dbFkFromColumn = fieldColumn (lookupNamedField toModel (relForeignField rel)),
dbFkToTable = modelTable fromModel,
dbFkToColumn = fieldColumn (lookupNamedField fromModel (relLocalField rel))
}
RelBelongsTo ->
DbForeignKey
{ dbFkFromTable = modelTable fromModel,
dbFkFromColumn = fieldColumn (lookupNamedField fromModel (relForeignField rel)),
dbFkToTable = modelTable toModel,
dbFkToColumn = fieldColumn (lookupNamedField toModel (relLocalField rel))
}
_ -> Nothing
where
findModel name = find ((== name) . modelName) (schemaModels schema)
lookupNamedField :: Model -> Text -> FieldSpec
lookupNamedField model name =
case find ((== name) . fieldName) (modelFields model) of
Just spec -> spec
Nothing ->
error $
"Poppy.Codegen.Drift: unknown field "
<> T.unpack name
<> " on "
<> T.unpack (modelName model)