@aginix/adonis-vulcan
v0.1.2
Published
PostgREST-style filtering and resource scaffolding for AdonisJS Lucid ORM
Downloads
687
Maintainers
Readme
@aginix/adonis-vulcan
PostgREST-style filtering and Rapid CRUD controllers for AdonisJS Lucid ORM.
Two layers, each independently usable:
- Filter layer — a query parser that brings the PostgREST horizontal filtering grammar to AdonisJS: comparison operators, logical groups (
and/or), pattern matching, regex, array/range/full-text operators, and embedded resource filters via dot notation. - Rapid CRUD layer —
BaseResourceControllerso a controllerextends BaseResourceControllerships index/show/store/update/destroy with PostgREST filtering, page-based pagination, optional VineJS validators, and integrates with AdonisJS's officialBaseTransformer— all behind a stable{ data }/{ data, metadata }response contract, with override-friendly primitives for Bouncer-style authorization.
Installation
Use node ace add to install and configure the package in one step:
node ace add @aginix/adonis-vulcanThis will:
- Register the package's provider in your
adonisrc.tssoModel.filter(...)becomes available on every Lucid query builder. - Register the package's commands so
node ace make:filterandnode ace make:resource-controllerwork.
If you prefer to install manually:
npm install @aginix/adonis-vulcan
node ace configure @aginix/adonis-vulcanThe package has no runtime dependency on adonis-lucid-filter — BaseModelFilter, Filterable, and the filter() query macro are vendored directly to avoid the broken getDirname import in [email protected] (which fails under @poppinss/utils v6+).
Quick start (filter layer)
1. Scaffold a filter
node ace make:filter CourseThat writes app/models/filters/course_filter.ts:
import { PostgRESTModelFilter } from '@aginix/adonis-vulcan'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import Course from '#models/course'
export default class CourseFilter extends PostgRESTModelFilter {
declare $query: ModelQueryBuilderContract<typeof Course>
// Add custom filter methods here. Each public method becomes a query
// parameter — defining `q(value)` enables `?q=search` in URLs.
}2. Wire the filter to a model
Add the Filterable mixin and a static $filter getter to your model:
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { Filterable } from '@aginix/adonis-vulcan'
import CourseFilter from '#models/filters/course_filter'
export default class Course extends compose(BaseModel, Filterable) {
static $filter = () => CourseFilter
@column({ isPrimary: true })
declare id: string
}3. Use it in a controller (manual)
import Course from '#models/course'
import type { HttpContext } from '@adonisjs/core/http'
export default class CoursesController {
async index({ request }: HttpContext) {
return Course.filter(request.qs()).exec()
}
}4. Query with PostgREST-style URL parameters
GET /courses?status=eq.active&faculty.nameTh=like.*วิศว*Rapid CRUD with BaseResourceController
For a typical resource endpoint you can skip the manual controller. Subclass BaseResourceController, declare a model, and you get index/show/store/update/destroy with PostgREST filtering, pagination, and the standard response envelope.
Minimal controller
node ace make:resource-controller Courseimport Course from '#models/course'
import { BaseResourceController } from '@aginix/adonis-vulcan'
export default class CoursesController extends BaseResourceController<typeof Course> {
protected model = Course
}Wire it to a resource route:
router.resource('courses', '#controllers/courses_controller').apiOnly()You now have:
| Route | Response | Status |
|-------|----------|--------|
| GET /courses | { data: Course[], metadata: {...} } — PostgREST filters + ?page=&perPage= | 200 |
| GET /courses/:id | { data: Course } | 200 |
| POST /courses | { data: Course } | 201 |
| PUT /courses/:id | { data: Course } | 200 |
| DELETE /courses/:id | (empty) | 204 |
metadata comes straight from Lucid's paginator .getMeta().
Full controller with all optional slots
import Course from '#models/course'
import CourseFilter from '#models/filters/course_filter'
import CourseTransformer from '#transformers/course_transformer'
import { createCourseValidator, updateCourseValidator } from '#validators/course'
import { BaseResourceController } from '@aginix/adonis-vulcan'
import type { HttpContext } from '@adonisjs/core/http'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
export default class CoursesController extends BaseResourceController<typeof Course> {
protected model = Course
protected filter = CourseFilter // optional — falls back to Model.filter()
protected transformer = CourseTransformer // pass the CLASS, not new ...
protected validators = {
store: createCourseValidator,
update: updateCourseValidator,
}
protected pagination = { defaultPerPage: 20, maxPerPage: 100 }
// Apply on every list request — tenant scope, default ordering, .preload(), etc.
protected async modifyQuery(
ctx: HttpContext,
query: ModelQueryBuilderContract<typeof Course>
) {
query.where('tenantId', ctx.auth.user!.tenantId).preload('faculty')
}
// Or override an action and compose primitives — `super.<action>()` also works
async index(ctx: HttpContext) {
return this.respondPaginated(ctx, (q) => q.where('published', true))
}
}Transformers
Use AdonisJS's official BaseTransformer — this package does NOT ship its own transformer class:
node ace make:transformer Course// app/transformers/course_transformer.ts
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Course from '#models/course'
export default class CourseTransformer extends BaseTransformer<Course> {
toObject() {
return this.pick(this.resource, ['id', 'nameTh', 'facultyId'])
}
// Variants — call via .useVariant('forDetailedView') in overrides
async forDetailedView() {
return {
...this.toObject(),
description: this.resource.description,
}
}
}Pass the class (not an instance) on the controller's transformer slot. The controller wires:
index→Transformer.paginate(rows, paginator.getMeta())→ serializer →{ data, metadata }show/store/update→Transformer.transform(record)→ serializer →{ data }
When transformer is omitted, the controller hand-shapes the same envelope using model.serialize() so the response shape is identical either way.
Serializer ({ data, metadata } envelope)
The default ResourceSerializer extends BaseSerializer from @adonisjs/core/transformers with wrap: 'data' and identity metadata, matching the response shape documented for AdonisJS transformers:
Item→{ data: {...} }Collection→{ data: [...] }Paginator→{ data: [...], metadata: {...} }
Resolution order in pickSerializer(ctx) — first match wins:
this.serializer— per-controller override (subclass sets it)ctx.serialize— app-wide, when augmented ontoHttpContextviaHttpContext.instanceProperty('serialize', ...)(Adonis pattern)sharedResourceSerializer— package default
So if your app already wires ctx.serialize (a custom BaseSerializer subclass on every request), BaseResourceController picks it up automatically — no per-controller config:
// start/serialize.ts (your app, wired once)
HttpContext.instanceProperty('serialize', /* serialize fn from ApiSerializer */)
// any controller — uses ctx.serialize transparently
export default class CoursesController extends BaseResourceController<typeof Course> {
protected model = Course
protected transformer = CourseTransformer
}To override per-controller (different wrap key, different metadata shape, etc.):
import { BaseSerializer } from '@adonisjs/core/transformers'
class MinimalSerializer extends BaseSerializer<{
Wrap: 'result'
PaginationMetaData: { page: number; total: number }
}> {
wrap = 'result' as const
definePaginationMetaData(meta: any) {
return { page: meta.currentPage, total: meta.total }
}
}
export default class CoursesController extends BaseResourceController<typeof Course> {
protected model = Course
protected serializer = new MinimalSerializer()
}Validators
VineJS validators on the validators slot are run via ctx.request.validateUsing(...):
protected validators = {
store: createCourseValidator,
update: updateCourseValidator,
}Omit either action to accept the raw ctx.request.body() for that action.
Hooks
Override any of these in a subclass — no need to touch the action method:
modifyQuery(ctx, query) // every list — global query scope
beforeIndex(ctx, query) // before pagination (mutate in place)
afterIndex(ctx, response) → response?
beforeStore(ctx, payload) → payload?
afterStore(ctx, record) → record?
beforeUpdate(ctx, record, payload) → { payload }?
afterUpdate(ctx, record) → record?
beforeDestroy(ctx, record)
afterDestroy(ctx, record)Authorization (Bouncer)
Two override patterns are supported. The base controller has no built-in Bouncer integration — choose the one that matches your authorization needs:
Action-level (no record needed) — call super.<action>(ctx):
async store(ctx: HttpContext) {
await ctx.bouncer.with(CoursePolicy).authorize('create')
return super.store(ctx)
}Record-bound — compose findOrFail + respondItem:
async show(ctx: HttpContext) {
const course = await this.findOrFail(ctx)
await ctx.bouncer.with(CoursePolicy).authorize('view', course)
return this.respondItem(course, ctx)
}findOrFail, respondItem, respondPaginated, buildListQuery, validateInput, resolvePagination, pickSerializer are all protected — compose them freely.
Preloading relationships
| Action | Where to call .preload() |
|--------|---------------------------|
| index (list) | modifyQuery(ctx, query) hook, or pass an extra mutator to respondPaginated(ctx, q => q.preload('...')) in an overridden action |
| show / update / destroy | Override findOrFail — the default uses Model.findByOrFail() which doesn't support preload |
// Index preload
protected async modifyQuery(_ctx: HttpContext, query: any) {
query.preload('faculty').preload('department')
}
// Single-resource preload
protected async findOrFail(ctx: HttpContext) {
return this.model.query()
.preload('faculty')
.preload('department')
.where(this.findBy, ctx.request.param(this.paramKey))
.firstOrFail()
}Customizing pagination
protected pagination = {
defaultPerPage: 50, // default 20
maxPerPage: 200, // default 100 — clamps the requested page size
pageInput: 'p', // default 'page'
perPageInput: 'limit', // default 'perPage'
}Empty/garbage values fall back to defaultPerPage. perPage > maxPerPage is silently clamped.
Lookups by something other than id
protected findBy = 'slug' // Lucid column to match
protected paramKey = 'slug' // route param nameOperators
Comparison
| Operator | Description | Example |
| -------- | --------------------- | --------------------- |
| eq | Equal | ?status=eq.active |
| neq | Not equal | ?status=neq.deleted |
| gt | Greater than | ?price=gt.100 |
| gte | Greater than or equal | ?price=gte.100 |
| lt | Less than | ?price=lt.500 |
| lte | Less than or equal | ?price=lte.500 |
Lists, null/boolean, patterns
?id=in.(1,2,3,4,5)
?id=in.() # empty set — returns 0 rows
?deletedAt=is.null
?isActive=is.true
?status=is.unknown
?name=like.*test* # contains "test" (case-sensitive)
?name=ilike.*TEST* # contains "TEST" (case-insensitive)
?name=like(any).{*foo*,*bar*} # OR across patterns
?name=like(all).{*urgent*,*important*} # AND across patternsThe (any) / (all) modifiers also apply to ilike, match, and imatch.
Empty and malformed in values: ?id=in.() parses as an empty list and emits WHERE 1 = 0, returning no rows (matches PostgREST semantics). A malformed in — missing the list entirely (?id=in., ?id=in) or missing the parentheses (?id=in.1,2) — throws FilterValidationError and returns HTTP 400, even in lenient mode. This prevents the previous footgun where a malformed in would be silently dropped and the request would fall through with no constraint.
Regex (PostgreSQL)
?code=match.^CPE[0-9]+$ # case-sensitive
?code=imatch.^cpe[0-9]+$ # case-insensitiveArray (PostgreSQL)
| Operator | SQL | Description |
| -------- | ---- | ------------ |
| cs | @> | Contains |
| cd | <@ | Contained in |
| ov | && | Overlaps |
?tags=cs.{admin,user}
?roles=cd.{admin,user,guest}
?categories=ov.{tech,science}Range (PostgreSQL)
| Operator | SQL | Description |
| -------- | ------------------ | ------------------------ |
| sl | << | Strictly left of |
| sr | >> | Strictly right of |
| nxr | &< | Does not extend right of |
| nxl | &> | Does not extend left of |
| adj | -|- | Adjacent to |
Bounds use PostgreSQL range syntax: [a,b] inclusive, (a,b) exclusive.
?period=sl.[2024-01-01,2024-12-31] # period is entirely before this range
?period=adj.[2024-01-01,2024-06-30] # period is directly adjacentFull-text search (PostgreSQL)
| Operator | Variant |
| -------- | ------------------------------- |
| fts | to_tsquery |
| plfts | plainto_tsquery (plain query) |
| phfts | phraseto_tsquery (phrase) |
| wfts | websearch_to_tsquery |
?description=fts.search+terms
?description=fts.thai.ค้นหา # with language config
?description=plfts.simple+phraseNegation
Add not. before any operator:
?status=not.eq.deleted
?id=not.in.(1,2,3)
?deletedAt=not.is.null
?name=not.like.*test*Logical operators
and / or groups
?or=(status.eq.active,status.eq.pending)
?and=(price.gte.100,price.lte.500)
?not.or=(status.eq.deleted,status.eq.archived)Nested groups
?and=(or(status.eq.active,status.eq.pending),or(type.eq.course,type.eq.program))
?or=(status.eq.draft,and(status.eq.approved,publishedAt.not.is.null))Flat filters are implicitly AND-ed with logical groups:
?facultyId=eq.1&or=(status.eq.active,status.eq.pending)Inside a logical group the syntax is
<column>.<operator>.<value>(dot-separated, no=). Use?or=(name.like.*test*,...), NOT?or=(name=like.*test*,...).
Embedded filters
Filter on related resources using dot notation. Works automatically — no configuration required.
?faculty.nameTh=like.*วิศว*
?faculty.department.code=eq.CPE
?course.instructor.faculty.id=eq.1
?faculty.order=nameTh.asc
?faculty.order=score.desc.nullslast
?faculty.limit=10
?courseSpecifications.exists=truePreloading and relationship mapping
By default embedded filters only apply whereHas. To preload the related data or remap names:
export default class CourseFilter extends PostgRESTModelFilter {
declare $query: ModelQueryBuilderContract<typeof Course>
protected $embeddedOptions = {
// true | false | string[]
preloadEmbedded: ['faculty', 'department'],
relationshipMap: {
dept: 'department',
specs: 'courseSpecifications',
},
}
}Custom filter methods
Any public method on the filter class is treated as a custom filter. The query parameter name matches the method name; the value is forwarded as the first argument.
Custom methods take precedence over PostgREST parsing. If a query key matches a public method name, the method is invoked and the key is consumed before the parser runs — so defining
q(value)shadows any incoming?q=eq.foofilter.
export default class CourseFilter extends PostgRESTModelFilter {
declare $query: ModelQueryBuilderContract<typeof Course>
// ?q=search+term
q(value: string): void {
this.$query
.orWhereILike('nameTh', `%${value}%`)
.orWhereILike('nameEn', `%${value}%`)
.orWhereILike('code', `%${value}%`)
}
// ?hasApprover=true
hasApprover(value: string): void {
this.$query.whereRaw(
value === 'true' ? 'cardinality("approverEmails") > 0' : 'cardinality("approverEmails") = 0'
)
}
// ?year=2024
year(value: string): void {
this.$query.whereRaw('EXTRACT(YEAR FROM "createdAt") = ?', [value])
}
}Notes & gotchas
- Top-level
orderis applied byBaseResourceController.?order=name.asc,?order=name.asc,createdAt.desc, and?order=name.asc.nullslastwork out of the box on any controller subclassingBaseResourceController. Unknown columns are dropped through$columnsDefinitions(overrideresolveOrderColumnto widen, or setorderInput = nullto disable when aPostgRESTModelFiltersubclass owns theorderparam via a customorder(value)method). The filter-layer (PostgRESTModelFilter/Model.filter()) does not apply ordering on its own — call from a controller, or apply manually viaModel.query().orderBy(...). - Top-level
select,limit,offset,on_conflict,columns,notare reserved — recognized as non-filters and skipped, but not yet implemented at the top level. Use the embedded form (<rel>.order=...,<rel>.limit=...) for related-resource ordering, and the controller'spaginationslot (or?page=&perPage=) for top-level pagination. - Unknown columns are silently dropped.
LucidFilterBuilderonly applies conditions for columns declared in the model's$columnsDefinitions— typos and missing@column()decorations result in no-op filters with no error. - Parsing is lenient by default. Invalid operator strings are skipped without erroring. Pass
{ strict: true }toFilterParser(orparseFilters(params, true)) when you want aParseErrorinstead. - Malformed
inis the one exception to lenient parsing. A missing list (?id=in.,?id=in) or missing parentheses (?id=in.1,2) throwsFilterValidationError(status 400) regardless of strict mode, because silently dropping it would let the request return every row. Empty lists (?id=in.()) are valid and return 0 rows. - Hooks that take a query are void-only.
modifyQuery/beforeIndexmutate in place — they don't return the query.ModelQueryBuilderContractis thenable in Lucid (await queryreturns the rows), so awaitingPromise<Query | void>would silently unwrap a returned query intoInstanceType<Model>[]. To swap the query entirely, overridebuildListQuery.
Programmatic API
import { FilterParser, parseFilters } from '@aginix/adonis-vulcan'
const parser = new FilterParser()
// Flat conditions
parser.parse({ status: 'eq.active', price: 'gte.100' })
// + logical groups
parser.parseWithLogical({
status: 'eq.active',
or: '(type.eq.a,type.eq.b)',
})
// + embedded filters
parser.parseWithEmbedded({
'status': 'eq.active',
'faculty.name': 'like.*test*',
'faculty.order': 'name.asc',
})
// Strict mode — throws ParseError on invalid filters
new FilterParser({ strict: true }).parse({ status: 'bogus.value' })
// Convenience helper
parseFilters({ status: 'eq.active' })The package also exports LucidFilterBuilder, LucidEmbeddedBuilder, BaseResourceController, ResourceSerializer, and the underlying types from @aginix/adonis-vulcan/types.
Worked example
GET /courses
?status=eq.active
&or=(type.eq.required,type.eq.elective)
&faculty.nameTh=like.*วิศวกรรม*
&faculty.department.code=eq.CPE
&courseSpecifications.exists=true
&credits=gte.3
¬.deletedAt=is.null
&page=2&perPage=20Conceptually equivalent to:
SELECT * FROM courses
WHERE status = 'active'
AND (type = 'required' OR type = 'elective')
AND EXISTS (
SELECT 1 FROM faculties
WHERE faculties.id = courses.faculty_id
AND faculties.name_th LIKE '%วิศวกรรม%'
AND EXISTS (
SELECT 1 FROM departments
WHERE departments.faculty_id = faculties.id
AND departments.code = 'CPE'
)
)
AND EXISTS (
SELECT 1 FROM course_specifications
WHERE course_specifications.course_id = courses.id
)
AND credits >= 3
AND deleted_at IS NOT NULL
LIMIT 20 OFFSET 20;Wrapped in a { data, metadata } envelope by the rapid CRUD controller.
Development
yarn install
yarn quick:test # run unit tests without lint
yarn test # lint + tests + coverage
yarn typecheck
yarn build # compiles to ./buildLicense
Proprietary — Copyright (c) Aginix. All rights reserved. See LICENSE.md.
