Skip to content

Oracle: split commands keep the LONG fetch size, so a descending index settles on the migration path - #660

Merged
jeremydmiller merged 1 commit into
JasperFx:masterfrom
lahma:oracle-long-fetch-size
Sep 29, 2026
Merged

jeremydmiller merged 1 commit into
JasperFx:masterfrom
lahma:oracle-long-fetch-size

Conversation

@lahma

@lahma lahma commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

On the migration path (SchemaMigration.DetermineAsync, MigrateAsync, IDatabase apply/assert), an Oracle index with a descending key reads back as its hidden column: PRIORITY DESC comes back as SYS_NC00007$ DESC. The index then never matches its model, and every apply rebuilds it. Two views migrated together are replaced on every apply for the same reason.

Cause: CompileCommands creates a new OracleCommand per statement when a batch holds several statements, and those commands dropped InitialLONGFetchSize. As a result, ALL_IND_EXPRESSIONS.COLUMN_EXPRESSION and ALL_VIEWS.TEXT (LONG columns) read back empty.

The fix: each split command copies CommandTimeout, FetchSize, InitialLONGFetchSize and InitialLOBFetchSize from the command being built.

There are three new tests, one builder-level and two against the database; all fail on master. The Oracle suite passes 421/421 on net9.0 and net10.0. Found by Quartz.NET's Weasel integration.

…x settles on the migration path

OracleDbCommandBuilder hands back a new OracleCommand per statement once a
batch holds more than one, and those commands dropped the options set on the
command being built. Table, View, MaterializedView and Trigger set
InitialLONGFetchSize = -1 there, so on the migration path ALL_IND_EXPRESSIONS
.COLUMN_EXPRESSION and ALL_VIEWS.TEXT read back empty: a descending index key
came back as its hidden SYS_NC...$ column and the index was dropped and
recreated on every apply, and views migrated together were replaced on every
apply. Table.FetchExistingAsync was unaffected because it runs its own
commands.

The split commands now copy CommandTimeout, FetchSize, InitialLONGFetchSize
and InitialLOBFetchSize from the command being built, so a batch of several
statements behaves as a batch of one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit c57c8d5 into JasperFx:master Sep 29, 2026
18 checks passed
jeremydmiller added a commit that referenced this pull request Sep 29, 2026
Five fixes since 9.36.0:

- #661 SQL Server 2025 JSON indexes were read back with no columns and emitted as a
  DROP INDEX, so every migration silently removed one. Also adds JsonIndexDefinition.
- #660 Oracle split commands dropped InitialLONGFetchSize, so a descending index key and
  a view body read back empty and neither ever settled (thanks @lahma).
- #658 TableDelta.HasChanges() threw a NullReferenceException for a table that does not
  exist yet, on SQL Server, PostgreSQL and SQLite.
- #659 A failed migration never released the global migration lock, locking every other
  replica out of migrating.
- #664 The reconnection loop abandoned the connection it replaced.

Minor rather than patch: #661 adds public API and two of these change migration output,
which is the same reason 9.29.1 was renumbered to 9.30.0.

Verified locally across both target frameworks, all eight suites, against PostgreSQL 17,
SQL Server 2022, MySQL 8.0.46, Oracle Free 23 and SQLite:
net9.0 3984 passed / 0 failed, net10.0 3980 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeremydmiller pushed a commit that referenced this pull request Oct 4, 2026
* Firebird: a provider that names, quotes and types Firebird 3, 4 and 5 objects

Weasel.Firebird starts with what every later piece stands on. FirebirdProvider
maps CLR types onto Firebird's (a Guid is CHAR(16) CHARACTER SET OCTETS, a
DateTimeOffset a TIMESTAMP WITH TIME ZONE bound as FbZonedDateTime, because
FirebirdClient rejects the offset itself), and FirebirdIdentifierRules is
Oracle's rule set with Firebird's lexis: an ASCII letter first, then letters,
digits, _ and $, and the union of the reserved words of 3, 4 and 5, measured by
probing each server, so one script runs on all three.

Firebird before 6 has no schemas. FirebirdObjectName carries the pseudo-schema
PUBLIC, Firebird 6's default, and never writes it; another schema is still
constructible, because the conformance suites expect a parsed name back, and is
refused at the DDL boundary by AssertDefaultSchema.

Firebird executes one statement per command, so FirebirdDbCommandBuilder
splits on StartNewCommand the way Oracle's does, with @ markers and a cloned
FbParameter for a parameter two statements share. The typed CommandBuilder
never splits, because Compile() is not virtual. FirebirdServerVersion reads the
version off an open connection for the few catalog reads that differ between
versions.

Firebird joins identifier_rules_conformance and
object_name_normalization_conformance.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: FirebirdScript writes and splits DDL the way isql runs it

Firebird executes one statement per command, so everything the provider
renders has to split back into statements, and a db-patch script has to run in
isql unchanged. FirebirdScript owns both halves. WriteStatement ends a plain
statement in ';'; WritePsql wraps an EXECUTE BLOCK, procedure or trigger in
SET TERM ^ ; ... ^ ... SET TERM ; ^, because its body is full of semicolons.
Split is a lexer modelled on SqlServerBatchSplitter's contract: it knows '...'
literals with doubled quotes, q'{...}' literals, "..." identifiers and both
comment forms, recognises SET TERM only where a statement starts, and drops
comment-only fragments.

