packages feed

beam-sqlite-0.6.0.0: test/Database/Beam/Sqlite/Test/Migrate.hs

module Database.Beam.Sqlite.Test.Migrate (tests) where

import Control.Exception (try, IOException)
import Data.List (isInfixOf)
import Database.SQLite.Simple
import Test.Tasty
import Test.Tasty.HUnit

import qualified Data.List.NonEmpty as NE
import Data.Int (Int32)
import Database.Beam
import Database.Beam.Backend.SQL.BeamExtensions (SqlSerial(..))
import Database.Beam.Sqlite
import Database.Beam.Sqlite.Migrate
import Database.Beam.Migrate
import Database.Beam.Migrate.Simple

import Database.Beam.Sqlite.Test
import Database.Beam.Sqlite.Syntax (SqliteIndexOptions)

tests :: TestTree
tests = testGroup "Migration tests"
  [ verifiesPrimaryKey
  , verifiesNoPrimaryKey
  , verifiesIndex
  , verifiesUniqueIndex
  , migrateNonUniqueToUniqueIndex
  , verifiesForeignKey
  , addingForeignKeyFails
  , verifiesForeignKeyActions
  , foreignKeyActionsWork
  , idempotentMigration
  ]

newtype WithPkT f = WithPkT
  { _with_pk_value :: C f Bool
  } deriving (Generic, Beamable)

instance Table WithPkT where
  newtype PrimaryKey WithPkT f = Pk (C f Bool)
    deriving (Generic, Beamable)

  primaryKey = Pk . _with_pk_value

data WithPkDb entity = WithPkDb
  { _with_pk :: entity (TableEntity WithPkT)
  } deriving (Generic, Database Sqlite)

withPkDbChecked :: CheckedDatabaseSettings Sqlite WithPkDb
withPkDbChecked = defaultMigratableDbSettings

newtype WithoutPkT f = WithoutPkT
  { _without_pk_value :: C f Bool
  } deriving (Generic, Beamable)

instance Table WithoutPkT where
  data PrimaryKey WithoutPkT f = NoPk
    deriving (Generic, Beamable)

  primaryKey _ = NoPk

data WithoutPkDb entity = WithoutPkDb
  { _without_pk :: entity (TableEntity WithoutPkT)
  } deriving (Generic, Database Sqlite)

withoutPkDbChecked :: CheckedDatabaseSettings Sqlite WithoutPkDb
withoutPkDbChecked = defaultMigratableDbSettings

verifiesPrimaryKey :: TestTree
verifiesPrimaryKey = testCase "verifySchema correctly detects primary key" $
  withTestDb $ \conn -> do
    execute_ conn "create table with_pk (with_pk_value bool not null primary key)"
    testVerifySchema conn withPkDbChecked

verifiesNoPrimaryKey :: TestTree
verifiesNoPrimaryKey = testCase "verifySchema correctly handles table with no primary key" $
  withTestDb $ \conn -> do
    execute_ conn "create table without_pk (without_pk_value bool not null)"
    testVerifySchema conn withoutPkDbChecked

testVerifySchema
  :: Database Sqlite db
  => Connection -> CheckedDatabaseSettings Sqlite db -> Assertion
testVerifySchema conn db =
  runBeamSqlite conn (verifySchema migrationBackend db) >>= \case
    VerificationSucceeded -> return ()
    VerificationFailed failures ->
      fail $ "Verification failed: " ++ show failures

-- Shared table type for index tests

newtype IdxT f = IdxT
  { _idx_value :: C f Int32
  } deriving (Generic, Beamable)

instance Table IdxT where
  newtype PrimaryKey IdxT f = IdxPk (C f Int32)
    deriving (Generic, Beamable)
  primaryKey = IdxPk . _idx_value

data IdxDb entity = IdxDb
  { _idx_tbl :: entity (TableEntity IdxT)
  } deriving (Generic, Database Sqlite)

