diff --git a/.gitignore b/.gitignore index 4d787249f..5b899f9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ target *.iml .idea **/ivy-ide-settings.properties +PLAN-*.md +CLAUDE.md diff --git a/morf-core/src/main/java/org/alfasoftware/morf/guicesupport/MorfModule.java b/morf-core/src/main/java/org/alfasoftware/morf/guicesupport/MorfModule.java index 1a1fff22b..c83e91b37 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/guicesupport/MorfModule.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/guicesupport/MorfModule.java @@ -70,10 +70,11 @@ public Upgrade provideUpgrade(ConnectionResources connectionResources, ViewDeploymentValidator viewDeploymentValidator, DatabaseUpgradePathValidationService databaseUpgradePathValidationService, GraphBasedUpgradeBuilderFactory graphBasedUpgradeBuilderFactory, - UpgradeConfigAndContext upgradeConfigAndContext) { + UpgradeConfigAndContext upgradeConfigAndContext, + org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck deferredIndexReadinessCheck) { return new Upgrade(connectionResources, factory, upgradeStatusTableService, viewChangesDeploymentHelper, viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeBuilderFactory, - upgradeConfigAndContext); + upgradeConfigAndContext, deferredIndexReadinessCheck); } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java b/morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java index cfc34d3ac..ab6871109 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java @@ -4047,6 +4047,40 @@ public Collection addIndexStatements(Table table, Index index) { } + /** + * Whether this dialect supports deferred index creation. When {@code true}, + * {@link org.alfasoftware.morf.upgrade.SchemaEditor#addIndexDeferred} queues + * the index for background creation. When {@code false}, deferred requests + * are silently converted to immediate index creation, because the platform's + * {@code CREATE INDEX} blocks DML and deferring would move the lock from the + * upgrade window (when no traffic is flowing) to post-startup (when it is). + * + *

The default returns {@code false}. Dialects that support non-blocking + * DDL (e.g. PostgreSQL {@code CONCURRENTLY}, Oracle {@code ONLINE}) should + * override this to return {@code true}.

+ * + * @return {@code true} if deferred index creation is beneficial on this platform. + */ + public boolean supportsDeferredIndexCreation() { + return false; + } + + + /** + * Generates the SQL to build a deferred index on an existing table. By default this + * delegates to {@link #addIndexStatements(Table, Index)}, which issues a standard + * {@code CREATE INDEX} statement. Platform-specific dialects may override this method + * to emit non-blocking variants (e.g. {@code CREATE INDEX CONCURRENTLY} on PostgreSQL). + * + * @param table The existing table. + * @param index The new index to build in the background. + * @return A collection of SQL statements. + */ + public Collection deferredIndexDeploymentStatements(Table table, Index index) { + return addIndexStatements(table, index); + } + + /** * Helper method to create all index statements defined for a table * @@ -4070,13 +4104,31 @@ protected List createAllIndexStatements(Table table) { * @return The SQL to deploy the index on the table. */ protected Collection indexDeploymentStatements(Table table, Index index) { + return ImmutableList.of(buildCreateIndexStatement(table, index, "")); + } + + + /** + * Builds a {@code CREATE [UNIQUE] INDEX} statement with an optional keyword + * inserted between {@code INDEX} and the index name (e.g. {@code "CONCURRENTLY"}). + * + * @param table The table to create the index on. + * @param index The index to create. + * @param afterIndexKeyword keyword to insert after {@code INDEX}, or empty string for none. + * @return the complete CREATE INDEX SQL string. + */ + protected String buildCreateIndexStatement(Table table, Index index, String afterIndexKeyword) { StringBuilder statement = new StringBuilder(); statement.append("CREATE "); if (index.isUnique()) { statement.append("UNIQUE "); } - statement.append("INDEX ") + statement.append("INDEX "); + if (!afterIndexKeyword.isEmpty()) { + statement.append(afterIndexKeyword).append(' '); + } + statement .append(schemaNamePrefix(table)) .append(index.getName()) .append(" ON ") @@ -4086,7 +4138,7 @@ protected Collection indexDeploymentStatements(Table table, Index index) .append(Joiner.on(", ").join(index.columnNames())) .append(')'); - return ImmutableList.of(statement.toString()); + return statement.toString(); } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/AbstractSchemaChangeVisitor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/AbstractSchemaChangeVisitor.java index df761fd93..55ae84678 100644 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/AbstractSchemaChangeVisitor.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/AbstractSchemaChangeVisitor.java @@ -1,13 +1,18 @@ + package org.alfasoftware.morf.upgrade; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.alfasoftware.morf.jdbc.SqlDialect; import org.alfasoftware.morf.metadata.Index; import org.alfasoftware.morf.metadata.Schema; import org.alfasoftware.morf.metadata.Table; import org.alfasoftware.morf.sql.Statement; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; +import org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService; +import org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeServiceImpl; /** * Common code between SchemaChangeVisitor implementors @@ -20,6 +25,7 @@ public abstract class AbstractSchemaChangeVisitor implements SchemaChangeVisitor protected final Table idTable; protected final TableNameResolver tracker; + private final DeferredIndexChangeService deferredIndexChangeService = new DeferredIndexChangeServiceImpl(); public AbstractSchemaChangeVisitor(Schema currentSchema, UpgradeConfigAndContext upgradeConfigAndContext, SqlDialect sqlDialect, Table idTable) { @@ -66,6 +72,7 @@ public void visit(AddTable addTable) { @Override public void visit(RemoveTable removeTable) { currentSchema = removeTable.apply(currentSchema); + deferredIndexChangeService.cancelAllPendingForTable(removeTable.getTable().getName()).forEach(this::visitStatement); writeStatements(sqlDialect.dropStatements(removeTable.getTable())); } @@ -80,6 +87,7 @@ public void visit(AddColumn addColumn) { @Override public void visit(ChangeColumn changeColumn) { currentSchema = changeColumn.apply(currentSchema); + deferredIndexChangeService.updatePendingColumnName(changeColumn.getTableName(), changeColumn.getFromColumn().getName(), changeColumn.getToColumn().getName()).forEach(this::visitStatement); writeStatements(sqlDialect.alterTableChangeColumnStatements(currentSchema.getTable(changeColumn.getTableName()), changeColumn.getFromColumn(), changeColumn.getToColumn())); } @@ -87,6 +95,7 @@ public void visit(ChangeColumn changeColumn) { @Override public void visit(RemoveColumn removeColumn) { currentSchema = removeColumn.apply(currentSchema); + deferredIndexChangeService.cancelPendingReferencingColumn(removeColumn.getTableName(), removeColumn.getColumnDefinition().getName()).forEach(this::visitStatement); writeStatements(sqlDialect.alterTableDropColumnStatements(currentSchema.getTable(removeColumn.getTableName()), removeColumn.getColumnDefinition())); } @@ -94,23 +103,41 @@ public void visit(RemoveColumn removeColumn) { @Override public void visit(RemoveIndex removeIndex) { currentSchema = removeIndex.apply(currentSchema); - writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(removeIndex.getTableName()), removeIndex.getIndexToBeRemoved())); + String tableName = removeIndex.getTableName(); + String indexName = removeIndex.getIndexToBeRemoved().getName(); + if (deferredIndexChangeService.hasPendingDeferred(tableName, indexName)) { + deferredIndexChangeService.cancelPending(tableName, indexName).forEach(this::visitStatement); + } else { + writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(tableName), removeIndex.getIndexToBeRemoved())); + } } @Override public void visit(ChangeIndex changeIndex) { currentSchema = changeIndex.apply(currentSchema); - writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(changeIndex.getTableName()), changeIndex.getFromIndex())); - writeStatements(sqlDialect.addIndexStatements(currentSchema.getTable(changeIndex.getTableName()), changeIndex.getToIndex())); + String tableName = changeIndex.getTableName(); + Optional existing = deferredIndexChangeService.getPendingDeferred(tableName, changeIndex.getFromIndex().getName()); + if (existing.isPresent()) { + deferredIndexChangeService.cancelPending(tableName, changeIndex.getFromIndex().getName()).forEach(this::visitStatement); + deferredIndexChangeService.trackPending(new DeferredAddIndex(existing.get().getTableName(), changeIndex.getToIndex(), existing.get().getUpgradeUUID())).forEach(this::visitStatement); + } else { + writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(tableName), changeIndex.getFromIndex())); + writeStatements(sqlDialect.addIndexStatements(currentSchema.getTable(tableName), changeIndex.getToIndex())); + } } @Override public void visit(final RenameIndex renameIndex) { currentSchema = renameIndex.apply(currentSchema); - writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(renameIndex.getTableName()), - renameIndex.getFromIndexName(), renameIndex.getToIndexName())); + String tableName = renameIndex.getTableName(); + if (deferredIndexChangeService.hasPendingDeferred(tableName, renameIndex.getFromIndexName())) { + deferredIndexChangeService.updatePendingIndexName(tableName, renameIndex.getFromIndexName(), renameIndex.getToIndexName()).forEach(this::visitStatement); + } else { + writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(tableName), + renameIndex.getFromIndexName(), renameIndex.getToIndexName())); + } } @@ -120,6 +147,7 @@ public void visit(RenameTable renameTable) { currentSchema = renameTable.apply(currentSchema); Table newTable = currentSchema.getTable(renameTable.getNewTableName()); + deferredIndexChangeService.updatePendingTableName(renameTable.getOldTableName(), renameTable.getNewTableName()).forEach(this::visitStatement); writeStatements(sqlDialect.renameTableStatements(oldTable, newTable)); } @@ -196,6 +224,22 @@ private void visitPortableSqlStatement(PortableSqlStatement sql) { } + /** + * @see org.alfasoftware.morf.upgrade.SchemaChangeVisitor#visit(org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex) + */ + @Override + public void visit(DeferredAddIndex deferredAddIndex) { + if (!sqlDialect.supportsDeferredIndexCreation()) { + // Dialect does not support deferred index creation — fall back to + // building the index immediately during the upgrade. + visit(new AddIndex(deferredAddIndex.getTableName(), deferredAddIndex.getNewIndex())); + return; + } + currentSchema = deferredAddIndex.apply(currentSchema); + deferredIndexChangeService.trackPending(deferredAddIndex).forEach(this::visitStatement); + } + + /** * @see org.alfasoftware.morf.upgrade.SchemaChangeVisitor#visit(org.alfasoftware.morf.upgrade.AddIndex) */ diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/HumanReadableStatementProducer.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/HumanReadableStatementProducer.java index a854d4359..195d9fa12 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/HumanReadableStatementProducer.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/HumanReadableStatementProducer.java @@ -160,6 +160,12 @@ public void addIndex(String tableName, Index index) { consumer.schemaChange(HumanReadableStatementHelper.generateAddIndexString(tableName, index)); } + /** @see org.alfasoftware.morf.upgrade.SchemaEditor#addIndexDeferred(java.lang.String, org.alfasoftware.morf.metadata.Index) **/ + @Override + public void addIndexDeferred(String tableName, Index index) { + consumer.schemaChange("Add index (deferred if supported): " + HumanReadableStatementHelper.generateAddIndexString(tableName, index)); + } + /** @see org.alfasoftware.morf.upgrade.SchemaEditor#addTable(org.alfasoftware.morf.metadata.Table) **/ @Override public void addTable(Table definition) { diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeAdaptor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeAdaptor.java index 4cf1a4486..9fbd58178 100644 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeAdaptor.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeAdaptor.java @@ -1,5 +1,7 @@ package org.alfasoftware.morf.upgrade; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; + /** * Interface for adapting schema changes, i.e. {@link SchemaChange} implementations. * @@ -169,6 +171,16 @@ public default RemoveSequence adapt(RemoveSequence removeSequence) { } + /** + * Perform adapt operation on a {@link DeferredAddIndex} instance. + * + * @param deferredAddIndex instance of {@link DeferredAddIndex} to adapt. + */ + public default DeferredAddIndex adapt(DeferredAddIndex deferredAddIndex) { + return deferredAddIndex; + } + + /** * Simply uses the default implementation, which is already no-op. * By no-op, we mean non-changing: the input is passed through as output. @@ -190,22 +202,22 @@ public Combining(SchemaChangeAdaptor first, SchemaChangeAdaptor second) { this.second = second; } - @Override + @Override public AddColumn adapt(AddColumn addColumn) { return second.adapt(first.adapt(addColumn)); } - @Override + @Override public AddTable adapt(AddTable addTable) { return second.adapt(first.adapt(addTable)); } - @Override + @Override public RemoveTable adapt(RemoveTable removeTable) { return second.adapt(first.adapt(removeTable)); } - @Override + @Override public AddIndex adapt(AddIndex addIndex) { return second.adapt(first.adapt(addIndex)); } @@ -269,5 +281,13 @@ public AddSequence adapt(AddSequence addSequence) { public RemoveSequence adapt(RemoveSequence removeSequence) { return second.adapt(first.adapt(removeSequence)); } + + /** + * @see org.alfasoftware.morf.upgrade.SchemaChangeAdaptor#adapt(org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex) + */ + @Override + public DeferredAddIndex adapt(DeferredAddIndex deferredAddIndex) { + return second.adapt(first.adapt(deferredAddIndex)); + } } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeSequence.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeSequence.java index 42ddfdadf..755cd82d9 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeSequence.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeSequence.java @@ -36,6 +36,9 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; /** * Tracks a sequence of {@link SchemaChange}s as various {@link SchemaEditor} @@ -46,6 +49,8 @@ */ public class SchemaChangeSequence { + private static final Log log = LogFactory.getLog(SchemaChangeSequence.class); + private final UpgradeConfigAndContext upgradeConfigAndContext; private final List upgradeSteps; @@ -74,7 +79,9 @@ public SchemaChangeSequence(UpgradeConfigAndContext upgradeConfigAndContext, Lis for (UpgradeStep step : steps) { InternalVisitor internalVisitor = new InternalVisitor(upgradeConfigAndContext.getSchemaChangeAdaptor()); UpgradeTableResolutionVisitor resolvedTablesVisitor = new UpgradeTableResolutionVisitor(); - Editor editor = new Editor(internalVisitor, resolvedTablesVisitor); + UUID uuidAnnotation = step.getClass().getAnnotation(UUID.class); + String upgradeUUID = uuidAnnotation != null ? uuidAnnotation.value() : ""; + Editor editor = new Editor(internalVisitor, resolvedTablesVisitor, upgradeUUID); // For historical reasons, we need to pass the editor in twice step.execute(editor, editor); @@ -227,14 +234,17 @@ private class Editor implements SchemaEditor, DataEditor { private final SchemaChangeVisitor visitor; private final SchemaAndDataChangeVisitor schemaAndDataChangeVisitor; + private final String upgradeUUID; /** * @param visitor The visitor to pass the changes to. + * @param upgradeUUID UUID string of the upgrade step being executed. */ - Editor(SchemaChangeVisitor visitor, SchemaAndDataChangeVisitor schemaAndDataChangeVisitor) { + Editor(SchemaChangeVisitor visitor, SchemaAndDataChangeVisitor schemaAndDataChangeVisitor, String upgradeUUID) { super(); this.visitor = visitor; this.schemaAndDataChangeVisitor = schemaAndDataChangeVisitor; + this.upgradeUUID = upgradeUUID; } @@ -361,12 +371,40 @@ public void removeColumns(String tableName, Column... definitions) { */ @Override public void addIndex(String tableName, Index index) { + if (upgradeConfigAndContext.isDeferredIndexCreationEnabled() + && upgradeConfigAndContext.isForceDeferredIndex(index.getName())) { + log.info("Force-deferring index [" + index.getName() + "] on table [" + tableName + "]"); + addIndexDeferred(tableName, index); + return; + } AddIndex addIndex = new AddIndex(tableName, index); visitor.visit(addIndex); schemaAndDataChangeVisitor.visit(addIndex); } + /** + * @see org.alfasoftware.morf.upgrade.SchemaEditor#addIndexDeferred(java.lang.String, org.alfasoftware.morf.metadata.Index) + */ + @Override + public void addIndexDeferred(String tableName, Index index) { + if (!upgradeConfigAndContext.isDeferredIndexCreationEnabled()) { + addIndex(tableName, index); + return; + } + if (upgradeConfigAndContext.isForceImmediateIndex(index.getName())) { + log.info("Force-immediate index [" + index.getName() + "] on table [" + tableName + "]"); + addIndex(tableName, index); + return; + } + DeferredAddIndex deferredAddIndex = new DeferredAddIndex(tableName, index, upgradeUUID); + visitor.visit(deferredAddIndex); + // schemaAndDataChangeVisitor is intentionally not notified: no DDL runs on tableName + // during this upgrade step, so no table-resolution dependency is created. Auto-cancel + // logic in AbstractSchemaChangeVisitor handles table/column removal. + } + + /** * @see org.alfasoftware.morf.upgrade.SchemaEditor#removeIndex(java.lang.String, org.alfasoftware.morf.metadata.Index) */ @@ -642,5 +680,14 @@ public void visit(AddSequence addSequence) { public void visit(RemoveSequence removeSequence) { changes.add(schemaChangeAdaptor.adapt(removeSequence)); } + + + /** + * @see org.alfasoftware.morf.upgrade.SchemaChangeVisitor#visit(org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex) + */ + @Override + public void visit(DeferredAddIndex deferredAddIndex) { + changes.add(schemaChangeAdaptor.adapt(deferredAddIndex)); + } } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeVisitor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeVisitor.java index 3c8583878..2091f9d59 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeVisitor.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaChangeVisitor.java @@ -15,6 +15,7 @@ package org.alfasoftware.morf.upgrade; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; /** * Interface for any upgrade / downgrade strategy which handles all the @@ -156,6 +157,14 @@ public interface SchemaChangeVisitor { public void visit(RemoveSequence removeSequence); + /** + * Perform visit operation on a {@link DeferredAddIndex} instance. + * + * @param deferredAddIndex instance of {@link DeferredAddIndex} to visit. + */ + public void visit(DeferredAddIndex deferredAddIndex); + + /** * Add the UUID audit record. * diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaEditor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaEditor.java index b771fbb01..b37b3c5dd 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaEditor.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/SchemaEditor.java @@ -138,6 +138,17 @@ public interface SchemaEditor { public void addIndex(String tableName, Index index); + /** + * Causes an add index schema change to be deferred and executed in the background + * after the upgrade completes. The index is reflected in the schema metadata immediately, + * but the actual DDL is executed by {@code DeferredIndexExecutor}. + * + * @param tableName name of table to add index to + * @param index {@link Index} to be added in the background + */ + public void addIndexDeferred(String tableName, Index index); + + /** * Causes a remove index schema change to be added to the change sequence. * diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/Upgrade.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/Upgrade.java index c9fec2e21..058ab8822 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/Upgrade.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/Upgrade.java @@ -77,6 +77,7 @@ public class Upgrade { private final DatabaseUpgradePathValidationService databaseUpgradePathValidationService; private final GraphBasedUpgradeBuilderFactory graphBasedUpgradeBuilderFactory; private final UpgradeConfigAndContext upgradeConfigAndContext; + private final org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck deferredIndexReadinessCheck; public Upgrade( @@ -87,7 +88,8 @@ public Upgrade( ViewDeploymentValidator viewDeploymentValidator, DatabaseUpgradePathValidationService databaseUpgradePathValidationService, GraphBasedUpgradeBuilderFactory graphBasedUpgradeBuilderFactory, - UpgradeConfigAndContext upgradeConfigAndContext) { + UpgradeConfigAndContext upgradeConfigAndContext, + org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck deferredIndexReadinessCheck) { super(); this.connectionResources = connectionResources; this.upgradePathFactory = upgradePathFactory; @@ -97,6 +99,7 @@ public Upgrade( this.databaseUpgradePathValidationService = databaseUpgradePathValidationService; this.graphBasedUpgradeBuilderFactory = graphBasedUpgradeBuilderFactory; this.upgradeConfigAndContext = upgradeConfigAndContext; + this.deferredIndexReadinessCheck = deferredIndexReadinessCheck; } @@ -160,11 +163,13 @@ public static UpgradePath createPath( UpgradePathFactory upgradePathFactory = new UpgradePathFactoryImpl(upgradeScriptAdditionsProvider, upgradeStatusTableServiceFactory); ViewChangesDeploymentHelper viewChangesDeploymentHelper = new ViewChangesDeploymentHelper(connectionResources.sqlDialect()); GraphBasedUpgradeBuilderFactory graphBasedUpgradeBuilderFactory = null; + org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck deferredIndexReadinessCheck = + org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck.create(connectionResources, upgradeConfigAndContext); Upgrade upgrade = new Upgrade( connectionResources, upgradePathFactory, upgradeStatusTableService, viewChangesDeploymentHelper, viewDeploymentValidator, databaseUpgradePathValidationService, - graphBasedUpgradeBuilderFactory, upgradeConfigAndContext); + graphBasedUpgradeBuilderFactory, upgradeConfigAndContext, deferredIndexReadinessCheck); Set exceptionRegexes = Collections.emptySet(); @@ -231,6 +236,10 @@ public UpgradePath findPath(Schema targetSchema, Collection> ignoredIndexes = Map.of(); + // ------------------------------------------------------------------------- + // Deferred index creation + // ------------------------------------------------------------------------- + + /** + * Whether deferred index creation is enabled. When {@code false} (the default), + * {@code addIndexDeferred()} behaves identically to {@code addIndex()} — indexes + * are built immediately during the upgrade. The tracking table is unaffected + * (not dropped or cleaned up); it simply receives no new rows. + */ + private boolean deferredIndexCreationEnabled; + + /** + * Set of index names that should bypass deferred creation and be built immediately during upgrade. + * Only effective when {@link #deferredIndexCreationEnabled} is {@code true}. + */ + private Set forceImmediateIndexes = Set.of(); + + /** + * Set of index names that should be deferred even when the upgrade step uses {@code addIndex()}. + * Only effective when {@link #deferredIndexCreationEnabled} is {@code true}. + */ + private Set forceDeferredIndexes = Set.of(); + + /** + * Number of threads in the deferred index executor thread pool. + */ + private int deferredIndexThreadPoolSize = 1; + + /** + * Maximum number of retry attempts per deferred index operation before marking it permanently FAILED. + */ + private int deferredIndexMaxRetries = 3; + + /** + * Base delay in milliseconds between deferred index retry attempts. + * Each successive retry doubles this delay (exponential backoff). + */ + private long deferredIndexRetryBaseDelayMs = 5_000L; + + /** + * Maximum delay in milliseconds between deferred index retry attempts. + * The exponential backoff is capped at this value. + */ + private long deferredIndexRetryMaxDelayMs = 300_000L; + + /** + * Maximum time in seconds to wait for all deferred index operations to complete + * during the pre-upgrade force-build ({@link org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck#forceBuildAllPending()}). + * Must be strictly greater than zero. + * + *

This is distinct from the {@code timeoutSeconds} parameter on + * {@link org.alfasoftware.morf.upgrade.deferred.DeferredIndexService#awaitCompletion(long)}, + * where zero means "wait indefinitely".

+ */ + private long deferredIndexForceBuildTimeoutSeconds = 28_800L; + + + /** * @see #exclusiveExecutionSteps */ @@ -140,4 +201,174 @@ public List getIgnoredIndexesForTable(String tableName) { return List.of(); } } + + + /** + * @see #deferredIndexCreationEnabled + */ + public boolean isDeferredIndexCreationEnabled() { + return deferredIndexCreationEnabled; + } + + + /** + * @see #deferredIndexCreationEnabled + */ + public void setDeferredIndexCreationEnabled(boolean deferredIndexCreationEnabled) { + this.deferredIndexCreationEnabled = deferredIndexCreationEnabled; + } + + + /** + * @see #forceImmediateIndexes + * @return forceImmediateIndexes set + */ + public Set getForceImmediateIndexes() { + return forceImmediateIndexes; + } + + + /** + * @see #forceImmediateIndexes + */ + public void setForceImmediateIndexes(Set forceImmediateIndexes) { + this.forceImmediateIndexes = forceImmediateIndexes.stream() + .map(String::toLowerCase) + .collect(ImmutableSet.toImmutableSet()); + validateNoIndexConflict(); + } + + + /** + * Check whether the given index name should be forced to build immediately + * during upgrade, bypassing deferred creation. + * + * @param indexName the index name to check + * @return true if the index should be built immediately + */ + public boolean isForceImmediateIndex(String indexName) { + return forceImmediateIndexes.contains(indexName.toLowerCase()); + } + + + /** + * @see #forceDeferredIndexes + * @return forceDeferredIndexes set + */ + public Set getForceDeferredIndexes() { + return forceDeferredIndexes; + } + + + /** + * @see #forceDeferredIndexes + */ + public void setForceDeferredIndexes(Set forceDeferredIndexes) { + this.forceDeferredIndexes = forceDeferredIndexes.stream() + .map(String::toLowerCase) + .collect(ImmutableSet.toImmutableSet()); + validateNoIndexConflict(); + } + + + /** + * Check whether the given index name should be forced to defer during upgrade, + * even when the upgrade step uses {@code addIndex()}. + * + * @param indexName the index name to check + * @return true if the index should be deferred + */ + public boolean isForceDeferredIndex(String indexName) { + return forceDeferredIndexes.contains(indexName.toLowerCase()); + } + + + + /** + * @see #deferredIndexThreadPoolSize + */ + public int getDeferredIndexThreadPoolSize() { + return deferredIndexThreadPoolSize; + } + + + /** + * @see #deferredIndexThreadPoolSize + */ + public void setDeferredIndexThreadPoolSize(int deferredIndexThreadPoolSize) { + this.deferredIndexThreadPoolSize = deferredIndexThreadPoolSize; + } + + + /** + * @see #deferredIndexMaxRetries + */ + public int getDeferredIndexMaxRetries() { + return deferredIndexMaxRetries; + } + + + /** + * @see #deferredIndexMaxRetries + */ + public void setDeferredIndexMaxRetries(int deferredIndexMaxRetries) { + this.deferredIndexMaxRetries = deferredIndexMaxRetries; + } + + + /** + * @see #deferredIndexRetryBaseDelayMs + */ + public long getDeferredIndexRetryBaseDelayMs() { + return deferredIndexRetryBaseDelayMs; + } + + + /** + * @see #deferredIndexRetryBaseDelayMs + */ + public void setDeferredIndexRetryBaseDelayMs(long deferredIndexRetryBaseDelayMs) { + this.deferredIndexRetryBaseDelayMs = deferredIndexRetryBaseDelayMs; + } + + + /** + * @see #deferredIndexRetryMaxDelayMs + */ + public long getDeferredIndexRetryMaxDelayMs() { + return deferredIndexRetryMaxDelayMs; + } + + + /** + * @see #deferredIndexRetryMaxDelayMs + */ + public void setDeferredIndexRetryMaxDelayMs(long deferredIndexRetryMaxDelayMs) { + this.deferredIndexRetryMaxDelayMs = deferredIndexRetryMaxDelayMs; + } + + + /** + * @see #deferredIndexForceBuildTimeoutSeconds + */ + public long getDeferredIndexForceBuildTimeoutSeconds() { + return deferredIndexForceBuildTimeoutSeconds; + } + + + /** + * @see #deferredIndexForceBuildTimeoutSeconds + */ + public void setDeferredIndexForceBuildTimeoutSeconds(long deferredIndexForceBuildTimeoutSeconds) { + this.deferredIndexForceBuildTimeoutSeconds = deferredIndexForceBuildTimeoutSeconds; + } + + + private void validateNoIndexConflict() { + Set overlap = Sets.intersection(forceImmediateIndexes, forceDeferredIndexes); + if (!overlap.isEmpty()) { + throw new IllegalStateException( + "Index names cannot be both force-immediate and force-deferred: " + overlap); + } + } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/db/DatabaseUpgradeTableContribution.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/db/DatabaseUpgradeTableContribution.java index 486e0032a..8662eb907 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/db/DatabaseUpgradeTableContribution.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/db/DatabaseUpgradeTableContribution.java @@ -15,6 +15,7 @@ package org.alfasoftware.morf.upgrade.db; import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; import static org.alfasoftware.morf.metadata.SchemaUtils.table; import java.util.Collection; @@ -41,6 +42,11 @@ public class DatabaseUpgradeTableContribution implements TableContribution { /** Name of the table containing information on the views deployed within the app's database. */ public static final String DEPLOYED_VIEWS_NAME = "DeployedViews"; + /** Name of the table tracking deferred index operations. */ + public static final String DEFERRED_INDEX_OPERATION_NAME = "DeferredIndexOperation"; + + + /** * @return The Table descriptor of UpgradeAudit @@ -68,6 +74,32 @@ public static TableBuilder deployedViewsTable() { } + /** + * @return The Table descriptor of DeferredIndexOperation + */ + public static Table deferredIndexOperationTable() { + return table(DEFERRED_INDEX_OPERATION_NAME) + .columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("upgradeUUID", DataType.STRING, 100), + column("tableName", DataType.STRING, 60), + column("indexName", DataType.STRING, 60), + column("indexUnique", DataType.BOOLEAN), + column("indexColumns", DataType.STRING, 2000), + column("status", DataType.STRING, 20), + column("retryCount", DataType.INTEGER), + column("createdTime", DataType.DECIMAL, 14), + column("startedTime", DataType.DECIMAL, 14).nullable(), + column("completedTime", DataType.DECIMAL, 14).nullable(), + column("errorMessage", DataType.CLOB).nullable() + ) + .indexes( + index("DeferredIndexOp_1").columns("status"), + index("DeferredIndexOp_2").columns("tableName") + ); + } + + /** * @see org.alfasoftware.morf.upgrade.TableContribution#tables() */ @@ -75,7 +107,8 @@ public static TableBuilder deployedViewsTable() { public Collection tables() { return ImmutableList.of( deployedViewsTable(), - upgradeAuditTable() + upgradeAuditTable(), + deferredIndexOperationTable() ); } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredAddIndex.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredAddIndex.java new file mode 100644 index 000000000..122e8ca8a --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredAddIndex.java @@ -0,0 +1,226 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.element.Criterion.and; + +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlDialect; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaHomology; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.sql.SelectStatement; +import org.alfasoftware.morf.upgrade.SchemaChange; +import org.alfasoftware.morf.upgrade.SchemaChangeVisitor; +import org.alfasoftware.morf.upgrade.adapt.AlteredTable; +import org.alfasoftware.morf.upgrade.adapt.TableOverrideSchema; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; + +/** + * {@link SchemaChange} which queues a new index for background creation via + * the deferred index execution mechanism. The index is added to the in-memory + * schema immediately (so schema validation remains consistent), but the actual + * {@code CREATE INDEX} DDL is deferred and executed by + * {@code DeferredIndexExecutor} after the upgrade completes. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class DeferredAddIndex implements SchemaChange { + + /** + * Name of table to add the index to. + */ + private final String tableName; + + /** + * New index to be created in the background. + */ + private final Index newIndex; + + /** + * UUID string of the upgrade step that queued this operation. + */ + private final String upgradeUUID; + + /** + * Construct a {@link DeferredAddIndex} schema change. + * + * @param tableName name of table to add the index to. + * @param index the index to be created in the background. + * @param upgradeUUID UUID string of the upgrade step that queued this operation. + */ + public DeferredAddIndex(String tableName, Index index, String upgradeUUID) { + this.tableName = tableName; + this.newIndex = index; + this.upgradeUUID = upgradeUUID; + } + + + /** + * {@inheritDoc} + * + * @see org.alfasoftware.morf.upgrade.SchemaChange#accept(org.alfasoftware.morf.upgrade.SchemaChangeVisitor) + */ + @Override + public void accept(SchemaChangeVisitor visitor) { + visitor.visit(this); + } + + + /** + * Adds the index to the in-memory schema. No DDL is emitted — the actual + * {@code CREATE INDEX} is handled by the background executor. + * + * @see org.alfasoftware.morf.upgrade.SchemaChange#apply(org.alfasoftware.morf.metadata.Schema) + */ + @Override + public Schema apply(Schema schema) { + Table original = schema.getTable(tableName); + if (original == null) { + throw new IllegalArgumentException( + String.format("Cannot defer add index [%s] to table [%s] as the table cannot be found", newIndex.getName(), tableName)); + } + + List indexes = new ArrayList<>(); + for (Index index : original.indexes()) { + if (index.getName().equalsIgnoreCase(newIndex.getName())) { + throw new IllegalArgumentException( + String.format("Cannot defer add index [%s] to table [%s] as the index already exists", newIndex.getName(), tableName)); + } + indexes.add(index.getName()); + } + indexes.add(newIndex.getName()); + + return new TableOverrideSchema(schema, new AlteredTable(original, null, null, indexes, List.of(newIndex))); + } + + + /** + * Returns {@code true} if either: + *
    + *
  1. the index already exists in the database schema (build has completed), or
  2. + *
  3. a deferred operation for this table and index name is present in the + * queue (the upgrade step has been processed but the build is still + * pending or in progress).
  4. + *
+ * + * @see org.alfasoftware.morf.upgrade.SchemaChange#isApplied(Schema, ConnectionResources) + */ + @Override + public boolean isApplied(Schema schema, ConnectionResources database) { + if (schema.tableExists(tableName)) { + Table table = schema.getTable(tableName); + SchemaHomology homology = new SchemaHomology(); + for (Index index : table.indexes()) { + if (homology.indexesMatch(index, newIndex)) { + return true; + } + } + } + + return existsInDeferredQueue(database); + } + + + /** + * Checks whether a deferred operation record exists for this table and index + * name in the {@code DeferredIndexOperation} table. + */ + private boolean existsInDeferredQueue(ConnectionResources database) { + SqlDialect sqlDialect = database.sqlDialect(); + SqlScriptExecutorProvider executorProvider = new SqlScriptExecutorProvider(database); + SelectStatement selectStatement = select(field("id")) + .from(tableRef(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)) + .where(and( + field("tableName").eq(tableName), + field("indexName").eq(newIndex.getName()) + )); + String sql = sqlDialect.convertStatementToSQL(selectStatement); + return executorProvider.get().executeQuery(sql, ResultSet::next); + } + + + /** + * Removes the index from the in-memory schema representation (inverse of + * {@link #apply}). This does not issue any DDL or modify the deferred + * operation queue; it is used by the upgrade framework to compute the + * schema state before this step was applied. + * + * @see org.alfasoftware.morf.upgrade.SchemaChange#reverse(org.alfasoftware.morf.metadata.Schema) + */ + @Override + public Schema reverse(Schema schema) { + Table original = schema.getTable(tableName); + List indexNames = new ArrayList<>(); + boolean found = false; + for (Index index : original.indexes()) { + if (index.getName().equalsIgnoreCase(newIndex.getName())) { + found = true; + } else { + indexNames.add(index.getName()); + } + } + + if (!found) { + throw new IllegalStateException( + "Error reversing DeferredAddIndex. Index [" + newIndex.getName() + "] not found in table [" + tableName + "]"); + } + + return new TableOverrideSchema(schema, new AlteredTable(original, null, null, indexNames, null)); + } + + + /** + * @return the UUID string of the upgrade step that queued this deferred index operation. + */ + public String getUpgradeUUID() { + return upgradeUUID; + } + + + /** + * @return the name of the table the index will be added to. + */ + public String getTableName() { + return tableName; + } + + + /** + * @return the index to be created in the background. + */ + public Index getNewIndex() { + return newIndex; + } + + + /** + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "DeferredAddIndex [tableName=" + tableName + ", newIndex=" + newIndex + ", upgradeUUID=" + upgradeUUID + "]"; + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeService.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeService.java new file mode 100644 index 000000000..f2efc57c1 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeService.java @@ -0,0 +1,141 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.List; +import java.util.Optional; + +import org.alfasoftware.morf.sql.Statement; + +/** + * Tracks pending deferred ADD INDEX operations within a single upgrade session + * and produces the DSL {@link Statement}s needed to cancel or rename those + * operations in the queue when subsequent schema changes affect them. + * + *

This service is stateful and scoped to one upgrade run. A fresh instance + * must be created for each upgrade execution. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public interface DeferredIndexChangeService { + + /** + * Records a deferred ADD INDEX operation in the service and returns the + * INSERT {@link Statement} that enqueues it in the database + * as a {@code DeferredIndexOperation} row. + * + * @param deferredAddIndex the operation to enqueue. + * @return INSERT statements to be executed by the caller. + */ + List trackPending(DeferredAddIndex deferredAddIndex); + + + /** + * Returns {@code true} if a PENDING deferred ADD INDEX is currently tracked + * for the given table and index (case-insensitive comparison). + * + * @param tableName the table name. + * @param indexName the index name. + * @return {@code true} if a pending deferred ADD is tracked. + */ + boolean hasPendingDeferred(String tableName, String indexName); + + + /** + * Returns the tracked pending {@link DeferredAddIndex} for the given table + * and index, if one is tracked. + * + * @param tableName the table name. + * @param indexName the index name. + * @return the tracked operation, or empty if none is tracked. + */ + Optional getPendingDeferred(String tableName, String indexName); + + + /** + * Produces DELETE {@link Statement}s to cancel the tracked PENDING operation + * for the given table/index, and removes it from tracking. Returns an empty + * list if no such operation is tracked. + * + * @param tableName the table name. + * @param indexName the index name. + * @return DELETE statements to execute, or an empty list. + */ + List cancelPending(String tableName, String indexName); + + + /** + * Produces DELETE {@link Statement}s to cancel all tracked PENDING operations + * for the given table, and removes them from tracking. Returns an empty list + * if no operations are tracked for the table. + * + * @param tableName the table name. + * @return DELETE statements to execute, or an empty list. + */ + List cancelAllPendingForTable(String tableName); + + + /** + * Produces DELETE {@link Statement}s to cancel all tracked PENDING operations + * for the given table whose column list includes {@code columnName}, and removes + * them from tracking. Returns an empty list if no matching operations are tracked. + * + * @param tableName the table name. + * @param columnName the column name. + * @return DELETE statements to execute, or an empty list. + */ + List cancelPendingReferencingColumn(String tableName, String columnName); + + + /** + * Produces an UPDATE {@link Statement} to rename {@code oldTableName} to + * {@code newTableName} in tracked PENDING rows, and updates internal tracking. + * Returns an empty list if no operations are tracked for the old table name. + * + * @param oldTableName the current table name. + * @param newTableName the new table name. + * @return UPDATE statement to execute, or an empty list. + */ + List updatePendingTableName(String oldTableName, String newTableName); + + + /** + * Produces an UPDATE {@link Statement} to rename {@code oldColumnName} to + * {@code newColumnName} in tracked PENDING column rows for the given table, + * for any deferred index that references the column. Returns an empty list if + * no matching operations are tracked. + * + * @param tableName the table name. + * @param oldColumnName the current column name. + * @param newColumnName the new column name. + * @return UPDATE statement to execute, or an empty list. + */ + List updatePendingColumnName(String tableName, String oldColumnName, String newColumnName); + + + /** + * Produces an UPDATE {@link Statement} to rename a pending deferred index + * from {@code oldIndexName} to {@code newIndexName} on the given table, + * and updates internal tracking. Returns an empty list if no matching + * operation is tracked. + * + * @param tableName the table name. + * @param oldIndexName the current index name. + * @param newIndexName the new index name. + * @return UPDATE statement to execute, or an empty list. + */ + List updatePendingIndexName(String tableName, String oldIndexName, String newIndexName); +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeServiceImpl.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeServiceImpl.java new file mode 100644 index 000000000..ea96cae7f --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexChangeServiceImpl.java @@ -0,0 +1,378 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.sql.SqlUtils.delete; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.insert; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.sql.element.Criterion.and; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.sql.Statement; +import org.alfasoftware.morf.sql.element.Criterion; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Default implementation of {@link DeferredIndexChangeService}. + * + *

Maintains an in-memory map of pending deferred ADD INDEX operations keyed + * by upper-cased table name then upper-cased index name, and constructs the + * DSL {@link Statement}s (INSERT/DELETE/UPDATE) needed to manage the deferred + * operation queue when subsequent schema changes interact with them. + * + *

A single instance is created per upgrade run by + * {@link org.alfasoftware.morf.upgrade.AbstractSchemaChangeVisitor} and lives + * for the duration of that run. It is not Guice-managed because the visitor + * itself is not Guice-managed. + * + *

The in-memory map mirrors what the generated SQL statements will do once + * executed, allowing fast lookups (e.g. {@link #hasPendingDeferred}) and + * column-level tracking (e.g. {@link #cancelPendingReferencingColumn}) without + * requiring database access. The SQL statements are persisted per-step rather + * than batched to the end so that crash recovery works correctly: if the + * upgrade fails mid-way, deferred operations from already-committed steps are + * safely in the database and will not be lost on restart. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class DeferredIndexChangeServiceImpl implements DeferredIndexChangeService { + + private static final Log log = LogFactory.getLog(DeferredIndexChangeServiceImpl.class); + + private static final String COL_ID = "id"; + private static final String COL_UPGRADE_UUID = "upgradeUUID"; + private static final String COL_TABLE_NAME = "tableName"; + private static final String COL_INDEX_NAME = "indexName"; + private static final String COL_INDEX_UNIQUE = "indexUnique"; + private static final String COL_INDEX_COLUMNS = "indexColumns"; + private static final String COL_STATUS = "status"; + private static final String COL_RETRY_COUNT = "retryCount"; + private static final String COL_CREATED_TIME = "createdTime"; + private static final String STATUS_PENDING = "PENDING"; + private static final String LOG_ARROW = "] -> ["; + + /** + * Pending deferred ADD INDEX operations registered during this upgrade session, + * keyed by table name (upper-cased) then index name (upper-cased). + */ + private final Map> pendingDeferredIndexes = new LinkedHashMap<>(); + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#trackPending(DeferredAddIndex) + */ + @Override + public List trackPending(DeferredAddIndex deferredAddIndex) { + if (log.isDebugEnabled()) { + log.debug("Tracking deferred index: table=" + deferredAddIndex.getTableName() + + ", index=" + deferredAddIndex.getNewIndex().getName() + + ", columns=" + deferredAddIndex.getNewIndex().columnNames()); + } + + pendingDeferredIndexes + .computeIfAbsent(deferredAddIndex.getTableName().toUpperCase(), k -> new LinkedHashMap<>()) + .put(deferredAddIndex.getNewIndex().getName().toUpperCase(), deferredAddIndex); + + return buildInsertStatements(deferredAddIndex); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#hasPendingDeferred(String, String) + */ + @Override + public boolean hasPendingDeferred(String tableName, String indexName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + return tableMap != null && tableMap.containsKey(indexName.toUpperCase()); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#getPendingDeferred(String, String) + */ + @Override + public Optional getPendingDeferred(String tableName, String indexName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + return Optional.ofNullable(tableMap != null ? tableMap.get(indexName.toUpperCase()) : null); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#cancelPending(String, String) + */ + @Override + public List cancelPending(String tableName, String indexName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + if (tableMap == null || !tableMap.containsKey(indexName.toUpperCase())) { + return List.of(); + } + if (log.isDebugEnabled()) { + log.debug("Cancelling deferred index: table=" + tableName + ", index=" + indexName); + } + + DeferredAddIndex dai = tableMap.remove(indexName.toUpperCase()); + if (tableMap.isEmpty()) { + pendingDeferredIndexes.remove(tableName.toUpperCase()); + } + + return buildDeleteStatements( + field(COL_TABLE_NAME).eq(literal(dai.getTableName())), + field(COL_INDEX_NAME).eq(literal(dai.getNewIndex().getName())) + ); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#cancelAllPendingForTable(String) + */ + @Override + public List cancelAllPendingForTable(String tableName) { + Map tableMap = pendingDeferredIndexes.remove(tableName.toUpperCase()); + if (tableMap == null || tableMap.isEmpty()) { + return List.of(); + } + if (log.isDebugEnabled()) { + log.debug("Cancelling all deferred indexes for table [" + tableName + "]: " + tableMap.keySet()); + } + + String storedTableName = tableMap.values().iterator().next().getTableName(); + return buildDeleteStatements( + field(COL_TABLE_NAME).eq(literal(storedTableName)) + ); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#cancelPendingReferencingColumn(String, String) + */ + @Override + public List cancelPendingReferencingColumn(String tableName, String columnName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + if (tableMap == null) { + return List.of(); + } + + String storedTableName = tableMap.values().iterator().next().getTableName(); + + List toCancel = new ArrayList<>(); + for (DeferredAddIndex dai : tableMap.values()) { + if (dai.getNewIndex().columnNames().stream().anyMatch(c -> c.equalsIgnoreCase(columnName))) { + toCancel.add(dai.getNewIndex().getName()); + } + } + + if (toCancel.isEmpty()) { + return List.of(); + } + + List statements = new ArrayList<>(); + for (String indexName : toCancel) { + statements.addAll(cancelPending(storedTableName, indexName)); + } + return statements; + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#updatePendingTableName(String, String) + */ + @Override + public List updatePendingTableName(String oldTableName, String newTableName) { + Map tableMap = pendingDeferredIndexes.remove(oldTableName.toUpperCase()); + if (tableMap == null || tableMap.isEmpty()) { + return List.of(); + } + if (log.isDebugEnabled()) { + log.debug("Renaming table in deferred indexes: [" + oldTableName + LOG_ARROW + newTableName + "]"); + } + + String storedOldTableName = tableMap.values().iterator().next().getTableName(); + + Map updatedMap = new LinkedHashMap<>(); + for (Map.Entry entry : tableMap.entrySet()) { + DeferredAddIndex dai = entry.getValue(); + updatedMap.put(entry.getKey(), new DeferredAddIndex(newTableName, dai.getNewIndex(), dai.getUpgradeUUID())); + } + pendingDeferredIndexes.put(newTableName.toUpperCase(), updatedMap); + + return buildUpdateOperationStatements( + literal(newTableName).as(COL_TABLE_NAME), + field(COL_TABLE_NAME).eq(literal(storedOldTableName)) + ); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#updatePendingColumnName(String, String, String) + */ + @Override + public List updatePendingColumnName(String tableName, String oldColumnName, String newColumnName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + if (tableMap == null) { + return List.of(); + } + + boolean anyAffected = tableMap.values().stream() + .anyMatch(dai -> dai.getNewIndex().columnNames().stream().anyMatch(c -> c.equalsIgnoreCase(oldColumnName))); + if (!anyAffected) { + return List.of(); + } + if (log.isDebugEnabled()) { + log.debug("Renaming column in deferred indexes: table=" + tableName + + ", [" + oldColumnName + LOG_ARROW + newColumnName + "]"); + } + + List statements = new ArrayList<>(); + for (Map.Entry entry : tableMap.entrySet()) { + DeferredAddIndex dai = entry.getValue(); + if (dai.getNewIndex().columnNames().stream().anyMatch(c -> c.equalsIgnoreCase(oldColumnName))) { + List updatedColumns = dai.getNewIndex().columnNames().stream() + .map(c -> c.equalsIgnoreCase(oldColumnName) ? newColumnName : c) + .collect(Collectors.toList()); + Index updatedIndex = dai.getNewIndex().isUnique() + ? index(dai.getNewIndex().getName()).columns(updatedColumns).unique() + : index(dai.getNewIndex().getName()).columns(updatedColumns); + DeferredAddIndex updated = new DeferredAddIndex(dai.getTableName(), updatedIndex, dai.getUpgradeUUID()); + entry.setValue(updated); + + statements.add( + update(tableRef(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)) + .set(literal(String.join(",", updatedColumns)).as(COL_INDEX_COLUMNS)) + .where(and( + field(COL_TABLE_NAME).eq(literal(dai.getTableName())), + field(COL_INDEX_NAME).eq(literal(dai.getNewIndex().getName())), + field(COL_STATUS).eq(literal(STATUS_PENDING)) + )) + ); + } + } + return statements; + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexChangeService#updatePendingIndexName(String, String, String) + */ + @Override + public List updatePendingIndexName(String tableName, String oldIndexName, String newIndexName) { + Map tableMap = pendingDeferredIndexes.get(tableName.toUpperCase()); + if (tableMap == null || !tableMap.containsKey(oldIndexName.toUpperCase())) { + return List.of(); + } + if (log.isDebugEnabled()) { + log.debug("Renaming index in deferred indexes: table=" + tableName + + ", [" + oldIndexName + LOG_ARROW + newIndexName + "]"); + } + + DeferredAddIndex existing = tableMap.remove(oldIndexName.toUpperCase()); + String storedTableName = existing.getTableName(); + String storedIndexName = existing.getNewIndex().getName(); + + Index renamedIndex = existing.getNewIndex().isUnique() + ? index(newIndexName).columns(existing.getNewIndex().columnNames()).unique() + : index(newIndexName).columns(existing.getNewIndex().columnNames()); + tableMap.put(newIndexName.toUpperCase(), new DeferredAddIndex(storedTableName, renamedIndex, existing.getUpgradeUUID())); + + return buildUpdateOperationStatements( + literal(newIndexName).as(COL_INDEX_NAME), + field(COL_TABLE_NAME).eq(literal(storedTableName)), + field(COL_INDEX_NAME).eq(literal(storedIndexName)) + ); + } + + + // ------------------------------------------------------------------------- + // SQL statement builders + // ------------------------------------------------------------------------- + + /** + * Builds an INSERT statement for a deferred operation. + */ + private List buildInsertStatements(DeferredAddIndex deferredAddIndex) { + long operationId = UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE; + long createdTime = System.currentTimeMillis(); + + return List.of( + insert().into(tableRef(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)) + .values( + literal(operationId).as(COL_ID), + literal(deferredAddIndex.getUpgradeUUID()).as(COL_UPGRADE_UUID), + literal(deferredAddIndex.getTableName()).as(COL_TABLE_NAME), + literal(deferredAddIndex.getNewIndex().getName()).as(COL_INDEX_NAME), + literal(deferredAddIndex.getNewIndex().isUnique()).as(COL_INDEX_UNIQUE), + literal(String.join(",", deferredAddIndex.getNewIndex().columnNames())).as(COL_INDEX_COLUMNS), + literal(STATUS_PENDING).as(COL_STATUS), + literal(0).as(COL_RETRY_COUNT), + literal(createdTime).as(COL_CREATED_TIME) + ) + ); + } + + + /** + * Builds a DELETE statement to remove pending operations. + * The criteria identify which operations to delete (e.g. by table name, index name). + */ + private List buildDeleteStatements(Criterion... operationCriteria) { + Criterion where = pendingWhere(operationCriteria); + + return List.of( + delete(tableRef(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)) + .where(where) + ); + } + + + /** + * Builds an UPDATE statement against the operation table. The SET clause + * is the first argument; the remaining arguments form the WHERE clause + * (combined with a {@code status = 'PENDING'} filter). + */ + private List buildUpdateOperationStatements(org.alfasoftware.morf.sql.element.AliasedField setClause, Criterion... whereCriteria) { + return List.of( + update(tableRef(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)) + .set(setClause) + .where(pendingWhere(whereCriteria)) + ); + } + + + /** + * Combines the given criteria with a {@code status = 'PENDING'} filter. + */ + private Criterion pendingWhere(Criterion... criteria) { + List all = new ArrayList<>(Arrays.asList(criteria)); + all.add(field(COL_STATUS).eq(literal(STATUS_PENDING))); + return and(all); + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutor.java new file mode 100644 index 000000000..c9ad5379a --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutor.java @@ -0,0 +1,47 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.concurrent.CompletableFuture; + +import com.google.inject.ImplementedBy; + +/** + * Picks up {@link DeferredIndexStatus#PENDING} operations and builds them + * asynchronously using a thread pool. Results are written to the database + * (each operation is marked {@link DeferredIndexStatus#COMPLETED} or + * {@link DeferredIndexStatus#FAILED}). + * + *

This is an internal service — callers should use + * {@link DeferredIndexService} which provides blocking orchestration + * on top of this executor.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexExecutorImpl.class) +interface DeferredIndexExecutor { + + /** + * Picks up all {@link DeferredIndexStatus#PENDING} operations and submits + * them to a thread pool for asynchronous index building. Returns immediately + * with a future that completes when all submitted operations reach a terminal + * state. + * + * @return a future that completes when all operations are done; completes + * immediately if there are no pending operations. + */ + CompletableFuture execute(); +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorImpl.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorImpl.java new file mode 100644 index 000000000..275090908 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorImpl.java @@ -0,0 +1,313 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.table; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.RuntimeSqlException; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Default implementation of {@link DeferredIndexExecutor}. + * + *

Picks up pending operations, issues the appropriate + * {@code CREATE INDEX} DDL via + * {@link org.alfasoftware.morf.jdbc.SqlDialect#deferredIndexDeploymentStatements(Table, Index)}, and + * marks each operation as {@link DeferredIndexStatus#COMPLETED} or + * {@link DeferredIndexStatus#FAILED}.

+ * + *

Retry logic uses exponential back-off up to + * {@link DeferredIndexExecutionConfig#getMaxRetries()} additional attempts after the + * first failure. Progress is logged at INFO level after each operation + * completes.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@Singleton +class DeferredIndexExecutorImpl implements DeferredIndexExecutor { + + private static final Log log = LogFactory.getLog(DeferredIndexExecutorImpl.class); + + private static final String LOG_OP_PREFIX = "Deferred index operation ["; + private static final String LOG_INDEX = ", index="; + + private final DeferredIndexOperationDAO dao; + private final ConnectionResources connectionResources; + private final SqlScriptExecutorProvider sqlScriptExecutorProvider; + private final UpgradeConfigAndContext config; + private final DeferredIndexExecutorServiceFactory executorServiceFactory; + + /** The worker thread pool; may be null if execution has not started. */ + private volatile ExecutorService threadPool; + + + /** + * Constructs an executor using the supplied dependencies. + * + * @param dao DAO for deferred index operations. + * @param connectionResources database connection resources. + * @param sqlScriptExecutorProvider provider for SQL script executors. + * @param config upgrade configuration. + * @param executorServiceFactory factory for creating the worker thread pool. + */ + @Inject + DeferredIndexExecutorImpl(DeferredIndexOperationDAO dao, ConnectionResources connectionResources, + SqlScriptExecutorProvider sqlScriptExecutorProvider, + UpgradeConfigAndContext config, + DeferredIndexExecutorServiceFactory executorServiceFactory) { + this.dao = dao; + this.connectionResources = connectionResources; + this.sqlScriptExecutorProvider = sqlScriptExecutorProvider; + this.config = config; + this.executorServiceFactory = executorServiceFactory; + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexExecutor#execute() + */ + @Override + public CompletableFuture execute() { + if (!config.isDeferredIndexCreationEnabled()) { + log.debug("Deferred index creation is disabled — skipping execution"); + return CompletableFuture.completedFuture(null); + } + + if (threadPool != null) { + log.fatal("execute() called more than once on DeferredIndexExecutorImpl"); + throw new IllegalStateException("DeferredIndexExecutor.execute() has already been called"); + } + + validateExecutorConfig(); + + // Reset any crashed IN_PROGRESS operations from a previous run. + // This is also called by DeferredIndexReadinessCheckImpl.forceBuildAllPending() + // before findPendingOperations() when an upgrade is about to run, so during + // upgrades this is a harmless duplicate — the readiness check must reset first + // so its findPendingOperations() includes previously-crashed operations; the + // executor resets again here because on a no-upgrade restart the readiness + // check's forceBuildAllPending() is not called, and the executor is the only caller. + dao.resetAllInProgressToPending(); + + List pending = dao.findPendingOperations(); + + if (pending.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + threadPool = executorServiceFactory.create(config.getDeferredIndexThreadPoolSize()); + + CompletableFuture[] futures = pending.stream() + .map(op -> CompletableFuture.runAsync(() -> { + executeWithRetry(op); + logProgress(); + }, threadPool)) + .toArray(CompletableFuture[]::new); + + return CompletableFuture.allOf(futures) + .whenComplete((v, t) -> { + threadPool.shutdown(); + threadPool = null; + logProgress(); + log.info("Deferred index execution complete."); + }); + } + + + // ------------------------------------------------------------------------- + // Internal execution logic + // ------------------------------------------------------------------------- + + /** + * Attempts to build the index for a single operation, retrying with + * exponential back-off on failure up to {@link DeferredIndexExecutionConfig#getMaxRetries()} + * times. Updates the operation status in the database after each attempt. + * + * @param op the deferred index operation to execute. + */ + private void executeWithRetry(DeferredIndexOperation op) { + int maxAttempts = config.getDeferredIndexMaxRetries() + 1; + + for (int attempt = op.getRetryCount(); attempt < maxAttempts; attempt++) { + if (Thread.currentThread().isInterrupted()) { + log.warn("Deferred index build interrupted for [" + op.getIndexName() + "] — aborting retries"); + return; + } + log.info("Starting deferred index operation [" + op.getId() + "]: table=" + op.getTableName() + + LOG_INDEX + op.getIndexName() + ", attempt=" + (attempt + 1) + "/" + maxAttempts); + long startedTime = System.currentTimeMillis(); + dao.markStarted(op.getId(), startedTime); + + try { + buildIndex(op); + long elapsedSeconds = (System.currentTimeMillis() - startedTime) / 1000; + dao.markCompleted(op.getId(), System.currentTimeMillis()); + log.info(LOG_OP_PREFIX + op.getId() + "] completed in " + elapsedSeconds + + " s: table=" + op.getTableName() + LOG_INDEX + op.getIndexName()); + return; + + } catch (Exception e) { + long elapsedSeconds = (System.currentTimeMillis() - startedTime) / 1000; + + // Post-failure check: if the index actually exists in the database + // (e.g. a previous crashed attempt completed the build), mark COMPLETED. + if (indexExistsInDatabase(op)) { + dao.markCompleted(op.getId(), System.currentTimeMillis()); + log.info(LOG_OP_PREFIX + op.getId() + "] failed but index exists in database" + + " — marking COMPLETED: table=" + op.getTableName() + LOG_INDEX + op.getIndexName()); + return; + } + + int newRetryCount = attempt + 1; + dao.markFailed(op.getId(), e.getMessage(), newRetryCount); + + if (newRetryCount < maxAttempts) { + log.error(LOG_OP_PREFIX + op.getId() + "] failed after " + elapsedSeconds + + " s (attempt " + newRetryCount + "/" + maxAttempts + "), will retry: table=" + + op.getTableName() + LOG_INDEX + op.getIndexName() + ", error=" + e.getMessage()); + dao.resetToPending(op.getId()); + sleepForBackoff(attempt); + } else { + log.error("Deferred index operation permanently failed after " + elapsedSeconds + " s (" + + newRetryCount + " attempt(s)): table=" + op.getTableName() + + LOG_INDEX + op.getIndexName(), e); + } + } + } + + log.error("DEFERRED INDEX BUILD FAILED: giving up on index [" + op.getIndexName() + + "] on table [" + op.getTableName() + "] after " + maxAttempts + + " attempt(s). The index was NOT built. Manual intervention is required."); + } + + + /** + * Executes the {@code CREATE INDEX} DDL for the given operation using an + * autocommit connection. Autocommit is required for PostgreSQL's + * {@code CREATE INDEX CONCURRENTLY}. + * + * @param op the deferred index operation containing table and index metadata. + */ + private void buildIndex(DeferredIndexOperation op) { + Index index = op.toIndex(); + Table table = table(op.getTableName()); + Collection statements = connectionResources.sqlDialect().deferredIndexDeploymentStatements(table, index); + + // Execute with autocommit enabled rather than inside a transaction. + // Some platforms require this — notably PostgreSQL's CREATE INDEX + // CONCURRENTLY, which cannot run inside a transaction block. Using a + // dedicated autocommit connection is harmless for platforms that do + // not have this restriction (Oracle, MySQL, H2, SQL Server). + try (Connection connection = connectionResources.getDataSource().getConnection()) { + boolean wasAutoCommit = connection.getAutoCommit(); + try { + connection.setAutoCommit(true); + sqlScriptExecutorProvider.get().execute(statements, connection); + } finally { + connection.setAutoCommit(wasAutoCommit); + } + } catch (SQLException e) { + throw new RuntimeSqlException("Error building deferred index " + op.getIndexName(), e); + } + } + + + /** + * Checks whether the index described by the operation exists in the live + * database schema. Used for post-failure recovery: if CREATE INDEX fails + * but the index was actually built (e.g. from a previous crashed attempt), + * the operation can be marked COMPLETED. + * + * @param op the operation to check. + * @return {@code true} if the index exists. + */ + private boolean indexExistsInDatabase(DeferredIndexOperation op) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + if (!sr.tableExists(op.getTableName())) { + return false; + } + return sr.getTable(op.getTableName()).indexes().stream() + .anyMatch(idx -> idx.getName().equalsIgnoreCase(op.getIndexName())); + } + } + + + /** + * Sleeps for an exponentially increasing delay, capped at + * {@link DeferredIndexExecutionConfig#getRetryMaxDelayMs()}. + * + * @param attempt the zero-based attempt number (used to compute the delay). + */ + private void sleepForBackoff(int attempt) { + try { + long delay = Math.min( + config.getDeferredIndexRetryBaseDelayMs() * (1L << Math.min(attempt, 30)), + config.getDeferredIndexRetryMaxDelayMs()); + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + + /** + * Validates executor-relevant configuration values. + */ + private void validateExecutorConfig() { + if (config.getDeferredIndexThreadPoolSize() < 1) { + throw new IllegalArgumentException("deferredIndexThreadPoolSize must be >= 1, was " + config.getDeferredIndexThreadPoolSize()); + } + if (config.getDeferredIndexMaxRetries() < 0) { + throw new IllegalArgumentException("deferredIndexMaxRetries must be >= 0, was " + config.getDeferredIndexMaxRetries()); + } + if (config.getDeferredIndexRetryBaseDelayMs() < 0) { + throw new IllegalArgumentException("deferredIndexRetryBaseDelayMs must be >= 0 ms, was " + config.getDeferredIndexRetryBaseDelayMs() + " ms"); + } + if (config.getDeferredIndexRetryMaxDelayMs() < config.getDeferredIndexRetryBaseDelayMs()) { + throw new IllegalArgumentException("deferredIndexRetryMaxDelayMs (" + config.getDeferredIndexRetryMaxDelayMs() + + " ms) must be >= deferredIndexRetryBaseDelayMs (" + config.getDeferredIndexRetryBaseDelayMs() + " ms)"); + } + } + + + private void logProgress() { + Map counts = dao.countAllByStatus(); + + log.info("Deferred index progress: completed=" + counts.get(DeferredIndexStatus.COMPLETED) + + ", in-progress=" + counts.get(DeferredIndexStatus.IN_PROGRESS) + + ", pending=" + counts.get(DeferredIndexStatus.PENDING) + + ", failed=" + counts.get(DeferredIndexStatus.FAILED)); + } + +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorServiceFactory.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorServiceFactory.java new file mode 100644 index 000000000..d8fdeb8ad --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorServiceFactory.java @@ -0,0 +1,75 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.google.inject.ImplementedBy; + +/** + * Factory for creating the {@link ExecutorService} used by + * {@link DeferredIndexExecutor} to build indexes asynchronously. + * + *

The default implementation creates a fixed-size thread pool with + * daemon threads, which is suitable for standalone JVM processes. In a + * managed environment such as a servlet container (e.g. Jetty), the + * adopting application should override this binding to provide a + * container-managed {@link ExecutorService} (e.g. wrapping a commonj + * {@code WorkManager}) so that threads participate in the container's + * lifecycle and classloader management.

+ * + *

Override example in a Guice module:

+ *
+ * bind(DeferredIndexExecutorServiceFactory.class)
+ *     .toInstance(size -> new CommonJExecutorService(workManager, size));
+ * 
+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexExecutorServiceFactory.Default.class) +public interface DeferredIndexExecutorServiceFactory { + + /** + * Creates an {@link ExecutorService} with the given thread pool size. + * + * @param threadPoolSize the number of threads in the pool. + * @return a new {@link ExecutorService}. + */ + ExecutorService create(int threadPoolSize); + + + /** + * Default implementation that creates a fixed-size thread pool with + * daemon threads named {@code DeferredIndexExecutor-N}. + */ + class Default implements DeferredIndexExecutorServiceFactory { + + private int threadCount; + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexExecutorServiceFactory#create(int) + */ + @Override + public ExecutorService create(int threadPoolSize) { + return Executors.newFixedThreadPool(threadPoolSize, r -> { + Thread t = new Thread(r, "DeferredIndexExecutor-" + ++threadCount); + t.setDaemon(true); + return t; + }); + } + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperation.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperation.java new file mode 100644 index 000000000..b755fd9e8 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperation.java @@ -0,0 +1,299 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import java.util.List; + +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaUtils.IndexBuilder; + +/** + * Represents a row in the {@code DeferredIndexOperation} table. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +class DeferredIndexOperation { + + + /** + * Unique identifier for this operation. + */ + private long id; + + /** + * UUID of the {@code UpgradeStep} that created this operation. + */ + private String upgradeUUID; + + /** + * Name of the table on which the index operation is to be applied. + */ + private String tableName; + + /** + * Name of the index to be created or modified. + */ + private String indexName; + + /** + * Whether the index should be unique. + */ + private boolean indexUnique; + + /** + * Current status of this operation. + */ + private DeferredIndexStatus status; + + /** + * Number of retry attempts made so far. + */ + private int retryCount; + + /** + * Time at which this operation was created, stored as epoch milliseconds. + */ + private long createdTime; + + /** + * Time at which execution started, stored as epoch milliseconds. Null if not yet started. + */ + private Long startedTime; + + /** + * Time at which execution completed, stored as epoch milliseconds. Null if not yet completed. + */ + private Long completedTime; + + /** + * Error message if the operation has failed. Null if not failed. + */ + private String errorMessage; + + /** + * Ordered list of column names making up the index. + */ + private List columnNames; + + + /** + * @see #id + */ + public long getId() { + return id; + } + + + /** + * @see #id + */ + public void setId(long id) { + this.id = id; + } + + + /** + * @see #upgradeUUID + */ + public String getUpgradeUUID() { + return upgradeUUID; + } + + + /** + * @see #upgradeUUID + */ + public void setUpgradeUUID(String upgradeUUID) { + this.upgradeUUID = upgradeUUID; + } + + + /** + * @see #tableName + */ + public String getTableName() { + return tableName; + } + + + /** + * @see #tableName + */ + public void setTableName(String tableName) { + this.tableName = tableName; + } + + + /** + * @see #indexName + */ + public String getIndexName() { + return indexName; + } + + + /** + * @see #indexName + */ + public void setIndexName(String indexName) { + this.indexName = indexName; + } + + + /** + * @see #indexUnique + */ + public boolean isIndexUnique() { + return indexUnique; + } + + + /** + * @see #indexUnique + */ + public void setIndexUnique(boolean indexUnique) { + this.indexUnique = indexUnique; + } + + + /** + * @see #status + */ + public DeferredIndexStatus getStatus() { + return status; + } + + + /** + * @see #status + */ + public void setStatus(DeferredIndexStatus status) { + this.status = status; + } + + + /** + * @see #retryCount + */ + public int getRetryCount() { + return retryCount; + } + + + /** + * @see #retryCount + */ + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + + /** + * @see #createdTime + */ + public long getCreatedTime() { + return createdTime; + } + + + /** + * @see #createdTime + */ + public void setCreatedTime(long createdTime) { + this.createdTime = createdTime; + } + + + /** + * @see #startedTime + */ + public Long getStartedTime() { + return startedTime; + } + + + /** + * @see #startedTime + */ + public void setStartedTime(Long startedTime) { + this.startedTime = startedTime; + } + + + /** + * @see #completedTime + */ + public Long getCompletedTime() { + return completedTime; + } + + + /** + * @see #completedTime + */ + public void setCompletedTime(Long completedTime) { + this.completedTime = completedTime; + } + + + /** + * @see #errorMessage + */ + public String getErrorMessage() { + return errorMessage; + } + + + /** + * @see #errorMessage + */ + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + + /** + * @see #columnNames + */ + public List getColumnNames() { + return columnNames; + } + + + /** + * @see #columnNames + */ + public void setColumnNames(List columnNames) { + this.columnNames = columnNames; + } + + + /** + * Reconstructs an {@link Index} metadata object from this operation's + * index name, uniqueness flag, and column names. + * + * @return the reconstructed index. + */ + Index toIndex() { + IndexBuilder builder = index(indexName); + if (indexUnique) { + builder = builder.unique(); + } + return builder.columns(columnNames.toArray(new String[0])); + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAO.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAO.java new file mode 100644 index 000000000..3325b5a73 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAO.java @@ -0,0 +1,105 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.List; +import java.util.Map; + +import com.google.inject.ImplementedBy; + +/** + * DAO for reading and writing {@link DeferredIndexOperation} records. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexOperationDAOImpl.class) +interface DeferredIndexOperationDAO { + + /** + * Returns all {@link DeferredIndexStatus#PENDING} operations with + * their ordered column names populated. + * + * @return list of pending operations. + */ + List findPendingOperations(); + + + /** + * Transitions the operation to {@link DeferredIndexStatus#IN_PROGRESS} + * and records its start time. + * + * @param id the operation to update. + * @param startedTime start timestamp (epoch milliseconds). + */ + void markStarted(long id, long startedTime); + + + /** + * Transitions the operation to {@link DeferredIndexStatus#COMPLETED} + * and records its completion time. + * + * @param id the operation to update. + * @param completedTime completion timestamp (epoch milliseconds). + */ + void markCompleted(long id, long completedTime); + + + /** + * Transitions the operation to {@link DeferredIndexStatus#FAILED}, + * records the error message, and stores the updated retry count. + * + * @param id the operation to update. + * @param errorMessage the error message. + * @param newRetryCount the new retry count value. + */ + void markFailed(long id, String errorMessage, int newRetryCount); + + + /** + * Resets a {@link DeferredIndexStatus#FAILED} operation back to + * {@link DeferredIndexStatus#PENDING} so it will be retried. + * + * @param id the operation to reset. + */ + void resetToPending(long id); + + + /** + * Resets all {@link DeferredIndexStatus#IN_PROGRESS} operations to + * {@link DeferredIndexStatus#PENDING}. Used for crash recovery: any + * operation that was mid-build when the process died should be retried. + */ + void resetAllInProgressToPending(); + + + /** + * Returns all operations in a non-terminal state + * ({@link DeferredIndexStatus#PENDING}, {@link DeferredIndexStatus#IN_PROGRESS}, + * or {@link DeferredIndexStatus#FAILED}) with their ordered column names populated. + * + * @return list of non-terminal operations. + */ + List findNonTerminalOperations(); + + + /** + * Returns the count of operations grouped by status. + * + * @return a map from each {@link DeferredIndexStatus} to its count; + * statuses with no operations have a count of zero. + */ + Map countAllByStatus(); +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAOImpl.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAOImpl.java new file mode 100644 index 000000000..bcdad7c27 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexOperationDAOImpl.java @@ -0,0 +1,308 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.sql.element.Criterion.or; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlDialect; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.sql.SelectStatement; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Default implementation of {@link DeferredIndexOperationDAO}. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@Singleton +class DeferredIndexOperationDAOImpl implements DeferredIndexOperationDAO { + + private static final Log log = LogFactory.getLog(DeferredIndexOperationDAOImpl.class); + + private static final String DEFERRED_INDEX_OP_TABLE = DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; + + // Column name constants + private static final String COL_ID = "id"; + private static final String COL_UPGRADE_UUID = "upgradeUUID"; + private static final String COL_TABLE_NAME = "tableName"; + private static final String COL_INDEX_NAME = "indexName"; + private static final String COL_INDEX_UNIQUE = "indexUnique"; + private static final String COL_INDEX_COLUMNS = "indexColumns"; + private static final String COL_STATUS = "status"; + private static final String COL_RETRY_COUNT = "retryCount"; + private static final String COL_CREATED_TIME = "createdTime"; + private static final String COL_STARTED_TIME = "startedTime"; + private static final String COL_COMPLETED_TIME = "completedTime"; + private static final String COL_ERROR_MESSAGE = "errorMessage"; + + private static final String LOG_MARKING_OP = "Marking operation ["; + + private final SqlScriptExecutorProvider sqlScriptExecutorProvider; + private final SqlDialect sqlDialect; + + + /** + * Constructs the DAO with injected dependencies. + * + * @param sqlScriptExecutorProvider provider for SQL executors. + * @param connectionResources database connection resources. + */ + @Inject + DeferredIndexOperationDAOImpl(SqlScriptExecutorProvider sqlScriptExecutorProvider, ConnectionResources connectionResources) { + this.sqlScriptExecutorProvider = sqlScriptExecutorProvider; + this.sqlDialect = connectionResources.sqlDialect(); + } + + + /** + * Returns all {@link DeferredIndexOperation#STATUS_PENDING} operations with + * their ordered column names populated. + * + * @return list of pending operations. + */ + @Override + public List findPendingOperations() { + return findOperationsByStatus(DeferredIndexStatus.PENDING); + } + + + /** + * Transitions the operation to {@link DeferredIndexOperation#STATUS_IN_PROGRESS} + * and records its start time. + * + * @param operationId the operation to update. + * @param startedTime start timestamp (epoch milliseconds). + */ + @Override + public void markStarted(long id, long startedTime) { + if (log.isDebugEnabled()) log.debug(LOG_MARKING_OP + id + "] as IN_PROGRESS"); + sqlScriptExecutorProvider.get().execute( + sqlDialect.convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OP_TABLE)) + .set( + literal(DeferredIndexStatus.IN_PROGRESS.name()).as(COL_STATUS), + literal(startedTime).as(COL_STARTED_TIME) + ) + .where(field(COL_ID).eq(id)) + ) + ); + } + + + /** + * Transitions the operation to {@link DeferredIndexOperation#STATUS_COMPLETED} + * and records its completion time. + * + * @param operationId the operation to update. + * @param completedTime completion timestamp (epoch milliseconds). + */ + @Override + public void markCompleted(long id, long completedTime) { + if (log.isDebugEnabled()) log.debug(LOG_MARKING_OP + id + "] as COMPLETED"); + sqlScriptExecutorProvider.get().execute( + sqlDialect.convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OP_TABLE)) + .set( + literal(DeferredIndexStatus.COMPLETED.name()).as(COL_STATUS), + literal(completedTime).as(COL_COMPLETED_TIME) + ) + .where(field(COL_ID).eq(id)) + ) + ); + } + + + /** + * Transitions the operation to {@link DeferredIndexOperation#STATUS_FAILED}, + * records the error message, and stores the updated retry count. + * + * @param operationId the operation to update. + * @param errorMessage the error message. + * @param newRetryCount the new retry count value. + */ + @Override + public void markFailed(long id, String errorMessage, int newRetryCount) { + if (log.isDebugEnabled()) log.debug(LOG_MARKING_OP + id + "] as FAILED (retryCount=" + newRetryCount + ")"); + sqlScriptExecutorProvider.get().execute( + sqlDialect.convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OP_TABLE)) + .set( + literal(DeferredIndexStatus.FAILED.name()).as(COL_STATUS), + literal(errorMessage).as(COL_ERROR_MESSAGE), + literal(newRetryCount).as(COL_RETRY_COUNT) + ) + .where(field(COL_ID).eq(id)) + ) + ); + } + + + /** + * Resets a {@link DeferredIndexOperation#STATUS_FAILED} operation back to + * {@link DeferredIndexOperation#STATUS_PENDING} so it will be retried. + * + * @param operationId the operation to reset. + */ + @Override + public void resetToPending(long id) { + if (log.isDebugEnabled()) log.debug("Resetting operation [" + id + "] to PENDING"); + sqlScriptExecutorProvider.get().execute( + sqlDialect.convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OP_TABLE)) + .set(literal(DeferredIndexStatus.PENDING.name()).as(COL_STATUS)) + .where(field(COL_ID).eq(id)) + ) + ); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexOperationDAO#resetAllInProgressToPending() + */ + @Override + public void resetAllInProgressToPending() { + log.info("Resetting any IN_PROGRESS deferred index operations to PENDING"); + sqlScriptExecutorProvider.get().execute( + sqlDialect.convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OP_TABLE)) + .set(literal(DeferredIndexStatus.PENDING.name()).as(COL_STATUS)) + .where(field(COL_STATUS).eq(DeferredIndexStatus.IN_PROGRESS.name())) + ) + ); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexOperationDAO#findNonTerminalOperations() + */ + @Override + public List findNonTerminalOperations() { + SelectStatement select = select( + field(COL_ID), field(COL_UPGRADE_UUID), field(COL_TABLE_NAME), + field(COL_INDEX_NAME), field(COL_INDEX_UNIQUE), field(COL_INDEX_COLUMNS), + field(COL_STATUS), field(COL_RETRY_COUNT), field(COL_CREATED_TIME), + field(COL_STARTED_TIME), field(COL_COMPLETED_TIME), field(COL_ERROR_MESSAGE) + ).from(tableRef(DEFERRED_INDEX_OP_TABLE)) + .where(or( + field(COL_STATUS).eq(DeferredIndexStatus.PENDING.name()), + field(COL_STATUS).eq(DeferredIndexStatus.IN_PROGRESS.name()), + field(COL_STATUS).eq(DeferredIndexStatus.FAILED.name()) + )) + .orderBy(field(COL_ID)); + + String sql = sqlDialect.convertStatementToSQL(select); + return sqlScriptExecutorProvider.get().executeQuery(sql, this::mapOperations); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexOperationDAO#countAllByStatus() + */ + @Override + public Map countAllByStatus() { + SelectStatement select = select(field(COL_STATUS)) + .from(tableRef(DEFERRED_INDEX_OP_TABLE)); + + String sql = sqlDialect.convertStatementToSQL(select); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> { + Map counts = new EnumMap<>(DeferredIndexStatus.class); + for (DeferredIndexStatus s : DeferredIndexStatus.values()) { + counts.put(s, 0); + } + while (rs.next()) { + String statusValue = rs.getString(1); + try { + DeferredIndexStatus status = DeferredIndexStatus.valueOf(statusValue); + counts.merge(status, 1, Integer::sum); + } catch (IllegalArgumentException e) { + log.warn("Ignoring unrecognised deferred index status value: " + statusValue); + } + } + return counts; + }); + } + + + /** + * Returns all operations with the given status, with column names populated. + * + * @param status the status to filter by. + * @return list of matching operations. + */ + private List findOperationsByStatus(DeferredIndexStatus status) { + SelectStatement select = select( + field(COL_ID), field(COL_UPGRADE_UUID), field(COL_TABLE_NAME), + field(COL_INDEX_NAME), field(COL_INDEX_UNIQUE), field(COL_INDEX_COLUMNS), + field(COL_STATUS), field(COL_RETRY_COUNT), field(COL_CREATED_TIME), + field(COL_STARTED_TIME), field(COL_COMPLETED_TIME), field(COL_ERROR_MESSAGE) + ).from(tableRef(DEFERRED_INDEX_OP_TABLE)) + .where(field(COL_STATUS).eq(status.name())) + .orderBy(field(COL_ID)); + + String sql = sqlDialect.convertStatementToSQL(select); + return sqlScriptExecutorProvider.get().executeQuery(sql, this::mapOperations); + } + + + /** + * Maps a result set into a list of {@link DeferredIndexOperation} instances. + * Each row maps directly to one operation. + */ + private List mapOperations(ResultSet rs) throws SQLException { + List result = new ArrayList<>(); + + while (rs.next()) { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setId(rs.getLong(COL_ID)); + op.setUpgradeUUID(rs.getString(COL_UPGRADE_UUID)); + op.setTableName(rs.getString(COL_TABLE_NAME)); + op.setIndexName(rs.getString(COL_INDEX_NAME)); + op.setIndexUnique(rs.getBoolean(COL_INDEX_UNIQUE)); + op.setColumnNames(Arrays.asList(rs.getString(COL_INDEX_COLUMNS).split(","))); + op.setStatus(DeferredIndexStatus.valueOf(rs.getString(COL_STATUS))); + op.setRetryCount(rs.getInt(COL_RETRY_COUNT)); + op.setCreatedTime(rs.getLong(COL_CREATED_TIME)); + long startedTime = rs.getLong(COL_STARTED_TIME); + op.setStartedTime(rs.wasNull() ? null : startedTime); + long completedTime = rs.getLong(COL_COMPLETED_TIME); + op.setCompletedTime(rs.wasNull() ? null : completedTime); + op.setErrorMessage(rs.getString(COL_ERROR_MESSAGE)); + result.add(op); + } + + return result; + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheck.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheck.java new file mode 100644 index 000000000..f66f31d7b --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheck.java @@ -0,0 +1,114 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.Schema; + +import com.google.inject.ImplementedBy; + +/** + * Startup hook that reconciles deferred index operations from a previous + * run before the upgrade framework begins schema diffing. + * + *

This check is invoked during application startup by the upgrade + * framework ({@link org.alfasoftware.morf.upgrade.Upgrade#findPath findPath}) + * for both the sequential and graph-based upgrade paths:

+ * + *
    + *
  • {@link #augmentSchemaWithPendingIndexes(Schema)} is always called + * after the source schema is read, to overlay virtual indexes for + * non-terminal operations so the schema comparison treats them as + * present.
  • + *
  • {@link #forceBuildAllPending()} is called only when an upgrade + * with new steps is about to run. It force-builds any pending or + * stale operations from a previous upgrade synchronously, ensuring + * the schema is clean before new changes are applied.
  • + *
+ * + *

On a normal restart with no upgrade, pending deferred indexes are + * left for {@link DeferredIndexService#execute()} to build. After every + * upgrade, adopters must call {@link DeferredIndexService#execute()} to + * start building deferred indexes queued by the current upgrade.

+ * + * @see DeferredIndexService + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexReadinessCheckImpl.class) +public interface DeferredIndexReadinessCheck { + + /** + * Force-builds all pending deferred index operations from a previous + * upgrade, blocking until complete. + * + *

Called by the upgrade framework only when an upgrade with new + * steps is about to run. If the deferred index infrastructure table + * does not exist (e.g. on the first upgrade), this is a safe no-op. + * If pending operations are found, they are force-built synchronously + * before returning. Any stale IN_PROGRESS operations from a crashed + * process are also reset to PENDING and built.

+ * + * @throws IllegalStateException if any operations failed permanently. + */ + void forceBuildAllPending(); + + + /** + * Augments the given source schema with virtual indexes from non-terminal + * deferred index operations. + * + *

Always called after the source schema is read. For each PENDING, + * IN_PROGRESS, or FAILED operation, the corresponding index is added to + * the schema so that the schema comparison treats it as present. The + * actual index will be built by {@link DeferredIndexService#execute()}.

+ * + * @param sourceSchema the current database schema before upgrade. + * @return the augmented schema with deferred indexes included. + */ + Schema augmentSchemaWithPendingIndexes(Schema sourceSchema); + + + /** + * Creates a readiness check instance from connection resources, for use + * in the static upgrade path where Guice is not available. + * + * @param connectionResources connection details for constructing services. + * @return a new readiness check instance. + */ + static DeferredIndexReadinessCheck create(ConnectionResources connectionResources) { + return create(connectionResources, new org.alfasoftware.morf.upgrade.UpgradeConfigAndContext()); + } + + + /** + * Creates a readiness check instance from connection resources and config, + * for use in the static upgrade path where Guice is not available. + * + * @param connectionResources connection details for constructing services. + * @param config upgrade configuration. + * @return a new readiness check instance. + */ + static DeferredIndexReadinessCheck create(ConnectionResources connectionResources, + org.alfasoftware.morf.upgrade.UpgradeConfigAndContext config) { + SqlScriptExecutorProvider executorProvider = new SqlScriptExecutorProvider(connectionResources); + DeferredIndexOperationDAO dao = new DeferredIndexOperationDAOImpl(executorProvider, connectionResources); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(dao, connectionResources, + executorProvider, config, + new DeferredIndexExecutorServiceFactory.Default()); + return new DeferredIndexReadinessCheckImpl(dao, executor, config, connectionResources); + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheckImpl.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheckImpl.java new file mode 100644 index 000000000..63f31b249 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexReadinessCheckImpl.java @@ -0,0 +1,235 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.upgrade.adapt.AlteredTable; +import org.alfasoftware.morf.upgrade.adapt.TableOverrideSchema; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +/** + * Default implementation of {@link DeferredIndexReadinessCheck}. + * + *

When the feature is enabled, {@link #augmentSchemaWithPendingIndexes(Schema)} + * overlays virtual indexes for non-terminal operations into the source schema, + * and {@link #forceBuildAllPending()} force-builds stale indexes before a new + * upgrade proceeds. When disabled, both methods are no-ops.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@Singleton +class DeferredIndexReadinessCheckImpl implements DeferredIndexReadinessCheck { + + private static final Log log = LogFactory.getLog(DeferredIndexReadinessCheckImpl.class); + + private final DeferredIndexOperationDAO dao; + private final DeferredIndexExecutor executor; + private final UpgradeConfigAndContext config; + private final ConnectionResources connectionResources; + + + /** + * Constructs a readiness check with injected dependencies. + * + * @param dao DAO for deferred index operations. + * @param executor executor used to force-build pending operations. + * @param config upgrade configuration. + * @param connectionResources database connection resources. + */ + @Inject + DeferredIndexReadinessCheckImpl(DeferredIndexOperationDAO dao, DeferredIndexExecutor executor, + UpgradeConfigAndContext config, + ConnectionResources connectionResources) { + this.dao = dao; + this.executor = executor; + this.config = config; + this.connectionResources = connectionResources; + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck#forceBuildAllPending() + */ + @Override + public void forceBuildAllPending() { + if (!config.isDeferredIndexCreationEnabled()) { + log.debug("Deferred index creation is disabled — skipping force-build"); + return; + } + + if (!deferredIndexTableExists()) { + log.debug("DeferredIndexOperation table does not exist — skipping readiness check"); + return; + } + + // Reset any crashed IN_PROGRESS operations so they are picked up + dao.resetAllInProgressToPending(); + + List pending = dao.findPendingOperations(); + if (!pending.isEmpty()) { + log.warn("Found " + pending.size() + " pending deferred index operation(s) before upgrade. " + + "Executing immediately before proceeding..."); + + awaitCompletion(executor.execute()); + + log.info("Pre-upgrade deferred index execution complete."); + } + + // Check for FAILED operations — whether they existed before this run + // or were created by the force-build above. An upgrade cannot proceed + // with permanently failed index operations from a previous upgrade. + int failedCount = dao.countAllByStatus().getOrDefault(DeferredIndexStatus.FAILED, 0); + if (failedCount > 0) { + throw new IllegalStateException("Deferred index force-build failed: " + + failedCount + " index operation(s) could not be built. " + + "Resolve the underlying issue before retrying."); + } + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck#augmentSchemaWithPendingIndexes(org.alfasoftware.morf.metadata.Schema) + */ + @Override + public Schema augmentSchemaWithPendingIndexes(Schema sourceSchema) { + if (!config.isDeferredIndexCreationEnabled()) { + return sourceSchema; + } + if (!deferredIndexTableExists()) { + return sourceSchema; + } + + List ops = dao.findNonTerminalOperations(); + if (ops.isEmpty()) { + return sourceSchema; + } + + log.info("Augmenting schema with " + ops.size() + " deferred index operation(s) not yet built"); + + Schema result = sourceSchema; + for (DeferredIndexOperation op : ops) { + result = augmentSchemaWithOperation(result, op); + } + + return result; + } + + + /** + * Augments the schema with a single deferred index operation, if applicable. + * Returns the schema unchanged if: + *
    + *
  • the target table does not exist in the schema, or
  • + *
  • the index already exists on the table (the operation row is stale — + * e.g. the status update failed after CREATE INDEX succeeded; the + * executor's post-failure indexExistsInDatabase check will clean it + * up on the next run).
  • + *
+ * + * @param schema the current schema. + * @param op the deferred index operation. + * @return the augmented schema, or the original if no augmentation was needed. + */ + private Schema augmentSchemaWithOperation(Schema schema, DeferredIndexOperation op) { + if (!schema.tableExists(op.getTableName())) { + log.warn("Skipping deferred index [" + op.getIndexName() + "] — table [" + + op.getTableName() + "] does not exist in schema"); + return schema; + } + + Table table = schema.getTable(op.getTableName()); + + boolean indexAlreadyExists = table.indexes().stream() + .anyMatch(idx -> idx.getName().equalsIgnoreCase(op.getIndexName())); + if (indexAlreadyExists) { + log.info("Deferred index [" + op.getIndexName() + "] already exists on table [" + + op.getTableName() + "] — skipping augmentation; stale row will be resolved by executor"); + return schema; + } + + Index newIndex = op.toIndex(); + List indexNames = new ArrayList<>(); + for (Index existing : table.indexes()) { + indexNames.add(existing.getName()); + } + indexNames.add(newIndex.getName()); + + log.info("Augmenting schema with deferred index [" + op.getIndexName() + "] on table [" + + op.getTableName() + "] [" + op.getStatus() + "]"); + + return new TableOverrideSchema(schema, + new AlteredTable(table, null, null, indexNames, Arrays.asList(newIndex))); + } + + + /** + * Blocks until the given future completes, with a timeout from config. + * + * @param future the future to await. + * @throws IllegalStateException on timeout, interruption, or execution failure. + */ + private void awaitCompletion(CompletableFuture future) { + long timeoutSeconds = config.getDeferredIndexForceBuildTimeoutSeconds(); + if (timeoutSeconds <= 0) { + throw new IllegalArgumentException( + "deferredIndexForceBuildTimeoutSeconds must be > 0 s, was " + timeoutSeconds + " s"); + } + try { + future.get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Deferred index force-build timed out after " + + timeoutSeconds + " seconds."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Deferred index force-build interrupted."); + } catch (ExecutionException e) { + throw new IllegalStateException("Deferred index force-build failed unexpectedly.", e.getCause()); + } + } + + + /** + * Checks whether the DeferredIndexOperation table exists in the database + * by opening a fresh schema resource. + * + * @return {@code true} if the table exists. + */ + private boolean deferredIndexTableExists() { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + return sr.tableExists(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME); + } + } + + +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexService.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexService.java new file mode 100644 index 000000000..488294aa0 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexService.java @@ -0,0 +1,92 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.Map; + +import com.google.inject.ImplementedBy; + +/** + * Public facade for the deferred index creation mechanism. Adopters inject + * this interface and invoke it after the upgrade completes to start + * background index builds. + * + *

Post-upgrade execution is the adopter's responsibility. + * The upgrade framework does not automatically run this service. + * A pre-upgrade {@link DeferredIndexReadinessCheck} is wired into the + * upgrade pipeline as a safety net: if the adopter forgets to call this + * service, the next upgrade will force-build any outstanding indexes + * before proceeding.

+ * + *

Typical usage (Guice path):

+ *
+ * @Inject DeferredIndexService deferredIndexService;
+ *
+ * // Run upgrade...
+ * upgrade.findPath(targetSchema, steps, exceptionRegexes, dataSource);
+ *
+ * // Then start building deferred indexes in the background:
+ * deferredIndexService.execute();
+ *
+ * // Optionally block until all indexes are built (or time out):
+ * boolean done = deferredIndexService.awaitCompletion(600);
+ * if (!done) {
+ *   log.warn("Deferred index builds still in progress");
+ * }
+ * 
+ * + * @see DeferredIndexReadinessCheck + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexServiceImpl.class) +public interface DeferredIndexService { + + /** + * Recovers stale operations and starts building all pending deferred + * indexes asynchronously. Returns immediately. + * + *

Use {@link #awaitCompletion(long)} to block until all operations + * reach a terminal state.

+ */ + void execute(); + + + /** + * Blocks until all deferred index operations reach a terminal state + * ({@code COMPLETED} or {@code FAILED}), or until the timeout elapses. + * + *

A value of zero means "wait indefinitely". This is acceptable here + * because the caller explicitly opts in to blocking after startup.

+ * + * @param timeoutSeconds maximum time to wait; zero means wait indefinitely. + * @return {@code true} if all operations reached a terminal state within the + * timeout; {@code false} if the timeout elapsed first. + * @throws IllegalStateException if called before {@link #execute()}. + */ + boolean awaitCompletion(long timeoutSeconds); + + + /** + * Returns the current count of deferred index operations grouped by status. + * + *

Adopters can poll this method on their own schedule (e.g. from a + * health endpoint or timer) to monitor progress.

+ * + * @return a map from each {@link DeferredIndexStatus} to its count; + * statuses with no operations have a count of zero. + */ + Map getProgress(); +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexServiceImpl.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexServiceImpl.java new file mode 100644 index 000000000..c2a4ed778 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexServiceImpl.java @@ -0,0 +1,119 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Default implementation of {@link DeferredIndexService}. + * + *

Thin facade over the executor and DAO. Crash recovery + * (IN_PROGRESS → PENDING reset) and configuration validation are + * handled by the executor.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@Singleton +class DeferredIndexServiceImpl implements DeferredIndexService { + + private static final Log log = LogFactory.getLog(DeferredIndexServiceImpl.class); + + private final DeferredIndexExecutor executor; + private final DeferredIndexOperationDAO dao; + + /** Future representing the current execution; {@code null} if not started. */ + private CompletableFuture executionFuture; + + + /** + * Constructs the service. + * + * @param executor executor for building deferred indexes. + * @param dao DAO for querying deferred index operation state. + */ + @Inject + DeferredIndexServiceImpl(DeferredIndexExecutor executor, + DeferredIndexOperationDAO dao) { + this.executor = executor; + this.dao = dao; + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexService#execute() + */ + @Override + public void execute() { + log.info("Deferred index service: executing pending operations..."); + executionFuture = executor.execute(); + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexService#awaitCompletion(long) + */ + @Override + public boolean awaitCompletion(long timeoutSeconds) { + CompletableFuture future = executionFuture; + if (future == null) { + throw new IllegalStateException("awaitCompletion() called before execute()"); + } + + log.info("Deferred index service: awaiting completion (timeout=" + timeoutSeconds + "s)..."); + + try { + if (timeoutSeconds > 0L) { + future.get(timeoutSeconds, TimeUnit.SECONDS); + } else { + future.get(); + } + log.info("Deferred index service: all operations complete."); + return true; + + } catch (TimeoutException e) { + log.warn("Deferred index service: timed out waiting for operations to complete."); + return false; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + + } catch (ExecutionException e) { + throw new IllegalStateException("Deferred index execution failed unexpectedly.", e.getCause()); + } + } + + + /** + * @see org.alfasoftware.morf.upgrade.deferred.DeferredIndexService#getProgress() + */ + @Override + public Map getProgress() { + return dao.countAllByStatus(); + } + + +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexStatus.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexStatus.java new file mode 100644 index 000000000..bb86f249f --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexStatus.java @@ -0,0 +1,46 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +/** + * Status of a {@link DeferredIndexOperation}, stored in the + * {@code DeferredIndexOperation} table. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +enum DeferredIndexStatus { + + /** + * The operation has been queued and is waiting to be picked up by the executor. + */ + PENDING, + + /** + * The operation is currently being executed by the executor. + */ + IN_PROGRESS, + + /** + * The operation completed successfully. + */ + COMPLETED, + + /** + * The operation failed; {@link DeferredIndexOperation#getRetryCount()} indicates + * how many attempts have been made. + */ + FAILED; +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/CreateDeferredIndexOperationTables.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/CreateDeferredIndexOperationTables.java new file mode 100644 index 000000000..c641d12e7 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/CreateDeferredIndexOperationTables.java @@ -0,0 +1,94 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.upgrade; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; + +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.ExclusiveExecution; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; +import org.alfasoftware.morf.upgrade.UpgradeStep; +import org.alfasoftware.morf.upgrade.Version; + +/** + * Create the {@code DeferredIndexOperation} table, which is used to track + * index operations deferred for background execution. + * + *

{@link ExclusiveExecution} and {@code @Sequence(1)} ensure this step + * runs before any step that uses {@code addIndexDeferred()}, which generates + * INSERT statements targeting these tables. Without this guarantee, + * {@link org.alfasoftware.morf.upgrade.GraphBasedUpgrade} could schedule + * such steps in parallel, causing INSERTs to fail on a non-existent table.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ExclusiveExecution +@Sequence(1) +@UUID("4aa4bb56-74c4-4fb6-b896-84064f6d6fe3") +@Version("2.29.1") +public class CreateDeferredIndexOperationTables implements UpgradeStep { + + /** + * @see org.alfasoftware.morf.upgrade.UpgradeStep#getJiraId() + */ + @Override + public String getJiraId() { + return "MORF-111"; + } + + + /** + * @see org.alfasoftware.morf.upgrade.UpgradeStep#getDescription() + */ + @Override + public String getDescription() { + return "Create tables for tracking deferred index operations"; + } + + + /** + * @see org.alfasoftware.morf.upgrade.UpgradeStep#execute(org.alfasoftware.morf.upgrade.SchemaEditor, org.alfasoftware.morf.upgrade.DataEditor) + */ + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addTable( + table("DeferredIndexOperation") + .columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("upgradeUUID", DataType.STRING, 100), + column("tableName", DataType.STRING, 60), + column("indexName", DataType.STRING, 60), + column("indexUnique", DataType.BOOLEAN), + column("indexColumns", DataType.STRING, 2000), + column("status", DataType.STRING, 20), + column("retryCount", DataType.INTEGER), + column("createdTime", DataType.DECIMAL, 14), + column("startedTime", DataType.DECIMAL, 14).nullable(), + column("completedTime", DataType.DECIMAL, 14).nullable(), + column("errorMessage", DataType.CLOB).nullable() + ) + .indexes( + index("DeferredIndexOp_1").columns("status"), + index("DeferredIndexOp_2").columns("tableName") + ) + ); + } +} diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/UpgradeSteps.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/UpgradeSteps.java index 6a974cedc..c4b67c7b2 100644 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/UpgradeSteps.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/upgrade/UpgradeSteps.java @@ -12,6 +12,7 @@ public class UpgradeSteps { CreateDeployedViews.class, RecreateOracleSequences.class, AddDeployedViewsSqlDefinition.class, - ExtendNameColumnOnDeployedViews.class + ExtendNameColumnOnDeployedViews.class, + CreateDeferredIndexOperationTables.class ); } diff --git a/morf-core/src/test/java/org/alfasoftware/morf/guicesupport/TestMorfModule.java b/morf-core/src/test/java/org/alfasoftware/morf/guicesupport/TestMorfModule.java index 45937c80c..8c6d569ff 100644 --- a/morf-core/src/test/java/org/alfasoftware/morf/guicesupport/TestMorfModule.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/guicesupport/TestMorfModule.java @@ -33,6 +33,7 @@ public class TestMorfModule { @Mock GraphBasedUpgradeBuilderFactory graphBasedUpgradeBuilderFactory; @Mock DatabaseUpgradePathValidationService databaseUpgradePathValidationService; @Mock UpgradeConfigAndContext upgradeConfigAndContext; + @Mock org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck deferredIndexReadinessCheck; private MorfModule module; @@ -51,7 +52,7 @@ public void setup() { @Test public void testProvideUpgrade() { Upgrade upgrade = module.provideUpgrade(connectionResources, factory, upgradeStatusTableService, - viewChangesDeploymentHelper, viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeBuilderFactory, upgradeConfigAndContext); + viewChangesDeploymentHelper, viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeBuilderFactory, upgradeConfigAndContext, deferredIndexReadinessCheck); assertNotNull("Instance of Upgrade should not be null", upgrade); assertThat("Instance of Upgrade", upgrade, IsInstanceOf.instanceOf(Upgrade.class)); diff --git a/morf-core/src/test/java/org/alfasoftware/morf/jdbc/MockDialect.java b/morf-core/src/test/java/org/alfasoftware/morf/jdbc/MockDialect.java index bb4d05bed..19b57bd1c 100755 --- a/morf-core/src/test/java/org/alfasoftware/morf/jdbc/MockDialect.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/jdbc/MockDialect.java @@ -422,4 +422,16 @@ protected String getSqlFrom(PortableSqlExpression expression) { public boolean useForcedSerialImport() { return false; } + + + /** + * Returns {@code true} to allow deferred index tests to exercise the + * full pipeline. + * + * @see org.alfasoftware.morf.jdbc.SqlDialect#supportsDeferredIndexCreation() + */ + @Override + public boolean supportsDeferredIndexCreation() { + return true; + } } \ No newline at end of file diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeBuilder.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeBuilder.java index 9213ce130..eac6f0c00 100644 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeBuilder.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeBuilder.java @@ -569,4 +569,78 @@ static class U1000 extends U1 {} */ @Sequence(1001L) static class U1001 extends U1 {} + + + /** + * Verify that {@code CreateDeferredIndexOperationTables} (exclusive, sequence 1) + * acts as a barrier before any step that modifies unrelated tables, ensuring + * the deferred index infrastructure tables exist before INSERT statements + * generated by {@code addIndexDeferred()} are executed. + */ + @Test + public void testCreateDeferredIndexTablesRunsBeforeOtherSteps() { + // CreateDeferredIndexOperationTables is @ExclusiveExecution @Sequence(1) + // DeferredUser modifies an unrelated table "Product" at sequence 100 + UpgradeStep createTablesStep = new org.alfasoftware.morf.upgrade.upgrade.CreateDeferredIndexOperationTables(); + UpgradeStep deferredUserStep = new DeferredUser(); + + when(upgradeTableResolution.getModifiedTables( + org.alfasoftware.morf.upgrade.upgrade.CreateDeferredIndexOperationTables.class.getName())) + .thenReturn(Sets.newHashSet("DeferredIndexOperation")); + when(upgradeTableResolution.getModifiedTables(DeferredUser.class.getName())) + .thenReturn(Sets.newHashSet("Product")); + + upgradeSteps.addAll(Lists.newArrayList(createTablesStep, deferredUserStep)); + + GraphBasedUpgrade upgrade = builder.prepareGraphBasedUpgrade(initialisationSql); + + // The exclusive step must be a parent of the deferred user step + checkParentChild(upgrade, createTablesStep, deferredUserStep); + } + + + /** + * Verify that two steps using {@code addIndexDeferred()} on different tables + * can run in parallel — the exclusive barrier only applies to + * {@code CreateDeferredIndexOperationTables}, not between deferred index users. + */ + @Test + public void testDeferredIndexUsersRunInParallel() { + UpgradeStep createTablesStep = new org.alfasoftware.morf.upgrade.upgrade.CreateDeferredIndexOperationTables(); + UpgradeStep deferredUser1 = new DeferredUser(); + UpgradeStep deferredUser2 = new DeferredUser2(); + + when(upgradeTableResolution.getModifiedTables( + org.alfasoftware.morf.upgrade.upgrade.CreateDeferredIndexOperationTables.class.getName())) + .thenReturn(Sets.newHashSet("DeferredIndexOperation")); + when(upgradeTableResolution.getModifiedTables(DeferredUser.class.getName())) + .thenReturn(Sets.newHashSet("Product")); + when(upgradeTableResolution.getModifiedTables(DeferredUser2.class.getName())) + .thenReturn(Sets.newHashSet("Customer")); + + upgradeSteps.addAll(Lists.newArrayList(createTablesStep, deferredUser1, deferredUser2)); + + GraphBasedUpgrade upgrade = builder.prepareGraphBasedUpgrade(initialisationSql); + + // Both deferred users depend on the exclusive create step + checkParentChild(upgrade, createTablesStep, deferredUser1); + checkParentChild(upgrade, createTablesStep, deferredUser2); + + // But they do NOT depend on each other — they can run in parallel + checkNotParentChild(upgrade, deferredUser1, deferredUser2); + checkNotParentChild(upgrade, deferredUser2, deferredUser1); + } + + + /** + * Test step simulating a user of addIndexDeferred() on table Product. + */ + @Sequence(100L) + static class DeferredUser extends U1 {} + + /** + * Test step simulating a user of addIndexDeferred() on table Customer. + */ + @Sequence(101L) + static class DeferredUser2 extends U1 {} } diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeSchemaChangeVisitor.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeSchemaChangeVisitor.java index e0216322c..04c161da9 100644 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeSchemaChangeVisitor.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeSchemaChangeVisitor.java @@ -7,7 +7,10 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -28,6 +31,9 @@ import org.alfasoftware.morf.sql.SelectStatement; import org.alfasoftware.morf.sql.Statement; import org.alfasoftware.morf.upgrade.GraphBasedUpgradeSchemaChangeVisitor.GraphBasedUpgradeSchemaChangeVisitorFactory; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatchers; @@ -75,6 +81,7 @@ public void setup() { nodes.put(U1.class.getName(), n1); nodes.put(U2.class.getName(), n2); upgradeConfigAndContext = new UpgradeConfigAndContext(); + when(sqlDialect.supportsDeferredIndexCreation()).thenReturn(true); visitor = new GraphBasedUpgradeSchemaChangeVisitor(sourceSchema, upgradeConfigAndContext, sqlDialect, idTable, nodes); } @@ -101,7 +108,9 @@ public void testRemoveTableVisit() { // given visitor.startStep(U1.class); RemoveTable removeTable = mock(RemoveTable.class); - when(removeTable.getTable()).thenReturn(mock(Table.class)); + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("SomeTable"); + when(removeTable.getTable()).thenReturn(mockTable); when(sqlDialect.dropStatements(any(Table.class))).thenReturn(STATEMENTS); // when @@ -220,8 +229,15 @@ public void testAddColumnVisit() { public void testChangeColumnVisit() { // given visitor.startStep(U1.class); + Column fromCol = mock(Column.class); + when(fromCol.getName()).thenReturn("col"); + Column toCol = mock(Column.class); + when(toCol.getName()).thenReturn("col"); ChangeColumn changeColumn = mock(ChangeColumn.class); when(changeColumn.apply(sourceSchema)).thenReturn(sourceSchema); + when(changeColumn.getTableName()).thenReturn("SomeTable"); + when(changeColumn.getFromColumn()).thenReturn(fromCol); + when(changeColumn.getToColumn()).thenReturn(toCol); when(sqlDialect.alterTableChangeColumnStatements(nullable(Table.class), nullable(Column.class), nullable(Column.class))).thenReturn(STATEMENTS); // when @@ -237,8 +253,12 @@ public void testChangeColumnVisit() { public void testRemoveColumnVisit() { // given visitor.startStep(U1.class); + Column col = mock(Column.class); + when(col.getName()).thenReturn("col"); RemoveColumn removeColumn = mock(RemoveColumn.class); when(removeColumn.apply(sourceSchema)).thenReturn(sourceSchema); + when(removeColumn.getTableName()).thenReturn("SomeTable"); + when(removeColumn.getColumnDefinition()).thenReturn(col); when(sqlDialect.alterTableDropColumnStatements(nullable(Table.class), nullable(Column.class))).thenReturn(STATEMENTS); // when @@ -254,8 +274,12 @@ public void testRemoveColumnVisit() { public void testRemoveIndexVisit() { // given visitor.startStep(U1.class); + Index mockIdx = mock(Index.class); + when(mockIdx.getName()).thenReturn("SomeIdx"); RemoveIndex removeIndex = mock(RemoveIndex.class); when(removeIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(removeIndex.getTableName()).thenReturn("SomeTable"); + when(removeIndex.getIndexToBeRemoved()).thenReturn(mockIdx); when(sqlDialect.indexDropStatements(nullable(Table.class), nullable(Index.class))).thenReturn(STATEMENTS); // when @@ -273,10 +297,13 @@ public void testChangeIndexVisit() { visitor.startStep(U1.class); ChangeIndex changeIndex = mock(ChangeIndex.class); when(changeIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(changeIndex.getTableName()).thenReturn("SomeTable"); + Index fromIdx = mock(Index.class); + when(fromIdx.getName()).thenReturn("SomeIndex"); + when(changeIndex.getFromIndex()).thenReturn(fromIdx); when(sqlDialect.indexDropStatements(nullable(Table.class), nullable(Index.class))).thenReturn(STATEMENTS); when(sqlDialect.addIndexStatements(nullable(Table.class), nullable(Index.class))).thenReturn(STATEMENTS); - // when visitor.visit(changeIndex); @@ -292,6 +319,8 @@ public void testRenameIndexVisit() { visitor.startStep(U1.class); RenameIndex renameIndex = mock(RenameIndex.class); when(renameIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(renameIndex.getTableName()).thenReturn("SomeTable"); + when(renameIndex.getFromIndexName()).thenReturn("OldIndex"); when(sqlDialect.renameIndexStatements(nullable(Table.class), nullable(String.class), nullable(String.class))).thenReturn(STATEMENTS); // when @@ -303,6 +332,97 @@ public void testRenameIndexVisit() { } + /** + * ChangeIndex for a pending deferred index cancels the deferred operation + * (two DELETE statements via convertStatementToSQL) without calling indexDropStatements, + * then adds the new index via addIndexStatements. + */ + @Test + public void testChangeIndexCancelsPendingDeferredAdd() { + // given — a pending deferred add on SomeTable/SomeIndex + visitor.startStep(U1.class); + Index deferredIdx = mock(Index.class); + when(deferredIdx.getName()).thenReturn("SomeIndex"); + when(deferredIdx.isUnique()).thenReturn(false); + when(deferredIdx.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + when(deferredAddIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(deferredAddIndex.getTableName()).thenReturn("SomeTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(deferredIdx); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + visitor.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, n1); + + // given — change the same index to a new definition + Index toIdx = mock(Index.class); + when(toIdx.getName()).thenReturn("SomeIndex"); + when(toIdx.isUnique()).thenReturn(false); + when(toIdx.columnNames()).thenReturn(List.of("col2")); + Table mockTable = mock(Table.class); + when(sourceSchema.getTable("SomeTable")).thenReturn(mockTable); + + ChangeIndex changeIndex = mock(ChangeIndex.class); + when(changeIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(changeIndex.getTableName()).thenReturn("SomeTable"); + when(changeIndex.getFromIndex()).thenReturn(deferredIdx); + when(changeIndex.getToIndex()).thenReturn(toIdx); + + // when + visitor.visit(changeIndex); + + // then — no DROP INDEX, no addIndexStatements; cancel (1 DELETE) + re-defer (1 INSERT) + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect, never()).addIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(2)).convertStatementToSQL(stmtCaptor.capture(), eq(sourceSchema), eq(idTable)); + assertThat(stmtCaptor.getAllValues().get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getAllValues().get(1).toString(), containsString("DeferredIndexOperation")); + } + + + /** + * RenameIndex for a pending deferred index updates the queued operation's index name + * (one UPDATE via convertStatementToSQL) without calling renameIndexStatements. + */ + @Test + public void testRenameIndexUpdatesPendingDeferredAdd() { + // given — a pending deferred add on SomeTable/OldIndex + visitor.startStep(U1.class); + Index deferredIdx = mock(Index.class); + when(deferredIdx.getName()).thenReturn("OldIndex"); + when(deferredIdx.isUnique()).thenReturn(false); + when(deferredIdx.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + when(deferredAddIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(deferredAddIndex.getTableName()).thenReturn("SomeTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(deferredIdx); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + visitor.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, n1); + + // given — rename OldIndex to NewIndex + RenameIndex renameIndex = mock(RenameIndex.class); + when(renameIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(renameIndex.getTableName()).thenReturn("SomeTable"); + when(renameIndex.getFromIndexName()).thenReturn("OldIndex"); + when(renameIndex.getToIndexName()).thenReturn("NewIndex"); + + // when + visitor.visit(renameIndex); + + // then — no RENAME INDEX DDL, 1 UPDATE via convertStatementToSQL + verify(sqlDialect, never()).renameIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), eq(sourceSchema), eq(idTable)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("NewIndex")); + } + + @Test public void testExecuteStatementVisit() { // given @@ -345,6 +465,8 @@ public void testRenameTableVisit() { visitor.startStep(U1.class); RenameTable renameTable = mock(RenameTable.class); when(renameTable.apply(sourceSchema)).thenReturn(sourceSchema); + when(renameTable.getOldTableName()).thenReturn("OldTable"); + when(renameTable.getNewTableName()).thenReturn("NewTable"); when(sqlDialect.renameTableStatements(nullable(Table.class), nullable(Table.class))).thenReturn(STATEMENTS); // when diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestInlineTableUpgrader.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestInlineTableUpgrader.java index dc8a285f4..d8f227ef4 100755 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestInlineTableUpgrader.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestInlineTableUpgrader.java @@ -18,6 +18,8 @@ package org.alfasoftware.morf.upgrade; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -49,6 +51,8 @@ import org.alfasoftware.morf.sql.MergeStatement; import org.alfasoftware.morf.sql.Statement; import org.alfasoftware.morf.sql.UpdateStatement; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; +import org.mockito.ArgumentMatchers; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -80,6 +84,7 @@ public void setUp() { sqlStatementWriter = mock(SqlStatementWriter.class); upgradeConfigAndContext = new UpgradeConfigAndContext(); upgradeConfigAndContext.setExclusiveExecutionSteps(Set.of()); + when(sqlDialect.supportsDeferredIndexCreation()).thenReturn(true); upgrader = new InlineTableUpgrader(schema, upgradeConfigAndContext, sqlDialect, sqlStatementWriter, SqlDialect.IdTable.withDeterministicName(ID_TABLE_NAME)); } @@ -139,8 +144,11 @@ public void testVisitAddTable() { @Test public void testVisitRemoveTable() { // given + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("SomeTable"); RemoveTable removeTable = mock(RemoveTable.class); given(removeTable.apply(schema)).willReturn(schema); + when(removeTable.getTable()).thenReturn(mockTable); // when upgrader.visit(removeTable); @@ -283,8 +291,15 @@ public void testVisitAddColumn() { @Test public void testVisitChangeColumn() { // given + Column fromCol = mock(Column.class); + when(fromCol.getName()).thenReturn("col"); + Column toCol = mock(Column.class); + when(toCol.getName()).thenReturn("col"); ChangeColumn changeColumn = mock(ChangeColumn.class); given(changeColumn.apply(schema)).willReturn(schema); + when(changeColumn.getTableName()).thenReturn("SomeTable"); + when(changeColumn.getFromColumn()).thenReturn(fromCol); + when(changeColumn.getToColumn()).thenReturn(toCol); // when upgrader.visit(changeColumn); @@ -302,8 +317,12 @@ public void testVisitChangeColumn() { @Test public void testVisitRemoveColumn() { // given + Column col = mock(Column.class); + when(col.getName()).thenReturn("col"); RemoveColumn removeColumn = mock(RemoveColumn.class); given(removeColumn.apply(schema)).willReturn(schema); + when(removeColumn.getTableName()).thenReturn("SomeTable"); + when(removeColumn.getColumnDefinition()).thenReturn(col); // when upgrader.visit(removeColumn); @@ -321,8 +340,12 @@ public void testVisitRemoveColumn() { @Test public void testVisitRemoveIndex() { // given + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("SomeIdx"); RemoveIndex removeIndex = mock(RemoveIndex.class); given(removeIndex.apply(schema)).willReturn(schema); + when(removeIndex.getTableName()).thenReturn("SomeTable"); + when(removeIndex.getIndexToBeRemoved()).thenReturn(mockIndex); // when upgrader.visit(removeIndex); @@ -342,6 +365,10 @@ public void testVisitChangeIndex() { // given ChangeIndex changeIndex = mock(ChangeIndex.class); given(changeIndex.apply(schema)).willReturn(schema); + given(changeIndex.getTableName()).willReturn("SomeTable"); + Index fromIndex = mock(Index.class); + given(fromIndex.getName()).willReturn("SomeIndex"); + given(changeIndex.getFromIndex()).willReturn(fromIndex); // when upgrader.visit(changeIndex); @@ -536,4 +563,401 @@ public void testVisitRemoveSequence() { verify(sqlStatementWriter).writeSql(anyCollection()); } + + /** + * Tests that visit(DeferredAddIndex) applies the schema change and writes a single INSERT SQL + * for DeferredIndexOperation containing the comma-separated indexColumns. + */ + @Test + public void testVisitDeferredAddIndex() { + // given + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1", "col2")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + // when + upgrader.visit(deferredAddIndex); + + // then + verify(deferredAddIndex).apply(schema); + // 1 INSERT for DeferredIndexOperation with indexColumns + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + verify(sqlStatementWriter, times(1)).writeSql(anyCollection()); + + List captured = stmtCaptor.getAllValues(); + assertThat(captured.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(captured.get(0).toString(), containsString("PENDING")); + assertThat(captured.get(0).toString(), containsString("col1,col2")); + } + + + /** When the dialect does not support deferred index creation, DeferredAddIndex should fall back to AddIndex. */ + @Test + public void testVisitDeferredAddIndexFallsBackWhenDialectUnsupported() { + // given — dialect does not support deferred + when(sqlDialect.supportsDeferredIndexCreation()).thenReturn(false); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + + when(mockTable.indexes()).thenReturn(List.of()); + when(mockTable.columns()).thenReturn(List.of()); + when(sqlDialect.addIndexStatements(nullable(Table.class), nullable(Index.class))).thenReturn(List.of("CREATE INDEX TestIdx ON TestTable (col1)")); + + // when + upgrader.visit(deferredAddIndex); + + // then — should call addIndexStatements, not convertStatementToSQL for INSERT into DeferredIndexOperation + verify(sqlDialect).addIndexStatements(nullable(Table.class), nullable(Index.class)); + verify(sqlDialect, never()).convertStatementToSQL(nullable(Statement.class), nullable(Schema.class), nullable(Table.class)); + } + + + /** + * Tests that ChangeIndex for an index with a pending deferred ADD cancels the deferred + * operation (one DELETE statement) and re-defers with the new definition (one INSERT), + * without emitting a DROP INDEX DDL. + */ + @Test + public void testChangeIndexCancelsPendingDeferredAddAndAddsNewIndex() { + // given — a pending deferred add index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — change the same index to a new definition + Index toIndex = mock(Index.class); + when(toIndex.getName()).thenReturn("TestIdx"); + when(toIndex.isUnique()).thenReturn(false); + when(toIndex.columnNames()).thenReturn(List.of("col2")); + Table mockTable = mock(Table.class); + when(schema.getTable("TestTable")).thenReturn(mockTable); + + ChangeIndex changeIndex = mock(ChangeIndex.class); + given(changeIndex.apply(schema)).willReturn(schema); + when(changeIndex.getTableName()).thenReturn("TestTable"); + when(changeIndex.getFromIndex()).thenReturn(mockIndex); + when(changeIndex.getToIndex()).thenReturn(toIndex); + + // when + upgrader.visit(changeIndex); + + // then — no DROP INDEX, no addIndexStatements; cancel (1 DELETE) + re-defer (1 INSERT) + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect, never()).addIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(2)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + List stmts = stmtCaptor.getAllValues(); + assertThat(stmts.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(stmts.get(1).toString(), containsString("DeferredIndexOperation")); + } + + + /** + * Tests that RenameIndex for an index with a pending deferred ADD updates the deferred + * operation's index name (one UPDATE statement) instead of emitting RENAME INDEX DDL. + */ + @Test + public void testRenameIndexUpdatesPendingDeferredAdd() { + // given — a pending deferred add index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — rename TestIdx to RenamedIdx + RenameIndex renameIndex = mock(RenameIndex.class); + given(renameIndex.apply(schema)).willReturn(schema); + when(renameIndex.getTableName()).thenReturn("TestTable"); + when(renameIndex.getFromIndexName()).thenReturn("TestIdx"); + when(renameIndex.getToIndexName()).thenReturn("RenamedIdx"); + + // when + upgrader.visit(renameIndex); + + // then — 1 UPDATE on DeferredIndexOperation, no RENAME INDEX DDL + verify(sqlDialect, never()).renameIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("RenamedIdx")); + } + + + /** + * Tests that RemoveIndex for an index with a pending deferred ADD emits one DELETE statement + * (cancel the queued operation) instead of DROP INDEX DDL. + */ + @Test + public void testRemoveIndexCancelsPendingDeferredAdd() { + // given — a pending deferred add index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — a remove of the same index + RemoveIndex removeIndex = mock(RemoveIndex.class); + given(removeIndex.apply(schema)).willReturn(schema); + when(removeIndex.getTableName()).thenReturn("TestTable"); + when(removeIndex.getIndexToBeRemoved()).thenReturn(mockIndex); + + // when + upgrader.visit(removeIndex); + + // then — one DELETE statement emitted, no DROP INDEX + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("TestIdx")); + } + + + /** + * Tests that RemoveIndex for an index with no pending deferred ADD emits normal DROP INDEX DDL. + */ + @Test + public void testRemoveIndexDropsNonDeferredIndex() { + // given — no pending deferred index + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + Table mockTable = mock(Table.class); + when(schema.getTable("TestTable")).thenReturn(mockTable); + + RemoveIndex removeIndex = mock(RemoveIndex.class); + given(removeIndex.apply(schema)).willReturn(schema); + when(removeIndex.getTableName()).thenReturn("TestTable"); + when(removeIndex.getIndexToBeRemoved()).thenReturn(mockIndex); + + // when + upgrader.visit(removeIndex); + + // then — normal DROP INDEX DDL emitted + verify(sqlDialect).indexDropStatements(mockTable, mockIndex); + } + + + /** + * Tests that RemoveTable cancels all pending deferred indexes for that table before the DROP TABLE, + * emitting one DELETE statement. + */ + @Test + public void testRemoveTableCancelsPendingDeferredIndexes() { + // given — a pending deferred add index on TestTable + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — remove the same table + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + + RemoveTable removeTable = mock(RemoveTable.class); + given(removeTable.apply(schema)).willReturn(schema); + when(removeTable.getTable()).thenReturn(mockTable); + + // when + upgrader.visit(removeTable); + + // then — 1 DELETE + 1 DROP TABLE (via dropStatements) + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("TestTable")); + verify(sqlDialect).dropStatements(mockTable); + } + + + /** + * Tests that RemoveColumn cancels pending deferred indexes that include that column, + * emitting one DELETE statement before the DROP COLUMN. + */ + @Test + public void testRemoveColumnCancelsPendingDeferredIndexContainingColumn() { + // given — a pending deferred add index on col1 + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1", "col2")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — remove col1 from TestTable + Column mockColumn = mock(Column.class); + when(mockColumn.getName()).thenReturn("col1"); + Table mockTable = mock(Table.class); + when(schema.getTable("TestTable")).thenReturn(mockTable); + + RemoveColumn removeColumn = mock(RemoveColumn.class); + given(removeColumn.apply(schema)).willReturn(schema); + when(removeColumn.getTableName()).thenReturn("TestTable"); + when(removeColumn.getColumnDefinition()).thenReturn(mockColumn); + + // when + upgrader.visit(removeColumn); + + // then — 1 DELETE to cancel the deferred index + DROP COLUMN + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("TestIdx")); + verify(sqlDialect).alterTableDropColumnStatements(mockTable, mockColumn); + } + + + /** + * Tests that RenameTable emits an UPDATE on pending deferred index rows to reflect the new table name. + */ + @Test + public void testRenameTableUpdatesPendingDeferredIndexTableName() { + // given — a pending deferred add index on OldTable + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("OldTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — rename OldTable to NewTable + Table oldTable = mock(Table.class); + Table newTable = mock(Table.class); + when(schema.getTable("OldTable")).thenReturn(oldTable); + when(schema.getTable("NewTable")).thenReturn(newTable); + + RenameTable renameTable = mock(RenameTable.class); + given(renameTable.apply(schema)).willReturn(schema); + when(renameTable.getOldTableName()).thenReturn("OldTable"); + when(renameTable.getNewTableName()).thenReturn("NewTable"); + + // when + upgrader.visit(renameTable); + + // then — 1 UPDATE on DeferredIndexOperation + RENAME TABLE DDL + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("NewTable")); + assertThat(stmtCaptor.getValue().toString(), containsString("OldTable")); + verify(sqlDialect).renameTableStatements(oldTable, newTable); + } + + + /** + * Tests that ChangeColumn with a column rename emits an UPDATE on pending deferred index + * column rows to reflect the new column name. + */ + @Test + public void testChangeColumnUpdatesPendingDeferredIndexColumnName() { + // given — a pending deferred add index referencing "oldCol" + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.columnNames()).thenReturn(List.of("oldCol")); + + DeferredAddIndex deferredAddIndex = mock(DeferredAddIndex.class); + given(deferredAddIndex.apply(schema)).willReturn(schema); + when(deferredAddIndex.getTableName()).thenReturn("TestTable"); + when(deferredAddIndex.getNewIndex()).thenReturn(mockIndex); + when(deferredAddIndex.getUpgradeUUID()).thenReturn(""); + + upgrader.visit(deferredAddIndex); + Mockito.clearInvocations(sqlDialect, sqlStatementWriter); + + // given — rename column oldCol → newCol on TestTable + Column fromColumn = mock(Column.class); + when(fromColumn.getName()).thenReturn("oldCol"); + Column toColumn = mock(Column.class); + when(toColumn.getName()).thenReturn("newCol"); + Table mockTable = mock(Table.class); + when(schema.getTable("TestTable")).thenReturn(mockTable); + + ChangeColumn changeColumn = mock(ChangeColumn.class); + given(changeColumn.apply(schema)).willReturn(schema); + when(changeColumn.getTableName()).thenReturn("TestTable"); + when(changeColumn.getFromColumn()).thenReturn(fromColumn); + when(changeColumn.getToColumn()).thenReturn(toColumn); + + // when + upgrader.visit(changeColumn); + + // then — 1 UPDATE on DeferredIndexOperation (setting indexColumns) + ALTER TABLE DDL + ArgumentCaptor stmtCaptor = ArgumentCaptor.forClass(Statement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(stmtCaptor.capture(), nullable(Schema.class), nullable(Table.class)); + assertThat(stmtCaptor.getValue().toString(), containsString("DeferredIndexOperation")); + assertThat(stmtCaptor.getValue().toString(), containsString("newCol")); + verify(sqlDialect).alterTableChangeColumnStatements(mockTable, fromColumn, toColumn); + } + } diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestSchemaChangeSequence.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestSchemaChangeSequence.java index ba0c7f6d1..2f3aa2a46 100644 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestSchemaChangeSequence.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestSchemaChangeSequence.java @@ -2,11 +2,15 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.any; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.alfasoftware.morf.metadata.Column; import org.alfasoftware.morf.metadata.DataType; @@ -15,6 +19,7 @@ import org.alfasoftware.morf.sql.SelectStatement; import org.alfasoftware.morf.sql.Statement; import org.alfasoftware.morf.sql.element.FieldLiteral; +import org.alfasoftware.morf.upgrade.deferred.DeferredAddIndex; import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; @@ -48,6 +53,7 @@ public class TestSchemaChangeSequence { @Before public void setUp() throws Exception { MockitoAnnotations.openMocks(this); + when(index.getName()).thenReturn("mockIndex"); } @@ -77,6 +83,195 @@ public void testTableResolution() { } + /** + * Tests that addIndexDeferred() records a DeferredAddIndex in the change sequence with the + * correct table, index, and upgradeUUID taken from the step's {@code @UUID} annotation. + */ + @Test + public void testAddIndexDeferredProducesDeferredAddIndex() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.columnNames()).thenReturn(List.of("col1")); + + // when + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + SchemaChangeSequence seq = new SchemaChangeSequence(config, List.of(new StepWithDeferredAddIndex())); + List changes = seq.getAllChanges(); + + // then + assertThat(changes, hasSize(1)); + assertThat(changes.get(0), instanceOf(DeferredAddIndex.class)); + DeferredAddIndex change = (DeferredAddIndex) changes.get(0); + assertEquals("TestTable", change.getTableName()); + assertEquals("TestIdx", change.getNewIndex().getName()); + assertEquals("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", change.getUpgradeUUID()); + } + + + /** Tests that addIndexDeferred with force-immediate config produces an AddIndex instead of DeferredAddIndex. */ + @Test + public void testAddIndexDeferredWithForceImmediateProducesAddIndex() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.columnNames()).thenReturn(List.of("col1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceImmediateIndexes(Set.of("TestIdx")); + + // when + SchemaChangeSequence seq = new SchemaChangeSequence(config, List.of(new StepWithDeferredAddIndex())); + List changes = seq.getAllChanges(); + + // then + assertThat(changes, hasSize(1)); + assertThat(changes.get(0), instanceOf(AddIndex.class)); + AddIndex change = (AddIndex) changes.get(0); + assertEquals("TestTable", change.getTableName()); + assertEquals("TestIdx", change.getNewIndex().getName()); + } + + + /** Tests that force-immediate matching is case-insensitive (H2 folds to uppercase). */ + @Test + public void testAddIndexDeferredWithForceImmediateCaseInsensitive() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.columnNames()).thenReturn(List.of("col1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceImmediateIndexes(Set.of("TESTIDX")); + + // when + SchemaChangeSequence seq = new SchemaChangeSequence(config, List.of(new StepWithDeferredAddIndex())); + List changes = seq.getAllChanges(); + + // then + assertThat(changes, hasSize(1)); + assertThat(changes.get(0), instanceOf(AddIndex.class)); + } + + + /** Tests that isForceImmediateIndex returns correct results with case-insensitive matching. */ + @Test + public void testIsForceImmediateIndex() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceImmediateIndexes(Set.of("Idx_One", "IDX_TWO")); + + assertEquals(true, config.isForceImmediateIndex("Idx_One")); + assertEquals(true, config.isForceImmediateIndex("idx_one")); + assertEquals(true, config.isForceImmediateIndex("IDX_ONE")); + assertEquals(true, config.isForceImmediateIndex("idx_two")); + assertEquals(false, config.isForceImmediateIndex("Idx_Three")); + assertEquals(2, config.getForceImmediateIndexes().size()); + } + + + /** Tests that addIndex with force-deferred config produces a DeferredAddIndex instead of AddIndex. */ + @Test + public void testAddIndexWithForceDeferredProducesDeferredAddIndex() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.columnNames()).thenReturn(List.of("col1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceDeferredIndexes(Set.of("TestIdx")); + + // when + SchemaChangeSequence seq = new SchemaChangeSequence(config, List.of(new StepWithAddIndex())); + List changes = seq.getAllChanges(); + + // then + assertThat(changes, hasSize(1)); + assertThat(changes.get(0), instanceOf(DeferredAddIndex.class)); + DeferredAddIndex change = (DeferredAddIndex) changes.get(0); + assertEquals("TestTable", change.getTableName()); + assertEquals("TestIdx", change.getNewIndex().getName()); + assertEquals("bbbbbbbb-cccc-dddd-eeee-ffffffffffff", change.getUpgradeUUID()); + } + + + /** Tests that force-deferred matching is case-insensitive. */ + @Test + public void testAddIndexWithForceDeferredCaseInsensitive() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.columnNames()).thenReturn(List.of("col1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceDeferredIndexes(Set.of("TESTIDX")); + + // when + SchemaChangeSequence seq = new SchemaChangeSequence(config, List.of(new StepWithAddIndex())); + List changes = seq.getAllChanges(); + + // then + assertThat(changes, hasSize(1)); + assertThat(changes.get(0), instanceOf(DeferredAddIndex.class)); + } + + + /** Tests that isForceDeferredIndex returns correct results with case-insensitive matching. */ + @Test + public void testIsForceDeferredIndex() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceDeferredIndexes(Set.of("Idx_One", "IDX_TWO")); + + assertEquals(true, config.isForceDeferredIndex("Idx_One")); + assertEquals(true, config.isForceDeferredIndex("idx_one")); + assertEquals(true, config.isForceDeferredIndex("IDX_ONE")); + assertEquals(true, config.isForceDeferredIndex("idx_two")); + assertEquals(false, config.isForceDeferredIndex("Idx_Three")); + assertEquals(2, config.getForceDeferredIndexes().size()); + } + + + /** Tests that configuring the same index as both force-immediate and force-deferred throws. */ + @Test(expected = IllegalStateException.class) + public void testConflictingForceImmediateAndForceDeferredThrows() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceImmediateIndexes(Set.of("ConflictIdx")); + config.setForceDeferredIndexes(Set.of("ConflictIdx")); + } + + + /** Tests that the conflict check is case-insensitive. */ + @Test(expected = IllegalStateException.class) + public void testConflictingForceImmediateAndForceDeferredCaseInsensitive() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setForceImmediateIndexes(Set.of("MyIndex")); + config.setForceDeferredIndexes(Set.of("MYINDEX")); + } + + + @UUID("bbbbbbbb-cccc-dddd-eeee-ffffffffffff") + private class StepWithAddIndex implements UpgradeStep { + @Override public String getJiraId() { return "TEST-2"; } + @Override public String getDescription() { return "test"; } + @Override public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndex("TestTable", index); + } + } + + + @UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + private class StepWithDeferredAddIndex implements UpgradeStep { + @Override public String getJiraId() { return "TEST-1"; } + @Override public String getDescription() { return "test"; } + @Override public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("TestTable", index); + } + } + + private class UpgradeStep1 implements UpgradeStep { @Override diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgrade.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgrade.java index daadeae41..4530578aa 100755 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgrade.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgrade.java @@ -195,7 +195,7 @@ public void testUpgrade() throws SQLException { when(schemaResource.tables()).thenReturn(tables); UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), - viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .withUpgradeConfiguration(upgradeConfigAndContext) .create(mockConnectionResources) .findPath(targetSchema, upgradeSteps, Lists.newArrayList("^Drivers$", "^EXCLUDE_.*$"), mockConnectionResources.getDataSource()); @@ -242,7 +242,7 @@ public void testUpgradeWithSchemaConsistencyHealing() throws SQLException { when(dialect.getSchemaConsistencyStatements(any(SchemaResource.class))).thenReturn(ImmutableList.of("HEALING1", "HEALING2")); - UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .withUpgradeConfiguration(upgradeConfigAndContext) .create(mockConnectionResources) .findPath(targetSchema, upgradeSteps, Lists.newArrayList(), mockConnectionResources.getDataSource()); @@ -297,7 +297,7 @@ public void testUpgradeWithSchemaHealing() throws SQLException { when(schemaAutoHealer.analyseSchema(any())).thenReturn(schemaHealingResults); upgradeConfigAndContext.setSchemaAutoHealer(schemaAutoHealer); - UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .withUpgradeConfiguration(upgradeConfigAndContext) .create(mockConnectionResources) .findPath(targetSchema, upgradeSteps, Lists.newArrayList(), mockConnectionResources.getDataSource()); @@ -324,7 +324,7 @@ public void testAuditRowCount() throws SQLException { SqlScriptExecutor.ResultSetProcessor upgradeRowProcessor = mock(SqlScriptExecutor.ResultSetProcessor.class); // When - new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .getUpgradeAuditRowCount(upgradeRowProcessor); @@ -357,7 +357,7 @@ public void testUpgradeWithTriggerMessage() throws SQLException { create(); when(connection.sqlDialect()).thenReturn(dialect); - UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath( schema(upgradeAudit(), deployedViews(), upgradedCar()), @@ -454,7 +454,7 @@ public void testUpgradeWithNoStepsToApply() { when(mockConnectionResources.sqlDialect().dropStatements(any(Table.class))).thenReturn(Lists.newArrayList("2")); when(mockConnectionResources.sqlDialect().getSchemaConsistencyStatements(any(SchemaResource.class))).thenReturn(Lists.newArrayList()); - UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath results = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(mockConnectionResources), viewChangesDeploymentHelperFactory(mockConnectionResources), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(mockConnectionResources) .findPath(targetSchema, upgradeSteps, new HashSet<>(), mockConnectionResources.getDataSource()); @@ -491,7 +491,7 @@ public void testUpgradeWithOnlyViewsToDeploy() { when(connection.sqlDialect().getSchemaConsistencyStatements(any(SchemaResource.class))).thenReturn(Lists.newArrayList()); // When - UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); @@ -537,7 +537,7 @@ public void testUpgradeWithChangedViewsToDeploy() { when(connection.sqlDialect().getSchemaConsistencyStatements(any(SchemaResource.class))).thenReturn(Lists.newArrayList()); // When - UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); @@ -607,7 +607,7 @@ public void testUpgradeWithUpgradeStepsAndViewDeclaredButNotPresent() throws SQL create(); // When - UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); @@ -676,7 +676,7 @@ public void testUpgradeWithUpgradeStepsAndViewDeclared() throws SQLException { withResultSet("SELECT name, hash FROM DeployedViews", viewResultSet). create(); // When - UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); @@ -737,7 +737,7 @@ public void testUpgradeWithViewDeclaredButNotPresent() throws SQLException { withResultSet("SELECT name, hash FROM DeployedViews", viewResultSet). create(); // When - UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory) + UpgradePath result = new Upgrade.Factory(upgradePathFactory(), upgradeStatusTableServiceFactory(connection), viewChangesDeploymentHelperFactory(connection), viewDeploymentValidatorFactory(), databaseUpgradeLockServiceFactory(), graphBasedUpgradeScriptGeneratorFactory, mockReadinessCheck()) .create(connection) .findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); @@ -781,7 +781,7 @@ public void testUpgradeWithOnlyViewsToDeployWithExistingDeployedViews() { when(connection.sqlDialect().getSchemaConsistencyStatements(any(SchemaResource.class))).thenReturn(Lists.newArrayList()); // When - UpgradePath result = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); + UpgradePath result = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext, mockReadinessCheck()).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); // Then assertEquals("Steps to apply " + result.getSteps(), 1, result.getSteps().size()); @@ -861,7 +861,7 @@ public void testUpgradeWithToDeployAndNewDeployedViews() throws SQLException { when(upgradeStatusTableService.getStatus(Optional.of(connection.getDataSource()))).thenReturn(NONE); // When - UpgradePath result = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); + UpgradePath result = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext, mockReadinessCheck()).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); // Then assertEquals("Steps to apply " + result.getSteps(), 1, result.getSteps().size()); @@ -902,7 +902,7 @@ public void testUpgradeWithStepsToApplyRebuildTriggers() throws SQLException { when(upgradeStatusTableService.getStatus(Optional.of(connection.getDataSource()))).thenReturn(NONE); - new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); + new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext, mockReadinessCheck()).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); ArgumentCaptor
tableArgumentCaptor = ArgumentCaptor.forClass(Table.class); verify(connection.sqlDialect(), times(3)).rebuildTriggers(tableArgumentCaptor.capture()); @@ -1002,7 +1002,7 @@ private void assertInProgressUpgrade(UpgradeStatus status1, UpgradeStatus status UpgradeStatusTableService upgradeStatusTableService = mock(UpgradeStatusTableService.class); when(upgradeStatusTableService.getStatus(Optional.of(connection.getDataSource()))).thenReturn(status1, status2, status3); - UpgradePath path = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); + UpgradePath path = new Upgrade(connection, upgradePathFactory(), upgradeStatusTableService, new ViewChangesDeploymentHelper(connection.sqlDialect()), viewDeploymentValidator, databaseUpgradePathValidationService, graphBasedUpgradeScriptGeneratorFactory, upgradeConfigAndContext, mockReadinessCheck()).findPath(targetSchema, upgradeSteps, new HashSet<>(), connection.getDataSource()); assertFalse("Steps to apply", path.hasStepsToApply()); assertTrue("In progress", path.upgradeInProgress()); } @@ -1029,4 +1029,12 @@ private static Table upgradeAudit() { public static Table deployedViews() { return table(DatabaseUpgradeTableContribution.DEPLOYED_VIEWS_NAME).columns(column("name", DataType.STRING, 30), column("hash", DataType.STRING, 64)); } + + + private static org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck mockReadinessCheck() { + org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck check = + mock(org.alfasoftware.morf.upgrade.deferred.DeferredIndexReadinessCheck.class); + when(check.augmentSchemaWithPendingIndexes(any(Schema.class))).thenAnswer(inv -> inv.getArgument(0)); + return check; + } } diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgradeGraph.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgradeGraph.java new file mode 100644 index 000000000..5ac1705ad --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestUpgradeGraph.java @@ -0,0 +1,520 @@ +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.Test; + +import com.google.common.collect.Lists; + +/** + * Tests for {@link UpgradeGraph}. + * + * @author Copyright (c) Alfa Financial Software 2024 + */ +public class TestUpgradeGraph { + + /** + * Test that valid steps with @Version annotation are accepted. + */ + @Test + public void testValidStepsWithVersionAnnotation() { + List> steps = new ArrayList<>(); + steps.add(ValidStepWithVersion.class); + steps.add(ValidStepMinimalVersion.class); + steps.add(ValidStepComplexVersion.class); + + UpgradeGraph graph = new UpgradeGraph(steps); + + Collection> ordered = graph.orderedSteps(); + assertEquals("Should contain all three steps", 3, ordered.size()); + } + + + /** + * Test that valid steps with package-based versioning are accepted. + */ + @Test + public void testValidStepsWithPackageName() { + List> steps = new ArrayList<>(); + steps.add(org.alfasoftware.morf.upgrade.testupgradegraph.upgrade.v1_0.ValidPackageStep.class); + + UpgradeGraph graph = new UpgradeGraph(steps); + + Collection> ordered = graph.orderedSteps(); + assertEquals("Should contain the step", 1, ordered.size()); + } + + + /** + * Test that steps are ordered by sequence number. + */ + @Test + public void testStepsOrderedBySequence() { + List> steps = new ArrayList<>(); + steps.add(ValidStepComplexVersion.class); // seq 4000 + steps.add(ValidStepWithVersion.class); // seq 1000 + steps.add(ValidStepHighSequence.class); // seq 9999 + steps.add(ValidStepMinimalVersion.class); // seq 3000 + + UpgradeGraph graph = new UpgradeGraph(steps); + + List> ordered = Lists.newArrayList(graph.orderedSteps()); + assertEquals("First should be seq 1000", ValidStepWithVersion.class, ordered.get(0)); + assertEquals("Second should be seq 3000", ValidStepMinimalVersion.class, ordered.get(1)); + assertEquals("Third should be seq 4000", ValidStepComplexVersion.class, ordered.get(2)); + assertEquals("Fourth should be seq 9999", ValidStepHighSequence.class, ordered.get(3)); + } + + + /** + * Test that empty collection of steps is handled correctly. + */ + @Test + public void testEmptyStepsCollection() { + List> steps = new ArrayList<>(); + + UpgradeGraph graph = new UpgradeGraph(steps); + + assertThat("Should be empty", graph.orderedSteps(), empty()); + } + + + /** + * Test that missing @Sequence annotation is detected. + */ + @Test + public void testMissingSequenceAnnotation() { + List> steps = new ArrayList<>(); + steps.add(StepMissingSequence.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for missing @Sequence"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("does not have an @Sequence annotation")); + assertThat(e.getMessage(), containsString("StepMissingSequence")); + } + } + + + /** + * Test that duplicate sequence numbers are detected. + */ + @Test + public void testDuplicateSequenceNumbers() { + List> steps = new ArrayList<>(); + steps.add(ValidStepWithVersion.class); // seq 1000 + steps.add(StepDuplicateSequence.class); // seq 1000 + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for duplicate sequence"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("sh are the same @Sequence annotation")); + assertThat(e.getMessage(), containsString("[1000]")); + } + } + + + /** + * Test that invalid @Version annotation format is detected. + */ + @Test + public void testInvalidVersionAnnotation() { + List> steps = new ArrayList<>(); + steps.add(StepInvalidVersionFormat.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for invalid @Version"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("invalid @Version annotation")); + assertThat(e.getMessage(), containsString("StepInvalidVersionFormat")); + } + } + + + /** + * Test various invalid version formats. + */ + @Test + public void testInvalidVersionFormats() { + // Test version with no minor number + List> steps = new ArrayList<>(); + steps.add(StepInvalidVersionNoMinor.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for version with no minor number"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("invalid @Version annotation")); + } + + // Test version with leading 'v' + steps.clear(); + steps.add(StepInvalidVersionLeadingV.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for version with leading v"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("invalid @Version annotation")); + } + } + + + /** + * Test that invalid package name is detected when no @Version annotation is present. + */ + @Test + public void testInvalidPackageName() { + List> steps = new ArrayList<>(); + steps.add(StepNoVersionInvalidPackage.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException for invalid package name"); + } catch (IllegalStateException e) { + assertThat(e.getMessage(), containsString("not contained in a package named after the release version")); + assertThat(e.getMessage(), containsString("StepNoVersionInvalidPackage")); + } + } + + + /** + * Test that multiple validation errors are accumulated. + */ + @Test + public void testMultipleValidationErrors() { + List> steps = new ArrayList<>(); + steps.add(StepMissingSequence.class); + steps.add(ValidStepWithVersion.class); // seq 1000 + steps.add(StepDuplicateSequence.class); // seq 1000 + steps.add(StepInvalidVersionFormat.class); + + try { + new UpgradeGraph(steps); + fail("Should throw IllegalStateException with multiple errors"); + } catch (IllegalStateException e) { + String message = e.getMessage(); + assertThat(message, containsString("does not have an @Sequence annotation")); + assertThat(message, containsString("sh are the same @Sequence annotation")); + assertThat(message, containsString("invalid @Version annotation")); + } + } + + + /** + * Test that various valid @Version annotation formats are accepted. + */ + @Test + public void testVersionAnnotationValidFormats() { + List> steps = new ArrayList<>(); + steps.add(ValidStepMinimalVersion.class); // "1.0" + steps.add(ValidStepWithVersion.class); // "1.0.0" + steps.add(ValidStepComplexVersion.class); // "5.3.20a" + steps.add(ValidStepMultiSegmentVersion.class); // "10.20.30.40" + + UpgradeGraph graph = new UpgradeGraph(steps); + + assertEquals("All valid formats should be accepted", 4, graph.orderedSteps().size()); + } + + + /** + * Test sequence ordering with boundary values. + */ + @Test + public void testSequenceOrderingBoundaryValues() { + List> steps = new ArrayList<>(); + steps.add(ValidStepHighSequence.class); // seq 9999 + steps.add(ValidStepWithVersion.class); // seq 1000 + steps.add(ValidStepMinimalVersion.class); // seq 3000 + + UpgradeGraph graph = new UpgradeGraph(steps); + + List> ordered = Lists.newArrayList(graph.orderedSteps()); + assertEquals("Should be sorted in ascending order", 3, ordered.size()); + assertEquals("First", ValidStepWithVersion.class, ordered.get(0)); + assertEquals("Second", ValidStepMinimalVersion.class, ordered.get(1)); + assertEquals("Third", ValidStepHighSequence.class, ordered.get(2)); + } + + + /** + * Test that orderedSteps() returns an unmodifiable collection. + */ + @Test + public void testOrderedStepsReturnsSortedCollection() { + List> steps = new ArrayList<>(); + steps.add(ValidStepComplexVersion.class); + steps.add(ValidStepWithVersion.class); + + UpgradeGraph graph = new UpgradeGraph(steps); + + Collection> ordered = graph.orderedSteps(); + List> orderedList = Lists.newArrayList(ordered); + + assertEquals("Should be in sequence order", ValidStepWithVersion.class, orderedList.get(0)); + assertEquals("Should be in sequence order", ValidStepComplexVersion.class, orderedList.get(1)); + } + + + /** + * Test that complex valid package names are accepted. + */ + @Test + public void testComplexValidPackageNames() { + List> steps = new ArrayList<>(); + steps.add(org.alfasoftware.morf.upgrade.testupgradegraph.upgrade.v10_20_30a.ComplexValidPackageStep.class); + + UpgradeGraph graph = new UpgradeGraph(steps); + + assertEquals("Should contain the step", 1, graph.orderedSteps().size()); + } + + + // ======================================================================== + // Mock UpgradeStep implementations for testing + // ======================================================================== + + @Sequence(1000) + @Version("1.0.0") + public static class ValidStepWithVersion implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-1"; + } + + @Override + public String getDescription() { + return "Valid step with version"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(3000) + @Version("1.0") + public static class ValidStepMinimalVersion implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-3"; + } + + @Override + public String getDescription() { + return "Valid step with minimal version"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(4000) + @Version("5.3.20a") + public static class ValidStepComplexVersion implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-4"; + } + + @Override + public String getDescription() { + return "Valid step with complex version"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(9999) + @Version("2.0.0") + public static class ValidStepHighSequence implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-9999"; + } + + @Override + public String getDescription() { + return "Valid step with high sequence"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(6001) + @Version("10.20.30.40") + public static class ValidStepMultiSegmentVersion implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-6001"; + } + + @Override + public String getDescription() { + return "Valid step with multi-segment version"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Version("1.0.0") + public static class StepMissingSequence implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-MISSING"; + } + + @Override + public String getDescription() { + return "Step missing sequence annotation"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(1000) + @Version("2.0.0") + public static class StepDuplicateSequence implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-DUP"; + } + + @Override + public String getDescription() { + return "Step with duplicate sequence"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(5000) + @Version("invalid") + public static class StepInvalidVersionFormat implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-INVALID"; + } + + @Override + public String getDescription() { + return "Step with invalid version format"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(6000) + @Version("1") + public static class StepInvalidVersionNoMinor implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-NO-MINOR"; + } + + @Override + public String getDescription() { + return "Step with version missing minor number"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(7000) + @Version("v1.0.0") + public static class StepInvalidVersionLeadingV implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-LEADING-V"; + } + + @Override + public String getDescription() { + return "Step with version having leading v"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } + + + @Sequence(8000) + public static class StepNoVersionInvalidPackage implements UpgradeStep { + @Override + public String getJiraId() { + return "TEST-INVALID-PKG"; + } + + @Override + public String getDescription() { + return "Step with no version and invalid package"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredAddIndex.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredAddIndex.java new file mode 100644 index 000000000..8c0a22d35 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredAddIndex.java @@ -0,0 +1,337 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlDialect; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.sql.SelectStatement; +import org.alfasoftware.morf.upgrade.SchemaChangeVisitor; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; + +/** + * Tests for {@link DeferredAddIndex}. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredAddIndex { + + /** Table with no indexes used as a starting point in most tests. */ + private Table appleTable; + + /** Subject under test with a simple unique index on "pips". */ + private DeferredAddIndex deferredAddIndex; + + + /** + * Set up a fresh table and a {@link DeferredAddIndex} before each test. + */ + @Before + public void setUp() { + appleTable = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("colour", DataType.STRING, 10).nullable() + ); + + deferredAddIndex = new DeferredAddIndex("Apple", index("Apple_1").unique().columns("pips"), "test-uuid-1234"); + } + + + /** + * Verify that apply() adds the index to the in-memory schema. + */ + @Test + public void testApplyAddsIndexToSchema() { + Schema result = deferredAddIndex.apply(schema(appleTable)); + + Table resultTable = result.getTable("Apple"); + assertNotNull(resultTable); + assertEquals("Post-apply index count", 1, resultTable.indexes().size()); + assertEquals("Post-apply index name", "Apple_1", resultTable.indexes().get(0).getName()); + assertEquals("Post-apply index column", "pips", resultTable.indexes().get(0).columnNames().get(0)); + assertTrue("Post-apply index unique", resultTable.indexes().get(0).isUnique()); + } + + + /** + * Verify that apply() throws when the target table does not exist in the schema. + */ + @Test + public void testApplyThrowsWhenTableMissing() { + DeferredAddIndex missingTable = new DeferredAddIndex("NoSuchTable", index("NoSuchTable_1").columns("pips"), ""); + try { + missingTable.apply(schema(appleTable)); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("NoSuchTable")); + } + } + + + /** + * Verify that apply() throws when the index already exists on the table. + */ + @Test + public void testApplyThrowsWhenIndexAlreadyExists() { + Table tableWithIndex = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_1").unique().columns("pips") + ); + + try { + deferredAddIndex.apply(schema(tableWithIndex)); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("Apple_1")); + } + } + + + /** + * Verify that reverse() removes the index from the in-memory schema. + */ + @Test + public void testReverseRemovesIndexFromSchema() { + Table tableWithIndex = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("colour", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_1").unique().columns("pips") + ); + + Schema result = deferredAddIndex.reverse(schema(tableWithIndex)); + + Table resultTable = result.getTable("Apple"); + assertNotNull(resultTable); + assertEquals("Post-reverse index count", 0, resultTable.indexes().size()); + } + + + /** + * Verify that reverse() throws when the index to remove is not present. + */ + @Test + public void testReverseThrowsWhenIndexNotFound() { + try { + deferredAddIndex.reverse(schema(appleTable)); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("Apple_1")); + } + } + + + /** + * Verify that isApplied() returns true when the index already exists in the database schema. + */ + @Test + public void testIsAppliedTrueWhenIndexExistsInSchema() { + Table tableWithIndex = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_1").unique().columns("pips") + ); + + assertTrue("Should be applied when index exists in schema", + deferredAddIndex.isApplied(schema(tableWithIndex), null)); + } + + + /** + * Verify that isApplied() returns true when a matching record exists in the deferred queue, + * even if the index is not yet in the database schema. + */ + @Test + public void testIsAppliedTrueWhenOperationInQueue() throws SQLException { + ConnectionResources mockDatabase = mockConnectionResources(true); + + assertTrue("Should be applied when operation is queued", + deferredAddIndex.isApplied(schema(appleTable), mockDatabase)); + } + + + /** + * Verify that isApplied() returns false when the index is absent from both + * the database schema and the deferred queue. + */ + @Test + public void testIsAppliedFalseWhenNeitherSchemaNorQueue() throws SQLException { + ConnectionResources mockDatabase = mockConnectionResources(false); + + assertFalse("Should not be applied when neither in schema nor queued", + deferredAddIndex.isApplied(schema(appleTable), mockDatabase)); + } + + + /** + * Verify that isApplied() returns false when the table is not present in the schema. + */ + @Test + public void testIsAppliedFalseWhenTableMissingFromSchema() throws SQLException { + ConnectionResources mockDatabase = mockConnectionResources(false); + + assertFalse("Should not be applied when table is absent from schema", + deferredAddIndex.isApplied(schema(), mockDatabase)); + } + + + /** + * Verify that accept() delegates to the visitor's visit(DeferredAddIndex) method. + */ + @Test + public void testAcceptDelegatesToVisitor() { + SchemaChangeVisitor visitor = mock(SchemaChangeVisitor.class); + + deferredAddIndex.accept(visitor); + + verify(visitor).visit(deferredAddIndex); + } + + + /** + * Verify that getTableName(), getNewIndex() and getUpgradeUUID() return the values supplied at construction. + */ + @Test + public void testGetters() { + assertEquals("getTableName", "Apple", deferredAddIndex.getTableName()); + assertEquals("getNewIndex name", "Apple_1", deferredAddIndex.getNewIndex().getName()); + assertEquals("getUpgradeUUID", "test-uuid-1234", deferredAddIndex.getUpgradeUUID()); + } + + + /** + * Verify that toString() includes the table name, index name and UUID. + */ + @Test + public void testToString() { + String result = deferredAddIndex.toString(); + assertTrue("Should contain table name", result.contains("Apple")); + assertTrue("Should contain UUID", result.contains("test-uuid-1234")); + } + + + /** + * Verify that apply() preserves existing indexes and adds the new one alongside them. + */ + @Test + public void testApplyPreservesExistingIndexes() { + Table tableWithOtherIndex = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("colour", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_Colour").columns("colour") + ); + + Schema result = deferredAddIndex.apply(schema(tableWithOtherIndex)); + + Table resultTable = result.getTable("Apple"); + assertEquals("Post-apply index count", 2, resultTable.indexes().size()); + } + + + /** + * Verify that reverse() preserves other indexes while removing only the target. + */ + @Test + public void testReversePreservesOtherIndexes() { + Table tableWithMultipleIndexes = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("colour", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_Colour").columns("colour"), + index("Apple_1").unique().columns("pips") + ); + + Schema result = deferredAddIndex.reverse(schema(tableWithMultipleIndexes)); + + Table resultTable = result.getTable("Apple"); + assertEquals("Post-reverse index count", 1, resultTable.indexes().size()); + assertEquals("Remaining index", "Apple_Colour", resultTable.indexes().get(0).getName()); + } + + + /** + * Verify that isApplied() returns false when the table has a different index that does not match. + */ + @Test + public void testIsAppliedFalseWhenDifferentIndexExists() throws SQLException { + Table tableWithOtherIndex = table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("colour", DataType.STRING, 10).nullable() + ).indexes( + index("Apple_Colour").columns("colour") + ); + + ConnectionResources mockDatabase = mockConnectionResources(false); + + assertFalse("Should not be applied when only a different index exists", + deferredAddIndex.isApplied(schema(tableWithOtherIndex), mockDatabase)); + } + + + /** + * Creates a mock {@link ConnectionResources} with the JDBC chain configured so + * that the deferred queue lookup returns the given result. + */ + private ConnectionResources mockConnectionResources(boolean queueContainsRecord) throws SQLException { + ResultSet mockResultSet = mock(ResultSet.class); + when(mockResultSet.next()).thenReturn(queueContainsRecord); + + PreparedStatement mockPreparedStatement = mock(PreparedStatement.class); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + Connection mockConnection = mock(Connection.class); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + + DataSource mockDataSource = mock(DataSource.class); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + + SqlDialect mockDialect = mock(SqlDialect.class); + when(mockDialect.convertStatementToSQL(ArgumentMatchers.any(SelectStatement.class))).thenReturn("SELECT 1"); + + ConnectionResources mockDatabase = mock(ConnectionResources.class); + when(mockDatabase.getDataSource()).thenReturn(mockDataSource); + when(mockDatabase.sqlDialect()).thenReturn(mockDialect); + + return mockDatabase; + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexChangeServiceImpl.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexChangeServiceImpl.java new file mode 100644 index 000000000..f397ce5aa --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexChangeServiceImpl.java @@ -0,0 +1,371 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.sql.Statement; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link DeferredIndexChangeServiceImpl}. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexChangeServiceImpl { + + private DeferredIndexChangeServiceImpl service; + + + /** + * Create a fresh service before each test. + */ + @Before + public void setUp() { + service = new DeferredIndexChangeServiceImpl(); + } + + + /** + * trackPending returns a single INSERT for the operation row containing the + * expected table, index, and comma-separated column names. + */ + @Test + public void testTrackPendingReturnsInsertStatements() { + List statements = new ArrayList<>(service.trackPending(makeDeferred("TestTable", "TestIdx", "col1", "col2"))); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("PENDING")); + assertThat(statements.get(0).toString(), containsString("TestTable")); + assertThat(statements.get(0).toString(), containsString("TestIdx")); + assertThat(statements.get(0).toString(), containsString("col1,col2")); + } + + + /** + * hasPendingDeferred returns true after trackPending and false before. + */ + @Test + public void testHasPendingDeferredReflectsTracking() { + assertFalse(service.hasPendingDeferred("TestTable", "TestIdx")); + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1")); + assertTrue(service.hasPendingDeferred("TestTable", "TestIdx")); + } + + + /** + * hasPendingDeferred is case-insensitive for both table name and index name. + */ + @Test + public void testHasPendingDeferredIsCaseInsensitive() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1")); + assertTrue(service.hasPendingDeferred("testtable", "testidx")); + assertTrue(service.hasPendingDeferred("TESTTABLE", "TESTIDX")); + } + + + /** + * cancelPending returns a single DELETE statement on the operation table + * and removes the operation from tracking. + */ + @Test + public void testCancelPendingReturnsDeleteAndRemovesFromTracking() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1")); + + List statements = new ArrayList<>(service.cancelPending("TestTable", "TestIdx")); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("TestIdx")); + assertFalse(service.hasPendingDeferred("TestTable", "TestIdx")); + } + + + /** + * cancelPending leaves other indexes on the same table still tracked. + */ + @Test + public void testCancelPendingLeavesOtherIndexesOnSameTableTracked() { + service.trackPending(makeDeferred("TestTable", "Idx1", "col1")); + service.trackPending(makeDeferred("TestTable", "Idx2", "col2")); + + service.cancelPending("TestTable", "Idx1"); + + assertFalse(service.hasPendingDeferred("TestTable", "Idx1")); + assertTrue(service.hasPendingDeferred("TestTable", "Idx2")); + } + + + /** + * cancelPending returns an empty list when no pending operation is tracked for that table/index. + */ + @Test + public void testCancelPendingReturnsEmptyWhenNoPending() { + assertThat(service.cancelPending("TestTable", "TestIdx"), is(empty())); + } + + + /** + * cancelAllPendingForTable returns a single DELETE statement scoped to the table + * and removes all tracked operations for that table, even when multiple indexes are registered. + */ + @Test + public void testCancelAllPendingForTableClearsAllIndexesOnTable() { + service.trackPending(makeDeferred("TestTable", "Idx1", "col1")); + service.trackPending(makeDeferred("TestTable", "Idx2", "col2")); + + List statements = new ArrayList<>(service.cancelAllPendingForTable("TestTable")); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("TestTable")); + assertFalse(service.hasPendingDeferred("TestTable", "Idx1")); + assertFalse(service.hasPendingDeferred("TestTable", "Idx2")); + } + + + /** + * cancelAllPendingForTable returns an empty list when no pending operations exist for that table. + */ + @Test + public void testCancelAllPendingForTableReturnsEmptyWhenNoPending() { + assertThat(service.cancelAllPendingForTable("TestTable"), is(empty())); + } + + + /** + * cancelPendingReferencingColumn returns a DELETE statement for any pending index + * that includes the named column, and removes only those from tracking. + */ + @Test + public void testCancelPendingReferencingColumnCancelsAffectedIndex() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1", "col2")); + + List statements = new ArrayList<>(service.cancelPendingReferencingColumn("TestTable", "col1")); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("TestIdx")); + assertFalse(service.hasPendingDeferred("TestTable", "TestIdx")); + } + + + /** + * cancelPendingReferencingColumn leaves indexes that do not reference the column still tracked. + */ + @Test + public void testCancelPendingReferencingColumnLeavesUnaffectedIndexTracked() { + service.trackPending(makeDeferred("TestTable", "Idx1", "col1")); + service.trackPending(makeDeferred("TestTable", "Idx2", "col2")); + + service.cancelPendingReferencingColumn("TestTable", "col1"); + + assertFalse(service.hasPendingDeferred("TestTable", "Idx1")); + assertTrue(service.hasPendingDeferred("TestTable", "Idx2")); + } + + + /** + * cancelPendingReferencingColumn is case-insensitive for the column name. + */ + @Test + public void testCancelPendingReferencingColumnIsCaseInsensitive() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "MyColumn")); + + List statements = service.cancelPendingReferencingColumn("TestTable", "mycolumn"); + + assertThat(statements, hasSize(1)); + assertFalse(service.hasPendingDeferred("TestTable", "TestIdx")); + } + + + /** + * cancelPendingReferencingColumn returns an empty list when no pending index references + * the named column. + */ + @Test + public void testCancelPendingReferencingColumnReturnsEmptyForUnrelatedColumn() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1", "col2")); + assertThat(service.cancelPendingReferencingColumn("TestTable", "col3"), is(empty())); + } + + + /** + * updatePendingTableName returns an UPDATE statement renaming the table in pending rows + * and updates internal tracking so subsequent lookups use the new name. + */ + @Test + public void testUpdatePendingTableNameReturnsUpdateStatement() { + service.trackPending(makeDeferred("OldTable", "TestIdx", "col1")); + + List statements = new ArrayList<>(service.updatePendingTableName("OldTable", "NewTable")); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("OldTable")); + assertThat(statements.get(0).toString(), containsString("NewTable")); + assertTrue(service.hasPendingDeferred("NewTable", "TestIdx")); + assertFalse(service.hasPendingDeferred("OldTable", "TestIdx")); + } + + + /** + * updatePendingTableName returns an empty list when no pending operations exist for the old table name. + */ + @Test + public void testUpdatePendingTableNameReturnsEmptyWhenNoPending() { + assertThat(service.updatePendingTableName("OldTable", "NewTable"), is(empty())); + } + + + /** + * updatePendingColumnName returns an UPDATE statement on the operation table + * setting the indexColumns to the new comma-separated string. + */ + @Test + public void testUpdatePendingColumnNameReturnsUpdateStatement() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "oldCol")); + + List statements = new ArrayList<>(service.updatePendingColumnName("TestTable", "oldCol", "newCol")); + + assertThat(statements, hasSize(1)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("newCol")); + } + + + /** + * updatePendingColumnName returns one UPDATE per affected index on the main table + * when multiple indexes on the same table both reference the renamed column. + */ + @Test + public void testUpdatePendingColumnNameReturnsOneUpdatePerAffectedIndex() { + service.trackPending(makeDeferred("TestTable", "Idx1", "sharedCol", "col1")); + service.trackPending(makeDeferred("TestTable", "Idx2", "sharedCol", "col2")); + + List statements = service.updatePendingColumnName("TestTable", "sharedCol", "renamedCol"); + + assertThat(statements, hasSize(2)); + assertThat(statements.get(0).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(0).toString(), containsString("renamedCol")); + assertThat(statements.get(1).toString(), containsString("DeferredIndexOperation")); + assertThat(statements.get(1).toString(), containsString("renamedCol")); + } + + + /** + * updatePendingColumnName returns an empty list when no pending index references the old column name. + */ + @Test + public void testUpdatePendingColumnNameReturnsEmptyWhenColumnNotReferenced() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "col1")); + assertThat(service.updatePendingColumnName("TestTable", "otherCol", "newCol"), is(empty())); + } + + + /** + * updatePendingIndexName updates tracking and returns an UPDATE statement. + */ + @Test + public void testUpdatePendingIndexNameUpdatesTrackingAndReturnsStatement() { + service.trackPending(makeDeferred("TestTable", "OldIdx", "col1")); + List stmts = service.updatePendingIndexName("TestTable", "OldIdx", "NewIdx"); + assertThat(stmts, hasSize(1)); + assertTrue("Should track new name", service.hasPendingDeferred("TestTable", "NewIdx")); + assertFalse("Should not track old name", service.hasPendingDeferred("TestTable", "OldIdx")); + } + + + /** + * updatePendingIndexName returns an empty list when no pending index matches. + */ + @Test + public void testUpdatePendingIndexNameReturnsEmptyWhenNotTracked() { + service.trackPending(makeDeferred("TestTable", "SomeIdx", "col1")); + assertThat(service.updatePendingIndexName("TestTable", "OtherIdx", "NewIdx"), is(empty())); + } + + + /** + * updatePendingIndexName returns an empty list when the table is not tracked. + */ + @Test + public void testUpdatePendingIndexNameReturnsEmptyWhenTableNotTracked() { + assertThat(service.updatePendingIndexName("NoTable", "OldIdx", "NewIdx"), is(empty())); + } + + + /** + * After updatePendingColumnName, cancelPendingReferencingColumn finds the + * index by the new column name. + */ + @Test + public void testCancelPendingReferencingColumnFindsRenamedColumn() { + service.trackPending(makeDeferred("TestTable", "TestIdx", "oldCol")); + service.updatePendingColumnName("TestTable", "oldCol", "newCol"); + + List stmts = new ArrayList<>(service.cancelPendingReferencingColumn("TestTable", "newCol")); + assertThat("should cancel by the new column name", stmts, hasSize(1)); + assertFalse(service.hasPendingDeferred("TestTable", "TestIdx")); + } + + + /** + * After updatePendingTableName, cancelPendingReferencingColumn finds the + * index under the new table name. + */ + @Test + public void testCancelPendingReferencingColumnAfterTableRename() { + service.trackPending(makeDeferred("OldTable", "TestIdx", "col1")); + service.updatePendingTableName("OldTable", "NewTable"); + + List stmts = new ArrayList<>(service.cancelPendingReferencingColumn("NewTable", "col1")); + assertThat("should cancel under the new table name", stmts, hasSize(1)); + assertFalse(service.hasPendingDeferred("NewTable", "TestIdx")); + } + + + // ------------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------------- + + private DeferredAddIndex makeDeferred(String tableName, String indexName, String... columns) { + Index index = mock(Index.class); + when(index.getName()).thenReturn(indexName); + when(index.isUnique()).thenReturn(false); + when(index.columnNames()).thenReturn(List.of(columns)); + + DeferredAddIndex deferred = mock(DeferredAddIndex.class); + when(deferred.getTableName()).thenReturn(tableName); + when(deferred.getNewIndex()).thenReturn(index); + when(deferred.getUpgradeUUID()).thenReturn("test-uuid"); + return deferred; + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutorUnit.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutorUnit.java new file mode 100644 index 000000000..6b2eadf91 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutorUnit.java @@ -0,0 +1,318 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.sql.DataSource; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlDialect; +import org.alfasoftware.morf.jdbc.SqlScriptExecutor; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.Table; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link DeferredIndexExecutorImpl} covering edge cases + * that are difficult to exercise in integration tests: progress logging, + * string truncation, and async execution behaviour. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexExecutorUnit { + + @Mock private DeferredIndexOperationDAO dao; + @Mock private ConnectionResources connectionResources; + @Mock private SqlDialect sqlDialect; + @Mock private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Mock private DataSource dataSource; + @Mock private Connection connection; + + private UpgradeConfigAndContext config; + private AutoCloseable mocks; + + + /** Set up mocks and a fast-retry config before each test. */ + @Before + public void setUp() throws SQLException { + mocks = MockitoAnnotations.openMocks(this); + config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + when(connectionResources.sqlDialect()).thenReturn(sqlDialect); + when(connectionResources.getDataSource()).thenReturn(dataSource); + when(dataSource.getConnection()).thenReturn(connection); + + // Default: openSchemaResource returns a mock that says table does not exist + // (post-failure index-exists check will return false) + org.alfasoftware.morf.metadata.SchemaResource mockSr = mock(org.alfasoftware.morf.metadata.SchemaResource.class); + when(mockSr.tableExists(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + when(connectionResources.openSchemaResource()).thenReturn(mockSr); + + Map zeroCounts = new EnumMap<>(DeferredIndexStatus.class); + for (DeferredIndexStatus s : DeferredIndexStatus.values()) { + zeroCounts.put(s, 0); + } + when(dao.countAllByStatus()).thenReturn(zeroCounts); + } + + + /** Close mocks after each test. */ + @After + public void tearDown() throws Exception { + mocks.close(); + } + + + /** execute with an empty pending queue should return an already-completed future. */ + @Test + public void testExecuteEmptyQueue() { + when(dao.findPendingOperations()).thenReturn(Collections.emptyList()); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + CompletableFuture future = executor.execute(); + + assertTrue("Future should be completed immediately", future.isDone()); + verify(dao, never()).markStarted(any(Long.class), any(Long.class)); + } + + + /** execute with a single successful operation should mark it completed. */ + @Test + public void testExecuteSingleSuccess() { + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + verify(dao).markCompleted(eq(1001L), any(Long.class)); + } + + + /** execute should retry on failure and succeed on a subsequent attempt. */ + @SuppressWarnings("unchecked") + @Test + public void testExecuteRetryThenSuccess() { + config.setDeferredIndexMaxRetries(2); + config.setDeferredIndexRetryBaseDelayMs(1L); + config.setDeferredIndexRetryMaxDelayMs(1L); + + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + + // First call throws, second call succeeds + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenThrow(new RuntimeException("temporary failure")) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + verify(dao).markCompleted(eq(1001L), any(Long.class)); + } + + + /** execute should mark an operation as permanently failed after exhausting retries. */ + @Test + public void testExecutePermanentFailure() { + config.setDeferredIndexMaxRetries(1); + config.setDeferredIndexRetryBaseDelayMs(1L); + config.setDeferredIndexRetryMaxDelayMs(1L); + + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenThrow(new RuntimeException("persistent failure")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + // Should be called twice (initial + 1 retry), each time with markFailed + verify(dao, org.mockito.Mockito.times(2)).markFailed(eq(1001L), any(String.class), any(Integer.class)); + } + + + /** execute should correctly reconstruct and build a unique index. */ + @Test + public void testExecuteWithUniqueIndex() { + DeferredIndexOperation op = buildOp(1001L); + op.setIndexUnique(true); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE UNIQUE INDEX idx ON t(c)")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + verify(dao).markCompleted(eq(1001L), any(Long.class)); + } + + + /** execute should handle a SQLException from getConnection as a failure. */ + @Test + public void testExecuteSqlExceptionFromConnection() throws SQLException { + config.setDeferredIndexMaxRetries(0); + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + when(dataSource.getConnection()).thenThrow(new SQLException("connection refused")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + verify(dao).markFailed(eq(1001L), any(String.class), eq(1)); + } + + + /** buildIndex should restore autocommit to its original value after execution. */ + @Test + public void testAutoCommitRestoredAfterBuildIndex() throws SQLException { + when(connection.getAutoCommit()).thenReturn(false); + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()).thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + InOrder order = inOrder(connection); + order.verify(connection).setAutoCommit(true); + order.verify(connection).setAutoCommit(false); + } + + + /** execute() should be callable again after a previous execution completes. */ + @Test + public void testExecuteCanBeCalledAgainAfterCompletion() { + DeferredIndexOperation op = buildOp(1001L); + when(dao.findPendingOperations()) + .thenReturn(List.of(op)) + .thenReturn(List.of(op)); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + + // First execution + executor.execute().join(); + verify(dao).markCompleted(eq(1001L), any(Long.class)); + + // Second execution should not throw + executor.execute().join(); + verify(dao, org.mockito.Mockito.times(2)).markCompleted(eq(1001L), any(Long.class)); + } + + + // ------------------------------------------------------------------------- + // Config validation (at point of use in execute()) + // ------------------------------------------------------------------------- + + /** threadPoolSize less than 1 should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidThreadPoolSize() { + config.setDeferredIndexThreadPoolSize(0); + when(dao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute(); + } + + + /** maxRetries less than 0 should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidMaxRetries() { + config.setDeferredIndexMaxRetries(-1); + when(dao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute(); + } + + + /** retryBaseDelayMs less than 0 should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidRetryBaseDelayMs() { + config.setDeferredIndexRetryBaseDelayMs(-1L); + when(dao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute(); + } + + + /** retryMaxDelayMs less than retryBaseDelayMs should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidRetryMaxDelayMs() { + config.setDeferredIndexRetryBaseDelayMs(10_000L); + config.setDeferredIndexRetryMaxDelayMs(5_000L); + when(dao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + DeferredIndexExecutorImpl executor = new DeferredIndexExecutorImpl(dao, connectionResources, sqlScriptExecutorProvider, config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute(); + } + + + private DeferredIndexOperation buildOp(long id) { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setId(id); + op.setUpgradeUUID("test-uuid"); + op.setTableName("TestTable"); + op.setIndexName("TestIndex"); + op.setIndexUnique(false); + op.setStatus(DeferredIndexStatus.PENDING); + op.setRetryCount(0); + op.setCreatedTime(20260101120000L); + op.setColumnNames(List.of("col1")); + return op; + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperation.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperation.java new file mode 100644 index 000000000..779c1dbe6 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperation.java @@ -0,0 +1,152 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; + +import org.junit.Test; + +/** + * Tests for the {@link DeferredIndexOperation} POJO, covering all + * getters and setters. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexOperation { + + /** The id field should return the value set via setId. */ + @Test + public void testId() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setId(42L); + assertEquals(42L, op.getId()); + } + + + /** The upgradeUUID field should return the value set via setUpgradeUUID. */ + @Test + public void testUpgradeUUID() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setUpgradeUUID("uuid-1234"); + assertEquals("uuid-1234", op.getUpgradeUUID()); + } + + + /** The tableName field should return the value set via setTableName. */ + @Test + public void testTableName() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setTableName("MyTable"); + assertEquals("MyTable", op.getTableName()); + } + + + /** The indexName field should return the value set via setIndexName. */ + @Test + public void testIndexName() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setIndexName("MyTable_1"); + assertEquals("MyTable_1", op.getIndexName()); + } + + + /** The indexUnique field should default to false and return the value set via setIndexUnique. */ + @Test + public void testIndexUnique() { + DeferredIndexOperation op = new DeferredIndexOperation(); + assertFalse(op.isIndexUnique()); + op.setIndexUnique(true); + assertTrue(op.isIndexUnique()); + } + + + /** The status field should return the value set via setStatus. */ + @Test + public void testStatus() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setStatus(DeferredIndexStatus.COMPLETED); + assertEquals(DeferredIndexStatus.COMPLETED, op.getStatus()); + } + + + /** The retryCount field should return the value set via setRetryCount. */ + @Test + public void testRetryCount() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setRetryCount(3); + assertEquals(3, op.getRetryCount()); + } + + + /** The createdTime field should return the value set via setCreatedTime. */ + @Test + public void testCreatedTime() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setCreatedTime(20260101120000L); + assertEquals(20260101120000L, op.getCreatedTime()); + } + + + /** The startedTime field is nullable and should return the value set via setStartedTime. */ + @Test + public void testStartedTime() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setStartedTime(20260101120100L); + assertEquals(Long.valueOf(20260101120100L), op.getStartedTime()); + } + + + /** The completedTime field is nullable and should return the value set via setCompletedTime. */ + @Test + public void testCompletedTime() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setCompletedTime(20260101120200L); + assertEquals(Long.valueOf(20260101120200L), op.getCompletedTime()); + } + + + /** The errorMessage field is nullable and should return the value set via setErrorMessage. */ + @Test + public void testErrorMessage() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setErrorMessage("something went wrong"); + assertEquals("something went wrong", op.getErrorMessage()); + } + + + /** The columnNames field stores ordered column names. */ + @Test + public void testColumnNames() { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setColumnNames(List.of("col1", "col2")); + assertEquals(List.of("col1", "col2"), op.getColumnNames()); + } + + + /** Nullable fields should default to null before being set. */ + @Test + public void testNullableFieldsDefaultToNull() { + DeferredIndexOperation op = new DeferredIndexOperation(); + assertNull(op.getStartedTime()); + assertNull(op.getCompletedTime()); + assertNull(op.getErrorMessage()); + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperationDAOImpl.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperationDAOImpl.java new file mode 100644 index 000000000..f339146e7 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexOperationDAOImpl.java @@ -0,0 +1,272 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.sql.element.Criterion.or; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlDialect; +import org.alfasoftware.morf.jdbc.SqlScriptExecutor; +import org.alfasoftware.morf.jdbc.SqlScriptExecutor.ResultSetProcessor; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.sql.InsertStatement; +import org.alfasoftware.morf.sql.SelectStatement; +import org.alfasoftware.morf.sql.UpdateStatement; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Tests for {@link DeferredIndexOperationDAOImpl}. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexOperationDAOImpl { + + @Mock private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Mock private SqlScriptExecutor sqlScriptExecutor; + @Mock private SqlDialect sqlDialect; + @Mock private ConnectionResources connectionResources; + + private DeferredIndexOperationDAO dao; + private AutoCloseable mocks; + + private static final String TABLE = DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; + + + @Before + public void setUp() { + mocks = MockitoAnnotations.openMocks(this); + when(sqlScriptExecutorProvider.get()).thenReturn(sqlScriptExecutor); + when(sqlDialect.convertStatementToSQL(any(InsertStatement.class))).thenReturn(List.of("SQL")); + when(sqlDialect.convertStatementToSQL(any(UpdateStatement.class))).thenReturn("UPDATE_SQL"); + when(sqlDialect.convertStatementToSQL(any(SelectStatement.class))).thenReturn("SELECT_SQL"); + when(connectionResources.sqlDialect()).thenReturn(sqlDialect); + dao = new DeferredIndexOperationDAOImpl(sqlScriptExecutorProvider, connectionResources); + } + + + @After + public void tearDown() throws Exception { + mocks.close(); + } + + + /** + * Verify findPendingOperations selects from the operation table + * with WHERE status = PENDING clause. + */ + @SuppressWarnings("unchecked") + @Test + public void testFindPendingOperations() { + when(sqlScriptExecutor.executeQuery(anyString(), any(ResultSetProcessor.class))).thenReturn(List.of()); + + dao.findPendingOperations(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SelectStatement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(captor.capture()); + + String expected = select( + field("id"), field("upgradeUUID"), field("tableName"), + field("indexName"), field("indexUnique"), field("indexColumns"), + field("status"), field("retryCount"), field("createdTime"), + field("startedTime"), field("completedTime"), field("errorMessage") + ).from(tableRef(TABLE)) + .where(field("status").eq(DeferredIndexStatus.PENDING.name())) + .orderBy(field("id")) + .toString(); + + assertEquals("SELECT statement", expected, captor.getValue().toString()); + } + + + /** + * Verify markStarted produces an UPDATE setting status=IN_PROGRESS and startedTime. + */ + @Test + public void testMarkStarted() { + dao.markStarted(1001L, 20260101120000L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateStatement.class); + verify(sqlDialect).convertStatementToSQL(captor.capture()); + + String expected = update(tableRef(TABLE)) + .set( + literal(DeferredIndexStatus.IN_PROGRESS.name()).as("status"), + literal(20260101120000L).as("startedTime") + ) + .where(field("id").eq(1001L)) + .toString(); + + assertEquals("UPDATE statement", expected, captor.getValue().toString()); + verify(sqlScriptExecutor).execute("UPDATE_SQL"); + } + + + /** + * Verify markCompleted produces an UPDATE setting status=COMPLETED and completedTime. + */ + @Test + public void testMarkCompleted() { + dao.markCompleted(1001L, 20260101130000L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateStatement.class); + verify(sqlDialect).convertStatementToSQL(captor.capture()); + + String expected = update(tableRef(TABLE)) + .set( + literal(DeferredIndexStatus.COMPLETED.name()).as("status"), + literal(20260101130000L).as("completedTime") + ) + .where(field("id").eq(1001L)) + .toString(); + + assertEquals("UPDATE statement", expected, captor.getValue().toString()); + } + + + /** + * Verify markFailed produces an UPDATE setting status=FAILED, errorMessage, + * and the updated retryCount. + */ + @Test + public void testMarkFailed() { + dao.markFailed(1001L, "Something went wrong", 2); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateStatement.class); + verify(sqlDialect).convertStatementToSQL(captor.capture()); + + String expected = update(tableRef(TABLE)) + .set( + literal(DeferredIndexStatus.FAILED.name()).as("status"), + literal("Something went wrong").as("errorMessage"), + literal(2).as("retryCount") + ) + .where(field("id").eq(1001L)) + .toString(); + + assertEquals("UPDATE statement", expected, captor.getValue().toString()); + } + + + /** + * Verify resetToPending produces an UPDATE setting status=PENDING. + */ + @Test + public void testResetToPending() { + dao.resetToPending(1001L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateStatement.class); + verify(sqlDialect).convertStatementToSQL(captor.capture()); + + String expected = update(tableRef(TABLE)) + .set(literal(DeferredIndexStatus.PENDING.name()).as("status")) + .where(field("id").eq(1001L)) + .toString(); + + assertEquals("UPDATE statement", expected, captor.getValue().toString()); + } + + + /** + * Verify resetAllInProgressToPending produces an UPDATE setting status=PENDING + * for all IN_PROGRESS operations. + */ + @Test + public void testResetAllInProgressToPending() { + dao.resetAllInProgressToPending(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateStatement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(captor.capture()); + + String expected = update(tableRef(TABLE)) + .set(literal(DeferredIndexStatus.PENDING.name()).as("status")) + .where(field("status").eq(DeferredIndexStatus.IN_PROGRESS.name())) + .toString(); + + assertEquals("UPDATE statement", expected, captor.getValue().toString()); + } + + + /** + * Verify countAllByStatus produces a SELECT on the status column. + */ + @SuppressWarnings("unchecked") + @Test + public void testCountAllByStatus() { + when(sqlScriptExecutor.executeQuery(anyString(), any(ResultSetProcessor.class))).thenReturn(new java.util.EnumMap<>(DeferredIndexStatus.class)); + + dao.countAllByStatus(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SelectStatement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(captor.capture()); + + String expected = select(field("status")) + .from(tableRef(TABLE)) + .toString(); + + assertEquals("SELECT statement", expected, captor.getValue().toString()); + } + + + /** + * Verify findNonTerminalOperations selects operations with PENDING, IN_PROGRESS, + * or FAILED status from the operation table. + */ + @SuppressWarnings("unchecked") + @Test + public void testFindNonTerminalOperations() { + when(sqlScriptExecutor.executeQuery(anyString(), any(ResultSetProcessor.class))).thenReturn(List.of()); + + dao.findNonTerminalOperations(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SelectStatement.class); + verify(sqlDialect, times(1)).convertStatementToSQL(captor.capture()); + + String expected = select( + field("id"), field("upgradeUUID"), field("tableName"), + field("indexName"), field("indexUnique"), field("indexColumns"), + field("status"), field("retryCount"), field("createdTime"), + field("startedTime"), field("completedTime"), field("errorMessage") + ).from(tableRef(TABLE)) + .where(or( + field("status").eq(DeferredIndexStatus.PENDING.name()), + field("status").eq(DeferredIndexStatus.IN_PROGRESS.name()), + field("status").eq(DeferredIndexStatus.FAILED.name()) + )) + .orderBy(field("id")) + .toString(); + + assertEquals("SELECT statement", expected, captor.getValue().toString()); + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheckUnit.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheckUnit.java new file mode 100644 index 000000000..45430be29 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheckUnit.java @@ -0,0 +1,400 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link DeferredIndexReadinessCheckImpl} covering the + * {@link DeferredIndexReadinessCheck#forceBuildAllPending()} and + * {@link DeferredIndexReadinessCheck#augmentSchemaWithPendingIndexes} methods + * with mocked DAO, executor, and connection dependencies. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexReadinessCheckUnit { + + private ConnectionResources connWithTable; + private ConnectionResources connWithoutTable; + + + /** Set up mock connections with and without the deferred index table. */ + @Before + public void setUp() { + connWithTable = mockConnectionResources(true); + connWithoutTable = mockConnectionResources(false); + } + + + /** forceBuildAllPending() should not call executor when no pending operations exist. */ + @Test + public void testRunWithEmptyQueue() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(Collections.emptyList()); + when(mockDao.countAllByStatus()).thenReturn(statusCounts(0)); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithTable); + check.forceBuildAllPending(); + + verify(mockDao).findPendingOperations(); + verify(mockExecutor, never()).execute(); + } + + + /** forceBuildAllPending() should execute pending operations and succeed when all complete. */ + @Test + public void testRunExecutesPendingOperationsSuccessfully() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + when(mockDao.countAllByStatus()).thenReturn(statusCounts(0)); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithTable); + check.forceBuildAllPending(); + + verify(mockExecutor).execute(); + verify(mockDao).countAllByStatus(); + } + + + /** forceBuildAllPending() should throw IllegalStateException when any operations fail. */ + @Test(expected = IllegalStateException.class) + public void testRunThrowsWhenOperationsFail() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(List.of(buildOp(1L))); + when(mockDao.countAllByStatus()).thenReturn(statusCounts(1)); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithTable); + check.forceBuildAllPending(); + } + + + /** The failure exception message should include the failed count. */ + @Test + public void testRunFailureMessageIncludesCount() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(List.of(buildOp(1L), buildOp(2L))); + when(mockDao.countAllByStatus()).thenReturn(statusCounts(2)); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithTable); + try { + check.forceBuildAllPending(); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue("Message should include count", e.getMessage().contains("2")); + } + } + + + /** The executor should not be called when the pending queue is empty. */ + @Test + public void testExecutorNotCalledWhenQueueEmpty() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(Collections.emptyList()); + + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithTable); + check.forceBuildAllPending(); + + verify(mockExecutor, never()).execute(); + } + + + /** forceBuildAllPending() should skip entirely when the DeferredIndexOperation table does not exist. */ + @Test + public void testRunSkipsWhenTableDoesNotExist() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mockExecutor, config, connWithoutTable); + check.forceBuildAllPending(); + + verify(mockDao, never()).findPendingOperations(); + verify(mockExecutor, never()).execute(); + } + + + /** forceBuildAllPending() should reset IN_PROGRESS operations to PENDING before querying. */ + @Test + public void testRunResetsInProgressToPending() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + + when(mockDao.findPendingOperations()).thenReturn(Collections.emptyList()); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexReadinessCheck check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + check.forceBuildAllPending(); + + verify(mockDao).resetAllInProgressToPending(); + verify(mockDao).findPendingOperations(); + } + + + // ------------------------------------------------------------------------- + // augmentSchemaWithPendingIndexes + // ------------------------------------------------------------------------- + + /** augment should return the same schema when the table does not exist. */ + @Test + public void testAugmentSkipsWhenTableDoesNotExist() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithoutTable); + Schema input = schema(table("Foo").columns(column("id", DataType.BIG_INTEGER).primaryKey())); + + assertSame("Should return input schema unchanged", input, check.augmentSchemaWithPendingIndexes(input)); + verify(mockDao, never()).findNonTerminalOperations(); + } + + + /** augment should return the same schema when no non-terminal ops exist. */ + @Test + public void testAugmentReturnsUnchangedWhenNoOps() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(Collections.emptyList()); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema(table("Foo").columns(column("id", DataType.BIG_INTEGER).primaryKey())); + + assertSame("Should return input schema unchanged", input, check.augmentSchemaWithPendingIndexes(input)); + } + + + /** augment should add a non-unique index to the schema. */ + @Test + public void testAugmentAddsIndex() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(List.of(buildOp(1L, "Foo", "Foo_Col1_1", false, "col1"))); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema(table("Foo").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("col1", DataType.STRING, 50) + )); + + Schema result = check.augmentSchemaWithPendingIndexes(input); + assertTrue("Index should be added", + result.getTable("Foo").indexes().stream() + .anyMatch(idx -> "Foo_Col1_1".equals(idx.getName()))); + } + + + /** augment should add a unique index when the operation specifies unique. */ + @Test + public void testAugmentAddsUniqueIndex() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(List.of(buildOp(1L, "Foo", "Foo_Col1_U", true, "col1"))); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema(table("Foo").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("col1", DataType.STRING, 50) + )); + + Schema result = check.augmentSchemaWithPendingIndexes(input); + assertTrue("Unique index should be added", + result.getTable("Foo").indexes().stream() + .anyMatch(idx -> "Foo_Col1_U".equals(idx.getName()) && idx.isUnique())); + } + + + /** augment should skip an op whose table does not exist in the schema. */ + @Test + public void testAugmentSkipsOpForMissingTable() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(List.of(buildOp(1L, "NoSuchTable", "Idx_1", false, "col1"))); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema(table("Foo").columns(column("id", DataType.BIG_INTEGER).primaryKey())); + + Schema result = check.augmentSchemaWithPendingIndexes(input); + // Should still have only the Foo table, no crash + assertTrue("Foo table should still exist", result.tableExists("Foo")); + assertEquals("No indexes should be added to Foo", 0, result.getTable("Foo").indexes().size()); + } + + + /** augment should skip an op whose index already exists on the table. */ + @Test + public void testAugmentSkipsExistingIndex() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(List.of(buildOp(1L, "Foo", "Foo_Col1_1", false, "col1"))); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema(table("Foo").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("col1", DataType.STRING, 50) + ).indexes( + index("Foo_Col1_1").columns("col1") + )); + + Schema result = check.augmentSchemaWithPendingIndexes(input); + long indexCount = result.getTable("Foo").indexes().stream() + .filter(idx -> "Foo_Col1_1".equals(idx.getName())) + .count(); + assertEquals("Should not duplicate existing index", 1, indexCount); + } + + + /** augment should handle multiple ops on different tables. */ + @Test + public void testAugmentMultipleOpsOnDifferentTables() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + when(mockDao.findNonTerminalOperations()).thenReturn(List.of( + buildOp(1L, "Foo", "Foo_Col1_1", false, "col1"), + buildOp(2L, "Bar", "Bar_Val_1", false, "val") + )); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + + DeferredIndexReadinessCheckImpl check = new DeferredIndexReadinessCheckImpl(mockDao, mock(DeferredIndexExecutor.class), config, connWithTable); + Schema input = schema( + table("Foo").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("col1", DataType.STRING, 50) + ), + table("Bar").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("val", DataType.STRING, 50) + ) + ); + + Schema result = check.augmentSchemaWithPendingIndexes(input); + assertTrue("Foo index should be added", + result.getTable("Foo").indexes().stream().anyMatch(idx -> "Foo_Col1_1".equals(idx.getName()))); + assertTrue("Bar index should be added", + result.getTable("Bar").indexes().stream().anyMatch(idx -> "Bar_Val_1".equals(idx.getName()))); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private DeferredIndexOperation buildOp(long id) { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setId(id); + op.setUpgradeUUID("test-uuid"); + op.setTableName("TestTable"); + op.setIndexName("TestIndex"); + op.setIndexUnique(false); + op.setStatus(DeferredIndexStatus.PENDING); + op.setRetryCount(0); + op.setCreatedTime(20260101120000L); + op.setColumnNames(List.of("col1")); + return op; + } + + + private DeferredIndexOperation buildOp(long id, String tableName, String indexName, + boolean unique, String... columns) { + DeferredIndexOperation op = new DeferredIndexOperation(); + op.setId(id); + op.setUpgradeUUID("test-uuid"); + op.setTableName(tableName); + op.setIndexName(indexName); + op.setIndexUnique(unique); + op.setStatus(DeferredIndexStatus.PENDING); + op.setRetryCount(0); + op.setCreatedTime(20260101120000L); + op.setColumnNames(List.of(columns)); + return op; + } + + + private Map statusCounts(int failedCount) { + Map counts = new EnumMap<>(DeferredIndexStatus.class); + for (DeferredIndexStatus s : DeferredIndexStatus.values()) { + counts.put(s, 0); + } + counts.put(DeferredIndexStatus.FAILED, failedCount); + return counts; + } + + + private static ConnectionResources mockConnectionResources(boolean tableExists) { + SchemaResource mockSr = mock(SchemaResource.class); + when(mockSr.tableExists(DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME)).thenReturn(tableExists); + ConnectionResources mockConn = mock(ConnectionResources.class); + when(mockConn.openSchemaResource()).thenReturn(mockSr); + return mockConn; + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexServiceImpl.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexServiceImpl.java new file mode 100644 index 000000000..99f2b3958 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexServiceImpl.java @@ -0,0 +1,173 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; + +import org.junit.Test; + +/** + * Unit tests for {@link DeferredIndexServiceImpl} covering the + * {@code execute()} / {@code awaitCompletion()} orchestration logic. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexServiceImpl { + + // ------------------------------------------------------------------------- + // execute() orchestration + // ------------------------------------------------------------------------- + + /** execute() should call executor. */ + @Test + public void testExecuteCallsExecutor() { + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + + DeferredIndexServiceImpl service = serviceWithMocks(mockExecutor); + service.execute(); + + verify(mockExecutor).execute(); + } + + + // ------------------------------------------------------------------------- + // awaitCompletion() orchestration + // ------------------------------------------------------------------------- + + /** awaitCompletion() should throw when execute() has not been called. */ + @Test(expected = IllegalStateException.class) + public void testAwaitCompletionThrowsWhenNoExecution() { + DeferredIndexServiceImpl service = serviceWithMocks(null); + service.awaitCompletion(60L); + } + + + /** awaitCompletion() should return true when the future is already done. */ + @Test + public void testAwaitCompletionReturnsTrueWhenFutureDone() { + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + + DeferredIndexServiceImpl service = serviceWithMocks(mockExecutor); + service.execute(); + + assertTrue("Should return true when future is complete", service.awaitCompletion(60L)); + } + + + /** awaitCompletion() should return false when the future does not complete in time. */ + @Test + public void testAwaitCompletionReturnsFalseOnTimeout() { + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(new CompletableFuture<>()); // never completes + + DeferredIndexServiceImpl service = serviceWithMocks(mockExecutor); + service.execute(); + + assertFalse("Should return false on timeout", service.awaitCompletion(1L)); + } + + + /** awaitCompletion() should return false and restore interrupt flag when interrupted. */ + @Test + public void testAwaitCompletionReturnsFalseWhenInterrupted() throws InterruptedException { + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(new CompletableFuture<>()); // never completes + + DeferredIndexServiceImpl service = serviceWithMocks(mockExecutor); + service.execute(); + + CountDownLatch enteredAwait = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean result = new java.util.concurrent.atomic.AtomicBoolean(true); + Thread testThread = new Thread(() -> { + enteredAwait.countDown(); + result.set(service.awaitCompletion(60L)); + }); + testThread.start(); + enteredAwait.await(); + testThread.interrupt(); + testThread.join(5_000L); + + assertFalse("Should return false when interrupted", result.get()); + } + + + /** awaitCompletion() with zero timeout should wait indefinitely until done. */ + @Test + public void testAwaitCompletionZeroTimeoutWaitsUntilDone() { + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + CompletableFuture future = new CompletableFuture<>(); + when(mockExecutor.execute()).thenReturn(future); + + DeferredIndexServiceImpl service = serviceWithMocks(mockExecutor); + service.execute(); + + CountDownLatch enteredAwait = new CountDownLatch(1); + // Complete the future once the test thread has entered awaitCompletion + new Thread(() -> { + try { enteredAwait.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + future.complete(null); + }).start(); + + enteredAwait.countDown(); + assertTrue("Should return true once done", service.awaitCompletion(0L)); + } + + + // ------------------------------------------------------------------------- + // getProgress() + // ------------------------------------------------------------------------- + + /** getProgress() should delegate to the DAO and return the counts map. */ + @Test + public void testGetProgressDelegatesToDao() { + DeferredIndexOperationDAO mockDao = mock(DeferredIndexOperationDAO.class); + Map counts = new EnumMap<>(DeferredIndexStatus.class); + counts.put(DeferredIndexStatus.COMPLETED, 3); + counts.put(DeferredIndexStatus.IN_PROGRESS, 1); + counts.put(DeferredIndexStatus.PENDING, 5); + counts.put(DeferredIndexStatus.FAILED, 0); + when(mockDao.countAllByStatus()).thenReturn(counts); + + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(null, mockDao); + Map result = service.getProgress(); + + assertEquals(Integer.valueOf(3), result.get(DeferredIndexStatus.COMPLETED)); + assertEquals(Integer.valueOf(1), result.get(DeferredIndexStatus.IN_PROGRESS)); + assertEquals(Integer.valueOf(5), result.get(DeferredIndexStatus.PENDING)); + assertEquals(Integer.valueOf(0), result.get(DeferredIndexStatus.FAILED)); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private DeferredIndexServiceImpl serviceWithMocks(DeferredIndexExecutor executor) { + return new DeferredIndexServiceImpl(executor, mock(DeferredIndexOperationDAO.class)); + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v10_20_30a/ComplexValidPackageStep.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v10_20_30a/ComplexValidPackageStep.java new file mode 100644 index 000000000..ad7783130 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v10_20_30a/ComplexValidPackageStep.java @@ -0,0 +1,45 @@ +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.testupgradegraph.upgrade.v10_20_30a; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Test upgrade step with complex valid package-based version (v10_20_30a). + * + * @author Copyright (c) Alfa Financial Software 2024 + */ +@Sequence(5500) +public class ComplexValidPackageStep implements UpgradeStep { + + @Override + public String getJiraId() { + return "TEST-PKG-COMPLEX"; + } + + @Override + public String getDescription() { + return "Valid package step for v10.20.30a"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v1_0/ValidPackageStep.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v1_0/ValidPackageStep.java new file mode 100644 index 000000000..ac873d587 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/testupgradegraph/upgrade/v1_0/ValidPackageStep.java @@ -0,0 +1,45 @@ +/* Copyright 2017 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.testupgradegraph.upgrade.v1_0; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Test upgrade step with valid package-based version (v1_0). + * + * @author Copyright (c) Alfa Financial Software 2024 + */ +@Sequence(2000) +public class ValidPackageStep implements UpgradeStep { + + @Override + public String getJiraId() { + return "TEST-PKG-1-0"; + } + + @Override + public String getDescription() { + return "Valid package step for v1.0"; + } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + // No-op + } +} diff --git a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/upgrade/TestUpgradeSteps.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/upgrade/TestUpgradeSteps.java index 90ad3d10f..d60e3b9fa 100644 --- a/morf-core/src/test/java/org/alfasoftware/morf/upgrade/upgrade/TestUpgradeSteps.java +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/upgrade/TestUpgradeSteps.java @@ -1,18 +1,25 @@ package org.alfasoftware.morf.upgrade.upgrade; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import java.util.stream.Collectors; + +import org.alfasoftware.morf.metadata.Column; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.Table; import org.alfasoftware.morf.upgrade.DataEditor; import org.alfasoftware.morf.upgrade.SchemaEditor; import org.alfasoftware.morf.upgrade.UpgradeStep; +import org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution; import org.junit.Test; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - public class TestUpgradeSteps { @@ -43,4 +50,50 @@ public void testRecreateOracleSequences() { verifyNoInteractions(schema); } + + /** + * Verify CreateDeferredIndexOperationTables has metadata and calls addTable once. + */ + @Test + public void testCreateDeferredIndexOperationTables() { + CreateDeferredIndexOperationTables upgradeStep = new CreateDeferredIndexOperationTables(); + testUpgradeStep(upgradeStep); + SchemaEditor schema = mock(SchemaEditor.class); + DataEditor dataEditor = mock(DataEditor.class); + upgradeStep.execute(schema, dataEditor); + verify(schema, times(1)).addTable(any()); + } + + + /** + * Verify DeferredIndexOperation table has all required columns and indexes. + */ + @Test + public void testDeferredIndexOperationTableStructure() { + Table table = DatabaseUpgradeTableContribution.deferredIndexOperationTable(); + assertEquals("DeferredIndexOperation", table.getName()); + + java.util.List columnNames = table.columns().stream() + .map(Column::getName) + .collect(Collectors.toList()); + assertTrue(columnNames.contains("id")); + assertTrue(columnNames.contains("upgradeUUID")); + assertTrue(columnNames.contains("tableName")); + assertTrue(columnNames.contains("indexName")); + assertTrue(columnNames.contains("indexUnique")); + assertTrue(columnNames.contains("status")); + assertTrue(columnNames.contains("retryCount")); + assertTrue(columnNames.contains("createdTime")); + assertTrue(columnNames.contains("startedTime")); + assertTrue(columnNames.contains("completedTime")); + assertTrue(columnNames.contains("indexColumns")); + assertTrue(columnNames.contains("errorMessage")); + + java.util.List indexNames = table.indexes().stream() + .map(Index::getName) + .collect(Collectors.toList()); + assertTrue(indexNames.contains("DeferredIndexOp_1")); + assertTrue(indexNames.contains("DeferredIndexOp_2")); + } + } \ No newline at end of file diff --git a/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java b/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java index 6a81fdb0b..28524ec1e 100755 --- a/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java +++ b/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java @@ -693,4 +693,19 @@ protected String tableNameWithSchemaName(TableReference tableRef) { public boolean useForcedSerialImport() { return true; } + + + /** + * H2 does not support non-blocking DDL, but returns {@code true} to enable + * deferred index creation. H2 is a small in-memory database where indexes + * are built very quickly, so blocking is not a concern in practice. Returning + * {@code true} allows integration tests to exercise the full deferred index + * pipeline (PENDING rows, executor, crash recovery). + * + * @see org.alfasoftware.morf.jdbc.SqlDialect#supportsDeferredIndexCreation() + */ + @Override + public boolean supportsDeferredIndexCreation() { + return true; + } } \ No newline at end of file diff --git a/morf-h2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java b/morf-h2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java index c44b32529..40b6c8aef 100755 --- a/morf-h2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java +++ b/morf-h2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java @@ -1445,4 +1445,13 @@ protected String expectedSelectWithJoinAndLimit() { protected String expectedSelectWithOrderByWhereAndLimit() { return "SELECT id, stringField FROM " + tableName(TEST_TABLE) + " WHERE (stringField IS NOT NULL) ORDER BY id DESC LIMIT 10"; } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedSupportsDeferredIndexCreation() + */ + @Override + protected boolean expectedSupportsDeferredIndexCreation() { + return true; + } } diff --git a/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java b/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java index 6720fe414..c0025a73b 100755 --- a/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java +++ b/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2Dialect.java @@ -709,4 +709,19 @@ protected String tableNameWithSchemaName(TableReference tableRef) { public boolean useForcedSerialImport() { return true; } + + + /** + * H2 does not support non-blocking DDL, but returns {@code true} to enable + * deferred index creation. H2 is a small in-memory database where indexes + * are built very quickly, so blocking is not a concern in practice. Returning + * {@code true} allows integration tests to exercise the full deferred index + * pipeline (PENDING rows, executor, crash recovery). + * + * @see org.alfasoftware.morf.jdbc.SqlDialect#supportsDeferredIndexCreation() + */ + @Override + public boolean supportsDeferredIndexCreation() { + return true; + } } \ No newline at end of file diff --git a/morf-h2v2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java b/morf-h2v2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java index 49c2aa94e..c8a3f70e8 100755 --- a/morf-h2v2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java +++ b/morf-h2v2/src/test/java/org/alfasoftware/morf/jdbc/h2/TestH2Dialect.java @@ -1398,4 +1398,10 @@ protected String expectedSelectWithOrderByWhereAndLimit() { return "SELECT id, stringField FROM " + tableName(TEST_TABLE) + " WHERE (stringField IS NOT NULL) ORDER BY id DESC LIMIT 10"; } + + @Override + protected boolean expectedSupportsDeferredIndexCreation() { + return true; + } + } diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutor.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutor.java new file mode 100644 index 000000000..6836e487e --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutor.java @@ -0,0 +1,275 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.insert; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deferredIndexOperationTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.UUID; + +import org.alfasoftware.morf.guicesupport.InjectMembersRule; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.testing.DatabaseSchemaManager; +import org.alfasoftware.morf.testing.DatabaseSchemaManager.TruncationBehavior; +import org.alfasoftware.morf.testing.TestingDataSourceModule; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.MethodRule; + +import com.google.inject.Inject; + +import net.jcip.annotations.NotThreadSafe; + +/** + * Integration tests for {@link DeferredIndexExecutorImpl} (Stages 7 and 8). + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@NotThreadSafe +public class TestDeferredIndexExecutor { + + @Rule + public MethodRule injectMembersRule = new InjectMembersRule(new TestingDataSourceModule()); + + @Inject private ConnectionResources connectionResources; + @Inject private DatabaseSchemaManager schemaManager; + @Inject private SqlScriptExecutorProvider sqlScriptExecutorProvider; + + private static final Schema TEST_SCHEMA = schema( + deferredIndexOperationTable(), + table("Apple").columns( + column("pips", DataType.STRING, 10).nullable(), + column("color", DataType.STRING, 20).nullable() + ) + ); + + private UpgradeConfigAndContext config; + + + /** + * Create a fresh schema and a default config before each test. + */ + @Before + public void setUp() { + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(TEST_SCHEMA, TruncationBehavior.ALWAYS); + config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); // fast retries for tests + } + + + /** + * Invalidate the schema manager cache after each test. + */ + @After + public void tearDown() { + schemaManager.invalidateCache(); + } + + + // ------------------------------------------------------------------------- + // Stage 7: execution tests + // ------------------------------------------------------------------------- + + /** + * A PENDING operation should transition to COMPLETED and the index should + * exist in the database schema after execution completes. + */ + @Test + public void testPendingTransitionsToCompleted() { + config.setDeferredIndexMaxRetries(0); + insertPendingRow("Apple", "Apple_1", false, "pips"); + + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("status should be COMPLETED", DeferredIndexStatus.COMPLETED.name(), queryStatus("Apple_1")); + + try (SchemaResource schema = connectionResources.openSchemaResource()) { + assertTrue("Apple_1 should exist in schema", + schema.getTable("Apple").indexes().stream().anyMatch(idx -> "Apple_1".equalsIgnoreCase(idx.getName()))); + } + } + + + /** + * With maxRetries=0 an operation that targets a non-existent table should be + * marked FAILED in a single attempt with no retries. + */ + @Test + public void testFailedAfterMaxRetriesWithNoRetries() { + config.setDeferredIndexMaxRetries(0); + insertPendingRow("NoSuchTable", "NoSuchTable_1", false, "col"); + + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("status should be FAILED", DeferredIndexStatus.FAILED.name(), queryStatus("NoSuchTable_1")); + assertEquals("retryCount should be 1", 1, queryRetryCount("NoSuchTable_1")); + } + + + /** + * With maxRetries=1 a failing operation should be retried once before being + * permanently marked FAILED with retryCount=2. + */ + @Test + public void testRetryOnFailure() { + config.setDeferredIndexMaxRetries(1); + insertPendingRow("NoSuchTable", "NoSuchTable_1", false, "col"); + + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("status should be FAILED", DeferredIndexStatus.FAILED.name(), queryStatus("NoSuchTable_1")); + assertEquals("retryCount should be 2 (initial + 1 retry)", 2, queryRetryCount("NoSuchTable_1")); + } + + + /** + * Executing on an empty queue should complete immediately with no errors. + */ + @Test + public void testEmptyQueueReturnsImmediately() { + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + // No operations in the table at all + assertEquals("No operations should exist", 0, countOperations()); + } + + + /** + * A unique index should be built with the UNIQUE constraint applied. + */ + @Test + public void testUniqueIndexCreated() { + config.setDeferredIndexMaxRetries(0); + insertPendingRow("Apple", "Apple_Unique_1", true, "pips"); + + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + try (SchemaResource schema = connectionResources.openSchemaResource()) { + assertTrue("Apple_Unique_1 should be unique", + schema.getTable("Apple").indexes().stream() + .filter(idx -> "Apple_Unique_1".equalsIgnoreCase(idx.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Index not found")) + .isUnique()); + } + } + + + /** + * A multi-column index should be built with columns in the correct order. + */ + @Test + public void testMultiColumnIndexCreated() { + config.setDeferredIndexMaxRetries(0); + insertPendingRow("Apple", "Apple_Multi_1", false, "pips", "color"); + + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("status should be COMPLETED", DeferredIndexStatus.COMPLETED.name(), queryStatus("Apple_Multi_1")); + + try (SchemaResource schema = connectionResources.openSchemaResource()) { + org.alfasoftware.morf.metadata.Index idx = schema.getTable("Apple").indexes().stream() + .filter(i -> "Apple_Multi_1".equalsIgnoreCase(i.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Multi-column index not found")); + assertEquals("column count", 2, idx.columnNames().size()); + assertTrue("first column should be pips", idx.columnNames().get(0).equalsIgnoreCase("pips")); + } + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void insertPendingRow(String tableName, String indexName, + boolean unique, String... columns) { + long operationId = Math.abs(UUID.randomUUID().getMostSignificantBits()); + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + insert().into(tableRef(DEFERRED_INDEX_OPERATION_NAME)).values( + literal(operationId).as("id"), + literal("test-upgrade-uuid").as("upgradeUUID"), + literal(tableName).as("tableName"), + literal(indexName).as("indexName"), + literal(unique ? 1 : 0).as("indexUnique"), + literal(String.join(",", columns)).as("indexColumns"), + literal(DeferredIndexStatus.PENDING.name()).as("status"), + literal(0).as("retryCount"), + literal(System.currentTimeMillis()).as("createdTime") + ) + ) + ); + } + + + private String queryStatus(String indexName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("status")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getString(1) : null); + } + + + private int queryRetryCount(String indexName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("retryCount")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getInt(1) : 0); + } + + + private int countOperations() { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("id")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> { + int count = 0; + while (rs.next()) count++; + return count; + }); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexIntegration.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexIntegration.java new file mode 100644 index 000000000..c6a8279b6 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexIntegration.java @@ -0,0 +1,841 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.insert; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deferredIndexOperationTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.alfasoftware.morf.guicesupport.InjectMembersRule; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.testing.DatabaseSchemaManager; +import org.alfasoftware.morf.testing.DatabaseSchemaManager.TruncationBehavior; +import org.alfasoftware.morf.testing.TestingDataSourceModule; +import org.alfasoftware.morf.upgrade.Upgrade; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.upgrade.UpgradeStep; +import org.alfasoftware.morf.upgrade.ViewDeploymentValidator; +import org.alfasoftware.morf.upgrade.upgrade.CreateDeferredIndexOperationTables; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddImmediateIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndexThenChange; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndexThenRemove; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndexThenRename; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndexThenRenameColumnThenRemove; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredMultiColumnIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredUniqueIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTableWithDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTwoDeferredIndexes; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.MethodRule; + +import com.google.inject.Inject; + +import net.jcip.annotations.NotThreadSafe; + +/** + * End-to-end integration tests for the deferred index lifecycle (Stage 12). + * Exercises the full upgrade framework path: upgrade step execution, + * deferred operation queueing, executor completion, and schema verification. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@NotThreadSafe +public class TestDeferredIndexIntegration { + + @Rule + public MethodRule injectMembersRule = new InjectMembersRule(new TestingDataSourceModule()); + + @Inject private ConnectionResources connectionResources; + @Inject private DatabaseSchemaManager schemaManager; + @Inject private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Inject private ViewDeploymentValidator viewDeploymentValidator; + + private final UpgradeConfigAndContext upgradeConfigAndContext = new UpgradeConfigAndContext(); + { upgradeConfigAndContext.setDeferredIndexCreationEnabled(true); } + + private static final Schema INITIAL_SCHEMA = schema( + deployedViewsTable(), + upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + + + /** Create a fresh schema before each test. */ + @Before + public void setUp() { + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(INITIAL_SCHEMA, TruncationBehavior.ALWAYS); + } + + + /** Invalidate the schema manager cache after each test. */ + @After + public void tearDown() { + schemaManager.invalidateCache(); + } + + + /** + * Verify that running an upgrade step with addIndexDeferred() inserts + * a PENDING row into the DeferredIndexOperation table. + */ + @Test + public void testDeferredAddCreatesPendingRow() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertEquals("Row count", 1, countOperations()); + } + + + /** + * Verify that running the executor after the upgrade step completes + * the build, marks the row COMPLETED, and the index exists in the schema. + */ + @Test + public void testExecutorCompletesAndIndexExistsInSchema() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify that addIndexDeferred() followed immediately by removeIndex() + * in the same step auto-cancels the deferred operation. + */ + @Test + public void testAutoCancelDeferredAddFollowedByRemove() { + Schema targetSchema = schema(INITIAL_SCHEMA); + performUpgrade(targetSchema, AddDeferredIndexThenRemove.class); + + assertEquals("No deferred operations should remain", 0, countOperations()); + assertIndexDoesNotExist("Product", "Product_Name_1"); + } + + + /** + * Verify that addIndexDeferred() followed by changeIndex() in the same + * step cancels the old deferred operation and re-tracks the new index + * as a PENDING deferred operation. + */ + @Test + public void testDeferredAddFollowedByChangeIndex() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_2").columns("name")) + ); + performUpgrade(targetSchema, AddDeferredIndexThenChange.class); + + // Old index cancelled, new index re-tracked as PENDING + assertEquals("One deferred operation for new index", 1, countOperations()); + assertEquals("PENDING", queryOperationStatus("Product_Name_2")); + assertIndexDoesNotExist("Product", "Product_Name_1"); + assertIndexDoesNotExist("Product", "Product_Name_2"); + } + + + /** + * Verify that addIndexDeferred() followed by renameIndex() in the same + * step updates the deferred operation's index name in the queue. + */ + @Test + public void testDeferredAddFollowedByRenameIndex() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_Renamed").columns("name")) + ); + performUpgrade(targetSchema, AddDeferredIndexThenRename.class); + + assertEquals("PENDING", queryOperationStatus("Product_Name_Renamed")); + assertEquals("Row count", 1, countOperations()); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_Renamed")); + assertIndexExists("Product", "Product_Name_Renamed"); + } + + + /** + * Verify that addIndexDeferred() followed by changeColumn() (rename) and + * then removeColumn() by the new name cancels the deferred operation, even + * though the column name changed between deferral and removal. + */ + @Test + public void testDeferredAddFollowedByRenameColumnThenRemove() { + // Initial schema has an extra "description" column for this test + Schema initialWithDesc = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100), + column("description", DataType.STRING, 200) + ) + ); + schemaManager.mutateToSupportSchema(initialWithDesc, TruncationBehavior.ALWAYS); + + // After the step: description renamed to summary then removed; index cancelled + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + performUpgrade(targetSchema, AddDeferredIndexThenRenameColumnThenRemove.class); + + assertEquals("Deferred operation should be cancelled", 0, countOperations()); + } + + + /** + * Verify that a deferred unique index is built correctly with + * the unique constraint preserved through the full pipeline. + */ + @Test + public void testDeferredUniqueIndex() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_UQ").unique().columns("name")) + ); + performUpgrade(targetSchema, AddDeferredUniqueIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertIndexExists("Product", "Product_Name_UQ"); + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Index should be unique", + sr.getTable("Product").indexes().stream() + .filter(idx -> "Product_Name_UQ".equalsIgnoreCase(idx.getName())) + .findFirst().get().isUnique()); + } + } + + + /** + * Verify that a deferred multi-column index preserves column ordering + * through the full pipeline. + */ + @Test + public void testDeferredMultiColumnIndex() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_IdName_1").columns("id", "name")) + ); + performUpgrade(targetSchema, AddDeferredMultiColumnIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + try (SchemaResource sr = connectionResources.openSchemaResource()) { + org.alfasoftware.morf.metadata.Index idx = sr.getTable("Product").indexes().stream() + .filter(i -> "Product_IdName_1".equalsIgnoreCase(i.getName())) + .findFirst().orElseThrow(() -> new AssertionError("Index not found")); + assertEquals("Column count", 2, idx.columnNames().size()); + assertEquals("First column", "id", idx.columnNames().get(0).toLowerCase()); + assertEquals("Second column", "name", idx.columnNames().get(1).toLowerCase()); + } + } + + + /** + * Verify that creating a new table and deferring an index on it + * in the same upgrade step works end-to-end. + */ + @Test + public void testNewTableWithDeferredIndex() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ), + table("Category").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("label", DataType.STRING, 50) + ).indexes(index("Category_Label_1").columns("label")) + ); + performUpgrade(targetSchema, AddTableWithDeferredIndex.class); + + assertEquals("PENDING", queryOperationStatus("Category_Label_1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Category_Label_1")); + assertIndexExists("Category", "Category_Label_1"); + } + + + /** + * Verify that deferring an index on a table that already contains rows + * builds the index correctly over existing data. + */ + @Test + public void testDeferredIndexOnPopulatedTable() { + insertProductRow(1L, "Widget"); + insertProductRow(2L, "Gadget"); + insertProductRow(3L, "Doohickey"); + + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify that deferring two indexes in a single upgrade step queues + * both and the executor builds them both to completion. + */ + @Test + public void testMultipleIndexesDeferredInOneStep() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name"), + index("Product_IdName_1").columns("id", "name") + ) + ); + performUpgrade(targetSchema, AddTwoDeferredIndexes.class); + + assertEquals("Row count", 2, countOperations()); + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertEquals("PENDING", queryOperationStatus("Product_IdName_1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertEquals("COMPLETED", queryOperationStatus("Product_IdName_1")); + assertIndexExists("Product", "Product_Name_1"); + assertIndexExists("Product", "Product_IdName_1"); + } + + + /** + * Verify that running the executor a second time on an already-completed + * queue is a safe no-op with no errors. + */ + @Test + public void testExecutorIdempotencyOnCompletedQueue() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + + // First run: build the index + DeferredIndexExecutor executor1 = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor1.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + + // Second run: should be a no-op + DeferredIndexExecutor executor2 = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor2.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify crash recovery: a stale IN_PROGRESS operation is reset to PENDING + * by the executor, then picked up and completed. + */ + @Test + public void testExecutorResetsInProgressAndCompletes() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // Simulate a crashed executor by marking the operation IN_PROGRESS + setOperationToStaleInProgress("Product_Name_1"); + assertEquals("IN_PROGRESS", queryOperationStatus("Product_Name_1")); + + // Executor should reset IN_PROGRESS → PENDING and build + UpgradeConfigAndContext execConfig = new UpgradeConfigAndContext(); + execConfig.setDeferredIndexCreationEnabled(true); + execConfig.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), execConfig, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify that when forceImmediateIndexes is configured for an index name, + * addIndexDeferred() builds the index immediately during the upgrade step + * and does not queue a deferred operation. + */ + @Test + public void testForceImmediateIndexBypassesDeferral() { + upgradeConfigAndContext.setForceImmediateIndexes(Set.of("Product_Name_1")); + try { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // Index should exist immediately — no executor needed + assertIndexExists("Product", "Product_Name_1"); + // No deferred operation should have been queued + assertEquals("No deferred operations expected", 0, countOperations()); + } finally { + upgradeConfigAndContext.setForceImmediateIndexes(Set.of()); + } + } + + + /** + * Verify that when forceDeferredIndexes is configured for an index name, + * addIndex() queues a deferred operation instead of building the index + * immediately, and the executor can then complete it. + */ + @Test + public void testForceDeferredIndexOverridesImmediateCreation() { + upgradeConfigAndContext.setForceDeferredIndexes(Set.of("Product_Name_1")); + try { + performUpgrade(schemaWithIndex(), AddImmediateIndex.class); + + // Index should NOT exist yet — it was deferred + assertIndexDoesNotExist("Product", "Product_Name_1"); + // A PENDING deferred operation should have been queued + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + + // Executor should complete the build + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } finally { + upgradeConfigAndContext.setForceDeferredIndexes(Set.of()); + } + } + + + /** + * Verify that on a fresh database without deferred index tables, + * running both {@code CreateDeferredIndexOperationTables} and a step + * using {@code addIndexDeferred()} in the same upgrade batch succeeds. + * This exercises the {@code @ExclusiveExecution @Sequence(1)} guarantee + * that the infrastructure tables are created before any INSERT into them. + */ + @Test + public void testFreshDatabaseWithDeferredIndexInSameBatch() { + // Start from a schema WITHOUT the deferred index tables + Schema schemaWithoutDeferredTables = schema( + deployedViewsTable(), + upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(schemaWithoutDeferredTables, TruncationBehavior.ALWAYS); + + // Run upgrade with both the table-creation step and a deferred index step + Upgrade.performUpgrade(schemaWithIndex(), + List.of(CreateDeferredIndexOperationTables.class, AddDeferredIndex.class), + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + + // The INSERT from AddDeferredIndex must have succeeded — the table existed + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + } + + + /** + * Verify that when the dialect does not support deferred index creation, + * addIndexDeferred() builds the index immediately and creates no PENDING row. + */ + @Test + public void testUnsupportedDialectFallsBackToImmediateIndex() { + // Spy on dialect to return false for supportsDeferredIndexCreation + org.alfasoftware.morf.jdbc.SqlDialect realDialect = connectionResources.sqlDialect(); + org.alfasoftware.morf.jdbc.SqlDialect spyDialect = org.mockito.Mockito.spy(realDialect); + org.mockito.Mockito.when(spyDialect.supportsDeferredIndexCreation()).thenReturn(false); + + ConnectionResources spyConn = org.mockito.Mockito.spy(connectionResources); + org.mockito.Mockito.when(spyConn.sqlDialect()).thenReturn(spyDialect); + + Upgrade.performUpgrade(schemaWithIndex(), Collections.singletonList(AddDeferredIndex.class), + spyConn, upgradeConfigAndContext, viewDeploymentValidator); + + // Index should exist immediately — built during upgrade, not deferred + assertIndexExists("Product", "Product_Name_1"); + // No deferred operation should have been queued + assertEquals("No deferred operations expected", 0, countOperations()); + } + + + /** + * Verify that when deferredIndexCreationEnabled is false (the default), + * addIndexDeferred() builds the index immediately and creates no PENDING row. + */ + @Test + public void testDisabledFeatureBuildsDeferredIndexImmediately() { + UpgradeConfigAndContext disabledConfig = new UpgradeConfigAndContext(); + // deferredIndexCreationEnabled defaults to false + + Upgrade.performUpgrade(schemaWithIndex(), Collections.singletonList(AddDeferredIndex.class), + connectionResources, disabledConfig, viewDeploymentValidator); + + // Index should exist immediately — built during upgrade, not deferred + assertIndexExists("Product", "Product_Name_1"); + // No deferred operation should have been queued + assertEquals("No deferred operations expected", 0, countOperations()); + } + + + // ========================================================================= + // Cross-step: column and table modifications affecting deferred indexes + // ========================================================================= + + /** + * Step A defers an index on column "name". Step B renames "name" to "label". + * The deferred index operation should reflect the renamed column. + */ + @Test + public void testCrossStepColumnRenameUpdatesDeferredIndex() { + // given -- target schema with column renamed from "name" to "label" + Schema renamedColSchema = schema( + deployedViewsTable(), upgradeAuditTable(), deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("label", DataType.STRING, 100) + ).indexes(index("Product_Name_1").columns("label")) + ); + + // when -- step 1 defers index on "name", step 2 renames "name" to "label" + performUpgradeSteps(renamedColSchema, + AddDeferredIndex.class, + org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.RenameColumnWithDeferredIndex.class); + + // then -- operation still pending with updated column name + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertEquals("Column name should be updated to label", "label", queryOperationField("Product_Name_1", "indexColumns")); + } + + + /** + * Step A defers an index on column "name". Step B removes the index and + * column "name". The deferred operation should be cancelled. + */ + @Test + public void testCrossStepColumnRemovalCleansDeferredIndex() { + // given + Schema noNameColSchema = schema( + deployedViewsTable(), upgradeAuditTable(), deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey() + ) + ); + + // when -- step 1 defers index on "name", step 2 removes index and column + performUpgradeSteps(noNameColSchema, + AddDeferredIndex.class, + org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.RemoveColumnWithDeferredIndex.class); + + // then -- operation cancelled, no index + assertIndexDoesNotExist("Product", "Product_Name_1"); + assertEquals("No deferred operations should remain", 0, countOperations()); + } + + + /** + * Step A defers an index on table "Product". Step B renames table to "Item". + * The deferred operation should reflect the renamed table. + */ + @Test + public void testCrossStepTableRenamePreservesDeferredIndex() { + // given + Schema renamedTableSchema = schema( + deployedViewsTable(), upgradeAuditTable(), deferredIndexOperationTable(), + table("Item").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_1").columns("name")) + ); + + // when -- step 1 defers index on "Product", step 2 renames table to "Item" + performUpgradeSteps(renamedTableSchema, + AddDeferredIndex.class, + org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.RenameTableWithDeferredIndex.class); + + // then -- operation still pending with updated table name + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertEquals("Table name should be updated to Item", "Item", queryOperationField("Product_Name_1", "tableName")); + } + + + /** + * Deferred indexes on multiple tables should both be tracked. + */ + @Test + public void testDeferredIndexesOnMultipleTables() { + // given -- deferred indexes on two different tables + Schema multiTableSchema = schema( + deployedViewsTable(), upgradeAuditTable(), deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_1").columns("name")), + table("Category").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("label", DataType.STRING, 50) + ).indexes(index("Category_Label_1").columns("label")) + ); + + // when + performUpgradeSteps(multiTableSchema, + AddDeferredIndex.class, + org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTableWithDeferredIndex.class); + + // then -- both tracked as PENDING + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertEquals("PENDING", queryOperationStatus("Category_Label_1")); + } + + + /** + * A deferred unique index on a table with duplicate data should fail gracefully + * when the executor tries to build it. + */ + @Test + public void testDeferredUniqueIndexWithDuplicateDataFailsGracefully() { + // given -- table with duplicate values in the indexed column + insertProductRow(1L, "Widget"); + insertProductRow(2L, "Widget"); + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_UQ").unique().columns("name")) + ); + performUpgrade(targetSchema, AddDeferredUniqueIndex.class); + + // when -- executor attempts to build (should not throw) + executeDeferred(); + + // then -- marked FAILED, index not built + assertEquals("FAILED", queryOperationStatus("Product_Name_UQ")); + assertIndexDoesNotExist("Product", "Product_Name_UQ"); + } + + + @SafeVarargs + private void performUpgradeSteps(Schema targetSchema, Class... upgradeSteps) { + Upgrade.performUpgrade(targetSchema, java.util.Arrays.asList(upgradeSteps), + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + } + + + private void executeDeferred() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + config.setDeferredIndexMaxRetries(0); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources), + connectionResources, new SqlScriptExecutorProvider(connectionResources), + config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void performUpgrade(Schema targetSchema, Class upgradeStep) { + Upgrade.performUpgrade(targetSchema, Collections.singletonList(upgradeStep), + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + } + + + private Schema schemaWithIndex() { + return schema( + deployedViewsTable(), + upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name") + ) + ); + } + + + private String queryOperationStatus(String indexName) { + return queryOperationField(indexName, "status"); + } + + + private String queryOperationField(String indexName, String fieldName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field(fieldName)) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getString(1) : null); + } + + + private int countOperations() { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("id")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> { + int count = 0; + while (rs.next()) count++; + return count; + }); + } + + + private void assertIndexExists(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Index " + indexName + " should exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + private void assertIndexDoesNotExist(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertFalse("Index " + indexName + " should not exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + private void insertProductRow(long id, String name) { + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + insert().into(tableRef("Product")) + .values(literal(id).as("id"), literal(name).as("name")) + ) + ); + } + + + private void setOperationToStaleInProgress(String indexName) { + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .set( + literal("IN_PROGRESS").as("status"), + literal(1_000_000_000L).as("startedTime") + ) + .where(field("indexName").eq(indexName)) + ) + ); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexLifecycle.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexLifecycle.java new file mode 100644 index 000000000..6c5d9da91 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexLifecycle.java @@ -0,0 +1,416 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deferredIndexOperationTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.List; + +import org.alfasoftware.morf.guicesupport.InjectMembersRule; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.testing.DatabaseSchemaManager; +import org.alfasoftware.morf.testing.DatabaseSchemaManager.TruncationBehavior; +import org.alfasoftware.morf.testing.TestingDataSourceModule; +import org.alfasoftware.morf.upgrade.Upgrade; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.upgrade.UpgradeStep; +import org.alfasoftware.morf.upgrade.ViewDeploymentValidator; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.AddSecondDeferredIndex; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.MethodRule; + +import com.google.inject.Inject; + +import net.jcip.annotations.NotThreadSafe; + +/** + * End-to-end lifecycle integration tests for the deferred index mechanism. + * Exercises upgrade, restart, and execute cycles through the real + * {@link Upgrade#performUpgrade} path. + * + *

The upgrade framework always augments the source schema with pending + * deferred indexes, and force-builds them only when an upgrade with new + * steps is about to run. On a no-upgrade restart, pending indexes are + * left for {@link DeferredIndexService#execute()} to build.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@NotThreadSafe +public class TestDeferredIndexLifecycle { + + @Rule + public MethodRule injectMembersRule = new InjectMembersRule(new TestingDataSourceModule()); + + @Inject private ConnectionResources connectionResources; + @Inject private DatabaseSchemaManager schemaManager; + @Inject private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Inject private ViewDeploymentValidator viewDeploymentValidator; + + private UpgradeConfigAndContext upgradeConfigAndContext; + + private static final Schema INITIAL_SCHEMA = schema( + deployedViewsTable(), + upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + + + /** Create a fresh schema before each test. */ + @Before + public void setUp() { + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(INITIAL_SCHEMA, TruncationBehavior.ALWAYS); + upgradeConfigAndContext = new UpgradeConfigAndContext(); + upgradeConfigAndContext.setDeferredIndexCreationEnabled(true); + } + + + /** Invalidate the schema manager cache after each test. */ + @After + public void tearDown() { + schemaManager.invalidateCache(); + } + + + // ========================================================================= + // Happy path + // ========================================================================= + + /** Upgrade defers index, execute builds it, restart finds schema correct. */ + @Test + public void testHappyPath_upgradeExecuteRestart() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + + executeDeferred(); + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + + // Restart — same steps, nothing new to do + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + // Should pass without error + } + + + // ========================================================================= + // No-upgrade restart — pending indexes left for execute() + // ========================================================================= + + /** No-upgrade restart with pending indexes should pass (schema augmented). */ + @Test + public void testNoUpgradeRestart_pendingIndexesAugmented() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + assertIndexDoesNotExist("Product", "Product_Name_1"); + + // Restart with same schema — no new upgrade steps + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + + // Index should NOT exist yet — no force-build on no-upgrade restart + assertIndexDoesNotExist("Product", "Product_Name_1"); + + // Execute builds it + executeDeferred(); + assertIndexExists("Product", "Product_Name_1"); + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + } + + + /** No-upgrade restart with crashed IN_PROGRESS ops should pass (schema augmented). */ + @Test + public void testNoUpgradeRestart_crashedOpsAugmented() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + setOperationStatus("Product_Name_1", "IN_PROGRESS"); + + // Restart with same schema — schema augmented with IN_PROGRESS op + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + + // Index should NOT exist yet + assertIndexDoesNotExist("Product", "Product_Name_1"); + + // Execute resets IN_PROGRESS → PENDING and builds + executeDeferred(); + assertIndexExists("Product", "Product_Name_1"); + } + + + // ========================================================================= + // Upgrade with pending indexes — force-built before proceeding + // ========================================================================= + + /** Upgrade with pending indexes from previous upgrade force-builds them first. */ + @Test + public void testUpgrade_pendingIndexesForceBuiltBeforeProceeding() { + // First upgrade — don't execute + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertIndexDoesNotExist("Product", "Product_Name_1"); + + // Second upgrade — readiness check should force-build first index + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + assertIndexExists("Product", "Product_Name_1"); + + // Execute builds second index + executeDeferred(); + assertIndexExists("Product", "Product_IdName_1"); + } + + + /** Upgrade with crashed IN_PROGRESS ops force-builds them. */ + @Test + public void testUpgrade_crashedOpsForceBuilt() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + setOperationStatus("Product_Name_1", "IN_PROGRESS"); + + // Second upgrade — readiness check should reset IN_PROGRESS and force-build + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + + assertIndexExists("Product", "Product_Name_1"); + } + + + // ========================================================================= + // Crash recovery via executor + // ========================================================================= + + /** Executor resets IN_PROGRESS ops to PENDING and builds them. */ + @Test + public void testCrashRecovery_inProgressResetToPending() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + setOperationStatus("Product_Name_1", "IN_PROGRESS"); + + // Execute should reset and build + executeDeferred(); + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** Executor handles index already built before crash — marks COMPLETED. */ + @Test + public void testCrashRecovery_indexAlreadyBuilt() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + + // Simulate: DB finished building the index before the crash + buildIndexManually("Product", "Product_Name_1", "name"); + setOperationStatus("Product_Name_1", "IN_PROGRESS"); + + // Execute resets to PENDING, tries CREATE INDEX, fails (exists), marks COMPLETED + executeDeferred(); + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + // ========================================================================= + // Force-build failure blocks upgrade + // ========================================================================= + + /** FAILED ops from a previous upgrade should block the force-build before a new upgrade. */ + @Test + public void testUpgrade_failedOpsBlockForceBuild() { + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + // Simulate a permanently failed operation + setOperationStatus("Product_Name_1", "FAILED"); + + // Second upgrade — force-build runs, builds nothing (no PENDING), but FAILED count > 0 → throws + try { + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + org.junit.Assert.fail("Expected IllegalStateException due to FAILED operations"); + } catch (IllegalStateException e) { + assertTrue("Message should mention failed count", e.getMessage().contains("1")); + } + } + + + // ========================================================================= + // Two sequential upgrades + // ========================================================================= + + /** Two upgrades, both executed — third restart passes. */ + @Test + public void testTwoSequentialUpgrades() { + // First upgrade + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + executeDeferred(); + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + + // Second upgrade adds another deferred index + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + executeDeferred(); + assertEquals("COMPLETED", queryOperationStatus("Product_IdName_1")); + + // Third restart — everything clean + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + } + + + /** Two upgrades, first index not built — force-built before second, second deferred until execute. */ + @Test + public void testTwoUpgrades_firstIndexNotBuilt_forceBuiltBeforeSecond() { + // First upgrade — don't execute + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertIndexDoesNotExist("Product", "Product_Name_1"); + + // Second upgrade — readiness check should force-build first index + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + assertIndexExists("Product", "Product_Name_1"); + // Second index should NOT be built yet — it was just deferred by the second upgrade + assertIndexDoesNotExist("Product", "Product_IdName_1"); + + // Execute builds second index + executeDeferred(); + assertIndexExists("Product", "Product_IdName_1"); + } + + + // ========================================================================= + // Helpers + // ========================================================================= + + private void performUpgrade(Schema targetSchema, Class step) { + performUpgradeWithSteps(targetSchema, Collections.singletonList(step)); + } + + + private void performUpgradeWithSteps(Schema targetSchema, + List> steps) { + Upgrade.performUpgrade(targetSchema, steps, connectionResources, + upgradeConfigAndContext, viewDeploymentValidator); + } + + + private void executeDeferred() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + config.setDeferredIndexMaxRetries(1); + DeferredIndexOperationDAO dao = new DeferredIndexOperationDAOImpl( + new SqlScriptExecutorProvider(connectionResources), connectionResources); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + dao, connectionResources, new SqlScriptExecutorProvider(connectionResources), + config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + } + + + private Schema schemaWithFirstIndex() { + return schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name") + ) + ); + } + + + private Schema schemaWithBothIndexes() { + return schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name"), + index("Product_IdName_1").columns("id", "name") + ) + ); + } + + + private String queryOperationStatus(String indexName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("status")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getString(1) : null); + } + + + private void setOperationStatus(String indexName, String status) { + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .set(literal(status).as("status")) + .where(field("indexName").eq(indexName)) + ) + ); + } + + + private void buildIndexManually(String tableName, String indexName, String columnName) { + sqlScriptExecutorProvider.get().execute( + List.of("CREATE INDEX " + indexName + " ON " + tableName + " (" + columnName + ")") + ); + } + + + private void assertIndexExists(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Index " + indexName + " should exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + private void assertIndexDoesNotExist(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertFalse("Index " + indexName + " should not exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheck.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheck.java new file mode 100644 index 000000000..6eff91eac --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexReadinessCheck.java @@ -0,0 +1,224 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.insert; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deferredIndexOperationTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.sql.ResultSet; +import java.util.UUID; + +import org.alfasoftware.morf.guicesupport.InjectMembersRule; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.testing.DatabaseSchemaManager; +import org.alfasoftware.morf.testing.DatabaseSchemaManager.TruncationBehavior; +import org.alfasoftware.morf.testing.TestingDataSourceModule; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.MethodRule; + +import com.google.inject.Inject; + +import net.jcip.annotations.NotThreadSafe; + +/** + * Integration tests for {@link DeferredIndexReadinessCheckImpl}. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@NotThreadSafe +public class TestDeferredIndexReadinessCheck { + + @Rule + public MethodRule injectMembersRule = new InjectMembersRule(new TestingDataSourceModule()); + + @Inject private ConnectionResources connectionResources; + @Inject private DatabaseSchemaManager schemaManager; + @Inject private SqlScriptExecutorProvider sqlScriptExecutorProvider; + + private static final Schema TEST_SCHEMA = schema( + deferredIndexOperationTable(), + table("Apple").columns(column("pips", DataType.STRING, 10).nullable()) + ); + + private UpgradeConfigAndContext config; + + + /** + * Drop and recreate the required schema before each test. + */ + @Before + public void setUp() { + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(TEST_SCHEMA, TruncationBehavior.ALWAYS); + config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexMaxRetries(0); + config.setDeferredIndexRetryBaseDelayMs(10L); + } + + + /** + * Invalidate the schema manager cache after each test. + */ + @After + public void tearDown() { + schemaManager.invalidateCache(); + } + + + /** + * forceBuildAllPending() should be a no-op when the queue is empty — no exception thrown + * and no operations executed. + */ + @Test + public void testValidateWithEmptyQueueIsNoOp() { + DeferredIndexReadinessCheck validator = createValidator(config); + validator.forceBuildAllPending(); // must not throw + } + + + /** + * When PENDING operations exist, forceBuildAllPending() must execute them before returning: + * the index should exist in the schema and the row should be COMPLETED + * (not PENDING) when the call returns. + */ + @Test + public void testPendingOperationsAreExecutedBeforeReturning() { + insertPendingRow("Apple", "Apple_V1", false, "pips"); + + DeferredIndexReadinessCheck validator = createValidator(config); + validator.forceBuildAllPending(); + + // Verify no PENDING rows remain + assertFalse("no non-terminal operations should remain after validate", + hasPendingOperations()); + + // Verify the index actually exists in the database + try (var schema = connectionResources.openSchemaResource()) { + assertTrue("Apple_V1 index should exist", + schema.getTable("Apple").indexes().stream().anyMatch(idx -> "Apple_V1".equalsIgnoreCase(idx.getName()))); + } + } + + + /** + * When multiple PENDING operations exist they should all be executed before + * forceBuildAllPending() returns. + */ + @Test + public void testMultiplePendingOperationsAllExecuted() { + insertPendingRow("Apple", "Apple_V2", false, "pips"); + insertPendingRow("Apple", "Apple_V3", true, "pips"); + + DeferredIndexReadinessCheck validator = createValidator(config); + validator.forceBuildAllPending(); + + assertFalse("no non-terminal operations should remain", hasPendingOperations()); + } + + + /** + * When a PENDING operation targets a non-existent table, forceBuildAllPending() should + * throw because the forced execution fails. + */ + @Test + public void testFailedForcedExecutionThrows() { + insertPendingRow("NoSuchTable", "NoSuchTable_V4", false, "col"); + + DeferredIndexReadinessCheck validator = createValidator(config); + try { + validator.forceBuildAllPending(); + fail("Expected IllegalStateException for failed forced execution"); + } catch (IllegalStateException e) { + assertTrue("exception message should mention failed count", + e.getMessage().contains("1 index operation(s) could not be built")); + } + + // The operation should be FAILED, not PENDING + assertEquals("status should be FAILED after forced execution", + DeferredIndexStatus.FAILED.name(), queryStatus("NoSuchTable_V4")); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void insertPendingRow(String tableName, String indexName, + boolean unique, String... columns) { + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + insert().into(tableRef(DEFERRED_INDEX_OPERATION_NAME)).values( + literal(Math.abs(UUID.randomUUID().getMostSignificantBits())).as("id"), + literal("test-upgrade-uuid").as("upgradeUUID"), + literal(tableName).as("tableName"), + literal(indexName).as("indexName"), + literal(unique ? 1 : 0).as("indexUnique"), + literal(String.join(",", columns)).as("indexColumns"), + literal(DeferredIndexStatus.PENDING.name()).as("status"), + literal(0).as("retryCount"), + literal(System.currentTimeMillis()).as("createdTime") + ) + ) + ); + } + + + private String queryStatus(String indexName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("status")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getString(1) : null); + } + + + private DeferredIndexReadinessCheck createValidator(UpgradeConfigAndContext validatorConfig) { + DeferredIndexOperationDAO dao = new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(dao, connectionResources, new SqlScriptExecutorProvider(connectionResources), validatorConfig, new DeferredIndexExecutorServiceFactory.Default()); + return new DeferredIndexReadinessCheckImpl(dao, executor, validatorConfig, connectionResources); + } + + + private boolean hasPendingOperations() { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("id")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("status").eq(DeferredIndexStatus.PENDING.name())) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, ResultSet::next); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexService.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexService.java new file mode 100644 index 000000000..08b7b7c4d --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexService.java @@ -0,0 +1,325 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.schema; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; +import static org.alfasoftware.morf.sql.SqlUtils.field; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.select; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.sql.SqlUtils.update; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.DEFERRED_INDEX_OPERATION_NAME; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deferredIndexOperationTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; + +import org.alfasoftware.morf.guicesupport.InjectMembersRule; +import org.alfasoftware.morf.jdbc.ConnectionResources; +import org.alfasoftware.morf.jdbc.SqlScriptExecutorProvider; +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaResource; +import org.alfasoftware.morf.testing.DatabaseSchemaManager; +import org.alfasoftware.morf.testing.DatabaseSchemaManager.TruncationBehavior; +import org.alfasoftware.morf.testing.TestingDataSourceModule; +import org.alfasoftware.morf.upgrade.Upgrade; +import org.alfasoftware.morf.upgrade.UpgradeConfigAndContext; +import org.alfasoftware.morf.upgrade.UpgradeStep; +import org.alfasoftware.morf.upgrade.ViewDeploymentValidator; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTwoDeferredIndexes; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.MethodRule; + +import com.google.inject.Inject; + +import net.jcip.annotations.NotThreadSafe; + +/** + * Integration tests for the {@link DeferredIndexService} facade, verifying + * the full lifecycle through a real database: upgrade step queues deferred + * index operations, then the service recovers stale entries, executes + * pending builds, and reports the results. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@NotThreadSafe +public class TestDeferredIndexService { + + @Rule + public MethodRule injectMembersRule = new InjectMembersRule(new TestingDataSourceModule()); + + @Inject private ConnectionResources connectionResources; + @Inject private DatabaseSchemaManager schemaManager; + @Inject private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Inject private ViewDeploymentValidator viewDeploymentValidator; + + private final UpgradeConfigAndContext upgradeConfigAndContext = new UpgradeConfigAndContext(); + { upgradeConfigAndContext.setDeferredIndexCreationEnabled(true); } + + private static final Schema INITIAL_SCHEMA = schema( + deployedViewsTable(), + upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + + + /** Create a fresh schema before each test. */ + @Before + public void setUp() { + schemaManager.dropAllTables(); + schemaManager.mutateToSupportSchema(INITIAL_SCHEMA, TruncationBehavior.ALWAYS); + } + + + /** Invalidate the schema manager cache after each test. */ + @After + public void tearDown() { + schemaManager.invalidateCache(); + } + + + /** + * Verify that execute() recovers, builds the index, marks it COMPLETED, + * and the index exists in the schema. + */ + @Test + public void testExecuteBuildsIndexEndToEnd() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + assertEquals("PENDING", queryOperationStatus("Product_Name_1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService service = createService(config); + service.execute(); + service.awaitCompletion(60L); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify that execute() handles multiple deferred indexes in a single run. + */ + @Test + public void testExecuteBuildsMultipleIndexes() { + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name"), + index("Product_IdName_1").columns("id", "name") + ) + ); + performUpgrade(targetSchema, AddTwoDeferredIndexes.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService service = createService(config); + service.execute(); + service.awaitCompletion(60L); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertEquals("COMPLETED", queryOperationStatus("Product_IdName_1")); + assertIndexExists("Product", "Product_Name_1"); + assertIndexExists("Product", "Product_IdName_1"); + } + + + /** + * Verify that execute() with an empty queue completes immediately with no error. + */ + @Test + public void testExecuteWithEmptyQueue() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService service = createService(config); + service.execute(); + + // awaitCompletion should return true immediately on an empty queue + assertTrue("Should complete immediately on empty queue", service.awaitCompletion(5L)); + } + + + /** + * Verify that execute() recovers a stale IN_PROGRESS operation before + * executing it. + */ + @Test + public void testExecuteRecoversStaleAndCompletes() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // Simulate a crashed executor — mark the operation as stale IN_PROGRESS + setOperationToStaleInProgress("Product_Name_1"); + assertEquals("IN_PROGRESS", queryOperationStatus("Product_Name_1")); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService service = createService(config); + service.execute(); + service.awaitCompletion(60L); + + assertEquals("COMPLETED", queryOperationStatus("Product_Name_1")); + assertIndexExists("Product", "Product_Name_1"); + } + + + /** + * Verify that awaitCompletion() throws when called before execute(). + */ + @Test(expected = IllegalStateException.class) + public void testAwaitCompletionThrowsWhenNoExecution() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexService service = createService(config); + service.awaitCompletion(5L); + } + + + /** + * Verify that awaitCompletion() returns true when all operations are + * already COMPLETED. + */ + @Test + public void testAwaitCompletionReturnsTrueWhenAllCompleted() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // Build the index first + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService firstService = createService(config); + firstService.execute(); + firstService.awaitCompletion(60L); + + // Execute on a new service (empty queue) then await — should return immediately + DeferredIndexService service = createService(config); + service.execute(); + assertTrue("Should return true when all completed", service.awaitCompletion(5L)); + } + + + /** + * Verify that execute() is idempotent — calling it a second time on an + * already-completed queue is a safe no-op. + */ + @Test + public void testExecuteIdempotent() { + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexRetryBaseDelayMs(10L); + DeferredIndexService service = createService(config); + + service.execute(); + service.awaitCompletion(60L); + assertEquals("First run should complete", "COMPLETED", queryOperationStatus("Product_Name_1")); + + // Second execute on a fresh service — should be a no-op + DeferredIndexService service2 = createService(config); + service2.execute(); + service2.awaitCompletion(60L); + assertEquals("Should still be COMPLETED after second run", "COMPLETED", queryOperationStatus("Product_Name_1")); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void performUpgrade(Schema targetSchema, Class upgradeStep) { + Upgrade.performUpgrade(targetSchema, Collections.singletonList(upgradeStep), + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + } + + + private Schema schemaWithIndex() { + return schema( + deployedViewsTable(), + upgradeAuditTable(), + deferredIndexOperationTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name") + ) + ); + } + + + private String queryOperationStatus(String indexName) { + String sql = connectionResources.sqlDialect().convertStatementToSQL( + select(field("status")) + .from(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .where(field("indexName").eq(indexName)) + ); + return sqlScriptExecutorProvider.get().executeQuery(sql, rs -> rs.next() ? rs.getString(1) : null); + } + + + private void assertIndexExists(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Index " + indexName + " should exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + private DeferredIndexService createService(UpgradeConfigAndContext config) { + DeferredIndexOperationDAO dao = new DeferredIndexOperationDAOImpl(new SqlScriptExecutorProvider(connectionResources), connectionResources); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl(dao, connectionResources, new SqlScriptExecutorProvider(connectionResources), config, new DeferredIndexExecutorServiceFactory.Default()); + return new DeferredIndexServiceImpl(executor, dao); + } + + + private void setOperationToStaleInProgress(String indexName) { + sqlScriptExecutorProvider.get().execute( + connectionResources.sqlDialect().convertStatementToSQL( + update(tableRef(DEFERRED_INDEX_OPERATION_NAME)) + .set( + literal("IN_PROGRESS").as("status"), + literal(1_000_000_000L).as("startedTime") + ) + .where(field("indexName").eq(indexName)) + ) + ); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AbstractDeferredIndexTestStep.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AbstractDeferredIndexTestStep.java new file mode 100644 index 000000000..b87a35b51 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AbstractDeferredIndexTestStep.java @@ -0,0 +1,35 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Base class for deferred-index integration test upgrade steps. + */ +abstract class AbstractDeferredIndexTestStep implements UpgradeStep { + + @Override + public String getJiraId() { + return "DEFERRED-000"; + } + + + @Override + public String getDescription() { + return ""; + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndex.java new file mode 100644 index 000000000..5e83cffee --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndex.java @@ -0,0 +1,36 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Adds a deferred index on Product.name. + */ +@Sequence(90001) +@UUID("d1f00001-0001-0001-0001-000000000001") +public class AddDeferredIndex extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_1").columns("name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenChange.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenChange.java new file mode 100644 index 000000000..93cab755f --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenChange.java @@ -0,0 +1,37 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Defers an index then immediately changes it in the same step. + */ +@Sequence(90009) +@UUID("d1f00001-0001-0001-0001-000000000009") +public class AddDeferredIndexThenChange extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_1").columns("name")); + schema.changeIndex("Product", index("Product_Name_1").columns("name"), index("Product_Name_2").columns("name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRemove.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRemove.java new file mode 100644 index 000000000..bc375f30c --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRemove.java @@ -0,0 +1,37 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Adds a deferred index then immediately removes it in the same step. + */ +@Sequence(90002) +@UUID("d1f00001-0001-0001-0001-000000000002") +public class AddDeferredIndexThenRemove extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_1").columns("name")); + schema.removeIndex("Product", index("Product_Name_1").columns("name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRename.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRename.java new file mode 100644 index 000000000..7a49a9170 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRename.java @@ -0,0 +1,37 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Defers an index then immediately renames it in the same step. + */ +@Sequence(90010) +@UUID("d1f00001-0001-0001-0001-000000000010") +public class AddDeferredIndexThenRename extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_1").columns("name")); + schema.renameIndex("Product", "Product_Name_1", "Product_Name_Renamed"); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRenameColumnThenRemove.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRenameColumnThenRemove.java new file mode 100644 index 000000000..75d720b75 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredIndexThenRenameColumnThenRemove.java @@ -0,0 +1,49 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Defers an index that includes "description", renames "description" to + * "summary", then removes the deferred index and the renamed column. + * The removeIndex must auto-cancel the deferred operation via + * {@code hasPendingDeferred}, even though an intermediate column rename + * occurred. The in-memory tracking must reflect the rename so that + * a hypothetical {@code cancelPendingReferencingColumn} call would also + * succeed — that path is verified by unit tests. + */ +@Sequence(90011) +@UUID("d1f00001-0001-0001-0001-000000000011") +public class AddDeferredIndexThenRenameColumnThenRemove extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Desc_1").columns("description")); + schema.changeColumn("Product", + column("description", DataType.STRING, 200), + column("summary", DataType.STRING, 200)); + schema.removeIndex("Product", index("Product_Desc_1").columns("description")); + schema.removeColumn("Product", column("summary", DataType.STRING, 200)); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredMultiColumnIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredMultiColumnIndex.java new file mode 100644 index 000000000..32d69986f --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredMultiColumnIndex.java @@ -0,0 +1,36 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Adds a deferred multi-column index on Product(id, name). + */ +@Sequence(90006) +@UUID("d1f00001-0001-0001-0001-000000000006") +public class AddDeferredMultiColumnIndex extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_IdName_1").columns("id", "name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredUniqueIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredUniqueIndex.java new file mode 100644 index 000000000..733f8140b --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddDeferredUniqueIndex.java @@ -0,0 +1,36 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Adds a deferred unique index on Product.name. + */ +@Sequence(90005) +@UUID("d1f00001-0001-0001-0001-000000000005") +public class AddDeferredUniqueIndex extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_UQ").unique().columns("name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddImmediateIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddImmediateIndex.java new file mode 100644 index 000000000..498fa832d --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddImmediateIndex.java @@ -0,0 +1,37 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Adds an immediate (non-deferred) index on Product.name. + * Used to test force-deferred config overriding immediate index creation. + */ +@Sequence(90012) +@UUID("d1f00001-0001-0001-0001-000000000012") +public class AddImmediateIndex extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndex("Product", index("Product_Name_1").columns("name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTableWithDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTableWithDeferredIndex.java new file mode 100644 index 000000000..ae4010a35 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTableWithDeferredIndex.java @@ -0,0 +1,43 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; +import static org.alfasoftware.morf.metadata.SchemaUtils.table; + +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Creates a new table and immediately defers an index on it. + */ +@Sequence(90007) +@UUID("d1f00001-0001-0001-0001-000000000007") +public class AddTableWithDeferredIndex extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addTable(table("Category").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("label", DataType.STRING, 50) + )); + schema.addIndexDeferred("Category", index("Category_Label_1").columns("label")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTwoDeferredIndexes.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTwoDeferredIndexes.java new file mode 100644 index 000000000..82fd9121a --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v1_0_0/AddTwoDeferredIndexes.java @@ -0,0 +1,37 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; + +/** + * Defers two indexes on Product in a single upgrade step. + */ +@Sequence(90008) +@UUID("d1f00001-0001-0001-0001-000000000008") +public class AddTwoDeferredIndexes extends AbstractDeferredIndexTestStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_Name_1").columns("name")); + schema.addIndexDeferred("Product", index("Product_IdName_1").columns("id", "name")); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/AddSecondDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/AddSecondDeferredIndex.java new file mode 100644 index 000000000..325b23748 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/AddSecondDeferredIndex.java @@ -0,0 +1,49 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Adds a second deferred index on Product(id, name) for lifecycle tests. + */ +@Sequence(90002) +@UUID("d1f00002-0002-0002-0002-000000000002") +public class AddSecondDeferredIndex implements UpgradeStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.addIndexDeferred("Product", index("Product_IdName_1").columns("id", "name")); + } + + + @Override + public String getJiraId() { + return "DEFERRED-000"; + } + + + @Override + public String getDescription() { + return ""; + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveColumnWithDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveColumnWithDeferredIndex.java new file mode 100644 index 000000000..b1ec3c1e5 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveColumnWithDeferredIndex.java @@ -0,0 +1,48 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Removes the deferred index and then column "name" from Product. Used + * to test cross-step column removal affecting a deferred index from a + * previous step. + */ +@Sequence(90016) +@UUID("d1f00002-0002-0002-0002-000000000016") +public class RemoveColumnWithDeferredIndex implements UpgradeStep { + + @Override + public String getJiraId() { return "TEST-16"; } + + @Override + public String getDescription() { return "Remove deferred index and column name from Product"; } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.removeIndex("Product", index("Product_Name_1").columns("name")); + schema.removeColumn("Product", column("name", DataType.STRING, 100)); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameColumnWithDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameColumnWithDeferredIndex.java new file mode 100644 index 000000000..e226d9a7c --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameColumnWithDeferredIndex.java @@ -0,0 +1,47 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0; + +import static org.alfasoftware.morf.metadata.SchemaUtils.column; + +import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Renames column "name" to "label" on Product. Used to test cross-step + * column rename affecting a deferred index from a previous step. + */ +@Sequence(90015) +@UUID("d1f00002-0002-0002-0002-000000000015") +public class RenameColumnWithDeferredIndex implements UpgradeStep { + + @Override + public String getJiraId() { return "TEST-15"; } + + @Override + public String getDescription() { return "Rename column name to label on Product"; } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.changeColumn("Product", + column("name", DataType.STRING, 100), + column("label", DataType.STRING, 100)); + } +} diff --git a/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameTableWithDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameTableWithDeferredIndex.java new file mode 100644 index 000000000..0e3ee95bc --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameTableWithDeferredIndex.java @@ -0,0 +1,42 @@ +/* Copyright 2026 Alfa Financial Software + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0; + +import org.alfasoftware.morf.upgrade.DataEditor; +import org.alfasoftware.morf.upgrade.SchemaEditor; +import org.alfasoftware.morf.upgrade.Sequence; +import org.alfasoftware.morf.upgrade.UUID; +import org.alfasoftware.morf.upgrade.UpgradeStep; + +/** + * Renames table "Product" to "Item". Used to test cross-step + * table rename affecting a deferred index from a previous step. + */ +@Sequence(90017) +@UUID("d1f00002-0002-0002-0002-000000000017") +public class RenameTableWithDeferredIndex implements UpgradeStep { + + @Override + public String getJiraId() { return "TEST-17"; } + + @Override + public String getDescription() { return "Rename table Product to Item"; } + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.renameTable("Product", "Item"); + } +} diff --git a/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleDialect.java b/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleDialect.java index d29899c61..7ab8b177d 100755 --- a/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleDialect.java +++ b/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleDialect.java @@ -905,7 +905,7 @@ protected String defaultNullOrder() { public Collection addIndexStatements(Table table, Index index) { return ImmutableList.of( // when adding indexes to existing tables, use PARALLEL NOLOGGING to efficiently build the index - Iterables.getOnlyElement(indexDeploymentStatements(table, index)) + " PARALLEL NOLOGGING", + buildCreateIndexStatement(table, index, "") + " PARALLEL NOLOGGING", indexPostDeploymentStatements(index) ); } @@ -916,31 +916,7 @@ public Collection addIndexStatements(Table table, Index index) { */ @Override protected Collection indexDeploymentStatements(Table table, Index index) { - StringBuilder createIndexStatement = new StringBuilder(); - - // Specify the preamble - createIndexStatement.append("CREATE "); - if (index.isUnique()) { - createIndexStatement.append("UNIQUE "); - } - - // Name the index - createIndexStatement - .append("INDEX ") - .append(schemaNamePrefix()) - .append(index.getName()) - - // Specify which table the index is over - .append(" ON ") - .append(schemaNamePrefix()) - .append(table.getName()) - - // Specify the fields that are used in the index - .append(" (") - .append(Joiner.on(", ").join(index.columnNames())) - .append(")"); - - return Collections.singletonList(createIndexStatement.toString()); + return Collections.singletonList(buildCreateIndexStatement(table, index, "")); } @@ -960,6 +936,27 @@ private String indexPostDeploymentStatements(Index index) { } + /** + * @see org.alfasoftware.morf.jdbc.SqlDialect#supportsDeferredIndexCreation() + */ + @Override + public boolean supportsDeferredIndexCreation() { + return true; + } + + + /** + * @see org.alfasoftware.morf.jdbc.SqlDialect#deferredIndexDeploymentStatements(org.alfasoftware.morf.metadata.Table, org.alfasoftware.morf.metadata.Index) + */ + @Override + public Collection deferredIndexDeploymentStatements(Table table, Index index) { + return ImmutableList.of( + buildCreateIndexStatement(table, index, "") + " ONLINE PARALLEL NOLOGGING", + indexPostDeploymentStatements(index) + ); + } + + /** * @see org.alfasoftware.morf.jdbc.SqlDialect#alterTableAddColumnStatements(org.alfasoftware.morf.metadata.Table, org.alfasoftware.morf.metadata.Column) */ diff --git a/morf-oracle/src/test/java/org/alfasoftware/morf/jdbc/oracle/TestOracleDialect.java b/morf-oracle/src/test/java/org/alfasoftware/morf/jdbc/oracle/TestOracleDialect.java index 1e22c2776..5615683c8 100755 --- a/morf-oracle/src/test/java/org/alfasoftware/morf/jdbc/oracle/TestOracleDialect.java +++ b/morf-oracle/src/test/java/org/alfasoftware/morf/jdbc/oracle/TestOracleDialect.java @@ -859,6 +859,45 @@ protected List expectedAddIndexStatementsUnique() { } + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedSupportsDeferredIndexCreation() + */ + @Override + protected boolean expectedSupportsDeferredIndexCreation() { + return true; + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsOnSingleColumn() + */ + @Override + protected List expectedDeferredAddIndexStatementsOnSingleColumn() { + return Arrays.asList("CREATE INDEX TESTSCHEMA.indexName ON TESTSCHEMA.Test (id) ONLINE PARALLEL NOLOGGING", + "ALTER INDEX TESTSCHEMA.indexName NOPARALLEL LOGGING"); + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsOnMultipleColumns() + */ + @Override + protected List expectedDeferredAddIndexStatementsOnMultipleColumns() { + return Arrays.asList("CREATE INDEX TESTSCHEMA.indexName ON TESTSCHEMA.Test (id, version) ONLINE PARALLEL NOLOGGING", + "ALTER INDEX TESTSCHEMA.indexName NOPARALLEL LOGGING"); + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsUnique() + */ + @Override + protected List expectedDeferredAddIndexStatementsUnique() { + return Arrays.asList("CREATE UNIQUE INDEX TESTSCHEMA.indexName ON TESTSCHEMA.Test (id) ONLINE PARALLEL NOLOGGING", + "ALTER INDEX TESTSCHEMA.indexName NOPARALLEL LOGGING"); + } + + /** * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedAddIndexStatementsUniqueNullable() */ diff --git a/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLDialect.java b/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLDialect.java index 8165ba342..7b55a3e68 100644 --- a/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLDialect.java +++ b/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLDialect.java @@ -872,30 +872,61 @@ public Collection alterTableDropColumnStatements(Table table, Column col @Override protected Collection indexDeploymentStatements(Table table, Index index) { - StringBuilder statement = new StringBuilder(); + return ImmutableList.of(buildPostgreSqlCreateIndex(table, index, ""), addIndexComment(index.getName())); + } + + + private String addIndexComment(String indexName) { + return "COMMENT ON INDEX " + indexName + " IS '"+REAL_NAME_COMMENT_LABEL+":[" + indexName + "]'"; + } + + + /** + * @see org.alfasoftware.morf.jdbc.SqlDialect#supportsDeferredIndexCreation() + */ + @Override + public boolean supportsDeferredIndexCreation() { + return true; + } + + + /** + * @see org.alfasoftware.morf.jdbc.SqlDialect#deferredIndexDeploymentStatements(org.alfasoftware.morf.metadata.Table, org.alfasoftware.morf.metadata.Index) + */ + @Override + public Collection deferredIndexDeploymentStatements(Table table, Index index) { + return ImmutableList.of(buildPostgreSqlCreateIndex(table, index, "CONCURRENTLY"), addIndexComment(index.getName())); + } + + /** + * Builds a PostgreSQL CREATE INDEX statement. PostgreSQL does not schema-qualify + * the index name (only the table name), so this cannot use the base class + * {@link #buildCreateIndexStatement(Table, Index, String)} which prefixes both. + * + * @param table the table to index. + * @param index the index to create. + * @param afterIndexKeyword keyword inserted after INDEX (e.g. "CONCURRENTLY"), or empty string. + * @return the CREATE INDEX SQL string. + */ + private String buildPostgreSqlCreateIndex(Table table, Index index, String afterIndexKeyword) { + StringBuilder statement = new StringBuilder(); statement.append("CREATE "); if (index.isUnique()) { statement.append("UNIQUE "); } - statement.append("INDEX ") - .append(index.getName()) + statement.append("INDEX "); + if (!afterIndexKeyword.isEmpty()) { + statement.append(afterIndexKeyword).append(' '); + } + statement.append(index.getName()) .append(" ON ") .append(schemaNamePrefix(table)) .append(table.getName()) .append(" (") .append(Joiner.on(", ").join(index.columnNames())) .append(")"); - - return ImmutableList.builder() - .add(statement.toString()) - .add(addIndexComment(index.getName())) - .build(); - } - - - private String addIndexComment(String indexName) { - return "COMMENT ON INDEX " + indexName + " IS '"+REAL_NAME_COMMENT_LABEL+":[" + indexName + "]'"; + return statement.toString(); } diff --git a/morf-postgresql/src/test/java/org/alfasoftware/morf/jdbc/postgresql/TestPostgreSQLDialect.java b/morf-postgresql/src/test/java/org/alfasoftware/morf/jdbc/postgresql/TestPostgreSQLDialect.java index 2d676854e..23d286972 100644 --- a/morf-postgresql/src/test/java/org/alfasoftware/morf/jdbc/postgresql/TestPostgreSQLDialect.java +++ b/morf-postgresql/src/test/java/org/alfasoftware/morf/jdbc/postgresql/TestPostgreSQLDialect.java @@ -817,6 +817,45 @@ protected List expectedAddIndexStatementsUnique() { } + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedSupportsDeferredIndexCreation() + */ + @Override + protected boolean expectedSupportsDeferredIndexCreation() { + return true; + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsOnSingleColumn() + */ + @Override + protected List expectedDeferredAddIndexStatementsOnSingleColumn() { + return Arrays.asList("CREATE INDEX CONCURRENTLY indexName ON testschema.Test (id)", + "COMMENT ON INDEX indexName IS '"+PostgreSQLDialect.REAL_NAME_COMMENT_LABEL+":[indexName]'"); + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsOnMultipleColumns() + */ + @Override + protected List expectedDeferredAddIndexStatementsOnMultipleColumns() { + return Arrays.asList("CREATE INDEX CONCURRENTLY indexName ON testschema.Test (id, version)", + "COMMENT ON INDEX indexName IS '"+PostgreSQLDialect.REAL_NAME_COMMENT_LABEL+":[indexName]'"); + } + + + /** + * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedDeferredAddIndexStatementsUnique() + */ + @Override + protected List expectedDeferredAddIndexStatementsUnique() { + return Arrays.asList("CREATE UNIQUE INDEX CONCURRENTLY indexName ON testschema.Test (id)", + "COMMENT ON INDEX indexName IS '"+PostgreSQLDialect.REAL_NAME_COMMENT_LABEL+":[indexName]'"); + } + + /** * @see org.alfasoftware.morf.jdbc.AbstractSqlDialectTest#expectedAddIndexStatementsUniqueNullable() */ diff --git a/morf-testsupport/src/main/java/org/alfasoftware/morf/jdbc/AbstractSqlDialectTest.java b/morf-testsupport/src/main/java/org/alfasoftware/morf/jdbc/AbstractSqlDialectTest.java index cf7af7aa4..f4b47644b 100755 --- a/morf-testsupport/src/main/java/org/alfasoftware/morf/jdbc/AbstractSqlDialectTest.java +++ b/morf-testsupport/src/main/java/org/alfasoftware/morf/jdbc/AbstractSqlDialectTest.java @@ -4337,6 +4337,48 @@ public void testAddIndexStatementsUnique() { } + /** + * Test deferred index creation over a single column. + */ + @SuppressWarnings("unchecked") + @Test + public void testDeferredAddIndexStatementsOnSingleColumn() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").columns(table.columns().get(0).getName()); + compareStatements( + expectedDeferredAddIndexStatementsOnSingleColumn(), + testDialect.deferredIndexDeploymentStatements(table, index)); + } + + + /** + * Test deferred index creation over multiple columns. + */ + @SuppressWarnings("unchecked") + @Test + public void testDeferredAddIndexStatementsOnMultipleColumns() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").columns(table.columns().get(0).getName(), table.columns().get(1).getName()); + compareStatements( + expectedDeferredAddIndexStatementsOnMultipleColumns(), + testDialect.deferredIndexDeploymentStatements(table, index)); + } + + + /** + * Test deferred unique index creation. + */ + @SuppressWarnings("unchecked") + @Test + public void testDeferredAddIndexStatementsUnique() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").unique().columns(table.columns().get(0).getName()); + compareStatements( + expectedDeferredAddIndexStatementsUnique(), + testDialect.deferredIndexDeploymentStatements(table, index)); + } + + /** * Test adding a unique index. */ @@ -4969,6 +5011,49 @@ protected List expectedAlterTableDropColumnWithDefaultStatement() { protected abstract List expectedAddIndexStatementsUnique(); + /** + * @return Expected value for {@link SqlDialect#supportsDeferredIndexCreation()}. + * Returns {@code false} by default. Subclasses for dialects that support + * deferred creation (PostgreSQL, Oracle, H2) must override to return {@code true}. + */ + protected boolean expectedSupportsDeferredIndexCreation() { + return false; + } + + + /** + * Test that supportsDeferredIndexCreation returns the expected value for this dialect. + */ + @Test + public void testSupportsDeferredIndexCreation() { + assertEquals("supportsDeferredIndexCreation", expectedSupportsDeferredIndexCreation(), testDialect.supportsDeferredIndexCreation()); + } + + + /** + * @return Expected SQL for {@link #testDeferredAddIndexStatementsOnSingleColumn()} + */ + protected List expectedDeferredAddIndexStatementsOnSingleColumn() { + return expectedAddIndexStatementsOnSingleColumn(); + } + + + /** + * @return Expected SQL for {@link #testDeferredAddIndexStatementsOnMultipleColumns()} + */ + protected List expectedDeferredAddIndexStatementsOnMultipleColumns() { + return expectedAddIndexStatementsOnMultipleColumns(); + } + + + /** + * @return Expected SQL for {@link #testDeferredAddIndexStatementsUnique()} + */ + protected List expectedDeferredAddIndexStatementsUnique() { + return expectedAddIndexStatementsUnique(); + } + + /** * @return Expected SQL for {@link #testAddIndexStatementsUniqueNullable()} */