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á:
- 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 comoQUERY_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 com400 QUERY_SYNTAX_UNKNOWN_PARAM, nunca ignorado; o nome da chave vai emdetails, o valor nunca vai. - Autorizou cada path contra as regras compiladas do endpoint, por correspondência exata, e cada operador contra a lista do campo.
- Coagiu cada valor pelo
kinddeclarado do campo — nunca pela aparência do texto. Entrada ruim éFILTER_VALUE_INVALID;decimalchega comoDecimalValueexato,datecomoCivilDate,datetimecomoDate,bigintcomobigint. - Resolveu a coluna efetiva: o próprio campo, o
foldedFielddele parailike/notIlike, ou oportableOrderFieldpara comparações de ordem. - Marcou os termos existenciais. Um filtro ou alvo de
searchcujo path atravessa uma relaçãomanycarregaexistential: true, então "alguma linha relacionada corresponde" é decidido pelo plano, não pela esperteza de cada adapter. - 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":
| Valor | Significado |
|---|---|
'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:
- Abra uma Discussion descrevendo o ORM.
- Siga a estrutura de
src/infra/adapters/—typeorm/é a implementação de referência,prisma/edrizzle/mostram duas estratégias de compilação diferentes. - 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. - 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.