@usebruno/query
Version:
Bruno query with deep navigation, filter and map support
153 lines (136 loc) • 4.11 kB
text/typescript
/**
* If value is an array returns the deeply flattened array, otherwise value
*/
function normalize(value: any) {
if (!Array.isArray(value)) return value;
const values = [] as any[];
value.forEach((item) => {
const value = normalize(item);
if (value != null) {
values.push(...(Array.isArray(value) ? value : [value]));
}
});
return values.length ? values : undefined;
}
/**
* Gets value of a prop from source.
*
* If source is an array get value from each item.
*
* If deep is true then recursively gets values for prop in nested objects.
*
* Once a value is found will not recurse further into that value.
*/
function getValue(source: any, prop: string, deep = false): any {
if (typeof source !== 'object') return;
let value;
if (Array.isArray(source)) {
value = source.map((item) => getValue(item, prop, deep));
} else {
value = source[prop];
if (deep) {
value = [value];
for (const [key, item] of Object.entries(source)) {
if (key !== prop && typeof item === 'object') {
value.push(getValue(source[key], prop, deep));
}
}
}
}
return normalize(value);
}
type PredicateOrMapper = ((obj: any) => any) | Record<string, any>;
/**
* Make a predicate function that checks scalar properties for equality
*/
function objectPredicate(obj: Record<string, any>) {
return (item: any) => {
for (const [key, value] of Object.entries(obj)) {
if (item[key] !== value) return false;
}
return true;
};
}
/**
* Apply filter on source array or object
*
* If the filter returns a non boolean non null value it is treated as a mapped value
*/
function filterOrMap(source: any, funOrObj: PredicateOrMapper) {
const fun = typeof funOrObj === 'object' ? objectPredicate(funOrObj) : funOrObj;
const isArray = Array.isArray(source);
const list = isArray ? source : [source];
const result = [] as any[];
for (const item of list) {
if (item == null) continue;
const value = fun(item);
if (value === true) {
result.push(item); // predicate
} else if (value != null && value !== false) {
result.push(value); // mapper
}
}
return normalize(isArray ? result : result[0]);
}
/**
* Getter with deep navigation, filter and map support
*
* 1. Easy array navigation
* ```js
* get(data, 'customer.orders.items.amount')
* ```
* 2. Deep navigation .. double dots
* ```js
* get(data, '..items.amount')
* ```
* 3. Array indexing
* ```js
* get(data, '..items[0].amount')
* ```
* 4. Array filtering [?] with corresponding filter function
* ```js
* get(data, '..items[?].amount', i => i.amount > 20)
* ```
* 5. Array filtering [?] with simple object predicate, same as (i => i.id === 2 && i.amount === 20)
* ```js
* get(data, '..items[?]', { id: 2, amount: 20 })
* ```
* 6. Array mapping [?] with corresponding mapper function
* ```js
* get(data, '..items[?].amount', i => i.amount + 10)
* ```
*/
export function get(source: any, path: string, ...fns: PredicateOrMapper[]) {
const paths = path
.replace(/\s+/g, '')
.split(/(\.{1,2}|\[\?\]|\[\d+\])/g) // ["..", "items", "[?]", ".", "amount", "[0]" ]
.filter((s) => s.length > 0)
.map((str) => {
str = str.replace(/\[|\]/g, '');
const index = parseInt(str);
return isNaN(index) ? str : index;
});
let index = 0,
lookbehind = '' as string | number,
funIndex = 0;
while (source != null && index < paths.length) {
const token = paths[index++];
switch (true) {
case token === '..':
case token === '.':
break;
case token === '?':
const fun = fns[funIndex++];
if (fun == null) throw new Error(`missing function for ${lookbehind}`);
source = filterOrMap(source, fun);
break;
case typeof token === 'number':
source = normalize(source[token]);
break;
default:
source = getValue(source, token as string, lookbehind === '..');
}
lookbehind = token;
}
return source;
}