nestjs-rest-querynestjs-rest-query

Escrevendo o seu Adapter

O contrato de adapter da v3, o que o núcleo garante antes de um adapter rodar, e por que adapters de terceiros ainda não são um ponto de extensão suportado.

Não é um ponto de extensão suportado

Na v3 a identidade do adapter é uma união fechada. Tanto RestQueryAdapterV3.id quanto QuerySource.kind são tipados 'typeorm' | 'prisma' | 'drizzle', então um adapter para um quarto ORM não pode ser tipado sem reutilizar um desses três ids ou escrever um cast — e "nenhum cast no uso documentado" é gate de release desta biblioteca. Também não existe forRoot({ adapter }) para registrar um.

Então esta página documenta o contrato como ele existe, para ler as três implementações e para contribuir uma quarta. Não é receita para publicar um adapter privado.

O que o núcleo faz antes de um adapter rodar

Um adapter nunca vê uma query string, e nunca precisa adivinhar um tipo. Quando compile() é chamado, o núcleo já:

  1. Fez o parse da entrada contra um alfabeto seguro de paths. *, ;, .., path vazio e qualquer caractere que pudesse escapar para o SQL são recusados como QUERY_SYNTAX_INVALID — é isso que mantém o wildcard de projeção fora do alcance do cliente. Parâmetro fora da gramática de oito nomes (filter, sort, fields, includes, search, page, perPage, paginate) é recusado com 400 QUERY_SYNTAX_UNKNOWN_PARAM, nunca ignorado; o nome da chave vai em details, o valor nunca vai.
  2. Autorizou cada path contra as regras compiladas do endpoint, por correspondência exata, e cada operador contra a lista do campo.
  3. Coagiu cada valor pelo kind declarado do campo — nunca pela aparência do texto. Entrada ruim é FILTER_VALUE_INVALID; decimal chega como DecimalValue exato, date como CivilDate, datetime como Date, bigint como bigint.
  4. Resolveu a coluna efetiva: o próprio campo, o foldedField dele para ilike/notIlike, ou o portableOrderField para comparações de ordem.
  5. Marcou os termos existenciais. Um filtro ou alvo de search cujo path atravessa uma relação many carrega existential: true, então "alguma linha relacionada corresponde" é decidido pelo plano, não pela esperteza de cada adapter.
  6. Construiu e congelou o plano. Dados e contagem derivam do mesmo plano congelado, então as duas queries sempre descrevem a mesma pergunta.

Depois do execute(), o núcleo normaliza: colunas internas e chaves primárias não pedidas são descartadas, e cada kind é serializado da mesma forma independentemente do que o driver devolveu. Normalizar não é trabalho do adapter — isso é deliberado, porque três normalizações seriam três chances de divergir.

O contrato

export interface RestQueryAdapterV3<
  TSource,
  TCompiled,
  TRow,
  TNative = TCompiled,
> {
  readonly id: 'typeorm' | 'prisma' | 'drizzle';

  /** Deriva o schema lógico a partir da metadata da source. */
  describe(source: TSource): Promise<QuerySchema>;

  capabilities(source: TSource): AdapterCapabilities;

  compile(plan: TypedQueryPlan, source: TSource): TCompiled;

  /**
   * Aplica o callback ao contexto nativo, uma vez por query do escopo.
   * Chamar uma vez por query — em vez de entregar as duas juntas — é o que faz
   * o 'both', o default seguro, realmente atingir dados e contagem.
   */
  customize(
    compiled: TCompiled,
    callback: (native: TNative) => void,
    scope?: CustomizeScope
  ): void;

  execute(compiled: TCompiled): Promise<AdapterResult<TRow>>;
}

export interface AdapterResult<TRow> {
  /** Linhas já hidratadas em forma aninhada, ainda com colunas internas. */
  readonly rows: readonly TRow[];
  /**
   * Ausente quando o plano pediu paginate=false. Nesse caso, busque no máximo
   * `plan.pagination.maxRows + 1` roots: o serviço recusa com 400 um resultado
   * acima de `maxRows`, e a linha a mais é como ele distingue "no teto" de
   * "passou do teto" sem um COUNT.
   */
  readonly total?: number;
  /** Quantas queries foram emitidas; usado pelos testes de orçamento. */
  readonly queryCount?: number;
}

