espial-0.0.40: src/Model/Migrations.hs
module Model.Migrations where
import ClassyPrelude.Yesod hiding (Value, exists, groupBy, on, (<=.), (==.), (>=.), (||.))
import Data.FileEmbed (embedFile)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Database.Persist.Sql
import GraphUtils (getPathValues)
import Model
import Types
import Version qualified as V
type DbVersion = Int
newtype CurrentDbVersion = CurrentDbVersion DbVersion
newtype LatestMigrationVersion = LatestMigrationVersion DbVersion
type DbVersionPath = (DbVersion, DbVersion)
(~>) :: DbVersion -> DbVersion -> DbVersionPath
(~>) = (,)
data SqlStatement = SqlStatement {sql :: Text, vals :: [PersistValue]} deriving (Show)
type OperationSpec m = (Text, SqlPersistT m [SqlStatement])
data OperationPath m = DbVersionPath := [OperationSpec m]
type AppMigrations m = [OperationPath m]
dumpMigration :: DB ()
dumpMigration = do
printMigration migrateSchema
printAppMigrations
runPersistentMigrations :: Bool -> DB ()
runPersistentMigrations enableLogging = do
if enableLogging
then void $ runMigration migrateSchema
else void $ runMigrationQuiet migrateSchema
runPreMigrations :: Bool -> DB ()
runPreMigrations enableLogging =
runPreMigrations' (if enableLogging then liftIO . TIO.hPutStrLn stderr else const (pure ()))
-- runs before runPersistentMigrations, so the schema may not exist yet and
-- only raw SQL guarded by table-existence checks is safe here
runPreMigrations' :: forall m. (MonadUnliftIO m) => (Text -> m ()) -> DBM m ()
runPreMigrations' logger = do
(mcv, _) <- getDbVersions True
let cv = maybe 0 (\(CurrentDbVersion v) -> v) mcv
when (cv <= 2) $ do
noteTableExists <- rawSql "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'note'" []
case noteTableExists :: [Single Int] of
[] -> pure ()
_ -> do
lift (logger "Applying preMigration operation: dedupe-duplicate-note-slugs")
rawExecute
"UPDATE note SET slug = lower(hex(randomblob(10))) WHERE id NOT IN (SELECT MIN(id) FROM note GROUP BY user_id, slug)"
[]
runAppMigrations :: Bool -> DB ()
runAppMigrations enableLogging = do
insertCurrentAppVersion
runAppMigrations' (if enableLogging then liftIO . TIO.hPutStrLn stderr else const (pure ()))
insertCurrentAppVersion :: DB ()
insertCurrentAppVersion = do
appVersion <- selectFirst [] [Desc AppVersionId]
when (maybe True ((/= V.versionSpec) . appVersionAppVersionSpec . entityVal) appVersion) $ do
now <- liftIO getCurrentTime
insert_ (AppVersion V.versionSpec (pack V.appVersion) (pack V.gitShaShort) now)
appMigrations :: (MonadUnliftIO m) => AppMigrations m
appMigrations =
[ 0 ~> 1 := [],
1 ~> 2 := [operation_normalize_bookmark_utctime_remove_z, operation_normalize_note_utctime_remove_z],
2 ~> 3 := [operation_marker_unique_user_note_slug]
]
-- run on every startup regardless of DbVersion, since migrateSchema may
-- recreate tables (dropping their indexes) at any time
alwaysAppMigrations :: (MonadUnliftIO m) => [OperationSpec m]
alwaysAppMigrations = [operation_create_initial_indexes]
printAppMigrations :: forall m. DBM m ()
printAppMigrations = do
forM_ (alwaysAppMigrations :: [OperationSpec m]) $ \(name, opSpec) -> do
putStrLn ("*** Dumping always-run migration operation: " <> name)
statements <- opSpec
traverse_ (\(SqlStatement sql vals) -> putStrLn (sql <> " " <> tshow vals)) statements
(mcv, LatestMigrationVersion lmv) <- getDbVersions True
let CurrentDbVersion cv = fromMaybe (CurrentDbVersion 0) mcv
if (cv >= lmv)
then pure () -- putStrLn "No appMigrations to apply."
else do
putStrLn ("appMigration needed: current version " <> tshow cv <> ", latest version " <> tshow lmv)
case getOperations appMigrations (Just cv) of
Left (current, target) ->
putStrLn (msg_migration_path_not_found current target)
Right (_, endv, _) | cv >= endv -> do
pure () -- putStrLn ("No appMigrations to apply. " <> if cv > endv then " (database version is newer than latest appMigrations version)" else "")
Right (startv, endv, ops) -> do
let strPath = tshow startv <> " ~> " <> tshow endv
strOps = T.intercalate ", " (fmap fst ops)
putStrLn ("Found appMigration path: " <> strPath <> " (" <> strOps <> ")")
forM_ ops $ \(name, opSpec) -> do
putStrLn ("*** Dumping migration operation: " <> name)
statements <- opSpec
traverse_ (\(SqlStatement sql vals) -> putStrLn (sql <> " " <> tshow vals)) statements
pure ()
runAppMigrations' :: (MonadUnliftIO m) => (Text -> m ()) -> DBM m ()
runAppMigrations' logger = do
let logFunc = lift . logger
forM_ alwaysAppMigrations $ \(_, opSpec) -> do
statements <- opSpec
traverse_ (\(SqlStatement sql vals) -> rawExecute sql vals) statements
(mcv, LatestMigrationVersion lmv) <- getDbVersions False
case mcv of
Nothing -> do
logFunc "table 'app_migration' does not exist. aborting appMigrations"
pure ()
Just (CurrentDbVersion cv) -> do
if (cv >= lmv)
then pure ()
else do
logFunc ("appMigration needed: current version " <> tshow cv <> ", latest version " <> tshow lmv)
case getOperations appMigrations (Just cv) of
Left (current, target) ->
logFunc (msg_migration_path_not_found current target)
Right (_, endv, _) | cv >= endv -> do
pure () -- putStrLn ("No appMigrations to apply. " <> if cv > endv then " (database version is newer than latest appMigrations version)" else "")
Right (startv, endv, ops) -> do
let strPath = tshow startv <> " ~> " <> tshow endv
strOps = T.intercalate ", " (fmap fst ops)
logFunc ("Found appMigration path: " <> strPath <> " (" <> strOps <> ")")
forM_ ops $ \(name, opSpec) -> do
logFunc ("Applying migration operation: " <> name)
statements <- opSpec
traverse_ (\(SqlStatement sql vals) -> rawExecute sql vals) statements
now <- liftIO getCurrentTime
insert_ (AppMigration endv (strPath <> ": " <> strOps) (V.versionSpec) now)
logFunc ("appMigration complete: " <> strPath)
pure ()
msg_migration_path_not_found :: DbVersion -> DbVersion -> Text
msg_migration_path_not_found current target =
"Cannot migrate from current version "
<> tshow current
<> " to "
<> tshow target
<> ". No path of appMigrations found."
getDbVersions :: forall m. Bool -> DBM m ((Maybe CurrentDbVersion, LatestMigrationVersion))
getDbVersions checkSchema = do
(if checkSchema then (rawSql "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'app_migration'" []) else pure [Single 1]) >>= \case
_p :: [Single Int]
| null _p ->
pure (Nothing, latestMigrationsVersion)
_ -> do
applied <- selectList [] [Asc AppMigrationDbVersion]
let appliedVersions = fmap (appMigrationDbVersion . entityVal) applied
currentDbVersion = CurrentDbVersion $ maybe 0 maximum (fromNullable appliedVersions)
pure (Just currentDbVersion, latestMigrationsVersion)
where
latestMigrationsVersion = LatestMigrationVersion $ getLatestVersion (appMigrations :: AppMigrations m)
getOperations :: AppMigrations m -> Maybe DbVersion -> Either (DbVersion, DbVersion) (DbVersion, DbVersion, [OperationSpec m])
getOperations migration mVersion =
case getPathValues edges start end of
Just pathValues -> Right (start, end, concat pathValues)
Nothing -> Left (start, end)
where
edges = map (\(path := ops) -> (path, ops)) migration
start = fromMaybe (getFirstVersion migration) mVersion
end = getLatestVersion migration
getFirstVersion = maybe 0 minimum . fromNullable . map (\((v1, _) := _) -> v1)
getLatestVersion :: AppMigrations m -> DbVersion
getLatestVersion = maybe 0 maximum . fromNullable . map (\((_, v2) := _) -> v2)
operation_create_initial_indexes :: (Applicative m) => OperationSpec m
operation_create_initial_indexes =
("ensure-base-indexes",)
$ pure
$ flip SqlStatement []
<$> [ "CREATE INDEX IF NOT EXISTS idx_bookmark_time ON bookmark (user_id, time DESC)",
"CREATE INDEX IF NOT EXISTS idx_bookmark_shared_time ON bookmark (user_id, shared, time DESC)",
"CREATE INDEX IF NOT EXISTS idx_bookmark_tag_bookmark_id ON bookmark_tag (bookmark_id, id, tag, seq)",
"CREATE INDEX IF NOT EXISTS idx_note_user_created ON note (user_id, created DESC)",
"CREATE INDEX IF NOT EXISTS idx_note_user_shared_created ON note (user_id, shared, created DESC)"
]
operation_normalize_bookmark_utctime_remove_z :: (Applicative m) => OperationSpec m
operation_normalize_bookmark_utctime_remove_z =
("normalize-bookmark-utctime-remove-z",)
$ pure
$ flip SqlStatement []
<$> filter (not . T.null . T.strip) (T.splitOn ";" operationSql)
where
operationSql = decodeUtf8 $(embedFile "appmigrations/002-normalize-bookmark-utctime-remove-z.sql")
operation_normalize_note_utctime_remove_z :: (Applicative m) => OperationSpec m
operation_normalize_note_utctime_remove_z =
("normalize-note-utctime-remove-z",)
$ pure
$ flip SqlStatement []
<$> filter (not . T.null . T.strip) (T.splitOn ";" operationSql)
where
operationSql = decodeUtf8 $(embedFile "appmigrations/002-normalize-note-utctime-remove-z.sql")
-- marker only: the unique_user_note_slug constraint itself is added by
-- runPersistentMigrations, after runPreMigrations has deduplicated any
-- conflicting note slugs
operation_marker_unique_user_note_slug :: (Applicative m) => OperationSpec m
operation_marker_unique_user_note_slug = ("marker-unique-user-note-slug", pure [])