Features

Filterable Classes

Encapsulate allowed filters, sorts, includes, setup hooks, and validation rules in dedicated 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:

app/filters/user_filterable.ts
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');
  }
}
Properties like 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 EntryChecked Method Names on ClassCallback 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;
}

Executing Filtered Queries

Once configured, you can build and run filtered queries in several ways:

1. From the Model via Model.$filter()

app/controllers/users_controller.ts
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);