diff --git a/README.md b/README.md index b7faa33..8dd0bb6 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,8 @@ await dbContext.ExecuteBulkInsertAsync(entities, onConflict: new OnConflictOptio ## Roadmap - [ ] [Add support for navigation properties](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/2) -- [ ] [Add support for complex types](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/3) -- [ ] Add support for owned types +- [x] [Add support for complex types](https://github.com/PhenX/PhenX.EntityFrameworkCore.BulkInsert/issues/3) +- [x] Add support for owned types - [ ] Add support for shadow properties - [ ] Add support for TPT (Table Per Type) inheritance - [ ] Add support for TPC (Table Per Concrete Type) inheritance diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs index b5d9ce3..60d4b3b 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.Oracle/OracleDialectBuilder.cs @@ -40,7 +40,7 @@ public override string BuildMoveDataSql( { matchColumns = GetColumns(target, onConflictTyped.Match); } - else if (target.PrimaryKey.Count > 0) + else if (target.PrimaryKey.Length > 0) { matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName); } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlDialectBuilder.cs index 483093f..8589ff4 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.PostgreSql/PostgreSqlDialectBuilder.cs @@ -22,7 +22,7 @@ protected override void AppendConflictMatch(StringBuilder sql, TableMetadata { base.AppendConflictMatch(sql, target, conflict); } - else if (target.PrimaryKey.Count > 0) + else if (target.PrimaryKey.Length > 0) { sql.Append(' '); sql.AppendLine("("); diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs index a45414f..89d09c6 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert.SqlServer/SqlServerDialectBuilder.cs @@ -48,7 +48,7 @@ public override string BuildMoveDataSql( { matchColumns = GetColumns(target, onConflictTyped.Match); } - else if (target.PrimaryKey.Count > 0) + else if (target.PrimaryKey.Length > 0) { matchColumns = target.PrimaryKey.Select(x => x.QuotedColumName); } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs index baf9a76..2ab9d11 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/ColumnMetadata.cs @@ -6,23 +6,39 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; -internal sealed class ColumnMetadata(IProperty property, SqlDialectBuilder dialect) +internal sealed class ColumnMetadata { - private readonly Func _getter = BuildGetter(property); + public ColumnMetadata(IProperty property, SqlDialectBuilder dialect, IComplexProperty? complexProperty = null) + { + StoreObjectIdentifier? ownerTable = complexProperty != null + ? StoreObjectIdentifier.Table(complexProperty.DeclaringType.GetTableName()!, complexProperty.DeclaringType.GetSchema()) + : null; + + _getter = BuildGetter(property, complexProperty); + Property = property; + PropertyName = property.Name; + ColumnName = ownerTable == null ? property.GetColumnName() : property.GetColumnName(ownerTable.Value)!; + QuotedColumName = dialect.Quote(ColumnName); + StoreDefinition = GetStoreDefinition(property); + ClrType = property.ClrType; + IsGenerated = property.ValueGenerated != ValueGenerated.Never; + } + + private readonly Func _getter; - public IProperty Property { get; } = property; + public IProperty Property { get; } - public string PropertyName { get; } = property.Name; + public string PropertyName { get; } - public string ColumnName { get; } = property.GetColumnName(); + public string ColumnName { get; } - public string QuotedColumName { get; } = dialect.Quote(property.GetColumnName()); + public string QuotedColumName { get; } - public string StoreDefinition { get; } = GetStoreDefinition(property); + public string StoreDefinition { get; } - public Type ClrType { get; } = property.ClrType; + public Type ClrType { get; } - public bool IsGenerated { get; } = property.ValueGenerated != ValueGenerated.Never; + public bool IsGenerated { get; } public object GetValue(object entity, BulkInsertOptions options) { @@ -43,7 +59,7 @@ public object GetValue(object entity, BulkInsertOptions options) return result ?? DBNull.Value; } - private static Func BuildGetter(IProperty property) + private static Func BuildGetter(IProperty property, IComplexProperty? complexProperty) { var valueConverter = property.GetValueConverter() ?? @@ -51,7 +67,7 @@ public object GetValue(object entity, BulkInsertOptions options) var propInfo = property.PropertyInfo!; - return PropertyAccessor.CreateGetter(propInfo, valueConverter?.ConvertToProviderExpression); + return PropertyAccessor.CreateGetter(propInfo, complexProperty, valueConverter?.ConvertToProviderExpression); } private static string GetStoreDefinition(IProperty property) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs index 582ce92..beee387 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/PropertyAccessor.cs @@ -1,64 +1,94 @@ using System.Linq.Expressions; using System.Reflection; +using Microsoft.EntityFrameworkCore.Metadata; + namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; internal static class PropertyAccessor { - public static Func CreateGetter(PropertyInfo propertyInfo, LambdaExpression? converter = null) + public static Func CreateGetter( + PropertyInfo propertyInfo, + IComplexProperty? complexProperty = null, + LambdaExpression? converter = null) { ArgumentNullException.ThrowIfNull(propertyInfo); - var getMethod = propertyInfo.GetMethod ?? throw new ArgumentException("Property does not have a getter."); + // instance => { } var instanceParam = Expression.Parameter(typeof(object), "instance"); - // Convert object to the declaring type - Expression typedInstance = propertyInfo.DeclaringType!.IsValueType - ? Expression.Unbox(instanceParam, propertyInfo.DeclaringType) - : Expression.Convert(instanceParam, propertyInfo.DeclaringType); + Expression body; + + if (complexProperty == null) + { + var propDeclaringType = propertyInfo.DeclaringType!; + + // Convert object to the declaring type + var typedInstance = GetTypedInstance(propDeclaringType, instanceParam); + + // instance => ((TEntity)instance).Property + body = Expression.Property(typedInstance, propertyInfo); + } + else + { + // Nested access: ((TEntity)instance).ComplexProp.Property + var complexPropInfo = complexProperty.PropertyInfo!; + var complexPropDeclaringType = complexPropInfo.DeclaringType!; + + var typedInstance = GetTypedInstance(complexPropDeclaringType, instanceParam); - // Call Getter - Expression getterExpression = Expression.Call(typedInstance, getMethod); + // instance => ((TEntity)instance).ComplexProp + Expression complexAccess = Expression.Property(typedInstance, complexPropInfo); - var propertyType = propertyInfo.PropertyType; + // instance => ((TEntity)instance).ComplexProp.Property + body = Expression.Property(complexAccess, propertyInfo); + } // If the converter is provided, we call it if (converter != null) { // Validate the converter input type matches property type var converterParamType = converter.Parameters[0].Type; - if (!converterParamType.IsAssignableFrom(propertyType) && !propertyType.IsAssignableFrom(converterParamType)) + if (!converterParamType.IsAssignableFrom(body.Type) && !body.Type.IsAssignableFrom(converterParamType)) { - throw new ArgumentException($"Converter input must be assignable from property type ({propertyType} -> {converterParamType})"); + throw new ArgumentException($"Converter input must be assignable from property type ({body.Type} -> {converterParamType})"); } - // If property type != converter param, convert - var converterInput = getterExpression; - if (converterParamType != propertyType) + Expression converterInput = body; + if (converterParamType != body.Type) { - converterInput = Expression.Convert(getterExpression, converterParamType); + // instance => converter((TConverterType)body) + converterInput = Expression.Convert(body, converterParamType); } + // instance => converter(body) var invokeConverter = Expression.Invoke(converter, converterInput); - if (propertyType.IsClass) + if (body.Type.IsClass) { - var nullCondition = Expression.Equal(getterExpression, Expression.Constant(null, propertyType)); - var nullResult = Expression.Constant(null, converter.ReturnType); - getterExpression = Expression.Condition(nullCondition, nullResult, invokeConverter); + // instance => body == null ? null : converter(body) + var nullCondition = Expression.Equal(body, Expression.Constant(null, body.Type)); + var nullResult = Expression.Constant(null, invokeConverter.Type); + + body = Expression.Condition(nullCondition, nullResult, invokeConverter); } else { - getterExpression = invokeConverter; + body = invokeConverter; } - - propertyType = getterExpression.Type; } - var finalExpression = propertyType.IsValueType - ? Expression.Convert(getterExpression, typeof(object)) - : getterExpression; + var finalExpression = body.Type.IsValueType + ? Expression.Convert(body, typeof(object)) + : body; return Expression.Lambda>(finalExpression, instanceParam).Compile(); } + + private static UnaryExpression GetTypedInstance(Type propDeclaringType, ParameterExpression instanceParam) + { + return propDeclaringType.IsValueType + ? Expression.Unbox(instanceParam, propDeclaringType) + : Expression.Convert(instanceParam, propDeclaringType); + } } diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/TableMetadata.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/TableMetadata.cs index 0968540..6305790 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/TableMetadata.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Metadata/TableMetadata.cs @@ -5,37 +5,58 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Metadata; -internal sealed class TableMetadata(IEntityType entityType, SqlDialectBuilder dialect) +internal sealed class TableMetadata { - private IReadOnlyList? _notGeneratedColumns; - private IReadOnlyList? _primaryKeys; + private ColumnMetadata[]? _notGeneratedColumns; + private ColumnMetadata[]? _primaryKeys; - public string QuotedTableName { get; } = - dialect.QuoteTableName(entityType.GetSchema(), entityType.GetTableName()!); + private readonly IEntityType _entityType; - public string TableName { get; } = - entityType.GetTableName() ?? throw new InvalidOperationException("Canot determine table name."); + public string QuotedTableName { get; } - public IReadOnlyList Columns { get; } = - entityType.GetProperties().Where(p => !p.IsShadowProperty()).Select(x => new ColumnMetadata(x, dialect)).ToList(); + public string TableName { get; } - public IReadOnlyList PrimaryKey => - _primaryKeys ??= GetPrimaryKey(); + private ColumnMetadata[] Columns { get; } - public IReadOnlyList GetColumns(bool includeGenerated = true) + public TableMetadata(IEntityType entityType, SqlDialectBuilder dialect) + { + _entityType = entityType; + TableName = entityType.GetTableName() ?? throw new InvalidOperationException("Cannot determine table name."); + QuotedTableName = dialect.QuoteTableName(entityType.GetSchema(), TableName); + Columns = GetColumns(entityType, dialect); + } + + private static ColumnMetadata[] GetColumns(IEntityType entityType, SqlDialectBuilder dialect) + { + var properties = entityType.GetProperties() + .Where(p => !p.IsShadowProperty()) + .Select(x => new ColumnMetadata(x, dialect)); + + var complexProperties = entityType.GetComplexProperties() + .SelectMany(cp => cp.ComplexType + .GetProperties() + .Where(p => !p.IsShadowProperty()) + .Select(x => new ColumnMetadata(x, dialect, cp))); + + return properties.Concat(complexProperties).ToArray(); + } + + public ColumnMetadata[] PrimaryKey => _primaryKeys ??= GetPrimaryKey(); + + public ColumnMetadata[] GetColumns(bool includeGenerated = true) { if (includeGenerated) { return Columns; } - return _notGeneratedColumns ??= Columns.Where(x => !x.IsGenerated).ToList(); + return _notGeneratedColumns ??= Columns.Where(x => !x.IsGenerated).ToArray(); } public string GetQuotedColumnName(string propertyName) { var property = Columns.FirstOrDefault(x => x.PropertyName == propertyName) - ?? throw new InvalidOperationException($"Property {propertyName} not found in entity type {entityType.Name}."); + ?? throw new InvalidOperationException($"Property {propertyName} not found in entity type {_entityType.Name}."); return property.QuotedColumName; } @@ -43,15 +64,17 @@ public string GetQuotedColumnName(string propertyName) public string GetColumnName(string propertyName) { var property = Columns.FirstOrDefault(x => x.PropertyName == propertyName) - ?? throw new InvalidOperationException($"Property {propertyName} not found in entity type {entityType.Name}."); + ?? throw new InvalidOperationException($"Property {propertyName} not found in entity type {_entityType.Name}."); return property.ColumnName; } - private List GetPrimaryKey() + private ColumnMetadata[] GetPrimaryKey() { - var primaryKey = entityType.FindPrimaryKey()?.Properties ?? []; + var primaryKey = _entityType.FindPrimaryKey()?.Properties ?? []; - return Columns.Where(x => primaryKey.Any(y => x.PropertyName == y.Name)).ToList(); + return Columns + .Where(x => primaryKey.Any(y => x.PropertyName == y.Name)) + .ToArray(); } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/GetValueComparator.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/GetValueComparator.cs index 948e948..c35cdd8 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/GetValueComparator.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Benchmark/GetValueComparator.cs @@ -76,7 +76,7 @@ public void IterationSetup() ? expression : null; - return PropertyAccessor.CreateGetter(propertyInfo, converter); + return PropertyAccessor.CreateGetter(propertyInfo, converter: converter); }) .ToArray(); diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/JsonDbObject.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/OwnedObject.cs similarity index 86% rename from tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/JsonDbObject.cs rename to tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/OwnedObject.cs index 1c138df..fee3a80 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/JsonDbObject.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/OwnedObject.cs @@ -1,6 +1,6 @@ namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; -public class JsonDbObject +public class OwnedObject { public int Code { get; set; } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs index ca15623..24e0e58 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestDbContext.cs @@ -10,6 +10,7 @@ public class TestDbContext : TestDbContextBase public DbSet TestEntitiesWithJson { get; set; } = null!; public DbSet TestEntitiesWithGuidId { get; set; } = null!; public DbSet TestEntitiesWithConverter { get; set; } = null!; + public DbSet TestEntitiesWithComplexType { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -26,6 +27,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) builder.Property(e => e.Id) .ValueGeneratedNever(); }); + + modelBuilder.Entity(builder => + { + builder + .ComplexProperty(e => e.OwnedComplexType) + .IsRequired(); + }); } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithComplexType.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithComplexType.cs new file mode 100644 index 0000000..19a8a98 --- /dev/null +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithComplexType.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PhenX.EntityFrameworkCore.BulkInsert.Tests.DbContext; + +[Table("test_entity_complex_type")] +public class TestEntityWithComplexType : TestEntityBase +{ + [Key] + public int Id { get; set; } + + public OwnedObject OwnedComplexType { get; set; } = null!; +} diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs index 900795d..f3a0b97 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestEntityWithJson.cs @@ -11,5 +11,5 @@ public class TestEntityWithJson : TestEntityBase public List JsonArray { get; set; } = []; - public JsonDbObject? JsonObject { get; set; } + public OwnedObject? JsonObject { 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 16b3870..6697dac 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Basic/BasicTestsBase.cs @@ -59,13 +59,13 @@ public async Task InsertEntities_WithJson(InsertStrategy strategy) { TestRun = _run, JsonArray = [1], - JsonObject = new JsonDbObject { Code = 1, Name = "Test1" }, + JsonObject = new OwnedObject { Code = 1, Name = "Test1" }, }, new TestEntityWithJson { TestRun = _run, JsonArray = [2], - JsonObject = new JsonDbObject { Code = 2, Name = "Test2" }, + JsonObject = new OwnedObject { Code = 2, Name = "Test2" }, }, }; @@ -443,4 +443,41 @@ public async Task InsertEntities_WithAllSimpleTypes(InsertStrategy strategy) .Excluding(e => e.Id) ); } + + [SkippableTheory] + [CombinatorialData] + public async Task InsertsEntities_WithComplexType(InsertStrategy strategy) + { + // Arrange + var entities = new List + { + new TestEntityWithComplexType + { + TestRun = _run, + Id = 1, + OwnedComplexType = new OwnedObject + { + Code = 1, + Name = "Name1", + } + }, + new TestEntityWithComplexType + { + TestRun = _run, + Id = 2, + OwnedComplexType = new OwnedObject + { + Code = 2, + Name = "Name2", + } + } + }; + + // Act + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); + + // Assert + insertedEntities.Should().BeEquivalentTo(entities, + o => o.RespectingRuntimeTypes().Excluding(e => e.Id)); + } }