nestjs-rest-querynestjs-rest-query

Adapter Drizzle

Use o nestjs-rest-query com o Drizzle ORM - descritor lógico declarado, dialeto explícito e os limites que ele declara.

O adapter Drizzle compila o plano de query num statement explícito — aliases, junções, condições, ordem, paginação — que um executor então materializa no dialeto declarado.

Instalação

pnpm add drizzle-orm postgres
# mysql2 para MySQL, ou o driver do SQL Server

0.45.x não é suportado

A faixa de peer é >=1.0.0-rc.4 <1.0.0 — fechada nos release candidates contra os quais a matriz de paridade foi medida. Um consumidor 2.x em 0.45.x tem de subir, e quatro coisas quebram no caminho, nenhuma delas descrita pelas release notes do próprio Drizzle de um jeito que você conectaria a esta biblioteca:

  1. drizzle(client, { schema }) não existe mais. A assinatura da 1.x deixa só drizzle({ client }). O argumento { schema } servia à API relacional (db.query.*), que este adapter não usa.
  2. relations() foi removido do drizzle-orm (substituído por defineRelations). Na v3 a resposta certa é apagar aquelas declarações: as relações são declaradas no descritor lógico, por path pontuado.
  3. declaration: true + TypeScript 6 + drizzle-orm 1.x é TS2883. Os tipos que pgTable() e drizzle() inferem não são nomeáveis de fora do pacote. Uma aplicação não publica tipos, então use "declaration": false. O skipLibCheck não resolve este — ele continua necessário por outro motivo, porque o drizzle-orm@1.0.0-rc.4 dá erro dentro do próprio .d.cts no TypeScript 6.
  4. db.all() só existe na família SQLite. PostgreSQL, MySQL e SQL Server expõem execute(), e cada um devolve uma forma diferente. Qual método chamar sai do dialeto que você declara, nunca da inspeção do objeto — e é por isso que o dialeto é argumento obrigatório.

O executor, uma vez

O que os seus serviços injetam não é o db do Drizzle: é o DrizzleDatabase que drizzleDatabase() devolve.

db/database.module.ts
import { Global, Module } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import {
  drizzleDatabase,
  type DrizzleDatabase,
} from 'nestjs-rest-query/drizzle';

export function createDatabase(url: string) {
  return drizzle({ client: postgres(url, { max: 5 }) });
}

export type AppDatabase = ReturnType<typeof createDatabase>;

export const APP_DATABASE = Symbol('APP_DATABASE');
export const DRIZZLE_EXECUTOR = Symbol('DRIZZLE_EXECUTOR');

@Global()
@Module({
  providers: [
    { provide: APP_DATABASE, useFactory: () => createDatabase(databaseUrl()) },
    {
      provide: DRIZZLE_EXECUTOR,
      inject: [APP_DATABASE],
      // Sem cast: o `db` do postgres-js satisfaz DrizzleClientLike
      // estruturalmente, porque expõe execute().
      useFactory: (client: AppDatabase): DrizzleDatabase =>
        drizzleDatabase({ client, dialect: 'postgres' }),
    },
  ],
  exports: [APP_DATABASE, DRIZZLE_EXECUTOR],
})
export class DatabaseModule {}

O descritor lógico

O adapter não inspeciona o seu pgTable. Você declara um descritor lógico, e é esse descritor que decide tipo do valor, nulabilidade, quais colunas são internas, qual coluna dobrada apoia search/ilike e qual coluna dá ordem total portável.

db/tables.ts
import {
  createDrizzleTable,
  type DrizzleRelationMap,
  type DrizzleTable,
} from 'nestjs-rest-query/drizzle';

export const companiesTable: DrizzleTable = createDrizzleTable({
  name: 'companies',
  model: 'company',
  columns: {
    id: {
      name: 'id',
      kind: 'uuid',
      nullable: false,
      primaryKey: true,
      // Sem isto, toda requisição falha: o desempate da paginação é sempre
      // aplicado sobre a chave primária, e uuid não tem ordem total portável.
      portableOrderField: 'idOrder',
    },
    idOrder: {
      name: 'idOrder',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      internal: true,
    },
    name: {
      name: 'name',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      foldedField: 'nameFolded',
    },
    nameFolded: {
      name: 'nameFolded',
      kind: 'string',
      nullable: false,
      primaryKey: false,
      internal: true,
    },
    createdAt: {
      name: 'createdAt',
      kind: 'datetime',
      nullable: false,
      primaryKey: false,
    },
  },
});

O kind é a decisão mais consequente aqui. uuid não é string: ele proíbe gt/lt/between sem portableOrderField e recusa valor que não seja um UUID canônico, em vez de comparar texto arbitrário.

Relações, por path pontuado

export const userRelations: DrizzleRelationMap = {
  company: {
    target: companiesTable,
    cardinality: 'one',
    // companyId é nullable, então a relação também é: o LEFT JOIN sem
    // correspondência vira company: null, não um objeto cheio de nulos.
    nullable: true,
    sourceColumn: 'companyId',
    targetColumn: 'id',
  },
  posts: {
    target: postsTable,
    cardinality: 'many',
    nullable: true,
    sourceColumn: 'id',
    targetColumn: 'userId',
  },
};

