From 7dac195b51e55c273ead8bbe1d32fd11df5bd77c Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Fri, 31 Jul 2026 15:38:23 +0100 Subject: [PATCH 1/2] Add withPreloadScopes to constrain included relations 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. --- .github/workflows/plan-release.yml | 2 +- .github/workflows/publish.yml | 2 +- docs/low-level.md | 6 +- docs/reading-data.md | 36 ++++++ docs/reference.md | 2 + .../controllers/preload_scopes_controller.ts | 21 ++++ examples/blog/app/models/comment.ts | 7 +- .../1769000000002_create_comments_table.ts | 1 + examples/blog/database/schema.ts | 12 +- examples/blog/start/routes.ts | 2 + .../functional/jsonapi_preload_scopes.spec.ts | 63 ++++++++++ providers/jsonapi_provider.ts | 40 ++++++ src/context.ts | 5 +- src/lucid_access.ts | 8 ++ src/query.ts | 67 +++++++++- tests/unit/preload_scopes.spec.ts | 117 ++++++++++++++++++ 16 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 examples/blog/app/controllers/preload_scopes_controller.ts create mode 100644 examples/blog/tests/functional/jsonapi_preload_scopes.spec.ts create mode 100644 tests/unit/preload_scopes.spec.ts diff --git a/.github/workflows/plan-release.yml b/.github/workflows/plan-release.yml index b2843ad..196efda 100644 --- a/.github/workflows/plan-release.yml +++ b/.github/workflows/plan-release.yml @@ -49,7 +49,7 @@ jobs: name: Create Prepare Release PR with: commit-message: "Prepare Release ${{ steps.explanation.outputs.new-version}} using 'release-plan'" - labels: "internal" + labels: 'internal' sign-commits: true branch: release-preview title: Prepare Release ${{ steps.explanation.outputs.new-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 77725c0..e7d5fae 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ concurrency: jobs: publish: - name: "NPM Publish" + name: 'NPM Publish' runs-on: ubuntu-latest permissions: contents: write diff --git a/docs/low-level.md b/docs/low-level.md index 86095ac..710b925 100644 --- a/docs/low-level.md +++ b/docs/low-level.md @@ -105,7 +105,11 @@ Inside a request, links are namespaced by the route that served it. Outside a re Useful for queue-delivered payloads or webhook bodies that carry JSON:API documents: ```ts -import { deserializeResourceDocument, verifyRelatedExist, JsonApiRegistry } from '@evoactivity/jsonapi-adonis' +import { + deserializeResourceDocument, + verifyRelatedExist, + JsonApiRegistry, +} from '@evoactivity/jsonapi-adonis' const registry = await app.container.make(JsonApiRegistry) const input = deserializeResourceDocument(Article, registry, payload, { diff --git a/docs/reading-data.md b/docs/reading-data.md index c4cb919..81e7033 100644 --- a/docs/reading-data.md +++ b/docs/reading-data.md @@ -203,6 +203,42 @@ The rules: - Filters compose with everything else: `?filter[author]=7&filter[search]=lucid&sort=-createdAt&page[size]=10`. - The declaration doubles as documentation. The resource class _is_ the list of what your API's query surface supports. +## Scopes on reads + +Filters are client input. Visibility is not: some rows a client must never see, whatever it asks for. Express that with Lucid model scopes, applied at read time, at the call site, so it stays a deliberate decision on every endpoint rather than hidden magic. + +Define the rule once, on the model, as a Lucid scope: + +```ts +import { BaseModel, scope } from '@adonisjs/lucid/orm' + +class Comment extends BaseModel { + static published = scope((query) => query.where('published', true)) +} +``` + +Apply it to the primary data with Lucid's own `withScopes()`, and to included relations with `withPreloadScopes()`, keyed by relation name: + +```ts +const articles = await jsonApi + .query(Article) + .withScopes((scopes) => scopes.published()) // the articles themselves + .withPreloadScopes({ + comments: (scopes) => scopes.published(), // ?include=comments + author: (scopes) => scopes.active(), // ?include=…author, at any depth + }) + .paginate(...jsonApi.page) + +return jsonApi.render(articles) +``` + +- `withScopes()` is Lucid's own; it constrains the root query. Nothing library-specific. +- `withPreloadScopes()` is what this package adds. The include preloads are built for you from `?include=`, so you cannot reach them at the call site; this map lets you scope them. Each callback is the exact shape of a `withScopes()` callback, so you reuse the related model's own named scopes rather than re-expressing the rule. +- **Keyed by relation name, applied at any depth.** A `comments` entry constrains comments whether they are included directly or nested under another path. A relation with no entry is left unconstrained. +- **Order in the chain does not matter.** Preload constraints run when Lucid loads the relation, at execution, so `withPreloadScopes()` may come before or after other builder calls. + +This is deliberately explicit and per-query. Visibility is a security concern, and a per-endpoint decision keeps it in plain sight in the code, rather than buried in a resource default that a new endpoint silently inherits or silently forgets. When several endpoints share a rule, factor the map into a shared helper; do not hide it. + --- Next: [Writing data](./writing-data.md) · [Links](./links.md) · [Errors & negotiation](./errors.md) · [Reference](./reference.md) diff --git a/docs/reference.md b/docs/reference.md index 8334a28..62e1d50 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -19,6 +19,8 @@ Everything hangs off the `jsonApi` context property, installed by the provider. | `handlesErrors()` | Whether this request's errors should render as JSON:API documents | | `links` | The request's `LinkBuilder` (rarely needed directly) | +The builder returned by `query(Model)` is a normal Lucid query builder: chain `withScopes()` to constrain the primary data and `withPreloadScopes()` to constrain included relations. See [Scopes on reads](./reading-data.md#scopes-on-reads). + Lower-level building blocks (`DocumentBuilder`, `JsonApiRegistry`, `parseQueryParams`, `deserializeResourceDocument`, `toErrorDocument`, …) are all exported from `@evoactivity/jsonapi-adonis` if you need to assemble custom behavior. See [Low-level building blocks](./low-level.md) for how to use them outside a request. ## Configuration diff --git a/examples/blog/app/controllers/preload_scopes_controller.ts b/examples/blog/app/controllers/preload_scopes_controller.ts new file mode 100644 index 0000000..39de0a1 --- /dev/null +++ b/examples/blog/app/controllers/preload_scopes_controller.ts @@ -0,0 +1,21 @@ +import Article from '#models/article' +import type { HttpContext } from '@adonisjs/core/http' + +/** + * Demonstrates withPreloadScopes end to end: the article itself is + * unconstrained, but its included comments are constrained to Comment's + * `published` scope, at any depth they are requested. + */ +export default class PreloadScopesController { + async show({ jsonApi, params }: HttpContext) { + const article = await jsonApi + .query(Article) + .where('id', params.id) + .withPreloadScopes({ + comments: (scopes) => scopes.published(), + }) + .firstOrFail() + + return jsonApi.render(article) + } +} diff --git a/examples/blog/app/models/comment.ts b/examples/blog/app/models/comment.ts index 9a743b9..2d3e093 100644 --- a/examples/blog/app/models/comment.ts +++ b/examples/blog/app/models/comment.ts @@ -1,10 +1,15 @@ import { CommentSchema } from '#database/schema' -import { belongsTo } from '@adonisjs/lucid/orm' +import { belongsTo, scope } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' import User from '#models/user' import Article from '#models/article' export default class Comment extends CommentSchema { + /** Only published comments are visible to readers. */ + static published = scope((query) => { + query.where('published', true) + }) + @belongsTo(() => Article) declare article: BelongsTo diff --git a/examples/blog/database/migrations/1769000000002_create_comments_table.ts b/examples/blog/database/migrations/1769000000002_create_comments_table.ts index 8d7e18d..96eb4ae 100644 --- a/examples/blog/database/migrations/1769000000002_create_comments_table.ts +++ b/examples/blog/database/migrations/1769000000002_create_comments_table.ts @@ -7,6 +7,7 @@ export default class extends BaseSchema { this.schema.createTable(this.tableName, (table) => { table.increments('id').notNullable() table.text('body').notNullable() + table.boolean('published').notNullable().defaultTo(true) table .integer('article_id') .unsigned() diff --git a/examples/blog/database/schema.ts b/examples/blog/database/schema.ts index 0479557..cb8d6d9 100644 --- a/examples/blog/database/schema.ts +++ b/examples/blog/database/schema.ts @@ -76,7 +76,15 @@ export class AuthAccessTokenSchema extends BaseModel { } export class CommentSchema extends BaseModel { - static $columns = ['articleId', 'authorId', 'body', 'createdAt', 'id', 'updatedAt'] as const + static $columns = [ + 'articleId', + 'authorId', + 'body', + 'createdAt', + 'id', + 'published', + 'updatedAt', + ] as const $columns = CommentSchema.$columns @column() declare articleId: number @@ -88,6 +96,8 @@ export class CommentSchema extends BaseModel { declare createdAt: DateTime @column({ isPrimary: true }) declare id: number + @column() + declare published: boolean @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null } diff --git a/examples/blog/start/routes.ts b/examples/blog/start/routes.ts index 72a4d3a..3471e6b 100644 --- a/examples/blog/start/routes.ts +++ b/examples/blog/start/routes.ts @@ -15,6 +15,7 @@ const AccessTokensController = () => import('#controllers/access_tokens_controll const ProfileController = () => import('#controllers/profile_controller') const ArticlesController = () => import('#controllers/articles_controller') const ArticleRelationshipsController = () => import('#controllers/article_relationships_controller') +const PreloadScopesController = () => import('#controllers/preload_scopes_controller') router.get('/', () => { return { hello: 'world' } @@ -45,6 +46,7 @@ router resource: ArticlesController, relationships: ArticleRelationshipsController, }) + router.get('scoped-articles/:id', [PreloadScopesController, 'show']) }) .as('jsonapi') .use(middleware.jsonApi()) diff --git a/examples/blog/tests/functional/jsonapi_preload_scopes.spec.ts b/examples/blog/tests/functional/jsonapi_preload_scopes.spec.ts new file mode 100644 index 0000000..44441be --- /dev/null +++ b/examples/blog/tests/functional/jsonapi_preload_scopes.spec.ts @@ -0,0 +1,63 @@ +/** + * End-to-end proof that withPreloadScopes constrains an included relation + * through a real HTTP request, real Lucid query builder, and real SQL: the + * macro registers, the chain keeps one builder instance, and the scope is + * read when the preload runs. + */ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import User from '#models/user' +import Article from '#models/article' +import Comment from '#models/comment' + +interface Resource { + type: string + attributes: { body: string } +} + +async function seed() { + const author = await User.create({ + fullName: 'Ann Author', + email: 'ann@example.com', + password: 'secret123', + }) + const article = await Article.create({ + title: 'Scoped includes', + body: 'A body.', + authorId: author.id, + }) + await Comment.createMany([ + { body: 'Visible', articleId: article.id, authorId: author.id, published: true }, + { body: 'Hidden', articleId: article.id, authorId: author.id, published: false }, + ]) + return { article } +} + +function comments(body: { included?: Resource[] }): Resource[] { + return (body.included ?? []).filter((resource) => resource.type === 'comments') +} + +test.group('withPreloadScopes end to end', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('constrains an included relation to a model scope', async ({ client, assert }) => { + const { article } = await seed() + + const response = await client.get(`/api/v1/scoped-articles/${article.id}?include=comments`) + + response.assertStatus(200) + const included = comments(response.body()) + assert.lengthOf(included, 1) + assert.equal(included[0].attributes.body, 'Visible') + }) + + test('the same include is unfiltered without a preload scope', async ({ client, assert }) => { + const { article } = await seed() + + const response = await client.get(`/api/v1/articles/${article.id}?include=comments`) + + response.assertStatus(200) + // proves the scope, not empty data, is what filtered the scoped endpoint + assert.lengthOf(comments(response.body()), 2) + }) +}) diff --git a/providers/jsonapi_provider.ts b/providers/jsonapi_provider.ts index caab86d..1c3b08e 100644 --- a/providers/jsonapi_provider.ts +++ b/providers/jsonapi_provider.ts @@ -1,8 +1,10 @@ import type { ApplicationService } from '@adonisjs/core/types' import { HttpContext } from '@adonisjs/core/http' +import { ModelQueryBuilder } from '@adonisjs/lucid/orm' import { JsonApiRegistry } from '../src/registry.ts' import { JsonApiRequestContext } from '../src/context.ts' import { defineConfig, type ResolvedJsonApiConfig } from '../src/define_config.ts' +import { addPreloadScopes, type PreloadScopeMap } from '../src/query.ts' import { registerJsonApiResource, type JsonApiResourceControllers, @@ -50,6 +52,26 @@ export default class JsonApiProvider { ) { registerJsonApiResource(this, type, controllers, options) } + + /** + * Constrains the preload query of included relations, keyed by relation + * name and applied at any depth. Chains after jsonApi.query() and + * composes with Lucid's own withScopes() (which handles the root): + * + * ```ts + * await jsonApi + * .query(Article) + * .withScopes((s) => s.published()) + * .withPreloadScopes({ comments: (s) => s.published() }) + * ``` + */ + ModelQueryBuilder.macro( + 'withPreloadScopes', + function (this: ModelQueryBuilder, scopes: PreloadScopeMap) { + addPreloadScopes(this, scopes) + return this + } + ) } /** @@ -81,3 +103,21 @@ declare module '@adonisjs/core/types' { ): void } } + +/** + * Two augmentations: the contract is what callers hold off jsonApi.query() + * and Lucid's chainable methods, so they need the method there; the + * concrete class is what Macroable.macro() keys off (name must be a key of + * the instance type), so registering the macro needs it there too. + */ +declare module '@adonisjs/lucid/types/model' { + interface ModelQueryBuilderContract> { + withPreloadScopes(scopes: PreloadScopeMap): this + } +} + +declare module '@adonisjs/lucid/orm' { + interface ModelQueryBuilder { + withPreloadScopes(scopes: PreloadScopeMap): this + } +} diff --git a/src/context.ts b/src/context.ts index d5fc449..bd65881 100644 --- a/src/context.ts +++ b/src/context.ts @@ -6,7 +6,7 @@ import { parseQueryParams } from './params.ts' import { DocumentBuilder, type Paginatorish } from './document_builder.ts' import { loadRelation, relatedClient, type DynamicModelQuery } from './lucid_access.ts' import { LinkBuilder, type RouterContract } from './links.ts' -import { applyIncludes, applySort, validateIncludeTree } from './query.ts' +import { applyIncludes, applySort, preloadScopesFor, validateIncludeTree } from './query.ts' import { applyFilters, type FilterQuery } from './filters.ts' import { deserializeResourceDocument, @@ -131,7 +131,8 @@ export class JsonApiRequestContext { * single place the bridge happens. */ const dynamicQuery = query as unknown as DynamicModelQuery & FilterQuery - applyIncludes(dynamicQuery, this.params.include) + const preloadScopes = preloadScopesFor(query) + applyIncludes(dynamicQuery, this.params.include, model, preloadScopes) applySort(dynamicQuery, model, this.params.sort) applyFilters(dynamicQuery, model, this.#registry.resourceFor(model), this.params.filter) return query diff --git a/src/lucid_access.ts b/src/lucid_access.ts index d149c59..3bcc588 100644 --- a/src/lucid_access.ts +++ b/src/lucid_access.ts @@ -56,6 +56,13 @@ export function setAttribute(row: LucidRow, attribute: string, value: unknown): ;(row as unknown as Record)[attribute] = value } +/** + * The runtime shape of Lucid's scopes bag, the object passed to + * withScopes(). Named scopes are methods on it; we invoke them by runtime + * name without knowing their signatures. + */ +export type DynamicScopes = Record unknown> + /** * The structural slice of Lucid's model query builder this package drives * with runtime relation/column names. Lucid's own contract types preload() @@ -65,4 +72,5 @@ export function setAttribute(row: LucidRow, attribute: string, value: unknown): export type DynamicModelQuery = { preload(relation: string, callback?: (query: DynamicModelQuery) => void): unknown orderBy(column: string, direction: 'asc' | 'desc'): unknown + withScopes(callback: (scopes: DynamicScopes) => void): unknown } diff --git a/src/query.ts b/src/query.ts index 4b2dc7c..635a358 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,10 +1,55 @@ import type { LucidModel } from '@adonisjs/lucid/types/model' import type { IncludeTree, SortField } from './types.ts' -import type { DynamicModelQuery } from './lucid_access.ts' +import type { DynamicModelQuery, DynamicScopes } from './lucid_access.ts' import type { JsonApiRegistry } from './registry.ts' import { isRelationExposed } from './resource.ts' import { JsonApiException } from './errors.ts' +/** + * A per-relation constraint applied to an included relation's preload + * query. The callback is the exact shape of Lucid's withScopes() callback, + * so a developer reuses the related model's own named scopes: + * `{ episodes: (scopes) => scopes.published() }`. + */ +export type PreloadScope = (scopes: DynamicScopes) => void + +/** + * Relation name → preload scope. Keyed by relation name, applied wherever + * that relation is preloaded in an include tree, at any depth. + */ +export type PreloadScopeMap = Record + +/** + * Preload-scope maps are attached to a query builder out of band so the + * `withPreloadScopes()` builder macro (which only has `this`) and this + * module (which builds the include preloads) can share one map. A WeakMap + * keyed by the builder keeps it off the builder's own surface and lets it + * be garbage collected with the query. + */ +const preloadScopeMaps = new WeakMap() + +/** + * Creates and registers a fresh preload-scope map for a query builder. + * Called once when the query is built; the returned map is mutated in + * place by later withPreloadScopes() calls and read at preload time. + */ +export function preloadScopesFor(query: object): PreloadScopeMap { + const map: PreloadScopeMap = {} + preloadScopeMaps.set(query, map) + return map +} + +/** + * Merges scopes into a query builder's preload-scope map, the entry point + * for the withPreloadScopes() macro. Safe to call before or after the + * includes are built: the map is read at preload (execution) time. + */ +export function addPreloadScopes(query: object, scopes: PreloadScopeMap): void { + const existing = preloadScopeMaps.get(query) + if (existing) Object.assign(existing, scopes) + else preloadScopeMaps.set(query, { ...scopes }) +} + /** * Validates every path of an include tree against the model's relationship * definitions. The spec requires a 400 when an unsupported include path is @@ -41,12 +86,26 @@ function validateIncludeLevel( } /** - * Applies an include tree as nested preloads on a model query. + * Applies an include tree as nested preloads on a model query. Each + * preloaded relation is constrained by the matching entry in the + * preload-scope map, if any, before recursing, so a scope keyed by + * relation name applies wherever that relation appears in the tree, at any + * depth. The map is read here, inside the preload callback, which Lucid + * invokes at execution time, so scopes added after the query is built + * (a `withPreloadScopes()` chained after `jsonApi.query()`) still apply. */ -export function applyIncludes(query: DynamicModelQuery, tree: IncludeTree): void { +export function applyIncludes( + query: DynamicModelQuery, + tree: IncludeTree, + Model?: LucidModel, + preloadScopes?: PreloadScopeMap +): void { for (const [name, subTree] of Object.entries(tree)) { + const RelatedModel = Model?.$relationsDefinitions.get(name)?.relatedModel() query.preload(name, (subQuery) => { - applyIncludes(subQuery, subTree) + const scope = preloadScopes?.[name] + if (scope) subQuery.withScopes(scope) + applyIncludes(subQuery, subTree, RelatedModel, preloadScopes) }) } } diff --git a/tests/unit/preload_scopes.spec.ts b/tests/unit/preload_scopes.spec.ts new file mode 100644 index 0000000..d908426 --- /dev/null +++ b/tests/unit/preload_scopes.spec.ts @@ -0,0 +1,117 @@ +import { test } from '@japa/runner' +import { + applyIncludes, + preloadScopesFor, + addPreloadScopes, + type PreloadScope, + type PreloadScopeMap, +} from '../../src/query.ts' +import { Article } from '../fixtures/models.ts' + +/** + * Records withScopes calls and defers preload callbacks: preload() stores + * the callback (as Lucid does) and runPreloads() invokes them, mirroring + * Lucid running preload constraints at execution time. That lets a test + * add scopes to the map after applyIncludes and still see them applied. + */ +interface StubQuery { + calls: unknown[][] + preloads: Record + withScopes(callback: PreloadScope): StubQuery + preload(name: string, callback: (child: StubQuery) => void): StubQuery + runPreloads(): void +} + +function stubQuery(): StubQuery { + const calls: unknown[][] = [] + const preloads: Record = {} + const pending: Record void> = {} + const query: StubQuery = { + calls, + preloads, + withScopes(callback) { + calls.push(['withScopes', callback]) + return query + }, + preload(name, callback) { + pending[name] = callback + return query + }, + runPreloads() { + for (const [name, callback] of Object.entries(pending)) { + const child = stubQuery() + preloads[name] = child + callback(child) + child.runPreloads() + } + }, + } + return query +} + +function anyQuery(query: StubQuery) { + return query as unknown as Parameters[0] +} + +const commentsScope: PreloadScope = (scopes) => { + scopes.published() +} +const authorScope: PreloadScope = (scopes) => { + scopes.active() +} + +test.group('applyIncludes preload scopes', () => { + test('applies the matching scope to a relation via withScopes', ({ assert }) => { + const root = stubQuery() + const map: PreloadScopeMap = { comments: commentsScope } + applyIncludes(anyQuery(root), { comments: {} }, Article, map) + root.runPreloads() + + assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) + }) + + test('applies scopes at any depth, keyed by relation name', ({ assert }) => { + const root = stubQuery() + const map: PreloadScopeMap = { author: authorScope } + applyIncludes(anyQuery(root), { comments: { author: {} } }, Article, map) + root.runPreloads() + + // comments has no scope in the map + assert.deepEqual(root.preloads.comments.calls, []) + // author, nested under comments, gets its scope + assert.deepEqual(root.preloads.comments.preloads.author.calls, [['withScopes', authorScope]]) + }) + + test('a relation with no scope is left untouched', ({ assert }) => { + const root = stubQuery() + applyIncludes(anyQuery(root), { comments: {}, tags: {} }, Article, {}) + root.runPreloads() + + assert.deepEqual(root.preloads.comments.calls, []) + assert.deepEqual(root.preloads.tags.calls, []) + }) + + test('reads the map at preload time, so scopes added after apply still count', ({ assert }) => { + const root = stubQuery() + const map: PreloadScopeMap = {} + // includes built first, with an empty map — as jsonApi.query() does + applyIncludes(anyQuery(root), { comments: {} }, Article, map) + // scope added afterwards — as a chained withPreloadScopes() would + map.comments = commentsScope + root.runPreloads() + + assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) + }) +}) + +test.group('preload scope map', () => { + test('addPreloadScopes merges into the builder map from preloadScopesFor', ({ assert }) => { + // the real flow: query() calls preloadScopesFor once, the macro merges + const builder = {} + const map = preloadScopesFor(builder) + addPreloadScopes(builder, { comments: commentsScope }) + addPreloadScopes(builder, { author: authorScope }) + + assert.deepEqual(map, { comments: commentsScope, author: authorScope }) + }) +}) From 15da8bb36fa642741d89ab6b19cf024682935795 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Fri, 31 Jul 2026 16:01:07 +0100 Subject: [PATCH 2/2] Type withPreloadScopes per relation and support nested includes 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. --- docs/reading-data.md | 23 ++++++-- providers/jsonapi_provider.ts | 8 +-- src/query.ts | 95 ++++++++++++++++++++----------- tests/unit/preload_scopes.spec.ts | 50 +++++++++------- 4 files changed, 116 insertions(+), 60 deletions(-) diff --git a/docs/reading-data.md b/docs/reading-data.md index 81e7033..4f5b6e6 100644 --- a/docs/reading-data.md +++ b/docs/reading-data.md @@ -217,7 +217,7 @@ class Comment extends BaseModel { } ``` -Apply it to the primary data with Lucid's own `withScopes()`, and to included relations with `withPreloadScopes()`, keyed by relation name: +Apply it to the primary data with Lucid's own `withScopes()`, and to included relations with `withPreloadScopes()`, keyed by the model's relations: ```ts const articles = await jsonApi @@ -225,16 +225,31 @@ const articles = await jsonApi .withScopes((scopes) => scopes.published()) // the articles themselves .withPreloadScopes({ comments: (scopes) => scopes.published(), // ?include=comments - author: (scopes) => scopes.active(), // ?include=…author, at any depth + author: (scopes) => scopes.active(), // ?include=author }) .paginate(...jsonApi.page) return jsonApi.render(articles) ``` +The map is **fully typed**: keys autocomplete to `Article`'s relations, and each callback's `scopes` is the related model's scope bag, exactly like `withScopes()`. A wrong relation name or a scope that model does not define is a compile error. + +For nested includes, give the value an object with a `preload` of its own, typed to the next model down: + +```ts +.withPreloadScopes({ + seasons: { + scope: (scopes) => scopes.visible(), // scopes: Season's + preload: { + episodes: (scopes) => scopes.visible(), // scopes: Episode's + }, + }, +}) +``` + - `withScopes()` is Lucid's own; it constrains the root query. Nothing library-specific. -- `withPreloadScopes()` is what this package adds. The include preloads are built for you from `?include=`, so you cannot reach them at the call site; this map lets you scope them. Each callback is the exact shape of a `withScopes()` callback, so you reuse the related model's own named scopes rather than re-expressing the rule. -- **Keyed by relation name, applied at any depth.** A `comments` entry constrains comments whether they are included directly or nested under another path. A relation with no entry is left unconstrained. +- `withPreloadScopes()` is what this package adds. The include preloads are built for you from `?include=`, so you cannot reach them at the call site; this constrains them. Each callback is the exact shape of a `withScopes()` callback, so you reuse the related model's own named scopes rather than re-expressing the rule. +- **Structural, typed at every level.** 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. A relation with no entry is left unconstrained. - **Order in the chain does not matter.** Preload constraints run when Lucid loads the relation, at execution, so `withPreloadScopes()` may come before or after other builder calls. This is deliberately explicit and per-query. Visibility is a security concern, and a per-endpoint decision keeps it in plain sight in the code, rather than buried in a resource default that a new endpoint silently inherits or silently forgets. When several endpoints share a rule, factor the map into a shared helper; do not hide it. diff --git a/providers/jsonapi_provider.ts b/providers/jsonapi_provider.ts index 1c3b08e..86b35e0 100644 --- a/providers/jsonapi_provider.ts +++ b/providers/jsonapi_provider.ts @@ -4,7 +4,7 @@ import { ModelQueryBuilder } from '@adonisjs/lucid/orm' import { JsonApiRegistry } from '../src/registry.ts' import { JsonApiRequestContext } from '../src/context.ts' import { defineConfig, type ResolvedJsonApiConfig } from '../src/define_config.ts' -import { addPreloadScopes, type PreloadScopeMap } from '../src/query.ts' +import { addPreloadScopes, type PreloadScopeMap, type PreloadScopeTree } from '../src/query.ts' import { registerJsonApiResource, type JsonApiResourceControllers, @@ -67,7 +67,7 @@ export default class JsonApiProvider { */ ModelQueryBuilder.macro( 'withPreloadScopes', - function (this: ModelQueryBuilder, scopes: PreloadScopeMap) { + function (this: ModelQueryBuilder, scopes: PreloadScopeTree) { addPreloadScopes(this, scopes) return this } @@ -112,12 +112,12 @@ declare module '@adonisjs/core/types' { */ declare module '@adonisjs/lucid/types/model' { interface ModelQueryBuilderContract> { - withPreloadScopes(scopes: PreloadScopeMap): this + withPreloadScopes(scopes: PreloadScopeMap): this } } declare module '@adonisjs/lucid/orm' { interface ModelQueryBuilder { - withPreloadScopes(scopes: PreloadScopeMap): this + withPreloadScopes(scopes: PreloadScopeTree): this } } diff --git a/src/query.ts b/src/query.ts index 635a358..926e68d 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,4 +1,5 @@ -import type { LucidModel } from '@adonisjs/lucid/types/model' +import type { ExtractScopes, LucidModel } from '@adonisjs/lucid/types/model' +import type { ExtractModelRelations } from '@adonisjs/lucid/types/relations' import type { IncludeTree, SortField } from './types.ts' import type { DynamicModelQuery, DynamicScopes } from './lucid_access.ts' import type { JsonApiRegistry } from './registry.ts' @@ -6,48 +7,74 @@ import { isRelationExposed } from './resource.ts' import { JsonApiException } from './errors.ts' /** - * A per-relation constraint applied to an included relation's preload - * query. The callback is the exact shape of Lucid's withScopes() callback, - * so a developer reuses the related model's own named scopes: - * `{ episodes: (scopes) => scopes.published() }`. + * The related model behind a relation property. Every Lucid relation type + * (HasMany, BelongsTo, …) carries its related model constructor as `model`. */ -export type PreloadScope = (scopes: DynamicScopes) => void +type RelatedModelOf = + NonNullable extends { model: infer Model extends LucidModel } ? Model : never + +/** + * A scope for one relation, typed to that relation's related model so its + * named scopes autocomplete, exactly like Lucid's withScopes() callback. + * Either a bare callback (scope this relation) or an object that also + * carries scopes for deeper includes. + */ +export type PreloadScopeEntry = + | ((scopes: ExtractScopes) => void) + | { + scope?: (scopes: ExtractScopes) => void + preload?: PreloadScopeMap + } /** - * Relation name → preload scope. Keyed by relation name, applied wherever - * that relation is preloaded in an include tree, at any depth. + * The argument to withPreloadScopes(): keyed by the model's relation names, + * each entry typed to that relation's related model. Recurse through + * `preload` to constrain nested includes, typed at every level. */ -export type PreloadScopeMap = Record +export type PreloadScopeMap = { + [Key in ExtractModelRelations>]?: PreloadScopeEntry< + RelatedModelOf, Key> + > +} + +/** + * The runtime, model-agnostic view of a preload-scope tree, mirroring the + * include tree. The public PreloadScopeMap narrows this per model + * for the caller; internally we walk this loose shape. + */ +export type PreloadScope = (scopes: DynamicScopes) => void +export type PreloadScopeNode = PreloadScope | { scope?: PreloadScope; preload?: PreloadScopeTree } +export type PreloadScopeTree = Record /** - * Preload-scope maps are attached to a query builder out of band so the + * Preload-scope trees are attached to a query builder out of band so the * `withPreloadScopes()` builder macro (which only has `this`) and this - * module (which builds the include preloads) can share one map. A WeakMap + * module (which builds the include preloads) can share one tree. A WeakMap * keyed by the builder keeps it off the builder's own surface and lets it * be garbage collected with the query. */ -const preloadScopeMaps = new WeakMap() +const preloadScopeTrees = new WeakMap() /** - * Creates and registers a fresh preload-scope map for a query builder. - * Called once when the query is built; the returned map is mutated in + * Creates and registers a fresh preload-scope tree for a query builder. + * Called once when the query is built; the returned tree is mutated in * place by later withPreloadScopes() calls and read at preload time. */ -export function preloadScopesFor(query: object): PreloadScopeMap { - const map: PreloadScopeMap = {} - preloadScopeMaps.set(query, map) - return map +export function preloadScopesFor(query: object): PreloadScopeTree { + const tree: PreloadScopeTree = {} + preloadScopeTrees.set(query, tree) + return tree } /** - * Merges scopes into a query builder's preload-scope map, the entry point + * Merges scopes into a query builder's preload-scope tree, the entry point * for the withPreloadScopes() macro. Safe to call before or after the - * includes are built: the map is read at preload (execution) time. + * includes are built: the tree is read at preload (execution) time. */ -export function addPreloadScopes(query: object, scopes: PreloadScopeMap): void { - const existing = preloadScopeMaps.get(query) +export function addPreloadScopes(query: object, scopes: PreloadScopeTree): void { + const existing = preloadScopeTrees.get(query) if (existing) Object.assign(existing, scopes) - else preloadScopeMaps.set(query, { ...scopes }) + else preloadScopeTrees.set(query, { ...scopes }) } /** @@ -86,26 +113,28 @@ function validateIncludeLevel( } /** - * Applies an include tree as nested preloads on a model query. Each - * preloaded relation is constrained by the matching entry in the - * preload-scope map, if any, before recursing, so a scope keyed by - * relation name applies wherever that relation appears in the tree, at any - * depth. The map is read here, inside the preload callback, which Lucid - * invokes at execution time, so scopes added after the query is built - * (a `withPreloadScopes()` chained after `jsonApi.query()`) still apply. + * Applies an include tree as nested preloads on a model query, walking the + * preload-scope tree alongside it. Each relation is constrained by its + * entry's scope (a bare callback, or the `scope` of an object entry), and + * the entry's `preload` carries scopes for the next level down. The tree is + * read here, inside the preload callback, which Lucid invokes at execution + * time, so scopes added after the query is built (a `withPreloadScopes()` + * chained after `jsonApi.query()`) still apply. */ export function applyIncludes( query: DynamicModelQuery, tree: IncludeTree, Model?: LucidModel, - preloadScopes?: PreloadScopeMap + scopeTree?: PreloadScopeTree ): void { for (const [name, subTree] of Object.entries(tree)) { const RelatedModel = Model?.$relationsDefinitions.get(name)?.relatedModel() query.preload(name, (subQuery) => { - const scope = preloadScopes?.[name] + const entry = scopeTree?.[name] + const scope = typeof entry === 'function' ? entry : entry?.scope + const childScopes = typeof entry === 'function' ? undefined : entry?.preload if (scope) subQuery.withScopes(scope) - applyIncludes(subQuery, subTree, RelatedModel, preloadScopes) + applyIncludes(subQuery, subTree, RelatedModel, childScopes) }) } } diff --git a/tests/unit/preload_scopes.spec.ts b/tests/unit/preload_scopes.spec.ts index d908426..def99c1 100644 --- a/tests/unit/preload_scopes.spec.ts +++ b/tests/unit/preload_scopes.spec.ts @@ -4,7 +4,7 @@ import { preloadScopesFor, addPreloadScopes, type PreloadScope, - type PreloadScopeMap, + type PreloadScopeTree, } from '../../src/query.ts' import { Article } from '../fixtures/models.ts' @@ -61,28 +61,40 @@ const authorScope: PreloadScope = (scopes) => { } test.group('applyIncludes preload scopes', () => { - test('applies the matching scope to a relation via withScopes', ({ assert }) => { + test('applies a bare-callback entry to a relation via withScopes', ({ assert }) => { const root = stubQuery() - const map: PreloadScopeMap = { comments: commentsScope } - applyIncludes(anyQuery(root), { comments: {} }, Article, map) + const tree: PreloadScopeTree = { comments: commentsScope } + applyIncludes(anyQuery(root), { comments: {} }, Article, tree) root.runPreloads() assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) }) - test('applies scopes at any depth, keyed by relation name', ({ assert }) => { + test('descends preload to scope nested includes, typed per level', ({ assert }) => { const root = stubQuery() - const map: PreloadScopeMap = { author: authorScope } - applyIncludes(anyQuery(root), { comments: { author: {} } }, Article, map) + // structural: scope comments, and its nested author, by path + const tree: PreloadScopeTree = { + comments: { scope: commentsScope, preload: { author: authorScope } }, + } + applyIncludes(anyQuery(root), { comments: { author: {} } }, Article, tree) root.runPreloads() - // comments has no scope in the map + assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) + assert.deepEqual(root.preloads.comments.preloads.author.calls, [['withScopes', authorScope]]) + }) + + test('an object entry with no scope only descends', ({ assert }) => { + const root = stubQuery() + const tree: PreloadScopeTree = { comments: { preload: { author: authorScope } } } + applyIncludes(anyQuery(root), { comments: { author: {} } }, Article, tree) + root.runPreloads() + + // comments itself is unscoped; only its nested author is scoped assert.deepEqual(root.preloads.comments.calls, []) - // author, nested under comments, gets its scope assert.deepEqual(root.preloads.comments.preloads.author.calls, [['withScopes', authorScope]]) }) - test('a relation with no scope is left untouched', ({ assert }) => { + test('a relation with no entry is left untouched', ({ assert }) => { const root = stubQuery() applyIncludes(anyQuery(root), { comments: {}, tags: {} }, Article, {}) root.runPreloads() @@ -91,27 +103,27 @@ test.group('applyIncludes preload scopes', () => { assert.deepEqual(root.preloads.tags.calls, []) }) - test('reads the map at preload time, so scopes added after apply still count', ({ assert }) => { + test('reads the tree at preload time, so scopes added after apply still count', ({ assert }) => { const root = stubQuery() - const map: PreloadScopeMap = {} - // includes built first, with an empty map — as jsonApi.query() does - applyIncludes(anyQuery(root), { comments: {} }, Article, map) + const tree: PreloadScopeTree = {} + // includes built first, with an empty tree — as jsonApi.query() does + applyIncludes(anyQuery(root), { comments: {} }, Article, tree) // scope added afterwards — as a chained withPreloadScopes() would - map.comments = commentsScope + tree.comments = commentsScope root.runPreloads() assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) }) }) -test.group('preload scope map', () => { - test('addPreloadScopes merges into the builder map from preloadScopesFor', ({ assert }) => { +test.group('preload scope tree', () => { + test('addPreloadScopes merges into the builder tree from preloadScopesFor', ({ assert }) => { // the real flow: query() calls preloadScopesFor once, the macro merges const builder = {} - const map = preloadScopesFor(builder) + const tree = preloadScopesFor(builder) addPreloadScopes(builder, { comments: commentsScope }) addPreloadScopes(builder, { author: authorScope }) - assert.deepEqual(map, { comments: commentsScope, author: authorScope }) + assert.deepEqual(tree, { comments: commentsScope, author: authorScope }) }) })