1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 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, isNumber, 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 const k = Array.isArray(value) ? value[0] : value;
108 const 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 { ...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 const path = Array.isArray(key) ? [].concat(key) : String(key).split(/(?=\[)|\./);
177 const 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 const el = path.shift();
182 if (el === '*' || el === '[]') {
183 const arrs = (el === '*') ? ctx.map(Object.values) : ctx.filter(Array.isArray);
184 ctx = arrs.flat();
185 } else {
186 const k = /^\[\d+\]$/.test(el) ? parseInt(el.substring(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 * Sets a value in a data structure. The `key` provides a dot-separated query
196 * path to traverse the data structure to any depth. The path may also be
197 * provided as an Array if needed. Returns a bound function if less than three
198 * arguments are specified.
199 *
200 * @param {object|Array} obj the data structure to traverse
201 * @param {string|Array} key the value query path
202 * @param {*} val the value to set
203 * @return {object|Array|function} the input `obj`, or a bound function
204 * @name set
205 * @memberof RapidContext.Data
206 *
207 * @example
208 * set({}, 'a.b', 13) //==> { a: { b: 13 } }
209 * set({ a: { b: 42 } }, 'a.b', 13) //==> { a: { b: 13 } }
210 */
211export function set(obj, key, val) {
212 if (arguments.length < 3) {
213 return set.bind(null, ...arguments);
214 } else {
215 let path = Array.isArray(key) ? [].concat(key) : String(key).split(/(?=\[)|\./);
216 path = path.filter(Boolean);
217 let ctx = obj;
218 while (ctx != null && path.length > 0) {
219 const el = path.shift();
220 const k = /^\[\d+\]$/.test(el) ? parseInt(el.substring(1), 10) : el;
221 if (path.length == 0) {
222 ctx[k] = val;
223 } else {
224 if (!hasProperty(k, ctx)) {
225 ctx[k] = (typeof(k) === 'number') ? [] : {};
226 }
227 ctx = ctx[k];
228 }
229 }
230 return obj;
231 }
232}
233
234/**
235 * Filters a collection based on a predicate function. The input collection can
236 * be either an Array-like object, or a plain object. The predicate function
237 * `fn(val, idx/key, arr/obj)` is called for each array item or object
238 * property. As an alternative to a function, a string query path can be
239 * specified instead. Returns a new `Array` or `Object` depending on the input
240 * collection. Returns a bound function if only a single argument is specified.
241 *
242 * @param {function|string} fn the predicate function or query path
243 * @param {object|Array} [coll] the collection to filter
244 * @return {object|Array|function} the new collection, or a bound function
245 * @name filter
246 * @memberof RapidContext.Data
247 *
248 * @example
249 * filter(Boolean, [null, undefined, true, 0, '']) //==> [true]
250 * filter(Boolean, { a: null, b: true, c: 0, d: 1 }) //==> { b: true, d: 1 }
251 * filter('id', [null, { id: 3 }, {}, { id: false }]) //==> [3]
252 */
253export function filter(fn, coll) {
254 function test(v, k, o) {
255 try {
256 return fn(v, k, o);
257 } catch (e) {
258 return false;
259 }
260 }
261 fn = isFunction(fn) ? fn : get(fn);
262 if (arguments.length < 2) {
263 return filter.bind(null, ...arguments);
264 } else if (Array.isArray(coll)) {
265 return coll.filter(test);
266 } else if (isObject(coll)) {
267 const obj = {};
268 for (const k in coll) {
269 const v = coll[k];
270 if (hasProperty(k, coll) && test(v, k, coll)) {
271 obj[k] = v;
272 }
273 }
274 return obj;
275 } else {
276 const arr = [];
277 const len = +coll?.length || 0;
278 for (let i = 0; i < len; i++) {
279 const v = coll[i];
280 if (test(v, i, coll)) {
281 arr.push(v);
282 }
283 }
284 return arr;
285 }
286}
287
288/**
289 * Concatenates nested `Array` elements into a parent `Array`. As an
290 * alternative, the array elements may be specified as arguments. Only the
291 * first level of `Array` elements are flattened by this method. In modern
292 * environments, consider using `Array.prototype.flat()` instead.
293 *
294 * @param {Array|...*} arr the input array or sequence of elements
295 * @return {Array} the new flattened `Array`
296 * @name flatten
297 * @memberof RapidContext.Data
298 */
299export function flatten(arr) {
300 if (arguments.length === 1 && Array.isArray(arr)) {
301 return Array.prototype.concat.apply([], arr);
302 } else {
303 return Array.prototype.concat.apply([], arguments);
304 }
305}
306
307/**
308 * Applies a function to each item or property in an input collection and
309 * returns the results. The input collection can be either an Array-like
310 * object, or a plain object. The mapping function `fn(val, idx/key, arr/obj)`
311 * is called for each array item or object property. As an alternative to a
312 * function, a string query path can be specified instead. Returns a new
313 * `Array` or `Object` depending on the input collection. Returns a bound
314 * function if only a single argument is specified.
315 *
316 * @param {function|string} fn the mapping function or query path
317 * @param {object|Array} [coll] the collection to process
318 * @return {object|Array|function} the new collection, or a bound function
319 * @name map
320 * @memberof RapidContext.Data
321 *
322 * @example
323 * map(Boolean, [null, true, 0, 1, '']) //==> [false, true, false, true, false]
324 * map(Boolean, { a: null, b: true, c: 0 }) //==> { a: false, b: true, c: false }
325 * map('id', [{ id: 1 }, { id: 3 }) //==> [1, 3]
326 */
327export function map(fn, coll) {
328 function inner(v, k, o) {
329 try {
330 return fn(v, k, o);
331 } catch (e) {
332 return undefined;
333 }
334 }
335 fn = isFunction(fn) ? fn : get(fn);
336 if (arguments.length < 2) {
337 return map.bind(null, ...arguments);
338 } else if (Array.isArray(coll)) {
339 return coll.map(inner);
340 } else if (isObject(coll)) {
341 const obj = {};
342 for (const k in coll) {
343 const v = coll[k];
344 if (hasProperty(k, coll)) {
345 obj[k] = inner(v, k, coll);
346 }
347 }
348 return obj;
349 } else {
350 const arr = [];
351 const len = +coll?.length || 0;
352 for (let i = 0; i < len; i++) {
353 const v = coll[i];
354 arr.push(inner(v, i, coll));
355 }
356 return arr;
357 }
358}
359
360/**
361 * Returns the unique values of a collection. An optional function may be
362 * provided to extract the key to compare with the other elements. If no
363 * function is specified, `JSON.stringify` is used to determine uniqueness.
364 * Returns a bound function if only a single function argument is specified.
365 *
366 * @param {function} [fn] the comparison key extract function
367 * @param {Array|Object|Iterable} coll the input collection
368 * @return {Array|function} the new `Array`, or a bound function
369 * @name uniq
370 * @memberof RapidContext.Data
371 *
372 * @example
373 * uniq([1, 2, 3, 3, 2, 1]) //==> [1, 2, 3]
374 * uniq([{ a: 1, b: 2 }, { b: 2, a: 1 }]) //==> [{ a: 1, b: 2 }, { b: 2, a: 1 }]
375 */
376export function uniq(fn, coll) {
377 if (arguments.length === 0) {
378 return [];
379 } else if (arguments.length === 1 && isFunction(fn)) {
380 return uniq.bind(null, fn);
381 } else if (arguments.length === 1) {
382 coll = arguments[0];
383 fn = JSON.stringify;
384 }
385 const arr = array(coll);
386 const keys = map(fn, arr).map(String);
387 return Object.values(object(keys, arr));
388}
389
390/**
391 * Compares two values to determine relative order. The return value is a
392 * number whose sign indicates the relative order: negative if `a` is less than
393 * `b`, positive if `a` is greater than `b`, or zero if they are equal. If the
394 * first argument is a function, it will be used to extract the values to
395 * compare from the other arguments. Returns a bound function if only a single
396 * argument is specified.
397 *
398 * @param {function} [valueOf] a function to extract the value
399 * @param {*} a the first value
400 * @param {*} [b] the second value
401 * @return {number|function} the test result, or a bound function
402 * @name compare
403 * @memberof RapidContext.Data
404 *
405 * @example
406 * compare(1, 1) //==> 0
407 * compare(13, 42) //==> -1
408 * compare('b', 'a') //==> +1
409 *
410 * @example
411 * compare((s) => s.toLowerCase(), 'Abc', 'aBC') //==> 0
412 */
413export function compare(valueOf, a, b) {
414 if (arguments.length < 2) {
415 return compare.bind(null, ...arguments);
416 } else if (arguments.length < 3 || !isFunction(valueOf)) {
417 b = arguments[1];
418 a = arguments[0];
419 } else {
420 a = valueOf(a);
421 b = valueOf(b);
422 }
423 const aNil = isNil(a) || (isNumber(a) && isNaN(a));
424 const bNil = isNil(b) || (isNumber(b) && isNaN(b));
425 if (aNil || bNil) {
426 return (aNil && bNil) ? 0 : (aNil ? -1 : 1);
427 } else {
428 return (a < b) ? -1 : (a > b ? 1 : 0);
429 }
430}
431
432/**
433 * Returns a sorted copy of a collection. An optional function may be provided
434 * to extract the value to compare with the other elements. If no function is
435 * specified, the comparison is made on the elements themselves. Returns a bound
436 * function if only a single function argument is specified.
437 *
438 * @param {function} [fn] the value extract function
439 * @param {Array|Object|Iterable} coll the input collection
440 * @return {Array|function} the new sorted `Array`, or a bound function
441 * @name sort
442 * @memberof RapidContext.Data
443 *
444 * @example
445 * sort([3, 2, 1]) //==> [1, 2, 3]
446 * sort(get('pos'), [{ pos: 3 }, { pos: 1 }]) //==> [{ pos: 1 }, { pos: 3 }]
447 *
448 * @example
449 * const toLower = (s) => s.toLowerCase();
450 * const sortInsensitive = sort(toLower);
451 * sortInsensitive(['b', 'B', 'a', 'aa', 'A']) //==> ['a', 'A', 'aa', 'b', 'B']
452 */
453export function sort(fn, coll) {
454 if (arguments.length === 0) {
455 return [];
456 } else if (arguments.length === 1 && isFunction(fn)) {
457 return sort.bind(null, fn);
458 } else if (arguments.length === 1) {
459 coll = arguments[0];
460 fn = null;
461 }
462 const arr = array(coll);
463 arr.sort(fn ? compare(fn) : compare);
464 return arr;
465}
466
467export default { bool, array, object, clone, get, set, filter, flatten, map, uniq, compare, sort };
468
RapidContext
Access · Discovery · Insight
www.rapidcontext.com