Skip to content

fix: ResolveBulk under root service lists/objects and nullable-nav keys - #539

Merged
lukemurray merged 6 commits into
mainfrom
fix/resolvebulk-service-root-list
Aug 3, 2026
Merged

lukemurray merged 6 commits into
mainfrom
fix/resolvebulk-service-root-list

Conversation

@alex-birch

Copy link
Copy Markdown
Collaborator

Summary

  • Fix ResolveBulk nested under root service-resolved lists (e.g. apiKeys from .Resolve<TService>(...)) so the two-pass flow loads bulk data once instead of failing with a null BulkParameter.
  • Fix ResolveBulk nested under root service-resolved objects that expose a list (status-page shape: statusPage { items { bulkField } }) — first pass now wraps/selects nested fields, second pass uses the materialized anon, and the DataSelector parameter is rebound when needed.
  • Fix nullable-navigation bulk keys (row.Board == null ? null : row.Board.SerialNumber) via ExpressionReplacer.VisitConditional so the Conditional rewrites onto the first-pass extracted field.

Test plan

  • ServiceRootListBulkTests + HardwareSensorStatusPageBulkTests (net8.0)
  • Manual: status-page query with lastSeen / isOffline ResolveBulk and nullable DetexyBoard key (XyAdmin Offline Active)

Made with Cursor

alexbirch-xy and others added 3 commits July 31, 2026 10:42
Root service list fields returned null on the first pass, so bulk loaders
never ran and nested ResolveBulk crashed on a null BulkParameter. Let those
lists participate in the two-pass flow and fall back to per-item Resolve
when bulk data was not loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
Root Resolve pages with nested list bulk fields (status-page shape) now
participate in the two-pass flow, and Conditional bulk keys such as
DetexyBoard == null ? null : DetexyBoard.SerialNumber rewrite onto the
first-pass extracted field instead of leaving unbound params.

Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid re-invoking the root service on the second pass (lists and objects)
and only use replaceInline when the replacement context is still the
entity type, so Dynamic egql__* names resolve correctly under bulk fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
@alex-birch
alex-birch force-pushed the fix/resolvebulk-service-root-list branch from bfe10e1 to 37ed7b5 Compare July 31, 2026 00:47
alexbirch-xy and others added 2 commits July 31, 2026 11:18
Root service lists now materialize on the first pass for ResolveBulk.
UseFilter/UseSort already ran there; re-applying against the first-pass
Dynamic broke with "No generic method Where/OrderBy". Nested service
collections still apply on the services pass only.

Co-authored-by: Cursor <cursoragent@cursor.com>
- 6.1.3 is released, so these changes are 6.1.4: new CHANGELOG section and
  PackageVersion bumps. 6.1.2's and 6.1.3's notes go back to what those
  releases shipped rather than being reworded for this change.
- The bulk key selector parameter rebind was duplicated in ReplaceContext and
  BuildBulkKeySelection - now ExpressionUtil.RebindBulkKeySelectorParameter.
- The "did this collection already exist in the first pass" element-type check
  was duplicated in the filter and sort extensions - now
  BaseFieldExtension.CollectionBuiltInFirstPass.
- Drop the unrelated formatting-only changes (IsSelectedOnToSingleNode,
  ValidateFieldsCanMerge, the keys Concat ternary, and the object initialisers
  in ServiceBackedCollectionExtensionsTests) so the diff is only behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lukemurray

Copy link
Copy Markdown
Collaborator

Reviewed and pushed a follow-up commit (af5827a) with the administrative fixes and the two duplication nits — the behaviour changes are untouched. Suites are green on that head: core 1140 (net9 + net8), EF 47, AspNet 92.

What I changed

  • 6.1.4, not 6.1.3. 6.1.3 is released, so your two entries moved to a new # 6.1.4 section and PackageVersion is bumped in both csproj files. I also reverted the edits to the 6.1.2/6.1.3 entries — those releases shipped the per-item fallback, so their notes should keep describing that; the new behaviour is described in the new section.
  • Deduplicated the bulk key selector rebind. It was the same 8 lines in ReplaceContext and BuildBulkKeySelection → ExpressionUtil.RebindBulkKeySelectorParameter.
  • Deduplicated the "already built in the first pass" check. It was duplicated verbatim in FilterExpressionExtension and SortExtension → BaseFieldExtension.CollectionBuiltInFirstPass. That one is a subtle heuristic, and two copies of it would have drifted.
  • Dropped the formatting-only changes (IsSelectedOnToSingleNode, ValidateFieldsCanMerge, the keys Concat ternary, and the object initialisers in ServiceBackedCollectionExtensionsTests) so the diff is only behaviour. Worth checking your csharpier version against CI's, since it clearly disagrees with what's on main.

Verification beyond your tests — two shapes I probed on your branch, both pass, so no action needed; noting them so you know they're covered:

  • a root service list and a root service object where every selected sub-field is a service field, so the first pass has nothing of its own to select → one bulk call, correct data
  • a root service field whose service returns null → no NRE, null/empty propagates (this one seemed most at risk given first-pass materialisation is new)

The design question, which is the one thing still open. Every root service list/object now takes the two-pass path whether or not anything below it has a bulk resolver — an extra expression compile plus an intermediate projection for a shape that used to run in one pass. Your original PR description raised exactly this and it's still unanswered:

Review whether always running the two-pass path for root service lists (even without nested bulk) is acceptable, vs opting in only when bulk resolvers are present

Two sub-questions I'd want settled before this merges:

  1. Is the unconditional cost measured? If a root service list with no bulk fields below it is now measurably slower, gating on "are there bulk resolvers at or below this node?" is cheap — the document already knows its own shape at compile time (HasServicesAtOrBelow is the same kind of walk). If it's noise, unconditional is simpler and I'd keep it.
  2. The timing change deserves a note. For these fields the service now runs during the first pass, so ExecuteServiceFieldsSeparately no longer means "services run after the context pass" for root service fields. Anything keyed off isFinal == false — a BeforeExecuting hook, debug timings — now sees services in pass one. Worth a line in the CHANGELOG entry even if we keep the behaviour.

No other blockers from me. One cosmetic thing I left alone deliberately: HardwareSensorStatusPageBulkTests carries domain names from the app (DetexyBoard, SerialOrExternalId). Good fidelity to the real failure, slightly odd in this repo long-term — your call whether to neutralise them.

… load

Measured on a root service list with no bulk resolver below it (100 rows, two
scalar fields, Release): 0.088ms/query on main vs 0.158ms with the two-pass
path always on - an extra expression compile and a projection of every row for
a query with nothing to bulk load. Gated on a new HasBulkResolverAtOrBelow walk
(the same shape as HasServicesAtOrBelow, and it looks inside fragment spreads -
without that a hoisted selection quietly drops back to one call per row).
Back to 0.083ms when no bulk field is selected.

Which nodes materialized their own projection in the first pass is now recorded
on the CompileContext, the way possibleNextContextTypes already is, so the
second pass cannot disagree with the first about whether to project from that
result or rebuild the service expression. Deciding it twice was the cause of
the 6 failures the gate first produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lukemurray
lukemurray merged commit fb469a2 into main Aug 3, 2026
1 check passed
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.

3 participants