Skip to content

DeployedIndexes: unified tracking table for all indexes (full model)#375

Open
michal-morzywolek wants to merge 125 commits intomainfrom
experimental/deployed-indexes
Open

DeployedIndexes: unified tracking table for all indexes (full model)#375
michal-morzywolek wants to merge 125 commits intomainfrom
experimental/deployed-indexes

Conversation

@michal-morzywolek
Copy link
Copy Markdown
Contributor

Summary

Introduces a DeployedIndexes tracking table that records every index managed by Morf (deferred and non-deferred), along with the lifecycle of any deferred index (PENDINGIN_PROGRESSCOMPLETED/FAILED).

  • Declare a deferred index via index("name").deferred().columns(...).
  • After upgrade, adopters call UpgradePath.getDeferredIndexStatements() to fetch DeferredIndexJob objects (table + index + SQL) and execute them at their own pace.
  • DeployedIndexTracker exposes markStarted / markCompleted / markFailed for status reporting.
  • Enricher merges physical schema with tracking rows at upgrade start and performs consistency checks (untracked physical index, tracked-but-missing non-deferred, orphan rows).
  • Dialect matrix: PostgreSQL CONCURRENTLY, Oracle ONLINE PARALLEL NOLOGGING, H2 standard CREATE INDEX; MySQL / SQL Server silently ignore .deferred() (no background creation support).

This branch is the full tracking alternative. See the sibling PR for the slim (deferred-only) variant — an A/B comparison.

Test plan

  • Review TestDeployedIndexesIntegration — full lifecycle coverage on H2.
  • Review enricher consistency-check tests (TestDeployedIndexesModelEnricherImpl).
  • Confirm supportsDeferredIndexCreation() returning false on MySQL / SQL Server falls back to immediate CREATE INDEX with a COMPLETED tracking row.
  • Manual: run an upgrade, assert getDeferredIndexStatements() returns the expected DeferredIndexJobs, execute them, verify tracker transitions.

🤖 Generated with Claude Code

Your Name and others added 30 commits February 3, 2026 23:24
Implements 14 test methods covering:
- Valid steps with @Version and package-based versioning
- Sequence ordering and validation
- Error detection for missing/duplicate sequences
- Invalid version format detection
- Package name validation
- Multiple validation error accumulation

Increases coverage from 56% to 94% instruction coverage.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DeferredIndexStatus enum: PENDING, IN_PROGRESS, COMPLETED, FAILED
- DeferredIndexOperationType enum: ADD
- DeferredIndexOperation domain class representing a row from the
  DeferredIndexOperation table plus ordered column names
- DeferredIndexOperationDAO for all CRUD operations on the deferred
  index queue, following the UpgradeStatusTableServiceImpl pattern
  (SqlScriptExecutorProvider + SqlDialect, enum values stored via .name())
- 10 unit tests using ArgumentCaptor to verify DSL statement structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce split

- Add DeferredAddIndex: SchemaChange for deferred index creation; apply() updates
  metadata only, reverse() removes index from metadata, isApplied() checks actual
  DB schema first then DeferredIndexOperation PENDING queue via DAO
- Split DeferredIndexOperationDAO into interface (@ImplementedBy) + DAOImpl class,
  following UpgradeStatusTableService/Impl convention
- Wire DeferredAddIndex into SchemaChangeVisitor (visit method), AbstractSchemaChangeVisitor
  (updates schema, no DDL), SchemaChangeSequence.InternalVisitor, and SchemaChangeAdaptor
  (default + Combining)
- Add 11 tests for DeferredAddIndex covering apply, reverse, isApplied, and accept
- Rename TestDeferredIndexOperationDAO -> TestDeferredIndexOperationDAOImpl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add addIndexDeferred() to SchemaEditor interface and SchemaChangeSequence.Editor:
  creates a DeferredAddIndex carrying the step's @uuid, calls visitor.visit() only
  (no schemaAndDataChangeVisitor — no DDL runs on the target table during upgrade)
- AbstractSchemaChangeVisitor.visit(DeferredAddIndex): write INSERT SQL into
  DeferredIndexOperation and DeferredIndexOperationColumn as part of the upgrade
  script; upgradeUUID read from DeferredAddIndex rather than stored visitor state
- DeferredAddIndex: add upgradeUUID field + getter; updated toString() to include it
- HumanReadableStatementProducer: implement addIndexDeferred() via generateAddIndexString
- Tests: TestInlineTableUpgrader, TestSchemaChangeSequence, TestDeferredAddIndex updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces DeferredIndexChangeService (interface + DeferredIndexChangeServiceImpl)
to track pending deferred ADD INDEX operations within an upgrade session and emit
the compensating SQL when subsequent schema changes interact with them:

- RemoveIndex on a pending deferred ADD → cancel (DELETE) instead of DROP INDEX
- RemoveTable → cancel all pending deferred indexes on that table
- RemoveColumn → cancel pending deferred indexes referencing that column
- RenameTable → UPDATE tableName in PENDING rows and update in-memory tracking
- ChangeColumn (rename) → UPDATE columnName in PENDING column rows

AbstractSchemaChangeVisitor delegates entirely to DeferredIndexChangeService,
keeping SQL construction out of the visitor. The service is independently tested
with 19 unit tests covering edge cases: multiple indexes on the same table,
partial column matches, case-insensitivity, and single-UPDATE coverage for
multi-index column renames.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stage 7: DeferredIndexExecutor picks up PENDING operations, builds indexes
via SqlDialect.deferredIndexDeploymentStatements(), and manages retry with
exponential backoff. Progress logged at 30s intervals.

Stage 8: awaitCompletion() polls the queue for multi-instance deployments
where non-executor nodes must block until index builds finish.

Stage 9: DeferredIndexRecoveryService detects stale IN_PROGRESS operations
(exceeded staleThresholdSeconds) and resets or completes them based on
whether the index exists in the schema.

Stage 10: DeferredIndexValidator force-executes any PENDING operations
before a new upgrade runs, ensuring no missing indexes.

Supporting changes: DeferredIndexTimestamps utility, retryBaseDelayMs
config field, DAO.hasNonTerminalOperations(), SqlDialect base method for
deferred index DDL.

