{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE UndecidableInstances #-}
module Yggdrasil where
import Control.Exception
import Data.List (minimumBy)
import Data.List.NonEmpty qualified as NE
import Data.Text qualified as T
import Data.Time
import Data.UUID.V4
import Database.SQLite.Simple
import NeatInterpolation
import Optics
import Relude
import System.Directory
data YggdrasilEngine = SQLite | PostgreSQL | MySQL deriving (Generic, Eq, Show)
data Yggdrasil = Yggdrasil
{ databaseFilePath :: Text,
migrationsDirectoryPath :: Text,
runMigrations :: Bool,
engine :: YggdrasilEngine
}
deriving (Generic, Eq, Show)
makeFieldLabelsNoPrefix ''Yggdrasil
data RanMigration = RanMigration Text Int Text UTCTime
deriving (Generic, Eq, Show)
instance FromRow RanMigration where
fromRow = RanMigration <$> field <*> field <*> field <*> field
defaultYggdrasil :: Yggdrasil
defaultYggdrasil =
Yggdrasil
{ databaseFilePath = "./resources/test/db.sqlite",
migrationsDirectoryPath = "./resources/migrations/sqlite/",
runMigrations = True,
engine = SQLite
}
runYggdrasil :: (MonadIO m) => Yggdrasil -> m ()
runYggdrasil yggdrasil = when (yggdrasil ^. #runMigrations) $ do
sortedFiles <- getSortedMigrationFiles yggdrasil
case nonEmpty sortedFiles of
Nothing -> error "No valid migration files found!"
Just ordersAndFiles -> do
ranMigrations <- liftIO $ catch (getRanMigrations yggdrasil) handler
let execReqHighest = minimumBy (comparing Down) $ NE.map fst ordersAndFiles
isThereHigher = isJust . find (\p -> fst p > execReqHighest) $ ranMigrations
when isThereHigher $ error "Detected a migration order mismatch! History contains newer items than in migrations folder!"
let toRun = NE.filter (\t -> isNothing $ find (== t) ranMigrations) ordersAndFiles
case nonEmpty toRun of
Nothing -> print ("All migrations up to date!" :: String)
Just toRun' -> do
_ <- print ("About to run migrations!" :: String)
_ <- print toRun'
mapM_ (runMigration yggdrasil) toRun'
where
handler :: SQLError -> IO [(Int, Text)]
handler _ = pure []
getSortedMigrationFiles :: (MonadIO m) => Yggdrasil -> m [(Int, Text)]
getSortedMigrationFiles yggdrasil = do
files <- liftIO $ listDirectory (fromString . T.unpack $ yggdrasil ^. #migrationsDirectoryPath)
let rawFs = mapMaybe (explodeMigrationPath . T.pack) files
rawFilesWithOrder = mapMaybe (liftTupleMaybeFromFst . toIntOrder) rawFs
sortedFiles = sortOn fst rawFilesWithOrder
pure sortedFiles
toIntOrder :: (Text, Text) -> (Maybe Int, Text)
toIntOrder = first (readMaybe . T.unpack)
explodeMigrationPath :: Text -> Maybe (Text, Text)
explodeMigrationPath filePath =
let k = (fmap head . nonEmpty . T.split (== '-')) filePath
in liftTupleMaybeFromFst (k, filePath)
liftTupleMaybeFromFst :: (Maybe a, b) -> Maybe (a, b)
liftTupleMaybeFromFst (Nothing, _) = Nothing
liftTupleMaybeFromFst (Just j, x) = Just (j, x)
parseTextToSqlStatements :: Text -> [Text]
parseTextToSqlStatements = filter (/= "") . map T.strip . T.split (== ';')
runMigration :: (MonadIO m) => Yggdrasil -> (Int, Text) -> m ()
runMigration yggdrasil (ord', fPath) = do
f <- readFileBS (T.unpack (yggdrasil ^. #migrationsDirectoryPath <> fPath))
let maybeFileContents = decodeUtf8' f
case maybeFileContents of
Left e -> print e >> error (show e) -- migration errors should be critical
Right fileContents' -> do
conn <- liftIO $ open (T.unpack $ yggdrasil ^. #databaseFilePath)
-- run statements from parsed file in order
_ <-
liftIO
$ mapM
(\qqq -> execute_ conn (fromString . T.unpack $ qqq))
(parseTextToSqlStatements fileContents')
-- record in migrations table that we ran migration so as to not run again
someUUID <- liftIO nextRandom
now <- liftIO getCurrentTime
-- TODO: add the Yggdrasil versio number when as library
_ <- liftIO $ execute conn (fromString . T.unpack $ insertYggdrasilMigrationQuery) (T.pack . show $ someUUID, ord', fPath, now)
_ <- liftIO $ close conn
pure ()
where
insertYggdrasilMigrationQuery =
[trimming|
INSERT INTO yggdrasil (identifier, order_value, file_name, ran_at) VALUES (?,?,?,?)
|]
getRanMigrations :: (MonadIO m) => Yggdrasil -> m [(Int, Text)]
getRanMigrations yggdrasil = do
conn <- liftIO $ open (fromString . T.unpack $ yggdrasil ^. #databaseFilePath)
(ms :: [RanMigration]) <- liftIO $ catch (query_ conn "SELECT identifier, order_value, file_name, ran_at from yggdrasil") handler
pure . sortOn fst . map mapRanMigration $ ms
where
mapRanMigration (RanMigration _ orderValue fileName _) = (orderValue, fileName)
handler :: SQLError -> IO [RanMigration]
handler _ = pure []