diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index c97207039d2..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.FindPropertyByPath(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) @@ -259,6 +265,21 @@ protected virtual void ValidateIndexIncludeProperties(IIndex index) index.DeclaringEntityType.DisplayName())); } + foreach (var includeProperty in includeProperties) + { +#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.IncludePropertyInJsonMappedType( + includeProperty, + index.DisplayName(), + index.DeclaringEntityType.DisplayName())); + } + } + var duplicateProperty = includeProperties .GroupBy(i => i) .Where(g => g.Count() > 1) 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 83ab414129e..fbcb9770d09 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}' is contained within a JSON-mapped type. Properties contained within JSON-mapped types cannot be included in an index. + /// + public static string IncludePropertyInJsonMappedType(object? property, object? index, object? entityType) + => string.Format( + 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 264d3c6af9e..69f87c83d93 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}' 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.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..09d4acce238 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -602,12 +602,130 @@ 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() + { + 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.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; } 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; }