From 33181b2c9dd6417bebc98007084bae1713f51f04 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 11 Jul 2026 14:35:36 +0200 Subject: [PATCH] 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. --- Elsa.sln | 15 +++++ .../AssemblyInfo.cs | 1 + .../Extensions/BulkUpsertExtensions.cs | 29 ++++----- .../BulkUpsertExtensionsTests.cs | 63 +++++++++++++++++++ .../Elsa.Persistence.EFCore.UnitTests.csproj | 16 +++++ 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 test/unit/Elsa.Persistence.EFCore.UnitTests/BulkUpsertExtensionsTests.cs create mode 100644 test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj diff --git a/Elsa.sln b/Elsa.sln index 4c0db89a7..60e0b80f5 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -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} diff --git a/src/modules/Elsa.Persistence.EFCore.Common/AssemblyInfo.cs b/src/modules/Elsa.Persistence.EFCore.Common/AssemblyInfo.cs index 77206ee4c..eb90fe57f 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/AssemblyInfo.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Elsa.Persistence.EFCore")] +[assembly: InternalsVisibleTo("Elsa.Persistence.EFCore.UnitTests")] diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs index 707702589..1d20f979c 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs @@ -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( + internal static (string, object[]) GenerateOracleUpsert( DbContext dbContext, IList entities, Expression> 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(); + 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(); @@ -390,23 +387,23 @@ public static class BulkUpsertExtensions value = converter.ConvertToProvider(value); parameters.Add(value!); - + // 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; var expr = columnType.StartsWith("NVARCHAR2", StringComparison.OrdinalIgnoreCase) ? $"CAST({paramName} AS {columnType})" : paramName; - + lineParts.Add($"{expr} AS {quotedAlias}"); } @@ -425,4 +422,4 @@ public static class BulkUpsertExtensions return (sb.ToString(), parameters.ToArray()); } -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/BulkUpsertExtensionsTests.cs b/test/unit/Elsa.Persistence.EFCore.UnitTests/BulkUpsertExtensionsTests.cs new file mode 100644 index 000000000..d0d1e24b5 --- /dev/null +++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/BulkUpsertExtensionsTests.cs @@ -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 + { + 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() + .UseOracle("Data Source=unused") + .Options; + return new TestDbContext(options); + } + + private sealed class TestDbContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entity = modelBuilder.Entity(); + 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; } + } +} diff --git a/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj b/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj new file mode 100644 index 000000000..50b51c57e --- /dev/null +++ b/test/unit/Elsa.Persistence.EFCore.UnitTests/Elsa.Persistence.EFCore.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + [Elsa.Persistence.EFCore]* + 0 + + + + + + + + + + +