@liqd-js/mongodb-model
Version:
Mongo model class
928 lines (927 loc) • 38.9 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.Arr = exports.isSet = exports.fromBase64 = exports.toBase64 = void 0;
exports.convert = convert;
exports.reverseSort = reverseSort;
exports.sortProjection = sortProjection;
exports.resolveBSONValue = resolveBSONValue;
exports.resolveBSONObject = resolveBSONObject;
exports.addPrefixToFilter = addPrefixToFilter;
exports.addPrefixToPipeline = addPrefixToPipeline;
exports.addPrefixToUpdate = addPrefixToUpdate;
exports.isExclusionProjection = isExclusionProjection;
exports.projectionToReplace = projectionToReplace;
exports.projectionToProject = projectionToProject;
exports.bsonValue = bsonValue;
exports.isUpdateOperator = isUpdateOperator;
exports.toUpdateOperations = toUpdateOperations;
exports.getCursor = getCursor;
exports.generateCursorCondition = generateCursorCondition;
exports.collectAddedFields = collectAddedFields;
exports.optimizeMatch = optimizeMatch;
exports.propertyModelUpdateParams = propertyModelUpdateParams;
exports.mergeComputedProperties = mergeComputedProperties;
exports.mergeProperties = mergeProperties;
exports.getUsedFields = getUsedFields;
exports.splitFilterToStages = splitFilterToStages;
exports.getSubPaths = getSubPaths;
exports.subfilter = subfilter;
exports.transformToElemMatch = transformToElemMatch;
const mongodb_1 = require("mongodb");
const external_1 = require("../external");
const SORT_DESC = [-1, '-1', 'desc', 'descending'];
const toBase64 = (str) => Buffer.from(str, 'utf8').toString('base64url');
exports.toBase64 = toBase64;
const fromBase64 = (str) => Buffer.from(str, 'base64url').toString('utf8');
exports.fromBase64 = fromBase64;
async function convert(model, converter, dbe, conversion) {
try {
return await converter(dbe);
}
catch (e) {
if (e instanceof external_1.ModelConverterError) {
throw e;
}
throw new external_1.ModelConverterError(model, conversion.toString(), dbe._id ?? dbe.id, e);
}
}
function reverseSort(sort) {
return Object.fromEntries(Object.entries(sort).map(([key, value]) => [key, SORT_DESC.includes(value) ? 1 : -1]));
}
function sortProjection(sort, id) {
return Object.fromEntries([...Object.keys(sort).map((key => [key, 1])), [id, 1]]);
}
function addPrefixToValue(filter, prefix, prefixKeys = true) {
if (typeof filter === 'string' && filter.match(/^\$\$ROOT\./)) {
return filter.replace(/^\$\$ROOT\./, '$');
}
if (typeof filter === 'string' && filter.match(/^\$_root\./)) {
return '$' + filter.substring('$_root.'.length);
}
if (typeof filter === 'string' && filter.match(/^\$[^\$]/)) {
return prefix ? filter.replace(/^\$/, '$' + prefix + '.') : filter;
}
if (typeof filter === 'string') {
return filter;
}
if (typeof filter !== 'object' || filter === null) {
return filter;
}
if (typeof filter === 'object' && !Array.isArray(filter) && Object.keys(filter).length === 1 && ['$cond', '$switch', '$function', '$accumulator', '$reduce', '$map', '$filter', '$convert', '$dateFromString'].includes(Object.keys(filter)[0])) {
const key = Object.keys(filter)[0];
if (key === '$switch') {
return { $switch: { branches: filter.$switch.branches.map((branch) => ({ ...branch, case: addPrefixToValue(branch.case, prefix, prefixKeys) })), default: addPrefixToValue(filter.$switch.default, prefix, prefixKeys) } };
}
else if (key === '$function') {
return { $function: { lang: filter[key].lang, args: addPrefixToValue(filter[key].args, prefix, false), body: filter[key].body } };
}
else {
return { [key]: Object.fromEntries(Object.entries(filter[key]).map(([key, value]) => [key, addPrefixToValue(value, prefix)])) };
}
}
if (typeof filter === 'object' &&
((filter instanceof mongodb_1.ObjectId) ||
(filter instanceof Date) ||
(filter instanceof RegExp) // TODO is basic object alternative?
)) {
return filter;
}
return addPrefixToFilter(filter, prefix, prefixKeys);
}
//export function resolveFilterValue( filter: Filter | any ): Filter | any
function resolveBSONValue(value) {
if (typeof value === 'string') {
return value;
}
if (typeof value !== 'object' || value === null) {
return value;
}
if (typeof value === 'object' &&
((value instanceof mongodb_1.ObjectId) ||
(value instanceof Date) ||
(value instanceof RegExp) // TODO is basic object alternative?
)) {
return value;
}
if (typeof value === 'object' && Object.keys(value).length === 1) {
if (value.hasOwnProperty('$oid')) {
return new mongodb_1.ObjectId(value.$oid);
}
if (value.hasOwnProperty('$date')) {
return new Date(value.$date);
} // TODO verify it is not colliding
if (value.hasOwnProperty('$function')) {
if (typeof value.$function.body === 'function') {
return { $function: { ...value.$function, body: value.$function.body.toString() } };
}
return value;
}
}
return resolveBSONObject(value);
}
//export function resolveFilterOIDs( filter: Filter ): Filter
function resolveBSONObject(obj) {
if (Array.isArray(obj)) {
return obj.map((item) => resolveBSONValue(item));
}
const resolved = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
resolved[key] = resolveBSONValue(obj[key]);
}
}
return resolved;
}
function addPrefixToFilter(filter, prefix, prefixKeys = true) {
if (Array.isArray(filter)) {
return filter.map((item) => addPrefixToValue(item, prefix, prefixKeys));
}
let newFilter = {};
for (const key in filter) {
if (filter.hasOwnProperty(key)) {
if (key === '_root') {
Object.assign(newFilter, addPrefixToValue(filter[key], prefix, false));
}
else if (key.startsWith('_root.')) {
newFilter[key.substring('_root.'.length)] = addPrefixToValue(filter[key], prefix, false);
}
else if (key === '$function') {
newFilter = addPrefixToValue(filter, prefix, prefixKeys);
break;
}
else if (!prefixKeys || key.startsWith('$')) {
newFilter[key] = addPrefixToValue(filter[key], prefix, prefixKeys);
}
else {
newFilter[`${prefix}.${key}`] = addPrefixToValue(filter[key], prefix, false);
}
}
}
return newFilter;
}
/**/ function addPrefixToPipeline(pipeline, prefix) {
if (!prefix) {
return pipeline;
}
let prefixed = [];
for (const pipelineStage of pipeline) {
const [stage, query] = Object.entries(pipelineStage)[0];
if (['$addFields', '$facet', '$match', '$set', '$sort'].includes(stage)) {
prefixed.push({ [stage]: addPrefixToValue(query, prefix) }); // Object.entries( query ).map(([ key, value ]) => [ prefix + '.' + key, ]))});
}
else if (['$group'].includes(stage)) {
prefixed.push({ [stage]: Object.fromEntries(Object.entries(query).map(([key, value]) => [key === '_id' ? key : prefix + '.' + key, addPrefixToValue(value, prefix)])) });
}
else if (['$limit', '$skip'].includes(stage)) {
prefixed.push({ [stage]: query });
}
else if (['$unset', '$count'].includes(stage)) {
prefixed.push({ [stage]: Array.isArray(query) ? query.map((field) => `${prefix}.${field}`) : `${prefix}.${query}` });
}
else if (['$project'].includes(stage)) {
prefixed.push({ [stage]: projectionToProject(query, prefix) });
}
else if (['$densify'].includes(stage)) {
prefixed.push({ $densify: {
field: addPrefixToValue(query.field, prefix),
partitionByFields: query.partitionByFields.map((field) => addPrefixToValue(field, prefix)),
range: {
step: query.range.step,
unit: query.range.unit,
bounds: query.range.bounds.map((bound) => addPrefixToValue(bound, prefix))
}
} });
}
else if (['$geoNear'].includes(stage)) {
prefixed.push({ $geoNear: {
...Object.fromEntries(Object.entries(query).map(([key, value]) => [key, addPrefixToValue(value, prefix)])),
near: query.near
} });
}
else if (['$lookup'].includes(stage)) {
prefixed.push({ $lookup: {
...query,
from: query.from,
...(query.localField ? { localField: prefix + '.' + query.localField } : undefined), // TODO root?
...(query.foreignField ? { foreignField: query.foreignField } : undefined),
as: prefix + '.' + query.as,
...(query.let ? { let: Object.fromEntries(Object.entries(query.let).map(([key, value]) => [key, addPrefixToValue(value, prefix)])) } : undefined),
} });
}
else if (['$bucket', '$bucketAuto', '$fill', '$graphLookup', '$replaceRoot', '$replaceWith', '$sample', '$unwind'].includes(stage)) {
// TODO toto nie je uplne dobre
if (typeof query === 'string') {
prefixed.push({ [stage]: addPrefixToValue(query, prefix) });
}
else {
prefixed.push({ [stage]: Object.fromEntries(Object.entries(query).map(([key, value]) => [key, addPrefixToValue(value, prefix)])) });
}
}
// zakazat graphLookup, collStats, indexStats merge, out, $redact $search, $searchMeta, $setWindowFields, $sortByCount, '$unionWith' vectorSearch
else {
throw new Error(`Unsupported pipeline stage: "${stage}"`);
}
//lookup - prefixovat localField a as
}
return prefixed;
}
/**/
function addPrefixToUpdate(update, prefix) {
const newUpdate = {};
for (const [key, value] of Object.entries(update)) {
if (key.startsWith('$')) {
newUpdate[key] = addPrefixToUpdate(value, prefix);
}
else {
newUpdate[`${prefix}.${key}`] = value; // TODO test when update is not a primitive
}
}
return newUpdate;
}
function isExclusionProjection(projection) {
if (typeof projection !== 'object' || projection === null) {
return false;
}
let isExclusion = undefined;
for (const value of Object.values(projection)) {
if (typeof value === 'number' && value === 0) {
if (isExclusion === false) {
throw new Error('Projection cannot contain both exclusion and inclusion');
}
isExclusion = true;
}
else if (typeof value === 'object' && Object.keys(value).every(key => !key.startsWith('$'))) {
const res = isExclusionProjection(value);
if (isExclusion !== undefined && isExclusion !== res) {
throw new Error('Projection cannot contain both exclusion and inclusion');
}
isExclusion = res;
}
else {
if (isExclusion === true) {
throw new Error('Projection cannot contain both exclusion and inclusion');
}
isExclusion = false;
}
}
return isExclusion ?? false;
}
/**
* Converts projection to project object
* @param projection - projection to convert
* @param prefix - prefix to add to keys
* @param prefixKeys - whether to add prefix to object keys
* @param fullPath - full path to the object - used for nested object projections
* @param simpleKeys - true if keys are simple - without dots
*/
function projectionToProjectInternal(projection = {}, prefix = '', prefixKeys = false, fullPath = '', simpleKeys) {
if (Array.isArray(projection)) {
//@ts-ignore
return projection.map(item => {
if (item) {
if (item instanceof mongodb_1.ObjectId || item instanceof Date || item instanceof RegExp) {
return item;
}
else if (typeof item === 'string') {
return addPrefixToValue(item, prefix, false);
}
else if (typeof item === 'object') {
return projectionToProjectInternal(item, prefix, false, fullPath);
}
}
return item;
});
}
else {
const result = {};
for (const [key, value] of Object.entries(projection || {})) {
if (key.startsWith('$') && prefixKeys) {
throw new Error('Projection key cannot start with "$"');
}
if (key.startsWith('$')) {
result[key] = projectionToProjectInternal(value, prefix, false, fullPath);
}
else {
const prefixedKey = prefix ? prefix + '.' + key : key;
const keyFullPath = fullPath ? fullPath + '.' + key : key;
let projectedValue;
switch (typeof value) {
case 'number':
projectedValue = (value === 0) ? 0 : '$' + keyFullPath;
break;
case 'string':
projectedValue = addPrefixToValue(value, prefix, false);
break;
case 'object':
projectedValue = projectionToProjectInternal(value, prefix, false, keyFullPath);
break;
default:
throw new Error('Unsupported projection value type');
}
if (simpleKeys) {
(0, external_1.objectSet)(result, (prefixKeys ? prefixedKey : key).split('.'), projectedValue);
}
else {
result[prefixKeys ? prefixedKey : key] = projectedValue;
}
}
}
return result;
}
}
function projectionToReplace(projection = {}, prefix) {
return projectionToProjectInternal(projection, prefix, false, prefix, true);
}
function projectionToProject(projection = {}, prefix, prefixKeys = true) {
return projectionToProjectInternal(projection, prefix, prefixKeys, prefix, false);
}
function bsonValue(value) {
if (value instanceof mongodb_1.ObjectId) {
return { $oid: value.toString() };
}
if (value instanceof Date) {
return { $date: value.getTime() };
}
return value;
}
function isUpdateOperator(update) {
return (typeof update === 'object' && update !== null && Object.keys(update).every(key => key.startsWith('$')));
}
function toUpdateOperations(update) {
if (isUpdateOperator(update)) {
return update;
}
const $set = (0, external_1.deleteUndefinedProperties)(update);
const $unset = Object.fromEntries(Object.entries(update).filter(([key, value]) => value === undefined).map(([key]) => [key, 1]));
return Object.keys($unset).length ? { $unset, $set } : { $set };
}
function getCursor(dbe, sort) {
return (0, exports.toBase64)(JSON.stringify(Object.keys(sort).map(key => bsonValue((0, external_1.objectGet)(dbe, key.split('.'))))));
}
function generateCursorCondition(cursor, sort) {
const direction = cursor.startsWith('prev:') ? 'prev' : cursor.startsWith('next:') ? 'next' : undefined;
const properties = Object.keys(sort);
const directions = Object.values(sort).map(value => (direction === 'prev' ? -1 : 1) * (SORT_DESC.includes(value) ? -1 : 1));
const values = JSON.parse((0, exports.fromBase64)(cursor.substring(direction ? direction.length + 1 : 0)));
if (properties.length !== values.length) {
throw new Error('Cursor does not match sort properties');
}
if (properties.length === 1) {
return { [properties[0]]: { [(directions[0] === 1 ? '$gt' : '$lt') + (!direction ? 'e' : '')]: values[0] } };
}
const filter = [];
for (let i = 0; i < properties.length; i++) {
const condition = {};
filter.push(condition);
for (let j = 0; j <= i; j++) {
condition[properties[j]] = { [(j < i ? '$eq' : directions[j] === 1 ? '$gt' : '$lt') + (j === properties.length - 1 && !direction ? 'e' : '')]: values[j] };
}
}
return { $or: filter };
}
function collectAddedFields(pipeline) {
const fields = new Set();
for (const stage of pipeline) {
const addedFields = stage.$addFields || stage.$set;
if (addedFields) {
Object.keys(addedFields).forEach(prop => fields.add(prop));
}
else if (stage.$lookup) {
fields.add(stage.$lookup.as);
}
else if (stage.$unset) {
stage.$unset.forEach((prop) => fields.delete(prop));
}
else if (!stage.$match) {
throw new Error(`Unsupported pipeline stage: "${Object.keys(stage)[0]}"`);
}
}
return [...fields];
}
/**
* Optimizes match filter by merging conditions and removing unnecessary operators
* @param obj - filter to optimize
* @returns optimized filter
*/
function optimizeMatch(obj) {
if (!obj) {
return undefined;
}
let result = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined) {
continue;
}
if (key === '$and' || key === '$or') {
const filteredArray = value
.map((item) => optimizeMatch(item))
.filter((optimizedItem) => optimizedItem && Object.keys(optimizedItem).length);
if (filteredArray.length > 1) {
if (key === '$and') {
// TODO: combo $and + key na jednej úrovni
if (filteredArray.every((item) => Object.keys(item).every((itemKey) => !itemKey.startsWith('$')))
|| filteredArray.every((item) => Object.keys(item).every((itemKey) => itemKey === '$and'))) {
const merged = mergeProperties(...filteredArray);
if (merged === false) {
result[key] = filteredArray;
}
else {
result = { ...result, ...merged };
}
}
else {
result[key] = filteredArray;
}
}
else if (key === '$or') {
if (filteredArray.every((item) => (Object.keys(item).length === 1 && item.$or) || Object.keys(item).every(itemKey => !itemKey.startsWith('$')))) {
const merged = [];
for (const item of filteredArray) {
if (item.$or) {
merged.push(...item.$or);
}
else {
merged.push(item);
}
}
result = { ...result, $or: merged };
}
else {
const or = filteredArray.filter((el) => Object.keys(el).length === 1 && el.$or);
const rest = filteredArray.filter((el) => Object.keys(el).length > 1 || !el.$or);
result[key] = [...rest, ...(or[0]?.$or || [])];
}
}
else {
result[key] = filteredArray;
}
}
else if (filteredArray.length === 1) {
const isInRoot = Object.keys(filteredArray[0]).some(key => result[key]);
const properties = mergeProperties(filteredArray[0]);
result = { ...result, ...(isInRoot ? { [key]: [filteredArray[0]] } : properties) };
}
}
else {
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
if ((value instanceof mongodb_1.ObjectId) || (value instanceof Date) || (value instanceof RegExp)) {
result[key] = value;
continue;
}
// iterate all keys and optimize them
for (const operator in value) {
if (operator === '$in' && value.$in && value.$in.length === 1 && !(value.$in[0] instanceof RegExp)) {
result[key] = { ...result[key], $eq: value.$in[0] };
}
else if (operator === '$nin' && value.$nin && value.$nin.length === 1 && !(value.$nin[0] instanceof RegExp)) {
result[key] = { ...result[key], $ne: value.$nin[0] };
}
else if (operator === '$not' && value.$not && value.$not.$in && value.$not.$in.length === 1 && !(value.$not.$in[0] instanceof RegExp)) {
result[key] = { ...result[key], $ne: value.$not.$in[0] };
}
else if (operator === '$elemMatch') {
// not possible in negative cases - $exists: false, $nin, $not, $ne...
// const elemMatch = optimizeMatch(value.$elemMatch);
// if ( elemMatch && Object.keys(elemMatch).length === 1 && !Object.keys(elemMatch).every( key => key.startsWith('$')) )
// {
// const [elemMatchKey, elemMatchValue] = Object.entries(elemMatch)[0];
// result[key + '.' + elemMatchKey] = elemMatchValue;
// }
// else
// {
result[key] = value;
// }
}
else {
result[key] = { ...result[key], [operator]: value[operator] };
}
}
}
else {
result[key] = value;
}
}
}
return result;
}
/**
* Builds update parameters for property model update
* @param paths
* @param parentID
// * @param operation - create or update - determines if filtering should be done on parentID or _id
*/
function propertyModelUpdateParams(paths, parentID) {
let parentPath = paths.slice(0, paths.length - 1).map(p => p.path).join('.');
parentPath = parentPath !== '' ? parentPath + '.' : undefined;
const parentIDPath = parentPath ? parentPath + 'id' : '_id';
const currModelSubPath = paths[paths.length - 1];
let updatePath = paths.slice(0, paths.length - 1).map((p, i) => p.path + (p.array ? `.$[path${i}]` : '')).join('.');
updatePath = updatePath !== '' ? `${updatePath}.${currModelSubPath.path}` : currModelSubPath.path;
const arrayFilters = [];
for (let i = 0; i < paths.length - 1; i++) {
const path = paths[i];
if (path.array) {
let pathRest = paths.slice(i + 1, paths.length - 1).map(p => p.path).join('.');
pathRest = pathRest !== '' ? pathRest + '.' : '';
arrayFilters.push({ [`path${i}.${pathRest}id`]: parentID });
}
}
return { parentIDPath, updatePath, arrayFilters };
}
function mergeComputedProperties(...objects) {
const computed = {};
for (const properties of objects) {
for (const prefix in properties) {
if (!computed[prefix]) {
computed[prefix] = { fields: {}, pipeline: [] };
}
computed[prefix].fields = { ...computed[prefix].fields, ...properties[prefix].fields };
properties[prefix].pipeline && computed[prefix].pipeline.push(...properties[prefix].pipeline);
}
}
for (const prefix in computed) {
if (!Object.entries(computed[prefix].fields || {}).length) {
computed[prefix].fields = null;
}
if (!computed[prefix].pipeline?.length) {
computed[prefix].pipeline = null;
}
if (!computed[prefix].fields && !computed[prefix].pipeline) {
delete computed[prefix];
}
}
return computed;
}
/**
* Merges properties of multiple objects into one object - helper for optimizeMatch
* @param objects - objects to merge
* @returns merged object or false if there are conflicting properties
*/
function mergeProperties(...objects) {
const result = {};
if (!objects.every(el => typeof el === 'object' && !Array.isArray(el))) {
throw new Error('Invalid input - expected objects');
}
for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) {
if (!result[key]) {
result[key] = value;
continue;
}
if (isConflicting(result[key], value)) {
return false;
}
if (isEq(result[key]) && !result[key]['$eq']) {
result[key] = { $eq: result[key] };
}
if (typeof value === 'object' && (value instanceof Date || value instanceof mongodb_1.ObjectId || value instanceof RegExp)) {
result[key] = { ...result[key], $eq: value };
}
else if (typeof value === 'object') {
result[key] = { ...result[key], ...value };
}
else {
if (Object.keys(result[key]).length === 0) {
result[key] = value;
}
else {
result[key] = { ...result[key], $eq: value };
}
}
}
}
return result;
}
function isConflicting(obj1, obj2) {
if (!obj1 || !obj2) {
return false;
}
if (isEq(obj1) && isEq(obj2)) {
return true;
}
if (typeof obj1 === 'object' && typeof obj2 === 'object') {
for (const key of Object.keys(obj1)) {
if (obj2.hasOwnProperty(key)) {
return true;
}
}
}
return false;
}
function isEq(obj) {
return typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean' || obj === null
|| (typeof obj === 'object'
&& (((obj instanceof mongodb_1.ObjectId) ||
(obj instanceof Date) ||
(obj instanceof RegExp))
||
(Object.keys(obj).length === 1 && Object.keys(obj).every(key => key === '$eq'))));
}
/**
* Extracts fields used in the pipeline
* @param pipeline
*/
function getUsedFields(pipeline) {
const usedFields = new Set();
const ignoredFields = new Set();
for (const el of pipeline) {
const stage = Object.keys(el)[0];
switch (stage) {
case '$match':
const extracted = extractRecursively(el.$match);
for (const field of extracted) {
// TODO: doesn't have to be full match - startsWith
if (ignoredFields.has(field)) {
continue;
}
usedFields.add(field);
}
break;
case '$project':
case '$addFields':
case '$set':
case '$group':
for (const [key, value] of Object.entries(el[stage])) {
ignoredFields.add(key);
if (typeof value === 'object' || (typeof value === 'string' && value.startsWith('$'))) {
extractRecursively(value).forEach(key => usedFields.add(key));
}
}
break;
case '$lookup':
ignoredFields.add(el.$lookup.as);
!ignoredFields.has(el.$lookup.localField) && usedFields.add(el.$lookup.localField);
break;
case '$replaceWith':
if (typeof el.$replaceWith === 'object') {
Object.entries(el.$replaceWith).forEach(([key, value]) => ignoredFields.add(key)
&& extractRecursively(value).forEach(key => usedFields.add(key)));
}
break;
case '$unwind': break;
default:
throw new Error(`Unsupported pipeline stage: "${stage}"`);
}
}
return { used: Array.from(usedFields), ignored: Array.from(ignoredFields) };
}
const MATHEMATICAL_OPERATORS = ['$sum', '$subtract', '$multiply', '$divide', '$mod', '$abs', '$ceil', '$floor', '$ln', '$log', '$log10', '$pow', '$sqrt', '$trunc'];
function extractRecursively(obj /* TODO: ignoredFields? */) {
const fields = new Set();
if (!obj) {
return fields;
}
if (typeof obj !== 'object') {
if (typeof obj === 'string' && obj.startsWith('$')) {
fields.add(obj);
}
}
else {
for (const [key, value] of Object.entries(obj)) {
if (key === '$and' || key === '$or') {
for (const item of value) {
extractRecursively(item).forEach(key => fields.add(key));
}
}
else if (key === '$expr') {
extractRecursively(value).forEach(key => fields.add(key));
}
else if (key === '$map' || key === '$filter') {
fields.add(value.input);
}
else if (key === '$mergeObjects') {
for (const item of value) {
if (typeof item === 'string' && item.startsWith('$')) {
extractRecursively(item).forEach(key => fields.add(key));
}
}
}
else if (key === '$arrayElemAt') {
fields.add(value[0]);
}
else if (key === '$function') {
value.args
.filter((arg) => typeof arg === 'string' && arg.startsWith('$'))
.forEach((arg) => fields.add(arg));
}
else if (key === '$switch') {
for (const branch of value.branches) {
extractRecursively(branch.case).forEach(key => fields.add(key));
}
extractRecursively(value.default).forEach(key => fields.add(key));
}
else if (MATHEMATICAL_OPERATORS.includes(key)) {
extractRecursively(value).forEach(key => fields.add(key));
}
else if (['$size'].includes(key)) {
if (typeof value === 'string') {
fields.add(value);
}
else if (value && typeof value === 'object' && Object.keys(value).length === 1 && Object.keys(value)[0].startsWith('$')) {
extractRecursively(value).forEach(key => fields.add(key));
}
}
else if (Array.isArray(value)) {
value.forEach((item) => typeof item === 'string' && item.startsWith('$') && fields.add(item));
}
else if (!key.startsWith('$')) {
fields.add(key);
}
else {
throw new Error(`Unsupported operator: "${key}"`);
}
}
}
const result = new Set();
for (const field of fields) {
result.add(field.startsWith('$') ? field.replace(/^\$/, '') : field);
}
return result;
}
function isPrefixedField(field, prefix) {
return typeof field === 'string' && (field.startsWith(prefix) || field.startsWith('$' + prefix));
}
/**
* Splits filter into stages to be put between unwinds based on the path
* @param filter
* @param paths
* @returns {MongoFilter[]} - array of optimized filters for each stage
*/
function splitFilterToStages(filter, paths) {
const result = [];
const subPaths = getSubPaths(paths);
for (let i = 0; i <= subPaths.length; i++) {
const stage = subPaths.slice(0, i).join('.');
const nextStage = subPaths.slice(0, i + 1).join('.');
result.push(optimizeMatch(subfilter(filter, stage, nextStage, paths)));
}
return result;
}
/**
* Create subPaths for unwinds
* a[].b.c[].d[].e.f => a, b.c, d, e.f
* @param paths
*/
function getSubPaths(paths) {
const subPaths = [];
let current = '';
for (const el of paths) {
if (!el.array) {
current += (current ? '.' : '') + el.path;
}
else {
current += (current ? '.' : '') + el.path;
subPaths.push(current);
current = '';
}
}
if (current !== '') {
subPaths.push(current);
}
return subPaths;
}
/**
* Extracts properties from filter that are relevant for the given stage
* @param filter
* @param stage
* @param nextStage
* @param paths
*/
function subfilter(filter, stage, nextStage, paths) {
const result = {};
if (stage === nextStage) {
return filter;
}
const currentStageIndex = paths.findIndex(s => s.path === stage.split('.').reverse()[0]);
const lastArrayStageIndex = [...paths].reverse().findIndex(s => s.array);
const isAfterLastArrayStage = currentStageIndex >= lastArrayStageIndex;
for (const [key, value] of Object.entries(filter)) {
if (key === '$and') {
for (const andCondition of value) {
const sub = subfilter(andCondition, stage, nextStage, paths);
if (Object.keys(sub).length) {
if (!result.$and) {
result.$and = [];
}
result.$and.push(sub);
}
}
}
else if (key === '$or') {
const tmpFilter = {};
for (const orCondition of value) {
// try to extract, if they're equal, add, otherwise skip
const sub = subfilter(orCondition, stage, nextStage, paths);
if (!tmpFilter.$or) {
tmpFilter.$or = [];
}
tmpFilter.$or.push(sub);
}
if ((0, external_1.objectHash)(tmpFilter.$or) === (0, external_1.objectHash)(value)) {
result.$or = tmpFilter.$or;
}
}
else if (shouldBeAddedToStage(key, value, stage, nextStage)) {
result[key] = value;
}
else if (typeof value === 'object' && Object.keys(value).length === 1) {
const operator = value.$in
? '$in'
: (value.$nin || value.$not?.$in ? '$nin' : undefined);
if (operator && !isAfterLastArrayStage) {
const elemMatch = transformToElemMatch(key, value.$in || value.$nin || value.$not.$in, operator, paths);
if (elemMatch) {
result[elemMatch.key] = elemMatch.value;
}
}
else if (isAfterLastArrayStage) {
result[key] = value;
}
}
}
return result;
}
function transformToElemMatch(key, value, operator, paths) {
const path = splitToSubPaths(key, paths);
return {
key: path.prefix,
value: { $elemMatch: { [path.property]: { [operator]: value } } }
};
}
/**
* Splits given path into prefix that is part of the full model path and the rest. Prefix is the furthest path that is leads to an array, property is the rest.
* @param path - path to split
* @param paths - reference full path
*/
function splitToSubPaths(path, paths) {
const splitPath = path.split('.');
const furthestArrayIndex = [...splitPath].reverse().findIndex(p => paths.find(s => s.path === p && s.array));
if (furthestArrayIndex === -1) {
return { prefix: '', property: path };
}
return {
prefix: splitPath.slice(0, splitPath.length - furthestArrayIndex).join('.'),
property: splitPath.slice(splitPath.length - furthestArrayIndex).join('.')
};
}
const BREAKING_OPERATORS = ['$not', '$ne', '$in', '$nin', '$expr', '$elemMatch', '$function'];
function shouldBeAddedToStage(key, value, stage, nextStage) {
// if key is in previous stages or current stage
if (!key.startsWith('$') && (!key.startsWith(stage) || (key.startsWith(stage) && !key.startsWith(nextStage)))) {
return true;
}
// if key is in next stages, add it has non-breaking operator
if (key.startsWith(nextStage)) {
if (typeof value !== 'object' || (typeof value === 'object' && (value === null || allOperationsAllowed(value)))) {
return true;
}
}
return false;
}
function allOperationsAllowed(obj) {
const operations = getOperations(obj);
return operations.every(operation => !BREAKING_OPERATORS.includes(operation))
&& (obj['$exists'] === undefined || obj['$exists'] === true);
}
/**
* Gets all operations in an object recursively
* @param obj
*/
function getOperations(obj) {
const operations = [];
for (const [key, value] of Object.entries(obj || {})) {
if (key.startsWith('$')) {
operations.push(key);
}
if (typeof value === 'object') {
operations.push(...getOperations(value));
}
}
return operations;
}
const isSet = (value) => value !== undefined && value !== null && (Array.isArray(value) ? value.length > 0 : (typeof value === 'object' ? Object.keys(value).length > 0 : true));
exports.isSet = isSet;
const Arr = (value) => Array.isArray(value) ? value : [value];
exports.Arr = Arr;
/*
LOG( addPrefixToFilter(
{
$and:
[
{ active: true, $root: { active: true }, '$root.events.closed': { $exists: false }, $eq: [ '$events.closed', '$root.events.closed' ]},
{ $or:
[
{ $root: { programmeID: { $gt: 1 }}},
{ $root: { programmeID: { $eq: 1 }}, '$root.events.closed': { $gt: 1 }},
{ $root: { programmeID: { $eq: 1 }}, '$root.events.closed': { $eq: 1 }, 'events.closed': { $gt: 1 }},
{ $root: { programmeID: { $eq: 1 }}, '$root.events.closed': { $eq: 1 }, 'events.closed': { $eq: 1 }, events: { created: { $gt: 1 }}},
{ $root: { programmeID: { $eq: 1 }}, '$root.events.closed': { $eq: 1 }, 'events.closed': { $eq: 1 }, events: { created: { $eq: 1 }}, id: { $gt: 1 }},
]}
]
},
'prefix' ));*/
//console.log( projectionToProject({ test: 1, 'foo.bar': 1 }));
// TODO podpora subobjektu
//console.log( projectionToProject({ testik: 'test', 'foo.bar': 1, 'jobID': '$root._id', zamestnanec: { janko: '$employer' } }));
//console.log( projectionToProject({ testik: 'test', 'foo.bar': 1, 'jobID': '$root._id' }) );
//console.log( addPrefixToFilter( projectionToProject({ testik: 'test', 'foo.bar': 1, 'jobID': '$root._id' }), 'prefixik', false ) )
;