镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Don't treat a filtered index as covering a foreign key index - #39652

Open
ajcvickers wants to merge 1 commit into
dotnet:mainfrom
ajcvickers:fix/24237-filtered-index-fk-index
Open

ajcvickers wants to merge 1 commit into
dotnet:mainfrom
ajcvickers:fix/24237-filtered-index-fk-index

Conversation

@ajcvickers

Copy link
Copy Markdown
Contributor

Fixes #24237

Problem

ForeignKeyIndexConvention doesn't create an index on a foreign key's properties when another index already starts with the same columns, and it removes the foreign key's index when such an index is added later. That is only correct if the other index contains every row. A filtered index contains just the rows that satisfy its filter, so a filtered composite index whose leading columns are a foreign key's columns made the foreign key's own index disappear from the model, and from the next migration:

modelBuilder.Entity<Invoice>()
    .HasIndex(i => new { i.PeriodId, i.CompanyId })
    .HasFilter("[CompanyId] > 0");   // IX_Invoices_PeriodId was dropped

The convention couldn't see the filter because filters are a relational concept, and HasFilter runs after the index has been added, so even a relational check made when the index is added would be too early.

Fix

Core. ForeignKeyIndexConvention has a new protected virtual IsPartial(IConventionIndex) hook (default false). A partial index never counts as covering a foreign key index. This applies everywhere the convention decides whether an index is covered: creating a foreign key index, adding or removing an index, changing index or foreign key uniqueness, changing the base type, and the redundant-index log message. A new protected virtual ProcessIndexCoverageChanged re-evaluates an index whose coverage changed. Foreign key indexes are created when an index becomes partial, and covered indexes are removed when it stops being partial.

Relational. The new RelationalForeignKeyIndexConvention replaces the core convention for relational providers.

  • An index is partial if it has a filter that wasn't set by convention.
  • It implements IIndexAnnotationChangedConvention, so adding, changing or removing a filter after the index exists adds or removes the foreign key index.
  • It re-checks partial indexes when the model is finalized. Changing a filter from convention to explicit with the same text only changes the configuration source, which doesn't raise an annotation change, so this is the backstop for that case.

Decisions to look at

  • Filters set by convention are ignored. SQL Server adds [A] IS NOT NULL AND [B] IS NOT NULL to unique indexes over nullable columns. Strictly that filter also excludes rows with a null in a later column that a lookup on a prefix of the columns needs. Counting it would add a new index to every existing SQL Server model with such an index, so these filters are treated as covering, as they always have been. This is documented on the class.
  • An unnamed filtered index on exactly the foreign key's columns is still the foreign key's index. The model can't hold two unnamed indexes on the same properties, so there's nothing to add. This is the limitation noted in the triage comment on the issue. A named filtered index on those columns does get a separate foreign key index (covered by tests).
  • Table-specific filters aren't considered. IsPartial looks at the filter on the index itself, not the GetFilter(StoreObjectIdentifier) fallback to the root index in a shared table.

Behavior change

A model with an explicitly filtered index whose leading columns are a foreign key's columns will get a new CreateIndex for the foreign key in its next migration. This is the intended fix, but it is visible to existing apps and may deserve a breaking-change note. CompiledModelTestBase.Tpc_Sprocs has a model like this, a named unique index filtered on AlternateId <> NULL over the foreign key column. Its assertions and generated baselines now include the foreign key's own IX_PrincipalBase_PrincipalBaseId index.

API

New public API, with the two *.baseline.json files updated:

  • ForeignKeyIndexConvention.IsPartial and ForeignKeyIndexConvention.ProcessIndexCoverageChanged (protected virtual).
  • RelationalForeignKeyIndexConvention.

Tests

  • RelationalForeignKeyIndexFilterTest (22 tests): filtered or unfiltered index created before or after the foreign key; filter added, changed and removed; filter source Convention, DataAnnotation and Explicit (including the same-text upgrade); named and unnamed indexes on exactly the foreign key columns, with unique and non-unique foreign keys; uniqueness changes; index removal; a hierarchy with the index and foreign key on different types, and setting the base type; and the redundant-index log message.
  • ForeignKeyIndexConventionTest (core): the IsPartial hook with a test convention, independent of relational.
  • SQL Server: a unique index over nullable columns keeps its convention filter and still covers the foreign key index, and an explicit filter does not.
  • Full test.cmd passes locally.

Not covered: filtered indexes over complex properties or collections, and table-specific filters.

🤖 Generated with Claude Code

Fixes dotnet#24237

ForeignKeyIndexConvention removed, or declined to create, the index on a
foreign key's properties whenever another index started with the same
columns. A filtered index only contains the rows that satisfy its filter,
so it can't stand in for the foreign key index.

ForeignKeyIndexConvention now asks a new protected virtual IsPartial hook
whether an index only includes some of the rows. A partial index never
covers a foreign key index. ProcessIndexCoverageChanged re-evaluates an
index when that changes: foreign key indexes are created when an index
becomes partial, and covered indexes are removed when it stops being
partial.

The new RelationalForeignKeyIndexConvention replaces the core convention
for relational providers. It treats an index as partial when it has a
filter that wasn't set by convention, and it reacts to the filter
annotation changing, since HasFilter runs after the index has been added.
It also re-checks partial indexes when the model is finalized, because
changing a filter from convention to explicit with the same text doesn't
raise an annotation change.

Filters set by convention, such as the one SQL Server adds to unique
indexes over nullable columns, are deliberately ignored so that existing
models don't gain new indexes.

An unnamed filtered index on exactly the foreign key's properties is still
the foreign key's index, because the model can't hold two unnamed indexes
on the same properties.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@ajcvickers
ajcvickers requested a review from a team as a code owner November 14, 2026 11:34
Copilot AI balanced review requested due to automatic review settings November 14, 2026 11:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EF Core migration drops existing index on a column when a composite filtered index on that column is added

2 participants