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