< Summary

Information
Class: Elsa.Persistence.EFCore.Extensions.BulkUpsertExtensions
Assembly: Elsa.Persistence.EFCore.Common
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs
Line coverage
41%
Covered lines: 90
Uncovered lines: 127
Coverable lines: 217
Total lines: 427
Line coverage: 41.4%
Branch coverage
38%
Covered branches: 46
Total branches: 120
Branch coverage: 38.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BulkUpsertAsync()100%11100%
BulkUpsertAsync()46.42%402875%
GenerateSqlServerUpsert(...)95%2020100%
GenerateSqliteUpsert(...)0%272160%
GeneratePostgresUpsert(...)70%202090.24%
GenerateMySqlUpsert(...)0%272160%
GenerateOracleUpsert(...)0%420200%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Persistence.EFCore.Common/Extensions/BulkUpsertExtensions.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using System.Text;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.EntityFrameworkCore.Infrastructure;
 5using Microsoft.EntityFrameworkCore.Metadata;
 6using Microsoft.EntityFrameworkCore.Storage;
 7
 8// ReSharper disable once CheckNamespace
 9namespace Elsa.Persistence.EFCore.Extensions;
 10
 11/// <summary>
 12/// Provides extension methods to perform bulk upsert operations for entities
 13/// in an Entity Framework Core context, supporting multiple database providers.
 14/// </summary>
 15public static class BulkUpsertExtensions
 16{
 17    internal const int DefaultBatchSize = 50;
 18
 19    /// <summary>
 20    /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector.
 21    /// </summary>
 22    /// <typeparam name="TDbContext">The type of the database context.</typeparam>
 23    /// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
 24    /// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
 25    /// <param name="entities">The list of entities to be upserted.</param>
 26    /// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
 27    /// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
 28    public static async Task BulkUpsertAsync<TDbContext, TEntity>(
 29        this TDbContext dbContext,
 30        IList<TEntity> entities,
 31        Expression<Func<TEntity, string>> keySelector,
 32        CancellationToken cancellationToken = default)
 33        where TDbContext : DbContext
 34        where TEntity : class, new()
 35    {
 324736        await BulkUpsertAsync(dbContext, entities, keySelector, DefaultBatchSize, cancellationToken);
 324737    }
 38
 39    /// <summary>
 40    /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector an
 41    /// </summary>
 42    /// <typeparam name="TDbContext">The type of the database context.</typeparam>
 43    /// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
 44    /// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
 45    /// <param name="entities">The list of entities to be upserted.</param>
 46    /// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
 47    /// <param name="batchSize">The size of each batch for processing the upsert operation. Defaults to 50.</param>
 48    /// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
 49    /// <exception cref="NotSupportedException">Thrown if the database provider for the context is not supported.</excep
 50    public static async Task BulkUpsertAsync<TDbContext, TEntity>(
 51        this TDbContext dbContext,
 52        IList<TEntity> entities,
 53        Expression<Func<TEntity, string>> keySelector,
 54        int batchSize = DefaultBatchSize,
 55        CancellationToken cancellationToken = default)
 56        where TDbContext : DbContext
 57        where TEntity : class, new()
 58    {
 324759        if (entities.Count == 0)
 060            return;
 61
 62        // Identify the current provider (e.g., "Microsoft.EntityFrameworkCore.SqlServer")
 324763        var providerName = dbContext.Database.ProviderName?.ToLowerInvariant() ?? string.Empty;
 64
 65        // Determine the method for generating SQL based on the provider
 324766        Func<DbContext, IList<TEntity>, Expression<Func<TEntity, string>>, (string, object[])> generateSql = providerNam
 324767        {
 647568            var pn when pn.Contains("sqlserver") => GenerateSqlServerUpsert,
 1969            var pn when pn.Contains("sqlite") => GenerateSqliteUpsert,
 3870            var pn when pn.Contains("postgres") => GeneratePostgresUpsert,
 071            var pn when pn.Contains("mysql") => GenerateMySqlUpsert,
 072            var pn when pn.Contains("oracle") => GenerateOracleUpsert,
 073            _ => throw new NotSupportedException($"Provider '{providerName}' is not supported.")
 324774        };
 75
 76        // Loop through batched entities
 1298877        foreach (var batch in entities.Chunk(batchSize))
 78        {
 79            // Generate SQL and parameters
 324780            var (sql, parameters) = generateSql(dbContext, batch, keySelector);
 81
 324782            await dbContext.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 83        }
 324784    }
 85
 86    private static (string, object[]) GenerateSqlServerUpsert<TEntity>(
 87        DbContext dbContext,
 88        IList<TEntity> entities,
 89        Expression<Func<TEntity, string>> keySelector)
 90        where TEntity : class
 91    {
 322892        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 322893        var tableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
 322894        var storeObject = StoreObjectIdentifier.Table(entityType.GetTableName()!, entityType.GetSchema());
 322895        var props = entityType.GetProperties().ToList();
 322896        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 322897        var keyColumnName = $"[{keyProp.GetColumnName(storeObject)}]";
 322898        var columnNames = props
 6508899            .Select(p => $"[{p.GetColumnName(storeObject)}]")
 3228100            .ToList();
 101
 3228102        var mergeSql = new StringBuilder();
 3228103        mergeSql.AppendLine($"MERGE {tableName} AS Target");
 3228104        mergeSql.AppendLine("USING (VALUES");
 105
 3228106        var parameters = new List<object>();
 3228107        var parameterCount = 0;
 108
 17670109        for (var i = 0; i < entities.Count; i++)
 110        {
 5607111            var entity = entities[i];
 5607112            var values = new List<string>();
 113
 255174114            foreach (var property in props)
 115            {
 121980116                var paramName = $"{{{parameterCount++}}}";
 117
 118                // If it's a shadow property, retrieve value via Entry(..).Property(..)
 121980119                var value = property.IsShadowProperty()
 121980120                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 121980121                    : property.PropertyInfo?.GetValue(entity);
 122
 121980123                var converter = property.GetTypeMapping().Converter;
 121980124                if (converter != null)
 5511125                    value = converter.ConvertToProvider(value)!;
 126
 127                // Explicitly cast null values for varbinary columns
 121980128                if (property.GetColumnType().StartsWith("varbinary", StringComparison.OrdinalIgnoreCase) && value is nul
 2763129                    values.Add("CAST(NULL AS varbinary(max))"); // Explicitly cast null
 130                else
 119217131                    values.Add(paramName);
 132
 121980133                parameters.Add(value!);
 134            }
 135
 5607136            var line = $"({string.Join(", ", values)}){(i < entities.Count - 1 ? "," : string.Empty)}";
 5607137            mergeSql.AppendLine(line);
 138        }
 139
 3228140        mergeSql.AppendLine($") AS Source ({string.Join(", ", columnNames)})");
 3228141        mergeSql.AppendLine($"ON Target.{keyColumnName} = Source.{keyColumnName}");
 3228142        mergeSql.AppendLine("WHEN MATCHED THEN");
 130176143        mergeSql.AppendLine($"    UPDATE SET {string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $
 3228144        mergeSql.AppendLine("WHEN NOT MATCHED THEN");
 3228145        mergeSql.AppendLine($"    INSERT ({string.Join(", ", columnNames)})");
 68316146        mergeSql.AppendLine($"    VALUES ({string.Join(", ", columnNames.Select(c => $"Source.{c}"))});");
 147
 3228148        return (mergeSql.ToString(), parameters.ToArray());
 149    }
 150
 151    private static (string, object[]) GenerateSqliteUpsert<TEntity>(
 152        DbContext dbContext,
 153        IList<TEntity> entities,
 154        Expression<Func<TEntity, string>> keySelector)
 155        where TEntity : class
 156    {
 0157        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0158        var tableName = entityType.GetTableName();
 0159        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 0160        var props = entityType.GetProperties().ToList();
 0161        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0162        var keyColumnName = keyProp.GetColumnName(storeObject);
 0163        var columnNames = props
 0164            .Select(p => p.GetColumnName(storeObject)!)
 0165            .ToList();
 166
 0167        var sb = new StringBuilder();
 0168        var parameters = new List<object>();
 0169        var parameterCount = 0;
 170
 0171        sb.Append($"INSERT INTO \"{tableName}\" ({string.Join(", ", columnNames.Select(c => $"\"{c}\""))}) VALUES ");
 172
 0173        for (var i = 0; i < entities.Count; i++)
 174        {
 0175            var entity = entities[i];
 0176            var placeholders = new List<string>();
 177
 0178            foreach (var property in props)
 179            {
 0180                var paramName = $"{{{parameterCount++}}}";
 181
 0182                var value = property.IsShadowProperty()
 0183                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0184                    : property.PropertyInfo?.GetValue(entity);
 185
 0186                var converter = property.GetTypeMapping().Converter;
 0187                if (converter != null)
 0188                    value = converter.ConvertToProvider(value);
 189
 0190                placeholders.Add(paramName);
 0191                parameters.Add(value!);
 192            }
 193
 0194            sb.Append($"({string.Join(", ", placeholders)})");
 0195            if (i < entities.Count - 1)
 0196                sb.Append(", ");
 197        }
 198
 0199        sb.AppendLine();
 0200        sb.AppendLine($"ON CONFLICT(\"{keyColumnName}\") DO UPDATE SET");
 201
 0202        var updateAssignments = columnNames
 0203            .Where(c => c != keyColumnName)
 0204            .Select(c => $"\"{c}\"=excluded.\"{c}\"");
 205
 0206        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 207
 0208        return (sb.ToString(), parameters.ToArray());
 209    }
 210
 211    private static (string, object[]) GeneratePostgresUpsert<TEntity>(
 212        DbContext dbContext,
 213        IList<TEntity> entities,
 214        Expression<Func<TEntity, string>> keySelector)
 215        where TEntity : class
 216    {
 19217        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 19218        var tableName = entityType.GetTableName();
 19219        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 220
 19221        var props = entityType.GetProperties().ToList();
 222
 19223        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 19224        var keyColumnName = keyProp.GetColumnName(storeObject);
 19225        var columnNames = props
 57226            .Select(p => p.GetColumnName(storeObject)!)
 19227            .ToList();
 228
 19229        var sb = new StringBuilder();
 19230        var parameters = new List<object>();
 19231        var parameterCount = 0;
 232
 76233        sb.Append($"INSERT INTO \"{storeObject.Schema}\".\"{storeObject.Name}\" ({string.Join(", ", columnNames.Select(c
 234
 76235        for (var i = 0; i < entities.Count; i++)
 236        {
 19237            var entity = entities[i];
 19238            var placeholders = new List<string>();
 239
 152240            foreach (var property in props)
 241            {
 57242                var paramName = $"{{{parameterCount++}}}";
 243
 57244                var value = property.IsShadowProperty()
 57245                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 57246                    : property.PropertyInfo?.GetValue(entity);
 247
 57248                var converter = property.GetTypeMapping().Converter;
 57249                if (converter != null)
 0250                    value = converter.ConvertToProvider(value);
 251
 252                // Detect json/jsonb column types and cast the parameter so PostgreSQL accepts it.
 57253                var columnType = property.GetColumnType();
 57254                if (columnType.StartsWith("jsonb", StringComparison.OrdinalIgnoreCase))
 0255                    placeholders.Add($"CAST({paramName} AS jsonb)");
 57256                else if (columnType.StartsWith("json", StringComparison.OrdinalIgnoreCase))
 0257                    placeholders.Add($"CAST({paramName} AS json)");
 258                else
 57259                    placeholders.Add(paramName);
 260
 57261                parameters.Add(value!);
 262            }
 263
 19264            sb.Append($"({string.Join(", ", placeholders)})");
 19265            if (i < entities.Count - 1)
 0266                sb.Append(", ");
 267        }
 268
 19269        sb.AppendLine();
 19270        sb.AppendLine($"ON CONFLICT (\"{keyColumnName}\") DO UPDATE SET");
 271
 19272        var updateAssignments = columnNames
 57273            .Where(c => c != keyColumnName)
 57274            .Select(c => $"\"{c}\" = EXCLUDED.\"{c}\"");
 275
 19276        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 277
 19278        return (sb.ToString(), parameters.ToArray());
 279    }
 280
 281    private static (string, object[]) GenerateMySqlUpsert<TEntity>(
 282        DbContext dbContext,
 283        IList<TEntity> entities,
 284        Expression<Func<TEntity, string>> keySelector)
 285        where TEntity : class
 286    {
 0287        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0288        var tableName = entityType.GetTableName();
 0289        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 290
 0291        var props = entityType.GetProperties().ToList();
 292
 0293        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0294        var keyColumnName = keyProp.GetColumnName(storeObject);
 0295        var columnNames = props
 0296            .Select(p => p.GetColumnName(storeObject)!)
 0297            .ToList();
 298
 0299        var sb = new StringBuilder();
 0300        var parameters = new List<object>();
 0301        var parameterCount = 0;
 302
 0303        sb.Append($"INSERT INTO `{tableName}` ({string.Join(", ", columnNames.Select(c => $"`{c}`"))}) VALUES ");
 304
 0305        for (var i = 0; i < entities.Count; i++)
 306        {
 0307            var entity = entities[i];
 0308            var placeholders = new List<string>();
 309
 0310            foreach (var property in props)
 311            {
 0312                var paramName = $"{{{parameterCount++}}}";
 313
 0314                var value = property.IsShadowProperty()
 0315                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0316                    : property.PropertyInfo?.GetValue(entity);
 317
 0318                var converter = property.GetTypeMapping().Converter;
 0319                if (converter != null)
 0320                    value = converter.ConvertToProvider(value);
 321
 0322                placeholders.Add(paramName);
 0323                parameters.Add(value!);
 324            }
 325
 0326            sb.Append($"({string.Join(", ", placeholders)})");
 0327            if (i < entities.Count - 1)
 0328                sb.Append(", ");
 329        }
 330
 0331        sb.AppendLine();
 0332        sb.AppendLine("ON DUPLICATE KEY UPDATE");
 333
 0334        var updateAssignments = columnNames
 0335            .Where(c => c != keyColumnName)
 0336            .Select(c => $"`{c}` = VALUES(`{c}`)");
 337
 0338        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 339
 0340        return (sb.ToString(), parameters.ToArray());
 341    }
 342
 343    internal static (string, object[]) GenerateOracleUpsert<TEntity>(
 344        DbContext dbContext,
 345        IList<TEntity> entities,
 346        Expression<Func<TEntity, string>> keySelector)
 347        where TEntity : class
 348    {
 0349        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0350        var schema = entityType.GetSchema();
 0351        var tableName = entityType.GetTableName()!;
 0352        var storeObject = StoreObjectIdentifier.Table(tableName, schema);
 0353        var sqlGenerationHelper = dbContext.GetService<ISqlGenerationHelper>();
 0354        var fullName = sqlGenerationHelper.DelimitIdentifier(tableName, schema);
 355
 0356        var props = entityType.GetProperties().ToList();
 357
 0358        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0359        var keyColumnName = keyProp.GetColumnName(storeObject)!;
 360
 361        // Pre-build quoted column names once and reuse throughout all clauses.
 0362        var quotedColumnNames = props
 0363            .Select(p => sqlGenerationHelper.DelimitIdentifier(p.GetColumnName(storeObject)!))
 0364            .ToList();
 0365        var quotedKeyColumnName = sqlGenerationHelper.DelimitIdentifier(keyColumnName);
 366
 0367        var sb = new StringBuilder();
 0368        var parameters = new List<object>();
 0369        var parameterCount = 0;
 370
 0371        sb.AppendLine($"MERGE INTO {fullName} Target");
 0372        sb.AppendLine("USING (SELECT");
 373
 0374        for (var i = 0; i < entities.Count; i++)
 375        {
 0376            var entity = entities[i];
 0377            var lineParts = new List<string>();
 378
 0379            foreach (var property in props)
 380            {
 0381                var paramName = $"{{{parameterCount++}}}";
 382
 0383                var value = property.IsShadowProperty()
 0384                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0385                    : property.PropertyInfo?.GetValue(entity);
 386
 0387                var converter = property.GetTypeMapping().Converter;
 0388                if (converter != null)
 0389                    value = converter.ConvertToProvider(value);
 390
 0391                parameters.Add(value!);
 392
 393                // Aliases must be quoted so Oracle preserves their case, matching
 394                // the quoted references in ON, UPDATE SET, and INSERT/VALUES below.
 0395                var quotedAlias = sqlGenerationHelper.DelimitIdentifier(property.GetColumnName(storeObject)!);
 396
 397                // In a SELECT … FROM DUAL subquery, ODP.NET has no target column to
 398                // derive bind parameter types from and defaults to VARCHAR2 for .NET
 399                // strings. Elsa's Oracle migrations define string columns as NVARCHAR2,
 400                // so an explicit CAST is required to avoid a datatype mismatch error.
 401                // The full EF Core column type string (e.g. "NVARCHAR2(450)") is
 402                // used directly in the CAST so all Oracle type variants are handled
 403                // correctly without any string parsing.
 0404                var columnType = property.GetColumnType() ?? string.Empty;
 0405                var expr = columnType.StartsWith("NVARCHAR2", StringComparison.OrdinalIgnoreCase)
 0406                    ? $"CAST({paramName} AS {columnType})"
 0407                    : paramName;
 408
 0409                lineParts.Add($"{expr} AS {quotedAlias}");
 410            }
 411
 0412            var suffix = i < entities.Count - 1 ? " FROM DUAL UNION ALL SELECT" : " FROM DUAL";
 0413            sb.AppendLine(string.Join(", ", lineParts) + suffix);
 414        }
 415
 0416        sb.AppendLine($") Source ON (Target.{quotedKeyColumnName} = Source.{quotedKeyColumnName})");
 0417        sb.AppendLine("WHEN MATCHED THEN UPDATE SET");
 0418        sb.AppendLine(string.Join(", ", quotedColumnNames
 0419            .Where(c => c != quotedKeyColumnName)
 0420            .Select(c => $"Target.{c} = Source.{c}")));
 0421        sb.AppendLine("WHEN NOT MATCHED THEN");
 0422        sb.AppendLine($"INSERT ({string.Join(", ", quotedColumnNames)})");
 0423        sb.AppendLine($"VALUES ({string.Join(", ", quotedColumnNames.Select(c => $"Source.{c}"))});");
 424
 0425        return (sb.ToString(), parameters.ToArray());
 426    }
 427}