@dudousxd/nestjs-filter-drizzle
Version:
Drizzle ORM adapter for @dudousxd/nestjs-filter.
525 lines • 21.6 kB
JavaScript
import { Column, SQL, aliasedTable, and, asc, count, eq, exists, getTableColumns, inArray, is, isNull, sql, } from 'drizzle-orm';
import { coerceValue } from './operator-resolver.js';
import { columnOf } from './schema-metadata.js';
/**
* State shared by a root query and every child it spawns (relation constraints,
* nested `EXISTS`): the database, the dialect, the schema metadata, and ONE
* alias counter — so two subqueries over the same table never collide, however
* deeply they nest.
*/
export class DrizzleQueryContext {
db;
dialect;
metadata;
aliasSeq = 0;
constructor(db, dialect, metadata) {
this.db = db;
this.dialect = dialect;
this.metadata = metadata;
}
/** A fresh, SQL-safe table alias (`posts_1`, `manager_2`, …). */
nextAlias(base) {
this.aliasSeq += 1;
return `${base.replace(/[^a-zA-Z0-9_]/g, '_')}_${this.aliasSeq}`;
}
/** @internal — the untyped select entry points. */
get queryDb() {
return this.db;
}
}
/** Largest `IN (...)` list sent when loading includes; keeps every driver under its bind limit. */
const INCLUDE_BATCH_SIZE = 1000;
/**
* The query state the core hands to a Drizzle filter as `this.$query`.
*
* Drizzle's select builders are immutable-ish and single-shot (`.where()`
* replaces, the builder is bound to one projection at creation time), which is
* the opposite of what a filter pipeline needs: many independent methods each
* contributing a condition. So filters write into this accumulator instead —
* conditions, ordering, a page window, a projection, includes — and the
* accumulated state is materialized into ONE `db.select().from(table)` at
* execution time ({@link toSelect}, {@link execute}).
*
* ```ts
* @FilterFor('minAge')
* applyMinAge(value: number) {
* this.$query.where(gte(users.age, value));
* }
* ```
*
* Relations never become joins on the root query. A relation constraint is an
* `EXISTS (…)` subquery and an include is a second, batched query — so the
* root query always returns exactly one row per matching entity, and
* `LIMIT`/`OFFSET`/`COUNT(*)` stay correct with any number of to-many
* relations involved.
*/
export class DrizzleQuery {
context;
baseTable;
conditions = [];
orderings = [];
limitValue;
offsetValue;
projection;
extras = {};
distinctFlag = false;
includePaths = [];
/**
* @param context - Shared db/dialect/metadata/alias state.
* @param baseTable - The ORIGINAL table object (metadata is keyed by it).
* @param aliased - When this query targets an aliased copy of the table (a
* relation constraint's subquery), that alias; columns are read off it.
*/
constructor(context, baseTable, aliased) {
this.context = context;
this.baseTable = baseTable;
this.table = aliased ?? baseTable;
}
/** The table the query reads — the original, or its alias inside a subquery. */
table;
/** The drizzle database the query executes against. */
get db() {
return this.context.db;
}
/** `'postgres' | 'mysql' | 'sqlite'`. */
get dialect() {
return this.context.dialect;
}
/** The table's columns keyed by property name — `this.$query.columns.email`. */
get columns() {
return getTableColumns(this.table);
}
// ─── Conditions ─────────────────────────────────────────────────────────────
/**
* ANDs one or more conditions onto the query. `undefined` entries are
* ignored, so drizzle's `and()`/`or()` (which return `undefined` for an empty
* list) compose without guards.
*/
where(...conditions) {
for (const condition of conditions) {
if (condition !== undefined)
this.conditions.push(condition);
}
return this;
}
/**
* Like {@link where}, but also accepts an equality map keyed by column
* property — `andWhere({ status: 'active', role: ['a', 'b'] })` — which is
* the shape the core's `related()` helper and `@TenantScoped` emit. Array
* values become `IN`, `null` becomes `IS NULL`; keys that are not columns of
* the table are ignored.
*/
andWhere(condition) {
if (condition === undefined)
return this;
if (is(condition, SQL))
return this.where(condition);
for (const [key, value] of Object.entries(condition)) {
const column = columnOf(this.table, key);
if (!column)
continue;
if (value === null)
this.where(isNull(column));
else if (Array.isArray(value)) {
this.where(inArray(column, coerceValue(column, value)));
}
else
this.where(eq(column, coerceValue(column, value)));
}
return this;
}
/**
* Constrains the query to rows that HAVE a related row — `EXISTS (SELECT 1
* FROM <relation> WHERE <correlation> AND <build(target)>)`. The callback
* receives the related table (an alias — reference its columns through the
* argument, not the imported table object). A dotted path follows several
* relations (`'posts.comments'`).
*
* ```ts
* this.$query.whereHas('posts', (posts) => eq(posts.status, 'published'));
* ```
*
* An unknown relation throws — silently ignoring a constraint would return
* rows the caller asked to exclude.
*/
whereHas(relationPath, build) {
const condition = this.relationExists(relationPath.split('.'), build);
if (!condition) {
throw new Error(`whereHas: "${relationPath}" is not a relation path of table "${this.context.metadata.tableDisplayName(this.baseTable)}". Declare it with relations() and pass the schema to drizzle() or DrizzleFilterModule.`);
}
return this.where(condition);
}
/** The negation of {@link whereHas}: rows with NO matching related row. */
whereDoesntHave(relationPath, build) {
const condition = this.relationExists(relationPath.split('.'), build);
if (!condition) {
throw new Error(`whereDoesntHave: "${relationPath}" is not a relation path of table "${this.context.metadata.tableDisplayName(this.baseTable)}".`);
}
return this.where(sql `not ${condition}`);
}
/** The accumulated WHERE — every condition ANDed — or `undefined` when empty. */
getWhere() {
return and(...this.conditions);
}
// ─── Ordering, window, projection ───────────────────────────────────────────
/**
* Appends ORDER BY terms. A bare column sorts ascending; use drizzle's
* `asc()`/`desc()` for an explicit direction.
*/
orderBy(...terms) {
this.orderings.push(...terms);
return this;
}
/** Drops every ORDER BY term accumulated so far. */
clearOrderBy() {
this.orderings.length = 0;
return this;
}
limit(limit) {
this.limitValue = limit;
return this;
}
offset(offset) {
this.offsetValue = offset;
return this;
}
/**
* Replaces the projection with the given fields (keyed by output name). The
* default projection is every column of the table.
*/
select(fields) {
this.projection = { ...fields };
return this;
}
/**
* Adds fields to the projection without replacing it — computed values that
* should come back on each row next to the table's own columns.
*/
addSelect(fields) {
Object.assign(this.extras, fields);
return this;
}
/** Marks the projection `SELECT DISTINCT`. */
distinct(on = true) {
this.distinctFlag = on;
return this;
}
/**
* Relations to load onto the fetched rows (dotted for nesting:
* `'posts.comments'`). Loaded by {@link execute} in separate batched
* queries after the page is fetched, never joined — see the class doc.
*/
include(...relationPaths) {
for (const path of relationPaths) {
if (!this.includePaths.includes(path))
this.includePaths.push(path);
}
return this;
}
isDistinct() {
return this.distinctFlag;
}
getIncludes() {
return this.includePaths;
}
getOrderBy() {
return this.orderings;
}
getLimit() {
return this.limitValue;
}
getOffset() {
return this.offsetValue;
}
/** The projection currently in effect (explicit, or all columns), plus additive fields. */
getSelection() {
const base = this.projection ?? this.columns;
return { ...base, ...this.extras };
}
/** True when the projection was narrowed (`select`/`distinct`) rather than all columns. */
hasExplicitProjection() {
return this.projection !== undefined;
}
// ─── Execution ──────────────────────────────────────────────────────────────
/**
* Materializes the accumulated state as a drizzle select builder:
* `db.select(<projection>).from(table).where(…).orderBy(…).limit(…).offset(…)`.
* Use it to extend the query with anything the accumulator does not model,
* or to hand it to drizzle APIs that take a builder.
*/
toSelect(opts = {}) {
const selection = this.selectionForExecution();
const db = this.context.queryDb;
const start = this.distinctFlag ? db.selectDistinct(selection) : db.select(selection);
let chain = start.from(this.table);
const where = this.getWhere();
if (where)
chain = chain.where(where);
if (opts.withOrder !== false && this.orderings.length > 0) {
chain = chain.orderBy(...this.orderings);
}
if (opts.withWindow !== false) {
if (this.limitValue !== undefined)
chain = chain.limit(this.limitValue);
else if (this.offsetValue !== undefined && this.context.dialect !== 'postgres') {
// MySQL and SQLite reject OFFSET without LIMIT; "no limit" is spelled as the max.
chain = chain.limit(Number.MAX_SAFE_INTEGER);
}
if (this.offsetValue !== undefined)
chain = chain.offset(this.offsetValue);
}
return chain;
}
/** The SQL + bound params the query would run. */
toSQL() {
return this.toSelect().toSQL();
}
/** Runs the query and loads every {@link include}d relation onto the rows. */
async execute() {
const rows = (await this.toSelect());
if (this.includePaths.length > 0 && !this.distinctFlag && rows.length > 0) {
await loadRelations(this.context, this.baseTable, rows, this.includePaths);
}
return rows;
}
/**
* `COUNT(*)` over the same WHERE, ignoring ordering and the page window. For
* a DISTINCT projection, counts distinct TUPLES of the projection (via a
* derived table, the one form every dialect accepts for several columns).
*/
async count() {
const db = this.context.queryDb;
if (this.distinctFlag) {
const inner = this.toSelect({ withWindow: false, withOrder: false }).as('distinct_count');
const [row] = (await db.select({ count: count() }).from(inner));
return Number(row?.count ?? 0);
}
let chain = db.select({ count: count() }).from(this.table);
const where = this.getWhere();
if (where)
chain = chain.where(where);
const [row] = (await chain);
return Number(row?.count ?? 0);
}
/** {@link execute} + {@link count} — the page and the total it was cut from. */
async executeAndCount() {
const [rows, total] = await Promise.all([this.execute(), this.count()]);
return { rows, total };
}
/**
* The projection sent to the database. Includes need the key they join on:
* when a narrowed projection dropped it, it is added back so the include can
* still be resolved.
*/
selectionForExecution() {
const selection = this.getSelection();
if (!this.projection || this.distinctFlag || this.includePaths.length === 0)
return selection;
const columns = this.columns;
for (const path of this.includePaths) {
const head = path.split('.')[0];
const relation = this.context.metadata.relation(this.baseTable, head);
for (const column of relation?.sourceColumns ?? []) {
const key = this.context.metadata.keyOf(this.baseTable, column);
if (key && !(key in selection) && columns[key])
selection[key] = columns[key];
}
}
return selection;
}
// ─── Relation plumbing (used by the adapter and by whereHas) ───────────────
/**
* A column of this query's table by property key — the aliased column when
* the query runs over an alias.
*/
column(key) {
return columnOf(this.table, key);
}
/**
* `EXISTS` over a relation chain starting at this query's table. The last
* hop's (aliased) table is handed to `build` for the inner condition.
* Returns `undefined` when any segment is not a relation.
*/
relationExists(segments, build) {
return existsChain(this.context, this.baseTable, this.table, segments, build);
}
/**
* A scalar expression for a to-one relation path's column
* (`manager.name` → `(SELECT m.name FROM users m WHERE m.id = users.manager_id)`),
* used for ORDER BY / DISTINCT on relation fields without joining. Returns
* `undefined` when the path crosses a to-many relation (it has no single
* value) or does not resolve.
*/
relationScalar(segments) {
return scalarChain(this.context, this.baseTable, this.table, segments);
}
/**
* Creates the child query a relation constraint's filter writes into: it
* targets a fresh alias of the related table, and {@link relationExistsFor}
* later folds its WHERE into an `EXISTS` on this query.
*/
childFor(relation) {
const alias = aliasedTable(relation.target, this.context.nextAlias(relation.name));
return new DrizzleQuery(this.context, relation.target, alias);
}
/**
* The predicate correlating `targetView` (an alias of `relation.target`) to
* this query's table — the WHERE of a correlated subquery over the relation.
*/
correlate(relation, targetView) {
return correlation(this.context, this.baseTable, this.table, relation, targetView);
}
/** `EXISTS` correlating `child` (made by {@link childFor}) to this query's table. */
relationExistsFor(relation, child) {
return relationExistsSql(this.context, this.baseTable, this.table, relation, child.table, child.getWhere());
}
}
/** The column `column` (declared on `base`) as seen through `view` (base or an alias of it). */
function viewColumn(context, base, view, column) {
const key = context.metadata.keyOf(base, column);
return key ? columnOf(view, key) : undefined;
}
function correlation(context, fromBase, fromView, relation, targetView) {
const pairs = relation.sourceColumns.map((source, i) => {
const left = viewColumn(context, fromBase, fromView, source);
const right = viewColumn(context, relation.target, targetView, relation.targetColumns[i]);
return left && right ? eq(right, left) : undefined;
});
if (pairs.some((p) => p === undefined))
return undefined;
return and(...pairs);
}
function relationExistsSql(context, fromBase, fromView, relation, targetView, inner) {
const on = correlation(context, fromBase, fromView, relation, targetView);
const subquery = context.queryDb.select({ one: sql `1` }).from(targetView).where(and(on, inner));
return exists(subquery);
}
function existsChain(context, fromBase, fromView, segments, build) {
const [head, ...rest] = segments;
if (!head)
return undefined;
const relation = context.metadata.relation(fromBase, head);
if (!relation)
return undefined;
const alias = aliasedTable(relation.target, context.nextAlias(head));
let inner;
if (rest.length > 0) {
inner = existsChain(context, relation.target, alias, rest, build);
if (inner === undefined)
return undefined;
}
else {
inner = build?.(alias);
}
return relationExistsSql(context, fromBase, fromView, relation, alias, inner);
}
function scalarChain(context, fromBase, fromView, segments) {
const [head, ...rest] = segments;
if (!head)
return undefined;
if (rest.length === 0) {
const column = columnOf(fromView, head);
return column ? sql `${column}` : undefined;
}
const relation = context.metadata.relation(fromBase, head);
if (!relation || relation.kind === 'one-to-many' || relation.kind === 'many-to-many') {
return undefined;
}
const alias = aliasedTable(relation.target, context.nextAlias(head));
const value = scalarChain(context, relation.target, alias, rest);
if (value === undefined)
return undefined;
const on = correlation(context, fromBase, fromView, relation, alias);
const subquery = context.queryDb
.select({ value: value.as('value') })
.from(alias)
.where(on)
.limit(1);
return sql `${subquery}`;
}
/** A value usable as a Map key across drivers (Dates by instant, bigints/objects by text). */
function keyOfValue(value) {
if (value instanceof Date)
return value.getTime();
if (typeof value === 'bigint')
return value.toString();
if (value !== null && typeof value === 'object')
return JSON.stringify(value);
return value;
}
/**
* Loads relation paths onto already-fetched rows with one batched
* `SELECT … WHERE <key> IN (…)` per relation (and per nesting level), then
* grafts the results back: a to-many relation becomes an array (empty when
* nothing matched), a to-one relation the row or `null`. The shape matches
* drizzle's relational queries (`with: { posts: true }`).
*
* Unknown relations and composite-key relations are skipped.
*/
export async function loadRelations(context, table, rows, paths) {
const groups = new Map();
for (const path of paths) {
const [head, ...rest] = path.split('.');
if (!head)
continue;
const nested = groups.get(head) ?? [];
if (rest.length > 0)
nested.push(rest.join('.'));
groups.set(head, nested);
}
for (const [name, nested] of groups) {
const relation = context.metadata.relation(table, name);
if (!relation || relation.sourceColumns.length !== 1)
continue;
const sourceKey = context.metadata.keyOf(table, relation.sourceColumns[0]);
const targetColumn = relation.targetColumns[0];
const targetKey = context.metadata.keyOf(relation.target, targetColumn);
if (!sourceKey || !targetKey)
continue;
const values = new Map();
for (const row of rows) {
const value = row[sourceKey];
if (value !== null && value !== undefined)
values.set(keyOfValue(value), value);
}
const children = [];
const distinctValues = [...values.values()];
for (let i = 0; i < distinctValues.length; i += INCLUDE_BATCH_SIZE) {
const batch = distinctValues.slice(i, i + INCLUDE_BATCH_SIZE);
const chunk = (await context.queryDb
.select()
.from(relation.target)
.where(inArray(targetColumn, batch))
.orderBy(...primaryKeyOrder(context, relation.target)));
children.push(...chunk);
}
if (nested.length > 0 && children.length > 0) {
await loadRelations(context, relation.target, children, nested);
}
const byKey = new Map();
for (const child of children) {
const key = keyOfValue(child[targetKey]);
const list = byKey.get(key);
if (list)
list.push(child);
else
byKey.set(key, [child]);
}
const many = relation.kind === 'one-to-many' || relation.kind === 'many-to-many';
for (const row of rows) {
const value = row[sourceKey];
const matches = value === null || value === undefined ? [] : (byKey.get(keyOfValue(value)) ?? []);
row[name] = many ? matches : (matches[0] ?? null);
}
}
}
/** Deterministic child order for includes: by primary key when the table declares one. */
function primaryKeyOrder(context, table) {
const columns = getTableColumns(table);
return context.metadata
.primaryKeys(table)
.map((key) => columns[key])
.filter((c) => c !== undefined && is(c, Column))
.map((c) => asc(c));
}
//# sourceMappingURL=drizzle-query.js.map