Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plan-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ concurrency:

jobs:
publish:
name: "NPM Publish"
name: 'NPM Publish'
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
6 changes: 5 additions & 1 deletion docs/low-level.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
51 changes: 51 additions & 0 deletions docs/reading-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 2 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions examples/blog/app/controllers/preload_scopes_controller.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 6 additions & 1 deletion examples/blog/app/models/comment.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Article>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 11 additions & 1 deletion examples/blog/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions examples/blog/start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -45,6 +46,7 @@ router
resource: ArticlesController,
relationships: ArticleRelationshipsController,
})
router.get('scoped-articles/:id', [PreloadScopesController, 'show'])
})
.as('jsonapi')
.use(middleware.jsonApi())
Expand Down
63 changes: 63 additions & 0 deletions examples/blog/tests/functional/jsonapi_preload_scopes.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
40 changes: 40 additions & 0 deletions providers/jsonapi_provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
}
)
}

/**
Expand Down Expand Up @@ -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<Model extends LucidModel, Result = InstanceType<Model>> {
withPreloadScopes(scopes: PreloadScopeMap<Model>): this
}
}

declare module '@adonisjs/lucid/orm' {
interface ModelQueryBuilder {
withPreloadScopes(scopes: PreloadScopeTree): this
}
}
5 changes: 3 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/lucid_access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export function setAttribute(row: LucidRow, attribute: string, value: unknown):
;(row as unknown as Record<string, unknown>)[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<string, (...args: unknown[]) => unknown>

/**
* The structural slice of Lucid's model query builder this package drives
* with runtime relation/column names. Lucid's own contract types preload()
Expand All @@ -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
}
Loading