Skip to content
add-query-filter logo

Add Query Filter

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".

SKILL.md

Full skill instructions

Add Query Filter

Adds custom query parameter filters to entity schemas and repository implementations.

Quick Reference

Files to modify:

  1. src/​schemas/​{entity}.schema.ts - Add filter to query params schema
  2. src/​repositories/​mockdb/​{entity}.mockdb.repository.ts - Implement filter logic
  3. src/​repositories/​mongodb/​{entity}.mongodb.repository.ts - Implement filter logic
  4. tests/​schemas/​{entity}.schema.test.ts - Test new filter
  5. tests/​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 schemas
  • create-mockdb-repository - MockDB repository implementation
  • create-mongodb-repository - MongoDB repository implementation
  • test-schema - Testing schema validation
  • test-mockdb-repository - Testing repository filters

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View