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. } }