From e6ae8ac5291d531433e7e471bbaa55a0a1f53cb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:21:11 +0000 Subject: [PATCH 1/7] Initial plan From 5cf90623505be17ab8400e10576352a675f48061 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:35:38 +0000 Subject: [PATCH 2/7] Fix complex property update in ON CONFLICT clause and add tests Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Dialect/SqlDialectBuilder.cs | 38 ++++++- .../Tests/Merge/MergeTestsBase.cs | 106 ++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index 4dd19cb..3dfe772 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -297,15 +297,20 @@ private string ToSqlExpression(DbContext context, TableMetadata table, case MemberExpression memberExpr: var columnName = table.GetColumnName(memberExpr.Member.Name); + // Traverse up the expression chain to find the root parameter + // This handles both simple properties (e.g., excluded.Name) and + // complex properties (e.g., excluded.ComplexObject.Property) + var rootParam = GetRootParameter(memberExpr); + // If the member expression is a property of the current lambda - if (lambda is { Parameters.Count: > 1 } && memberExpr.Expression is ParameterExpression paramExpr) + if (lambda is { Parameters.Count: > 1 } && rootParam != null) { - if (paramExpr.Name == lambda.Parameters[0].Name) + if (rootParam.Name == lambda.Parameters[0].Name) { return GetInsertedColumnName(columnName); } - if (paramExpr.Name == lambda.Parameters[1].Name) + if (rootParam.Name == lambda.Parameters[1].Name) { return GetExcludedColumnName(columnName); } @@ -405,4 +410,31 @@ private string ToSqlExpression(DbContext context, TableMetadata table, throw new NotSupportedException($"Expression not supported: {expr.NodeType}"); } } + + /// + /// Traverses up a member expression chain to find the root parameter expression. + /// This handles both simple properties (e.g., excluded.Name) and complex properties (e.g., excluded.ComplexObject.Property). + /// + private static ParameterExpression? GetRootParameter(MemberExpression memberExpr) + { + Expression? current = memberExpr.Expression; + while (current != null) + { + if (current is ParameterExpression param) + { + return param; + } + + if (current is MemberExpression nested) + { + current = nested.Expression; + } + else + { + break; + } + } + + return null; + } } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs index 57076b4..b3cce15 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs @@ -348,4 +348,110 @@ public async Task InsertEntities_WithConflict_MultipleColumns(InsertStrategy str Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity2"); Assert.Contains(insertedEntities, e => e.Name == $"{_run}_Entity1 - Conflict" && e.Price == 0); } + + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithComplexType_UpdateAll(InsertStrategy strategy) + { + Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); + + // Arrange + var entities = new List + { + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 1, Name = "Name1" } + }, + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 2, Name = "Name2" } + } + }; + + // Act - First insert (without CopyGeneratedColumns - returns generated IDs via RETURNING) + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); + + // Update the complex properties + foreach (var entity in insertedEntities) + { + entity.OwnedComplexType = new OwnedObject + { + Code = entity.OwnedComplexType.Code + 100, + Name = $"Updated_{entity.OwnedComplexType.Name}" + }; + } + + // Act - Second insert with update on conflict + // Using 'inserted' parameter to update all columns (both behave the same for ParameterExpression case) + var updatedEntities = await _context.InsertWithStrategyAsync(strategy, insertedEntities, o => o.CopyGeneratedColumns = true, + onConflict: new OnConflictOptions + { + Update = (inserted, excluded) => inserted, + }); + + // Assert - complex properties should be updated + Assert.Equal(2, updatedEntities.Count); + Assert.All(updatedEntities, e => + { + Assert.StartsWith("Updated_", e.OwnedComplexType.Name); + Assert.True(e.OwnedComplexType.Code > 100); + }); + } + + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithComplexType_UpdateWithWhere(InsertStrategy strategy) + { + Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); + + // Arrange + var entities = new List + { + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 10, Name = "Original1" } + }, + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 20, Name = "Original2" } + } + }; + + // Act - First insert (without CopyGeneratedColumns - returns generated IDs via RETURNING) + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); + + // Update the complex property name + foreach (var entity in insertedEntities) + { + entity.OwnedComplexType.Name = $"Changed_{entity.OwnedComplexType.Name}"; + entity.OwnedComplexType.Code = entity.OwnedComplexType.Code + 100; + } + + // Act - Second insert updating complex properties with a WHERE condition + // This tests that complex property access works correctly in the Where clause + var updatedEntities = await _context.InsertWithStrategyAsync(strategy, insertedEntities, o => o.CopyGeneratedColumns = true, + onConflict: new OnConflictOptions + { + Update = (inserted, excluded) => inserted, + Where = (inserted, excluded) => excluded.OwnedComplexType.Code > inserted.OwnedComplexType.Code + }); + + // Assert - entities should be updated because excluded.Code (110, 120) > inserted.Code (10, 20) + Assert.Equal(2, updatedEntities.Count); + Assert.All(updatedEntities, e => + { + Assert.StartsWith("Changed_", e.OwnedComplexType.Name); + Assert.True(e.OwnedComplexType.Code > 100); + }); + } } From f8a5c62edbb2d7aa7ee9163c0046ff3b3fdbc63c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:39:45 +0000 Subject: [PATCH 3/7] Address code review feedback for documentation and comments Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Dialect/SqlDialectBuilder.cs | 2 ++ .../Tests/Merge/MergeTestsBase.cs | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index 3dfe772..76e048d 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -415,6 +415,8 @@ private string ToSqlExpression(DbContext context, TableMetadata table, /// Traverses up a member expression chain to find the root parameter expression. /// This handles both simple properties (e.g., excluded.Name) and complex properties (e.g., excluded.ComplexObject.Property). /// + /// The member expression to traverse. + /// The root parameter expression if found; otherwise, null if the chain contains unsupported expression types. private static ParameterExpression? GetRootParameter(MemberExpression memberExpr) { Expression? current = memberExpr.Expression; diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs index b3cce15..21796d8 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs @@ -387,7 +387,7 @@ public async Task InsertEntities_WithComplexType_UpdateAll(InsertStrategy strate } // Act - Second insert with update on conflict - // Using 'inserted' parameter to update all columns (both behave the same for ParameterExpression case) + // The ParameterExpression case in GetUpdates generates UPDATE statements for all columns var updatedEntities = await _context.InsertWithStrategyAsync(strategy, insertedEntities, o => o.CopyGeneratedColumns = true, onConflict: new OnConflictOptions { @@ -412,7 +412,7 @@ public async Task InsertEntities_WithComplexType_UpdateWithWhere(InsertStrategy // Oracle MERGE does not support returning entities Skip.If(_context.IsProvider(ProviderType.Oracle)); - // Arrange + // Arrange - initial Code values are 10 and 20 var entities = new List { new TestEntityWithComplexType @@ -430,7 +430,7 @@ public async Task InsertEntities_WithComplexType_UpdateWithWhere(InsertStrategy // Act - First insert (without CopyGeneratedColumns - returns generated IDs via RETURNING) var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); - // Update the complex property name + // Update the complex property - new Code values will be original + 100 (110 and 120) foreach (var entity in insertedEntities) { entity.OwnedComplexType.Name = $"Changed_{entity.OwnedComplexType.Name}"; @@ -446,7 +446,8 @@ public async Task InsertEntities_WithComplexType_UpdateWithWhere(InsertStrategy Where = (inserted, excluded) => excluded.OwnedComplexType.Code > inserted.OwnedComplexType.Code }); - // Assert - entities should be updated because excluded.Code (110, 120) > inserted.Code (10, 20) + // Assert - entities should be updated because the new Code values (110, 120) + // are greater than the existing values in the database (10, 20) Assert.Equal(2, updatedEntities.Count); Assert.All(updatedEntities, e => { From 7cfed6825a677a4330fb326aefea12ea2bc7cb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sun, 18 Jan 2026 16:34:09 +0100 Subject: [PATCH 4/7] Improve comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Dialect/SqlDialectBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index 76e048d..305e768 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -416,7 +416,7 @@ private string ToSqlExpression(DbContext context, TableMetadata table, /// This handles both simple properties (e.g., excluded.Name) and complex properties (e.g., excluded.ComplexObject.Property). /// /// The member expression to traverse. - /// The root parameter expression if found; otherwise, null if the chain contains unsupported expression types. + /// The root parameter expression if found; otherwise, null if the expression chain doesn't contain a parameter. private static ParameterExpression? GetRootParameter(MemberExpression memberExpr) { Expression? current = memberExpr.Expression; From 4dbb7b9e485674b016b83adce766b697f12cd20e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:14:25 +0000 Subject: [PATCH 5/7] Fix nested MemberInitExpression for complex property updates in ON CONFLICT Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Dialect/SqlDialectBuilder.cs | 36 ++++++++++- .../Tests/Merge/MergeTestsBase.cs | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index 305e768..cb35ab9 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -253,9 +253,9 @@ protected IEnumerable GetUpdates(DbContext context, TableMetadata tab } case MemberInitExpression memberInit: { - foreach (var binding in memberInit.Bindings.OfType()) + foreach (var updateSql in GetUpdatesFromMemberInit(context, table, memberInit, lambda)) { - yield return $"{table.GetQuotedColumnName(binding.Member.Name)} = {ToSqlExpression(context, table, binding.Expression, lambda)}"; + yield return updateSql; } break; @@ -411,6 +411,38 @@ private string ToSqlExpression(DbContext context, TableMetadata table, } } + /// + /// Extracts update SQL statements from a MemberInitExpression, handling both simple properties + /// and nested complex property initializations. + /// + /// DB context + /// Table metadata + /// The member initialization expression + /// Current lambda expression + /// Entity type + /// SQL update statements for each property assignment + private IEnumerable GetUpdatesFromMemberInit(DbContext context, TableMetadata table, MemberInitExpression memberInit, LambdaExpression lambda) + { + foreach (var binding in memberInit.Bindings.OfType()) + { + // Check if the binding expression is a nested MemberInitExpression (complex property assignment) + if (binding.Expression is MemberInitExpression nestedMemberInit) + { + // Recursively process nested complex property assignments + foreach (var nestedBinding in nestedMemberInit.Bindings.OfType()) + { + // For complex properties, the column name is the nested property name (e.g., "Code", "Name") + yield return $"{table.GetQuotedColumnName(nestedBinding.Member.Name)} = {ToSqlExpression(context, table, nestedBinding.Expression, lambda)}"; + } + } + else + { + // Simple property assignment + yield return $"{table.GetQuotedColumnName(binding.Member.Name)} = {ToSqlExpression(context, table, binding.Expression, lambda)}"; + } + } + } + /// /// Traverses up a member expression chain to find the root parameter expression. /// This handles both simple properties (e.g., excluded.Name) and complex properties (e.g., excluded.ComplexObject.Property). diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs index 21796d8..49f02ec 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Merge/MergeTestsBase.cs @@ -455,4 +455,63 @@ public async Task InsertEntities_WithComplexType_UpdateWithWhere(InsertStrategy Assert.True(e.OwnedComplexType.Code > 100); }); } + + [SkippableTheory] + [InlineData(InsertStrategy.InsertReturn)] + [InlineData(InsertStrategy.InsertReturnAsync)] + public async Task InsertEntities_WithComplexType_UpdateComplexPropertyConditionally(InsertStrategy strategy) + { + Skip.If(_context.IsProvider(ProviderType.MySql)); + // Oracle MERGE does not support returning entities + Skip.If(_context.IsProvider(ProviderType.Oracle)); + + // Arrange - Create entities with different Code values + var entities = new List + { + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 50, Name = "LowCode" } + }, + new TestEntityWithComplexType + { + TestRun = _run, + OwnedComplexType = new OwnedObject { Code = 150, Name = "HighCode" } + } + }; + + // Act - First insert (returns entities with generated IDs) + var insertedEntities = await _context.InsertWithStrategyAsync(strategy, entities); + + // Update both entities with new values + foreach (var entity in insertedEntities) + { + entity.OwnedComplexType.Code = entity.OwnedComplexType.Code + 10; + entity.OwnedComplexType.Name = $"Modified_{entity.OwnedComplexType.Name}"; + } + + // Act - Update using nested MemberInitExpression for complex property assignment + // Note: entities with Code >= 100 (original value) will not be updated due to WHERE clause + var updatedEntities = await _context.InsertWithStrategyAsync(strategy, insertedEntities, + o => o.CopyGeneratedColumns = true, + onConflict: new OnConflictOptions + { + Update = (inserted, excluded) => new TestEntityWithComplexType + { + OwnedComplexType = new OwnedObject + { + Code = excluded.OwnedComplexType.Code, + Name = excluded.OwnedComplexType.Name + } + }, + Where = (inserted, excluded) => inserted.OwnedComplexType.Code < 100 + }); + + // Assert - Only the entity with original Code < 100 should be updated (Code was 50, now 60) + // The one with original Code >= 100 is not updated but is also not returned by RETURNING clause + Assert.Single(updatedEntities); + var updatedEntity = updatedEntities.Single(); + Assert.Equal(60, updatedEntity.OwnedComplexType.Code); + Assert.Equal("Modified_LowCode", updatedEntity.OwnedComplexType.Name); + } } From 43b45ceb2f9e75452ac052962f6e218b35b8d964 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:18:18 +0000 Subject: [PATCH 6/7] Make GetUpdatesFromMemberInit truly recursive for arbitrary nesting Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Dialect/SqlDialectBuilder.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs index cb35ab9..cde3d67 100644 --- a/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs +++ b/src/PhenX.EntityFrameworkCore.BulkInsert/Dialect/SqlDialectBuilder.cs @@ -413,7 +413,7 @@ private string ToSqlExpression(DbContext context, TableMetadata table, /// /// Extracts update SQL statements from a MemberInitExpression, handling both simple properties - /// and nested complex property initializations. + /// and nested complex property initializations recursively. /// /// DB context /// Table metadata @@ -428,16 +428,15 @@ private IEnumerable GetUpdatesFromMemberInit(DbContext context, Table // Check if the binding expression is a nested MemberInitExpression (complex property assignment) if (binding.Expression is MemberInitExpression nestedMemberInit) { - // Recursively process nested complex property assignments - foreach (var nestedBinding in nestedMemberInit.Bindings.OfType()) + // Recursively process nested complex property assignments to handle arbitrary nesting levels + foreach (var update in GetUpdatesFromMemberInit(context, table, nestedMemberInit, lambda)) { - // For complex properties, the column name is the nested property name (e.g., "Code", "Name") - yield return $"{table.GetQuotedColumnName(nestedBinding.Member.Name)} = {ToSqlExpression(context, table, nestedBinding.Expression, lambda)}"; + yield return update; } } else { - // Simple property assignment + // Simple property assignment - the column name is the property name yield return $"{table.GetQuotedColumnName(binding.Member.Name)} = {ToSqlExpression(context, table, binding.Expression, lambda)}"; } } From d9eec1452d54e2e7cc280807f03bcd13250dc302 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 18 Jan 2026 21:46:59 +0100 Subject: [PATCH 7/7] Fix compilation warning --- .../DbContext/TestSmartEnum.cs | 2 +- .../Tests/Various/VariousTestsBase.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestSmartEnum.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestSmartEnum.cs index 02aad51..4d3c4a8 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestSmartEnum.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/DbContext/TestSmartEnum.cs @@ -8,5 +8,5 @@ private TestSmartEnum(string name, int value) : base(name, value) { } - public static readonly TestSmartEnum Value = new TestSmartEnum("test", 1); + public static readonly TestSmartEnum Test = new TestSmartEnum("test", 1); } diff --git a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Various/VariousTestsBase.cs b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Various/VariousTestsBase.cs index 748e447..ca5724e 100644 --- a/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Various/VariousTestsBase.cs +++ b/tests/PhenX.EntityFrameworkCore.BulkInsert.Tests/Tests/Various/VariousTestsBase.cs @@ -34,8 +34,8 @@ public async Task InsertSmartEnumEntities(InsertStrategy strategy) // Arrange var entities = new List { - new TestEntityWithSmartEnum { TestRun = _run, Enum = TestSmartEnum.Value}, - new TestEntityWithSmartEnum { TestRun = _run, Enum = TestSmartEnum.Value} + new TestEntityWithSmartEnum { TestRun = _run, Enum = TestSmartEnum.Test}, + new TestEntityWithSmartEnum { TestRun = _run, Enum = TestSmartEnum.Test} }; // Act