Skip to content

fix(oracle): let a schema object register more than one introspection query - #476

Merged
jeremydmiller merged 1 commit into
masterfrom
gh-474-oracle-batched-introspection
Aug 20, 2026
Merged

jeremydmiller merged 1 commit into
masterfrom
gh-474-oracle-batched-introspection

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #474.

The bug

On Oracle, the migration path could only see a table's columns.

ODP.NET will not execute several statements from a single command, so ConfigureQueryCommand got one query — and Table spent it on columns. Indexes, foreign keys and the primary key were invisible to SchemaMigration.DetermineAsync, which is what ApplyChangesAsync, ApplyAllConfiguredChangesToDatabaseAsync and AssertDatabaseMatchesConfigurationAsync all go through.

What that meant in practice:

  • A declared index was created with the table and never touched again.
  • Add an index to an existing table → nothing happens.
  • Change one → nothing happens.
  • Remove one → it stays.
  • db-assert reports a clean match throughout.

The fix

OracleDbCommandBuilder already knew how to split a batch into one command per StartNewCommand() boundary — that machinery went in for the batching work and nothing used it here. What was missing was the other half: something to read across the split.

MultiCommandDataReader presents an ordered list of commands as one continuous sequence of result sets. NextResultAsync walks the current command's result sets and then rolls over into the next command's — and since a freshly executed reader sits exactly where NextResult() leaves a caller, the rollover is indistinguishable from an ordinary result-set boundary. Nothing consuming the results has to know.

Three small seams carry it:

Seam Why
IBatchedCommandBuilder Deliberately separate from ICommandBuilder<T>, so adding it breaks nothing that implements that interface outside this repo
Migrator.CreateCommandBuilder Defaults to the dialect-neutral builder; Oracle is the only override
SchemaMigration.DetermineAsync(conn, builder, ct, objects) Plus StartNewCommand() between objects — not before the first, or a splitting builder opens with an empty statement

Every provider except Oracle returns a single command from CompileCommands() and executes exactly as before.

One trap worth naming: the single-command case cannot fall through to Compile(). A splitting builder consumes its accumulated SQL while compiling, so a second Compile() hands back a command with empty CommandText — which is ORA-50029, and how I found it.

Oracle's introspection is now one implementation

Table registers all six queries it needs (columns, PK, FKs, index metadata, index expressions, index columns), and the batched path and FetchExistingAsync now share the same SQL constants and the same readers rather than being two copies that can drift apart. InitialLONGFetchSize is set on both — all_ind_expressions.column_expression is a LONG and reads back empty without it, the same trap #450 hit.

Two workarounds go away with the cause

  • DetermineOracleMigrationAsync sniffed for Tables.Table and rerouted it to FindDeltaAsync, because the generic path would have missed everything. It is now a one-line delegation with no type test — and it batches the objects instead of looping one at a time.
  • The Oracle rows of the index scenario matrix no longer override how a delta is found. They run the ordinary path, like every other provider's.

Tests

Weasel.Oracle.Tests/Tables/migration_path_sees_more_than_columns.cs — five tests going through ApplyChangesAsync and DetermineAsync deliberately, since the point is the path; FetchExistingAsync could always see all of this. Including several_tables_in_one_batch_each_read_their_own_result_sets, because the reader now has to walk six result sets per table and land on the right boundary or the second table reads the first one's rows.

The regression proof is #449's matrix: with the override removed and before this fix, Oracle failed 8 of its 11 scenarios with "missing 1", while querying all_indexes by hand showed the index had been there the whole time. All 11 pass now through the ordinary path.

Verified locally

Core 216, SQLite 418, PostgreSQL 879, SQL Server 440, Oracle 259, MySQL 277, EF Core 106. No failures.

🤖 Generated with Claude Code

… query

On Oracle the migration path could only see a table's columns. ODP.NET will not
execute several statements from a single command, so ConfigureQueryCommand got one
query and Table spent it on columns -- leaving indexes, foreign keys and the
primary key invisible to SchemaMigration.DetermineAsync, which is what
ApplyChangesAsync, ApplyAllConfiguredChangesToDatabaseAsync and
AssertDatabaseMatchesConfigurationAsync all go through.

The practical effect: a declared index was created with the table and never
touched again. Add one to an existing table and nothing happened. Change one and
nothing happened. Remove one and it stayed. And db-assert reported a clean match
throughout.

OracleDbCommandBuilder already knew how to split a batch into one command per
StartNewCommand boundary -- that machinery went in for the batching work and
nothing used it here. What was missing is the other half: something to read across
the split. MultiCommandDataReader presents an ordered list of commands as one
continuous sequence of result sets, so NextResultAsync rolls over into the next
command and the code consuming the results cannot tell the difference.

The seams:

  - IBatchedCommandBuilder, deliberately separate from ICommandBuilder<T> so that
    adding it breaks nothing implementing that interface outside this repo.
  - Migrator.CreateCommandBuilder, defaulting to the dialect-neutral builder.
    Oracle is the only override.
  - A SchemaMigration.DetermineAsync overload taking the builder, with
    StartNewCommand between objects so a splitting builder gets a boundary there
    too.

Everything except Oracle returns a single command from CompileCommands and
executes exactly as before.

Oracle's Table now registers all six queries it needs, and its introspection is
restructured so the batched path and FetchExistingAsync share the same SQL
constants and the same readers rather than having two copies that can drift.
InitialLONGFetchSize is set on both, because all_ind_expressions.column_expression
is a LONG and reads back empty without it -- the same trap weasel#450 hit.

Two workarounds go away with the cause. DetermineOracleMigrationAsync sniffed for
Tables.Table and rerouted it to FindDeltaAsync; it is now a one-line delegation
with no type test. And the Oracle rows of the index scenario matrix no longer
override how a delta is found -- they run the ordinary path, like every other
provider's.

Found by that matrix in weasel#449: Oracle failed eight of its eleven scenarios
where the other four passed, and querying all_indexes by hand showed the index had
been there the whole time.

Closes #474.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit dad8281 into master Aug 20, 2026
18 checks passed
@jeremydmiller
jeremydmiller deleted the gh-474-oracle-batched-introspection branch August 20, 2026 18:18
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.

Oracle: index and foreign key drift is invisible to the migration path

1 participant