packages feed

poppy-1.0.0: src/Poppy/Internal/Migrate.hs

{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_HADDOCK hide #-}

-- | Apply hand-written @.sql@ files and record names in @_poppy_migrations@.
module Poppy.Internal.Migrate
  ( MigrateError (..),
    applyMigrations,
  )
where

import Control.Exception (IOException, displayException, try)
import Control.Monad (filterM, void)
import qualified Data.ByteString as B8
import Data.List (isPrefixOf, sort)
import Data.Text (Text)
import qualified Data.Text as T
import Database.PostgreSQL.Simple (Connection, Only (..))
import qualified Database.PostgreSQL.Simple as PG
import Database.PostgreSQL.Simple.Types (Query (..))
import Poppy.Internal.Db (DbPool, withConn)
import Poppy.Internal.Errors (ORMError)
import Poppy.Internal.Sql (catchSql)
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (takeExtension, (</>))

-- | Missing directory / unreadable file, or a SQL file that Postgres rejected.
data MigrateError
  = -- | Directory missing or a migration file could not be read.
    MigrateDirectoryError Text
  | -- | Postgres rejected that file; the name is not recorded.
    MigrateFailed Text ORMError
  deriving (Show, Eq)

-- | Run pending @*.sql@ files in @dir@ (sorted, not hidden) against the pool.
-- Creates @_poppy_migrations@ if needed. Returns names applied on this call.
-- A failed file is not recorded; fix it and re-run. Poppy does not generate SQL.
applyMigrations :: DbPool -> FilePath -> IO (Either MigrateError [Text])
applyMigrations pool dir = do
  listed <- listMigrationFiles dir
  case listed of
    Left err -> pure (Left err)
    Right files -> withConn pool $ \conn -> do
      ensureHistoryTable conn
      applied <- fetchApplied conn
      let pending = filter (\(name, _) -> name `notElem` applied) files
      applyPending conn pending

listMigrationFiles :: FilePath -> IO (Either MigrateError [(Text, FilePath)])
listMigrationFiles dir = do
  exists <- doesDirectoryExist dir
  if not exists
    then pure (Left (MigrateDirectoryError ("not a directory: " <> T.pack dir)))
    else do
      names <- listDirectory dir
      let sqlNames = sort (filter isMigrationName names)
      fileNames <- filterM (\name -> doesFileExist (dir </> name)) sqlNames
      pure (Right [(T.pack name, dir </> name) | name <- fileNames])

isMigrationName :: FilePath -> Bool
isMigrationName name =
  takeExtension name == ".sql" && not ("." `isPrefixOf` name)

ensureHistoryTable :: Connection -> IO ()
ensureHistoryTable conn =
  void (PG.execute_ conn historyTableSql)

historyTableSql :: Query
historyTableSql =
  "CREATE TABLE IF NOT EXISTS _poppy_migrations (\
  \ name TEXT PRIMARY KEY,\
  \ applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP\
  \)"

fetchApplied :: Connection -> IO [Text]
fetchApplied conn = do
  rows <- PG.query_ conn "SELECT name FROM _poppy_migrations"
  pure [name | Only name <- rows]

applyPending ::
  Connection ->
  [(Text, FilePath)] ->
  IO (Either MigrateError [Text])
applyPending conn = go []
  where
    go acc [] = pure (Right (reverse acc))
    go acc ((name, path) : rest) = do
      result <- applyOne conn name path
      case result of
        Left err -> pure (Left err)
        Right () -> go (name : acc) rest

applyOne :: Connection -> Text -> FilePath -> IO (Either MigrateError ())
applyOne conn name path = do
  sqlResult <- try @IOException (B8.readFile path)
  case sqlResult of
    Left ex ->
      pure (Left (MigrateDirectoryError (T.pack (displayException ex))))
    Right sql -> do
      result <-
        catchSql $
          PG.withTransaction conn $ do
            _ <- PG.execute_ conn (Query sql)
            _ <- PG.execute conn insertHistorySql (Only name)
            pure ()
      case result of
        Left err -> pure (Left (MigrateFailed name err))
        Right () -> pure (Right ())

insertHistorySql :: Query
insertHistorySql = "INSERT INTO _poppy_migrations (name) VALUES (?)"