Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/LC045_MissingInclude.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The fix only registers when the source expression it would wrap is statically `I
1. **Anchor**: register on entity-producing materializers — `ToList`/`ToArray`/`ToHashSet` (+ supported `Async` forms), `First`/`Single`/`Last` (`OrDefault`, `Async`), and query-root `ElementAt` (`OrDefault`, supported `Async`) — plus synchronous `foreach` directly over a statically `IQueryable<T>` DbSet-rooted source. Inline collection materializers use the same source proof. Aggregates (`Count`, `Any`, …) never materialize entities and are ignored.
2. **Chain proof**: walk the semantic source parameter back to a `DbSet<T>` property/field on a `DbContext`, or to `DbContext.Set<TEntity>()`. Exact `Queryable`, EF, and relational symbols preserve only known query shapes, including `AsQueryable`, `IgnoreAutoIncludes`, and EF Core `FromSql*`; reordered static arguments are resolved by parameter ordinal. Anything else — `Select`, `Join`, `GroupBy`, custom extensions, and lookalikes — bails.
3. **Included paths**: parse every `Include`/`ThenInclude` (lambda, filtered-lambda, and constant-string overloads) into navigation paths and record every prefix (`Include(o => o.A.B)` covers `A` and `A.B`). If any Include cannot be parsed (dynamic string), the whole query is skipped — it could cover anything.
4. **Model-level eager loading**: cache exact top-level `OnModelCreating` overrides per context, matching constructed generic contexts through their original symbols. An exact EF `modelBuilder.Entity<TEntity>().Navigation(e => e.Nav).AutoInclude()` chain counts as loading only that entity/navigation path. Later fluent or standalone disablement removes the proof, an unproven later model-configuration boundary invalidates prior evidence, and a query-level `IgnoreAutoIncludes()` disables all model-level evidence for that query.
4. **Model-level eager loading**: cache exact top-level `OnModelCreating` overrides per context, matching constructed generic contexts through their original symbols. An exact EF `modelBuilder.Entity<TEntity>().Navigation(e => e.Nav).AutoInclude()` chain counts as loading only that entity/navigation path. The same proof follows an unconditional `modelBuilder.ApplyConfiguration(new TConfiguration())` into the source-visible implementation of the exact `IEntityTypeConfiguration<TEntity>.Configure` method, where direct top-level `builder.Navigation(...).AutoInclude()` settings are applied in execution order. Later fluent, standalone, or applied-configuration disablement removes the proof; conditional/runtime settings and unknown builder-consuming helpers invalidate it. A query-level `IgnoreAutoIncludes()` disables all model-level evidence for that query.
5. **Navigation classification**: a property is a navigation when its type (or collection element type) has a `DbSet` on the same context. Owned and unmapped types have no `DbSet` and are never flagged.
6. **Usage scan**: build and cache the Roslyn CFG for the containing method or constructor, then analyse forward from the materializer or direct loop source. Track entity-bearing locals — collection results, `foreach` iteration variables, indexer-initialized locals, exact `System.Linq.Enumerable` element extraction (`First*`, `Single*`, `Last*`, `ElementAt*`) from a materialized collection, aliases, and locals extracted from reference navigations — by origin, binding generation, and navigation prefix. Nested collection iteration carries that prefix (`order.Items` then `item.Product` → `Items.Product`). At joins, keep only identical bindings, retain navigation writes that occurred on every incoming path, and treat an escape or uncertain reassignment on any incoming path as uncertainty for subsequent reads. Exact `List<T>.ForEach` and single-source `Enumerable.Where`/`Select`/`Any`/`All` inline callbacks receive their own nested CFG only while the original materialized collection generation is proven active at the call. `Where` forwards provenance only through an effect-free inline predicate, and scalar `Select` projections do not poison later ordinary reads; entity-returning projections, arbitrary callbacks, and delegate/method-group forms remain boundaries. Direct property-subpattern reads use the same navigation-path and dominance proof.
7. **Emit**: one diagnostic per distinct missing navigation path, at the first access site, carrying the exact query source location and the dotted path for the code fix. Only maximal paths are reported — fixing `Customer.Address` eagerly loads `Customer` too.
Expand All @@ -60,14 +60,14 @@ The fix only registers when the source expression it would wrap is statically `I
- Reassigning a result local or repointing an entity local similarly suppresses only subsequent reads whose origin is no longer proven. If only one control-flow branch escapes or repoints the value, the merged origin is uncertain and stays quiet afterward.
- Navigation writes are not reads: `o.Customer = c` (including compound, `??=`, and deconstruction assignments) and `o.Items.Add(x)` are recognized relationship-fix-up patterns and stay quiet. A navigation write satisfies a later read only for the same entity origin and only when every path reaching that read performs the write; a one-branch write or a write to a different extracted entity does not suppress the diagnostic.
- Mid-path casts and null-forgiving operators in Include lambdas (`Include(o => o.Customer!.Address)`, `Include(o => ((Derived)o.Nav).Child)`) parse as the full path; an Include shape the parser cannot prove silences the whole query.
- Model-level eager-loading evidence is context- and path-scoped. Conditional, deferred, runtime-valued, or early-exit-guarded `AutoInclude()` calls, shadowed or hidden-slot `OnModelCreating` lookalikes, fluent or standalone `AutoInclude(false)`, later base/helper/indirect configuration boundaries, and configuration belonging to another context or navigation never suppress LC045.
- Model-level eager-loading evidence is context- and path-scoped. Conditional, deferred, runtime-valued, or early-exit-guarded `AutoInclude()` calls, shadowed or hidden-slot `OnModelCreating` lookalikes, fluent, standalone, or applied-configuration `AutoInclude(false)`, later base/helper/indirect configuration boundaries, and configuration belonging to another context, entity, or navigation never suppress LC045. Ordinary exact EF builder calls such as `HasKey` and relational mapping extensions do not invalidate an otherwise direct configuration proof.
- `nameof(o.Customer)` evaluates nothing and is never flagged.
- Properties whose type has no `DbSet` (owned/unmapped types) are never navigations.
- Non-EF sources (`List<T>` LINQ) never match the DbSet root proof.

