Skip to content

Add withPreloadScopes to constrain included relations - #4

Merged
evoactivity merged 2 commits into
mainfrom
feat/resource-scopes
Jul 31, 2026
Merged

Add withPreloadScopes to constrain included relations#4
evoactivity merged 2 commits into
mainfrom
feat/resource-scopes

Conversation

@evoactivity

@evoactivity evoactivity commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

Adds withPreloadScopes(), a query-builder macro for constraining the preload queries of included relations, fully typed per relation.

jsonApi.query() builds the ?include= preload tree for you, so there is no call site where a developer can scope those relation queries. A scope applied to the root with Lucid's withScopes() never reaches the included rows, which for a visibility rule means hidden rows leak through the include. withPreloadScopes() closes that gap:

const articles = await jsonApi
  .query(Article)
  .withScopes((scopes) => scopes.published())          // root: Lucid's own
  .withPreloadScopes({
    comments: (scopes) => scopes.published(),           // ?include=comments
    author:   (scopes) => scopes.active(),              // ?include=author
  })
  .paginate(...jsonApi.page)

Typed like withScopes

The map is keyed by the model's relation names, and each callback's scopes argument is the related model's scope bag (ExtractScopes<Related>), so scope names autocomplete and a wrong relation name or an undefined scope is a compile error, exactly the safety withScopes() gives. Deeper includes are constrained by nesting, typed to the next model down:

.withPreloadScopes({
  seasons: {
    scope: (scopes) => scopes.visible(),                // scopes: Season's
    preload: {
      episodes: (scopes) => scopes.visible(),           // scopes: Episode's
    },
  },
})

An entry is either a bare callback (scope that relation) or { scope?, preload? } to also constrain deeper includes. Scopes apply along the path you write, so a relation on one branch never leaks to a same-named relation on another.

Design

  • Reuses model scopes, does not redefine them. Each callback is the exact shape of a withScopes() callback, so the visibility rule lives once on the model.
  • Composes after jsonApi.query(). The scope tree is read when Lucid loads the relation (execution time), verified against Lucid's preloader, so chain order does not matter. withScopes/preload both return the same builder instance, so the builder-keyed WeakMap the macro uses is stable.
  • Explicit and per-query, on purpose. Visibility is a security concern; keeping it a call-site decision means a new endpoint must consciously apply it, rather than inheriting a resource default it can silently forget.

Implementation

  • withPreloadScopes registered as a ModelQueryBuilder macro in the provider boot(). Two augmentations: the contract carries the typed PreloadScopeMap<Model> for callers; the concrete class carries the loose runtime tree for Macroable.macro's keyof.
  • The typed map derives the related model per relation from Lucid's own relation types (each carries model: RelatedModel), then ExtractScopes<Related>.
  • A WeakMap keyed by the builder holds the scope tree; applyIncludes walks it alongside the include tree, applying each relation's scope via withScopes and descending through preload.
  • applyIncludes stays backward compatible: the Model and scope-tree arguments are optional, so the existing low-level applyIncludes(query, tree) form is unchanged.

Tests

  • Unit: bare-callback entry applied, nested { scope, preload } descent, object-entry-without-scope, read at preload time (added after applyIncludes and still applied), and the tree merge.
  • End to end in the blog example: a published scope on Comment and a /scoped-articles/:id endpoint using withPreloadScopes; the test asserts the scoped include returns only the published comment (1) while the unscoped endpoint returns both (2), proving real SQL filtering through a real HTTP request. The example's typecheck also exercises the typed map (scopes.published() resolved against ExtractScopes<Comment>).

Notes

  • CHANGELOG and version are left to the release automation.
  • Docs: a new "Scopes on reads" section in docs/reading-data.md and a pointer in docs/reference.md.

jsonApi.query() preloads the include tree for you, so a developer cannot reach those relation queries to scope them. withPreloadScopes(), a query builder macro, closes that: a map keyed by relation name constrains each included relation's preload query, at any depth, using callbacks that are the exact shape of Lucid's withScopes(), so a related model's own named scopes are reused rather than redefined.

It composes after jsonApi.query() with Lucid's own withScopes() for the root. The map is read when the preload runs (execution), so chain order does not matter. applyIncludes keeps its old two-argument form working; the new model and preload-scope arguments are optional.

Docs and a real end-to-end example test are included.
@evoactivity evoactivity added the enhancement New feature or request label Jul 31, 2026
The scope map is keyed by the model's relation names, and each callback's scopes argument is the related model's scope bag, matching withScopes(): a wrong relation name or an undefined scope is a compile error. Deeper includes are constrained by nesting a preload of their own, typed to the next model down.

This replaces the flat, any-depth-by-name shape (which could not be typed, and could apply a scope to a same-named relation on another branch) with a structural tree walked alongside the include tree. applyIncludes stays backward compatible.
@evoactivity
evoactivity merged commit 8d25985 into main Jul 31, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant