Skip to content

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, executes

Controller layer

ControllerFactory.defineCrudController generates a full CRUD controller, including filter parsing and validation, from an entity and a repository binding:

typescript
// 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:

MethodEndpointQuery param
GET/productsfilter
GET/products/{id}filter (where is ignored - the id is the condition)
GET/products/find-onefilter
GET/products/countwhere
  • isStrict.requestSchema controls whether these query params are Zod-required or optional.
  • isStrict.path controls 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:

typescript
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:

typescript
// 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):

typescript
@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:

bash
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:

typescript
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:

typescript
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

Files: