@dudousxd/nestjs-filter-drizzle
Version:
Drizzle ORM adapter for @dudousxd/nestjs-filter.
241 lines (191 loc) • 9.2 kB
Markdown
# @dudousxd/nestjs-filter-drizzle
Drizzle ORM adapter for [`@dudousxd/nestjs-filter`](../../README.md).
Provides `DrizzleFilter`, `DrizzleAdapter`, `DrizzleQuery`, and `DrizzleFilterModule`.
Postgres, MySQL and SQLite are supported (every dialect runs the cross-adapter contract suite).
## Install
```bash
pnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-drizzle
```
Peer dependencies: `drizzle-orm` >= 0.40 < 1.0, `@nestjs/common` >= 10, `@nestjs/core` >= 10.
## Quick Start
```typescript
// schema.ts — the table object IS the entity
import { relations } from 'drizzle-orm';
import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
age: integer('age').notNull(),
role: text('role').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
status: text('status').notNull(),
authorId: integer('author_id').notNull(),
});
// relations() powers includes, dot-notation filters, whereHas(), aggregates and describe()
export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
export const schema = { users, posts, usersRelations, postsRelations };
```
```typescript
// user.filter.ts
import { Injectable } from '@nestjs/common';
import { Filterable, FilterFor } from '@dudousxd/nestjs-filter';
import { DrizzleFilter } from '@dudousxd/nestjs-filter-drizzle';
import { eq, gte } from 'drizzle-orm';
import { posts, users } from './schema.js';
@Injectable()
@Filterable({ entity: users })
export class UserFilter extends DrizzleFilter<typeof users> {
static readonly sort = ['name', 'age', 'posts.$count'];
static readonly search = ['name'];
static readonly includes = ['posts'];
@FilterFor('minAge')
applyMinAge(value: number) {
this.$query.where(gte(users.age, value));
}
@FilterFor('name')
applyName(value: string) {
this.whereILike('name', value); // escaped; ILIKE on Postgres, lower() LIKE elsewhere
}
@FilterFor('hasPublished')
applyHasPublished(value: boolean) {
if (value) this.$query.whereHas('posts', (p) => eq((p as typeof posts).status, 'published'));
}
}
```
```typescript
// app.module.ts — drizzle ships no Nest module; provide `db` under your own token
import { Global, Module } from '@nestjs/common';
import { FilterModule } from '@dudousxd/nestjs-filter';
import { drizzleAdapter } from '@dudousxd/nestjs-filter-drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { schema } from './schema.js';
export const DRIZZLE = Symbol('DRIZZLE');
@Global()
@Module({
providers: [{ provide: DRIZZLE, useFactory: () => drizzle(process.env.DATABASE_URL!, { schema }) }],
exports: [DRIZZLE],
})
class DatabaseModule {}
@Module({
imports: [
DatabaseModule,
FilterModule.forRoot({ inputNormalizer: 'camelCase', adapter: drizzleAdapter({ connection: DRIZZLE }) }),
FilterModule.forFeature([UserFilter]),
],
controllers: [UsersController],
})
export class AppModule {}
```
```typescript
// users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { ApplyFilter } from '@dudousxd/nestjs-filter';
import type { DrizzleQuery } from '@dudousxd/nestjs-filter-drizzle';
@Controller('users')
export class UsersController {
@Get()
list(@ApplyFilter(UserFilter) q: DrizzleQuery<typeof users>) {
return q.execute(); // or q.executeAndCount(), q.count(), q.toSelect(), q.toSQL()
}
}
```
`GET /users?minAge=18&name=al&sort=-age&include=posts&paginate[page]=0&paginate[size]=20`
## How it maps onto Drizzle
| Concept | MikroORM / TypeORM adapters | Drizzle adapter |
|---|---|---|
| Entity | decorated class (`User`) | table object (`users`) |
| Metadata | ORM metadata registry | `getTableColumns(table)` + the `relations()` in your schema |
| `this.$query` | the ORM's query builder | `DrizzleQuery` — an accumulator of `SQL` conditions, ordering, window, projection and includes, materialized as ONE `db.select().from(table)` on execute |
| Relation filter (`posts.status`, `@Relations`, `related()`) | JOIN | correlated `EXISTS (…)` subquery |
| Sort / distinct on a to-one path (`manager.name`) | JOIN | correlated scalar subquery |
| `include` | join / ORM populate | batched `SELECT … WHERE fk IN (…)` per relation, grafted as `row.posts[]` / `row.author` (the shape `db.query…findMany({ with })` returns) |
| `findAndCount` total | ORM count | `count(*)` over the same WHERE (a derived table for `distinct`) |
| Aggregates (`posts.$count`) | correlated subquery | correlated subquery |
The root query never joins, so it always returns one row per matching parent:
`LIMIT`/`OFFSET` and `COUNT(*)` stay exact whatever relations a request touches.
### `DrizzleQuery` API
| Member | |
|---|---|
| `where(...conds)` | AND SQL conditions (`undefined` ignored — compose `and()`/`or()` freely) |
| `andWhere(cond \| { col: value })` | also accepts an equality map (array → `IN`, `null` → `IS NULL`) |
| `whereHas(path, (target) => cond?)` / `whereDoesntHave(...)` | correlated `EXISTS` over a relation path (`'posts.comments'`) |
| `orderBy(...terms)`, `clearOrderBy()`, `limit(n)`, `offset(n)` | ordering and window |
| `select(fields)`, `addSelect(fields)`, `distinct()` | projection |
| `include(...paths)` | relations loaded by `execute()` |
| `columns`, `table`, `db`, `dialect` | the (possibly aliased) table's columns, and the execution context |
| `execute()`, `count()`, `executeAndCount()` | run it |
| `toSelect()`, `toSQL()` | the materialized drizzle builder / SQL + params |
### Filters that double as relation filters
When a filter runs through `@Relations` (or `this.related(...)`), its `$query` targets an
**alias** of its table inside the `EXISTS` subquery. Read columns through `this.columns`
(or the callback argument of `whereHas`) rather than the imported table object in such filters:
```typescript
@FilterFor('postStatus')
applyStatus(value: string) {
this.$query.where(eq(this.columns.status, value));
}
```
## Computed fields
Same three source forms as the other adapters. Because the adapter never joins, an
unqualified column in a string source always means the root table's column; a function
source receives `{ alias: <table name>, em: db }` and may return a SQL string, a drizzle
`sql` fragment, or a select builder (used as a scalar subquery):
```typescript
@Filterable({
entity: users,
computed: {
doubleAge: '(age * 2)',
label: { source: `name || ' <' || role || '>'`, project: true },
},
})
export class UserFilter extends DrizzleFilter<typeof users> {
@Computed({ type: 'number' })
postCount() {
return sql`(select count(*) from ${posts} where ${posts.authorId} = ${users.id})`;
}
}
```
## Testing
No database needed: run a filter against a `DrizzleQuery` over `drizzle.mock()` and assert the SQL.
```typescript
import { drizzle } from 'drizzle-orm/node-postgres';
const q = new DrizzleAdapter(drizzle.mock()).query(users);
await runner.apply(UserFilter, { name: 'al', minAge: 18 }, q);
expect(q.toSQL()).toEqual({
sql: 'select "id", "name", "age" from "users" where ("users"."name" ilike $1 and "users"."age" >= $2)',
params: ['%al%', 18],
});
```
`FilterTestingModule` + `makeMockQueryBuilder` also work: the mock records `['where', SQL]`
calls, which you can render with the dialect (`new PgDialect().sqlToQuery(sql)`).
## Limitations
- **drizzle-orm 0.x only** (`relations()` v1). Drizzle 1.0's `defineRelations` is not read yet.
- **No many-to-many** relation kind: drizzle models it as a junction table, so it shows up
as a one-to-many to the junction (`user.userTags[].tag`).
- **JSON sub-paths** (`metadata.tier`) are not resolved (the MikroORM adapter supports them).
- **Composite-key relations** are skipped by `include`; filtering through them works.
- **Vector search** (`search = { vector }`) is Postgres-only.
- **Codegen** (`@dudousxd/nestjs-filter-codegen`) reads entity classes via the TypeScript AST;
it does not yet derive `filterFields` from Drizzle tables.
## API Reference
### `DrizzleFilter<TTable>`
Abstract base class. `this.$query` is a `DrizzleQuery<TTable>`. Helpers: `columns`,
`whereLike`, `whereILike`, `whereBeginsWith`, `whereEndsWith` (all escape the value).
### `DrizzleFilterModule.forRoot({ connection | db, schema?, dialect?, imports? })`
Registers `DrizzleAdapter` as the filter adapter. `connection` is the injection token of your
drizzle instance (provided globally, or by a module listed in `imports`); `db` passes the
instance directly. `schema` defaults to the schema the instance was created with.
`drizzleAdapter(options)` is the same thing as a descriptor for `FilterModule.forRoot({ adapter })`.
### `DrizzleAdapter`
Implements `FilterAdapter`. `adapter.query(table)` creates a `DrizzleQuery` for services:
```typescript
const q = adapter.query(users);
await runner.apply(UserFilter, input, q);
const { rows, total } = await q.executeAndCount();
```