Skip to content

perf: defer unused joined-field lookups to the paginated page - #18

Merged
Upd4ting merged 3 commits into
mainfrom
perf/issue-17-lazy-joined-fields
Jul 13, 2026
Merged

perf: defer unused joined-field lookups to the paginated page#18
Upd4ting merged 3 commits into
mainfrom
perf/issue-17-lazy-joined-fields

Conversation

@MrSociety404

@MrSociety404 MrSociety404 commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Query.List now splits @Joined lookups three ways, matching the pattern already used for computed fields: groups referenced by an active filter stay before the $match/count(), sort-only groups (and groups whose localKey a later Foreign lookup would overwrite) are applied after count() but before orderBy, and all remaining display-only groups are excluded from the streamed query entirely.
  • The default list route defers those display-only joined lookups to after .slice(), so they run only on the returned page instead of the entire matched set. A new Query.DeferredJoinedFields() tells routes which fields were deferred, and Query.Joined() gained an optional, backward-compatible only parameter.
  • Because a @Computed expression may read a joined field, any case where a computed field is materialized (filter, sort, or post-slice display) conservatively pulls in the relevant joined groups first, preserving semantics; the final pluck() strips anything the response did not request.
  • Responses and total are unchanged: the deferred lookups are left joins (count-preserving), and joined values on the returned page are identical to before.

Performance impact

Before: every @Joined field triggered a per-row lookup across the entire result set on every list call — including the duplicate pass inside count() — even when the joined fields were not filtered, sorted, or even plucked. For a table with N matching rows, J joined groups, and a page size of L, that was O(2 x N x J) lookups per request.

After: display-only joined groups run O(L x J) lookups (page only, never in the count pipeline), sort-only groups run O(N x J) once (excluded from count()), and only filter-referenced groups keep the previous pre-match behavior, which is required for correctness. For the common case (joined fields shown but not filtered/sorted), a list of 100k rows with limit=10 goes from ~200k lookups per joined group to 10.

Fixes #17

Testing

  • pnpm run build (tsc) passes; pnpm run lint (biome) clean.
  • pnpm test: 75/75 passing, including a new pagination test (deferred joined fields on a sliced page) and a new regression test (listable computed field whose expression reads a non-listable joined field), the latter added after adversarial review caught the gap. The regression test was differentially verified: it fails (display = null) without the follow-up commit and passes with it.
  • Note on local infra: the suite spins up a real AntelopeJS module on hardcoded port 5010. On the verification machine that port was occupied by an unrelated process, so the suite was verified green via a temporary, uncommitted port override (reverted before commit). CI or any machine with 5010 free runs the suite as-is.

This fix was generated by an automated AI performance review pipeline (multi-agent fix + adversarial pre-PR review).

Greptile Summary

This PR optimizes Query.List by deferring display-only @Joined field lookups to after pagination, so those lookups run against the page (e.g., 10 rows) rather than the entire matched set. A new three-way split (filter / sort / deferred) mirrors the existing pattern for computed fields, and the default list route applies deferred joins on queryPaged after slice().

  • splitJoinedFields categorizes each joined group into pre-count (filter-required), post-count (sort-required or localKey-is-foreign), or deferred; the conservative rule pulls every group into sort when any computed sort or filter is active.
  • Query.List now returns a three-tuple [stream, total, deferredJoined]; the third element is consumed by DefaultRoutes.List to apply Query.Joined(…, displayJoined) on the sliced page only.
  • Two new tests are added: a pagination smoke test confirming joined fields appear on paged rows, and a regression test verifying a computed field that reads a non-listable joined field is correctly resolved before the final pluck().

Confidence Score: 5/5

Safe to merge — the optimization is logically sound and fully backward-compatible, with no changes to externally visible response shape or total counts.

The three-way split of joined groups (filter / sort / deferred) is implemented correctly across all edge cases: computed filters conservatively pull every group pre-count, groups whose localKey is overwritten by a Foreign lookup are moved to sort rather than deferred, and the materializeAll guard in the list route ensures computed fields that read joined data always find those fields materialized before the expression runs. The return-type extension of Query.List to a three-tuple is backward-compatible with existing destructuring. Tests cover both the happy-path pagination case and the regression scenario that was caught during adversarial review.

No files require special attention.

Important Files Changed

Filename Overview
src/components.ts Adds splitJoinedFields / deferredJoinedFields helpers, adds optional only parameter to Query.Joined, and changes Query.List return type to a three-tuple; logic correctly handles all filter/sort/foreign-key edge cases.
src/index.ts Adds displayOnlyJoinedFields helper and wires the deferred join into DefaultRoutes.list after slice(); the materializeAll guard correctly forces all deferred groups when display-only computed fields are present.
src/tests/components/joined.test.ts Adds pagination and computed-reads-joined regression tests; test fixtures are correctly isolated by unique route names, and the regression fixture validates both the positive case and that the non-listable joined field is stripped from the response.

Reviews (2): Last reviewed commit: "address greptile review feedback (greplo..." | Re-trigger Greptile

Query.List applied every @joined lookup to the full stream before the
filter, the count() and the list route's slice(), so list endpoints paid
for all joined groups across the entire matched set (twice, counting the
count aggregation) even when the request neither sorted nor filtered by
them.

Split joined groups the same way computed fields already are: groups
referenced by filters (or forced in by a filtered computed field, whose
expression may read joined fields) stay before the filter and count;
groups needed only for sorting (or whose local key a later Foreign lookup
replaces) are applied after count() and before orderBy, so their lookups
never enter the count pipeline; the remaining display-only groups are
resolved by the list route after slice(), so their lookups only run on
the returned page. Joined() gains an optional `only` set and the new
Query.DeferredJoinedFields() exposes the deferred names to the route.

Left-join lookups never change the row count, so totals and responses
are unchanged.

Refs #17
Materialize all deferred joined fields on the page whenever a display-only
computed field is resolved post-slice, since its expression may read a
joined field that is not itself plucked. Mirrors the conservative rule
Query.List already applies for filter- and sort-materialized computed
fields; the final pluck() strips any fields the response did not request.

Adds a regression test: a listable computed field whose expression reads a
non-listable joined field previously evaluated against a missing field and
returned null.
Comment thread src/components.ts
@MrSociety404

Copy link
Copy Markdown
Member Author

@greptile review

@Upd4ting
Upd4ting merged commit 2bf937d into main Jul 13, 2026
3 checks passed
@Upd4ting
Upd4ting deleted the perf/issue-17-lazy-joined-fields branch July 13, 2026 17:44
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.

[perf] Joined fields resolved across the entire result set (and inside the count query) even when unused by the request

2 participants