hadron-document
Version:
Hadron Document
436 lines • 19.9 kB
JavaScript
"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