diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs index ff32163..445632f 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.MySql/MySqlBulkInsertProvider.cs @@ -9,12 +9,8 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.MySql; -internal class MySqlBulkInsertProvider : BulkInsertProviderBase +internal class MySqlBulkInsertProvider(ILogger logger) : BulkInsertProviderBase(logger) { - public MySqlBulkInsertProvider(ILogger? logger = null) : base(logger) - { - } - //language=sql /// protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD {BulkInsertId} INT AUTO_INCREMENT PRIMARY KEY;"; @@ -51,20 +47,16 @@ CancellationToken ctk ) { var connection = (MySqlConnection)context.Database.GetDbConnection(); - - var sqlTransaction = context.Database.CurrentTransaction?.GetDbTransaction() + var sqlTransaction = context.Database.CurrentTransaction!.GetDbTransaction() ?? throw new InvalidOperationException("No open transaction found."); - if (sqlTransaction is not MySqlTransaction mySqlTransaction) { throw new InvalidOperationException($"Invalid transaction foud, got {sqlTransaction.GetType()}."); } - var bulkCopy = new MySqlBulkCopy(connection, mySqlTransaction) - { - DestinationTableName = tableName, - BulkCopyTimeout = options.GetCopyTimeoutInSeconds(), - }; + var bulkCopy = new MySqlBulkCopy(connection, mySqlTransaction); + bulkCopy.DestinationTableName = tableName; + bulkCopy.BulkCopyTimeout = options.GetCopyTimeoutInSeconds(); var sourceOrdinal = 0; foreach (var prop in properties) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlBulkInsertProvider.cs index 4dd86a2..e571558 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlBulkInsertProvider.cs @@ -6,6 +6,9 @@ using Microsoft.Extensions.Logging; using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; + +using NpgsqlTypes; using PhenX.EntityFrameworkCore.BulkInsert.Metadata; using PhenX.EntityFrameworkCore.BulkInsert.Options; @@ -13,12 +16,8 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.PostgreSql; [UsedImplicitly] -internal class PostgreSqlBulkInsertProvider : BulkInsertProviderBase +internal class PostgreSqlBulkInsertProvider(ILogger? logger) : BulkInsertProviderBase(logger) { - public PostgreSqlBulkInsertProvider(ILogger? logger = null) : base(logger) - { - } - //language=sql /// protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD COLUMN {BulkInsertId} SERIAL PRIMARY KEY;"; @@ -57,6 +56,9 @@ protected override async Task BulkInsert( ? connection.BeginBinaryImport(command) : await connection.BeginBinaryImportAsync(command, ctk); + // The type mapping can be null for obvious types like string. + var columnTypes = columns.Select(GetPostgreSqlType).ToArray(); + foreach (var entity in entities) { if (sync) @@ -69,19 +71,40 @@ protected override async Task BulkInsert( await writer.StartRowAsync(ctk); } + var columnIndex = 0; foreach (var column in columns) { var value = column.GetValue(entity); + // Get the actual type, so that the writer can do the conversation to the target type automatically. + var type = columnTypes[columnIndex]; + if (sync) { - // ReSharper disable once MethodHasAsyncOverloadWithCancellation - writer.Write(value); + if (type != null) + { + // ReSharper disable once MethodHasAsyncOverloadWithCancellation + writer.Write(value, type.Value); + } + else + { + // ReSharper disable once MethodHasAsyncOverloadWithCancellation + writer.Write(value); + } } else { - await writer.WriteAsync(value, ctk); + if (type != null) + { + await writer.WriteAsync(value, type.Value, ctk); + } + else + { + await writer.WriteAsync(value, ctk); + } } + + columnIndex++; } } @@ -97,6 +120,12 @@ protected override async Task BulkInsert( await writer.CompleteAsync(ctk); await writer.DisposeAsync(); } + } + + private static NpgsqlDbType? GetPostgreSqlType(ColumnMetadata column) + { + var mapping = column.Property.GetRelationalTypeMapping() as NpgsqlTypeMapping; + return mapping?.NpgsqlDbType; } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerBulkInsertProvider.cs index 1efbbb4..9fa08d1 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerBulkInsertProvider.cs @@ -6,17 +6,12 @@ using Microsoft.Extensions.Logging; using PhenX.EntityFrameworkCore.BulkInsert.Metadata; -using PhenX.EntityFrameworkCore.BulkInsert.Options; namespace PhenX.EntityFrameworkCore.BulkInsert.SqlServer; [UsedImplicitly] -internal class SqlServerBulkInsertProvider : BulkInsertProviderBase +internal class SqlServerBulkInsertProvider(ILogger? logger) : BulkInsertProviderBase(logger) { - public SqlServerBulkInsertProvider(ILogger? logger = null) : base(logger) - { - } - //language=sql /// protected override string AddTableCopyBulkInsertId => $"ALTER TABLE {{0}} ADD {BulkInsertId} INT IDENTITY PRIMARY KEY;"; diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs index 439d0f2..0e064f4 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Sqlite/SqliteBulkInsertProvider.cs @@ -13,11 +13,9 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Sqlite; [UsedImplicitly] -internal class SqliteBulkInsertProvider : BulkInsertProviderBase +internal class SqliteBulkInsertProvider(ILogger? logger) : BulkInsertProviderBase(logger) { - public SqliteBulkInsertProvider(ILogger? logger = null) : base(logger) - { - } + private const int MaxParams = 1000; /// protected override string BulkInsertId => "rowid"; @@ -40,48 +38,30 @@ protected override Task AddBulkInsertIdColumn( CancellationToken cancellationToken ) where T : class => Task.CompletedTask; - /// - /// Taken from https://github.com/dotnet/efcore/blob/667c569c49a1ab7e142621395d3f14f2af0508b4/src/Microsoft.Data.Sqlite.Core/SqliteValueBinder.cs#L231 - /// As the method is not exposed in the public API, we need to copy it here. - /// - private static readonly Dictionary SqliteTypeMapping = - new() - { - { typeof(bool), SqliteType.Integer }, - { typeof(byte), SqliteType.Integer }, - { typeof(byte[]), SqliteType.Blob }, - { typeof(char), SqliteType.Text }, - { typeof(DateTime), SqliteType.Text }, - { typeof(DateTimeOffset), SqliteType.Text }, - { typeof(DateOnly), SqliteType.Text }, - { typeof(TimeOnly), SqliteType.Text }, - { typeof(DBNull), SqliteType.Text }, - { typeof(decimal), SqliteType.Text }, - { typeof(double), SqliteType.Real }, - { typeof(float), SqliteType.Real }, - { typeof(Guid), SqliteType.Text }, - { typeof(int), SqliteType.Integer }, - { typeof(long), SqliteType.Integer }, - { typeof(sbyte), SqliteType.Integer }, - { typeof(short), SqliteType.Integer }, - { typeof(string), SqliteType.Text }, - { typeof(TimeSpan), SqliteType.Text }, - { typeof(uint), SqliteType.Integer }, - { typeof(ulong), SqliteType.Integer }, - { typeof(ushort), SqliteType.Integer } - }; - - private static SqliteType GetSqliteType(Type clrType) + private static SqliteType GetSqliteType(ColumnMetadata column) { - var type = Nullable.GetUnderlyingType(clrType) ?? clrType; - type = type.IsEnum ? Enum.GetUnderlyingType(type) : type; + var storeType = column.Property.GetRelationalTypeMapping().StoreType; - if (SqliteTypeMapping.TryGetValue(type, out var sqliteType)) + if (string.Equals(storeType, "INTEGER", StringComparison.OrdinalIgnoreCase)) { - return sqliteType; + return SqliteType.Integer; + } + else if (string.Equals(storeType, "FLOAT", StringComparison.OrdinalIgnoreCase)) + { + return SqliteType.Real; + } + else if (string.Equals(storeType, "TEXT", StringComparison.OrdinalIgnoreCase)) + { + return SqliteType.Text; + } + else if (string.Equals(storeType, "BLOB", StringComparison.OrdinalIgnoreCase)) + { + return SqliteType.Blob; + } + else + { + throw new NotSupportedException($"Invalid store type '{storeType}' for property '{column.PropertyName}'"); } - - throw new InvalidOperationException($"Unknown Sqlite type for {clrType}"); } private static DbCommand GetInsertCommand( @@ -147,15 +127,13 @@ protected override async Task BulkInsert( CancellationToken ctk ) where T : class { - const int maxParams = 1000; - var batchSize = options.BatchSize; - batchSize = Math.Min(batchSize, maxParams / columns.Count); + var batchSize = Math.Min(options.BatchSize, MaxParams / columns.Count); // The StringBuilder can be resuse between the batches. var sb = new StringBuilder(); var columnList = tableInfo.GetColumns(options.CopyGeneratedColumns); - var columnTypes = columnList.Select(c => GetSqliteType(c.ProviderClrType ?? c.ClrType)).ToArray(); + var columnTypes = columnList.Select(GetSqliteType).ToArray(); await using var insertCommand = GetInsertCommand( diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertOptionsExtension.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertOptionsExtension.cs index ebd131b..b62d69c 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertOptionsExtension.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertOptionsExtension.cs @@ -1,5 +1,8 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using PhenX.EntityFrameworkCore.BulkInsert.Abstractions; @@ -13,6 +16,7 @@ public DbContextOptionsExtensionInfo Info public void ApplyServices(IServiceCollection services) { + services.TryAddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); services.AddSingleton(); } @@ -20,10 +24,8 @@ public void Validate(IDbContextOptions options) { } - private class BulkInsertOptionsExtensionInfo : DbContextOptionsExtensionInfo + private class BulkInsertOptionsExtensionInfo(IDbContextOptionsExtension extension) : DbContextOptionsExtensionInfo(extension) { - public BulkInsertOptionsExtensionInfo(IDbContextOptionsExtension extension) - : base(extension) { } /// public override int GetServiceProviderHashCode() => 0; diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs index 48fa384..0e1d11e 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/BulkInsertProviderBase.cs @@ -12,7 +12,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert; -internal abstract class BulkInsertProviderBase(ILogger>? logger = null) : IBulkInsertProvider +internal abstract class BulkInsertProviderBase(ILogger> logger) : IBulkInsertProvider where TDialect : SqlDialectBuilder, new() where TOptions : BulkInsertOptions, new() { @@ -56,7 +56,7 @@ public virtual async IAsyncEnumerable BulkInsertReturnEntities( { if (logger != null) { - Log.UsingTempTablToReturnData(logger); + Log.UsingTempTableToReturnData(logger); } var tableName = await PerformBulkInsertAsync(sync, context, tableInfo, entities, providerOptions, tempTableRequired: true, ctk: ctk); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs index 16443b7..111e8c7 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/InternalExtensions.cs @@ -22,9 +22,8 @@ internal static TableMetadata GetTableInfo(this DbContext context) internal static DbContextOptionsBuilder UseProvider(this DbContextOptionsBuilder optionsBuilder) where TProvider : class, IBulkInsertProvider { - var extension = optionsBuilder.Options.FindExtension>() ?? new BulkInsertOptionsExtension(); - - ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension); + ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension( + optionsBuilder.Options.FindExtension>() ?? new()); ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension( optionsBuilder.Options.FindExtension() ?? new()); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs index c7cd183..5aa9aef 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.DbSet.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using PhenX.EntityFrameworkCore.BulkInsert.Options; @@ -107,7 +107,7 @@ public static IAsyncEnumerable ExecuteBulkInsertReturnEnumerableAsync(), entities, options, onConflict, ctk); @@ -155,7 +155,7 @@ public static async Task ExecuteBulkInsertAsync( where T : class where TOptions : BulkInsertOptions { - var provider = InitProvider(dbSet, configure, out var context, out var options); + var (provider, context, options) = InitProvider(dbSet, configure); await provider.BulkInsert(false, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict, ctk); @@ -202,7 +202,7 @@ public static void ExecuteBulkInsert( where T : class where TOptions : BulkInsertOptions { - var provider = InitProvider(dbSet, configure, out var context, out var options); + var (provider, context, options) = InitProvider(dbSet, configure); provider.BulkInsert(true, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict) .GetAwaiter().GetResult(); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.cs index 4dcc850..cdcdf6e 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Extensions/PublicExtensions.cs @@ -11,22 +11,22 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Extensions; /// public static partial class PublicExtensions { - private static async Task> ExecuteBulkInsertReturnEntitiesCoreAsync( - this DbSet dbSet, + private static async Task> ExecuteBulkInsertReturnEntitiesCoreAsync( + this DbSet dbSet, bool sync, - IEnumerable entities, + IEnumerable entities, Action configure, - OnConflictOptions? onConflict, + OnConflictOptions? onConflict, CancellationToken ctk ) - where T : class + where TEntity : class where TOptions : BulkInsertOptions { - var provider = InitProvider(dbSet, configure, out var context, out var options); + var (provider, context, options) = InitProvider(dbSet, configure); - var enumerable = provider.BulkInsertReturnEntities(sync, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict, ctk); + var enumerable = provider.BulkInsertReturnEntities(sync, context, dbSet.GetDbContext().GetTableInfo(), entities, options, onConflict, ctk); - var result = new List(); + var result = new List(); await foreach (var item in enumerable.WithCancellation(ctk)) { result.Add(item); @@ -41,27 +41,23 @@ private static DbContext GetDbContext(this DbSet dbSet) where T : class return (infrastructure.Instance.GetService(typeof(ICurrentDbContext)) as ICurrentDbContext)!.Context; } - private static IBulkInsertProvider InitProvider( + private static (IBulkInsertProvider, DbContext, TOptions) InitProvider( DbSet dbSet, - Action? configure, - out DbContext context, - out TOptions options + Action? configure ) where T : class where TOptions : BulkInsertOptions { - context = dbSet.GetDbContext(); + var context = dbSet.GetDbContext(); var provider = context.GetService(); + var options = provider.InternalCreateDefaultOptions(); - var defaultOptions = provider.InternalCreateDefaultOptions(); - - if (defaultOptions is not TOptions castedOptions) + if (options is not TOptions castedOptions) { - throw new InvalidOperationException($"Options type mismatch. Expected {defaultOptions.GetType().Name}, but got {typeof(TOptions).Name}."); + throw new InvalidOperationException($"Options type mismatch. Expected {options.GetType().Name}, but got {typeof(TOptions).Name}."); } - options = castedOptions; - configure?.Invoke(options); + configure?.Invoke(castedOptions); - return provider; + return (provider, context, castedOptions); } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs index c81b727..d8271e3 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Log.cs @@ -8,7 +8,7 @@ internal static partial class Log EventId = 1000, Level = LogLevel.Trace, Message = "Using temporary table to return data")] - public static partial void UsingTempTablToReturnData(ILogger logger); + public static partial void UsingTempTableToReturnData(ILogger logger); [LoggerMessage( EventId = 1001, diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs index b640e66..6e06524 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs @@ -9,6 +9,8 @@ internal sealed class ColumnMetadata(IProperty property, SqlDialectBuilder dial { private readonly PropertyAccessor.Getter _getter = BuildGetter(property); + public IProperty Property { get; } = property; + public string PropertyName { get; } = property.Name; public string ColumnName { get; } = property.GetColumnName(); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/MetadataProviderExtension.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/MetadataProviderExtension.cs index 3fe2a20..9774f1d 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/MetadataProviderExtension.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/MetadataProviderExtension.cs @@ -17,10 +17,8 @@ public void Validate(IDbContextOptions options) { } - private class MetadataProviderExtensionInfo : DbContextOptionsExtensionInfo + private class MetadataProviderExtensionInfo(IDbContextOptionsExtension extension) : DbContextOptionsExtensionInfo(extension) { - public MetadataProviderExtensionInfo(IDbContextOptionsExtension extension) - : base(extension) { } /// public override int GetServiceProviderHashCode() => 0; diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs index 8b31a5d..0e09a4b 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/TestDbContext.cs @@ -2,17 +2,12 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Benchmark; -public class TestDbContext : DbContext +public class TestDbContext(Action configure) : DbContext { - public Action Configure { get; } + public Action Configure { get; } = configure; public DbSet TestEntities { get; set; } = null!; - public TestDbContext(Action configure) - { - Configure = configure; - } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { Configure(optionsBuilder); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs index 88d9417..3a50d54 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs @@ -1,6 +1,7 @@ using DotNet.Testcontainers.Containers; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; @@ -40,7 +41,11 @@ public async Task CreateContextAsync() { var dbContext = new TDbContext { - ConfigureOptions = Configure + ConfigureOptions = (builder) => + { + builder.UseLoggerFactory(NullLoggerFactory.Instance); + Configure(builder); + } }; dbContext.Database.SetConnectionString(GetConnectionString()); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Extensions.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Extensions.cs new file mode 100644 index 0000000..5ec25a0 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/Extensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +public static class Extensions +{ + public static PropertyBuilder AsJsonString(this PropertyBuilder propertyBuilder, string? columnType) + where T : class + { + var converter = new ValueConverter( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)! + ); + + propertyBuilder.HasConversion(converter).HasColumnType(columnType); + return propertyBuilder; + } + +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs index 530d42e..9c67ab8 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs @@ -6,12 +6,14 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; public class TestDbContext : TestDbContextBase { public DbSet TestEntities { get; set; } = null!; + public DbSet TestEntitiesWithJson { get; set; } = null!; public DbSet TestEntitiesWithGuidIds { get; set; } = null!; public DbSet TestEntitiesWithConverters { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + modelBuilder.Entity(builder => { builder.Property(e => e.CreatedAt) @@ -25,3 +27,59 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); } } + +public class TestDbContextPostgreSql : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.Json).AsJsonString("jsonb"); + }); + } +} + +public class TestDbContextMySql : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.Json).AsJsonString("json"); + }); + } +} + +public class TestDbContextSqlServer : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.Json).AsJsonString(null); + }); + } +} + + +public class TestDbContextSqlite : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.Json).AsJsonString(null); + }); + } +} + + + diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeneratedGuidId.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeneratedGuidId.cs new file mode 100644 index 0000000..4a8b23a --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGeneratedGuidId.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +[Table("test_entity_generated_guids")] +public class TestEntityWithGeneratedGuidId +{ + [Key] + public Guid Id { get; set; } + + [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/TestEntityWithGuidId.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs index 0bcd218..e15a28e 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithGuidId.cs @@ -1,14 +1,12 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using Microsoft.EntityFrameworkCore; - namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; -[PrimaryKey(nameof(Id))] [Table("test_entity_guids")] public class TestEntityWithGuidId { + [Key] public Guid Id { get; set; } [Column("name")] diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs new file mode 100644 index 0000000..d9915fd --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +[Table("test_entity_json")] +public class TestEntityWithJson +{ + [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/Tests/Basic/BasicTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs index d344a1c..bb01dd1 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs @@ -10,16 +10,12 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; -public abstract class BasicTestsBase : IClassFixture, IAsyncLifetime - where TFixture : TestDbContainer +public abstract class BasicTestsBase(TestDbContainer dbContainer) : IClassFixture, IAsyncLifetime + where TDbContext : TestDbContext, new() + where TFixture : TestDbContainer { private readonly Guid _run = Guid.NewGuid(); - private TestDbContext _context = null!; - - protected BasicTestsBase(TestDbContainer dbContainer) - { - DbContainer = dbContainer; - } + private TDbContext _context = null!; public async Task InitializeAsync() { @@ -32,10 +28,10 @@ public Task DisposeAsync() return Task.CompletedTask; } - protected TestDbContainer DbContainer { get; } + protected TestDbContainer DbContainer { get; } = dbContainer; [Fact] - public async Task InsertsEntitiesSuccessfully() + public async Task InsertsEntities() { // Arrange var entities = new List @@ -55,7 +51,7 @@ public async Task InsertsEntitiesSuccessfully() } [Fact] - public void InsertsEntitiesSuccessfully_Sync() + public void InsertsEntities_Sync() { // Arrange var entities = new List @@ -75,7 +71,7 @@ public void InsertsEntitiesSuccessfully_Sync() } [SkippableFact] - public async Task InsertsEntitiesAndReturn() + public async Task InsertsEntities_AndReturn() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -96,7 +92,27 @@ public async Task InsertsEntitiesAndReturn() } [SkippableFact] - public async Task InsertsEntitiesAndReturnAsyncEnumerable() + public async Task InsertsEntities_WithJson() + { + // Arrange + var entities = new List + { + new TestEntityWithJson { TestRun = _run, Json = [1] }, + new TestEntityWithJson { TestRun = _run, Json = [2] } + }; + + // Act + await _context.ExecuteBulkInsertAsync(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); + } + + [SkippableFact] + public async Task InsertsEntities_AndReturn_AsyncEnumerable() { Skip.If(_context.Database.ProviderName!.Contains("Mysql", StringComparison.InvariantCultureIgnoreCase)); @@ -122,7 +138,7 @@ public async Task InsertsEntitiesAndReturnAsyncEnumerable() } [SkippableFact] - public void InsertsEntitiesAndReturn_Sync() + public void InsertsEntities_AndReturn_Sync() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -241,7 +257,7 @@ await _context.ExecuteBulkInsertAsync(insertedEntities0, } [Fact] - public async Task InsertsEntitiesMoveRowsSuccessfully() + public async Task InsertsEntities_MoveRows() { // Arrange var entities = new List @@ -264,7 +280,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [SkippableFact] - public async Task InsertsEntitiesWithConflict_SingleColumn() + public async Task InsertsEntities_WithConflict_SingleColumn() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -303,7 +319,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [SkippableFact] - public async Task InsertsEntitiesWithConflict_DoNothing() + public async Task InsertsEntities_WithConflict_DoNothing() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -333,7 +349,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [SkippableFact] - public async Task InsertsEntitiesWithConflict_Condition() + public async Task InsertsEntities_WithConflict_Condition() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -364,7 +380,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [SkippableFact] - public async Task InsertsEntitiesWithConflict_MultipleColumns() + public async Task InsertsEntities_WithConflict_MultipleColumns() { Skip.If(_context.IsProvider(ProviderType.MySql)); @@ -400,7 +416,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [Fact] - public async Task DoesNothingWhenEntitiesAreEmpty() + public async Task InsertsEntities_DoesNothing_WhenEntitiesAreEmpty() { // Arrange var entities = new List(); @@ -442,7 +458,7 @@ await _context.ExecuteBulkInsertAsync(entities, o => } [Fact] - public async Task InsertAndRead_EntityWithValueConverters() + public async Task InsertEntities_AndReturn_WithEntityWithValueConverters() { // Arrange var now = DateTime.UtcNow; @@ -463,7 +479,7 @@ public async Task InsertAndRead_EntityWithValueConverters() } [Fact] - public async Task BulkInsert_WithOpenTransaction_CommitsSuccessfully() + public async Task InsertEntities_WithOpenTransaction_CommitsSuccessfully() { // Arrange var entities = new List @@ -485,7 +501,7 @@ public async Task BulkInsert_WithOpenTransaction_CommitsSuccessfully() } [Fact] - public void BulkInsert_WithOpenTransaction_CommitsSuccessfully_Sync() + public void InsertEntities_WithOpenTransaction_CommitsSuccessfully_Sync() { // Arrange var entities = new List @@ -507,7 +523,7 @@ public void BulkInsert_WithOpenTransaction_CommitsSuccessfully_Sync() } [Fact] - public async Task BulkInsert_WithOpenTransaction_RollsBackOnFailure() + public async Task InsertEntities_WithOpenTransaction_RollsBackOnFailure() { // Arrange var entities = new List @@ -530,7 +546,7 @@ public async Task BulkInsert_WithOpenTransaction_RollsBackOnFailure() } [Fact] - public void BulkInsert_WithOpenTransaction_RollsBackOnFailure_Sync() + public void InsertEntities_WithOpenTransaction_RollsBackOnFailure_Sync() { // Arrange var entities = new List diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsMySql.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsMySql.cs index 9181409..34716be 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsMySql.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsMySql.cs @@ -6,9 +6,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; [Trait("Category", "MySql")] -public class BasicTestsMySql : BasicTestsBase> +public class BasicTestsMySql(TestDbContainerMySql dbContainer) : BasicTestsBase, TestDbContextMySql>(dbContainer) { - public BasicTestsMySql(TestDbContainerMySql dbContainer) : base(dbContainer) - { - } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsPostgreSql.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsPostgreSql.cs index f1a9cf4..b0ce8d3 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsPostgreSql.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsPostgreSql.cs @@ -6,9 +6,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; [Trait("Category", "PostgreSql")] -public class BasicTestsPostgreSql : BasicTestsBase> +public class BasicTestsPostgreSql(TestDbContainerPostgreSql dbContainer) : BasicTestsBase, TestDbContextPostgreSql>(dbContainer) { - public BasicTestsPostgreSql(TestDbContainerPostgreSql dbContainer) : base(dbContainer) - { - } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlServer.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlServer.cs index 0d658e4..f07ac40 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlServer.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlServer.cs @@ -6,9 +6,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; [Trait("Category", "SqlServer")] -public class BasicTestsSqlServer : BasicTestsBase> +public class BasicTestsSqlServer(TestDbContainerSqlServer dbContainer) : BasicTestsBase, TestDbContextSqlServer>(dbContainer) { - public BasicTestsSqlServer(TestDbContainerSqlServer dbContainer) : base(dbContainer) - { - } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlite.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlite.cs index 0aabe0a..2c728fd 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlite.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsSqlite.cs @@ -6,10 +6,7 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.Tests.Basic; [Trait("Category", "Sqlite")] -public class BasicTestsSqlite : BasicTestsBase> +public class BasicTestsSqlite(TestDbContainerSqlite dbContainer) : BasicTestsBase, TestDbContextSqlite>(dbContainer) { - public BasicTestsSqlite(TestDbContainerSqlite dbContainer) : base(dbContainer) - { - } }