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/PLAN-deferred-index-comments.md b/PLAN-deferred-index-comments.md new file mode 100644 index 000000000..e88811212 --- /dev/null +++ b/PLAN-deferred-index-comments.md @@ -0,0 +1,27 @@ +# Deferred Index Creation — Comments-Based Model + +See integration guide at: `~/deferred-index-comments-integration-guide.md` +See dev description at: `~/deferred-index-comments-dev.txt` + +## Summary + +Replace the tracking-table approach (`DeferredIndexOperation` table + DAO + state machine) +with a comments-based declarative model (~3600 net lines removed): + +- `Index.isDeferred()` — deferred is a property on the index itself +- Table comments store permanent `DEFERRED:[name|cols|unique]` segments +- MetaDataProvider merges comments with physical catalog: declared indexes always have `isDeferred()=true` +- Executor scans for indexes that are `isDeferred()=true` but not physically present +- `getMissingDeferredIndexStatements()` exposes raw SQL for custom execution +- IF EXISTS DDL for safe operations on deferred indexes (built or not) +- "Change the plan" — unbuilt deferred indexes modified in later upgrades without force-build + +## Branch + +`experimental/deferred-index-comments` (branched from `experimental/deferred-index-creation`) + +## Key Design Rule + +DEFERRED comment segments are **permanent** — never removed when the index is physically built. +They are only modified during upgrade steps (removeIndex, changeIndex, renameIndex). +The executor never writes comments. diff --git a/morf-core/src/main/java/org/alfasoftware/morf/jdbc/DatabaseMetaDataProviderUtils.java b/morf-core/src/main/java/org/alfasoftware/morf/jdbc/DatabaseMetaDataProviderUtils.java index 16a4cd1db..fbad17761 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/jdbc/DatabaseMetaDataProviderUtils.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/jdbc/DatabaseMetaDataProviderUtils.java @@ -15,10 +15,20 @@ package org.alfasoftware.morf.jdbc; +import static org.alfasoftware.morf.metadata.SchemaUtils.index; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaUtils.IndexBuilder; import org.apache.commons.lang3.StringUtils; /** @@ -65,6 +75,123 @@ public static Optional getDataTypeFromColumnComment(String columnComment } + /** + * Label used in table comments to declare deferred indexes. + */ + public static final String DEFERRED_COMMENT_LABEL = "DEFERRED"; + + /** + * Regex for extracting DEFERRED segments from table comments. + * Matches: /DEFERRED:[indexName|col1,col2|unique] or /DEFERRED:[indexName|col1,col2] + */ + private static final Pattern DEFERRED_INDEX_REGEX = Pattern.compile("DEFERRED:\\[([^\\]]+)\\]"); + + + /** + * Parses deferred index declarations from a table comment string. + * Each DEFERRED segment has the format: {@code DEFERRED:[indexName|col1,col2|unique]} + * where the {@code |unique} suffix is optional. + * + * @param tableComment the full table comment string, may be null or empty. + * @return a list of deferred indexes parsed from the comment, each with {@code isDeferred()=true}. + */ + public static List parseDeferredIndexesFromComment(String tableComment) { + if (StringUtils.isEmpty(tableComment)) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + Matcher matcher = DEFERRED_INDEX_REGEX.matcher(tableComment); + while (matcher.find()) { + String content = matcher.group(1); + String[] parts = content.split("\\|"); + if (parts.length < 2) { + continue; + } + + String indexName = parts[0]; + List columns = Arrays.asList(parts[1].split(",")); + boolean unique = parts.length >= 3 && "unique".equalsIgnoreCase(parts[2]); + + IndexBuilder builder = index(indexName).columns(columns).deferred(); + if (unique) { + builder = builder.unique(); + } + result.add(builder); + } + return result; + } + + + /** + * Builds the DEFERRED comment segments for the given deferred indexes. + * Each index produces a segment like {@code /DEFERRED:[indexName|col1,col2|unique]}. + * + * @param deferredIndexes the deferred indexes to serialize. + * @return the concatenated DEFERRED segments, or an empty string if none. + */ + public static String buildDeferredIndexCommentSegments(List deferredIndexes) { + if (deferredIndexes == null || deferredIndexes.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (Index idx : deferredIndexes) { + sb.append("/").append(DEFERRED_COMMENT_LABEL).append(":["); + sb.append(idx.getName()); + sb.append("|"); + sb.append(idx.columnNames().stream().collect(Collectors.joining(","))); + if (idx.isUnique()) { + sb.append("|unique"); + } + sb.append("]"); + } + return sb.toString(); + } + + + /** + * Merges deferred index declarations from a table comment with physical indexes + * loaded from the database catalog. + * + *

Physical indexes whose names match a deferred declaration are returned with + * {@code isDeferred()=true}. Deferred declarations with no physical counterpart + * are returned as virtual indexes (also with {@code isDeferred()=true}).