28 tests (10 executor, 6 recovery, 4 validator, 8 unit) all passing.
Coverage: Executor 87%/81%, Recovery 100%, Validator 100%, Timestamps 100%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Override deferredIndexDeploymentStatements() in PostgreSQLDialect
(CREATE INDEX CONCURRENTLY) and OracleDialect (ONLINE PARALLEL
NOLOGGING) so deferred index builds avoid table-level write locks.

DeferredIndexExecutor.buildIndex() now uses a dedicated autocommit
connection because PostgreSQL's CONCURRENTLY cannot run inside a
transaction block. This is harmless for other platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 H2 integration tests covering: pending row creation, executor
completion, auto-cancel, unique/multi-column/new-table indexes,
populated table builds, multiple indexes per step, executor
idempotency, and recovery-to-execution pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…x/RenameIndex deferred handling

- Fix DeferredIndexChangeServiceImpl to use DeferredIndexTimestamps.currentTimestamp()
  instead of System.currentTimeMillis() for createdTime (yyyyMMddHHmmss format)
- Fix DeferredIndexOperationDAOImpl to use literal(boolean)/getBoolean() for the
  indexUnique column instead of literal(int)/getInt()
- Add ChangeIndex/RenameIndex handling for pending deferred indexes in
  AbstractSchemaChangeVisitor: ChangeIndex cancels the deferred op and adds the
  new index immediately; RenameIndex updates the queued index name
- Add updatePendingIndexName() to DeferredIndexChangeService/Impl
- Add unit tests for deferred branches in both visitor test classes
- Add integration tests for deferred-add-then-change and deferred-add-then-rename

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add retryMaxDelayMs config (default 5 min) to DeferredIndexConfig to cap
  exponential backoff and prevent overflow at high retry counts
- DeferredIndexValidator now throws IllegalStateException when forced
  execution fails, blocking the upgrade until the issue is resolved
- Update integration test to expect the exception on failed forced execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ry tracking

