sq-0.0.2: lib/Sq/Migrations.hs
module Sq.Migrations {--}
( migrate
, migration
, Migration
, MigrationId
, MigrationsTable
) -- }
where
import Control.Exception.Safe qualified as Ex
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Resource.Extra qualified as R
import Data.Aeson qualified as Ae
import Data.List qualified as List
import Data.Set (Set)
import Data.Set qualified as Set
import Data.String
import Data.Text qualified as T
import Data.Time qualified as Time
import GHC.Records
import Sq.Connection
import Sq.Decoders
import Sq.Encoders
import Sq.Mode
import Sq.Names
import Sq.Pool
import Sq.Statement
import Sq.Transactional
-- | Name of the database table keeping a registry of executed 'Migration's, by
-- their 'MigrationId'.
--
-- * Same syntax rules as 'Name'.
--
-- * This table will be created and updated by 'migrate' as necessary.
newtype MigrationsTable = MigrationsTable Name
deriving newtype (Eq, Ord, Show, IsString, Ae.FromJSON, Ae.ToJSON)
instance HasField "text" MigrationsTable T.Text where
getField (MigrationsTable x) = x.text
-- | A single 'Migration' consisting of a 'Transactional' action uniquely
-- identified by a 'MigrationId'.
--
-- * Construct with 'migration'.
--
-- * Run through 'migrate'.
data Migration = Migration MigrationId (Transaction 'Write -> IO ())
instance HasField "id" Migration MigrationId where
getField (Migration x _) = x
-- | 'Just' if at least one 'MigrationId' is duplicate.
duplicateMigrationId :: [Migration] -> Maybe MigrationId
duplicateMigrationId = go mempty
where
go :: Set MigrationId -> [Migration] -> Maybe MigrationId
go !mIds = \case
Migration mId _ : ms
| Set.member mId mIds -> Just mId
| otherwise -> go (Set.insert mId mIds) ms
[] -> Nothing
-- | Define a single 'Migration' that, when executed, will perform
-- the given 'Transactional'.
--
-- * See 'Migration'.
migration
:: MigrationId
-> (forall g. Transactional g 'NoRetry 'Write ())
-- ^ Notice the 'NoRetry'. In other words, this 'Transactional'
-- can't perform 'retry' nor any 'Control.Applicative.Alternative'
-- nor 'MonadPlus' features.
-> Migration
migration mId ta = Migration mId \tx -> embed tx ta
-- | Unique identifier for a 'Migration' within a 'MigrationsTable'.
--
-- * You are supposed to type these statically, so construct a 'MigrationId'
-- by typing down the literal string.
newtype MigrationId = MigrationId T.Text
deriving newtype (Eq, Ord, IsString, Show, EncodeDefault, DecodeDefault)
instance HasField "text" MigrationId T.Text where
getField (MigrationId x) = x
--------------------------------------------------------------------------------
createMigrationsTable :: MigrationsTable -> Statement 'Write () ()
createMigrationsTable tbl =
-- We are storing the timestamp in case we need it in the future.
-- We aren't really using it now.
writeStatement mempty mempty $
fromString $
"CREATE TABLE IF NOT EXISTS "
<> show tbl
<> " (ord INTEGER PRIMARY KEY NOT NULL CHECK (ord >= 0)"
<> ", id TEXT UNIQUE NOT NULL"
<> ", time TEXT NOT NULL)"
getMigrations
:: MigrationsTable -> Statement 'Read () (Time.UTCTime, MigrationId)
getMigrations tbl =
readStatement mempty (liftA2 (,) "time" "id") $
fromString $
"SELECT time, id FROM " <> show tbl <> " ORDER BY ord ASC"
pushMigration :: MigrationsTable -> Statement 'Write MigrationId Time.UTCTime
pushMigration tbl =
writeStatement "id" "time" $
fromString $
"INSERT INTO "
<> show tbl
<> " (ord, time, id)"
<> " SELECT t.ord"
<> ", strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now', 'subsecond')"
<> ", $id"
<> " FROM (SELECT coalesce(max(ord) + 1, 0) AS ord FROM "
<> show tbl
<> ") AS t"
<> " RETURNING time"
--------------------------------------------------------------------------------
-- | Run all the migrations in 'Migration's that haven't been run yet.
--
-- * If the 'MigrationId's are not compatible with the current migration
-- history as reported by 'MigrationsTable', there will be an exception.
--
-- * If 'MigrationsTable' doesn't exist, it will be created.
--
-- * All the changes are run in a single 'Transaction', including those to
-- 'MigrationsTable'.
migrate
:: forall a m
. (MonadIO m, Ex.MonadMask m)
=> Pool 'Write
-- ^ Connection 'Pool' to the database to migrate.
-> MigrationsTable
-- ^ Name of the table where the registry of ran 'Migration's is kept.
-> [Migration]
-- ^ 'Migration's to apply to the database, if necessary, in chronological
-- order.
-> ([MigrationId] -> m a)
-- ^ This will be performed __while the write transaction is active__,
-- letting you know which 'MigrationId's are to be performed, and in which
-- order.
--
-- This can be a good place to perform a backup of the database if
-- necessary. Presumably, 'migrate' is being run during the initialization
-- of your program, suggesting that nobody else is trying to write to the
-- database at this time, so it's OK if this code takes a while to run.
--
-- Don't try to acquire a 'Write' transaction here, it will deadlock.
-- It's OK to interact with the 'Pool' through 'Read'-only means.
-> m a
migrate p tbl want k
| Just mId <- duplicateMigrationId want =
Ex.throwString $ "Duplicate migration id: " <> show mId
| otherwise = R.withAcquire (commitTransaction p) \tx -> do
pending <- embed tx do
zero (createMigrationsTable tbl) ()
(nran, ran) <- list (getMigrations tbl) ()
case List.stripPrefix (fmap snd ran) (fmap (.id) want) of
Just _ -> pure $ List.drop (fromIntegral nran) want
Nothing ->
Ex.throwString $
"Incompatible migration history: " <> show (fmap snd ran)
a <- k $ fmap (.id) pending
forM_ pending \(Migration mId f) -> do
liftIO $ f tx
void $ embed tx $ one (pushMigration tbl) mId
pure a