Drizzle Adapter
Usar nestjs-rest-query com Drizzle ORM — type-safe, moderno, com controle fino sobre relações.
O Drizzle adapter permite usar nestjs-rest-query com Drizzle ORM. Drizzle oferece tipagem estrita, geração de schema type-safe, e um estilo funcional que complementa bem os decorators do NestJS.
Instalação
Instale Drizzle e o driver de banco:
pnpm add drizzle-orm postgres
# ou com mysql: drizzle-orm mysql2
# ou com sqlite: drizzle-orm better-sqlite3Então instale nestjs-rest-query:
pnpm add nestjs-rest-queryA partir da versão 2.0.0, typeorm é uma peer dependency opcional. Se
você usar apenas Drizzle, não precisa instalar TypeORM.
Configuração do módulo
import { Module } from '@nestjs/common';
import { DynamicQueryBuilderModule } from 'nestjs-rest-query';
import { DrizzleAdapter } from 'nestjs-rest-query/drizzle';
@Module({
imports: [
DynamicQueryBuilderModule.forRoot({
adapter: new DrizzleAdapter(),
pagination: { defaultPerPage: 20, maxPerPage: 100 },
}),
],
})
export class AppModule {}Definindo DrizzleSource
Cada endpoint Drizzle passa um objeto DrizzleSource ao queryBuilderService.execute(). Este objeto descreve:
db— a instância Drizzle (dbdo seu cliente PostgreSQL, MySQL, etc.)table— a tabela raiz (ex:users)primaryKey— a coluna PK da tabela raiz (ex:users.id)relations(opcional) — mapa de relações para JOINscolumnMap(opcional) — mapeamento explícito de colunas não-triviais
Exemplo de schema:
import {
pgTable,
serial,
varchar,
text,
timestamp,
integer,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
companyId: integer('company_id'),
createdAt: timestamp('created_at').defaultNow(),
});
export const companies = pgTable('companies', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
userId: integer('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
// Relations (optional, for the ORM - not used directly by the adapter)
export const usersRelations = relations(users, ({ one, many }) => ({
company: one(companies, {
fields: [users.companyId],
references: [companies.id],
}),
posts: many(posts),
}));Agora, no seu controller:
import { Controller, Get, Query } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import {
ApiDynamicQuery,
QueryRules,
QueryBuilderService,
RulesConfig,
QueryInput,
} from 'nestjs-rest-query';
import { DrizzleService } from './drizzle.service';
import { users, companies, posts } from './db/schema';
const rules: RulesConfig = {
alias: 'user',
filters: ['email', 'name', 'createdAt', 'company.name'],
sorts: ['name', 'createdAt'],
fields: ['id', 'name', 'email'],
includes: ['company', 'posts'],
search: ['name', 'email'],
};
@Controller('users')
export class UsersController {
constructor(
private readonly drizzleService: DrizzleService,
private readonly qb: QueryBuilderService
) {}
@Get()
@ApiDynamicQuery(rules)
list(@Query() query: QueryInput, @QueryRules() endpointRules = rules) {
return this.qb.execute(
{
db: this.drizzleService.db,
table: users,
primaryKey: users.id,
relations: {
company: {
table: companies,
on: eq(users.companyId, companies.id),
cardinality: 'one',
},
posts: {
table: posts,
on: eq(posts.userId, users.id),
cardinality: 'many',
primaryKey: posts.id,
},
},
},
query,
endpointRules
);
}
}Relations map é obrigatório para caminhos com pontos
Diferente de TypeORM (que auto-descobre relações via @OneToOne, @OneToMany), Drizzle não descobrirá relações automaticamente. Se sua query string inclui um dotted path (ex: ?filter[company.name][eq]=Acme), você deve registrar a relação no mapa relations.
Se você tentar usar um caminho não registrado:
GET /users?filter[company.name][eq]=AcmeE relations não contém company, o adapter lançará:
DrizzleAdapter: no relation registered for "company". Add it to DrizzleSource.relations.O mapa relations é keyed por joinPath (ex: "company", "company.owner"):
relations: {
company: {
table: companies,
on: eq(users.companyId, companies.id),
cardinality: 'one',
},
'company.owner': {
table: ownerUsers, // the User table again, for owner
on: eq(companies.ownerId, ownerUsers.id),
cardinality: 'one',
},
}Field paths em query strings
Field paths na query string devem corresponder a:
- Colunas diretas na tabela raiz — ex:
name→users.name - Colunas em relações registradas — ex:
company.name→companies.name(serelations.companyestá registrado) - Mapeadas via
columnMap— ex: secolumnMap['company.name'] = companies.name, o caminhocompany.nameé resolvido
Se uma coluna não for encontrada:
DrizzleAdapter: column "name" not found on relation "company".
Map it explicitly via DrizzleSource.columnMap["company.name"].Use columnMap para colunas não-triviais ou quando a estrutura de schema dificulta a descoberta automática:
columnMap: {
'company.name': companies.name,
'company.owner.email': ownerUsers.email,
}cardinality: 'many' requer primaryKey
Quando você declara uma relação 1:N (cardinality: 'many'), você DEVE fornecer a coluna PK da tabela relacionada. Isso é necessário para o adapter deduplicar linhas durante a agregação.
relations: {
posts: {
table: posts,
on: eq(posts.userId, users.id),
cardinality: 'many',
primaryKey: posts.id, // required for 'many'
},
}Se você esquecer:
DrizzleAdapter: relation "posts" has cardinality 'many' but no primaryKey. 'many' relations require relations["posts"].primaryKey for deduplication. If this is a 1:1 relation, change cardinality to 'one' (or omit it).Isso é verificado em tempo de compilação (o TypeScript discriminated union evita sintaxe inválida) e em tempo de execução (quando createQueryBuilder é chamado).
ORDER BY através de relação 'many' NÃO é suportado
Se você tentar ordenar por uma coluna de uma relação 1:N:
GET /users?sort=-posts.createdAtO adapter lançará:
DrizzleAdapter: ORDER BY a column from 'many' relation "posts" is not supported. Sort by root or 'one' relation columns. To order presented relation arrays, use the customize hook to add per-relation ORDER BY in your application layer.Por quê? Quando você JOIN 1:N, cada usuário aparece várias vezes na result set. Fazer ORDER BY colapsando essas linhas com DISTINCT deixa o significado da ordenação indefinido — qual createdAt você está ordenando, o primeiro post? O último?
Workaround: use o hook customize para aplicar ordenação de relações após o pipeline do adapter, na camada de aplicação:
return this.qb.execute(drizzleSource, query, rules, (qb) => {
// qb is the Drizzle query builder - you can perform custom operations
// After the adapter runs, you can transform the data in the application
});Ou, na camada de controller, após receber data, ordene os arrays de relação:
const result = await this.qb.execute(...);
result.data = result.data.map(row => ({
...row,
posts: row.posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
}));
return result;Forma do resultado — flat, compatível com TypeORM
DrizzleAdapter retorna linhas no mesmo formato flat que TypeOrmAdapter. Colunas da raiz ficam no nível superior; relações são chaves no mesmo nível (objeto para 'one', array para 'many').
GET /users?filter[email][ilike]=%@acme.com&includes=company,posts&page=1&perPage=2{
"data": [
{
"id": 1,
"name": "Ana",
"email": "ana@acme.com",
"createdAt": "2024-01-15T10:00:00Z",
"company": { "id": 101, "name": "Acme" },
"posts": [
{
"id": 201,
"title": "Hello",
"userId": 1,
"createdAt": "2024-01-20T14:00:00Z"
},
{
"id": 202,
"title": "World",
"userId": 1,
"createdAt": "2024-01-21T09:00:00Z"
}
]
},
{
"id": 2,
"name": "Bia",
"email": "bia@acme.com",
"createdAt": "2024-02-01T08:00:00Z",
"company": { "id": 101, "name": "Acme" },
"posts": []
}
],
"page": 1,
"perPage": 2,
"total": 17,
"lastPage": 9
}Notas:
- Colunas da raiz vão direto pro topo do objeto. Não há chave wrapper (
root/user/etc). - Relações 1:N (como
posts) aparecem como arrays, deduplicated porposts.id. - Relações 1:1 (como
company) aparecem como objetos escalares (ounullse o LEFT JOIN não achou match). - Não há aninhamento profundo (sem
company.ownerdentro decompany). Se precisar disso, aguarde oDrizzleRelationalAdapterfuturo baseado emdb.query.<table>.findMany({ with }). rules.aliascontinua útil para logging e mensagens de erro, mas não afeta o shape da resposta.- Atenção: se uma coluna da tabela raiz tem o mesmo nome de uma chave de relação, a relação sobrescreve a coluna no objeto. Evite no design do schema.
Paginação em duas fases (transparente)
Quando uma relação 'many' está presente, o adapter ativa a paginação em duas fases:
- Fase 1: SELECT DISTINCT root.id WHERE ... ORDER BY ... LIMIT/OFFSET
- Encontra IDs de usuários que combinam, respeitando página/limite
- Fase 2: SELECT root., relations. WHERE root.id IN (...) ORDER BY ...
- Busca todos os dados e relações para esses usuários
- Fase 3: Agregação no cliente — agrupa por root.id, arrays para 'many', escalares para 'one'
Resultado:
data.length <= perPagesempre (nunca inflado por 1:N JOINs)total= contagem distinta de usuários (não linhas de resultado)lastPageé correto
Trade-off: 3 queries por request em vez de 1. Custo de agregação em memória ≈ perPage × Σ(cardinalidade de cada relação 'many').
Recomendação: para endpoints esperando muitos children per parent, mantenha perPage modesto (≤50). Ou considere um endpoint separado para enumerar children.
Customize hook para casos avançados
Às vezes você quer aplicar operações Drizzle que o adapter não expõe:
return this.qb.execute(drizzleSource, query, rules, (qb) => {
// qb is the accumulated Drizzle query builder
// You can call custom methods here
});Exemplo: ordenar arrays de relação após agregação (conforme mencionado na seção ORDER BY):
const result = await this.qb.execute(drizzleSource, query, rules);
result.data = result.data.map((row) => ({
...row,
posts: [...(row.posts || [])].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
),
}));
return result;Próximos passos
- Migrando de uma versão anterior? Veja o guia de migração 1.x → 2.x para detalhes sobre peer dependencies opcionais, mudanças no shape do resultado e mensagens de erro do adapter.
- Para um guia completo de parâmetros, operadores e whitelist, consulte Guia de Uso.
- Se você quer implementar seu próprio adapter, veja Escrevendo seu próprio.