nestjs-rest-querynestjs-rest-query

Adapter TypeORM

Use o nestjs-rest-query com TypeORM - o adapter de referência.

O TypeORM é o adapter de referência: é contra ele que os outros dois são comparados. Ele não é um default, porém — não existe adapter default na v3.

Instalação

pnpm add typeorm @nestjs/typeorm

A faixa suportada é ^0.3.26 || ^1.0.0; as duas majors passam o corpus de paridade. O typeorm é peer dependency opcional, então ele não é instalado por você.

Configuração do módulo

app.module.ts
import { Module } from '@nestjs/common';
import { DynamicQueryBuilderModule } from 'nestjs-rest-query';

@Module({
  imports: [
    DynamicQueryBuilderModule.forRoot({
      pagination: { defaultPerPage: 10, maxPerPage: 500 },
    }),
  ],
})
export class AppModule {}

Nenhum adapter entra aqui. forRoot({ adapter: new TypeOrmAdapter() }) é recusado na inicialização com SOURCE_CONFIGURATION_INVALID.

Uso

users.business.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
  DynamicQueryDto,
  QueryBuilderService,
  type CompiledQueryRules,
  type NormalizedQueryResult,
} from 'nestjs-rest-query';
import { typeormSource } from 'nestjs-rest-query/typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersBusiness {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly queryBuilderService: QueryBuilderService
  ) {}

  async findAll(
    query: DynamicQueryDto,
    rules: CompiledQueryRules
  ): Promise<NormalizedQueryResult<User>> {
    return this.queryBuilderService.execute(
      typeormSource(this.userRepository),
      query,
      rules
    );
  }
}

O typeormSource infere o tipo da linha do repositório que você entrega, então a anotação Promise<NormalizedQueryResult<User>> não precisa de argumento de tipo aqui. O prismaSource<TRow> e o drizzleSource<TRow> também são genéricos no tipo da linha; o que muda é por onde o tipo chega.

Opções da source

typeormSource(repository, {
  // Tipos lógicos que o banco não expressa — um char(36) que guarda UUID.
  fieldKinds: { user: { id: 'uuid' } },
  // Fatos do perfil certificado, coletados uma vez na subida.
  portabilityProfile: facts,
});

Derivando o schema

Este é o único adapter com um derivador de schema:

import { buildSchemaRegistry } from 'nestjs-rest-query/typeorm';

const registry = buildSchemaRegistry(userRepository, {
  fieldKinds: { user: { id: 'uuid' } },
});

Ele percorre o grafo de relações transitivamente, então todo model alcançável cai no registry. A restrição está em onde ele pode rodar: exige um Repository vivo, e as regras do endpoint são consumidas por @ApiDynamicQuery(rules) — um decorator de método, avaliado quando a classe do controller carrega, antes de o Nest montar o container. Dentro de uma factory de provider ele é utilizável; ao lado de um decorator, não — e é por isso que os quatro exemplos declaram o schema à mão.

O correspondente do Drizzle é o buildSourceSchema, que deriva do descritor declarado e não do ORM. O Prisma não tem equivalente nenhum.

Convenções que o resolver cobra

O schema declarado é comparado com o derivado, campo a campo, antes da primeira query. São comparados: model, primaryKey, e por campo kind, nullable, primaryKey, foldedField, portableOrderField, internal; por relação target, cardinality, nullable. Qualquer diferença é SOURCE_CONFIGURATION_INVALID. Só o schema do root é comparado — os schemas das relações no registry não são.

Então três detalhes não são escolha de estilo:

Nome das colunas companheiras. O resolver reconhece a companheira dobrada como <path do campo>_folded e a de ordem portável como <path do campo>_order, sobre o nome da propriedade da entidade, não sobre a coluna física. Para firstName mapeado em first_name, a propriedade tem de ser firstName_folded — a coluna física pode continuar first_name_folded. Qualquer propriedade cujo nome termine em _folded ou _order é marcada como interna automaticamente.

