diff --git a/.gitignore b/.gitignore index bdba052..982abde 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,8 @@ fabric.properties # Vitepress docs/.vitepress/dist docs/.vitepress/cache + +.direnv +.envrc +.launch +PhenX.EntityFrameworkCore.BulkInsert.sln.DotSettings.user diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs index f66a813..103896f 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs @@ -64,8 +64,22 @@ internal static class PropertyAccessor // instance => converter(body) var invokeConverter = Expression.Invoke(converter, converterInput); - // If the property is a reference type, we need to check for null before calling the converter - if (body.Type.IsClass && !invokeConverter.Type.IsValueType) + // For nullable types, only call the converter if the value is not null + if (Nullable.GetUnderlyingType(body.Type) != null) + { + // Nullable<> as common result type for the converter and constant 'null' + var resultType = typeof(Nullable<>).MakeGenericType(invokeConverter.Type); + + // instance => body == null ? null : (Nullable)converter(body) + var nullCondition = Expression.Equal(body, Expression.Constant(null, body.Type)); + var nullResult = Expression.Constant(null, resultType); + var nonNullResult = Expression.Convert(invokeConverter, resultType); + + body = Expression.Condition(nullCondition, nullResult, nonNullResult); + } + // If the property is a reference type, we need to check for null before calling the converter. even if the + // property is not nullable + else if (body.Type.IsClass && !invokeConverter.Type.IsValueType) { // instance => body == null ? null : converter(body) var nullCondition = Expression.Equal(body, Expression.Constant(null, body.Type)); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs index 8c396cd..deefaeb 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContainer/TestDbContainer.cs @@ -24,7 +24,7 @@ public abstract class TestDbContainer(IMessage protected abstract TBuilderEntity CreateBuilder(); - protected virtual string DbmsName => typeof(TContainerEntity).Name.Replace("Container", ""); + protected virtual string DbmsName => GetType().Name.Replace("TestDbContainer", ""); protected override TBuilderEntity Configure() { diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs index a895781..584dcd6 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs @@ -1,177 +1,179 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -using SmartEnum.EFCore; - -namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; - -public class TestDbContext : TestDbContextBase -{ - public DbSet TestEntities { get; set; } = null!; - public DbSet TestEntitiesWithSimpleTypes { get; set; } = null!; - public DbSet TestEntitiesWithJson { get; set; } = null!; - public DbSet TestEntitiesWithGuidId { get; set; } = null!; - public DbSet TestEntitiesWithConverter { get; set; } = null!; - public DbSet TestEntitiesWithComplexType { get; set; } = null!; - public DbSet TestEntitiesWithSmartEnum { get; set; } = null!; - public DbSet TestEntitiesWithSpecialColumnNames { get; set; } = null!; - public DbSet Students { get; set; } = null!; - public DbSet Courses { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.ConfigureSmartEnum(); - - modelBuilder.Entity(builder => - { - builder.Property(e => e.CreatedAt) - .HasConversion(new DateTimeToBinaryConverter()); - }); - - modelBuilder.Entity(builder => - { - builder - .ComplexProperty(e => e.OwnedComplexType) - .IsRequired(); - }); - - // Many-to-many with shadow property - modelBuilder.Entity() - .HasMany(s => s.Courses) - .WithMany(c => c.Students) - .UsingEntity>( - "StudentCourse", - j => j.HasOne().WithMany().HasForeignKey("CourseId"), - j => j.HasOne().WithMany().HasForeignKey("StudentId"), - j => - { - j.Property("EnrolledAt"); - j.HasKey("StudentId", "CourseId"); - } - ); - - // Keyless entity type - modelBuilder.Entity(builder => - { - builder.HasNoKey(); - // ToView will use the given table name read-only, it doesn't have to actually be a database view. - // We just reuse the table for the standard TestEntity. - builder.ToView("test_entity"); - }); - } -} - -public class TestDbContextPostgreSql : TestDbContext -{ - public DbSet TestEntitiesWithArrays { get; set; } = null!; - public DbSet TestEntitiesWithEnumList { get; set; } = null!; - public DbSet TestEntitiesWithEnumArray { get; set; } = null!; - public DbSet TestEntitiesWithIntList { get; set; } = null!; - public DbSet TestEntitiesWithEnumListExplicitType { get; set; } = null!; - public DbSet TestRecordsWithEnumList { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(b => - { - b.Property(x => x.JsonArray).AsJsonString("jsonb"); - b.Property(x => x.JsonObject).AsJsonString("jsonb"); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.StringEnumValue).HasColumnType("text"); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.EnumList).HasColumnType("integer[]"); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.TestRun).HasColumnName("test_run"); - b.Property(x => x.Values).HasColumnName("values"); - }); - } -} - -public class TestDbContextMySql : TestDbContext -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(b => - { - b.Property(x => x.JsonArray).AsJsonString("json"); - b.Property(x => x.JsonObject).AsJsonString("json"); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.StringEnumValue).HasColumnType("text"); - }); - } -} - -public class TestDbContextSqlServer : TestDbContext -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(b => - { - b.Property(x => x.JsonArray).AsJsonString(null); - b.Property(x => x.JsonObject).AsJsonString(null); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.StringEnumValue).HasColumnType("text"); - }); - } -} - -public class TestDbContextSqlite : TestDbContext -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(b => - { - b.Property(x => x.JsonArray).AsJsonString(null); - b.Property(x => x.JsonObject).AsJsonString(null); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.StringEnumValue).HasColumnType("text"); - }); - } -} - -public class TestDbContextOracle : TestDbContext -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(b => - { - b.Property(x => x.JsonArray).AsJsonString(null); - b.Property(x => x.JsonObject).AsJsonString(null); - }); - - modelBuilder.Entity(b => - { - b.Property(x => x.StringEnumValue).HasColumnType("nvarchar2(255)"); - }); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +using SmartEnum.EFCore; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +public class TestDbContext : TestDbContextBase +{ + public DbSet TestEntities { get; set; } = null!; + public DbSet TestEntitiesWithSimpleTypes { get; set; } = null!; + public DbSet TestEntitiesWithJson { get; set; } = null!; + public DbSet TestEntitiesWithGuidId { get; set; } = null!; + public DbSet TestEntitiesWithConverter { get; set; } = null!; + public DbSet TestEntitiesWithComplexType { get; set; } = null!; + public DbSet TestEntitiesWithSmartEnum { get; set; } = null!; + public DbSet TestEntitiesWithSpecialColumnNames { get; set; } = null!; + public DbSet Students { get; set; } = null!; + public DbSet Courses { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ConfigureSmartEnum(); + + modelBuilder.Entity(builder => + { + builder.Property(e => e.CreatedAt) + .HasConversion(new DateTimeToBinaryConverter()); + }); + + modelBuilder.Entity(builder => + { + builder + .ComplexProperty(e => e.OwnedComplexType) + .IsRequired(); + }); + + // Many-to-many with shadow property + modelBuilder.Entity() + .HasMany(s => s.Courses) + .WithMany(c => c.Students) + .UsingEntity>( + "StudentCourse", + j => j.HasOne().WithMany().HasForeignKey("CourseId"), + j => j.HasOne().WithMany().HasForeignKey("StudentId"), + j => + { + j.Property("EnrolledAt"); + j.HasKey("StudentId", "CourseId"); + } + ); + + // Keyless entity type + modelBuilder.Entity(builder => + { + builder.HasNoKey(); + // ToView will use the given table name read-only, it doesn't have to actually be a database view. + // We just reuse the table for the standard TestEntity. + builder.ToView("test_entity"); + }); + + modelBuilder.Entity(); + } +} + +public class TestDbContextPostgreSql : TestDbContext +{ + public DbSet TestEntitiesWithArrays { get; set; } = null!; + public DbSet TestEntitiesWithEnumList { get; set; } = null!; + public DbSet TestEntitiesWithEnumArray { get; set; } = null!; + public DbSet TestEntitiesWithIntList { get; set; } = null!; + public DbSet TestEntitiesWithEnumListExplicitType { get; set; } = null!; + public DbSet TestRecordsWithEnumList { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.JsonArray).AsJsonString("jsonb"); + b.Property(x => x.JsonObject).AsJsonString("jsonb"); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.EnumList).HasColumnType("integer[]"); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.TestRun).HasColumnName("test_run"); + b.Property(x => x.Values).HasColumnName("values"); + }); + } +} + +public class TestDbContextMySql : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.JsonArray).AsJsonString("json"); + b.Property(x => x.JsonObject).AsJsonString("json"); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); + } +} + +public class TestDbContextSqlServer : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.JsonArray).AsJsonString(null); + b.Property(x => x.JsonObject).AsJsonString(null); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); + } +} + +public class TestDbContextSqlite : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.JsonArray).AsJsonString(null); + b.Property(x => x.JsonObject).AsJsonString(null); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("text"); + }); + } +} + +public class TestDbContextOracle : TestDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.Property(x => x.JsonArray).AsJsonString(null); + b.Property(x => x.JsonObject).AsJsonString(null); + }); + + modelBuilder.Entity(b => + { + b.Property(x => x.StringEnumValue).HasColumnType("nvarchar2(255)"); + }); + } +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithNullableEnums.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithNullableEnums.cs new file mode 100644 index 0000000..61f5870 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithNullableEnums.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Microsoft.EntityFrameworkCore; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +[PrimaryKey(nameof(Id))] +[Table("test_entity_with_nullable_enums")] +public class TestEntityWithNullableEnums : TestEntityBase +{ + public int Id { get; set; } + + + [Column("string_enum_value")] + public StringEnum? StringEnumValue { get; set; } + + [Column("num_enum_value")] + public NumericEnum? NumericEnumValue { 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 bde7d79..71ef7dd 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs @@ -76,6 +76,35 @@ public async Task InsertEntities_WithJson(InsertStrategy strategy) o => o.RespectingRuntimeTypes().Excluding(e => e.Id)); } + [SkippableTheory] + [CombinatorialData] + public async Task InsertEntities_WithNullableEnums(InsertStrategy strategy) + { + // Arrange + var entities = new List + { + new() + { + TestRun = _run, + StringEnumValue = null, + NumericEnumValue = null, + }, + new() + { + TestRun = _run, + StringEnumValue = StringEnum.First, + NumericEnumValue = NumericEnum.Second, + }, + }; + + // Act + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); + + // Assert + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding(e => e.Id)); + } + [SkippableFact] public async Task InsertEntities_AndReturn_AsyncEnumerable() {