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