TNative é o contexto que o customize entrega ao consumidor, e ele é distinto de TCompiled porque um plano compila para duas queries: o callback age sobre uma de cada vez. No TypeORM TNative é o SelectQueryBuilder; no Prisma é { kind: 'data' | 'count', args }.

O describe() sustenta a garantia

Antes de compilar, o núcleo compara o schema que o adapter deriva com o schema que as regras do endpoint declaram, campo a campo: 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.

Um adapter que não consiga derivar metadata honestamente enfraquece essa garantia em vez da checagem: o adapter Prisma devolve o schema do manifesto na íntegra, e a consequência está documentada na página dele — nada ali valida a declaração contra o banco.

O capabilities() é uma promessa que o núcleo cobra

export interface AdapterCapabilities {
  readonly dialect: 'postgres' | 'mysql' | 'mssql' | 'sqlite';
  readonly transactionalConsistency: boolean;
  readonly escapeCharacter: string;
  readonly patternEscape: 'clause' | 'native' | 'unsupported';
}

O patternEscape é o que mais importa, e ele existe porque um caractere sozinho não distingue "eu emito a cláusula" de "eu confio no default do banco":

ValorSignificado
'clause'Emite ESCAPE '<escapeCharacter>' explicitamente. Correto em todo dialeto.
'native'Confia no caractere de escape default do dialeto, sem cláusula. Só correto em Postgres e MySQL.
'unsupported'Sem escape default e sem cláusula possível: os operadores de padrão são recusados com CAPABILITY_UNAVAILABLE em vez de devolverem linhas erradas.

Declarar escapeCharacter e nunca emitir ESCAPE é exatamente o defeito que essa separação foi introduzida para tornar impossível.

transactionalConsistency: false faz consistency: 'transactional' falhar cedo em vez de rodar dados e contagem sob snapshots diferentes. Os três adapters entregues reportam false hoje, e é por isso que o forRoot recusa consistency: 'transactional' já na inicialização, com SOURCE_CONFIGURATION_INVALID. A checagem por source continua valendo: é ela que decide o caso de um quarto adapter que ofereça a garantia.

A source

export interface QuerySource<TSource, TCompiled, TRow, TNative = TCompiled> {
  readonly kind: 'typeorm' | 'prisma' | 'drizzle';
  readonly adapter: RestQueryAdapterV3<TSource, TCompiled, TRow, TNative>;
  readonly input: TSource;
  /** Fatos do perfil certificado, coletados na subida — nunca no request. */
  readonly portabilityProfile?: ProfileFacts;
}

Carregar adapter, entrada nativa e discriminante juntos é o que permite ao execute() inferir o tipo da linha e o tipo do callback de customize sem cast no ponto de chamada. É também por isso que não existe configuração global de adapter: a source já sabe.

Contribuindo um adapter

Se você quer um quarto ORM suportado, o caminho é um pull request, não um plugin:

  1. Abra uma Discussion descrevendo o ORM.
  2. Siga a estrutura de src/infra/adapters/ — typeorm/ é a implementação de referência, prisma/ e drizzle/ mostram duas estratégias de compilação diferentes.
  3. Faça o corpus de paridade em tests/v3/corpus/ passar primeiro no dialeto de referência, depois nas células reais. Uma divergência tem de ser declarada como dado no próprio caso, com justificativa obrigatória — o inventário do corpus recusa uma nova que ninguém revisou.
  4. Acrescente o id do adapter às uniões e exporte-o como subpath, por exemplo nestjs-rest-query/kysely.

O piso de cobertura de branches dos diretórios de adapter é 100%, em catraca: a paridade passa pelos adapters, e catraca é o único regime que impede erosão silenciosa.

Referências

Editar esta página no GitHub

On this page