UNPKG

@dudousxd/nestjs-filter-drizzle

Version:

Drizzle ORM adapter for @dudousxd/nestjs-filter.

525 lines • 21.6 kB
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