content: {-|
  Module: IHP.SchemaMigration
  Description: Managing Database Migrations
  Copyright: (c) digitally induced GmbH, 2020
  -}
  module IHP.SchemaMigration where
  
  import IHP.Prelude
  import qualified System.Directory as Directory
  import qualified Data.Text as Text
  import qualified Data.Text.IO as Text
  import IHP.ModelSupport hiding (withTransaction)
  import qualified Data.Char as Char
  import IHP.Log.Types
  import IHP.EnvVar
  
  data Migration = Migration
      { revision :: Int
      , migrationFile :: Text
      } deriving (Show, Eq)
  
  data MigrateOptions = MigrateOptions
      { minimumRevision :: !(Maybe Int) -- ^ When deploying a fresh install of an existing app that has existing migrations, it might be useful to ignore older migrations as they're already part of the existing schema
      }
  
  -- | Migrates the database schema to the latest version
  migrate :: (?modelContext :: ModelContext) => MigrateOptions -> IO ()
  migrate options = do
      createSchemaMigrationsTable
  
      let minimumRevision = fromMaybe 0 options.minimumRevision
  
      openMigrations <- findOpenMigrations minimumRevision
      forEach openMigrations runMigration
  
  -- | The sql statements contained in the migration file are executed. Then the revision is inserted into the @schema_migrations@ table.
  --
  -- All queries are executed inside a database transaction to make sure that it can be restored when something goes wrong.
  runMigration :: (?modelContext :: ModelContext) => Migration -> IO ()
  runMigration migration@Migration { revision, migrationFile } = do
      -- | User can specify migrations directory as environment variable (defaults to /Application/Migrations/...)
      migrationFilePath <- migrationPath migration
      migrationSql <- Text.readFile (cs migrationFilePath)
  
      let fullSql = [trimming|
          BEGIN;
              ${migrationSql};
              INSERT INTO schema_migrations (revision) VALUES (?);
          COMMIT;
      |]
      sqlExec (fromString . cs $ fullSql) [revision]
  
      pure ()
  
  -- | Creates the @schema_migrations@ table if it doesn't exist yet
  createSchemaMigrationsTable :: (?modelContext :: ModelContext) => IO ()
  createSchemaMigrationsTable = do
      -- Hide this query from the log
      withoutQueryLogging do
          -- We don't use CREATE TABLE IF NOT EXISTS as adds a "NOTICE: relation schema_migrations already exists, skipping"
          -- This sometimes confuses users as they don't know if the this is an error or not (it's not)
          -- https://github.com/digitallyinduced/ihp/issues/818
          maybeTableName :: Maybe Text <- sqlQueryScalar "SELECT (to_regclass('schema_migrations')) :: text" ()
          let schemaMigrationTableExists = isJust maybeTableName
  
          unless schemaMigrationTableExists do
              let ddl = "CREATE TABLE IF NOT EXISTS schema_migrations (revision BIGINT NOT NULL UNIQUE)"
              _ <- sqlExec ddl ()
              pure ()
  
  -- | Returns all migrations that haven't been executed yet. The result is sorted so that the oldest revision is first.
  findOpenMigrations :: (?modelContext :: ModelContext) => Int -> IO [Migration]
  findOpenMigrations !minimumRevision = do
      let modelContext = ?modelContext
      let ?modelContext = modelContext { logger = (modelContext.logger) { write = \_ -> pure ()} }
  
      migratedRevisions <- findMigratedRevisions
      migrations <- findAllMigrations
      migrations
          |> filter (\Migration { revision } -> not (migratedRevisions |> includes revision))
          |> filter (\Migration { revision } -> revision > minimumRevision)
          |> pure
  
  -- | Returns all migration revisions applied to the database schema
  --
  -- >>> findMigratedRevisions
  -- [ 1604850570, 1604850660 ]
  --
  findMigratedRevisions :: (?modelContext :: ModelContext) => IO [Int]
  findMigratedRevisions = map (\[revision] -> revision) <$> sqlQuery "SELECT revision FROM schema_migrations ORDER BY revision" ()
  
  -- | Returns all migrations found in @Application/Migration@
  --
  -- >>> findAllMigrations
  -- [ Migration { revision = 1604850570, migrationFile = "Application/Migration/1604850570-create-projects.sql" } ]
  --
  -- The result is sorted so that the oldest revision is first.
  findAllMigrations :: IO [Migration]
  findAllMigrations = do
      migrationDir <- detectMigrationDir
      directoryFiles <- Directory.listDirectory (cs migrationDir)
      directoryFiles
          |> map cs
          |> filter (\path -> ".sql" `isSuffixOf` path)
          |> mapMaybe pathToMigration
          |> sortBy (comparing revision)
          |> pure
  
  -- | Given a path such as Application/Migrate/00-initial-migration.sql it returns a Migration
  --
  -- Returns Nothing if the path is not following the usual migration file path convention.
  --
  -- >>> pathToMigration "Application/Migration/1604850570-create-projects.sql"
  -- Migration { revision = 1604850570, migrationFile = "Application/Migration/1604850570-create-projects.sql" }
  --
  pathToMigration :: Text -> Maybe Migration
  pathToMigration fileName = case revision of
          Just revision -> Just Migration { migrationFile = fileName, revision }
          Nothing -> Nothing
      where
          revision :: Maybe Int
          revision = fileName
                  |> Text.split (not . Char.isDigit)
                  |> head
                  |> fmap textToInt
                  |> join
  
  migrationPath :: Migration -> IO Text
  migrationPath Migration { migrationFile } = do
      migrationDir <- detectMigrationDir
      pure (migrationDir <> migrationFile)
  
  detectMigrationDir :: IO Text
  detectMigrationDir =
      envOrDefault "IHP_MIGRATION_DIR" "Application/Migration/"
  
  