Filterable Classes
Instead of repeating .allowedFilters(), .allowedSorts(), and .allowedIncludes() across your controllers, you can encapsulate all query building logic in a dedicated Filterable Class extending BaseFilterable.
Creating a Filterable Class
Use BaseFilterable.for(() => Model) to create a strongly-typed filterable class for a specific Lucid model:
import type { HttpRequest } from '@adonisjs/core/http';
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model';
import { AllowedFilter, BaseFilterable } from '@eienjs/adonisjs-api-query';
import User from '#models/user';
export default class UserFilterable extends BaseFilterable.for(() => User) {
// Allowed filters: column names, AllowedFilter instances, or custom method names
public filters = [
'name',
'status',
AllowedFilter.exact('email'),
AllowedFilter.scope('verified'),
];
// Allowed sorts
public sorts = ['name', 'createdAt', 'postsCount'];
// Fallback sort when ?sort= is not present in the URL
public defaultSorts = '-createdAt';
// Allowed relationship includes
public includes = ['posts', 'profile'];
// Optional lifecycle hook executed before filters, sorts, and includes
public setup(query: ModelQueryBuilderContract<typeof User>, _request?: HttpRequest) {
void query.where('isActive', true);
}
// Auto-bound custom filter method for 'status'
public filterStatus(query: ModelQueryBuilderContract<typeof User>, value: unknown) {
void query.where('status', String(value));
}
// Auto-bound custom sort method for 'postsCount'
public sortPostsCount(query: ModelQueryBuilderContract<typeof User>, isDescending: boolean) {
void query.withCount('posts').orderBy('posts_count', isDescending ? 'desc' : 'asc');
}
}
filters, sorts, defaultSorts, includes, and rules can also be defined as methods (e.g., public filters() { return [...]; }) if you need to compute them dynamically using this.request.Auto-Bound Custom Methods
When you include a string identifier in filters, sorts, or includes, BaseFilterable automatically checks if your class defines a matching method and binds it as a callback:
| Property Entry | Checked Method Names on Class | Callback Signature |
|---|---|---|
filters = ['status'] or ['user_status'] | filterStatus(query, value) or status(query, value) (filterUserStatus) | (query, value) => void |
sorts = ['popularity'] | sortPopularity(query, isDescending) or popularity(query, isDescending) | (query, isDescending) => void |
includes = ['posts'] | includePosts(query) or posts(query) | (relationSubQuery) => void |
Both camelCase, snake_case, and kebab-case names are converted to PascalCase when looking up filter<PascalName>, sort<PascalName>, and include<PascalName>.
Attaching a Filterable to a Lucid Model
You can associate a filterable class with your Lucid model in three ways:
import { BaseModel } from '@adonisjs/lucid/orm';
import UserFilterable from '#filters/user_filterable';
export default class User extends BaseModel {
// Using a lazy thunk `() => UserFilterable` prevents ESM circular dependency issues
public static $filterable = () => UserFilterable;
}
import { BaseModel } from '@adonisjs/lucid/orm';
import { filterable } from '@eienjs/adonisjs-api-query';
import UserFilterable from '#filters/user_filterable';
@filterable(() => UserFilterable)
export default class User extends BaseModel {}
import { compose } from '@adonisjs/core/helpers';
import { BaseModel } from '@adonisjs/lucid/orm';
import { Filterable } from '@eienjs/adonisjs-api-query';
import UserFilterable from '#filters/user_filterable';
export default class User extends compose(BaseModel, Filterable(() => UserFilterable)) {}
Executing Filtered Queries
Once configured, you can build and run filtered queries in several ways:
1. From the Model via Model.$filter()
import type { HttpContext } from '@adonisjs/core/http';
import AdminUserFilterable from '#filters/admin_user_filterable';
import User from '#models/user';
export default class UsersController {
public async index({ request }: HttpContext) {
// Uses the model's default $filterable
return await User.$filter(request).paginate(1, 15);
}
public async admins({ request }: HttpContext) {
// Override with a specific Filterable class (or instance)
return await User.$filter(AdminUserFilterable, request).paginate(1, 15);
}
}
2. Chained on an Existing Query via query.filter()
// Apply your own constraints before or after calling .filter()
const baseQuery = User.query().where('role', 'admin');
const activeAdmins = await baseQuery
.filter()
.paginate(1, 15);
// Or pass an explicit Filterable class to .filter():
const orgQuery = User.query().where('organizationId', orgId);
const results = await orgQuery.filter(AdminUserFilterable);
3. Directly from the Filterable Class via Filterable.$query()
Because UserFilterable extends BaseFilterable.for(() => User), it knows its target model and provides static query helpers:
import UserFilterable from '#filters/user_filterable';
// Returns a typed ModelQueryBuilderContract<typeof User>
const users = await UserFilterable.$query(request).paginate(1, 15);
// Explicitly validate first and then return the query builder:
const query = await UserFilterable.$validateAndQuery(request);
const validatedUsers = await query.paginate(1, 15);
