@chromav/frizzle
v0.1.2
Published
A fun, type-safe query compiler with a Pratt parser, pluggable code generators, and built-in validation. Filter, sort, and query your data.
Maintainers
Readme
frizzle
A type-safe query compiler and service layer toolkit. Parse URL-safe filter strings, compile to database queries (Drizzle ORM or raw SQL), and compose interceptor pipelines for scoping, soft-delete, and audit trails — with zero exceptions thrown.
?filter=status eq 'active' AND priority eq 'high'&sort=createdAt:desc&limit=50Install
npm install frizzleDrizzle ORM is an optional peer dependency — only needed if you use the Drizzle code generators.
npm install drizzle-orm # if using frizzle/drizzleQuick Start
Filter a query
import { isSuccess } from "frizzle";
import { compileFilter } from "frizzle/drizzle";
const result = await compileFilter(
"status eq 'active' AND priority eq 'high'",
{ status: "status", priority: "priority" }
);
if (isSuccess(result)) {
const tasks = await db.query.tasks.findMany({
where: result.data,
});
}Filter + sort with schema validation
import { isSuccess } from "frizzle";
import { compileFilter, compileSort } from "frizzle/drizzle";
import type { Schema } from "frizzle";
const schema: Schema = {
status: { type: "enum", enumValues: ["active", "pending", "done"] },
priority: { type: "enum", enumValues: ["low", "medium", "high"] },
title: { type: "string" },
createdAt: { type: "date" },
};
const columns = {
status: "status",
priority: "priority",
title: "title",
createdAt: "createdAt",
};
// Validates field names, operators, and value types before compiling
const filterResult = await compileFilter(
"status eq 'active' AND title contains 'deploy'",
columns,
{ schema }
);
const sortResult = compileSort("createdAt:desc", columns);
if (isSuccess(filterResult) && isSuccess(sortResult)) {
const tasks = await db.query.tasks.findMany({
where: filterResult.data,
orderBy: sortResult.data,
});
}SQL builder alternative (Drizzle)
import { isSuccess } from "frizzle";
import { compileFilterToSQL } from "frizzle/drizzle";
// Pass Drizzle column objects instead of strings
const result = compileFilterToSQL("status eq 'active'", {
status: tasks.status,
priority: tasks.priority,
});
if (isSuccess(result)) {
const rows = await db.select().from(tasks).where(result.data);
}Raw SQL target
For databases Drizzle doesn't cover (e.g., Trino, Spark, Iceberg):
import { compileSelect } from "frizzle/sql";
import { sqliteDialect } from "frizzle/sql";
const columns = { status: "status", priority: "priority", createdAt: "created_at" };
const { sql, parameters } = compileSelect("tasks", {
filter: { status: "active", priority: { gte: 3 } },
sort: { createdAt: "desc" },
take: 20,
}, columns, sqliteDialect);
// sql: SELECT * FROM "tasks" WHERE "status" = ? AND "priority" >= ? ORDER BY "created_at" DESC LIMIT ?
// parameters: ["active", 3, 20]Interceptor pipelines
Compose cross-cutting concerns as reusable interceptors:
import {
scopeQueryInterceptor,
softDeleteQueryInterceptor,
scopeWriteInterceptor,
softDeleteWriteInterceptor,
injectWriteInterceptor,
} from "frizzle";
// Read pipeline: every query is scoped to workspace + excludes deleted
const queryInterceptors = [
scopeQueryInterceptor({ workspaceId: "ws-123" }),
softDeleteQueryInterceptor(),
];
// Write pipeline: scope + audit + soft-delete transform
const writeInterceptors = [
scopeWriteInterceptor({ workspaceId: "ws-123" }),
injectWriteInterceptor("user-456"),
softDeleteWriteInterceptor("user-456"),
];Query Syntax
Filter operators
| Operator | Category | Example |
|----------|----------|---------|
| eq, ne | Comparison | status eq 'active' |
| gt, gte, lt, lte | Comparison | age gte 18 |
| like, ilike | String | name like 'john' |
| contains, starts_with, ends_with | String | title contains 'deploy' |
| in, not_in | Array | status in ['active', 'pending'] |
| between | Range | age between [18, 65] |
| is_null, is_not_null | Null check | assignedTo is_null |
Symbol aliases work too: =, !=, >, >=, <, <=.
Logical operators
status eq 'active' AND priority eq 'high'
status eq 'active' OR status eq 'pending'
(status eq 'active' OR status eq 'pending') AND priority eq 'high'AND binds tighter than OR. Parentheses override precedence.
Sorting and pagination
sort=name:asc,createdAt:desc
limit=50
offset=100Error Handling
All operations return Result<T> — no exceptions thrown.
import { isSuccess, isError } from "frizzle";
const result = await compileFilter("bad ?? syntax", columns);
if (isError(result)) {
result.error.message; // "Unexpected token '??' at position 4"
result.error.code; // "SYNTAX_ERROR"
result.error.suggestions; // Possible fixes
}Entry Points
| Import | Contents |
|--------|----------|
| frizzle | Core: compiler, parser, AST types, result types, schema types, query string parsing, descriptors, interceptors |
| frizzle/drizzle | Drizzle ORM: compileFilter, compileFilterToSQL, compileSort, DrizzleCodeGenerator |
| frizzle/sql | Raw SQL: compileSelect, compileFilter, compileFilterNode, compileInsert, compileUpdate, compileDelete, Dialect, sqliteDialect |
The core package has zero dependencies. frizzle/drizzle requires drizzle-orm as a peer dependency. frizzle/sql has zero dependencies.
Descriptors
Frizzle uses database-agnostic intermediate representations that flow through interceptor pipelines before reaching a database adapter.
QueryDescriptor — describes a read operation:
import { createQueryDescriptor, mergeQueryDescriptor } from "frizzle";
const descriptor = mergeQueryDescriptor(createQueryDescriptor(), {
filter: { status: "active", workspaceId: "ws-1" },
sort: { createdAt: "desc" },
take: 20,
skip: 0,
select: { id: true, title: true, status: true },
include: { assignee: true },
});WriteDescriptor — describes a write operation:
import { createWriteDescriptor, mergeWriteDescriptor } from "frizzle";
// Insert
const insert = createWriteDescriptor("insert", { title: "New task", status: "backlog" });
// Update
const update = mergeWriteDescriptor(
createWriteDescriptor("update", { status: "done" }),
{ targetIds: ["task-1", "task-2"] },
);
// Delete
const remove = mergeWriteDescriptor(
createWriteDescriptor("delete"),
{ targetIds: ["task-3"] },
);Reference Interceptors
Reusable implementations for common service layer patterns. All exported from the main frizzle entry point.
Query interceptors
| Interceptor | Description |
|------------|-------------|
| scopeQueryInterceptor(fields) | Appends tenant/workspace filter to every query |
| softDeleteQueryInterceptor(config?) | Excludes soft-deleted rows (isDeleted: false) |
| defaultIncludesInterceptor(defaults) | Deep-merges default relation includes |
| extraScopeInterceptor(scopeFn) | Adds entity-specific filter conditions from a factory |
Write interceptors
| Interceptor | Description |
|------------|-------------|
| scopeWriteInterceptor(fields) | Injects scope values into every write |
| softDeleteWriteInterceptor(userId, config?) | Transforms delete → update with isDeleted: true |
| injectWriteInterceptor(userId, config?) | Injects createdById on insert, updatedById on update |
| extraScopeWriteInterceptor(scopeFn) | Injects additional scope fields on insert |
All interceptors are generalized (configurable column names, generic scope fields) and use only frizzle's own types — zero external dependencies.
SQL Target
The frizzle/sql entry point compiles descriptors to parameterized SQL strings. It supports any SQL database through the Dialect interface.
Dialect interface
import type { Dialect } from "frizzle/sql";
const trinoDialect: Dialect = {
name: "trino",
quoteIdentifier: (name) => `"${name}"`,
parameter: (index) => `$${index + 1}`,
booleanLiteral: (v) => v ? "TRUE" : "FALSE",
supportsILike: true,
escapeLikePattern: (p) => p.replace(/[%_\\]/g, "\\$&"),
};Write operations
import { compileInsert, compileUpdate, compileDelete } from "frizzle/sql";
import { createWriteDescriptor, mergeWriteDescriptor } from "frizzle";
import { sqliteDialect } from "frizzle/sql";
const insert = createWriteDescriptor("insert", { title: "Task", status: "backlog" });
const { sql, parameters } = compileInsert("tasks", insert, sqliteDialect);
// sql: INSERT INTO "tasks" ("title", "status") VALUES (?, ?)
const update = mergeWriteDescriptor(
createWriteDescriptor("update", { status: "done" }),
{ targetIds: ["t-1"] },
);
const { sql, parameters } = compileUpdate("tasks", update, sqliteDialect);
// sql: UPDATE "tasks" SET "status" = ? WHERE "id" = ?Architecture
URL query string
↓ parseQueryString()
QueryDescriptor (database-agnostic IR)
↓ interceptor pipeline (scope, soft-delete, inject, ...)
QueryDescriptor (enriched)
↓ adapter
├── frizzle/drizzle → Drizzle RQB findMany/findFirst
└── frizzle/sql → parameterized SQL stringDocumentation
Core
- Getting Started — Both approaches, architecture overview, what each step produces
- Descriptors — QueryDescriptor and WriteDescriptor as database-agnostic IRs
- Interceptors — Pipeline concept, reference implementations, writing your own
- Query Language — Complete syntax reference with examples for every operator
- Schema Validation — Field types, operator compatibility, runtime enums
- Error Handling — Result types, error codes, resilient patterns
- Query Spec (EBNF) — Formal grammar
Database targets
- Drizzle Integration — RQB vs SQL, column mappings, nested paths, resolvers
- Drizzle Code Generator — Validation rules, adding operators, test coverage
- SQL Target — Raw SQL compilation, dialect interface, all compilers and operators
Guides
- Recipes — Express, Hono, Next.js, React Router examples + filter UI patterns
- URL Examples — 50+ real-world URL query patterns
License
MIT
