Oracle: first-class command builder + real statement splitting - #390
Merged
Conversation
Oracle is the one Weasel provider whose driver cannot execute several statements from a single command: ODP.NET reports CanCreateBatch = false, CreateBatch() throws NotSupportedException, and a semicolon-separated command fails with ORA-00933/ORA-00936. Its bind marker is also `:` rather than `@`. Consumers that build batches against the dialect-neutral DbCommandBuilder therefore emitted SQL that Oracle rejects outright, and had no way to hand back an Oracle-shaped builder instead -- Weasel.Oracle's CommandBuilder is a sibling of DbCommandBuilder, not a subclass. Weasel.Core: - AddParameter / AddNamedParameter are now virtual, so a provider can normalize values on every path that binds one. - New StartNewCommand() / CommandCount / CompileCommands() on CommandBuilderBase, defaulting to exactly today's behaviour: one command holding every statement. A consumer can now mark statement boundaries unconditionally and let the provider decide whether they mean anything. Postgres and SQL Server keep concatenating and pay nothing. - DbCommandBuilder gains a protected constructor taking the bind marker. Weasel.Oracle: - New OracleDbCommandBuilder: a DbCommandBuilder that emits `:` markers, sets BindByName, types parameters through OracleProvider rather than the generic DbType mapping (which resolves Guid to DbType.Object and is rejected), converts Guid to RAW(16) and bool to NUMBER(1), and splits at every StartNewCommand() boundary into one OracleCommand per statement, each carrying only the parameters its own statement bound. - New Weasel.Oracle.ICommandBuilder, and CommandBuilder now implements it, bringing Oracle to parity with Postgres and SQL Server. - CommandBuilder's Guid conversion was a `new` member, so the base class's typed AppendParameter overloads all routed straight past it through AddParameter and handed a raw Guid to OracleParameter.Value. It is now an override, and covers bool as well. - CommandBuilder sets BindByName, which ODP.NET otherwise leaves off. Integration tests execute a real multi-statement batch against Oracle in a single transaction and assert the Guid/bool round trip, plus a guard that fails if ODP.NET ever grows DbBatch support and makes the splitting moot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…parator Two things surfaced while wiring Wolverine's durability agent onto this. Callers terminate each statement with a trailing semicolon, because that is what the providers that concatenate everything into one command need. Oracle executes one statement per command, where a trailing semicolon is ORA-00911, so OracleDbCommandBuilder now strips it when it closes a statement rather than making every caller branch on the provider. Callers that hand-write a bind marker for a named parameter -- rather than going through AppendParameter, which writes one for them -- had no way to ask what this dialect's marker is, so they hard-coded `@`. CommandBuilderBase now exposes ParameterPrefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es it AddNamedParameter finds-or-adds, so a named parameter referenced by more than one statement in a batch is only ever created once, and the index range that decides which split command owns it put it on exactly one of them. The others bound nothing and failed at execution. A parameter now belongs to a split command if it was bound while that statement was open *or* if the statement's SQL names it. Matching is on the whole token, so :p1 is not treated as a reference to :p11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Jul 26, 2026
Merged
jeremydmiller
added a commit
to JasperFx/wolverine
that referenced
this pull request
Jul 26, 2026
#3659) * fix(oracle): run the durability agent through the shared batching mechanics The durability agent batches its whole recovery operation set into one command builder and executes it. Oracle's message store handed back the generic DbCommandBuilder, which emits `@` bind markers and concatenates every statement into a single command. ODP.NET rejects both -- it has no DbBatch support at all (CanCreateBatch is false, CreateBatch throws) and will not execute several statements from one command -- so the agent threw ORA-00933 / ORA-00936 / ORA-03405 on every sweep and nothing persisted in the inbox or outbox was ever recovered. Rather than give Oracle a bespoke execution path, this teaches the shared batching mechanics about statement boundaries and lets the provider decide what they mean. DatabaseOperationBatch now marks a boundary before each operation and executes whatever CompileCommands() hands back. On every provider whose driver can execute several statements from one command, StartNewCommand() is a no-op, CompileCommands() returns a single command, and the behaviour is byte for byte what it was. Oracle returns Weasel.Oracle's OracleDbCommandBuilder, which emits `:` markers, types parameters through OracleProvider, and splits. Three things the semicolon-splitting approach would have missed: - Four operations write more than one statement each (both ReleaseOrphaned variants, MoveReplayableErrorMessagesToIncoming, and PersistNodeRecord's insert per event). Splitting per operation is not enough, so those now mark their internal boundaries explicitly. - MoveReplayableErrorMessagesToIncoming binds :replayable from two different statements. AddNamedParameter finds-or-adds, so it exists once and has to be bound to both split commands. - The same operation hard-coded `@replayable` in its SQL text, which no provider-neutral consumer should do. It reads the marker off the builder now. Also documents the real reason OracleMessageStore.EnqueueAsync is a no-op: it implements IMessageDatabase directly rather than deriving from MessageDatabase, so it has no DatabaseBatcher. The durability agent does not use that path. Adds Pedro Andrade's coverage from #3615, retargeted at the new design and extended with an end-to-end assertion that the real recovery batch runs against a real Oracle database -- red-verified as ORA-03405 before this change. Fixes #3614. Co-Authored-By: Pedro Henrique Andrade Siqueira <pedroandrade03@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): Weasel 9.19.0 Brings in JasperFx/weasel#390 -- Weasel.Oracle's OracleDbCommandBuilder and the StartNewCommand()/CompileCommands() statement-boundary hooks on CommandBuilderBase that the Oracle durability fix is built on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Pedro Henrique Andrade Siqueira <pedroandrade03@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <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.
Background
Oracle is the one Weasel provider whose driver cannot execute several statements from a single command. Confirmed against ODP.NET 23.7.0:
Its bind marker is also
:rather than@. So a database-agnostic consumer building a batch against the dialect-neutralDbCommandBuilderemits SQL Oracle rejects outright (ORA-00933/ORA-00936), and there was no way to hand back an Oracle-shaped builder instead —Weasel.Oracle.CommandBuilderis a sibling ofDbCommandBuilder, not a subclass, so it can't be returned where one is expected.This is what's currently blocking Wolverine's durability agent on Oracle (JasperFx/wolverine#3614).
Weasel.Core
AddParameter/AddNamedParameterare nowvirtual, so a provider can normalize values on every path that binds one.StartNewCommand()/CommandCount/CompileCommands()onCommandBuilderBase, defaulting to exactly today's behaviour — one command holding every statement. A consumer can now mark statement boundaries unconditionally and let the provider decide whether they mean anything. Postgres and SQL Server keep concatenating and pay nothing for it.DbCommandBuildergains a protected constructor taking the bind marker.Nothing here changes existing behaviour for any current caller;
Weasel.Core.ICommandBuilderis deliberately untouched so Marten is unaffected.Weasel.Oracle
OracleDbCommandBuilder— aDbCommandBuilderthat emits:markers, setsBindByName, types parameters throughOracleProviderrather than the genericDbTypemapping (which resolvesGuidtoDbType.Object, which ODP.NET rejects), convertsGuid→RAW(16)andbool→NUMBER(1), and splits at everyStartNewCommand()boundary into oneOracleCommandper statement, each carrying only the parameters its own statement bound.Weasel.Oracle.ICommandBuilder, withCommandBuilderimplementing it — parity with the Postgres and SQL Server providers.CommandBuilder'sGuid→ RAW conversion was anewmember rather than anoverride. Since the base class routes all of its typedAppendParameteroverloads throughAddParameter, the hiding member was bypassed on every one of those paths and a rawGuidreachedOracleParameter.Value. Now anoverride, and it coversbooltoo.CommandBuildernow setsBindByName, which ODP.NET otherwise leaves off — it binds positionally by default, silently mis-binding any command whose parameters weren't added in the same order they appear in the SQL.Validation
Weasel.Oracle.Tests(full, live Oracle)Weasel.Core.TestsWeasel.Postgresql.Testsbuilder + batcher (live PG)Weasel.SqlServer.Testsbuilder + batcher (live SQL Server)Integration tests execute a real multi-statement batch against Oracle in a single transaction and assert the Guid/bool round trip, read results back per-command, and include a guard that will fail if ODP.NET ever grows
DbBatchsupport and makes the splitting unnecessary.🤖 Generated with Claude Code