packages feed

persistent-migration-0.0.1: test/Test/Integration/Migration.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-unused-top-binds #-}

module Test.Integration.Migration (testMigrations) where

import Control.Exception (finally)
import Control.Monad (unless, when)
import Data.ByteString.Lazy (ByteString, fromStrict)
import Data.Maybe (mapMaybe)
import Data.Monoid ((<>))
import Data.Pool (Pool)
import Data.Text (Text)
import Data.Yaml (array, encode, object, (.=))
import Database.Persist (Entity(..), get, insertKey, insertMany_, selectList)
import Database.Persist.Migration
    ( AddColumn(..)
    , Column(..)
    , ColumnProp(..)
    , CreateTable(..)
    , DropColumn(..)
    , MigrateBackend
    , Migration
    , Operation(..)
    , RawOperation(..)
    , TableConstraint(..)
    , checkMigration
    , hasMigration
    , (~>)
    )
import Database.Persist.Migration.Utils.Sql (interpolate, uncommas)
import Database.Persist.Sql
    ( PersistValue(..)
    , Single(..)
    , SqlBackend
    , SqlPersistT
    , SqlType(..)
    , rawExecute
    , rawSql
    )
import Database.Persist.TH
    (mkMigrate, mkPersist, persistLowerCase, share, sqlSettings)
import Test.Integration.Utils.RunSql (runMigration, runSql)
import Test.Tasty (TestTree, testGroup)
import Test.Utils.Goldens (goldenVsString)

{- Schema and migration -}

share [mkPersist sqlSettings, mkMigrate "autoMigration"] [persistLowerCase|
Person
  name String
  hometown CityId
  gender String Maybe
  colorblind Bool
  deriving Show
City
  name String
  state String
  UniqueCity name state
  deriving Show
|]

manualMigration :: Migration
manualMigration =
  -- create tables
  [ Operation (0 ~> 1) $
      CreateTable
        { ctName = "city"
        , ctSchema =
            [ Column "id" SqlInt64 [NotNull, AutoIncrement]
            , Column "name" SqlString [NotNull]
            , Column "state" SqlString [NotNull]
            ]
        , ctConstraints =
            [ PrimaryKey ["id"]
            , Unique "unique_city" ["state", "name"]
            ]
        }
  , Operation (1 ~> 2) $
      CreateTable
        { ctName = "person"
        , ctSchema =
            [ Column "id" SqlInt64 [NotNull, AutoIncrement]
            , Column "name" SqlString [NotNull]
            , Column "hometown" SqlInt64 [NotNull, References ("city", "id")]
            ]
        , ctConstraints =
            [ PrimaryKey ["id"]
            ]
        }

  -- add binary sex column
  , Operation (2 ~> 3) $ AddColumn "person" (Column "sex" SqlInt32 []) Nothing

  -- change binary sex to stringly gender
  , Operation (3 ~> 4) $ AddColumn "person" (Column "gender" SqlString []) Nothing
  , Operation (4 ~> 5) migrateGender
  , Operation (5 ~> 6) $ DropColumn ("person", "sex")
  -- shortcut for databases that hadn't already added the sex column
  , Operation (2 ~> 6) $ AddColumn "person" (Column "gender" SqlString []) Nothing

  -- add colorblind column, with everyone currently in the database being not colorblind
  , Operation (6 ~> 7) $
      AddColumn "person" (Column "colorblind" SqlBool [NotNull]) (Just "FALSE")
  ]
  where
    migrateGender = RawOperation "Convert binary sex column into stringly gender column" $
      mapMaybe migrateGender' <$> rawSql "SELECT id, sex FROM person" []
    migrateGender' = \case
      (_, Single Nothing) -> Nothing
      (Single id', Single (Just sex)) ->
        Just $ interpolate "UPDATE person SET gender = ? WHERE id = ?"
          [ PersistText $ sexToGender sex
          , PersistInt64 id'
          ]
    sexToGender :: Int -> Text
    sexToGender sex = if sex == 0 then "Male" else "Female"

{- Test suite -}

-- | A test suite for running migrations.
testMigrations :: FilePath -> MigrateBackend -> IO (Pool SqlBackend) -> TestTree
testMigrations dir backend getPool = testGroup "migrations"
  [ testMigration' "Migrate from empty" 0 []
  , testMigration' "Migrate after CREATE city" 1 []
  , testMigration' "Migrate with v1 person" 2 [insertPerson "David" []]
  , testMigration' "Migrate from sex to gender" 3
      [ insertPerson "David" [("sex", "0")]
      , insertPerson "Elizabeth" [("sex", "1")]
      , insertPerson "Foster" [("sex", "NULL")]
      ]
  , testMigration' "Migrate with default colorblind" 7 [insertPerson "David" []]
  , testMigration' "Migrations are idempotent" 8 [insertPerson "David" [("colorblind", "TRUE")]]
  ]
  where
    testMigration' = testMigration dir backend getPool
    insertPerson name extra =
      let cols = ["name", "hometown"] ++ map fst extra
          vals = ["'" <> name <> "'", "1"] ++ map snd extra
      in rawExecute
          ("INSERT INTO person(" <> uncommas cols <> ") VALUES (" <> uncommas vals <> ")")
          []

-- | Run a test where:
--    * the first N operations have been migrated
--    * the given query is run to populate the database
--    * the remaining operations are migrated
--    * insert some data into the database
--    * output "SELECT * FROM person" to goldens file
--    * clean up database
testMigration
  :: FilePath
  -> MigrateBackend
  -> IO (Pool SqlBackend)
  -> String
  -> Int
  -> [SqlPersistT IO ()]
  -> TestTree
testMigration dir backend getPool name n populateDb = goldenVsString dir name $ do
  pool <- getPool
  let runMigration' = runMigration backend pool
      city = CityKey 1
      insertCity = insertKey city $ City "Berkeley" "CA"

  res <- (`finally` cleanup pool) $ do
    -- test setup
    unless (null setupMigration) $ do
      runMigration' setupMigration
      -- populateDb scripts can use hometown=1
      runSql pool insertCity
    needsMore <- runSql pool $ hasMigration autoMigration
    if n >= length manualMigration
      then when needsMore $ fail "More migrations are detected"
      else unless needsMore $ fail "No more migrations detected"
    mapM_ (runSql pool) populateDb

    -- run migrations and check inserting current models works
    runMigration' manualMigration
    runSql pool $ do
      checkMigration autoMigration
      get city >>= \case
        Just _ -> return ()
        Nothing -> insertCity
      insertMany_
        [ Person "Alice" city (Just "Female") False
        , Person "Bob" city (Just "Male") True
        , Person "Courtney" city Nothing False
        ]
      map entityVal <$> selectList [] []

  return $ showPersons res
  where
    setupMigration = take n manualMigration
    cleanup pool = runSql pool $ do
      rawExecute "DROP TABLE persistent_migration" []
      rawExecute "DROP TABLE person" []
      rawExecute "DROP TABLE city" []

-- | Display a Person as a YAML object.
showPersons :: [Person] -> ByteString
showPersons = fromStrict . encode . array . map showPerson
  where
    showPerson Person{..} = object
      [ "name" .= personName
      , "hometown" .= personHometown
      , "gender" .= personGender
      , "colorblind" .= personColorblind
      ]