verifiesIndex :: TestTree
verifiesIndex = testCase "verifySchema correctly detects a secondary index" $
  withTestDb $ \conn -> do
    execute_ conn "create table idx_tbl (idx_value int not null primary key)"
    execute_ conn "create index idx_tbl_value on idx_tbl (idx_value)"
    let db :: CheckedDatabaseSettings Sqlite IdxDb
        db = defaultMigratableDbSettings `withDbModification`
              (dbModification @_ @Sqlite)
                { _idx_tbl =
                    addTableIndex "idx_tbl_value" (defaultIndexOptions @SqliteCommandSyntax)
                      (\t -> selectorColumnName _idx_value t NE.:| []) }
    testVerifySchema conn db

verifiesUniqueIndex :: TestTree
verifiesUniqueIndex = testCase "verifySchema correctly detects a UNIQUE secondary index" $
  withTestDb $ \conn -> do
    execute_ conn "create table idx_tbl (idx_value int not null primary key)"
    execute_ conn "create unique index idx_tbl_value_uniq on idx_tbl (idx_value)"
    let db :: CheckedDatabaseSettings Sqlite IdxDb
        db = defaultMigratableDbSettings `withDbModification`
              (dbModification @_ @Sqlite)
                { _idx_tbl =
                    let idxOpts = setUniqueIndexOptions @SqliteCommandSyntax True
                                $ defaultIndexOptions @SqliteCommandSyntax
                    in
                    addTableIndex "idx_tbl_value_uniq" idxOpts
                      (\t -> selectorColumnName _idx_value t NE.:| []) }
    testVerifySchema conn db

-- | Check that we can change the uniqueness of an index in a migration
migrateNonUniqueToUniqueIndex :: TestTree
migrateNonUniqueToUniqueIndex =
  testCase "autoMigrate can change a non-unique index to a unique index" $
  withTestDb $ \conn -> do
    let nonUnique :: CheckedDatabaseSettings Sqlite IdxDb
        nonUnique =
          defaultMigratableDbSettings `withDbModification`
            (dbModification @_ @Sqlite)
              { _idx_tbl =
                  addTableIndex "idx_tbl_value"
                    (defaultIndexOptions @SqliteCommandSyntax)
                    (\t -> selectorColumnName _idx_value t NE.:| []) }
        unique :: CheckedDatabaseSettings Sqlite IdxDb
        unique =
          defaultMigratableDbSettings `withDbModification`
            (dbModification @_ @Sqlite)
              { _idx_tbl =
                  addTableIndex "idx_tbl_value"
                    (setUniqueIndexOptions @SqliteCommandSyntax True $
                     defaultIndexOptions @SqliteCommandSyntax)
                    (\t -> selectorColumnName _idx_value t NE.:| []) }
    runBeamSqlite conn $ autoMigrate migrationBackend nonUnique
    runBeamSqlite conn $ autoMigrate migrationBackend unique

-- Foreign key tests

data ParentT f = ParentT
  { _parent_id :: C f Int32
  } deriving (Generic, Beamable)

instance Table ParentT where
  newtype PrimaryKey ParentT f = ParentPk (C f Int32)
    deriving (Generic, Beamable)
  primaryKey = ParentPk . _parent_id

data ChildT f = ChildT
  { _child_id        :: C f Int32
  , _child_parent_id :: PrimaryKey ParentT f
  } deriving (Generic, Beamable)

instance Table ChildT where
  newtype PrimaryKey ChildT f = ChildPk (C f Int32)
    deriving (Generic, Beamable)
  primaryKey = ChildPk . _child_id

data FkDb entity = FkDb
  { _fk_parent :: entity (TableEntity ParentT)
  , _fk_child  :: entity (TableEntity ChildT)
  } deriving (Generic, Database Sqlite)

verifiesForeignKey :: TestTree
verifiesForeignKey = testCase "verifySchema detects a plain foreign key" $
  withTestDb $ \conn -> do
    execute_ conn "create table fk_parent (parent_id int not null primary key)"
    execute_ conn "create table fk_child  (child_id int not null primary key, \
                  \child_parent_id int not null, \
                  \foreign key (child_parent_id) references fk_parent (parent_id))"
    let db :: CheckedDatabaseSettings Sqlite FkDb
        db = defaultMigratableDbSettings `withDbModification`
              (dbModification @_ @Sqlite)
                { _fk_child =
                    addTableForeignKey (_fk_parent db)
                      (foreignKeyColumns _child_parent_id)
                      primaryKeyColumns
                      ForeignKeyNoAction
                      ForeignKeyNoAction
                    <> modifyCheckedTable id
                         (ChildT { _child_id        = "child_id"
                                 , _child_parent_id = ParentPk "child_parent_id" }) }
    testVerifySchema conn db

