• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const util = require('util')
2const _delete = Symbol('delete')
3const _append = Symbol('append')
4
5const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
6
7// replaces any occurrence of an empty-brackets (e.g: []) with a special
8// Symbol(append) to represent it, this is going to be useful for the setter
9// method that will push values to the end of the array when finding these
10const replaceAppendSymbols = str => {
11  const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/)
12
13  if (matchEmptyBracket) {
14    const [, pre, post] = matchEmptyBracket
15    return [...replaceAppendSymbols(pre), _append, post].filter(Boolean)
16  }
17
18  return [str]
19}
20
21const parseKeys = key => {
22  const sqBracketItems = new Set()
23  sqBracketItems.add(_append)
24  const parseSqBrackets = str => {
25    const index = sqBracketsMatcher(str)
26
27    // once we find square brackets, we recursively parse all these
28    if (index) {
29      const preSqBracketPortion = index[1]
30
31      // we want to have a `new String` wrapper here in order to differentiate
32      // between multiple occurrences of the same string, e.g:
33      // foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } }
34      /* eslint-disable-next-line no-new-wrappers */
35      const foundKey = new String(index[2])
36      const postSqBracketPortion = index[3]
37
38      // we keep track of items found during this step to make sure
39      // we don't try to split-separate keys that were defined within
40      // square brackets, since the key name itself might contain dots
41      sqBracketItems.add(foundKey)
42
43      // returns an array that contains either dot-separate items (that will
44      // be split apart during the next step OR the fully parsed keys
45      // read from square brackets, e.g:
46      // foo.bar[1.0.0].a.b -> ['foo.bar', '1.0.0', 'a.b']
47      return [
48        ...parseSqBrackets(preSqBracketPortion),
49        foundKey,
50        ...(postSqBracketPortion ? parseSqBrackets(postSqBracketPortion) : []),
51      ]
52    }
53
54    // at the end of parsing, any usage of the special empty-bracket syntax
55    // (e.g: foo.array[]) has  not yet been parsed, here we'll take care
56    // of parsing it and adding a special symbol to represent it in
57    // the resulting list of keys
58    return replaceAppendSymbols(str)
59  }
60
61  const res = []
62  // starts by parsing items defined as square brackets, those might be
63  // representing properties that have a dot in the name or just array
64  // indexes, e.g: foo[1.0.0] or list[0]
65  const sqBracketKeys = parseSqBrackets(key.trim())
66
67  for (const k of sqBracketKeys) {
68    // keys parsed from square brackets should just be added to list of
69    // resulting keys as they might have dots as part of the key
70    if (sqBracketItems.has(k)) {
71      res.push(k)
72    } else {
73      // splits the dot-sep property names and add them to the list of keys
74      /* eslint-disable-next-line no-new-wrappers */
75      for (const splitKey of k.split('.')) {
76        res.push(String(splitKey))
77      }
78    }
79  }
80
81  // returns an ordered list of strings in which each entry
82  // represents a key in an object defined by the previous entry
83  return res
84}
85
86const getter = ({ data, key }) => {
87  // keys are a list in which each entry represents the name of
88  // a property that should be walked through the object in order to
89  // return the final found value
90  const keys = parseKeys(key)
91  let _data = data
92  let label = ''
93
94  for (const k of keys) {
95    // empty-bracket-shortcut-syntax is not supported on getter
96    if (k === _append) {
97      throw Object.assign(new Error('Empty brackets are not valid syntax for retrieving values.'), {
98        code: 'EINVALIDSYNTAX',
99      })
100    }
101
102    // extra logic to take into account printing array, along with its
103    // special syntax in which using a dot-sep property name after an
104    // arry will expand it's results, e.g:
105    // arr.name -> arr[0].name=value, arr[1].name=value, ...
106    const maybeIndex = Number(k)
107    if (Array.isArray(_data) && !Number.isInteger(maybeIndex)) {
108      _data = _data.reduce((acc, i, index) => {
109        acc[`${label}[${index}].${k}`] = i[k]
110        return acc
111      }, {})
112      return _data
113    } else {
114      if (!Object.hasOwn(_data, k)) {
115        return undefined
116      }
117      _data = _data[k]
118    }
119
120    label += k
121  }
122
123  // these are some legacy expectations from
124  // the old API consumed by lib/view.js
125  if (Array.isArray(_data) && _data.length <= 1) {
126    _data = _data[0]
127  }
128
129  return {
130    [key]: _data,
131  }
132}
133
134const setter = ({ data, key, value, force }) => {
135  // setter goes to recursively transform the provided data obj,
136  // setting properties from the list of parsed keys, e.g:
137  // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz:  {} } }
138  const keys = parseKeys(key)
139  const setKeys = (_data, _key) => {
140    // handles array indexes, converting valid integers to numbers,
141    // note that occurrences of Symbol(append) will throw,
142    // so we just ignore these for now
143    let maybeIndex = Number.NaN
144    try {
145      maybeIndex = Number(_key)
146    } catch {
147      // leave it NaN
148    }
149    if (!Number.isNaN(maybeIndex)) {
150      _key = maybeIndex
151    }
152
153    // creates new array in case key is an index
154    // and the array obj is not yet defined
155    const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
156    const dataHasNoItems = !Object.keys(_data).length
157    if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data)) {
158      _data = []
159    }
160
161    // converting from array to an object is also possible, in case the
162    // user is using force mode, we should also convert existing arrays
163    // to an empty object if the current _data is an array
164    if (force && Array.isArray(_data) && !keyIsAnArrayIndex) {
165      _data = { ..._data }
166    }
167
168    // the _append key is a special key that is used to represent
169    // the empty-bracket notation, e.g: arr[] -> arr[arr.length]
170    if (_key === _append) {
171      if (!Array.isArray(_data)) {
172        throw Object.assign(new Error(`Can't use append syntax in non-Array element`), {
173          code: 'ENOAPPEND',
174        })
175      }
176      _key = _data.length
177    }
178
179    // retrieves the next data object to recursively iterate on,
180    // throws if trying to override a literal value or add props to an array
181    const next = () => {
182      const haveContents = !force && _data[_key] != null && value !== _delete
183      const shouldNotOverrideLiteralValue = !(typeof _data[_key] === 'object')
184      // if the next obj to recurse is an array and the next key to be
185      // appended to the resulting obj is not an array index, then it
186      // should throw since we can't append arbitrary props to arrays
187      const shouldNotAddPropsToArrays =
188        typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0]))
189
190      const overrideError = haveContents && shouldNotOverrideLiteralValue
191      if (overrideError) {
192        throw Object.assign(
193          new Error(`Property ${_key} already exists and is not an Array or Object.`),
194          { code: 'EOVERRIDEVALUE' }
195        )
196      }
197
198      const addPropsToArrayError = haveContents && shouldNotAddPropsToArrays
199      if (addPropsToArrayError) {
200        throw Object.assign(new Error(`Can't add property ${key} to an Array.`), {
201          code: 'ENOADDPROP',
202        })
203      }
204
205      return typeof _data[_key] === 'object' ? _data[_key] || {} : {}
206    }
207
208    // sets items from the parsed array of keys as objects, recurses to
209    // setKeys in case there are still items to be handled, otherwise it
210    // just sets the original value set by the user
211    if (keys.length) {
212      _data[_key] = setKeys(next(), keys.shift())
213    } else {
214      // handles special deletion cases for obj props / array items
215      if (value === _delete) {
216        if (Array.isArray(_data)) {
217          _data.splice(_key, 1)
218        } else {
219          delete _data[_key]
220        }
221      } else {
222        // finally, sets the value in its right place
223        _data[_key] = value
224      }
225    }
226
227    return _data
228  }
229
230  setKeys(data, keys.shift())
231}
232
233class Queryable {
234  #data = null
235
236  constructor (obj) {
237    if (!obj || typeof obj !== 'object') {
238      throw Object.assign(new Error('Queryable needs an object to query properties from.'), {
239        code: 'ENOQUERYABLEOBJ',
240      })
241    }
242
243    this.#data = obj
244  }
245
246  query (queries) {
247    // this ugly interface here is meant to be a compatibility layer
248    // with the legacy API lib/view.js is consuming, if at some point
249    // we refactor that command then we can revisit making this nicer
250    if (queries === '') {
251      return { '': this.#data }
252    }
253
254    const q = query =>
255      getter({
256        data: this.#data,
257        key: query,
258      })
259
260    if (Array.isArray(queries)) {
261      let res = {}
262      for (const query of queries) {
263        res = { ...res, ...q(query) }
264      }
265      return res
266    } else {
267      return q(queries)
268    }
269  }
270
271  // return the value for a single query if found, otherwise returns undefined
272  get (query) {
273    const obj = this.query(query)
274    if (obj) {
275      return obj[query]
276    }
277  }
278
279  // creates objects along the way for the provided `query` parameter
280  // and assigns `value` to the last property of the query chain
281  set (query, value, { force } = {}) {
282    setter({
283      data: this.#data,
284      key: query,
285      value,
286      force,
287    })
288  }
289
290  // deletes the value of the property found at `query`
291  delete (query) {
292    setter({
293      data: this.#data,
294      key: query,
295      value: _delete,
296    })
297  }
298
299  toJSON () {
300    return this.#data
301  }
302
303  [util.inspect.custom] () {
304    return this.toJSON()
305  }
306}
307
308module.exports = Queryable
309