diff --git a/src/Weasel.Core/CommandBuilderBase.cs b/src/Weasel.Core/CommandBuilderBase.cs
index 9b49a659..2ec92fb1 100644
--- a/src/Weasel.Core/CommandBuilderBase.cs
+++ b/src/Weasel.Core/CommandBuilderBase.cs
@@ -57,6 +57,14 @@ TCommand command
? null
: _command.Parameters[^1].ParameterName;
+ ///
+ /// The bind marker this dialect uses in command text — @ for SQL Server, MySQL and
+ /// SQLite, : for PostgreSQL and Oracle. Callers that hand-write a marker for a named
+ /// parameter (rather than going through ,
+ /// which writes it for them) should read it from here rather than hard-coding one.
+ ///
+ public char ParameterPrefix => _parameterPrefix;
+
///
/// Add text to the batched command SQL string
///
@@ -94,6 +102,53 @@ public TCommand Compile()
return _command;
}
+ ///
+ /// Marks the end of a logical statement within the batch. On providers whose ADO.NET
+ /// driver can execute several statements from a single command — which is every Weasel
+ /// provider except Oracle — this is a no-op, because the statements are simply
+ /// concatenated into one .
+ ///
+ /// A provider whose driver cannot do that overrides this to close the current statement
+ /// and start a new one, so that hands back one command per
+ /// boundary. Callers that build a batch should call this between logical operations
+ /// regardless of provider; on the multi-statement providers it costs nothing.
+ ///
+ ///
+ public virtual void StartNewCommand()
+ {
+ // Nothing by default -- multi-statement providers just keep appending
+ }
+
+ ///
+ /// The number of executable commands accumulated so far. Providers that support
+ /// multi-statement commands always report 1, no matter how many times
+ /// has been called.
+ ///
+ public virtual int CommandCount => 1;
+
+ ///
+ /// Build out the batch as one or more executable ADO.NET commands, in order. Providers
+ /// that support multi-statement commands return a single command holding every statement;
+ /// providers that do not return one command per boundary.
+ ///
+ ///
+ public virtual IReadOnlyList CompileCommands()
+ {
+ return [Compile()];
+ }
+
+ ///
+ /// Take the SQL accumulated since the last call and reset the buffer. Intended for derived
+ /// builders that implement real statement splitting in .
+ ///
+ ///
+ protected string TakeSql()
+ {
+ var sql = _sql.ToString();
+ _sql.Clear();
+ return sql;
+ }
+
///
/// Adds a parameter to the underlying command, but does NOT add the
/// parameter usage to the command text
@@ -101,7 +156,7 @@ public TCommand Compile()
///
///
///
- public TParameter AddParameter(object? value, TParameterType? dbType = null)
+ public virtual TParameter AddParameter(object? value, TParameterType? dbType = null)
{
var name = "p" + _command.Parameters.Count;
@@ -127,7 +182,7 @@ public TParameter AddParameter(object? value, TParameterType? dbType = null)
///
///
///
- public TParameter AddNamedParameter(string name, object value, TParameterType? dbType = null)
+ public virtual TParameter AddNamedParameter(string name, object value, TParameterType? dbType = null)
{
var existing = _command.Parameters.OfType().FirstOrDefault(x => x.ParameterName == name);
if (existing != null)
diff --git a/src/Weasel.Core/DbCommandBuilder.cs b/src/Weasel.Core/DbCommandBuilder.cs
index 2e24ec7c..47d3647f 100644
--- a/src/Weasel.Core/DbCommandBuilder.cs
+++ b/src/Weasel.Core/DbCommandBuilder.cs
@@ -18,6 +18,18 @@ public DbCommandBuilder(DbCommand command): base(DbDatabaseProvider.Instance, '@
public DbCommandBuilder(DbConnection connection): base(DbDatabaseProvider.Instance, '@', connection.CreateCommand())
{
}
+
+ ///
+ /// Build against a dialect whose bind marker is not @ — Oracle's is :, for
+ /// example. Lets a database-agnostic consumer keep using
+ /// against such a provider instead of emitting SQL the driver will reject.
+ ///
+ ///
+ ///
+ protected DbCommandBuilder(DbCommand command, char parameterPrefix)
+ : base(DbDatabaseProvider.Instance, parameterPrefix, command)
+ {
+ }
}
public static class DbCommandBuilderExtensions
diff --git a/src/Weasel.Oracle.Tests/CommandBuilderTests.cs b/src/Weasel.Oracle.Tests/CommandBuilderTests.cs
new file mode 100644
index 00000000..be353687
--- /dev/null
+++ b/src/Weasel.Oracle.Tests/CommandBuilderTests.cs
@@ -0,0 +1,144 @@
+using System.Data.Common;
+using Oracle.ManagedDataAccess.Client;
+using Shouldly;
+using Xunit;
+
+namespace Weasel.Oracle.Tests;
+
+public class CommandBuilderTests
+{
+ [Fact]
+ public void uses_the_oracle_bind_marker()
+ {
+ var builder = new CommandBuilder();
+
+ builder.Append("select data from messages where ");
+ builder.AppendWithParameters("foo = ?").Length.ShouldBe(1);
+
+ builder.ToString().ShouldBe("select data from messages where foo = :p0");
+ }
+
+ [Fact]
+ public void binds_by_name()
+ {
+ var command = new OracleCommand();
+ _ = new CommandBuilder(command);
+
+ command.BindByName.ShouldBeTrue();
+ }
+
+ ///
+ /// The Guid conversion used to be a `new` member, so every one of the base class's typed
+ /// AppendParameter overloads routed straight past it through AddParameter and handed a raw
+ /// Guid to OracleParameter.Value. Regression guard for that.
+ ///
+ [Fact]
+ public void typed_guid_overload_converts_to_raw()
+ {
+ var id = Guid.NewGuid();
+ var builder = new CommandBuilder();
+
+ builder.Append("select 1 from dual where id = ");
+ builder.AppendParameter(id);
+
+ var parameter = builder.Compile().Parameters[0];
+ parameter.OracleDbType.ShouldBe(OracleDbType.Raw);
+ parameter.Value.ShouldBe(id.ToByteArray());
+ }
+
+ [Fact]
+ public void boxed_guid_converts_to_raw()
+ {
+ var id = Guid.NewGuid();
+ var builder = new CommandBuilder();
+
+ builder.Append("select 1 from dual where id = ");
+ builder.AppendParameter((object)id);
+
+ var parameter = builder.Compile().Parameters[0];
+ parameter.OracleDbType.ShouldBe(OracleDbType.Raw);
+ parameter.Value.ShouldBe(id.ToByteArray());
+ }
+
+ [Fact]
+ public void named_boolean_parameter_converts_to_an_oracle_number()
+ {
+ var builder = new CommandBuilder();
+
+ builder.Append("update dead_letters set replayable = ");
+ builder.AddNamedParameter("replayable", true);
+
+ var parameter = builder.Compile().Parameters["replayable"];
+ parameter.OracleDbType.ShouldBe(OracleDbType.Int16);
+ parameter.Value.ShouldBe(1);
+ }
+
+ [Fact]
+ public void named_guid_parameter_converts_to_raw()
+ {
+ var id = Guid.NewGuid();
+ var builder = new CommandBuilder();
+
+ builder.AddNamedParameter("id", id);
+
+ var parameter = builder.Compile().Parameters["id"];
+ parameter.OracleDbType.ShouldBe(OracleDbType.Raw);
+ parameter.Value.ShouldBe(id.ToByteArray());
+ }
+
+ [Fact]
+ public void implements_the_dialect_neutral_command_builder()
+ {
+ Weasel.Core.ICommandBuilder builder = new CommandBuilder();
+
+ builder.Append("select data from messages where foo = ");
+ var parameter = builder.AppendParameter(5);
+
+ parameter.ShouldBeOfType();
+ builder.ToString().ShouldBe("select data from messages where foo = :p0");
+ }
+
+ [Fact]
+ public void append_with_db_parameters_returns_neutral_db_parameters()
+ {
+ Weasel.Core.ICommandBuilder builder = new CommandBuilder();
+
+ builder.Append("select data from messages where ");
+ DbParameter[] parameters = builder.AppendWithDbParameters("foo = ? and bar = ?");
+
+ parameters.Length.ShouldBe(2);
+ builder.ToString().ShouldBe("select data from messages where foo = :p0 and bar = :p1");
+ }
+
+ [Fact]
+ public void grouped_parameter_builder_appends_a_separated_run()
+ {
+ Weasel.Core.ICommandBuilder builder = new CommandBuilder();
+
+ builder.Append("select data from messages where id in (");
+ var grouped = builder.CreateGroupedParameterBuilder(',');
+ grouped.AppendParameter(1);
+ grouped.AppendParameter(2);
+ builder.Append(")");
+
+ builder.ToString().ShouldBe("select data from messages where id in (:p0,:p1)");
+ }
+
+ ///
+ /// Oracle is the one provider that splits a batch. Everything else concatenates, so
+ /// StartNewCommand has to stay free for them.
+ ///
+ [Fact]
+ public void start_new_command_is_a_no_op_on_the_plain_command_builder()
+ {
+ var builder = new CommandBuilder();
+
+ builder.Append("delete from incoming");
+ builder.StartNewCommand();
+ builder.Append(";delete from outgoing");
+
+ builder.CommandCount.ShouldBe(1);
+ builder.CompileCommands().Count.ShouldBe(1);
+ builder.CompileCommands()[0].CommandText.ShouldBe("delete from incoming;delete from outgoing");
+ }
+}
diff --git a/src/Weasel.Oracle.Tests/OracleDbCommandBuilderIntegrationTests.cs b/src/Weasel.Oracle.Tests/OracleDbCommandBuilderIntegrationTests.cs
new file mode 100644
index 00000000..ec4ae7e9
--- /dev/null
+++ b/src/Weasel.Oracle.Tests/OracleDbCommandBuilderIntegrationTests.cs
@@ -0,0 +1,167 @@
+using Oracle.ManagedDataAccess.Client;
+using Shouldly;
+using Weasel.Core;
+using Xunit;
+
+namespace Weasel.Oracle.Tests;
+
+[Collection("integration")]
+public class OracleDbCommandBuilderIntegrationTests: IAsyncLifetime
+{
+ private readonly OracleConnection theConnection = new(ConnectionSource.ConnectionString);
+
+ public async Task InitializeAsync()
+ {
+ await theConnection.OpenAsync();
+
+ // In Oracle a schema *is* a user, so this has to go through Weasel rather than a plain
+ // "create schema"
+ await theConnection.ResetSchemaAsync("BATCHING");
+
+ await theConnection.CreateCommand(
+ "create table batching.messages (id raw(16) not null primary key, replayable number(1), expires timestamp with time zone)")
+ .ExecuteNonQueryAsync();
+
+ await theConnection.CreateCommand(
+ "create table batching.batch_notes (note varchar2(100))")
+ .ExecuteNonQueryAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await theConnection.CloseAsync();
+ await theConnection.DisposeAsync();
+ }
+
+ ///
+ /// The whole point of the exercise: a batch built exactly the way a database-agnostic consumer
+ /// builds one for PostgreSQL or SQL Server executes correctly against Oracle, with the `:` bind
+ /// markers, the Guid-as-RAW and bool-as-NUMBER conversions, and one command per statement.
+ ///
+ [Fact]
+ public async Task execute_a_multi_statement_batch_in_one_transaction()
+ {
+ var kept = Guid.NewGuid();
+ var expired = Guid.NewGuid();
+
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("insert into batching.messages (id, replayable, expires) values (");
+ builder.AppendParameter(kept);
+ builder.Append(", ");
+ // AddNamedParameter deliberately does not touch the command text, so the marker is written
+ // by hand -- this is exactly how Wolverine's replayable-message operation binds
+ builder.Append(":replayable");
+ builder.AddNamedParameter("replayable", true);
+ builder.Append(", ");
+ builder.AppendParameter(DateTimeOffset.UtcNow.AddDays(1));
+ builder.Append(")");
+
+ builder.StartNewCommand();
+
+ builder.Append("insert into batching.messages (id, replayable, expires) values (");
+ builder.AppendParameter(expired);
+ builder.Append(", ");
+ builder.Append(":replayable2");
+ builder.AddNamedParameter("replayable2", false);
+ builder.Append(", ");
+ builder.AppendParameter(DateTimeOffset.UtcNow.AddDays(-1));
+ builder.Append(")");
+
+ builder.StartNewCommand();
+
+ builder.Append("insert into batching.batch_notes (note) values (");
+ builder.AppendParameter("batched");
+ builder.Append(")");
+
+ var commands = builder.CompileCommands();
+ commands.Count.ShouldBe(3);
+
+ await using var tx = (OracleTransaction)await theConnection.BeginTransactionAsync();
+ foreach (var command in commands)
+ {
+ command.Connection = theConnection;
+ command.Transaction = tx;
+ await command.ExecuteNonQueryAsync();
+ }
+
+ await tx.CommitAsync();
+
+ (await countAsync("select count(*) from batching.messages")).ShouldBe(2);
+ (await countAsync("select count(*) from batching.batch_notes where note = 'batched'")).ShouldBe(1);
+
+ // The Guid round-trips through RAW(16), and the bool through NUMBER(1)
+ (await countAsync(
+ $"select count(*) from batching.messages where id = '{Convert.ToHexString(kept.ToByteArray())}' and replayable = 1"))
+ .ShouldBe(1);
+ (await countAsync(
+ $"select count(*) from batching.messages where id = '{Convert.ToHexString(expired.ToByteArray())}' and replayable = 0"))
+ .ShouldBe(1);
+ }
+
+ ///
+ /// A batch that returns data: each statement gets its own reader, because ODP.NET cannot
+ /// hand back several result sets from one command.
+ ///
+ [Fact]
+ public async Task read_results_from_each_command_in_the_batch()
+ {
+ var id = Guid.NewGuid();
+
+ await theConnection.CreateCommand(
+ "insert into batching.messages (id, replayable, expires) values (:id, 1, systimestamp)")
+ .With("id", id.ToByteArray())
+ .ExecuteNonQueryAsync();
+
+ await theConnection.CreateCommand("insert into batching.batch_notes (note) values ('read me')")
+ .ExecuteNonQueryAsync();
+
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("select count(*) from batching.messages where id = ");
+ builder.AppendParameter(id);
+
+ builder.StartNewCommand();
+
+ builder.Append("select note from batching.batch_notes where note = ");
+ builder.AppendParameter("read me");
+
+ var commands = builder.CompileCommands();
+ commands.Count.ShouldBe(2);
+
+ foreach (var command in commands)
+ {
+ command.Connection = theConnection;
+ }
+
+ await using (var reader = await commands[0].ExecuteReaderAsync())
+ {
+ (await reader.ReadAsync()).ShouldBeTrue();
+ Convert.ToInt32(reader.GetValue(0)).ShouldBe(1);
+ }
+
+ await using (var reader = await commands[1].ExecuteReaderAsync())
+ {
+ (await reader.ReadAsync()).ShouldBeTrue();
+ reader.GetString(0).ShouldBe("read me");
+ }
+ }
+
+ ///
+ /// ODP.NET does not implement the ADO.NET batching API at all, which is why Oracle needs the
+ /// statement splitting in the first place. If this ever starts failing, ODP.NET has grown
+ /// support and the splitting could be revisited.
+ ///
+ [Fact]
+ public void odp_net_still_cannot_batch()
+ {
+ theConnection.CanCreateBatch.ShouldBeFalse();
+ Should.Throw(() => theConnection.CreateBatch());
+ }
+
+ private async Task countAsync(string sql)
+ {
+ await using var command = theConnection.CreateCommand(sql);
+ return Convert.ToInt32(await command.ExecuteScalarAsync());
+ }
+}
diff --git a/src/Weasel.Oracle.Tests/OracleDbCommandBuilderTests.cs b/src/Weasel.Oracle.Tests/OracleDbCommandBuilderTests.cs
new file mode 100644
index 00000000..56b802a4
--- /dev/null
+++ b/src/Weasel.Oracle.Tests/OracleDbCommandBuilderTests.cs
@@ -0,0 +1,288 @@
+using System.Data.Common;
+using Oracle.ManagedDataAccess.Client;
+using Shouldly;
+using Xunit;
+
+namespace Weasel.Oracle.Tests;
+
+public class OracleDbCommandBuilderTests
+{
+ [Fact]
+ public void uses_the_oracle_bind_marker_rather_than_the_generic_one()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from messages where keep_until <= ");
+ builder.AppendParameter(DateTimeOffset.UtcNow);
+
+ builder.ToString().ShouldBe("delete from messages where keep_until <= :p0");
+ }
+
+ [Fact]
+ public void binds_by_name()
+ {
+ var command = new OracleCommand();
+ _ = new OracleDbCommandBuilder(command);
+
+ command.BindByName.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void a_single_statement_compiles_to_a_single_command()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming where id = ");
+ builder.AppendParameter(Guid.NewGuid());
+
+ var commands = builder.CompileCommands();
+
+ commands.Count.ShouldBe(1);
+ commands[0].CommandText.ShouldBe("delete from incoming where id = :p0");
+ commands[0].Parameters.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void no_statements_at_all_compiles_to_nothing()
+ {
+ new OracleDbCommandBuilder().CompileCommands().Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void splits_at_every_start_new_command_boundary()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("select destination from outgoing");
+ builder.StartNewCommand();
+ builder.Append("delete from incoming");
+ builder.StartNewCommand();
+ builder.Append("update nodes set active = 1");
+
+ var commands = builder.CompileCommands();
+
+ commands.Select(x => x.CommandText).ShouldBe([
+ "select destination from outgoing",
+ "delete from incoming",
+ "update nodes set active = 1"
+ ]);
+ }
+
+ [Fact]
+ public void strips_the_trailing_semicolon_callers_write_for_other_providers()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming;");
+ builder.StartNewCommand();
+ builder.Append("delete from outgoing;");
+
+ builder.CompileCommands().Select(x => x.CommandText).ShouldBe([
+ "delete from incoming",
+ "delete from outgoing"
+ ]);
+ }
+
+ [Fact]
+ public void a_semicolon_only_statement_is_not_a_statement()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append(";");
+ builder.StartNewCommand();
+ builder.Append("delete from incoming;");
+
+ builder.CompileCommands().Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void exposes_the_oracle_bind_marker()
+ {
+ new OracleDbCommandBuilder().ParameterPrefix.ShouldBe(':');
+ }
+
+ [Fact]
+ public void empty_statements_do_not_produce_commands()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.StartNewCommand();
+ builder.Append("delete from incoming");
+ builder.StartNewCommand();
+ builder.StartNewCommand();
+
+ var commands = builder.CompileCommands();
+
+ commands.Count.ShouldBe(1);
+ commands[0].CommandText.ShouldBe("delete from incoming");
+ }
+
+ [Fact]
+ public void each_split_command_only_carries_the_parameters_its_own_statement_bound()
+ {
+ var first = Guid.NewGuid();
+ var cutoff = DateTimeOffset.UtcNow;
+
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming where id = ");
+ builder.AppendParameter(first);
+
+ builder.StartNewCommand();
+
+ builder.Append("delete from dead_letters where expires <= ");
+ builder.AppendParameter(cutoff);
+ builder.Append(" and node = ");
+ builder.AppendParameter(5);
+
+ var commands = builder.CompileCommands();
+
+ commands.Count.ShouldBe(2);
+
+ commands[0].CommandText.ShouldBe("delete from incoming where id = :p0");
+ commands[0].Parameters.Count.ShouldBe(1);
+ commands[0].Parameters[0].ParameterName.ShouldBe("p0");
+ commands[0].Parameters[0].Value.ShouldBe(first.ToByteArray());
+
+ commands[1].CommandText.ShouldBe("delete from dead_letters where expires <= :p1 and node = :p2");
+ commands[1].Parameters.Count.ShouldBe(2);
+ commands[1].Parameters[0].ParameterName.ShouldBe("p1");
+ commands[1].Parameters[0].Value.ShouldBe(cutoff);
+ commands[1].Parameters[1].ParameterName.ShouldBe("p2");
+ commands[1].Parameters[1].Value.ShouldBe(5);
+ }
+
+ [Fact]
+ public void a_named_parameter_shared_by_two_statements_is_bound_to_both()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("insert into incoming select * from dead_letters where replayable = :replayable;");
+ builder.AddNamedParameter("replayable", true);
+
+ builder.StartNewCommand();
+
+ builder.Append("delete from dead_letters where replayable = :replayable;");
+
+ var commands = builder.CompileCommands();
+
+ commands.Count.ShouldBe(2);
+ commands[0].Parameters["replayable"].Value.ShouldBe(1);
+ commands[1].Parameters["replayable"].Value.ShouldBe(1);
+ }
+
+ [Fact]
+ public void a_parameter_is_not_shared_just_because_its_name_is_a_prefix_of_another()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming where a = ");
+ builder.AppendParameter(1);
+ builder.Append(" and b = ");
+ builder.AppendParameter(2);
+
+ builder.StartNewCommand();
+
+ // References :p11 only -- must not drag in :p1
+ builder.Append("delete from outgoing where c = :p11");
+
+ var commands = builder.CompileCommands();
+
+ commands[0].Parameters.Count.ShouldBe(2);
+ commands[1].Parameters.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void split_commands_bind_by_name()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming");
+ builder.StartNewCommand();
+ builder.Append("delete from outgoing");
+
+ builder.CompileCommands().OfType()
+ .ShouldAllBe(x => x.BindByName);
+ }
+
+ [Fact]
+ public void command_count_reports_the_open_statement_too()
+ {
+ var builder = new OracleDbCommandBuilder();
+ builder.CommandCount.ShouldBe(0);
+
+ builder.Append("delete from incoming");
+ builder.CommandCount.ShouldBe(1);
+
+ builder.StartNewCommand();
+ builder.CommandCount.ShouldBe(1);
+
+ builder.Append("delete from outgoing");
+ builder.CommandCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public void guids_are_bound_as_raw()
+ {
+ var id = Guid.NewGuid();
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("select 1 from dual where id = ");
+ builder.AppendParameter(id);
+
+ var parameter = (OracleParameter)builder.CompileCommands()[0].Parameters[0];
+
+ parameter.OracleDbType.ShouldBe(OracleDbType.Raw);
+ parameter.Value.ShouldBe(id.ToByteArray());
+ }
+
+ [Fact]
+ public void booleans_are_bound_as_oracle_numbers()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("update dead_letters set replayable = ");
+ builder.AddNamedParameter("replayable", true);
+
+ var parameter = (OracleParameter)builder.CompileCommands()[0].Parameters["replayable"];
+
+ parameter.OracleDbType.ShouldBe(OracleDbType.Int16);
+ parameter.Value.ShouldBe(1);
+ }
+
+ [Fact]
+ public void date_time_offsets_keep_their_oracle_type()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("delete from incoming where expires <= ");
+ builder.AppendParameter(DateTimeOffset.UtcNow);
+
+ var parameter = (OracleParameter)builder.CompileCommands()[0].Parameters[0];
+
+ parameter.OracleDbType.ShouldBe(OracleDbType.TimeStampTZ);
+ }
+
+ [Fact]
+ public void null_values_are_bound_as_db_null()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("select 1 from dual where description = ");
+ builder.AppendParameter((object?)null);
+
+ builder.CompileCommands()[0].Parameters[0].Value.ShouldBe(DBNull.Value);
+ }
+
+ [Fact]
+ public void append_with_db_parameters_uses_the_oracle_marker()
+ {
+ var builder = new OracleDbCommandBuilder();
+
+ builder.Append("select data from messages where ");
+ DbParameter[] parameters = builder.AppendWithDbParameters("foo = ? and bar = ?");
+
+ parameters.Length.ShouldBe(2);
+ builder.ToString().ShouldBe("select data from messages where foo = :p0 and bar = :p1");
+ }
+}
diff --git a/src/Weasel.Oracle/CommandBuilder.cs b/src/Weasel.Oracle/CommandBuilder.cs
index 3627c142..93cc4a00 100644
--- a/src/Weasel.Oracle/CommandBuilder.cs
+++ b/src/Weasel.Oracle/CommandBuilder.cs
@@ -4,7 +4,7 @@
namespace Weasel.Oracle;
-public class CommandBuilder: CommandBuilderBase
+public class CommandBuilder: CommandBuilderBase, ICommandBuilder
{
public CommandBuilder(): this(new OracleCommand())
{
@@ -12,8 +12,17 @@ public CommandBuilder(): this(new OracleCommand())
public CommandBuilder(OracleCommand command): base(OracleProvider.Instance, ':', command)
{
+ // ODP.NET binds by position unless told otherwise, which silently mis-binds any
+ // command whose parameters were added in a different order than they appear in the SQL.
+ command.BindByName = true;
}
+ ///
+ /// It became so common, that it's turned out to be convenient to place
+ /// this here
+ ///
+ public string TenantId { get; set; } = string.Empty;
+
///
/// Oracle-specific override: converts Guid to byte[] before setting parameter value,
/// since Oracle stores Guids as RAW(16) and OracleParameter.Value rejects raw Guid objects.
@@ -25,15 +34,98 @@ public CommandBuilder(OracleCommand command): base(OracleProvider.Instance, ':',
///
/// Oracle-specific override: converts Guid values to byte[] before setting parameter value.
+ ///
+ /// This has to be an override rather than a new member — the base class routes
+ /// every one of its typed AppendParameter overloads through AddParameter, so a
+ /// hiding member would be bypassed on all of those paths and the raw would
+ /// reach and be rejected.
+ ///
+ ///
+ public override OracleParameter AddParameter(object? value, OracleDbType? dbType = null)
+ {
+ return base.AddParameter(normalize(value), dbType ?? inferType(value));
+ }
+
+ ///
+ /// Oracle-specific override, for the same reason as .
+ ///
+ public override OracleParameter AddNamedParameter(string name, object value, OracleDbType? dbType = null)
+ {
+ return base.AddNamedParameter(name, normalize(value)!, dbType ?? inferType(value));
+ }
+
+ ///
+ /// Oracle has no boolean type and stores Guids as RAW(16), so both have to be converted
+ /// before they reach .
///
- public new OracleParameter AddParameter(object? value, OracleDbType? dbType = null)
+ private static object? normalize(object? value)
+ {
+ return value switch
+ {
+ Guid guid => guid.ToByteArray(),
+ bool boolean => boolean ? 1 : 0,
+ _ => value
+ };
+ }
+
+ private static OracleDbType? inferType(object? value)
+ {
+ return value switch
+ {
+ Guid => OracleDbType.Raw,
+ bool => OracleDbType.Int16,
+ _ => null
+ };
+ }
+
+ OracleParameter ICommandBuilder.AppendParameter(T value)
+ {
+ base.AppendParameter(value);
+ return _command.Parameters[^1];
+ }
+
+ public OracleParameter AppendParameter(T value, OracleDbType dbType)
+ {
+ base.AppendParameter(value, dbType);
+ return _command.Parameters[^1];
+ }
+
+ OracleParameter ICommandBuilder.AppendParameter(object value)
+ {
+ base.AppendParameter(value);
+ return _command.Parameters[^1];
+ }
+
+ OracleParameter ICommandBuilder.AppendParameter(object? value, OracleDbType? dbType)
{
- if (value is Guid guidValue)
+ base.AppendParameter(value, dbType);
+ return _command.Parameters[^1];
+ }
+
+ DbParameter Weasel.Core.ICommandBuilder.AppendParameter(object value)
+ {
+ base.AppendParameter(value);
+ return _command.Parameters[^1];
+ }
+
+ void Weasel.Core.ICommandBuilder.AppendParameters(params object[] parameters)
+ {
+ if (parameters.Length == 0)
+ throw new ArgumentOutOfRangeException(nameof(parameters),
+ "Must be at least one parameter value, but got " + parameters.Length);
+
+ AppendParameter(parameters[0]);
+
+ for (var i = 1; i < parameters.Length; i++)
{
- return base.AddParameter(guidValue.ToByteArray(), OracleDbType.Raw);
+ Append(", ");
+ AppendParameter(parameters[i]);
}
+ }
- return base.AddParameter(value, dbType);
+ public Weasel.Core.IGroupedParameterBuilder CreateGroupedParameterBuilder(char? seperator = null)
+ {
+ return new Weasel.Core.GroupedParameterBuilder(this, seperator);
}
}
diff --git a/src/Weasel.Oracle/ICommandBuilder.cs b/src/Weasel.Oracle/ICommandBuilder.cs
new file mode 100644
index 00000000..93da6171
--- /dev/null
+++ b/src/Weasel.Oracle/ICommandBuilder.cs
@@ -0,0 +1,39 @@
+using Oracle.ManagedDataAccess.Client;
+
+namespace Weasel.Oracle;
+
+///
+/// Oracle command-builder surface. Derives from the dialect-neutral
+/// (which contributes ,
+/// , AddParameters, tenant id, etc.)
+/// and adds the ODP.NET-typed overloads that return .
+///
+public interface ICommandBuilder: Weasel.Core.ICommandBuilder
+{
+ OracleParameter AppendParameter(T value);
+ OracleParameter AppendParameter(T value, OracleDbType dbType);
+
+ ///
+ /// ODP.NET-typed override of .
+ ///
+ new OracleParameter AppendParameter(object value);
+
+ OracleParameter AppendParameter(object? value, OracleDbType? dbType);
+
+ ///
+ /// Append a SQL string with `?` placeholders for new parameters, and returns an
+ /// array of the newly created parameters
+ ///
+ ///
+ ///
+ OracleParameter[] AppendWithParameters(string text);
+
+ ///
+ /// Append a SQL string with user defined placeholder characters for new parameters, and returns an
+ /// array of the newly created parameters
+ ///
+ ///
+ ///
+ ///
+ OracleParameter[] AppendWithParameters(string text, char placeholder);
+}
diff --git a/src/Weasel.Oracle/OracleDbCommandBuilder.cs b/src/Weasel.Oracle/OracleDbCommandBuilder.cs
new file mode 100644
index 00000000..578fbf9f
--- /dev/null
+++ b/src/Weasel.Oracle/OracleDbCommandBuilder.cs
@@ -0,0 +1,241 @@
+using System.Data;
+using System.Data.Common;
+using JasperFx.Core;
+using Oracle.ManagedDataAccess.Client;
+using Weasel.Core;
+// System.Data.Common has its own unrelated DbCommandBuilder (the SQL-generating one)
+using DbCommandBuilder = Weasel.Core.DbCommandBuilder;
+
+namespace Weasel.Oracle;
+
+///
+/// A that emits Oracle-shaped SQL, for database-agnostic consumers
+/// that build batches against the dialect-neutral surface rather than
+/// against .
+///
+/// Two things make Oracle different from every other Weasel provider here. First, its bind marker is
+/// : rather than @. Second — and this is the one that can't be papered over — ODP.NET
+/// does not implement the ADO.NET batching API ( is
+/// and throws), and Oracle will not
+/// execute several semicolon-separated statements from a single . So
+/// here does real work: it closes the current statement and starts a
+/// new one, and hands back one per
+/// boundary, each carrying only the parameters that its own statement bound.
+///
+///
+/// Consumers do not need to know any of that. Build the batch exactly as you would for PostgreSQL or
+/// SQL Server, calling between logical statements — on those providers
+/// it is a no-op and you still get a single multi-statement command back.
+///
+///
+public class OracleDbCommandBuilder: DbCommandBuilder
+{
+ private readonly OracleCommand _oracleCommand;
+ private readonly List _statements = [];
+
+ ///
+ /// Index into the underlying command's parameter collection at which the statement
+ /// currently being built started binding.
+ ///
+ private int _boundary;
+
+ public OracleDbCommandBuilder(): this(new OracleCommand())
+ {
+ }
+
+ public OracleDbCommandBuilder(OracleCommand command): base(command, ':')
+ {
+ _oracleCommand = command;
+
+ // ODP.NET binds by position unless told otherwise, which silently mis-binds any command
+ // whose parameters were not added in the same order they appear in the SQL. Splitting a
+ // batch into per-statement commands reorders them by construction, so this is mandatory.
+ _oracleCommand.BindByName = true;
+ }
+
+ ///
+ /// Closes the statement currently being built and starts a new one. Unlike every other
+ /// Weasel provider, this is not a no-op — see the type-level remarks.
+ ///
+ public override void StartNewCommand()
+ {
+ var sql = trim(TakeSql());
+ var end = _oracleCommand.Parameters.Count;
+
+ if (sql.IsNotEmpty())
+ {
+ _statements.Add(new Statement(sql, _boundary, end));
+ }
+
+ _boundary = end;
+ }
+
+ ///
+ public override int CommandCount => _statements.Count + (trim(ToString()).IsNotEmpty() ? 1 : 0);
+
+ ///
+ /// Callers separate statements with a trailing semicolon, because that is what the providers
+ /// that concatenate into one command need. Oracle executes one statement per command, where a
+ /// trailing semicolon is a syntax error (ORA-00911), so strip it here rather than making every
+ /// caller branch on the provider.
+ ///
+ private static string trim(string sql)
+ {
+ return sql.Trim().TrimEnd(';').Trim();
+ }
+
+ ///
+ /// Compile into one per boundary.
+ /// Each command carries only the parameters bound by its own statement.
+ ///
+ public override IReadOnlyList CompileCommands()
+ {
+ // Flush whatever statement is still open
+ StartNewCommand();
+
+ if (_statements.Count == 0)
+ {
+ return [];
+ }
+
+ if (_statements.Count == 1)
+ {
+ // Nothing to split -- hand back the command we've been building all along, parameters
+ // and all, so that callers keep the single-command diagnostics they'd get elsewhere.
+ _oracleCommand.CommandText = _statements[0].Sql;
+ return [_oracleCommand];
+ }
+
+ // An OracleParameter cannot belong to two collections at once, so detach them all first
+ // and then deal each one out to the command whose statement actually bound it.
+ var parameters = _oracleCommand.Parameters.Cast().ToArray();
+ _oracleCommand.Parameters.Clear();
+
+ var commands = new List(_statements.Count);
+ foreach (var statement in _statements)
+ {
+ var command = new OracleCommand(statement.Sql) { BindByName = true };
+
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ // A parameter belongs to this statement if it was bound while the statement was open,
+ // or if the statement's SQL names it. The second case matters for a named parameter
+ // shared by more than one statement -- AddNamedParameter finds-or-adds, so it is only
+ // ever created once, but every statement that references it needs it bound.
+ var owned = i >= statement.Start && i < statement.End;
+ if (owned || references(statement.Sql, parameters[i].ParameterName))
+ {
+ // An OracleParameter cannot be in two collections at once, so a shared one has
+ // to be copied rather than moved
+ command.Parameters.Add(owned ? parameters[i] : copyOf(parameters[i]));
+ }
+ }
+
+ commands.Add(command);
+ }
+
+ return commands;
+ }
+
+ ///
+ public override DbParameter AddParameter(object? value, DbType? dbType = null)
+ {
+ var parameter = base.AddParameter(normalize(value), null);
+ applyOracleType(parameter, value, dbType);
+
+ return parameter;
+ }
+
+ ///
+ public override DbParameter AddNamedParameter(string name, object value, DbType? dbType = null)
+ {
+ var parameter = base.AddNamedParameter(name, normalize(value)!, null);
+ applyOracleType(parameter, value, dbType);
+
+ return parameter;
+ }
+
+ ///
+ /// Oracle has no boolean type and stores Guids as RAW(16), so neither can be handed to
+ /// as-is.
+ ///
+ private static object? normalize(object? value)
+ {
+ return value switch
+ {
+ Guid guid => guid.ToByteArray(),
+ bool boolean => boolean ? 1 : 0,
+ _ => value
+ };
+ }
+
+ ///
+ /// Type the parameter from the *original* CLR value through , rather
+ /// than through the generic mapping the neutral builder would otherwise
+ /// apply. The generic mapping has no entry for and resolves it to
+ /// , which ODP.NET rejects.
+ ///
+ private static void applyOracleType(DbParameter parameter, object? original, DbType? dbType)
+ {
+ if (parameter is not OracleParameter oracleParameter)
+ {
+ if (dbType.HasValue)
+ {
+ parameter.DbType = dbType.Value;
+ }
+
+ return;
+ }
+
+ if (original is null or DBNull)
+ {
+ return;
+ }
+
+ oracleParameter.OracleDbType = OracleProvider.Instance.ToParameterType(original.GetType());
+ }
+
+ ///
+ /// Does this statement's SQL bind ? Matches :name as a whole
+ /// token, so that :p1 does not count as a reference to :p11.
+ ///
+ private static bool references(string sql, string name)
+ {
+ var from = 0;
+ while (true)
+ {
+ var at = sql.IndexOf(':' + name, from, StringComparison.OrdinalIgnoreCase);
+ if (at < 0)
+ {
+ return false;
+ }
+
+ var after = at + name.Length + 1;
+ if (after >= sql.Length || !isIdentifierCharacter(sql[after]))
+ {
+ return true;
+ }
+
+ from = after;
+ }
+ }
+
+ private static bool isIdentifierCharacter(char c)
+ {
+ return char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#';
+ }
+
+ private static OracleParameter copyOf(OracleParameter parameter)
+ {
+ return new OracleParameter
+ {
+ ParameterName = parameter.ParameterName,
+ OracleDbType = parameter.OracleDbType,
+ Value = parameter.Value,
+ Direction = parameter.Direction,
+ Size = parameter.Size
+ };
+ }
+
+ private readonly record struct Statement(string Sql, int Start, int End);
+}
diff --git a/src/Weasel.Postgresql/CommandBuilder.cs b/src/Weasel.Postgresql/CommandBuilder.cs
index aae8a802..33edbe66 100644
--- a/src/Weasel.Postgresql/CommandBuilder.cs
+++ b/src/Weasel.Postgresql/CommandBuilder.cs
@@ -87,9 +87,9 @@ Weasel.Core.IGroupedParameterBuilder Weasel.Core.ICommandBuilder.CreateGroupedPa
return CreateGroupedParameterBuilder(seperator);
}
- public void StartNewCommand()
+ public override void StartNewCommand()
{
- // do nothing!
+ // do nothing! Npgsql happily executes several statements from one command.
}
}
diff --git a/src/Weasel.SqlServer/CommandBuilder.cs b/src/Weasel.SqlServer/CommandBuilder.cs
index 39fe79a7..a673cea7 100644
--- a/src/Weasel.SqlServer/CommandBuilder.cs
+++ b/src/Weasel.SqlServer/CommandBuilder.cs
@@ -59,9 +59,9 @@ public Weasel.Core.IGroupedParameterBuilder CreateGroupedParameterBuilder(char?
return new Weasel.Core.GroupedParameterBuilder(this, seperator);
}
- public void StartNewCommand()
+ public override void StartNewCommand()
{
- // do nothing!
+ // do nothing! SqlClient happily executes several statements from one command.
}
}