From 70dbc40fdba54e785427afe5b98133816978cd6e Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Wed, 6 Nov 2024 17:43:11 +0100 Subject: [PATCH 1/9] allow array parameters to all the functions where it makes sense --- README.md | 2 +- doc/spec.adoc | 2 +- src/functions.js | 810 +++++++++++++++++++++++------------------- test/functions.json | 384 +++++++++++++++++++- test/specSamples.json | 2 +- test/tests.json | 59 ++- 6 files changed, 853 insertions(+), 406 deletions(-) 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..405fe0d7 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -91,7 +91,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 ---- diff --git a/src/functions.js b/src/functions.js index 4c2039a7..6dcc9e5a 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 => fn(...a)); + } + 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: @@ -124,8 +365,8 @@ export default function functions( * 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. @@ -137,8 +378,8 @@ export default function functions( * 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,7 +402,7 @@ export default function functions( }); return result; }, - _signature: [{ types: [dataTypes.TYPE_ANY], variadic: true }], + _signature: [{ types: [TYPE_ANY], variadic: true }], }, /** @@ -174,8 +415,8 @@ export default function functions( * 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] }], }, /** @@ -190,10 +431,10 @@ export default function functions( * 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] }, ], }, @@ -229,12 +470,11 @@ export default function functions( * 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] }, ], }, @@ -250,8 +490,8 @@ 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 @@ -262,12 +502,12 @@ export default function functions( * 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] }, ], }, @@ -320,8 +560,8 @@ export default function functions( * 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] }], }, /** @@ -353,44 +593,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,13 +642,13 @@ 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 }, ], }, @@ -456,9 +663,9 @@ export default function functions( * 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 +698,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,8 +739,8 @@ 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] }, ], }, @@ -548,15 +755,11 @@ export default function functions( * 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 +781,8 @@ export default function functions( _signature: [ { types: [ - dataTypes.TYPE_ARRAY, - dataTypes.TYPE_OBJECT, + TYPE_ARRAY, + TYPE_OBJECT, ], }, ], @@ -599,17 +802,10 @@ export default function functions( * 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] }, ], }, @@ -622,9 +818,9 @@ export default function functions( * 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] }, ], }, @@ -654,27 +850,15 @@ 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 }, ], }, @@ -689,8 +873,8 @@ export default function functions( * 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] }], }, /** @@ -705,15 +889,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] }, ], }, @@ -744,7 +928,7 @@ export default function functions( return Object.fromEntries(array); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_ARRAY] }, + { types: [TYPE_ARRAY_ARRAY] }, ], }, @@ -758,8 +942,8 @@ export default function functions( * 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 +970,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,8 +982,8 @@ 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] }, ], }, /** @@ -815,9 +999,9 @@ export default function functions( * hour(time(12, 0, 0)) // returns 12 */ hour: { - _func: args => getDateObj(args[0]).getHours(), + _func: args => evaluate(args, a => getDateObj(a).getHours()), _signature: [ - { types: [dataTypes.TYPE_NUMBER] }, + { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, ], }, @@ -852,9 +1036,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 +1098,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 }, ], }, @@ -956,8 +1140,8 @@ export default function functions( * 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] }], }, /** @@ -969,8 +1153,8 @@ export default function functions( * 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] }], }, /** @@ -982,12 +1166,9 @@ export default function functions( * 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] }, ], }, @@ -1101,9 +1282,9 @@ 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] }, ], }, @@ -1119,9 +1300,9 @@ export default function functions( * 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] }, ], }, @@ -1173,9 +1354,9 @@ export default function functions( * 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] }, ], }, @@ -1192,16 +1373,14 @@ 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] }, ], }, @@ -1218,9 +1397,9 @@ export default function functions( */ 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 +1418,7 @@ export default function functions( */ not: { _func: resolveArgs => !toBoolean(valueOf(resolveArgs[0])), - _signature: [{ types: [dataTypes.TYPE_ANY] }], + _signature: [{ types: [TYPE_ANY] }], }, /** @@ -1302,7 +1481,7 @@ export default function functions( }); return result; }, - _signature: [{ types: [dataTypes.TYPE_ANY], variadic: true }], + _signature: [{ types: [TYPE_ANY], variadic: true }], }, /** @@ -1315,10 +1494,10 @@ export default function functions( * 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] }, ], }, @@ -1337,16 +1516,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] }, ], }, @@ -1489,10 +1661,10 @@ 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] }, ], }, @@ -1508,15 +1680,10 @@ export default function functions( * 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 +1734,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 }, ], }, @@ -1592,10 +1759,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 }, ], }, @@ -1619,61 +1793,15 @@ 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 }, ], }, @@ -1691,9 +1819,9 @@ export default function functions( * 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] }, ], }, @@ -1709,8 +1837,8 @@ 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] }], }, /** @@ -1723,8 +1851,8 @@ export default function functions( * 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] }], }, /** @@ -1834,15 +1962,10 @@ export default function functions( * 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] }, ], }, @@ -1855,12 +1978,9 @@ export default function functions( * 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] }, ], }, @@ -1874,16 +1994,11 @@ export default function functions( * 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. @@ -1908,7 +2023,7 @@ export default function functions( return validNumber(result, 'stdev'); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_NUMBER] }, + { types: [TYPE_ARRAY_NUMBER] }, ], }, @@ -1936,7 +2051,7 @@ export default function functions( return validNumber(result, 'stdevp'); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY_NUMBER] }, + { types: [TYPE_ARRAY_NUMBER] }, ], }, @@ -1959,44 +2074,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 }, ], }, @@ -2029,8 +2126,8 @@ export default function functions( * 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 +2157,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 }, ], }, @@ -2216,7 +2313,7 @@ export default function functions( }, _signature: [ { types: [TYPE_ANY] }, - { types: [dataTypes.TYPE_NUMBER], optional: true }, + { types: [TYPE_NUMBER], optional: true }, ], }, @@ -2250,14 +2347,9 @@ export default function functions( * 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 +2365,26 @@ 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} 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 + */ 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,7 +2444,7 @@ export default function functions( ); }, _signature: [ - { types: [dataTypes.TYPE_ARRAY] }, + { types: [TYPE_ARRAY] }, ], }, @@ -2366,9 +2457,9 @@ export default function functions( * 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 +2488,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 +2509,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] }, ], }, @@ -2462,29 +2553,14 @@ 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 }, ], }, @@ -2500,9 +2576,9 @@ export default function functions( * 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/test/functions.json b/test/functions.json index 7a66a97f..7af7ce2e 100644 --- a/test/functions.json +++ b/test/functions.json @@ -138,8 +138,7 @@ }, { "expression": "abs(`false`)", - "result": 0, - "was": "TypeError" + "error": "TypeError" }, { "expression": "abs(`-24`)", @@ -264,8 +263,7 @@ }, { "expression": "endsWith(str, `0`)", - "result": false, - "was": "TypeError" + "error": "TypeError" }, { "expression": "floor(`1.2`)", @@ -733,8 +731,7 @@ }, { "expression": "startsWith(str, `0`)", - "result": false, - "was": "TypeError" + "error": "TypeError" }, { "expression": "sum(numbers)", @@ -1753,5 +1750,380 @@ "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": "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(arrayNum)", + "result": [ + -0.9817374728267503, -0.6045522710579296, 0.4535961214255773, 1, + 0.4535961214255773, -0.6045522710579296, -0.9817374728267503 + ] + }, + { + "expression": "now() | datedif(@, @ + 1, [\"y\",\"m\",\"d\",\"ym\",\"yd\"])", + "result": [0, 0, 1, 0, 1] + }, + { + "expression": "now() | datedif([@, @], [@ + 1, @ + 2], \"d\")", + "result": [1, 2] + }, + + + + { + "expression": "datetime(2024, 10, 12) | day([@, @ + 1])", + "result": [12, 13] + }, + { + "expression": "endsWith(arrayStr, arrayStr)", + "result": [true, true, true, true, true] + }, + { + "expression": "endsWith(arrayStr, \"c\")", + "result": [true, false, false, false, false] + }, + { + "expression": "datetime(2024, 10, 12) | eomonth([@, @, @, @, @, @, @], 1)", + "result": [ + 20056.958333333332, 20056.958333333332, 20056.958333333332, + 20056.958333333332, 20056.958333333332, 20056.958333333332, + 20056.958333333332 + ] + }, + { + "expression": "{d: datetime(2024, 10, 12), n:arrayInt} | eomonth([@.d, @.d, @.d, @.d, @.d, @.d, @.d], @.n)", + "result": [ + 20056.958333333332, 20087.958333333332, 20118.958333333332, + 20146.958333333332, 20177.916666666668, 20207.916666666668, + 20238.916666666668 + ] + }, + { + "expression": "exp(arrayInt)", + "result": [ + 2.718281828459045, 7.38905609893065, 20.085536923187668, + 54.598150033144236, 148.4131591025766, 403.4287934927351, + 1096.6331584284585 + ] + }, + { + "expression": "find(arrayStr, arrayStr, [0,0,0,0,0,0])", + "result": [0, 0, 0, 0, 0, 0] + }, + { + "expression": "find(\"c\", [\"cc\",\"ccc\",\"cccc\",\"ccccc\",\"cccccc\",\"ccccccc\",\"cccccccc\"], arrayInt)", + "result": [1, 2, 3, 4, 5, 6, 7] + }, + { + "expression": "find(arrayStr, arrayStr, 0)", + "result": [0, 0, 0, 0, 0] + }, + { + "expression": "find(\"c\", \"abcdefg\", arrayInt)", + "result": [2, 2, null, null, null, null, null] + }, + { + "expression": "floor(arrayNum)", + "result": [-4, -3, -2, 0, 1, 2, 3] + }, + { + "expression": "fromCodePoint(64 + arrayInt)", + "result": "ABCDEFG" + }, + { + "expression": "fround(arrayNum)", + "result": [ + -3.3329999446868896, -2.2200000286102295, -1.100000023841858, 0, + 1.100000023841858, 2.2200000286102295, 3.3329999446868896 + ] + }, + { + "expression": "datetime(2024, 10, 12, 13) | hour([@, @])", + "result": [13, 13] + }, + { + "expression": "log(arrayInt)", + "result": [ + 0, 0.6931471805599453, 1.0986122886681096, 1.3862943611198906, + 1.6094379124341003, 1.791759469228055, 1.9459101490553132 + ] + }, + { + "expression": "log10(arrayInt)", + "result": [ + 0, 0.3010299956639812, 0.47712125471966244, 0.6020599913279624, + 0.6989700043360189, 0.7781512503836436, 0.8450980400142568 + ] + }, + { + "expression": "lower(arrayStr)", + "result": ["abc", "bcd", "cde", "def", "efg"] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | millisecond([@, @ + 1])", + "result": [16, 16] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | minute([@, @ + 1])", + "result": [14, 14] + }, + { + "expression": "mod(arrayInt, 2)", + "result": [1, 0, 1, 0, 1, 0, 1] + }, + { + "expression": "mod(arrayInt, arrayInt + 1)", + "result": [1, 2, 3, 4, 5, 6, 7] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | month([@, @])", + "result": [10, 10] + }, + { + "expression": "power(arrayInt, arrayInt)", + "result": [1, 4, 27, 256, 3125, 46656, 823543] + }, + { + "expression": "power(arrayInt, 2)", + "result": [1, 4, 9, 16, 25, 36, 49] + }, + { + "expression": "power(2, arrayInt)", + "result": [2, 4, 8, 16, 32, 64, 128] + }, + { + "expression": "proper(arrayStr)", + "result": ["Abc", "Bcd", "Cde", "Def", "Efg"] + }, + { + "expression": "rept(arrayStr, arrayInt)", + "result": [ + "abc", + "bcdbcd", + "cdecdecde", + "defdefdefdef", + "efgefgefgefgefg", + "", + "" + ] + }, + { + "expression": "rept(arrayStr, 2)", + "result": ["abcabc", "bcdbcd", "cdecde", "defdef", "efgefg"] + }, + { + "expression": "rept(\"a\", arrayInt)", + "result": ["a", "aa", "aaa", "aaaa", "aaaaa", "aaaaaa", "aaaaaaa"] + }, + { + "expression": "round(arrayNum)", + "result": [-3, -2, -1, 0, 1, 2, 3] + }, + { + "expression": "search(arrayStr, arrayStr, [0,0,0,0,0])", + "result": [ + [0, "abc"], + [0, "bcd"], + [0, "cde"], + [0, "def"], + [0, "efg"] + ] + }, + { + "expression": "search(\"c\", arrayStr, [0,1,2,3,4,5])", + "result": [[2, "c"], [1, "c"], [], [], [], []] + }, + { + "expression": "search(arrayStr, arrayStr, 0)", + "result": [ + [0, "abc"], + [0, "bcd"], + [0, "cde"], + [0, "def"], + [0, "efg"] + ] + }, + { + "expression": "search(arrayStr, \"abcdefg\", [0,1,2,3])", + "result": [ + [0, "abc"], + [1, "bcd"], + [2, "cde"], + [3, "def"], + [4, "efg"] + ] + }, + { + "expression": "search(\"b\", \"abcbebg\", [0,1,2,3])", + "result": [ + [1, "b"], + [1, "b"], + [3, "b"], + [3, "b"] + ] + }, + { + "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | second([@, @])", + "result": [15, 15] + }, + { + "expression": "sign(arrayInt)", + "result": [1, 1, 1, 1, 1, 1, 1] + }, + { + "expression": "sin(arrayNum)", + "result": [ + 0.19024072762619915, -0.7965654722360865, -0.8912073600614354, 0, + 0.8912073600614354, 0.7965654722360865, -0.19024072762619915 + ] + }, + { + "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..61c50d13 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\"])", "result": 3 }, { "expression": "1 == \"1\"", "result": false }, { "expression": "\"$123.00\" + 1", "error": "TypeError" }, { diff --git a/test/tests.json b/test/tests.json index e9a211ba..a307a383 100644 --- a/test/tests.json +++ b/test/tests.json @@ -349,7 +349,7 @@ { "data": "'purchase-order'", "expression": "lower(address.missing)", - "result": "" + "error": "TypeError" }, { "data": "'purchase-order'", @@ -359,11 +359,11 @@ { "expression": "lower(\"\")", "result": "" }, { "expression": "lower(\"abc\")", "result": "abc" }, { "expression": "lower(\"aBc\")", "result": "abc" }, - { "expression": "lower(42)", "result": "42" }, + { "expression": "lower(42)", "error": "TypeError" }, { "data": "'purchase-order'", "expression": "upper(address.missing)", - "result": "" + "error": "TypeError" }, { "data": "'purchase-order'", @@ -373,7 +373,7 @@ { "expression": "upper(\"\")", "result": "" }, { "expression": "upper(\"ABC\")", "result": "ABC" }, { "expression": "upper(\"aBc\")", "result": "ABC" }, - { "expression": "upper(42)", "result": "42" }, + { "expression": "upper(42)", "error": "TypeError" }, { "data": "'purchase-order'", "expression": "exp(items[0].quantity)", @@ -382,10 +382,10 @@ { "data": "'purchase-order'", "expression": "exp(missing)", - "result": 1 + "error": "TypeError" }, { "expression": "exp(0)", "result": 1 }, - { "expression": "exp(\"0\")", "result": 1 }, + { "expression": "exp(\"0\")", "error": "TypeError" }, { "expression": "exp(1)", "result": 2.718281828459045 }, { "data": "'purchase-order'", @@ -395,7 +395,7 @@ { "data": "'purchase-order'", "expression": "power(missing, 1)", - "result": 0 + "error": "TypeError" }, { "expression": "power(1, 1)", "result": 1 }, { "expression": "power(2, 3)", "result": 8 }, @@ -450,17 +450,17 @@ { "data": "'purchase-order'", "expression": "find(\"Oak\", missing)", - "result": null + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "find(missing, address.street)", - "result": 0 + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "find(missing, missing)", - "result": 0 + "error": "TypeError" }, { "expression": "left(\"abc\")", "result": "a" }, { "expression": "left(\"abc\", 1)", "result": "a" }, @@ -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"] }, { @@ -625,9 +625,9 @@ { "data": "'purchase-order'", "expression": "proper(missing)", - "result": "" + "error": "TypeError" }, - { "expression": "rept('', 10)", "result": "" }, + { "expression": "rept(\"\", 10)", "result": "" }, { "expression": "rept(\"a\", 2)", "result": "aa" }, { "expression": "rept(\"abc\", 2)", @@ -643,12 +643,12 @@ { "data": "'purchase-order'", "expression": "rept(address.country,missing)", - "result": "" + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "rept(missing,3)", - "result": "" + "error": "TypeError" }, { "expression": "replace(\"abcdefg\", 2, 2, \"yz\")", @@ -753,12 +753,12 @@ { "data": "'purchase-order'", "expression": "round(items[0].price, missing)", - "result": 3 + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "round(missing, 1)", - "result": 0 + "error": "TypeError" }, { "expression": "round(1.5)", "result": 2 }, { "expression": "round(-1.5)", "result": -1 }, @@ -779,7 +779,7 @@ { "data": "'purchase-order'", "expression": "sqrt(missing)", - "result": 0 + "error": "TypeError" }, { "expression": "stdev(`[1,\"2\",3]`)", "result": 1 }, { "expression": "stdev(`[1]`)", "error": "EvaluationError" }, @@ -823,7 +823,7 @@ { "data": "'purchase-order'", "expression": "trim(missing)", - "result": "" + "error": "TypeError" }, { "expression": "trunc(123.456)", "result": 123 }, { "expression": "trunc(123.456, 1)", "result": 123.4 }, @@ -853,12 +853,12 @@ { "data": "'purchase-order'", "expression": "trunc(items[0].price, missing)", - "result": 3 + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "trunc(missing, 1)", - "result": 0 + "error": "TypeError" }, { "expression": "fromCodePoint(13055)", @@ -876,7 +876,7 @@ { "data": "'purchase-order'", "expression": "fromCodePoint(missing)", - "result": "\u0000" + "error": "TypeError" }, { "expression": "codePoint(\"\\t\")", "result": 9 }, { "expression": "codePoint(\"ã‹¿\")", "result": 13055 }, @@ -888,7 +888,7 @@ { "data": "'purchase-order'", "expression": "codePoint(missing)", - "result": null + "error": "TypeError" }, { "data": "'purchase-order'", @@ -1097,7 +1097,7 @@ { "data": "casefold", "expression": "casefold(notfound)", - "result": "" + "error": "TypeError" }, { "data": "casefold.test1", @@ -1210,18 +1210,18 @@ }, { "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"] + "error": "TypeError" }, { "data": "'purchase-order'", "expression": "split(no, where)", - "result": [] + "error": "TypeError" }, { "data": "'purchase-order'", @@ -1558,8 +1558,7 @@ "expression": "search(\".\\\\\\\\^$(+{]\", \"pada.\\\\^$(+{]b\")", "result": [4, ".\\^$(+{]"] }, - { "expression": "search(\"\", null)", "result": [] }, - { "expression": "search(\"\", null)", "result": [] }, + { "expression": "search(\"\", null)", "error": "TypeError" }, { "expression": "search(\"a**a\", \"pada\")", "result": [1, "ada"]}, { "expression": "search(\"a*?a\", \"pada\")", "result": [1, "ada"]}, { From 6322953b28031888ca2f1eaa38c2ee6ca77fc3c2 Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Thu, 7 Nov 2024 13:15:51 +0100 Subject: [PATCH 2/9] update documentation for array functions --- doc/spec.adoc | 30 +++++-- src/functions.js | 220 ++++++++++++++++++++++++----------------------- 2 files changed, 135 insertions(+), 115 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index 405fe0d7..7b7bb7ec 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -433,7 +433,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] ---- @@ -1194,27 +1194,43 @@ 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 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. + +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)]`. + +The rules for the treatment of parameters with arrays of values: + +When any parameter is an array then: + +* 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. + +=== 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/functions.js b/src/functions.js index 6dcc9e5a..bba13daa 100644 --- a/src/functions.js +++ b/src/functions.js @@ -358,8 +358,8 @@ 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 @@ -370,9 +370,9 @@ export default function functions( }, /** * 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 @@ -407,9 +407,9 @@ export default function functions( /** * 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 @@ -422,9 +422,9 @@ export default function functions( /** * 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 @@ -463,8 +463,8 @@ export default function functions( /** * 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" @@ -481,8 +481,8 @@ export default function functions( /** * 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 @@ -495,8 +495,9 @@ export default function functions( }, /** * 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 @@ -553,8 +554,8 @@ 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 @@ -575,15 +576,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 @@ -654,10 +655,10 @@ export default function functions( /** * 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 @@ -746,9 +747,9 @@ export default function functions( /** * 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 @@ -790,12 +791,12 @@ 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] @@ -811,8 +812,8 @@ export default function functions( /** * 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 @@ -837,11 +838,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 @@ -865,8 +866,8 @@ export default function functions( /** * 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 @@ -879,9 +880,9 @@ export default function functions( /** * 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" @@ -934,8 +935,8 @@ export default function functions( /** * 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 @@ -988,11 +989,11 @@ export default function functions( }, /** * 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 @@ -1133,8 +1134,8 @@ 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 @@ -1146,8 +1147,8 @@ export default function functions( /** * 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 @@ -1159,8 +1160,8 @@ export default function functions( /** * 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 @@ -1290,11 +1291,11 @@ export default function functions( /** * 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 @@ -1343,11 +1344,11 @@ export default function functions( /** * 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 @@ -1362,9 +1363,9 @@ export default function functions( /** * 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 @@ -1386,11 +1387,11 @@ export default function functions( /** * 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 @@ -1486,9 +1487,9 @@ export default function functions( /** * 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) @@ -1507,8 +1508,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" @@ -1670,10 +1671,10 @@ export default function functions( /** * 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 @@ -1745,9 +1746,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 @@ -1780,9 +1781,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: * @@ -1808,11 +1810,11 @@ export default function functions( /** * 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 @@ -1827,8 +1829,8 @@ export default function functions( /** * 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 @@ -1843,8 +1845,8 @@ export default function functions( /** * 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 @@ -1953,9 +1955,9 @@ 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"] @@ -1971,8 +1973,8 @@ export default function functions( /** * 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 @@ -1986,9 +1988,9 @@ export default function functions( /** * 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 @@ -2060,13 +2062,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" @@ -2118,8 +2121,8 @@ 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 @@ -2340,8 +2343,8 @@ 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" @@ -2367,9 +2370,10 @@ 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 + * @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 @@ -2450,8 +2454,8 @@ export default function functions( /** * 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" @@ -2537,15 +2541,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 @@ -2566,11 +2570,11 @@ export default function functions( /** * 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 From 161c0a96ede22f1f73621c9b3a2705c8b0e8bff8 Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Mon, 11 Nov 2024 11:50:16 -0800 Subject: [PATCH 3/9] Allow more lenient coercion rules for function parameters --- doc/spec.adoc | 6 ++++-- src/matchType.js | 49 +++++++++++++++++++++++++++++++++++++++++-- test/functions.json | 22 +++++++------------ test/specSamples.json | 2 +- test/tests.json | 10 ++++----- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index 7b7bb7ec..0f005909 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -73,7 +73,7 @@ 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 just 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. The equality and inequality operators (`=`, `==`, `!=`, `<>`) do **not** perform type coercion. If operands are different types, the values are considered not equal. @@ -127,6 +127,8 @@ 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. e.g. just as a string can be coerced to a number, an array of strings may be coerced to an array of numbers. + [discrete] ==== Examples @@ -135,7 +137,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 diff --git a/src/matchType.js b/src/matchType.js index 8d041072..439e2112 100644 --- a/src/matchType.js +++ b/src/matchType.js @@ -92,6 +92,46 @@ 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_EMPTY_ARRAY, + TYPE_ARRAY_STRING, + TYPE_ARRAY_NUMBER, + TYPE_ARRAY_ARRAY, + TYPE_ARRAY, + TYPE_OBJECT, + 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,8 +146,13 @@ 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 diff --git a/test/functions.json b/test/functions.json index 7af7ce2e..4f2b3487 100644 --- a/test/functions.json +++ b/test/functions.json @@ -138,7 +138,7 @@ }, { "expression": "abs(`false`)", - "error": "TypeError" + "result": 0 }, { "expression": "abs(`-24`)", @@ -263,7 +263,7 @@ }, { "expression": "endsWith(str, `0`)", - "error": "TypeError" + "result": false }, { "expression": "floor(`1.2`)", @@ -731,7 +731,7 @@ }, { "expression": "startsWith(str, `0`)", - "error": "TypeError" + "result": false }, { "expression": "sum(numbers)", @@ -1837,20 +1837,12 @@ "result": [true, false, false, false, false] }, { - "expression": "datetime(2024, 10, 12) | eomonth([@, @, @, @, @, @, @], 1)", - "result": [ - 20056.958333333332, 20056.958333333332, 20056.958333333332, - 20056.958333333332, 20056.958333333332, 20056.958333333332, - 20056.958333333332 - ] + "expression": "datetime(2024, 10, 12) | eomonth([@, @, @, @, @, @, @], 1)| [month(@) & day(@)][]", + "result": ["1130", "1130", "1130", "1130", "1130", "1130", "1130"] }, { - "expression": "{d: datetime(2024, 10, 12), n:arrayInt} | eomonth([@.d, @.d, @.d, @.d, @.d, @.d, @.d], @.n)", - "result": [ - 20056.958333333332, 20087.958333333332, 20118.958333333332, - 20146.958333333332, 20177.916666666668, 20207.916666666668, - 20238.916666666668 - ] + "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": "exp(arrayInt)", diff --git a/test/specSamples.json b/test/specSamples.json index 61c50d13..f50ed094 100644 --- a/test/specSamples.json +++ b/test/specSamples.json @@ -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\"])", "result": 25 }, { "expression": "left + right", "data": { "left": 8, "right": 12 }, diff --git a/test/tests.json b/test/tests.json index a307a383..5f697ed9 100644 --- a/test/tests.json +++ b/test/tests.json @@ -359,7 +359,7 @@ { "expression": "lower(\"\")", "result": "" }, { "expression": "lower(\"abc\")", "result": "abc" }, { "expression": "lower(\"aBc\")", "result": "abc" }, - { "expression": "lower(42)", "error": "TypeError" }, + { "expression": "lower(42)", "result": "42" }, { "data": "'purchase-order'", "expression": "upper(address.missing)", @@ -373,7 +373,7 @@ { "expression": "upper(\"\")", "result": "" }, { "expression": "upper(\"ABC\")", "result": "ABC" }, { "expression": "upper(\"aBc\")", "result": "ABC" }, - { "expression": "upper(42)", "error": "TypeError" }, + { "expression": "upper(42)", "result": "42" }, { "data": "'purchase-order'", "expression": "exp(items[0].quantity)", @@ -385,7 +385,7 @@ "error": "TypeError" }, { "expression": "exp(0)", "result": 1 }, - { "expression": "exp(\"0\")", "error": "TypeError" }, + { "expression": "exp(\"0\")", "result": 1 }, { "expression": "exp(1)", "result": 2.718281828459045 }, { "data": "'purchase-order'", @@ -1151,7 +1151,7 @@ { "data": "'purchase-order'", "expression": "entries(address.country)", - "error": "TypeError" + "result": [["0", "USA"]] }, { "expression": "fromEntries([[\"a\", 1], [\"b\", 2, 4], [\"a\", 3]])", @@ -1440,7 +1440,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 }, { From 6e1506410ae9546aeec5f1a626c1706aff667964 Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Thu, 16 Jan 2025 11:05:09 -0800 Subject: [PATCH 4/9] improved debug messages --- doc/spec.adoc | 10 ++++--- src/TreeInterpreter.js | 11 +++++--- src/functions.js | 7 ++++- src/matchType.js | 14 +++------- src/utils.js | 8 +++--- test/extensions.spec.js | 4 +-- test/functions.json | 11 ++++---- test/specSamples.json | 2 +- test/tests.json | 58 ++++++++++++++++++++--------------------- 9 files changed, 66 insertions(+), 59 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index 0f005909..bb333e02 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,7 @@ 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 when there is just 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. +* 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. The equality and inequality operators (`=`, `==`, `!=`, `<>`) do **not** perform type coercion. If operands are different types, the values are considered not equal. @@ -127,7 +129,9 @@ 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. e.g. just as a string can be coerced to a number, an array of strings may be coerced to an array of numbers. +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 @@ -454,7 +458,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 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 bba13daa..471e2e91 100644 --- a/src/functions.js +++ b/src/functions.js @@ -2310,7 +2310,12 @@ 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; } }, diff --git a/src/matchType.js b/src/matchType.js index 439e2112..503d2aec 100644 --- a/src/matchType.js +++ b/src/matchType.js @@ -115,12 +115,6 @@ function supportedConversion(from, to) { [TYPE_NULL]: [ TYPE_STRING, TYPE_NUMBER, - TYPE_EMPTY_ARRAY, - TYPE_ARRAY_STRING, - TYPE_ARRAY_NUMBER, - TYPE_ARRAY_ARRAY, - TYPE_ARRAY, - TYPE_OBJECT, TYPE_BOOLEAN, ], [TYPE_STRING]: [ @@ -188,14 +182,12 @@ export function matchType(expectedList, argValue, context, toNumber, toString) { } 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/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 4f2b3487..dcc97bbd 100644 --- a/test/functions.json +++ b/test/functions.json @@ -170,7 +170,7 @@ }, { "expression": "avg('abc')", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "avg(foo)", @@ -195,7 +195,7 @@ }, { "expression": "avg(`null`)", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "ceil(`1.2`)", @@ -635,7 +635,7 @@ }, { "expression": "keys(`null`)", - "result": [] + "error": "TypeError" }, { "expression": "values(foo)", @@ -643,7 +643,7 @@ }, { "expression": "values(null())", - "result": [] + "error": "TypeError" }, { "expression": "join(strings, \", \")", @@ -1287,8 +1287,7 @@ }, { "expression": "map(badkey, &a)", - "result": [], - "was": "TypeError" + "error": "TypeError" }, { "expression": "map(empty, &foo)", diff --git a/test/specSamples.json b/test/specSamples.json index f50ed094..7bd8f666 100644 --- a/test/specSamples.json +++ b/test/specSamples.json @@ -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`", diff --git a/test/tests.json b/test/tests.json index 5f697ed9..c1b8997e 100644 --- a/test/tests.json +++ b/test/tests.json @@ -349,7 +349,7 @@ { "data": "'purchase-order'", "expression": "lower(address.missing)", - "error": "TypeError" + "result": "" }, { "data": "'purchase-order'", @@ -363,7 +363,7 @@ { "data": "'purchase-order'", "expression": "upper(address.missing)", - "error": "TypeError" + "result": "" }, { "data": "'purchase-order'", @@ -382,7 +382,7 @@ { "data": "'purchase-order'", "expression": "exp(missing)", - "error": "TypeError" + "result": 1 }, { "expression": "exp(0)", "result": 1 }, { "expression": "exp(\"0\")", "result": 1 }, @@ -395,7 +395,7 @@ { "data": "'purchase-order'", "expression": "power(missing, 1)", - "error": "TypeError" + "result": 0 }, { "expression": "power(1, 1)", "result": 1 }, { "expression": "power(2, 3)", "result": 8 }, @@ -450,17 +450,17 @@ { "data": "'purchase-order'", "expression": "find(\"Oak\", missing)", - "error": "TypeError" + "result": null }, { "data": "'purchase-order'", "expression": "find(missing, address.street)", - "error": "TypeError" + "result": 0 }, { "data": "'purchase-order'", "expression": "find(missing, missing)", - "error": "TypeError" + "result": 0 }, { "expression": "left(\"abc\")", "result": "a" }, { "expression": "left(\"abc\", 1)", "result": "a" }, @@ -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\", \"\"))", @@ -625,7 +625,7 @@ { "data": "'purchase-order'", "expression": "proper(missing)", - "error": "TypeError" + "result": "" }, { "expression": "rept(\"\", 10)", "result": "" }, { "expression": "rept(\"a\", 2)", "result": "aa" }, @@ -643,12 +643,12 @@ { "data": "'purchase-order'", "expression": "rept(address.country,missing)", - "error": "TypeError" + "result": "" }, { "data": "'purchase-order'", "expression": "rept(missing,3)", - "error": "TypeError" + "result": "" }, { "expression": "replace(\"abcdefg\", 2, 2, \"yz\")", @@ -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\")", @@ -753,12 +753,12 @@ { "data": "'purchase-order'", "expression": "round(items[0].price, missing)", - "error": "TypeError" + "result": 3 }, { "data": "'purchase-order'", "expression": "round(missing, 1)", - "error": "TypeError" + "result": 0 }, { "expression": "round(1.5)", "result": 2 }, { "expression": "round(-1.5)", "result": -1 }, @@ -779,7 +779,7 @@ { "data": "'purchase-order'", "expression": "sqrt(missing)", - "error": "TypeError" + "result": 0 }, { "expression": "stdev(`[1,\"2\",3]`)", "result": 1 }, { "expression": "stdev(`[1]`)", "error": "EvaluationError" }, @@ -792,7 +792,7 @@ { "data": "'purchase-order'", "expression": "stdev(missing)", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "stdevp(`[2,3]`)", "result": 0.5 }, { "expression": "stdevp(`[2]`)", "result": 0 }, @@ -805,7 +805,7 @@ { "data": "'purchase-order'", "expression": "stdevp(missing)", - "error": "EvaluationError" + "error": "TypeError" }, { "expression": "trim(\" abc def ghi \")", @@ -823,7 +823,7 @@ { "data": "'purchase-order'", "expression": "trim(missing)", - "error": "TypeError" + "result": "" }, { "expression": "trunc(123.456)", "result": 123 }, { "expression": "trunc(123.456, 1)", "result": 123.4 }, @@ -853,12 +853,12 @@ { "data": "'purchase-order'", "expression": "trunc(items[0].price, missing)", - "error": "TypeError" + "result": 3 }, { "data": "'purchase-order'", "expression": "trunc(missing, 1)", - "error": "TypeError" + "result": 0 }, { "expression": "fromCodePoint(13055)", @@ -876,7 +876,7 @@ { "data": "'purchase-order'", "expression": "fromCodePoint(missing)", - "error": "TypeError" + "result": "\u0000" }, { "expression": "codePoint(\"\\t\")", "result": 9 }, { "expression": "codePoint(\"ã‹¿\")", "result": 13055 }, @@ -888,7 +888,7 @@ { "data": "'purchase-order'", "expression": "codePoint(missing)", - "error": "TypeError" + "result": null }, { "data": "'purchase-order'", @@ -1097,7 +1097,7 @@ { "data": "casefold", "expression": "casefold(notfound)", - "error": "TypeError" + "result": "" }, { "data": "casefold.test1", @@ -1163,7 +1163,7 @@ }, { "expression": "fromEntries(`null`)", - "result": {} + "error": "TypeError" }, { "expression": "fromEntries(`[]`)", @@ -1216,12 +1216,12 @@ { "data": "'purchase-order'", "expression": "split(address.country, `null`)", - "error": "TypeError" + "result": ["U","S","A"] }, { "data": "'purchase-order'", "expression": "split(no, where)", - "error": "TypeError" + "result": [] }, { "data": "'purchase-order'", @@ -1558,7 +1558,7 @@ "expression": "search(\".\\\\\\\\^$(+{]\", \"pada.\\\\^$(+{]b\")", "result": [4, ".\\^$(+{]"] }, - { "expression": "search(\"\", null)", "error": "TypeError" }, + { "expression": "search(\"\", null)", "result": [] }, { "expression": "search(\"a**a\", \"pada\")", "result": [1, "ada"]}, { "expression": "search(\"a*?a\", \"pada\")", "result": [1, "ada"]}, { From 0008cb7aa519bd013a869c942a594c6c4f56644e Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Mon, 10 Feb 2025 09:03:58 -0500 Subject: [PATCH 5/9] deal with nested arrays --- doc/spec.adoc | 1 + src/functions.js | 326 ++++++++++++++++++++++++++++++++++-------- src/matchType.js | 1 + test/docSamples.json | 46 +++++- test/functions.json | 225 +++++++++++++++++++++++++---- test/specSamples.json | 4 +- test/tests.json | 13 +- 7 files changed, 526 insertions(+), 90 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index bb333e02..ebd3d3d2 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -76,6 +76,7 @@ If the supplied data is not correct for the execution context, json-formula will * 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 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, 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. diff --git a/src/functions.js b/src/functions.js index 471e2e91..465c035e 100644 --- a/src/functions.js +++ b/src/functions.js @@ -123,7 +123,7 @@ export default function functions( function evaluate(args, fn) { if (args.some(Array.isArray)) { - return balanceArrays(args).map(a => fn(...a)); + return balanceArrays(args).map(a => evaluate(a, fn)); } return fn(...args); } @@ -440,6 +440,8 @@ export default function functions( /** * 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 @@ -450,14 +452,49 @@ 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] }], }, /** @@ -758,8 +795,8 @@ export default function functions( endsWith: { _func: args => evaluate(args, endsWithFn), _signature: [ - { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, - { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_STRING, TYPE_ARRAY] }, ], }, @@ -1193,35 +1230,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, }], }, @@ -1308,36 +1379,66 @@ export default function functions( }, /** - * 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, }], }, @@ -1858,29 +1959,58 @@ export default function functions( }, /** - * 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] }], }, /** @@ -2007,6 +2137,8 @@ export default function functions( * `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) @@ -2017,7 +2149,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); @@ -2025,7 +2159,45 @@ export default function functions( return validNumber(result, 'stdev'); }, _signature: [ - { types: [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] }, ], }, @@ -2044,7 +2216,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; @@ -2053,7 +2228,42 @@ export default function functions( return validNumber(result, 'stdevp'); }, _signature: [ - { types: [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] }, ], }, diff --git a/src/matchType.js b/src/matchType.js index 503d2aec..79269f22 100644 --- a/src/matchType.js +++ b/src/matchType.js @@ -172,6 +172,7 @@ 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]); diff --git a/test/docSamples.json b/test/docSamples.json index 85d7a999..01dc82d3 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)", @@ -213,6 +237,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 +600,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/functions.json b/test/functions.json index dcc97bbd..2ea52b5c 100644 --- a/test/functions.json +++ b/test/functions.json @@ -166,7 +166,7 @@ }, { "expression": "avg(array)", - "error": "TypeError" + "result": 2.75 }, { "expression": "avg('abc')", @@ -183,7 +183,7 @@ }, { "expression": "avg(strings)", - "error": "TypeError" + "error": "EvaluationError" }, { "expression": "avg(`[]`)", @@ -191,12 +191,53 @@ }, { "expression": "avg(`[2,1,\"b\"]`)", - "error": "TypeError" + "result": 1.5 }, { "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 @@ -423,15 +464,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)", @@ -439,19 +480,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)", @@ -467,24 +508,80 @@ }, { "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(decimals)", + "result": 1.2 + }, + { + "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": {} @@ -523,19 +620,19 @@ }, { "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" + "result": 4 }, { "expression": "min(decimals)", @@ -543,19 +640,19 @@ }, { "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)", @@ -567,12 +664,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" @@ -613,6 +762,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" @@ -1025,7 +1182,7 @@ }, { "expression": "sort(array)", - "error": "TypeError" + "result": [-1, 3, 4, 5, "100", "a"] }, { "expression": "sort(abc)", @@ -1440,6 +1597,10 @@ "expression": "avg(&[1,1])", "error": "TypeError" }, + { + "expression": "avgA(&[1,1])", + "error": "TypeError" + }, { "expression": "casefold(&\"abc\")", "error": "TypeError" @@ -1556,6 +1717,10 @@ "expression": "max(&[1,2,3])", "error": "TypeError" }, + { + "expression": "maxA(&[1,2,3])", + "error": "TypeError" + }, { "expression": "merge(&{a: 1}, {b: 2})", "error": "TypeError" @@ -1572,6 +1737,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" @@ -1676,6 +1845,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" diff --git a/test/specSamples.json b/test/specSamples.json index 7bd8f666..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": "avg([\"2\", \"3\", \"4\"])", "result": 3 }, + { "expression": "avg([\"2\", \"3\", \"4\"])", "error": "EvaluationError" }, { "expression": "1 == \"1\"", "result": false }, { "expression": "\"$123.00\" + 1", "error": "TypeError" }, { @@ -65,7 +65,7 @@ "result": { "month": 1, "day": 21, "hour": 12 } }, { "expression": "2 + `true`", "result": 3 }, - { "expression": "avg([\"20\", \"30\"])", "result": 25 }, + { "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 c1b8997e..e3ee9a99 100644 --- a/test/tests.json +++ b/test/tests.json @@ -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": "TypeError" }, { "expression": "stdevp(`[2,3]`)", "result": 0.5 }, + { "expression": "stdevp(`[2,[3]]`)", "result": 0.5 }, { "expression": "stdevp(`[2]`)", "result": 0 }, { "expression": "stdevp(`[]`)", "error": "EvaluationError" }, { From 141b7cbf0be00dcb4064421c2bf41b757da3c7a6 Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Wed, 2 Apr 2025 21:47:51 -0400 Subject: [PATCH 6/9] handle nested arrays --- doc/spec.adoc | 25 +- src/functions.js | 13 +- src/matchType.js | 13 +- test/functions.json | 948 +++++++++++++++++++++++++++++++++----------- 4 files changed, 747 insertions(+), 252 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index ebd3d3d2..7232490c 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -76,7 +76,7 @@ If the supplied data is not correct for the execution context, json-formula will * 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 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, 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]`. +* 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. @@ -742,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 @@ -1211,7 +1212,7 @@ 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. Function parameters can either have a specific arity or be variadic with a minimum @@ -1224,18 +1225,30 @@ 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)]`. -The rules for the treatment of parameters with arrays of values: +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. -When any parameter is an array then: +`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: diff --git a/src/functions.js b/src/functions.js index 465c035e..2c2aa5f0 100644 --- a/src/functions.js +++ b/src/functions.js @@ -795,8 +795,8 @@ export default function functions( endsWith: { _func: args => evaluate(args, endsWithFn), _signature: [ - { types: [TYPE_STRING, TYPE_ARRAY] }, - { types: [TYPE_STRING, TYPE_ARRAY] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, + { types: [TYPE_STRING, TYPE_ARRAY_STRING] }, ], }, @@ -957,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; @@ -966,7 +967,7 @@ export default function functions( return Object.fromEntries(array); }, _signature: [ - { types: [TYPE_ARRAY_ARRAY] }, + { types: [TYPE_ARRAY_ARRAY, TYPE_ARRAY_STRING, TYPE_ARRAY_NUMBER] }, ], }, @@ -1038,9 +1039,7 @@ export default function functions( */ hour: { _func: args => evaluate(args, a => getDateObj(a).getHours()), - _signature: [ - { types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }, - ], + _signature: [{ types: [TYPE_NUMBER, TYPE_ARRAY_NUMBER] }], }, /** @@ -2322,7 +2321,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; diff --git a/src/matchType.js b/src/matchType.js index 79269f22..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 @@ -152,7 +152,7 @@ export function matchType(expectedList, argValue, context, toNumber, toString) { // 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); @@ -176,9 +176,12 @@ export function matchType(expectedList, argValue, context, toNumber, toString) { // 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); } diff --git a/test/functions.json b/test/functions.json index 2ea52b5c..15cbbd5f 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" @@ -136,6 +145,10 @@ "expression": "abs(array[1])", "result": 3 }, + { + "expression": "abs([-1, 3, [-4, 5]])", + "result": [1, 3, [4, 5]] + }, { "expression": "abs(`false`)", "result": 0 @@ -193,6 +206,10 @@ "expression": "avg(`[2,1,\"b\"]`)", "result": 1.5 }, + { + "expression": "avg([1, 2, [3, 4, `[5]`]])", + "result": 3 + }, { "expression": "avg(`null`)", "error": "TypeError" @@ -1505,6 +1522,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" @@ -1956,6 +1977,10 @@ 1.0654358165107394 ] }, + { + "expression": "asin([1, [0, 0.19866933079506123]])", + "result": [1.5707963267948966, [0, 0.2]] + }, { "expression": "atan2(arrayNum, 10)", "result": [ @@ -1971,327 +1996,782 @@ ] }, { - "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(arrayNum)", - "result": [ - -0.9817374728267503, -0.6045522710579296, 0.4535961214255773, 1, - 0.4535961214255773, -0.6045522710579296, -0.9817374728267503 - ] - }, - { - "expression": "now() | datedif(@, @ + 1, [\"y\",\"m\",\"d\",\"ym\",\"yd\"])", - "result": [0, 0, 1, 0, 1] - }, - { - "expression": "now() | datedif([@, @], [@ + 1, @ + 2], \"d\")", - "result": [1, 2] - }, - - - - { - "expression": "datetime(2024, 10, 12) | day([@, @ + 1])", - "result": [12, 13] + "expression": "atan2(1, [1, [2, 3]])", + "result": [0.7853981633974483, [0.4636476090008061, 0.3217505543966422]] }, { - "expression": "endsWith(arrayStr, arrayStr)", - "result": [true, true, true, true, true] + "expression": "ceil([1.1, [2.2, 3.3]])", + "result": [2, [3, 4]] }, { - "expression": "endsWith(arrayStr, \"c\")", - "result": [true, false, false, false, false] + "expression": "codePoint([\"A\", [\"B\", \"C\"]])", + "result": [65, [66, 67]] }, { - "expression": "datetime(2024, 10, 12) | eomonth([@, @, @, @, @, @, @], 1)| [month(@) & day(@)][]", - "result": ["1130", "1130", "1130", "1130", "1130", "1130", "1130"] + "expression": "cos([1, [2, 3]])", + "result": [0.5403023058681398, [-0.4161468365471424, -0.9899924966004454]] }, { - "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": "now() | datedif([@, [@, @]], [@ + 1, [@ + 2, @ + 3]], \"d\")", + "result": [1, [2, 3]] }, { - "expression": "exp(arrayInt)", - "result": [ - 2.718281828459045, 7.38905609893065, 20.085536923187668, - 54.598150033144236, 148.4131591025766, 403.4287934927351, - 1096.6331584284585 - ] + "expression": "datetime(2024, 3, 8) | day([[@, @], [@, @]])", + "result": [[8, 8], [8, 8]] }, { - "expression": "find(arrayStr, arrayStr, [0,0,0,0,0,0])", - "result": [0, 0, 0, 0, 0, 0] + "expression": "endsWith([\"abc\", [\"def\", \"ghi\"]], \"c\")", + "result": [true, [false, false]] }, { - "expression": "find(\"c\", [\"cc\",\"ccc\",\"cccc\",\"ccccc\",\"cccccc\",\"ccccccc\",\"cccccccc\"], arrayInt)", - "result": [1, 2, 3, 4, 5, 6, 7] + "expression": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1)", + "result": [[19843.166666666668, 19843.166666666668], [19843.166666666668, 19843.166666666668]] }, { - "expression": "find(arrayStr, arrayStr, 0)", - "result": [0, 0, 0, 0, 0] + "expression": "exp([1, [2, 3]])", + "result": [2.718281828459045, [7.38905609893065, 20.085536923187668]] }, { - "expression": "find(\"c\", \"abcdefg\", arrayInt)", - "result": [2, 2, null, null, null, null, null] + "expression": "find(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [1, 2]])", + "result": [0, [null, null]] }, { - "expression": "floor(arrayNum)", - "result": [-4, -3, -2, 0, 1, 2, 3] + "expression": "floor([1.1, [2.2, 3.3]])", + "result": [1, [2, 3]] }, { - "expression": "fromCodePoint(64 + arrayInt)", - "result": "ABCDEFG" + "expression": "fround([1.1, [2.2, 3.3]])", + "result": [1.100000023841858, [2.200000047683716, 3.299999952316284]] }, { - "expression": "fround(arrayNum)", - "result": [ - -3.3329999446868896, -2.2200000286102295, -1.100000023841858, 0, - 1.100000023841858, 2.2200000286102295, 3.3329999446868896 - ] + "expression": "datetime(2024, 3, 8, 13, 45) | hour([[@, @], [@, @]])", + "result": [[13, 13], [13, 13]] }, { - "expression": "datetime(2024, 10, 12, 13) | hour([@, @])", - "result": [13, 13] + "expression": "log([1, [2, 3]])", + "result": [0, [0.6931471805599453, 1.0986122886681096]] }, { - "expression": "log(arrayInt)", - "result": [ - 0, 0.6931471805599453, 1.0986122886681096, 1.3862943611198906, - 1.6094379124341003, 1.791759469228055, 1.9459101490553132 - ] + "expression": "log10([1, [2, 3]])", + "result": [0, [0.3010299956639812, 0.47712125471966244]] }, { - "expression": "log10(arrayInt)", - "result": [ - 0, 0.3010299956639812, 0.47712125471966244, 0.6020599913279624, - 0.6989700043360189, 0.7781512503836436, 0.8450980400142568 - ] + "expression": "lower([\"ABC\", [\"DEF\", \"GHI\"]])", + "result": ["abc", ["def", "ghi"]] }, { - "expression": "lower(arrayStr)", - "result": ["abc", "bcd", "cde", "def", "efg"] - }, - { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | millisecond([@, @ + 1])", - "result": [16, 16] - }, - { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | minute([@, @ + 1])", - "result": [14, 14] - }, - { - "expression": "mod(arrayInt, 2)", - "result": [1, 0, 1, 0, 1, 0, 1] - }, - { - "expression": "mod(arrayInt, arrayInt + 1)", - "result": [1, 2, 3, 4, 5, 6, 7] + "expression": "max([1, [2, 3]])", + "result": 3 }, { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | month([@, @])", - "result": [10, 10] + "expression": "datetime(2024, 3, 8, 13, 45, 30, 500) | millisecond([[@, @], [@, @]])", + "result": [[500, 500], [500, 500]] }, { - "expression": "power(arrayInt, arrayInt)", - "result": [1, 4, 27, 256, 3125, 46656, 823543] + "expression": "min([2, [1, 3]])", + "result": 1 }, { - "expression": "power(arrayInt, 2)", - "result": [1, 4, 9, 16, 25, 36, 49] + "expression": "datetime(2024, 3, 8, 13, 45) | minute([[@, @], [@, @]])", + "result": [[45, 45], [45, 45]] }, { - "expression": "power(2, arrayInt)", - "result": [2, 4, 8, 16, 32, 64, 128] + "expression": "mod([2, [4, 6]], [1, [2, 3]])", + "result": [0, [0, 0]] }, { - "expression": "proper(arrayStr)", - "result": ["Abc", "Bcd", "Cde", "Def", "Efg"] + "expression": "datetime(2024, 3, 8) | month([[@, @], [@, @]])", + "result": [[3, 3], [3, 3]] }, { - "expression": "rept(arrayStr, arrayInt)", - "result": [ - "abc", - "bcdbcd", - "cdecdecde", - "defdefdefdef", - "efgefgefgefgefg", - "", - "" - ] + "expression": "power([2, [3, 4]], [2, [2, 2]])", + "result": [4, [9, 16]] }, { - "expression": "rept(arrayStr, 2)", - "result": ["abcabc", "bcdbcd", "cdecde", "defdef", "efgefg"] + "expression": "proper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["Abc", ["Def", "Ghi"]] }, { - "expression": "rept(\"a\", arrayInt)", - "result": ["a", "aa", "aaa", "aaaa", "aaaaa", "aaaaaa", "aaaaaaa"] + "expression": "rept([\"a\", [\"b\", \"c\"]], [2, [2, 2]])", + "result": ["aa", ["bb", "cc"]] }, { - "expression": "round(arrayNum)", - "result": [-3, -2, -1, 0, 1, 2, 3] + "expression": "round([1.5, [2.5, 3.5]])", + "result": [2, [3, 4]] }, { - "expression": "search(arrayStr, arrayStr, [0,0,0,0,0])", - "result": [ - [0, "abc"], - [0, "bcd"], - [0, "cde"], - [0, "def"], - [0, "efg"] - ] + "expression": "search(\"a\", [\"abc\", [\"def\", \"ghi\"]], [0, [0, 0]])", + "result": [[0, "a"], [[], []]] }, { - "expression": "search(\"c\", arrayStr, [0,1,2,3,4,5])", - "result": [[2, "c"], [1, "c"], [], [], [], []] + "expression": "datetime(2024, 3, 8, 13, 45, 30) | second([[@, @], [@, @]])", + "result": [[30, 30], [30, 30]] }, { - "expression": "search(arrayStr, arrayStr, 0)", - "result": [ - [0, "abc"], - [0, "bcd"], - [0, "cde"], - [0, "def"], - [0, "efg"] - ] + "expression": "sign([1.1, [-2.2, 3.3]])", + "result": [1, [-1, 1]] }, { - "expression": "search(arrayStr, \"abcdefg\", [0,1,2,3])", - "result": [ - [0, "abc"], - [1, "bcd"], - [2, "cde"], - [3, "def"], - [4, "efg"] - ] + "expression": "sin([1, [2, 3]])", + "result": [0.8414709848078965, [0.9092974268256817, 0.1411200080598672]] }, { - "expression": "search(\"b\", \"abcbebg\", [0,1,2,3])", - "result": [ - [1, "b"], - [1, "b"], - [3, "b"], - [3, "b"] - ] + "expression": "split([\"a,b\", [\"c,d\", \"e,f\"]], \",\")", + "result": [["a", "b"], [["c", "d"], ["e", "f"]]] }, { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | second([@, @])", - "result": [15, 15] + "expression": "sqrt([4, [9, 16]])", + "result": [2, [3, 4]] }, { - "expression": "sign(arrayInt)", - "result": [1, 1, 1, 1, 1, 1, 1] + "expression": "startsWith([\"abc\", [\"def\", \"ghi\"]], \"a\")", + "result": [true, [false, false]] }, { - "expression": "sin(arrayNum)", - "result": [ - 0.19024072762619915, -0.7965654722360865, -0.8912073600614354, 0, - 0.8912073600614354, 0.7965654722360865, -0.19024072762619915 - ] - }, - { - "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": "stdev([1, [2, 3]])", + "result": 1 }, { - "expression": "sqrt(arrayNum[?@ > 0])", - "result": [ - 1.0488088481701516, - 1.489966442575134, - 1.8256505689753448 - ] + "expression": "stdevA([1, [2, 3]])", + "result": 1 }, { - "expression": "startsWith(arrayStr, arrayStr)", - "result": [true, - true, - true, - true, - true] + "expression": "stdevp([1, [2, 3]])", + "result": 0.8164965809277263 }, { - "expression": "startsWith(arrayStr, \"b\")", - "result": [ - false, - true, - false, - false, - false - ] + "expression": "stdevpA([1, [2, 3]])", + "result": 0.8164965809277263 }, { - "expression": "startsWith(\"abcdefg\", [\"a\", \"b\", \"c\"])", - "result": [ - true, - false, - false - ] + "expression": "substitute([\"abc\", [\"aab\", \"ghi\"]], \"a\", \"A\", [0, [1, 0]])", + "result": ["Abc", ["aAb", "ghi"]] }, { - "expression": "substitute(arrayStr, \"c\", \"C\", [0,0,0,0,0])", - "result": ["abC","bCd","Cde","def","efg"] + "expression": "sum([1, [2, 3]])", + "result": 6 }, { - "expression": "tan(arrayNum)", - "result": [-0.1937796334476594, 1.3176122402817965, -1.9647596572486523, 0, 1.9647596572486523, -1.3176122402817965, 0.1937796334476594] + "expression": "tan([1, [2, 3]])", + "result": [1.5574077246549023, [-2.185039863261519, -0.1425465430742778]] }, { - "expression": "trim(arrayStr)", - "result": ["abc", "bcd", "cde", "def", "efg"] + "expression": "trim([\" abc \", [\" def \", \" ghi \"]])", + "result": ["abc", ["def", "ghi"]] }, { - "expression": "trunc(arrayNum, [1,1,0,0,0,1,1])", - "result": [-3.3, -2.2, -1, 0, 1, 2.2, 3.3] + "expression": "trunc([1.5, [2.5, 3.5]])", + "result": [1, [2, 3]] }, { - "expression": "trunc(arrayNum)", - "result": [-3, -2, -1, 0, 1, 2, 3] + "expression": "upper([\"abc\", [\"def\", \"ghi\"]])", + "result": ["ABC", ["DEF", "GHI"]] }, { - "expression": "upper(arrayStr)", - "result": ["ABC", "BCD", "CDE", "DEF", "EFG"] + "expression": "datetime(2024, 3, 8) | weekday([[@, @], [@, @]], 1)", + "result": [[6, 6], [6, 6]] }, { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | weekday([@, @ + 1], 1)", - "result": [7, 1] + "expression": "datetime(2024, 3, 8) | year([[@, @], [@, @]])", + "result": [[2024, 2024], [2024, 2024]] }, { - "expression": "datetime(2024, 10, 12, 13, 14, 15, 16) | year([@, @ + 1])", - "result": [2024, 2024] - } + "expression": "abs([-1, 3, [-4, 5]])", + "result": [1, 3, [4, 5]] + }, + { + "expression": "avg([1, 2, [3, 4, `[5]`]])", + "result": 3 + }, + { + "expression": "acos([0, [0, 0]])", + "result": [1.5707963267948966, [1.5707963267948966, 1.5707963267948966]] + }, + { + "expression": "asin([1, [0, 0.19866933079506123]])", + "result": [1.5707963267948966, [0, 0.2]] + }, + { + + "expression": "atan2(1, [1, [2, 3]])", + "result": [0.7853981633974483, [0.4636476090008061, 0.3217505543966422]] + }, + { + + "expression": "casefold(arrayStr)", + "result": ["abc", "bcd", "cde", "def", "efg"] + }, + { + "expression": "ceil([1.1, [2.2, 3.3]])", + "result": [2, [3, 4]] + }, + { + "expression": "ceil(arrayNum)", + "result": [-3, -2, -1, 0, 2, 3, 4] + }, + { + + "expression": "codePoint([\"A\", [\"B\", \"C\"]])", + "result": [65, [66, 67]] + }, + { + + "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": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1)", + "result": [[19843.166666666668, 19843.166666666668], [19843.166666666668, 19843.166666666668]] + }, + { + + "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] + } ] } ] From cb8f3842d9be820181ee31ed4a3a684f1f82fbcf Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Thu, 3 Apr 2025 07:15:15 -0400 Subject: [PATCH 7/9] add registerWithParams() function --- doc/spec.adoc | 6 ++--- src/functions.js | 53 +++++++++++++++++++++++++++++++++++++++++--- src/interpreter.js | 2 +- test/docSamples.json | 20 +++++++++++++++++ test/tests.json | 8 +++---- 5 files changed, 78 insertions(+), 11 deletions(-) diff --git a/doc/spec.adoc b/doc/spec.adoc index 7232490c..f23c7b10 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -76,7 +76,7 @@ If the supplied data is not correct for the execution context, json-formula will * 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 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]`. +* 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. @@ -117,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 diff --git a/src/functions.js b/src/functions.js index 2c2aa5f0..3d6106ad 100644 --- a/src/functions.js +++ b/src/functions.js @@ -1683,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 @@ -1699,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 @@ -1718,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). 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/test/docSamples.json b/test/docSamples.json index 01dc82d3..68cc17df 100644 --- a/test/docSamples.json +++ b/test/docSamples.json @@ -229,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"] diff --git a/test/tests.json b/test/tests.json index e3ee9a99..7ac178e4 100644 --- a/test/tests.json +++ b/test/tests.json @@ -1581,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 } @@ -1593,6 +1589,10 @@ "expression": "register(\"_identity\", &@) || _identity()", "result": null }, + { + "expression": "registerWithParams(\"Identity\", &if (length(@) = 0, null(), @)) || Identity()", + "result": null + }, { "expression": "register(\"\", &42)", "error": "FunctionError" From 797c3b116bb251884c72c6a40dac4470bf5cbda3 Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Thu, 3 Apr 2025 07:29:57 -0400 Subject: [PATCH 8/9] fix failing test --- test/functions.json | 80 ++------------------------------------------- 1 file changed, 2 insertions(+), 78 deletions(-) diff --git a/test/functions.json b/test/functions.json index 15cbbd5f..dd4624d2 100644 --- a/test/functions.json +++ b/test/functions.json @@ -2024,8 +2024,8 @@ "result": [true, [false, false]] }, { - "expression": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1)", - "result": [[19843.166666666668, 19843.166666666668], [19843.166666666668, 19843.166666666668]] + "expression": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1) | month(@) & \"/\" &day(@)", + "result": [["4/30", "4/30"], ["4/30", "4/30"]] }, { "expression": "exp([1, [2, 3]])", @@ -2261,52 +2261,38 @@ "result": [12, 13] }, { - - "expression": "datetime(2024, 3, 8) | eomonth([[@, @], [@, @]], 1)", - "result": [[19843.166666666668, 19843.166666666668], [19843.166666666668, 19843.166666666668]] - }, - { - "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, @@ -2315,42 +2301,34 @@ ] }, { - "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] }, @@ -2371,12 +2349,10 @@ "result": "ABCDEFG" }, { - "expression": "min([2, [1, 3]])", "result": 1 }, { - "expression": "fround(arrayNum)", "result": [ -3.3329999446868896, -2.2200000286102295, -1.100000023841858, 0, @@ -2384,22 +2360,18 @@ ] }, { - "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, @@ -2407,12 +2379,10 @@ ] }, { - "expression": "datetime(2024, 3, 8) | month([[@, @], [@, @]])", "result": [[3, 3], [3, 3]] }, { - "expression": "log10(arrayInt)", "result": [ 0, 0.3010299956639812, 0.47712125471966244, 0.6020599913279624, @@ -2420,112 +2390,90 @@ ] }, { - "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", @@ -2538,42 +2486,34 @@ ] }, { - "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"], @@ -2584,22 +2524,18 @@ ] }, { - "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"], @@ -2610,12 +2546,10 @@ ] }, { - "expression": "tan([1, [2, 3]])", "result": [1.5574077246549023, [-2.185039863261519, -0.1425465430742778]] }, { - "expression": "search(arrayStr, \"abcdefg\", [0,1,2,3])", "result": [ [0, "abc"], @@ -2626,12 +2560,10 @@ ] }, { - "expression": "trim([\" abc \", [\" def \", \" ghi \"]])", "result": ["abc", ["def", "ghi"]] }, { - "expression": "search(\"b\", \"abcbebg\", [0,1,2,3])", "result": [ [1, "b"], @@ -2641,32 +2573,26 @@ ] }, { - "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, @@ -2674,12 +2600,10 @@ ] }, { - "expression": "datetime(2024, 3, 8) | year([[@, @], [@, @]])", "result": [[2024, 2024], [2024, 2024]] }, { - "expression": "split(arrayStr, \"\")", "result": [ ["a", "b", "c"], From dcaad1a80cfa501d840d68ef221558b9112c82cc Mon Sep 17 00:00:00 2001 From: John Brinkman Date: Thu, 3 Apr 2025 08:43:41 -0400 Subject: [PATCH 9/9] remove duplicate tests --- test/functions.json | 54 --------------------------------------------- 1 file changed, 54 deletions(-) diff --git a/test/functions.json b/test/functions.json index dd4624d2..5f21fa4b 100644 --- a/test/functions.json +++ b/test/functions.json @@ -129,10 +129,6 @@ "expression": "abs(foo)", "result": 1 }, - { - "expression": "abs(foo)", - "result": 1 - }, { "expression": "abs(str)", "error": "TypeError" @@ -141,10 +137,6 @@ "expression": "abs(array[1])", "result": 3 }, - { - "expression": "abs(array[1])", - "result": 3 - }, { "expression": "abs([-1, 3, [-4, 5]])", "result": [1, 3, [4, 5]] @@ -157,10 +149,6 @@ "expression": "abs(`-24`)", "result": 24 }, - { - "expression": "abs(`-24`)", - "result": 24 - }, { "expression": "abs(`1`, `2`)", "error": "FunctionError" @@ -559,10 +547,6 @@ "expression": "maxA(array)", "error": "TypeError" }, - { - "expression": "maxA(decimals)", - "result": 1.2 - }, { "expression": "maxA(empty_list)", "result": 0 @@ -651,10 +635,6 @@ "expression": "min([4, \"foo\"])", "result": 4 }, - { - "expression": "min(decimals)", - "result": -1.5 - }, { "expression": "min(strings)", "result": 0 @@ -1977,10 +1957,6 @@ 1.0654358165107394 ] }, - { - "expression": "asin([1, [0, 0.19866933079506123]])", - "result": [1.5707963267948966, [0, 0.2]] - }, { "expression": "atan2(arrayNum, 10)", "result": [ @@ -2007,10 +1983,6 @@ "expression": "codePoint([\"A\", [\"B\", \"C\"]])", "result": [65, [66, 67]] }, - { - "expression": "cos([1, [2, 3]])", - "result": [0.5403023058681398, [-0.4161468365471424, -0.9899924966004454]] - }, { "expression": "now() | datedif([@, [@, @]], [@ + 1, [@ + 2, @ + 3]], \"d\")", "result": [1, [2, 3]] @@ -2183,49 +2155,27 @@ "expression": "avg([1, 2, [3, 4, `[5]`]])", "result": 3 }, - { - "expression": "acos([0, [0, 0]])", - "result": [1.5707963267948966, [1.5707963267948966, 1.5707963267948966]] - }, { "expression": "asin([1, [0, 0.19866933079506123]])", "result": [1.5707963267948966, [0, 0.2]] }, { - - "expression": "atan2(1, [1, [2, 3]])", - "result": [0.7853981633974483, [0.4636476090008061, 0.3217505543966422]] - }, - { - "expression": "casefold(arrayStr)", "result": ["abc", "bcd", "cde", "def", "efg"] }, - { - "expression": "ceil([1.1, [2.2, 3.3]])", - "result": [2, [3, 4]] - }, { "expression": "ceil(arrayNum)", "result": [-3, -2, -1, 0, 2, 3, 4] }, { - - "expression": "codePoint([\"A\", [\"B\", \"C\"]])", - "result": [65, [66, 67]] - }, - { - "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, @@ -2233,22 +2183,18 @@ ] }, { - "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] },