Skip to content

ORM query call-sites are invisible: the table name is never in the source, so no SQL-literal fix can link them (EF Core / C#) #3528

Open

Description

@mforce

Version: graphify 0.9.56, Linux, graphify-out/graph.json 14,360 nodes
Corpus: .NET 10 / C# layered DDD backend + React SPA — EF Core 10 with a single DbContext, 27 mapped entities, Postgres.

Summary

ORM query call-sites are not modelled, so the graph cannot answer "what code reads or writes this table?" — the single most useful data-layer question — on any ORM-based corpus.

This is the sibling of #2884, and the distinction matters because the fix is different. #2884 reports SQL string literals never linking to the tables they name (SELECT … FROM volunteer_assignments in a .ts file). This issue is the case where the table name never appears in the source at all:

await db.Users.AsNoTracking().Where(u => u.Email == email).FirstOrDefaultAsync(ct);

There is no string to parse. The link is db.Users → entity type ApplicationUser → table AspNetUsers, and only the ORM's own mapping knows the last hop. SQL-literal extraction cannot reach it, which is why #2884's fix will not cover this even once it lands.

What the graph has today, and what it is missing

The DbContext node exists and is well connected — but every edge stops at the type, at the declaration line:

$ graphify explain "AppDbContext"
Node: AppDbContext
  Source:    src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs L18
  Degree:    83

  --> Account        [references] …/AppDbContext.cs:L25
  --> Flock          [references] …/AppDbContext.cs:L26
  --> DailyEntry     [references] …/AppDbContext.cs:L28
  --> Customer       [references] …/AppDbContext.cs:L33
  …

Those 83 edges all point at the DbSet<T> declarations. Not one points at a use. The hundreds of db.<DbSet>.<method>(…) call-sites scattered across repositories, middleware and handlers are neither nodes nor edges, so the data layer is connected to its own declaration file and to nothing that queries it.

Reproduce

Any ORM corpus — EF Core, SQLAlchemy, ActiveRecord, Prisma, GORM. Concretely, on a C#/EF repo:

1 — the DbSet property is unreachable by name. A same-named symbol anywhere else in the corpus wins:

$ graphify explain "Users"
Node: users
  ID:        tools_simulation_k6_auth_smoke_users
  Source:    tools/simulation/k6/auth-smoke.js L25
  Type:      code
  Degree:    1

Connections (1):
  <-- auth-smoke.js [contains] tools/simulation/k6/auth-smoke.js:L25

That is a local const in an unrelated k6 load-test script. The EF DbSet<ApplicationUser> Users — queried from ~40 sites — is not a candidate at all.

2 — the natural question returns unrelated nodes.

$ graphify query "which methods query the Users table"
Graph: graphify-out/graph.json (14360 nodes) | Start: ['users', 'Table', '.QueryColumnsAsync()']
NODE users   [src=tools/simulation/k6/auth-smoke.js loc=L25]
NODE Table   [community=SchemaDocsTests]
NODE .QueryColumnsAsync()  [src=…/SchemaDocsTests.cs loc=L1656]
NODE login()               [src=tools/simulation/k6/auth.js loc=L84]
…

52 nodes, and not one is an actual query site. The traversal anchored on the k6 variable and on a test helper that happens to contain the word Table.

Why it matters

We shipped a tenant-isolation guard that had to enumerate every EF query lacking a global query filter — a security control, where a missed call-site is a cross-tenant data leak. The graph could not answer it, so the guard was built as a hand-rolled Roslyn AST walk instead.

That is the general shape of the loss: for ORM corpora, the graph cannot do

  • impact analysis — what breaks if this column changes,
  • security/compliance enumeration — every site that touches a PII table,
  • completeness cross-checks — is this hand-built allow-list missing a site?

and those are precisely the questions a schema-heavy project buys a knowledge graph for. The README's promise is a queryable codebase graph; on ORM code the data layer is the part it cannot answer about.

Suggested approach

  1. Emit a node per ORM query call-site (file + line), with an edge to the entity type it resolves through. Purely syntactic for the common shape: a member access on a field/property whose declared type is DbSet<T> / Model / Repository<T>, per language.
  2. Add the entity → table hop. Where the mapping is discoverable — an attribute ([Table("…")]), a fluent call (ToTable("…")), a naming convention, or an already-extracted schema file — emit entity --maps_to--> table so the call-site reaches the table transitively. Where it is not discoverable, stop at the entity; that alone is most of the value.
  3. Let an explicit table vocabulary win the disambiguation. If the corpus contains schema DDL (or a generated schema doc), prefer a table/DbSet node over an unrelated same-named local when resolving explain "Users". This is the concrete fix for reproduce-case 1.
  4. .sql files and ORM migration files in the corpus — migration classes are extracted here, but the DDL inside them is not, so even the declaration side is partial.

Cross-links: #2884 (the SQL-literal half of the same goal — these two together are what makes a data layer navigable) and #3034 (C# type references resolving to a same-named DbSet<T> property, which is the inverse resolution bug and suggests the DbSet shape is already partly recognised in the C# extractor).

Happy to test a branch against this corpus — it has 27 entities, a single DbContext, ~49 known IgnoreQueryFilters/raw-SQL sites already enumerated by hand for the guard above, and a CI-checked generated schema doc, so ground truth for "did it find them all" already exists.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions