const util = require('util') const _delete = Symbol('delete') const _append = Symbol('append') const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/) // replaces any occurrence of an empty-brackets (e.g: []) with a special // Symbol(append) to represent it, this is going to be useful for the setter // method that will push values to the end of the array when finding these const replaceAppendSymbols = str => { const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/) if (matchEmptyBracket) { const [, pre, post] = matchEmptyBracket return [...replaceAppendSymbols(pre), _append, post].filter(Boolean) } return [str] } const parseKeys = key => { const sqBracketItems = new Set() sqBracketItems.add(_append) const parseSqBrackets = str => { const index = sqBracketsMatcher(str) // once we find square brackets, we recursively parse all these if (index) { const preSqBracketPortion = index[1] // we want to have a `new String` wrapper here in order to differentiate // between multiple occurrences of the same string, e.g: // foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } } /* eslint-disable-next-line no-new-wrappers */ const foundKey = new String(index[2]) const postSqBracketPortion = index[3] // we keep track of items found during this step to make sure // we don't try to split-separate keys that were defined within // square brackets, since the key name itself might contain dots sqBracketItems.add(foundKey) // returns an array that contains either dot-separate items (that will // be split apart during the next step OR the fully parsed keys // read from square brackets, e.g: // foo.bar[1.0.0].a.b -> ['foo.bar', '1.0.0', 'a.b'] return [ ...parseSqBrackets(preSqBracketPortion), foundKey, ...(postSqBracketPortion ? parseSqBrackets(postSqBracketPortion) : []), ] } // at the end of parsing, any usage of the special empty-bracket syntax // (e.g: foo.array[]) has not yet been parsed, here we'll take care // of parsing it and adding a special symbol to represent it in // the resulting list of keys return replaceAppendSymbols(str) } const res = [] // starts by parsing items defined as square brackets, those might be // representing properties that have a dot in the name or just array // indexes, e.g: foo[1.0.0] or list[0] const sqBracketKeys = parseSqBrackets(key.trim()) for (const k of sqBracketKeys) { // keys parsed from square brackets should just be added to list of // resulting keys as they might have dots as part of the key if (sqBracketItems.has(k)) { res.push(k) } else { // splits the dot-sep property names and add them to the list of keys /* eslint-disable-next-line no-new-wrappers */ for (const splitKey of k.split('.')) { res.push(String(splitKey)) } } } // returns an ordered list of strings in which each entry // represents a key in an object defined by the previous entry return res } const getter = ({ data, key }) => { // keys are a list in which each entry represents the name of // a property that should be walked through the object in order to // return the final found value const keys = parseKeys(key) let _data = data let label = '' for (const k of keys) { // empty-bracket-shortcut-syntax is not supported on getter if (k === _append) { throw Object.assign(new Error('Empty brackets are not valid syntax for retrieving values.'), { code: 'EINVALIDSYNTAX', }) } // extra logic to take into account printing array, along with its // special syntax in which using a dot-sep property name after an // arry will expand it's results, e.g: // arr.name -> arr[0].name=value, arr[1].name=value, ... const maybeIndex = Number(k) if (Array.isArray(_data) && !Number.isInteger(maybeIndex)) { _data = _data.reduce((acc, i, index) => { acc[`${label}[${index}].${k}`] = i[k] return acc }, {}) return _data } else { // if can't find any more values, it means it's just over // and there's nothing to return if (!_data[k]) { return undefined } // otherwise sets the next value _data = _data[k] } label += k } // these are some legacy expectations from // the old API consumed by lib/view.js if (Array.isArray(_data) && _data.length <= 1) { _data = _data[0] } return { [key]: _data, } } const setter = ({ data, key, value, force }) => { // setter goes to recursively transform the provided data obj, // setting properties from the list of parsed keys, e.g: // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } } const keys = parseKeys(key) const setKeys = (_data, _key) => { // handles array indexes, converting valid integers to numbers, // note that occurrences of Symbol(append) will throw, // so we just ignore these for now let maybeIndex = Number.NaN try { maybeIndex = Number(_key) } catch { // leave it NaN } if (!Number.isNaN(maybeIndex)) { _key = maybeIndex } // creates new array in case key is an index // and the array obj is not yet defined const keyIsAnArrayIndex = _key === maybeIndex || _key === _append const dataHasNoItems = !Object.keys(_data).length if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data)) { _data = [] } // converting from array to an object is also possible, in case the // user is using force mode, we should also convert existing arrays // to an empty object if the current _data is an array if (force && Array.isArray(_data) && !keyIsAnArrayIndex) { _data = { ..._data } } // the _append key is a special key that is used to represent // the empty-bracket notation, e.g: arr[] -> arr[arr.length] if (_key === _append) { if (!Array.isArray(_data)) { throw Object.assign(new Error(`Can't use append syntax in non-Array element`), { code: 'ENOAPPEND', }) } _key = _data.length } // retrieves the next data object to recursively iterate on, // throws if trying to override a literal value or add props to an array const next = () => { const haveContents = !force && _data[_key] != null && value !== _delete const shouldNotOverrideLiteralValue = !(typeof _data[_key] === 'object') // if the next obj to recurse is an array and the next key to be // appended to the resulting obj is not an array index, then it // should throw since we can't append arbitrary props to arrays const shouldNotAddPropsToArrays = typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0])) const overrideError = haveContents && shouldNotOverrideLiteralValue if (overrideError) { throw Object.assign( new Error(`Property ${_key} already exists and is not an Array or Object.`), { code: 'EOVERRIDEVALUE' } ) } const addPropsToArrayError = haveContents && shouldNotAddPropsToArrays if (addPropsToArrayError) { throw Object.assign(new Error(`Can't add property ${key} to an Array.`), { code: 'ENOADDPROP', }) } return typeof _data[_key] === 'object' ? _data[_key] || {} : {} } // sets items from the parsed array of keys as objects, recurses to // setKeys in case there are still items to be handled, otherwise it // just sets the original value set by the user if (keys.length) { _data[_key] = setKeys(next(), keys.shift()) } else { // handles special deletion cases for obj props / array items if (value === _delete) { if (Array.isArray(_data)) { _data.splice(_key, 1) } else { delete _data[_key] } } else { // finally, sets the value in its right place _data[_key] = value } } return _data } setKeys(data, keys.shift()) } class Queryable { #data = null constructor (obj) { if (!obj || typeof obj !== 'object') { throw Object.assign(new Error('Queryable needs an object to query properties from.'), { code: 'ENOQUERYABLEOBJ', }) } this.#data = obj } query (queries) { // this ugly interface here is meant to be a compatibility layer // with the legacy API lib/view.js is consuming, if at some point // we refactor that command then we can revisit making this nicer if (queries === '') { return { '': this.#data } } const q = query => getter({ data: this.#data, key: query, }) if (Array.isArray(queries)) { let res = {} for (const query of queries) { res = { ...res, ...q(query) } } return res } else { return q(queries) } } // return the value for a single query if found, otherwise returns undefined get (query) { const obj = this.query(query) if (obj) { return obj[query] } } // creates objects along the way for the provided `query` parameter // and assigns `value` to the last property of the query chain set (query, value, { force } = {}) { setter({ data: this.#data, key: query, value, force, }) } // deletes the value of the property found at `query` delete (query) { setter({ data: this.#data, key: query, value: _delete, }) } toJSON () { return this.#data } [util.inspect.custom] () { return this.toJSON() } } module.exports = Queryable