- Add retryMaxDelayMs config (default 5 min) to cap exponential backoff
  and prevent overflow at high retry counts (#4)
- DeferredIndexValidator throws IllegalStateException when forced execution
  fails, blocking the upgrade until resolved (#5)
- updatePendingColumnName/updatePendingTableName now rebuild in-memory
  DeferredAddIndex entries so cancelPendingReferencingColumn finds indexes
  by renamed column/table names (#7)
- Add unit and integration tests for all three fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace STRING(100) operationId PK on DeferredIndexOperation with
BIG_INTEGER id column. Change DeferredIndexOperationColumn FK from
STRING(100) to BIG_INTEGER to match. Update domain class, DAO
interface/impl, services, executor, recovery service, and all tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
findOperationsByStatus and findStaleInProgressOperations now use a
single LEFT OUTER JOIN query to fetch operations with their column
names, eliminating per-operation column lookups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DeferredAddIndex.apply() now uses equalsIgnoreCase() for index name
comparison, consistent with reverse() and other SchemaChange classes.

DeferredIndexChangeServiceImpl SQL statements now use the original
casing from stored DeferredAddIndex entries rather than the caller's
casing, ensuring SQL WHERE clauses match rows on case-sensitive
databases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tableName, indexName, and columnName were STRING(30) which is too
narrow for the validated maximum identifier length of 60 characters.
Made SchemaValidator.MAX_LENGTH public and referenced it directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove existsByUpgradeUUIDAndIndexName from DAO interface and
implementation — no production code calls it. Update stale comment
in SchemaChangeSequence that referenced a future stage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…cted directly

The DAO is never injected via Guice — all consumers construct it
directly with ConnectionResources. Remove the misleading annotations
to match the actual usage pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
addIndexDeferred now prefixes "Deferred: " so the output is
distinguishable from a regular addIndex operation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each service now validates the config fields it consumes at
construction time: threadPoolSize, maxRetries, retry delays,
staleThresholdSeconds, and operationTimeoutSeconds. Also fixes
stale comment in SchemaChangeSequence and distinguishes deferred
index in human-readable output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…vate

Introduce DeferredIndexService as the single public entry point for
adopters. The facade orchestrates recovery, execution, and failure
detection in one execute() call, and provides awaitCompletion() for
passive nodes. Internal classes (Executor, RecoveryService, Validator,
Operation, OperationType, Status) are now package-private. Config
validation is consolidated in DeferredIndexServiceImpl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update column and index name assertions to match actual table structure
after previous refactoring (operationId → id, updated index names).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TestDeferredIndexValidatorUnit (5 tests): validates empty queue
  shortcut, successful execution, failure exception with count
- TestDeferredIndexRecoveryServiceUnit (6 tests): stale recovery
  for index-exists, index-absent, table-missing, multiple ops,
  and case-insensitive index name matching
- TestDeferredIndexExecutorUnit additions (8 tests): empty queue,
  single success, retry-then-success, permanent failure, getStatus
  before/after execution, awaitCompletion true/false paths
- Added test constructors to DeferredIndexValidator and
  DeferredIndexRecoveryService for mock injection
- Made DeferredIndexValidator.createExecutor() overridable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TestDeferredIndexOperation (2 tests): full POJO getter/setter
  coverage including nullable fields → 100% line coverage
- TestDeferredAddIndex additions (4 tests): toString(), apply/reverse
  with existing other indexes, isApplied with non-matching index
  → 100% line coverage
- TestDeferredIndexExecutorUnit additions (3 tests): unique index
  reconstruction, SQLException from getConnection, zero-timeout
  awaitCompletion → 83% line coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace internal factory methods with proper Guice @Inject/@singleton
wiring so that DeferredIndexService can be injected by adopters. All
services now share a single DAO instance instead of each creating its
own. Bind DeferredIndexConfig in MorfModule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deployers can now configure a set of index names that should be built
immediately during upgrade even when the upgrade step uses
addIndexDeferred(). This allows overriding deferral for critical indexes
without modifying upgrade steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deployers can now configure a set of index names that should be deferred
even when the upgrade step uses addIndex(). This enables retroactive
deferred index creation on old upgrade steps without modifying them.

Includes conflict validation that throws if an index name appears in
both forceImmediateIndexes and forceDeferredIndexes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Your Name and others added 28 commits April 15, 2026 22:26
- testReUpgradeIsIdempotent: running the same upgrade twice should
  produce no errors and leave DeployedIndexes state unchanged.

Remaining edge cases that need new upgrade step fixtures (not added):
- Prepopulation verification (needs table with pre-existing index)
- RemoveTable cleanup (needs RemoveTable upgrade step)
- Crash recovery resetAllInProgressToPending (needs direct DAO access)

Full mvn clean verify: 4,727 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- testRemoveTableCleansUpDeployedIndexes: verifies that removeTable
  deletes all DeployedIndexes rows for the removed table. Uses new
  RemoveProductTable upgrade step fixture.
- testCrashRecoveryResetsInProgressToPending: simulates crash by
  marking index as IN_PROGRESS, then calls resetAllInProgressToPending
  and verifies it transitions back to PENDING.
- DeployedIndexesDAO made public for crash recovery test access.

Full mvn clean verify: 4,729 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Production: org.alfasoftware.morf.upgrade.deployed -> deployedindexes
Unit tests: org.alfasoftware.morf.upgrade.deployed -> deployedindexes
Integration tests: org.alfasoftware.morf.upgrade.deferred -> deployedindexes
Upgrade step fixtures: same

Package name now matches the table name (DeployedIndexes).

Full mvn clean verify: 4,729 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documentation fixes:
- deployed-indexes-dev.txt: fix API example (performUpgrade not findPath),
  add package structure section, API changes list, crash recovery section,
  known limitations (column rename, _PRF, MySQL/SQL Server)
- deployed-indexes-integration-guide.md: add crash recovery section,
  column rename limitation, _PRF exclusion, fix code example
- TODO-deployed-indexes.md: column rename schema model bug, missing
  integration tests for prepopulation and ChangeColumn on non-deferred

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChangeColumn.applyChange() rebuilds indexes that reference the renamed
column but previously only preserved isUnique(). isDeferred() was reset
to false, causing getDeferredIndexStatements() to miss the renamed index
after a cross-step column rename.

Also add two integration tests:
- testPrepopulationPopulatesExistingIndexes: verifies CreateDeployedIndexes
  populates pre-existing physical indexes.
- testCrossStepColumnRenameOnNonDeferredIndex: verifies column rename on
  a non-deferred index updates indexColumns in DeployedIndexes.

Strengthen testCrossStepColumnRename to assert the renamed column is now
properly reflected in getDeferredIndexStatements().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app runs deferred-index SQL itself on this branch — Morf no longer
owns a thread pool. The field, getter, and setter had no callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SqlDialect.buildCreateIndexStatement: drop unused afterIndexKeyword
  parameter (no caller ever passed a non-empty value).
- PostgreSQLDialect.buildPostgreSqlCreateIndex: replace the String
  "afterIndexKeyword" with a clearer `boolean concurrent` flag.
- Rename EnrichedIndex -> ObservedIndex and expand the class javadoc
  with a concise reason-for-existence (declarative vs observed state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ObservedIndex decorator pattern with a Data/Context split.
The Index interface goes back to being purely declarative (name, columns,
unique, deferred) and the runtime-observed fact "is this index physically
present?" moves to a new DeployedIndexState object produced by the
enricher alongside the schema.

Why: the decorator + default-method combo was incoherent — Index had a
runtime-only property (isPhysicallyPresent) as a leaky default, and the
ObservedIndex class existed only to override that one method. With the
split, data is data and operational state is state, and no type needs
to straddle both.

Changes:
- Add DeployedIndexState with two explicit queries — isKnownPhysicallyPresent
  (for the getDeferredIndexStatements scan) and isKnownPhysicallyAbsent
  (for the visitor's DDL decisions). Different defaults for unknown reflect
  what each caller actually wants.
- Refactor DeployedIndexesModelEnricher: enrichSchema(Schema)->Schema
  becomes enrich(Schema)->Result (schema + state). Physical indexes are
  rebuilt via SchemaUtils.index() to carry the declarative deferred flag
  from the tracking table; deferred-not-yet-built entries appear as
  virtual indexes in the schema; presence is recorded in the state.
- Remove Index.isPhysicallyPresent() default method and delete
  ObservedIndex + TestObservedIndex.
- Thread DeployedIndexState through AbstractSchemaChangeVisitor and
  InlineTableUpgrader (new overload; empty-state default preserved).
- Upgrade.findPath wires the state from enricher to upgrader and to the
  final deferred-SQL scan.

Tests: TestDeployedIndexesModelEnricher rewritten against the new API
with more precise assertions (schema fact + state fact). Stale
isPhysicallyPresent mocks removed from visitor/upgrader tests; those
mocks were load-bearing under the old code but redundant now because
the visitor gets the same answer from the state's default. All 27
deployed-index integration tests + full module build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, inline parsing

- Drop the unused upgradeUUID column from the DeployedIndexes table and
  from every plumbing layer (DAO, change service, entity, visitor call
  sites, Editor). The column was never populated anywhere; wiring it up
  properly was out of scope for now.
- Widen indexColumns from 2000 to 4000 chars, matching the comfortable
  headroom we want for composite indexes.
- Rename DeployedIndexEntry -> DeployedIndex. The POJO is the row type;
  the "Entry" suffix was noise (mirroring the DeployedViews convention,
  which has no separate entry class at all).
- Remove the static DeployedIndex.parseColumns / joinColumns helpers.
  parseColumns had one caller (the DAO); inline it there as
  Arrays.asList(s.split(",")). joinColumns was dead. Deletes the two
  matching tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two-method boolean API (isKnownPhysicallyPresent, isKnownPhysicallyAbsent)
encoded a three-state value (present, absent, unknown) via a nullable Boolean
in the internal map. Readers had to mentally decode what false meant in each
method, and !isKnownPhysicallyPresent was not the complement of
isKnownPhysicallyAbsent (both false when UNKNOWN).

Replace with an explicit enum: IndexPresence { PRESENT, ABSENT, UNKNOWN }.
Single getPresence(tableName, indexName) method. Call sites use explicit
!= PRESENT / != ABSENT comparisons — the tri-state is now visible in every
read and write.

- New IndexPresence enum.
- DeployedIndexState: Map<String, IndexPresence> internally.
- Drop both boolean wrapper methods — don't reintroduce the ambiguity.
- Update AbstractSchemaChangeVisitor and Upgrade.findPath call sites.
- Enricher records PRESENT/ABSENT explicitly.
- TestDeployedIndexesModelEnricher collapses two-asserts-per-case into
  single assertEquals on the enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract Result to its own top-level class EnrichedModel. Returned type
  is now a first-class public API surface rather than a nested-and-generic
  inner class.
- Split DeployedIndexesModelEnricher.enrich() into five private methods:
  fastPathEmpty, enrichTable, processPhysicalIndexes,
  processRemainingTrackingEntries, logOrphanTrackingRows. enrich() is now
  a ~20-line orchestrator.
- Add WHY comments for the non-obvious invariants: _PRF exclusion, the
  "consumed tracking entry is removed" invariant that makes the leftover
  loop correct, why missing non-deferred is a hard error, why orphan
  tracking rows are a warn.

Callers updated: Upgrade.findPath, TestDeployedIndexesModelEnricher,
TestUpgrade.mockEnricher. No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tatements

UpgradePath.getDeferredIndexStatements() used to return a List<String> of
raw CREATE INDEX SQL. The adopter then had no reliable way to recover the
(tableName, indexName) needed for DeployedIndexTracker calls — parsing
the SQL with regex, maintaining a side-map, or querying pending entries
separately and regenerating SQL were the only workarounds.

Change the return type to List<DeferredIndexJob>. Each job carries:
- tableName
- indexName
- sql: the statements (usually one, sometimes more e.g. PostgreSQL's
  COMMENT ON INDEX) to build that one index

The adopter loop becomes:
  for (DeferredIndexJob job : path.getDeferredIndexStatements()) {
      tracker.markStarted(job.getTableName(), job.getIndexName());
      try {
          for (String sql : job.getSql()) executeSql(sql);
          tracker.markCompleted(job.getTableName(), job.getIndexName());
      } catch (Exception e) {
          tracker.markFailed(job.getTableName(), job.getIndexName(), e.getMessage());
      }
  }

Also update TestDeployedIndexesIntegration — assertions that previously
did .anyMatch(s -> s.toUpperCase().contains("INDEX_NAME")) now target
job.getIndexName() / job.getTableName() directly; assertions that really
needed to peek at the SQL content (UNIQUE keyword, renamed-column name
in CREATE INDEX) flatMap into job.getSql().

Breaking change but no adopter consumed this API on this branch yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gate all DSL

Replace the scattered DSL construction in DAOImpl and ChangeServiceImpl
with a single source of truth. The two classes now have one responsibility
each, distinct column constants are consolidated, and there's one place
to look for "how does Morf talk to the DeployedIndexes table".

DeployedIndexesStatementFactory (new):
- Owns every DSL statement targeting the DeployedIndexes table — reads,
  status updates executed by the DAO, and tracking DML used by the visitor.
- Column-name constants live here; the other classes no longer have their
  own copies.
- Methods grouped: read queries, status updates, tracking DML.

DeployedIndexesDAOImpl:
- Now a thin executor. Each public method asks the factory for DSL,
  converts via the dialect, executes. Still owns the ResultSet mapper.
- No DSL construction inline.

DeployedIndexesChangeServiceImpl:
- In-memory session state + orchestration only.
- All returned Statement lists come from factory calls; no insert/update/
  delete DSL built here.

Both the DAO and the ChangeService accept an explicit factory constructor
for tests; default constructors create their own.

No behavioural change — all 4,700+ tests across 11 modules pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on tracker

The DAO and StatementFactory are internal machinery — no external adopter
should need to construct them. Tighten the API surface:

- DeployedIndexesDAO interface, DeployedIndexesDAOImpl, and
  DeployedIndexesStatementFactory go from public to package-private.
  Constructors too.
- Add DeployedIndexesModelEnricher.create(ConnectionResources,
  UpgradeConfigAndContext) factory method. Upgrade.java used to
  hand-wire the DAO with a cross-package `new DeployedIndexesDAOImpl(...)`;
  now it calls the factory, keeping the DAO invisible.
- Add resetInProgress() to DeployedIndexTracker. TestDeployedIndexesIntegration
  now uses tracker.resetInProgress() for the crash-recovery test instead of
  constructing a DAO directly — matches how adopters would use the API.

The integration tests happen to still compile because they live in the
same Java package name as the classes (even though they're in a separate
Maven module), so package-private access still works. For actual external
adopters these types are now invisible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new unit-test classes covering previously-untested machinery, and
negative/edge-path additions to three existing test files.

New test classes:
- TestDeployedIndexState — tri-state enum behaviour (empty/PRESENT/ABSENT/
  UNKNOWN) and case-insensitive keys. 5 tests.
- TestDeployedIndexesStatementFactory — asserts DSL shape for every
  statement method (read, status update, tracking DML), plus multi-column
  CSV join and deferred/non-deferred status mapping. 17 tests.
- TestDeployedIndexTrackerImpl — mocks the DAO and verifies pure
  delegation for every tracker method including resetInProgress. 6 tests.
- TestDeployedIndexesDAOImpl — mocks the factory + dialect + executor;
  verifies each DAO method calls the correct factory method, converts via
  the dialect, and executes; plus ResultSet mapping including null
  startedTime/completedTime via wasNull. 9 tests.

Negative/edge additions to existing tests:
- TestDeployedIndexesChangeServiceImpl — no-op assertions for removeIndex /
  removeAllForTable / removeIndexesReferencingColumn / updateTableName /
  updateColumnName / updateIndexName on untracked tables; updateIndexName
  on unknown index; case-insensitive column matching in updateColumnName;
  multi-column INSERT shape verification.
- TestDeployedIndexesModelEnricher — isUnique + multi-column order
  preservation in rebuildIndex; non-deferred + physical path directly
  asserted; mixed physical + virtual on one table; multiple tables;
  orphan-row tolerance (log.warn, no throw).
- TestDeployedIndex — composite column order preservation in toIndex().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real bug caught by the tightened test assertions:
- DeployedIndexesStatementFactory.statementToMark{Started,Completed,Failed}
  and statementToBumpRetryCount used .where(X.eq(a).and(Y.eq(b))). Because
  Criterion.and is statically imported and Criterion has no instance .and(),
  the compiler resolved .and(Y.eq(b)) as a static call and silently discarded
  the LHS X.eq(a). Bytecode confirmed with a `pop`. Result: the UPDATE
  filtered by indexName only, ignoring tableName. Two indexes with the same
  name on different tables would cross-update. Fixed to use the explicit
  and(c1, c2) form.

Review fixes:
- Remove dead deferredIndexes field + getDeferredIndexes() in
  AbstractSchemaChangeVisitor (populated but never read).
- Replace the unreachable-but-misleading else-branch in
  visitDeployedIndexesStatement with an IllegalStateException. Aligns with
  the class's own documented "no schema validation" contract.
- Delete speculative static DeployedIndexesStatementFactory.allColumns() and
  its self-referential test.

New tests + stronger assertions:
- TestDeferredIndexJob (new): constructor null-safety via Objects.requireNonNull
  on all three args; multi-statement SQL order; unmodifiable and decoupled
  sql list; per-arg NPE messages.
- TestDeployedIndexesStatementFactory: factory tests now verify SET-field
  aliases and literal values, WHERE AND structure and leaf values — not
  just counts. This is what caught the tracker WHERE bug.
- TestDeployedIndexesChangeServiceImpl.testUpdateColumnName: now verifies
  UPDATE sets the right indexColumns value and targets the correct index.
- TestDeployedIndexTrackerImpl: markStarted/markCompleted now assert a
  [before, after] bounds check on the captured timestamp; no more anyLong().
- TestDeployedIndexesIntegration.testDisabledFeatureBuildsDeferredImmediately:
  captures UpgradePath and asserts jobs list is empty.
- TestDeployedIndexesIntegration new tests:
  testAppSideAdopterFlowBuildsAndMarksCompleted — exercises the full
  documented adopter loop (markStarted → execute job SQL → markCompleted);
  testAppSideAdopterFlowMarksFailed — verifies markFailed flips status and
  persists errorMessage.
- Shared newTracker() helper across integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s handling into buildUpgradePath

Upgrade.java:
- Replace fully-qualified org.alfasoftware.morf.upgrade.deployedindexes.* references with imports.
- Fold deferredIndexJobs setup into buildUpgradePath (now takes List<DeferredIndexJob>)
  rather than setting it at the call site; findPath simplifies to a single return.
- Revert unnecessary hoisting of the InlineTableUpgrader local variable.

DeployedIndexesChangeServiceImpl.java:
- Remove accidental "jjv" typo between field declarations.

SchemaChangeAdaptor.java, SchemaChangeSequence.java:
- Trim trailing blank lines (no behaviour change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into graph-based visitor

Removes silent-empty-state / silent-null-schema / silent-new-factory footguns across
the branch and fixes a latent production bug where GraphBasedUpgradeSchemaChangeVisitor
was constructed via its 4-arg super call, silently substituting DeployedIndexState.empty()
and degrading the visitor's model-based DDL decisions to default behaviour.

Constructors deleted (shorter-arg convenience forms that silently default a dependency):
- AbstractSchemaChangeVisitor (4-arg → gone; 5-arg required)
- InlineTableUpgrader (5-arg → gone; 6-arg required)
- SchemaChangeSequence (1-arg and 2-arg → gone; 3-arg required)
- UpgradePathFinder.getSchemaChangeSequence() no-arg → gone; 1-arg required
- DeployedIndexesDAOImpl 2-arg → gone; Guice wires factory via @Inject on 3-arg
- DeployedIndexesChangeServiceImpl no-arg → gone; 1-arg required

Editor.getSourceSchema() silent SchemaUtils.schema() fallback is removed: sourceSchema
is now non-null via Objects.requireNonNull in the surviving constructor.

Real DeployedIndexState threaded through the graph-based chain:
- GraphBasedUpgradeSchemaChangeVisitor constructor + factory
- GraphBasedUpgradeBuilder field + constructor + factory
- Upgrade.findPath passes deployedIndexState to graphBasedUpgradeBuilderFactory.create(...)

DeployedIndexesStatementFactory is now public (needed by AbstractSchemaChangeVisitor
to construct DeployedIndexesChangeServiceImpl without reflection). P1.6 will split
this into interface + impl.

SchemaEditor.getSourceSchema() Javadoc now honestly documents the retrofit: a read
method on an otherwise-pure-write interface, added for infrastructure steps like
CreateDeployedIndexes that need the pre-upgrade schema.

DeployedIndexState gains two public test-friendly factories (of/with) so tests outside
the package can construct populated states. Verified by a new regression test
testRemoveIndexVisitRespectsAbsentStateForGraphBasedPath in
TestGraphBasedUpgradeSchemaChangeVisitor: when state reports an index as ABSENT,
the graph-based visitor no longer emits spurious DROP INDEX DDL.

Updated callers (tests):
- UpgradeTestHelper, TestInlineTableUpgrader, TestGraphBasedUpgradeSchemaChangeVisitor,
  TestGraphBasedUpgradeBuilder, TestSchemaChangeSequence (6 sites), TestUpdateToCtasAdaptor,
  TestUpgradePathFinder, TestDeployedIndexesChangeServiceImpl, TestDeployedIndexTracker,
  TestDeployedIndexesIntegration — all pass DeployedIndexState.empty() / SchemaUtils.schema()
  / DeployedIndexesStatementFactory explicitly where relevant.

Verification: morf-core unit tests (2737, +1 new), deployed-indexes integration tests
(29), checkstyle, spotbugs, javadoc all clean on morf-core verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment, hard-fail on orphans

DeployedIndexesModelEnricher:
- Replace Optional-returning fastPathEmpty(...) with boolean shouldSkipEnrichment(...).
  Eliminates the triple-return and the double dao.findAll() call (one call in enrich, the
  "table empty" check becomes an inline early-return against the fetched list).
- Add missing debug log on the feature-disabled branch.
- Delete the isMorfInfrastructureTable skip in enrich() and in the orphan check. Morf
  tables (UpgradeAudit, DeployedViews, DeployedIndexes) are now enriched like any other
  table; consistency validation applies. _PRF indexes remain the only carve-out.
- Rename local entryMap → trackingRowsByTable (and buildEntryMap → buildTrackingRowsByTable).
  Rename presence → observedPresence across enrich and helper method parameters.
- Collapse the enrichTable result block using orElse + |=.
- Replace soft-warn logOrphanTrackingRows with hard-fail validateNoOrphanTrackingRows:
  throws IllegalStateException on first orphan. Orphans cannot be produced by correct
  Morf operation (RemoveTable/RenameTable always emit matching tracking DML), so the
  same severity class as non-deferred-missing-from-DB (already a hard error) applies.
- processRemainingTrackingEntries now clears the inner map after consuming entries —
  the new hard-fail requires all consumed entries to be cleared so only truly-orphan
  tables remain for validation.
- Delete isMorfInfrastructureTable helper (no remaining callers).

CreateDeployedIndexes:
- Delete the isMorfTable skip in the prepopulation loop and the helper method.
  Morf-table indexes are now prepopulated alongside user-table indexes. Since this
  branch is pre-release, no migration step is needed for existing deployments —
  fresh CreateDeployedIndexes handles all tables on first run.

Tests:
- TestDeployedIndexesModelEnricher: testOrphanRowForMissingTableDoesNotThrow flipped to
  testOrphanRowForMissingTableThrows with @test(expected = IllegalStateException.class).
  New tests confirm Morf infrastructure tables ARE enriched: indexes on UpgradeAudit
  recorded PRESENT in state, and an untracked physical index on DeployedViews triggers
  consistency validation.
- TestDeployedIndexesIntegration: updated testPrepopulationPopulatesExistingIndexes
  Javadoc to reflect that Morf tables are no longer excluded.

Verification: morf-core 2739 tests pass, checkstyle + spotbugs + javadoc clean, all 26
TestDeployedIndexesIntegration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ployedIndexState

Adds a package-private IndexKey(tableName, indexName) value type that upper-cases
both names on construction. Replaces the Map<String, IndexPresence> keyed by a
string-concat convention (TABLE:INDEX) with Map<IndexKey, IndexPresence>.

Benefits:
- Type-safe: callers can't accidentally pass a pre-formatted string where a key
  was expected.
- Canonicalisation (upper-casing) is encapsulated in the key type; consumers no
  longer need to know the key format.
- Collisions are impossible (no TABLE:INDEX ambiguity if a name contains ':').
- Eliminates the last static helper on DeployedIndexState (the package-private
  key() method).

Changes:
- New IndexKey.java (with equals/hashCode/toString, null-rejection).
- DeployedIndexState internal map is now Map<IndexKey, IndexPresence>; the static
  key() helper is deleted. Public API (getPresence/empty/of/with) unchanged.
- DeployedIndexesModelEnricher uses new IndexKey(...) instead of
  DeployedIndexState.key(...).

Tests:
- New TestIndexKey covers equals/hashCode/case-insensitivity/toString/null-rejection.
- TestDeployedIndexState updated to construct Map<IndexKey, IndexPresence> via
  new IndexKey(...) directly — existing case-insensitivity tests still pass.

Verification: morf-core 2747 tests pass (+8 new TestIndexKey), checkstyle +
spotbugs + javadoc clean, TestDeployedIndexesIntegration 26/26 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of related refactors in the DDL-emission path.

CreateDeployedIndexes:
- @sequence(2) → @sequence(1). Explicit about "runs first"; Morf-internal steps all
  use Unix-epoch-seconds values (billions), so they naturally sort after it.

AbstractSchemaChangeVisitor:
- Replace the visitDeployedIndexesStatement(Statement) instanceof chain with three
  typed overloads — writeDeployedIndexesDml(Insert/Update/Delete). New DML types
  force a missing-overload compile error rather than a runtime IllegalStateException.
- Rename isPhysicallyPresent → willBePhysicallyPresentAtThisEmission. Nothing has
  hit the DB yet at visitor time — the method is a projection ("will the index
  exist at this emission point"), not a query. New name reflects that.
- Delete the defensive currentSchema.tableExists(tableName) branch; all callers
  invoke this before SchemaChange.apply() which would fail loudly on a missing
  table anyway.
- Add capture-before-mutate comments at each local-variable capture (RemoveIndex,
  ChangeIndex, RenameIndex): both isTrackedDeferred and the enricher state would
  be out of sync by DDL-emission time otherwise, so inlining would silently produce
  wrong DDL.
- Split visit(AddIndex) into four named helpers: shouldEmitPhysicalIndexDdl,
  emitAddIndexOrRename, findMatchingIgnoredIndex, trackInDeployedIndexes. The
  top-level method collapses to apply+DDL+track — reads as prose.
- Collapse visit(ChangeIndex) to reuse the same helpers. Tracking call that
  previously appeared twice (deferred vs immediate branches) is now one call.

DeployedIndexesChangeService(Impl):
- Tighten return types: List<Statement> → List<InsertStatement> / List<DeleteStatement>
  / List<UpdateStatement> on each method. DeployedIndexesStatementFactory already
  returned concrete types; the ChangeService's List<Statement> was leaking
  implementation laxity into the contract.

SchemaChangeSequence.Editor:
- Simplify resolveDeferred: split into resolveTargetDeferred(index) → boolean pure
  predicate + two-line rebuild-on-mismatch call. The triple-repeated
  index.isDeferred() ? X : Y ternary collapses into one equality check.
- Add private isForcedImmediate/isForcedDeferred helpers reading the config's
  case-insensitive stored sets directly. These replace calls to the config's
  now-deleted predicate methods.

UpgradeConfigAndContext:
- Delete isForceImmediateIndex(String) and isForceDeferredIndex(String) —
  behavioural predicates don't belong on a data/setter class. Setter-side
  case-folding stays as pure normalisation. Public getForceImmediate/DeferredIndexes
  getters stay.

Tests:
- TestSchemaChangeSequence: testIsForceImmediateIndex / testIsForceDeferredIndex
  replaced with testForceImmediate/DeferredIndexesStoredCaseInsensitively —
  they now assert only the storage contract (normalised, deduped). Behavioural
  coverage is already provided by the existing
  testAddIndexDeferredWithForceImmediateProducesAddIndex /
  testAddIndexWithForceDeferredProducesDeferredAddIndex / case-insensitive
  variants, which exercise resolveDeferred end-to-end.
- TestDeployedIndexesChangeServiceImpl: local List<Statement> declarations widen
  to List<? extends Statement> to match the tightened return types.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc clean,
TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Javadoc

Upgrade.java:
- Extract collectDeferredIndexJobs(schemaChangeSequence, sourceSchema,
  deployedIndexState, dialect) → List<DeferredIndexJob>. The ~20-line scan
  that used to live inline in findPath becomes a named, individually-testable
  helper with a documented early-return on isDeferredIndexCreationEnabled().
- Extract enrichSourceSchema(sourceSchema) → EnrichedModel. The pair-mutation
  of sourceSchema + deployedIndexState now happens at one named boundary.
  Null-guard on deployedIndexesModelEnricher stays (legacy test paths).
- findPath's two ~20-line blocks collapse to two one-liners.
- buildUpgradePath passes deferredIndexJobs through the factory (see below);
  the if(!isEmpty) guard + setDeferredIndexStatements setter call disappear.

Upgrade.performUpgrade Javadoc:
- Document the void → UpgradePath return-type change: callers access
  UpgradePath.getDeferredIndexStatements() for the list of DeferredIndexJob
  entries the application must execute asynchronously via
  DeployedIndexTracker.
- Note these static entry points are simplified and primarily used by
  tests/examples; production code typically goes through Upgrade.Factory.

UpgradePath.java:
- Add import for DeferredIndexJob; replace three inline fully-qualified
  references with the imported name.
- Make deferredIndexJobs field final — initialised only in the constructor.
- Delete setDeferredIndexStatements(...) setter.
- Widen the 6-arg UpgradePath constructor to 7-arg with List<DeferredIndexJob>.
- Keep the 4-arg and 5-arg public constructors as explicit "no-deferred-jobs"
  shapes (empty-path sentinel and simpler test/build paths, per the plan).
  Their Javadoc documents the no-jobs contract.
- Widen UpgradePathFactory.create 4-arg → 5-arg (+ List<DeferredIndexJob>).
  The 1-arg and 2-arg overloads stay unchanged — they're for paths that
  genuinely can't have deferred jobs; not convenience defaults.

CreateDeployedIndexes:
- Build the DeployedIndexes table as a local variable so the prepopulation
  loop can iterate its OWN indexes too. Without this, the table's physical
  indexes (DeployedIdx_1, DeployedIdx_2) would be untracked, and the P1.2
  enricher hard-fail on untracked physical indexes would trigger on any
  subsequent upgrade run. Caught by
  TestDeployedIndexesIntegration.testReUpgradeIsIdempotent +
  testSequentialUpgradeIncludesPreviousDeferred.
- Extract a small sourceSchema(SchemaEditor) helper to keep the main loop
  one-liner-shaped.

Tests:
- TestUpgrade.upgradePathFactory() mock signature updated: create(...,
  anyList(), anyList(), nullable(builder), anyList(), anyList()).
- TestUpgradePath.testFactoryCreateUpgradeWithInitialisationSql: the
  factory call grows an ImmutableList.of() for deferredIndexJobs.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…exes package

Renames:
- DeployedIndexesChangeService → DeployedIndexesService. The word "Change"
  was misleading: Morf uses "Change" only as a noun for SchemaChange objects
  (ChangeColumn, ChangeIndex, SchemaChangeVisitor), never as a service verb.
  Service classes use noun-based names (UpgradeStatusTableService,
  DatabaseUpgradePathValidationService). git mv applied to both production
  files and the test file; sed applied class + field-name references
  (deployedIndexesChangeService → deployedIndexesService).

Interface + impl splits:
- DeployedIndexesStatementFactory promoted to interface with @ImplementedBy.
  New DeployedIndexesStatementFactoryImpl carries the bodies. Column-name
  constants moved to the interface as static final (allowed in Java 8+
  interfaces). Callers updated to instantiate Impl for non-Guice contexts
  (visitor, tests, static create factories).
- DeployedIndexesModelEnricher promoted to interface with @ImplementedBy.
  New DeployedIndexesModelEnricherImpl carries the bodies. The static
  create(...) factory moves to the interface (Java 8+). LogFactory.getLog()
  switches to the Impl class. Test file renamed to TestDeployedIndexesModelEnricherImpl.

Integration test fixture fix:
- TestDeployedIndexesIntegration.INITIAL_SCHEMA pre-creates the DeployedIndexes
  table via schemaManager, bypassing the CreateDeployedIndexes step. Since P1.2
  removed the Morf-table skip and made the enricher hard-fail on untracked
  physical indexes, tests that trigger enrichment on the pre-created
  DeployedIndexes table (testReUpgradeIsIdempotent, testSequentialUpgradeIncludesPreviousDeferred)
  would fail. Added seedDeployedIndexesOwnTrackingRows() in setUp that
  inserts tracking rows for DeployedIdx_1 and DeployedIdx_2 — mirrors what
  CreateDeployedIndexes would have done in production.

CreateDeployedIndexes:
- Removed the redundant "dedicated loop" added in P1.5 that inserted tracking
  rows for DeployedIdx_1/DeployedIdx_2. The visitor's visit(AddTable) path
  already tracks these via deployedIndexesChangeService.trackIndex(...) when
  the step calls schema.addTable(deployedIndexesTable). The P1.5 dedicated
  loop was double-inserting and triggering unique-constraint violations.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IndexPresence.java:
- Rewrite UNKNOWN Javadoc to distinguish happy path from bug path.
- Happy path: UNKNOWN is the normal result for indexes that first appear
  during the current upgrade session — the enricher runs once against the
  source schema + tracking table and does not see subsequent in-memory
  mutations. Callers decide meaning in context
  (AbstractSchemaChangeVisitor.willBePhysicallyPresentAtThisEmission treats
  UNKNOWN as "present"; Upgrade.collectDeferredIndexJobs treats UNKNOWN as
  "needs building").
- Bug path: UNKNOWN should NOT appear for an index that was live in the
  source schema or had a tracking row when the enricher ran — that would
  indicate the enricher skipped work (a bug).
- Class-level Javadoc lifts the distinction so readers don't conflate the
  two meanings.

DeployedIndexState.getPresence Javadoc:
- Added a one-line pointer to IndexPresence.UNKNOWN for the full
  happy-path-vs-bug distinction and caller interpretations.

mvn javadoc:javadoc clean (no warnings anywhere in morf-core). The earlier
commits (P1.1–P1.6) already added Javadoc to every new method in the
deployedindexes package and every modified method across the branch —
this commit completes the pass by addressing the one remaining ambiguity
(UNKNOWN semantics) and cross-referencing it from the sole consumer of
its return value.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ulation assertions

Caught during self-audit after the Phase 1 push:

TestSchemaChangeSequence:
- Add testAddIndexDeferredWithKillSwitchOffProducesImmediate — covers the
  resolveDeferred kill-switch-off branch (setDeferredIndexCreationEnabled(false)
  rebuilds a declared-deferred index as non-deferred). Missing from the
  existing coverage which only hit the force-immediate / force-deferred /
  default-passthrough branches.

TestDeployedIndexesIntegration.testPrepopulationPopulatesExistingIndexes:
- Add assertions that DeployedIdx_1 and DeployedIdx_2 (the DeployedIndexes
  table's own indexes) are recorded in the tracking table after
  CreateDeployedIndexes runs. Exercises the P1.2 removal of the
  Morf-infrastructure-table skip + the visitor's visit(AddTable)
  auto-tracking behaviour end-to-end.

Verification: morf-core 2748 tests pass (+1), checkstyle + spotbugs +
javadoc clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker
3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third parameter to UpgradeStep.execute — an UpgradeContext providing
read-only upgrade-time state — and deletes the SchemaEditor.getSourceSchema()
retrofit added in Phase 1.

Rationale: SchemaEditor is a command interface ("tell me what to change").
Adding a read method to it was a retrofit for one consumer (CreateDeployedIndexes).
UpgradeContext is the principled home for read-only state steps may need;
future needs (dialect, UpgradeConfigAndContext, step metadata) grow the
context rather than polluting SchemaEditor further.

Contract:
- Framework always invokes the 3-arg execute(schema, data, context).
- The 2-arg execute stays abstract — adopters must implement it.
- The new 3-arg execute has a default implementation that bridges to the
  2-arg form, so existing adopter steps that only override 2-arg keep
  working unchanged.
- Steps needing context override 3-arg and provide a throwing stub for
  2-arg (never called when 3-arg is overridden).
- Override ONE of the two, not both — documented in Javadoc on both
  overloads. Overriding both is legal but not useful: only 3-arg runs;
  the 2-arg body is dormant unless the 3-arg override invokes it
  explicitly via execute(schema, data).

Backwards compatibility:
- Source- and binary-compatible for the common case (existing adopter
  steps overriding only 2-arg) — the new 3-arg method is a default,
  invisible unless opted into.
- One adopter in the repo (CreateDeployedIndexes) migrates to the 3-arg
  form; all other steps untouched.
- HumanReadableStatementProducer's anonymous SchemaEditor never called
  getSourceSchema, so no change needed there.

Files changed:
- New: morf-core/.../UpgradeContext.java — interface with getSourceSchema().
- UpgradeStep.java: add default 3-arg execute + explicit "override one"
  Javadoc on both overloads.
- SchemaEditor.java: delete the getSourceSchema() default method (retrofit
  removed) + unused Schema import.
- SchemaChangeSequence.java: Editor drops its sourceSchema field and
  getSourceSchema override; constructor loop builds UpgradeContext as a
  lambda (() -> sourceSchema) and invokes the 3-arg execute.
- CreateDeployedIndexes.java: 2-arg execute becomes a throwing stub;
  3-arg execute overrides and reads context.getSourceSchema().
- UpgradePathFinder.java: update the stale {@link SchemaEditor#getSourceSchema}
  Javadoc reference to point at the 3-arg execute.

Verification: morf-core 2748 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker
3/3 pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…port

On dialects where SqlDialect.supportsDeferredIndexCreation() returns false
(MySQL, SQL Server, and the base SqlDialect default), a declared-deferred
index was being handled inconsistently:
- Visitor emitted CREATE INDEX immediately (correct — shouldEmitPhysicalIndexDdl
  returns true when dialect doesn't support deferred).
- But trackInDeployedIndexes passed the original declared flag to
  statementToTrackIndex, which unconditionally wrote indexDeferred=true /
  status=PENDING based on the declared flag.
- Upgrade.collectDeferredIndexJobs then saw the deferred flag in the final
  schema and (state = UNKNOWN because the new index didn't exist at
  enrichment time) added it to the app-side executor's jobs list.
- The app would then issue a second CREATE INDEX → duplicate-index error.

This was not caught by integration tests because morf-h2v2 explicitly
overrides supportsDeferredIndexCreation() to return true (so the test
harness exercises the full deferred pipeline even on H2).

Fix — two parts:

AbstractSchemaChangeVisitor:
- New effectiveIndex(Index) helper: on dialects without deferred support,
  rebuild a declared-deferred index without the deferred flag. The returned
  index flows into shouldEmitPhysicalIndexDdl (still emits CREATE) and
  trackInDeployedIndexes (now tracks as COMPLETED / indexDeferred=false).
- Applied in visit(AddIndex), visit(ChangeIndex) (for toIndex), and
  visit(AddTable) (per-index in the loop).

Upgrade.collectDeferredIndexJobs:
- Early-return empty list when dialect.supportsDeferredIndexCreation() is
  false. The app-side executor would otherwise receive jobs for indexes
  already built at upgrade time, causing duplicate-CREATE errors.

Tests:
- TestInlineTableUpgrader.testVisitAddIndexDeferredOnDialectWithoutDeferredSupport:
  mock dialect with supportsDeferredIndexCreation=false, verify CREATE INDEX
  emitted AND tracking INSERT carries indexDeferred=false / status=COMPLETED
  (not PENDING).
- TestInlineTableUpgrader.testVisitChangeIndexToDeferredOnDialectWithoutDeferredSupport:
  same shape, ChangeIndex immediate→declared-deferred path.
- Two small helpers findBooleanLiteralByAlias / findStringLiteralByAlias
  inspect the captured InsertStatement's values without SQL parsing.

Verification: morf-core 2750 tests pass (+2), checkstyle + spotbugs +
javadoc clean, TestDeployedIndexesIntegration 26/26 +
TestDeployedIndexTracker 3/3 pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant