From b4ae8af7bf943c5518e756a5b043e765ce2cdb67 Mon Sep 17 00:00:00 2001 From: Tunahan Ali Ozturk Date: Mon, 5 Oct 2026 00:26:47 +0300 Subject: [PATCH 1/2] Don't add an IS NOT NULL filter to unique indexes on required TPT and TPC columns on SQL Server SqlServerIndexConvention computed the filter while the hierarchy still looked like TPH, so derived properties were treated as nullable. Re-evaluate the filters when the discriminator is removed, which is when the hierarchy is finalized as TPT or TPC. Fixes #32689 --- .../EFCore.SqlServer.baseline.json | 5 ++- .../Conventions/SqlServerIndexConvention.cs | 28 +++++++++++++++ .../SqlServerModelBuilderTestBase.cs | 34 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json index ccccf94a903..d83ba4e1c98 100644 --- a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json +++ b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json @@ -1461,11 +1461,14 @@ ] }, { - "Type": "class Microsoft.EntityFrameworkCore.Metadata.Conventions.SqlServerIndexConvention : Microsoft.EntityFrameworkCore.Metadata.Conventions.IEntityTypeBaseTypeChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexAddedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexUniquenessChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexAnnotationChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IPropertyNullabilityChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IPropertyAnnotationChangedConvention", + "Type": "class Microsoft.EntityFrameworkCore.Metadata.Conventions.SqlServerIndexConvention : Microsoft.EntityFrameworkCore.Metadata.Conventions.IEntityTypeBaseTypeChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IDiscriminatorPropertySetConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexAddedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexUniquenessChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IIndexAnnotationChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IPropertyNullabilityChangedConvention, Microsoft.EntityFrameworkCore.Metadata.Conventions.IPropertyAnnotationChangedConvention", "Methods": [ { "Member": "SqlServerIndexConvention(Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure.ProviderConventionSetBuilderDependencies dependencies, Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure.RelationalConventionSetBuilderDependencies relationalDependencies, Microsoft.EntityFrameworkCore.Storage.ISqlGenerationHelper sqlGenerationHelper);" }, + { + "Member": "virtual void ProcessDiscriminatorPropertySet(Microsoft.EntityFrameworkCore.Metadata.Builders.IConventionTypeBaseBuilder structuralTypeBuilder, string? name, Microsoft.EntityFrameworkCore.Metadata.Conventions.IConventionContext context);" + }, { "Member": "virtual void ProcessEntityTypeBaseTypeChanged(Microsoft.EntityFrameworkCore.Metadata.Builders.IConventionEntityTypeBuilder entityTypeBuilder, Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType? newBaseType, Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType? oldBaseType, Microsoft.EntityFrameworkCore.Metadata.Conventions.IConventionContext context);" }, diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs index 0b60779247b..0ca6fce11a3 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs @@ -18,6 +18,7 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; /// public class SqlServerIndexConvention : IEntityTypeBaseTypeChangedConvention, + IDiscriminatorPropertySetConvention, IIndexAddedConvention, IIndexUniquenessChangedConvention, IIndexAnnotationChangedConvention, @@ -76,6 +77,33 @@ public virtual void ProcessEntityTypeBaseTypeChanged( } } + /// + /// Called after a discriminator property is set. + /// + /// The builder for the type. + /// The name of the discriminator property. + /// Additional information associated with convention execution. + public virtual void ProcessDiscriminatorPropertySet( + IConventionTypeBaseBuilder structuralTypeBuilder, + string? name, + IConventionContext context) + { + if (structuralTypeBuilder is not IConventionEntityTypeBuilder entityTypeBuilder) + { + return; + } + + // Whether the columns of derived types are nullable depends on the mapping strategy, which is only known + // for sure once the discriminator is removed for TPT and TPC hierarchies. + foreach (var entityType in entityTypeBuilder.Metadata.GetDerivedTypesInclusive()) + { + foreach (var index in entityType.GetDeclaredIndexes()) + { + SetIndexFilter(index.Builder); + } + } + } + /// /// Called after an index is added to the entity type. /// diff --git a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs index 7a7be52fa9a..79ee768ab67 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs @@ -495,6 +495,40 @@ public void Index_convention_sets_filter_for_unique_index_when_base_type_changed Assert.Null(index.GetFilter()); } + [Fact] // #32689 + public void Index_convention_does_not_set_filter_for_unique_index_on_required_property_in_TPT() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Ignore(); + modelBuilder.Entity().ToTable("DetailsBase"); + modelBuilder.Entity(b => + { + b.ToTable("CustomerDetails"); + b.HasIndex(e => e.CustomerId).IsUnique(); + }); + + var model = modelBuilder.FinalizeModel(); + + var index = model.FindEntityType(typeof(CustomerDetails))!.GetDeclaredIndexes().Single(); + Assert.Null(index.GetFilter()); + } + + [Fact] // #32689 + public void Index_convention_does_not_set_filter_for_unique_index_on_required_property_in_TPC() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Ignore(); + modelBuilder.Entity() + .HasIndex(e => e.CustomerId) + .IsUnique(); + modelBuilder.Entity().UseTpcMappingStrategy(); + + var model = modelBuilder.FinalizeModel(); + + var index = model.FindEntityType(typeof(CustomerDetails))!.GetDeclaredIndexes().Single(); + Assert.Null(index.GetFilter()); + } + [Fact] public virtual void Can_override_TPC_with_TPH() { From 1828e353ac3d46a3af2af8667265a4c5db8a3fff Mon Sep 17 00:00:00 2001 From: Tunahan Ali Ozturk Date: Mon, 5 Oct 2026 01:19:50 +0300 Subject: [PATCH 2/2] Regenerate existing index filters when the discriminator changes A composite unique index over a required and a nullable column got a filter for both columns while the hierarchy still looked like TPH, and the filter was kept because one already existed. Regenerate it so only the nullable columns remain; explicitly configured filters are still left alone. --- .../Conventions/SqlServerIndexConvention.cs | 11 +++--- .../SqlServerModelBuilderTestBase.cs | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs index 0ca6fce11a3..4313c0b94d5 100644 --- a/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs +++ b/src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs @@ -94,12 +94,13 @@ public virtual void ProcessDiscriminatorPropertySet( } // Whether the columns of derived types are nullable depends on the mapping strategy, which is only known - // for sure once the discriminator is removed for TPT and TPC hierarchies. + // for sure once the discriminator is removed for TPT and TPC hierarchies. Regenerate existing filters too, since + // the set of nullable columns may have shrunk; a filter configured explicitly is still not overridden. foreach (var entityType in entityTypeBuilder.Metadata.GetDerivedTypesInclusive()) { foreach (var index in entityType.GetDeclaredIndexes()) { - SetIndexFilter(index.Builder); + SetIndexFilter(index.Builder, regenerate: true); } } } @@ -179,19 +180,19 @@ public virtual void ProcessPropertyAnnotationChanged( { foreach (var index in propertyBuilder.Metadata.GetContainingIndexes()) { - SetIndexFilter(index.Builder, columnNameChanged: true); + SetIndexFilter(index.Builder, regenerate: true); } } } - private void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool columnNameChanged = false) + private void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool regenerate = false) { var index = indexBuilder.Metadata; if (index.IsUnique && index.IsClustered() != true && GetNullableColumns(index) is { Count: > 0 } nullableColumns) { - if (columnNameChanged + if (regenerate || index.GetFilter() == null) { indexBuilder.HasFilter(CreateIndexFilter(nullableColumns)); diff --git a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs index 79ee768ab67..c311ac191ad 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ModelBuilding/SqlServerModelBuilderTestBase.cs @@ -529,6 +529,43 @@ public void Index_convention_does_not_set_filter_for_unique_index_on_required_pr Assert.Null(index.GetFilter()); } + [Fact] // #32689 + public void Index_convention_keeps_only_nullable_columns_in_filter_for_composite_unique_index_in_TPT() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Ignore(); + modelBuilder.Entity().ToTable("DetailsBase"); + modelBuilder.Entity(b => + { + b.ToTable("CustomerDetails"); + b.Property("Note"); + b.HasIndex(nameof(CustomerDetails.CustomerId), "Note").IsUnique(); + }); + + var model = modelBuilder.FinalizeModel(); + + var index = model.FindEntityType(typeof(CustomerDetails))!.GetDeclaredIndexes().Single(); + Assert.Equal("[Note] IS NOT NULL", index.GetFilter()); + } + + [Fact] // #32689 + public void Index_convention_keeps_only_nullable_columns_in_filter_for_composite_unique_index_in_TPC() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Ignore(); + modelBuilder.Entity(b => + { + b.Property("Note"); + b.HasIndex(nameof(CustomerDetails.CustomerId), "Note").IsUnique(); + }); + modelBuilder.Entity().UseTpcMappingStrategy(); + + var model = modelBuilder.FinalizeModel(); + + var index = model.FindEntityType(typeof(CustomerDetails))!.GetDeclaredIndexes().Single(); + Assert.Equal("[Note] IS NOT NULL", index.GetFilter()); + } + [Fact] public virtual void Can_override_TPC_with_TPH() {