@Column({ name: 'first_name' })
firstName: string;

@Column({ name: 'first_name_folded', select: false })
firstName_folded: string;

O nome do model é derivado, não escolhido: metadata.name.replace(/Entity$/, '').toLowerCase(). AccessRequestItem vira accessrequestitem — não access_request_item, não accessRequestItem. A divergência aparece na primeira requisição como Source model X does not match query rules model Y.

A nulabilidade da relação tem de bater com a derivação, que é relation.isNullable || relation.isOneToMany: uma coleção OneToMany é sempre nullable: true, e um ManyToOne sobre uma chave estrangeira NOT NULL tem de ser nullable: false.

O que o compilador faz

  • Junções idempotentes para filtro, search, sort e fields, mesmo sem includes; joins de predicado ficam separados dos de apresentação.
  • Chaves primárias compostas, inclusive no desempate de paginação sobre todas as partes.
  • Paginação em duas fases quando a projeção inclui relação many, então uma página nunca é inflada pelo join.
  • EXISTS correlacionado — com correlação por chave estrangeira composta — para filtro ou alvo de search que atravesse uma relação many, em qualquer profundidade. O total continua contando roots e a página continua com o tamanho pedido.

Cadeias existenciais, em qualquer profundidade

Uma condição existencial — filtro ou alvo de search cujo path atravessa uma relação many — compila com o que vier depois daquele salto: outra relação one (posts.author.name), uma segunda coleção, ou uma many-to-many (posts.tags.label). Nenhuma forma é recusada, e o adapter não emite CAPABILITY_UNAVAILABLE em lugar nenhum.

A forma compilada é sempre um EXISTS correlacionado ao root uma só vez, no primeiro salto; cada salto seguinte é um INNER JOIN dentro da subconsulta, e a condição da folha é qualificada pelo último alias. Correlacionar cada salto por fora traria a coleção para o FROM externo, inflaria os roots e estragaria o total — exatamente o problema que o EXISTS existe para evitar.

Na many-to-many a tabela de junção é um passo como os outros, então é ela que fica no FROM da subconsulta, e a tabela alvo entra por join a partir dela. Esses identificadores de junção são os únicos que este compilador tira da estratégia de nomes do TypeORM em vez das suas entidades (articlesId), então passam pelo escape do driver.

As regras que você pode declarar também não ficam limitadas — era ali que o limite mordia primeiro, na subida, e ele acabou: search: ['items.company.name'] sobe e roda, que é o que o 02-app-with-postgres declara.

ESM: @nestjs/typeorm 12

O @nestjs/typeorm@12 é "type": "module" sem entrada CommonJS, o que mata os globs baseados em __dirname que toda aplicação NestJS + TypeORM tem:

- entities: [path.join(__dirname, '/../**/*.entity{.ts,.js}')],
- migrations: [path.join(__dirname, '/migrations/*{.ts,.js}')],
+ entities: [User, Company, AccessRequest],
+ migrations: MIGRATIONS, // um array explícito e ordenado

__dirname não existe em ESM, então sob runtime ESM a aplicação sobe com zero entidades e zero migrations — sem erro, apenas um schema vazio. Listar explicitamente tem um segundo benefício: uma entidade renomeada quebra o build em vez da subida.

Entidades num ciclo de relação também querem o próprio Relation<T> do TypeORM; sem ele o emitDecoratorMetadata avalia uma classe ainda em TDZ e a inicialização estoura com Cannot access 'Category' before initialization.

O customize recebe o SelectQueryBuilder

No TypeORM o contexto nativo entregue ao customize é o SelectQueryBuilder tipado, uma vez por query do escopo:

await this.queryBuilderService.execute(
  typeormSource(this.userRepository),
  query,
  rules,
  {
    customize: (qb) => {
      qb.andWhere('root.tenant_id = :tenant', { tenant });
    },
    customizeScope: 'both',
  }
);

'both' é o default e o seguro — veja Customizando a Query.

Próximos passos

Editar esta página no GitHub

On this page