Skip to content

Ship Roslyn analyzers inside the EntityGraphQL package - #548

Open
lukemurray wants to merge 3 commits into
mainfrom
feature/analyzers
Open

Ship Roslyn analyzers inside the EntityGraphQL package#548
lukemurray wants to merge 3 commits into
mainfrom
feature/analyzers

Conversation

@lukemurray

@lukemurray lukemurray commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Adds ten Roslyn analyzers (EGQL001–EGQL010) that ship inside the main EntityGraphQL NuGet package under analyzers/dotnet/cs, so consumers get them with no extra install.

They target the mistakes that are invisible in a simple test: a field works with a single object and no services, then behaves differently once it is selected on a list or once two-pass service execution kicks in. The schema is built in ordinary C#, so an analyzer can see the whole fluent chain and check it.

Rules

ID Severity What it catches
EGQL001 Info .Resolve<TService>() on an entity type with no .ResolveBulk(...) — the N+1 trap
EGQL002 Warning An async field using a DbContext, which resolves concurrently on lists (MaxQueryConcurrency defaults to 100)
EGQL003 Warning UseFilter/UseSort/UseOffsetPaging/UseConnectionPaging/UseAggregate on a non-collection field
EGQL004 Warning A synchronous Resolve() whose expression returns a Taskhas a code fix
EGQL005 Warning A name that is not a valid GraphQL name
EGQL006 Warning A [GraphQLSubscription] method not returning an observable
EGQL007 Warning A [GraphQLOneOf] input type with a non-nullable field
EGQL008 Warning The same field added to a type twice
EGQL009 Info A blocking ExecuteRequest inside an async method
EGQL010 Info A Resolve/ResolveBulk blocking on a Task with .Result/.Wait()/GetAwaiter().GetResult()

Most mirror an exception EntityGraphQL already throws at schema build, so they cannot break a build that was previously working — they just move the report into the editor. Every rule's severity can be changed or turned off per project in .editorconfig.

Notes

  • Everything is matched by symbol, never by identifier name, so the analyzers no-op cleanly in projects that do not reference EntityGraphQL.
  • Analyzer and code fix are separate assemblies: code fixes may reference Workspaces, analyzers may not (RS1038).
  • The example projects reference the analyzers directly to dogfood them. demo gained a ResolveBulk on contributedBy, which is exactly what EGQL001 was pointing at.
  • EGQL010 is Info, not a warning, on purpose: moving to ResolveAsync makes the field resolve concurrently across a list (MaxQueryConcurrency defaults to 100), so the service has to be safe to use that way. Blocking a thread you own is the safer of the two mistakes, so the rule nudges rather than insists.
  • Docs at docs/docs/analyzers.md; each rule's helpLinkUri deep-links to its section.

Testing

dotnet test src/tests/EntityGraphQL.Analyzers.Tests — 39 tests, compiling snippets in memory against the real EntityGraphQL and EF Core assemblies. Full solution builds with no new warnings, and dotnet pack was verified to place both analyzer DLLs in the package.

🤖 Generated with Claude Code

lukemurray and others added 2 commits August 18, 2026 15:57
Nine rules (EGQL001-EGQL009) covering the mistakes that only show up at
schema build or under a particular query shape: missing bulk resolvers,
DbContext on async fields, collection-only field extensions on scalars,
sync Resolve of a Task (with a code fix), invalid GraphQL names,
subscriptions that are not observables, non-nullable OneOf fields,
duplicate AddField and blocking ExecuteRequest in async methods.

Everything is matched by symbol, so the analyzer no-ops in projects that
do not reference EntityGraphQL. The example projects reference the
analyzers directly to dogfood them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.Result, .Wait() and GetAwaiter().GetResult() written in a Resolve() or
ResolveBulk() lambda block the executing thread, once per item on a list.

Info rather than a warning: moving to ResolveAsync makes the field resolve
concurrently across a list (MaxQueryConcurrency defaults to 100), so the
service has to be safe to use that way. Blocking a thread you own is the
safer of the two mistakes.

Only the resolver's own lambda is scanned - blocking inside a helper it
calls is not detected, which keeps the rule cheap and free of guesses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EGQL001 looked for a ResolveBulk call in the syntax of the statement containing
the Resolve call, matched on identifier text. A field defined over several
statements through a stored builder - var f = type.AddField(..); f.Resolve(..);
f.ResolveBulk(..); - reported the N+1 despite being batched, and a same-named
method on an unrelated type silenced it.

Resolve the builder to its symbol instead and look for an EntityGraphQL
ResolveBulk* call on the same symbol: anywhere in the fluent chain either side
of this call, or on the local/field/property/parameter it was stored in.

Also tighten EGQL002: any explicit maxConcurrency used to clear it, but only
serialising the resolves makes a non-thread-safe service safe - a smaller limit
still runs them concurrently. Now only maxConcurrency: 1 clears it, which is
what the docs already showed. The literal arrives wrapped in the int-to-int?
conversion, so unwrap before reading the constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant