From fe43fd370120ec99d91906450cba3bf6fe7384ee Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 16 Jul 2025 13:58:25 -0700 Subject: [PATCH] Add basic update pipeline support for complex properties mapped to JSON Part of #31252 --- .../RelationalComplexPropertyExtensions.cs | 2 +- .../Metadata/Internal/RelationalModel.cs | 44 +- .../Update/ModificationCommand.cs | 145 ++-- .../ComplexCollectionJsonUpdateTestBase.cs | 655 ++++++++++++++++++ .../SqlServerComplianceTest.cs | 6 + ...omplexCollectionJsonUpdateSqlServerTest.cs | 198 ++++++ .../SqliteComplianceTest.cs | 3 +- .../ComplexCollectionJsonUpdateSqliteTest.cs | 39 ++ 8 files changed, 1026 insertions(+), 66 deletions(-) create mode 100644 test/EFCore.Relational.Specification.Tests/Update/ComplexCollectionJsonUpdateTestBase.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Update/ComplexCollectionJsonUpdateSqlServerTest.cs create mode 100644 test/EFCore.Sqlite.FunctionalTests/Update/ComplexCollectionJsonUpdateSqliteTest.cs diff --git a/src/EFCore.Relational/Extensions/RelationalComplexPropertyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalComplexPropertyExtensions.cs index 44a0be87cfb..e3f6e63b074 100644 --- a/src/EFCore.Relational/Extensions/RelationalComplexPropertyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalComplexPropertyExtensions.cs @@ -24,7 +24,7 @@ public static class RelationalComplexPropertyExtensions /// public static string? GetJsonPropertyName(this IReadOnlyComplexProperty complexProperty) => (string?)complexProperty.FindAnnotation(RelationalAnnotationNames.JsonPropertyName)?.Value - ?? (!complexProperty.DeclaringType.IsMappedToJson() ? null : complexProperty.Name); + ?? (complexProperty.DeclaringType.IsMappedToJson() ? complexProperty.Name : null); /// /// Sets the value of JSON property name used for the given complex property of an entity mapped to a JSON column. diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 66fb013d164..b14858e928a 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -542,31 +542,31 @@ private static void CreateTableMapping( CreateColumnMapping(column, property, tableMapping); } + } - // TODO: Change this to call GetComplexProperties() - // Issue #31248 - foreach (var complexProperty in mappedType.GetDeclaredComplexProperties()) - { - var complexType = complexProperty.ComplexType; - - var complexTableMappings = - (List?)complexType.FindRuntimeAnnotationValue(RelationalAnnotationNames.TableMappings); - if (complexTableMappings == null) - { - complexTableMappings = []; - complexType.AddRuntimeAnnotation(RelationalAnnotationNames.TableMappings, complexTableMappings); - } + // TODO: Change this to call GetComplexProperties() + // Issue #31248 + foreach (var complexProperty in mappedType.GetDeclaredComplexProperties()) + { + var complexType = complexProperty.ComplexType; - CreateTableMapping( - relationalTypeMappingSource, - complexType, - complexType, - mappedTable, - databaseModel, - complexTableMappings, - includesDerivedTypes: true, - isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal); + var complexTableMappings = + (List?)complexType.FindRuntimeAnnotationValue(RelationalAnnotationNames.TableMappings); + if (complexTableMappings == null) + { + complexTableMappings = []; + complexType.AddRuntimeAnnotation(RelationalAnnotationNames.TableMappings, complexTableMappings); } + + CreateTableMapping( + relationalTypeMappingSource, + complexType, + complexType, + mappedTable, + databaseModel, + complexTableMappings, + includesDerivedTypes: true, + isSplitEntityTypePrincipal: isSplitEntityTypePrincipal == true ? false : isSplitEntityTypePrincipal); } if (((ITableMappingBase)tableMapping).ColumnMappings.Any() diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs index acebcfec9b3..1497728e59b 100644 --- a/src/EFCore.Relational/Update/ModificationCommand.cs +++ b/src/EFCore.Relational/Update/ModificationCommand.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Internal; using IColumnMapping = Microsoft.EntityFrameworkCore.Metadata.IColumnMapping; using ITableMapping = Microsoft.EntityFrameworkCore.Metadata.ITableMapping; @@ -243,7 +244,7 @@ private sealed class JsonPartialUpdateInfo public object? PropertyValue { get; set; } } - private record struct JsonPartialUpdatePathEntry(string PropertyName, int? Ordinal, IUpdateEntry ParentEntry, INavigation Navigation); + private record struct JsonPartialUpdatePathEntry(string PropertyName, int? Ordinal, IUpdateEntry ParentEntry, INavigation? Navigation, IComplexProperty? ComplexProperty = null); private List GenerateColumnModifications() { @@ -263,7 +264,7 @@ private List GenerateColumnModifications() { Check.DebugAssert(StoreStoredProcedure is null, "Multiple entries/shared identity not supported with stored procedures"); - sharedTableColumnMap = new Dictionary(); + sharedTableColumnMap = []; if (_comparer != null && _entries.Count > 1) @@ -297,6 +298,7 @@ private List GenerateColumnModifications() if (!jsonEntry) { if (entry.EntityType.IsMappedToJson() + || entry.EntityType.GetFlattenedComplexProperties().Any(cp => cp.ComplexType.IsMappedToJson()) || entry.EntityType.GetNavigations().Any(e => e.IsCollection && e.TargetEntityType.IsMappedToJson())) { jsonEntry = true; @@ -654,7 +656,8 @@ static JsonPartialUpdateInfo FindCommonJsonPartialUpdateInfo( first.Path[i].PropertyName, null, first.Path[i].ParentEntry, - first.Path[i].Navigation); + Navigation: first.Path[i].Navigation, + ComplexProperty: first.Path[i].ComplexProperty); result.Path.Add(common); @@ -671,11 +674,15 @@ void HandleJson(List columnModifications) { var jsonColumnsUpdateMap = new Dictionary(); var processedEntries = new List(); - foreach (var entry in _entries.Where(e => e.EntityType.IsMappedToJson())) + foreach (var entry in _entries) { + if (!entry.EntityType.IsMappedToJson()) + { + continue; + } + var jsonColumn = GetTableMapping(entry.EntityType)!.Table.FindColumn(entry.EntityType.GetContainerColumnName()!)!; var jsonPartialUpdateInfo = FindJsonPartialUpdateInfo(entry, processedEntries); - if (jsonPartialUpdateInfo == null) { continue; @@ -691,8 +698,13 @@ void HandleJson(List columnModifications) jsonColumnsUpdateMap[jsonColumn] = jsonPartialUpdateInfo; } - foreach (var entry in _entries.Where(e => !e.EntityType.IsMappedToJson())) + foreach (var entry in _entries) { + if (entry.EntityType.IsMappedToJson()) + { + continue; + } + foreach (var jsonCollectionNavigation in entry.EntityType.GetNavigations() .Where( n => n.IsCollection @@ -712,14 +724,32 @@ void HandleJson(List columnModifications) jsonColumnsUpdateMap[jsonCollectionColumn] = jsonPartialUpdateInfo; } } + + foreach (var complexProperty in entry.EntityType.GetFlattenedComplexProperties() + .Where(cp => cp.ComplexType.IsMappedToJson() && !cp.DeclaringType.IsMappedToJson())) + { + var complexType = complexProperty.ComplexType; + var jsonColumn = GetTableMapping(entry.EntityType)!.Table.FindColumn(complexType.GetContainerColumnName()!)!; + + if (!jsonColumnsUpdateMap.ContainsKey(jsonColumn)) + { + var jsonPartialUpdateInfo = new JsonPartialUpdateInfo(); + jsonPartialUpdateInfo.Path.Insert(0, new JsonPartialUpdatePathEntry("$", null, entry, Navigation: null, ComplexProperty: complexProperty)); + jsonPartialUpdateInfo.PropertyValue = entry.GetCurrentValue(complexProperty); + jsonColumnsUpdateMap[jsonColumn] = jsonPartialUpdateInfo; + } + } } foreach (var (jsonColumn, updateInfo) in jsonColumnsUpdateMap) { var finalUpdatePathElement = updateInfo.Path.Last(); var navigation = finalUpdatePathElement.Navigation; + var complexProperty = finalUpdatePathElement.ComplexProperty; var jsonColumnTypeMapping = jsonColumn.StoreTypeMapping; - var navigationValue = finalUpdatePathElement.ParentEntry.GetCurrentValue(navigation); + var jsonContainerProperty = (IPropertyBase?)navigation ?? complexProperty; + var navigationValue = finalUpdatePathElement.ParentEntry.GetCurrentValue(jsonContainerProperty!); + var jsonPathString = string.Join( ".", updateInfo.Path.Select(x => x.PropertyName + (x.Ordinal != null ? "[" + x.Ordinal + "]" : ""))); if (updateInfo.Property is IProperty property) @@ -755,8 +785,8 @@ void HandleJson(List columnModifications) WriteJson( writer, navigationValueElement, - finalUpdatePathElement.ParentEntry, - navigation.TargetEntityType, + (IInternalEntry)finalUpdatePathElement.ParentEntry, + ((IPropertyBase?)navigation) ?? complexProperty!, ordinal: null, isCollection: false, isTopLevel: true); @@ -769,13 +799,14 @@ void HandleJson(List columnModifications) } else { + var propertyBase = ((IPropertyBase?)navigation) ?? complexProperty!; WriteJson( writer, navigationValue, - finalUpdatePathElement.ParentEntry, - navigation.TargetEntityType, + (IInternalEntry)finalUpdatePathElement.ParentEntry, + ((IPropertyBase?)navigation) ?? complexProperty!, ordinal: null, - isCollection: navigation.IsCollection, + isCollection: propertyBase.IsCollection, isTopLevel: true); } @@ -840,16 +871,20 @@ protected virtual void ProcessSinglePropertyJsonUpdate(ref ColumnModificationPar } } +#pragma warning disable EF1001 // Internal EF Core API usage. private void WriteJson( Utf8JsonWriter writer, - object? navigationValue, - IUpdateEntry parentEntry, - IEntityType entityType, + object? value, + IInternalEntry parentEntry, + IPropertyBase property, int? ordinal, bool isCollection, bool isTopLevel) { - if (navigationValue == null) + var structuralType = property is INavigation navigation + ? (ITypeBase)navigation.TargetEntityType + : ((IComplexProperty)property).ComplexType; + if (value is null) { if (!isTopLevel) { @@ -861,15 +896,15 @@ private void WriteJson( if (isCollection) { - var i = 1; + var i = 0; writer.WriteStartArray(); - foreach (var collectionElement in (IEnumerable)navigationValue) + foreach (var collectionElement in (IEnumerable)value) { WriteJson( writer, collectionElement, parentEntry, - entityType, + property, i++, isCollection: false, isTopLevel: false); @@ -879,18 +914,27 @@ private void WriteJson( return; } -#pragma warning disable EF1001 // Internal EF Core API usage. - var entry = (IUpdateEntry)((InternalEntityEntry)parentEntry).StateManager.TryGetEntry(navigationValue, entityType)!; -#pragma warning restore EF1001 // Internal EF Core API usage. - writer.WriteStartObject(); - foreach (var property in entityType.GetFlattenedProperties()) + + var entry = structuralType is IComplexType complexType + ? complexType.ComplexProperty.IsCollection + ? parentEntry.GetComplexCollectionEntry(complexType.ComplexProperty, ordinal!.Value) + : parentEntry + : ((InternalEntityEntry)parentEntry).StateManager.TryGetEntry(value, (IEntityType)structuralType)!; + WriteJsonObject(writer, parentEntry, entry, structuralType, ordinal); + + writer.WriteEndObject(); + } + + private void WriteJsonObject(Utf8JsonWriter writer, IInternalEntry parentEntry, IInternalEntry entry, ITypeBase structuralType, int? ordinal) + { + foreach (var property in structuralType.GetProperties()) { if (property.IsKey()) { if (property.IsOrdinalKeyProperty() && ordinal != null) { - entry.SetStoreGeneratedValue(property, ordinal.Value, setModified: false); + entry.SetStoreGeneratedValue(property, ordinal.Value + 1, setModified: false); } continue; @@ -898,14 +942,14 @@ private void WriteJson( // jsonPropertyName can only be null for key properties var jsonPropertyName = property.GetJsonPropertyName()!; - var value = entry.GetCurrentValue(property); + var propertyValue = entry.GetCurrentValue(property); writer.WritePropertyName(jsonPropertyName); - if (value is not null) + if (propertyValue is not null) { var jsonValueReaderWriter = property.GetJsonValueReaderWriter() ?? property.GetTypeMapping().JsonValueReaderWriter; Check.DebugAssert(jsonValueReaderWriter is not null, "Missing JsonValueReaderWriter on JSON property"); - jsonValueReaderWriter.ToJson(writer, value); + jsonValueReaderWriter.ToJson(writer, propertyValue); } else { @@ -913,29 +957,46 @@ private void WriteJson( } } - foreach (var navigation in entityType.GetNavigations()) + foreach (var complexProperty in structuralType.GetComplexProperties()) { - // skip back-references to the parent - if (navigation.IsOnDependent) - { - continue; - } - - var jsonPropertyName = navigation.TargetEntityType.GetJsonPropertyName()!; - var ownedNavigationValue = entry.GetCurrentValue(navigation)!; - + var jsonPropertyName = complexProperty.GetJsonPropertyName()!; + var complexPropertyValue = entry.GetCurrentValue(complexProperty); writer.WritePropertyName(jsonPropertyName); + WriteJson( writer, - ownedNavigationValue, + complexPropertyValue, entry, - navigation.TargetEntityType, + complexProperty, ordinal: null, - isCollection: navigation.IsCollection, + isCollection: complexProperty.IsCollection, isTopLevel: false); } - writer.WriteEndObject(); + if (structuralType is IEntityType entityType) + { + foreach (var navigation in entityType.GetNavigations()) + { + // skip back-references to the parent + if (navigation.IsOnDependent) + { + continue; + } + + var jsonPropertyName = navigation.TargetEntityType.GetJsonPropertyName()!; + var ownedNavigationValue = entry.GetCurrentValue(navigation)!; + + writer.WritePropertyName(jsonPropertyName); + WriteJson( + writer, + ownedNavigationValue, + entry, + navigation, + ordinal: null, + isCollection: navigation.IsCollection, + isTopLevel: false); + } + } } private ITableMapping? GetTableMapping(ITypeBase structuralType) diff --git a/test/EFCore.Relational.Specification.Tests/Update/ComplexCollectionJsonUpdateTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/ComplexCollectionJsonUpdateTestBase.cs new file mode 100644 index 00000000000..af400117a87 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Update/ComplexCollectionJsonUpdateTestBase.cs @@ -0,0 +1,655 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Update; + +public abstract class ComplexCollectionJsonUpdateTestBase(TFixture fixture) : IClassFixture + where TFixture : ComplexCollectionJsonUpdateTestBase.ComplexCollectionJsonUpdateFixtureBase, new() +{ + public TFixture Fixture { get; } = fixture; + + protected ComplexCollectionJsonContext CreateContext() + => (ComplexCollectionJsonContext)Fixture.CreateContext(); + + [ConditionalFact] + public virtual Task Add_element_to_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts!.Add(new Contact { Name = "New Contact", PhoneNumbers = ["555-0000"] }); + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal(3, company.Contacts!.Count); + Assert.Equal("New Contact", company.Contacts[2].Name); + Assert.Single(company.Contacts[2].PhoneNumbers); + Assert.Equal("555-0000", company.Contacts[2].PhoneNumbers[0]); + }); + + [ConditionalFact] + public virtual Task Remove_element_from_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts!.RemoveAt(0); + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Single(company.Contacts!); + Assert.Equal("Second Contact", company.Contacts![0].Name); + }); + + [ConditionalFact] + public virtual Task Modify_element_in_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts![0].Name = "First Contact - Modified"; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal("First Contact - Modified", company.Contacts![0].Name); + }); + + [ConditionalFact] + public virtual Task Move_elements_in_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + var temp = company.Contacts![0]; + company.Contacts[0] = company.Contacts[1]; + company.Contacts[1] = temp; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal("Second Contact", company.Contacts![0].Name); + Assert.Equal("First Contact", company.Contacts[1].Name); + }); + + [ConditionalFact] + public virtual Task Change_empty_complex_collection_to_null_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts!.Clear(); + await context.SaveChangesAsync(); + + company.Contacts = null; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Null(company.Contacts); + }); + + [ConditionalFact] + public virtual Task Change_null_complex_collection_to_empty_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts = null; + await context.SaveChangesAsync(); + + company.Contacts = []; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.NotNull(company.Contacts); + Assert.Empty(company.Contacts); + }); + + [ConditionalFact] + public virtual Task Complex_collection_with_nested_complex_type_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees = + [ + new Employee + { + Name = "John Doe", + PhoneNumbers = ["555-1234", "555-5678"], + Address = new Address + { + Street = "123 Main St", + City = "Seattle", + PostalCode = "98101", + Country = "USA" + } + }, + new Employee + { + Name = "Jane Smith", + PhoneNumbers = ["555-9876"], + Address = new Address + { + Street = "456 Oak Ave", + City = "Portland", + PostalCode = "97201", + Country = "USA" + } + } + ]; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal(2, company.Employees!.Count); + + var john = company.Employees[0]; + Assert.Equal("John Doe", john.Name); + Assert.Equal("123 Main St", john.Address.Street); + Assert.Equal("Seattle", john.Address.City); + + var jane = company.Employees[1]; + Assert.Equal("Jane Smith", jane.Name); + Assert.Equal("456 Oak Ave", jane.Address.Street); + Assert.Equal("Portland", jane.Address.City); + }); + + [ConditionalFact] + public virtual Task Modify_multiple_complex_properties_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts = [new Contact { Name = "Contact 1", PhoneNumbers = ["555-1111"] }]; + company.Department = new Department { Name = "Department A", Budget = 50000.00m }; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Single(company.Contacts!); + Assert.Equal("Contact 1", company.Contacts![0].Name); + + Assert.NotNull(company.Department); + Assert.Equal("Department A", company.Department.Name); + Assert.Equal(50000.00m, company.Department.Budget); + }); + + [ConditionalFact] + public virtual Task Clear_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts!.Clear(); + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Empty(company.Contacts!); + }); + + [ConditionalFact] + public virtual Task Replace_entire_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Contacts = + [ + new Contact { Name = "Replacement Contact 1", PhoneNumbers = ["999-1111"] }, + new Contact { Name = "Replacement Contact 2", PhoneNumbers = ["999-2222", "999-3333"] } + ]; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal(2, company.Contacts!.Count); + Assert.Equal("Replacement Contact 1", company.Contacts[0].Name); + Assert.Equal("Replacement Contact 2", company.Contacts[1].Name); + }); + + [ConditionalFact] + public virtual Task Add_element_to_nested_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees![0].PhoneNumbers.Add("555-9999"); + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + var employee = company.Employees![0]; + Assert.Equal(2, employee.PhoneNumbers.Count); + Assert.Equal("555-0001", employee.PhoneNumbers[0]); + Assert.Equal("555-9999", employee.PhoneNumbers[1]); + }); + + [ConditionalFact] + public virtual Task Modify_nested_complex_property_in_complex_collection_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees![0].Address.City = "Modified City"; + company.Employees[0].Address.PostalCode = "99999"; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + var employee = company.Employees![0]; + Assert.Equal("Modified City", employee.Address.City); + Assert.Equal("99999", employee.Address.PostalCode); + Assert.Equal("100 First St", employee.Address.Street); // Unchanged + Assert.Equal("USA", employee.Address.Country); // Unchanged + }); + + [ConditionalFact] + public virtual Task Set_complex_collection_to_null_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees = null; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Null(company.Employees); + }); + + [ConditionalFact] + public virtual Task Set_null_complex_collection_to_non_empty_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees = null; + await context.SaveChangesAsync(); + + company.Employees = + [ + new Employee + { + Name = "New Employee", + PhoneNumbers = ["555-1111"], + Address = new Address + { + Street = "123 New St", + City = "New City", + PostalCode = "12345", + Country = "USA" + } + } + ]; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.NotNull(company.Employees); + Assert.Single(company.Employees); + Assert.Equal("New Employee", company.Employees[0].Name); + }); + + [ConditionalFact] + public virtual Task Replace_complex_collection_element_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees![0] = new Employee + { + Name = "Replacement Employee", + PhoneNumbers = ["555-7777", "555-8888"], + Address = new Address + { + Street = "789 Replace St", + City = "Replace City", + PostalCode = "54321", + Country = "Canada" + } + }; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + var employee = company.Employees![0]; + Assert.Equal("Replacement Employee", employee.Name); + Assert.Equal(2, employee.PhoneNumbers.Count); + Assert.Equal("555-7777", employee.PhoneNumbers[0]); + Assert.Equal("789 Replace St", employee.Address.Street); + Assert.Equal("Canada", employee.Address.Country); + }); + + [ConditionalFact] + public virtual Task Complex_collection_with_empty_nested_collections_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Employees!.Add(new Employee + { + Name = "Employee No Phone", + PhoneNumbers = [], // Empty collection + Address = new Address + { + Street = "456 No Phone St", + City = "Quiet City", + PostalCode = "00000", + Country = "USA" + } + }); + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Equal(2, company.Employees!.Count); + var employeeWithoutPhone = company.Employees[1]; + Assert.Equal("Employee No Phone", employeeWithoutPhone.Name); + Assert.Empty(employeeWithoutPhone.PhoneNumbers); + Assert.Equal("Quiet City", employeeWithoutPhone.Address.City); + }); + + [ConditionalFact] + public virtual Task Modify_complex_property_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Department!.Name = "Modified Department"; + company.Department.Budget = 75000.00m; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.NotNull(company.Department); + Assert.Equal("Modified Department", company.Department.Name); + Assert.Equal(75000.00m, company.Department.Budget); + }); + + [ConditionalFact] + public virtual Task Set_complex_property_mapped_to_json_to_null() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Department = null; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.Null(company.Department); + }); + + [ConditionalFact] + public virtual Task Set_null_complex_property_to_non_null_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Department = null; + await context.SaveChangesAsync(); + + company.Department = new Department { Name = "New Department", Budget = 25000.00m }; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.NotNull(company.Department); + Assert.Equal("New Department", company.Department.Name); + Assert.Equal(25000.00m, company.Department.Budget); + }); + + [ConditionalFact] + public virtual Task Replace_complex_property_mapped_to_json() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + company.Department = new Department { Name = "Replacement Department", Budget = 99999.99m }; + + ClearLog(); + await context.SaveChangesAsync(); + }, + async context => + { + var company = await context.Companies.OrderBy(c => c.Id).FirstAsync(); + Assert.NotNull(company.Department); + Assert.Equal("Replacement Department", company.Department.Name); + Assert.Equal(99999.99m, company.Department.Budget); + }); + + protected virtual void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction) + { + } + + protected virtual void ClearLog() + { + } + + protected class ComplexCollectionJsonContext(DbContextOptions options) : DbContext(options) + { + public DbSet Companies { get; set; } = null!; + } + + protected class CompanyWithComplexCollections + { + public int Id { get; set; } + public required string Name { get; set; } + public List? Contacts { get; set; } + public List? Employees { get; set; } + public Department? Department { get; set; } + } + + protected class Contact + { + public required string Name { get; set; } + public List PhoneNumbers { get; set; } = []; + } + + protected class Employee + { + public required string Name { get; set; } + public List PhoneNumbers { get; set; } = []; + public required Address Address { get; set; } + } + + protected class Address + { + public required string Street { get; set; } + public required string City { get; set; } + public required string PostalCode { get; set; } + public required string Country { get; set; } + } + + protected class Department + { + public required string Name { get; set; } + public decimal Budget { get; set; } + } + + public abstract class ComplexCollectionJsonUpdateFixtureBase : SharedStoreFixtureBase + { + protected override string StoreName + => "ComplexCollectionJsonUpdateTest"; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + protected override Type ContextType => typeof(ComplexCollectionJsonContext); + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + modelBuilder.Entity(b => + { + b.Property(x => x.Id).ValueGeneratedNever(); + + b.ComplexCollection(x => x.Contacts, cb => + { + cb.ToJson(); + cb.PrimitiveCollection(c => c.PhoneNumbers); + }); + + b.ComplexCollection(x => x.Employees, cb => + { + cb.ToJson(); + cb.PrimitiveCollection(e => e.PhoneNumbers); + cb.ComplexProperty(e => e.Address); + }); + + b.ComplexProperty(x => x.Department, cb => + { + cb.ToJson(); + }); + }); + } + + protected override Task SeedAsync(DbContext context) + { + var company = new CompanyWithComplexCollections + { + Id = 1, + Name = "Test Company", + Contacts = + [ + new Contact + { + Name = "First Contact", + PhoneNumbers = ["555-1234", "555-5678"] + }, + new Contact + { + Name = "Second Contact", + PhoneNumbers = ["555-9876", "555-5432"] + } + ], + Employees = + [ + new Employee + { + Name = "Initial Employee", + PhoneNumbers = ["555-0001"], + Address = new Address + { + Street = "100 First St", + City = "Initial City", + PostalCode = "00001", + Country = "USA" + } + } + ], + Department = new Department + { + Name = "Initial Department", + Budget = 10000.00m + } + }; + + context.Add(company); + return context.SaveChangesAsync(); + } + } + +} diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerComplianceTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerComplianceTest.cs index ae60f5f00bf..c0758fde4f3 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerComplianceTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerComplianceTest.cs @@ -1,11 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. + namespace Microsoft.EntityFrameworkCore; #nullable disable public class SqlServerComplianceTest : RelationalComplianceTestBase { + protected override ICollection IgnoredTestBases => new HashSet + { + typeof(ComplexCollectionJsonUpdateTestBase<>) // issue #31252 + }; + protected override Assembly TargetAssembly { get; } = typeof(SqlServerComplianceTest).Assembly; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/ComplexCollectionJsonUpdateSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/ComplexCollectionJsonUpdateSqlServerTest.cs new file mode 100644 index 00000000000..6ca6fd33154 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/ComplexCollectionJsonUpdateSqlServerTest.cs @@ -0,0 +1,198 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Update; + +// TODO: Requires query support for complex collections mapped to JSON, issue #31252 +public abstract class ComplexCollectionJsonUpdateSqlServerTest : ComplexCollectionJsonUpdateTestBase +{ + public ComplexCollectionJsonUpdateSqlServerTest(ComplexCollectionJsonUpdateSqlServerFixture fixture) + : base(fixture) + => ClearLog(); + + public override async Task Add_element_to_complex_collection_mapped_to_json() + { + await base.Add_element_to_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"First Contact","PhoneNumbers":["555-1234","555-5678"]},{"Name":"Second Contact","PhoneNumbers":["555-9876","555-5432"]},{"Name":"New Contact","PhoneNumbers":["555-0000"]}]' (Nullable = false) (Size = 200) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Remove_element_from_complex_collection_mapped_to_json() + { + await base.Remove_element_from_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"Second Contact","PhoneNumbers":["555-9876","555-5432"]}]' (Nullable = false) (Size = 65) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Modify_element_in_complex_collection_mapped_to_json() + { + await base.Modify_element_in_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"First Contact - Modified","PhoneNumbers":["555-1234","555-5678"]},{"Name":"Second Contact","PhoneNumbers":["555-9876","555-5432"]}]' (Nullable = false) (Size = 141) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Move_elements_in_complex_collection_mapped_to_json() + { + await base.Move_elements_in_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"Second Contact","PhoneNumbers":["555-9876","555-5432"]},{"Name":"First Contact","PhoneNumbers":["555-1234","555-5678"]}]' (Nullable = false) (Size = 141) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Change_empty_complex_collection_to_null_mapped_to_json() + { + await base.Change_empty_complex_collection_to_null_mapped_to_json(); + + AssertSql( + """ +@p0=NULL (Size = 4000) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Change_null_complex_collection_to_empty_mapped_to_json() + { + await base.Change_null_complex_collection_to_empty_mapped_to_json(); + + AssertSql( + """ +@p0='[]' (Nullable = false) (Size = 2) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Complex_collection_with_nested_complex_type_mapped_to_json() + { + await base.Complex_collection_with_nested_complex_type_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Address":{"City":"Seattle","Country":"USA","PostalCode":"98101","Street":"123 Main St"},"Name":"John Doe","PhoneNumbers":["555-1234","555-5678"]},{"Address":{"City":"Portland","Country":"USA","PostalCode":"97201","Street":"456 Oak Ave"},"Name":"Jane Smith","PhoneNumbers":["555-9876"]}]' (Nullable = false) (Size = 320) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Employees] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Modify_multiple_complex_properties_mapped_to_json() + { + await base.Modify_multiple_complex_properties_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"Contact 1","PhoneNumbers":["555-1111"]}]' (Nullable = false) (Size = 51) +@p1='[{"Name":"Department A","Budget":50000.00}]' (Nullable = false) (Size = 44) +@p2='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0, [Departments] = @p1 +OUTPUT 1 +WHERE [Id] = @p2; +"""); + } + + public override async Task Clear_complex_collection_mapped_to_json() + { + await base.Clear_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[]' (Nullable = false) (Size = 2) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + public override async Task Replace_entire_complex_collection_mapped_to_json() + { + await base.Replace_entire_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"Replacement Contact 1","PhoneNumbers":["999-1111"]},{"Name":"Replacement Contact 2","PhoneNumbers":["999-2222","999-3333"]}]' (Nullable = false) (Size = 144) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [CompanyWithComplexCollections] SET [Contacts] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +"""); + } + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected override void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + public class ComplexCollectionJsonUpdateSqlServerFixture : ComplexCollectionJsonUpdateFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base.AddOptions(builder).ConfigureWarnings(e => e.Log(SqlServerEventId.JsonTypeExperimental)); + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/SqliteComplianceTest.cs b/test/EFCore.Sqlite.FunctionalTests/SqliteComplianceTest.cs index 7d70113488b..5a16eb2a04f 100644 --- a/test/EFCore.Sqlite.FunctionalTests/SqliteComplianceTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/SqliteComplianceTest.cs @@ -24,7 +24,8 @@ public class SqliteComplianceTest : RelationalComplianceTestBase typeof(OwnedNavigationsProjectionTestBase<>), typeof(OwnedNavigationsProjectionRelationalTestBase<>), typeof(JsonOwnedNavigationsProjectionRelationalTestBase<>), - typeof(OwnedTableSplittingProjectionRelationalTestBase<>) + typeof(OwnedTableSplittingProjectionRelationalTestBase<>), + typeof(ComplexCollectionJsonUpdateTestBase<>) // issue #31252 }; protected override Assembly TargetAssembly { get; } = typeof(SqliteComplianceTest).Assembly; diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/ComplexCollectionJsonUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/ComplexCollectionJsonUpdateSqliteTest.cs new file mode 100644 index 00000000000..6a43bb8d4a9 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Update/ComplexCollectionJsonUpdateSqliteTest.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Update; + +// TODO: Requires query support for complex collections mapped to JSON, issue #31252 +public abstract class ComplexCollectionJsonUpdateSqliteTest : ComplexCollectionJsonUpdateTestBase +{ + public ComplexCollectionJsonUpdateSqliteTest(ComplexCollectionJsonUpdateSqliteFixture fixture) + : base(fixture) + => ClearLog(); + + public override async Task Add_element_to_complex_collection_mapped_to_json() + { + await base.Add_element_to_complex_collection_mapped_to_json(); + + AssertSql( + """ +@p0='[{"Name":"First Contact","PhoneNumbers":["555-1234","555-5678"]},{"Name":"Second Contact","PhoneNumbers":["555-9876","555-5432"]},{"Name":"New Contact","PhoneNumbers":["555-0000"]}]' (Size = 200) +@p1='1' (DbType = String) + +UPDATE "CompanyWithComplexCollections" SET "Contacts" = @p0 +WHERE "Id" = @p1 +RETURNING 1; +"""); + } + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected override void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + public class ComplexCollectionJsonUpdateSqliteFixture : ComplexCollectionJsonUpdateFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + } +}