fix: correct Oracle identifier quoting and NVARCHAR2 cast in GenerateOracleUpsert

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
This commit is contained in:
MohitGuptaC 2026-06-24 00:13:51 +05:30 committed by GitHub
parent 869aecfd7e
commit 82e069c265
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -16,12 +16,6 @@ public static class BulkUpsertExtensions
/// <summary>
/// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector.
/// </summary>
/// <typeparam name="TDbContext">The type of the database context.</typeparam>
/// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
/// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
/// <param name="entities">The list of entities to be upserted.</param>
/// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
/// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
public static async Task BulkUpsertAsync<TDbContext, TEntity>(
this TDbContext dbContext,
IList<TEntity> entities,
@ -36,13 +30,6 @@ public static class BulkUpsertExtensions
/// <summary>
/// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector and optional batch size.
/// </summary>
/// <typeparam name="TDbContext">The type of the database context.</typeparam>
/// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
/// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
/// <param name="entities">The list of entities to be upserted.</param>
/// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
/// <param name="batchSize">The size of each batch for processing the upsert operation. Defaults to 50.</param>
/// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
/// <exception cref="NotSupportedException">Thrown if the database provider for the context is not supported.</exception>
public static async Task BulkUpsertAsync<TDbContext, TEntity>(
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<DbContext, IList<TEntity>, Expression<Func<TEntity, string>>, (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<TEntity>(
DbContext dbContext,
IList<TEntity> 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<TEntity>(
DbContext dbContext,
IList<TEntity> 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<object>();
@ -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<TEntity>(
DbContext dbContext,
IList<TEntity> 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<object>();
@ -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)");
@ -259,22 +225,20 @@ public static class BulkUpsertExtensions
}
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<TEntity>(
DbContext dbContext,
IList<TEntity> 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<object>();
@ -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<TEntity>(
DbContext dbContext,
IList<TEntity> 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<object>();
@ -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());
}
/// <summary>
/// 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.
/// </summary>
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;
}
}