relocant-1.1.0: src/Relocant/Script.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE PackageImports #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
-- | This module deals with migrations that haven't yet been applied.
module Relocant.Script
( Script(..)
, readScripts
, readScript
, apply
, markApplied
-- * Extra
, parseFilePath
, readContent
) where
import Control.Exception (catch, throwIO)
import "crypton" Crypto.Hash (hash)
import Data.Aeson qualified as Aeson
import Data.Aeson ((.=))
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.Char qualified as Char
import Data.List qualified as List
import Data.String (IsString(..))
import Database.PostgreSQL.Simple qualified as DB
import Database.PostgreSQL.Simple.Types qualified as DB (Query(..))
import GHC.Records (HasField(getField))
import Prelude hiding (id)
import System.Directory qualified as D
import System.FilePath ((</>), isExtensionOf, takeBaseName)
import Relocant.Applied (Applied(..))
import Relocant.At (At)
import Relocant.At qualified as At
import Relocant.ID (ID)
import Relocant.Checksum (Checksum(..))
import Relocant.Content (Content(..))
import Relocant.Duration (Duration)
import Relocant.Duration qualified as Duration
import Relocant.Name (Name)
-- | A migration script. Most users would get them by running 'readScripts',
-- but those wanting a more fine-grained control would use 'readScript'.
data Script = Script
{ id :: ID -- ^ if XXX-YYYY.sql is the whole filename, then XXX is the id; see 'parseFilePath'
, name :: Name -- ^ if XXX-YYYY.sql is the whole filename, then XXX-YYYY is the name
, content :: Content
} deriving (Show, Eq)
-- | The content (as in actual bytes) is skipped.
instance Aeson.ToJSON Script where
toJSON s =
Aeson.object
[ "id" .= s.id
, "name" .= s.name
, "checksum" .= show s.checksum
]
instance HasField "bytes" Script ByteString where
getField s =
s.content.bytes
instance HasField "checksum" Script Checksum where
getField s =
s.content.checksum
-- | Read migration 'Script's from a directory. It only tries to read paths
-- ending with the /.sql/ extension and doesn't recurse into subdirectories.
--
-- /Note/: the directory must exist
readScripts :: FilePath -> IO [Script]
readScripts dir = do
paths <- D.listDirectory dir
scripts <- traverse (\path -> readScript (dir </> path)) (filter (isExtensionOf ".sql") paths)
pure (List.sortOn (\script -> script.id) scripts)
-- | Read migration 'Script's from a file. Useful when you need a more fine-grained
-- control over which paths are read then what 'readScripts' provides.
readScript :: FilePath -> IO Script
readScript path = do
let
(id, name) =
parseFilePath path
content <- readContent path
pure Script
{ id
, name
, content
}
-- | Get XXX as the 'ID' and XXX-YYYY as the 'Name' from XXX-YYYY.sql
--
-- Basically, the longest alphanumeric prefix of the basename is the 'ID',
-- and the basename is the 'Name'.
parseFilePath :: FilePath -> (ID, Name)
parseFilePath path = do
let
basename =
takeBaseName path
(id, _rest) =
span Char.isAlphaNum basename
(fromString id, fromString basename)
-- | Get migration 'Script'\'s content and its checksum.
readContent :: FilePath -> IO Content
readContent path = do
bytes <- ByteString.readFile path
pure Content
{ bytes
, checksum = Checksum (hash bytes)
}
applyWith :: (ByteString -> IO Duration) -> Script -> IO Applied
applyWith f s = do
appliedAt <- At.now
durationS <- f s.bytes
pure (applied s appliedAt durationS)
-- | Run a 'Script' against the database, get an 'Applied' migration back if successful.
--
-- /Note:/ You probably want to run this together with 'Relocant.Applied.record'
-- in a single transaction.
-- /Note:/ This has to run inside a transaction context, will error out otherwise.
apply :: Script -> DB.Connection -> IO Applied
apply s conn = do
applyWith (Duration.measure_ . execute_ conn . DB.Query) s
-- Like DB.execute_, but ignores empty query errors.
execute_ :: DB.Connection -> DB.Query -> IO ()
execute_ conn query = do
_ <- DB.withSavepoint conn (DB.execute_ conn query)
pure ()
`catch`
\exc ->
case exc of
DB.QueryError {DB.qeMessage = "execute: Empty query"} ->
pure ()
_ ->
throwIO exc
-- | Get an 'Applied' migration from a 'Script', without running it. This should not
-- be normally used, but can be useful for fixing checksums after cosmetics updates
-- of migration 'Script's (such as adding comments, for example).
markApplied :: Script -> IO Applied
markApplied =
applyWith (\_bytes -> pure mempty)
applied :: Script -> At -> Duration -> Applied
applied s appliedAt durationS = Applied
{ id = s.id
, name = s.name
, content = s.content
, appliedAt
, durationS
}