bot/node_modules/npm/lib/utils/queryable.js

310 lines
9.6 KiB
JavaScript

const util = require('util')
const _data = Symbol('data')
const _delete = Symbol('delete')
const _append = Symbol('append')
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
// replaces any occurence 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 occurences 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 splitted appart 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 occurences of Symbol(append) will throw,
// so we just ignore these for now
let maybeIndex = Number.NaN
try {
maybeIndex = Number(_key)
} catch (err) {}
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 {
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