test: cover Oracle bulk upsert SQL generation

Exercise quoted Oracle identifiers, NVARCHAR2 casts, multi-row SQL, update/insert clauses, and parameter ordering. Reuse the provider SQL generation helper so identifier delimiting stays aligned with Oracle EF Core.
This commit is contained in:
Sipke Schoorstra 2026-07-11 14:35:36 +02:00
parent 6b7296fa29
commit 33181b2c9d
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
5 changed files with 108 additions and 16 deletions

View file

@ -413,6 +413,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.Runt
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Hosting.Management.UnitTests", "test\unit\Elsa.Hosting.Management.UnitTests\Elsa.Hosting.Management.UnitTests.csproj", "{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.EFCore.UnitTests", "test\unit\Elsa.Persistence.EFCore.UnitTests\Elsa.Persistence.EFCore.UnitTests.csproj", "{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -1837,6 +1839,18 @@ Global
{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x64.Build.0 = Release|Any CPU
{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x86.ActiveCfg = Release|Any CPU
{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6}.Release|x86.Build.0 = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|x64.ActiveCfg = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|x64.Build.0 = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|x86.ActiveCfg = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Debug|x86.Build.0 = Debug|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|Any CPU.Build.0 = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|x64.ActiveCfg = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|x64.Build.0 = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|x86.ActiveCfg = Release|Any CPU
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -1993,6 +2007,7 @@ Global
{E1607923-038B-41D6-9D23-F540FC9E6CCE} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{6E3B6948-B16D-480C-879E-B805F732649F} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{39DE4EE7-0FDB-499F-9BC2-7ECC779FA5F6} = {18453B51-25EB-4317-A4B3-B10518252E92}
{C6EFA89A-923E-4F7A-A63B-BC8DDF44A208} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Elsa.Persistence.EFCore")]
[assembly: InternalsVisibleTo("Elsa.Persistence.EFCore.UnitTests")]

View file

@ -1,8 +1,9 @@
using System.Linq.Expressions;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Storage;
// ReSharper disable once CheckNamespace
namespace Elsa.Persistence.EFCore.Extensions;
@ -337,7 +338,7 @@ public static class BulkUpsertExtensions
return (sb.ToString(), parameters.ToArray());
}
private static (string, object[]) GenerateOracleUpsert<TEntity>(
internal static (string, object[]) GenerateOracleUpsert<TEntity>(
DbContext dbContext,
IList<TEntity> entities,
Expression<Func<TEntity, string>> keySelector)
@ -347,12 +348,8 @@ public static class BulkUpsertExtensions
var schema = entityType.GetSchema();
var tableName = entityType.GetTableName()!;
var storeObject = StoreObjectIdentifier.Table(tableName, schema);
// Both schema and table must be quoted so Oracle treats them as case-sensitive
// identifiers, matching what EF Core migrations create.
var fullName = !string.IsNullOrEmpty(schema)
? $"\"{schema}\".\"{tableName}\""
: $"\"{tableName}\"";
var sqlGenerationHelper = dbContext.GetService<ISqlGenerationHelper>();
var fullName = sqlGenerationHelper.DelimitIdentifier(tableName, schema);
var props = entityType.GetProperties().ToList();
@ -361,9 +358,9 @@ public static class BulkUpsertExtensions
// Pre-build quoted column names once and reuse throughout all clauses.
var quotedColumnNames = props
.Select(p => $"\"{p.GetColumnName(storeObject)}\"")
.Select(p => sqlGenerationHelper.DelimitIdentifier(p.GetColumnName(storeObject)!))
.ToList();
var quotedKeyColumnName = $"\"{keyColumnName}\"";
var quotedKeyColumnName = sqlGenerationHelper.DelimitIdentifier(keyColumnName);
var sb = new StringBuilder();
var parameters = new List<object>();
@ -393,13 +390,13 @@ public static class BulkUpsertExtensions
// Aliases must be quoted so Oracle preserves their case, matching
// the quoted references in ON, UPDATE SET, and INSERT/VALUES below.
var quotedAlias = $"\"{property.GetColumnName(storeObject)}\"";
var quotedAlias = sqlGenerationHelper.DelimitIdentifier(property.GetColumnName(storeObject)!);
// In a SELECT … FROM DUAL subquery, ODP.NET has no target column to
// derive bind parameter types from and defaults to VARCHAR2 for .NET
// strings. Elsa's Oracle migrations define string columns as NVARCHAR2,
// so an explicit CAST is required to avoid a datatype mismatch error.
// The full EF Core column type string (e.g. "NVARCHAR2(450 CHAR)") is
// The full EF Core column type string (e.g. "NVARCHAR2(450)") is
// used directly in the CAST so all Oracle type variants are handled
// correctly without any string parsing.
var columnType = property.GetColumnType() ?? string.Empty;

View file

@ -0,0 +1,63 @@
using Elsa.Persistence.EFCore.Extensions;
using Microsoft.EntityFrameworkCore;
namespace Elsa.Persistence.EFCore.UnitTests;
public class BulkUpsertExtensionsTests
{
[Fact]
public void GenerateOracleUpsert_ProducesQuotedMergeWithNvarcharCasts()
{
using var dbContext = CreateDbContext();
var entities = new List<TestEntity>
{
new() { Id = "first", Name = "First", Count = 1 },
new() { Id = "second", Name = null, Count = 2 }
};
var (sql, parameters) = BulkUpsertExtensions.GenerateOracleUpsert(dbContext, entities, x => x.Id);
Assert.Contains("MERGE INTO \"Elsa\".\"ActivityExecutionRecords\" Target", sql);
Assert.Contains("CAST({0} AS NVARCHAR2(450)) AS \"RecordId\"", sql);
Assert.Contains("CAST({2} AS NVARCHAR2(2000)) AS \"DisplayName\"", sql);
Assert.Contains("CAST({5} AS NVARCHAR2(2000)) AS \"DisplayName\"", sql);
Assert.Contains("FROM DUAL UNION ALL SELECT", sql);
Assert.Contains("Target.\"RecordId\" = Source.\"RecordId\"", sql);
Assert.Contains("INSERT (\"RecordId\", \"Count\", \"DisplayName\")", sql);
Assert.Contains("VALUES (Source.\"RecordId\", Source.\"Count\", Source.\"DisplayName\")", sql);
Assert.DoesNotContain("CAST({1} AS NUMBER", sql);
var updateClause = sql[sql.IndexOf("WHEN MATCHED", StringComparison.Ordinal)..sql.IndexOf("WHEN NOT MATCHED", StringComparison.Ordinal)];
Assert.DoesNotContain("Target.\"RecordId\" = Source.\"RecordId\"", updateClause);
Assert.Contains("Target.\"DisplayName\" = Source.\"DisplayName\"", updateClause);
Assert.Equal(new object?[] { "first", 1, "First", "second", 2, null }, parameters);
}
private static TestDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<TestDbContext>()
.UseOracle("Data Source=unused")
.Options;
return new TestDbContext(options);
}
private sealed class TestDbContext(DbContextOptions<TestDbContext> options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var entity = modelBuilder.Entity<TestEntity>();
entity.ToTable("ActivityExecutionRecords", "Elsa");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasColumnName("RecordId").HasColumnType("NVARCHAR2(450)");
entity.Property(x => x.Count).HasColumnType("NUMBER(10)");
entity.Property(x => x.Name).HasColumnName("DisplayName").HasColumnType("NVARCHAR2(2000)");
}
}
private sealed class TestEntity
{
public string Id { get; set; } = null!;
public int Count { get; set; }
public string? Name { get; set; }
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.Persistence.EFCore]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Oracle.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.EFCore.Common\Elsa.Persistence.EFCore.Common.csproj" />
</ItemGroup>
</Project>