{-# LANGUAGE GADTs #-} {-# LANGUAGE ScopedTypeVariables #-} module Test.AutoMigration ( autoMigrationTests ) where import qualified Control.Exception.Safe as ExSafe import qualified Control.Monad.IO.Class as MIO import qualified Data.ByteString.Char8 as B8 import qualified Data.Foldable as Fold import qualified Data.Function as Function import Data.Int (Int32) import Data.List ((\\)) import qualified Data.List as List import qualified Data.List.NonEmpty as NEL import qualified Data.Maybe as Maybe import qualified Data.String as String import Hedgehog ((===)) import qualified Hedgehog as HH import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range import qualified Orville.PostgreSQL as Orville import qualified Orville.PostgreSQL.AutoMigration as AutoMigration import qualified Orville.PostgreSQL.Expr as Expr import qualified Orville.PostgreSQL.PgCatalog as PgCatalog import qualified Orville.PostgreSQL.Raw.Connection as Conn import qualified Orville.PostgreSQL.Raw.RawSql as RawSql import qualified Orville.PostgreSQL.Schema as Schema import qualified Test.Entities.Foo as Foo import qualified Test.PgAssert as PgAssert import qualified Test.PgGen as PgGen import qualified Test.Property as Property import qualified Test.TestTable as TestTable autoMigrationTests :: Orville.ConnectionPool -> Property.Group autoMigrationTests pool = Property.group "AutoMigration" [ prop_raisesErrorIfMigrationLockIsLocked pool , prop_releasesMigrationLockOnError pool , prop_createsMissingTables pool , prop_dropsRequestedTables pool , prop_addsAndRemovesColumns pool , prop_columnsWithSystemNameConflictsRaiseError pool , prop_altersColumnDataType pool , prop_altersColumnDefaultValue_TextNumeric pool , prop_altersColumnDefaultValue_Bool pool , prop_altersColumnDefaultValue_Timelike pool , prop_respectsImplicitDefaultOnSerialFields pool , prop_addAndRemovesUniqueConstraints pool , prop_addAndRemovesForeignKeyConstraints pool , prop_createsMissingSequences pool , prop_dropsRequestedSequences pool , prop_altersModifiedSequences pool , prop_addsAndRemovesMixedIndexes pool , prop_arbitrarySchemaInitialMigration pool ] prop_raisesErrorIfMigrationLockIsLocked :: Property.NamedDBProperty prop_raisesErrorIfMigrationLockIsLocked = Property.singletonNamedDBProperty "Raises an error when the migration lock is hold" $ \pool -> do errOrSuccess <- HH.evalIO $ Orville.runOrville pool $ AutoMigration.withMigrationLock AutoMigration.defaultLockId $ MIO.liftIO $ ExSafe.try $ Orville.runOrville pool $ AutoMigration.withMigrationLock AutoMigration.defaultLockId (pure ()) case errOrSuccess of Left (_err :: AutoMigration.MigrationLockError) -> pure () Right () -> do HH.annotate "Expected MigrationLockError error to be thrown, but it was not" HH.failure prop_releasesMigrationLockOnError :: Property.NamedDBProperty prop_releasesMigrationLockOnError = Property.singletonNamedDBProperty "Releases the migration lock on error" $ \pool -> do HH.evalIO $ Orville.runOrville pool $ -- Acquire a connection before running a second orville context to -- ensure that each context will get a separate connection from the -- pool. Both connections must be open simultaneously for this test -- to be valid since the lock we are testing is held at a session -- level. Orville.withConnection_ $ do MIO.liftIO $ Orville.runOrville pool $ ExSafe.handle (\SimulatedError -> pure ()) $ AutoMigration.withMigrationLock AutoMigration.defaultLockId (ExSafe.throwM SimulatedError) -- If the lock is not released by the previous 'withMigrationLock' -- this second call will fail to acquire the lock and the test will -- fail AutoMigration.withMigrationLock AutoMigration.defaultLockId (pure ()) data SimulatedError = SimulatedError deriving (Show) instance ExSafe.Exception SimulatedError prop_createsMissingTables :: Property.NamedDBProperty prop_createsMissingTables = Property.singletonNamedDBProperty "Creates missing tables" $ \pool -> do let fooTableId = Orville.tableIdentifier Foo.table firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql Foo.table AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable Foo.table] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable Foo.table] length (AutoMigration.migrationPlanSteps firstTimePlan) === 1 _ <- PgAssert.assertTableExists pool (Orville.tableIdUnqualifiedNameString fooTableId) migrationPlanStepStrings secondTimePlan === [] prop_dropsRequestedTables :: Property.NamedDBProperty prop_dropsRequestedTables = Property.singletonNamedDBProperty "Drops requested tables" $ \pool -> do let fooTableId = Orville.tableIdentifier Foo.table firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql Foo.table Orville.executeVoid Orville.DDLQuery $ Orville.mkCreateTableExpr Foo.table AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaDropTable fooTableId] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaDropTable fooTableId] length (AutoMigration.migrationPlanSteps firstTimePlan) === 1 PgAssert.assertTableDoesNotExist pool (Orville.tableIdUnqualifiedNameString fooTableId) migrationPlanStepStrings secondTimePlan === [] prop_addsAndRemovesColumns :: Property.NamedDBProperty prop_addsAndRemovesColumns = Property.namedDBProperty "Adds and removes columns columns" $ \pool -> do let genColumnList = Gen.subsequence ["foo", "bar", "baz", "bat", "bax"] originalColumns <- HH.forAll genColumnList newColumns <- HH.forAll genColumnList let columnsToDrop = originalColumns \\ newColumns originalTableDef = mkIntListTable "migration_test" originalColumns newTableDef = Orville.dropColumns columnsToDrop $ mkIntListTable "migration_test" newColumns firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalTableDef Orville.executeVoid Orville.DDLQuery $ Schema.mkCreateTableExpr originalTableDef AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] migrationPlanStepStrings secondTimePlan === [] tableDesc <- PgAssert.assertTableExists pool "migration_test" PgAssert.assertColumnNamesEqual tableDesc newColumns prop_columnsWithSystemNameConflictsRaiseError :: Property.NamedDBProperty prop_columnsWithSystemNameConflictsRaiseError = Property.singletonNamedDBProperty "An error is raised trying to add a column that conflicts with a system name" $ \pool -> do let tableWithSystemAttributeNames = Orville.mkTableDefinitionWithoutKey "table_with_system_attribute_names" (Orville.marshallField id (Orville.unboundedTextField "tableoid")) result <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql tableWithSystemAttributeNames -- Create the table with no columns first to ensure we go down the -- "add column" path Orville.executeVoid Orville.DDLQuery $ Expr.createTableExpr (Orville.tableName tableWithSystemAttributeNames) [] Nothing [] ExSafe.try $ AutoMigration.autoMigrateSchema AutoMigration.defaultOptions [AutoMigration.SchemaTable tableWithSystemAttributeNames] case result of Left err -> Conn.sqlExecutionErrorSqlState err === Just (B8.pack "42701") Right () -> do HH.annotate "Expected migration to fail, but it did not" HH.failure {- | Migration Guide: @SomeField@ has been removed. @foldMarshallerFields@ can be used to collect data from the fields in a @SqlMarshaller@ while converting the results to whatever type you desire. @since 1.0.0.0 -} data SomeField where SomeField :: Orville.FieldDefinition nullability a -> SomeField describeField :: SomeField -> String describeField (SomeField field) = B8.unpack (RawSql.toExampleBytes $ Orville.fieldColumnDefinition field) prop_altersColumnDataType :: Property.NamedDBProperty prop_altersColumnDataType = Property.namedDBProperty "Alters data type on existing column" $ \pool -> do let baseFieldDefs = -- Serial columns are omitted from this list currently because -- the are pseudo-types in postgresql, rather than real column types. -- We don't handle migrating "away" from them to regular integer type. -- -- time-like and boolean columns are omitted because postgresql raises -- an unable-to-cast error when attempting to migrate between them and -- numeric columns. [ SomeField $ Orville.unboundedTextField "column" , SomeField $ Orville.boundedTextField "column" 1 , SomeField $ Orville.boundedTextField "column" 255 , SomeField $ Orville.fixedTextField "column" 1 , SomeField $ Orville.fixedTextField "column" 255 , SomeField $ Orville.integerField "column" , SomeField $ Orville.smallIntegerField "column" , SomeField $ Orville.bigIntegerField "column" , SomeField $ Orville.doubleField "column" ] mkNullable (SomeField field) = case Orville.fieldNullability field of Orville.NullableField nullable -> SomeField $ nullable Orville.NotNullField notNull -> SomeField $ Orville.nullableField notNull generateFieldDefinition = Gen.element (baseFieldDefs ++ fmap mkNullable baseFieldDefs) SomeField originalField <- HH.forAllWith describeField generateFieldDefinition SomeField newField <- HH.forAllWith describeField generateFieldDefinition let originalTableDef = Orville.mkTableDefinitionWithoutKey "migration_test" (Orville.marshallField id originalField) newTableDef = Orville.mkTableDefinitionWithoutKey "migration_test" (Orville.marshallField id newField) newSqlType = Orville.fieldType newField firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalTableDef Orville.executeVoid Orville.DDLQuery $ Schema.mkCreateTableExpr originalTableDef AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] migrationPlanStepStrings secondTimePlan === [] newTableDesc <- PgAssert.assertTableExists pool "migration_test" attr <- PgAssert.assertColumnExists newTableDesc "column" PgCatalog.pgAttributeTypeOid attr === Orville.sqlTypeOid newSqlType PgCatalog.pgAttributeMaxLength attr === Orville.sqlTypeMaximumLength newSqlType PgCatalog.pgAttributeIsNotNull attr === Orville.fieldIsNotNullable newField genFieldWithMaybeDefault :: HH.Gen a -> (a -> Orville.DefaultValue a) -> Orville.FieldDefinition nullability a -> HH.Gen (Orville.FieldDefinition nullability a) genFieldWithMaybeDefault defaultGen mkDefaultValue fieldDef = do maybeDefault <- Gen.maybe defaultGen pure $ case maybeDefault of Nothing -> fieldDef Just def -> Orville.setDefaultValue (mkDefaultValue def) fieldDef prop_altersColumnDefaultValue_TextNumeric :: Property.NamedDBProperty prop_altersColumnDefaultValue_TextNumeric = Property.namedDBProperty "Alters default value on existing column (text/numeric)" $ \pool -> do let genDefaultText = PgGen.pgText (Range.linear 0 10) genDefaultIntegral :: Integral n => HH.Gen n genDefaultIntegral = Gen.integral (Range.linear (-10) 10) genDefaultDouble = PgGen.pgDouble assertDefaultValuesMigrateProperly pool $ Gen.choice [ SomeField <$> genFieldWithMaybeDefault genDefaultText Orville.textDefault (Orville.unboundedTextField "column") , SomeField <$> genFieldWithMaybeDefault genDefaultText Orville.textDefault (Orville.boundedTextField "column" 10) , SomeField <$> genFieldWithMaybeDefault genDefaultText Orville.textDefault (Orville.fixedTextField "column" 10) , SomeField <$> genFieldWithMaybeDefault genDefaultIntegral Orville.integerDefault (Orville.integerField "column") , SomeField <$> genFieldWithMaybeDefault genDefaultIntegral Orville.smallIntegerDefault (Orville.smallIntegerField "column") , SomeField <$> genFieldWithMaybeDefault genDefaultIntegral Orville.bigIntegerDefault (Orville.bigIntegerField "column") , SomeField <$> genFieldWithMaybeDefault genDefaultDouble Orville.doubleDefault (Orville.doubleField "column") ] prop_altersColumnDefaultValue_Bool :: Property.NamedDBProperty prop_altersColumnDefaultValue_Bool = Property.namedDBProperty "Alters default value on existing column (boolean)" $ \pool -> do assertDefaultValuesMigrateProperly pool $ Gen.choice [ SomeField <$> genFieldWithMaybeDefault Gen.bool Orville.booleanDefault (Orville.booleanField "column") ] prop_altersColumnDefaultValue_Timelike :: Property.NamedDBProperty prop_altersColumnDefaultValue_Timelike = Property.namedDBProperty "Alters default value on existing column (timelike)" $ \pool -> do assertDefaultValuesMigrateProperly pool $ Gen.choice [ -- Fields without default, or with specific times for the default SomeField <$> genFieldWithMaybeDefault PgGen.pgUTCTime Orville.utcTimestampDefault (Orville.utcTimestampField "column") , SomeField <$> genFieldWithMaybeDefault PgGen.pgLocalTime Orville.localTimestampDefault (Orville.localTimestampField "column") , SomeField <$> genFieldWithMaybeDefault PgGen.pgDay Orville.dateDefault (Orville.dateField "column") , -- Fields with "now" for the default pure . SomeField $ Orville.setDefaultValue Orville.currentUTCTimestampDefault (Orville.utcTimestampField "column") , pure . SomeField $ Orville.setDefaultValue Orville.currentLocalTimestampDefault (Orville.localTimestampField "column") , pure . SomeField $ Orville.setDefaultValue Orville.currentDateDefault (Orville.dateField "column") ] prop_respectsImplicitDefaultOnSerialFields :: Property.NamedDBProperty prop_respectsImplicitDefaultOnSerialFields = Property.namedDBProperty "Respects implicit default on serial fields" $ \pool -> do SomeField fieldDef <- HH.forAllWith describeField $ Gen.element [ SomeField $ Orville.serialField "column" , SomeField $ Orville.bigSerialField "column" ] let tableDef = Orville.mkTableDefinitionWithoutKey "migration_test" (Orville.marshallField id fieldDef) firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql tableDef Orville.executeVoid Orville.DDLQuery $ Schema.mkCreateTableExpr tableDef AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable tableDef] originalTableDesc <- PgAssert.assertTableExists pool "migration_test" PgAssert.assertColumnDefaultExists originalTableDesc "column" secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable tableDef] newTableDesc <- PgAssert.assertTableExists pool "migration_test" PgAssert.assertColumnDefaultExists newTableDesc "column" migrationPlanStepStrings secondTimePlan === [] assertDefaultValuesMigrateProperly :: Orville.ConnectionPool -> HH.Gen SomeField -> HH.PropertyT IO () assertDefaultValuesMigrateProperly pool genSomeField = do SomeField originalField <- HH.forAllWith describeField genSomeField SomeField newField <- HH.forAllWith describeField genSomeField let originalTableDef = Orville.mkTableDefinitionWithoutKey "migration_test" (Orville.marshallField id originalField) newTableDef = Orville.mkTableDefinitionWithoutKey "migration_test" (Orville.marshallField id newField) firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalTableDef Orville.executeVoid Orville.DDLQuery $ Schema.mkCreateTableExpr originalTableDef AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] originalTableDesc <- PgAssert.assertTableExists pool "migration_test" PgAssert.assertColumnDefaultMatches originalTableDesc "column" (Orville.fieldDefaultValue originalField) secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] newTableDesc <- PgAssert.assertTableExists pool "migration_test" PgAssert.assertColumnDefaultMatches newTableDesc "column" (Orville.fieldDefaultValue newField) migrationPlanStepStrings secondTimePlan === [] prop_addAndRemovesUniqueConstraints :: Property.NamedDBProperty prop_addAndRemovesUniqueConstraints = Property.namedDBProperty "Adds and removes unique constraints" $ \pool -> do let genColumnList = Gen.subsequence ["foo", "bar", "baz", "bat", "bax"] genConstraintColumns :: [String] -> HH.Gen [NEL.NonEmpty String] genConstraintColumns columns = fmap Maybe.catMaybes $ Gen.list (Range.linear 0 10) $ do subcolumns <- Gen.subsequence columns NEL.nonEmpty <$> Gen.shuffle subcolumns originalColumns <- HH.forAll genColumnList originalConstraintColumns <- HH.forAll $ genConstraintColumns originalColumns newColumns <- HH.forAll genColumnList newConstraintColumns <- HH.forAll $ genConstraintColumns newColumns let columnsToDrop = originalColumns \\ newColumns originalConstraints = fmap mkUniqueConstraint originalConstraintColumns newConstraints = fmap mkUniqueConstraint newConstraintColumns originalTableDef = Orville.addTableConstraints originalConstraints $ mkIntListTable "migration_test" originalColumns newTableDef = Orville.addTableConstraints newConstraints $ Orville.dropColumns columnsToDrop $ mkIntListTable "migration_test" newColumns HH.cover 5 (String.fromString "Adding Constraints") (not $ null (newConstraintColumns \\ originalConstraintColumns)) HH.cover 5 (String.fromString "Dropping Constraints") (not $ null (originalConstraintColumns \\ newConstraintColumns)) firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalTableDef AutoMigration.autoMigrateSchema AutoMigration.defaultOptions [AutoMigration.SchemaTable originalTableDef] AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] HH.annotate ("First time migration steps: " <> show (migrationPlanStepStrings firstTimePlan)) secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] migrationPlanStepStrings secondTimePlan === [] tableDesc <- PgAssert.assertTableExists pool "migration_test" Fold.traverse_ (PgAssert.assertUniqueConstraintExists tableDesc) newConstraintColumns length (PgCatalog.relationConstraints tableDesc) === length (List.nub newConstraintColumns) prop_addAndRemovesForeignKeyConstraints :: Property.NamedDBProperty prop_addAndRemovesForeignKeyConstraints = Property.namedDBProperty "Adds and removes foreign key constraints" $ \pool -> do let genColumnList = Gen.subsequence ["foo", "bar", "baz", "bat", "bax"] localColumns <- HH.forAll genColumnList foreignColumns <- HH.forAll genColumnList let genForeignKeyInfos :: HH.Gen [PgAssert.ForeignKeyInfo] genForeignKeyInfos = fmap Maybe.catMaybes $ Gen.list (Range.linear 0 10) $ do shuffledLocal <- Gen.shuffle localColumns shuffledForeign <- Gen.shuffle foreignColumns references <- Gen.subsequence (zip shuffledLocal shuffledForeign) onUpdateAction <- generateForeignKeyAction onDeleteAction <- generateForeignKeyAction pure $ PgAssert.ForeignKeyInfo <$> NEL.nonEmpty references <*> Just onUpdateAction <*> Just onDeleteAction originalForeignKeyInfos <- HH.forAll genForeignKeyInfos newForeignKeyInfos <- HH.forAll genForeignKeyInfos -- We sort the columns in the unique constraints here to avoid edge cases -- with equivalent unique constraints in a different order. In these -- situations PostgreSQL can end up creating foreign keys that depending on -- indexes with a different ordering than the foreign key, which this test -- would then assume can be dropped even though it cannot. Standardizing -- the order of the columns in the unique constraints ensures this test -- will not try to drop a constraint that is used in both the original and -- new schemas while a foreign key is dropped and a new one added. let originalUniqueConstraints = fmap (mkUniqueConstraint . NEL.sort . fmap snd . PgAssert.foreignKeyInfoReferences) originalForeignKeyInfos newUniqueConstraints = fmap (mkUniqueConstraint . NEL.sort . fmap snd . PgAssert.foreignKeyInfoReferences) newForeignKeyInfos originalForeignKeyConstraints = fmap (mkForeignKeyConstraint "migration_test_foreign") originalForeignKeyInfos newForeignKeyConstraints = fmap (mkForeignKeyConstraint "migration_test_foreign") newForeignKeyInfos originalLocalTableDef = Orville.addTableConstraints originalForeignKeyConstraints $ mkIntListTable "migration_test" localColumns newLocalTableDef = Orville.addTableConstraints newForeignKeyConstraints $ mkIntListTable "migration_test" localColumns originalForeignTableDef = Orville.addTableConstraints originalUniqueConstraints $ mkIntListTable "migration_test_foreign" foreignColumns newForeignTableDef = Orville.addTableConstraints newUniqueConstraints $ mkIntListTable "migration_test_foreign" foreignColumns originalSchema <- HH.forAllWith (show . map AutoMigration.schemaItemSummary) $ Gen.shuffle [ AutoMigration.SchemaTable originalForeignTableDef , AutoMigration.SchemaTable originalLocalTableDef ] newSchema <- HH.forAllWith (show . map AutoMigration.schemaItemSummary) $ Gen.shuffle [ AutoMigration.SchemaTable newForeignTableDef , AutoMigration.SchemaTable newLocalTableDef ] HH.cover 5 (String.fromString "Adding Constraints") (not $ null (newForeignKeyInfos \\ originalForeignKeyInfos)) HH.cover 5 (String.fromString "Dropping Constraints") (not $ null (originalForeignKeyInfos \\ newForeignKeyInfos)) firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalLocalTableDef Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalForeignTableDef AutoMigration.autoMigrateSchema AutoMigration.defaultOptions originalSchema AutoMigration.generateMigrationPlan AutoMigration.defaultOptions newSchema HH.annotate ("First time migration steps: " <> show (migrationPlanStepStrings firstTimePlan)) secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions newSchema HH.annotate ("Second time migration steps: " <> show (migrationPlanStepStrings secondTimePlan)) migrationPlanStepStrings secondTimePlan === [] tableDesc <- PgAssert.assertTableExists pool "migration_test" Fold.traverse_ (PgAssert.assertForeignKeyConstraintExists tableDesc) newForeignKeyInfos length (PgCatalog.relationConstraints tableDesc) === length (List.nub newForeignKeyInfos) prop_addsAndRemovesMixedIndexes :: Property.NamedDBProperty prop_addsAndRemovesMixedIndexes = Property.namedDBProperty "Adds and removes named indexes" $ \pool -> do let genColumnList = Gen.subsequence ["foo", "bar", "baz", "bat", "bax"] originalColumns <- HH.forAll genColumnList originalTestIndexes <- HH.forAll $ generateTestIndexes originalColumns "migration_test" newColumns <- HH.forAll genColumnList newTestIndexes <- HH.forAll $ generateTestIndexes newColumns "migration_test" let columnsToDrop = originalColumns \\ newColumns originalIndexes = fmap mkIndexDefinition originalTestIndexes newIndexes = fmap mkIndexDefinition newTestIndexes originalTableDef = Orville.addTableIndexes originalIndexes $ mkIntListTable "migration_test" originalColumns newTableDef = Orville.addTableIndexes newIndexes $ Orville.dropColumns columnsToDrop $ mkIntListTable "migration_test" newColumns HH.cover 5 (String.fromString "Adding Indexes") (not $ null (newTestIndexes \\ originalTestIndexes)) HH.cover 5 (String.fromString "Dropping Indexes") (not $ null (originalTestIndexes \\ newTestIndexes)) HH.cover 5 (String.fromString "Concurrent Indexes") ( any (\i -> testIndexCreationStrategy i == Orville.Concurrent) (originalTestIndexes <> newTestIndexes) ) firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ TestTable.dropTableDefSql originalTableDef AutoMigration.autoMigrateSchema AutoMigration.defaultOptions [AutoMigration.SchemaTable originalTableDef] AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] HH.annotate ("First time migration steps: " <> show (migrationPlanStepStrings firstTimePlan)) originalTableDesc <- PgAssert.assertTableExists pool "migration_test" Fold.traverse_ (PgAssert.assertIndexExists originalTableDesc <$> testIndexUniqueness <*> testIndexColumns) originalTestIndexes secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaTable newTableDef] migrationPlanStepStrings secondTimePlan === [] newTableDesc <- PgAssert.assertTableExists pool "migration_test" Fold.traverse_ (PgAssert.assertIndexExists newTableDesc <$> testIndexUniqueness <*> testIndexColumns) newTestIndexes length (PgCatalog.relationIndexes newTableDesc) === length (List.nub newTestIndexes) prop_createsMissingSequences :: Property.NamedDBProperty prop_createsMissingSequences = Property.singletonNamedDBProperty "Creates missing sequences" $ \pool -> do let sequenceDef = Orville.mkSequenceDefinition "migration_test_sequence" sequenceId = Orville.sequenceIdentifier sequenceDef firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ Expr.dropSequenceExpr (Just Expr.ifExists) (Orville.sequenceName sequenceDef) AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaSequence sequenceDef] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaSequence sequenceDef] length (AutoMigration.migrationPlanSteps firstTimePlan) === 1 _ <- PgAssert.assertSequenceExists pool (Orville.sequenceIdUnqualifiedNameString sequenceId) migrationPlanStepStrings secondTimePlan === [] prop_dropsRequestedSequences :: Property.NamedDBProperty prop_dropsRequestedSequences = Property.singletonNamedDBProperty "Drops requested tables" $ \pool -> do let sequenceDef = Orville.mkSequenceDefinition "migration_test_sequence" sequenceId = Orville.sequenceIdentifier sequenceDef firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ Expr.dropSequenceExpr (Just Expr.ifExists) (Orville.sequenceName sequenceDef) Orville.executeVoid Orville.DDLQuery $ Orville.mkCreateSequenceExpr sequenceDef AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaDropSequence sequenceId] secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaDropSequence sequenceId] length (AutoMigration.migrationPlanSteps firstTimePlan) === 1 PgAssert.assertSequenceDoesNotExist pool (Orville.sequenceIdUnqualifiedNameString sequenceId) migrationPlanStepStrings secondTimePlan === [] prop_altersModifiedSequences :: Property.NamedDBProperty prop_altersModifiedSequences = Property.namedDBProperty "Alters modified sequences" $ \pool -> do let baseSequenceDef = Orville.mkSequenceDefinition "migration_test_sequence" generateIncrement = Gen.choice [ Gen.int64 (Range.linearFrom 1 1 maxBound) , Gen.int64 (Range.linearFrom (-1) minBound (-1)) ] originalSequenceDef <- HH.forAll $ do increment <- generateIncrement minValue <- Gen.int64 (Range.linearFrom 0 minBound (maxBound - 1)) maxValue <- Gen.int64 (Range.linear (minValue + 1) maxBound) start <- Gen.int64 (Range.linear minValue maxValue) cache <- Gen.int64 (Range.linear 1 maxBound) cycleFlag <- Gen.bool pure . Orville.setSequenceIncrement increment . Orville.setSequenceMinValue minValue . Orville.setSequenceMaxValue maxValue . Orville.setSequenceStart start . Orville.setSequenceCache cache . Orville.setSequenceCycle cycleFlag $ baseSequenceDef newSequenceDef <- HH.forAll $ do mbNewIncrement <- Gen.maybe generateIncrement -- The range between min and max values must contain the current next -- value of the sequence for PostgreSQL to not raise an error. Because -- no value has been fetched from our test sequence, that value is -- the start value from the original sequence definition mbNewMinValue <- Gen.maybe $ Gen.int64 (Range.linear minBound (Orville.sequenceStart originalSequenceDef)) mbNewMaxValue <- Gen.maybe $ Gen.int64 (Range.linear (Orville.sequenceStart originalSequenceDef + 1) maxBound) -- The new start value must lie in the new range of the sequence. If no -- changes are being made to these values then the will remain the same as -- in the original sequence let newMinValue = Maybe.fromMaybe (Orville.sequenceMinValue originalSequenceDef) mbNewMinValue newMaxValue = Maybe.fromMaybe (Orville.sequenceMaxValue originalSequenceDef) mbNewMaxValue mbNewStart <- Gen.maybe $ Gen.int64 (Range.linear newMinValue newMaxValue) mbNewCache <- Gen.maybe $ Gen.int64 (Range.linear 1 maxBound) mbNewCycleFlag <- Gen.maybe Gen.bool pure . maybe id Orville.setSequenceIncrement mbNewIncrement . maybe id Orville.setSequenceMinValue mbNewMinValue . maybe id Orville.setSequenceMaxValue mbNewMaxValue . maybe id Orville.setSequenceStart mbNewStart . maybe id Orville.setSequenceCache mbNewCache . maybe id Orville.setSequenceCycle mbNewCycleFlag $ originalSequenceDef firstTimePlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ Expr.dropSequenceExpr (Just Expr.ifExists) (Orville.sequenceName originalSequenceDef) AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaSequence originalSequenceDef] HH.annotate ("First time steps: " <> show (migrationPlanStepStrings firstTimePlan)) length (AutoMigration.migrationPlanSteps firstTimePlan) === 1 secondTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions firstTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaSequence newSequenceDef] HH.annotate ("Second time steps: " <> show (migrationPlanStepStrings secondTimePlan)) assertSequenceExistsMatching pool originalSequenceDef thirdTimePlan <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions secondTimePlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions [AutoMigration.SchemaSequence newSequenceDef] assertSequenceExistsMatching pool newSequenceDef migrationPlanStepStrings thirdTimePlan === [] assertSequenceExistsMatching :: (HH.MonadTest m, MIO.MonadIO m) => Orville.ConnectionPool -> Orville.SequenceDefinition -> m () assertSequenceExistsMatching pool sequenceDef = do sequenceRelation <- PgAssert.assertSequenceExists pool (Orville.sequenceIdUnqualifiedNameString . Orville.sequenceIdentifier $ sequenceDef) pgSequence <- PgAssert.assertRelationHasPgSequence sequenceRelation PgCatalog.pgSequenceIncrement pgSequence === Orville.sequenceIncrement sequenceDef PgCatalog.pgSequenceStart pgSequence === Orville.sequenceStart sequenceDef PgCatalog.pgSequenceMin pgSequence === Orville.sequenceMinValue sequenceDef PgCatalog.pgSequenceMax pgSequence === Orville.sequenceMaxValue sequenceDef PgCatalog.pgSequenceCache pgSequence === Orville.sequenceCache sequenceDef PgCatalog.pgSequenceCycle pgSequence === Orville.sequenceCycle sequenceDef prop_arbitrarySchemaInitialMigration :: Property.NamedDBProperty prop_arbitrarySchemaInitialMigration = Property.namedDBProperty "An arbitrary list of schema items can be created from scratch" $ \pool -> do testTables <- HH.forAll $ generateTestTables (Range.constant 0 10) HH.cover 75 (String.fromString "With Tables") (not . null $ testTables) HH.cover 75 (String.fromString "With Columns") (not . null $ concatMap testTableColumns testTables) HH.cover 50 (String.fromString "With Indexes") (not . null $ concatMap testTableIndexes testTables) HH.cover 50 (String.fromString "With Unique Constraints") (not . null $ concatMap testTableUniqueConstraints testTables) HH.cover 30 (String.fromString "With Foreign Keys") (not . null $ concatMap testTableForeignKeys testTables) let testSchema = map testTableSchemaItem testTables initialMigrationPlan <- HH.evalIO $ Orville.runOrville pool $ do Orville.executeVoid Orville.DDLQuery $ RawSql.fromString "DROP SCHEMA IF EXISTS orville_migration_test CASCADE" Orville.executeVoid Orville.DDLQuery $ RawSql.fromString "CREATE SCHEMA orville_migration_test" AutoMigration.generateMigrationPlan AutoMigration.defaultOptions testSchema HH.annotate ("Initial migration steps: " <> show (migrationPlanStepStrings initialMigrationPlan)) migrationPlanAfterMigration <- HH.evalIO $ Orville.runOrville pool $ do AutoMigration.executeMigrationPlan AutoMigration.defaultOptions initialMigrationPlan AutoMigration.generateMigrationPlan AutoMigration.defaultOptions testSchema migrationPlanStepStrings migrationPlanAfterMigration === [] Fold.traverse_ (assertTableStructure pool) testTables assertTableStructure :: (HH.MonadTest m, MIO.MonadIO m) => Orville.ConnectionPool -> TestTable -> m () assertTableStructure pool testTable = do tableDesc <- PgAssert.assertTableExistsInSchema pool "orville_migration_test" (testTableName testTable) PgAssert.assertColumnNamesEqual tableDesc (testTableColumns testTable) Fold.traverse_ (PgAssert.assertIndexExists tableDesc <$> testIndexUniqueness <*> testIndexColumns) (testTableIndexes testTable) Fold.traverse_ (PgAssert.assertUniqueConstraintExists tableDesc) (testTableUniqueConstraints testTable) Fold.traverse_ (PgAssert.assertForeignKeyConstraintExists tableDesc) (map mkForeignKeyInfo . testTableForeignKeys $ testTable) mkForeignKeyInfo :: TestForeignKey -> PgAssert.ForeignKeyInfo mkForeignKeyInfo testForeignKey = PgAssert.ForeignKeyInfo { PgAssert.foreignKeyInfoReferences = testForeignKeyReferences testForeignKey , PgAssert.foreignKeyInfoOnUpdate = testForeignKeyOnUpdate testForeignKey , PgAssert.foreignKeyInfoOnDelete = testForeignKeyOnDelete testForeignKey } data TestTable = TestTable { testTableName :: String , testTableColumns :: [String] , testTablePrimaryKey :: [String] , testTableIndexes :: [TestIndex] , testTableUniqueConstraints :: [NEL.NonEmpty String] , testTableForeignKeys :: [TestForeignKey] } deriving (Show) data TestIndex = TestIndex { testIndexName :: Maybe String , testIndexUniqueness :: Orville.IndexUniqueness , testIndexCreationStrategy :: Orville.IndexCreationStrategy , testIndexColumns :: NEL.NonEmpty String } deriving (Show, Eq) data TestForeignKey = TestForeignKey { testForeignKeyReferences :: NEL.NonEmpty (String, String) , testForeignKeyTableName :: String , testForeignKeyOnUpdate :: Orville.ForeignKeyAction , testForeignKeyOnDelete :: Orville.ForeignKeyAction } deriving (Show) data TestForeignKeyTarget = TestForeignKeyTarget { testForeignKeyTargetTableName :: String , testForeignKeyTargetColumns :: NEL.NonEmpty String } deriving (Show) testTableForeignKeyTargets :: TestTable -> [TestForeignKeyTarget] testTableForeignKeyTargets testTable = map (TestForeignKeyTarget $ testTableName testTable) (testTableUniqueConstraints testTable) mkIndexDefinition :: TestIndex -> Orville.IndexDefinition mkIndexDefinition testIndex = let strategy = testIndexCreationStrategy testIndex baseIndex = case testIndexName testIndex of Just name -> -- If the test index has a name, use it to test Orville's support for -- custome named indexes let indexBody = Expr.indexBodyColumns . fmap Expr.columnName . testIndexColumns $ testIndex in Orville.mkNamedIndexDefinition (testIndexUniqueness testIndex) name indexBody Nothing -> -- If the test index has no name, use it to test Orville's support for -- unnamed indexes Orville.mkIndexDefinition (testIndexUniqueness testIndex) (Orville.stringToFieldName <$> testIndexColumns testIndex) in Orville.setIndexCreationStrategy strategy baseIndex generateTestTable :: HH.Gen TestTable generateTestTable = do columns <- generateTestTableColumns tableName <- PgGen.pgIdentifierWithPrefix "t_" 1 TestTable tableName <$> pure columns <*> Gen.subsequence columns <*> generateTestIndexes columns tableName <*> generateTestUniqueConstraints columns <*> pure [] -- No foreign keys can be generated until we've generate test tables generateTestTables :: HH.Range Int -> HH.Gen [TestTable] generateTestTables tableCountRange = do tablesWithoutForeignKeys <- fmap (List.nubBy (Function.on (==) testTableName)) (Gen.list tableCountRange generateTestTable) let foreignKeyTargets = concatMap testTableForeignKeyTargets tablesWithoutForeignKeys traverse (addTestTableForeignKeys foreignKeyTargets) tablesWithoutForeignKeys addTestTableForeignKeys :: [TestForeignKeyTarget] -> TestTable -> HH.Gen TestTable addTestTableForeignKeys targets table = do let targetColumnCount = length . testForeignKeyTargetColumns possibleTargets = filter (\t -> targetColumnCount t <= length (testTableColumns table)) targets genForeignKey target = do let targetColumns = testForeignKeyTargetColumns target sourceColumns <- -- nonEmpty can never produce a 'Nothing' here because the target's -- columns are a non-empty list. Gen.mapMaybe NEL.nonEmpty (take (targetColumnCount target) <$> Gen.shuffle (testTableColumns table)) onUpdateAction <- generateForeignKeyAction onDeleteAction <- generateForeignKeyAction pure $ TestForeignKey { testForeignKeyReferences = NEL.zip sourceColumns targetColumns , testForeignKeyTableName = testForeignKeyTargetTableName target , testForeignKeyOnUpdate = onUpdateAction , testForeignKeyOnDelete = onDeleteAction } chosenTargets <- case possibleTargets of [] -> pure [] _ -> Gen.list (Range.linear 0 2) (Gen.element possibleTargets) foreignKeys <- traverse genForeignKey chosenTargets pure $ table {testTableForeignKeys = foreignKeys} generateForeignKeyAction :: HH.Gen Orville.ForeignKeyAction generateForeignKeyAction = Gen.element [ Orville.NoAction , Orville.Restrict , Orville.Cascade , Orville.SetNull , Orville.SetDefault ] generateTestIndexes :: [String] -> String -> HH.Gen [TestIndex] generateTestIndexes columns tableName = do testIndices <- fmap Maybe.catMaybes $ Gen.list (Range.linear 0 10) $ do -- The use of `take 8` is to avoid creating a prefix that would be truncated -- but is also long enough to avoid collision when generating indexes for -- an arbitrary amount of tables indexName <- Gen.maybe $ PgGen.pgIdentifierWithPrefix ((take 8 tableName) <> "i_") 3 subcolumns <- Gen.subsequence columns maybeNonEmptyColumns <- NEL.nonEmpty <$> Gen.shuffle subcolumns uniqueness <- Gen.element [Orville.UniqueIndex, Orville.NonUniqueIndex] strategy <- Gen.frequency [ (10, pure Orville.Transactional) , (1, pure Orville.Concurrent) ] pure $ fmap (TestIndex indexName uniqueness strategy) maybeNonEmptyColumns pure $ (List.nubBy (Function.on (==) testIndexColumns)) testIndices generateTestUniqueConstraints :: [String] -> HH.Gen [NEL.NonEmpty String] generateTestUniqueConstraints columns = fmap Maybe.catMaybes $ Gen.list (Range.linear 0 5) (NEL.nonEmpty <$> Gen.subsequence columns) testTableSchemaItem :: TestTable -> AutoMigration.SchemaItem testTableSchemaItem testTable = let addTableItems :: Orville.TableDefinition key writeEntity readEntity -> Orville.TableDefinition key writeEntity readEntity addTableItems tableDef = Orville.addTableConstraints (testTableForeignKeyDefinition <$> testTableForeignKeys testTable) . Orville.addTableConstraints (mkUniqueConstraint <$> testTableUniqueConstraints testTable) . Orville.addTableIndexes (mkIndexDefinition <$> testTableIndexes testTable) . Orville.setTableSchema "orville_migration_test" $ tableDef in case testTablePrimaryKeyDefinition testTable of Nothing -> AutoMigration.SchemaTable $ addTableItems $ Orville.mkTableDefinitionWithoutKey (testTableName testTable) (intColumnsMarshaller $ testTableColumns testTable) Just primaryKey -> AutoMigration.SchemaTable $ addTableItems $ Orville.mkTableDefinition (testTableName testTable) primaryKey (intColumnsMarshaller $ testTableColumns testTable) testTablePrimaryKeyDefinition :: TestTable -> Maybe (Orville.PrimaryKey [Int32]) testTablePrimaryKeyDefinition testTable = let mkPart (index, column) = Orville.primaryKeyPart (!! index) (Orville.integerField column) in case zip [1 ..] (testTablePrimaryKey testTable) of [] -> Nothing (first : rest) -> Just $ Orville.compositePrimaryKey (mkPart first) (fmap mkPart rest) testTableForeignKeyDefinition :: TestForeignKey -> Orville.ConstraintDefinition testTableForeignKeyDefinition foreignKey = let mkForeignReference (localColumn, foreignColumn) = Orville.foreignReference (Orville.stringToFieldName localColumn) (Orville.stringToFieldName foreignColumn) foreignTableId = Orville.setTableIdSchema "orville_migration_test" . Orville.unqualifiedNameToTableId . testForeignKeyTableName $ foreignKey in Orville.foreignKeyConstraintWithOptions foreignTableId (fmap mkForeignReference $ testForeignKeyReferences foreignKey) ( Orville.defaultForeignKeyOptions { Orville.foreignKeyOptionsOnUpdate = testForeignKeyOnUpdate foreignKey , Orville.foreignKeyOptionsOnDelete = testForeignKeyOnDelete foreignKey } ) generateTestTableColumns :: HH.Gen [String] generateTestTableColumns = List.nub <$> Gen.list (Range.constant 0 10) PgGen.pgIdentifier mkIntListTable :: String -> [String] -> Orville.TableDefinition Orville.NoKey [Int32] [Int32] mkIntListTable tableName columns = Orville.mkTableDefinitionWithoutKey tableName (intColumnsMarshaller columns) intColumnsMarshaller :: [String] -> Orville.SqlMarshaller [Int32] [Int32] intColumnsMarshaller columns = let field (idx, column) = Orville.marshallField (!! idx) $ Orville.integerField column in traverse field (zip [0 ..] columns) mkUniqueConstraint :: NEL.NonEmpty String -> Orville.ConstraintDefinition mkUniqueConstraint columnList = Orville.uniqueConstraint (fmap Orville.stringToFieldName columnList) mkForeignKeyConstraint :: String -> PgAssert.ForeignKeyInfo -> Orville.ConstraintDefinition mkForeignKeyConstraint foreignTableName foreignKeyInfo = let mkForeignReference (localColumn, foreignColumn) = Orville.foreignReference (Orville.stringToFieldName localColumn) (Orville.stringToFieldName foreignColumn) in Orville.foreignKeyConstraintWithOptions (Orville.unqualifiedNameToTableId foreignTableName) (fmap mkForeignReference $ PgAssert.foreignKeyInfoReferences foreignKeyInfo) ( Orville.defaultForeignKeyOptions { Orville.foreignKeyOptionsOnUpdate = PgAssert.foreignKeyInfoOnUpdate foreignKeyInfo , Orville.foreignKeyOptionsOnDelete = PgAssert.foreignKeyInfoOnDelete foreignKeyInfo } ) migrationPlanStepStrings :: AutoMigration.MigrationPlan -> [B8.ByteString] migrationPlanStepStrings = fmap RawSql.toExampleBytes . AutoMigration.migrationPlanSteps