Claude-skill-registry add-query-filter
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".
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/add-query-filter" ~/.claude/skills/majiayu000-claude-skill-registry-add-query-filter && rm -rf "$T"
manifest:
skills/data/add-query-filter/SKILL.mdsource content
Add Query Filter
Adds custom query parameter filters to entity schemas and repository implementations.
Quick Reference
Files to modify:
- Add filter to query params schemasrc/schemas/{entity}.schema.ts
- Implement filter logicsrc/repositories/mockdb/{entity}.mockdb.repository.ts
- Implement filter logicsrc/repositories/mongodb/{entity}.mongodb.repository.ts
- Test new filtertests/schemas/{entity}.schema.test.ts
- Test filter behaviortests/repositories/{entity}.*.repository.test.ts
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
- Creating entity schemascreate-schema
- MockDB repository implementationcreate-mockdb-repository
- MongoDB repository implementationcreate-mongodb-repository
- Testing schema validationtest-schema
- Testing repository filterstest-mockdb-repository