Using Filters in Your Application
A filter typically starts as a JSON-encoded query string on an HTTP request and ends as a Drizzle query - here is what happens at each layer in between.
HTTP Request GET /products?filter={"where":{"status":"active"},"limit":10}
|
v
Controller Layer Validates via Zod (FilterSchema), parses JSON string -> Filter object
|
v
Service Layer Optional - business logic, authorization, may edit the filter
|
v
Repository Layer applyDefaultFilter() merges the @model default filter,
FilterBuilder converts Filter -> Drizzle query options, executesController layer
ControllerFactory (recommended)
ControllerFactory.defineCrudController generates a full CRUD controller, including filter parsing and validation, from an entity and a repository binding:
// src/controllers/product.controller.ts
import { Product } from '@/models';
import { ProductRepository } from '@/repositories';
import {
controller,
ControllerFactory,
inject,
BindingKeys,
BindingNamespaces,
} from '@venizia/ignis';
const BASE_PATH = '/products';
const _Controller = ControllerFactory.defineCrudController({
repository: { name: ProductRepository.name },
controller: {
name: 'ProductController',
basePath: BASE_PATH,
isStrict: { path: true, requestSchema: true },
},
entity: () => Product,
});
@controller({ path: BASE_PATH })
export class ProductController extends _Controller {
constructor(
@inject({
key: BindingKeys.build({ namespace: BindingNamespaces.REPOSITORY, key: ProductRepository.name }),
})
repository: ProductRepository,
) {
super(repository);
}
}Filter-bearing endpoints generated:
| Method | Endpoint | Query param |
|---|---|---|
| GET | /products | filter |
| GET | /products/{id} | filter (where is ignored - the id is the condition) |
| GET | /products/find-one | filter |
| GET | /products/count | where |
isStrict.requestSchemacontrols whether these query params are Zod-required or optional.isStrict.pathcontrols trailing-slash strictness.- Write endpoints (
POST /,PATCH /{id},DELETE /{id}, ...) are also generated by the factory, and take no filter.
Custom controller with manual filter handling
For a route outside the generated CRUD set, accept FilterSchema directly:
import { z } from '@hono/zod-openapi';
import { BaseRestController, controller, FilterSchema, inject, jsonResponse } from '@venizia/ignis';
@controller({ path: '/products' })
export class ProductController extends BaseRestController {
constructor(
@inject({ key: 'repositories.ProductRepository' })
private _productRepository: ProductRepository,
) {
super({ scope: 'ProductController', path: '/products' });
}
override binding() {
this.defineRoute({
configs: {
path: '/search',
method: 'get',
request: { query: z.object({ filter: FilterSchema }) },
responses: jsonResponse({ schema: z.array(z.object({ id: z.string() })) }),
},
handler: async context => {
const { filter = {} } = context.req.valid('query');
const results = await this._productRepository.find({ filter });
return context.json(results);
},
});
}
}Filter schema validation
FilterSchema and WhereSchema are both z.union([<object shape>, <JSON string>.transform(JSON.parse)]) - they accept either format, so a Hono query param (always a string) and a filter built in code both validate:
// Object format (built in code)
{ where: { status: 'active' }, limit: 10 }
// JSON string format (from a URL query string)
'{"where":{"status":"active"},"limit":10}'WhereSchema is independent of FilterSchema - it backs the count endpoint, which takes where directly rather than a full filter.
Service layer
A service can rewrite the filter before it reaches the repository - useful when a constraint is a caller/session concern rather than a per-model constant (see Default Filter for the model-level alternative):
@service()
export class ProductService {
constructor(
@inject({ key: 'repositories.ProductRepository' })
private _productRepository: ProductRepository,
) {}
async findProductsForTenant(tenantId: string, filter: TFilter<TProductSchema> = {}) {
return this._productRepository.find({
filter: { ...filter, where: { ...filter.where, tenantId } },
});
}
}HTTP request examples
cURL:
curl -G "http://localhost:3000/products" \
--data-urlencode 'filter={"where":{"price":{"gte":100,"lte":500},"tags":{"contains":["featured"]}},"order":["price ASC"],"limit":20}'Fetch/Axios:
const filter = { where: { status: 'active', price: { lte: 100 } }, order: ['createdAt DESC'], limit: 10 };
// fetch
const response = await fetch(`/api/products?filter=${encodeURIComponent(JSON.stringify(filter))}`);
// axios
const response = await axios.get('/api/products', { params: { filter: JSON.stringify(filter) } });Debugging a filter
buildQuery compiles a filter into Drizzle query options without executing it - the fastest way to check what a filter actually resolves to:
const queryOptions = repository.buildQuery({ filter: complexFilter });
console.log('Generated query options:', queryOptions);options.log also traces repository calls, but only on the write path (create/updateById/updateAll/deleteById/deleteAll) - find/findOne/findById/count never read it. See Advanced Repository Features -> Log option for the write-side form.
See also
- Filter System Overview - the
filtershape and everywhereoperator family - Default Filter -
shouldSkipDefaultFilter, and the model-level alternative to the service-layer pattern above - Use Case Gallery - more filter shapes with their generated SQL
- Advanced Repository Features - transactions, locking, and the full
log/lockoptions
Files:
packages/core/src/base/controllers/factory/controller.ts-ControllerFactory.defineCrudControllerpackages/core/src/base/repositories/query-schemas/filter.ts-FilterSchema,TFilter,TInclusionpackages/core/src/connectors/postgres/repositories/core/base.ts-RelationalBaseRepository.buildQuery