325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
import {
|
|
ALIAS_REPLACEMENT,
|
|
GroupOperator,
|
|
QueryFlag,
|
|
raw,
|
|
RawQueryFragment,
|
|
ReferenceKind,
|
|
Utils,
|
|
} from '@mikro-orm/core';
|
|
import { CriteriaNode } from './CriteriaNode.js';
|
|
import { JoinType, QueryType } from './enums.js';
|
|
const COLLECTION_OPERATORS = ['$some', '$none', '$every', '$size'];
|
|
/**
|
|
* @internal
|
|
*/
|
|
export class ObjectCriteriaNode extends CriteriaNode {
|
|
process(qb, options) {
|
|
const matchPopulateJoins =
|
|
options?.matchPopulateJoins ||
|
|
(this.prop && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(this.prop.kind));
|
|
const nestedAlias = qb.getAliasForJoinPath(this.getPath(options), { ...options, matchPopulateJoins });
|
|
const ownerAlias = options?.alias || qb.alias;
|
|
const keys = Utils.getObjectQueryKeys(this.payload);
|
|
let alias = options?.alias;
|
|
if (nestedAlias) {
|
|
alias = nestedAlias;
|
|
}
|
|
if (this.shouldAutoJoin(qb, nestedAlias)) {
|
|
if (keys.some(k => COLLECTION_OPERATORS.includes(k))) {
|
|
if (![ReferenceKind.MANY_TO_MANY, ReferenceKind.ONE_TO_MANY].includes(this.prop.kind)) {
|
|
// ignore collection operators when used on a non-relational property - this can happen when they get into
|
|
// populateWhere via `infer` on m:n properties with select-in strategy
|
|
if (this.parent?.parent) {
|
|
// we validate only usage on top level
|
|
return {};
|
|
}
|
|
throw new Error(
|
|
`Collection operators can be used only inside a collection property context, but it was used for ${this.getPath()}.`,
|
|
);
|
|
}
|
|
const $and = [];
|
|
const knownKey =
|
|
[ReferenceKind.SCALAR, ReferenceKind.MANY_TO_ONE, ReferenceKind.EMBEDDED].includes(this.prop.kind) ||
|
|
(this.prop.kind === ReferenceKind.ONE_TO_ONE && this.prop.owner);
|
|
const parentMeta = this.metadata.find(this.parent.entityName);
|
|
const primaryKeys = parentMeta.primaryKeys.map(pk => {
|
|
return [QueryType.SELECT, QueryType.COUNT].includes(qb.type) ? `${knownKey ? alias : ownerAlias}.${pk}` : pk;
|
|
});
|
|
for (const key of keys) {
|
|
if (typeof key !== 'string' || !COLLECTION_OPERATORS.includes(key)) {
|
|
throw new Error('Mixing collection operators with other filters is not allowed.');
|
|
}
|
|
const payload = this.payload[key].unwrap();
|
|
const qb2 = qb.clone(true, ['schema']);
|
|
const joinAlias = qb2.getNextAlias(this.prop.targetMeta.class);
|
|
const sub = qb2
|
|
.from(parentMeta.class)
|
|
// eslint-disable-next-line no-unexpected-multiline
|
|
[key === '$size' ? 'leftJoin' : 'innerJoin'](this.key, joinAlias)
|
|
.select(parentMeta.primaryKeys);
|
|
if (key === '$size') {
|
|
const sizeCondition = typeof payload === 'number' ? { $eq: payload } : payload;
|
|
const pks = this.prop.referencedColumnNames;
|
|
const countExpr = raw(
|
|
`count(${pks.map(() => '??').join(', ')})`,
|
|
pks.map(pk => `${joinAlias}.${pk}`),
|
|
);
|
|
sub.groupBy(parentMeta.primaryKeys);
|
|
sub.having({
|
|
$and: Object.keys(sizeCondition).map(op => ({ [countExpr]: { [op]: sizeCondition[op] } })),
|
|
});
|
|
} else if (key === '$every') {
|
|
sub.where({ $not: { [this.key]: payload } });
|
|
} else {
|
|
sub.where({ [this.key]: payload });
|
|
}
|
|
const op = ['$size', '$some'].includes(key) ? '$in' : '$nin';
|
|
$and.push({
|
|
[Utils.getPrimaryKeyHash(primaryKeys)]: { [op]: sub.getNativeQuery().toRaw() },
|
|
});
|
|
}
|
|
if ($and.length === 1) {
|
|
return $and[0];
|
|
}
|
|
return { $and };
|
|
}
|
|
alias = this.autoJoin(qb, ownerAlias, options);
|
|
}
|
|
if (this.prop && nestedAlias) {
|
|
const toOneProperty = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(this.prop.kind);
|
|
// if the property is nullable and the filter is strict, we need to use left join, so we mimic the inner join behaviour
|
|
// with an exclusive condition on the join columns:
|
|
// - if the owning column is null, the row is missing, we don't apply the filter
|
|
// - if the target column is not null, the row is matched, we apply the filter
|
|
if (toOneProperty && this.prop.nullable && this.isStrict()) {
|
|
const key = this.prop.owner ? this.prop.name : this.prop.referencedPKs;
|
|
qb.andWhere({
|
|
$or: [
|
|
{ [ownerAlias + '.' + key]: null },
|
|
{ [nestedAlias + '.' + Utils.getPrimaryKeyHash(this.prop.referencedPKs)]: { $ne: null } },
|
|
],
|
|
});
|
|
}
|
|
}
|
|
return keys.reduce((o, field) => {
|
|
const childNode = this.payload[field];
|
|
const payload = childNode.process(qb, { ...options, alias: this.prop ? alias : ownerAlias });
|
|
const operator = Utils.isOperator(field);
|
|
const isRawField = RawQueryFragment.isKnownFragmentSymbol(field);
|
|
// we need to keep the prefixing for formulas otherwise we would lose aliasing context when nesting inside group operators
|
|
const virtual = childNode.prop?.persist === false && !childNode.prop?.formula && !!options?.type;
|
|
// if key is missing, we are inside group operator and we need to prefix with alias
|
|
const primaryKey = this.key && this.metadata.find(this.entityName)?.primaryKeys.includes(field);
|
|
const isToOne =
|
|
childNode.prop && [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(childNode.prop.kind);
|
|
if (childNode.shouldInline(payload)) {
|
|
const childAlias = qb.getAliasForJoinPath(childNode.getPath(), { preferNoBranch: isToOne, ...options });
|
|
const a = qb.helper.isTableNameAliasRequired(qb.type) ? alias : undefined;
|
|
this.inlineChildPayload(o, payload, field, a, childAlias);
|
|
} else if (childNode.shouldRename(payload)) {
|
|
this.inlineCondition(childNode.renameFieldToPK(qb, alias), o, payload);
|
|
} else if (isRawField) {
|
|
const rawField = RawQueryFragment.getKnownFragment(field);
|
|
o[raw(rawField.sql.replaceAll(ALIAS_REPLACEMENT, alias), rawField.params)] = payload;
|
|
} else if (!childNode.validate && !childNode.prop && !field.includes('.') && !operator) {
|
|
// wrap unknown fields in raw() to prevent alias prefixing (e.g. raw SQL aliases in HAVING)
|
|
// use '??' placeholder to properly quote the identifier
|
|
o[raw('??', [field])] = payload;
|
|
} else if (
|
|
primaryKey ||
|
|
virtual ||
|
|
operator ||
|
|
field.includes('.') ||
|
|
![QueryType.SELECT, QueryType.COUNT].includes(qb.type)
|
|
) {
|
|
this.inlineCondition(field.replaceAll(ALIAS_REPLACEMENT, alias), o, payload);
|
|
} else {
|
|
this.inlineCondition(`${alias ?? qb.alias}.${field}`, o, payload);
|
|
}
|
|
return o;
|
|
}, {});
|
|
}
|
|
isStrict() {
|
|
return (
|
|
this.strict ||
|
|
Utils.getObjectQueryKeys(this.payload).some(key => {
|
|
return this.payload[key].isStrict();
|
|
})
|
|
);
|
|
}
|
|
unwrap() {
|
|
return Utils.getObjectQueryKeys(this.payload).reduce((o, field) => {
|
|
o[field] = this.payload[field].unwrap();
|
|
return o;
|
|
}, {});
|
|
}
|
|
willAutoJoin(qb, alias, options) {
|
|
const nestedAlias = qb.getAliasForJoinPath(this.getPath(options), options);
|
|
const ownerAlias = alias || qb.alias;
|
|
const keys = Utils.getObjectQueryKeys(this.payload);
|
|
if (nestedAlias) {
|
|
alias = nestedAlias;
|
|
}
|
|
if (this.shouldAutoJoin(qb, nestedAlias)) {
|
|
return !keys.some(k => COLLECTION_OPERATORS.includes(k));
|
|
}
|
|
return keys.some(field => {
|
|
const childNode = this.payload[field];
|
|
return childNode.willAutoJoin(qb, this.prop ? alias : ownerAlias, options);
|
|
});
|
|
}
|
|
shouldInline(payload) {
|
|
const rawField = RawQueryFragment.isKnownFragmentSymbol(this.key);
|
|
const scalar = Utils.isPrimaryKey(payload) || payload instanceof RegExp || payload instanceof Date || rawField;
|
|
const operator =
|
|
Utils.isObject(payload) &&
|
|
Utils.getObjectQueryKeys(payload).every(k => {
|
|
if (k === '$not' && Utils.isPlainObject(payload[k])) {
|
|
// $not wrapping non-operator conditions (entity props) should be inlined
|
|
return Utils.getObjectQueryKeys(payload[k]).every(ik => Utils.isOperator(ik, false));
|
|
}
|
|
return Utils.isOperator(k, false);
|
|
});
|
|
return !!this.prop && this.prop.kind !== ReferenceKind.SCALAR && !scalar && !operator;
|
|
}
|
|
getChildKey(k, prop, childAlias, alias) {
|
|
const idx = prop.referencedPKs.indexOf(k);
|
|
return idx !== -1 && !childAlias && ![ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)
|
|
? this.aliased(prop.joinColumns[idx], alias)
|
|
: k;
|
|
}
|
|
inlineArrayChildPayload(obj, payload, k, prop, childAlias, alias) {
|
|
const key = this.getChildKey(k, prop, childAlias);
|
|
const value = payload.map(child =>
|
|
Utils.getObjectQueryKeys(child).reduce((inner, childKey) => {
|
|
const key =
|
|
RawQueryFragment.isKnownFragmentSymbol(childKey) || this.isPrefixed(childKey) || Utils.isOperator(childKey)
|
|
? childKey
|
|
: this.aliased(childKey, childAlias);
|
|
inner[key] = child[childKey];
|
|
return inner;
|
|
}, {}),
|
|
);
|
|
this.inlineCondition(key, obj, value);
|
|
}
|
|
inlineChildPayload(o, payload, field, alias, childAlias) {
|
|
const prop = this.metadata.find(this.entityName).properties[field];
|
|
for (const k of Utils.getObjectQueryKeys(payload)) {
|
|
if (RawQueryFragment.isKnownFragmentSymbol(k)) {
|
|
o[k] = payload[k];
|
|
} else if (
|
|
k === '$not' &&
|
|
Utils.isPlainObject(payload[k]) &&
|
|
Utils.getObjectQueryKeys(payload[k]).some(ik => !Utils.isOperator(ik, false))
|
|
) {
|
|
// $not wraps entity conditions (from auto-join), inline at current level
|
|
this.inlineCondition(k, o, payload[k]);
|
|
} else if (Utils.isOperator(k, false)) {
|
|
const tmp = payload[k];
|
|
delete payload[k];
|
|
o[this.aliased(field, alias)] = { [k]: tmp, ...o[this.aliased(field, alias)] };
|
|
} else if (k in GroupOperator && Array.isArray(payload[k])) {
|
|
this.inlineArrayChildPayload(o, payload[k], k, prop, childAlias, alias);
|
|
} else if (this.isPrefixed(k) || Utils.isOperator(k) || !childAlias) {
|
|
const key = this.getChildKey(k, prop, childAlias, alias);
|
|
this.inlineCondition(key, o, payload[k]);
|
|
} else {
|
|
o[this.aliased(k, childAlias)] = payload[k];
|
|
}
|
|
}
|
|
}
|
|
inlineCondition(key, o, value) {
|
|
if (!(key in o)) {
|
|
o[key] = value;
|
|
return;
|
|
}
|
|
/* v8 ignore next */
|
|
if (key === '$and') {
|
|
o.$and.push({ [key]: value });
|
|
return;
|
|
}
|
|
const $and = o.$and ?? [];
|
|
$and.push({ [key]: o[key] }, { [key]: value });
|
|
delete o[key];
|
|
o.$and = $and;
|
|
}
|
|
shouldAutoJoin(qb, nestedAlias) {
|
|
if (!this.prop || !this.parent) {
|
|
return false;
|
|
}
|
|
const keys = Utils.getObjectQueryKeys(this.payload);
|
|
if (keys.every(k => typeof k === 'string' && k.includes('.') && k.startsWith(`${qb.alias}.`))) {
|
|
return false;
|
|
}
|
|
if (keys.some(k => COLLECTION_OPERATORS.includes(k))) {
|
|
return true;
|
|
}
|
|
const meta = this.metadata.find(this.entityName);
|
|
const embeddable = this.prop.kind === ReferenceKind.EMBEDDED;
|
|
const knownKey =
|
|
[ReferenceKind.SCALAR, ReferenceKind.MANY_TO_ONE, ReferenceKind.EMBEDDED].includes(this.prop.kind) ||
|
|
(this.prop.kind === ReferenceKind.ONE_TO_ONE && this.prop.owner);
|
|
const operatorKeys =
|
|
knownKey &&
|
|
keys.every(key => {
|
|
if (key === '$not') {
|
|
// $not wraps conditions like $and/$or, check if it wraps entity property conditions (needs auto-join)
|
|
// vs simple operator conditions on the FK (doesn't need auto-join)
|
|
const childPayload = this.payload[key].payload;
|
|
if (Utils.isPlainObject(childPayload)) {
|
|
return Utils.getObjectQueryKeys(childPayload).every(k => Utils.isOperator(k, false));
|
|
}
|
|
}
|
|
return Utils.isOperator(key, false);
|
|
});
|
|
const primaryKeys =
|
|
knownKey &&
|
|
keys.every(key => {
|
|
if (typeof key !== 'string' || !meta.primaryKeys.includes(key)) {
|
|
return false;
|
|
}
|
|
if (
|
|
!Utils.isPlainObject(this.payload[key].payload) ||
|
|
![ReferenceKind.ONE_TO_ONE, ReferenceKind.MANY_TO_ONE].includes(meta.properties[key].kind)
|
|
) {
|
|
return true;
|
|
}
|
|
return Utils.getObjectQueryKeys(this.payload[key].payload).every(
|
|
k => typeof k === 'string' && meta.properties[key].targetMeta.primaryKeys.includes(k),
|
|
);
|
|
});
|
|
return !primaryKeys && !nestedAlias && !operatorKeys && !embeddable;
|
|
}
|
|
autoJoin(qb, alias, options) {
|
|
const nestedAlias = qb.getNextAlias(this.prop?.pivotEntity ?? this.entityName);
|
|
const rawField = RawQueryFragment.isKnownFragmentSymbol(this.key);
|
|
const scalar =
|
|
Utils.isPrimaryKey(this.payload) || this.payload instanceof RegExp || this.payload instanceof Date || rawField;
|
|
const operator =
|
|
Utils.isPlainObject(this.payload) &&
|
|
Utils.getObjectQueryKeys(this.payload).every(k => Utils.isOperator(k, false));
|
|
const field = `${alias}.${this.prop.name}`;
|
|
const method = qb.hasFlag(QueryFlag.INFER_POPULATE) ? 'joinAndSelect' : 'join';
|
|
const path = this.getPath();
|
|
if (this.prop.kind === ReferenceKind.MANY_TO_MANY && (scalar || operator)) {
|
|
qb.join(field, nestedAlias, undefined, JoinType.pivotJoin, path);
|
|
} else {
|
|
const prev = qb.state.fields?.slice();
|
|
const toOneProperty = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(this.prop.kind);
|
|
const joinType = toOneProperty && !this.prop.nullable ? JoinType.innerJoin : JoinType.leftJoin;
|
|
qb[method](field, nestedAlias, undefined, joinType, path);
|
|
if (!qb.hasFlag(QueryFlag.INFER_POPULATE)) {
|
|
qb.state.fields = prev;
|
|
}
|
|
}
|
|
if (options?.type !== 'orderBy') {
|
|
qb.scheduleFilterCheck(path);
|
|
}
|
|
return nestedAlias;
|
|
}
|
|
isPrefixed(field) {
|
|
return !!/\w+\./.exec(field);
|
|
}
|
|
}
|