Two refusals fire when the DDL is written rather than when it runs: a PSQL body
with '^' outside a literal or comment, which isql would silently truncate there
(weasel#624; '^=' is Firebird's not-equal), and a plain statement with a bare
';', which is two statements or PSQL in the wrong writer.

WriteIsqlScript puts COMMIT; after every statement. Without it isql runs a
guarded block and the plain DDL after it in one transaction, the commit fails
with 40001 and the guarded object is silently lost -- measured on 3, 4 and 5.

Guarded is the one place DDL is written into a string literal, so the one place
its quotes are doubled (weasel#643). It wraps a CREATE or ADD in an EXECUTE
BLOCK that runs only when the probe finds nothing, which makes patches
re-runnable and races safe, and writes a statement longer than 16,000
characters as literals joined with ||, since a UTF8 attachment caps one
literal at 16,383. It lands here rather than with the table model so the PSQL
objects can use it from this commit on.

FirebirdClient's FbScript is not used: it keeps leading comments, throws on
statement kinds it cannot classify, and rejects q'{...}' literals and CREATE OR
ALTER SEQUENCE. A test holds the two splitters to the same answer on the
scripts FbScript can parse.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: tables that create, read back and migrate through guarded DDL

The table model follows Weasel.Oracle's shape with MySQL's index semantics.
Every CREATE and ADD -- table, column, index, foreign key -- is an EXECUTE
BLOCK that runs only while its object is missing, and every DROP only while it
exists, so a patch runs twice cleanly (weasel#620) and racing appliers do not
collide. The probes compare RDB$..._NAME = '...' directly, never through TRIM,
so the catalog's index is used. Foreign keys are separate statements, and
TableDelta takes SQL Server's deferrable-foreign-key contract so two tables
that reference each other are created by one migration. Dropping a table first
drops the keys in other tables that reference it: Firebird has no CASCADE.

FirebirdColumnType folds synonyms onto the catalog spelling (INT, CHARACTER
VARYING, NCHAR, BINARY(n), BLOB SUB_TYPE 1...) and rebuilds a type from
RDB$FIELDS. Character set, collation and NUMERIC precision are compared only
when the model states them (weasel#644), and FLOAT(p) is folded by the
server's version, because Firebird 3 stores a double from 8 and 4 from 25.
COLLATE is written last, as Firebird's grammar wants; identity is written
GENERATED BY DEFAULT AS IDENTITY NOT NULL, in that order.

Introspection reads four result sets per table through
FirebirdDbCommandBuilder -- columns with nullability and collation coalesced
from the column and its domain, the key in segment order, foreign keys through
RDB$REF_CONSTRAINTS, and only the indexes that back no constraint (weasel#445).
It lands here rather than with the deltas because ConfigureQueryCommand is
abstract. TableDelta compares defaults and nullability only under
DetectColumnDrift, reads DEFAULT NULL as no default, folds a catalog RESTRICT
into NoAction, filters IgnoredIndexes on both sides (weasel#642), withholds
drops on add-only tables (weasel#629), and reports as Invalid, with a reason,
what Firebird cannot do in place: narrowing a column, changing the type of a
key column (335544538, widening included), and adding an identity or a NOT
NULL column without a default.

FirebirdMigrator renders a whole migration before running any of it, so every
refusal fires first, then runs each statement in its own WAIT transaction with
a lock timeout, rolled back whenever executing or committing throws; a guarded
statement that loses a catalog race is run again. WriteScript produces the
isql form with COMMIT after every statement. Identifiers are held to 31 bytes
by default -- Firebird 3's limit, in bytes -- and refused rather than
truncated; a table whose derived pk_{table} is over the limit is told to set
PrimaryKeyName. A schema other than PUBLIC is refused at every DDL writer.

The docker-compose file gains Firebird 3, 4 and 5 on ports 3063-3065, and the
test harness gives each test class a database file of its own, recreated per
test with 16 KB pages, so the first round trips run here on all three
servers. Firebird joins the Core suites: termination (exempt, as Oracle is,
because each query is a command of its own), the support matrix, column
names, check constraints, identifier coverage and primary key order.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a teardown that empties the database, and a database that ensures itself

Firebird 3, 4 and 5 have no schemas and no server-side cascade, so
DropSchemaAsync enumerates, the way SQL Server's and Oracle's do: foreign keys,
triggers, packages, procedures, functions (legacy UDFs included), views,
tables (global temporary ones included), sequences, exceptions and domains.
Every query filters on RDB$SYSTEM_FLAG = 0, which leaves out the two triggers
every CHECK constraint gets (flag 3) and the generator behind every identity
column (flag 6) -- both undroppable, both gone with their owner. It repeats
until a pass drops nothing, which settles a procedure that calls another or a
view over a view without a dependency graph. The pool is cleared first,
because an idle pooled attachment still holds the tables it touched, and every
drop runs under WAIT. The test creates every kind of object with raw SQL and
asserts the database comes out empty, so a kind nobody taught it about fails
here rather than silently surviving (weasel#464, weasel#465).

EnsureDatabaseExistsAsync opens the database first and creates it only when
the open fails because the file is missing (weasel#647's lesson from MySQL),
told apart from a server that is down by the error numbers, not the SQLSTATE
class, which is 08 for both. It creates with 16 KB pages, since Quartz's UTF8
schema does not fit 8 KB, and treats a create that finds the file there -- or
loses a race to another creator -- as success.

The compose services and the per-class test harness arrived with the table
model in the previous commit, so that commit could run its round trips on all
three servers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: tables migrate on Firebird 3, 4 and 5, Quartz's job store schema included

The table integration suite runs the introspection and delta machinery against
real servers. Deltas are detected and converge for columns, indexes (direction,
uniqueness, expressions, activity), foreign keys (actions, composite order),
and primary keys (pinned order, added, dropped); synonyms and the CLR type map
read back as no change; widening converges with its data and what Firebird
cannot do in place is refused before anything runs, with the server's own
refusals measured beside it; defaults and nullability converge under
DetectColumnDrift, and a nullable ADD ... DEFAULT is shown to leave existing
rows NULL. index_scenarios joins the shared IndexScenarioMatrix; names that
need delimiting round-trip everywhere they appear; the 31-byte limit is
measured on Firebird 3 and refused by Weasel first; five racing appliers all
succeed; and DDL beside uncommitted DML waits under the migrator's WAIT
transaction where FirebirdClient's default NO WAIT fails at once.

quartz_job_store_schema_round_trips holds Quartz.NET's 14 Firebird tables to
Weasel both ways on a UTF8 and a NONE database: the script's schema reads back
as no change against a model written the way Quartz.Weasel will write it
(add-only, upper-case, PK_/FK_..._1/IDX_ names, DEFAULT NULL), Weasel's schema
prints the same catalog as the script's, and a 4.2-shaped database gains the
4.3 columns with its rows intact.

Three fixes came out of running it. A guard that probed by name alone treated
a primary key's own index, or a view, of the same name as the object already
there and skipped the statement silently, leaving drift no migration could
close; each probe now looks for that kind of object on that table, so a real
clash reaches the server and fails loudly. A partial index on Firebird 3 or 4
is refused before the first statement instead of failing halfway, since DDL is
written without knowing the server. GenerateDeleteAllSql declares only the
variables it uses.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: rollbacks, delete-all, fingerprints and failure classification, all run as isql runs them

A rollback is rendered DDL like any other, so SplitIntoBatches returns
FirebirdScript.Split and executeRollback runs each statement in its own WAIT
transaction, as an apply does. The default of one batch sent the whole
rollback as one command, which Firebird refuses at its second statement --
the gap Oracle still has.

GenerateDeleteAllSql is one EXECUTE BLOCK, since DatabaseCleaner runs it as a
single command: it deletes children first, finds each table in the catalog
ignoring case so a case-preserved EF table is found too, and restarts
identities so the next value is 1 -- RESTART WITH 0 on Firebird 3 and 1 on 4
and 5, asked of the engine at run time.

Two Core seams, each byte-identical for every other provider.
Migrator.FingerprintTableName names the fingerprint tables, defaulting to
{DefaultSchemaName}.{table} as before; Firebird names them alone, because
PUBLIC.weasel_schema_fingerprints is a syntax error there, and SchemaFingerprint
and DatabaseBase take it from the migrator. DatabaseBase.ToDatabaseScript wrote
past the writer WriteScript hands its step into the outer one, so Firebird's
whole-database script lacked the COMMIT after every statement that A3
requires; it now writes through the step's writer, which every in-tree
migrator passes straight through. Core.Tests pins both, and the PostgreSQL
fingerprint tests pass against the change.

IsInsufficientPrivilege reads every number in FbException.Errors for 335544352
and the create-denials 335545094 (Firebird 3) and 335545264 (4 and 5);
IsTransientConnectionFailure covers 335544721, 335544727/335544726, 335544856
and a client TimeoutException, never the 08 SQLSTATE class, which a missing
file shares; ReleaseConnectionPoolAsync clears FirebirdClient's pool; and
IsSystemColumn knows RDB$DB_KEY and RDB$RECORD_VERSION. Each is tested
against a real server: a limited user, a killed attachment, a closed port.

running_scripts_through_isql feeds a migration patch, its drop file, an
update patch and the whole-database script to isql -q -b -i inside the
server's container, and runs the patch twice. It runs when
weasel_firebird_isql_container names the container and is skipped otherwise.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: sequences whose first value is StartWith on Firebird 3, 4 and 5 alike

Firebird 3 and 4 read START WITH differently: 3 sets the current value, so
the first NEXT VALUE is one increment past it, and 4 and later hand out the
value itself. DDL is written without knowing the server, so a sequence with a
start value or an increment is created by an EXECUTE BLOCK that asks the
engine its version and passes 3 the value one increment lower; the tests
measure the first two values on all three servers. The create is guarded,
never CREATE OR ALTER SEQUENCE -- a syntax error without an option, and a
reset of a sequence in use with one.

An increment the model states is compared, and a changed one is altered in
place by SequenceDelta, keeping the value the sequence has reached; an
existence-only delta would drop and recreate it and hand out values already
used, which EF Core's HiLo depends on not happening. The start value is not
compared, because the catalog keeps where a sequence has got to rather than
where it began. The introspection query stays unterminated, and an identity
column's generator (system flag 6) is not mistaken for a sequence.

FirebirdMigrator.CreateSequence returns one, and Sequence joins the Core
termination and support-matrix suites.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: computed columns are written, read back and migrated, as MySQL's are

A column with ComputedExpression is written COMPUTED BY (expression), read
back from RDB$FIELDS.RDB$COMPUTED_SOURCE without the parentheses the server
keeps, and compared the way index expressions are -- letters folded,
whitespace ignored beside operators, literals and delimited identifiers kept
exactly -- so no spelling of the same expression drifts. That comparison now
lives in ExpressionText, shared with IndexDefinition, and learned to treat a*b
and a * b alike while keeping a or b apart from aor b.

What Firebird allows was measured on 3 and 5 and the delta follows it. A
changed expression or type is altered in place, both together, because ALTER
... TYPE alone on a computed column is refused as removing the COMPUTED. A
column cannot change between computed and stored in place, so that delta is
Invalid, says so, and is refused before anything runs short of AutoCreate.All.
A computed column can be added to a table with rows and dropped again, and a
rollback restores the previous expression.

Firebird's computed columns are virtual only, so ComputedColumnIsStored is
refused with a message that says so; a default, an identity or a collation on
a computed column is refused too. NOT NULL is not written -- the server
rejects it beside COMPUTED BY -- and neither nullability nor a default is
compared for a computed column under DetectColumnDrift. ColumnExpression gains
ComputedBy().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a guarded statement is run again only after a race, and is recognised after a comment

IsCatalogConflict no longer counts "unsuccessful metadata update" (335544351)
on its own. It heads every failed DDL statement, so a guarded statement that
failed for good -- an index named like a key's own index, a column that is
not there -- was run MaxGuardedStatementAttempts times, with a growing delay
between, before the failure surfaced. A lost race still carries one of the
numbers that remain: the catalog's unique key violation, a lock conflict at
commit or a lock timeout. A deterministic failure now surfaces at once, which
a test holds with twenty attempts allowed.

Whether a statement is guarded, and whether it is a COMMIT a script can skip,
is now decided past leading whitespace and comments by FirebirdScript, which
reads them with the same lexer that splits the script. Split keeps a comment
written before a statement, so a hand-written script that commented its
guarded block lost the retry; a test now times out the first attempt on an
uncommitted insert and sees a later one succeed. EXECUTE and BLOCK may also
be separated by any whitespace or a comment.

IsPermissionErrorNumber is internal: it exists for the tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: dropping the schema drops user collations too

CREATE COLLATION makes an object DropSchemaAsync did not know about, so a
database a test had given one did not come out empty. Collations are now
dropped after domains, since a column or a domain may use one, and the
teardown test creates a collation that a domain and a column both use and
counts RDB$COLLATIONS among the objects that must be gone.

Roles and mappings stay, and the remarks say why: they say who may do what
rather than what the database holds, and emptying it for the next test or
migration is no reason to revoke an administrator's grants.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a column a unique constraint covers is not retyped in place

Firebird refuses to change the type of a column an integrity constraint
covers, widening included ("Cannot update index segment used by an Integrity
Constraint", measured on 3 and 5). The delta knew that for the primary key,
unique indexes and foreign keys, but a UNIQUE constraint was invisible to it:
its index is left out of the table's indexes, as every constraint's is, and
the model has no unique constraints of its own. A table created outside
Weasel with one therefore produced an Update that failed halfway through.

The key query now reads unique constraints beside the primary key, into
UniqueConstraintColumns, and keyColumns counts them, so widening such a
column is Invalid, says why, and is refused before anything runs. A table
with a unique constraint and an unchanged model still compares as None.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: every name a migration creates is held to the length limit before anything runs

Only the derived primary key name was checked against MaxIdentifierLength
when DDL was written. An index name derived from a table and a column,
idx_people_a_much_longer_column_name say, passes 31 bytes easily, and the
server refuses it outright, so the migration stopped at that statement with
every table before it already committed.

With a FirebirdMigrator, creating a table now checks its name, its columns',
its primary key's, its indexes' and its foreign keys', and an update checks
the columns, key, indexes and foreign keys it adds. Everything is written
before anything runs, so a refused name leaves the database untouched, which
the tests show for a two-table migration, a foreign key, a column and an
update through ApplyChangesAsync. A name already in the database is compared,
not created, and is not checked again. The message says what the name
belongs to; the derived primary key name still says which setting to use.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a migration is rolled back last delta first, so a view goes before its table

SchemaMigration.WriteAllRollbacks undid a migration's deltas in the order
they were applied, for RollbackAllAsync and a migration file's drop script
alike. PostgreSQL cascades the drop of a table a view uses, and MySQL and
Oracle leave the view invalid, but Firebird refuses to drop a table, or a
column, that a view, procedure or trigger still uses, so rolling back a
migration that created a table and a view over it failed on the table.

Migrator.OrderRollbacks is the seam: the base keeps the applied order, so
every other provider writes exactly what it wrote before, which a Core test
holds for each of them. FirebirdMigrator undoes the deltas last to first.
Tests roll back a table with a view over it, a new column with a view over
it, and run a migration file's drop script, on Firebird 3, 4 and 5.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: deleting all data empties the table named, not every table that folds to its name

GenerateDeleteAllSql looked each table up with UPPER(TRIM(RDB$RELATION_NAME)),
so naming "Blogs" also emptied a BLOGS beside it, and restarted its identity.
Each table is now the one whose stored name is exactly the name given, which
is how a case-preserved table is stored, and only when there is no such table
the upper-case name an undelimited one is folded to. The deletes and the
identity restarts resolve names the same way. A test creates "Blogs" and
BLOGS, empties one, and sees the other keep its row and its identity.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a name longer than the catalog holds is refused before introspection binds it

Introspection binds a table's or a sequence's name as a parameter against a
catalog column, CHAR(31) on Firebird 3 and CHAR(63) from 4 on. A longer name
failed the query with "string truncation" (335544321), which says nothing
about the name, and it did so on every way in -- DetermineAsync,
FetchExistingAsync, FindDeltaAsync, ApplyChangesAsync, ExistsInDatabaseAsync
and a database checking itself -- before the length checks that run when DDL
is written could say anything. Stream S4 found it validating Quartz's schema.

A name the server's catalog cannot hold is now refused as over the limit
before it is bound, whatever MaxIdentifierLength is set to, and the message
says no object by that name can exist. A name the catalog holds but the
migrator's limit does not is still read -- there is no such table -- and
refused when its DDL is written. Tests cover each entry point on Firebird 3,
4 and 5.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: views that read back unchanged and alter in place

A Firebird view is written as one plain CREATE OR ALTER VIEW. It adds,
removes, renames and retypes columns in place, so the views, procedures and
grants that depend on the view survive an update, where a drop would be
refused. Firebird keeps the query as written in RDB$VIEW_SOURCE -- the text
after AS, trimmed, comments included -- so the delta compares the two texts
ignoring whitespace and case outside literals. The source is read as a blob,
whole, so a query past 8191 characters does not drift. The drop is guarded.
CreateOrAlterDelta keeps the view read from the catalog, so a rollback puts
the old query back.

Two fixes in shared code, each with a test that fails without it:
- FirebirdMigrator runs a CREATE OR ALTER again on a catalog conflict, as it
  does a guarded EXECUTE BLOCK. Four concurrent CREATE OR ALTERs of one object
  measured three losers on Firebird 3, 4 and 5 ("update conflicts with
  concurrent update", or a unique key violation in the catalog), and the
  statement leaves the same object however often it runs.
- FirebirdScript.WriteStatement puts the terminator on its own line when the
  statement ends in a line comment, which a view's query can. On the same
  line the ';' was part of the comment and the statement ran into the next.

View joins the Core termination and support-matrix suites, and
DropSchemaAsync's view step is proven with the typed object, a view over a
view included. docs/firebird/views.md, with its sample_firebird_* regions in
src/DocSamples/FirebirdViewSamples.cs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: functions, procedures and triggers compared whole, not by their bodies alone

Firebird keeps only a routine's body as source -- the text after AS -- and
records its header in RDB$FUNCTION_ARGUMENTS and RDB$PROCEDURE_PARAMETERS.
Comparing the body alone would miss a changed parameter type for good, so
Function and StoredProcedure parse the model's statement into the same parts
and compare each: every parameter's name, position, type, NOT NULL, collation
and default (= or DEFAULT; DEFAULT NULL is a default, since it makes a
parameter optional), a function's return type and DETERMINISTIC, SQL SECURITY
on Firebird 4 and later, and the body. Types fold through FirebirdColumnType,
so INT matches INTEGER and a character set, collation or precision the
statement leaves out is not compared; a domain, TYPE OF a domain and TYPE OF
COLUMN are compared by name, and FLOAT(p) is read for the server. One
unterminated query per routine reads the routine and its parameters together.

The statement's verb is rewritten to CREATE OR ALTER and written through
FirebirdScript.WritePsql, so a '^' in the body is refused (weasel#624) and the
callers and grants survive a signature change. A statement naming another
routine than the identifier -- "fn" delimited against fn -- is refused rather
than creating one routine and looking for another forever; so is an external
(UDR) routine. FetchExistingAsync rebuilds a statement from the catalog, names
delimited, that recreates the routine, and a rollback runs it.
Function.ForRemoval and StoredProcedure.IsRemoved drop.

Trigger writes CREATE OR ALTER TRIGGER ... ACTIVE -- without ACTIVE an inactive
trigger stays inactive -- and compares its relation, the timing and events
unpacked from RDB$TRIGGER_TYPE as a set (UPDATE OR INSERT is 11, INSERT OR
UPDATE 17: one trigger), whether it is active, and the body. Table and view
triggers only, as on MySQL and Oracle; WHEN, INSTEAD OF and TRUNCATE are
refused, and ForEachRow is not emitted, as on MySQL and SQLite.

The three types join the Core termination and support-matrix suites, and
docs/core/object-types.md gains a Firebird column. DropSchemaAsync takes each
with it, proven with the typed objects; the racing-appliers test covers all
four PSQL kinds; and a patch of them runs through isql twice, then its drop
file. docs/firebird/{functions,procedures,triggers}.md, with their samples in
src/DocSamples/FirebirdPsqlSamples.cs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a trigger names a case-preserved table the way the table names itself

A trigger wrote its table through the folding rule alone, so a trigger on an
EF Core model's table -- "Blogs", case preserved -- came out FOR Blogs, which
Firebird folds to BLOGS: another table, or none, and a Create reported forever.

Trigger.PreserveTargetCase names the table exactly, delimited in the DDL and
bound as-is in the catalog, through SchemaUtils.QuoteName and CatalogName with
the same flag Table uses. The constructors that take a Table copy it from
PreserveIdentifierCase, so the trigger resolves its table as the table does. A
trigger read from the catalog keeps the catalog's exact spelling, so a rollback
lands on the same table. The tests create, fire, compare and roll back a
trigger on a case-preserved table, and see the folded name as another table.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: views, routines and triggers refuse a name the catalog cannot hold before binding it

A view, function, procedure or trigger name longer than the server's catalog
holds -- 31 bytes on Firebird 3, 63 characters from 4 on -- failed its
introspection query with "string truncation" (335544321), which says nothing
about the name. Each ConfigureQueryCommand now calls
FirebirdMigrator.AssertCatalogCanHold first, as Table and Sequence do, so a
migration, a delta and a read all refuse it as over the limit. One test per
object type covers all three ways in, and fails without the check.

With rollbacks now undone last to first, the rollback tests build on the typed
View rather than the raw stand-in, and a new one rolls back a table with a
view, a function, a procedure and a trigger over it and finds nothing left.
The isql patch of views, routines and triggers includes their table again, so
its drop file drops the table last. Before that drop the test lets go of its
own attachment: Firebird will not drop a table an attachment has in use, and
isql would wait on it for good.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: CI on Firebird 3, 4 and 5, and a package in every release

ci-build-firebird.yml runs the Firebird suite on net10.0 against the official
firebirdsql/firebird images 3, 4 and 5, and on net9.0 against 5 only: four
jobs rather than six, because each is a slot out of the org-wide ceiling
(weasel#611). The provider's code does not differ by target framework; what
differs by server is the catalog it reads. The workflow carries the same
concurrency group as the other seven, and every "seven workflows / 18 jobs"
comment now says eight and 22.

The server is a service container gated on health rather than on its port,
because the first connection right after start fails for about half a second.
The image has no nc or curl, so the check asks the service manager, which needs
no database. Its credentials come from ISC_USER and ISC_PASSWORD, not from the
command line: the env context is not available in a service's options, which
actionlint confirms, so ${{ env.db_pwd }} there would not expand.

publish_nuget.yml packs Weasel.Firebird, and the solution lists the workflow.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: EF Core models map, migrate and read back through Weasel.Firebird

The EF Core suite gets a Firebird folder mirroring MySQL's and Oracle's: an
entity mapped to a table with EF Core's casing verbatim, a table EF Core
creates and uses, a migration that finds the table missing, and one more than
the other providers have -- a table Weasel creates from the EF model, which
reads back as None and which EF Core then writes to and reads from through its
own quoted SQL. The database cleaner is covered as well: its one EXECUTE BLOCK
has to find the table by the case-preserved name EF Core gave it.

FirebirdSql.EntityFrameworkCore.Firebird has a release for each EF Core the
suite targets (12.0.0 for 9, 13.0.0 for 10), so unlike Oracle and MySQL the
Firebird tests run on both frameworks, and ci-build-efcore.yml starts a
Firebird 5 service on both legs.

That fourth test found a bug in the bridge rather than the provider.
CreateMigrationAsync introspected through a plain DbCommandBuilder, so every
object's queries went to the server as one command. Firebird and Oracle run one
statement per command, and each has a migrator whose command builder splits the
batch -- which is what the rest of the migration path already uses. Firebird
failed with "Token unknown"; Oracle failed with ORA-03048, which is why its
can_create_migration_and_apply had been skipped as "a pre-existing bug in
Weasel.Oracle schema detection SQL". CreateMigrationAsync now goes through
SchemaMigration.DetermineAsync(conn, migrator, ...), which also batches a large
model by the migrator's parameter limit, and the Oracle test runs again. Both
fail without the change and pass with it.

The database file is the suite's own (weasel_efcore.fdb, beside the one
weasel_firebird_testing_database names), because the tests drop and recreate it:
Firebird has no DROP TABLE IF EXISTS before version 6, and DROP TABLE refuses a
table an idle pooled attachment has read.

Ran the whole EF Core suite locally against every database it uses, before the
cleaner test joined: 164 passed on net9.0 and 159 on net10.0, none skipped. The
six Firebird tests then pass on Firebird 3, 4 and 5.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: provider docs and samples, and Firebird in every provider table

docs/firebird/index.md covers what is Firebird-specific about the provider: the
connection string (an absolute database path, and the charset a created
database inherits), the type map, the migrator's settings, ensuring the
database exists, the PUBLIC pseudo-schema and the refusal of any other schema
before Firebird 6, identifiers (31 bytes on Firebird 3, 63 characters by
opt-in, refused rather than truncated), how a migration runs -- one WAIT
transaction per statement, guarded CREATE and ADD, what each change does beside
a running application, and that a nullable ADD ... DEFAULT leaves existing rows
NULL -- the 16 KB pages a UTF8 key needs, and the isql form of a script.
docs/firebird/tables.md mirrors MySQL's page: defining a table, foreign keys,
whole-index direction, partitioning and check constraints, the synonyms delta
detection folds, and what a delta can change in place. Their code is
sample_firebird_* regions in src/DocSamples/FirebirdSamples.cs, and
mdsnippets.json excludes src/Weasel.Firebird as it does the other providers.

The nav and sidebar list all seven Firebird pages; views, functions, procedures,
triggers and sequences are written with their object types. Firebird joins the
provider tables on the home page, the guide, installation, the trait matrix,
identifiers, schema objects, schema migrations, db-apply's transient-failure
list and the EF Core pages, and CLAUDE.md's teardown table, termination
exemption and connection-string list. README.md's intro line now names all six
providers.

mdsnippets and npm run docs-build both pass. mdsnippets 28.4.2 also rewrites
unrelated pages -- line endings everywhere, snippet anchors that keep their case,
and two snippets whose markdown had drifted from its source -- none of which is
Firebird's, so none of it is here.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a sequences page, computed columns, and what racing appliers and rollbacks do

The pages now describe the provider as it finally stands. docs/firebird/sequences.md
covers the sequence: a guarded CREATE SEQUENCE, StartWith as the first value on
Firebird 3, 4 and 5 alike (Firebird 3 is written one increment lower), never
CREATE OR ALTER SEQUENCE, and a delta that changes only the increment, in place,
keeping the value reached. Its code is four more sample_firebird_* regions.

tables.md gains computed columns -- COMPUTED BY, virtual only, altered in place,
addable to a table with rows, and Invalid when a column switches between
computed and stored -- and the DDL the final code writes. The overview says
which objects are guarded and which are CREATE OR ALTER; that a statement that
loses a catalog race, and only such a statement, is run again, which is what
makes concurrent appliers safe without a lock; that a rollback undoes the last
delta first, because Firebird refuses to drop what a view, procedure or trigger
still uses; that a name the server's catalog cannot hold is refused before
introspection binds it; and, under its own heading, what WAIT means beside a
running application.

The core triggers and procedures pages get Firebird's column and rows, the trait
matrix lists ComputedBy, and the fingerprint section says the table is
unqualified on Firebird. The privilege and transient error numbers cited in
schema-migrations.md and db-apply.md, NewDatabasePageSize, LockTimeout and
MaxGuardedStatementAttempts were checked against FirebirdMigrator. No Core page
lists the migrator's extension points, so Migrator.OrderRollbacks is described
where Firebird uses it rather than in a Core list.

mdsnippets and npm run docs-build pass with all seven Firebird pages; the EF
Core Firebird tests pass on Firebird 3, 4 and 5 after the rebase.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: the second loser of an index or foreign key race is run again too

80522a0 stopped counting a bare "unsuccessful metadata update" (335544351) as
a race, and so stopped re-running the double loss the spike recorded: two
appliers pass the guard of a CREATE INDEX or ADD CONSTRAINT ... FOREIGN KEY,
one loses with the catalog's unique key violation (335544665) when it runs,
the other with "too many keys defined for index" (335544631) when it
commits. Stream S4 measured 236 of 300 racers failing on Firebird 4 and 112
of 150 on 3. 335544631 is a race again; a bare 335544351 still is not.

The race-loss codes, re-derived from the spike's S13 runs: 335544665 for
CREATE TABLE, ADD COLUMN, CREATE SEQUENCE, CREATE INDEX and ADD FK when they
run; 335544631 for CREATE INDEX and ADD FK at commit; a lock conflict
(40001, 335544345) or a lock timeout (335544510) under WAIT; and 335544336
with 335544451 for CREATE OR ALTER.

335544631 is also what an index over more than 16 columns gets, with the
same numbers, SQLSTATE and timing, so nothing tells the two apart. Weasel
now refuses a primary key, index or foreign key over 16 columns before
anything runs; a hand-written one is run MaxGuardedStatementAttempts times
before it surfaces, and the page says so.

concurrent_appliers raced whole migrations, where the first lost CREATE
TABLE staggers the racers and the index and key statements rarely collide.
Three new tests determine five appliers' migrations first and start them
together, ten rounds each, racing guarded CREATE INDEX, ADD FOREIGN KEY,
and CREATE TABLE, ADD COLUMN and CREATE SEQUENCE; every applier has to
succeed and every object has to exist once. Without 335544631 the index and
foreign key tests fail on round 0 on Firebird 3, 4 and 5.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a TableDelta for a table that does not exist yet answers, as gh-658 made the others

Upstream's gh-658 (#662) made PostgreSQL's, SQL Server's and SQLite's
TableDelta build its ItemDeltas against no actuals when the table is not
there, so HasChanges() and the rest answer rather than throw. Firebird's
already built them and answered, but by running the pairing constructor
against an empty list; it now does what the other three do, through
ItemDelta.AllMissing, with ignored indexes left out and nothing withheld.
table_delta_for_a_missing_table is SQLite's test, ported.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: split introspection commands keep the options set on the command being built

Upstream's #660 found Oracle's split commands dropping what a schema object
had set on the command being built -- InitialLONGFetchSize, and with it a
LONG read back empty. FirebirdDbCommandBuilder splits the same way and had
the same gap: a single statement ran the built command, CommandTimeout and
FetchSize included, and several ran fresh ones with the defaults. Each split
command now carries both, and a test holds it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a CREATE OR ALTER that finds its object created mid-statement is run again

Firebird looks for the object a CREATE OR ALTER names, finds none, sets out to
create it, and looks again before storing it. When another applier commits the
same object between the two looks, the statement fails with that kind's
"already exists": "Table @1 already exists" (336068740) for a view, "Procedure
@1 already exists" (336068743), "Function @1 already exists" (336068876). The
migrator did not count that as a lost race, so the loser failed where a re-run
would simply have altered what the winner made -- which is how the racing
appliers test failed on CI's Firebird 5 runner.

For a CREATE OR ALTER, and for the kind of object it creates, nothing else
produces that number: an object of that kind that was there all along is altered
instead. So it is now a race for those statements and those numbers only.
Another kind's "already exists" is a name clash that recurs -- a procedure named
like a table gets 336068740, a view named like a procedure 336068743 -- and is
still reported on the first attempt, as bare 335544351 still is. A trigger's
loser gets the catalog's unique key violation, which was already a race.

Losers of a CREATE OR ALTER race write the catalog again when they re-run, unlike
a guarded statement's, so retrying in step they raced each other; the backoff's
random part now widens with each attempt so they come back apart.

Measured by stressing ten appliers released together on Firebird 3, 4 and 5:
losers get the unique key violation (335544665), deadlock with update conflict
(335544336, 335544451), or the view's and function's "already exists"; the
procedure's number is the one a duplicate CREATE PROCEDURE gets. The racing test
now releases ten appliers, twenty-five rounds, against a table that exists
already, and failed in every one of seven local runs without the change. Two
tests pin that a clash with another kind is not run again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: a guarded CREATE that finds its object created after its guard is run again

The CREATE OR ALTER race has a guarded twin. A guard reads the catalog, finds
nothing, and runs its CREATE -- and when a racing applier commits between the
two, the CREATE fails with its kind's "already exists". Firebird 3 answered a
guarded CREATE TABLE with "Table @1 already exists" under ten racing appliers.
Run again, the guard sees the object and the block does nothing, so for the
kind of object the block creates it is a lost race.

A guarded block is classified by the statement it executes, read out of its
EXECUTE STATEMENT literal: 336068740 for CREATE TABLE, "Sequence @1 already
exists" (336068862) for CREATE SEQUENCE, in either branch of the version-aware
block a sequence with a start value gets. A column's, domain's and trigger's
loser already got the unique key violation.

"Index @1 already exists" (336068859), for an index or a constraint, is left
out. It is also what a name taken by another table's index or by a constraint's
own index gets -- a clash the guard does not see, which the existing test pins
to surface on the first attempt -- and no racing applier was seen to get it;
theirs get the unique key violation or "too many keys defined for index".

One clash shares a counted number: a view holding a table's name gets 336068740
too. The table's guard looks for a table, so the block runs again, fails the
same way, and surfaces when the attempts run out; a test pins that it surfaces,
and within the bound.

The racing test releases ten appliers twenty-five times, creating a table, its
index and a sequence, then adding a column. Without the change it failed once
in five local runs, on Firebird 3; the race is rare here, so it guards the
outcome more than it reproduces the failure. A unit test pins the
classification against the statements Table and Sequence write.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Firebird: an "already exists" is run again once, at once, which settles a race and surfaces a clash

A guarded statement or a CREATE OR ALTER that fails with the "already exists" of
the kind of object it creates has either lost a race -- another applier
committed between its look at the catalog and its store -- or hit a name clash
it does not look for. CI's Firebird 3 runner showed the index case the previous
change left out: a guarded CREATE INDEX answered "Index @1 already exists"
(336068859) under racing appliers, where a guard that found nothing was
overtaken by a peer's commit.

The two now get told apart by running the statement once more, immediately, in
a fresh transaction. After a race the statement sees the object: the guard
skips it and CREATE OR ALTER alters it. A clash -- a view holding a table's
name, an index name taken on another table or by a constraint's own index --
fails the same way again and is reported at once, without waiting out the
retries a lock conflict gets. The numbers, by the kind a statement creates:
336068740 for CREATE OR ALTER VIEW and a guarded CREATE TABLE, 336068743 for a
procedure, 336068876 for a function, 336068862 for a sequence, and 336068859
for a guarded CREATE INDEX or ADD CONSTRAINT. Another kind's number is still
reported on the first attempt, and the jittered, bounded retry is unchanged for
the lock-conflict codes.

The migrator's wait before a jittered retry is now a seam a test can replace,
so the clash tests state the contract exactly: an index named like a primary
key's own index, and a table named like a view, surface after one immediate
re-run with no wait at all. The racing stress races sixteen appliers for forty
rounds on Firebird 3 -- without the re-run it failed five of six local runs
with the index's "already exists" -- and ten for twenty-five on Firebird 4 and
5, which take several times as long over the same work.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lahma added a commit to quartznet/quartznet that referenced this pull request Oct 5, 2026
…le drops its fetch-size builder

The six older Weasel rows move to [9.39.0, 10.0.0), beside Weasel.Firebird,
which started there. 9.37.0 carries JasperFx/weasel#660: the commands an
Oracle comparison splits off keep InitialLONGFetchSize, so the descending
PRIORITY of IDX_QRTZ_T_NFT_ST reads back whole on the migration path.
QuartzOracleMigrator therefore loses its LongFetchingCommandBuilder and
keeps only RefuseDestructiveChanges. 9.39.0 is the first release with
Weasel.Firebird, so every row is one range again, and the interim
"Weasel.Firebird starts at 9.39.0" notes go from Directory.Packages.props,
the Quartz.Weasel readme and the Weasel page. It still floors JasperFx at
2.76.0, and no advisory names any Weasel or JasperFx version.

The tests move with it. The split-command test now has upstream's shape:
the fetch size is set on the builder's command and the split commands must
carry it; on Weasel 9.36.0 it reads [0, 0]. The Oracle fresh-install test
applies twice and checks that IDX_QRTZ_T_NFT_ST keeps its object id; on
Weasel.Oracle 9.36.0 without the builder the migration after the first
apply already reads Update. The MySQL creation assertion matches the
foreign key's name whether it is added after the table (9.39) or inline
(9.40 and later).

Marten moves to 9.45.0 and WolverineFx to 6.45.0, superseding dependabot
#4000 and #3997. Both floor JasperFx at 2.79.1 and Weasel below 9.39.0, so
the integration tests run Quartz.Weasel on its own floor. WolverineFx.Postgresql
already brings WolverineFx 6.45.0, so the integration tests need no direct
WolverineFx reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants