UNPKG

@muze-nl/jaqt

Version:

Javascript Queries and Transformations, allows GraphQL-like functionality on Javascript arrays of objects.

822 lines (789 loc) 25.2 kB
/** * checks if data is a wrapper object for one of the primitive types * * @param {mixed} data The data to check * @return {Boolean} True if data is a primitive wrapper object */ function isPrimitiveWrapper(data) { return [String, Boolean, Number, BigInt].includes(data?.constructor) } /** * Selects only one of the matching values. You can specify whether to return * the 'last' value, or the 'first'. Or you can pass a function as the whichOne * parameter and let it decide which value to return. * * @param function selectFn The selector, e.g. `_.name` * @param {mixed} whichOne Default value 'last'. Either 'first', 'last' or a function with (array) => value * @return {mixed} One of the matching values */ export function one(selectFn, whichOne='last') { return (data, key, context) => { if (typeof selectFn !== 'function') { let selectFnOriginal = selectFn selectFn = (o) => { return from(o[key]).select(selectFnOriginal); } } let result = selectFn(data, key, context) if (Array.isArray(result)) { if (whichOne=='last') { result = result.pop() } else if (whichOne=='first') { result = result.shift() } else if (typeof whichOne == 'function') { result = whichOne(result) } } return result } } /** * Turns the selected value into an array, if it wasn't already an array * * @param {function} selectFn the selector function, e.g. _.name * @return {array} */ export function many(selectFn) { return (data, key, context) => { if (typeof selectFn !== 'function') { let selectFnOriginal = selectFn selectFn = (o) => { return from(o[key]).select(selectFnOriginal); } } let result = selectFn(data, key, context) if (result == null) { result = [] } else if (!Array.isArray(result)) { result = [result] } return result } } /** * Returns the first matching value that is not null or undefined or an empty string. * * @param {...mixed} args A list of selector functions or static values. * @return the first selected value that is not empty (null, undefined or "") */ export function first(...args) { return (data, key, context) => { let result = null for (let arg of args) { if (typeof arg == 'function') { result = arg(data, key, context) if (result!=null && result!==undefined && result!=="") { return result } } else if (arg!==null && arg!==undefined && arg!=="") { return arg } } return null } } /** * implements a minimal graphql-alike selection syntax, using plain javascript * use with from(...arr).select * * @param {object|function} filter Which keys with which values you want * @return Function a function that selects values from objects as defined by filter */ function getSelectFn(filter) { let fns = [] if (filter instanceof Function) { fns.push(filter) } else for (const [filterKey, filterValue] of Object.entries(filter)) { if (filterValue instanceof Function) { fns.push( (data) => { if (filterKey=='_') { return filterValue(data, filterKey, 'select') } else { return { [filterKey]: filterValue(data, filterKey, 'select') } } }) } else if (!isPrimitiveWrapper(filterValue)) { fns.push( (data) => { if (filterKey=='_') { return from(data[filterKey]).select(filterValue) } else { return { [filterKey]: from(data[filterKey]).select(filterValue) } } }) } else { fns.push( () => { if (filterKey=='_') { return filterValue } else { return { [filterKey]: filterValue } } }) } } if (fns.length==1) { return fns[0] } return (data) => { let result = {} for (let fn of fns) { Object.assign(result, fn(data)) } return result } } /** * This function checks whether the given data matches the given pattern * Pattern can be a function, a regular expression, an object or a literal value * The pattern is matched recursively * Use with from(...arr).where * * @param {mixed} pattern The pattern to test * @return Function The filter function */ export function getMatchFn(pattern) { let fns = [] if (Array.isArray(pattern)) { fns.push(anyOf(...pattern)) } else if (pattern instanceof RegExp) { fns.push((data) => pattern.test(data)) } else if (pattern instanceof Function) { fns.push((data) => pattern(data)) } else if (!isPrimitiveWrapper(pattern)) { let patternMatches = {} for (const [wKey, wVal] of Object.entries(pattern)) { patternMatches[wKey] = getMatchFn(wVal) } let matchFn = (data) => { if (Array.isArray(data)) { return data.filter(element => matchFn(element)).length>0 } if (isPrimitiveWrapper(data)) { return false } for (let wKey in patternMatches) { let patternMatchFn = patternMatches[wKey] if (!patternMatchFn(data?.[wKey])) { return false } } return true } fns.push(matchFn) } else { fns.push((data) => { if (Array.isArray(data)) { return data.filter(element => pattern==element).length>0 } else { return pattern==data } }) } if (fns.length==1) { return fns[0] } return (data) => { for (let fn of fns) { if (!fn(data)) { return false } } return true } } /** * If used in a pattern for orderBy(), denotes that the key * value should be sorted ascending */ export const asc = Symbol('asc') /** * If used in a pattern for orderBy(), denotes that the key * value should be sorted descending */ export const desc = Symbol('desc') /** * Returns a function to sort an array according to the pattern. A pattern is * an object with keys which are a sub pattern object or * one of the asc/desc symbols, or a custom sort(a,b) function * @param {mixed} pattern The comparison pattern * @return Function The function to use with toSorted() */ export function getSortFn(pattern) { let comparisons = Object.entries(pattern) let fns = [] for (let [key,compare] of comparisons) { if (compare instanceof Function) { fns.push(compare) } else if (compare === asc) { fns.push((a,b) => (a[key]>b[key] ? 1 : a[key]<b[key] ? -1: 0)) } else if (compare === desc) { fns.push((a,b) => (a[key]<b[key] ? 1 : a[key]>b[key] ? -1: 0)) } else if (!isPrimitiveWrapper(compare)) { let subFn = getSortFn(compare) fns.push((a,b) => subFn(a[key],b[key])) } else { throw new Error('Unknown sort order',compare) } } if (fns.length==1) { return fns[0] // special case, if you only have one sort element, just return that, it is faster } return (a,b) => { for (let fn of fns) { let result = fn(a,b) if (result!==0) { return result } } return 0 } } /** * Like getSelectFn this accepts an object, but function values must be * reducers. * @param {object|function} filter Which keys with which values you want * @return Function a function that reduces values */ export function getAggregateFn(filter) { let fns = [] if (filter instanceof Function) { fns.push(filter) } else for (const [filterKey, filterValue] of Object.entries(filter)) { if (filterValue instanceof Function) { fns.push( (a, o, i, l) => { if (isPrimitiveWrapper(a)) { a = {} } if (o.reduce) { a[filterKey] = o.reduce(filterValue, a[filterKey] || []) } else { a[filterKey] = filterValue(a[filterKey] || [], o, i, l) } return a }) } else if (!isPrimitiveWrapper(filterValue)) { fns.push( (a, o) => { if (isPrimitiveWrapper(a)) { a = {} } a[filterKey] = from(o[filterKey]).reduce(filterValue, []) return a }) } else { fns.push( (a) => { if (isPrimitiveWrapper(a)) { a = {} } a[filterKey] = filterValue return a }) } } if (fns.length==1) { return fns[0] } return (a, o, i, l) => { let result = {} for (let fn of fns) { Object.assign(result, fn(a, o, i, l)) } return result } } /** * This is an alternative implementation of Object.groupBy * With support for objects being part of multiple groups * So if pointerFn() returns an array, each element of the * array is a group * */ function getMatchingGroups(data, pointerFn) { let result = {} for (let entity of data) { let groups = pointerFn(entity) if (!Array.isArray(groups)) { groups = [groups] } for (let group of groups) { if (typeof group!='string' && !(group instanceof String)) { console.warn('JAQT: groupBy(selector) can only handle string values, got:',group) continue } if (!result[group]) { result[group] = [] } result[group].push(entity) } } return result } /** * Returns a function that groups an array by one or more values defined in the pattern * * @param (object) data The data to parse and get the group from * @param (array) properties The properties to group by, in order, should be pointer functions */ function groupBy(data, pointerFunctions) { let pointerFn = pointerFunctions.shift() if (typeof pointerFn=='string') { pointerFn = _[pointerFn] } if (typeof pointerFn != 'function') { throw new Error('groupBy parameters must be either a property name or a pointer function (e.g.: _.name)') } let groups = getMatchingGroups(data, pointerFn) if (pointerFunctions.length) { for (let group in groups) { groups[group] = groupBy(groups[group], pointerFunctions) } } return groups } /** * Creates a function to sum (add) all grouped values, assumes/enforces all values are floats * * @param fetchFn the function that fetches the correct value, e.g. _.price * @return Function function (value, accumulator) => accumulator + value */ export function sum(fetchFn) { return (a,o) => { if (Array.isArray(a)) { a = 0 } a += parseFloat(fetchFn(o)) || 0 return a } } /** * Creates a function to average all grouped values, assumes/enforces all values are floats * * @param fetchFn the function that fetches the correct value, e.g. _.price * @return Function function (value, accumulator) => average(accumulator + value) */ export function avg(fetchFn) { return (a,o,count) => { return +a + ((parseFloat(fetchFn(o)) || 0) - a) / (count+1) } } /** * Creates a function that removes duplicate values from the grouped data * * @param fetchFn the function that fetches the correct value, e.g. _.name * @return Function */ export function distinct(fetchFn) { return (a, o, context) => { let v if (context=='select') { // a is the data param in select() context, o is the key v = fetchFn(a) if (Array.isArray(v)) { return v.filter((val,i,arr) => arr.indexOf(val)===i) } } else { // assume context is reduce, since context is the 3rd param, in reduce that contains the index // a is the accumulator, o is the current object/value let v = fetchFn(o) if (!a.includes[v]) { a.push(v) } } return a } } /** * Creates a function to count all grouped values * * @param fetchFn the function that fetches the correct value, e.g. _.price * @return Function function (value, accumulator) => accumulator + 1 */ export function count() { return (a) => { if (Array.isArray(a)) { a = 0 } return a+1 } } /** * Creates a function to find the maximum value in all grouped values, assumes/enforces all values are floats * * @param fetchFn the function that fetches the correct value, e.g. _.price * @return Function function (value, accumulator) => Math.max(accumulator, value) */ export function max(fetchFn) { return (a,o) => { if (Array.isArray(a)) { a = Number.NEGATIVE_INFINITY } let value = parseFloat(fetchFn(o)) if (!isNaN(value) && value>a) { a = value } return a } } /** * Creates a function to find the minimum value in all grouped values, assumes/enforces all values are floats * * @param fetchFn the function that fetches the correct value, e.g. _.price * @return Function function (value, accumulator) => Math.min(accumulator, value) */ export function min(fetchFn) { return (a,o) => { if (Array.isArray(a)) { a = Number.POSITIVE_INFINITY } let value = parseFloat(fetchFn(o)) if (!isNaN(value) && value<a) { a = value } return a } } /** * Not inverts the result from the matches function. * It returns a function expecting a data parameter and inverts the result * of matching that data with the pattern given to not() * * @param {mixed} pattern The pattern to match not * @return {function} A function that inverts the match, with a single data parameter */ export function not(...pattern) { let matchFn = getMatchFn(pattern) return data => !matchFn(data) } /** * AnyOf returns a function that returns true if any of the patterns match the data parameter * * @param {...mixed} patterns The patterns to test * @return {Boolean} True if at least one pattern matches */ export function anyOf(...patterns) { let matchFns = patterns.map(pattern => getMatchFn(pattern)) return data => matchFns.some(fn => fn(data)) } /** * AllOf returns a function that returns true if all of the patterns match the data parameter * * @param {...mixed} patterns The patterns to test * @return {Boolean} True if all of the patterns match */ export function allOf(...patterns) { let matchFns = patterns.map(pattern => getMatchFn(pattern)) return data => matchFns .map(matchFn => matchFn(data)) .filter(value => !value) .length===0 } /** * Handler for proxying functions like filter, map, etc. So that * results of those functions will still be proxied when using from() * and you can chain .select() after it * * @type {Object} */ const FunctionProxyHandler = { apply(target, thisArg, argumentsList) { let result = target.apply(thisArg,argumentsList) if (typeof result === 'object') { return new Proxy(result, DataProxyHandler) } return result } } /** * Handler for proxying data returned with from() * * @type {Object} */ const DataProxyHandler = { get(target, property) { let result = null if (typeof property === 'symbol') { // handles iterators and other stuff we don't want to change result = target[property] } if (Array.isArray(target)) { switch(property) { case 'where': result = function(shape) { let matchFn = getMatchFn(shape) return new Proxy(target .filter(element => matchFn(element)) , DataProxyHandler) } break case 'select': result = function(filter) { let selectFn = getSelectFn(filter) return new Proxy(target .map(selectFn) , DataProxyHandler) } break case 'reduce': result = function(pattern, initial=[]) { let aggregateFn = getAggregateFn(pattern) let temp = target.reduce(aggregateFn, initial) if (Array.isArray(temp)) { return new Proxy(temp, DataProxyHandler) } else if (!isPrimitiveWrapper(temp)) { return new Proxy(temp, GroupByProxyHandler) } else { return temp } } break case 'orderBy': result = function(pattern) { let sortFn = getSortFn(pattern) return new Proxy(target .toSorted(sortFn) , DataProxyHandler) } break case 'groupBy': result = function(...groups) { let temp = groupBy(target, groups) return new Proxy(temp , GroupByProxyHandler) } break } } if (!result && target && typeof target==='object') { if (property==='select') { result = function(filter) { let selector = getSelectFn(filter) return new Proxy(selector(target), DataProxyHandler) } } } if (!result && target && typeof target[property]==='function' ) { result = new Proxy(target[property], FunctionProxyHandler) } if (!result) { result = target[property] } return result } } const GroupByProxyHandler = { get(target, property) { let result = null switch(property) { case 'select': result = function(filter) { let selectFn = getSelectFn(filter) let result = {} for (let group in target) { if (Array.isArray(target[group])) { result[group] = new Proxy(target[group].map(selectFn), DataProxyHandler) } else { result[group] = new Proxy(target[group], GroupByProxyHandler) } } return result } break case 'reduce': result = function(pattern, initial=[]) { let aggregateFn = getAggregateFn(pattern) let result = {} for (let group in target) { if (Array.isArray(target[group])) { let temp = target[group].reduce(aggregateFn, initial) if (Array.isArray(temp)) { result[group] = new Proxy(temp, DataProxyHandler) } else if (!isPrimitiveWrapper(temp)) { result[group] = new Proxy(temp, GroupByProxyHandler) } else { result[group] = temp } } else { result[group] = new Proxy(target[group], GroupByProxyHandler) } } return result } break default: if (Array.isArray(target[property])) { result = from(target[property]) } else { result = target[property] } break } return result } } /** * Handler for proxying null of undefined values, so that * you can still chain the from.where.select functions * * @type {Object} */ const EmptyHandler = { get(target, property) { let result = null switch(property) { case 'where': result = function() { return new Proxy(new Null(), EmptyHandler) } break case 'reduce': case 'select': result = function() { return null } break case 'orderBy': result = function() { return new Proxy(new Null(), EmptyHandler) } break case 'groupBy': result = function() { return new Proxy(new Null(), EmptyHandler) } break } if (!result && typeof target?.[property] == 'function') { result = target[property]; } return result } } class Null { toJSON() { return null } } /** * This returns a proxy object for the given data, that adds * .where() and .select() functions * * @param {mixed} data The data to proxy * @return {Proxy} The proxy */ export function from(data) { if (!data || typeof data !== 'object') { return new Proxy(new Null(), EmptyHandler) } return new Proxy(data, DataProxyHandler) } /** * This is the function factory that builds the _ function * It will return a function that walks over the root object to * return the correct data * * @param {array} path The list of properties to access in order * @return {function} The accessor function that returns the data matching the path */ function getPointerFn(path) { /** * The json pointer function * @param {mixed} data Any data * @param {string} key Optional key for data objects in select context or group in groupBy context * @return {mixed} data or data[key] */ return (data, key) => { if (path?.length>0) { let localPath = path.slice() let prop = localPath.shift() while(prop) { if (Array.isArray(data) && parseInt(prop)!=prop) { localPath.unshift(prop) // put it back to call in .map return data.map(getPointerFn(localPath)) } else if (typeof data?.[prop] != 'undefined') { data = data[prop] } else { data = null } prop = localPath.shift() } return data } else if (key && key!=='_') { if (typeof data?.[key] != 'undefined') { return data[key] } else { return null } } else { return data } } } /** * Handler for the getval proxy, used to implement _ * The get trap handles things like _.key, it returns a function * so that select can apply it on result objects * * @type {Object} */ const pointerHandler = (path) => { if (!path) { path = [] } return { get(target, property) { if (property=='constructor' || typeof property == 'symbol') { return target[property] } // creates a new path, which is passed to pointerFn en pointerHandler // so it is kept in a new stack frame let newpath = path.concat([property]) return new Proxy(getPointerFn(newpath), pointerHandler(newpath)) }, apply(target, thisArg, argumentsList) { let result = target(...argumentsList) if (Array.isArray(result)) { result = result.flat(Infinity) } return result } } } /** * Placeholder in select queries that gets replaced with the * object or value being selected, or a specific key of that object * * @type {Proxy} */ export const _ = new Proxy(getPointerFn(), pointerHandler())