Source rapidcontext/data.mjs

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15/**
16 * Provides functions for data conversion, filtering, mapping, etc.
17 * @namespace RapidContext.Data
18 */
19
20import { isNil, isFunction, isObject, isArrayLike, isIterable, hasProperty, hasValue } from './fn.mjs';
21
22const OFF = ['null', 'undefined', '0', 'f', 'false', 'off', 'n', 'no'];
23
24/**
25 * Converts a value to a boolean. This is identical to `!!val`, but also
26 * converts `"null"`, `"0"`, `"false"`, `"off"` and similar values to `false`.
27 *
28 * @param {*} val the value to convert
29 * @return {boolean} `true` or `false` depending on the value
30 * @name bool
31 * @memberof RapidContext.Data
32 *
33 * @example
34 * bool(undefined) //==> false
35 * bool('') //==> false
36 * bool('FaLsE') //==> false
37 */
38export function bool(val) {
39    return !!val && !OFF.includes(String(val).toLowerCase());
40}
41
42/**
43 * Creates a new array from a collection of elements. This is similar to
44 * `Array.from()`, but also supports iterating over object properties (using
45 * `Object.values()`). When more than a single argument is provided, this
46 * function is similar to `Array.of()`.
47 *
48 * @param {Object|Iterable|...*} coll the elements to include
49 * @return {Array} a new `Array` with all the collection elements
50 * @name array
51 * @memberof RapidContext.Data
52 *
53 * @example
54 * array(null) //==> []
55 * array({ a: 1, b: 2, c: 3 }) //==> [1, 2, 3]
56 * array(1, 2, 3) //==> [1, 2, 3]
57 */
58export function array(coll) {
59    if (arguments.length === 1 && isNil(coll)) {
60        return [];
61    } else if (arguments.length === 1 && isObject(coll)) {
62        return Object.values(coll);
63    } else if (arguments.length === 1 && (isArrayLike(coll) || isIterable(coll))) {
64        return Array.from(coll);
65    } else {
66        return Array.from(arguments);
67    }
68}
69
70/**
71 * Creates a new object from a collection of properties. The properties can be
72 * specified in a number of ways, either as two separate arrays of keys and
73 * values, as a single array of key-value pairs, as a object with properties to
74 * copy, or as a lookup function (or query path). The values can be specified
75 * as an array, a lookup object, a generator function, or a constant value.
76 *
77 * @param {Array|Object|function|string} keys the object keys or key-pairs
78 * @param {Array|Object|function|*} [values] the values or generator function
79 * @return {Object} a new `Object` with the specified properties
80 * @name object
81 * @memberof RapidContext.Data
82 *
83 * @example
84 * object(['a', 'b'], true) //==> { a: true, b: true }
85 * object(['a', 'b'], [1, 2]) //==> { a: 1, b: 2 }
86 * object(['a', 'b'], { a: 1, b: 2, c: 3, d: 4 }) //==> { a: 1, b: 2 }
87 * object({ a: 1, b: 2 }, true) //==> { a: true, b: true }
88 * object('id', [{ id: 'a', val: 1 }, ...]) //==> { a: { id: 'a', val: 1 }, ... }
89 */
90export function object(keys, values) {
91    function keyPair(key, idx) {
92        if (Array.isArray(values)) {
93            return [key, values[idx]];
94        } else if (isObject(values)) {
95            return [key, values[key]];
96        } else if (isFunction(values)) {
97            try {
98                return [key, values(key)];
99            } catch (ignore) {
100                return [key];
101            }
102        } else {
103            return [key, values];
104        }
105    }
106    function merge(obj, value) {
107        let k = Array.isArray(value) ? value[0] : value;
108        let v = Array.isArray(value) ? value[1] : undefined;
109        if (hasValue(k) && !(k in obj)) {
110            obj[k] = v;
111        }
112        return obj;
113    }
114    if (Array.isArray(keys)) {
115        if (arguments.length < 2) {
116            return keys.reduce(merge, {});
117        } else {
118            return keys.map(keyPair).reduce(merge, {});
119        }
120    } else if (isObject(keys)) {
121        if (arguments.length < 2) {
122            return Object.assign({}, keys);
123        } else {
124            return Object.keys(keys).map(keyPair).reduce(merge, {});
125        }
126    } else if (keys) {
127        keys = map(keys, values);
128        return keys.map(keyPair).reduce(merge, {});
129    } else {
130        return {};
131    }
132}
133
134/**
135 * Creates a deep copy of an `Array` or an `Object`. Nested values will be
136 * copied recursively. Only plain objects are be copied, others (including
137 * primitive values) are returned as-is.
138 *
139 * @param {Array|Object|*} value the object or array to copy
140 * @return {Array|Object|*} a new `Array` or `Object` with the same content
141 * @name clone
142 * @memberof RapidContext.Data
143 */
144export function clone(value) {
145    if (Array.isArray(value) || isObject(value)) {
146        return map(clone, value);
147    } else {
148        return value;
149    }
150}
151
152/**
153 * Retrieves one or more values from a data structure. The `key` provides a
154 * dot-separated query path to traverse the data structure to any depth.
155 * Wildcard property names are specified as `*`. Wildcard array elements are
156 * specified as `[]`. The path may also be provided as an Array if needed.
157 * Returns a bound function if only a single argument is specified.
158 *
159 * @param {string|Array} key the value query path
160 * @param {object|Array} [val] the data structure to traverse
161 * @return {number|function} the value found, or a bound function
162 * @name get
163 * @memberof RapidContext.Data
164 *
165 * @example
166 * get('a.b', { a: { b: 42 } }) //==> 42
167 * get('*.b', { a: { b: 42 }, c: { b: 13 } }) //==> [42, 13]
168 * get('a.*', { a: { b: 42 }, c: { b: 13 } }) //==> [42]
169 * get('[].b', [ { a: 42 }, { b: 13 }, { b: 1} }) //==> [13, 1]
170 * get('1.b', [ { a: 42 }, { b: 13 }, { b: 1} }) //==> 13
171 */
172export function get(key, val) {
173    if (arguments.length < 2) {
174        return get.bind(null, ...arguments);
175    } else {
176        let path = Array.isArray(key) ? [].concat(key) : ('' + key).split(/(?=\[)|\./);
177        let hasWildcard = path.some((el) => el === '*' || el === '[]');
178        let ctx = [val];
179        while (path.length > 0 && ctx.length > 0) {
180            ctx = ctx.filter((o) => !isNil(o));
181            let el = path.shift();
182            if (el === '*' || el === '[]') {
183                let arrs = (el === '*') ? ctx.map(Object.values) : ctx.filter(Array.isArray);
184                ctx = Array.prototype.concat.apply([], arrs); // FIXME: arrs.flat()
185            } else {
186                let k = /^\[\d+\]$/.test(el) ? parseInt(el.substr(1), 10) : el;
187                ctx = ctx.filter(hasProperty(k)).map((o) => o[k]);
188            }
189        }
190        return (ctx.length > 1 || hasWildcard) ? ctx : ctx[0];
191    }
192}
193
194/**
195 * Filters a collection based on a predicate function. The input collection can
196 * be either an Array-like object, or a plain object. The predicate function
197 * `fn(val, idx/key, arr/obj)` is called for each array item or object
198 * property. As an alternative to a function, a string query path can be
199 * specified instead. Returns a new `Array` or `Object` depending on the input
200 * collection. Returns a bound function if only a single argument is specified.
201 *
202 * @param {function|string} fn the predicate function or query path
203 * @param {object|Array} [coll] the collection to filter
204 * @return {object|Array|function} the new collection, or a bound function
205 * @name filter
206 * @memberof RapidContext.Data
207 *
208 * @example
209 * filter(Boolean, [null, undefined, true, 0, '']) //==> [true]
210 * filter(Boolean, { a: null, b: true, c: 0, d: 1 }) //==> { b: true, d: 1 }
211 * filter('id', [null, { id: 3 }, {}, { id: false }]) //==> [3]
212 */
213export function filter(fn, coll) {
214    function test(v, k, o) {
215        try {
216            return fn(v, k, o);
217        } catch (e) {
218            return false;
219        }
220    }
221    fn = isFunction(fn) ? fn : get(fn);
222    if (arguments.length < 2) {
223        return filter.bind(null, ...arguments);
224    } else if (Array.isArray(coll)) {
225        return coll.filter(test);
226    } else if (isObject(coll)) {
227        let obj = {};
228        for (let k in coll) {
229            let v = coll[k];
230            if (hasProperty(k, coll) && test(v, k, coll)) {
231                obj[k] = v;
232            }
233        }
234        return obj;
235    } else {
236        let arr = [];
237        let len = +(coll && coll.length) || 0;
238        for (let i = 0; i < len; i++) {
239            let v = coll[i];
240            if (test(v, i, coll)) {
241                arr.push(v);
242            }
243        }
244        return arr;
245    }
246}
247
248/**
249 * Concatenates nested `Array` elements into a parent `Array`. As an
250 * alternative, the array elements may be specified as arguments. Only the
251 * first level of `Array` elements are flattened by this method. In modern
252 * environments, consider using `Array.prototype.flat()` instead.
253 *
254 * @param {Array|...*} arr the input array or sequence of elements
255 * @return {Array} the new flattened `Array`
256 * @name flatten
257 * @memberof RapidContext.Data
258 */
259export function flatten(arr) {
260    if (arguments.length === 1 && Array.isArray(arr)) {
261        return Array.prototype.concat.apply([], arr);
262    } else {
263        return Array.prototype.concat.apply([], arguments);
264    }
265}
266
267/**
268 * Applies a function to each item or property in an input collection and
269 * returns the results. The input collection can be either an Array-like
270 * object, or a plain object. The mapping function `fn(val, idx/key, arr/obj)`
271 * is called for each array item or object property. As an alternative to a
272 * function, a string query path can be specified instead. Returns a new
273 * `Array` or `Object` depending on the input collection. Returns a bound
274 * function if only a single argument is specified.
275 *
276 * @param {function|string} fn the mapping function or query path
277 * @param {object|Array} [coll] the collection to process
278 * @return {object|Array|function} the new collection, or a bound function
279 * @name map
280 * @memberof RapidContext.Data
281 *
282 * @example
283 * map(Boolean, [null, true, 0, 1, '']) //==> [false, true, false, true, false]
284 * map(Boolean, { a: null, b: true, c: 0 }) //==> { a: false, b: true, c: false }
285 * map('id', [{ id: 1 }, { id: 3 }) //==> [1, 3]
286 */
287export function map(fn, coll) {
288    function inner(v, k, o) {
289        try {
290            return fn(v, k, o);
291        } catch (e) {
292            return undefined;
293        }
294    }
295    fn = isFunction(fn) ? fn : get(fn);
296    if (arguments.length < 2) {
297        return map.bind(null, ...arguments);
298    } else if (Array.isArray(coll)) {
299        return coll.map(inner);
300    } else if (isObject(coll)) {
301        let obj = {};
302        for (let k in coll) {
303            let v = coll[k];
304            if (hasProperty(k, coll)) {
305                obj[k] = inner(v, k, coll);
306            }
307        }
308        return obj;
309    } else {
310        let arr = [];
311        let len = +(coll && coll.length) || 0;
312        for (let i = 0; i < len; i++) {
313            let v = coll[i];
314            arr.push(inner(v, i, coll));
315        }
316        return arr;
317    }
318}
319
320/**
321 * Returns the unique values of a collection. An optional function may be
322 * provided to extract the key to compare with the other elements. If no
323 * function is specified, `JSON.stringify` is used to determine uniqueness.
324 * Returns a bound function if only a single function argument is specified.
325 *
326 * @param {function} [fn] the comparison key extract function
327 * @param {Array|Object|Iterable} coll the input collection
328 * @return {Array|function} the new `Array`, or a bound function
329 * @name uniq
330 * @memberof RapidContext.Data
331 *
332 * @example
333 * uniq([1, 2, 3, 3, 2, 1]) //==> [1, 2, 3]
334 * uniq([{ a: 1, b: 2 }, { b: 2, a: 1 }]) //==> [{ a: 1, b: 2 }, { b: 2, a: 1 }]
335 */
336export function uniq(fn, coll) {
337    if (arguments.length === 0) {
338        return [];
339    } else if (arguments.length === 1 && isFunction(fn)) {
340        return uniq.bind(null, fn);
341    } else if (arguments.length === 1) {
342        coll = arguments[0];
343        fn = JSON.stringify;
344    }
345    let arr = array(coll);
346    let keys = map(fn, arr).map(String);
347    return Object.values(object(keys, arr));
348}
349
350/**
351 * Compares two values to determine relative order. The return value is a
352 * number whose sign indicates the relative order: negative if `a` is less than
353 * `b`, positive if `a` is greater than `b`, or zero if they are equal. If the
354 * first argument is a function, it will be used to extract the values to
355 * compare from the other arguments. Returns a bound function if only a single
356 * argument is specified.
357 *
358 * @param {function} [valueOf] a function to extract the value
359 * @param {*} a the first value
360 * @param {*} [b] the second value
361 * @return {number|function} the test result, or a bound function
362 * @name compare
363 * @memberof RapidContext.Data
364 *
365 * @example
366 * compare(1, 1) //==> 0
367 * compare(13, 42) //==> -1
368 * compare('b', 'a') //==> +1
369 *
370 * @example
371 * compare((s) => s.toLowerCase(), 'Abc', 'aBC') //==> 0
372 */
373export function compare(valueOf, a, b) {
374    if (arguments.length < 2) {
375        return compare.bind(null, ...arguments);
376    } else if (arguments.length < 3 || !isFunction(valueOf)) {
377        b = arguments[1];
378        a = arguments[0];
379    } else {
380        a = valueOf(a);
381        b = valueOf(b);
382    }
383    return a < b ? -1 : (a > b ? 1 : 0);
384}
385
386/**
387 * Returns a sorted copy of a collection. An optional function may be provided
388 * to extract the value to compare with the other elements. If no function is
389 * specified, the comparison is made on the elements themselves. Returns a bound
390 * function if only a single function argument is specified.
391 *
392 * @param {function} [fn] the value extract function
393 * @param {Array|Object|Iterable} coll the input collection
394 * @return {Array|function} the new sorted `Array`, or a bound function
395 * @name sort
396 * @memberof RapidContext.Data
397 *
398 * @example
399 * sort([3, 2, 1]) //==> [1, 2, 3]
400 * sort(get('pos'), [{ pos: 3 }, { pos: 1 }]) //==> [{ pos: 1 }, { pos: 3 }]
401 *
402 * @example
403 * const toLower = (s) => s.toLowerCase();
404 * const sortInsensitive = sort(toLower);
405 * sortInsensitive(['b', 'B', 'a', 'aa', 'A']) //==> ['a', 'A', 'aa', 'b', 'B']
406 */
407export function sort(fn, coll) {
408    if (arguments.length === 0) {
409        return [];
410    } else if (arguments.length === 1 && isFunction(fn)) {
411        return sort.bind(null, fn);
412    } else if (arguments.length === 1) {
413        coll = arguments[0];
414        fn = null;
415    }
416    let arr = array(coll);
417    arr.sort(fn ? compare(fn) : compare);
418    return arr;
419}
420
421export default { bool, array, object, clone, get, filter, flatten, map, uniq, compare, sort };
422