Specify DB migrations in terms of your persistent model
[[ 🗃
^0rd3E persistent-migration
]] ::
[📥 Inbox]
[📤 Outbox]
[🐤 Followers]
[🤝 Collaborators]
[🛠 Changes]
Clone
HTTPS:
darcs clone https://vervis.peers.community/repos/0rd3E
SSH:
darcs clone USERNAME@vervis.peers.community:0rd3E
Tags
TODO
Migration.hs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 | {- This file is part of persistent-migration.
-
- Written in 2016, 2017, 2018, 2019, 2020
- by fr33domlover <fr33domlover@riseup.net>.
-
- ♡ Copying is an act of love. Please copy, reuse and share.
-
- The author(s) have dedicated all copyright and related and neighboring
- rights to this software to the public domain worldwide. This software is
- distributed without any warranty.
-
- You should have received a copy of the CC0 Public Domain Dedication along
- with this software. If not, see
- <http://creativecommons.org/publicdomain/zero/1.0/>.
-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
-- This is just for UnnecessaryConstraint, remove when not needed anymore
{-# LANGUAGE ConstraintKinds #-}
module Database.Persist.Migration
( unchecked
, runMigrations
, A.addEntity
, A.addEntities
, A.renameEntity
, A.removeEntity
, A.addFieldPrimOptional
, A.addFieldPrimRequired
, A.addFieldRefOptional
, A.addFieldRefRequired
, A.addFieldRefRequired'
, A.addFieldRefRequired''
, A.addFieldRefRequiredEmpty
, A.renameField
, A.removeField
, A.addUnique
, A.renameUnique
, A.removeUnique
, A.setFieldMaybe
, A.unsetFieldPrimMaybe
, A.unsetFieldRefMaybe
, A.changeFieldTypeImplicit
, A.changeFieldTypePrimRequiredFreeHs
)
where
import Control.Monad
import Control.Monad.IO.Class (MonadIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader
import Control.Monad.Trans.Except
import Data.Bifunctor
import Data.Foldable
import Data.List (sort)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.Proxy
import Data.Text (Text)
import Database.Persist hiding (Entity, EntityDef (..), FieldDef (..))
import Database.Persist.Sql (SqlBackend, toSqlKey)
import Database.Persist.TH
import qualified Database.Persist as P
import Database.Persist.BackendDataType
import Database.Persist.Schema
import Database.Persist.Schema.Types
import Database.Persist.Schema.Validate (Schema)
import qualified Database.Persist.Migration.Actions as A
import qualified Database.Persist.Schema.Validate as V
import qualified Data.Text as T
-- TODO check if I can use HasPersistBackend to eliminate the need for SchemaT
-- by using the SchemaBackend directly as the backend
share [mkPersist sqlSettings { mpsGeneric = True }] [persistLowerCase|
SchemaVersion
component Text
number Int
UniqueSchemaVersion component
|]
migrateSchemaVersionIfExists
:: ( MonadIO m
, PersistSchema b
, PersistFieldBackend Text b
, PersistUniqueRead b
, PersistStoreWrite b
, UnnecessaryConstraint (BaseBackend b)
)
=> SchemaT b m (Maybe Text)
migrateSchemaVersionIfExists = withReaderT (second $ const "") $ do
has <- hasSchemaEntity
if has
then do
v <- determineVersion
case v of
Left fs ->
return $ Just $
"Schema version entity's fields unexpected: " <>
T.pack (show fs)
Right True -> return Nothing
Right False -> do
let component = "component"
addFieldPrimRequired entity ("" :: Text) component
addUnique entity $
Unique "UniqueSchemaVersion" [component]
lift $ do
mesv <- getBy $ UniqueSchemaVersion ""
case mesv of
Nothing ->
return $ Just "Schema verion record not found"
Just (P.Entity svid sv) -> do
delete svid
insert_ sv
return Nothing
else return Nothing
where
entity = EntityName "SchemaVersion"
determineVersion =
upToDate . sort . map unFieldName <$> getFieldNames entity
where
upToDate ["component", "id", "number"] = Right True
upToDate ["id", "number"] = Right False
upToDate l = Left l
-- As of LTS-11.5, when mpsGeneric is True, persistent-template generates a
-- ToBackendKey instance with a PersistStore constraint. It seems to me that it
-- remained from the days PersistStore was the at the top of the typeclass
-- hierarchy in persistent, and it should be changed to require just
-- PersistCore. I sent an email to persistent maintainers about this. For now,
-- I'm using this alias to make it clear that this constraint is not really
-- needed and should eventually be removed.
--
-- As of LTS 13.22, this is still not fixed.
type UnnecessaryConstraint = PersistStoreWrite
getDbSchemaVersion
:: ( MonadIO m
, PersistEntityBackend (SchemaVersionGeneric b) ~ BaseBackend b
, PersistUniqueRead b
, UnnecessaryConstraint b
)
=> Text -> ReaderT (BaseBackend b) m (Maybe Int)
getDbSchemaVersion comp =
fmap (schemaVersionNumber . entityVal) <$> getBy (UniqueSchemaVersion comp)
setDbSchemaVersion
:: ( MonadIO m
, PersistEntityBackend (SchemaVersionGeneric b) ~ BaseBackend b
, PersistUniqueWrite b
)
=> Text -> Int -> ReaderT b m ()
setDbSchemaVersion c v =
void $ upsert (SchemaVersion c v) [SchemaVersionNumber =. v]
addSchemaEntity
:: ( MonadIO m
, PersistSchema b
, PersistFieldBackend Text b
, PersistFieldBackend Int b
)
=> SchemaT b m ()
addSchemaEntity = withReaderT (second $ const "") $ addEntity schemaEntity
where
schemaEntity
:: (PersistFieldBackend Int b, PersistFieldBackend Text b) => Entity b
schemaEntity = Entity
{ entityName = EntityName "SchemaVersion"
, entityFields =
[ Field
{ fieldName = "number"
, fieldType = FTPrim $ backendDataType (Proxy :: Proxy Int)
, fieldMaybe = FieldRequired
}
, Field
{ fieldName = "component"
, fieldType = FTPrim $ backendDataType (Proxy :: Proxy Text)
, fieldMaybe = FieldRequired
}
]
, entityUniques =
[ Unique "UniqueSchemaVersion" ["component"]
]
}
getSchemaVersion
:: ( MonadIO m
, PersistEntityBackend (SchemaVersionGeneric b) ~ BaseBackend b
, PersistUniqueRead b
, UnnecessaryConstraint b
)
=> Bool -> SchemaT b m (Maybe Int)
getSchemaVersion True = do
comp <- asks snd
lift $ getDbSchemaVersion comp
getSchemaVersion False = return Nothing
-- | Sometimes you want to perform migrations without checking them against the
-- schema on the Haskell side. For example, some backend specific schema
-- change:
--
-- > unchecked $ resizeQueryCache "Person" 4096
--
-- Or perhaps insert data into a table:
--
-- > unchecked $ lift $ insert_ $ Person "Cecile" 35
unchecked :: SchemaT b m () -> Migration b m
unchecked a = (Right, a)
checkMigrations
:: Applicative m
=> Schema b -> [Migration b m] -> ExceptT Text (SchemaT b m) (Schema b)
checkMigrations schema migrations =
ExceptT . pure $ V.runSchemaActions (map fst $ migrations) schema
entitiesMatch
:: Schema b
-> [EntityName]
-> Bool
entitiesMatch schema entities =
let dnames = map unEntityName entities
snames = map unEntityName $ V.schemaEntityNames schema
in sort snames == sort dnames
-- | Run the migration system. The migration process is:
--
-- * Check the schema version of the DB
-- * Compare to the schema version of the app, which is the length of the list
-- * If any migrations are required, run them
-- * Update the schema version in the DB
runMigrations
:: ( MonadIO m
, PersistEntityBackend (SchemaVersionGeneric b) ~ BaseBackend b
, PersistFieldBackend Text b
, PersistFieldBackend Int b
, PersistUniqueWrite b
, PersistSchema b
, UnnecessaryConstraint b
)
=> SchemaBackend b
-- ^ Backend for DB schema operations. Note that it's currently not checked
-- whether it matches the @persistent@ DB backend you're using, so make
-- sure you use matching ones. For example, if you're using PostgreSQL and
-- creating the connection pool using "Database.Persist.PostgreSQL", make
-- sure you're using the @schemaBackend@ from
-- "Database.Persist.Schema.PostgreSQL".
-> Text
-- ^ Component
-> Int
-- ^ Initial schema version your application is at, at the point you're
-- starting to use this library to handle your DB migrations.
--
-- If you're using this library right from the start, i.e. you don't have
-- deployments running with DBs and data in them, then this parameter
-- should be 0.
--
-- If you're starting to use this library after you've already added
-- entities and built and deployed your app and it has deployments with DB
-- data, here are the steps to start using this library:
--
-- (1) Start your list of changes that you pass to this function, with
-- adding all the entities you currently have. You can use the
-- "Database.Persist.Schema.TH" to easily parse your whole @persistent@
-- model file into 'addEntity' actions.
-- (2) After you write that initial list of migration actions, check how
-- many you have on the list. It may be just 1, or much more, it's up
-- to you and doesn't really matter. Anyway, that number, the length of
-- your initial list, is the number you should pass as this parameter.
-- (3) Each deployment you wish to upgrade to the latest version of your
-- software should first upgrade to the last version before you started
-- using this library. That's of course done using whatever DB
-- migration method you were using until now. /Then/ you can upgrade
-- the deployment further to new versions, and migration will work
-- transparently.
--
-- Note that if you start a new fresh deployment, you don't need to
-- manually migrate it to the point before using this library. It can just
-- use this migration system from the start, transparently. This works
-- because if no schema version is found in the database, it is checked
-- whether any tables exist in the database at all:
--
-- * If no tables exist, this is a fresh deployment, starting from schema
-- version 0 and just running the migration actions from the start
-- * If tables exist, this is an existing deployment, which first has to be
-- manually migrated (or using whatever migration tool you used before)
-- to the point before you started using this library, and then it can
-- transparently continue from the point specified by this parameter.
-- However the presence of tables doesn't guarantee the database we're
-- connecting to has indeed done the manual migration, so a little check
-- is performed: Since the initial list is usually just adding entities,
-- we compare the entities defined in the initial list with the entities
-- existing in the database. Only the entity names are compared though.
-- If you have a use case in which it's useful to check more than just
-- names, extra checks can be added.
-> [Migration b m]
-- ^ List of migration actions in chronological order.
-> ReaderT b m (Either Text Int)
-- ^ If we brought the database schema version successfully to the app
-- schema version, return the version at which we found the database.
--
-- * If it's identical to the length of the migration list, it means we
-- found the versions equal and didn't need to run migrations (but
-- possibly did something else, such as write the schema version to the
-- database if it wasn't found there).
-- * If it's smaller than that length, it means we did run migrations
-- and now the versions match
-- * If it's bigger than that length, this is a bug in this library because
-- we were supposed to raise an error! So, treat this as an error and
-- please file a bug report
--
-- If we failed to bring the database schema to match the application
-- schema, return an error message.
runMigrations sb comp soFar migrations = flip runReaderT (sb, comp) $ runExceptT $ do
-- soFar is the number of migrations corresponding to previously created
-- entities, it has to be 0 or more
when (soFar < 0) $ throwE "Negative soFar not allowed"
-- soFar can't be bigger than the number of migrations defined, either it's
-- a developer mistake or it's meant to say the migration list describing
-- the migration path up to right before using this library isn't complete,
-- so we refuse to proceed until that list is complete
when (soFar > length migrations) $
throwE "soFar is greater than the length of the migration list"
-- Run migrations on the schema version table itself, if needed
versionTableOk <- lift migrateSchemaVersionIfExists
for_ versionTableOk $ \ e -> throwE $ "Schema version meta error: " <> e
hasSE <- lift hasSchemaEntity
dnames <- lift getEntityNames
let hasE = not $ null dnames
-- If there's no schema entity but other entities exist, we're connecting
-- to a deployment from before this migration system started being used, so
-- the migration list has to have at least one item describing the entities
-- defined in the app before the switch to this library. It can be one or
-- more migrations, but 0 is unacceptable
when (not hasSE && hasE && soFar == 0) $
throwE "Found old deployment but soFar is 0"
-- If there's no schema entity but other entities exist, compare their
-- names to the names of entities defined in the first @soFar@ migrations,
-- to raise the chance we're connected to a deployment that really has
-- manually migrated to the point right before switching to this library,
-- and has all of the first @soFar@ migrations applied
when (not hasSE && hasE) $ do
schema <-
checkMigrations V.emptySchema $ take soFar migrations
let match = entitiesMatch schema dnames
unless match $
throwE "Missing entities, please finish manual migration step"
-- Initial schema version in case the database doesn't yet specify one,
-- i.e. either a previous deployment or a fresh empty database. If entities
-- exist, they should be described by the first @soFar@ migrations, so skip
-- those since they're already applied. Otherwise, it's a new database so
-- we start from zero to apply all migrations
let initial = if hasE then soFar else 0
-- Compare app schema version with DB schema version
mdver <- lift $ getSchemaVersion hasSE
let dver = fromMaybe initial mdver
aver = length migrations
case compare aver dver of
-- Old app version running against DB with newer schema, we can't make
-- the versions match because it requires a newer app that has the
-- up-to-date migration list. So raise an error! Before that also check
-- if the schema version record already existed in the DB. If it did,
-- there's nothing to do. If it didn't, we could write it, but, it
-- means @soFar@ is bigger than the length of the migration list, which
-- is an error, and we already checked for that above. So don't write
-- the version, raise an error.
LT -> throwE $ if isNothing mdver
then "soFar greater than aver! Impossible, we checked that!"
else "Older app version running with newer DB schema version"
-- Versions are equal, no need to run migrations. Write schema version
-- if it didn't exist before in the database, and report success.
EQ -> do
schema <- checkMigrations V.emptySchema migrations
let match = entitiesMatch schema dnames
unless match $
throwE
"Schema versions match, but the entity names in the \
\migrations don't match the entities found in the \
\database!"
unless hasSE $ do
lift addSchemaEntity
lift . lift $ setDbSchemaVersion comp dver
return dver
-- Database schema version is smaller, so run migrations to upgrade it
-- and update schema version
GT -> do
let (done, todo) = splitAt dver migrations
schema <- checkMigrations V.emptySchema done
let match = entitiesMatch schema dnames
unless match $
throwE
"We have migrations to run, but the entity names in \
\the migrations already applied don't match the \
\entities found in the database! Either the database \
\schema version number is wrong, or the migration \
\list is invalid!"
schema2 <- checkMigrations schema todo
lift $ sequence_ $ map snd todo
dnames2 <- lift getEntityNames
let match2 = entitiesMatch schema2 dnames2
unless match2 $
throwE
"We ran migrations, but now the entity names in the \
\migration list don't match the entities found in \
\database! Either the database schema version \
\number was wrong, or the migration list is invalid!"
unless hasSE $ lift addSchemaEntity
lift . lift $ setDbSchemaVersion comp aver
return dver
|