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: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
# changelog, so neither should be hand-formatted.
pnpm-lock.yaml
CHANGELOG.md
# Regenerated unformatted by the example app's migrations
examples/blog/database/schema.ts
7 changes: 7 additions & 0 deletions docs/reading-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ export default class ArticleResource extends JsonApiResource<Article> {
search: filter.custom((query, value) => {
query.where((q) => q.whereILike('title', `%${value}%`).orWhereILike('body', `%${value}%`))
}),

// Handlers also receive { Model, name, ctx }. ctx is the request
// when filtering runs inside one, so a filter can depend on the
// viewer; it is undefined on the low-level path outside a request.
mine: filter.custom((query, _value, { ctx }) => {
query.where('author_id', ctx!.auth.user!.id)
}),
}
}
```
Expand Down
8 changes: 7 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,13 @@ export class JsonApiRequestContext {
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)
applyFilters(
dynamicQuery,
model,
this.#registry.resourceFor(model),
this.params.filter,
this.#ctx
)
return query
}

Expand Down
30 changes: 21 additions & 9 deletions src/filters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { LucidModel, ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import { JsonApiException } from './errors.ts'
import { csvList } from './params.ts'
Expand All @@ -10,16 +11,24 @@ import { resolveColumn } from './query.ts'
*/
export type FilterQuery = ModelQueryBuilderContract<LucidModel>

/**
* What a filter handler knows about where it is being applied. ctx is
* the request when filtering runs inside one, so a handler can depend
* on the viewer (the current user's favourites, say), and undefined on
* the low-level path outside any request.
*/
export type FilterContext = {
Model: LucidModel
name: string
ctx?: HttpContext
}

/**
* A declared filter: receives the model query builder, the raw value from
* `?filter[name]=...` (a string, or an array when the client sent commas),
* and metadata about where it is being applied.
*/
export type FilterHandler = (
query: FilterQuery,
value: unknown,
context: { Model: LucidModel; name: string }
) => void
export type FilterHandler = (query: FilterQuery, value: unknown, context: FilterContext) => void

/**
* Nothing is filterable unless the resource declares it:
Expand Down Expand Up @@ -111,8 +120,10 @@ export const filter = {
* Full control: receive the Lucid query builder and the raw value.
* Scopes, joins and subqueries all work here.
*/
custom(handler: (query: FilterQuery, value: unknown) => void): FilterHandler {
return (query, value) => handler(query, value)
custom(
handler: (query: FilterQuery, value: unknown, context: FilterContext) => void
): FilterHandler {
return handler
},
}

Expand Down Expand Up @@ -147,7 +158,8 @@ export function applyFilters(
query: FilterQuery,
Model: LucidModel,
ResourceClass: { filters?: Record<string, FilterHandler> },
filters: Record<string, unknown>
filters: Record<string, unknown>,
ctx?: HttpContext
): void {
for (const [name, value] of Object.entries(filters)) {
const handler = ResourceClass.filters?.[name]
Expand All @@ -157,6 +169,6 @@ export function applyFilters(
`"${name}" is not a supported filter for ${Model.name}`
)
}
handler(query, value, { Model, name })
handler(query, value, { Model, name, ctx })
}
}
35 changes: 35 additions & 0 deletions tests/unit/filters.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { test } from '@japa/runner'
import { HttpContextFactory } from '@adonisjs/core/factories/http'
import { filter, applyFilters } from '../../src/filters.ts'
import { JsonApiException } from '../../src/errors.ts'
import { JsonApiResource } from '../../src/resource.ts'
import { Article } from '../fixtures/models.ts'

/**
Expand Down Expand Up @@ -126,3 +128,36 @@ test.group('applyFilters policy', () => {
assert.deepEqual(apply({}), [])
})
})

test.group('filter handlers receive the request context', () => {
function capturingResource() {
const captured: { ctx?: unknown } = {}
class FilteredResource extends JsonApiResource<Article> {
static model = () => Article
static filters = {
mine: filter.custom((_query, _value, context) => {
captured.ctx = context.ctx
}),
}
}
return { FilteredResource, captured }
}

test('a custom filter can read the current request', ({ assert }) => {
const { FilteredResource, captured } = capturingResource()
const httpContext = new HttpContextFactory().create()

// the query object is opaque to the handler under test
applyFilters({} as never, Article, FilteredResource, { mine: '1' }, httpContext)

assert.strictEqual(captured.ctx, httpContext)
})

test('the low-level path outside a request leaves ctx undefined', ({ assert }) => {
const { FilteredResource, captured } = capturingResource()

applyFilters({} as never, Article, FilteredResource, { mine: '1' })

assert.isUndefined(captured.ctx)
})
})
Loading