nestjs-paginate
Version:
Pagination and filtering helper method for TypeORM repositories or query builders using Nest.js framework.
902 lines (901 loc) • 52.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TYPEORM_PARAM_REGEX = exports.OperatorSymbolToFunction = exports.FilterComparator = exports.FilterQuantifier = exports.FilterSuffix = exports.FilterOperator = void 0;
exports.isOperator = isOperator;
exports.isSuffix = isSuffix;
exports.isQuantifier = isQuantifier;
exports.isComparator = isComparator;
exports.hasExplicitAndComparator = hasExplicitAndComparator;
exports.fixQueryParam = fixQueryParam;
exports.generatePredicateCondition = generatePredicateCondition;
exports.addWhereCondition = addWhereCondition;
exports.parseFilterToken = parseFilterToken;
exports.parseFilter = parseFilter;
exports.getRelationPath = getRelationPath;
exports.addFilter = addFilter;
exports.addDirectFilters = addDirectFilters;
exports.addToManySubFilters = addToManySubFilters;
exports.createSubFilter = createSubFilter;
const common_1 = require("@nestjs/common");
const lodash_1 = require("lodash");
const typeorm_1 = require("typeorm");
const helper_1 = require("./helper");
const paginate_1 = require("./paginate");
var FilterOperator;
(function (FilterOperator) {
FilterOperator["EQ"] = "$eq";
FilterOperator["GT"] = "$gt";
FilterOperator["GTE"] = "$gte";
FilterOperator["IN"] = "$in";
FilterOperator["NULL"] = "$null";
FilterOperator["LT"] = "$lt";
FilterOperator["LTE"] = "$lte";
FilterOperator["BTW"] = "$btw";
FilterOperator["ILIKE"] = "$ilike";
FilterOperator["SW"] = "$sw";
FilterOperator["CONTAINS"] = "$contains";
})(FilterOperator || (exports.FilterOperator = FilterOperator = {}));
function isOperator(value) {
return (0, lodash_1.values)(FilterOperator).includes(value);
}
var FilterSuffix;
(function (FilterSuffix) {
// Used to negate a filter
FilterSuffix["NOT"] = "$not";
})(FilterSuffix || (exports.FilterSuffix = FilterSuffix = {}));
function isSuffix(value) {
return (0, lodash_1.values)(FilterSuffix).includes(value);
}
var FilterQuantifier;
(function (FilterQuantifier) {
FilterQuantifier["ALL"] = "$all";
FilterQuantifier["ANY"] = "$any";
FilterQuantifier["NONE"] = "$none";
})(FilterQuantifier || (exports.FilterQuantifier = FilterQuantifier = {}));
function isQuantifier(value) {
return (0, lodash_1.values)(FilterQuantifier).includes(value);
}
var FilterComparator;
(function (FilterComparator) {
FilterComparator["AND"] = "$and";
FilterComparator["OR"] = "$or";
})(FilterComparator || (exports.FilterComparator = FilterComparator = {}));
function isComparator(value) {
return (0, lodash_1.values)(FilterComparator).includes(value);
}
/**
* Returns true when the raw filter string explicitly carries the `$and` comparator token.
*
* This is distinct from the default AND comparator that every token carries implicitly —
* we only want to enter AND-mode when the user deliberately wrote `$and:` in the filter value.
* Using `parseFilterToken` (rather than a naive substring split) ensures that `$and` embedded
* inside a user value (e.g. `$eq:$and`) is not misidentified as the comparator.
*
* Must be called after `parseFilterToken` is defined (hoisting applies to function declarations).
*/
function hasExplicitAndComparator(raw) {
const token = parseFilterToken(raw);
if (!token)
return false;
if (token.comparator !== FilterComparator.AND)
return false;
// The default token comparator is AND, so we must confirm the user actually wrote `$and` as a
// colon-delimited token segment. We reconstruct the consumed prefix (everything before the value)
// and check whether `$and` appears in it as a discrete segment.
// This correctly rejects `$eq:$and` (value = `$and`, prefix = `$eq:`) and accepts `$and:Ball`.
const valueSuffix = token.value !== undefined ? `:${token.value}` : '';
const prefix = valueSuffix ? raw.slice(0, raw.length - valueSuffix.length) : raw;
return prefix.split(':').some((seg) => seg === FilterComparator.AND);
}
exports.OperatorSymbolToFunction = new Map([
[FilterOperator.EQ, typeorm_1.Equal],
[FilterOperator.GT, typeorm_1.MoreThan],
[FilterOperator.GTE, typeorm_1.MoreThanOrEqual],
[FilterOperator.IN, typeorm_1.In],
[FilterOperator.NULL, typeorm_1.IsNull],
[FilterOperator.LT, typeorm_1.LessThan],
[FilterOperator.LTE, typeorm_1.LessThanOrEqual],
[FilterOperator.BTW, typeorm_1.Between],
[FilterOperator.ILIKE, typeorm_1.ILike],
[FilterSuffix.NOT, typeorm_1.Not],
[FilterOperator.SW, typeorm_1.ILike],
[FilterOperator.CONTAINS, typeorm_1.ArrayContains],
]);
/**
* Matches TypeORM named parameters (`:name` and `:...name` spread form) while skipping
* PostgreSQL cast syntax (`::type`).
*
* TypeORM parameter names may contain letters, digits, underscores, and dots (the latter
* for embedded-property paths, e.g. `size.height0`). The pattern captures the full name
* including any embedded-path dots.
*
* Capture groups:
* 1 — optional `...` spread prefix (present for IN parameters)
* 2 — parameter name (may contain dots for embedded paths)
*
* Examples:
* `:name` → matches, spread=undefined, name='name'
* `:...vals` → matches, spread='...', name='vals'
* `:size.height0` → matches, spread=undefined, name='size.height0'
* `col::text` → no match (lookbehind rejects `::`)
* `:param::int` → matches `:param`, skips `::int`
*/
/** @internal Exported for testing only. */
exports.TYPEORM_PARAM_REGEX = /(?<!:):(\.\.\.)?([a-zA-Z0-9_.]+)/g;
// This function is used to fix the query parameters when using relation, embeded or virtual properties
// It will replace the column name with the alias name and return the new parameters
function fixQueryParam(alias, column, filter, condition, parameters) {
const isNotOperator = condition.operator === 'not';
const conditionFixer = (alias, column, filter, operator, parameters) => {
let condition_params = undefined;
let params = parameters;
switch (operator) {
case 'between':
condition_params = [alias, `:${column}_from`, `:${column}_to`];
params = {
[column + '_from']: filter.findOperator.value[0],
[column + '_to']: filter.findOperator.value[1],
};
break;
case 'in':
condition_params = [alias, `:...${column}`];
break;
default:
condition_params = [alias, `:${column}`];
break;
}
return { condition_params, params };
};
const { condition_params, params } = conditionFixer(alias, column, filter, isNotOperator ? condition['condition']['operator'] : condition.operator, parameters);
if (isNotOperator) {
condition['condition']['parameters'] = condition_params;
}
else {
condition.parameters = condition_params;
}
return params;
}
function generatePredicateCondition(qb, column, filter, alias, isVirtualProperty = false) {
return qb['getWherePredicateCondition'](isVirtualProperty ? column : alias, filter.findOperator);
}
function addWhereCondition(qb, column, filter) {
const columnProperties = (0, helper_1.getPropertiesByColumnName)(column);
const { isVirtualProperty, query: virtualQuery } = (0, helper_1.extractVirtualProperty)(qb, columnProperties);
const isRelation = (0, helper_1.checkIsRelation)(qb, columnProperties.propertyPath);
const isEmbedded = (0, helper_1.checkIsEmbedded)(qb, columnProperties.propertyPath);
const isArray = (0, helper_1.checkIsArray)(qb, columnProperties.propertyName);
const alias = (0, helper_1.fixColumnAlias)(columnProperties, qb.alias, isRelation, isVirtualProperty, isEmbedded, virtualQuery, qb);
filter[column].forEach((columnFilter, index) => {
var _a;
const columnNamePerIteration = `${columnProperties.column}${index}`;
const condition = generatePredicateCondition(qb, columnProperties.column, columnFilter, alias, isVirtualProperty);
const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, {
[columnNamePerIteration]: columnFilter.findOperator.value,
});
if (isArray &&
((_a = condition.parameters) === null || _a === void 0 ? void 0 : _a.length) &&
!['not', 'isNull', 'arrayContains'].includes(condition.operator)) {
condition.parameters[0] = `cardinality(${condition.parameters[0]})`;
}
const expression = qb['createWhereConditionExpression'](condition);
if (columnFilter.comparator === FilterComparator.OR) {
qb.orWhere(expression, parameters);
}
else {
qb.andWhere(expression, parameters);
}
});
}
function parseFilterToken(raw) {
if (raw === undefined || raw === null) {
return null;
}
const token = {
quantifier: FilterQuantifier.ANY,
comparator: FilterComparator.AND,
suffix: undefined,
operator: FilterOperator.EQ,
value: raw,
};
const MAX_OPERATOR = 5; // max 5 operator: $none:$and:$not:$eq:$null
const OPERAND_SEPARATOR = ':';
const matches = raw.split(OPERAND_SEPARATOR);
const maxOperandCount = matches.length > MAX_OPERATOR ? MAX_OPERATOR : matches.length;
const notValue = [];
for (let i = 0; i < maxOperandCount; i++) {
const match = matches[i];
if (isQuantifier(match)) {
token.quantifier = match;
}
else if (isComparator(match)) {
token.comparator = match;
}
else if (isSuffix(match)) {
token.suffix = match;
}
else if (isOperator(match)) {
token.operator = match;
}
else {
break;
}
notValue.push(match);
}
if (notValue.length) {
token.value =
!raw.includes(OPERAND_SEPARATOR) || token.operator === FilterOperator.NULL
? // things like `$null`, `$none`, and `$any`, have no token value
undefined
: // otherwise, remove the operators and separators from the raw string to obtain the token value
raw.replace(`${notValue.join(OPERAND_SEPARATOR)}${OPERAND_SEPARATOR}`, '');
}
return token;
}
function fixColumnFilterValue(column, qb, isJsonb = false) {
const columnProperties = (0, helper_1.getPropertiesByColumnName)(column);
const virtualProperty = (0, helper_1.extractVirtualProperty)(qb, columnProperties);
const columnType = virtualProperty.type;
return (value) => {
if (((0, helper_1.isDateColumnType)(columnType) || isJsonb) && (0, helper_1.isISODate)(value)) {
return new Date(value);
}
if ((columnType === Number || columnType === 'number' || isJsonb) && !isNaN(Number(value))) {
return Number(value);
}
return value;
};
}
function parseFilter(query, filterableColumns, qb, throwOnInvalidFilter = false) {
const filter = {};
if (!filterableColumns || !query.filter) {
return {};
}
for (const column of Object.keys(query.filter)) {
if (!(column in filterableColumns)) {
if (throwOnInvalidFilter) {
throw new common_1.BadRequestException(`Column '${column}' is not filterable`);
}
continue;
}
const allowedOperators = filterableColumns[column];
const input = query.filter[column];
const statements = !Array.isArray(input) ? [input] : input;
for (const raw of statements) {
const token = parseFilterToken(raw);
if (!token) {
continue;
}
if (allowedOperators === true) {
if (token.operator && !isOperator(token.operator)) {
if (throwOnInvalidFilter) {
throw new common_1.BadRequestException(`Invalid filter operator '${token.operator}' for column '${column}'`);
}
continue;
}
if (token.suffix && !isSuffix(token.suffix)) {
if (throwOnInvalidFilter) {
throw new common_1.BadRequestException(`Invalid filter suffix '${token.suffix}' for column '${column}'`);
}
continue;
}
}
else {
if (token.operator &&
token.operator !== FilterOperator.EQ &&
!allowedOperators.includes(token.operator)) {
if (throwOnInvalidFilter) {
throw new common_1.BadRequestException(`Filter operator '${token.operator}' is not allowed for column '${column}'`);
}
continue;
}
if (token.suffix && !allowedOperators.includes(token.suffix)) {
if (throwOnInvalidFilter) {
throw new common_1.BadRequestException(`Filter suffix '${token.suffix}' is not allowed for column '${column}'`);
}
continue;
}
if (token.quantifier !== FilterQuantifier.ANY && !allowedOperators.includes(token.quantifier)) {
continue;
}
// Gate the $and comparator: only allow it if explicitly listed in filterableColumns.
// The default token comparator is AND (used for normal andWhere), so we must check
// whether $and was explicitly present in the raw filter string, not just the token default.
if (!allowedOperators.includes(FilterComparator.AND) && hasExplicitAndComparator(raw)) {
continue;
}
}
const params = {
quantifier: token.quantifier,
comparator: token.comparator,
findOperator: undefined,
};
const fixValue = fixColumnFilterValue(column, qb);
const columnProperties = (0, helper_1.getPropertiesByColumnName)(column);
const jsonbResolution = (0, helper_1.resolveJsonbPath)(qb, columnProperties.column);
switch (token.operator) {
case FilterOperator.BTW:
params.findOperator = exports.OperatorSymbolToFunction.get(token.operator)(...token.value.split(',').map(fixValue));
break;
case FilterOperator.IN:
case FilterOperator.CONTAINS:
params.findOperator = exports.OperatorSymbolToFunction.get(token.operator)(token.value.split(',').map((v) => fixValue(v.trim())));
break;
case FilterOperator.ILIKE:
params.findOperator = exports.OperatorSymbolToFunction.get(token.operator)(`%${token.value}%`);
break;
case FilterOperator.SW:
params.findOperator = exports.OperatorSymbolToFunction.get(token.operator)(`${token.value}%`);
break;
default:
params.findOperator = exports.OperatorSymbolToFunction.get(token.operator)(fixValue(token.value));
}
if (jsonbResolution.isJsonb) {
const supportJsonContains = [FilterOperator.EQ, FilterOperator.IN, FilterOperator.CONTAINS].includes(token.operator);
if (supportJsonContains) {
const jsonFixValue = fixColumnFilterValue(column, qb, true);
// Use a stable filter key that preserves the relation path so that
// addWhereCondition can later resolve the correct JOIN alias.
// e.g. 'detail.referrer.source.platform' → filterKey = 'detail.referrer'
// 'metadata.length' → filterKey = 'metadata'
// 'underCoat.metadata.length' → filterKey = 'underCoat.metadata'
const filterKey = [...jsonbResolution.relationPath, jsonbResolution.jsonbColumn].join('.');
// Build a JsonContains containment object for a single leaf value.
// e.g. jsonPath=['source','platform'], value='web' → { source: { platform: 'web' } }
// jsonPath=['length'], value=5 → { length: 5 }
const buildContainment = (rawValue) => {
const leafValue = jsonFixValue(rawValue);
return jsonbResolution.jsonPath.reduceRight((acc, key) => ({ [key]: acc }), leafValue);
};
if (token.operator === FilterOperator.IN) {
token.value.split(',').forEach((val, i) => {
filter[filterKey] = [
...(filter[filterKey] || []),
{
comparator: i === 0 ? params.comparator : FilterComparator.OR,
findOperator: (0, typeorm_1.JsonContains)(buildContainment(val.trim())),
quantifier: FilterQuantifier.ANY,
},
];
});
}
else {
filter[filterKey] = [
...(filter[filterKey] || []),
{
comparator: params.comparator,
findOperator: (0, typeorm_1.JsonContains)(buildContainment(token.value)),
quantifier: FilterQuantifier.ANY,
},
];
}
}
else {
filter[column] = [...(filter[column] || []), params];
}
}
else {
filter[column] = [...(filter[column] || []), params];
}
// suffix ($not) is applied on the filter key used above.
// For JSONB $in, $not must be applied to every expanded entry so that
// NOT (col @> '{a}') AND NOT (col @> '{b}') is produced (NOT IN semantics).
if (token.suffix) {
const isJsonbAndSupportsJsonContains = jsonbResolution.isJsonb &&
[FilterOperator.EQ, FilterOperator.IN, FilterOperator.CONTAINS].includes(token.operator);
const filterKey = isJsonbAndSupportsJsonContains
? [...jsonbResolution.relationPath, jsonbResolution.jsonbColumn].join('.')
: column;
const isJsonbIn = isJsonbAndSupportsJsonContains && token.operator === FilterOperator.IN;
const applyFrom = isJsonbIn
? filter[filterKey].length - token.value.split(',').length
: filter[filterKey].length - 1;
for (let i = applyFrom; i < filter[filterKey].length; i++) {
filter[filterKey][i].findOperator = exports.OperatorSymbolToFunction.get(token.suffix)(filter[filterKey][i].findOperator);
// $not:$in means NOT IN — all expanded values are AND'd with NOT
if (isJsonbIn && i > applyFrom) {
filter[filterKey][i].comparator = FilterComparator.AND;
}
}
}
}
}
return filter;
}
class RelationPathError extends Error {
}
/**
* Retrieves the relation path for a given column name within the provided metadata.
*
* This method analyzes the column name's segments to identify corresponding relations or embedded entities
* within the hierarchy described by the given metadata and returns a structured path.
*
* @param {string} columnName - The dot-delimited name of the column whose relation path is to be determined.
* @param {EntityMetadata | EmbeddedMetadata} metadata - The metadata of the entity or embedded component
* which holds the relations or embedded items.
* @return {[string, RelationMetadata | EmbeddedMetadata][]} The ordered array describing the path,
* where each element contains a field name and its corresponding relation or embedded metadata.
* Throws an error if no matching relation or embedded metadata is found.
*/
function getRelationPath(columnName, metadata) {
var _a;
const relationSegments = columnName.split('.');
const deeper = relationSegments.slice(1).join('.');
const fieldName = relationSegments[0].replace(/[()]/g, '');
try {
// Check if there's a relation with this property name
const relation = metadata.relations.find((r) => r.propertyName === fieldName);
if (relation) {
return [
[fieldName, relation],
...(relationSegments.length > 1 ? getRelationPath(deeper, relation.inverseEntityMetadata) : []),
];
}
// Check if there's something embedded with this property name
const embedded = metadata.embeddeds.find((embedded) => embedded.propertyName === fieldName);
if (embedded) {
return [
[fieldName, embedded],
...(relationSegments.length > 1 ? getRelationPath(deeper, embedded) : []),
];
}
// A JSON(B) column followed by a key path (e.g. `metadata.length`) is a direct filter,
// not a relation chain: the remaining segments index into the JSON value, they are not
// relations. Terminate the path here rather than treating `length` as a missing relation.
if ('findColumnWithPropertyName' in metadata) {
const columnType = (_a = metadata.findColumnWithPropertyName(fieldName)) === null || _a === void 0 ? void 0 : _a.type;
if (helper_1.JSON_COLUMN_TYPES.includes(columnType)) {
return [];
}
}
}
catch (e) {
if (e instanceof RelationPathError) {
throw new RelationPathError(`No relation or embedded found for property path ${columnName}`);
}
throw e;
}
if (relationSegments.length > 1)
throw new RelationPathError(`No relation or embedded found for property path ${columnName}`);
return [];
}
/**
* Finds the first 'to-many' relationship in a given entity metadata or embedded metadata
* given a column name. A 'to-many' relationship can be either a one-to-many or a
* many-to-many relationship.
*
* @param {string} columnName - The column name to traverse through its segments and find relationships.
* @param {EntityMetadata | EmbeddedMetadata} metadata - The metadata of the entity or
* embedded object in which relationships are defined.
* @return {{ path: string[]; relation: RelationMetadata } | undefined} An object containing
* the path to the 'to-many' relationship and the relationship metadata, or undefined if no
* 'to-many' relationships are found.
*/
function findFirstToManyRelationship(columnName, metadata) {
let relationPath;
try {
relationPath = getRelationPath(columnName, metadata);
}
catch (e) {
if (e instanceof RelationPathError)
return undefined;
throw e;
}
const relationSegments = columnName.split('.');
const firstToMany = relationPath.findIndex(([, relation]) => 'isOneToMany' in relation && (relation.isOneToMany || relation.isManyToMany));
if (firstToMany > -1)
return {
path: relationSegments.slice(0, firstToMany + 1),
relation: relationPath[firstToMany][1],
};
}
function addFilter(qb, query, filterableColumns, opts = {}, throwOnInvalidFilter = false) {
const mainMetadata = qb.expressionMap.mainAlias.metadata;
const filter = parseFilter(query, filterableColumns, qb, throwOnInvalidFilter);
addDirectFilters(qb, filter);
addToManySubFilters(qb, filter, query, filterableColumns, opts);
// Direct filters require to be joined, so pass the join information back up to the main pagination builder
// (or the parent filter query in case of subfilters)
const columnJoinMethods = {};
for (const [key] of Object.entries(filter)) {
const relationPath = getRelationPath(key, mainMetadata);
// Skip filters that don't result in WHERE clauses and so don't need to be joined..
if (relationPath.find(([, relation]) => 'isOneToMany' in relation && (relation.isOneToMany || relation.isManyToMany))) {
continue;
}
for (let i = 0; i < relationPath.length; i++) {
const column = relationPath
.slice(0, i + 1)
.map((p) => p[0])
.join('.');
// Skip joins on embedded entities
if ('inverseRelation' in relationPath[i][1]) {
columnJoinMethods[column] = 'innerJoinAndSelect';
}
}
}
return columnJoinMethods;
}
function addDirectFilters(qb, filter) {
const filterEntries = Object.entries(filter);
const metadata = qb.expressionMap.mainAlias.metadata;
// Direct filters are those without toMany relationships on their path, and can be expressed as simple JOINs + WHERE clauses
const whereFilters = filterEntries.filter(([key]) => !findFirstToManyRelationship(key, metadata));
const orFilters = whereFilters.filter(([, value]) => value[0].comparator === '$or');
const andFilters = whereFilters.filter(([, value]) => value[0].comparator === '$and');
qb.andWhere(new typeorm_1.Brackets((qb) => {
for (const [column] of orFilters) {
addWhereCondition(qb, column, filter);
}
}));
for (const [column] of andFilters) {
qb.andWhere(new typeorm_1.Brackets((qb) => {
addWhereCondition(qb, column, filter);
}));
}
}
/**
* Adds correlated EXISTS subqueries to `qb` for all to-many relationship filters in `filter`.
*
* **AND-mode (`$and` comparator)**
*
* When a sub-column filter uses the `$and` comparator (e.g. `filter[toys.name]=$and:Ball`),
* each distinct `$and` value produces a separate correlated EXISTS subquery, ANDed on the
* outer query. This is the only correct way to express "entity has ALL of these related values"
* — a single EXISTS with AND conditions on the same column is always false on a single row.
*
* **Performance note**: each `$and` value adds one correlated EXISTS with the full join chain
* for that relation path. For a relation path of depth D and N `$and` values, this produces
* N × D joins. The `maxAndValues` option (default 20) caps N to limit query complexity.
*
* **Restrictions**:
* - `$and` may only be used on to-many relationship columns.
* - `$and` values may not be mixed with non-`$and` values on the same sub-column.
* - `$and` may not be combined with `$none` or `$all` quantifiers.
* - `$and` may only be applied to a single sub-column per relation path at a time.
*/
function addToManySubFilters(qb, filter, query, filterableColumns, { maxAndValues = 20, validateAndComparator = true } = {}) {
var _a, _b, _c;
const dbType = qb.connection.options.type;
const quote = (column) => (0, helper_1.quoteColumn)(column, ['mysql', 'mariadb'].includes(dbType));
const mainMetadata = qb.expressionMap.mainAlias.metadata;
const filterEntries = Object.entries(filter);
// Filters with toMany relationships on their path need to be expressed as EXISTS subqueries.
const existsFilters = filterEntries.map(([key]) => findFirstToManyRelationship(key, mainMetadata)).filter(Boolean);
// Find all the different toMany starting paths
const toManyPaths = [...new Set(existsFilters.map((f) => f.path.join('.')))];
// Validate that $and is not used on scalar (non-to-many) columns — it has no meaningful
// semantics there and would produce always-false conditions (e.g. WHERE name = 'a' AND name = 'b').
// Skip this validation when called recursively for EXISTS subqueries (validateAndComparator = false),
// because the sub-filter keys are already stripped of the relation prefix and the entity metadata
// is the leaf entity, so the to-many check would incorrectly throw.
if (validateAndComparator) {
for (const [key, rawValues] of Object.entries((_a = query.filter) !== null && _a !== void 0 ? _a : {})) {
const isToMany = findFirstToManyRelationship(key, mainMetadata) != null;
if (!isToMany) {
const values = Array.isArray(rawValues) ? rawValues : [rawValues];
const hasAndValue = values.some((v) => hasExplicitAndComparator(v));
if (hasAndValue) {
throw new Error(`The $and comparator can only be used on to-many relationship columns. ` +
`Column "${key}" is not a to-many relationship.`);
}
}
}
}
const toManyRelations = toManyPaths.map((path) => [path, existsFilters.find((f) => f.path.join('.') === path).relation]);
for (const [path, relation] of toManyRelations) {
const mainQueryAlias = qb.alias;
const relationPath = getRelationPath(path, mainMetadata);
// 1. Create the EXISTS subquery, starting from the toMany entity
const existsMetadata = relation.inverseEntityMetadata;
// Slug used in alias/parameter suffixes to scope them to this relation path.
// Using the path (e.g. "toys" → "toys", "cat.toys" → "cat_toys") ensures that two
// independent to-many paths in the same outer query never share alias or parameter names,
// even when both use existsIndex=0 (OR-mode) or overlapping sub-column names.
const pathSlug = path.replace(/\./g, '_');
// 2. Extract sub-filters for this path.
// If any sub-filter entry uses the $and comparator, we must emit one correlated EXISTS
// subquery per value — because a single EXISTS with AND conditions on the same column
// (e.g. WHERE tag.id = A AND tag.id = B) is always false on a single row.
// We split $and values into individual sub-queries and AND them on the outer query.
const { subQuery, subFilterableColumns } = createSubFilter(query, filterableColumns, path);
// Collect $and filter values per sub-column so we can split them out.
const andValuesBySubColumn = {};
for (const [subCol, rawValues] of Object.entries((_b = subQuery.filter) !== null && _b !== void 0 ? _b : {})) {
const values = Array.isArray(rawValues) ? rawValues : [rawValues];
const andValues = values.filter((v) => hasExplicitAndComparator(v));
if (andValues.length >= 1) {
andValuesBySubColumn[subCol] = andValues;
}
}
// Determine how many EXISTS subqueries to emit for this path.
// If there are $and values on any sub-column, we need one EXISTS per value (for those columns).
// Other sub-columns (OR-mode) are included in every EXISTS subquery unchanged.
const andSubColumns = Object.keys(andValuesBySubColumn);
// Build a helper that constructs and correlates a single EXISTS subquery for this path.
// existsIndex must be unique per EXISTS emitted for this path to avoid alias collisions
// when multiple EXISTS subqueries are added to the same outer query (AND-mode).
// The pathSlug is included in all aliases and parameter names so that two independent
// to-many paths (e.g. "toys" and "friends") never collide even when both use existsIndex=0.
const buildExistsQb = (extraSubQuery, existsIndex = 0) => {
const relAlias = (name, depth) => `_rel_${name}_${depth}_${pathSlug}_e${existsIndex}`;
const juncAlias = (name, depth) => `_junc_${name}_${depth}_${pathSlug}_e${existsIndex}`;
const leafAlias = relAlias(relationPath[relationPath.length - 1][0], relationPath.length - 1);
const existsQb = qb.connection.createQueryBuilder(existsMetadata.target, leafAlias);
const subJoins = addFilter(existsQb, extraSubQuery, subFilterableColumns, { validateAndComparator: false });
// Step 3: Add the sub relationship joins to the EXISTS subquery.
const relationsSchema = (0, helper_1.mergeRelationSchema)((0, helper_1.createRelationSchema)(Object.keys(subJoins)));
(0, paginate_1.addRelationsFromSchema)(existsQb, relationsSchema, {}, 'innerJoin');
// Step 4: Build the chain of joins that backtracks our toMany relationship to the root.
buildSubqueryJoinChain(existsQb, relAlias, juncAlias);
// Step 5: Rename all parameters to include a path+index suffix, preventing collisions
// when multiple EXISTS subqueries share the outer query's parameter namespace.
const suffix = `_${pathSlug}_e${existsIndex}`;
renameSubqueryParameters(existsQb, suffix);
return existsQb;
};
/**
* Adds the join chain that correlates the EXISTS subquery back to the root entity.
* Iterates from the deepest relation segment to the root, adding INNER JOINs for
* intermediate relations and a correlated WHERE clause for the root relation.
*/
function buildSubqueryJoinChain(existsQb, relAlias, juncAlias) {
for (let i = relationPath.length - 1; i >= 0; i--) {
const [, meta] = relationPath[i];
// --- A: Skip Embedded Entities ---
if (meta.type === 'embedded') {
continue;
}
// --- B: Handle Table Relation (RelationMetadata) ---
const parentMeta = meta;
if (i !== 0) {
// --- Intermediate Join ---
const parentAlias = relAlias(relationPath[i - 1][0], i - 1);
const childAlias = relAlias(relationPath[i][0], i);
const childRelationMetadata = parentMeta.inverseRelation;
const joinCols = childRelationMetadata.joinColumns;
const onConditions = joinCols
.map((jc) => {
const fk = jc.databaseName;
const pk = jc.referencedColumn.databaseName;
return `${quote(childAlias)}.${quote(fk)} = ${quote(parentAlias)}.${quote(pk)}`;
})
.join(' AND ');
existsQb.innerJoin(childRelationMetadata.inverseRelation.entityMetadata.target, parentAlias, onConditions);
}
else {
correlateManyToManyOrFk(existsQb, parentMeta, relAlias, juncAlias);
}
}
}
/**
* Adds the root correlation clause to the EXISTS subquery.
* For ManyToMany relations, JOINs the junction table and correlates via it.
* For OneToMany / ManyToOne relations, adds a direct FK = PK WHERE clause.
*/
function correlateManyToManyOrFk(existsQb, parentMeta, relAlias, juncAlias) {
var _a;
// --- Root correlation ---
//
// `joinMeta` is always the owning side of the relation, regardless of which side
// the filter is expressed from. This is because TypeORM stores join column metadata
// (including junction table metadata for ManyToMany) only on the owning side.
//
// Example (ManyToMany, inverse side):
// Cat.friends (owning) ←→ Cat.friendOf (inverse)
// Filtering on "friendOf.name": parentMeta = friendOf (inverse, !isOwning)
// joinMeta = parentMeta.inverseRelation = friends (owning)
// toRelatedCols = joinMeta.joinColumns (junction → owning entity = friendOf side)
// toMainCols = joinMeta.inverseJoinColumns (junction → inverse entity = Cat being filtered)
if (!parentMeta.isOwning && !parentMeta.inverseRelation) {
throw new Error(`Cannot build EXISTS subquery for ManyToMany relation "${path}": ` +
`the relation has no inverse side defined. ` +
`Ensure the @ManyToMany decorator on the inverse entity references this relation.`);
}
const joinMeta = parentMeta.isOwning ? parentMeta : parentMeta.inverseRelation;
if (parentMeta.isManyToMany) {
// For ManyToMany, the FK columns live in the junction (join) table, not on the
// related entity's table. We must JOIN the junction table into the EXISTS subquery
// and correlate via it.
const junctionMeta = joinMeta.junctionEntityMetadata;
const junctionAlias = juncAlias(relationPath[0][0], 0);
const relatedAlias = relAlias(relationPath[0][0], 0);
// Column role mapping (always relative to `joinMeta`, which is the owning side):
// joinMeta.joinColumns → junction columns pointing to the owning entity
// joinMeta.inverseJoinColumns → junction columns pointing to the inverse entity
//
// When filtering from the owning side (parentMeta.isOwning):
// toRelatedCols = inverseJoinColumns → junction → related entity (EXISTS root)
// toMainCols = joinColumns → junction → main query entity
//
// When filtering from the inverse side (!parentMeta.isOwning):
// joinMeta = parentMeta.inverseRelation (the owning side)
// toRelatedCols = joinMeta.joinColumns → junction → owning entity (EXISTS root)
// toMainCols = joinMeta.inverseJoinColumns → junction → main query entity
const toRelatedCols = parentMeta.isOwning ? joinMeta.inverseJoinColumns : joinMeta.joinColumns;
const toMainCols = parentMeta.isOwning ? joinMeta.joinColumns : joinMeta.inverseJoinColumns;
const junctionToRelatedConditions = toRelatedCols
.map((jc) => {
const junctionCol = jc.databaseName;
const relatedPk = jc.referencedColumn.databaseName;
return `${quote(junctionAlias)}.${quote(junctionCol)} = ${quote(relatedAlias)}.${quote(relatedPk)}`;
})
.join(' AND ');
const junctionTarget = (_a = junctionMeta.target) !== null && _a !== void 0 ? _a : junctionMeta.tableName;
existsQb.innerJoin(junctionTarget, junctionAlias, junctionToRelatedConditions);
for (const joinColumn of toMainCols) {
const junctionCol = joinColumn.databaseName;
const mainPk = joinColumn.referencedColumn.databaseName;
existsQb.andWhere(`${quote(junctionAlias)}.${quote(junctionCol)} = ${quote(mainQueryAlias)}.${quote(mainPk)}`);
}
}
else {
for (const joinColumn of joinMeta.joinColumns) {
const fkColumn = joinColumn.databaseName;
const pkColumn = joinColumn.referencedColumn.databaseName;
let fkAlias;
let pkAlias;
if (parentMeta.isOwning) {
pkAlias = relAlias(relationPath[0][0], 0);
fkAlias = mainQueryAlias;
}
else {
fkAlias = relAlias(relationPath[0][0], 0);
pkAlias = mainQueryAlias;
}
existsQb.andWhere(`${quote(fkAlias)}.${quote(fkColumn)} = ${quote(pkAlias)}.${quote(pkColumn)}`);
}
}
}
/**
* Renames all TypeORM named parameters in the EXISTS subquery by appending `suffix`.
* This prevents parameter name collisions when multiple EXISTS subqueries share the
* outer query's parameter namespace (AND-mode emits one EXISTS per `$and` value).
*
* Handles both simple parameters (`:name`) and spread parameters (`:...name` for IN),
* and correctly skips PostgreSQL cast syntax (`::type`).
*/
function renameSubqueryParameters(existsQb, suffix) {
const oldParams = Object.assign({}, existsQb.expressionMap.parameters);
existsQb.expressionMap.parameters = {};
for (const [key, value] of Object.entries(oldParams)) {
existsQb.expressionMap.parameters[key + suffix] = value;
}
// Use the module-level TYPEORM_PARAM_REGEX (handles both :name and :...name spread form,
// skips PostgreSQL ::type cast syntax). Must reset lastIndex since the regex is stateful (flag g).
const renameParamsInString = (s) => {
exports.TYPEORM_PARAM_REGEX.lastIndex = 0;
return s.replace(exports.TYPEORM_PARAM_REGEX, (_, spread, name) => `:${spread !== null && spread !== void 0 ? spread : ''}${name}${suffix}`);
};
const renameParamsInCondition = (condition) => {
if (typeof condition === 'string') {
// Top-level string conditions are handled by the caller (the `for` loop over
// `existsQb.expressionMap.wheres` below). Nested string conditions that appear
// inside a `WhereClause.condition` object are handled by the `condition.condition`
// branch below. This branch is a no-op guard — TypeORM never passes a bare string
// here in practice, but the type allows it.
return;
}
if (Array.isArray(condition)) {
// Arrays cannot be mutated element-by-element for strings (immutable),
// so we map over the array and replace string items directly.
for (let i = 0; i < condition.length; i++) {
if (typeof condition[i] === 'string') {
condition[i] = renameParamsInString(condition[i]);
}
else {
renameParamsInCondition(condition[i]);
}
}
return;
}
if (condition && typeof condition === 'object') {
if (typeof condition.condition === 'string') {
condition.condition = renameParamsInString(condition.condition);
}
else if (condition.condition) {
renameParamsInCondition(condition.condition);
}
// Also rename in nested children arrays (e.g. Brackets with multiple clauses)
if (Array.isArray(condition.wheres)) {
for (const child of condition.wheres) {
if (typeof child.condition === 'string') {
child.condition = renameParamsInString(child.condition);
}
else {
renameParamsInCondition(child.condition);
}
}
}
}
};
for (const where of existsQb.expressionMap.wheres) {
if (typeof where.condition === 'string') {
where.condition = renameParamsInString(where.condition);
}
else {
renameParamsInCondition(where.condition);
}
}
}
// Determine the quantifier for this path (must be uniform across all sub-columns).
const quantifiers = Object.entries(filter)
.filter(([key]) => key.startsWith(path))
.flatMap(([, multiFilter]) => multiFilter.map((f) => f.quantifier));
let quantifier = FilterQuantifier.ANY;
for (const q of quantifiers) {
if (q !== FilterQuantifier.ANY) {
if (quantifier !== FilterQuantifier.ANY && quantifier !== q) {
throw new Error(`Quantifier ${quantifier} and ${q} are not compatible for the same column ${path}`);
}
quantifier = q;
}
}
if (andSubColumns.length > 0) {
// AND-mode: emit one EXISTS per $and value on each sub-column, ANDed on the outer query.
// OR-mode values on other sub-columns are included in every EXISTS subquery unchanged.
//
// Example: filter[tag.id]=$and:tagA&filter[tag.id]=$and:tagB produces:
// AND EXISTS (SELECT 1 FROM tag JOIN junction ON ... WHERE tag.id = 'tagA' AND ...)
// AND EXISTS (SELECT 1 FROM tag JOIN junction ON ... WHERE tag.id = 'tagB' AND ...)
//
// This is the only correct way to express "entity has ALL of these related values" —
// a single EXISTS with AND conditions on the same column is always false on a single row.
// $and comparator is incompatible with $none/$all quantifiers — the $and comparator
// already implies AND-mode (multiple correlated EXISTS), so combining it with a
// quantifier that changes the EXISTS semantics is a user error.
if (quantifier !== FilterQuantifier.ANY) {
throw new Error(`The $and comparator cannot be combined with the $${quantifier} quantifier on column ${path}. ` +
`Use $any (the default) with $and, or use $${quantifier} without $and.`);
}
// $and on multiple sub-columns produces a cartesian product of EXISTS subqueries,
// which has surprising semantics: each EXISTS asserts a separate row exists, not a
// single row matching all conditions. Disallow this to avoid silent incorrect results.
if (andSubColumns.length > 1) {
throw new Error(`The $and comparator cannot be used on multiple sub-columns of the same relation path ${path} simultaneously ` +
`(found: ${andSubColumns.join(', ')}). Apply $and to a single sub-column at a time.`);
}
// Build the base sub-query without the $and values (keeps OR-mode filters on other columns,
// and keeps any non-$and values on the $and sub-column itself).
//
// Note: OR-mode values on the $and sub-column (non-$and values mixed with $and values)
// are included in every EXISTS subquery as an additional filter. For example:
// filter[toys.name]=$and:Ball&filter[toys.name]=$eq:Mouse
// produces EXISTS subqueries that each include `$eq:Mouse` as an additional condition.
// This means each EXISTS asserts: "a related row exists where name = 'Ball' AND name = 'Mouse'"
// (always false for a single row), which is likely not the intended behavior.
// Users should use only $and values or only OR-mode values on a given sub-column, not both.
const baseSubQuery = Object.assign(Object.assign({}, subQuery), { filter: Object.fromEntries(Object.entries((_c = subQuery.filter) !== null && _c !== void 0 ? _c : {}).flatMap(([col, rawValues]) => {
if (!andSubColumns.includes(col)) {
return [[col, rawValues]];
}
// Reject mixing $and values with non-$and (OR-mode) values on the same sub-column.
// A mixed filter would produce EXISTS subqueries with conditions like
// `name = 'Ball' AND name = 'Mouse'` (always false on a single row),
// which silently returns empty results rather than the user's intent.
const values = Array.isArray(rawValues) ? rawValues : [rawValues];
const orValues = values.filter((v) => !hasExplicitAndComparator(v));
if (orValues.length > 0) {
throw new Error(`Cannot mix $and values with non-$and values on sub-column "${col}" of relation "${path}". ` +
`Use either all $and or no $and on a given sub-column.`);
}
return [];
})) });
// For each $and value on the (single) sub-column, emit a separate EXISTS subquery.
// Multiple $and sub-columns are disallowed (throws above), so andSubColumns always
// has exactly one entry here.
const andSubCol = andSubColumns[0];
const andValues = andValuesBySubColumn[andSubCol];
// Validate that each $and value has an actual operand after the comparator.
// A bare `$and` (no colon separator) produces token.value = undefined, which
// would generate a malformed query. Require at least `$and:` with a value.
for (const v of andValues) {
const token = parseFilterToken(v);
if (!token || token.value === undefined) {
throw new Error(`Invalid $and filter value "${v}" for column ${path}.${andSubCol}: ` +
`$and must be followed by a value, e.g. "$and:Ball" or "$and:$eq:Ball".`);
}
}
if (andValues.length > maxAndValues) {
throw new Error(`Too many $and filter values for column ${path}.${andSubCol}: ${andValues.length} (max ${maxAndValues}). ` +
`Reduce the number of $and values.`);
}