The Drizzle adapter transforms filter definitions into SQL expressions that integrate seamlessly with Drizzle ORM queries. Define your filters once and get parameterized SQL that works with PostgreSQL, MySQL, and SQLite.
Installation
Install the packages
npm install @filter-def/drizzle drizzle-orm
# or
pnpm add @filter-def/drizzle drizzle-orm
drizzle-orm is a peer dependency and must be installed separately.
Basic Usage
Define filters for your Drizzle table and use them in queries:
import { drizzleFilter } from "@filter-def/drizzle";
import type { DrizzleFilterInput } from "@filter-def/drizzle";
import { pgTable, text, integer, boolean } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/node-postgres";
// Define your Drizzle table
const usersTable = pgTable("users", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
age: integer("age").notNull(),
isActive: boolean("is_active").notNull(),
});
// Create a filter definition
const userFilter = drizzleFilter(usersTable).def({
name: { kind: "eq" },
emailContains: { kind: "contains", field: "email" },
minAge: { kind: "gte", field: "age" },
isActive: { kind: "eq" },
});
// Use in queries
const db = drizzle(client);
const where = userFilter({
name: "John",
emailContains: "@example.com",
minAge: 18,
isActive: true,
});
const results = await db.select().from(usersTable).where(where);
Filter Types
The Drizzle adapter supports all standard filter kinds and maps them to Drizzle operators:
| Kind | Drizzle Operator | SQL Output | Example |
|---|
eq | eq() | = | { name: { kind: "eq" } } |
neq | ne() | != | { status: { kind: "neq" } } |
contains | like() / ilike() | LIKE / ILIKE | { email: { kind: "contains" } } |
inArray | inArray() | IN | { status: { kind: "inArray" } } |
gt | gt() | > | { age: { kind: "gt" } } |
gte | gte() | >= | { price: { kind: "gte" } } |
lt | lt() | < | { age: { kind: "lt" } } |
lte | lte() | <= | { price: { kind: "lte" } } |
isNull | isNull() / isNotNull() | IS NULL / IS NOT NULL | { deletedAt: { kind: "isNull" } } |
isNotNull | isNotNull() / isNull() | IS NOT NULL / IS NULL | { email: { kind: "isNotNull" } } |
Case-Insensitive String Matching
Use caseInsensitive: true to use ILIKE instead of LIKE (PostgreSQL):
const userFilter = drizzleFilter(usersTable).def({
nameSearch: {
kind: "contains",
field: "name",
caseInsensitive: true, // Uses ilike('%value%')
},
});
const where = userFilter({ nameSearch: "john" });
// SQL: name ILIKE '%john%'
In MySQL, LIKE is case-insensitive by default depending on collation. In SQLite, LIKE is case-insensitive for ASCII characters.
Empty Filters
The filter function returns undefined when no filters are provided:
const where = userFilter({});
// where: undefined
const noInput = userFilter();
// noInput: undefined
// Drizzle handles undefined gracefully
await db.select().from(usersTable).where(where);
// SQL: SELECT * FROM users (no WHERE clause)
Boolean Filters (AND/OR)
Combine multiple conditions with logical operators:
const userFilter = drizzleFilter(usersTable).def({
// OR: match any condition
searchTerm: {
kind: "or",
conditions: [
{ kind: "contains", field: "name" },
{ kind: "contains", field: "email" },
],
},
// AND: match all conditions
ageRange: {
kind: "and",
conditions: [
{ kind: "gte", field: "age" },
{ kind: "lte", field: "age" },
],
},
});
const where = userFilter({
searchTerm: "john",
ageRange: 30,
});
// SQL: (name LIKE '%john%' OR email LIKE '%john%') AND (age >= 30 AND age <= 30)
await db.select().from(usersTable).where(where);
All conditions in boolean filters must have explicit field properties.
Custom Filters
Define custom SQL expressions for complex filtering logic:
import { sql, eq, and, gte, lte } from "drizzle-orm";
const postsTable = pgTable("posts", {
id: integer("id").primaryKey(),
title: text("title").notNull(),
content: text("content").notNull(),
authorId: integer("author_id").notNull(),
viewCount: integer("view_count").notNull(),
likeCount: integer("like_count").notNull(),
commentCount: integer("comment_count").notNull(),
publishedAt: timestamp("published_at").notNull(),
isPublished: boolean("is_published").notNull(),
});
const postFilter = drizzleFilter(postsTable).def({
// Standard primitive filters
id: { kind: "eq" },
authorId: { kind: "eq" },
titleContains: { kind: "contains", field: "title", caseInsensitive: true },
// Custom filter: Views divisible by a number
viewsDivisibleBy: (divisor: number) =>
sql`${postsTable.viewCount} % ${divisor} = 0`,
// Custom filter: Published within X days
publishedWithinDays: (days: number) =>
sql`${postsTable.publishedAt} >= NOW() - INTERVAL '${sql.raw(String(days))} days'`,
// Custom filter: Date range
publishedBetween: (range: { start: Date; end: Date }) =>
and(
gte(postsTable.publishedAt, range.start),
lte(postsTable.publishedAt, range.end)
),
// Custom filter: Minimum engagement (likes + comments)
minEngagement: (minTotal: number) =>
sql`${postsTable.likeCount} + ${postsTable.commentCount} >= ${minTotal}`,
// Custom filter: Popularity score (weighted calculation)
minPopularityScore: (minScore: number) =>
sql`(${postsTable.viewCount} * 1 + ${postsTable.likeCount} * 5 + ${postsTable.commentCount} * 10) >= ${minScore}`,
// Custom filter: Return undefined to skip filtering
optionalStatus: (status: "published" | "draft" | "all") =>
status === "all"
? undefined
: eq(postsTable.isPublished, status === "published"),
});
// Usage
const trendingPosts = await db
.select()
.from(postsTable)
.where(
postFilter({
publishedWithinDays: 14,
minEngagement: 30,
minPopularityScore: 3000,
})
);
Conditional Filters
Return undefined from custom filters to skip them conditionally:
const userFilter = drizzleFilter(usersTable).def({
// Skip filter when status is "all"
optionalStatus: (status: string | "all") =>
status === "all" ? undefined : eq(usersTable.status, status),
});
const where1 = userFilter({ optionalStatus: "all" });
// where1: undefined (no filtering)
const where2 = userFilter({ optionalStatus: "active" });
// where2: SQL expression for status = 'active'
The Drizzle adapter generates WHERE clauses, not JOIN clauses. For filtering by related table data, use EXISTS subqueries via custom filters:
import { exists, and, eq, ilike } from "drizzle-orm";
const usersTable = pgTable("users", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
});
const postsTable = pgTable("posts", {
id: integer("id").primaryKey(),
authorId: integer("author_id").notNull(),
title: text("title").notNull(),
});
const userFilter = drizzleFilter(usersTable).def({
name: { kind: "eq" },
// Custom filter with EXISTS subquery
hasPostWithTitle: (title: string) =>
exists(
db
.select()
.from(postsTable)
.where(
and(
eq(postsTable.authorId, usersTable.id),
ilike(postsTable.title, `%${title}%`)
)
)
),
});
// No join needed - EXISTS handles the relationship
const where = userFilter({ hasPostWithTitle: "TypeScript" });
const authors = await db.select().from(usersTable).where(where);
EXISTS subqueries are often more performant than JOINs for filtering because they can short-circuit as soon as a match is found.
Complete Example
Here’s a real-world product catalog implementation:
import { drizzleFilter } from "@filter-def/drizzle";
import type { DrizzleFilterInput } from "@filter-def/drizzle";
import {
pgTable,
text,
integer,
boolean,
timestamp,
} from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/node-postgres";
// Table definition
const productsTable = pgTable("products", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
price: integer("price").notNull(),
category: text("category").notNull(),
inStock: boolean("in_stock").notNull(),
createdAt: timestamp("created_at").notNull(),
});
// Filter definition
const productFilter = drizzleFilter(productsTable).def({
// Inferred fields
name: { kind: "eq" },
category: { kind: "eq" },
inStock: { kind: "eq" },
// Explicit fields
nameContains: { kind: "contains", field: "name", caseInsensitive: true },
minPrice: { kind: "gte", field: "price" },
maxPrice: { kind: "lte", field: "price" },
inCategories: { kind: "inArray", field: "category" },
// Boolean filter for search
search: {
kind: "or",
conditions: [
{ kind: "contains", field: "name" },
{ kind: "contains", field: "description" },
],
},
});
type ProductFilterInput = DrizzleFilterInput<typeof productFilter>;
// Usage
const db = drizzle(client);
async function searchProducts(
input: ProductFilterInput
) {
const where = productFilter(input);
return db.select().from(productsTable).where(where);
}
// Example queries
const electronics = await searchProducts({
category: "electronics",
inStock: true,
maxPrice: 500,
});
const searchResults = await searchProducts({
search: "laptop",
minPrice: 200,
maxPrice: 1000,
});
Type Utilities
Use DrizzleFilterInput to extract the input type:
import type { DrizzleFilterInput } from "@filter-def/drizzle";
const userFilter = drizzleFilter(usersTable).def({
name: { kind: "eq" },
minAge: { kind: "gte", field: "age" },
});
type UserFilterInput = DrizzleFilterInput<typeof userFilter>;
// { name?: string; minAge?: number }
async function searchUsers(filters: UserFilterInput) {
const where = userFilter(filters);
return db.select().from(usersTable).where(where);
}
Custom Filter Type
Use DrizzleCustomFilter to type custom filter functions:
import type { DrizzleCustomFilter } from "@filter-def/drizzle";
import type { SQL } from "drizzle-orm";
type DivisibleByFilter = DrizzleCustomFilter<number>;
// (input: number) => SQL | undefined
const ageDivisibleBy: DivisibleByFilter = (divisor) =>
sql`${usersTable.age} % ${divisor} = 0`;
Database-Specific Behaviors
contains uses LIKE (case-sensitive) by default
contains with caseInsensitive: true uses ILIKE
- All Drizzle PostgreSQL operators are supported
- Use
sql template for PostgreSQL-specific functions
contains uses LIKE which is case-insensitive by default in MySQL
caseInsensitive option behavior depends on collation
- Use
sql template for MySQL-specific functions
contains uses LIKE which is case-insensitive for ASCII characters
- Limited support for date/time functions compared to PostgreSQL
- Use
sql template for SQLite-specific functions
Limitations
Nested Fields Not SupportedNested field paths (e.g., "name.first") are not supported by the Drizzle adapter, since Drizzle operates on flat table columns. Using a dot-separated path will throw a runtime error.// ❌ This will throw an error
const filter = drizzleFilter(usersTable).def({
firstName: { kind: "eq", field: "name.first" },
});
For nested data (e.g., JSON columns), use a custom filter with Drizzle’s JSON operators.
SQL Injection ProtectionWhen using custom filters with sql template, always use parameterized queries. Never concatenate user input directly into SQL strings.// ✅ Safe: Uses parameterization
const filter = drizzleFilter(table).def({
custom: (value: string) => sql`column = ${value}`,
});
// ❌ Unsafe: SQL injection risk
const filter = drizzleFilter(table).def({
custom: (value: string) => sql.raw(`column = '${value}'`),
});