diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs index f607225..489927c 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs @@ -20,10 +20,12 @@ internal class SqliteBulkInsertProvider(ILogger? logge /// protected override string BulkInsertId => "rowid"; - //language=sql /// protected override string AddTableCopyBulkInsertId => "--"; // No need to add an ID column in SQLite + /// + protected override string GetTempTableName(string tableName) => $"_temp_bulk_insert_test_entity_{Guid.NewGuid():N}"; + /// protected override BulkInsertOptions CreateDefaultOptions() => new() { @@ -116,6 +118,12 @@ private static DbCommand GetInsertCommand( return command; } + /// + protected override Task DropTempTableAsync(bool sync, DbContext dbContext, string tableName) + { + return ExecuteAsync(sync, dbContext, $"DROP TABLE IF EXISTS {tableName}", default); + } + /// protected override async Task BulkInsert( bool sync, diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs index bf9ea0f..8103304 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; @@ -43,14 +44,20 @@ protected override async IAsyncEnumerable BulkInsertReturnEntities( } var tableName = await PerformBulkInsertAsync(sync, context, tableInfo, entities, options, tempTableRequired: true, ctk: ctk); + try + { + var result = + await CopyFromTempTableAsync(sync, context, tableInfo, tableName, true, options, onConflict, ctk: ctk) + ?? throw new InvalidOperationException("Copy returns null enumerable."); - var result = - await CopyFromTempTableAsync(sync, context, tableInfo, tableName, true, options, onConflict, ctk: ctk) - ?? throw new InvalidOperationException("Copy returns null enumerable."); - - await foreach (var item in result.WithCancellation(ctk)) + await foreach (var item in result.WithCancellation(ctk)) + { + yield return item; + } + } + finally { - yield return item; + await PerformDropTempTableAsync(sync, context, tableName); } // Commit the transaction if we own them. @@ -71,6 +78,11 @@ protected override async Task BulkInsert( OnConflictOptions? onConflict, CancellationToken ctk) where T : class { + if (entities.TryGetNonEnumeratedCount(out var count) && count == 0) + { + throw new InvalidOperationException("No entities to insert."); + } + using var activity = Telemetry.ActivitySource.StartActivity("BulkInsert"); activity?.AddTag("tableName", tableInfo.TableName); activity?.AddTag("synchronous", sync); @@ -86,8 +98,14 @@ protected override async Task BulkInsert( } var tableName = await PerformBulkInsertAsync(sync, context, tableInfo, entities, options, tempTableRequired: true, ctk: ctk); - - await CopyFromTempTableAsync(sync, context, tableInfo, tableName, false, options, onConflict, ctk); + try + { + await CopyFromTempTableAsync(sync, context, tableInfo, tableName, false, options, onConflict, ctk); + } + finally + { + await PerformDropTempTableAsync(sync, context, tableName); + } } else { @@ -107,7 +125,6 @@ protected override async Task BulkInsert( await connection.Close(sync, ctk); } } - private async Task PerformBulkInsertAsync( bool sync, DbContext context, @@ -117,11 +134,6 @@ private async Task PerformBulkInsertAsync( bool tempTableRequired, CancellationToken ctk) where T : class { - if (entities.TryGetNonEnumeratedCount(out var count) && count == 0) - { - throw new InvalidOperationException("No entities to insert."); - } - var tableName = tempTableRequired ? await CreateTableCopyAsync(sync, context, options, tableInfo, ctk) : tableInfo.QuotedTableName; @@ -208,6 +220,33 @@ protected virtual async Task AddBulkInsertIdColumn( return null; } + private async Task PerformDropTempTableAsync(bool sync, DbContext dbContext, string tableName) + { + try + { + await DropTempTableAsync(sync, dbContext, tableName); + } + catch (Exception ex) + { + // The drop operation is not mandatory, therefore never fail the actual operation. + if (logger != null) + { + Log.DropTemporaryTableFailed(logger, ex); + } + } + } + + /// + /// Drops the temporary table manually if needed. + /// + /// Indicates if the operation is synchronous. + /// The context. + /// The table name. + protected virtual Task DropTempTableAsync(bool sync, DbContext dbContext, string tableName) + { + return Task.CompletedTask; + } + protected static async Task ExecuteAsync( bool sync, DbContext context, diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs index baab9b2..28a545a 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderUntyped.cs @@ -36,6 +36,11 @@ public IAsyncEnumerable BulkInsertReturnEntities( throw new InvalidOperationException($"Invalid options type: {options.GetType().Name}. Expected: {typeof(TOptions).Name}"); } + if (entities.TryGetNonEnumeratedCount(out var count) && count == 0) + { + throw new InvalidOperationException("No entities to insert."); + } + return BulkInsertReturnEntities(sync, context, tableInfo, entities, providerOptions, onConflict, ctk); } @@ -62,6 +67,11 @@ public Task BulkInsert( throw new InvalidOperationException($"Invalid options type: {options.GetType().Name}. Expected: {typeof(TOptions).Name}"); } + if (entities.TryGetNonEnumeratedCount(out var count) && count == 0) + { + throw new InvalidOperationException("No entities to insert."); + } + return BulkInsert(sync, context, tableInfo, entities, providerOptions, onConflict, ctk); } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index 801d044..4fcf0ce 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -84,7 +84,7 @@ public virtual string BuildMoveDataSql( if (returnedColumns.Count != 0) { - q.Append("RETURNING "); + q.Append(" RETURNING "); q.AppendJoin(", ", returnedColumns.Select(p => p.QuotedColumName)); q.AppendLine(); } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs index d8271e3..b66ebf3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs @@ -21,4 +21,10 @@ internal static partial class Log Level = LogLevel.Trace, Message = "Insert to table directly")] public static partial void UsingDirectInsert(ILogger logger); + + [LoggerMessage( + EventId = 1003, + Level = LogLevel.Error, + Message = "Failed to drop temporary table.")] + public static partial void DropTemporaryTableFailed(ILogger logger, Exception exception); } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs index 361e3d2..89dfab1 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntity.cs @@ -8,7 +8,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; [PrimaryKey(nameof(Id))] [Index(nameof(Name), IsUnique = true)] [Table("test_entity")] -public class TestEntity +public class TestEntity : TestEntityBase { public int Id { get; set; } @@ -19,9 +19,6 @@ public class TestEntity [Column("some_price")] public decimal Price { get; set; } - [Column("test_run")] - public Guid TestRun { get; set; } - [Column("the_identifier")] public Guid Identifier { get; set; } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityBase.cs new file mode 100644 index 0000000..8f063ef --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityBase.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +public abstract class TestEntityBase +{ + [Column("test_run")] + public Guid TestRun { get; set; } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithConverters.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithConverters.cs index ecfc945..041a6be 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithConverters.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithConverters.cs @@ -4,7 +4,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; [Table("test_entity_with_converters")] -public class TestEntityWithConverters +public class TestEntityWithConverters : TestEntityBase { public int Id { get; set; } @@ -14,8 +14,5 @@ public class TestEntityWithConverters [Column("created_at")] public DateTime CreatedAt { get; set; } - - [Column("test_run")] - public Guid TestRun { get; set; } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeo.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeo.cs index 667645f..705f74a 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeo.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeo.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using NetTopologySuite.Geometries; @@ -6,13 +6,10 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; [Table("test_entity_geo")] -public class TestEntityWithGeo +public class TestEntityWithGeo : TestEntityBase { [Key] public int Id { get; set; } public Geometry GeoObject { get; set; } = null!; - - [Column("test_run")] - public Guid TestRun { get; set; } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs index e15a28e..2e5e00e 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs @@ -4,7 +4,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; [Table("test_entity_guids")] -public class TestEntityWithGuidId +public class TestEntityWithGuidId : TestEntityBase { [Key] public Guid Id { get; set; } @@ -12,7 +12,4 @@ public class TestEntityWithGuidId [Column("name")] [MaxLength(100)] public string Name { get; set; } = string.Empty; - - [Column("test_run")] - public Guid TestRun { get; set; } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs index d9915fd..dbde0b1 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs @@ -4,13 +4,10 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; [Table("test_entity_json")] -public class TestEntityWithJson +public class TestEntityWithJson : TestEntityBase { [Key] public int Id { get; set; } public List Json { get; set; } = []; - - [Column("test_run")] - public Guid TestRun { get; set; } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj index b701dba..12f938b 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/PhenX.EntityFrameworkCore.BulkInsert.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 @@ -13,8 +13,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs new file mode 100644 index 0000000..dac6711 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/TestHelpers.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; + +using PhenX.EntityFrameworkCore.BulkInsert.Enums; +using PhenX.EntityFrameworkCore.BulkInsert.Extensions; +using PhenX.EntityFrameworkCore.BulkInsert.Options; +using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +using Xunit; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests; + +public enum InsertStrategy +{ + Insert, + InsertReturn, + InsertAsync, + InsertReturnAsync +} + +public static class TestHelpers +{ + public static async Task> InsertWithStrategyAsync( + this TestDbContextBase dbContext, + InsertStrategy strategy, + List entities, + Action? configure = null, + OnConflictOptions? onConflict = null) + where T : TestEntityBase + { + Skip.If(strategy is InsertStrategy.InsertReturn or InsertStrategy.InsertReturnAsync && dbContext.IsProvider(ProviderType.MySql)); + + var runId = Guid.NewGuid(); + if (entities.Any(x => x.TestRun == default)) + { + foreach (var entity in entities) + { + if (entity.TestRun == default) + { + entity.TestRun = runId; + } + } + } + else if (entities.Count > 0) + { + runId = entities[0].TestRun; + } + + var actualConfigure = configure ?? (_ => { }); + try + { + switch (strategy) + { + case InsertStrategy.InsertReturn: + return dbContext.ExecuteBulkInsertReturnEntities(entities, actualConfigure, onConflict); + case InsertStrategy.InsertReturnAsync: + return await dbContext.ExecuteBulkInsertReturnEntitiesAsync(entities, actualConfigure, onConflict); + case InsertStrategy.Insert: + dbContext.ExecuteBulkInsert(entities, actualConfigure, onConflict); + return dbContext.Set().Where(x => x.TestRun == runId).ToList(); + case InsertStrategy.InsertAsync: + await dbContext.ExecuteBulkInsertAsync(entities, actualConfigure, onConflict); + return await dbContext.Set().Where(x => x.TestRun == runId).ToListAsync(); + default: + throw new NotImplementedException(); + } + } + finally + { + dbContext.ChangeTracker.Clear(); + } + } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs index dba9848..0607f81 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs @@ -1,3 +1,5 @@ +using FluentAssertions; + using PhenX.EntityFrameworkCore.BulkInsert.Enums; using PhenX.EntityFrameworkCore.BulkInsert.Extensions; using PhenX.EntityFrameworkCore.BulkInsert.MySql; @@ -27,91 +29,48 @@ public Task DisposeAsync() return Task.CompletedTask; } - [Fact] - public async Task InsertsEntities() - { - // Arrange - var entities = new List - { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } - }; - - // Act - await _context.ExecuteBulkInsertAsync(entities); - - // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); - } - - [Fact] - public void InsertEntities_Sync() - { - // Arrange - var entities = new List - { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } - }; - - // Act - _context.ExecuteBulkInsert(entities); - - // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); - } - - [SkippableFact] - public async Task InsertEntities_AndReturn() + [SkippableTheory] + [CombinatorialData] + public async Task InsertsEntities(InsertStrategy strategy) { - Skip.If(_context.IsProvider(ProviderType.MySql)); - // Arrange var entities = new List { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } + new TestEntity { Name = $"{_run}_Entity1" }, + new TestEntity { Name = $"{_run}_Entity2" } }; // Act - var insertedEntities = await _context.ExecuteBulkInsertReturnEntitiesAsync(entities); + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); // Assert - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [Fact] - public async Task InsertEntities_WithJson() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_WithJson(InsertStrategy strategy) { // Arrange var entities = new List { - new TestEntityWithJson { TestRun = _run, Json = [1] }, - new TestEntityWithJson { TestRun = _run, Json = [2] } + new TestEntityWithJson { Json = [1] }, + new TestEntityWithJson { Json = [2] } }; // Act - await _context.ExecuteBulkInsertAsync(entities); + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); // Assert - var insertedEntities = _context.TestEntitiesWithJson.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Json[0] == 1); - Assert.Contains(insertedEntities, e => e.Json[0] == 2); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntityWithJson e) => e.Id)); } [SkippableFact] public async Task InsertEntities_AndReturn_AsyncEnumerable() { - Skip.If(_context.Database.ProviderName!.Contains("Mysql", StringComparison.InvariantCultureIgnoreCase)); + Skip.If(_context.IsProvider(ProviderType.MySql)); // Arrange var entities = new List @@ -122,6 +81,7 @@ public async Task InsertEntities_AndReturn_AsyncEnumerable() // Act var enumerable = _context.ExecuteBulkInsertReturnEnumerableAsync(entities); + var insertedEntities = new List(); await foreach (var item in enumerable) { @@ -129,34 +89,13 @@ public async Task InsertEntities_AndReturn_AsyncEnumerable() } // Assert - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); - } - - [SkippableFact] - public void InsertEntities_AndReturn_Sync() - { - Skip.If(_context.IsProvider(ProviderType.MySql)); - - // Arrange - var entities = new List - { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } - }; - - // Act - var insertedEntities = _context.ExecuteBulkInsertReturnEntities(entities); - - // Assert - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [SkippableFact] - public async Task InsertEntities_MultipleTimes() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_MultipleTimes(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.PostgreSql)); Skip.If(_context.IsProvider(ProviderType.SqlServer)); @@ -169,34 +108,33 @@ public async Task InsertEntities_MultipleTimes() }; // Act - await _context.ExecuteBulkInsertAsync(entities); + await _context.InsertWithStrategyAsync(strategy, entities); foreach (var entity in entities) { entity.NumericEnumValue = NumericEnum.Second; } - await _context.ExecuteBulkInsertAsync(entities, + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, onConflict: new OnConflictOptions { Update = e => e, }); // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.NumericEnumValue == NumericEnum.Second); - Assert.Contains(insertedEntities, e => e.NumericEnumValue == NumericEnum.Second); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [SkippableFact] - public async Task InsertEntities_MultipleTimes_WithGuidId() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_MultipleTimes_WithGuidId(InsertStrategy strategy) { // Arrange var entities = new List { - new TestEntityWithGuidId { Id = Guid.NewGuid(), TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntityWithGuidId { Id = Guid.NewGuid(), TestRun = _run, Name = $"{_run}_Entity2" } + new TestEntityWithGuidId { Id = Guid.NewGuid(), Name = $"{_run}_Entity1" }, + new TestEntityWithGuidId { Id = Guid.NewGuid(), Name = $"{_run}_Entity2" } }; // Act @@ -207,39 +145,36 @@ public async Task InsertEntities_MultipleTimes_WithGuidId() entity.Name = $"Updated_{entity.Name}"; } - await _context.ExecuteBulkInsertAsync(entities, + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, onConflict: new OnConflictOptions { Update = e => e, }); // Assert - var insertedEntities = _context.TestEntitiesWithGuidId.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"Updated_{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"Updated_{_run}_Entity2"); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntityWithGuidId e) => e.Id)); } - [SkippableFact] - public async Task InsertEntities_MultipleTimes_With_Conflict_On_Id() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_MultipleTimes_With_Conflict_On_Id(InsertStrategy strategy) { // Arrange var entities = new List { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } + new TestEntity { Name = $"{_run}_Entity1" }, + new TestEntity { Name = $"{_run}_Entity2" } }; // Act - await _context.ExecuteBulkInsertAsync(entities); - - var insertedEntities0 = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); + var insertedEntities0 = await _context.InsertWithStrategyAsync(strategy, entities); foreach (var entity in insertedEntities0) { entity.Name = $"Updated_{entity.Name}"; } - await _context.ExecuteBulkInsertAsync(insertedEntities0, + var insertedEntities1 = await _context.InsertWithStrategyAsync(strategy, insertedEntities0, o => o.CopyGeneratedColumns = true, onConflict: new OnConflictOptions { @@ -247,53 +182,52 @@ await _context.ExecuteBulkInsertAsync(insertedEntities0, }); // Assert - var insertedEntities1 = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities1.Count); - Assert.Contains(insertedEntities1, e => e.Name == $"Updated_{_run}_Entity1"); - Assert.Contains(insertedEntities1, e => e.Name == $"Updated_{_run}_Entity2"); + insertedEntities1.Should().BeEquivalentTo(insertedEntities0, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [Fact] - public async Task InsertEntities_MoveRows() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_MoveRows(InsertStrategy strategy) { // Arrange var entities = new List { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } + new TestEntity { Name = $"{_run}_Entity1" }, + new TestEntity { Name = $"{_run}_Entity2" } }; // Act - await _context.ExecuteBulkInsertAsync(entities, o => + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = true; }); // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [SkippableFact] - public async Task InsertEntities_WithConflict_SingleColumn() + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithConflict_SingleColumn(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); - _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }); - await _context.SaveChangesAsync(); + // Arrange + _context.TestEntities.Add(new TestEntity { Name = $"{_run}_Entity1" }); + _context.SaveChanges(); _context.ChangeTracker.Clear(); - // Arrange var entities = new List { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, + new TestEntity { Name = $"{_run}_Entity1" }, new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" }, }; // Act - await _context.ExecuteBulkInsertAsync(entities, o => + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = true; }, new OnConflictOptions @@ -309,19 +243,21 @@ await _context.ExecuteBulkInsertAsync(entities, o => }); // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); Assert.Equal(2, insertedEntities.Count); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1 - Conflict"); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); } - [SkippableFact] - public async Task InsertEntities_WithConflict_DoNothing() + [SkippableTheory] + [InlineData(InsertStrategy.Insert)] + [InlineData(InsertStrategy.InsertAsync)] + public async Task InsertEntities_WithConflict_DoNothing(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }); - await _context.SaveChangesAsync(); + _context.SaveChanges(); _context.ChangeTracker.Clear(); var entities = new List @@ -330,28 +266,31 @@ public async Task InsertEntities_WithConflict_DoNothing() new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" }, }; - await _context.ExecuteBulkInsertAsync(entities, o => + // Act + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = true; }, new OnConflictOptions { Match = e => new { e.Name } - // Pas de Update => DO NOTHING }); - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); + // Assert Assert.Equal(2, insertedEntities.Count); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); } - [SkippableFact] - public async Task InsertEntities_WithConflict_Condition() + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithConflict_Condition(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); - await _context.SaveChangesAsync(); + _context.SaveChanges(); _context.ChangeTracker.Clear(); var entities = new List @@ -360,7 +299,8 @@ public async Task InsertEntities_WithConflict_Condition() new TestEntity { TestRun = _run, Name = $"{_run}_Entity2", Price = 30 }, }; - await _context.ExecuteBulkInsertAsync(entities, o => + // Act + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = true; }, new OnConflictOptions @@ -370,19 +310,22 @@ await _context.ExecuteBulkInsertAsync(entities, o => Condition = "EXCLUDED.some_price > test_entity.some_price" }); - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); + // Assert Assert.Equal(2, insertedEntities.Count); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1" && e.Price == 20); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2" && e.Price == 30); } - [SkippableFact] - public async Task InsertEntities_WithConflict_MultipleColumns() + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithConflict_MultipleColumns(InsertStrategy strategy) { Skip.If(_context.IsProvider(ProviderType.MySql)); + // Arrange _context.TestEntities.Add(new TestEntity { TestRun = _run, Name = $"{_run}_Entity1", Price = 10 }); - await _context.SaveChangesAsync(); + _context.SaveChanges(); _context.ChangeTracker.Clear(); var entities = new List @@ -391,92 +334,89 @@ public async Task InsertEntities_WithConflict_MultipleColumns() new TestEntity { TestRun = _run, Name = $"{_run}_Entity2", Price = 30, Identifier = Guid.NewGuid() }, }; - await _context.ExecuteBulkInsertAsync(entities, o => + // Act + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = true; }, new OnConflictOptions { Match = e => new { e.Name }, - Update = e => new TestEntity { - Name = e.Name + " - Conflict", - Price = 0, - } + Update = e => new TestEntity { Name = e.Name + " - Conflict", Price = 0 } }); - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); + // Assert Assert.Equal(2, insertedEntities.Count); - Assert.Equal(1, insertedEntities.Count(e => e.Name == $"{_run}_Entity1 - Conflict")); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); - - var entity1 = insertedEntities.First(e => e.Name == $"{_run}_Entity1 - Conflict"); - Assert.Equal(0, entity1.Price); + Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1 - Conflict" && e.Price == 0); } - [Fact] - public async Task InsertEntities_DoesNothing_WhenEntitiesAreEmpty() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_DoesNothing_WhenEntitiesAreEmpty(InsertStrategy strategy) { // Arrange var entities = new List(); // Act - await Assert.ThrowsAsync(async () => await _context.ExecuteBulkInsertAsync(entities)); + await Assert.ThrowsAsync(async () => await _context.InsertWithStrategyAsync(strategy, entities)); // Assert var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); Assert.Empty(insertedEntities); } - [Fact] - public async Task InsertEntities_Many() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_Many(InsertStrategy strategy) { // Arrange const int count = 156055; + var entities = Enumerable.Range(1, count).Select(i => new TestEntity { + Identifier = Guid.NewGuid(), Name = $"{_run}_Entity{i}", + NumericEnumValue = (NumericEnum)(i % 2), Price = (decimal)(i * 0.1), - Identifier = Guid.NewGuid(), StringEnumValue = (StringEnum)(i % 2), - NumericEnumValue = (NumericEnum)(i % 2), - TestRun = _run, }).ToList(); // Act - await _context.ExecuteBulkInsertAsync(entities, o => + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities, o => { o.MoveRows = false; }); // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); Assert.Equal(count, insertedEntities.Count); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1"); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity" + count); } - [Fact] - public async Task InsertEntities_AndReturn_WithEntityWithValueConverters() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_AndReturn_WithEntityWithValueConverters(InsertStrategy strategy) { // Arrange var now = DateTime.UtcNow; + var entities = new List { - new() { TestRun = _run, Name = $"{_run}_Entity1", CreatedAt = now }, - new() { TestRun = _run, Name = $"{_run}_Entity2", CreatedAt = now.AddDays(-1) } + new TestEntityWithConverters() { Name = $"{_run}_Entity1", CreatedAt = now }, + new TestEntityWithConverters() { Name = $"{_run}_Entity2", CreatedAt = now.AddDays(-1) } }; // Act - await _context.ExecuteBulkInsertAsync(entities); - var inserted = _context.TestEntitiesWithConverter.Where(x => x.TestRun == _run).ToList(); + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); // Assert - Assert.Equal(2, inserted.Count); - Assert.Contains(inserted, e => e.Name == $"{_run}_Entity1" && e.CreatedAt == now); - Assert.Contains(inserted, e => e.Name == $"{_run}_Entity2" && e.CreatedAt == now.AddDays(-1)); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntityWithConverters e) => e.Id)); } - [Fact] - public async Task InsertEntities_WithOpenTransaction_CommitsSuccessfully() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_WithOpenTransaction_CommitsSuccessfully(InsertStrategy strategy) { // Arrange var entities = new List @@ -485,42 +425,19 @@ public async Task InsertEntities_WithOpenTransaction_CommitsSuccessfully() new TestEntity { TestRun = _run, Name = $"{_run}_EntityWithTx2" } }; + // Act await using var transaction = await _context.Database.BeginTransactionAsync(); - - await _context.ExecuteBulkInsertAsync(entities); - + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); await transaction.CommitAsync(); // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_EntityWithTx1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_EntityWithTx2"); - } - - [Fact] - public void InsertEntities_WithOpenTransaction_CommitsSuccessfully_Sync() - { - // Arrange - var entities = new List - { - new TestEntity { TestRun = _run, Name = $"{_run}_EntityWithTx1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_EntityWithTx2" } - }; - - var transaction = _context.Database.BeginTransaction(); - - _context.ExecuteBulkInsert(entities); - - transaction.Commit(); - - // Assert - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_EntityWithTx1"); - Assert.Contains(insertedEntities, e => e.Name == $"{_run}_EntityWithTx2"); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntity e) => e.Id)); } - [Fact] - public async Task InsertEntities_WithOpenTransaction_RollsBackOnFailure() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_WithOpenTransaction_RollsBackOnFailure(InsertStrategy strategy) { // Arrange var entities = new List @@ -530,53 +447,27 @@ public async Task InsertEntities_WithOpenTransaction_RollsBackOnFailure() }; await using var transaction = await _context.Database.BeginTransactionAsync(); - - await _context.ExecuteBulkInsertAsync(entities); - + await _context.InsertWithStrategyAsync(strategy, entities); await transaction.RollbackAsync(); // Assert _context.ChangeTracker.Clear(); - var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.DoesNotContain(insertedEntities, e => e.Name == $"{_run}_EntityWithTxFail1"); - Assert.DoesNotContain(insertedEntities, e => e.Name == $"{_run}_EntityWithTxFail2"); - } - - [Fact] - public void InsertEntities_WithOpenTransaction_RollsBackOnFailure_Sync() - { - // Arrange - var entities = new List - { - new TestEntity { TestRun = _run, Name = $"{_run}_EntityWithTxFail1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_EntityWithTxFail2" } - }; - - using var transaction = _context.Database.BeginTransaction(); - - _context.ExecuteBulkInsert(entities); - transaction.Rollback(); - - // Assert - _context.ChangeTracker.Clear(); var insertedEntities = _context.TestEntities.Where(x => x.TestRun == _run).ToList(); - Assert.DoesNotContain(insertedEntities, e => e.Name == $"{_run}_EntityWithTxFail1"); - Assert.DoesNotContain(insertedEntities, e => e.Name == $"{_run}_EntityWithTxFail2"); + Assert.Empty(insertedEntities); } - + [SkippableFact] public async Task ThrowsWhenUsingWrongConfigurationType() { // Skip for providers that don't support this feature - Skip.If(_context.IsProvider(ProviderType.PostgreSql)); Skip.If(_context.IsProvider(ProviderType.Sqlite)); // Arrange var entities = new List { - new TestEntity { TestRun = _run, Name = $"{_run}_Entity1" }, - new TestEntity { TestRun = _run, Name = $"{_run}_Entity2" } + new TestEntity { Name = $"{_run}_Entity1" }, + new TestEntity { Name = $"{_run}_Entity2" } }; // Act & Assert @@ -595,5 +486,13 @@ await _context.ExecuteBulkInsertAsync(entities, (SqlServerBulkInsertOptions o) = { })); } + + if (_context.IsProvider(ProviderType.PostgreSql)) + { + await Assert.ThrowsAsync(async () => + await _context.ExecuteBulkInsertAsync(entities, (SqlServerBulkInsertOptions o) => + { + })); + } } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Geo/GeoTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Geo/GeoTestsBase.cs index 890d0ba..26b1677 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Geo/GeoTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Geo/GeoTestsBase.cs @@ -1,6 +1,7 @@ -using NetTopologySuite.Geometries; +using FluentAssertions; + +using NetTopologySuite.Geometries; -using PhenX.EntityFrameworkCore.BulkInsert.Extensions; using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContainer; using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; @@ -11,7 +12,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Geo; public abstract class GeoTestsBase(TestDbContainer dbContainer) : IAsyncLifetime where TDbContext : TestDbContextGeo, new() { - private readonly Guid _run = Guid.NewGuid(); private TDbContext _context = null!; public async Task InitializeAsync() @@ -25,8 +25,9 @@ public Task DisposeAsync() return Task.CompletedTask; } - [Fact] - public async Task InsertEntities_WithGeo() + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_WithGeo(InsertStrategy strategy) { // Arrange var geo1 = new Point(1, 2) { SRID = 4326 }; @@ -34,17 +35,15 @@ public async Task InsertEntities_WithGeo() var entities = new List { - new TestEntityWithGeo { TestRun = _run, GeoObject = geo1 }, - new TestEntityWithGeo { TestRun = _run, GeoObject = geo2 } + new TestEntityWithGeo { GeoObject = geo1 }, + new TestEntityWithGeo { GeoObject = geo2 } }; // Act - await _context.ExecuteBulkInsertAsync(entities); + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); // Assert - var insertedEntities = _context.TestEntitiesWithGeo.Where(x => x.TestRun == _run).ToList(); - Assert.Equal(2, insertedEntities.Count); - Assert.Contains(insertedEntities, e => e.GeoObject == geo1); - Assert.Contains(insertedEntities, e => e.GeoObject == geo2); + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding((TestEntityWithGeo e) => e.Id)); } }