## Deliberate Decisions & Known Limits
- **Null-guarded access still fires.** `if (o.Customer != null)` and `order?.Customer` are flagged on purpose: with proxies the null check itself can trigger the N+1 load, and without another loading mechanism a consistently null navigation makes the guard dead code hiding the bug. Suppress with `#pragma warning disable LC045` if the guard is intentional. This holds for every null-conditional spelling: chained inline access on the materializer (`FirstOrDefault()?.Customer?.Name`, `FirstOrDefault()?.Customer.Address?.City`), parenthesized regrouping (`(order?.Customer)?.Address?.City`, reported as `Customer.Address`, including inline materializer and inherited-navigation forms), conditional element access on the result (`orders?[0].Customer`), and locals initialized from a conditional indexer (`var o = orders?[0];`). Conditional method-call results such as `(order?.Customer.GetDetached())?.Address` are treated as call results, not as a continuation of the queried navigation path.
- Exact top-level `AutoInclude()` chains in the nearest real `OnModelCreating` override are recognised, including constructed generic contexts. Indirect builder locals, `IEntityTypeConfiguration<T>`/`ApplyConfiguration`, inferred base-method calls, deferred calls, later unproven model mutations, and transitive auto-includes on a different entity remain unproven and can still report; keep an explicit `Include`, project, or use a reviewed suppression for those shapes.
- Exact top-level `AutoInclude()` chains in the nearest real `OnModelCreating` override are recognised, including constructed generic contexts. Exact unconditional `ApplyConfiguration(new TConfiguration())` calls are also recognised when the source-visible `IEntityTypeConfiguration<T>.Configure` implementation contains direct builder chains. Configuration instances supplied through locals, fields, factories, assembly scanning, builder aliases, helper-delegated settings, inferred base-method calls, deferred calls, later unproven model mutations, and transitive auto-includes on a different entity remain unproven and can still report; keep an explicit `Include`, project, or use a reviewed suppression for those shapes.
- Widened `IEnumerable<T>` aliases are diagnostic-only: the analyzer can still prove the DbSet root, but the fixer will not emit `Include` against a non-queryable source expression.
- Current scope is intra-procedural and local-based (methods and constructors). Out of scope (quiet, not flagged): `await foreach`, arbitrary callbacks and delegate/method-group consumers, predicate/default-value element extractor overloads, custom extraction lookalikes, provider-specific temporal APIs, `Find*`, `Entry(...).Reference/Collection(...).Load()` recognition, and `IQueryable` parameters / repository-returned queries as roots.

Expand Down Expand Up @@ -106,6 +106,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
var autoIncluded = db.Orders.ToList();
foreach (var order in autoIncluded) Console.WriteLine(order.Customer.Name);

// Exact source-visible configuration classes are also recognised.
sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
=> builder.Navigation(o => o.Customer).AutoInclude();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfiguration(new OrderConfiguration());

var names = db.Orders.Select(o => o.Customer.Name).ToList(); // projection — out of scope

var list = db.Orders.ToList();
Expand Down
Loading
Loading