@dudousxd/nestjs-filter-drizzle
Version:
Drizzle ORM adapter for @dudousxd/nestjs-filter.
598 lines • 27.4 kB
JavaScript
import { escapeLike, valueToColumnFilters, } from '@dudousxd/nestjs-filter';
import { aggregateDistinctAlias } from '@dudousxd/nestjs-filter/aggregate';
import { Logger } from '@nestjs/common';
import { Column, SQL, Table, aliasedTable, and, asc, avg, count, desc, eq, getTableName, gt, is, isSQLWrapper, lt, max, min, or, sql, } from 'drizzle-orm';
import { MySqlDatabase } from 'drizzle-orm/mysql-core';
import { PgDatabase } from 'drizzle-orm/pg-core';
import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
import { DrizzleQuery, DrizzleQueryContext, loadRelations, } from './drizzle-query.js';
import { buildColumnFiltersCondition, buildOperatorCondition, coerceValue, likeCondition, } from './operator-resolver.js';
import { DrizzleSchemaMetadata, columnOf } from './schema-metadata.js';
/**
* Safe identifier segment: every field / relation name that can arrive from a
* client is checked against this before it is looked up, so nothing else ever
* reaches the column/relation maps.
*/
const SAFE_FIELD = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/** Reads the dialect off a drizzle database instance. */
export function detectDialect(db) {
if (is(db, PgDatabase))
return 'postgres';
if (is(db, MySqlDatabase))
return 'mysql';
if (is(db, BaseSQLiteDatabase))
return 'sqlite';
throw new Error('DrizzleAdapter: could not detect the dialect of the given database. Pass `dialect` explicitly.');
}
function asQuery(qb) {
if (!(qb instanceof DrizzleQuery)) {
throw new Error('DrizzleAdapter expected a DrizzleQuery (created by DrizzleAdapter.createQueryBuilder).');
}
return qb;
}
function asTable(entity) {
return entity !== undefined && is(entity, Table) ? entity : null;
}
/**
* {@link FilterAdapter} for Drizzle ORM.
*
* The "entity" is a Drizzle table object (`pgTable`/`mysqlTable`/`sqliteTable`),
* and the query builder handed to filters is a {@link DrizzleQuery} — an
* accumulator of conditions/ordering/projection that becomes one
* `db.select().from(table)` at execution time. See the package README for the
* design and the differences from the MikroORM / TypeORM adapters.
*/
export class DrizzleAdapter {
db;
logger = new Logger(DrizzleAdapter.name);
dialect;
metadata;
constructor(db, options = {}) {
this.db = db;
this.dialect = options.dialect ?? detectDialect(db);
const fromDb = db._
?.fullSchema;
this.metadata = new DrizzleSchemaMetadata(options.schema ?? fromDb);
}
newContext() {
return new DrizzleQueryContext(this.db, this.dialect, this.metadata);
}
/**
* A fresh {@link DrizzleQuery} over `table` — the same object `@ApplyFilter`
* injects into controllers. Useful in services:
*
* ```ts
* const q = adapter.query(users);
* await runner.apply(UserFilter, input, q);
* const rows = await q.execute();
* ```
*/
query(table) {
return new DrizzleQuery(this.newContext(), table);
}
createQueryBuilder(entity) {
const table = asTable(entity);
if (!table) {
throw new Error('DrizzleAdapter: the entity must be a Drizzle table (pgTable / mysqlTable / sqliteTable).');
}
return this.query(table);
}
// ─── Relations ──────────────────────────────────────────────────────────────
async applyRelationConstraint(qb, relationName, callback) {
const q = asQuery(qb);
const relation = this.metadata.relation(q.baseTable, relationName);
if (!relation) {
throw new Error(`DrizzleAdapter: "${relationName}" is not a relation of table "${this.metadata.tableDisplayName(q.baseTable)}". Declare it with relations() and register the schema.`);
}
// The related filter writes into a child query over an alias of the
// related table; its WHERE then becomes one correlated EXISTS — which
// filters parents without joining (no duplicate parent rows, no DISTINCT).
const child = q.childFor(relation);
await callback(child);
q.where(q.relationExistsFor(relation, child));
}
getEntityFields(entity) {
const table = asTable(entity);
return table ? this.metadata.fields(table) : null;
}
getEntityRelations(entity) {
const table = asTable(entity);
if (!table)
return null;
const relations = this.metadata.relations(table);
if (!relations)
return [];
return [...relations.values()].map((r) => ({
name: r.name,
targetEntity: r.targetName,
type: r.kind,
}));
}
getRelatedFields(entity, relationName) {
const table = asTable(entity);
const relation = table ? this.metadata.relation(table, relationName) : undefined;
return relation ? this.metadata.fields(relation.target) : null;
}
resolveFieldPath(entity, path) {
let table = asTable(entity);
if (!table)
return null;
const segments = path.split('.');
if (segments.some((s) => !SAFE_FIELD.test(s)))
return null;
for (let i = 0; i < segments.length - 1; i++) {
const relation = this.metadata.relation(table, segments[i]);
if (!relation)
return null; // JSON sub-paths are not supported (see README)
table = relation.target;
}
const last = segments[segments.length - 1];
if (columnOf(table, last))
return 'field';
if (this.metadata.relation(table, last))
return 'relation';
return null;
}
applyIncludes(qb, includes) {
asQuery(qb).include(...includes.filter((p) => p.split('.').every((s) => SAFE_FIELD.test(s))));
}
async populate(rows, relations, entity) {
const table = asTable(entity);
if (!table || rows.length === 0)
return;
await loadRelations(this.newContext(), table, rows, relations.filter((p) => p.split('.').every((s) => SAFE_FIELD.test(s))));
}
// ─── Conditions ─────────────────────────────────────────────────────────────
/**
* Resolves a (possibly dotted) field path of `q` to a condition built over
* the column it lands on. A root column is used directly; a relation path
* becomes nested `EXISTS` subqueries with `build` applied to the last hop's
* column; a path ending on a relation compares its key. Unknown/unsafe paths
* resolve to `undefined` — never to a condition-less `EXISTS`, which would
* quietly widen the filter to "has any related row".
*/
pathCondition(q, path, build) {
const segments = path.split('.');
if (segments.some((s) => !SAFE_FIELD.test(s)))
return undefined;
if (segments.length === 1) {
const column = q.column(path);
if (column)
return build(column);
return this.relationKeyCondition(q, path, build);
}
const field = segments[segments.length - 1];
let resolved = false;
const condition = q.relationExists(segments.slice(0, -1), (target) => {
const column = columnOf(target, field);
if (!column)
return undefined;
const inner = build(column);
resolved = inner !== undefined;
return inner;
});
return resolved ? condition : undefined;
}
/**
* A condition on a bare relation (`where: [{ field: 'manager', … }]`): a
* to-one relation that owns its foreign key compares that key directly;
* any other relation compares the related row's key inside an `EXISTS`.
*/
relationKeyCondition(q, relationName, build) {
const relation = this.metadata.relation(q.baseTable, relationName);
if (!relation || relation.sourceColumns.length !== 1)
return undefined;
if (relation.kind === 'many-to-one') {
const key = this.metadata.keyOf(q.baseTable, relation.sourceColumns[0]);
const column = key ? q.column(key) : undefined;
return column ? build(column) : undefined;
}
const targetKey = this.metadata.keyOf(relation.target, relation.targetColumns[0]);
let resolved = false;
const condition = q.relationExists([relationName], (target) => {
const column = targetKey ? columnOf(target, targetKey) : undefined;
const inner = column ? build(column) : undefined;
resolved = inner !== undefined;
return inner;
});
return resolved ? condition : undefined;
}
applyColumnFilters(qb, filters) {
if (filters.length === 0)
return;
const q = asQuery(qb);
const condition = buildColumnFiltersCondition(filters, (filter) => this.pathCondition(q, filter.field, (column) => buildOperatorCondition(column, filter, this.dialect)));
q.where(condition);
}
applyAutoField(qb, field, value) {
const q = asQuery(qb);
const filters = valueToColumnFilters(field, value);
// A dotted, allowlisted key (`posts.status`) is a relation path; unsafe or
// unknown names resolve to nothing and are silently skipped.
q.where(this.pathCondition(q, field, (column) => and(...filters.map((f) => buildOperatorCondition(column, f, this.dialect)))));
}
applyAutoRelationField(qb, relationName, field, value) {
const q = asQuery(qb);
const filters = valueToColumnFilters(field, value);
// All operators on one relation field share ONE `EXISTS`: `{ gte: a, lte:
// b }` must hold for the same related row, as it would across a join.
q.where(this.pathCondition(q, `${relationName}.${field}`, (column) => and(...filters.map((f) => buildOperatorCondition(column, f, this.dialect)))));
}
applySearch(qb, term, columns) {
const q = asQuery(qb);
const pattern = `%${escapeLike(term)}%`;
const conditions = columns.map((path) => this.pathCondition(q, path, (column) => likeCondition(column, pattern, this.dialect, { caseInsensitive: true })));
q.where(or(...conditions));
}
applyVectorSearch(qb, term, vectorColumn, opts) {
const q = asQuery(qb);
const column = SAFE_FIELD.test(vectorColumn) ? q.column(vectorColumn) : undefined;
if (!column)
return;
if (this.dialect !== 'postgres') {
this.logger.warn(`Vector (tsvector) search is Postgres-only; the ${this.dialect} dialect has no equivalent. Skipping.`);
return;
}
// `websearch_to_tsquery` accepts arbitrary user text (quotes, `-word`,
// `or`) without the syntax errors raw `to_tsquery` throws.
q.where(sql `${column} @@ websearch_to_tsquery(${term})`);
if (opts?.rank) {
q.orderBy(desc(sql `ts_rank(${column}, websearch_to_tsquery(${term}))`));
}
}
// ─── Projection ─────────────────────────────────────────────────────────────
/** A projectable value for a field: a root column, or a to-one relation column's scalar subquery. */
fieldValue(q, field) {
const segments = field.split('.');
if (segments.some((s) => !SAFE_FIELD.test(s)))
return undefined;
if (segments.length === 1)
return q.column(field);
return q.relationScalar(segments)?.as(field);
}
applyDistinct(qb, fields) {
const q = asQuery(qb);
const selection = {};
for (const field of fields) {
const value = this.fieldValue(q, field);
if (value)
selection[field] = value;
}
if (Object.keys(selection).length === 0)
return;
q.select(selection).distinct();
}
applySelect(qb, fields, entity) {
const q = asQuery(qb);
const selection = {};
for (const field of fields) {
const column = SAFE_FIELD.test(field) ? q.column(field) : undefined;
if (column)
selection[field] = column;
}
if (Object.keys(selection).length === 0)
return;
// Keep the primary key so rows stay addressable (includes, cursors).
const table = asTable(entity) ?? q.baseTable;
for (const key of this.metadata.primaryKeys(table)) {
const column = q.column(key);
if (column && !(key in selection))
selection[key] = column;
}
q.select(selection);
}
// ─── Sort & pagination ──────────────────────────────────────────────────────
/**
* What to ORDER BY for a field. Under a DISTINCT projection that already
* selected a non-column expression for this field, order by its output
* alias: a second copy of a correlated subquery is a different expression,
* which Postgres rejects under DISTINCT.
*/
sortTarget(q, field) {
if (q.isDistinct()) {
const projected = q.getSelection()[field];
if (projected !== undefined && !is(projected, Column))
return sql `${sql.identifier(field)}`;
}
const segments = field.split('.');
if (segments.some((s) => !SAFE_FIELD.test(s)))
return undefined;
if (segments.length === 1)
return q.column(field);
return q.relationScalar(segments);
}
applySort(qb, sorts) {
const q = asQuery(qb);
for (const s of sorts) {
const target = this.sortTarget(q, s.field);
if (!target)
continue;
q.orderBy(s.direction === 'desc' ? desc(target) : asc(target));
}
}
applyOffsetPagination(qb, page, size) {
asQuery(qb)
.limit(size)
.offset(page * size);
}
getPrimaryKey(entity) {
const table = asTable(entity);
if (!table)
return null;
const keys = this.metadata.primaryKeys(table);
return keys.length === 1 ? keys[0] : null;
}
applyKeysetPagination(qb, keyset, values) {
const q = asQuery(qb);
const targets = [];
for (const s of keyset) {
const target = this.sortTarget(q, s.field);
if (!target)
return; // unknown keyset column — skip the predicate entirely
targets.push(target);
}
const bind = (target, value) => is(target, Column) ? coerceValue(target, value) : value;
const expr = (target) => target;
// Lexicographic tuple comparison, spelled as an OR of AND tiers so every
// dialect runs it: (c0 > v0) OR (c0 = v0 AND c1 > v1) OR …
const tiers = keyset.map((s, tier) => {
const equalities = targets
.slice(0, tier)
.map((target, i) => eq(expr(target), bind(target, values[i])));
const target = targets[tier];
const value = bind(target, values[tier]);
const cmp = s.direction === 'asc' ? gt(expr(target), value) : lt(expr(target), value);
return and(...equalities, cmp);
});
q.where(or(...tiers));
}
applyKeysetOrderAndLimit(qb, keyset, limit) {
const q = asQuery(qb);
for (const s of keyset) {
const target = this.sortTarget(q, s.field);
if (!target)
continue;
q.orderBy(s.direction === 'desc' ? desc(target) : asc(target));
}
q.limit(limit);
}
// ─── Execution ──────────────────────────────────────────────────────────────
async getResultAndCount(qb) {
const q = asQuery(qb);
const rows = await q.execute();
const total = await q.count();
return { rows: rows, total };
}
async getDistinctResultAndCount(qb) {
const q = asQuery(qb);
const rows = (await q.execute());
const total = await q.count();
return { rows, total };
}
async getResult(qb) {
return asQuery(qb).execute();
}
// ─── Computed fields ────────────────────────────────────────────────────────
/**
* Resolves a developer-declared computed source to SQL. A string is emitted
* verbatim (auto-parenthesized when it is a bare `SELECT …` / `EXISTS (…)`);
* a function receives `{ alias: <root table name>, em: db }` and may return a
* SQL string, a drizzle `sql` fragment, or a drizzle select builder (used as
* a scalar subquery). Because the adapter never joins, an unqualified column
* name in a string source always means the root table's column.
*/
computedExpression(source, q) {
if (typeof source === 'string')
return sql.raw(normalizeComputedSql(source));
const out = source({ alias: getTableName(q.table), em: this.db });
if (typeof out === 'string')
return sql.raw(normalizeComputedSql(out));
if (is(out, SQL))
return out;
if (isSQLWrapper(out))
return sql `${out}`;
throw new Error('Unsupported computed return type for the Drizzle adapter: return a SQL string, a sql`` fragment or a select builder.');
}
applyComputedField(qb, source, value) {
const q = asQuery(qb);
const expression = this.computedExpression(source, q);
for (const filter of valueToColumnFilters('computed', value)) {
q.where(buildOperatorCondition(expression, filter, this.dialect));
}
}
applyComputedSort(qb, source, direction) {
const q = asQuery(qb);
const expression = this.computedExpression(source, q);
q.orderBy(direction === 'desc' ? desc(expression) : asc(expression));
}
applyComputedSelect(qb, alias, source) {
if (!SAFE_FIELD.test(alias))
return;
const q = asQuery(qb);
// Additive by contract: the entity-row (or sparse `select`) projection
// stays, the computed value rides along under its alias on every row.
q.addSelect({ [alias]: this.computedExpression(source, q).as(alias) });
}
applyComputedDistinct(qb, alias, source) {
if (!SAFE_FIELD.test(alias))
return;
const q = asQuery(qb);
this.addDistinctMember(q, alias, this.computedExpression(source, q).as(alias));
}
addDistinctMember(q, alias, value) {
if (q.isDistinct())
q.select({ ...q.getSelection(), [alias]: value });
else
q.select({ [alias]: value }).distinct();
}
// ─── To-many aggregates ─────────────────────────────────────────────────────
/**
* Compiles `posts.$count` / `posts.$sum.views` / … into a correlated scalar
* subquery over the to-many relation — never a JOIN + GROUP BY, which would
* multiply the root rows. The relation and child column come from schema
* metadata; nothing client-supplied is emitted as SQL text.
*
* @returns the expression, and — for `min`/`max` — the child column, whose
* encoder binds comparison values (dates compare as the column stores them).
*/
aggregateSubquery(q, aggregate) {
const relation = this.metadata.relation(q.baseTable, aggregate.relation);
if (!relation || relation.kind !== 'one-to-many') {
throw new Error(`Aggregate relation "${aggregate.relation}" is not a to-many relation the Drizzle adapter can correlate.`);
}
const alias = aliasedTable(relation.target, q.context.nextAlias(relation.name));
const on = q.correlate(relation, alias);
let value;
let encoder;
if (aggregate.fn === 'count') {
value = count();
}
else {
const column = aggregate.column ? columnOf(alias, aggregate.column) : undefined;
if (!column || !SAFE_FIELD.test(aggregate.column ?? '')) {
throw new Error(`Cannot resolve child column "${aggregate.column}" for aggregate function "${aggregate.fn}".`);
}
if (aggregate.fn === 'sum')
value = sql `coalesce(sum(${column}), 0)`;
else if (aggregate.fn === 'avg')
value = avg(column);
else if (aggregate.fn === 'min')
value = min(column);
else
value = max(column);
if (aggregate.fn === 'min' || aggregate.fn === 'max') {
encoder = this.originalColumn(relation, aggregate.column);
}
}
const subquery = q.context.queryDb
.select({ value: value.as('value') })
.from(alias)
.where(on);
return { expression: sql `${subquery}`, ...(encoder && { encoder }) };
}
originalColumn(relation, key) {
return columnOf(relation.target, key);
}
applyAggregateSort(qb, aggregate, direction) {
const q = asQuery(qb);
const { expression } = this.aggregateSubquery(q, aggregate);
q.orderBy(direction === 'desc' ? desc(expression) : asc(expression));
}
applyAggregateField(qb, aggregate, filter) {
const q = asQuery(qb);
const { expression, encoder } = this.aggregateSubquery(q, aggregate);
q.where(buildOperatorCondition(expression, filter, this.dialect, encoder));
}
applyAggregateDistinct(qb, aggregate) {
const alias = aggregateDistinctAlias(aggregate);
if (!SAFE_FIELD.test(alias))
return;
const q = asQuery(qb);
const { expression, encoder } = this.aggregateSubquery(q, aggregate);
const value = encoder ? expression.mapWith(encoder) : expression.mapWith(Number);
this.addDistinctMember(q, alias, value.as(alias));
}
// ─── Terminal aggregations ──────────────────────────────────────────────────
/**
* The expression for a measurable/groupable field, plus the column whose
* decoder should map its values back (so a date comes back as a `Date`, a
* SQLite boolean as `true`/`false`).
*/
measurable(q, field) {
if (typeof field !== 'string') {
return { expression: this.computedExpression(field.source, q) };
}
const value = this.fieldValue(q, field);
if (!value)
return undefined;
if (is(value, Column))
return { expression: sql `${value}`, decoder: value };
return { expression: is(value, SQL.Aliased) ? value.sql : value };
}
asText(expression) {
return this.dialect === 'mysql'
? sql `cast(${expression} as char)`
: sql `cast(${expression} as text)`;
}
/**
* `SELECT <expr> AS value, COUNT(*) AS count … GROUP BY 1`, or the bucketed
* `FLOOR(<expr> / ?) * ?` variant. The bucket width is a bound parameter;
* grouping by ordinal position keeps Postgres from rejecting the query
* because the SELECT and GROUP BY copies of the expression carry different
* placeholders (`$1` vs `$3`).
*/
async groupByCount(qb, field, _entity, opts) {
const q = asQuery(qb);
const measured = this.measurable(q, field);
if (!measured) {
throw new Error(`Cannot resolve a DB column for groupByCount field "${String(field)}".`);
}
const bucket = opts?.bucket;
const bucketed = bucket !== undefined && bucket > 0;
let value = bucketed
? sql `floor(${measured.expression} / ${bucket}) * ${bucket}`.mapWith(Number)
: measured.expression;
if (!bucketed && measured.decoder)
value = value.mapWith(measured.decoder);
const conditions = [q.getWhere()];
if (opts?.search) {
conditions.push(likeCondition(this.asText(measured.expression), `%${escapeLike(opts.search)}%`, this.dialect, { caseInsensitive: true }));
}
let chain = q.context.queryDb
.select({ value: value.as('value'), count: count().as('count') })
.from(q.table)
.where(and(...conditions))
.groupBy(sql `1`);
if (opts?.limit !== undefined && opts.limit > 0) {
chain = chain.orderBy(desc(count())).limit(opts.limit);
if (opts.offset !== undefined && opts.offset > 0)
chain = chain.offset(opts.offset);
}
const rows = (await chain);
return rows.map((row) => ({ value: row.value, count: Number(row.count) }));
}
/**
* `MIN`/`MAX` of every requested field in ONE select over the filtered rows —
* ordering and the page window are not part of the question and are simply
* not emitted. Values are decoded through the field's column, so dates stay
* dates.
*/
async fieldExtent(qb, fields) {
const q = asQuery(qb);
const selection = {};
const keys = [];
fields.forEach((field, slot) => {
const measured = this.measurable(q, field);
if (!measured)
return;
const key = typeof field === 'string' ? field : field.alias;
const lo = sql `min(${measured.expression})`;
const hi = sql `max(${measured.expression})`;
selection[`min_${slot}`] = (measured.decoder ? lo.mapWith(measured.decoder) : lo).as(`min_${slot}`);
selection[`max_${slot}`] = (measured.decoder ? hi.mapWith(measured.decoder) : hi).as(`max_${slot}`);
keys.push({ key, slot });
});
if (keys.length === 0)
return {};
let chain = q.context.queryDb.select(selection).from(q.table);
const where = q.getWhere();
if (where)
chain = chain.where(where);
const [row] = (await chain);
const out = {};
for (const { key, slot } of keys) {
out[key] = { min: row?.[`min_${slot}`] ?? null, max: row?.[`max_${slot}`] ?? null };
}
return out;
}
}
/**
* Parenthesizes a bare scalar subquery (`SELECT …`) or existence predicate
* (`EXISTS (…)` / `NOT EXISTS (…)`) so it composes inside a comparison or an
* ORDER BY — the same normalization the TypeORM adapter applies.
*/
function normalizeComputedSql(source) {
const trimmed = source.trim();
return /^(?:select|(?:not\s+)?exists)\b/i.test(trimmed) ? `(${trimmed})` : source;
}
//# sourceMappingURL=drizzle.adapter.js.map