A chave é o path visto do root: company é um salto, company.owner seria o seguinte. Só as chaves sem ponto entram no schema lógico do root; as pontuadas existem para o compilador saber juntar ou correlacionar caminhos profundos.

sourceColumn/targetColumn são as colunas da junção na direção do path, e não "a FK de verdade": numa relação many o lado do root entra com id e o alvo com userId, o inverso de uma relação one.

Monte o registry a partir do descritor

users/users.query.ts
import { defineQueryRules } from 'nestjs-rest-query';
import type { SchemaRegistry } from 'nestjs-rest-query';
import { buildSourceSchema } from 'nestjs-rest-query/drizzle';

export const USER_SCHEMAS: SchemaRegistry = new Map([
  ['user', buildSourceSchema(usersTable, userRelations)],
  ['company', buildSourceSchema(companiesTable, {})],
  ['post', buildSourceSchema(postsTable, {})],
]);

O buildSourceSchema é a função que o drizzleSource usa internamente para descrever a source, então derivar o registry dela remove uma classe inteira de SOURCE_CONFIGURATION_INVALID: escrever um defineQuerySchema à mão aqui daria duas verdades sobre a mesma tabela, e o núcleo compara as duas antes de executar.

A source, por requisição

users/users.service.ts
import { drizzleSource, type DrizzleDatabase } from 'nestjs-rest-query/drizzle';

async findAll(
  query: DynamicQueryDto,
  rules: CompiledQueryRules,
): Promise<NormalizedQueryResult<object>> {
  return this.queryBuilderService.execute(
    drizzleSource({
      db: this.db,            // o DrizzleDatabase, não o db cru do drizzle
      dialect: 'postgres',    // tem de bater com o executor; falha fechado
      table: usersTable,
      relations: userRelations,
    }),
    query,
    rules,
  );
}

O dialect declarado tem de ser o do executor. Divergir não daria erro em lugar nenhum — daria resultado errado, porque paginação e coerção de boolean saem do dialeto —, então o drizzleSource compara os dois e falha fechado.

O drizzleSource<TRow> é genérico no tipo da linha, e o TRow chega pelo executor: monte-o como drizzleDatabase<UserRow>({ client, dialect }) e o drizzleSource({ db, ... }) infere UserRow, então o método pode ser anotado Promise<NormalizedQueryResult<UserRow>> sem cast. O object acima é só o default de quem não estreita o tipo.

O que o compilador faz

Relações por path pontuado, planner de junções idempotente, EXISTS correlacionado para qualquer salto many — inclusive cadeias, com o segundo salto como join dentro da subconsulta — e coleção de primeiro nível hidratada por consulta própria. ILIKE nunca é emitido.

A chave lógica e a coluna física são coisas diferentes, e as duas são honradas. A chave de columns é o campo lógico — o que o fields=, as regras e o JSON falam — e o DrizzleColumn.name é a coluna física que o compilador põe no SQL por sql.identifier(...). Uma chave de descritor diferente do nome físico da coluna é mapeamento suportado, não defeito.

Campo não declarado falha fechado enquanto a aplicação sobe, não no banco: o buildSourceSchema passa o sourceColumn e o targetColumn de cada relação pela mesma resolução, então coluna de junção não declarada é SOURCE_CONFIGURATION_INVALID — Drizzle table users has no column declared for field companyId — na construção da source.

O 03-app-with-drizzle usa o mapeamento: nomes físicos em snake_case (id_order, name_folded, created_at) atrás de chaves lógicas em camelCase, com uma verificação na subida contra getTableColumns(pgTable) de que cada chave é a propriedade do pgTable e cada name é a coluna física daquela propriedade.

Limites

Coleção aninhada sob outra relação falha fechado. Projetar uma relação many pendurada em outra relação é recusado com ADAPTER_CONTRACT_VIOLATION — Drizzle adapter cannot project the nested to-many relation <path> — em vez de devolver silenciosamente uma coleção vazia. Coleções de primeiro nível são suportadas.

drizzle-kit push/generate não expressam o perfil certificado. Eles não emitem COLLATE "C", e collation por ponto de código nas colunas de texto portáveis faz parte da promessa de paridade. O exemplo 03 emite DDL explícito em src/database/bootstrap.ts; veja test/profiles/ para o DDL de referência por família.

Migrando uma source Drizzle da 2.x

A v2 entregava objetos de coluna do Drizzle ao adapter; a v3 recebe um descritor lógico e nomes simples:

v2v3
source.db = o db do DrizzledrizzleDatabase({ client: db, dialect })
source.table = um pgTablecreateDrizzleTable({ name, model, columns }) (um descritor)
source.primaryKey = uma colunacolumns[x].primaryKey: true
relations.x.tablerelations.x.target (outro descritor)
relations.x.on: eq(a, b)relations.x.sourceColumn + .targetColumn
relations.x.primaryKey (era obrigatório)removido
—relations.x.nullable (obrigatório)
—relations['x.y'] para saltos profundos

O columnMap também deixou de existir: os paths resolvem pelo descritor lógico.

Próximos passos

Editar esta página no GitHub

On this page