diff --git a/README.md b/README.md index c4e935ce..25004deb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Given: Visit the [Playground](https://opensource.adobe.com/json-formula/dist/index.html) # Documentation -Specification / Reference: [HTML](https://opensource.adobe.com/json-formula/doc/output/json-formula-specification-1.1.0.html) / [PDF](https://opensource.adobe.com/json-formula/doc/output/json-formula-specification-1.1.0.pdf) +Specification / Reference: [HTML](https://opensource.adobe.com/json-formula/doc/output/json-formula-specification-1.1.2.html) / [PDF](https://opensource.adobe.com/json-formula/doc/output/json-formula-specification-1.1.2.pdf) [JavaScript API](./doc/output/JSDOCS.md) diff --git a/doc/spec.adoc b/doc/spec.adoc index 2c5c5861..f23c7b10 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -64,6 +64,8 @@ json-formula functions: * expression: A string prefixed with an ampersand (`&`) character +This specification uses the term "scalar" to refer to any value that is not an array, object or expression. Scalars include numbers, strings, booleans, and null values. + === Type Coercion If the supplied data is not correct for the execution context, json-formula will attempt to coerce the data to the correct type. Coercion will occur in these contexts: @@ -73,7 +75,8 @@ If the supplied data is not correct for the execution context, json-formula will * Operands of the union operator (`~`) shall be coerced to an array * The left-hand operand of ordering comparison operators (`>`, `>=`, `<`, `\<=`) must be a string or number. Any other type shall be coerced to a number. * If the operands of an ordering comparison are different, they shall both be coerced to a number -* Parameters to functions shall be coerced to the expected type as long as the expected type is a single choice. If the function signature allows multiple types for a parameter e.g. either string or array, then coercion will not occur. +* Parameters to functions shall be coerced when there is a single viable coercion available. For example, if a null value is provided to a function that accepts a number or string, then coercion shall not happen, since a null value can be coerced to both types. Conversely if a string is provided to a function that accepts a number or array of numbers, then the string shall be coerced to a number, since there is no supported coercion to convert it to an array of numbers. +* When functions accept a typed array, the function rules determine whether coercion may occur. Some functions (e.g. `avg()`) ignore array members of the wrong type. Other functions (e.g. `abs()`) coerce array members. If coercion may occur, then any provided array will have each of its members coerced to the expected type. e.g., if the input array is `[2,3,"6"]` and an array of numbers is expected, the array will be coerced to `[2,3,6]`. The equality and inequality operators (`=`, `==`, `!=`, `<>`) do **not** perform type coercion. If operands are different types, the values are considered not equal. @@ -91,7 +94,7 @@ In all cases except ordering comparison, if the coercion is not possible, a `Typ eval([1,2,3] ~ 4, {}) -> [1,2,3,4] eval(123 < "124", {}) -> true eval("23" > 111, {}) -> false - eval(abs("-2"), {}) -> 2 + eval(avg(["2", "3", "4"]), {}) -> 3 eval(1 == "1", {}) -> false ---- @@ -114,12 +117,12 @@ In all cases except ordering comparison, if the coercion is not possible, a `Typ | string | array | create a single-element array with the string | boolean | array | create a single-element array with the boolean | object | array | Not supported -| null | array | Empty array +| null | array | Not supported | number | object | Not supported | string | object | Not supported | boolean | object | Not supported | array | object | Not supported. Use: `fromEntries(entries(array))` -| null | object | Empty object +| null | object | Not supported | number | boolean | zero is false, all other numbers are true | string | boolean | Empty string is false, populated strings are true | array | boolean | Empty array is false, populated arrays are true @@ -127,6 +130,10 @@ In all cases except ordering comparison, if the coercion is not possible, a `Typ | null | boolean | false |=== +An array may be coerced to another type of array as long as there is a supported coercion for the array content. For examples, just as a string can be coerced to a number, an array of strings may be coerced to an array of numbers. + +Note that while strings, numbers and booleans may be coerced to arrays, they may not be coerced to a different type within that array. For example, a number cannot be coerced to an array of strings -- even though a number can be coerced to a string, and a string can be coerced to an array of strings. + [discrete] ==== Examples @@ -135,7 +142,7 @@ In all cases except ordering comparison, if the coercion is not possible, a `Typ eval("\"$123.00\" + 1", {}) -> TypeError eval("truth is " & `true`, {}) -> "truth is true" eval(2 + `true`, {}) -> 3 - eval(avg("20"), {}) -> 20 + eval(avg(["20", "30"]), {}) -> 25 ---- === Date and Time Values @@ -433,7 +440,7 @@ The numeric and concatenation operators (`+`, `-`, `{asterisk}`, `/`, `&`) have * When both operands are arrays, a new array is returned where the elements are populated by applying the operator on each element of the left operand array with the corresponding element from the right operand array * If both operands are arrays and they do not have the same size, the shorter array is padded with null values -* If one operand is an array and one is a scalar value, a new array is returned where the operator is applied with the scalar against each element in the array +* If one operand is an array and one is a scalar value, the scalar operand will be converted to an array by repeating the value to the same size array as the other operand [source%unbreakable] ---- @@ -452,7 +459,7 @@ The union operator (`~`) returns an array formed by concatenating the contents o eval(a ~ b, {"a": [[0,1,2]], "b": [[3,4,5]]}) -> [[0,1,2],[3,4,5]] eval(a[] ~ b[], {"a": [[0,1,2]], "b": [[3,4,5]]}) -> [0,1,2,3,4,5] eval(a ~ 10, {"a": [0,1,2]}) -> [0,1,2,10] - eval(a ~ `null`, {"a": [0,1,2]}) -> [0,1,2] + eval(a ~ `null`, {"a": [0,1,2]}) -> [0,1,2,null] ---- === Boolean Operators @@ -735,8 +742,9 @@ operator follows these processing steps: * The result array is returned as a <> Once the flattening operation has been performed, subsequent operations -are projected onto the flattened array. The difference between a bracketed wildcard (`[{asterisk}]`) and flatten (`[]`) is that -flatten will first merge sub-arrays. +are projected onto the flattened array. + +A bracketed wildcard (`[{asterisk}]`) and flatten (`[]`) behave similarly in that they produce a projection from an array. The only difference is that a bracketed wildcard preserves the original array structure while flatten collapses one level of array structure. [discrete] ==== Examples @@ -1194,27 +1202,55 @@ output. return_type function_name2(type1|type2 $argname) ---- +=== Function parameters + Functions support the set of standard json-formula <>. If the resolved arguments cannot be coerced to match the types specified in the signature, a `TypeError` error occurs. As a shorthand, the type `any` is used to indicate that the function argument can be -of any of (`array|object|number|string|boolean|null`). +any of (`array|object|number|string|boolean|null`). The expression type, (denoted by `&expression`), is used to specify an expression that is not immediately evaluated. Instead, a reference to that -expression is provided to the function being called. The function can then apply the expression reference as needed. It is semantically similar +expression is provided to the function. The function can then apply the expression reference as needed. It is semantically similar to an https://en.wikipedia.org/wiki/Anonymous_function[anonymous function]. See the <<_sortBy, sortBy()>> function for an example of the expression type. -The result of the `functionExpression` is the result returned by the -function call. If a `functionExpression` is evaluated for a function that -does not exist, a `FunctionError` error is raised. - -Functions can either have a specific arity or be variadic with a minimum +Function parameters can either have a specific arity or be variadic with a minimum number of arguments. If a `functionExpression` is encountered where the arity does not match, or the minimum number of arguments for a variadic function is not provided, or too many arguments are provided, then a `FunctionError` error is raised. +The result of the `functionExpression` is the result returned by the +function call. If a `functionExpression` is evaluated for a function that +does not exist, a `FunctionError` error is raised. + +==== Array Parameters +Many functions that process scalar values also allow for the processing of arrays of values. For example, the `round()` function may be called to process a single value: `round(1.2345, 2)` or to process an array of values: `round([1.2345, 2.3456], 2)`. The first call will return a single value, the second call will return an array of values. +When processing arrays of values, and where there is more than one parameter, each parameter is converted to an array so that the function processes each value in the set of arrays. From our example above, the call to `round([1.2345, 2.3456], 2)` would be processed as if it were `round([1.2345, 2.3456], [2, 2])`, and the result would be the same as: `[round(1.2345, 2), round(2.3456, 2)]`. + +Functions that accept array parameters will also accept nested arrays. With nested arrays, aggregating functions (min(), max(), avg(), sum() etc.) will flatten the arrays. e.g. + +`avg([2.1, 3.1, [4.1, 5.1]])` will be processed as `avg([2.1, 3.1, 4.1, 5.1])` and return `3.6`. + +Non-aggregating functions will return the same array hierarchy. e.g. + +`upper("a", ["b"]]) => ["A", ["B"]]` +`round([2.12, 3.12, [4.12, 5.12]], 1)` will be processed as `round([2.12, 3.12, [4.12, 5.12]], [1, 1, [1, 1]])` and return `[2.1, 3.1, [4.1, 5.1]] ` + +These array balancing rules apply when any parameter is an array: + +* All parameters will be treated as arrays +* Any scalar parameters will be converted to an array by repeating the scalar value to the length of the longest array +* All array parameters will be padded to the length of the longest array by adding null values +* The function will return an array which is the result of iterating over the elements of the arrays and applying the function logic on the values at the same index. + +With nested arrays: +* Nested arrays will be flattened for aggregating functions +* Non-aggregating functions will preserve the array hierarchy and will apply the balancing rules to each element of the nested arrays + +=== Function evaluation + Functions are evaluated in applicative order: - Each argument must be an expression - Each argument expression must be evaluated before evaluating the diff --git a/src/TreeInterpreter.js b/src/TreeInterpreter.js index 5d3d8fbc..b57e70b0 100644 --- a/src/TreeInterpreter.js +++ b/src/TreeInterpreter.js @@ -73,6 +73,9 @@ export default class TreeInterpreter { this.debug = debug; this.language = language; this.visitFunctions = this.initVisitFunctions(); + // track the identifier name that started the chain + // so that we can use it in debug hints + this.debugChainStart = null; } search(node, value) { @@ -85,12 +88,12 @@ export default class TreeInterpreter { if (value !== null && (isObject(value) || isArray(value))) { const field = getProperty(value, node.name); if (field === undefined) { - debugAvailable(this.debug, value, node.name); + debugAvailable(this.debug, value, node.name, this.debugChainStart); return null; } return field; } - debugAvailable(this.debug, value, node.name); + debugAvailable(this.debug, value, node.name, this.debugChainStart); return null; } @@ -98,9 +101,9 @@ export default class TreeInterpreter { return { Identifier: this.field.bind(this), QuotedIdentifier: this.field.bind(this), - ChainedExpression: (node, value) => { let result = this.visit(node.children[0], value); + this.debugChainStart = node.children[0].name; for (let i = 1; i < node.children.length; i += 1) { result = this.visit(node.children[1], result); if (result === null) return null; @@ -322,7 +325,9 @@ export default class TreeInterpreter { UnionExpression: (node, value) => { let first = this.visit(node.children[0], value); + if (first === null) first = [null]; let second = this.visit(node.children[1], value); + if (second === null) second = [null]; first = matchType([TYPE_ARRAY], first, 'union', this.toNumber, this.toString); second = matchType([TYPE_ARRAY], second, 'union', this.toNumber, this.toString); return first.concat(second); diff --git a/src/functions.js b/src/functions.js index 4c2039a7..3d6106ad 100644 --- a/src/functions.js +++ b/src/functions.js @@ -101,6 +101,247 @@ export default function functions( return JSON.stringify(value, null, offset); } + function balanceArrays(listOfArrays) { + const maxLen = Math.max(...listOfArrays.map(a => (Array.isArray(a) ? a.length : 0))); + const allArrays = listOfArrays.map(a => { + if (Array.isArray(a)) { + return a.concat(Array(maxLen - a.length).fill(null)); + } + return Array(maxLen).fill(a); + }); + // convolve allArrays + const arrays = []; + for (let i = 0; i < maxLen; i += 1) { + const row = []; + for (let j = 0; j < allArrays.length; j += 1) { + row.push(allArrays[j][i]); + } + arrays.push(row); + } + return arrays; + } + + function evaluate(args, fn) { + if (args.some(Array.isArray)) { + return balanceArrays(args).map(a => evaluate(a, fn)); + } + return fn(...args); + } + + function datedifFn(date1Arg, date2Arg, unitArg) { + const unit = toString(unitArg).toLowerCase(); + const date1 = getDateObj(date1Arg); + const date2 = getDateObj(date2Arg); + if (date2 === date1) return 0; + if (date2 < date1) throw functionError('end_date must be >= start_date in datedif()'); + + if (unit === 'd') return Math.floor(getDateNum(date2 - date1)); + const yearDiff = date2.getFullYear() - date1.getFullYear(); + let monthDiff = date2.getMonth() - date1.getMonth(); + const dayDiff = date2.getDate() - date1.getDate(); + + if (unit === 'y') { + let y = yearDiff; + if (monthDiff < 0) y -= 1; + if (monthDiff === 0 && dayDiff < 0) y -= 1; + return y; + } + if (unit === 'm') { + return yearDiff * 12 + monthDiff + (dayDiff < 0 ? -1 : 0); + } + if (unit === 'ym') { + if (dayDiff < 0) monthDiff -= 1; + if (monthDiff <= 0 && yearDiff > 0) return 12 + monthDiff; + return monthDiff; + } + if (unit === 'yd') { + if (dayDiff < 0) monthDiff -= 1; + if (monthDiff < 0) date2.setFullYear(date1.getFullYear() + 1); + else date2.setFullYear(date1.getFullYear()); + return Math.floor(getDateNum(date2 - date1)); + } + throw functionError(`Unrecognized unit parameter "${unit}" for datedif()`); + } + + function endsWithFn(searchArg, suffixArg) { + const searchStr = valueOf(searchArg); + const suffix = valueOf(suffixArg); + // make sure the comparison is based on code points + const search = Array.from(searchStr).reverse(); + const ending = Array.from(suffix).reverse(); + return ending.every((c, i) => c === search[i]); + } + + function eomonthFn(dateArg, monthsArg) { + const jsDate = getDateObj(dateArg); + const months = toInteger(monthsArg); + // We can give the constructor a month value > 11 and it will increment the years + // Since day is 1-based, giving zero will yield the last day of the previous month + const newDate = new Date(jsDate.getFullYear(), jsDate.getMonth() + months + 1, 0); + return getDateNum(newDate); + } + + function findFn(queryArg, textArg, offsetArg) { + const query = Array.from(toString(queryArg)); + const text = Array.from(toString(textArg)); + const offset = toInteger(offsetArg); + if (offset < 0) throw evaluationError('find() start position must be >= 0'); + if (query.length === 0) { + // allow an empty string to be found at any position -- including the end + if (offset > text.length) return null; + return offset; + } + for (let i = offset; i < text.length; i += 1) { + if (text.slice(i, i + query.length).every((c, j) => c === query[j])) { + return i; + } + } + return null; + } + + function properFn(arg) { + const capitalize = word => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`; + const original = toString(arg); + // split the string by whitespace, punctuation, and numbers + const wordParts = original.match(/[\s\d\p{P}]+|[^\s\d\p{P}]+/gu); + if (wordParts !== null) return wordParts.map(w => capitalize(w)).join(''); + return capitalize(original); + } + + function reptFn(textArg, countArg) { + const text = toString(textArg); + const count = toInteger(countArg); + if (count < 0) throw evaluationError('rept() count must be greater than or equal to 0'); + return text.repeat(count); + } + + function searchFn(findTextString, withinTextString, startPosInt = 0) { + const findText = toString(findTextString); + const withinText = toString(withinTextString); + const startPos = toInteger(startPosInt); + if (startPos < 0) throw functionError('search() startPos must be greater than or equal to 0'); + if (findText === null || withinText === null || withinText.length === 0) return []; + + // Process as an array of code points + // Find escapes and wildcards + const globString = Array.from(findText).reduce((acc, cur) => { + if (acc.escape) return { escape: false, result: acc.result.concat(cur) }; + if (cur === '\\') return { escape: true, result: acc.result }; + if (cur === '?') return { escape: false, result: acc.result.concat('dot') }; + if (cur === '*') { + // consecutive * are treated as a single * + if (acc.result.slice(-1).pop() === 'star') return acc; + return { escape: false, result: acc.result.concat('star') }; + } + return { escape: false, result: acc.result.concat(cur) }; + }, { escape: false, result: [] }).result; + + const testMatch = (array, glob, match) => { + // we've consumed the entire glob, so we're done + if (glob.length === 0) return match; + // we've consumed the entire array, but there's still glob left -- no match + if (array.length === 0) return null; + const testChar = array[0]; + let [globChar, ...nextGlob] = glob; + const isStar = globChar === 'star'; + if (isStar) { + // '*' is at the end of the match -- so we're done matching + if (glob.length === 1) return match; + // we'll check for a match past the * and if not found, we'll process the * + [globChar, ...nextGlob] = glob.slice(1); + } + if (testChar === globChar || globChar === 'dot') { + return testMatch(array.slice(1), nextGlob, match.concat(testChar)); + } + // no match, so consume wildcard * + if (isStar) return testMatch(array.slice(1), glob, match.concat(testChar)); + + return null; + }; + // process code points + const within = Array.from(withinText); + for (let i = startPos; i < within.length; i += 1) { + const result = testMatch(within.slice(i), globString, []); + if (result !== null) return [i, result.join('')]; + } + return []; + } + + function splitFn(strArg, separatorArg) { + const str = toString(strArg); + const separator = toString(separatorArg); + // for empty separator, return an array of code points + return separator.length === 0 ? Array.from(str) : str.split(separator); + } + + function startsWithFn(subjectString, prefixString) { + const subject = Array.from(toString(subjectString)); + const prefix = Array.from(toString(prefixString)); + if (prefix.length > subject.length) return false; + for (let i = 0; i < prefix.length; i += 1) { + if (prefix[i] !== subject[i]) return false; + } + return true; + } + + function substituteFn(source, oldString, replacementString, nearest) { + const src = Array.from(toString(source)); + const old = Array.from(toString(oldString)); + const replacement = Array.from(toString(replacementString)); + + if (old.length === 0) return source; + + // no third parameter? replace all instances + let replaceAll = true; + let whch = 0; + if (nearest > -1) { + replaceAll = false; + whch = nearest + 1; + } + + let found = 0; + const result = []; + // find the instances to replace + for (let j = 0; j < src.length;) { + const match = old.every((c, i) => src[j + i] === c); + if (match) found += 1; + if (match && (replaceAll || found === whch)) { + result.push(...replacement); + j += old.length; + } else { + result.push(src[j]); + j += 1; + } + } + return result.join(''); + } + + function truncFn(number, d) { + const digits = toInteger(d); + + const method = number >= 0 ? Math.floor : Math.ceil; + return method(number * 10 ** digits) / 10 ** digits; + } + + function weekdayFn(date, type) { + const jsDate = getDateObj(date); + const day = jsDate.getDay(); + // day is in range [0-7) with 0 mapping to sunday + switch (toInteger(type)) { + case 1: + // range = [1, 7], sunday = 1 + return day + 1; + case 2: + // range = [1, 7] sunday = 7 + return ((day + 6) % 7) + 1; + case 3: + // range = [0, 6] sunday = 6 + return (day + 6) % 7; + default: + throw functionError(`Unsupported returnType: "${type}" for weekday()`); + } + } + const functionMap = { // name: [function, ] // The can be: @@ -117,28 +358,28 @@ export default function functions( // and if not provided is assumed to be false. /** * Find the absolute (non-negative) value of the provided argument `value`. - * @param {number} value a numeric value - * @return {number} If `value < 0`, returns `-value`, otherwise returns `value` + * @param {number|number[]} value A numeric value + * @return {number|number[]} If `value < 0`, returns `-value`, otherwise returns `value` * @function abs * @example * abs(-1) // returns 1 */ abs: { - _func: resolvedArgs => Math.abs(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.abs), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Compute the inverse cosine (in radians) of a number. - * @param {number} cosine A number between -1 and 1, inclusive, + * @param {number|number[]} cosine A number between -1 and 1, inclusive, * representing the angle's cosine value. - * @return {number} The inverse cosine angle in radians between 0 and PI + * @return {number|number[]} The inverse cosine angle in radians between 0 and PI * @function acos * @example * acos(0) => 1.5707963267948966 */ acos: { - _func: resolvedArgs => validNumber(Math.acos(resolvedArgs[0]), 'acos'), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, n => validNumber(Math.acos(n), 'acos')), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -161,44 +402,46 @@ export default function functions( }); return result; }, - _signature: [{ types: [dataTypes.TYPE_ANY], variadic: true }], + _signature: [{ types: [TYPE_ANY], variadic: true }], }, /** * Compute the inverse sine (in radians) of a number. - * @param {number} sine A number between -1 and 1, inclusive, + * @param {number|number[]} sine A number between -1 and 1, inclusive, * representing the angle's sine value. - * @return {number} The inverse sine angle in radians between -PI/2 and PI/2 + * @return {number|number[]} The inverse sine angle in radians between -PI/2 and PI/2 * @function asin * @example * Math.asin(0) => 0 */ asin: { - _func: resolvedArgs => validNumber(Math.asin(resolvedArgs[0]), 'asin'), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, n => validNumber(Math.asin(n), 'asin')), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Compute the angle in the plane (in radians) between the positive * x-axis and the ray from (0, 0) to the point (x, y) - * @param {number} y The y coordinate of the point - * @param {number} x The x coordinate of the point - * @return {number} The angle in radians (between -PI and PI), + * @param {number|number[]} y The y coordinate of the point + * @param {number|number[]} x The x coordinate of the point + * @return {number|number[]} The angle in radians (between -PI and PI), * between the positive x-axis and the ray from (0, 0) to the point (x, y). * @function atan2 * @example * atan2(20,10) => 1.1071487177940904 */ atan2: { - _func: resolvedArgs => Math.atan2(resolvedArgs[0], resolvedArgs[1]), + _func: args => evaluate(args, Math.atan2), _signature: [ - { types: [TYPE_NUMBER] }, - { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Finds the average of the elements in an array. + * Non-numeric values (text, boolean, null etc) are ignored. + * If there are nested arrays, they are flattened. * If the array is empty, an evaluation error is thrown * @param {number[]} elements array of numeric values * @return {number} average value @@ -209,40 +452,74 @@ export default function functions( avg: { _func: resolvedArgs => { let sum = 0; - const inputArray = resolvedArgs[0]; - if (inputArray.length === 0) throw evaluationError('avg() requires at least one argument'); - inputArray.forEach(a => { + const filtered = resolvedArgs + .flat(Infinity) + .filter(a => getType(a) === TYPE_NUMBER); + + if (filtered.length === 0) throw evaluationError('avg() requires at least one argument'); + filtered.forEach(a => { sum += a; }); - return sum / inputArray.length; + return sum / filtered.length; }, - _signature: [{ types: [TYPE_ARRAY_NUMBER] }], + _signature: [{ types: [TYPE_ARRAY] }], + }, + + /** + * Finds the average of the elements in an array, converting strings and booleans to number. + * If any conversions to number fail, an type error is thrown. + * If there are nested arrays, they are flattened. + * If the array is empty, an evaluation error is thrown + * @param {number[]} elements array of numeric values + * @return {number} average value + * @function avgA + * @example + * avgA([1, 2, "3", null()]) // returns 2 + */ + avgA: { + _func: resolvedArgs => { + let sum = 0; + let filtered; + try { + filtered = resolvedArgs + .flat(Infinity) + .filter(a => getType(a) !== TYPE_NULL) + .map(toNumber); + } catch (_e) { + throw typeError('avgA() received non-numeric parameters'); + } + if (filtered.length === 0) throw evaluationError('avg() requires at least one argument'); + filtered.forEach(a => { + sum += a; + }); + return sum / filtered.length; + }, + _signature: [{ types: [TYPE_ARRAY] }], }, /** * Generates a lower-case string of the `input` string using locale-specific mappings. * e.g. Strings with German letter ß (eszett) can be compared to "ss" - * @param {string} input string to casefold - * @returns {string} A new string converted to lower case + * @param {string|string[]} input string to casefold + * @returns {string|string[]} A new string converted to lower case * @function casefold * @example * casefold("AbC") // returns "abc" */ casefold: { - _func: (args, _data, interpreter) => { - const str = toString(args[0]); - return str.toLocaleUpperCase(interpreter.language).toLocaleLowerCase(interpreter.language); - }, + _func: (args, _data, interpreter) => evaluate(args, s => toString(s) + .toLocaleUpperCase(interpreter.language) + .toLocaleLowerCase(interpreter.language)), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, /** * Finds the next highest integer value of the argument `num` by rounding up if necessary. * i.e. ceil() rounds toward positive infinity. - * @param {number} num numeric value - * @return {integer} The smallest integer greater than or equal to num + * @param {number|number[]} num numeric value + * @return {integer|integer[]} The smallest integer greater than or equal to num * @function ceil * @example * ceil(10) // returns 10 @@ -250,24 +527,25 @@ export default function functions( */ ceil: { - _func: resolvedArgs => Math.ceil(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.ceil), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Retrieve the first code point from a string - * @param {string} str source string. - * @return {integer} Unicode code point value. If the input string is empty, returns `null`. + * @param {string|string[]} str source string. + * @return {integer|integer[]} Unicode code point value. + * If the input string is empty, returns `null`. * @function codePoint * @example * codePoint("ABC") // 65 */ codePoint: { - _func: args => { - const text = toString(args[0]); + _func: args => evaluate(args, arg => { + const text = toString(arg); return text.length === 0 ? null : text.codePointAt(0); - }, + }), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -313,15 +591,15 @@ export default function functions( }, /** * Compute the cosine (in radians) of a number. - * @param {number} angle A number representing an angle in radians - * @return {number} The cosine of the angle, between -1 and 1, inclusive. + * @param {number|number[]} angle A number representing an angle in radians + * @return {number|number[]} The cosine of the angle, between -1 and 1, inclusive. * @function cos * @example * cos(1.0471975512) => 0.4999999999970535 */ cos: { - _func: resolvedArgs => Math.cos(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.cos), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -335,15 +613,15 @@ export default function functions( * after subtracting whole years. * * `yd` the number of days between `start_date` and `end_date`, assuming `start_date` * and `end_date` were no more than one year apart - * @param {number} start_date The starting <<_date_and_time_values, date/time value>>. + * @param {number|number[]} start_date The starting <<_date_and_time_values, date/time value>>. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @param {number} end_date The end <<_date_and_time_values, date/time value>> -- must + * @param {number|number[]} end_date The end <<_date_and_time_values, date/time value>> -- must * be greater or equal to start_date. If not, an error will be thrown. - * @param {string} unit Case-insensitive string representing the unit of time to measure. - * An unrecognized unit will result in an error. - * @returns {integer} The number of days/months/years difference + * @param {string|string[]} unit Case-insensitive string representing the unit of + * time to measure. An unrecognized unit will result in an error. + * @returns {integer|integer[]} The number of days/months/years difference * @function datedif * @example * datedif(datetime(2001, 1, 1), datetime(2003, 1, 1), "y") // returns 2 @@ -353,44 +631,11 @@ export default function functions( * // 75 days between June 1 and August 15, ignoring the years of the dates (75) */ datedif: { - _func: args => { - const unit = toString(args[2]).toLowerCase(); - const date1 = getDateObj(args[0]); - const date2 = getDateObj(args[1]); - if (date2 === date1) return 0; - if (date2 < date1) throw functionError('end_date must be >= start_date in datedif()'); - - if (unit === 'd') return Math.floor(getDateNum(date2 - date1)); - const yearDiff = date2.getFullYear() - date1.getFullYear(); - let monthDiff = date2.getMonth() - date1.getMonth(); - const dayDiff = date2.getDate() - date1.getDate(); - - if (unit === 'y') { - let y = yearDiff; - if (monthDiff < 0) y -= 1; - if (monthDiff === 0 && dayDiff < 0) y -= 1; - return y; - } - if (unit === 'm') { - return yearDiff * 12 + monthDiff + (dayDiff < 0 ? -1 : 0); - } - if (unit === 'ym') { - if (dayDiff < 0) monthDiff -= 1; - if (monthDiff <= 0 && yearDiff > 0) return 12 + monthDiff; - return monthDiff; - } - if (unit === 'yd') { - if (dayDiff < 0) monthDiff -= 1; - if (monthDiff < 0) date2.setFullYear(date1.getFullYear() + 1); - else date2.setFullYear(date1.getFullYear()); - return Math.floor(getDateNum(date2 - date1)); - } - throw functionError(`Unrecognized unit parameter "${unit}" for datedif()`); - }, + _func: args => evaluate(args, datedifFn), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -435,30 +680,30 @@ export default function functions( return getDateNum(baseDate); }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, ], }, /** * Finds the day of the month for a date value - * @param {number} date <<_date_and_time_values, date/time value>> generated using the + * @param {number|number[]} date <<_date_and_time_values, date/time value>> generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} The day of the month ranging from 1 to 31. + * @return {integer|integer[]} The day of the month ranging from 1 to 31. * @function day * @example * day(datetime(2008,5,23)) // returns 23 */ day: { - _func: args => getDateObj(args[0]).getDate(), + _func: args => evaluate(args, a => getDateObj(a).getDate()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -491,8 +736,8 @@ export default function functions( return arg; }, _signature: [ - { types: [dataTypes.TYPE_ANY] }, - { types: [dataTypes.TYPE_ANY, dataTypes.TYPE_EXPREF], optional: true }, + { types: [TYPE_ANY] }, + { types: [TYPE_ANY, TYPE_EXPREF], optional: true }, ], }, @@ -532,31 +777,27 @@ export default function functions( return items; }, _signature: [ - { types: [dataTypes.TYPE_OBJECT, dataTypes.TYPE_ARRAY, dataTypes.TYPE_NULL] }, - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_NUMBER] }, + { types: [TYPE_OBJECT, TYPE_ARRAY, TYPE_NULL] }, + { types: [TYPE_STRING, TYPE_NUMBER] }, ], }, /** * Determines if the `subject` string ends with a specific `suffix` - * @param {string} subject source string in which to search - * @param {string} suffix search string - * @return {boolean} true if the `suffix` value is at the end of the `subject` + * @param {string|string[]} subject source string in which to search + * @param {string|string[]} suffix search string + * @return {boolean|boolean[]} true if the `suffix` value is at the end of the `subject` * @function endsWith * @example * endsWith("Abcd", "d") // returns true * endsWith("Abcd", "A") // returns false */ endsWith: { - _func: resolvedArgs => { - const searchStr = valueOf(resolvedArgs[0]); - const suffix = valueOf(resolvedArgs[1]); - // make sure the comparison is based on code points - const search = Array.from(searchStr).reverse(); - const ending = Array.from(suffix).reverse(); - return ending.every((c, i) => c === search[i]); - }, - _signature: [{ types: [TYPE_STRING] }, { types: [TYPE_STRING] }], + _func: args => evaluate(args, endsWithFn), + _signature: [ + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + ], }, /** @@ -578,8 +819,8 @@ export default function functions( _signature: [ { types: [ - dataTypes.TYPE_ARRAY, - dataTypes.TYPE_OBJECT, + TYPE_ARRAY, + TYPE_OBJECT, ], }, ], @@ -587,44 +828,37 @@ export default function functions( /** * Finds the date value of the end of a month, given `startDate` plus `monthAdd` months - * @param {number} startDate The base date to start from. + * @param {number|number[]} startDate The base date to start from. * <<_date_and_time_values, Date/time values>> can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @param {integer} monthAdd Number of months to add to start date - * @return {number} the date of the last day of the month + * @param {integer|integer[]} monthAdd Number of months to add to start date + * @return {number|number[]} the date of the last day of the month * @function eomonth * @example * eomonth(datetime(2011, 1, 1), 1) | [month(@), day(@)] // returns [2, 28] * eomonth(datetime(2011, 1, 1), -3) | [month(@), day(@)] // returns [10, 31] */ eomonth: { - _func: args => { - const jsDate = getDateObj(args[0]); - const months = toInteger(args[1]); - // We can give the constructor a month value > 11 and it will increment the years - // Since day is 1-based, giving zero will yield the last day of the previous month - const newDate = new Date(jsDate.getFullYear(), jsDate.getMonth() + months + 1, 0); - return getDateNum(newDate); - }, + _func: args => evaluate(args, eomonthFn), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Finds e (the base of natural logarithms) raised to a power. (i.e. e^x) - * @param {number} x A numeric expression representing the power of e. - * @returns {number} e (the base of natural logarithms) raised to power x + * @param {number|number[]} x A numeric expression representing the power of e. + * @returns {number|number[]} e (the base of natural logarithms) raised to power x * @function exp * @example * exp(10) // returns 22026.465794806718 */ exp: { - _func: args => Math.exp(args[0]), + _func: args => evaluate(args, Math.exp), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -641,11 +875,11 @@ export default function functions( /** * Finds and returns the index of query in text from a start position - * @param {string} findText string to search - * @param {string} withinText text to be searched - * @param {integer} [start=0] zero-based position to start searching. + * @param {string|string[]} findText string to search + * @param {string|string[]} withinText text to be searched + * @param {integer|integer[]} [start=0] zero-based position to start searching. * If specified, `start` must be greater than or equal to 0 - * @returns {integer|null} The position of the found string, null if not found. + * @returns {integer|null|integer[]} The position of the found string, null if not found. * @function find * @example * find("m", "abm") // returns 2 @@ -654,50 +888,38 @@ export default function functions( * find("M", "abMcdM", 2) // returns 2 */ find: { - _func: args => { - const query = Array.from(toString(args[0])); - const text = Array.from(toString(args[1])); - const offset = args.length > 2 ? toInteger(args[2]) : 0; - if (offset < 0) throw evaluationError('find() start position must be >= 0'); - if (query.length === 0) { - // allow an empty string to be found at any position -- including the end - if (offset > text.length) return null; - return offset; - } - for (let i = offset; i < text.length; i += 1) { - if (text.slice(i, i + query.length).every((c, j) => c === query[j])) { - return i; - } - } - return null; + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + if (args.length < 3) args.push(0); + return evaluate(args, findFn); }, _signature: [ - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER], optional: true }, ], }, /** * Calculates the next lowest integer value of the argument `num` by rounding down if necessary. * i.e. floor() rounds toward negative infinity. - * @param {number} num numeric value - * @return {integer} The largest integer smaller than or equal to num + * @param {number|number[]} num numeric value + * @return {integer|integer[]} The largest integer smaller than or equal to num * @function floor * @example * floor(10.4) // returns 10 * floor(10) // returns 10 */ floor: { - _func: resolvedArgs => Math.floor(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.floor), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Create a string from a code point. - * @param {integer} codePoint An integer between 0 and 0x10FFFF (inclusive) - * representing a Unicode code point. - * @return {string} A string from a given code point + * @param {integer|integer[]} codePoint An integer or array of integers + * between 0 and 0x10FFFF (inclusive) representing Unicode code point(s). + * @return {string} A string from the given code point(s) * @function fromCodePoint * @example * fromCodePoint(65) // "A" @@ -705,15 +927,15 @@ export default function functions( */ fromCodePoint: { _func: args => { - const code = toInteger(args[0]); try { - return String.fromCodePoint(code); + const points = Array.isArray(args[0]) ? args[0] : [args[0]]; + return String.fromCodePoint(...points.map(toInteger)); } catch (e) { - throw evaluationError(`Invalid code point: "${code}"`); + throw evaluationError(`Invalid code point: "${args[0]}"`); } }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -735,6 +957,7 @@ export default function functions( const array = args[0]; // validate beyond the TYPE_ARRAY_ARRAY check if (!array.every(a => { + if (!Array.isArray(a)) return false; if (a.length !== 2) return false; if (getType(a[0]) !== TYPE_STRING) return false; return true; @@ -744,22 +967,22 @@ export default function functions( return Object.fromEntries(array); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_ARRAY] }, + { types: [TYPE_ARRAY_ARRAY, TYPE_ARRAY_STRING, TYPE_ARRAY_NUMBER] }, ], }, /** * Compute the nearest 32-bit single precision float representation of a number - * @param {number} num input to be rounded - * @return {number} The rounded representation of `num` + * @param {number|number[]} num input to be rounded + * @return {number|number[]} The rounded representation of `num` * @function fround * @example * fround(2147483650.987) => 2147483648 * fround(100.44444444444444444444) => 100.44444274902344 */ fround: { - _func: resolvedArgs => Math.fround(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.fround), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -786,7 +1009,7 @@ export default function functions( const obj = valueOf(args[0]); if (obj === null) return false; const isArray = isArrayType(obj); - if (!(isArray || getType(obj) === dataTypes.TYPE_OBJECT)) { + if (!(isArray || getType(obj) === TYPE_OBJECT)) { throw typeError('First parameter to hasProperty() must be either an object or array.'); } @@ -798,27 +1021,25 @@ export default function functions( return result !== undefined; }, _signature: [ - { types: [dataTypes.TYPE_ANY] }, - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_NUMBER] }, + { types: [TYPE_ANY] }, + { types: [TYPE_STRING, TYPE_NUMBER] }, ], }, /** * Extract the hour from a <<_date_and_time_values, date/time value>> - * @param {number} date The datetime/time for which the hour is to be returned. + * @param {number|number[]} date The datetime/time for which the hour is to be returned. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} value between 0 and 23 + * @return {integer|integer[]} value between 0 and 23 * @function hour * @example * hour(datetime(2008,5,23,12, 0, 0)) // returns 12 * hour(time(12, 0, 0)) // returns 12 */ hour: { - _func: args => getDateObj(args[0]).getHours(), - _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - ], + _func: args => evaluate(args, a => getDateObj(a).getHours()), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -852,9 +1073,9 @@ export default function functions( return interpreter.visit(rightBranchNode, data); }, _signature: [ - { types: [dataTypes.TYPE_ANY] }, - { types: [dataTypes.TYPE_ANY] }, - { types: [dataTypes.TYPE_ANY] }], + { types: [TYPE_ANY] }, + { types: [TYPE_ANY] }, + { types: [TYPE_ANY] }], }, /** @@ -914,8 +1135,8 @@ export default function functions( return text.slice(0, numEntries).join(''); }, _signature: [ - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_ARRAY] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_NUMBER], optional: true }, ], }, @@ -949,45 +1170,42 @@ export default function functions( /** * Compute the natural logarithm (base e) of a number - * @param {number} num A number greater than zero - * @return {number} The natural log value + * @param {number|number[]} num A number greater than zero + * @return {number|number[]} The natural log value * @function log * @example * log(10) // 2.302585092994046 */ log: { - _func: resolvedArgs => validNumber(Math.log(resolvedArgs[0]), 'log'), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, a => validNumber(Math.log(a), 'log')), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Compute the base 10 logarithm of a number. - * @param {number} num A number greater than or equal to zero - * @return {number} The base 10 log result + * @param {number|number[]} num A number greater than or equal to zero + * @return {number|number[]} The base 10 log result * @function log10 * @example * log10(100000) // 5 */ log10: { - _func: resolvedArgs => validNumber(Math.log10(resolvedArgs[0]), 'log10'), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, a => validNumber(Math.log10(a), 'log10')), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Converts all the alphabetic code points in a string to lowercase. - * @param {string} input input string - * @returns {string} the lower case value of the input string + * @param {string|string[]} input input string + * @returns {string|string[]} the lower case value of the input string * @function lower * @example * lower("E. E. Cummings") // returns e. e. cummings */ lower: { - _func: args => { - const value = toString(args[0]); - return value.toLowerCase(); - }, + _func: args => evaluate(args, a => toString(a).toLowerCase()), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -1011,35 +1229,69 @@ export default function functions( }, /** - * Calculates the largest value in the provided `collection` arguments. - * If all collections are empty, an evaluation error is thrown. - * `max()` can work on numbers or strings, but not a combination of numbers and strings. - * If all values are null, the result is 0. - * @param {...(number[]|string[]|number|string)} collection values/array(s) in which the maximum + * Calculates the largest value in the input numbers. + * Any values that are not numbers (e.g. null, boolean, strings, objects) will be ignored. + * If any parameters are arrays, the arrays will be flattened. + * If no numbers are provided, the function will return zero. + * @param {...(number[]|number)} collection values/array(s) in which the maximum * element is to be calculated - * @return {number|string} the largest value found + * @return {number} the largest value found * @function max * @example * max([1, 2, 3], [4, 5, 6]) // returns 6 - * max(["a", "a1", "b"]) // returns "b" - * max(8, 10, 12) // returns 12 + * max([\"a\", \"a1\", \"b\"], null(), true())) // returns 0 + * max(8, 10, 12, "14") // returns 12 */ max: { _func: args => { // flatten the args into a single array - const array = args.reduce((prev, cur) => prev.concat(cur), []); - if (array.length === 0) throw evaluationError('max() requires at least one argument'); - const isNumber = a => getType(a) === TYPE_NUMBER; - const isString = a => getType(a) === TYPE_STRING; - if (!(array.every(isNumber) || array.every(isString))) { - throw typeError('max() requires all arguments to be of the same type'); + const array = args + .flat(Infinity) + .filter(a => typeof valueOf(a) === 'number'); + + if (array.length === 0) return 0; + + return Math.max(...array); + }, + _signature: [{ + types: [TYPE_ARRAY, TYPE_ANY], + variadic: true, + }], + }, + + /** + * Calculates the largest value in the input values, coercing parameters to numbers. + * Null values are ignored. + * If any parameters cannot be converted to a number, + * the function will fail with an type error. + * If any parameters are arrays, the arrays will be flattened. + * If no numbers are provided, the function will return zero. + * @param {...(any)} collection values/array(s) in which the maximum + * element is to be calculated + * @return {number} the largest value found + * @function maxA + * @example + * maxA([1, 2, 3], [4, 5, 6]) // returns 6 + * maxA(["a", "a1", "b", null()]) // error + * maxA(8, 10, 12, "14") // returns 14 + */ + maxA: { + _func: args => { + // flatten the args into a single array + const array = args + .flat(Infinity) + .filter(a => valueOf(a) !== null) + .map(toNumber); + + if (array.find(a => a === null)) { + throw evaluationError('maxA() received non-numeric parameters'); } - return array - .sort((a, b) => (a > b ? 1 : -1)) - .pop(); + if (array.length === 0) return 0; + + return Math.max(...array); }, _signature: [{ - types: [TYPE_ARRAY_NUMBER, TYPE_ARRAY_STRING, TYPE_NUMBER, TYPE_STRING], + types: [TYPE_ARRAY, TYPE_ANY], variadic: true, }], }, @@ -1101,89 +1353,119 @@ export default function functions( return text.slice(startPos, startPos + numEntries).join(''); }, _signature: [ - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_ARRAY] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER] }, ], }, /** * Extract the milliseconds of the time value in a <<_date_and_time_values, date/time value>>. - * @param {number} date datetime/time for which the millisecond is to be returned. + * @param {number|number[]} date datetime/time for which the millisecond is to be returned. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} The number of milliseconds: 0 through 999 + * @return {integer|integer[]} The number of milliseconds: 0 through 999 * @function millisecond * @example * millisecond(datetime(2008, 5, 23, 12, 10, 53, 42)) // returns 42 */ millisecond: { - _func: args => getDateObj(args[0]).getMilliseconds(), + _func: args => evaluate(args, a => getDateObj(a).getMilliseconds()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** - * Calculates the smallest value in the input arguments. - * If all collections/values are empty, an evaluation error is thrown. - * `min()` can work on numbers or strings, but not a combination of numbers and strings. - * If all values are null, zero is returned. - * @param {...(number[]|string[]|number|string)} collection + * Calculates the smallest value in the input numbers. + * Any values that are not numbers (e.g. null, boolean, strings, objects) will be ignored. + * If any parameters are arrays, the arrays will be flattened. + * If no numbers are provided, the function will return zero. + * @param {...(number[]|number)} collection * Values/arrays to search for the minimum value - * @return {number|string} the smallest value found + * @return {number} the smallest value found * @function min * @example * min([1, 2, 3], [4, 5, 6]) // returns 1 - * min(["a", "a1", "b"]) // returns "a" - * min(8, 10, 12) // returns 8 + * min("4", 8, 10, 12, null()) // returns 8 */ min: { _func: args => { // flatten the args into a single array - const array = args.reduce((prev, cur) => prev.concat(cur), []); - if (array.length === 0) throw evaluationError('min() requires at least one argument'); + const array = args + .flat(Infinity) + .filter(a => typeof valueOf(a) === 'number'); + if (array.length === 0) return 0; - const isNumber = a => getType(a) === TYPE_NUMBER; - const isString = a => getType(a) === TYPE_STRING; - if (!(array.every(isNumber) || array.every(isString))) { - throw typeError('max() requires all arguments to be of the same type'); + return Math.min(...array); + }, + _signature: [{ + types: [TYPE_ARRAY, TYPE_ANY], + variadic: true, + }], + }, + + /** + * Calculates the smallest value in the input values, coercing parameters to numbers. + * Null values are ignored. + * If any parameters cannot be converted to a number, + * the function will fail with an type error. + * If any parameters are arrays, the arrays will be flattened. + * If no numbers are provided, the function will return zero. + * @param {...(any)} collection values/array(s) in which the maximum + * element is to be calculated + * @return {number} the largest value found + * @function minA + * @example + * minA([1, 2, 3], [4, 5, 6]) // returns 1 + * minA("4", 8, 10, 12, null()) // returns 4 + */ + minA: { + _func: args => { + // flatten the args into a single array + const array = args + .flat(Infinity) + .filter(a => valueOf(a) !== null) + .map(toNumber); + + if (array.find(a => a === null)) { + throw evaluationError('minA() received non-numeric parameters'); } - return array - .sort((a, b) => (a < b ? 1 : -1)) - .pop(); + if (array.length === 0) return 0; + + return Math.min(...array); }, _signature: [{ - types: [TYPE_ARRAY_NUMBER, TYPE_ARRAY_STRING, TYPE_NUMBER, TYPE_STRING], + types: [TYPE_ARRAY, TYPE_ANY], variadic: true, }], }, /** * Extract the minute (0 through 59) from a <<_date_and_time_values, date/time value>> - * @param {number} date A datetime/time value. + * @param {number|number[]} date A datetime/time value. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} Number of minutes in the time portion of the date/time value + * @return {integer|integer[]} Number of minutes in the time portion of the date/time value * @function minute * @example * minute(datetime(2008,5,23,12, 10, 0)) // returns 10 * minute(time(12, 10, 0)) // returns 10 */ minute: { - _func: args => getDateObj(args[0]).getMinutes(), + _func: args => evaluate(args, a => getDateObj(a).getMinutes()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Return the remainder when one number is divided by another number. - * @param {number} dividend The number for which to find the remainder. - * @param {number} divisor The number by which to divide number. - * @return {number} Computes the remainder of `dividend`/`divisor`. + * @param {number|number[]} dividend The number for which to find the remainder. + * @param {number|number[]} divisor The number by which to divide number. + * @return {number|number[]} Computes the remainder of `dividend`/`divisor`. * If `dividend` is negative, the result will also be negative. * If `dividend` is zero, an error is thrown. * @function mod @@ -1192,35 +1474,33 @@ export default function functions( * mod(-3, 2) // returns -1 */ mod: { - _func: args => { - const p1 = args[0]; - const p2 = args[1]; - const result = p1 % p2; - if (Number.isNaN(result)) throw evaluationError(`Bad parameter for mod: '${p1} % ${p2}'`); + _func: args => evaluate(args, (a, b) => { + const result = a % b; + if (Number.isNaN(result)) throw evaluationError(`Bad parameter for mod: '${a} % ${b}'`); return result; - }, + }), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Finds the month of a date. - * @param {number} date source <<_date_and_time_values, date/time value>>. + * @param {number|number[]} date source <<_date_and_time_values, date/time value>>. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} The month number value, ranging from 1 (January) to 12 (December). + * @return {integer|integer[]} The month number value, ranging from 1 (January) to 12 (December) * @function month * @example * month(datetime(2008,5,23)) // returns 5 */ month: { // javascript months start from 0 - _func: args => getDateObj(args[0]).getMonth() + 1, + _func: args => evaluate(args, a => getDateObj(a).getMonth() + 1), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -1239,7 +1519,7 @@ export default function functions( */ not: { _func: resolveArgs => !toBoolean(valueOf(resolveArgs[0])), - _signature: [{ types: [dataTypes.TYPE_ANY] }], + _signature: [{ types: [TYPE_ANY] }], }, /** @@ -1302,23 +1582,23 @@ export default function functions( }); return result; }, - _signature: [{ types: [dataTypes.TYPE_ANY], variadic: true }], + _signature: [{ types: [TYPE_ANY], variadic: true }], }, /** * Computes `a` raised to a power `x`. (a^x) - * @param {number} a The base number -- can be any real number. - * @param {number} x The exponent to which the base number is raised. - * @return {number} + * @param {number|number[]} a The base number -- can be any real number. + * @param {number|number[]} x The exponent to which the base number is raised. + * @return {number|number[]} * @function power * @example * power(10, 2) // returns 100 (10 raised to power 2) */ power: { - _func: args => validNumber(args[0] ** args[1], 'power'), + _func: args => evaluate(args, (a, b) => validNumber(a ** b, 'power')), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -1328,8 +1608,8 @@ export default function functions( * uppercase letter and the rest of the letters in the word converted to lowercase. * Words are demarcated by whitespace, punctuation, or numbers. * Specifically, any character(s) matching the regular expression: `[\s\d\p{P}]+`. - * @param {string} text source string - * @returns {string} source string with proper casing applied. + * @param {string|string[]} text source string + * @returns {string|string[]} source string with proper casing applied. * @function proper * @example * proper("this is a TITLE") // returns "This Is A Title" @@ -1337,16 +1617,9 @@ export default function functions( * proper("76BudGet") // returns "76Budget" */ proper: { - _func: args => { - const capitalize = word => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`; - const original = toString(args[0]); - // split the string by whitespace, punctuation, and numbers - const wordParts = original.match(/[\s\d\p{P}]+|[^\s\d\p{P}]+/gu); - if (wordParts !== null) return wordParts.map(w => capitalize(w)).join(''); - return capitalize(original); - }, + _func: args => evaluate(args, properFn), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -1410,9 +1683,9 @@ export default function functions( * Note that implementations are not required to provide `register()` in order to be conformant. * Built-in functions may not be overridden. * @param {string} functionName Name of the function to register. - * `functionName` must begin with an underscore and follow the regular + * `functionName` must begin with an underscore or uppercase letter and follow the regular * expression pattern: - * `{caret}_{startsb}_a-zA-Z0-9${endsb}{asterisk}$` + * `{caret}{startsb}_A-Z{endsb}{startsb}_a-zA-Z0-9${endsb}{asterisk}$` * @param {expression} expr Expression to execute with this function call * @return {{}} returns an empty object * @function register @@ -1426,7 +1699,7 @@ export default function functions( const functionName = resolvedArgs[0]; const exprefNode = resolvedArgs[1]; - if (!/^_[_a-zA-Z0-9$]*$/.test(functionName)) throw functionError(`Invalid function name: "${functionName}"`); + if (!/^[_A-Z][_a-zA-Z0-9$]*$/.test(functionName)) throw functionError(`Invalid function name: "${functionName}"`); if (functionMap[functionName] && functionMap[functionName]._exprefNode.value !== exprefNode.value) { // custom functions can be re-registered as long as the expression is the same @@ -1445,6 +1718,53 @@ export default function functions( ], }, + /** + * Register a function that accepts multiple parameters. + * A function may not be re-registered with a different definition. + * Note that implementations are not required to provide `registerWithParams()` + * in order to be conformant. + * Built-in functions may not be overridden. + * @param {string} functionName Name of the function to register. + * `functionName` must begin with an underscore or uppercase letter and follow the regular + * expression pattern: + * `{caret}{startsb}_A-Z{endsb}{startsb}_a-zA-Z0-9${endsb}{asterisk}$` + * @param {expression} expr Expression to execute with this function call. + * Parameters are passed as an array. + * @return {{}} returns an empty object + * @function registerWithParams + * @example + * registerWithParams("Product", &@[0] * @[1]) + * // can now call: Product(2,21) => returns 42 + * registerWithParams( + * "Ltrim", + * &split(@[0],"").reduce(@, &accumulated & current | if(@ = " ", "", @), "") + * ) + * // Ltrim(" abc ") => returns "abc " + */ + registerWithParams: { + _func: resolvedArgs => { + const functionName = resolvedArgs[0]; + const exprefNode = resolvedArgs[1]; + + if (!/^[_A-Z][_a-zA-Z0-9$]*$/.test(functionName)) throw functionError(`Invalid function name: "${functionName}"`); + if (functionMap[functionName] + && functionMap[functionName]._exprefNode.value !== exprefNode.value) { + // custom functions can be re-registered as long as the expression is the same + throw functionError(`Cannot override function: "${functionName}" with a different definition`); + } + functionMap[functionName] = { + _func: args => runtime.interpreter.visit(exprefNode, args), + _signature: [{ types: [TYPE_ANY], optional: true, variadic: true }], + _exprefNode: exprefNode, + }; + return {}; + }, + _signature: [ + { types: [TYPE_STRING] }, + { types: [TYPE_EXPREF] }, + ], + }, + /** * Generates text (or an array) where we substitute elements at a given start position and * length, with new text (or array elements). @@ -1489,34 +1809,29 @@ export default function functions( return subject.join(''); }, _signature: [ - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_ARRAY] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_ANY] }, + { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_ANY] }, ], }, /** * Return text repeated `count` times. - * @param {string} text text to repeat - * @param {integer} count number of times to repeat the text. + * @param {string|string[]} text text to repeat + * @param {integer|integer[]} count number of times to repeat the text. * Must be greater than or equal to 0. - * @returns {string} Text generated from the repeated text. + * @returns {string|string[]} Text generated from the repeated text. * if `count` is zero, returns an empty string. * @function rept * @example * rept("x", 5) // returns "xxxxx" */ rept: { - _func: args => { - const text = toString(args[0]); - const count = toInteger(args[1]); - if (count < 0) throw evaluationError('rept() count must be greater than or equal to 0'); - return text.repeat(count); - }, + _func: args => evaluate(args, reptFn), _signature: [ - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -1567,8 +1882,8 @@ export default function functions( return text.slice(numEntries * -1).join(''); }, _signature: [ - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_ARRAY] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_NUMBER], optional: true }, ], }, @@ -1578,9 +1893,9 @@ export default function functions( * * If `precision` is greater than zero, round to the specified number of decimal places. * * If `precision` is 0, round to the nearest integer. * * If `precision` is less than 0, round to the left of the decimal point. - * @param {number} num number to round - * @param {integer} [precision=0] precision to use for the rounding operation. - * @returns {number} rounded value. Rounding a half value will round up. + * @param {number|number[]} num number to round + * @param {integer|integer[]} [precision=0] precision to use for the rounding operation. + * @returns {number|number[]} rounded value. Rounding a half value will round up. * @function round * @example * round(2.15, 1) // returns 2.2 @@ -1592,10 +1907,17 @@ export default function functions( * round(-1.5) // -1 */ round: { - _func: args => round(args[0], args.length > 1 ? toInteger(args[1]) : 0), + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + if (args.length < 2)args.push(0); + return evaluate(args, (a, n) => { + const digits = toInteger(n); + return round(a, digits); + }); + }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER], optional: true }, ], }, @@ -1606,9 +1928,10 @@ export default function functions( * precede them with an escape (`{backslash}`) character. * Note that the wildcard search is not greedy. * e.g. `search("a{asterisk}b", "abb")` will return `[0, "ab"]` Not `[0, "abb"]` - * @param {string} findText the search string -- which may include wild cards. - * @param {string} withinText The string to search. - * @param {integer} [startPos=0] The zero-based position of withinText to start searching. + * @param {string|string[]} findText the search string -- which may include wild cards. + * @param {string|string[]} withinText The string to search. + * @param {integer|integer[]} [startPos=0] The zero-based position of withinText + * to start searching. * A negative value is not allowed. * @returns {array} returns an array with two values: * @@ -1619,88 +1942,42 @@ export default function functions( * search("a?c", "acabc") // returns [2, "abc"] */ search: { - _func: args => { - const findText = toString(args[0]); - const withinText = toString(args[1]); - const startPos = args.length > 2 ? toInteger(args[2]) : 0; - if (startPos < 0) throw functionError('search() startPos must be greater than or equal to 0'); - if (findText === null || withinText === null || withinText.length === 0) return []; - - // Process as an array of code points - // Find escapes and wildcards - const globString = Array.from(findText).reduce((acc, cur) => { - if (acc.escape) return { escape: false, result: acc.result.concat(cur) }; - if (cur === '\\') return { escape: true, result: acc.result }; - if (cur === '?') return { escape: false, result: acc.result.concat('dot') }; - if (cur === '*') { - // consecutive * are treated as a single * - if (acc.result.slice(-1).pop() === 'star') return acc; - return { escape: false, result: acc.result.concat('star') }; - } - return { escape: false, result: acc.result.concat(cur) }; - }, { escape: false, result: [] }).result; - - const testMatch = (array, glob, match) => { - // we've consumed the entire glob, so we're done - if (glob.length === 0) return match; - // we've consumed the entire array, but there's still glob left -- no match - if (array.length === 0) return null; - const testChar = array[0]; - let [globChar, ...nextGlob] = glob; - const isStar = globChar === 'star'; - if (isStar) { - // '*' is at the end of the match -- so we're done matching - if (glob.length === 1) return match; - // we'll check for a match past the * and if not found, we'll process the * - [globChar, ...nextGlob] = glob.slice(1); - } - if (testChar === globChar || globChar === 'dot') { - return testMatch(array.slice(1), nextGlob, match.concat(testChar)); - } - // no match, so consume wildcard * - if (isStar) return testMatch(array.slice(1), glob, match.concat(testChar)); - - return null; - }; - // process code points - const within = Array.from(withinText); - for (let i = startPos; i < within.length; i += 1) { - const result = testMatch(within.slice(i), globString, []); - if (result !== null) return [i, result.join('')]; - } - return []; + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + if (args.length < 2) args.push(0); + return evaluate(args, searchFn); }, _signature: [ - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER], optional: true }, ], }, /** * Extract the seconds of the time value in a <<_date_and_time_values, date/time value>>. - * @param {number} date datetime/time for which the second is to be returned. + * @param {number|number[]} date datetime/time for which the second is to be returned. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} The number of seconds: 0 through 59 + * @return {integer|integer[]} The number of seconds: 0 through 59 * @function second * @example * second(datetime(2008,5,23,12, 10, 53)) // returns 53 * second(time(12, 10, 53)) // returns 53 */ second: { - _func: args => getDateObj(args[0]).getSeconds(), + _func: args => evaluate(args, a => getDateObj(a).getSeconds()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Computes the sign of a number passed as argument. - * @param {number} num any number - * @return {number} returns 1 or -1, indicating the sign of `num`. + * @param {number|number[]} num any number + * @return {number|number[]} returns 1 or -1, indicating the sign of `num`. * If the `num` is 0, it will return 0. * @function sign * @example @@ -1709,48 +1986,77 @@ export default function functions( * sign(0) // 0 */ sign: { - _func: resolvedArgs => Math.sign(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.sign), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** * Computes the sine of a number in radians - * @param {number} angle A number representing an angle in radians. - * @return {number} The sine of `angle`, between -1 and 1, inclusive + * @param {number|number[]} angle A number representing an angle in radians. + * @return {number|number[]} The sine of `angle`, between -1 and 1, inclusive * @function sin * @example * sin(0) // 0 * sin(1) // 0.8414709848078965 */ sin: { - _func: resolvedArgs => Math.sin(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.sin), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** - * This function accepts an array of strings or numbers and returns an + * This function accepts an array values and returns an * array with the elements in sorted order. + * If there are mixed data types, the values will be grouped in order: + * numbers, strings, booleans, nulls * String sorting is based on code points and is not locale-sensitive. - * @param {number[]|string[]} list to be sorted - * @return {number[]|string[]} The ordered result + * If the sort encounters any objects or arrays, it will throw an evaluation error. + * @param {any[]} list to be sorted + * @return {any[]} The ordered result * @function sort * @example * sort([1, 2, 4, 3, 1]) // returns [1, 1, 2, 3, 4] + * sort(["20", 20, true(), "100", null(), 100]) // returns [20, 100, "100", "20", true, null] */ sort: { _func: resolvedArgs => { - const array = resolvedArgs[0].slice(); - if (array.length === 0) return []; - // JavaScript default sort converts numbers to strings - if (getType(array[0]) === TYPE_STRING) return array.sort(); + /* + numbers sort first + strings sort second + Booleans sort third + nulls sort last + */ + const typeVals = resolvedArgs[0].map(value => { + const type = getType(value); + if (![TYPE_NUMBER, TYPE_STRING, TYPE_BOOLEAN, TYPE_NULL].includes(type)) { + throw evaluationError('Bad datatype for sort'); + } + return { type, value }; + }); - return array.sort((a, b) => { + const sortFunction = (a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; - }); + }; + + const sorted = typeVals + .filter(v => v.type === TYPE_NUMBER) + .map(v => v.value) + .sort(sortFunction); + + sorted.push( + ...typeVals + .filter(v => v.type === TYPE_STRING) + .map(v => v.value) + .sort(), + ); + + sorted.push(...typeVals.filter(v => v.type === TYPE_BOOLEAN).map(v => v.value)); + sorted.push(...typeVals.filter(v => v.type === TYPE_NULL).map(v => v.value)); + return sorted; }, - _signature: [{ types: [TYPE_ARRAY_STRING, TYPE_ARRAY_NUMBER] }], + _signature: [{ types: [TYPE_ARRAY] }], }, /** @@ -1825,71 +2131,60 @@ export default function functions( /** * Split a string into an array, given a separator - * @param {string} string string to split - * @param {string} separator separator where the split(s) should occur - * @return {string[]} The array of separated strings + * @param {string|string[]} string string to split + * @param {string|string[]} separator separator where the split(s) should occur + * @return {string[]|string[][]} The array of separated strings * @function split * @example * split("abcdef", "") // returns ["a", "b", "c", "d", "e", "f"] * split("abcdef", "e") // returns ["abcd", "f"] */ split: { - _func: args => { - const str = toString(args[0]); - const separator = toString(args[1]); - // for empty separator, return an array of code points - return separator.length === 0 ? Array.from(str) : str.split(separator); - }, + _func: args => evaluate(args, splitFn), _signature: [ - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, /** * Find the square root of a number - * @param {number} num source number - * @return {number} The calculated square root value + * @param {number|number[]} num source number + * @return {number|number[]} The calculated square root value * @function sqrt * @example * sqrt(4) // returns 2 */ sqrt: { - _func: args => { - const result = Math.sqrt(args[0]); - return validNumber(result, 'sqrt'); - }, + _func: args => evaluate(args, arg => validNumber(Math.sqrt(arg), 'sqrt')), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, /** * Determine if a string starts with a prefix. - * @param {string} subject string to search - * @param {string} prefix prefix to search for - * @return {boolean} true if `prefix` matches the start of `subject` + * @param {string|string[]} subject string to search + * @param {string|string[]} prefix prefix to search for + * @return {boolean|boolean[]} true if `prefix` matches the start of `subject` * @function startsWith * @example * startsWith("jack is at home", "jack") // returns true */ startsWith: { - _func: resolvedArgs => { - const subject = Array.from(toString(resolvedArgs[0])); - const prefix = Array.from(toString(resolvedArgs[1])); - if (prefix.length > subject.length) return false; - for (let i = 0; i < prefix.length; i += 1) { - if (prefix[i] !== subject[i]) return false; - } - return true; - }, - _signature: [{ types: [TYPE_STRING] }, { types: [TYPE_STRING] }], + _func: args => evaluate(args, startsWithFn), + _signature: [ + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + ], }, /** * Estimates standard deviation based on a sample. * `stdev` assumes that its arguments are a sample of the entire population. * If your data represents a entire population, * then compute the standard deviation using [stdevp]{@link stdevp}. + * Non-numeric values (text, boolean, null etc) are ignored. + * If there are nested arrays, they are flattened. * @param {number[]} numbers The array of numbers comprising the population. * Array size must be greater than 1. * @returns {number} [Standard deviation](https://en.wikipedia.org/wiki/Standard_deviation) @@ -1900,7 +2195,9 @@ export default function functions( */ stdev: { _func: args => { - const values = args[0]; + const values = args.flat(Infinity) + .filter(a => getType(a) === TYPE_NUMBER); + if (values.length <= 1) throw evaluationError('stdev() must have at least two values'); const mean = values.reduce((a, b) => a + b, 0) / values.length; const sumSquare = values.reduce((a, b) => a + b * b, 0); @@ -1908,7 +2205,45 @@ export default function functions( return validNumber(result, 'stdev'); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_NUMBER] }, + { types: [TYPE_ARRAY] }, + ], + }, + + /** + * Estimates standard deviation based on a sample. + * `stdev` assumes that its arguments are a sample of the entire population. + * If your data represents a entire population, + * then compute the standard deviation using [stdevpA]{@link stdevpA}. + * Nested arrays are flattened. + * Null values are ignored. All other parameters are converted to number. + * If conversion to number fails, a type error is thrown. + * @param {number[]} numbers The array of numbers comprising the population. + * Array size must be greater than 1. + * @returns {number} [Standard deviation](https://en.wikipedia.org/wiki/Standard_deviation) + * @function stdevA + * @example + * stdevA([1345, "1301", 1368]) // returns 34.044089061098404 + * stdevpA([1345, 1301, "1368"]) // returns 27.797 + */ + stdevA: { + _func: args => { + let values; + try { + values = args.flat(Infinity) + .filter(a => getType(a) !== TYPE_NULL) + .map(toNumber); + } catch (_e) { + throw evaluationError('stdevA() received non-numeric parameters'); + } + + if (values.length <= 1) throw evaluationError('stdevA() must have at least two values'); + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const sumSquare = values.reduce((a, b) => a + b * b, 0); + const result = Math.sqrt((sumSquare - values.length * mean * mean) / (values.length - 1)); + return validNumber(result, 'stdevA'); + }, + _signature: [ + { types: [TYPE_ARRAY] }, ], }, @@ -1927,7 +2262,10 @@ export default function functions( */ stdevp: { _func: args => { - const values = args[0]; + const values = args[0] + .flat(Infinity) + .filter(a => getType(a) === TYPE_NUMBER); + if (values.length === 0) throw evaluationError('stdevp() must have at least one value'); const mean = values.reduce((a, b) => a + b, 0) / values.length; @@ -1936,7 +2274,42 @@ export default function functions( return validNumber(result, 'stdevp'); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_NUMBER] }, + { types: [TYPE_ARRAY] }, + ], + }, + + /** + * Calculates standard deviation based on the entire population given as arguments. + * `stdevpA` assumes that its arguments are the entire population. + * If your data represents a sample of the population, + * then compute the standard deviation using [stdevA]{@link stdevA}. + * Nested arrays are flattened. + * Null values are ignored. All other parameters are converted to number. + * If conversion to number fails, a type error is thrown. + * @param {number[]} numbers The array of numbers comprising the population. + * An empty array is not allowed. + * @returns {number} Calculated standard deviation + * @function stdevp + * @example + * stdevpA([1345, "1301", 1368]) // returns 27.797 + * stdevA([1345, 1301, "1368"]) // returns 34.044 + */ + stdevpA: { + _func: args => { + const values = args[0] + .flat(Infinity) + .filter(a => getType(a) !== TYPE_NULL) + .map(toNumber); + + if (values.length === 0) throw evaluationError('stdevp() must have at least one value'); + + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const meanSumSquare = values.reduce((a, b) => a + b * b, 0) / values.length; + const result = Math.sqrt(meanSumSquare - mean * mean); + return validNumber(result, 'stdevp'); + }, + _signature: [ + { types: [TYPE_ARRAY] }, ], }, @@ -1945,13 +2318,14 @@ export default function functions( * with text `old` replaced by text `new` (when searching from the left). * If there is no match, or if `old` has length 0, `text` is returned unchanged. * Note that `old` and `new` may have different lengths. - * @param {string} text The text for which to substitute code points. - * @param {string} old The text to replace. - * @param {string} new The text to replace `old` with. If `new` is an empty string, then - * occurrences of `old` are removed from `text`. - * @param {integer} [which] The zero-based occurrence of `old` text to replace with `new` text. + * @param {string|string[]} text The text for which to substitute code points. + * @param {string|string[]} old The text to replace. + * @param {string|string[]} new The text to replace `old` with. + * If `new` is an empty string, then occurrences of `old` are removed from `text`. + * @param {integer|integer[]} [which] + * The zero-based occurrence of `old` text to replace with `new` text. * If `which` parameter is omitted, every occurrence of `old` is replaced with `new`. - * @returns {string} replaced string + * @returns {string|string[]} replaced string * @function substitute * @example * substitute("Sales Data", "Sales", "Cost") // returns "Cost Data" @@ -1959,44 +2333,26 @@ export default function functions( * substitute("Quarter 1, 2011", "1", "2", 2)" // returns "Quarter 1, 2012" */ substitute: { - _func: args => { - const src = Array.from(toString(args[0])); - const old = Array.from(toString(args[1])); - const replacement = Array.from(toString(args[2])); - - if (old.length === 0) return args[0]; - - // no third parameter? replace all instances - let replaceAll = true; - let whch = 0; + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + let n; if (args.length > 3) { - replaceAll = false; - whch = toInteger(args[3]); - if (whch < 0) throw evaluationError('substitute() which parameter must be greater than or equal to 0'); - whch += 1; - } - - let found = 0; - const result = []; - // find the instances to replace - for (let j = 0; j < src.length;) { - const match = old.every((c, i) => src[j + i] === c); - if (match) found += 1; - if (match && (replaceAll || found === whch)) { - result.push(...replacement); - j += old.length; + if (Array.isArray(args[3])) { + n = args[3].map(toInteger); + if (n.find(o => o < 0) !== undefined) throw evaluationError('substitute() which parameter must be greater than or equal to 0'); } else { - result.push(src[j]); - j += 1; + n = toInteger(args[3]); + if (n < 0) throw evaluationError('substitute() which parameter must be greater than or equal to 0'); } + args[3] = n; } - return result.join(''); + return evaluate(args, substituteFn); }, _signature: [ - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_STRING] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER], optional: true }, ], }, @@ -2012,7 +2368,7 @@ export default function functions( sum: { _func: resolvedArgs => { let sum = 0; - resolvedArgs[0].forEach(arg => { + resolvedArgs[0].flat(Infinity).forEach(arg => { sum += arg * 1; }); return sum; @@ -2021,16 +2377,16 @@ export default function functions( }, /** * Computes the tangent of a number in radians - * @param {number} angle A number representing an angle in radians. - * @return {number} The tangent of `angle` + * @param {number|number[]} angle A number representing an angle in radians. + * @return {number|number[]} The tangent of `angle` * @function tan * @example * tan(0) // 0 * tan(1) // 1.5574077246549023 */ tan: { - _func: resolvedArgs => Math.tan(resolvedArgs[0]), - _signature: [{ types: [TYPE_NUMBER] }], + _func: args => evaluate(args, Math.tan), + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -2060,9 +2416,9 @@ export default function functions( return getDateNum(epochTime); }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER] }, + { types: [TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, ], }, @@ -2210,13 +2566,18 @@ export default function functions( try { return toNumber(num); } catch (e) { - debug.push(`Failed to convert "${num}" to number`); + const errorString = arg => { + const v = toJSON(arg); + return v.length > 50 ? `${v.substring(0, 20)} ...` : v; + }; + + debug.push(`Failed to convert "${errorString(num)}" to number`); return null; } }, _signature: [ { types: [TYPE_ANY] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, ], }, @@ -2243,21 +2604,16 @@ export default function functions( /** * Remove leading and trailing spaces (U+0020), and replace all internal multiple spaces * with a single space. Note that other whitespace characters are left intact. - * @param {string} text string to trim - * @return {string} trimmed string + * @param {string|string[]} text string to trim + * @return {string|string[]} trimmed string * @function trim * @example * trim(" ab c ") // returns "ab c" */ trim: { - _func: args => { - const text = toString(args[0]); - // only removes the space character - // other whitespace characters like \t \n left intact - return text.split(' ').filter(x => x).join(' '); - }, + _func: args => evaluate(args, s => toString(s).split(' ').filter(x => x).join(' ')), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -2273,27 +2629,27 @@ export default function functions( }, /** - * Truncates a number to an integer by removing the fractional part of the number. - * i.e. it rounds towards zero. - * @param {number} numA number to truncate - * @param {integer} [numB=0] A number specifying the number of decimal digits to preserve. - * @return {number} Truncated value - * @function trunc - * @example - * trunc(8.9) // returns 8 - * trunc(-8.9) // returns -8 - * trunc(8.912, 2) // returns 8.91 - */ + * Truncates a number to an integer by removing the fractional part of the number. + * i.e. it rounds towards zero. + * @param {number|number[]} numA number to truncate + * @param {integer|integer[]} [numB=0] + * A number specifying the number of decimal digits to preserve. + * @return {number|number[]} Truncated value + * @function trunc + * @example + * trunc(8.9) // returns 8 + * trunc(-8.9) // returns -8 + * trunc(8.912, 2) // returns 8.91 + */ trunc: { - _func: args => { - const number = args[0]; - const digits = args.length > 1 ? toInteger(args[1]) : 0; - const method = number >= 0 ? Math.floor : Math.ceil; - return method(number * 10 ** digits) / 10 ** digits; + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + if (args.length < 2) args.push(0); + return evaluate(args, truncFn); }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER], optional: true }, ], }, @@ -2353,22 +2709,22 @@ export default function functions( ); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY] }, + { types: [TYPE_ARRAY] }, ], }, /** * Converts all the alphabetic code points in a string to uppercase. - * @param {string} input input string - * @returns {string} the upper case value of the input string + * @param {string|string[]} input input string + * @returns {string|string[]} the upper case value of the input string * @function upper * @example * upper("abcd") // returns "ABCD" */ upper: { - _func: args => toString(args[0]).toUpperCase(), + _func: args => evaluate(args, a => toString(a).toUpperCase()), _signature: [ - { types: [dataTypes.TYPE_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -2397,7 +2753,7 @@ export default function functions( } const obj = valueOf(args[0]); if (obj === null) return null; - if (!(getType(obj) === dataTypes.TYPE_OBJECT || subjectArray)) { + if (!(getType(obj) === TYPE_OBJECT || subjectArray)) { throw typeError('First parameter to value() must be one of: object, array, null.'); } if (subjectArray) { @@ -2418,8 +2774,8 @@ export default function functions( return result; }, _signature: [ - { types: [dataTypes.TYPE_ANY] }, - { types: [dataTypes.TYPE_STRING, dataTypes.TYPE_NUMBER] }, + { types: [TYPE_ANY] }, + { types: [TYPE_STRING, TYPE_NUMBER] }, ], }, @@ -2446,15 +2802,15 @@ export default function functions( * * 1 : Sunday (1), Monday (2), ..., Saturday (7) * * 2 : Monday (1), Tuesday (2), ..., Sunday(7) * * 3 : Monday (0), Tuesday (1), ...., Sunday(6) - * @param {number} date <<_date_and_time_values, date/time value>> for + * @param {number|number[]} date <<_date_and_time_values, date/time value>> for * which the day of the week is to be returned. * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @param {integer} [returnType=1] Determines the + * @param {integer|integer[]} [returnType=1] Determines the * representation of the result. * An unrecognized returnType will result in a error. - * @returns {integer} day of the week + * @returns {integer|integer[]} day of the week * @function weekday * @example * weekday(datetime(2006,5,21)) // 1 @@ -2462,47 +2818,32 @@ export default function functions( * weekday(datetime(2006,5,21), 3) // 6 */ weekday: { - _func: args => { - const date = args[0]; - const type = args.length > 1 ? toInteger(args[1]) : 1; - const jsDate = getDateObj(date); - const day = jsDate.getDay(); - // day is in range [0-7) with 0 mapping to sunday - switch (type) { - case 1: - // range = [1, 7], sunday = 1 - return day + 1; - case 2: - // range = [1, 7] sunday = 7 - return ((day + 6) % 7) + 1; - case 3: - // range = [0, 6] sunday = 6 - return (day + 6) % 7; - default: - throw functionError(`Unsupported returnType: "${type}" for weekday()`); - } + _func: resolvedArgs => { + const args = resolvedArgs.slice(); + if (args.length < 2) args.push(1); + return evaluate(args, weekdayFn); }, _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, + { types: [TYPE_NUMBER], optional: true }, ], }, /** * Finds the year of a datetime value - * @param {number} date input <<_date_and_time_values, date/time value>> + * @param {number|number[]} date input <<_date_and_time_values, date/time value>> * Date/time values can be generated using the * [datetime]{@link datetime}, [toDate]{@link todate}, [today]{@link today}, [now]{@link now} * and [time]{@link time} functions. - * @return {integer} The year value + * @return {integer|integer[]} The year value * @function year * @example * year(datetime(2008,5,23)) // returns 2008 */ year: { - _func: args => getDateObj(args[0]).getFullYear(), + _func: args => evaluate(args, a => getDateObj(a).getFullYear()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, diff --git a/src/interpreter.js b/src/interpreter.js index 5b3dd6ed..7535dba0 100644 --- a/src/interpreter.js +++ b/src/interpreter.js @@ -116,7 +116,7 @@ class Runtime { const argsNeeded = signature.filter(arg => !arg.optional).length; const lastArg = signature[signature.length - 1]; if (lastArg.variadic) { - if (args.length < signature.length) { + if (args.length < signature.length && !lastArg.optional) { pluralized = signature.length === 1 ? ' argument' : ' arguments'; throw functionError(`${argName}() takes at least ${signature.length}${pluralized } but received ${args.length}`); diff --git a/src/matchType.js b/src/matchType.js index 8d041072..8cc56ef3 100644 --- a/src/matchType.js +++ b/src/matchType.js @@ -64,9 +64,9 @@ export function getType(inputObj) { if (t === 'boolean') return TYPE_BOOLEAN; if (Array.isArray(obj)) { if (obj.length === 0) return TYPE_EMPTY_ARRAY; + if (obj.flat(Infinity).every(a => getType(a) === TYPE_NUMBER)) return TYPE_ARRAY_NUMBER; + if (obj.flat(Infinity).every(a => getType(a) === TYPE_STRING)) return TYPE_ARRAY_STRING; if (obj.every(a => isArray(getType(a)))) return TYPE_ARRAY_ARRAY; - if (obj.every(a => getType(a) === TYPE_NUMBER)) return TYPE_ARRAY_NUMBER; - if (obj.every(a => getType(a) === TYPE_STRING)) return TYPE_ARRAY_STRING; return TYPE_ARRAY; } // Check if it's an expref. If it has, it's been @@ -92,6 +92,40 @@ export function getTypeName(arg) { return typeNameTable[getType(arg)]; } +function supportedConversion(from, to) { + const pairs = { + [TYPE_NUMBER]: [ + TYPE_STRING, + TYPE_ARRAY, + TYPE_ARRAY_NUMBER, + TYPE_BOOLEAN, + ], + [TYPE_BOOLEAN]: [ + TYPE_STRING, + TYPE_NUMBER, + TYPE_ARRAY, + ], + [TYPE_ARRAY]: [TYPE_BOOLEAN, TYPE_ARRAY_STRING, TYPE_ARRAY_NUMBER], + [TYPE_ARRAY_NUMBER]: [TYPE_BOOLEAN, TYPE_ARRAY_STRING, TYPE_ARRAY], + [TYPE_ARRAY_STRING]: [TYPE_BOOLEAN, TYPE_ARRAY_NUMBER, TYPE_ARRAY], + [TYPE_ARRAY_ARRAY]: [TYPE_BOOLEAN], + [TYPE_EMPTY_ARRAY]: [TYPE_BOOLEAN], + + [TYPE_OBJECT]: [TYPE_BOOLEAN], + [TYPE_NULL]: [ + TYPE_STRING, + TYPE_NUMBER, + TYPE_BOOLEAN, + ], + [TYPE_STRING]: [ + TYPE_NUMBER, + TYPE_ARRAY_STRING, + TYPE_ARRAY, + TYPE_BOOLEAN], + }; + return pairs[from].includes(to); +} + export function matchType(expectedList, argValue, context, toNumber, toString) { const actual = getType(argValue); if (argValue?.jmespathType === TOK_EXPREF && !expectedList.includes(TYPE_EXPREF)) { @@ -106,14 +140,19 @@ export function matchType(expectedList, argValue, context, toNumber, toString) { if (expectedList.some(type => match(type, actual))) return argValue; // if the function allows multiple types, we can't coerce the type and we need an exact match - const exactMatch = expectedList.length > 1; - const expected = expectedList[0]; + // Of the set of expected types, filter out the ones that can be coerced from the actual type + const filteredList = expectedList.filter(t => supportedConversion(actual, t)); + if (filteredList.length === 0) { + throw typeError(`${context} expected argument to be type ${typeNameTable[expectedList[0]]} but received type ${typeNameTable[actual]} instead.`); + } + const exactMatch = filteredList.length > 1; + const expected = filteredList[0]; let wrongType = false; // Can't coerce objects and arrays to any other type if (isArray(actual)) { if ([TYPE_ARRAY_NUMBER, TYPE_ARRAY_STRING].includes(expected)) { - if (argValue.some(a => { + if (argValue.flat(Infinity).some(a => { const t = getType(a); // can't coerce arrays or objects to numbers or strings return isArray(t) || isObject(t); @@ -133,24 +172,26 @@ export function matchType(expectedList, argValue, context, toNumber, toString) { if (isObject(actual) && expected === TYPE_BOOLEAN) { return Object.keys(argValue).length === 0; } + // no exact match, see if we can coerce an array type if (isArray(actual)) { const toArray = a => (Array.isArray(a) ? a : [a]); + const coerceString = a => (Array.isArray(a) ? a.map(coerceString) : toString(a)); + const coerceNumber = a => (Array.isArray(a) ? a.map(coerceNumber) : toNumber(a)); + if (expected === TYPE_BOOLEAN) return argValue.length > 0; - if (expected === TYPE_ARRAY_STRING) return argValue.map(toString); - if (expected === TYPE_ARRAY_NUMBER) return argValue.map(toNumber); + if (expected === TYPE_ARRAY_STRING) return argValue.map(coerceString); + if (expected === TYPE_ARRAY_NUMBER) return argValue.map(coerceNumber); if (expected === TYPE_ARRAY_ARRAY) return argValue.map(toArray); } if (!isArray(actual) && !isObject(actual)) { - if (expected === TYPE_ARRAY_STRING) return actual === TYPE_NULL ? [] : [toString(argValue)]; - if (expected === TYPE_ARRAY_NUMBER) return actual === TYPE_NULL ? [] : [toNumber(argValue)]; - if (expected === TYPE_ARRAY) return actual === TYPE_NULL ? [] : [argValue]; - if ([TYPE_ARRAY_ARRAY, TYPE_EMPTY_ARRAY].includes(expected) && actual === TYPE_NULL) return []; + if (expected === TYPE_ARRAY_STRING) return [toString(argValue)]; + if (expected === TYPE_ARRAY_NUMBER) return [toNumber(argValue)]; + if (expected === TYPE_ARRAY) return [argValue]; if (expected === TYPE_NUMBER) return toNumber(argValue); if (expected === TYPE_STRING) return toString(argValue); if (expected === TYPE_BOOLEAN) return !!argValue; - if (expected === TYPE_OBJECT && actual === TYPE_NULL) return {}; } throw typeError(`${context} expected argument to be type ${typeNameTable[expected]} but received type ${typeNameTable[actual]} instead.`); diff --git a/src/utils.js b/src/utils.js index e7725fc7..999d3dd9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -108,13 +108,15 @@ export function getProperty(obj, key) { return undefined; } -export function debugAvailable(debug, obj, key) { +export function debugAvailable(debug, obj, key, chainStart = null) { try { - debug.push(`Failed to find: '${key}'`); let available = []; if (isArray(obj) && obj.length > 0) { - available.push(`${0}..${obj.length - 1}`); + debug.push(`Failed to find: '${key}' on an array object.`); + debug.push(`Did you mean to use a projection? e.g. ${chainStart || 'array'}[*].${key}`); + return; } + debug.push(`Failed to find: '${key}'`); if (obj !== null) { available = [...available, ...Object.entries(Object.getOwnPropertyDescriptors(obj, key)) .filter(([k, desc]) => (desc?.enumerable || !!desc?.get) && !/^[0-9]+$/.test(k) && (!k.startsWith('$') || key.startsWith('$'))) diff --git a/test/docSamples.json b/test/docSamples.json index 85d7a999..68cc17df 100644 --- a/test/docSamples.json +++ b/test/docSamples.json @@ -148,13 +148,37 @@ "result": 7 }, { - "expression": "max([\"a\", \"a1\", \"b\"])", - "result": "b" + "expression": "max([\"a\", \"a1\", \"b\"], null(), true())", + "result": 0 }, { "expression": "max(8, 10, 12)", "result": 12 }, + { + "expression": "maxA([1, 2, 3], [4, 5, 6])", + "result": 6 + }, + { + "expression": "maxA([\"a\", \"a1\", \"b\", null()])", + "error": "TypeError" + }, + { + "expression": "maxA(8, 10, 12, \"14\")", + "result": 14 + }, + { + "expression": "minA([1, 2, 3], [4, 5, 6])", + "result": 1 + }, + { + "expression": "minA(\"4\", 8, 10, 12, null())", + "result": 4 + }, + { + "expression": "avgA([1, 2, \"3\", null()])", + "result": 2 + }, { "expression": "merge({a: 1, b: 2}, {c : 3, d: 4})", "result": { @@ -173,12 +197,12 @@ } }, { - "expression": "min([1, 2, 3], [4, 5, 6], 7)", + "expression": "min([1, 2, 3], [4, 5, 6], 7, false())", "result": 1 }, { "expression": "min([\"a\", \"a1\", \"b\"])", - "result": "a" + "result": 0 }, { "expression": "min(8, 10, 12)", @@ -205,6 +229,26 @@ "expression": "register(\"_product\", &@[0] * @[1]) | _product([2,21])", "result": 42 }, + { + "data": "registerWithParams(\"Product\", &@[0] * @[1])", + "expression": "registerWithParams(\"Product\", &@[0] * @[1]) | Product(2, 21)", + "result": 42 + }, + { + "data": "register(\"_ltrim\", &split(@,\"\").reduce(@, &accumulated & current | if(@ = \" \", \"\", @), \"\")) | _ltrim(\" abc \")", + "expression": "register(\"_ltrim\", &split(@,\"\").reduce(@, &accumulated & current | if(@ = \" \", \"\", @), \"\")) | _ltrim(\" abc \")", + "result": "abc " + }, + { + "data": "registerWithParams(\"Product\", &@[0] * @[1])", + "expression": "registerWithParams(\"Product\", &@[0] * @[1]) | Product(2, 21)", + "result": 42 + }, + { + "data": "registerWithParams(\"Ltrim\", &split(@[0],\"\").reduce(@, &accumulated & current | if(@ = \" \", \"\", @), \"\")) | Ltrim(\" abc \")", + "expression": "registerWithParams(\"Ltrim\", &split(@[0],\"\").reduce(@, &accumulated & current | if(@ = \" \", \"\", @), \"\")) | Ltrim(\" abc \")", + "result": "abc " + }, { "expression": "reverse([\"a\", \"b\", \"c\"])", "result": ["c", "b", "a"] @@ -213,6 +257,10 @@ "expression": "sort([1, 2, 4, 3, 1])", "result": [1, 1, 2, 3, 4] }, + { + "expression": "sort([\"20\", 20, true(), \"100\", null(), 100])", + "result": [20, 100, "100", "20", true, null] + }, { "expression": "sortBy([\"abcd\", \"e\", \"def\"], &length(@))", "result": ["e", "def", "abcd"] @@ -572,7 +620,15 @@ "result": 27.79688231918724 }, { - "expression": "stdev([1345, 1301, 1368])", + "expression": "stdevA([1345, 1301, 1368])", + "result": 34.044089061098404 + }, + { + "expression": "stdevpA([1345, \"1301\", 1368])", + "result": 27.79688231918724 + }, + { + "expression": "stdevA([1345, 1301, \"1368\"])", "result": 34.044089061098404 }, { diff --git a/test/extensions.spec.js b/test/extensions.spec.js index d1dbb6fd..91b6431a 100644 --- a/test/extensions.spec.js +++ b/test/extensions.spec.js @@ -140,8 +140,8 @@ test('debug output', () => { expect(debug).toEqual([ 'Index: 10 out of range for array size: 6', - 'Failed to find: \'$values\'', - 'Available fields: 0..5,\'$name\',\'$fields\',\'$value\'', + 'Failed to find: \'$values\' on an array object.', + 'Did you mean to use a projection? e.g. array[*].$values', 'Failed to find: \'foo\'', 'Available fields: \'array1\',\'prop\'', 'Failed to find: \'$readOnly\'', diff --git a/test/functions.json b/test/functions.json index 7a66a97f..5f21fa4b 100644 --- a/test/functions.json +++ b/test/functions.json @@ -57,9 +57,18 @@ "expression": "hasProperty(@, \"null_key\")", "result": true }, - { "expression": "hasProperty(`false`, \"key\")", "error": "TypeError"}, - { "expression": "hasProperty(42, \"key\")", "error": "TypeError"}, - { "expression": "hasProperty(\"abc\", \"key\")", "error": "TypeError"}, + { + "expression": "hasProperty(`false`, \"key\")", + "error": "TypeError" + }, + { + "expression": "hasProperty(42, \"key\")", + "error": "TypeError" + }, + { + "expression": "hasProperty(\"abc\", \"key\")", + "error": "TypeError" + }, { "expression": "and(true() true())", "error": "SyntaxError" @@ -120,10 +129,6 @@ "expression": "abs(foo)", "result": 1 }, - { - "expression": "abs(foo)", - "result": 1 - }, { "expression": "abs(str)", "error": "TypeError" @@ -133,17 +138,12 @@ "result": 3 }, { - "expression": "abs(array[1])", - "result": 3 + "expression": "abs([-1, 3, [-4, 5]])", + "result": [1, 3, [4, 5]] }, { "expression": "abs(`false`)", - "result": 0, - "was": "TypeError" - }, - { - "expression": "abs(`-24`)", - "result": 24 + "result": 0 }, { "expression": "abs(`-24`)", @@ -167,11 +167,11 @@ }, { "expression": "avg(array)", - "error": "TypeError" + "result": 2.75 }, { "expression": "avg('abc')", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "avg(foo)", @@ -184,7 +184,7 @@ }, { "expression": "avg(strings)", - "error": "TypeError" + "error": "EvaluationError" }, { "expression": "avg(`[]`)", @@ -192,12 +192,57 @@ }, { "expression": "avg(`[2,1,\"b\"]`)", - "error": "TypeError" + "result": 1.5 + }, + { + "expression": "avg([1, 2, [3, 4, `[5]`]])", + "result": 3 }, { "expression": "avg(`null`)", + "error": "TypeError" + }, + { + "expression": "avgA(numbers)", + "result": 2.75 + }, + { + "expression": "avgA(array)", + "error": "TypeError" + }, + { + "expression": "avgA('abc')", + "error": "TypeError" + }, + { + "expression": "avgA(foo)", + "result": -1, + "was": "TypeError" + }, + { + "expression": "avgA(@)", + "error": "TypeError" + }, + { + "expression": "avgA(strings)", + "error": "TypeError" + }, + { + "expression": "avgA(`[]`)", "error": "EvaluationError" }, + { + "expression": "avgA(`[2,1,\"b\"]`)", + "error": "TypeError" + }, + { + "expression": "avgA(`null`)", + "error": "TypeError" + }, + { + "expression": "avgA([1,[2,[3,[4,5]],6]])", + "result": 3.5 + }, { "expression": "ceil(`1.2`)", "result": 2 @@ -264,8 +309,7 @@ }, { "expression": "endsWith(str, `0`)", - "result": false, - "was": "TypeError" + "result": false }, { "expression": "floor(`1.2`)", @@ -425,15 +469,15 @@ }, { "expression": "max(strings)", - "result": "c" + "result": 0 }, { "expression": "max(abc)", - "error": "TypeError" + "result": 0 }, { "expression": "max(array)", - "error": "TypeError" + "result": 5 }, { "expression": "max(decimals)", @@ -441,19 +485,19 @@ }, { "expression": "max(empty_list)", - "error": "EvaluationError" + "result": 0 }, { "expression": "max(strings, [\"A\", \"B\", \"C\"])", - "result": "c" + "result": 0 }, { "expression": "max([\"D\", \"E\", \"F\"], [\"A\", \"B\", \"C\"])", - "result": "F" + "result": 0 }, { "expression": "max(empty_list, \"a\")", - "result": "a" + "result": 0 }, { "expression": "max(empty_list, decimals, 0)", @@ -469,24 +513,76 @@ }, { "expression": "max([-4, \"foo\"])", - "error": "TypeError" + "result": -4 }, { "expression": "max([null(), null()])", - "error": "TypeError" + "result": 0 }, { "expression": "max(2,\"3\")", - "error": "TypeError" + "result": 2 }, { "expression": "max([2,[4,5]])", + "result": 5 + }, + { + "expression": "maxA(numbers)", + "result": 5 + }, + { + "expression": "maxA(decimals)", + "result": 1.2 + }, + { + "expression": "maxA(strings)", "error": "TypeError" }, { - "expression": "min(2,\"3\")", + "expression": "maxA(abc)", + "result": 0 + }, + { + "expression": "maxA(array)", + "error": "TypeError" + }, + { + "expression": "maxA(empty_list)", + "result": 0 + }, + { + "expression": "maxA(strings, [\"A\", \"B\", \"C\"])", + "error": "TypeError" + }, + { + "expression": "maxA(empty_list, decimals, 0)", + "result": 1.2 + }, + { + "expression": "maxA([1, toNumber(\"2\")])", + "result": 2 + }, + { + "expression": "maxA([-4, \"foo\"])", "error": "TypeError" }, + { + "expression": "maxA([null(), null()])", + "result": 0 + }, + { + "expression": "maxA(2,\"3\")", + "result": 3 + }, + { + "expression": "maxA([2,[4,5]])", + "result": 5 + }, + { + "expression": "min(2,\"3\")", + "result": 2 + }, { "expression": "merge(`{}`)", "result": {} @@ -525,39 +621,35 @@ }, { "expression": "min(abc)", - "error": "TypeError" + "result": 0 }, { "expression": "min(array)", - "error": "TypeError" + "result": -1 }, { "expression": "min(empty_list)", - "error": "EvaluationError" + "result": 0 }, { "expression": "min([4, \"foo\"])", - "error": "TypeError" - }, - { - "expression": "min(decimals)", - "result": -1.5 + "result": 4 }, { "expression": "min(strings)", - "result": "a" + "result": 0 }, { "expression": "min(strings, [\"A\", \"B\", \"C\"])", - "result": "A" + "result": 0 }, { "expression": "min([\"D\", \"E\", \"F\"], [\"A\", \"B\", \"C\"])", - "result": "A" + "result": 0 }, { "expression": "min(empty_list, \"a\")", - "result": "a" + "result": 0 }, { "expression": "min(empty_list, decimals, 0)", @@ -569,12 +661,64 @@ }, { "expression": "min(`null`, 23, \"21\")", - "error": "TypeError" + "result": 23 }, { "expression": "min([null(), null()])", + "result": 0 + }, + { + "expression": "minA(numbers)", + "result": -1 + }, + { + "expression": "minA(decimals)", + "result": -1.5 + }, + { + "expression": "minA(abc)", + "result": 0 + }, + { + "expression": "minA(array)", + "error": "TypeError" + }, + { + "expression": "minA(empty_list)", + "result": 0 + }, + { + "expression": "minA([4, \"foo\"])", "error": "TypeError" }, + { + "expression": "minA(decimals)", + "result": -1.5 + }, + { + "expression": "minA(strings)", + "error": "TypeError" + }, + { + "expression": "minA(strings, [\"A\", \"B\", \"C\"])", + "error": "TypeError" + }, + { + "expression": "minA(empty_list, decimals, 0)", + "result": -1.5 + }, + { + "expression": "minA(empty_list, decimals, 0, [-2, 0])", + "result": -2 + }, + { + "expression": "minA(`null`, 23, \"21\")", + "result": 21 + }, + { + "expression": "minA([null(), null()])", + "result": 0 + }, { "expression": "type(\"abc\")", "result": "string" @@ -615,6 +759,14 @@ "expression": "sort(keys(objects))", "result": ["bar", "foo"] }, + { + "expression": "sort([1,2,[3,4]])", + "error": "EvaluationError" + }, + { + "expression": "sort([1,2,{a:[3,4]}])", + "error": "EvaluationError" + }, { "expression": "keys(foo)", "error": "TypeError" @@ -637,7 +789,7 @@ }, { "expression": "keys(`null`)", - "result": [] + "error": "TypeError" }, { "expression": "values(foo)", @@ -645,7 +797,7 @@ }, { "expression": "values(null())", - "result": [] + "error": "TypeError" }, { "expression": "join(strings, \", \")", @@ -733,8 +885,7 @@ }, { "expression": "startsWith(str, `0`)", - "result": false, - "was": "TypeError" + "result": false }, { "expression": "sum(numbers)", @@ -1028,7 +1179,7 @@ }, { "expression": "sort(array)", - "error": "TypeError" + "result": [-1, 3, 4, 5, "100", "a"] }, { "expression": "sort(abc)", @@ -1290,8 +1441,7 @@ }, { "expression": "map(badkey, &a)", - "result": [], - "was": "TypeError" + "error": "TypeError" }, { "expression": "map(empty, &foo)", @@ -1352,6 +1502,10 @@ "expression": "acos(0)", "result": 1.5707963267948966 }, + { + "expression": "acos([0, [0, 0]])", + "result": [1.5707963267948966, [1.5707963267948966, 1.5707963267948966]] + }, { "expression": "acos(1.01)", "error": "EvaluationError" @@ -1444,6 +1598,10 @@ "expression": "avg(&[1,1])", "error": "TypeError" }, + { + "expression": "avgA(&[1,1])", + "error": "TypeError" + }, { "expression": "casefold(&\"abc\")", "error": "TypeError" @@ -1560,6 +1718,10 @@ "expression": "max(&[1,2,3])", "error": "TypeError" }, + { + "expression": "maxA(&[1,2,3])", + "error": "TypeError" + }, { "expression": "merge(&{a: 1}, {b: 2})", "error": "TypeError" @@ -1576,6 +1738,10 @@ "expression": "min(&[1,2,3])", "error": "TypeError" }, + { + "expression": "minA(&[1,2,3])", + "error": "TypeError" + }, { "expression": "datetime(2000, 1, 1, 1, 1, 1, 12) | minute(&@)", "error": "TypeError" @@ -1680,6 +1846,14 @@ "expression": "stdevp(&[1,1])", "error": "TypeError" }, + { + "expression": "stdevA(&[1,1])", + "error": "TypeError" + }, + { + "expression": "stdevpA(&[1,1])", + "error": "TypeError" + }, { "expression": "substitute(&\"abc\", \"b\", \"d\")", "error": "TypeError" @@ -1753,5 +1927,721 @@ "error": "TypeError" } ] + }, + { + "given": { + "num": 3, + "arrayNum": [-3.333, -2.22, -1.1, 0, 1.1, 2.22, 3.333], + "arrayInt": [1,2,3,4,5,6,7], + "str": "abcdefg", + "arrayStr": ["abc", "bcd", "cde", "def", "efg"] + }, + "cases": [ + { + "expression": "abs(arrayNum)", + "result": [3.333, 2.22, 1.1, 0, 1.1, 2.22, 3.333] + }, + { + "expression": "acos(arrayInt / (arrayInt + 1))", + "result": [ + 1.0471975511965979, 0.8410686705679303, 0.7227342478134157, + 0.6435011087932843, 0.5856855434571508, 0.5410995259571458, + 0.5053605102841573 + ] + }, + { + "expression": "asin(arrayInt / (arrayInt + 1))", + "result": [ + 0.5235987755982989, 0.7297276562269663, 0.848062078981481, + 0.9272952180016123, 0.9851107833377457, 1.0296968008377507, + 1.0654358165107394 + ] + }, + { + "expression": "atan2(arrayNum, 10)", + "result": [ + -0.32172055409664824, -0.21845717120535865, -0.10955952677394436, 0, + 0.10955952677394436, 0.21845717120535865, 0.32172055409664824 + ] + }, + { + "expression": "atan2(arrayNum, arrayInt)", + "result": [ + -1.2793120068559851, -0.837483712611627, -0.3514447940035517, 0, + 0.2165503049760893, 0.35437991912343786, 0.44438039217805514 + ] + }, + { + "expression": "atan2(1, [1, [2, 3]])", + "result": [0.7853981633974483, [0.4636476090008061, 0.3217505543966422]] + }, + { + "expression": "ceil([1.1, [2.2, 3.3]])", + "result": [2, [3, 4]] + }, + { + "expression": "codePoint([\"A\", [\"B\", \"C\"]])", + "result": [65, [66, 67]] + }, + { + "expression": "now() | datedif([@, [@, @]], [@ + 1, [@ + 2, @ + 3]], \"d\")", + "result": [1, [2, 3]] + }, + { + "expression": "datetime(2024, 3, 8) | day([[@, @], [@, @]])", + "result": [[8, 8], [8, 8]] + }, + { + "expression": "endsWith([\"abc\", [\"def\", \"ghi\"]], \"c\")", + "result": [true, [false, false]] + }, + { + "expression": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1) | month(@) & \"/\" &day(@)", + "result": [["4/30", "4/30"], ["4/30", "4/30"]] + }, + { + "expression": "exp([1, [2, 3]])", + "result": [2.718281828459045, [7.38905609893065, 20.085536923187668]] + }, + { + "expression": "find(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [1, 2]])", + "result": [0, [null, null]] + }, + { + "expression": "floor([1.1, [2.2, 3.3]])", + "result": [1, [2, 3]] + }, + { + "expression": "fround([1.1, [2.2, 3.3]])", + "result": [1.100000023841858, [2.200000047683716, 3.299999952316284]] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45) | hour([[@, @], [@, @]])", + "result": [[13, 13], [13, 13]] + }, + { + "expression": "log([1, [2, 3]])", + "result": [0, [0.6931471805599453, 1.0986122886681096]] + }, + { + "expression": "log10([1, [2, 3]])", + "result": [0, [0.3010299956639812, 0.47712125471966244]] + }, + { + "expression": "lower([\"ABC\", [\"DEF\", \"GHI\"]])", + "result": ["abc", ["def", "ghi"]] + }, + { + "expression": "max([1, [2, 3]])", + "result": 3 + }, + { + "expression": "datetime(2024, 3, 8, 13, 45, 30, 500) | millisecond([[@, @], [@, @]])", + "result": [[500, 500], [500, 500]] + }, + { + "expression": "min([2, [1, 3]])", + "result": 1 + }, + { + "expression": "datetime(2024, 3, 8, 13, 45) | minute([[@, @], [@, @]])", + "result": [[45, 45], [45, 45]] + }, + { + "expression": "mod([2, [4, 6]], [1, [2, 3]])", + "result": [0, [0, 0]] + }, + { + "expression": "datetime(2024, 3, 8) | month([[@, @], [@, @]])", + "result": [[3, 3], [3, 3]] + }, + { + "expression": "power([2, [3, 4]], [2, [2, 2]])", + "result": [4, [9, 16]] + }, + { + "expression": "proper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["Abc", ["Def", "Ghi"]] + }, + { + "expression": "rept([\"a\", [\"b\", \"c\"]], [2, [2, 2]])", + "result": ["aa", ["bb", "cc"]] + }, + { + "expression": "round([1.5, [2.5, 3.5]])", + "result": [2, [3, 4]] + }, + { + "expression": "search(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [0, 0]])", + "result": [[0, "a"], [[], []]] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45, 30) | second([[@, @], [@, @]])", + "result": [[30, 30], [30, 30]] + }, + { + "expression": "sign([1.1, [-2.2, 3.3]])", + "result": [1, [-1, 1]] + }, + { + "expression": "sin([1, [2, 3]])", + "result": [0.8414709848078965, [0.9092974268256817, 0.1411200080598672]] + }, + { + "expression": "split([\"a,b\", [\"c,d\", \"e,f\"]], \",\")", + "result": [["a", "b"], [["c", "d"], ["e", "f"]]] + }, + { + "expression": "sqrt([4, [9, 16]])", + "result": [2, [3, 4]] + }, + { + "expression": "startsWith([\"abc\", [\"def\", \"ghi\"]], \"a\")", + "result": [true, [false, false]] + }, + { + "expression": "stdev([1, [2, 3]])", + "result": 1 + }, + { + "expression": "stdevA([1, [2, 3]])", + "result": 1 + }, + { + "expression": "stdevp([1, [2, 3]])", + "result": 0.8164965809277263 + }, + { + "expression": "stdevpA([1, [2, 3]])", + "result": 0.8164965809277263 + }, + { + "expression": "substitute([\"abc\", [\"aab\", \"ghi\"]], \"a\", \"A\", [0, [1, 0]])", + "result": ["Abc", ["aAb", "ghi"]] + }, + { + "expression": "sum([1, [2, 3]])", + "result": 6 + }, + { + "expression": "tan([1, [2, 3]])", + "result": [1.5574077246549023, [-2.185039863261519, -0.1425465430742778]] + }, + { + "expression": "trim([\" abc \", [\" def \", \" ghi \"]])", + "result": ["abc", ["def", "ghi"]] + }, + { + "expression": "trunc([1.5, [2.5, 3.5]])", + "result": [1, [2, 3]] + }, + { + "expression": "upper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["ABC", ["DEF", "GHI"]] + }, + { + "expression": "datetime(2024, 3, 8) | weekday([[@, @], [@, @]], 1)", + "result": [[6, 6], [6, 6]] + }, + { + "expression": "datetime(2024, 3, 8) | year([[@, @], [@, @]])", + "result": [[2024, 2024], [2024, 2024]] + }, + { + "expression": "abs([-1, 3, [-4, 5]])", + "result": [1, 3, [4, 5]] + }, + { + "expression": "avg([1, 2, [3, 4, `[5]`]])", + "result": 3 + }, + { + "expression": "asin([1, [0, 0.19866933079506123]])", + "result": [1.5707963267948966, [0, 0.2]] + }, + { + "expression": "casefold(arrayStr)", + "result": ["abc", "bcd", "cde", "def", "efg"] + }, + { + "expression": "ceil(arrayNum)", + "result": [-3, -2, -1, 0, 2, 3, 4] + }, + { + "expression": "codePoint(arrayStr)", + "result": [97, 98, 99, 100, 101] + }, + { + "expression": "cos([1, [2, 3]])", + "result": [0.5403023058681398, [-0.4161468365471424, -0.9899924966004454]] + }, + { + "expression": "cos(arrayNum)", + "result": [ + -0.9817374728267503, -0.6045522710579296, 0.4535961214255773, 1, + 0.4535961214255773, -0.6045522710579296, -0.9817374728267503 + ] + }, + { + "expression": "now() | datedif([@, [@, @]], [@ + 1, [@ + 2, @ + 3]], \"d\")", + "result": [1, [2, 3]] + }, + { + "expression": "now() | datedif(@, @ + 1, [\"y\",\"m\",\"d\",\"ym\",\"yd\"])", + "result": [0, 0, 1, 0, 1] + }, + { + "expression": "datetime(2024, 3, 8) | day([[@, @], [@, @]])", + "result": [[8, 8], [8, 8]] + }, + { + "expression": "now() | datedif([@, @], [@ + 1, @ + 2], \"d\")", + "result": [1, 2] + }, + { + "expression": "endsWith([\"abc\", [\"def\", \"ghi\"]], \"c\")", + "result": [true, [false, false]] + }, + { + "expression": "datetime(2024, 10, 12) | day([@, @ + 1])", + "result": [12, 13] + }, + { + "expression": "endsWith(arrayStr, arrayStr)", + "result": [true, true, true, true, true] + }, + { + "expression": "exp([1, [2, 3]])", + "result": [2.718281828459045, [7.38905609893065, 20.085536923187668]] + }, + { + "expression": "endsWith(arrayStr, \"c\")", + "result": [true, false, false, false, false] + }, + { + "expression": "find(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [1, 2]])", + "result": [0, [null, null]] + }, + { + "expression": "datetime(2024, 10, 12) | eomonth([@, @, @, @, @, @, @], 1)| [month(@) & day(@)][]", + "result": ["1130", "1130", "1130", "1130", "1130", "1130", "1130"] + }, + { + "expression": "floor([1.1, [2.2, 3.3]])", + "result": [1, [2, 3]] + }, + { + "expression": "{d: datetime(2024, 10, 12), n:arrayInt} | eomonth([@.d, @.d, @.d, @.d, @.d, @.d, @.d], @.n) | [month(@) & day(@)][]", + "result": ["1130", "1231", "131", "228", "331", "430", "531"] + }, + { + "expression": "fround([1.1, [2.2, 3.3]])", + "result": [1.100000023841858, [2.200000047683716, 3.299999952316284]] + }, + { + "expression": "exp(arrayInt)", + "result": [ + 2.718281828459045, 7.38905609893065, 20.085536923187668, + 54.598150033144236, 148.4131591025766, 403.4287934927351, + 1096.6331584284585 + ] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45) | hour([[@, @], [@, @]])", + "result": [[13, 13], [13, 13]] + }, + { + "expression": "find(arrayStr, arrayStr, [0,0,0,0,0,0])", + "result": [0, 0, 0, 0, 0, 0] + }, + { + "expression": "log([1, [2, 3]])", + "result": [0, [0.6931471805599453, 1.0986122886681096]] + }, + { + "expression": "find(\"c\", [\"cc\",\"ccc\",\"cccc\",\"ccccc\",\"cccccc\",\"ccccccc\",\"cccccccc\"], arrayInt)", + "result": [1, 2, 3, 4, 5, 6, 7] + }, + { + "expression": "log10([1, [2, 3]])", + "result": [0, [0.3010299956639812, 0.47712125471966244]] + }, + { + "expression": "find(arrayStr, arrayStr, 0)", + "result": [0, 0, 0, 0, 0] + }, + { + "expression": "lower([\"ABC\", [\"DEF\", \"GHI\"]])", + "result": ["abc", ["def", "ghi"]] + }, + { + "expression": "find(\"c\", \"abcdefg\", arrayInt)", + "result": [2, 2, null, null, null, null, null] + }, + { + "expression": "max([1, [2, 3]])", + "result": 3 + }, + { + "expression": "floor(arrayNum)", + "result": [-4, -3, -2, 0, 1, 2, 3] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45, 30, 500) | millisecond([[@, @], [@, @]])", + "result": [[500, 500], [500, 500]] + }, + { + "expression": "fromCodePoint(64 + arrayInt)", + "result": "ABCDEFG" + }, + { + "expression": "min([2, [1, 3]])", + "result": 1 + }, + { + "expression": "fround(arrayNum)", + "result": [ + -3.3329999446868896, -2.2200000286102295, -1.100000023841858, 0, + 1.100000023841858, 2.2200000286102295, 3.3329999446868896 + ] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45) | minute([[@, @], [@, @]])", + "result": [[45, 45], [45, 45]] + }, + { + "expression": "datetime(2024, 10, 12, 13) | hour([@, @])", + "result": [13, 13] + }, + { + "expression": "mod([2, [4, 6]], [1, [2, 3]])", + "result": [0, [0, 0]] + }, + { + "expression": "log(arrayInt)", + "result": [ + 0, 0.6931471805599453, 1.0986122886681096, 1.3862943611198906, + 1.6094379124341003, 1.791759469228055, 1.9459101490553132 + ] + }, + { + "expression": "datetime(2024, 3, 8) | month([[@, @], [@, @]])", + "result": [[3, 3], [3, 3]] + }, + { + "expression": "log10(arrayInt)", + "result": [ + 0, 0.3010299956639812, 0.47712125471966244, 0.6020599913279624, + 0.6989700043360189, 0.7781512503836436, 0.8450980400142568 + ] + }, + { + "expression": "power([2, [3, 4]], [2, [2, 2]])", + "result": [4, [9, 16]] + }, + { + "expression": "lower(arrayStr)", + "result": ["abc", "bcd", "cde", "def", "efg"] + }, + { + "expression": "proper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["Abc", ["Def", "Ghi"]] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | millisecond([@, @ + 1])", + "result": [16, 16] + }, + { + "expression": "rept([\"a\", [\"b\", \"c\"]], [2, [2, 2]])", + "result": ["aa", ["bb", "cc"]] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | minute([@, @ + 1])", + "result": [14, 14] + }, + { + "expression": "round([1.5, [2.5, 3.5]])", + "result": [2, [3, 4]] + }, + { + "expression": "mod(arrayInt, 2)", + "result": [1, 0, 1, 0, 1, 0, 1] + }, + { + "expression": "search(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [0, 0]])", + "result": [[0, "a"], [[], []]] + }, + { + "expression": "mod(arrayInt, arrayInt + 1)", + "result": [1, 2, 3, 4, 5, 6, 7] + }, + { + "expression": "datetime(2024, 3, 8, 13, 45, 30) | second([[@, @], [@, @]])", + "result": [[30, 30], [30, 30]] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | month([@, @])", + "result": [10, 10] + }, + { + "expression": "sign([1.1, [-2.2, 3.3]])", + "result": [1, [-1, 1]] + }, + { + "expression": "power(arrayInt, arrayInt)", + "result": [1, 4, 27, 256, 3125, 46656, 823543] + }, + { + "expression": "sin([1, [2, 3]])", + "result": [0.8414709848078965, [0.9092974268256817, 0.1411200080598672]] + }, + { + "expression": "power(arrayInt, 2)", + "result": [1, 4, 9, 16, 25, 36, 49] + }, + { + "expression": "split([\"a,b\", [\"c,d\", \"e,f\"]], \",\")", + "result": [["a", "b"], [["c", "d"], ["e", "f"]]] + }, + { + "expression": "power(2, arrayInt)", + "result": [2, 4, 8, 16, 32, 64, 128] + }, + { + "expression": "sqrt([4, [9, 16]])", + "result": [2, [3, 4]] + }, + { + "expression": "proper(arrayStr)", + "result": ["Abc", "Bcd", "Cde", "Def", "Efg"] + }, + { + "expression": "startsWith([\"abc\", [\"def\", \"ghi\"]], \"a\")", + "result": [true, [false, false]] + }, + { + "expression": "rept(arrayStr, arrayInt)", + "result": [ + "abc", + "bcdbcd", + "cdecdecde", + "defdefdefdef", + "efgefgefgefgefg", + "", + "" + ] + }, + { + "expression": "stdev([1, [2, 3]])", + "result": 1 + }, + { + "expression": "rept(arrayStr, 2)", + "result": ["abcabc", "bcdbcd", "cdecde", "defdef", "efgefg"] + }, + { + "expression": "stdevA([1, [2, 3]])", + "result": 1 + }, + { + "expression": "rept(\"a\", arrayInt)", + "result": ["a", "aa", "aaa", "aaaa", "aaaaa", "aaaaaa", "aaaaaaa"] + }, + { + "expression": "stdevp([1, [2, 3]])", + "result": 0.8164965809277263 + }, + { + "expression": "round(arrayNum)", + "result": [-3, -2, -1, 0, 1, 2, 3] + }, + { + "expression": "stdevpA([1, [2, 3]])", + "result": 0.8164965809277263 + }, + { + "expression": "search(arrayStr, arrayStr, [0,0,0,0,0])", + "result": [ + [0, "abc"], + [0, "bcd"], + [0, "cde"], + [0, "def"], + [0, "efg"] + ] + }, + { + "expression": "substitute([\"abc\", [\"aab\", \"ghi\"]], \"a\", \"A\", [0, [1, 0]])", + "result": ["Abc", ["aAb", "ghi"]] + }, + { + "expression": "search(\"c\", arrayStr, [0,1,2,3,4,5])", + "result": [[2, "c"], [1, "c"], [], [], [], []] + }, + { + "expression": "sum([1, [2, 3]])", + "result": 6 + }, + { + "expression": "search(arrayStr, arrayStr, 0)", + "result": [ + [0, "abc"], + [0, "bcd"], + [0, "cde"], + [0, "def"], + [0, "efg"] + ] + }, + { + "expression": "tan([1, [2, 3]])", + "result": [1.5574077246549023, [-2.185039863261519, -0.1425465430742778]] + }, + { + "expression": "search(arrayStr, \"abcdefg\", [0,1,2,3])", + "result": [ + [0, "abc"], + [1, "bcd"], + [2, "cde"], + [3, "def"], + [4, "efg"] + ] + }, + { + "expression": "trim([\" abc \", [\" def \", \" ghi \"]])", + "result": ["abc", ["def", "ghi"]] + }, + { + "expression": "search(\"b\", \"abcbebg\", [0,1,2,3])", + "result": [ + [1, "b"], + [1, "b"], + [3, "b"], + [3, "b"] + ] + }, + { + "expression": "trunc([1.5, [2.5, 3.5]])", + "result": [1, [2, 3]] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | second([@, @])", + "result": [15, 15] + }, + { + "expression": "upper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["ABC", ["DEF", "GHI"]] + }, + { + "expression": "sign(arrayInt)", + "result": [1, 1, 1, 1, 1, 1, 1] + }, + { + "expression": "datetime(2024, 3, 8) | weekday([[@, @], [@, @]], 1)", + "result": [[6, 6], [6, 6]] + }, + { + "expression": "sin(arrayNum)", + "result": [ + 0.19024072762619915, -0.7965654722360865, -0.8912073600614354, 0, + 0.8912073600614354, 0.7965654722360865, -0.19024072762619915 + ] + }, + { + "expression": "datetime(2024, 3, 8) | year([[@, @], [@, @]])", + "result": [[2024, 2024], [2024, 2024]] + }, + { + "expression": "split(arrayStr, \"\")", + "result": [ + ["a", "b", "c"], + ["b", "c", "d"], + ["c", "d", "e"], + ["d", "e", "f"], + ["e", "f", "g"] + ] + }, + { + "expression": "split(\"abcdefg\", [\"b\", \"c\"])", + "result": [ + ["a", "cdefg"], + ["ab", "defg"] + ] + }, + { + "expression": "split(arrayStr, [\"a\", \"b\"])", + "result": [ + ["", "bc"], + ["", "cd"], + ["c", "d", "e"], + ["d", "e", "f"], + ["e", "f", "g"] + ] + }, + { + "expression": "sqrt(arrayNum[?@ > 0])", + "result": [ + 1.0488088481701516, + 1.489966442575134, + 1.8256505689753448 + ] + }, + { + "expression": "startsWith(arrayStr, arrayStr)", + "result": [true, + true, + true, + true, + true] + }, + { + "expression": "startsWith(arrayStr, \"b\")", + "result": [ + false, + true, + false, + false, + false + ] + }, + { + "expression": "startsWith(\"abcdefg\", [\"a\", \"b\", \"c\"])", + "result": [ + true, + false, + false + ] + }, + { + "expression": "substitute(arrayStr, \"c\", \"C\", [0,0,0,0,0])", + "result": ["abC","bCd","Cde","def","efg"] + }, + { + "expression": "tan(arrayNum)", + "result": [-0.1937796334476594, 1.3176122402817965, -1.9647596572486523, 0, 1.9647596572486523, -1.3176122402817965, 0.1937796334476594] + }, + { + "expression": "trim(arrayStr)", + "result": ["abc", "bcd", "cde", "def", "efg"] + }, + { + "expression": "trunc(arrayNum, [1,1,0,0,0,1,1])", + "result": [-3.3, -2.2, -1, 0, 1, 2.2, 3.3] + }, + { + "expression": "trunc(arrayNum)", + "result": [-3, -2, -1, 0, 1, 2, 3] + }, + { + "expression": "upper(arrayStr)", + "result": ["ABC", "BCD", "CDE", "DEF", "EFG"] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | weekday([@, @ + 1], 1)", + "result": [7, 1] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | year([@, @ + 1])", + "result": [2024, 2024] + } + ] } ] diff --git a/test/specSamples.json b/test/specSamples.json index f6bd89f4..134cb646 100644 --- a/test/specSamples.json +++ b/test/specSamples.json @@ -12,7 +12,7 @@ { "expression": "[1,2,3] ~ 4", "result": [1, 2, 3, 4] }, { "expression": "123 < \"124\"", "result": true }, { "expression": "\"23\" > 111", "result": false }, - { "expression": "abs(\"-2\")", "result": 2 }, + { "expression": "avg([\"2\", \"3\", \"4\"])", "error": "EvaluationError" }, { "expression": "1 == \"1\"", "result": false }, { "expression": "\"$123.00\" + 1", "error": "TypeError" }, { @@ -41,7 +41,7 @@ { "expression": "a ~ `null`", "data": { "a": [0, 1, 2] }, - "result": [0, 1, 2] + "result": [0, 1, 2, null] }, { "expression": "\"truth is \" & `true`", @@ -65,7 +65,7 @@ "result": { "month": 1, "day": 21, "hour": 12 } }, { "expression": "2 + `true`", "result": 3 }, - { "expression": "avg(\"20\")", "result": 20 }, + { "expression": "avg([\"20\", \"30\"])", "error": "EvaluationError" }, { "expression": "left + right", "data": { "left": 8, "right": 12 }, diff --git a/test/tests.json b/test/tests.json index e9a211ba..7ac178e4 100644 --- a/test/tests.json +++ b/test/tests.json @@ -476,7 +476,7 @@ { "data": "'purchase-order'", "expression": "left(missing)", - "error": "TypeError" + "result": "" }, { "expression": "right(\"abc\")", "result": "c" }, { "expression": "right(\"abc\", 1)", "result": "c" }, @@ -495,7 +495,7 @@ { "data": "'purchase-order'", "expression": "right(missing)", - "error": "TypeError" + "result": "" }, { "expression": "mid(\"abc\", 0, 0)", "result": "" }, { "expression": "mid(\"abc\", 1, 1)", "result": "b" }, @@ -523,7 +523,7 @@ { "data": "'purchase-order'", "expression": "mid(missing, 0, 1)", - "error": "TypeError" + "result": "" }, { "expression": "left(split(\"abc\", \"\"))", @@ -551,7 +551,7 @@ }, { "data": "'purchase-order'", - "expression": "left(split(address.street, ''))", + "expression": "left(split(address.street, \"\"))", "result": ["1"] }, { @@ -580,7 +580,7 @@ }, { "data": "'purchase-order'", - "expression": "right(split(address.street, ''))", + "expression": "right(split(address.street, \"\"))", "result": ["t"] }, { @@ -627,7 +627,7 @@ "expression": "proper(missing)", "result": "" }, - { "expression": "rept('', 10)", "result": "" }, + { "expression": "rept(\"\", 10)", "result": "" }, { "expression": "rept(\"a\", 2)", "result": "aa" }, { "expression": "rept(\"abc\", 2)", @@ -683,7 +683,7 @@ { "data": "'purchase-order'", "expression": "replace(missing,0, 1, address.country)", - "error": "TypeError" + "result": "USA" }, { "expression": "replace([\"blue\",\"black\",\"white\",\"red\"], 1, 0, \"green\")", @@ -781,7 +781,8 @@ "expression": "sqrt(missing)", "result": 0 }, - { "expression": "stdev(`[1,\"2\",3]`)", "result": 1 }, + { "expression": "stdev(`[1,\"2\",2,3]`)", "result": 1 }, + { "expression": "stdev(`[1,[\"2\",[2,3]]]`)", "result": 1 }, { "expression": "stdev(`[1]`)", "error": "EvaluationError" }, { "expression": "stdev(`[]`)", "error": "EvaluationError" }, { @@ -789,12 +790,22 @@ "expression": "stdev(items[*].quantity)", "result": 0.7071067811865476 }, + { "expression": "stdevA(`[\"1\",2,3]`)", "result": 1 }, + { "expression": "stdevA(`[1,[[\"2\",3]]]`)", "result": 1 }, + { "expression": "stdevA(`[1]`)", "error": "EvaluationError" }, + { "expression": "stdevA(`[]`)", "error": "EvaluationError" }, + { + "data": "'purchase-order'", + "expression": "stdev(items[*].quantity)", + "result": 0.7071067811865476 + }, { "data": "'purchase-order'", "expression": "stdev(missing)", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "stdevp(`[2,3]`)", "result": 0.5 }, + { "expression": "stdevp(`[2,[3]]`)", "result": 0.5 }, { "expression": "stdevp(`[2]`)", "result": 0 }, { "expression": "stdevp(`[]`)", "error": "EvaluationError" }, { @@ -805,7 +816,7 @@ { "data": "'purchase-order'", "expression": "stdevp(missing)", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "trim(\" abc def ghi \")", @@ -1151,7 +1162,7 @@ { "data": "'purchase-order'", "expression": "entries(address.country)", - "error": "TypeError" + "result": [["0", "USA"]] }, { "expression": "fromEntries([[\"a\", 1], [\"b\", 2, 4], [\"a\", 3]])", @@ -1163,7 +1174,7 @@ }, { "expression": "fromEntries(`null`)", - "result": {} + "error": "TypeError" }, { "expression": "fromEntries(`[]`)", @@ -1210,13 +1221,13 @@ }, { "data": "'purchase-order'", - "expression": "split(address.country, '')", + "expression": "split(address.country, \"\")", "result": ["U", "S", "A"] }, { "data": "'purchase-order'", "expression": "split(address.country, `null`)", - "result": ["U", "S", "A"] + "result": ["U","S","A"] }, { "data": "'purchase-order'", @@ -1440,7 +1451,7 @@ ] }, { "expression": "zip([1,2,3])", "result": [[1], [2], [3]] }, - { "expression": "zip([])", "result": [] }, + { "expression": "zip(`[]`)", "result": [] }, { "expression": "null() == `null`", "result": true }, { "expression": "null() == notfound", "result": true }, { @@ -1559,7 +1570,6 @@ "result": [4, ".\\^$(+{]"] }, { "expression": "search(\"\", null)", "result": [] }, - { "expression": "search(\"\", null)", "result": [] }, { "expression": "search(\"a**a\", \"pada\")", "result": [1, "ada"]}, { "expression": "search(\"a*?a\", \"pada\")", "result": [1, "ada"]}, { @@ -1571,10 +1581,6 @@ "expression": "register(\"_summarize\", &42)", "error": "FunctionError" }, - { - "expression": "_product([3, 4]) + _product([4, 5])", - "result": 32 - }, { "expression": "merge(register(\"_p1\", &42), register(\"_p2\", &43), {r: _p1() + _p2()})", "result": { "r": 85 } @@ -1583,6 +1589,10 @@ "expression": "register(\"_identity\", &@) || _identity()", "result": null }, + { + "expression": "registerWithParams(\"Identity\", &if (length(@) = 0, null(), @)) || Identity()", + "result": null + }, { "expression": "register(\"\", &42)", "error": "FunctionError"