How to add filtering
FilterSet lets a resource publish a deliberate query-parameter contract instead of exposing raw ORM filter syntax directly to callers.
The current API is centered on FilterSet.define(...). That API starts from model fields, then lets you layer in public parameter aliases, lookup choices, parser hooks, and custom resolver logic where the endpoint needs it.
Start with an allowlist
Filtering is allowlist-first by default. A filter only exists when you declare it.
import { FilterSet } from '@danceroutine/tango-resources';
type Post = {
id: number;
title: string;
content: string;
authorId: number;
published: boolean;
createdAt: string;
};
export const postFilters = FilterSet.define<Post>({
fields: {
authorId: true,
published: true,
},
});true means exact matching on the same public parameter name:
authorId=42becomes{ authorId: '42' }published=falsebecomes{ published: 'false' }
Unknown query parameters are ignored unless you explicitly opt into __all__.
Declare lookup families per field
When an endpoint should support more than exact matching, declare the allowed lookups for that field.
export const postFilters = FilterSet.define<Post>({
fields: {
id: ['exact', 'in', 'lt', 'lte', 'gt', 'gte'],
title: ['icontains', 'contains', 'startswith', 'istartswith', 'endswith', 'iendswith'],
published: ['exact', 'isnull'],
createdAt: ['gte', 'lte'],
},
});Those declarations generate Django-style parameter names:
id=3id__in=1,2,3id__gte=10title__icontains=tangotitle__startswith=Thepublished__isnull=falsecreatedAt__lte=2026-12-31
The built-in field-first API currently supports these lookup shapes:
- exact equality through
exactortrue - set membership through
in - range comparisons through
lt,lte,gt, andgte - text matching through
contains,icontains,startswith,istartswith,endswith, andiendswith - null checks through
isnull
Rename a public parameter without renaming the field
Field declarations can expose a different public parameter name through param.
export const postFilters = FilterSet.define<Post>({
fields: {
published: {
param: 'is_published',
},
},
});That resource now accepts ?is_published=true, while the underlying filter still targets published.
param is useful when:
- a field name is awkward in a public API
- an older endpoint needs a compatibility alias
- you want the request contract to read more clearly than the database column name
Parse values into application-level types
By default, FilterSet passes raw query-string values through as strings or string arrays. Parser hooks let you coerce those values before the ORM filter is built.
You can attach parsers in two places:
parsers, keyed by field nameparseinside a field or alias declaration
export const postFilters = FilterSet.define<Post>({
fields: {
authorId: ['exact', 'in'],
published: {
param: 'is_published',
parse: (raw) => {
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
},
},
},
parsers: {
authorId: (raw) => {
const value = Array.isArray(raw) ? raw[0] : raw;
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
},
},
});The parser contract is intentionally simple:
- returning a value applies the filter with that parsed value
- returning
undefinedomits that filter entirely - throwing an error lets the normal request error path handle the failure
When both a field-level parser map and a declaration-level parse hook exist, the declaration-level parser wins for that filter.
Add public aliases
Aliases let the public query interface diverge from the model field names without forcing you into the lower-level constructor API.
Single-field aliases
Single-field aliases map one public parameter to one field and one lookup.
export const postFilters = FilterSet.define<Post>({
aliases: {
author: { field: 'authorId' },
created_after: { field: 'createdAt', lookup: 'gte' },
},
});That configuration accepts:
author=42created_after=2026-01-01
Multi-field aliases
Multi-field aliases apply the same lookup across several fields.
export const postFilters = FilterSet.define<Post>({
aliases: {
text: {
fields: ['title', 'content'],
lookup: 'contains',
},
},
});text=hello produces one composed filter fragment that targets both fields.
This is useful when one public parameter should constrain several fields at once. It is not the same feature as searchFields on GenericAPIView or ModelViewSet.
Use searchFields when you want Tango's built-in OR-style free-text search through the search query parameter. Use a multi-field alias when you want one named filter parameter to build one explicit composed filter.
If you omit lookup on a multi-field alias, it defaults to icontains.
Repeated parameters and in filters
in filters accept both comma-separated values and repeated query parameters.
export const postFilters = FilterSet.define<Post>({
fields: {
id: ['in'],
},
});These requests are both valid:
?id__in=1,2,3?id__in=1&id__in=2&id__in=3
The filter set normalizes both forms into the __in lookup shape the ORM expects.
Use __all__ only for explicitly permissive endpoints
FilterSet stays allowlist-first unless you opt into catch-all behavior:
const permissiveFilters = FilterSet.define<Post>({
all: '__all__',
});In __all__ mode, unknown query parameters are translated into filter fragments instead of being ignored.
That mode is useful for internal tools, debugging endpoints, or admin-style surfaces where flexibility matters more than a tightly documented public contract. Application-facing APIs should usually stay explicit.
Attach the filter set to a resource
GenericAPIView and ModelViewSet both accept a filters option.
class PostSerializer extends ModelSerializer<
Post,
typeof PostWriteSchema,
ReturnType<typeof PostWriteSchema.partial>,
typeof PostReadSchema
> {
static readonly model = PostModel;
static readonly createSchema = PostWriteSchema;
static readonly updateSchema = PostWriteSchema.partial();
static readonly outputSchema = PostReadSchema;
}
class PostViewSet extends ModelViewSet<Post, typeof PostSerializer> {
constructor() {
super({
serializer: PostSerializer,
filters: postFilters,
orderingFields: ['createdAt', 'title'],
searchFields: ['title', 'content'],
});
}
}At request time, Tango:
- reads
URLSearchParams - asks
FilterSetfor the declared filter fragments - combines those fragments with
Q.and(...) - applies the result to the manager-backed query
That means multiple declared filters narrow the result set together.
Keep the constructor form for fully custom resolvers
The original constructor API still works and remains the right tool when a filter does not fit the field-first declaration model.
new FilterSet<Post>({
authorId: { type: 'scalar', column: 'authorId' },
created_after: { type: 'range', column: 'createdAt', op: 'gte' },
ids: { type: 'in', column: 'id' },
domain: {
type: 'custom',
apply: (value) => {
if (!value) return undefined;
return { title__iendswith: `@${value}` };
},
},
});Use the constructor form when you need:
- a resolver shape that
define(...)does not describe cleanly - full control over the produced filter fragment
- compatibility with older code that already uses resolver objects
For new application code, FilterSet.define(...) is usually the clearer default because it keeps the public filter contract centered on fields and named aliases.