From a27abea4ca56b069cab9a7cc1e789eccc6936416 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 10 Jun 2026 14:23:05 -0700 Subject: [PATCH 1/4] Add validation for Index included complex collection properties --- .../Internal/SqlServerModelValidator.cs | 40 +++++++++++++++++ .../Properties/SqlServerStrings.Designer.cs | 8 ++++ .../Properties/SqlServerStrings.resx | 3 ++ .../Migrations/MigrationsSqlServerTest.cs | 43 +++++++++++++++++++ .../SqlServerModelValidatorTest.cs | 28 ++++++++++++ 5 files changed, 122 insertions(+) diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index c97207039d2..ce155304905 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -259,6 +259,20 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) index.DeclaringEntityType.DisplayName())); } + foreach (var includeProperty in includeProperties) + { + var traversedComplexCollection = FindTraversedComplexCollection(index.DeclaringEntityType, includeProperty); + if (traversedComplexCollection != null) + { + throw new InvalidOperationException( + SqlServerStrings.IncludePropertyTraversesComplexCollection( + includeProperty, + index.DisplayName(), + index.DeclaringEntityType.DisplayName(), + traversedComplexCollection)); + } + } + var duplicateProperty = includeProperties .GroupBy(i => i) .Where(g => g.Count() > 1) @@ -286,6 +300,32 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) index.DisplayName())); } } + + // Returns the name of the first complex collection traversed by the given dotted include-property path, or + // if the path does not traverse any complex collection. Properties nested inside a complex + // collection live in a JSON document and so cannot be included in a covering index. + static string? FindTraversedComplexCollection(IReadOnlyEntityType entityType, string propertyPath) + { + var segments = propertyPath.Split('.'); + IReadOnlyTypeBase currentType = entityType; + for (var i = 0; i < segments.Length - 1; i++) + { + var complexProperty = currentType.FindComplexProperty(segments[i]); + if (complexProperty == null) + { + return null; + } + + if (complexProperty.IsCollection) + { + return complexProperty.Name; + } + + currentType = complexProperty.ComplexType; + } + + return null; + } } /// diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index 83ab414129e..d3509ba5346 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -237,6 +237,14 @@ public static string IncludePropertyNotFound(object? property, object? index, ob GetString("IncludePropertyNotFound", nameof(property), nameof(index), nameof(entityType)), property, index, entityType); + /// + /// The include property '{property}' specified on the index {index} on entity type '{entityType}' traverses the complex collection '{complexCollection}'. Properties nested inside a complex collection are stored in a JSON document and cannot be included in an index. + /// + public static string IncludePropertyTraversesComplexCollection(object? property, object? index, object? entityType, object? complexCollection) + => string.Format( + GetString("IncludePropertyTraversesComplexCollection", nameof(property), nameof(index), nameof(entityType), nameof(complexCollection)), + property, index, entityType, complexCollection); + /// /// Cannot use table '{table}' for entity type '{entityType}' since it is being used for entity type '{otherEntityType}' and entity type '{entityTypeWithSqlOutputClause}' is configured to use the SQL OUTPUT clause, but entity type '{entityTypeWithoutSqlOutputClause}' is not. /// diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index 264d3c6af9e..c25de4f7172 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -201,6 +201,9 @@ The include property '{property}' specified on the index {index} was not found on entity type '{entityType}'. + + The include property '{property}' specified on the index {index} on entity type '{entityType}' traverses the complex collection '{complexCollection}'. Properties nested inside a complex collection are stored in a JSON document and cannot be included in an index. + Cannot use table '{table}' for entity type '{entityType}' since it is being used for entity type '{otherEntityType}' and entity type '{entityTypeWithSqlOutputClause}' is configured to use the SQL OUTPUT clause, but entity type '{entityTypeWithoutSqlOutputClause}' is not. diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index f7023e774b2..7920f8f4502 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -2357,6 +2357,49 @@ FROM [sys].[columns] [c] """); } + [Fact] + public virtual async Task Create_index_with_include_on_complex_property() + { + await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.Property("Name"); + e.ComplexProperty( + "Details", cb => + { + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("People").HasIndex("Name") + .IncludeProperties("Details.Value"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal(1, index.Columns.Count); + Assert.Contains(table.Columns.Single(c => c.Name == "Name"), index.Columns); + }); + + AssertSql( + """ +DECLARE @var nvarchar(max); +SELECT @var = QUOTENAME(OBJECT_NAME([c].[default_object_id])) +FROM [sys].[columns] [c] +WHERE [c].[object_id] = OBJECT_ID(N'[People]') AND [c].[name] = N'Name'; +IF @var IS NOT NULL EXEC(N'ALTER TABLE [People] DROP CONSTRAINT ' + @var + ';'); +ALTER TABLE [People] ALTER COLUMN [Name] nvarchar(450) NULL; +""", + // + """ +CREATE INDEX [IX_People_Name] ON [People] ([Name]) INCLUDE ([Details_Value]); +"""); + } + [Fact] public virtual async Task Create_index_with_include_and_filter() { diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index 29e7aabbabc..6a00f0e5d06 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -602,12 +602,40 @@ public void IncludeProperties_dotted_path_not_found_throws() modelBuilder); } + [Fact] + public void IncludeProperties_traversing_complex_collection_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.Addresses, cb => + { + cb.ToJson(); + cb.Property(a => a.City).IsRequired(); + cb.Property(a => a.Street).IsRequired(); + }); + b.HasIndex(e => e.Id).IncludeProperties("Addresses.City"); + }); + + VerifyError( + SqlServerStrings.IncludePropertyTraversesComplexCollection( + "Addresses.City", "{'Id'}", nameof(EntityWithIncludedComplexCollection), "Addresses"), + modelBuilder); + } + protected class EntityWithIncludedComplex { public int Id { get; set; } public required EntityWithIncludedComplexAddress Address { get; set; } } + protected class EntityWithIncludedComplexCollection + { + public int Id { get; set; } + public required List Addresses { get; set; } + } + protected class EntityWithIncludedComplexAddress { public required string City { get; set; } From 6793b6d1aab49e8bc18e8acc8ca2e3f84b127453 Mon Sep 17 00:00:00 2001 From: Andriy Svyryd Date: Wed, 10 Jun 2026 14:36:15 -0700 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Infrastructure/Internal/SqlServerModelValidator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index ce155304905..8cdcc631e8b 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -301,9 +301,9 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) } } - // Returns the name of the first complex collection traversed by the given dotted include-property path, or - // if the path does not traverse any complex collection. Properties nested inside a complex - // collection live in a JSON document and so cannot be included in a covering index. + // Returns the name of the first complex collection traversed by the given dotted include-property path, or null if + // the path does not traverse any complex collection. Properties nested inside a complex collection live in a JSON + // document and so cannot be included in a covering index. static string? FindTraversedComplexCollection(IReadOnlyEntityType entityType, string propertyPath) { var segments = propertyPath.Split('.'); From 781fe49a6436ae23e08c84ea573eedf03175bcfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:53:06 +0000 Subject: [PATCH 3/4] Change include property validation to check if property is inside a JSON-mapped type Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/SqlServerModelValidator.cs | 39 ++-------- .../Internal/SqlServerAnnotationProvider.cs | 10 ++- .../Properties/SqlServerStrings.Designer.cs | 8 +- .../Properties/SqlServerStrings.resx | 4 +- .../SqlServerModelValidatorTest.cs | 76 ++++++++++++++++++- 5 files changed, 94 insertions(+), 43 deletions(-) diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index 8cdcc631e8b..d8e9e60638f 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -247,7 +247,7 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) { #pragma warning disable EF1001 // Internal EF Core API usage. var notFound = includeProperties - .FirstOrDefault(i => RelationalModel.FindPropertyByPath(index.DeclaringEntityType, i) == null); + .FirstOrDefault(i => RelationalModel.FindPropertyBaseByPath(index.DeclaringEntityType, i) == null); #pragma warning restore EF1001 // Internal EF Core API usage. if (notFound != null) @@ -261,15 +261,16 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) foreach (var includeProperty in includeProperties) { - var traversedComplexCollection = FindTraversedComplexCollection(index.DeclaringEntityType, includeProperty); - if (traversedComplexCollection != null) +#pragma warning disable EF1001 // Internal EF Core API usage. + var propertyBase = RelationalModel.FindPropertyBaseByPath(index.DeclaringEntityType, includeProperty)!; +#pragma warning restore EF1001 // Internal EF Core API usage. + if (propertyBase.DeclaringType.IsMappedToJson()) { throw new InvalidOperationException( - SqlServerStrings.IncludePropertyTraversesComplexCollection( + SqlServerStrings.IncludePropertyInJsonMappedType( includeProperty, index.DisplayName(), - index.DeclaringEntityType.DisplayName(), - traversedComplexCollection)); + index.DeclaringEntityType.DisplayName())); } } @@ -300,32 +301,6 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) index.DisplayName())); } } - - // Returns the name of the first complex collection traversed by the given dotted include-property path, or null if - // the path does not traverse any complex collection. Properties nested inside a complex collection live in a JSON - // document and so cannot be included in a covering index. - static string? FindTraversedComplexCollection(IReadOnlyEntityType entityType, string propertyPath) - { - var segments = propertyPath.Split('.'); - IReadOnlyTypeBase currentType = entityType; - for (var i = 0; i < segments.Length - 1; i++) - { - var complexProperty = currentType.FindComplexProperty(segments[i]); - if (complexProperty == null) - { - return null; - } - - if (complexProperty.IsCollection) - { - return complexProperty.Name; - } - - currentType = complexProperty.ComplexType; - } - - return null; - } } /// diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index c9e49a76931..88e034dc2e7 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -269,9 +269,15 @@ public override IEnumerable For(ITableIndex index, bool designTime) if (modelIndex.GetIncludeProperties(table) is { } includeProperties) { #pragma warning disable EF1001 // Internal EF Core API usage. + var storeObjectIdentifier = StoreObjectIdentifier.Table(table.Name, table.Schema); var includeColumns = includeProperties - .Select(p => RelationalModel.FindPropertyByPath(modelIndex.DeclaringEntityType, p)! - .GetColumnName(StoreObjectIdentifier.Table(table.Name, table.Schema))) + .Select(p => + { + var propertyBase = RelationalModel.FindPropertyBaseByPath(modelIndex.DeclaringEntityType, p)!; + return propertyBase is IReadOnlyProperty property + ? property.GetColumnName(storeObjectIdentifier) + : ((IReadOnlyComplexProperty)propertyBase).ComplexType.GetContainerColumnName(); + }) .ToArray(); #pragma warning restore EF1001 // Internal EF Core API usage. diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index d3509ba5346..fbcb9770d09 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -238,12 +238,12 @@ public static string IncludePropertyNotFound(object? property, object? index, ob property, index, entityType); /// - /// The include property '{property}' specified on the index {index} on entity type '{entityType}' traverses the complex collection '{complexCollection}'. Properties nested inside a complex collection are stored in a JSON document and cannot be included in an index. + /// The include property '{property}' specified on the index {index} on entity type '{entityType}' is contained within a JSON-mapped type. Properties contained within JSON-mapped types cannot be included in an index. /// - public static string IncludePropertyTraversesComplexCollection(object? property, object? index, object? entityType, object? complexCollection) + public static string IncludePropertyInJsonMappedType(object? property, object? index, object? entityType) => string.Format( - GetString("IncludePropertyTraversesComplexCollection", nameof(property), nameof(index), nameof(entityType), nameof(complexCollection)), - property, index, entityType, complexCollection); + GetString("IncludePropertyInJsonMappedType", nameof(property), nameof(index), nameof(entityType)), + property, index, entityType); /// /// Cannot use table '{table}' for entity type '{entityType}' since it is being used for entity type '{otherEntityType}' and entity type '{entityTypeWithSqlOutputClause}' is configured to use the SQL OUTPUT clause, but entity type '{entityTypeWithoutSqlOutputClause}' is not. diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index c25de4f7172..69f87c83d93 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -201,8 +201,8 @@ The include property '{property}' specified on the index {index} was not found on entity type '{entityType}'. - - The include property '{property}' specified on the index {index} on entity type '{entityType}' traverses the complex collection '{complexCollection}'. Properties nested inside a complex collection are stored in a JSON document and cannot be included in an index. + + The include property '{property}' specified on the index {index} on entity type '{entityType}' is contained within a JSON-mapped type. Properties contained within JSON-mapped types cannot be included in an index. Cannot use table '{table}' for entity type '{entityType}' since it is being used for entity type '{otherEntityType}' and entity type '{entityTypeWithSqlOutputClause}' is configured to use the SQL OUTPUT clause, but entity type '{entityTypeWithoutSqlOutputClause}' is not. diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index 6a00f0e5d06..e8f0c58aac5 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -603,7 +603,7 @@ public void IncludeProperties_dotted_path_not_found_throws() } [Fact] - public void IncludeProperties_traversing_complex_collection_throws() + public void IncludeProperties_inside_json_complex_collection_throws() { var modelBuilder = CreateConventionModelBuilder(); modelBuilder.Entity(b => @@ -619,11 +619,81 @@ public void IncludeProperties_traversing_complex_collection_throws() }); VerifyError( - SqlServerStrings.IncludePropertyTraversesComplexCollection( - "Addresses.City", "{'Id'}", nameof(EntityWithIncludedComplexCollection), "Addresses"), + SqlServerStrings.IncludePropertyInJsonMappedType( + "Addresses.City", "{'Id'}", nameof(EntityWithIncludedComplexCollection)), modelBuilder); } + [Fact] + public void IncludeProperties_on_json_complex_collection_is_valid() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.Addresses, cb => + { + cb.ToJson(); + cb.Property(a => a.City).IsRequired(); + cb.Property(a => a.Street).IsRequired(); + }); + b.HasIndex(e => e.Id).IncludeProperties("Addresses"); + }); + + var model = Validate(modelBuilder); + var index = model.FindEntityType(typeof(EntityWithIncludedComplexCollection))!.GetIndexes().Single(); + Assert.Equal(["Addresses"], index.GetIncludeProperties()); + } + + [Fact] + public void IncludeProperties_inside_json_complex_property_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.Address, cb => + { + cb.ToJson(); + cb.Property(a => a.City).IsRequired(); + cb.Property(a => a.Street).IsRequired(); + }); + b.HasIndex(e => e.Id).IncludeProperties("Address.City"); + }); + + VerifyError( + SqlServerStrings.IncludePropertyInJsonMappedType( + "Address.City", "{'Id'}", nameof(EntityWithIncludedComplexJson)), + modelBuilder); + } + + [Fact] + public void IncludeProperties_on_json_complex_property_is_valid() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.Address, cb => + { + cb.ToJson(); + cb.Property(a => a.City).IsRequired(); + cb.Property(a => a.Street).IsRequired(); + }); + b.HasIndex(e => e.Id).IncludeProperties("Address"); + }); + + var model = Validate(modelBuilder); + var index = model.FindEntityType(typeof(EntityWithIncludedComplexJson))!.GetIndexes().Single(); + Assert.Equal(["Address"], index.GetIncludeProperties()); + } + + protected class EntityWithIncludedComplexJson + { + public int Id { get; set; } + public required EntityWithIncludedComplexAddress Address { get; set; } + } + protected class EntityWithIncludedComplex { public int Id { get; set; } From a2f561d97c230071c937529594f94bd9e8f6b790 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:28:47 +0000 Subject: [PATCH 4/4] Validate non-JSON complex include properties Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/SqlServerModelValidator.cs | 8 +++++++- .../SqlServerModelValidatorTest.cs | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index d8e9e60638f..05847aba234 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -247,7 +247,13 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) { #pragma warning disable EF1001 // Internal EF Core API usage. var notFound = includeProperties - .FirstOrDefault(i => RelationalModel.FindPropertyBaseByPath(index.DeclaringEntityType, i) == null); + .FirstOrDefault(i => + { + var propertyBase = RelationalModel.FindPropertyBaseByPath(index.DeclaringEntityType, i); + return propertyBase == null + || (propertyBase is IComplexProperty complexProperty + && !complexProperty.ComplexType.IsMappedToJson()); + }); #pragma warning restore EF1001 // Internal EF Core API usage. if (notFound != null) diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index e8f0c58aac5..09d4acce238 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -602,6 +602,26 @@ public void IncludeProperties_dotted_path_not_found_throws() modelBuilder); } + [Fact] + public void IncludeProperties_on_non_json_complex_property_throws() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexProperty(e => e.Address, cb => + { + cb.Property(a => a.City).IsRequired(); + cb.Property(a => a.Street).IsRequired(); + }); + b.HasIndex(e => e.Id).IncludeProperties("Address"); + }); + + VerifyError( + SqlServerStrings.IncludePropertyNotFound("Address", "{'Id'}", nameof(EntityWithIncludedComplex)), + modelBuilder); + } + [Fact] public void IncludeProperties_inside_json_complex_collection_throws() {