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..4f5b6e6 100644 --- a/docs/reading-data.md +++ b/docs/reading-data.md @@ -203,6 +203,57 @@ 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 the model's relations: + +```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 + }) + .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 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. + --- 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..86b35e0 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, type PreloadScopeTree } 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: PreloadScopeTree) { + 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: PreloadScopeTree): 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..926e68d 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,10 +1,82 @@ -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 } 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' +/** + * The related model behind a relation property. Every Lucid relation type + * (HasMany, BelongsTo, …) carries its related model constructor as `model`. + */ +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 + } + +/** + * 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 = { + [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 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 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 preloadScopeTrees = new WeakMap() + +/** + * 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): PreloadScopeTree { + const tree: PreloadScopeTree = {} + preloadScopeTrees.set(query, tree) + return tree +} + +/** + * 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 tree is read at preload (execution) time. + */ +export function addPreloadScopes(query: object, scopes: PreloadScopeTree): void { + const existing = preloadScopeTrees.get(query) + if (existing) Object.assign(existing, scopes) + else preloadScopeTrees.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 +113,28 @@ function validateIncludeLevel( } /** - * Applies an include tree as nested preloads on a model query. + * 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): void { +export function applyIncludes( + query: DynamicModelQuery, + tree: IncludeTree, + Model?: LucidModel, + scopeTree?: PreloadScopeTree +): void { for (const [name, subTree] of Object.entries(tree)) { + const RelatedModel = Model?.$relationsDefinitions.get(name)?.relatedModel() query.preload(name, (subQuery) => { - applyIncludes(subQuery, subTree) + 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, childScopes) }) } } diff --git a/tests/unit/preload_scopes.spec.ts b/tests/unit/preload_scopes.spec.ts new file mode 100644 index 0000000..def99c1 --- /dev/null +++ b/tests/unit/preload_scopes.spec.ts @@ -0,0 +1,129 @@ +import { test } from '@japa/runner' +import { + applyIncludes, + preloadScopesFor, + addPreloadScopes, + type PreloadScope, + type PreloadScopeTree, +} 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 a bare-callback entry to a relation via withScopes', ({ assert }) => { + const root = stubQuery() + const tree: PreloadScopeTree = { comments: commentsScope } + applyIncludes(anyQuery(root), { comments: {} }, Article, tree) + root.runPreloads() + + assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) + }) + + test('descends preload to scope nested includes, typed per level', ({ assert }) => { + const root = stubQuery() + // 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() + + 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, []) + assert.deepEqual(root.preloads.comments.preloads.author.calls, [['withScopes', authorScope]]) + }) + + test('a relation with no entry 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 tree at preload time, so scopes added after apply still count', ({ assert }) => { + const root = stubQuery() + 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 + tree.comments = commentsScope + root.runPreloads() + + assert.deepEqual(root.preloads.comments.calls, [['withScopes', commentsScope]]) + }) +}) + +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 tree = preloadScopesFor(builder) + addPreloadScopes(builder, { comments: commentsScope }) + addPreloadScopes(builder, { author: authorScope }) + + assert.deepEqual(tree, { comments: commentsScope, author: authorScope }) + }) +})