From 82e069c265c0ae483fdc0899b0d89b680238218b Mon Sep 17 00:00:00 2001 From: MohitGuptaC <155074110+MohitGuptaC@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:13:51 +0530 Subject: [PATCH] fix: correct Oracle identifier quoting and NVARCHAR2 cast in GenerateOracleUpsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unquoted aliases in SELECT … FROM DUAL caused ORA-00904 because Oracle uppercases bare identifiers. All aliases, ON condition, UPDATE SET, and INSERT/VALUES column references are now double-quoted to match the case-sensitive names EF Core migrations produce. NVARCHAR2 columns additionally required an explicit CAST because ODP.NET cannot infer bind parameter types from a FROM DUAL subquery and defaults to VARCHAR2. CAST(:p AS NVARCHAR2(n)) with length extracted from the EF column type string resolves the datatype mismatch. Both fixes are required — neither alone produces working Oracle persistence. All other providers are unchanged. fixes Fixes #7755 --- .../Extensions/BulkUpsertExtensions.cs | 213 +++++++++--------- 1 file changed, 103 insertions(+), 110 deletions(-) diff --git a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs index fadf509a7..2749790bd 100644 --- a/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs +++ b/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs @@ -16,12 +16,6 @@ public static class BulkUpsertExtensions /// /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector. /// - /// The type of the database context. - /// The type of the entity being upserted. - /// The database context where the bulk upsert operation will be executed. - /// The list of entities to be upserted. - /// An expression used to determine the key for upsert operations. - /// A token to observe while waiting for the operation to complete. public static async Task BulkUpsertAsync( this TDbContext dbContext, IList entities, @@ -36,13 +30,6 @@ public static class BulkUpsertExtensions /// /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector and optional batch size. /// - /// The type of the database context. - /// The type of the entity being upserted. - /// The database context where the bulk upsert operation will be executed. - /// The list of entities to be upserted. - /// An expression used to determine the key for upsert operations. - /// The size of each batch for processing the upsert operation. Defaults to 50. - /// A token to observe while waiting for the operation to complete. /// Thrown if the database provider for the context is not supported. public static async Task BulkUpsertAsync( this TDbContext dbContext, @@ -56,30 +43,29 @@ public static class BulkUpsertExtensions if (entities.Count == 0) return; - // Identify the current provider (e.g., "Microsoft.EntityFrameworkCore.SqlServer") var providerName = dbContext.Database.ProviderName?.ToLowerInvariant() ?? string.Empty; - // Determine the method for generating SQL based on the provider Func, Expression>, (string, object[])> generateSql = providerName switch { var pn when pn.Contains("sqlserver") => GenerateSqlServerUpsert, - var pn when pn.Contains("sqlite") => GenerateSqliteUpsert, - var pn when pn.Contains("postgres") => GeneratePostgresUpsert, - var pn when pn.Contains("mysql") => GenerateMySqlUpsert, - var pn when pn.Contains("oracle") => GenerateOracleUpsert, + var pn when pn.Contains("sqlite") => GenerateSqliteUpsert, + var pn when pn.Contains("postgres") => GeneratePostgresUpsert, + var pn when pn.Contains("mysql") => GenerateMySqlUpsert, + var pn when pn.Contains("oracle") => GenerateOracleUpsert, _ => throw new NotSupportedException($"Provider '{providerName}' is not supported.") }; - // Loop through batched entities foreach (var batch in entities.Chunk(batchSize)) { - // Generate SQL and parameters var (sql, parameters) = generateSql(dbContext, batch, keySelector); - await dbContext.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken); } } + // ------------------------------------------------------------------------- + // SQL Server + // ------------------------------------------------------------------------- + private static (string, object[]) GenerateSqlServerUpsert( DbContext dbContext, IList entities, @@ -92,9 +78,7 @@ public static class BulkUpsertExtensions var props = entityType.GetProperties().ToList(); var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; var keyColumnName = $"[{keyProp.GetColumnName(storeObject)}]"; - var columnNames = props - .Select(p => $"[{p.GetColumnName(storeObject)}]") - .ToList(); + var columnNames = props.Select(p => $"[{p.GetColumnName(storeObject)}]").ToList(); var mergeSql = new StringBuilder(); mergeSql.AppendLine($"MERGE {tableName} AS Target"); @@ -111,27 +95,21 @@ public static class BulkUpsertExtensions foreach (var property in props) { var paramName = $"{{{parameterCount++}}}"; - - // If it's a shadow property, retrieve value via Entry(..).Property(..) var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); - var converter = property.GetTypeMapping().Converter; - if (converter != null) - value = converter.ConvertToProvider(value)!; + if (converter != null) value = converter.ConvertToProvider(value)!; - // Explicitly cast null values for varbinary columns if (property.GetColumnType().StartsWith("varbinary", StringComparison.OrdinalIgnoreCase) && value is null) - values.Add("CAST(NULL AS varbinary(max))"); // Explicitly cast null + values.Add("CAST(NULL AS varbinary(max))"); else values.Add(paramName); - + parameters.Add(value!); } - var line = $"({string.Join(", ", values)}){(i < entities.Count - 1 ? "," : string.Empty)}"; - mergeSql.AppendLine(line); + mergeSql.AppendLine($"({string.Join(", ", values)}){(i < entities.Count - 1 ? "," : string.Empty)}"); } mergeSql.AppendLine($") AS Source ({string.Join(", ", columnNames)})"); @@ -145,6 +123,10 @@ public static class BulkUpsertExtensions return (mergeSql.ToString(), parameters.ToArray()); } + // ------------------------------------------------------------------------- + // SQLite + // ------------------------------------------------------------------------- + private static (string, object[]) GenerateSqliteUpsert( DbContext dbContext, IList entities, @@ -157,9 +139,7 @@ public static class BulkUpsertExtensions var props = entityType.GetProperties().ToList(); var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; var keyColumnName = keyProp.GetColumnName(storeObject); - var columnNames = props - .Select(p => p.GetColumnName(storeObject)!) - .ToList(); + var columnNames = props.Select(p => p.GetColumnName(storeObject)!).ToList(); var sb = new StringBuilder(); var parameters = new List(); @@ -175,36 +155,30 @@ public static class BulkUpsertExtensions foreach (var property in props) { var paramName = $"{{{parameterCount++}}}"; - var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); - var converter = property.GetTypeMapping().Converter; - if (converter != null) - value = converter.ConvertToProvider(value); - + if (converter != null) value = converter.ConvertToProvider(value); placeholders.Add(paramName); parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); - if (i < entities.Count - 1) - sb.Append(", "); + if (i < entities.Count - 1) sb.Append(", "); } sb.AppendLine(); sb.AppendLine($"ON CONFLICT(\"{keyColumnName}\") DO UPDATE SET"); - - var updateAssignments = columnNames - .Where(c => c != keyColumnName) - .Select(c => $"\"{c}\"=excluded.\"{c}\""); - - sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + sb.AppendLine(string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $"\"{c}\"=excluded.\"{c}\"")) + ";"); return (sb.ToString(), parameters.ToArray()); } + // ------------------------------------------------------------------------- + // PostgreSQL + // ------------------------------------------------------------------------- + private static (string, object[]) GeneratePostgresUpsert( DbContext dbContext, IList entities, @@ -214,14 +188,10 @@ public static class BulkUpsertExtensions var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; var tableName = entityType.GetTableName(); var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema()); - var props = entityType.GetProperties().ToList(); - var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; var keyColumnName = keyProp.GetColumnName(storeObject); - var columnNames = props - .Select(p => p.GetColumnName(storeObject)!) - .ToList(); + var columnNames = props.Select(p => p.GetColumnName(storeObject)!).ToList(); var sb = new StringBuilder(); var parameters = new List(); @@ -237,16 +207,12 @@ public static class BulkUpsertExtensions foreach (var property in props) { var paramName = $"{{{parameterCount++}}}"; - var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); - var converter = property.GetTypeMapping().Converter; - if (converter != null) - value = converter.ConvertToProvider(value); + if (converter != null) value = converter.ConvertToProvider(value); - // Detect json/jsonb column types and cast the parameter so PostgreSQL accepts it. var columnType = property.GetColumnType(); if (columnType.StartsWith("jsonb", StringComparison.OrdinalIgnoreCase)) placeholders.Add($"CAST({paramName} AS jsonb)"); @@ -254,27 +220,25 @@ public static class BulkUpsertExtensions placeholders.Add($"CAST({paramName} AS json)"); else placeholders.Add(paramName); - + parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); - if (i < entities.Count - 1) - sb.Append(", "); + if (i < entities.Count - 1) sb.Append(", "); } sb.AppendLine(); sb.AppendLine($"ON CONFLICT (\"{keyColumnName}\") DO UPDATE SET"); - - var updateAssignments = columnNames - .Where(c => c != keyColumnName) - .Select(c => $"\"{c}\" = EXCLUDED.\"{c}\""); - - sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + sb.AppendLine(string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $"\"{c}\" = EXCLUDED.\"{c}\"")) + ";"); return (sb.ToString(), parameters.ToArray()); } + // ------------------------------------------------------------------------- + // MySQL + // ------------------------------------------------------------------------- + private static (string, object[]) GenerateMySqlUpsert( DbContext dbContext, IList entities, @@ -284,14 +248,10 @@ public static class BulkUpsertExtensions var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; var tableName = entityType.GetTableName(); var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema()); - var props = entityType.GetProperties().ToList(); - var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; var keyColumnName = keyProp.GetColumnName(storeObject); - var columnNames = props - .Select(p => p.GetColumnName(storeObject)!) - .ToList(); + var columnNames = props.Select(p => p.GetColumnName(storeObject)!).ToList(); var sb = new StringBuilder(); var parameters = new List(); @@ -307,36 +267,30 @@ public static class BulkUpsertExtensions foreach (var property in props) { var paramName = $"{{{parameterCount++}}}"; - var value = property.IsShadowProperty() ? dbContext.Entry(entity).Property(property.Name).CurrentValue : property.PropertyInfo?.GetValue(entity); - var converter = property.GetTypeMapping().Converter; - if (converter != null) - value = converter.ConvertToProvider(value); - + if (converter != null) value = converter.ConvertToProvider(value); placeholders.Add(paramName); parameters.Add(value!); } sb.Append($"({string.Join(", ", placeholders)})"); - if (i < entities.Count - 1) - sb.Append(", "); + if (i < entities.Count - 1) sb.Append(", "); } sb.AppendLine(); sb.AppendLine("ON DUPLICATE KEY UPDATE"); - - var updateAssignments = columnNames - .Where(c => c != keyColumnName) - .Select(c => $"`{c}` = VALUES(`{c}`)"); - - sb.AppendLine(string.Join(", ", updateAssignments) + ";"); + sb.AppendLine(string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $"`{c}` = VALUES(`{c}`)")) + ";"); return (sb.ToString(), parameters.ToArray()); } + // ------------------------------------------------------------------------- + // Oracle + // ------------------------------------------------------------------------- + private static (string, object[]) GenerateOracleUpsert( DbContext dbContext, IList entities, @@ -345,18 +299,24 @@ public static class BulkUpsertExtensions { var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!; var schema = entityType.GetSchema(); - var tableName = entityType.GetTableName(); - var storeObject = StoreObjectIdentifier.Table(tableName!, schema); - var fullName = !string.IsNullOrEmpty(schema) ? $"{schema}.{tableName}" : tableName; + 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 props = entityType.GetProperties().ToList(); - var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!; - var keyColumnName = keyProp.GetColumnName(storeObject); + var keyColumnName = keyProp.GetColumnName(storeObject)!; - var columnNames = props - .Select(p => p.GetColumnName(storeObject)!) + // Pre-build quoted column name list once; reuse throughout. + var quotedColumnNames = props + .Select(p => $"\"{p.GetColumnName(storeObject)}\"") .ToList(); + var quotedKeyColumnName = $"\"{keyColumnName}\""; var sb = new StringBuilder(); var parameters = new List(); @@ -384,28 +344,61 @@ public static class BulkUpsertExtensions parameters.Add(value!); - // Oracle aliases must match the column name - var alias = property.GetColumnName(storeObject); - lineParts.Add($"{paramName} AS {alias}"); + // Alias must be quoted so Oracle preserves case, matching the + // quoted references in the WHEN MATCHED / WHEN NOT MATCHED clauses. + var quotedAlias = $"\"{property.GetColumnName(storeObject)}\""; + + // Oracle cannot infer the bind parameter type from a bare SELECT … + // FROM DUAL — there is no target column to derive it from. For + // NVARCHAR2 columns this causes ODP.NET to default to VARCHAR2, + // which leads to datatype mismatch errors in the MERGE. An explicit + // CAST restores the correct type. The length is read from the EF + // column type string (e.g. "NVARCHAR2(500)") so it matches the + // actual column definition rather than an arbitrary hardcoded value. + string expr; + var columnType = property.GetColumnType() ?? string.Empty; + if (columnType.StartsWith("NVARCHAR2", StringComparison.OrdinalIgnoreCase)) + { + var length = ParseNVarchar2Length(columnType); + expr = $"CAST({paramName} AS NVARCHAR2({length}))"; + } + else + { + expr = paramName; + } + + lineParts.Add($"{expr} AS {quotedAlias}"); } - // Comma if not last - var suffix = (i < entities.Count - 1) ? " FROM DUAL UNION ALL SELECT" : " FROM DUAL"; + var suffix = i < entities.Count - 1 ? " FROM DUAL UNION ALL SELECT" : " FROM DUAL"; sb.AppendLine(string.Join(", ", lineParts) + suffix); } - sb.AppendLine($") Source ON (Target.{keyColumnName} = Source.{keyColumnName})"); + sb.AppendLine($") Source ON (Target.{quotedKeyColumnName} = Source.{quotedKeyColumnName})"); sb.AppendLine("WHEN MATCHED THEN UPDATE SET"); - - var updateSetClauses = columnNames - .Where(c => c != keyColumnName) - .Select(c => $"Target.{c} = Source.{c}"); - - sb.AppendLine(string.Join(", ", updateSetClauses)); + sb.AppendLine(string.Join(", ", quotedColumnNames + .Where(c => c != quotedKeyColumnName) + .Select(c => $"Target.{c} = Source.{c}"))); sb.AppendLine("WHEN NOT MATCHED THEN"); - sb.AppendLine($"INSERT ({string.Join(", ", columnNames)})"); - sb.AppendLine($"VALUES ({string.Join(", ", columnNames.Select(c => $"Source.{c}"))});"); + sb.AppendLine($"INSERT ({string.Join(", ", quotedColumnNames)})"); + sb.AppendLine($"VALUES ({string.Join(", ", quotedColumnNames.Select(c => $"Source.{c}"))});"); return (sb.ToString(), parameters.ToArray()); } -} \ No newline at end of file + + /// + /// Extracts the maximum length from an Oracle NVARCHAR2 column type string. + /// For example, "NVARCHAR2(500)" returns 500. + /// Falls back to 2000 (Oracle's maximum for NVARCHAR2) if the string is malformed. + /// + private static int ParseNVarchar2Length(string columnType) + { + var open = columnType.IndexOf('('); + var close = columnType.IndexOf(')'); + if (open >= 0 && close > open && + int.TryParse(columnType.AsSpan(open + 1, close - open - 1), out var length)) + return length; + + return 2000; + } +}