• 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 can't find any more values, it means it's just over
115      // and there's nothing to return
116      if (!_data[k]) {
117        return undefined
118      }
119
120      // otherwise sets the next value
121      _data = _data[k]
122    }
123
124    label += k
125  }
126
127  // these are some legacy expectations from
128  // the old API consumed by lib/view.js
129  if (Array.isArray(_data) && _data.length <= 1) {
130    _data = _data[0]
131  }
132
133  return {
134    [key]: _data,
135  }
136}
137
138const setter = ({ data, key, value, force }) => {
139  // setter goes to recursively transform the provided data obj,
140  // setting properties from the list of parsed keys, e.g:
141  // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz:  {} } }
142  const keys = parseKeys(key)
143  const setKeys = (_data, _key) => {
144    // handles array indexes, converting valid integers to numbers,
145    // note that occurrences of Symbol(append) will throw,
146    // so we just ignore these for now
147    let maybeIndex = Number.NaN
148    try {
149      maybeIndex = Number(_key)
150    } catch {
151      // leave it NaN
152    }
153    if (!Number.isNaN(maybeIndex)) {
154      _key = maybeIndex
155    }
156
157    // creates new array in case key is an index
158    // and the array obj is not yet defined
159    const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
160    const dataHasNoItems = !Object.keys(_data).length
161    if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data)) {
162      _data = []
163    }
164
165    // converting from array to an object is also possible, in case the
166    // user is using force mode, we should also convert existing arrays
167    // to an empty object if the current _data is an array
168    if (force && Array.isArray(_data) && !keyIsAnArrayIndex) {
169      _data = { ..._data }
170    }
171
172    // the _append key is a special key that is used to represent
173    // the empty-bracket notation, e.g: arr[] -> arr[arr.length]
174    if (_key === _append) {
175      if (!Array.isArray(_data)) {
176        throw Object.assign(new Error(`Can't use append syntax in non-Array element`), {
177          code: 'ENOAPPEND',
178        })
179      }
180      _key = _data.length
181    }
182
183    // retrieves the next data object to recursively iterate on,
184    // throws if trying to override a literal value or add props to an array
185    const next = () => {
186      const haveContents = !force && _data[_key] != null && value !== _delete
187      const shouldNotOverrideLiteralValue = !(typeof _data[_key] === 'object')
188      // if the next obj to recurse is an array and the next key to be
189      // appended to the resulting obj is not an array index, then it
190      // should throw since we can't append arbitrary props to arrays
191      const shouldNotAddPropsToArrays =
192        typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0]))
193
194      const overrideError = haveContents && shouldNotOverrideLiteralValue
195      if (overrideError) {
196        throw Object.assign(
197          new Error(`Property ${_key} already exists and is not an Array or Object.`),
198          { code: 'EOVERRIDEVALUE' }
199        )
200      }
201
202      const addPropsToArrayError = haveContents && shouldNotAddPropsToArrays
203      if (addPropsToArrayError) {
204        throw Object.assign(new Error(`Can't add property ${key} to an Array.`), {
205          code: 'ENOADDPROP',
206        })
207      }
208
209      return typeof _data[_key] === 'object' ? _data[_key] || {} : {}
210    }
211
212    // sets items from the parsed array of keys as objects, recurses to
213    // setKeys in case there are still items to be handled, otherwise it
214    // just sets the original value set by the user
215    if (keys.length) {
216      _data[_key] = setKeys(next(), keys.shift())
217    } else {
218      // handles special deletion cases for obj props / array items
219      if (value === _delete) {
220        if (Array.isArray(_data)) {
221          _data.splice(_key, 1)
222        } else {
223          delete _data[_key]
224        }
225      } else {
226        // finally, sets the value in its right place
227        _data[_key] = value
228      }
229    }
230
231    return _data
232  }
233
234  setKeys(data, keys.shift())
235}
236
237class Queryable {
238  #data = null
239
240  constructor (obj) {
241    if (!obj || typeof obj !== 'object') {
242      throw Object.assign(new Error('Queryable needs an object to query properties from.'), {
243        code: 'ENOQUERYABLEOBJ',
244      })
245    }
246
247    this.#data = obj
248  }
249
250  query (queries) {
251    // this ugly interface here is meant to be a compatibility layer
252    // with the legacy API lib/view.js is consuming, if at some point
253    // we refactor that command then we can revisit making this nicer
254    if (queries === '') {
255      return { '': this.#data }
256    }
257
258    const q = query =>
259      getter({
260        data: this.#data,
261        key: query,
262      })
263
264    if (Array.isArray(queries)) {
265      let res = {}
266      for (const query of queries) {
267        res = { ...res, ...q(query) }
268      }
269      return res
270    } else {
271      return q(queries)
272    }
273  }
274
275  // return the value for a single query if found, otherwise returns undefined
276  get (query) {
277    const obj = this.query(query)
278    if (obj) {
279      return obj[query]
280    }
281  }
282
283  // creates objects along the way for the provided `query` parameter
284  // and assigns `value` to the last property of the query chain
285  set (query, value, { force } = {}) {
286    setter({
287      data: this.#data,
288      key: query,
289      value,
290      force,
291    })
292  }
293
294  // deletes the value of the property found at `query`
295  delete (query) {
296    setter({
297      data: this.#data,
298      key: query,
299      value: _delete,
300    })
301  }
302
303  toJSON () {
304    return this.#data
305  }
306
307  [util.inspect.custom] () {
308    return this.toJSON()
309  }
310}
311
312module.exports = Queryable
313