< 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
23%
Covered lines: 51
Uncovered lines: 166
Coverable lines: 217
Total lines: 425
Line coverage: 23.5%
Branch coverage
22%
Covered branches: 27
Total branches: 120
Branch coverage: 22.5%
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()28.57%692862.5%
GenerateSqlServerUpsert(...)95%2020100%
GenerateSqliteUpsert(...)0%272160%
GeneratePostgresUpsert(...)0%420200%
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    /// <summary>
 18    /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector.
 19    /// </summary>
 20    /// <typeparam name="TDbContext">The type of the database context.</typeparam>
 21    /// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
 22    /// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
 23    /// <param name="entities">The list of entities to be upserted.</param>
 24    /// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
 25    /// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
 26    public static async Task BulkUpsertAsync<TDbContext, TEntity>(
 27        this TDbContext dbContext,
 28        IList<TEntity> entities,
 29        Expression<Func<TEntity, string>> keySelector,
 30        CancellationToken cancellationToken = default)
 31        where TDbContext : DbContext
 32        where TEntity : class, new()
 33    {
 318634        await BulkUpsertAsync(dbContext, entities, keySelector, 50, cancellationToken);
 318635    }
 36
 37    /// <summary>
 38    /// Performs a bulk upsert operation on a list of entities in the specified database context using a key selector an
 39    /// </summary>
 40    /// <typeparam name="TDbContext">The type of the database context.</typeparam>
 41    /// <typeparam name="TEntity">The type of the entity being upserted.</typeparam>
 42    /// <param name="dbContext">The database context where the bulk upsert operation will be executed.</param>
 43    /// <param name="entities">The list of entities to be upserted.</param>
 44    /// <param name="keySelector">An expression used to determine the key for upsert operations.</param>
 45    /// <param name="batchSize">The size of each batch for processing the upsert operation. Defaults to 50.</param>
 46    /// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
 47    /// <exception cref="NotSupportedException">Thrown if the database provider for the context is not supported.</excep
 48    public static async Task BulkUpsertAsync<TDbContext, TEntity>(
 49        this TDbContext dbContext,
 50        IList<TEntity> entities,
 51        Expression<Func<TEntity, string>> keySelector,
 52        int batchSize = 50,
 53        CancellationToken cancellationToken = default)
 54        where TDbContext : DbContext
 55        where TEntity : class, new()
 56    {
 318657        if (entities.Count == 0)
 058            return;
 59
 60        // Identify the current provider (e.g., "Microsoft.EntityFrameworkCore.SqlServer")
 318661        var providerName = dbContext.Database.ProviderName?.ToLowerInvariant() ?? string.Empty;
 62
 63        // Determine the method for generating SQL based on the provider
 318664        Func<DbContext, IList<TEntity>, Expression<Func<TEntity, string>>, (string, object[])> generateSql = providerNam
 318665        {
 637266            var pn when pn.Contains("sqlserver") => GenerateSqlServerUpsert,
 067            var pn when pn.Contains("sqlite") => GenerateSqliteUpsert,
 068            var pn when pn.Contains("postgres") => GeneratePostgresUpsert,
 069            var pn when pn.Contains("mysql") => GenerateMySqlUpsert,
 070            var pn when pn.Contains("oracle") => GenerateOracleUpsert,
 071            _ => throw new NotSupportedException($"Provider '{providerName}' is not supported.")
 318672        };
 73
 74        // Loop through batched entities
 1274475        foreach (var batch in entities.Chunk(batchSize))
 76        {
 77            // Generate SQL and parameters
 318678            var (sql, parameters) = generateSql(dbContext, batch, keySelector);
 79
 318680            await dbContext.Database.ExecuteSqlRawAsync(sql, parameters, cancellationToken);
 81        }
 318682    }
 83
 84    private static (string, object[]) GenerateSqlServerUpsert<TEntity>(
 85        DbContext dbContext,
 86        IList<TEntity> entities,
 87        Expression<Func<TEntity, string>> keySelector)
 88        where TEntity : class
 89    {
 318690        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 318691        var tableName = $"[{entityType.GetSchema()}].[{entityType.GetTableName()}]";
 318692        var storeObject = StoreObjectIdentifier.Table(entityType.GetTableName()!, entityType.GetSchema());
 318693        var props = entityType.GetProperties().ToList();
 318694        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 318695        var keyColumnName = $"[{keyProp.GetColumnName(storeObject)}]";
 318696        var columnNames = props
 6422497            .Select(p => $"[{p.GetColumnName(storeObject)}]")
 318698            .ToList();
 99
 3186100        var mergeSql = new StringBuilder();
 3186101        mergeSql.AppendLine($"MERGE {tableName} AS Target");
 3186102        mergeSql.AppendLine("USING (VALUES");
 103
 3186104        var parameters = new List<object>();
 3186105        var parameterCount = 0;
 106
 17466107        for (var i = 0; i < entities.Count; i++)
 108        {
 5547109            var entity = entities[i];
 5547110            var values = new List<string>();
 111
 252470112            foreach (var property in props)
 113            {
 120688114                var paramName = $"{{{parameterCount++}}}";
 115
 116                // If it's a shadow property, retrieve value via Entry(..).Property(..)
 120688117                var value = property.IsShadowProperty()
 120688118                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 120688119                    : property.PropertyInfo?.GetValue(entity);
 120
 120688121                var converter = property.GetTypeMapping().Converter;
 120688122                if (converter != null)
 5451123                    value = converter.ConvertToProvider(value)!;
 124
 125                // Explicitly cast null values for varbinary columns
 120688126                if (property.GetColumnType().StartsWith("varbinary", StringComparison.OrdinalIgnoreCase) && value is nul
 2726127                    values.Add("CAST(NULL AS varbinary(max))"); // Explicitly cast null
 128                else
 117962129                    values.Add(paramName);
 130
 120688131                parameters.Add(value!);
 132            }
 133
 5547134            var line = $"({string.Join(", ", values)}){(i < entities.Count - 1 ? "," : string.Empty)}";
 5547135            mergeSql.AppendLine(line);
 136        }
 137
 3186138        mergeSql.AppendLine($") AS Source ({string.Join(", ", columnNames)})");
 3186139        mergeSql.AppendLine($"ON Target.{keyColumnName} = Source.{keyColumnName}");
 3186140        mergeSql.AppendLine("WHEN MATCHED THEN");
 128448141        mergeSql.AppendLine($"    UPDATE SET {string.Join(", ", columnNames.Where(c => c != keyColumnName).Select(c => $
 3186142        mergeSql.AppendLine("WHEN NOT MATCHED THEN");
 3186143        mergeSql.AppendLine($"    INSERT ({string.Join(", ", columnNames)})");
 67410144        mergeSql.AppendLine($"    VALUES ({string.Join(", ", columnNames.Select(c => $"Source.{c}"))});");
 145
 3186146        return (mergeSql.ToString(), parameters.ToArray());
 147    }
 148
 149    private static (string, object[]) GenerateSqliteUpsert<TEntity>(
 150        DbContext dbContext,
 151        IList<TEntity> entities,
 152        Expression<Func<TEntity, string>> keySelector)
 153        where TEntity : class
 154    {
 0155        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0156        var tableName = entityType.GetTableName();
 0157        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 0158        var props = entityType.GetProperties().ToList();
 0159        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0160        var keyColumnName = keyProp.GetColumnName(storeObject);
 0161        var columnNames = props
 0162            .Select(p => p.GetColumnName(storeObject)!)
 0163            .ToList();
 164
 0165        var sb = new StringBuilder();
 0166        var parameters = new List<object>();
 0167        var parameterCount = 0;
 168
 0169        sb.Append($"INSERT INTO \"{tableName}\" ({string.Join(", ", columnNames.Select(c => $"\"{c}\""))}) VALUES ");
 170
 0171        for (var i = 0; i < entities.Count; i++)
 172        {
 0173            var entity = entities[i];
 0174            var placeholders = new List<string>();
 175
 0176            foreach (var property in props)
 177            {
 0178                var paramName = $"{{{parameterCount++}}}";
 179
 0180                var value = property.IsShadowProperty()
 0181                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0182                    : property.PropertyInfo?.GetValue(entity);
 183
 0184                var converter = property.GetTypeMapping().Converter;
 0185                if (converter != null)
 0186                    value = converter.ConvertToProvider(value);
 187
 0188                placeholders.Add(paramName);
 0189                parameters.Add(value!);
 190            }
 191
 0192            sb.Append($"({string.Join(", ", placeholders)})");
 0193            if (i < entities.Count - 1)
 0194                sb.Append(", ");
 195        }
 196
 0197        sb.AppendLine();
 0198        sb.AppendLine($"ON CONFLICT(\"{keyColumnName}\") DO UPDATE SET");
 199
 0200        var updateAssignments = columnNames
 0201            .Where(c => c != keyColumnName)
 0202            .Select(c => $"\"{c}\"=excluded.\"{c}\"");
 203
 0204        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 205
 0206        return (sb.ToString(), parameters.ToArray());
 207    }
 208
 209    private static (string, object[]) GeneratePostgresUpsert<TEntity>(
 210        DbContext dbContext,
 211        IList<TEntity> entities,
 212        Expression<Func<TEntity, string>> keySelector)
 213        where TEntity : class
 214    {
 0215        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0216        var tableName = entityType.GetTableName();
 0217        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 218
 0219        var props = entityType.GetProperties().ToList();
 220
 0221        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0222        var keyColumnName = keyProp.GetColumnName(storeObject);
 0223        var columnNames = props
 0224            .Select(p => p.GetColumnName(storeObject)!)
 0225            .ToList();
 226
 0227        var sb = new StringBuilder();
 0228        var parameters = new List<object>();
 0229        var parameterCount = 0;
 230
 0231        sb.Append($"INSERT INTO \"{storeObject.Schema}\".\"{storeObject.Name}\" ({string.Join(", ", columnNames.Select(c
 232
 0233        for (var i = 0; i < entities.Count; i++)
 234        {
 0235            var entity = entities[i];
 0236            var placeholders = new List<string>();
 237
 0238            foreach (var property in props)
 239            {
 0240                var paramName = $"{{{parameterCount++}}}";
 241
 0242                var value = property.IsShadowProperty()
 0243                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0244                    : property.PropertyInfo?.GetValue(entity);
 245
 0246                var converter = property.GetTypeMapping().Converter;
 0247                if (converter != null)
 0248                    value = converter.ConvertToProvider(value);
 249
 250                // Detect json/jsonb column types and cast the parameter so PostgreSQL accepts it.
 0251                var columnType = property.GetColumnType();
 0252                if (columnType.StartsWith("jsonb", StringComparison.OrdinalIgnoreCase))
 0253                    placeholders.Add($"CAST({paramName} AS jsonb)");
 0254                else if (columnType.StartsWith("json", StringComparison.OrdinalIgnoreCase))
 0255                    placeholders.Add($"CAST({paramName} AS json)");
 256                else
 0257                    placeholders.Add(paramName);
 258
 0259                parameters.Add(value!);
 260            }
 261
 0262            sb.Append($"({string.Join(", ", placeholders)})");
 0263            if (i < entities.Count - 1)
 0264                sb.Append(", ");
 265        }
 266
 0267        sb.AppendLine();
 0268        sb.AppendLine($"ON CONFLICT (\"{keyColumnName}\") DO UPDATE SET");
 269
 0270        var updateAssignments = columnNames
 0271            .Where(c => c != keyColumnName)
 0272            .Select(c => $"\"{c}\" = EXCLUDED.\"{c}\"");
 273
 0274        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 275
 0276        return (sb.ToString(), parameters.ToArray());
 277    }
 278
 279    private static (string, object[]) GenerateMySqlUpsert<TEntity>(
 280        DbContext dbContext,
 281        IList<TEntity> entities,
 282        Expression<Func<TEntity, string>> keySelector)
 283        where TEntity : class
 284    {
 0285        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0286        var tableName = entityType.GetTableName();
 0287        var storeObject = StoreObjectIdentifier.Table(tableName!, entityType.GetSchema());
 288
 0289        var props = entityType.GetProperties().ToList();
 290
 0291        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0292        var keyColumnName = keyProp.GetColumnName(storeObject);
 0293        var columnNames = props
 0294            .Select(p => p.GetColumnName(storeObject)!)
 0295            .ToList();
 296
 0297        var sb = new StringBuilder();
 0298        var parameters = new List<object>();
 0299        var parameterCount = 0;
 300
 0301        sb.Append($"INSERT INTO `{tableName}` ({string.Join(", ", columnNames.Select(c => $"`{c}`"))}) VALUES ");
 302
 0303        for (var i = 0; i < entities.Count; i++)
 304        {
 0305            var entity = entities[i];
 0306            var placeholders = new List<string>();
 307
 0308            foreach (var property in props)
 309            {
 0310                var paramName = $"{{{parameterCount++}}}";
 311
 0312                var value = property.IsShadowProperty()
 0313                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0314                    : property.PropertyInfo?.GetValue(entity);
 315
 0316                var converter = property.GetTypeMapping().Converter;
 0317                if (converter != null)
 0318                    value = converter.ConvertToProvider(value);
 319
 0320                placeholders.Add(paramName);
 0321                parameters.Add(value!);
 322            }
 323
 0324            sb.Append($"({string.Join(", ", placeholders)})");
 0325            if (i < entities.Count - 1)
 0326                sb.Append(", ");
 327        }
 328
 0329        sb.AppendLine();
 0330        sb.AppendLine("ON DUPLICATE KEY UPDATE");
 331
 0332        var updateAssignments = columnNames
 0333            .Where(c => c != keyColumnName)
 0334            .Select(c => $"`{c}` = VALUES(`{c}`)");
 335
 0336        sb.AppendLine(string.Join(", ", updateAssignments) + ";");
 337
 0338        return (sb.ToString(), parameters.ToArray());
 339    }
 340
 341    internal static (string, object[]) GenerateOracleUpsert<TEntity>(
 342        DbContext dbContext,
 343        IList<TEntity> entities,
 344        Expression<Func<TEntity, string>> keySelector)
 345        where TEntity : class
 346    {
 0347        var entityType = dbContext.Model.FindEntityType(typeof(TEntity))!;
 0348        var schema = entityType.GetSchema();
 0349        var tableName = entityType.GetTableName()!;
 0350        var storeObject = StoreObjectIdentifier.Table(tableName, schema);
 0351        var sqlGenerationHelper = dbContext.GetService<ISqlGenerationHelper>();
 0352        var fullName = sqlGenerationHelper.DelimitIdentifier(tableName, schema);
 353
 0354        var props = entityType.GetProperties().ToList();
 355
 0356        var keyProp = entityType.FindProperty(keySelector.GetMemberAccess().Name)!;
 0357        var keyColumnName = keyProp.GetColumnName(storeObject)!;
 358
 359        // Pre-build quoted column names once and reuse throughout all clauses.
 0360        var quotedColumnNames = props
 0361            .Select(p => sqlGenerationHelper.DelimitIdentifier(p.GetColumnName(storeObject)!))
 0362            .ToList();
 0363        var quotedKeyColumnName = sqlGenerationHelper.DelimitIdentifier(keyColumnName);
 364
 0365        var sb = new StringBuilder();
 0366        var parameters = new List<object>();
 0367        var parameterCount = 0;
 368
 0369        sb.AppendLine($"MERGE INTO {fullName} Target");
 0370        sb.AppendLine("USING (SELECT");
 371
 0372        for (var i = 0; i < entities.Count; i++)
 373        {
 0374            var entity = entities[i];
 0375            var lineParts = new List<string>();
 376
 0377            foreach (var property in props)
 378            {
 0379                var paramName = $"{{{parameterCount++}}}";
 380
 0381                var value = property.IsShadowProperty()
 0382                    ? dbContext.Entry(entity).Property(property.Name).CurrentValue
 0383                    : property.PropertyInfo?.GetValue(entity);
 384
 0385                var converter = property.GetTypeMapping().Converter;
 0386                if (converter != null)
 0387                    value = converter.ConvertToProvider(value);
 388
 0389                parameters.Add(value!);
 390
 391                // Aliases must be quoted so Oracle preserves their case, matching
 392                // the quoted references in ON, UPDATE SET, and INSERT/VALUES below.
 0393                var quotedAlias = sqlGenerationHelper.DelimitIdentifier(property.GetColumnName(storeObject)!);
 394
 395                // In a SELECT … FROM DUAL subquery, ODP.NET has no target column to
 396                // derive bind parameter types from and defaults to VARCHAR2 for .NET
 397                // strings. Elsa's Oracle migrations define string columns as NVARCHAR2,
 398                // so an explicit CAST is required to avoid a datatype mismatch error.
 399                // The full EF Core column type string (e.g. "NVARCHAR2(450)") is
 400                // used directly in the CAST so all Oracle type variants are handled
 401                // correctly without any string parsing.
 0402                var columnType = property.GetColumnType() ?? string.Empty;
 0403                var expr = columnType.StartsWith("NVARCHAR2", StringComparison.OrdinalIgnoreCase)
 0404                    ? $"CAST({paramName} AS {columnType})"
 0405                    : paramName;
 406
 0407                lineParts.Add($"{expr} AS {quotedAlias}");
 408            }
 409
 0410            var suffix = i < entities.Count - 1 ? " FROM DUAL UNION ALL SELECT" : " FROM DUAL";
 0411            sb.AppendLine(string.Join(", ", lineParts) + suffix);
 412        }
 413
 0414        sb.AppendLine($") Source ON (Target.{quotedKeyColumnName} = Source.{quotedKeyColumnName})");
 0415        sb.AppendLine("WHEN MATCHED THEN UPDATE SET");
 0416        sb.AppendLine(string.Join(", ", quotedColumnNames
 0417            .Where(c => c != quotedKeyColumnName)
 0418            .Select(c => $"Target.{c} = Source.{c}")));
 0419        sb.AppendLine("WHEN NOT MATCHED THEN");
 0420        sb.AppendLine($"INSERT ({string.Join(", ", quotedColumnNames)})");
 0421        sb.AppendLine($"VALUES ({string.Join(", ", quotedColumnNames.Select(c => $"Source.{c}"))});");
 422
 0423        return (sb.ToString(), parameters.ToArray());
 424    }
 425}