| name | add-query-filter |
| description | Add custom query parameter filters to entity endpoints. Use when extending search/filter capabilities beyond the base pagination. Triggers on "add filter", "query parameter", "search filter", "filter by". |
Add Query Filter
Adds custom query parameter filters to entity schemas and repository implementations.
Quick Reference
Files to modify:
src/schemas/{entity}.schema.ts- Add filter to query params schemasrc/repositories/mockdb/{entity}.mockdb.repository.ts- Implement filter logicsrc/repositories/mongodb/{entity}.mongodb.repository.ts- Implement filter logictests/schemas/{entity}.schema.test.ts- Test new filtertests/repositories/{entity}.*.repository.test.ts- Test filter behavior
Prerequisites
- Entity schema exists with
{entity}QueryParamsSchema - Repository implementations exist
Instructions
Step 1: Add Filter to Query Params Schema
Update src/schemas/{entity}.schema.ts:
import { z } from "zod";
import { queryParamsSchema } from "@/schemas/shared.schema";
// Extend the base query params with entity-specific filters
export const {entity}QueryParamsSchema = queryParamsSchema.extend({
// Existing filters...
createdBy: z.string().optional(),
// Add new filter
{filterName}: z.string().optional(),
// OR for enum filter
status: z.enum(["draft", "published", "archived"]).optional(),
// OR for boolean filter (from query string)
isActive: z.coerce.boolean().optional(),
// OR for date range
createdAfter: z.coerce.date().optional(),
createdBefore: z.coerce.date().optional(),
// OR for numeric range
minPrice: z.coerce.number().optional(),
maxPrice: z.coerce.number().optional(),
});
export type {Entity}QueryParamsType = z.infer<typeof {entity}QueryParamsSchema>;
Step 2: Implement Filter in MockDB Repository
Update src/repositories/mockdb/{entity}.mockdb.repository.ts:
async findAll(query: {Entity}QueryParamsType): Promise<PaginatedResultType<{Entity}Type>> {
let filtered = [...this.{entities}];
// Existing filters
if (query.createdBy) {
filtered = filtered.filter((item) => item.createdBy === query.createdBy);
}
if (query.search) {
const searchLower = query.search.toLowerCase();
filtered = filtered.filter((item) =>
item.content.toLowerCase().includes(searchLower)
);
}
// NEW: Add your filter
if (query.{filterName}) {
filtered = filtered.filter((item) => item.{fieldName} === query.{filterName});
}
// For enum filter
if (query.status) {
filtered = filtered.filter((item) => item.status === query.status);
}
// For boolean filter
if (query.isActive !== undefined) {
filtered = filtered.filter((item) => item.isActive === query.isActive);
}
// For date range
if (query.createdAfter) {
filtered = filtered.filter((item) => item.createdAt >= query.createdAfter!);
}
if (query.createdBefore) {
filtered = filtered.filter((item) => item.createdAt <= query.createdBefore!);
}
// For numeric range
if (query.minPrice !== undefined) {
filtered = filtered.filter((item) => item.price >= query.minPrice!);
}
if (query.maxPrice !== undefined) {
filtered = filtered.filter((item) => item.price <= query.maxPrice!);
}
// ... sorting and pagination continue as before
}
Step 3: Implement Filter in MongoDB Repository
Update src/repositories/mongodb/{entity}.mongodb.repository.ts:
async findAll(query: {Entity}QueryParamsType): Promise<PaginatedResultType<{Entity}Type>> {
const collection = await this.getCollection();
const filter: Filter<{Entity}Document> = {};
// Existing filters
if (query.createdBy) {
filter.createdBy = query.createdBy;
}
if (query.search) {
filter.$text = { $search: query.search };
}
// NEW: Add your filter
if (query.{filterName}) {
filter.{fieldName} = query.{filterName};
}
// For enum filter
if (query.status) {
filter.status = query.status;
}
// For boolean filter
if (query.isActive !== undefined) {
filter.isActive = query.isActive;
}
// For date range
if (query.createdAfter || query.createdBefore) {
filter.createdAt = {};
if (query.createdAfter) {
filter.createdAt.$gte = query.createdAfter;
}
if (query.createdBefore) {
filter.createdAt.$lte = query.createdBefore;
}
}
// For numeric range
if (query.minPrice !== undefined || query.maxPrice !== undefined) {
filter.price = {};
if (query.minPrice !== undefined) {
filter.price.$gte = query.minPrice;
}
if (query.maxPrice !== undefined) {
filter.price.$lte = query.maxPrice;
}
}
// ... continue with sorting, pagination
}
Step 4: Add Schema Tests
Update tests/schemas/{entity}.schema.test.ts:
describe("{entity}QueryParamsSchema", () => {
// ... existing tests ...
it("accepts {filterName} filter", () => {
const parsed = {entity}QueryParamsSchema.parse({
{filterName}: "filter-value",
});
expect(parsed.{filterName}).toBe("filter-value");
});
// For enum filter
it("accepts valid status values", () => {
expect({entity}QueryParamsSchema.parse({ status: "draft" }).status).toBe("draft");
expect({entity}QueryParamsSchema.parse({ status: "published" }).status).toBe("published");
});
it("rejects invalid status values", () => {
expect(() => {entity}QueryParamsSchema.parse({ status: "invalid" })).toThrow();
});
// For boolean filter
it("coerces isActive to boolean", () => {
expect({entity}QueryParamsSchema.parse({ isActive: "true" }).isActive).toBe(true);
expect({entity}QueryParamsSchema.parse({ isActive: "false" }).isActive).toBe(false);
});
// For date filter
it("coerces date strings to Date objects", () => {
const parsed = {entity}QueryParamsSchema.parse({
createdAfter: "2024-01-01",
});
expect(parsed.createdAfter).toBeInstanceOf(Date);
});
// For numeric filter
it("coerces price filters to numbers", () => {
const parsed = {entity}QueryParamsSchema.parse({
minPrice: "10",
maxPrice: "100",
});
expect(parsed.minPrice).toBe(10);
expect(parsed.maxPrice).toBe(100);
});
});
Step 5: Add Repository Tests
Update repository test files:
describe("findAll", () => {
// ... existing tests ...
it("filters by {filterName}", async () => {
await repo.create({ content: "A", {fieldName}: "value1" }, userId);
await repo.create({ content: "B", {fieldName}: "value2" }, userId);
await repo.create({ content: "C", {fieldName}: "value1" }, userId);
const result = await repo.findAll({ {filterName}: "value1" });
expect(result.data.length).toBe(2);
expect(result.data.every((item) => item.{fieldName} === "value1")).toBe(true);
});
// For enum filter
it("filters by status", async () => {
await repo.create({ content: "A", status: "draft" }, userId);
await repo.create({ content: "B", status: "published" }, userId);
const result = await repo.findAll({ status: "published" });
expect(result.data.length).toBe(1);
expect(result.data[0].status).toBe("published");
});
// For date range
it("filters by date range", async () => {
// Create items with different dates
const old = await repo.create({ content: "Old" }, userId);
await new Promise((r) => setTimeout(r, 10));
const recent = await repo.create({ content: "Recent" }, userId);
const result = await repo.findAll({
createdAfter: old.createdAt,
});
expect(result.data.length).toBeGreaterThanOrEqual(1);
});
// For numeric range
it("filters by price range", async () => {
await repo.create({ content: "Cheap", price: 10 }, userId);
await repo.create({ content: "Mid", price: 50 }, userId);
await repo.create({ content: "Expensive", price: 100 }, userId);
const result = await repo.findAll({ minPrice: 20, maxPrice: 80 });
expect(result.data.length).toBe(1);
expect(result.data[0].content).toBe("Mid");
});
});
Common Filter Patterns
String Exact Match
// Schema
categoryId: z.string().optional(),
// MockDB
if (query.categoryId) {
filtered = filtered.filter((item) => item.categoryId === query.categoryId);
}
// MongoDB
if (query.categoryId) {
filter.categoryId = query.categoryId;
}
String Array (IN query)
// Schema
tags: z.array(z.string()).optional(),
// OR from comma-separated string
tags: z.string().transform(s => s.split(",")).optional(),
// MockDB
if (query.tags?.length) {
filtered = filtered.filter((item) =>
query.tags!.some(tag => item.tags.includes(tag))
);
}
// MongoDB
if (query.tags?.length) {
filter.tags = { $in: query.tags };
}
Partial Text Match
// Schema
title: z.string().optional(),
// MockDB
if (query.title) {
const titleLower = query.title.toLowerCase();
filtered = filtered.filter((item) =>
item.title.toLowerCase().includes(titleLower)
);
}
// MongoDB (requires text index for $text, or use regex)
if (query.title) {
filter.title = { $regex: query.title, $options: "i" };
}
Null/Not Null Check
// Schema
hasParent: z.coerce.boolean().optional(),
// MockDB
if (query.hasParent !== undefined) {
if (query.hasParent) {
filtered = filtered.filter((item) => item.parentId != null);
} else {
filtered = filtered.filter((item) => item.parentId == null);
}
}
// MongoDB
if (query.hasParent !== undefined) {
if (query.hasParent) {
filter.parentId = { $ne: null };
} else {
filter.parentId = null;
}
}
Adding MongoDB Indexes for Filters
If a filter is frequently used, add an index:
// In MongoDB repository constructor or initialization
async ensureIndexes() {
const collection = await this.getCollection();
// Single field index
await collection.createIndex({ status: 1 });
// Compound index for common filter combinations
await collection.createIndex({ createdBy: 1, status: 1 });
// For date range queries
await collection.createIndex({ createdAt: -1 });
}
Filter Validation Tips
Coercion for Query Strings
Query parameters are always strings. Use z.coerce for non-string types:
// Numbers
minPrice: z.coerce.number().optional(),
// Booleans
isActive: z.coerce.boolean().optional(),
// Dates
createdAfter: z.coerce.date().optional(),
Default Values
// With default
status: z.enum(["all", "active", "inactive"]).default("all"),
// Optional without default
status: z.enum(["draft", "published"]).optional(),
Validation Constraints
// Positive numbers only
minPrice: z.coerce.number().positive().optional(),
// Max length
search: z.string().max(100).optional(),
// Future dates only
eventDate: z.coerce.date().min(new Date()).optional(),
What NOT to Do
- Do NOT forget to add the filter to both MockDB and MongoDB implementations
- Do NOT skip coercion for query string values
- Do NOT use complex filters without indexes in MongoDB
- Do NOT forget to test the filter behavior
- Do NOT filter on fields that don't exist in the schema
See Also
create-schema- Creating entity schemascreate-mockdb-repository- MockDB repository implementationcreate-mongodb-repository- MongoDB repository implementationtest-schema- Testing schema validationtest-mockdb-repository- Testing repository filters