queryable.js 9.6 KB

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