-- Schema used to test solver behaviour when a foreign key migration is impossible.
-- Needs to be somewhat large to trigger the exponential blowup in search space.

data WideParentT f = WideParentT
  { _wp_id :: C f Int32
  , _wp_a  :: C f Int32
  , _wp_b  :: C f Int32
  , _wp_c  :: C f Int32
  , _wp_d  :: C f Int32
  } deriving (Generic, Beamable)

instance Table WideParentT where
  newtype PrimaryKey WideParentT f = WideParentPk (C f Int32)
    deriving (Generic, Beamable)
  primaryKey = WideParentPk . _wp_id

data WideChildT f = WideChildT
  { _wc_id        :: C f Int32
  , _wc_parent_id :: PrimaryKey WideParentT f
  , _wc_a         :: C f Int32
  , _wc_b         :: C f Int32
  , _wc_c         :: C f Int32
  , _wc_d         :: C f Int32
  } deriving (Generic, Beamable)

instance Table WideChildT where
  newtype PrimaryKey WideChildT f = WideChildPk (C f Int32)
    deriving (Generic, Beamable)
  primaryKey = WideChildPk . _wc_id

data WideFkDb entity = WideFkDb
  { _wide_parent :: entity (TableEntity WideParentT)
  , _wide_child  :: entity (TableEntity WideChildT)
  } deriving (Generic, Database Sqlite)

-- | Check that the solver doesn't grow an enormous search space trying to find
-- a way to add a foreign key constraint (which SQLite cannot do to an existing
-- table).
addingForeignKeyFails :: TestTree
addingForeignKeyFails =
  testCase "autoMigrate fails promptly when asked to add a foreign key" $
  withTestDb $ \conn -> do
    let noFk :: CheckedDatabaseSettings Sqlite WideFkDb
        noFk = defaultMigratableDbSettings
        withFk :: CheckedDatabaseSettings Sqlite WideFkDb
        withFk = noFk `withDbModification`
                   (dbModification @_ @Sqlite)
                     { _wide_child =
                         addTableForeignKey (_wide_parent withFk)
                           (foreignKeyColumns _wc_parent_id)
                           primaryKeyColumns
                           ForeignKeyNoAction
                           ForeignKeyNoAction }
    runBeamSqlite conn $ autoMigrate migrationBackend noFk
    result <- try @IOException (runBeamSqlite conn $ autoMigrate migrationBackend withFk)
    case result of
      Left e
        | "Could not determine migration" `isInfixOf` show e
        -> return ()
      Left e  -> assertFailure $ unlines
        [ "unexpected exception from autoMigrate:"
        , "  - " ++ show e
        ]
      Right _ -> assertFailure $ unlines
        [ "expected autoMigrate to fail:"
        , "  SQLite cannot add foreign key constraints to existing tables"
        ]

verifiesForeignKeyActions :: TestTree
verifiesForeignKeyActions =
  testCase "verifySchema detects a foreign key with ON DELETE & ON UPDATE actions" $
  withTestDb $ \conn -> do
    execute_ conn "create table fk_parent (parent_id int not null primary key)"
    execute_ conn "create table fk_child  (child_id int not null primary key, \
                  \child_parent_id int not null, \
                  \foreign key (child_parent_id) references fk_parent (parent_id) \
                  \on delete cascade on update restrict)"
    let db :: CheckedDatabaseSettings Sqlite FkDb
        db = defaultMigratableDbSettings `withDbModification`
              (dbModification @_ @Sqlite)
                { _fk_child =
                    addTableForeignKey (_fk_parent db)
                      (foreignKeyColumns _child_parent_id)
                      primaryKeyColumns
                      ForeignKeyActionRestrict
                      ForeignKeyActionCascade
                    <> modifyCheckedTable id
                         (ChildT { _child_id        = "child_id"
                                 , _child_parent_id = ParentPk "child_parent_id" }) }
    testVerifySchema conn db

