projection-utils
Version:
Utilities to work with projections (e.g. mongo)
420 lines (378 loc) • 11.4 kB
JavaScript
function splitDot(path) {
if (!path.length) {
return [];
}
return path.split('.');
}
/**
* A set of fields that are for a projection. This set can be intersected and
* unioned with another set to enable simple implementation of field whitelists
* and permanent fields.
*/
class ProjectionFieldSet {
/**
* Create a field set from an iterable containing array paths.
*
* @param {Iterable<String[]>=} fields The fields to initially include. Widens
* to a broader set if there are conflicts (e.g. if both ['user'] and
* ['user', 'email'] are specified, we include all of ['user']).
*/
constructor(fields = []) {
if (!fields || typeof fields !== 'object' || !(Symbol.iterator in fields)) {
throw new TypeError('expected iterable of fields');
}
this._root = new Map();
for (const path of fields || []) {
if (!Array.isArray(path)) {
throw new TypeError('expected iterable of field arrays');
}
if (!path.length) {
this._root = true;
break;
}
widen(this._root, path);
}
}
/**
* Create a ProjectionFieldSet from an iterable containing dot-separated
* paths.
*
* @param {Iterable<String>=} dotFields The iterable of dot-separated path
* strings.
* @returns {ProjectionFieldSet} The new projection field set.
*/
static fromDotted(dotFields = []) {
if (!dotFields || typeof dotFields !== 'object' || !(Symbol.iterator in dotFields)) {
throw new TypeError('expected iterable of fields');
}
return new ProjectionFieldSet(toPathIterable(dotFields));
}
/**
* Check whether the projection is empty.
*
* @returns {Boolean} Whether the projection is empty.
*/
isEmpty() {
return this._root !== true && !this._root.size;
}
/**
* In-place widen with the given path - similar semantics to
* ProjectionFieldSet#union except it modifies the instance instead of
* creating a new one.
*
* @param {String[]} path The path to widen to.
* @throws {TypeError} If the path is not an array of strings.
*/
widen(path) {
if (!Array.isArray(path)) {
throw new TypeError('expected field array');
}
if (!path.length) {
this._root = true;
return;
}
if (this._root === true) {
return;
}
widen(this._root, path);
}
/**
* Enumerate all projection fields as arrays.
*
* @returns {Iterator<String[]>} The iterator that produces path entries.
*/
entries() {
return iterEntries(this._root, []);
}
/**
* Check whether this field set includes the given path.
*
* @param {String[]} path The path to check.
* @returns {Boolean} Whether the path is in the field set. If you ask for
* ['users', 'id'] and ['users'] is permitted, then we also return true -
* we check whether the field would be included in the final projection.
*/
contains(path) {
if (!Array.isArray(path)) {
throw new TypeError('expected field array');
}
return findNode(path, this._root) === true;
}
/**
* Check whether we contains the given dotted field.
*
* @param {String} path The dot-separated path to check.
* @returns {Boolean} Whether the path is contained in the field set.
*/
containsDotted(path) {
return findNode(splitDot(path), this._root) === true;
}
/**
* Get the entries under the given path.
*
* @param {String[]} path The path to enumerate.
* @param {Boolean} includePrefix Whether to include the given path as a
* prefix to the output entries.
* @returns {Iterator<String[]>} The fields under the given path.
*/
*get(path, includePrefix = true) {
if (!Array.isArray(path)) {
throw new TypeError('expected field array');
}
const node = findNode(path, this._root);
if (node) {
yield* iterEntries(node, includePrefix ? [...path] : []);
}
}
/**
* Get the entries under the given dot-separated path. If we return an
* iterator that contains an empty string as its only element, then we contain
* the given path exactly.
*
* @param {String} path The path to enumerate.
* @param {Boolean} includePrefix Whether to include the given path as a
* prefix to the output entries.
* @returns {Iterator<String>} The dot-separated fields under the given path.
*/
*getDotted(path, includePrefix = true) {
const splitPath = splitDot(path), node = findNode(splitPath, this._root);
if (node) {
yield* toDottedIterable(iterEntries(node, includePrefix ? splitPath : []));
}
}
/**
* Find the intersection of the two projection field sets. If one field set
* includes all of the ['user'] path, and the other includes ['user.id',
* 'user.email'], then the resulting field set will include the only
* ['user.id', 'user.email'] from the ['user'] path.
*
* @param {ProjectionFieldSet} other The other set to intersect.
* @returns {ProjectionFieldSet}
*/
intersect(other) {
const fields = new ProjectionFieldSet();
fields._root = intersection(this._root, other._root);
return fields;
}
/**
* Find the union of the two projection field sets. If one field set includes
* all of the ['user'] path, then regardless of how specific the other field
* set is, we will include all of ['user'] or more.
*
* @param {ProjectionFieldSet} other The other set to union.
* @returns {ProjectionFieldSet}
*/
union(other) {
const fields = new ProjectionFieldSet();
fields._root = union(this._root, other._root);
return fields;
}
/**
* Produce dotted paths.
*
* @returns {Iterator<String>} The dot-separated paths represented by the
* projection field set.
*/
toDotted() {
return toDottedIterable(this);
}
/**
* Produce a mongo representation of the projection field set, with the
* optionally provided mode. Use the mode to force blacklist behavior instead
* of whitelist.
*
* @param {*} mode The mode - this will be the value of all keys in the
* the field set.
* @returns {Object<String, *>} The mongo projection, defaulting to whitelist.
*/
toMongo(mode = 1) {
const mongoProjection = {};
// We specify everything.
if (this._root !== true) {
for (const path of this.toDotted()) {
mongoProjection[path] = mode;
}
}
return mongoProjection;
}
}
// Make ProjectionFieldSet an iterator.
ProjectionFieldSet.prototype[Symbol.iterator] = ProjectionFieldSet.prototype.entries;
/**
* Iterate over the entries of the node and all its decendants.
*
* @param {Map|Boolean} node The "node" to iterate through.
* @param {String[]} prefix The iterator prefix - this is the path to the
* current subtree, which we prepend to all the paths we produce. This array
* may be mutated during normal operation, but will be returned to its initial
* state when iterEntries returns.
* @returns {Iterator<String[]>} The paths that are encapsulated within the
* node's subtree.
*/
function *iterEntries(node, prefix) {
if (node === true) {
yield [...prefix];
} else {
for (const [suffix, child] of node) {
prefix.push(suffix);
yield* iterEntries(child, prefix);
prefix.pop();
}
}
}
/**
* Widen the given node to include the given path. Modifies the rootNode to
* accomplish this. The rootNode must be a Map, and the path must have at least
* one element.
*
* @param {Map} rootNode The root node of a ProjectionFieldSet.
* @param {String[]} path The path to widen to include.
*/
function widen(rootNode, path) {
let node = rootNode;
for (let index = 0; index < path.length; ++index) {
const key = path[index];
if (index === path.length - 1) {
node.set(key, true);
break;
}
const child = node.get(key);
if (child instanceof Map) {
node = child;
} else if (child) {
break;
} else {
const newChild = new Map();
node.set(key, newChild);
node = newChild;
}
}
}
/**
* Perform a deep copy of the tree of Maps and trues.
*
* @param {Map|Boolean} value The value to copy.
* @returns {Map|Boolean} The copied value.
*/
function copy(value) {
if (value === true) {
return true;
}
const newValue = new Map();
for (const [key, item] of value) {
newValue.set(key, copy(item));
}
return newValue;
}
/**
* Intersect the keys from the two given maps. The two parameters should be Maps
* or any object that implements keys() and has() per the Map spec.
*
* @param {Map} a
* @param {Map} b
* @returns {Iterator} The keys common between the two maps.
*/
function* intersectKeys(a, b) {
for (const key of a.keys()) {
if (b.has(key)) {
yield key;
}
}
}
/**
* Recursively union the two subtrees. The nodes are either true, representing
* any value, or a Map, which specifies the values that are permitted.
*
* @param {Map|Boolean} a The left value to intersect.
* @param {Map|Boolean} b The right value to intersect.
* @returns {Boolean|Map} The unioned value.
*/
function union(a, b) {
if (!a) {
return copy(b);
}
if (!b) {
return copy(a);
}
if (a === true || b === true) {
return true;
}
const allKeys = new Set([...a.keys(), ...b.keys()]);
const newValue = new Map();
for (const key of allKeys) {
newValue.set(key, union(a.get(key), b.get(key)));
}
return newValue;
}
/**
* Find the intersection of two nodes. The nodes are either true, representing
* any value, or a Map, which specifies the values that are permitted.
*
* @param {Boolean|Map} a The left value to intersect.
* @param {Boolean|Map} b The right value to intersect.
* @returns {Boolean|Map} The intersected value.
*/
function intersection(a, b) {
if (a === true) {
return copy(b);
}
if (b === true) {
return copy(a);
}
const newValue = new Map();
for (const key of intersectKeys(a, b)) {
newValue.set(key, intersection(a.get(key), b.get(key)));
}
return newValue;
}
/**
* Get the node at the given path, or true if the path is contained in the node
* entirely.
*
* @param {String[]} path The path items.
* @param {Boolean|Map} node The node to traverse.
* @returns {?Boolean|Map} The node identified by the path, or null if it's not
* included.
*/
function findNode(path, node) {
for (const part of path) {
if (node === true) {
return true;
}
if (!node) {
return null;
}
node = node.get(part);
}
return node;
}
/**
* For each string in the given iterable, produce that string split on its dots.
*
* @param {Iterable<String>} iterable The iterable to path.
* @returns {Iterable<String[]>} The paths, split.
* @throws {TypeError} If any of the items in the iterable are not strings, we
* throw this exception.
*/
function* toPathIterable(iterable) {
for (const dotField of iterable) {
if (typeof dotField !== 'string') {
throw new TypeError('expected iterable of dot-separated string fields');
}
yield dotField.split('.');
}
}
/**
* Convert an iterable of string arrays to dot-separated strings.
*
* @param {Iterable<String[]>} iterable The input iterable.
* @returns {Iterable<String>} The dot-separated iterable.
*/
function* toDottedIterable(iterable) {
for (const field of iterable) {
yield field.join('.');
}
}
module.exports = {
ProjectionFieldSet,
};