Repository navigation
Firebird: a Weasel.Firebird provider for Firebird 3, 4 and 5 - #666
Merged
Merged
Conversation
… 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 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>
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>
…nsures 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>
…chema 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>
…ion, 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>
…nd 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>
…SQL'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>
…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>
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 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>
…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>
… 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>
…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>
…ospection 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>
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>
…heir 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>
…mes 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>
…ot 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>
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>
…rebird 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>
…able 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>
…s 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>
…in 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>
…JasperFxgh-658 made the others Upstream's JasperFxgh-658 (JasperFx#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>
…mmand being built Upstream's JasperFx#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>
Closed
2 tasks
…nt 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>
…ard 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>
…es 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>
This was referenced Oct 5, 2026
lahma
added a commit
to quartznet/quartznet
that referenced
this pull request
Oct 5, 2026
UseWeaselForFirebird() on a UseFirebird store hands its tables to Weasel, at startup and from db-apply, db-assert, db-patch and resources, through the same glue as the other dialect packages. It depends on Weasel.Firebird [9.39.0, 10.0.0), the first release with the provider (JasperFx/weasel#666). The model is generated like the others, through a WeaselDialect record for "firebird": the script's upper-case names, PK_<table>, FK_<table>_1 and IDX_ names, DEFAULT NULL as written, no cascading keys, and an index column with a direction refused, because a Firebird index has one direction for all its columns. A column type now runs to the first NOT, NULL or DEFAULT, so BLOB SUB_TYPE TEXT is read whole; the other five models are unchanged. The 3.x index names 4.x retired are dropped through a guarded drop, from a RetiredQuartzIndex. Firebird 3, 4 and 5 have no schemas, so a table prefix with a dot is refused. FirebirdWeaselOptions.MaxIdentifierLength is 31 by default, Firebird 3's limit, and takes up to 63 for a database only Firebird 4 or later opens; a prefix that makes a name too long is refused before anything runs, with the prefix length that fits. There is no lock: Weasel.Firebird guards every statement and re-runs one that lost a catalog race, and an apply that still fails goes through the shared LockFreeApply, 3 attempts at 200 ms. BuiltInDbMetadataFactoryTest and DataSourceMetadataResolutionTest use MySql.Data as the driver the unit tests cannot load, because the Firebird model tests bring FirebirdClient in. The Firebird leg covers, on UTF8 and NONE databases at page size 16384: a fresh apply equals tables_firebird.sql, types, defaults, key names, rules and directions included; both scripts read as unchanged; a prefix of its own, lower-case included; 4.2, 4.3 (add-only) and 3.20 baselines with rows kept and retired indexes gone, by an apply and by a db-patch run in isql; a patch and its rollback in isql; an upgrade beside an open transaction; application objects and a second model kept; a widened column refused; five concurrent appliers, all succeeding with no Quartz-level retry; the driver delegate's workload on tables Weasel built; the identifier limit on Firebird 3 and on 4 and 5; hosted startup, AutoCreate.None and the command line. SchemaBaselines 4.2 and 4.3 gain tables_firebird.sql from their tags. Part of #3990. 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
The package table gains Firebird 3, 4 and 5 and its Weasel.Firebird range, and loses "Firebird is not planned". A Firebird section says that a prefix with a dot is refused, that no lock is taken and how appliers converge instead, the identifier limit on Firebird 3 and on 4 and 5 with the prefix length each allows, and that a UTF8 database needs 16384-byte pages. It credits JasperFx/weasel#666 and thanks the JasperFx maintainers for merging it. The comparison page's schema row, the third-party page, the 4.4 "Added" rows of the migration guide, the Quartz.Weasel readme and the API reference name the package. Closes #3990. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Weasel.Firebird, a provider for Firebird 3, 4 and 5 at parity with Weasel.MySql:
Firebird shapes most of the design:
IF NOT EXISTS, and a racing DDL statement can lose;So Weasel.Firebird does the following:
EXECUTE BLOCK;COMMIT;after every statement.Changes outside the provider are small, default-preserving, and pinned by Core.Tests for every provider:
Migrator.FingerprintTableNameandMigrator.OrderRollbackshooks;DatabaseBase.ToDatabaseScriptwrites through the writerWriteScripthands its step.One bridge fix touches every EF Core provider:
DbContextExtensions.CreateMigrationAsyncnow introspects through the migrator's command builder. It sent every query in one command, which Firebird and Oracle cannot run. This also un-skips Oracle's EFend_to_endtest.The driver is FirebirdSql.Data.FirebirdClient, under the IDPL (an MPL-style licence).
A few helpers are public, as
SqlServerBatchSplitteris, and can go internal on request:FirebirdScript.Guarded/StatementLiteralandFirebirdMigrator.ExecuteScriptAsync.Suggested reading order:
FirebirdScript, thenFirebirdMigrator, thenTables/, then the PSQL objects. Squash on merge.