-- | Verifies that foreign key actions are enforced at runtime.
foreignKeyActionsWork :: TestTree
foreignKeyActionsWork =
  testCase "foreign key actions" $
  withTestDb $ \conn -> do
    -- Enable foreign key support (required for SQLite)
    execute_ conn "PRAGMA foreign_keys = ON"
    let db :: CheckedDatabaseSettings Sqlite FkDb
        db = defaultMigratableDbSettings `withDbModification`
              (dbModification @_ @Sqlite)
                { _fk_child =
                    addTableForeignKey (_fk_parent db)
                      (foreignKeyColumns _child_parent_id)
                      primaryKeyColumns
                      ForeignKeyActionCascade
                      ForeignKeyActionCascade
                }
        unc = unCheckDatabase db
    runBeamSqlite conn $ autoMigrate migrationBackend db

    -- Insert two parents and three children (two for parent 1, one for parent 2).
    runBeamSqlite conn $ do
      runInsert $ insert (_fk_parent unc) $ insertValues
        [ ParentT 1, ParentT 2 ]
      runInsert $ insert (_fk_child unc) $ insertValues
        [ ChildT 1 (ParentPk 1), ChildT 2 (ParentPk 1), ChildT 3 (ParentPk 2) ]

    -- ON UPDATE CASCADE: changing parent_id 1 → 10 should cascade to child rows.
    runBeamSqlite conn $
      runUpdate $ update (_fk_parent unc)
        (\p -> _parent_id p <-. val_ 10)
        (\p -> _parent_id p ==. val_ 1)
    childrenOf10 <- runBeamSqlite conn $ runSelectReturningList $ select $
      filter_ (\c -> let ParentPk pid = _child_parent_id c in pid ==. val_ 10) $ all_ (_fk_child unc)
    assertEqual "two children should now reference updated parent id 10"
      2 (length childrenOf10)
    childrenOf1 <- runBeamSqlite conn $ runSelectReturningList $ select $
      filter_ (\c -> let ParentPk pid = _child_parent_id c in pid ==. val_ 1) $ all_ (_fk_child unc)
    assertEqual "no children should still reference old parent id 1"
      0 (length childrenOf1)

    -- ON DELETE CASCADE: deleting parent 2 should remove its child row.
    runBeamSqlite conn $
      runDelete $ delete (_fk_parent unc) (\p -> _parent_id p ==. val_ 2)
    childrenOf2 <- runBeamSqlite conn $ runSelectReturningList $ select $
      filter_ (\c -> let ParentPk pid = _child_parent_id c in pid ==. val_ 2) $ all_ (_fk_child unc)
    assertEqual "child of deleted parent 2 should be removed"
      0 (length childrenOf2)
    allChildren <- runBeamSqlite conn $ runSelectReturningList $ select $
      all_ (_fk_child unc)
    assertEqual "only the two children of parent 10 should remain"
      2 (length allChildren)

--------------------------------------------------------------------------------
-- Ensure migration is idempotent (no data loss due to internal tables)

data SimpleT f = SimpleT
  { _auto_incr_id :: C f (SqlSerial Int32)
  } deriving (Generic, Beamable)

instance Table SimpleT where
  newtype PrimaryKey SimpleT f = AutoIncrPk (C f (SqlSerial Int32))
    deriving (Generic, Beamable)
  primaryKey = AutoIncrPk . _auto_incr_id

data SimpleDb entity = SimpleDb
  { _simple_tbl :: entity (TableEntity SimpleT)
  } deriving (Generic, Database Sqlite)

simpleCheckedDb :: CheckedDatabaseSettings Sqlite SimpleDb
simpleCheckedDb = defaultMigratableDbSettings

idempotentMigration :: TestTree
idempotentMigration =
  testCase "autoMigrate is idempotent" $
  withTestDb $ \conn -> do
    runBeamSqlite conn $ autoMigrate migrationBackend simpleCheckedDb
    -- Insert a row so that sqlite_sequence is populated in sqlite_master.
    execute_ conn "INSERT INTO simple_tbl DEFAULT VALUES"
    -- Make sure we don't think a migration would lose data due to the
    -- presence of the sqlite_sequence table.
    runBeamSqlite conn $ autoMigrate migrationBackend simpleCheckedDb

--------------------------------------------------------------------------------