UNPKG

hadron-document

Version:
436 lines • 19.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ObjectGenerator = void 0; const lodash_1 = require("lodash"); const DECRYPTED_KEYS = Symbol.for('@@mdb.decryptedKeys'); function maybeDecorateWithDecryptedKeys(object, element) { if (element.isValueDecrypted()) { if (!object[DECRYPTED_KEYS]) { // non-enumerable object[DECRYPTED_KEYS] = [] Object.defineProperty(object, DECRYPTED_KEYS, { value: [], writable: true, configurable: true, enumerable: false, }); } object[DECRYPTED_KEYS].push(String(element.currentKey)); } } /** Used to represent missing values, i.e. non-existent fields. */ const DoesNotExist = Symbol('DoesNotExist'); /** * Generates javascript objects from elements. */ class ObjectGenerator { /** * Generate a javascript object from the elements. * * @param {Array} elements - The elements. * * @returns {Object} The javascript object. */ static generate(elements, options = {}) { if (elements) { const object = Object.create(null); for (const element of elements) { if (options.excludeInternalFields && element.isInternalField()) { continue; } if (!element.isRemoved() && element.currentKey !== '') { object[element.currentKey] = element.generateObject(options); maybeDecorateWithDecryptedKeys(object, element); } } return object; } return elements; } /** * Generate a javascript object from the elements with their original keys * and values. This can be used in a query with an update to * ensure the values on the document to edit are still up to date. * * @param {Array} elements - The elements. * * @returns {Object} The javascript object. */ static generateOriginal(elements, options = {}) { if (elements) { const object = Object.create(null); for (const element of elements) { if (options.excludeInternalFields && element.isInternalField()) { continue; } if (!element.isAdded()) { object[element.key] = element.generateOriginalObject(options); maybeDecorateWithDecryptedKeys(object, element); } } return object; } return elements; } /** * Generate an array from the elements. * * @param {Array} elements - The elements. * * @returns {Array} The array. */ static generateArray(elements, options = {}) { if (elements) { const array = []; for (const element of elements) { if (options.excludeInternalFields && element.isInternalField()) { continue; } if (!element.isRemoved()) { if (element.elements) { array.push(element.generateObject(options)); } else { array.push(element.currentValue); } maybeDecorateWithDecryptedKeys(array, element); } } return array; } return elements; } /** * Generate an array from the elements using their original values. * * @param {Array} elements - The elements. * * @returns {Array} The array. */ static generateOriginalArray(elements, options = {}) { if (elements) { const array = []; for (const element of elements) { if (options.excludeInternalFields && element.isInternalField()) { continue; } if (element.originalExpandableValue) { array.push(element.generateOriginalObject(options)); } else if (!element.isAdded()) { array.push(element.value); } maybeDecorateWithDecryptedKeys(array, element); } return array; } return elements; } /** * As the first step in generating query and update documents for updated * fields in a document, gather the original and new paths and values * for those updated fields. * * @param target The target document, or, when recursing, element. * @param keyInclusionOptions Specify which fields to include in the * originalFields list. * @param includeUpdatedFields Whether to include original and new values * of updated fields. If set to `false`, only fields included in * @see alwaysIncludeOriginalKeys are included. * @returns A pair `{ originalFields, newFields }`, each listing the * original and new paths and values for updated fields, respectively. */ static recursivelyGatherFieldsAndValuesForUpdate(target, keyInclusionOptions, includeUpdatedFields) { const originalFields = []; const newFields = []; const alwaysIncludeKeys = keyInclusionOptions.alwaysIncludeKeys ?? []; const includableEncryptedKeys = keyInclusionOptions.includableEncryptedKeys ?? []; for (const element of target.elements ?? []) { const isArrayIndex = target.currentType === 'Array'; // Do not include encrypted fields in the `originalFields` list // unless we know that it is okay to include them (i.e. because // we know that we can perform equality queries on those fields). const canIncludeOriginalValue = !element.isValueDecrypted() || includableEncryptedKeys.some((key) => key.length === 1 && key[0] === String(element.key)); // Recurse into an element if it either has been updated and we are looking // for updated fields, or it is part of the set of keys that we should always // include. if ((includeUpdatedFields && element.isModified() && !element.isAdded() && !element.hasChangedKey()) || alwaysIncludeKeys.some((key) => key[0] === String(element.key))) { // Two possible cases: Either we recurse into this element and change // nested values, or we replace the element entirely. // We can only recurse if: // - This is a nested element with children, i.e. array or document // - It was not explicitly requested via alwaysIncludeKeys to // always include it in its entirety // - Its type has not changed // - It is not an array with removed elements, since MongoDB has // no way to remove individual array elements (!!) prior to // agg-pipeline-style updates added in 4.2, and even then it's complex // to actually do so if (element.elements && !alwaysIncludeKeys.some((key) => key.length === 1 && key[0] === String(element.key)) && ((element.type === 'Object' && element.currentType === 'Object') || (element.type === 'Array' && element.currentType === 'Array' && !element.hasAnyRemovedChild()))) { // Nested case: Translate keyInclusionOptions to the nested keys, // get the original keys and values for the nested element, // then translate the result back to this level. const filterAndShiftFieldPaths = (paths) => paths .filter((key) => key[0] === String(element.key)) .map((key) => key.slice(1)) .filter((key) => key.length > 0); const nestedKeyInclusionOptions = { alwaysIncludeKeys: filterAndShiftFieldPaths(alwaysIncludeKeys), includableEncryptedKeys: filterAndShiftFieldPaths(includableEncryptedKeys), }; const nestedResult = ObjectGenerator.recursivelyGatherFieldsAndValuesForUpdate(element, nestedKeyInclusionOptions, includeUpdatedFields); for (const { path, value } of nestedResult.originalFields) { originalFields.push({ path: [{ key: String(element.key), isArrayIndex }, ...path], value, }); } for (const { path, value } of nestedResult.newFields) { newFields.push({ path: [ { key: String(element.currentKey), isArrayIndex }, ...path, ], value, }); } } else { // Using `.key` instead of `.currentKey` to ensure we look at // the original field's value. if (canIncludeOriginalValue) { originalFields.push({ path: [{ key: String(element.key), isArrayIndex }], value: element.generateOriginalObject(), }); } if (includeUpdatedFields && element.currentKey !== '' && !element.isRemoved()) { newFields.push({ path: [{ key: String(element.currentKey), isArrayIndex }], value: element.generateObject(), }); } } } if (includeUpdatedFields && !element.isRemoved() && (element.isAdded() || element.hasChangedKey()) && element.currentKey !== '') { // When a new field is added, check if the original value of that // field (which is typically that it was missing) was changed in // the background. If there *was* another field in its place, // that means that it was removed, and is added to `originalValue` // at another point. let wasRenamedToKeyOfPreviouslyExistingElement = false; for (const otherElement of target.elements ?? []) { if (otherElement !== element && otherElement.key === element.currentKey) { wasRenamedToKeyOfPreviouslyExistingElement = true; break; } } if (!wasRenamedToKeyOfPreviouslyExistingElement) { originalFields.push({ path: [{ key: String(element.currentKey), isArrayIndex }], value: DoesNotExist, }); } newFields.push({ path: [{ key: String(element.currentKey), isArrayIndex }], value: element.generateObject(), }); } if (includeUpdatedFields && !element.isAdded() && (element.isRemoved() || element.hasChangedKey()) && element.key !== '') { // Remove the original field when an element is removed or renamed. if (canIncludeOriginalValue) { originalFields.push({ path: [{ key: String(element.key), isArrayIndex }], value: element.generateOriginalObject(), }); } let wasRemovedAndLaterReplacedByNewElement = false; for (const otherElement of target.elements ?? []) { if (otherElement !== element && otherElement.currentKey === element.key) { wasRemovedAndLaterReplacedByNewElement = true; break; } } if (!wasRemovedAndLaterReplacedByNewElement) { newFields.push({ path: [{ key: String(element.key), isArrayIndex }], value: DoesNotExist, }); } } } // Sometimes elements are removed or renamed, and then another // element is added or renamed to take its place. We filter out // the DoesNotExist entry for that case. for (let i = 0; i < newFields.length;) { const entry = newFields[i]; if (entry.value === DoesNotExist) { if (newFields.some((otherEntry) => (0, lodash_1.isEqual)(otherEntry.path, entry.path) && entry !== otherEntry)) { // Drop `entry`. newFields.splice(i, 1); continue; } } i++; } return { originalFields, newFields }; } // Return a $getField expression that evaluates to the current value // of the document at `path`. static createGetFieldExpr(path) { return path.reduce((input, { key, isArrayIndex }) => isArrayIndex ? { $arrayElemAt: [input, +key], } : { $getField: { field: { $literal: key }, input, }, }, '$$ROOT'); } // Return a $setField expression that writes the specified value // to the document at `path`. static createSetFieldExpr(path, value) { return path.reduceRight((value, { key, isArrayIndex }, idx, array) => { const input = ObjectGenerator.createGetFieldExpr(array.slice(0, idx)); if (!isArrayIndex) { // 'Simple' case: Change a property of a document return { $setField: { field: { $literal: key }, input, value, }, }; } // Array case: concatenate the prefix of the array before the changed // index, an array containing the new value at the changed index, // and the suffix afterwards; use $let to avoid specifying the full // input value expression multiple times. return { $let: { vars: { input }, in: { $concatArrays: [ // The third argument to $slice must not be 0 ...(+key > 0 ? [{ $slice: ['$$input', 0, +key] }] : []), [value], // The third argument is required; 2^31-1 is the maximum // accepted value, and well beyond what BSON can represent. { $slice: ['$$input', +key + 1, 2 ** 31 - 1] }, ], }, }, }; }, (value === DoesNotExist ? '$$REMOVE' : { $literal: value })); } /** * Generate the query javascript object reflecting original * values of specific elements in this documents. This can include * elements that were updated in this document. In that case, the * values of this object are the original values, this can be used * when querying for an update to see if the original document was * changed in the background while it was being updated elsewhere. * * NOTE: `alwaysIncludeKeys` is currently used for sharding, since * updates on sharded setups need to include the shard key in their * find part. https://jira.mongodb.org/browse/SPM-1632 will make * this requirement go away for future MongoDB versions! * * @param target The target (sub-)document. * @param keyInclusionOptions Specify which fields to include in the * originalFields list. * @param includeUpdatedFields Whether to include the original values for * updated fields. * * @returns A pair of lists, one containing the original values for updated fields * or those specified in the always-include list, and one containing new values * of the updated fields. If includeUpdatedFields is not set, the second * list will be empty. */ static getQueryForOriginalKeysAndValuesForSpecifiedFields(target, keyInclusionOptions, includeUpdatedFields) { const { originalFields } = ObjectGenerator.recursivelyGatherFieldsAndValuesForUpdate(target, keyInclusionOptions, includeUpdatedFields); const query = {}; if (originalFields.some(({ path }) => path.some(({ key }) => key.includes('.') || key.startsWith('$')))) { // Some of the keys in this query are only accesible via $getField, // which was introduced in MongoDB 5.0. const equalityMatches = []; for (const { path, value } of originalFields) { const getFieldExpr = ObjectGenerator.createGetFieldExpr(path); equalityMatches.push(value !== DoesNotExist ? { $eq: [getFieldExpr, { $literal: value }] } : { $eq: [{ $type: getFieldExpr }, 'missing'] }); } if (equalityMatches.length === 1) { query.$expr = equalityMatches[0]; } else if (equalityMatches.length > 1) { query.$expr = { $and: equalityMatches }; } } else { for (const { path, value } of originalFields) { const matchValue = value === DoesNotExist ? { $exists: false } : value; query[path.map(({ key }) => key).join('.')] = matchValue; } } return query; } /** * Generate an update document or pipeline which reflects the updates * that have taken place for this document. A pipeline will be returned * if the updates require changes to fields containing dots or prefixed * with $. * * @param target The target (sub-)document. */ static generateUpdateDoc(target) { const { newFields } = ObjectGenerator.recursivelyGatherFieldsAndValuesForUpdate(target, {}, true); if (newFields.some(({ path }) => path.some(({ key }) => key.includes('.') || key.startsWith('$')))) { // Some of the keys in this query are only writable via $setField/$unsetField, // which was introduced in MongoDB 5.0. In this case we can use pipeline-style updates. return newFields.map(({ path, value }) => { return { $replaceWith: ObjectGenerator.createSetFieldExpr(path, value), }; }); } else { const updateDoc = {}; for (const { path, value } of newFields) { if (value === DoesNotExist) { updateDoc.$unset ??= {}; updateDoc.$unset[path.map(({ key }) => key).join('.')] = true; } else { updateDoc.$set ??= {}; updateDoc.$set[path.map(({ key }) => key).join('.')] = value; } } return updateDoc; } } } exports.ObjectGenerator = ObjectGenerator; exports.default = ObjectGenerator; //# sourceMappingURL=object-generator.js.map