nestjs-rest-querynestjs-rest-query

Writing your own Adapter

The v3 adapter contract, what the core guarantees before an adapter runs, and why third-party adapters are not a supported extension point yet.

Not a supported extension point

In v3 the adapter identity is a closed union. Both RestQueryAdapterV3.id and QuerySource.kind are typed 'typeorm' | 'prisma' | 'drizzle', so an adapter for a fourth ORM cannot be typed without reusing one of those three ids or writing a cast — and "no cast in documented usage" is a release gate for this library. There is also no forRoot({ adapter }) to register one.

So this page documents the contract as it exists, for reading the three implementations and for contributing a fourth. It is not a recipe for shipping a private adapter.

What the core does before an adapter runs

An adapter never sees a query string, and never has to guess a type. By the time compile() is called, the core has already:

  1. Parsed the input against a safe path alphabet. *, ;, .., an empty path and anything that could escape into SQL are rejected as QUERY_SYNTAX_INVALID — which is what keeps the projection wildcard out of a client's reach. A parameter outside the eight-name grammar (filter, sort, fields, includes, search, page, perPage, paginate) is refused with 400 QUERY_SYNTAX_UNKNOWN_PARAM, never ignored; the key name travels in details, the value never does.
  2. Authorised every path against the endpoint's compiled rules, by exact match, and every operator against the per-field list.
  3. Coerced every value by the field's declared kind — never by how the text looks. Bad input is FILTER_VALUE_INVALID; decimal arrives as an exact DecimalValue, date as a CivilDate, datetime as a Date, bigint as a bigint.
  4. Resolved the effective column: the field itself, its foldedField for ilike/notIlike, or its portableOrderField for order comparisons.
  5. Marked existential terms. A filter or search target whose path crosses a many relation carries existential: true, so "some related row matches" is decided by the plan, not by each adapter's cleverness.
  6. Built and frozen the plan. Data and count derive from the same frozen plan, so the two queries always describe the same question.

After execute(), the core normalises: internal columns and unrequested primary keys are dropped, and each kind is serialised the same way regardless of what the driver returned. Normalisation is not the adapter's job — that is deliberate, because three normalisations would be three chances to diverge.

The contract

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

  /** Derive the logical schema from the source's metadata. */
  describe(source: TSource): Promise<QuerySchema>;

  capabilities(source: TSource): AdapterCapabilities;

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

  /**
   * Apply the callback to the native context, once per query in scope.
   * Calling once per query — rather than handing both over together — is what
   * makes 'both', the safe default, actually reach data and count.
   */
  customize(
    compiled: TCompiled,
    callback: (native: TNative) => void,
    scope?: CustomizeScope
  ): void;

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

export interface AdapterResult<TRow> {
  /** Rows already hydrated in nested form, internal columns still present. */
  readonly rows: readonly TRow[];
  /**
   * Absent when the plan asked for paginate=false. In that case fetch at most
   * `plan.pagination.maxRows + 1` roots: the service refuses a result above
   * `maxRows` with a 400, and the extra row is how it tells "at the cap" from
   * "over it" without a COUNT.
   */
  readonly total?: number;
  /** How many queries were emitted; used by the budget tests. */
  readonly queryCount?: number;
}

TNative is the context customize hands to the consumer, and it is distinct from TCompiled because one plan compiles to two queries: the callback acts on one at a time. Under TypeORM TNative is the SelectQueryBuilder; under Prisma it is { kind: 'data' | 'count', args }.

describe() is load-bearing

Before compiling, the core compares the schema the adapter derives with the schema the endpoint rules declare, field by field: model, primaryKey, and per field kind, nullable, primaryKey, foldedField, portableOrderField, internal; per relation target, cardinality, nullable. Any difference is SOURCE_CONFIGURATION_INVALID. Only the root schema is compared.

An adapter that cannot derive metadata honestly weakens that guarantee rather than the check: the Prisma adapter returns its manifest's schema verbatim, and the consequence is documented on its page — nothing there validates the declaration against the database.

capabilities() is a promise the core enforces

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

patternEscape is the one that matters most, and it exists because a character alone cannot distinguish "I emit the clause" from "I trust the database default":

ValueMeaning
'clause'Emits ESCAPE '<escapeCharacter>' explicitly. Correct in every dialect.
'native'Trusts the dialect's default escape character, with no clause. Only correct on Postgres and MySQL.
'unsupported'No default escape and no clause possible: the pattern operators are refused with CAPABILITY_UNAVAILABLE rather than returning wrong rows.

Declaring escapeCharacter while never emitting ESCAPE is exactly the bug this split was introduced to make impossible.

transactionalConsistency: false makes consistency: 'transactional' fail early instead of running data and count under different snapshots. All three shipped adapters report false today, which is why forRoot refuses consistency: 'transactional' at startup with SOURCE_CONFIGURATION_INVALID. The per-source capability check stays: it is what decides the case for a fourth adapter that does offer the guarantee.

The source

export interface QuerySource<TSource, TCompiled, TRow, TNative = TCompiled> {
  readonly kind: 'typeorm' | 'prisma' | 'drizzle';
  readonly adapter: RestQueryAdapterV3<TSource, TCompiled, TRow, TNative>;
  readonly input: TSource;
  /** Certified profile facts, collected at startup — never in the request path. */
  readonly portabilityProfile?: ProfileFacts;
}

Carrying adapter, native input and discriminant together is what lets execute() infer the row type and the customize callback type with no cast at the call site. It is also why there is no global adapter setting: the source already knows.

Contributing an adapter

If you want a fourth ORM supported, the path is a pull request, not a plugin:

  1. Open a Discussion describing the ORM.
  2. Follow the structure in src/infra/adapters/ — typeorm/ is the reference implementation, prisma/ and drizzle/ show two different compilation strategies.
  3. Make the parity corpus in tests/v3/corpus/ pass on the reference dialect first, then on the real cells. A divergence has to be declared as data on the case itself, with a mandatory reason — the corpus inventory refuses a new one that nobody reviewed.
  4. Add the adapter's id to the unions and export it as a subpath, e.g. nestjs-rest-query/kysely.

The branch coverage floor for adapter directories is 100%, on a ratchet: parity runs through the adapters, and a ratchet is the only regime that stops silent erosion.

Resources

Edit this page on GitHub

On this page