+ * + * @param physicalIndexes the indexes loaded from the database catalog. + * @param tableComment the raw table comment string, may be null or empty. + * @return the merged index list, or {@code physicalIndexes} unchanged if + * no deferred declarations are found. + */ + public static List mergeDeferredIndexes(List physicalIndexes, String tableComment) { + List deferredFromComment = parseDeferredIndexesFromComment(tableComment); + if (deferredFromComment.isEmpty()) { + return physicalIndexes; + } + + Set deferredNames = deferredFromComment.stream() + .map(i -> i.getName().toUpperCase()) + .collect(Collectors.toCollection(java.util.HashSet::new)); + + List merged = new ArrayList<>(); + for (Index physical : physicalIndexes) { + if (deferredNames.contains(physical.getName().toUpperCase())) { + IndexBuilder builder = index(physical.getName()).columns(physical.columnNames()).deferred(); + merged.add(physical.isUnique() ? builder.unique() : builder); + deferredNames.remove(physical.getName().toUpperCase()); + } else { + merged.add(physical); + } + } + for (Index deferred : deferredFromComment) { + if (deferredNames.contains(deferred.getName().toUpperCase())) { + merged.add(deferred); + } + } + return merged; + } + + /** * Indexes which contain the suffix _PRF and a digit are to be ignored: * this allows performance testing of new index to verify their effect, 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..8088a132b 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 @@ -29,6 +29,7 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -3898,6 +3899,33 @@ public Collection indexDropStatements(@SuppressWarnings("unused") Table } + /** + * Generate the SQL to drop an index if it exists. Used for deferred indexes + * which may or may not have been physically built yet. + * + * @param table The table to drop the index from. + * @param indexToBeRemoved The index to be dropped. + * @return The SQL to drop the specified index if it exists. + */ + public Collection indexDropStatementsIfExists(@SuppressWarnings("unused") Table table, Index indexToBeRemoved) { + return ImmutableList.of("DROP INDEX IF EXISTS " + indexToBeRemoved.getName()); + } + + + /** + * Generate the SQL to rename an index if it exists. Used for deferred indexes + * which may or may not have been physically built yet. + * + * @param table The table on which the index exists. + * @param fromIndexName The index to rename. + * @param toIndexName The new index name. + * @return The SQL to rename the index if it exists. + */ + public Collection renameIndexStatementsIfExists(Table table, String fromIndexName, String toIndexName) { + return ImmutableList.of("ALTER INDEX IF EXISTS " + schemaNamePrefix(table) + fromIndexName + " RENAME TO " + toIndexName); + } + + /** * Generates the SQL to create a table and insert the data specified in the {@link SelectStatement}. * @@ -4047,6 +4075,104 @@ public Collection addIndexStatements(Table table, Index index) { } + /** + * Whether this dialect supports deferred index creation. When {@code true}, + * indexes marked as deferred are queued for background creation via table + * comments. 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); + } + + + /** + * Returns SQL to find table names that have DEFERRED index declarations in their + * table comments. Used by the executor to avoid a full schema scan on large databases. + * + *

The default implementation returns {@code null}, meaning the caller should + * fall back to a full schema scan. Dialects that use table comments override this + * to produce an efficient query against the database catalog.

+ * + * @return SQL that returns a single-column result set of table names, or {@code null} + * if the dialect does not support this optimization. + */ + public String findTablesWithDeferredIndexesSql() { + return null; + } + + + /** + * Returns SQL to check whether a named index exists but is invalid (e.g. PostgreSQL's + * {@code indisvalid=false} after a crashed {@code CREATE INDEX CONCURRENTLY}). The SQL + * should return at least one row if the index is invalid, and no rows otherwise. + * + *

The default implementation returns {@code null}, meaning no invalid-index checking + * is performed. PostgreSQL overrides this.

+ * + * @param indexName the index name to check. + * @return SQL that returns rows if the index is invalid, or {@code null} if not supported. + */ + public String checkInvalidIndexSql(String indexName) { + return null; + } + + + /** + * Returns SQL to drop an invalid index before rebuilding it. Only called when + * {@link #checkInvalidIndexSql(String)} indicated the index is invalid. + * + *

The default implementation returns an empty list. PostgreSQL overrides this + * with {@code DROP INDEX IF EXISTS}.

+ * + * @param indexName the index to drop. + * @return SQL statements to drop the invalid index. + */ + public Collection dropInvalidIndexStatements(String indexName) { + return Collections.emptyList(); + } + + + /** + * Generates a {@code COMMENT ON TABLE} SQL statement that includes both the standard + * table metadata (e.g. REALNAME) and any deferred index declarations. The deferred index + * declarations are appended as {@code /DEFERRED:[indexName|col1,col2|unique]} segments. + * + *

The default implementation returns an empty list. Dialects that use table comments + * (PostgreSQL, Oracle, H2) override this to produce the appropriate SQL.

+ * + * @param table The table to generate the comment for. + * @param deferredIndexes The deferred indexes to declare in the comment. + * @return A collection of SQL statements, or an empty list if the dialect does not support comments. + */ + public Collection generateTableCommentStatements(Table table, List deferredIndexes) { + return Collections.emptyList(); + } + + /** * Helper method to create all index statements defined for a table * @@ -4070,13 +4196,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 +4230,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/metadata/Index.java b/morf-core/src/main/java/org/alfasoftware/morf/metadata/Index.java index 99c798361..f65568a05 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/metadata/Index.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/metadata/Index.java @@ -42,16 +42,30 @@ public interface Index { public boolean isUnique(); + /** + * Returns whether this index is deferred, meaning it may be built + * asynchronously after an upgrade rather than inline during the upgrade. + * + * @return True if the index is deferred. + */ + public default boolean isDeferred() { + return false; + } + + /** * Helper for {@link Object#toString()} implementations. * * @return String representation of the index. */ public default String toStringHelper() { - return new StringBuilder() + StringBuilder sb = new StringBuilder() .append("Index-").append(getName()) .append("-").append(isUnique() ? "unique" : "") - .append("-").append(Joiner.on(',').join(columnNames())) - .toString(); + .append("-").append(Joiner.on(',').join(columnNames())); + if (isDeferred()) { + sb.append("-deferred"); + } + return sb.toString(); } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/metadata/IndexBean.java b/morf-core/src/main/java/org/alfasoftware/morf/metadata/IndexBean.java index a9a1d7fe0..855f1489d 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/metadata/IndexBean.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/metadata/IndexBean.java @@ -41,6 +41,11 @@ class IndexBean implements Index { */ private final boolean unique; + /** + * Flags if the index is deferred (built asynchronously after upgrade). + */ + private final boolean deferred; + /** * Creates an index bean. @@ -50,7 +55,7 @@ class IndexBean implements Index { * @param columnNames Column names to order the index. */ IndexBean(String name, boolean unique, String... columnNames) { - this(name, unique, ImmutableList.copyOf(columnNames)); + this(name, unique, false, ImmutableList.copyOf(columnNames)); } @@ -62,17 +67,18 @@ class IndexBean implements Index { * @param columnNames Column names to order the index. */ IndexBean(String name, boolean unique, Iterable columnNames) { - this(name, unique, ImmutableList.copyOf(columnNames)); + this(name, unique, false, ImmutableList.copyOf(columnNames)); } /** * Internal constructor. */ - private IndexBean(String name, boolean unique, ImmutableList columnNames) { + IndexBean(String name, boolean unique, boolean deferred, ImmutableList columnNames) { super(); this.name = name; this.unique = unique; + this.deferred = deferred; this.columnNames = columnNames; } @@ -81,7 +87,7 @@ private IndexBean(String name, boolean unique, ImmutableList columnNames * @param toCopy Index to copy. */ IndexBean(Index toCopy) { - this(toCopy.getName(), toCopy.isUnique(), toCopy.columnNames()); + this(toCopy.getName(), toCopy.isUnique(), toCopy.isDeferred(), ImmutableList.copyOf(toCopy.columnNames())); } @@ -110,6 +116,12 @@ public boolean isUnique() { } + @Override + public boolean isDeferred() { + return deferred; + } + + @Override public String toString() { return this.toStringHelper(); diff --git a/morf-core/src/main/java/org/alfasoftware/morf/metadata/SchemaUtils.java b/morf-core/src/main/java/org/alfasoftware/morf/metadata/SchemaUtils.java index 8aaa9744a..fbcf3107d 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/metadata/SchemaUtils.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/metadata/SchemaUtils.java @@ -615,6 +615,14 @@ public interface IndexBuilder extends Index { * @return this, for method chaining. */ public IndexBuilder unique(); + + + /** + * Mark this index as deferred (built asynchronously after upgrade). + * + * @return this, for method chaining. + */ + public IndexBuilder deferred(); } @@ -776,12 +784,12 @@ public ColumnBuilder dataType(DataType dataType) { private static final class IndexBuilderImpl extends IndexBean implements IndexBuilder { private IndexBuilderImpl(String name) { - super(name, false, new String[0]); + super(name, false, false, ImmutableList.of()); } - private IndexBuilderImpl(String name, boolean unique, Iterable columnNames) { - super(name, unique, columnNames); + private IndexBuilderImpl(String name, boolean unique, boolean deferred, Iterable columnNames) { + super(name, unique, deferred, ImmutableList.copyOf(columnNames)); } @@ -790,7 +798,7 @@ private IndexBuilderImpl(String name, boolean unique, Iterable columnNam */ @Override public IndexBuilder columns(String... columnNames) { - return new IndexBuilderImpl(getName(), isUnique(), Arrays.asList(columnNames)); + return new IndexBuilderImpl(getName(), isUnique(), isDeferred(), Arrays.asList(columnNames)); } @@ -799,7 +807,7 @@ public IndexBuilder columns(String... columnNames) { */ @Override public IndexBuilder columns(Iterable columnNames) { - return new IndexBuilderImpl(getName(), isUnique(), columnNames); + return new IndexBuilderImpl(getName(), isUnique(), isDeferred(), columnNames); } @@ -808,7 +816,13 @@ public IndexBuilder columns(Iterable columnNames) { */ @Override public IndexBuilder unique() { - return new IndexBuilderImpl(getName(), true, columnNames()); + return new IndexBuilderImpl(getName(), true, isDeferred(), columnNames()); + } + + + @Override + public IndexBuilder deferred() { + return new IndexBuilderImpl(getName(), isUnique(), true, columnNames()); } 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..5441ea2a7 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.ArrayList; import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; import org.alfasoftware.morf.jdbc.SqlDialect; import org.alfasoftware.morf.metadata.Index; import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaUtils; import org.alfasoftware.morf.metadata.Table; import org.alfasoftware.morf.sql.Statement; +import org.alfasoftware.morf.upgrade.adapt.TableOverrideSchema; /** * Common code between SchemaChangeVisitor implementors @@ -20,7 +25,6 @@ public abstract class AbstractSchemaChangeVisitor implements SchemaChangeVisitor protected final Table idTable; protected final TableNameResolver tracker; - public AbstractSchemaChangeVisitor(Schema currentSchema, UpgradeConfigAndContext upgradeConfigAndContext, SqlDialect sqlDialect, Table idTable) { this.currentSchema = currentSchema; @@ -79,38 +83,99 @@ public void visit(AddColumn addColumn) { @Override public void visit(ChangeColumn changeColumn) { + String tableName = changeColumn.getTableName(); + String oldColName = changeColumn.getFromColumn().getName(); + String newColName = changeColumn.getToColumn().getName(); + + // If a column is renamed and deferred indexes reference the old name, update the in-memory schema + if (!oldColName.equalsIgnoreCase(newColName)) { + updateDeferredIndexColumnName(tableName, oldColName, newColName); + } + currentSchema = changeColumn.apply(currentSchema); - writeStatements(sqlDialect.alterTableChangeColumnStatements(currentSchema.getTable(changeColumn.getTableName()), changeColumn.getFromColumn(), changeColumn.getToColumn())); + writeStatements(sqlDialect.alterTableChangeColumnStatements(currentSchema.getTable(tableName), changeColumn.getFromColumn(), changeColumn.getToColumn())); + + if (!oldColName.equalsIgnoreCase(newColName) && hasDeferredIndexes(tableName)) { + writeDeferredIndexComment(tableName); + } } @Override public void visit(RemoveColumn removeColumn) { + String tableName = removeColumn.getTableName(); + String colName = removeColumn.getColumnDefinition().getName(); + + // Remove deferred indexes that reference the removed column + boolean hadDeferred = removeDeferredIndexesReferencingColumn(tableName, colName); + currentSchema = removeColumn.apply(currentSchema); - writeStatements(sqlDialect.alterTableDropColumnStatements(currentSchema.getTable(removeColumn.getTableName()), removeColumn.getColumnDefinition())); + writeStatements(sqlDialect.alterTableDropColumnStatements(currentSchema.getTable(tableName), removeColumn.getColumnDefinition())); + + if (hadDeferred) { + writeDeferredIndexComment(tableName); + } } @Override public void visit(RemoveIndex removeIndex) { + String tableName = removeIndex.getTableName(); + boolean wasDeferred = isIndexDeferred(tableName, removeIndex.getIndexToBeRemoved().getName()); + currentSchema = removeIndex.apply(currentSchema); - writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(removeIndex.getTableName()), removeIndex.getIndexToBeRemoved())); + + if (wasDeferred) { + writeStatements(sqlDialect.indexDropStatementsIfExists(currentSchema.getTable(tableName), removeIndex.getIndexToBeRemoved())); + writeDeferredIndexComment(tableName); + } else { + writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(tableName), removeIndex.getIndexToBeRemoved())); + } } @Override public void visit(ChangeIndex changeIndex) { + String tableName = changeIndex.getTableName(); + boolean fromDeferred = isIndexDeferred(tableName, changeIndex.getFromIndex().getName()); + currentSchema = changeIndex.apply(currentSchema); - writeStatements(sqlDialect.indexDropStatements(currentSchema.getTable(changeIndex.getTableName()), changeIndex.getFromIndex())); - writeStatements(sqlDialect.addIndexStatements(currentSchema.getTable(changeIndex.getTableName()), changeIndex.getToIndex())); + Table table = currentSchema.getTable(tableName); + + if (fromDeferred) { + writeStatements(sqlDialect.indexDropStatementsIfExists(table, changeIndex.getFromIndex())); + } else { + writeStatements(sqlDialect.indexDropStatements(table, changeIndex.getFromIndex())); + } + + boolean toDeferred = changeIndex.getToIndex().isDeferred() && sqlDialect.supportsDeferredIndexCreation(); + if (toDeferred) { + writeDeferredIndexComment(tableName); + } else { + writeStatements(sqlDialect.addIndexStatements(table, changeIndex.getToIndex())); + if (fromDeferred) { + // Old was deferred, new is not — update comment to remove old deferred declaration + writeDeferredIndexComment(tableName); + } + } } @Override public void visit(final RenameIndex renameIndex) { + String tableName = renameIndex.getTableName(); + boolean wasDeferred = isIndexDeferred(tableName, renameIndex.getFromIndexName()); + currentSchema = renameIndex.apply(currentSchema); - writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(renameIndex.getTableName()), - renameIndex.getFromIndexName(), renameIndex.getToIndexName())); + + if (wasDeferred) { + writeStatements(sqlDialect.renameIndexStatementsIfExists(currentSchema.getTable(tableName), + renameIndex.getFromIndexName(), renameIndex.getToIndexName())); + writeDeferredIndexComment(tableName); + } else { + writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(tableName), + renameIndex.getFromIndexName(), renameIndex.getToIndexName())); + } } @@ -121,6 +186,11 @@ public void visit(RenameTable renameTable) { Table newTable = currentSchema.getTable(renameTable.getNewTableName()); writeStatements(sqlDialect.renameTableStatements(oldTable, newTable)); + + // Regenerate deferred index comment with the new table name + if (hasDeferredIndexes(renameTable.getNewTableName())) { + writeDeferredIndexComment(renameTable.getNewTableName()); + } } @@ -202,19 +272,123 @@ private void visitPortableSqlStatement(PortableSqlStatement sql) { @Override public void visit(AddIndex addIndex) { currentSchema = addIndex.apply(currentSchema); - Index foundIndex = null; - List ignoredIndexes = upgradeConfigAndContext.getIgnoredIndexesForTable(addIndex.getTableName()); - for (Index index : ignoredIndexes) { - if (index.columnNames().equals(addIndex.getNewIndex().columnNames()) && index.isUnique() == addIndex.getNewIndex().isUnique()) { - foundIndex = index; - break; + boolean shouldDefer = addIndex.getNewIndex().isDeferred() && sqlDialect.supportsDeferredIndexCreation(); + + if (shouldDefer) { + writeDeferredIndexComment(addIndex.getTableName()); + } else { + Index foundIndex = null; + List ignoredIndexes = upgradeConfigAndContext.getIgnoredIndexesForTable(addIndex.getTableName()); + for (Index index : ignoredIndexes) { + if (index.columnNames().equals(addIndex.getNewIndex().columnNames()) && index.isUnique() == addIndex.getNewIndex().isUnique()) { + foundIndex = index; + break; + } + } + + if (foundIndex != null) { + writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(addIndex.getTableName()), foundIndex.getName(), addIndex.getNewIndex().getName())); + } else { + writeStatements(sqlDialect.addIndexStatements(currentSchema.getTable(addIndex.getTableName()), addIndex.getNewIndex())); } } + } - if (foundIndex != null) { - writeStatements(sqlDialect.renameIndexStatements(currentSchema.getTable(addIndex.getTableName()), foundIndex.getName(), addIndex.getNewIndex().getName())); - } else { - writeStatements(sqlDialect.addIndexStatements(currentSchema.getTable(addIndex.getTableName()), addIndex.getNewIndex())); + + // ------------------------------------------------------------------------- + // Deferred index helpers + // ------------------------------------------------------------------------- + + /** + * Generates a COMMENT ON TABLE statement declaring all deferred indexes for the given table. + */ + private void writeDeferredIndexComment(String tableName) { + Table table = currentSchema.getTable(tableName); + List deferredIndexes = getDeferredIndexes(table); + writeStatements(sqlDialect.generateTableCommentStatements(table, deferredIndexes)); + } + + + private List getDeferredIndexes(Table table) { + return table.indexes().stream() + .filter(Index::isDeferred) + .collect(Collectors.toList()); + } + + + private boolean hasDeferredIndexes(String tableName) { + return currentSchema.tableExists(tableName) + && currentSchema.getTable(tableName).indexes().stream().anyMatch(Index::isDeferred); + } + + + private boolean isIndexDeferred(String tableName, String indexName) { + if (!currentSchema.tableExists(tableName)) { + return false; + } + return currentSchema.getTable(tableName).indexes().stream() + .anyMatch(i -> i.getName().equalsIgnoreCase(indexName) && i.isDeferred()); + } + + + /** + * Removes deferred indexes that reference the given column from the current schema, + * emitting DROP INDEX IF EXISTS DDL and updating the in-memory schema so that the + * regenerated comment does not contain stale index declarations. + * + * @return true if any deferred indexes were found and removed. + */ + private boolean removeDeferredIndexesReferencingColumn(String tableName, String columnName) { + if (!currentSchema.tableExists(tableName)) { + return false; + } + Table table = currentSchema.getTable(tableName); + List survivingIndexes = new ArrayList<>(); + boolean found = false; + for (Index idx : table.indexes()) { + if (idx.isDeferred() && idx.columnNames().stream().anyMatch(c -> c.equalsIgnoreCase(columnName))) { + writeStatements(sqlDialect.indexDropStatementsIfExists(table, idx)); + found = true; + } else { + survivingIndexes.add(idx); + } + } + if (found) { + Table updatedTable = SchemaUtils.table(table.getName()).columns(table.columns()).indexes(survivingIndexes); + currentSchema = new TableOverrideSchema(currentSchema, updatedTable); + } + return found; + } + + + /** + * Updates deferred index column references in the current schema when a column is renamed. + * Indexes store column names as strings, so ChangeColumn.apply() does not update them. + * This method rebuilds affected deferred indexes with the new column name so that the + * regenerated comment contains the correct references. + */ + private void updateDeferredIndexColumnName(String tableName, String oldColName, String newColName) { + if (!currentSchema.tableExists(tableName)) { + return; + } + Table table = currentSchema.getTable(tableName); + List updatedIndexes = new ArrayList<>(); + boolean changed = false; + for (Index idx : table.indexes()) { + if (idx.isDeferred() && idx.columnNames().stream().anyMatch(c -> c.equalsIgnoreCase(oldColName))) { + List newCols = idx.columnNames().stream() + .map(c -> c.equalsIgnoreCase(oldColName) ? newColName : c) + .collect(Collectors.toList()); + SchemaUtils.IndexBuilder builder = SchemaUtils.index(idx.getName()).columns(newCols).deferred(); + updatedIndexes.add(idx.isUnique() ? builder.unique() : builder); + changed = true; + } else { + updatedIndexes.add(idx); + } + } + if (changed) { + Table updatedTable = SchemaUtils.table(table.getName()).columns(table.columns()).indexes(updatedIndexes); + currentSchema = new TableOverrideSchema(currentSchema, updatedTable); } } } diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/RenameIndex.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/RenameIndex.java index 5d8e378be..1eb98b9ad 100755 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/RenameIndex.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/RenameIndex.java @@ -24,6 +24,7 @@ import org.alfasoftware.morf.jdbc.ConnectionResources; import org.alfasoftware.morf.metadata.Index; import org.alfasoftware.morf.metadata.Schema; +import org.alfasoftware.morf.metadata.SchemaUtils.IndexBuilder; import org.alfasoftware.morf.metadata.Table; import org.alfasoftware.morf.upgrade.adapt.AlteredTable; import org.alfasoftware.morf.upgrade.adapt.TableOverrideSchema; @@ -145,11 +146,14 @@ private Schema applyChange(Schema schema, String indexStartName, String indexEnd if (currentIndexName.equalsIgnoreCase(indexStartName)) { // Substitute in the new index name currentIndexName = indexEndName; + IndexBuilder builder = index(indexEndName).columns(index.columnNames()); if (index.isUnique()) { - newIndex = index(indexEndName).columns(index.columnNames()).unique(); - } else { - newIndex = index(indexEndName).columns(index.columnNames()); + builder = builder.unique(); } + if (index.isDeferred()) { + builder = builder.deferred(); + } + newIndex = builder; foundMatch = true; } 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..33e8db2b7 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; + + /** * Interface for adapting schema changes, i.e. {@link SchemaChange} implementations. * @@ -190,22 +192,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)); } 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..1d2a7349e 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.metadata.SchemaUtils; +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,9 +371,40 @@ public void removeColumns(String tableName, Column... definitions) { */ @Override public void addIndex(String tableName, Index index) { - AddIndex addIndex = new AddIndex(tableName, index); + Index effectiveIndex = resolveDeferred(index); + AddIndex addIndex = new AddIndex(tableName, effectiveIndex); visitor.visit(addIndex); - schemaAndDataChangeVisitor.visit(addIndex); + // Deferred indexes don't generate DDL on the table data, so no dependency + if (!effectiveIndex.isDeferred()) { + schemaAndDataChangeVisitor.visit(addIndex); + } + } + + + private Index resolveDeferred(Index index) { + if (!upgradeConfigAndContext.isDeferredIndexCreationEnabled()) { + // Kill switch: strip deferred flag + return index.isDeferred() ? rebuildIndex(index, false) : index; + } + if (upgradeConfigAndContext.isForceImmediateIndex(index.getName())) { + return index.isDeferred() ? rebuildIndex(index, false) : index; + } + if (upgradeConfigAndContext.isForceDeferredIndex(index.getName())) { + return index.isDeferred() ? index : rebuildIndex(index, true); + } + return index; + } + + + private Index rebuildIndex(Index index, boolean deferred) { + SchemaUtils.IndexBuilder builder = SchemaUtils.index(index.getName()).columns(index.columnNames()); + if (index.isUnique()) { + builder = builder.unique(); + } + if (deferred) { + builder = builder.deferred(); + } + return builder; } 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..9846088d5 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 @@ -16,6 +16,7 @@ package org.alfasoftware.morf.upgrade; + /** * Interface for any upgrade / downgrade strategy which handles all the * defined {@link SchemaChange} implementations. diff --git a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/UpgradeConfigAndContext.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/UpgradeConfigAndContext.java index 72979ae68..e72e850e0 100644 --- a/morf-core/src/main/java/org/alfasoftware/morf/upgrade/UpgradeConfigAndContext.java +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/UpgradeConfigAndContext.java @@ -8,6 +8,8 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; /** * Configuration and context bean for the {@link Upgrade} process. @@ -45,6 +47,40 @@ public class UpgradeConfigAndContext { private Map> ignoredIndexes = Map.of(); + // ------------------------------------------------------------------------- + // Deferred index creation + // ------------------------------------------------------------------------- + + /** + * Whether deferred index creation is enabled. When {@code false} (the default), + * deferred indexes are built immediately during the upgrade. When {@code true}, + * indexes marked as deferred are queued for background creation. + */ + 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; + + /** * @see #exclusiveExecutionSteps */ @@ -140,4 +176,126 @@ 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; + } + + + 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/deferred/DeferredIndexExecutor.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutor.java new file mode 100644 index 000000000..4332c2642 --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutor.java @@ -0,0 +1,65 @@ +/* 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.concurrent.CompletableFuture; + +import com.google.inject.ImplementedBy; + +/** + * Scans the database schema for deferred indexes that have not yet been + * physically built and builds them asynchronously using a thread pool. + * + *

A deferred index is identified by {@code Index.isDeferred() == true} + * in the schema returned by the MetaDataProvider. The MetaDataProvider + * merges comment-declared deferred indexes with physical indexes: if a + * comment declares a deferred index but the physical index does not yet + * exist, the MetaDataProvider returns a virtual index with + * {@code isDeferred()=true}. Once physically built, the physical index + * takes precedence and {@code isDeferred()} returns {@code false}.

+ * + *

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 { + + /** + * Scans the database schema for deferred indexes not yet physically + * built and submits them to a thread pool for asynchronous 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 deferred indexes to build. + */ + CompletableFuture execute(); + + + /** + * Scans the database schema for deferred indexes not yet physically + * built and returns the SQL statements that would be executed to build + * them, without actually executing. + * + * @return a list of SQL statements; empty if there are no deferred + * indexes to build. + */ + List getMissingDeferredIndexStatements(); +} 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..069336bca --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexExecutorImpl.java @@ -0,0 +1,449 @@ +/* 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.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; + +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}. + * + *

Scans the database schema for indexes with {@code isDeferred()=true} + * (virtual indexes declared in table comments but not yet physically built) + * and creates them using + * {@link org.alfasoftware.morf.jdbc.SqlDialect#deferredIndexDeploymentStatements(Table, Index)}. + *

+ * + *

Retry logic uses a fixed delay between attempts, up to + * {@link UpgradeConfigAndContext#getDeferredIndexMaxRetries()} additional + * attempts after the first failure.

+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@Singleton +class DeferredIndexExecutorImpl implements DeferredIndexExecutor { + + private static final Log log = LogFactory.getLog(DeferredIndexExecutorImpl.class); + + /** Fixed delay in milliseconds between retry attempts. */ + private static final long RETRY_DELAY_MS = 5_000L; + + 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 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(ConnectionResources connectionResources, + SqlScriptExecutorProvider sqlScriptExecutorProvider, + UpgradeConfigAndContext config, + DeferredIndexExecutorServiceFactory executorServiceFactory) { + this.connectionResources = connectionResources; + this.sqlScriptExecutorProvider = sqlScriptExecutorProvider; + this.config = config; + this.executorServiceFactory = executorServiceFactory; + } + + + /** + * @see 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(); + + List missing = findMissingDeferredIndexes(); + + if (missing.isEmpty()) { + log.info("No deferred indexes to build."); + return CompletableFuture.completedFuture(null); + } + + log.info("Found " + missing.size() + " deferred index(es) to build."); + + threadPool = executorServiceFactory.create(config.getDeferredIndexThreadPoolSize()); + + AtomicInteger completed = new AtomicInteger(); + int total = missing.size(); + + CompletableFuture[] futures = missing.stream() + .map(entry -> CompletableFuture.runAsync(() -> { + executeWithRetry(entry); + log.info("Deferred index progress: " + completed.incrementAndGet() + "/" + total + " complete."); + }, threadPool)) + .toArray(CompletableFuture[]::new); + + return CompletableFuture.allOf(futures) + .whenComplete((v, t) -> { + threadPool.shutdown(); + threadPool = null; + log.info("Deferred index execution complete."); + }); + } + + + /** + * @see DeferredIndexExecutor#getMissingDeferredIndexStatements() + */ + @Override + public List getMissingDeferredIndexStatements() { + if (!config.isDeferredIndexCreationEnabled()) { + return Collections.emptyList(); + } + + List missing = findMissingDeferredIndexes(); + List statements = new ArrayList<>(); + for (DeferredIndexEntry entry : missing) { + statements.addAll( + connectionResources.sqlDialect().deferredIndexDeploymentStatements(entry.table, entry.index)); + } + return statements; + } + + + // ------------------------------------------------------------------------- + // Internal: schema scanning + // ------------------------------------------------------------------------- + + /** + * Scans the database schema for deferred indexes that have not yet been + * physically built. Uses an optimized query (via + * {@link org.alfasoftware.morf.jdbc.SqlDialect#findTablesWithDeferredIndexesSql()}) to + * find only tables with DEFERRED comment segments, avoiding a full schema scan + * on large databases. + * + *

An index with {@code isDeferred()=true} that was NOT physically built appears + * as a virtual index from the MetaDataProvider. An index with {@code isDeferred()=true} + * that WAS physically built appears as a real index marked deferred. We skip the + * latter by checking {@link #indexExistsPhysically(String, String)}.

+ * + * @return list of table/index pairs to build. + */ + private List findMissingDeferredIndexes() { + Set targetTables = findTablesWithDeferredComments(); + if (targetTables.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + try (SchemaResource sr = connectionResources.openSchemaResource()) { + for (String tableName : targetTables) { + if (!sr.tableExists(tableName)) { + continue; + } + Table table = sr.getTable(tableName); + for (Index index : table.indexes()) { + if (index.isDeferred() && !indexExistsPhysically(tableName, index.getName())) { + log.debug("Found unbuilt deferred index [" + index.getName() + + "] on table [" + table.getName() + "]"); + result.add(new DeferredIndexEntry(table, index)); + } + } + } + } + + return result; + } + + + /** + * Queries the database catalog to find table names that have DEFERRED index + * declarations in their table comments. This avoids loading metadata for all + * tables on large schemas. + * + * @return set of table names (case as stored in the catalog). + */ + private Set findTablesWithDeferredComments() { + String sql = connectionResources.sqlDialect().findTablesWithDeferredIndexesSql(); + if (sql == null) { + log.debug("Dialect does not support targeted deferred index scan — falling back to full scan"); + return findAllTableNames(); + } + + Set result = new HashSet<>(); + try (Connection conn = connectionResources.getDataSource().getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } catch (SQLException e) { + log.warn("Error querying for tables with deferred indexes — falling back to full scan: " + e.getMessage()); + return findAllTableNames(); + } + + log.debug("Found " + result.size() + " table(s) with DEFERRED comment segments"); + return result; + } + + + /** + * Fallback: returns all table names from the schema resource. + */ + private Set findAllTableNames() { + Set result = new HashSet<>(); + try (SchemaResource sr = connectionResources.openSchemaResource()) { + result.addAll(sr.tableNames()); + } + return result; + } + + + // ------------------------------------------------------------------------- + // Internal: execution logic + // ------------------------------------------------------------------------- + + /** + * Attempts to build the index for a single entry, retrying with a fixed + * delay on failure up to {@link UpgradeConfigAndContext#getDeferredIndexMaxRetries()} + * times. + * + * @param entry the table/index pair to build. + */ + private void executeWithRetry(DeferredIndexEntry entry) { + int maxAttempts = config.getDeferredIndexMaxRetries() + 1; + + for (int attempt = 0; attempt < maxAttempts; attempt++) { + if (Thread.currentThread().isInterrupted()) { + log.warn("Deferred index build interrupted for [" + entry.index.getName() + "] — aborting retries"); + return; + } + log.info("Building deferred index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "], attempt " + (attempt + 1) + "/" + maxAttempts); + long startTime = System.currentTimeMillis(); + + try { + repairInvalidIndex(entry); + buildIndex(entry); + long elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000; + log.info("Deferred index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "] completed in " + elapsedSeconds + " s"); + return; + + } catch (Exception e) { + // Post-failure check: if the index actually exists in the database + // (e.g. from a previous run or a crashed attempt that completed), + // treat as success. + if (indexExistsPhysically(entry.table.getName(), entry.index.getName())) { + log.info("Deferred index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "] already exists — skipping"); + return; + } + + long elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000; + int nextAttempt = attempt + 1; + + if (nextAttempt < maxAttempts) { + log.error("Deferred index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "] failed after " + elapsedSeconds + + " s (attempt " + nextAttempt + "/" + maxAttempts + "), will retry: " + + e.getMessage()); + sleepForRetry(); + } else { + log.error("Deferred index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "] permanently failed after " + elapsedSeconds + + " s (" + nextAttempt + " attempt(s))", e); + } + } + } + + log.error("DEFERRED INDEX BUILD FAILED: giving up on index [" + entry.index.getName() + + "] on table [" + entry.table.getName() + "] after " + maxAttempts + + " attempt(s). The index was NOT built. Manual intervention is required."); + } + + + /** + * Checks if an invalid index exists (e.g. PostgreSQL's {@code indisvalid=false} + * after a crashed {@code CREATE INDEX CONCURRENTLY}) and drops it before rebuilding. + * No-op on platforms that don't support this check. + * + * @param entry the table/index pair to check. + */ + private void repairInvalidIndex(DeferredIndexEntry entry) { + String checkSql = connectionResources.sqlDialect().checkInvalidIndexSql(entry.index.getName()); + if (checkSql == null) { + return; + } + + try (Connection conn = connectionResources.getDataSource().getConnection()) { + conn.setAutoCommit(true); + boolean isInvalid; + try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(checkSql)) { + isInvalid = rs.next(); + } + + if (isInvalid) { + log.warn("Found invalid index [" + entry.index.getName() + "] on table [" + + entry.table.getName() + "] — dropping before rebuild"); + Collection dropSql = connectionResources.sqlDialect() + .dropInvalidIndexStatements(entry.index.getName()); + sqlScriptExecutorProvider.get().execute(dropSql, conn); + } + } catch (SQLException e) { + log.warn("Error checking for invalid index [" + entry.index.getName() + "]: " + e.getMessage()); + } + } + + + /** + * Executes the {@code CREATE INDEX} DDL for the given entry using an + * autocommit connection. Autocommit is required for PostgreSQL's + * {@code CREATE INDEX CONCURRENTLY}. + * + * @param entry the table/index pair containing the metadata. + */ + private void buildIndex(DeferredIndexEntry entry) { + Collection statements = connectionResources.sqlDialect() + .deferredIndexDeploymentStatements(entry.table, entry.index); + + 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 " + entry.index.getName(), e); + } + } + + + /** + * Sleeps for a fixed delay between retry attempts. + */ + private void sleepForRetry() { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + + /** + * Checks whether a physical index exists in the live database catalog, + * bypassing the MetaDataProvider merge (which marks virtual deferred indexes + * as present). Uses JDBC {@code DatabaseMetaData.getIndexInfo()} for a raw + * catalog check. + * + * @param tableName the table name. + * @param indexName the index name. + * @return true if the physical index exists in the catalog. + */ + private boolean indexExistsPhysically(String tableName, String indexName) { + try (Connection conn = connectionResources.getDataSource().getConnection()) { + try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, tableName, false, true)) { + while (rs.next()) { + String name = rs.getString("INDEX_NAME"); + if (name != null && name.equalsIgnoreCase(indexName)) { + return true; + } + } + } + return false; + } catch (SQLException e) { + log.warn("Error checking physical index existence for [" + indexName + "]: " + e.getMessage()); + return false; + } + } + + + /** + * 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()); + } + } + + + // ------------------------------------------------------------------------- + // Inner class + // ------------------------------------------------------------------------- + + /** + * Pairs a {@link Table} with an {@link Index} that needs to be built. + */ + private static final class DeferredIndexEntry { + + /** The table the index belongs to. */ + final Table table; + + /** The deferred index to build. */ + final Index index; + + DeferredIndexEntry(Table table, Index index) { + this.table = table; + this.index = index; + } + } +} 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/DeferredIndexService.java b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexService.java new file mode 100644 index 000000000..6b6065a7a --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexService.java @@ -0,0 +1,87 @@ +/* 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 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. + * + *

Deferred indexes are declared in table comments by the upgrade framework. + * The MetaDataProvider reads these comments and exposes unbuilt deferred + * indexes as virtual indexes with {@code isDeferred()=true}. This service + * scans for such indexes and builds them asynchronously.

+ * + *

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");
+ * }
+ * 
+ * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +@ImplementedBy(DeferredIndexServiceImpl.class) +public interface DeferredIndexService { + + /** + * Scans the database schema and starts building all unbuilt 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 + * (completed or 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 completed within the timeout; + * {@code false} if the timeout elapsed first. + * @throws IllegalStateException if called before {@link #execute()}. + */ + boolean awaitCompletion(long timeoutSeconds); + + + /** + * Returns the SQL statements that would be executed to build all + * currently unbuilt deferred indexes, without actually executing them. + * + * @return a list of SQL statements; empty if there are no deferred + * indexes to build. + */ + List getMissingDeferredIndexStatements(); +} 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..0ef10791b --- /dev/null +++ b/morf-core/src/main/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexServiceImpl.java @@ -0,0 +1,113 @@ +/* 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.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. Scans the database schema for unbuilt + * deferred indexes (declared in table comments) and builds them + * asynchronously.

+ * + * @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; + + /** Future representing the current execution; {@code null} if not started. */ + private CompletableFuture executionFuture; + + + /** + * Constructs the service. + * + * @param executor executor for building deferred indexes. + */ + @Inject + DeferredIndexServiceImpl(DeferredIndexExecutor executor) { + this.executor = executor; + } + + + /** + * @see DeferredIndexService#execute() + */ + @Override + public void execute() { + log.info("Deferred index service: executing pending operations..."); + executionFuture = executor.execute(); + } + + + /** + * @see 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 DeferredIndexService#getMissingDeferredIndexStatements() + */ + @Override + public List getMissingDeferredIndexStatements() { + return executor.getMissingDeferredIndexStatements(); + } +} 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..fdaea30bc 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,6 @@ static class U1000 extends U1 {} */ @Sequence(1001L) static class U1001 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..b7b9187ee 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 @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.BDDMockito.given; 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 +29,7 @@ import org.alfasoftware.morf.sql.SelectStatement; import org.alfasoftware.morf.sql.Statement; import org.alfasoftware.morf.upgrade.GraphBasedUpgradeSchemaChangeVisitor.GraphBasedUpgradeSchemaChangeVisitorFactory; +import org.mockito.Mockito; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatchers; @@ -75,6 +77,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 +104,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 @@ -118,9 +123,12 @@ public void testAddIndexVisit() { // given visitor.startStep(U1.class); String idTableName = "IdTableName"; + Index newIndex = mock(Index.class); + when(newIndex.isDeferred()).thenReturn(false); AddIndex addIndex = mock(AddIndex.class); when(addIndex.apply(sourceSchema)).thenReturn(sourceSchema); when(addIndex.getTableName()).thenReturn(idTableName); + when(addIndex.getNewIndex()).thenReturn(newIndex); when(sqlDialect.addIndexStatements(nullable(Table.class), nullable(Index.class))).thenReturn(STATEMENTS); // when @@ -220,8 +228,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 +252,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 +273,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 +296,16 @@ 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); + Index toIdx = mock(Index.class); + when(toIdx.isDeferred()).thenReturn(false); + when(changeIndex.getToIndex()).thenReturn(toIdx); 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 +321,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 +334,112 @@ public void testRenameIndexVisit() { } + /** + * ChangeIndex for a pending deferred index uses IF EXISTS drop and emits a comment + * instead of standard DROP INDEX + CREATE INDEX. + */ + @Test + public void testChangeIndexHandlesPendingDeferredAdd() { + // given — a deferred AddIndex on SomeTable/SomeIndex + visitor.startStep(U1.class); + Index deferredIdx = mock(Index.class); + when(deferredIdx.getName()).thenReturn("SomeIndex"); + when(deferredIdx.isUnique()).thenReturn(false); + when(deferredIdx.isDeferred()).thenReturn(true); + when(deferredIdx.columnNames()).thenReturn(List.of("col1")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("SomeTable"); + when(mockTable.indexes()).thenReturn(List.of(deferredIdx)); + when(sourceSchema.getTable("SomeTable")).thenReturn(mockTable); + when(sourceSchema.tableExists("SomeTable")).thenReturn(true); + + AddIndex addIndex = mock(AddIndex.class); + when(addIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(addIndex.getTableName()).thenReturn("SomeTable"); + when(addIndex.getNewIndex()).thenReturn(deferredIdx); + + when(sqlDialect.supportsDeferredIndexCreation()).thenReturn(true); + when(sqlDialect.generateTableCommentStatements(ArgumentMatchers.any(), ArgumentMatchers.anyList())).thenReturn(STATEMENTS); + + visitor.visit(addIndex); + 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.isDeferred()).thenReturn(false); + when(toIdx.columnNames()).thenReturn(List.of("col2")); + + 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(sqlDialect.indexDropStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(STATEMENTS); + when(sqlDialect.addIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(STATEMENTS); + + // when + visitor.visit(changeIndex); + + // then — uses IF EXISTS drop since from-index was deferred + verify(sqlDialect).indexDropStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + + /** + * RenameIndex for a pending deferred index uses IF EXISTS rename + * and emits a comment instead of standard renameIndexStatements. + */ + @Test + public void testRenameIndexHandlesPendingDeferredAdd() { + // given — a deferred AddIndex on SomeTable/OldIndex + visitor.startStep(U1.class); + Index deferredIdx = mock(Index.class); + when(deferredIdx.getName()).thenReturn("OldIndex"); + when(deferredIdx.isUnique()).thenReturn(false); + when(deferredIdx.isDeferred()).thenReturn(true); + when(deferredIdx.columnNames()).thenReturn(List.of("col1")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("SomeTable"); + when(mockTable.indexes()).thenReturn(List.of(deferredIdx)); + when(sourceSchema.getTable("SomeTable")).thenReturn(mockTable); + when(sourceSchema.tableExists("SomeTable")).thenReturn(true); + + AddIndex addIndex = mock(AddIndex.class); + when(addIndex.apply(sourceSchema)).thenReturn(sourceSchema); + when(addIndex.getTableName()).thenReturn("SomeTable"); + when(addIndex.getNewIndex()).thenReturn(deferredIdx); + + when(sqlDialect.supportsDeferredIndexCreation()).thenReturn(true); + when(sqlDialect.generateTableCommentStatements(ArgumentMatchers.any(), ArgumentMatchers.anyList())).thenReturn(STATEMENTS); + + visitor.visit(addIndex); + 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(sqlDialect.renameIndexStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(STATEMENTS); + when(sqlDialect.generateTableCommentStatements(ArgumentMatchers.any(), ArgumentMatchers.anyList())).thenReturn(STATEMENTS); + + // when + visitor.visit(renameIndex); + + // then — uses IF EXISTS rename since index was deferred, no standard rename + verify(sqlDialect, never()).renameIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect).renameIndexStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + @Test public void testExecuteStatementVisit() { // given @@ -345,6 +482,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..d06099bd0 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 @@ -49,6 +49,7 @@ import org.alfasoftware.morf.sql.MergeStatement; import org.alfasoftware.morf.sql.Statement; import org.alfasoftware.morf.sql.UpdateStatement; +import org.mockito.ArgumentMatchers; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -80,6 +81,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 +141,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); @@ -158,9 +163,12 @@ public void testVisitRemoveTable() { @Test public void testVisitAddIndex() { // given + Index newIndex = mock(Index.class); + when(newIndex.isDeferred()).thenReturn(false); AddIndex addIndex = mock(AddIndex.class); given(addIndex.apply(schema)).willReturn(schema); when(addIndex.getTableName()).thenReturn(ID_TABLE_NAME); + when(addIndex.getNewIndex()).thenReturn(newIndex); Table newTable = mock(Table.class); when(newTable.getName()).thenReturn(ID_TABLE_NAME); @@ -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,13 @@ 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); + Index toIndex = mock(Index.class); + when(toIndex.isDeferred()).thenReturn(false); + given(changeIndex.getToIndex()).willReturn(toIndex); // when upgrader.visit(changeIndex); @@ -536,4 +566,321 @@ public void testVisitRemoveSequence() { verify(sqlStatementWriter).writeSql(anyCollection()); } + + /** + * Tests that visit(AddIndex) with a deferred index generates a COMMENT ON TABLE statement + * instead of a CREATE INDEX statement. + */ + @Test + public void testVisitAddIndexDeferred() { + // given + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1", "col2")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + + AddIndex addIndex = mock(AddIndex.class); + given(addIndex.apply(schema)).willReturn(schema); + when(addIndex.getTableName()).thenReturn("TestTable"); + when(addIndex.getNewIndex()).thenReturn(mockIndex); + + // when + upgrader.visit(addIndex); + + // then + verify(addIndex).apply(schema); + verify(sqlDialect).generateTableCommentStatements(nullable(Table.class), ArgumentMatchers.anyList()); + verify(sqlDialect, never()).addIndexStatements(nullable(Table.class), nullable(Index.class)); + verify(sqlStatementWriter).writeSql(anyCollection()); + } + + + /** When the dialect does not support deferred index creation, a deferred AddIndex falls back to CREATE INDEX. */ + @Test + public void testVisitAddIndexDeferredFallsBackWhenDialectUnsupported() { + // 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); + + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + AddIndex addIndex = mock(AddIndex.class); + given(addIndex.apply(schema)).willReturn(schema); + when(addIndex.getTableName()).thenReturn("TestTable"); + when(addIndex.getNewIndex()).thenReturn(mockIndex); + + when(sqlDialect.addIndexStatements(nullable(Table.class), nullable(Index.class))).thenReturn(List.of("CREATE INDEX TestIdx ON TestTable (col1)")); + + // when + upgrader.visit(addIndex); + + // then -- should call addIndexStatements, not generateTableCommentStatements + verify(sqlDialect).addIndexStatements(nullable(Table.class), nullable(Index.class)); + verify(sqlDialect, never()).generateTableCommentStatements(nullable(Table.class), ArgumentMatchers.anyList()); + } + + + /** + * Tests that ChangeIndex for an index that was deferred uses IF EXISTS drop + * instead of standard DROP INDEX. + */ + @Test + public void testChangeIndexOnDeferredIndexUsesIfExistsDrop() { + // given -- a deferred index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + // given -- change the same index to a new non-deferred definition + Index toIndex = mock(Index.class); + when(toIndex.getName()).thenReturn("TestIdx"); + when(toIndex.isUnique()).thenReturn(false); + when(toIndex.isDeferred()).thenReturn(false); + when(toIndex.columnNames()).thenReturn(List.of("col2")); + + 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 -- uses IF EXISTS drop since from-index was deferred, plus addIndexStatements for new + verify(sqlDialect).indexDropStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect).addIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + + /** + * Tests that RenameIndex for a deferred index uses IF EXISTS rename + * instead of standard RENAME INDEX. + */ + @Test + public void testRenameIndexOnDeferredIndexUsesIfExistsRename() { + // given -- a deferred index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + // 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 -- uses IF EXISTS rename since index was deferred + verify(sqlDialect, never()).renameIndexStatements(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect).renameIndexStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + + /** + * Tests that RemoveIndex for a deferred index uses IF EXISTS drop + * instead of standard DROP INDEX. + */ + @Test + public void testRemoveIndexOnDeferredIndexUsesIfExistsDrop() { + // given -- a deferred index on TestTable/TestIdx + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + // 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 -- uses IF EXISTS drop since index was deferred + verify(sqlDialect, never()).indexDropStatements(ArgumentMatchers.any(), ArgumentMatchers.any()); + verify(sqlDialect).indexDropStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.any()); + } + + + /** + * 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 RemoveColumn drops deferred indexes referencing the removed column + * using IF EXISTS before the DROP COLUMN. + */ + @Test + public void testRemoveColumnDropsDeferredIndexContainingColumn() { + // given -- a deferred index on TestTable referencing col1 + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1", "col2")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + // given -- remove col1 from TestTable + Column mockColumn = mock(Column.class); + when(mockColumn.getName()).thenReturn("col1"); + + RemoveColumn removeColumn = mock(RemoveColumn.class); + given(removeColumn.apply(ArgumentMatchers.any())).willReturn(schema); + when(removeColumn.getTableName()).thenReturn("TestTable"); + when(removeColumn.getColumnDefinition()).thenReturn(mockColumn); + + // when + upgrader.visit(removeColumn); + + // then -- IF EXISTS drop for the deferred index + DROP COLUMN + verify(sqlDialect).indexDropStatementsIfExists(ArgumentMatchers.any(), ArgumentMatchers.eq(mockIndex)); + verify(sqlDialect).alterTableDropColumnStatements(ArgumentMatchers.any(), ArgumentMatchers.eq(mockColumn)); + } + + + /** + * Tests that RenameTable regenerates the deferred index comment with the new table name. + */ + @Test + public void testRenameTableRegeneratesDeferredIndexComment() { + // given -- a deferred index on OldTable + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("col1")); + + Table oldTable = mock(Table.class); + when(oldTable.getName()).thenReturn("OldTable"); + when(oldTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("OldTable")).thenReturn(oldTable); + + Table newTable = mock(Table.class); + when(newTable.getName()).thenReturn("NewTable"); + when(newTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("NewTable")).thenReturn(newTable); + when(schema.tableExists("NewTable")).thenReturn(true); + + 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 -- RENAME TABLE DDL + comment regeneration for deferred indexes + verify(sqlDialect).renameTableStatements(oldTable, newTable); + verify(sqlDialect).generateTableCommentStatements(nullable(Table.class), ArgumentMatchers.anyList()); + } + + + /** + * Tests that ChangeColumn with a column rename regenerates the deferred index comment + * with the updated column name. + */ + @Test + public void testChangeColumnRegeneratesDeferredIndexComment() { + // given -- a deferred index referencing "oldCol" + Index mockIndex = mock(Index.class); + when(mockIndex.getName()).thenReturn("TestIdx"); + when(mockIndex.isUnique()).thenReturn(false); + when(mockIndex.isDeferred()).thenReturn(true); + when(mockIndex.columnNames()).thenReturn(List.of("oldCol")); + + Table mockTable = mock(Table.class); + when(mockTable.getName()).thenReturn("TestTable"); + when(mockTable.indexes()).thenReturn(List.of(mockIndex)); + when(schema.getTable("TestTable")).thenReturn(mockTable); + when(schema.tableExists("TestTable")).thenReturn(true); + + // given -- rename column oldCol to newCol on TestTable + Column fromColumn = mock(Column.class); + when(fromColumn.getName()).thenReturn("oldCol"); + Column toColumn = mock(Column.class); + when(toColumn.getName()).thenReturn("newCol"); + + ChangeColumn changeColumn = mock(ChangeColumn.class); + given(changeColumn.apply(ArgumentMatchers.any())).willReturn(schema); + when(changeColumn.getTableName()).thenReturn("TestTable"); + when(changeColumn.getFromColumn()).thenReturn(fromColumn); + when(changeColumn.getToColumn()).thenReturn(toColumn); + + // when + upgrader.visit(changeColumn); + + // then -- ALTER TABLE DDL + comment regeneration for deferred indexes + verify(sqlDialect).alterTableChangeColumnStatements(ArgumentMatchers.any(), ArgumentMatchers.eq(fromColumn), ArgumentMatchers.eq(toColumn)); + verify(sqlDialect).generateTableCommentStatements(nullable(Table.class), ArgumentMatchers.anyList()); + } + } 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..d32413d05 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; @@ -48,6 +52,7 @@ public class TestSchemaChangeSequence { @Before public void setUp() throws Exception { MockitoAnnotations.openMocks(this); + when(index.getName()).thenReturn("mockIndex"); } @@ -77,6 +82,201 @@ public void testTableResolution() { } + /** + * Tests that addIndex() with a deferred index records an AddIndex in the change sequence + * with isDeferred() true and the correct table and index name. + */ + @Test + public void testAddIndexDeferredProducesAddIndexWithDeferredFlag() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.isDeferred()).thenReturn(true); + 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(AddIndex.class)); + AddIndex change = (AddIndex) changes.get(0); + assertEquals("TestTable", change.getTableName()); + assertEquals("TestIdx", change.getNewIndex().getName()); + assertEquals(true, change.getNewIndex().isDeferred()); + } + + + /** Tests that addIndex with a deferred index and force-immediate config produces an AddIndex with isDeferred()=false. */ + @Test + public void testAddIndexDeferredWithForceImmediateProducesNonDeferredAddIndex() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.isDeferred()).thenReturn(true); + 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()); + assertEquals(false, change.getNewIndex().isDeferred()); + } + + + /** Tests that force-immediate matching is case-insensitive (H2 folds to uppercase). */ + @Test + public void testAddIndexDeferredWithForceImmediateCaseInsensitive() { + // given + when(index.getName()).thenReturn("TestIdx"); + when(index.isDeferred()).thenReturn(true); + 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)); + assertEquals(false, ((AddIndex) changes.get(0)).getNewIndex().isDeferred()); + } + + + /** 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 an AddIndex with isDeferred()=true. */ + @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(AddIndex.class)); + AddIndex change = (AddIndex) changes.get(0); + assertEquals("TestTable", change.getTableName()); + assertEquals("TestIdx", change.getNewIndex().getName()); + assertEquals(true, change.getNewIndex().isDeferred()); + } + + + /** 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(AddIndex.class)); + assertEquals(true, ((AddIndex) changes.get(0)).getNewIndex().isDeferred()); + } + + + /** 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.addIndex("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..6672c25c7 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 @@ -1029,4 +1029,6 @@ 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)); } + + } 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/TestDeferredIndexExecutorUnit.java b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutorUnit.java new file mode 100644 index 000000000..7d98976e9 --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexExecutorUnit.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.table; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +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.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +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.DataType; +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 org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link DeferredIndexExecutorImpl} covering the + * comments-based schema scanning approach: the executor finds indexes + * with {@code isDeferred()=true} in the SchemaResource and builds them. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +public class TestDeferredIndexExecutorUnit { + + @Mock private ConnectionResources connectionResources; + @Mock private SqlDialect sqlDialect; + @Mock private SqlScriptExecutorProvider sqlScriptExecutorProvider; + @Mock private DataSource dataSource; + @Mock private Connection connection; + @Mock private java.sql.DatabaseMetaData databaseMetaData; + + private UpgradeConfigAndContext config; + private AutoCloseable mocks; + + + /** Set up mocks and config before each test. */ + @Before + public void setUp() throws SQLException { + mocks = MockitoAnnotations.openMocks(this); + config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + when(connectionResources.sqlDialect()).thenReturn(sqlDialect); + when(connectionResources.getDataSource()).thenReturn(dataSource); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getMetaData()).thenReturn(databaseMetaData); + // Default: no physical indexes exist (empty result set for getIndexInfo) + ResultSet emptyRs = mock(ResultSet.class); + when(emptyRs.next()).thenReturn(false); + when(databaseMetaData.getIndexInfo(any(), any(), any(), anyBoolean(), anyBoolean())).thenReturn(emptyRs); + } + + + /** Close mocks after each test. */ + @After + public void tearDown() throws Exception { + mocks.close(); + } + + + /** execute with no deferred indexes should return an already-completed future. */ + @Test + public void testExecuteNoDeferredIndexes() { + // given -- schema with no deferred indexes + SchemaResource sr = mockSchemaResource(); + when(connectionResources.openSchemaResource()).thenReturn(sr); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + CompletableFuture future = executor.execute(); + + // then + assertTrue("Future should be completed immediately", future.isDone()); + } + + + /** execute with a single deferred index should build it. */ + @Test + public void testExecuteSingleDeferredIndex() throws SQLException { + // given -- one deferred index on TestTable + mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX TestIdx ON TestTable(col1, col2)")); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + executor.execute().join(); + + // then + verify(sqlDialect).deferredIndexDeploymentStatements(any(Table.class), any(Index.class)); + verify(scriptExecutor).execute(any(Collection.class), any(Connection.class)); + } + + + /** execute should skip non-deferred indexes. */ + @Test + public void testExecuteSkipsNonDeferredIndexes() { + // given -- table with only non-deferred indexes + Table tableWithNonDeferred = table("TestTable") + .columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("col1", DataType.STRING, 50) + ) + .indexes(index("TestIdx").columns("id", "col1")); + SchemaResource sr = mockSchemaResource(tableWithNonDeferred); + when(connectionResources.openSchemaResource()).thenReturn(sr); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + CompletableFuture future = executor.execute(); + + // then + assertTrue("Future should be completed immediately (no deferred indexes)", future.isDone()); + verify(sqlDialect, never()).deferredIndexDeploymentStatements(any(Table.class), any(Index.class)); + } + + + /** execute should retry on failure and succeed on a subsequent attempt. */ + @SuppressWarnings("unchecked") + @Test + public void testExecuteRetryThenSuccess() { + // given -- deferred index that fails on first attempt, succeeds on second + config.setDeferredIndexMaxRetries(2); + mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenThrow(new RuntimeException("temporary failure")) + .thenReturn(List.of("CREATE INDEX TestIdx ON TestTable(col1, col2)")); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + executor.execute().join(); + + // then + verify(scriptExecutor).execute(any(Collection.class), any(Connection.class)); + } + + + /** execute should give up after exhausting retries. */ + @Test + public void testExecutePermanentFailure() { + // given -- deferred index that always fails + config.setDeferredIndexMaxRetries(1); + SchemaResource sr = mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + when(connectionResources.openSchemaResource()).thenReturn(sr); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenThrow(new RuntimeException("persistent failure")); + + // when -- should not throw; the future completes and the error is logged + DeferredIndexExecutorImpl executor = createExecutor(); + executor.execute().join(); + } + + + /** execute should handle a SQLException from getConnection as a failure. */ + @Test + public void testExecuteSqlExceptionFromConnection() throws SQLException { + // given -- deferred index exists but connection fails during build + config.setDeferredIndexMaxRetries(0); + SchemaResource sr = mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + when(connectionResources.openSchemaResource()).thenReturn(sr); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX TestIdx ON TestTable(col1, col2)")); + when(dataSource.getConnection()).thenThrow(new SQLException("connection refused")); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + executor.execute().join(); + } + + + /** buildIndex should restore autocommit to its original value after execution. */ + @Test + public void testAutoCommitRestoredAfterBuildIndex() throws SQLException { + // given -- connection with autocommit=false and a deferred index to build + when(connection.getAutoCommit()).thenReturn(false); + mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + SqlScriptExecutor scriptExecutor = mock(SqlScriptExecutor.class); + when(sqlScriptExecutorProvider.get()).thenReturn(scriptExecutor); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX TestIdx ON TestTable(col1, col2)")); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + executor.execute().join(); + + // then -- autocommit was set to true for the build, then restored + verify(connection, org.mockito.Mockito.atLeastOnce()).setAutoCommit(true); + verify(connection).setAutoCommit(false); + } + + + /** execute should be a no-op when deferred index creation is disabled. */ + @Test + public void testExecuteDisabled() { + // given + config.setDeferredIndexCreationEnabled(false); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + CompletableFuture future = executor.execute(); + + // then + assertTrue("Future should be completed immediately", future.isDone()); + verify(connectionResources, never()).openSchemaResource(); + } + + + // ------------------------------------------------------------------------- + // getMissingDeferredIndexStatements + // ------------------------------------------------------------------------- + + /** getMissingDeferredIndexStatements should return SQL for unbuilt deferred indexes. */ + @Test + public void testGetMissingDeferredIndexStatements() { + // given + mockSchemaResourceWithDeferredIndex("TestTable", "TestIdx", "col1", "col2"); + when(sqlDialect.deferredIndexDeploymentStatements(any(Table.class), any(Index.class))) + .thenReturn(List.of("CREATE INDEX TestIdx ON TestTable(col1, col2)")); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + List statements = executor.getMissingDeferredIndexStatements(); + + // then + assertEquals(1, statements.size()); + assertEquals("CREATE INDEX TestIdx ON TestTable(col1, col2)", statements.get(0)); + } + + + /** getMissingDeferredIndexStatements should return empty when disabled. */ + @Test + public void testGetMissingDeferredIndexStatementsDisabled() { + // given + config.setDeferredIndexCreationEnabled(false); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + List statements = executor.getMissingDeferredIndexStatements(); + + // then + assertTrue("Should return empty list when disabled", statements.isEmpty()); + } + + + /** getMissingDeferredIndexStatements should return empty when no deferred indexes. */ + @Test + public void testGetMissingDeferredIndexStatementsNone() { + // given + SchemaResource sr = mockSchemaResource(); + when(connectionResources.openSchemaResource()).thenReturn(sr); + + // when + DeferredIndexExecutorImpl executor = createExecutor(); + List statements = executor.getMissingDeferredIndexStatements(); + + // then + assertTrue("Should return empty list", statements.isEmpty()); + } + + + // ------------------------------------------------------------------------- + // Config validation + // ------------------------------------------------------------------------- + + /** threadPoolSize less than 1 should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidThreadPoolSize() { + // given + config.setDeferredIndexThreadPoolSize(0); + SchemaResource sr = mockSchemaResourceWithDeferredIndex("T", "Idx", "c1", "c2"); + when(connectionResources.openSchemaResource()).thenReturn(sr); + + // when -- should throw + createExecutor().execute(); + } + + + /** maxRetries less than 0 should be rejected. */ + @Test(expected = IllegalArgumentException.class) + public void testInvalidMaxRetries() { + // given + config.setDeferredIndexMaxRetries(-1); + SchemaResource sr = mockSchemaResourceWithDeferredIndex("T", "Idx", "c1", "c2"); + when(connectionResources.openSchemaResource()).thenReturn(sr); + + // when -- should throw + createExecutor().execute(); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private DeferredIndexExecutorImpl createExecutor() { + return new DeferredIndexExecutorImpl(connectionResources, sqlScriptExecutorProvider, + config, new DeferredIndexExecutorServiceFactory.Default()); + } + + + /** + * Creates a mock SchemaResource with no tables. + */ + private SchemaResource mockSchemaResource() { + SchemaResource sr = mock(SchemaResource.class); + when(sr.tables()).thenReturn(Collections.emptyList()); + when(sr.tableNames()).thenReturn(Collections.emptySet()); + return sr; + } + + + /** + * Creates a mock SchemaResource with a single table containing only + * non-deferred indexes. + */ + private SchemaResource mockSchemaResource(Table table) { + SchemaResource sr = mock(SchemaResource.class); + when(sr.tables()).thenReturn(List.of(table)); + when(sr.tableNames()).thenReturn(Set.of(table.getName())); + when(sr.tableExists(table.getName())).thenReturn(true); + when(sr.getTable(table.getName())).thenReturn(table); + return sr; + } + + + /** + * Creates a mock SchemaResource with a single table that has one + * deferred index. The deferred index has {@code isDeferred()=true}. + * The table name lookups are set up for the targeted scan. + */ + private SchemaResource mockSchemaResourceWithDeferredIndex(String tableName, String indexName, + String col1, String col2) { + Index deferredIndex = mock(Index.class); + when(deferredIndex.getName()).thenReturn(indexName); + when(deferredIndex.isDeferred()).thenReturn(true); + when(deferredIndex.isUnique()).thenReturn(false); + when(deferredIndex.columnNames()).thenReturn(List.of(col1, col2)); + + Table table = mock(Table.class); + when(table.getName()).thenReturn(tableName); + when(table.indexes()).thenReturn(List.of(deferredIndex)); + + SchemaResource sr = mock(SchemaResource.class); + when(sr.tables()).thenReturn(List.of(table)); + when(sr.tableNames()).thenReturn(Set.of(tableName)); + when(sr.tableExists(tableName)).thenReturn(true); + when(sr.getTable(tableName)).thenReturn(table); + + // indexExistsPhysically now uses JDBC DatabaseMetaData (not SchemaResource), + // and the default mock returns an empty ResultSet (no physical indexes). + when(connectionResources.openSchemaResource()).thenReturn(sr); + + return sr; + } +} 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..1be8137fa --- /dev/null +++ b/morf-core/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexServiceImpl.java @@ -0,0 +1,171 @@ +/* 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.List; +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() { + // given + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + + // when + service.execute(); + + // then + verify(mockExecutor).execute(); + } + + + // ------------------------------------------------------------------------- + // awaitCompletion() orchestration + // ------------------------------------------------------------------------- + + /** awaitCompletion() should throw when execute() has not been called. */ + @Test(expected = IllegalStateException.class) + public void testAwaitCompletionThrowsWhenNoExecution() { + // given + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mock(DeferredIndexExecutor.class)); + + // when -- should throw + service.awaitCompletion(60L); + } + + + /** awaitCompletion() should return true when the future is already done. */ + @Test + public void testAwaitCompletionReturnsTrueWhenFutureDone() { + // given + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(CompletableFuture.completedFuture(null)); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + service.execute(); + + // when / then + 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() { + // given -- future that never completes + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(new CompletableFuture<>()); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + service.execute(); + + // when / then + 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 { + // given -- future that never completes + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.execute()).thenReturn(new CompletableFuture<>()); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + service.execute(); + + // when -- interrupt the waiting thread + 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); + + // then + assertFalse("Should return false when interrupted", result.get()); + } + + + /** awaitCompletion() with zero timeout should wait indefinitely until done. */ + @Test + public void testAwaitCompletionZeroTimeoutWaitsUntilDone() { + // given + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + CompletableFuture future = new CompletableFuture<>(); + when(mockExecutor.execute()).thenReturn(future); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + service.execute(); + + // when -- complete the future from another thread after await starts + CountDownLatch enteredAwait = new CountDownLatch(1); + new Thread(() -> { + try { enteredAwait.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + future.complete(null); + }).start(); + enteredAwait.countDown(); + + // then + assertTrue("Should return true once done", service.awaitCompletion(0L)); + } + + + // ------------------------------------------------------------------------- + // getMissingDeferredIndexStatements() + // ------------------------------------------------------------------------- + + /** getMissingDeferredIndexStatements() should delegate to the executor. */ + @Test + public void testGetMissingDeferredIndexStatementsDelegatesToExecutor() { + // given + DeferredIndexExecutor mockExecutor = mock(DeferredIndexExecutor.class); + when(mockExecutor.getMissingDeferredIndexStatements()) + .thenReturn(List.of("CREATE INDEX idx ON t(c)")); + DeferredIndexServiceImpl service = new DeferredIndexServiceImpl(mockExecutor); + + // when + List result = service.getMissingDeferredIndexStatements(); + + // then + assertEquals(1, result.size()); + assertEquals("CREATE INDEX idx ON t(c)", result.get(0)); + } +} 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..0534a7bb8 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 @@ -3,16 +3,15 @@ import static org.junit.Assert.assertFalse; 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 org.alfasoftware.morf.upgrade.DataEditor; import org.alfasoftware.morf.upgrade.SchemaEditor; import org.alfasoftware.morf.upgrade.UpgradeStep; 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 +42,5 @@ public void testRecreateOracleSequences() { verifyNoInteractions(schema); } + } \ 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..1ff09cbae 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 @@ -25,6 +25,7 @@ import java.util.List; import java.util.Optional; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; import org.alfasoftware.morf.jdbc.DatabaseType; import org.alfasoftware.morf.jdbc.SqlDialect; import org.alfasoftware.morf.metadata.Column; @@ -693,4 +694,34 @@ 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; + } + + + @Override + public Collection generateTableCommentStatements(Table table, List deferredIndexes) { + String comment = REAL_NAME_COMMENT_LABEL + ":[" + table.getName() + "]" + + DatabaseMetaDataProviderUtils.buildDeferredIndexCommentSegments(deferredIndexes); + return List.of("COMMENT ON TABLE " + schemaNamePrefix() + table.getName() + " IS '" + comment + "'"); + } + + + @Override + public String findTablesWithDeferredIndexesSql() { + return "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES" + + " WHERE TABLE_SCHEMA = 'PUBLIC' AND REMARKS LIKE '%/DEFERRED:%'"; + } } \ No newline at end of file diff --git a/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java b/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java index 6dc699a4b..cd9d846da 100755 --- a/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java +++ b/morf-h2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java @@ -20,9 +20,16 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.alfasoftware.morf.jdbc.DatabaseMetaDataProvider; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaUtils; import org.alfasoftware.morf.metadata.SchemaUtils.ColumnBuilder; +import org.alfasoftware.morf.metadata.Table; /** * Database meta-data layer for H2. @@ -31,7 +38,11 @@ */ class H2MetaDataProvider extends DatabaseMetaDataProvider { - /** + /** Stores raw table comments keyed by uppercase table name, for deferred index parsing. */ + private final Map tableComments = new HashMap<>(); + + + /** * @param connection DataSource to provide meta data for. */ public H2MetaDataProvider(Connection connection) { @@ -39,6 +50,29 @@ public H2MetaDataProvider(Connection connection) { } + @Override + protected RealName readTableName(ResultSet tableResultSet) throws SQLException { + String tableName = tableResultSet.getString(TABLE_NAME); + String comment = tableResultSet.getString(TABLE_REMARKS); + if (comment != null && !comment.isEmpty()) { + tableComments.put(tableName.toUpperCase(), comment); + } + return super.readTableName(tableResultSet); + } + + + @Override + protected Table loadTable(AName tableName) { + Table base = super.loadTable(tableName); + String comment = tableComments.get(base.getName().toUpperCase()); + List merged = DatabaseMetaDataProviderUtils.mergeDeferredIndexes(base.indexes(), comment); + if (merged == base.indexes()) { + return base; + } + return SchemaUtils.table(base.getName()).columns(base.columns()).indexes(merged); + } + + /** * H2 reports its primary key indexes as PRIMARY_KEY_49 or similar. * 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..2bdb332c2 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 @@ -25,6 +25,7 @@ import java.util.List; import java.util.Optional; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; import org.alfasoftware.morf.jdbc.DatabaseType; import org.alfasoftware.morf.jdbc.SqlDialect; import org.alfasoftware.morf.metadata.Column; @@ -709,4 +710,36 @@ 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; + } + + + @Override + public Collection generateTableCommentStatements(Table table, List deferredIndexes) { + String comment = REAL_NAME_COMMENT_LABEL + ":[" + table.getName() + "]" + + DatabaseMetaDataProviderUtils.buildDeferredIndexCommentSegments(deferredIndexes); + return List.of("COMMENT ON TABLE " + schemaNamePrefix() + table.getName() + " IS '" + comment + "'"); + } + + + @Override + public String findTablesWithDeferredIndexesSql() { + String prefix = schemaNamePrefix(); + String schema = prefix.isEmpty() ? "PUBLIC" : prefix.replace(".", ""); + return "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES" + + " WHERE TABLE_SCHEMA = '" + schema + "' AND REMARKS LIKE '%/DEFERRED:%'"; + } } \ No newline at end of file diff --git a/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java b/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java index 978007b41..0f641f2f8 100755 --- a/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java +++ b/morf-h2v2/src/main/java/org/alfasoftware/morf/jdbc/h2/H2MetaDataProvider.java @@ -20,10 +20,17 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Set; import org.alfasoftware.morf.jdbc.DatabaseMetaDataProvider; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; +import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaUtils; import org.alfasoftware.morf.metadata.SchemaUtils.ColumnBuilder; +import org.alfasoftware.morf.metadata.Table; /** * Database meta-data layer for H2. @@ -32,7 +39,11 @@ */ class H2MetaDataProvider extends DatabaseMetaDataProvider { - /** + /** Stores raw table comments keyed by uppercase table name, for deferred index parsing. */ + private final Map tableComments = new HashMap<>(); + + + /** * @param connection DataSource to provide meta data for. */ public H2MetaDataProvider(Connection connection) { @@ -47,6 +58,30 @@ public H2MetaDataProvider(Connection connection, String schemaName) { super(connection, schemaName); } + + @Override + protected RealName readTableName(ResultSet tableResultSet) throws SQLException { + String tableName = tableResultSet.getString(TABLE_NAME); + String comment = tableResultSet.getString(TABLE_REMARKS); + if (comment != null && !comment.isEmpty()) { + tableComments.put(tableName.toUpperCase(), comment); + } + return super.readTableName(tableResultSet); + } + + + @Override + protected Table loadTable(AName tableName) { + Table base = super.loadTable(tableName); + String comment = tableComments.get(base.getName().toUpperCase()); + List merged = DatabaseMetaDataProviderUtils.mergeDeferredIndexes(base.indexes(), comment); + if (merged == base.indexes()) { + return base; + } + return SchemaUtils.table(base.getName()).columns(base.columns()).indexes(merged); + } + + /** * H2 reports its primary key indexes as PRIMARY_KEY_49 or similar. * 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/DeferredIndexTestSupport.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexTestSupport.java new file mode 100644 index 000000000..d4dee9af1 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/DeferredIndexTestSupport.java @@ -0,0 +1,124 @@ +/* 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.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +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.upgrade.UpgradeConfigAndContext; + +/** + * Shared constants and assertion helpers for deferred index integration tests. + * + * @author Copyright (c) Alfa Financial Software Limited. 2026 + */ +final class DeferredIndexTestSupport { + + private DeferredIndexTestSupport() {} + + + /** Standard initial schema used across deferred index integration tests. */ + static final Schema INITIAL_SCHEMA = schema( + deployedViewsTable(), + upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ) + ); + + + /** Builds a target schema with a single index on Product.name. */ + static Schema schemaWithIndex(String indexName, String... columns) { + return schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index(indexName).columns(columns)) + ); + } + + + /** Creates a fresh executor with default test settings. */ + static DeferredIndexExecutor createExecutor(ConnectionResources connectionResources, + SqlScriptExecutorProvider sqlScriptExecutorProvider) { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexMaxRetries(0); + return new DeferredIndexExecutorImpl( + connectionResources, sqlScriptExecutorProvider, + config, new DeferredIndexExecutorServiceFactory.Default()); + } + + + /** Creates a fresh service with default test settings. */ + static DeferredIndexService createService(ConnectionResources connectionResources, + SqlScriptExecutorProvider sqlScriptExecutorProvider) { + return new DeferredIndexServiceImpl(createExecutor(connectionResources, sqlScriptExecutorProvider)); + } + + + /** Runs the executor synchronously, blocking until all deferred indexes are built. */ + static void executeDeferred(ConnectionResources connectionResources, + SqlScriptExecutorProvider sqlScriptExecutorProvider) { + createExecutor(connectionResources, sqlScriptExecutorProvider).execute().join(); + } + + + /** Asserts that a physical index exists in the database schema. */ + static void assertPhysicalIndexExists(ConnectionResources connectionResources, + String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Physical index " + indexName + " should exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + /** Asserts that a deferred index exists with isDeferred()=true. */ + static void assertDeferredIndexPending(ConnectionResources connectionResources, + String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Deferred index " + indexName + " should be present with isDeferred()=true on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()) && idx.isDeferred())); + } + } + + + /** Asserts that an index does not exist at all in the schema. */ + static void assertIndexNotPresent(ConnectionResources connectionResources, + String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertFalse("Index " + indexName + " should not be present 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/TestDeferredIndexIntegration.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexIntegration.java new file mode 100644 index 000000000..e5a44d30b --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexIntegration.java @@ -0,0 +1,885 @@ +/* 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.insert; +import static org.alfasoftware.morf.sql.SqlUtils.literal; +import static org.alfasoftware.morf.sql.SqlUtils.tableRef; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +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.deferred.upgrade.v1_0_0.AddDeferredIndex; +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.AddDeferredMultiColumnIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddDeferredUniqueIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddImmediateIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTableWithDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v1_0_0.AddTwoDeferredIndexes; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.ChangeDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.RemoveDeferredIndex; +import org.alfasoftware.morf.upgrade.deferred.upgrade.v2_0_0.RenameDeferredIndex; +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 using the + * comments-based model. Exercises the full upgrade framework path: upgrade + * step adds a deferred index (recorded in table comments), the executor + * scans for unbuilt deferred indexes, and builds them. + * + * @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(), + 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(); + } + + + /** + * After upgrade, the deferred index should appear in the schema as a + * virtual index (isDeferred=true) but not yet physically built. After + * the executor runs, the physical index should exist. + */ + @Test + public void testDeferredIndexLifecycle() { + // when -- upgrade step defers an index + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // then -- virtual index present + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * A deferred unique index should preserve the unique constraint + * through the full pipeline. + */ + @Test + public void testDeferredUniqueIndex() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 + executeDeferred(); + + // then + assertPhysicalIndexExists("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()); + } + } + + + /** + * A deferred multi-column index should preserve column ordering. + */ + @Test + public void testDeferredMultiColumnIndex() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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); + + // when + executeDeferred(); + + // then + 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")); + assertTrue("First column should be id", idx.columnNames().get(0).equalsIgnoreCase("id")); + assertTrue("Second column should be name", idx.columnNames().get(1).equalsIgnoreCase("name")); + } + } + + + /** + * Creating a new table and deferring an index on it in the same + * upgrade step should work end-to-end. + */ + @Test + public void testNewTableWithDeferredIndex() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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")) + ); + + // when + performUpgrade(targetSchema, AddTableWithDeferredIndex.class); + + // then -- deferred index present + assertDeferredIndexPending("Category", "Category_Label_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built + assertPhysicalIndexExists("Category", "Category_Label_1"); + } + + + /** + * Deferring an index on a table that already contains rows should + * build the index correctly over existing data. + */ + @Test + public void testDeferredIndexOnPopulatedTable() { + // given -- table with existing rows + insertProductRow(1L, "Widget"); + insertProductRow(2L, "Gadget"); + insertProductRow(3L, "Doohickey"); + + // when + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + executeDeferred(); + + // then + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * Deferring two indexes in a single upgrade step should queue both + * and the executor should build them both. + */ + @Test + public void testMultipleIndexesDeferredInOneStep() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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") + ) + ); + + // when + performUpgrade(targetSchema, AddTwoDeferredIndexes.class); + executeDeferred(); + + // then + assertPhysicalIndexExists("Product", "Product_Name_1"); + assertPhysicalIndexExists("Product", "Product_IdName_1"); + } + + + /** + * Running the executor a second time on an already-built set of + * indexes should be a safe no-op. + */ + @Test + public void testExecutorIdempotencyOnBuiltIndexes() { + // given -- deferred index already built + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + executeDeferred(); + assertPhysicalIndexExists("Product", "Product_Name_1"); + + // when -- second run + executeDeferred(); + + // then -- still exists, no error + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * When forceImmediateIndexes is configured for an index name, the + * deferred index should be built immediately during the upgrade step + * (not deferred to the executor). + */ + @Test + public void testForceImmediateIndexBypassesDeferral() { + // given + upgradeConfigAndContext.setForceImmediateIndexes(Set.of("Product_Name_1")); + try { + // when + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // then -- index built immediately, no executor needed + assertPhysicalIndexExists("Product", "Product_Name_1"); + } finally { + upgradeConfigAndContext.setForceImmediateIndexes(Set.of()); + } + } + + + /** + * When forceDeferredIndexes is configured for an index name, addIndex() + * should defer the index instead of building it immediately. + */ + @Test + public void testForceDeferredIndexOverridesImmediateCreation() { + // given + upgradeConfigAndContext.setForceDeferredIndexes(Set.of("Product_Name_1")); + try { + // when -- addIndex() with force-deferred override + performUpgrade(schemaWithIndex(), AddImmediateIndex.class); + + // then -- index deferred, not built yet + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built + assertPhysicalIndexExists("Product", "Product_Name_1"); + } finally { + upgradeConfigAndContext.setForceDeferredIndexes(Set.of()); + } + } + + + /** + * When deferredIndexCreationEnabled is false (the default), a deferred + * index should be built immediately during the upgrade step. + */ + @Test + public void testDisabledFeatureBuildsDeferredIndexImmediately() { + // given -- kill switch disabled (default) + UpgradeConfigAndContext disabledConfig = new UpgradeConfigAndContext(); + + // when + Upgrade.performUpgrade(schemaWithIndex(), Collections.singletonList(AddDeferredIndex.class), + connectionResources, disabledConfig, viewDeploymentValidator); + + // then -- index built immediately + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * When the dialect does not support deferred index creation, + * the deferred index should be built immediately. + */ + @Test + public void testUnsupportedDialectFallsBackToImmediateIndex() { + // given -- dialect that does not support deferred creation + 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); + + // when + Upgrade.performUpgrade(schemaWithIndex(), Collections.singletonList(AddDeferredIndex.class), + spyConn, upgradeConfigAndContext, viewDeploymentValidator); + + // then -- index built immediately during upgrade + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + // ========================================================================= + // Same-step operations: add deferred then modify in the same upgrade step + // ========================================================================= + + /** + * Add a deferred index then remove it in the same step. After upgrade, + * neither the physical index nor a deferred declaration should exist. + */ + @Test + public void testAddDeferredThenRemoveInSameStep() { + // when -- add deferred then remove in same step + performUpgrade(INITIAL_SCHEMA, AddDeferredIndexThenRemove.class); + + // then + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + /** + * Add a deferred index then change it to a non-deferred index in the same + * step. The new index should be built immediately during upgrade. + */ + @Test + public void testAddDeferredThenChangeInSameStep() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_2").columns("name")) + ); + + // when -- add deferred then change to non-deferred in same step + performUpgrade(targetSchema, AddDeferredIndexThenChange.class); + + // then -- changed index built immediately, original gone + assertPhysicalIndexExists("Product", "Product_Name_2"); + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + /** + * Add a deferred index then rename it in the same step. The renamed index + * should still be deferred and buildable by the executor. + */ + @Test + public void testAddDeferredThenRenameInSameStep() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_Renamed").columns("name")) + ); + + // when -- add deferred then rename in same step + performUpgrade(targetSchema, AddDeferredIndexThenRename.class); + + // then -- renamed index still deferred + assertDeferredIndexPending("Product", "Product_Name_Renamed"); + assertIndexNotPresent("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built with renamed name + assertPhysicalIndexExists("Product", "Product_Name_Renamed"); + } + + + // ========================================================================= + // Cross-step operations: deferred index NOT YET BUILT, modified in later step + // ("change the plan" — no force-build needed, comment is simply updated) + // ========================================================================= + + /** + * Step A defers an index. Before the executor runs, step B removes it. + * The deferred index is never built — the comment is cleaned up. + */ + @Test + public void testRemoveUnbuiltDeferredIndexInLaterStep() { + // given -- step A defers an index + performUpgradeSteps(schemaWithIndex(), AddDeferredIndex.class); + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- step B removes it before executor runs + performUpgradeSteps(INITIAL_SCHEMA, AddDeferredIndex.class, RemoveDeferredIndex.class); + + // then -- index never built + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + /** + * Step A defers an index. Before the executor runs, step B changes it + * to a non-deferred multi-column index. The old deferred definition is + * never built — the "plan" changes from comment-only to immediate CREATE INDEX. + */ + @Test + public void testChangeUnbuiltDeferredIndexInLaterStep() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_1").columns("id", "name")) + ); + + // when -- step B changes the unbuilt deferred index to non-deferred + performUpgradeSteps(targetSchema, AddDeferredIndex.class, ChangeDeferredIndex.class); + + // then -- changed index built immediately + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * Step A defers an index. Before the executor runs, step B renames it. + * The renamed deferred index is built by the executor under the new name. + */ + @Test + public void testRenameUnbuiltDeferredIndexInLaterStep() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_Renamed").columns("name")) + ); + + // when -- step B renames the unbuilt deferred index + performUpgradeSteps(targetSchema, AddDeferredIndex.class, RenameDeferredIndex.class); + + // then -- renamed deferred index present + assertDeferredIndexPending("Product", "Product_Name_Renamed"); + assertIndexNotPresent("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built with new name + assertPhysicalIndexExists("Product", "Product_Name_Renamed"); + } + + + // ========================================================================= + // Cross-step operations: deferred index ALREADY BUILT, modified in later step + // ========================================================================= + + /** + * Step A defers an index. Executor builds it. Step B removes it. + * The physical index should be dropped. + */ + @Test + public void testRemoveBuiltDeferredIndexInLaterStep() { + // given -- deferred index already built + performUpgradeSteps(schemaWithIndex(), AddDeferredIndex.class); + executeDeferred(); + assertPhysicalIndexExists("Product", "Product_Name_1"); + + // when -- step B removes it + performUpgradeSteps(INITIAL_SCHEMA, AddDeferredIndex.class, RemoveDeferredIndex.class); + + // then + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + /** + * Step A defers an index. Executor builds it. Step B renames it. + * The physical index should be renamed. + */ + @Test + public void testRenameBuiltDeferredIndexInLaterStep() { + // given -- deferred index already built + Schema renamedSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Name_Renamed").columns("name")) + ); + performUpgradeSteps(schemaWithIndex(), AddDeferredIndex.class); + executeDeferred(); + assertPhysicalIndexExists("Product", "Product_Name_1"); + + // when -- step B renames it + performUpgradeSteps(renamedSchema, AddDeferredIndex.class, RenameDeferredIndex.class); + + // then + assertPhysicalIndexExists("Product", "Product_Name_Renamed"); + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + // ========================================================================= + // 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 comment should be updated with the new column name, + * and the executor should build the index using the renamed column. + */ + @Test + public void testCrossStepColumnRenameUpdatesDeferredIndex() { + // given -- target schema with column renamed from "name" to "label" + Schema renamedColSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 -- deferred index references new column + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built using renamed column + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * Step A defers an index on column "name". Step B removes the index and + * column "name". The deferred index should be cleaned up — no ghost + * entries in the comment, and the column removal should not trip over + * stale deferred index declarations. + */ + @Test + public void testCrossStepColumnRemovalCleansDeferredIndex() { + // given + Schema noNameColSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 + assertIndexNotPresent("Product", "Product_Name_1"); + } + + + /** + * Step A defers an index on table "Product". Step B renames table to "Item". + * The deferred index comment should migrate to the new table and the executor + * should build the index under the new table name. + */ + @Test + public void testCrossStepTableRenamePreservesDeferredIndex() { + // given + Schema renamedTableSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 -- deferred index migrates to new table + assertDeferredIndexPending("Item", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- physical index built on renamed table + assertPhysicalIndexExists("Item", "Product_Name_1"); + } + + + // ========================================================================= + // Additional edge cases + // ========================================================================= + + /** + * Adding a deferred index to a table that already has a non-deferred index + * should preserve the existing index. + */ + @Test + public void testDeferredIndexOnTableWithExistingIndex() { + // given -- table already has a non-deferred index + Schema schemaWithExisting = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes(index("Product_Id_1").columns("id", "name")) + ); + schemaManager.mutateToSupportSchema(schemaWithExisting, DatabaseSchemaManager.TruncationBehavior.ALWAYS); + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Id_1").columns("id", "name"), + index("Product_Name_1").columns("name") + ) + ); + + // when -- add deferred index to same table + performUpgrade(targetSchema, AddDeferredIndex.class); + + // then -- existing index preserved, deferred index pending + assertPhysicalIndexExists("Product", "Product_Id_1"); + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- both indexes present + assertPhysicalIndexExists("Product", "Product_Id_1"); + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + /** + * getMissingDeferredIndexStatements should return valid SQL for unbuilt deferred indexes. + */ + @Test + public void testGetMissingDeferredIndexStatements() { + // given -- one unbuilt deferred index + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + connectionResources, sqlScriptExecutorProvider, config, + new DeferredIndexExecutorServiceFactory.Default()); + + // when + java.util.List statements = executor.getMissingDeferredIndexStatements(); + + // then + assertFalse("Should return at least one statement", statements.isEmpty()); + assertTrue("Statement should reference the index name", + statements.stream().anyMatch(s -> s.toUpperCase().contains("PRODUCT_NAME_1"))); + } + + + /** + * Deferred indexes on multiple tables should all be found and built by the executor. + */ + @Test + public void testDeferredIndexesOnMultipleTables() { + // given -- deferred indexes on two different tables + Schema multiTableSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 deferred + assertDeferredIndexPending("Product", "Product_Name_1"); + assertDeferredIndexPending("Category", "Category_Label_1"); + + // when -- execute deferred + executeDeferred(); + + // then -- both physical indexes built + assertPhysicalIndexExists("Product", "Product_Name_1"); + assertPhysicalIndexExists("Category", "Category_Label_1"); + } + + + /** + * A deferred unique index on a table with duplicate data should fail gracefully. + */ + @Test + public void testDeferredUniqueIndexWithDuplicateDataFailsGracefully() { + // given -- table with duplicate values in the indexed column + insertProductRow(1L, "Widget"); + insertProductRow(2L, "Widget"); + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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 -- index not built, deferred declaration remains + assertDeferredIndexPending("Product", "Product_Name_UQ"); + } + + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void performUpgrade(Schema targetSchema, Class upgradeStep) { + Upgrade.performUpgrade(targetSchema, Collections.singletonList(upgradeStep), + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + } + + + @SafeVarargs + private void performUpgradeSteps(Schema targetSchema, Class... upgradeSteps) { + List> steps = Arrays.asList(upgradeSteps); + Upgrade.performUpgrade(targetSchema, steps, + connectionResources, upgradeConfigAndContext, viewDeploymentValidator); + } + + + private void executeDeferred() { + UpgradeConfigAndContext config = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexMaxRetries(0); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + connectionResources, sqlScriptExecutorProvider, config, + new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + } + + + private Schema schemaWithIndex() { + return schema( + deployedViewsTable(), + upgradeAuditTable(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name") + ) + ); + } + + + private void assertPhysicalIndexExists(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Physical index " + indexName + " should exist on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()))); + } + } + + + private void assertDeferredIndexPending(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Deferred index " + indexName + " should be present with isDeferred()=true on " + tableName, + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()) && idx.isDeferred())); + } + } + + + private void assertIndexNotPresent(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertFalse("Index " + indexName + " should not be present 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")) + ) + ); + } +} 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..39fb5b74b --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexLifecycle.java @@ -0,0 +1,277 @@ +/* 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.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; + +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 using the comments-based model. + * + *

In the comments-based model, deferred indexes are declared in table + * comments and the MetaDataProvider includes them as virtual indexes with + * {@code isDeferred()=true}. After the executor physically builds them, + * the physical index takes precedence and {@code isDeferred()} returns + * {@code false}.

+ * + * @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(), + 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() { + // when -- upgrade defers index + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + + // then -- deferred + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute + executeDeferred(); + + // then -- built + assertPhysicalIndexExists("Product", "Product_Name_1"); + + // when -- restart (same steps, nothing new) + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + // then -- should pass without error + } + + + // ========================================================================= + // No-upgrade restart -- pending indexes left for execute() + // ========================================================================= + + /** No-upgrade restart with pending deferred indexes should pass. */ + @Test + public void testNoUpgradeRestart_pendingIndexesVisible() { + // given -- upgrade defers index + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- restart with same schema (no new upgrade steps) + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + + // then -- index still deferred + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- execute + executeDeferred(); + + // then -- built + assertPhysicalIndexExists("Product", "Product_Name_1"); + } + + + // ========================================================================= + // Two sequential upgrades + // ========================================================================= + + /** Two upgrades, both executed -- third restart passes. */ + @Test + public void testTwoSequentialUpgrades() { + // when -- first upgrade, execute + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + executeDeferred(); + + // then + assertPhysicalIndexExists("Product", "Product_Name_1"); + + // when -- second upgrade adds another deferred index, execute + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + executeDeferred(); + + // then + assertPhysicalIndexExists("Product", "Product_IdName_1"); + + // when -- third restart + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + // then -- should pass without error + } + + + /** Two upgrades, first index not built -- first still deferred, second also deferred. */ + @Test + public void testTwoUpgrades_firstIndexNotBuilt() { + // given -- first upgrade defers index, not executed + performUpgrade(schemaWithFirstIndex(), AddDeferredIndex.class); + assertDeferredIndexPending("Product", "Product_Name_1"); + + // when -- second upgrade adds another deferred index + performUpgradeWithSteps(schemaWithBothIndexes(), + List.of(AddDeferredIndex.class, AddSecondDeferredIndex.class)); + + // when -- execute builds both + executeDeferred(); + + // then + assertPhysicalIndexExists("Product", "Product_Name_1"); + assertPhysicalIndexExists("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.setDeferredIndexMaxRetries(1); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + connectionResources, sqlScriptExecutorProvider, + config, new DeferredIndexExecutorServiceFactory.Default()); + executor.execute().join(); + } + + + private Schema schemaWithFirstIndex() { + return schema( + deployedViewsTable(), upgradeAuditTable(), + 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(), + 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 void assertDeferredIndexPending(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Deferred index " + indexName + " should be present with isDeferred()=true", + sr.getTable(tableName).indexes().stream() + .anyMatch(idx -> indexName.equalsIgnoreCase(idx.getName()) && idx.isDeferred())); + } + } + + + private void assertPhysicalIndexExists(String tableName, String indexName) { + try (SchemaResource sr = connectionResources.openSchemaResource()) { + assertTrue("Physical index " + indexName + " should 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/TestDeferredIndexService.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexService.java new file mode 100644 index 000000000..89bcdafed --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/TestDeferredIndexService.java @@ -0,0 +1,228 @@ +/* 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.upgrade.db.DatabaseUpgradeTableContribution.deployedViewsTable; +import static org.alfasoftware.morf.upgrade.db.DatabaseUpgradeTableContribution.upgradeAuditTable; +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 declares deferred + * indexes via table comments, then the service scans for unbuilt deferred + * indexes and builds them. + * + * @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(), + 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() builds the deferred index and it exists in the schema. */ + @Test + public void testExecuteBuildsIndexEndToEnd() { + // given + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + + // when + DeferredIndexService service = createService(); + service.execute(); + service.awaitCompletion(60L); + + // then + assertIndexExists("Product", "Product_Name_1"); + } + + + /** Verify that execute() handles multiple deferred indexes in a single run. */ + @Test + public void testExecuteBuildsMultipleIndexes() { + // given + Schema targetSchema = schema( + deployedViewsTable(), upgradeAuditTable(), + 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); + + // when + DeferredIndexService service = createService(); + service.execute(); + service.awaitCompletion(60L); + + // then + assertIndexExists("Product", "Product_Name_1"); + assertIndexExists("Product", "Product_IdName_1"); + } + + + /** Verify that execute() with no deferred indexes completes immediately. */ + @Test + public void testExecuteWithEmptySchema() { + // when + DeferredIndexService service = createService(); + service.execute(); + + // then + assertTrue("Should complete immediately on empty schema", service.awaitCompletion(5L)); + } + + + /** Verify that awaitCompletion() throws when called before execute(). */ + @Test(expected = IllegalStateException.class) + public void testAwaitCompletionThrowsWhenNoExecution() { + // given + DeferredIndexService service = createService(); + + // when -- should throw + service.awaitCompletion(5L); + } + + + /** Verify that execute() is idempotent -- second run is a safe no-op. */ + @Test + public void testExecuteIdempotent() { + // given -- deferred index already built + performUpgrade(schemaWithIndex(), AddDeferredIndex.class); + DeferredIndexService service = createService(); + service.execute(); + service.awaitCompletion(60L); + assertIndexExists("Product", "Product_Name_1"); + + // when -- second execute on a fresh service + DeferredIndexService service2 = createService(); + service2.execute(); + service2.awaitCompletion(60L); + + // then -- still exists, no error + assertIndexExists("Product", "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(), + table("Product").columns( + column("id", DataType.BIG_INTEGER).primaryKey(), + column("name", DataType.STRING, 100) + ).indexes( + index("Product_Name_1").columns("name") + ) + ); + } + + + 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 = new UpgradeConfigAndContext(); + config.setDeferredIndexCreationEnabled(true); + config.setDeferredIndexMaxRetries(0); + DeferredIndexExecutor executor = new DeferredIndexExecutorImpl( + connectionResources, sqlScriptExecutorProvider, config, + new DeferredIndexExecutorServiceFactory.Default()); + return new DeferredIndexServiceImpl(executor); + } +} 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..6664affa6 --- /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.addIndex("Product", index("Product_Name_1").deferred().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..8ea6a656a --- /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.addIndex("Product", index("Product_Name_1").deferred().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..fc7b26bec --- /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.addIndex("Product", index("Product_Name_1").deferred().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..42550533f --- /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.addIndex("Product", index("Product_Name_1").deferred().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/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..029d65c5c --- /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.addIndex("Product", index("Product_IdName_1").deferred().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..f9f64c9ed --- /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.addIndex("Product", index("Product_Name_UQ").deferred().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..4cf66dd9e --- /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.addIndex("Category", index("Category_Label_1").deferred().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..c808353c7 --- /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.addIndex("Product", index("Product_Name_1").deferred().columns("name")); + schema.addIndex("Product", index("Product_IdName_1").deferred().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..fbca58d67 --- /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.addIndex("Product", index("Product_IdName_1").deferred().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/ChangeDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/ChangeDeferredIndex.java new file mode 100644 index 000000000..435c5726f --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/ChangeDeferredIndex.java @@ -0,0 +1,45 @@ +/* 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; + +/** + * Changes the deferred index created by AddDeferredIndex to a + * non-deferred multi-column index. Tests the "change the plan" path: + * if the original deferred index hasn't been built, the comment is + * updated and no wasted physical build occurs. + */ +@Sequence(90013) +@UUID("d1f00002-0002-0002-0002-000000000013") +public class ChangeDeferredIndex implements UpgradeStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.changeIndex("Product", + index("Product_Name_1").columns("name"), + index("Product_Name_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..5c945c915 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveColumnWithDeferredIndex.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.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. The removeColumn triggers cleanup of deferred index + * declarations referencing the removed column. + */ +@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/RemoveDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveDeferredIndex.java new file mode 100644 index 000000000..871cd0082 --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RemoveDeferredIndex.java @@ -0,0 +1,40 @@ +/* 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; + +/** + * Removes the deferred index created by AddDeferredIndex. + */ +@Sequence(90012) +@UUID("d1f00002-0002-0002-0002-000000000012") +public class RemoveDeferredIndex implements UpgradeStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.removeIndex("Product", index("Product_Name_1").columns("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/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/RenameDeferredIndex.java b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameDeferredIndex.java new file mode 100644 index 000000000..af00aa11e --- /dev/null +++ b/morf-integration-test/src/test/java/org/alfasoftware/morf/upgrade/deferred/upgrade/v2_0_0/RenameDeferredIndex.java @@ -0,0 +1,38 @@ +/* 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 the deferred index created by AddDeferredIndex. + */ +@Sequence(90014) +@UUID("d1f00002-0002-0002-0002-000000000014") +public class RenameDeferredIndex implements UpgradeStep { + + @Override + public void execute(SchemaEditor schema, DataEditor data) { + schema.renameIndex("Product", "Product_Name_1", "Product_Name_Renamed"); + } + + @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/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..a6167deac 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 @@ -35,6 +35,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; import org.alfasoftware.morf.jdbc.DatabaseType; import org.alfasoftware.morf.jdbc.NamedParameterPreparedStatement; import org.alfasoftware.morf.jdbc.SqlDialect; @@ -402,6 +403,15 @@ private String commentOnTable(String truncatedTableName) { return "COMMENT ON TABLE " + schemaNamePrefix() + truncatedTableName + " IS '"+REAL_NAME_COMMENT_LABEL+":[" + truncatedTableName + "]'"; } + + @Override + public Collection generateTableCommentStatements(Table table, List deferredIndexes) { + String comment = REAL_NAME_COMMENT_LABEL + ":[" + table.getName() + "]" + + DatabaseMetaDataProviderUtils.buildDeferredIndexCommentSegments(deferredIndexes); + return Arrays.asList("COMMENT ON TABLE " + schemaNamePrefix() + table.getName() + " IS '" + comment + "'"); + } + + private String disableParallelAndEnableLoggingForPrimaryKey(Table table) { return "ALTER INDEX " + schemaNamePrefix() + primaryKeyConstraintName(table.getName()) + " NOPARALLEL LOGGING"; } @@ -905,7 +915,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 +926,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 +946,35 @@ 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) + ); + } + + + @Override + public String findTablesWithDeferredIndexesSql() { + String owner = schemaNamePrefix().replace(".", ""); + return "SELECT table_name FROM ALL_TAB_COMMENTS" + + " WHERE owner = '" + owner + "' AND comments LIKE '%/DEFERRED:%'"; + } + + /** * @see org.alfasoftware.morf.jdbc.SqlDialect#alterTableAddColumnStatements(org.alfasoftware.morf.metadata.Table, org.alfasoftware.morf.metadata.Column) */ @@ -1202,6 +1217,24 @@ public Collection indexDropStatements(Table table, Index indexToBeRemove } + @Override + public Collection indexDropStatementsIfExists(Table table, Index indexToBeRemoved) { + String indexName = schemaNamePrefix() + indexToBeRemoved.getName(); + return Arrays.asList( + "BEGIN EXECUTE IMMEDIATE 'DROP INDEX " + indexName + "'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END;" + ); + } + + + @Override + public Collection renameIndexStatementsIfExists(Table table, String fromIndexName, String toIndexName) { + String fullName = schemaNamePrefix() + fromIndexName; + return Arrays.asList( + "BEGIN EXECUTE IMMEDIATE 'ALTER INDEX " + fullName + " RENAME TO " + toIndexName + "'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END;" + ); + } + + /** * @see org.alfasoftware.morf.jdbc.SqlDialect#getSqlForYYYYMMDDToDate(org.alfasoftware.morf.sql.element.Function) */ diff --git a/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleMetaDataProvider.java b/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleMetaDataProvider.java index 817145484..aea053f8a 100755 --- a/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleMetaDataProvider.java +++ b/morf-oracle/src/main/java/org/alfasoftware/morf/jdbc/oracle/OracleMetaDataProvider.java @@ -82,6 +82,9 @@ public class OracleMetaDataProvider implements AdditionalMetadata { private Map viewMap; private Map sequenceMap; + /** Stores raw table comments keyed by table name, for deferred index parsing. */ + private final Map tableComments = new HashMap<>(); + private final Connection connection; private final String schemaName; private Map primaryKeyIndexNames; @@ -249,6 +252,7 @@ private void handleTableColumnRow(final Map> primaryKeys, String commentType = null; if (tableComment != null) { + tableComments.put(tableName.toUpperCase(), tableComment); Matcher matcher = realnameCommentMatcher.matcher(tableComment); if (matcher.matches()) { String tableNameFromComment = matcher.group(1); @@ -525,6 +529,16 @@ private String getColumnCorrectCase(Table currentTable, String columnName) { long end = System.currentTimeMillis(); if (log.isDebugEnabled()) log.debug(String.format("Loaded index column list in %dms", end - pointThree)); + // Merge deferred indexes declared in table comments + for (Entry entry : tableMap.entrySet()) { + String comment = tableComments.get(entry.getKey().toUpperCase()); + Table table = entry.getValue(); + List merged = DatabaseMetaDataProviderUtils.mergeDeferredIndexes(table.indexes(), comment); + if (merged != table.indexes()) { + entry.setValue(SchemaUtils.table(table.getName()).columns(table.columns()).indexes(merged)); + } + } + log.info(String.format("Read table metadata in %dms; %d tables", end - start, tableMap.size())); } 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..eca6222cd 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#expectedDeferredIndexDeploymentStatementsOnSingleColumn() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsOnSingleColumn() { + 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#expectedDeferredIndexDeploymentStatementsOnMultipleColumns() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsOnMultipleColumns() { + 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#expectedDeferredIndexDeploymentStatementsUnique() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsUnique() { + 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..fb376bda7 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 @@ -17,6 +17,7 @@ import java.util.StringJoiner; import org.alfasoftware.morf.jdbc.DatabaseMetaDataProvider; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; import org.alfasoftware.morf.jdbc.DatabaseType; import org.alfasoftware.morf.jdbc.NamedParameterPreparedStatement; import org.alfasoftware.morf.jdbc.SqlDialect; @@ -435,6 +436,14 @@ private String addTableComment(Table table) { } + @Override + public Collection generateTableCommentStatements(Table table, List deferredIndexes) { + String comment = REAL_NAME_COMMENT_LABEL + ":[" + table.getName() + "]" + + DatabaseMetaDataProviderUtils.buildDeferredIndexCommentSegments(deferredIndexes); + return List.of("COMMENT ON TABLE " + schemaNamePrefix(table) + table.getName() + " IS '" + comment + "'"); + } + + @Override public Collection renameTableStatements(Table from, Table to) { Iterable renameTable = ImmutableList.of("ALTER TABLE " + schemaNamePrefix(from) + from.getName() + " RENAME TO " + to.getName()); @@ -466,6 +475,15 @@ public Collection renameIndexStatements(Table table, String fromIndexNam } + @Override + public Collection renameIndexStatementsIfExists(Table table, String fromIndexName, String toIndexName) { + return ImmutableList.builder() + .addAll(super.renameIndexStatementsIfExists(table, fromIndexName, toIndexName)) + .add(addIndexComment(toIndexName)) + .build(); + } + + private Collection renameSequenceStatements(String fromSeqName, String toSeqName) { return ImmutableList.of(String.format("ALTER SEQUENCE %s RENAME TO %s", fromSeqName, toSeqName)); } @@ -872,30 +890,87 @@ 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 statement.toString(); + } - return ImmutableList.builder() - .add(statement.toString()) - .add(addIndexComment(index.getName())) - .build(); + + @Override + public String findTablesWithDeferredIndexesSql() { + String schema = StringUtils.isNotBlank(getSchemaName()) + ? " AND n.nspname = '" + getSchemaName() + "'" + : ""; + return "SELECT c.relname FROM pg_description d" + + " JOIN pg_class c ON d.objoid = c.oid" + + " JOIN pg_namespace n ON n.oid = c.relnamespace" + schema + + " WHERE d.objsubid = 0 AND d.description LIKE '%/DEFERRED:%'"; } - private String addIndexComment(String indexName) { - return "COMMENT ON INDEX " + indexName + " IS '"+REAL_NAME_COMMENT_LABEL+":[" + indexName + "]'"; + @Override + public String checkInvalidIndexSql(String indexName) { + return "SELECT 1 FROM pg_index i" + + " JOIN pg_class c ON c.oid = i.indexrelid" + + " WHERE LOWER(c.relname) = LOWER('" + indexName + "') AND NOT i.indisvalid"; + } + + + @Override + public Collection dropInvalidIndexStatements(String indexName) { + return List.of("DROP INDEX CONCURRENTLY IF EXISTS " + indexName); } diff --git a/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLMetaDataProvider.java b/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLMetaDataProvider.java index 1295800d7..1941fc186 100644 --- a/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLMetaDataProvider.java +++ b/morf-postgresql/src/main/java/org/alfasoftware/morf/jdbc/postgresql/PostgreSQLMetaDataProvider.java @@ -9,6 +9,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.sql.Types; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -19,11 +20,15 @@ import java.util.regex.Pattern; import org.alfasoftware.morf.jdbc.DatabaseMetaDataProvider; +import org.alfasoftware.morf.jdbc.DatabaseMetaDataProviderUtils; import org.alfasoftware.morf.jdbc.RuntimeSqlException; import org.alfasoftware.morf.metadata.AdditionalMetadata; import org.alfasoftware.morf.metadata.DataType; +import org.alfasoftware.morf.metadata.Column; import org.alfasoftware.morf.metadata.Index; +import org.alfasoftware.morf.metadata.SchemaUtils; import org.alfasoftware.morf.metadata.SchemaUtils.ColumnBuilder; +import org.alfasoftware.morf.metadata.Table; import org.apache.commons.lang3.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -46,6 +51,9 @@ public class PostgreSQLMetaDataProvider extends DatabaseMetaDataProvider impleme private final Supplier>> allIgnoredIndexes = Suppliers.memoize(this::loadIgnoredIndexes); private final Set allIgnoredIndexesTables = new HashSet<>(); + /** Stores raw table comments keyed by uppercase table name, for deferred index parsing. */ + private final Map tableComments = new HashMap<>(); + public PostgreSQLMetaDataProvider(Connection connection, String schemaName) { super(connection, schemaName); } @@ -106,6 +114,9 @@ protected RealName readColumnName(ResultSet columnResultSet) throws SQLException protected RealName readTableName(ResultSet tableResultSet) throws SQLException { String tableName = tableResultSet.getString(TABLE_NAME); String comment = tableResultSet.getString(TABLE_REMARKS); + if (StringUtils.isNotBlank(comment)) { + tableComments.put(tableName.toUpperCase(), comment); + } String realName = matchComment(comment); return StringUtils.isNotBlank(realName) ? createRealName(tableName, realName) @@ -113,6 +124,18 @@ protected RealName readTableName(ResultSet tableResultSet) throws SQLException { } + @Override + protected Table loadTable(AName tableName) { + Table base = super.loadTable(tableName); + String comment = tableComments.get(base.getName().toUpperCase()); + List merged = DatabaseMetaDataProviderUtils.mergeDeferredIndexes(base.indexes(), comment); + if (merged == base.indexes()) { + return base; + } + return SchemaUtils.table(base.getName()).columns(base.columns()).indexes(merged); + } + + @Override protected RealName readViewName(ResultSet viewResultSet) throws SQLException { String viewName = viewResultSet.getString(TABLE_NAME); 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..b412caed4 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#expectedDeferredIndexDeploymentStatementsOnSingleColumn() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsOnSingleColumn() { + 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#expectedDeferredIndexDeploymentStatementsOnMultipleColumns() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsOnMultipleColumns() { + 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#expectedDeferredIndexDeploymentStatementsUnique() + */ + @Override + protected List expectedDeferredIndexDeploymentStatementsUnique() { + 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..1ef1796b7 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 testDeferredIndexDeploymentStatementsOnSingleColumn() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").columns(table.columns().get(0).getName()); + compareStatements( + expectedDeferredIndexDeploymentStatementsOnSingleColumn(), + testDialect.deferredIndexDeploymentStatements(table, index)); + } + + + /** + * Test deferred index creation over multiple columns. + */ + @SuppressWarnings("unchecked") + @Test + public void testDeferredIndexDeploymentStatementsOnMultipleColumns() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").columns(table.columns().get(0).getName(), table.columns().get(1).getName()); + compareStatements( + expectedDeferredIndexDeploymentStatementsOnMultipleColumns(), + testDialect.deferredIndexDeploymentStatements(table, index)); + } + + + /** + * Test deferred unique index creation. + */ + @SuppressWarnings("unchecked") + @Test + public void testDeferredIndexDeploymentStatementsUnique() { + Table table = metadata.getTable(TEST_TABLE); + Index index = index("indexName").unique().columns(table.columns().get(0).getName()); + compareStatements( + expectedDeferredIndexDeploymentStatementsUnique(), + 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 #testDeferredIndexDeploymentStatementsOnSingleColumn()} + */ + protected List expectedDeferredIndexDeploymentStatementsOnSingleColumn() { + return expectedAddIndexStatementsOnSingleColumn(); + } + + + /** + * @return Expected SQL for {@link #testDeferredIndexDeploymentStatementsOnMultipleColumns()} + */ + protected List expectedDeferredIndexDeploymentStatementsOnMultipleColumns() { + return expectedAddIndexStatementsOnMultipleColumns(); + } + + + /** + * @return Expected SQL for {@link #testDeferredIndexDeploymentStatementsUnique()} + */ + protected List expectedDeferredIndexDeploymentStatementsUnique() { + return expectedAddIndexStatementsUnique(); + } + + /** * @return Expected SQL for {@link #testAddIndexStatementsUniqueNullable()} */