diff --git a/AUTHORS b/AUTHORS index dfd737ae0c..4f314dd456 100644 --- a/AUTHORS +++ b/AUTHORS @@ -278,6 +278,7 @@ kyle-compute DongKwanKho <70864292+dodokw@users.noreply.github.com> Ikem Peter Josh Kelley +Nils Dietrich Richard Taylor # Generated by tools/update-authors.js diff --git a/docs/expressions/expression_trees.md b/docs/expressions/expression_trees.md index 945f4fb9af..fb6fc666d3 100644 --- a/docs/expressions/expression_trees.md +++ b/docs/expressions/expression_trees.md @@ -277,13 +277,20 @@ Construction: ``` new AccessorNode(object: Node, index: IndexNode) +new AccessorNode(object: Node, index: IndexNode, optionalChaining: boolean) ``` +An optional property `optionalChaining` can be provided whether the accessor was +written as optional-chaining using `a?.b`, or `a?.["b"]` with bracket notation. +Default value is `false`. Forces evaluate to undefined if the given object is +undefined or null. + Properties: - `object: Node` - `index: IndexNode` - `name: string` (read-only) The function or method name. Returns an empty string when undefined. +- `optionalChaining: boolean` Examples: diff --git a/docs/expressions/syntax.md b/docs/expressions/syntax.md index f435773bd4..0d90d49e67 100644 --- a/docs/expressions/syntax.md +++ b/docs/expressions/syntax.md @@ -55,54 +55,55 @@ interchangeably. For example, `x+y` will always evaluate identically to Functions below. -Operator | Name | Syntax | Associativity | Example | Result ------------ | -------------------------- | ---------- | ------------- | --------------------- | --------------- -`(`, `)` | Grouping | `(x)` | None | `2 * (3 + 4)` | `14` -`[`, `]` | Matrix, Index | `[...]` | None | `[[1,2],[3,4]]` | `[[1,2],[3,4]]` -`{`, `}` | Object | `{...}` | None | `{a: 1, b: 2}` | `{a: 1, b: 2}` -`,` | Parameter separator | `x, y` | Left to right | `max(2, 1, 5)` | `5` -`.` | Property accessor | `obj.prop` | Left to right | `obj={a: 12}; obj.a` | `12` -`;` | Statement separator | `x; y` | Left to right | `a=2; b=3; a*b` | `[6]` -`;` | Row separator | `[x; y]` | Left to right | `[1,2;3,4]` | `[[1,2],[3,4]]` -`\n` | Statement separator | `x \n y` | Left to right | `a=2 \n b=3 \n a*b` | `[2,3,6]` -`+` | Add | `x + y` | Left to right | `4 + 5` | `9` -`+` | Unary plus | `+y` | Right to left | `+4` | `4` -`-` | Subtract | `x - y` | Left to right | `7 - 3` | `4` -`-` | Unary minus | `-y` | Right to left | `-4` | `-4` -`*` | Multiply | `x * y` | Left to right | `2 * 3` | `6` -`.*` | Element-wise multiply | `x .* y` | Left to right | `[1,2,3] .* [1,2,3]` | `[1,4,9]` -`/` | Divide | `x / y` | Left to right | `6 / 2` | `3` -`./` | Element-wise divide | `x ./ y` | Left to right | `[9,6,4] ./ [3,2,2]` | `[3,3,2]` -`%` | Percentage | `x%` | None | `8%` | `0.08` -`%` | Addition with Percentage | `x + y%` | Left to right | `100 + 3%` | `103` -`%` | Subtraction with Percentage| `x - y%` | Left to right | `100 - 3%` | `97` -`%` `mod` | Modulus | `x % y` | Left to right | `8 % 3` | `2` -`^` | Power | `x ^ y` | Right to left | `2 ^ 3` | `8` -`.^` | Element-wise power | `x .^ y` | Right to left | `[2,3] .^ [3,3]` | `[8,27]` -`'` | Transpose | `y'` | Left to right | `[[1,2],[3,4]]'` | `[[1,3],[2,4]]` -`!` | Factorial | `y!` | Left to right | `5!` | `120` -`&` | Bitwise and | `x & y` | Left to right | `5 & 3` | `1` -`~` | Bitwise not | `~x` | Right to left | `~2` | `-3` -| | Bitwise or | x | y | Left to right | 5 | 3 | `7` -^| | Bitwise xor | x ^| y | Left to right | 5 ^| 2 | `7` -`<<` | Left shift | `x << y` | Left to right | `4 << 1` | `8` -`>>` | Right arithmetic shift | `x >> y` | Left to right | `8 >> 1` | `4` -`>>>` | Right logical shift | `x >>> y` | Left to right | `-8 >>> 1` | `2147483644` -`and` | Logical and | `x and y` | Left to right | `true and false` | `false` -`not` | Logical not | `not y` | Right to left | `not true` | `false` -`or` | Logical or | `x or y` | Left to right | `true or false` | `true` -`xor` | Logical xor | `x xor y` | Left to right | `true xor true` | `false` -`=` | Assignment | `x = y` | Right to left | `a = 5` | `5` -`?` `:` | Conditional expression | `x ? y : z` | Right to left | `15 > 100 ? 1 : -1` | `-1` -`??` | Nullish coalescing | `x ?? y` | Left to right | `null ?? 2` | `2` -`:` | Range | `x : y` | Right to left | `1:4` | `[1,2,3,4]` -`to`, `in` | Unit conversion | `x to y` | Left to right | `2 inch to cm` | `5.08 cm` -`==` | Equal | `x == y` | Left to right | `2 == 4 - 2` | `true` -`!=` | Unequal | `x != y` | Left to right | `2 != 3` | `true` -`<` | Smaller | `x < y` | Left to right | `2 < 3` | `true` -`>` | Larger | `x > y` | Left to right | `2 > 3` | `false` -`<=` | Smallereq | `x <= y` | Left to right | `4 <= 3` | `false` -`>=` | Largereq | `x >= y` | Left to right | `2 + 4 >= 6` | `true` +Operator | Name | Syntax | Associativity | Example | Result +----------- |-----------------------------|-------------| ------------- |-----------------------| --------------- +`(`, `)` | Grouping | `(x)` | None | `2 * (3 + 4)` | `14` +`[`, `]` | Matrix, Index | `[...]` | None | `[[1,2],[3,4]]` | `[[1,2],[3,4]]` +`{`, `}` | Object | `{...}` | None | `{a: 1, b: 2}` | `{a: 1, b: 2}` +`,` | Parameter separator | `x, y` | Left to right | `max(2, 1, 5)` | `5` +`.` | Property accessor | `obj.prop` | Left to right | `obj={a: 12}; obj.a` | `12` +`;` | Statement separator | `x; y` | Left to right | `a=2; b=3; a*b` | `[6]` +`;` | Row separator | `[x; y]` | Left to right | `[1,2;3,4]` | `[[1,2],[3,4]]` +`\n` | Statement separator | `x \n y` | Left to right | `a=2 \n b=3 \n a*b` | `[2,3,6]` +`+` | Add | `x + y` | Left to right | `4 + 5` | `9` +`+` | Unary plus | `+y` | Right to left | `+4` | `4` +`-` | Subtract | `x - y` | Left to right | `7 - 3` | `4` +`-` | Unary minus | `-y` | Right to left | `-4` | `-4` +`*` | Multiply | `x * y` | Left to right | `2 * 3` | `6` +`.*` | Element-wise multiply | `x .* y` | Left to right | `[1,2,3] .* [1,2,3]` | `[1,4,9]` +`/` | Divide | `x / y` | Left to right | `6 / 2` | `3` +`./` | Element-wise divide | `x ./ y` | Left to right | `[9,6,4] ./ [3,2,2]` | `[3,3,2]` +`%` | Percentage | `x%` | None | `8%` | `0.08` +`%` | Addition with Percentage | `x + y%` | Left to right | `100 + 3%` | `103` +`%` | Subtraction with Percentage | `x - y%` | Left to right | `100 - 3%` | `97` +`%` `mod` | Modulus | `x % y` | Left to right | `8 % 3` | `2` +`^` | Power | `x ^ y` | Right to left | `2 ^ 3` | `8` +`.^` | Element-wise power | `x .^ y` | Right to left | `[2,3] .^ [3,3]` | `[8,27]` +`'` | Transpose | `y'` | Left to right | `[[1,2],[3,4]]'` | `[[1,3],[2,4]]` +`!` | Factorial | `y!` | Left to right | `5!` | `120` +`&` | Bitwise and | `x & y` | Left to right | `5 & 3` | `1` +`~` | Bitwise not | `~x` | Right to left | `~2` | `-3` +| | Bitwise or | x | y | Left to right | 5 | 3 | `7` +^| | Bitwise xor | x ^| y | Left to right | 5 ^| 2 | `7` +`<<` | Left shift | `x << y` | Left to right | `4 << 1` | `8` +`>>` | Right arithmetic shift | `x >> y` | Left to right | `8 >> 1` | `4` +`>>>` | Right logical shift | `x >>> y` | Left to right | `-8 >>> 1` | `2147483644` +`and` | Logical and | `x and y` | Left to right | `true and false` | `false` +`not` | Logical not | `not y` | Right to left | `not true` | `false` +`or` | Logical or | `x or y` | Left to right | `true or false` | `true` +`xor` | Logical xor | `x xor y` | Left to right | `true xor true` | `false` +`=` | Assignment | `x = y` | Right to left | `a = 5` | `5` +`?` `:` | Conditional expression | `x ? y : z` | Right to left | `15 > 100 ? 1 : -1` | `-1` +`??` | Nullish coalescing | `x ?? y` | Left to right | `null ?? 2` | `2` +`?.` | Optional chaining accessor | `obj?.prop` | Left to right | `obj={}; obj?.a` | `undefined` +`:` | Range | `x : y` | Right to left | `1:4` | `[1,2,3,4]` +`to`, `in` | Unit conversion | `x to y` | Left to right | `2 inch to cm` | `5.08 cm` +`==` | Equal | `x == y` | Left to right | `2 == 4 - 2` | `true` +`!=` | Unequal | `x != y` | Left to right | `2 != 3` | `true` +`<` | Smaller | `x < y` | Left to right | `2 < 3` | `true` +`>` | Larger | `x > y` | Left to right | `2 > 3` | `false` +`<=` | Smallereq | `x <= y` | Left to right | `4 <= 3` | `false` +`>=` | Largereq | `x >= y` | Left to right | `2 + 4 >= 6` | `true` ## Precedence diff --git a/src/expression/node/AccessorNode.js b/src/expression/node/AccessorNode.js index 8806699197..2fa6f97ec5 100644 --- a/src/expression/node/AccessorNode.js +++ b/src/expression/node/AccessorNode.js @@ -47,8 +47,12 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ * @param {Node} object The object from which to retrieve * a property or subset. * @param {IndexNode} index IndexNode containing ranges + * @param {boolean} [optionalChaining=false] + * Optional property, if the accessor was written as optional-chaining + * using `a?.b`, or `a?.["b"] with bracket notation. + * Forces evaluate to undefined if the given object is undefined or null. */ - constructor (object, index) { + constructor (object, index, optionalChaining = false) { super() if (!isNode(object)) { throw new TypeError('Node expected for parameter "object"') @@ -59,6 +63,7 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ this.object = object this.index = index + this.optionalChaining = optionalChaining } // readonly property name @@ -93,15 +98,41 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ const evalObject = this.object._compile(math, argNames) const evalIndex = this.index._compile(math, argNames) + const optionalChaining = this.optionalChaining + const prevOptionalChaining = isAccessorNode(this.object) && this.object.optionalChaining + if (this.index.isObjectProperty()) { const prop = this.index.getObjectProperty() return function evalAccessorNode (scope, args, context) { + const ctx = context || {} + const object = evalObject(scope, args, ctx) + + if (optionalChaining && object == null) { + ctx.optionalShortCircuit = true + return undefined + } + + if (prevOptionalChaining && ctx?.optionalShortCircuit) { + return undefined + } + // get a property from an object evaluated using the scope. - return getSafeProperty(evalObject(scope, args, context), prop) + return getSafeProperty(object, prop) } } else { return function evalAccessorNode (scope, args, context) { - const object = evalObject(scope, args, context) + const ctx = context || {} + const object = evalObject(scope, args, ctx) + + if (optionalChaining && object == null) { + ctx.optionalShortCircuit = true + return undefined + } + + if (prevOptionalChaining && ctx?.optionalShortCircuit) { + return undefined + } + // we pass just object here instead of context: const index = evalIndex(scope, args, object) return access(object, index) @@ -127,7 +158,8 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ map (callback) { return new AccessorNode( this._ifNode(callback(this.object, 'object', this)), - this._ifNode(callback(this.index, 'index', this)) + this._ifNode(callback(this.index, 'index', this)), + this.optionalChaining ) } @@ -136,7 +168,7 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ * @return {AccessorNode} */ clone () { - return new AccessorNode(this.object, this.index) + return new AccessorNode(this.object, this.index, this.optionalChaining) } /** @@ -149,8 +181,8 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ if (needParenthesis(this.object)) { object = '(' + object + ')' } - - return object + this.index.toString(options) + const optionalChaining = this.optionalChaining ? (this.index.dotNotation ? '?' : '?.') : '' + return object + optionalChaining + this.index.toString(options) } /** @@ -192,7 +224,8 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ return { mathjs: name, object: this.object, - index: this.index + index: this.index, + optionalChaining: this.optionalChaining } } @@ -205,7 +238,7 @@ export const createAccessorNode = /* #__PURE__ */ factory(name, dependencies, ({ * @returns {AccessorNode} */ static fromJSON (json) { - return new AccessorNode(json.object, json.index) + return new AccessorNode(json.object, json.index, json.optionalChaining) } } diff --git a/src/expression/node/FunctionNode.js b/src/expression/node/FunctionNode.js index 7de973c39d..9ba05fb435 100644 --- a/src/expression/node/FunctionNode.js +++ b/src/expression/node/FunctionNode.js @@ -137,6 +137,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({ _compile (math, argNames) { // compile arguments const evalArgs = this.args.map((arg) => arg._compile(math, argNames)) + const fromOptionalChaining = isAccessorNode(this.fn) && this.fn.optionalChaining if (isSymbolNode(this.fn)) { const name = this.fn.name @@ -242,6 +243,12 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({ return function evalFunctionNode (scope, args, context) { const object = evalObject(scope, args, context) + + // Optional chaining: if the base object is nullish, short-circuit to undefined + if (fromOptionalChaining && object == null) { + return undefined + } + const fn = getSafeMethod(object, prop) if (fn?.rawArgs) { diff --git a/src/expression/parse.js b/src/expression/parse.js index d3564cc76e..ca5976a29b 100644 --- a/src/expression/parse.js +++ b/src/expression/parse.js @@ -151,6 +151,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ '=': true, ':': true, '?': true, + '?.': true, '??': true, '==': true, @@ -1370,9 +1371,9 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ /** * parse accessors: - * - function invocation in round brackets (...), for example sqrt(2) - * - index enclosed in square brackets [...], for example A[2,3] - * - dot notation for properties, like foo.bar + * - function invocation in round brackets (...), for example sqrt(2) or sqrt?.(2) with optional chaining + * - index enclosed in square brackets [...], for example A[2,3] or A?.[2,3] with optional chaining + * - dot notation for properties, like foo.bar or foo?.bar with optional chaining * @param {Object} state * @param {Node} node Node on which to apply the parameters. If there * are no parameters in the expression, the node @@ -1385,8 +1386,49 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ function parseAccessors (state, node, types) { let params - while ((state.token === '(' || state.token === '[' || state.token === '.') && - (!types || types.includes(state.token))) { // eslint-disable-line no-unmodified-loop-condition + // Iterate and handle chained accessors, including repeated optional chaining + while (true) { // eslint-disable-line no-unmodified-loop-condition + // Track whether an optional chaining operator precedes the next accessor + let optional = false + + // Consume an optional chaining operator if present + if (state.token === '?.') { + optional = true + // consume the '?.' token + getToken(state) + + // Special case: property access via dot-notation following optional chaining (obj?.foo) + // After consuming '?.', the dot is already consumed as part of the token, + // so the next token is the property name itself. Handle that here. + const isPropertyNameAfterOptional = (!types || types.includes('.')) && ( + state.tokenType === TOKENTYPE.SYMBOL || + (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS) + ) + if (isPropertyNameAfterOptional) { + params = [] + params.push(new ConstantNode(state.token)) + getToken(state) + const dotNotation = true + node = new AccessorNode(node, new IndexNode(params, dotNotation), true) + // Continue parsing, allowing more chaining after this accessor + continue + } + // Otherwise, fall through to allow patterns like obj?.[...] + } + + // If the next token does not start an accessor, we're done + const hasNextAccessor = + (state.token === '(' || state.token === '[' || state.token === '.') && + (!types || types.includes(state.token)) + + if (!hasNextAccessor) { + // A dangling '?.' without a following accessor is a syntax error + if (optional) { + throw createSyntaxError(state, 'Unexpected operator ?.') + } + break + } + params = [] if (state.token === '(') { @@ -1439,7 +1481,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ closeParams(state) getToken(state) - node = new AccessorNode(node, new IndexNode(params)) + node = new AccessorNode(node, new IndexNode(params), optional) } else { // dot notation like variable.prop getToken(state) @@ -1454,7 +1496,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ getToken(state) const dotNotation = true - node = new AccessorNode(node, new IndexNode(params, dotNotation)) + node = new AccessorNode(node, new IndexNode(params, dotNotation), optional) } } diff --git a/test/benchmark/accessor.js b/test/benchmark/accessor.js new file mode 100644 index 0000000000..15384b2217 --- /dev/null +++ b/test/benchmark/accessor.js @@ -0,0 +1,76 @@ +// test the accessor-node performance in node.js + +// browserify benchmark/expression_parser.js -o ./benchmark_expression_parser.js + +import assert from 'node:assert' +import { Bench } from 'tinybench' +import { all, create } from '../../lib/esm/index.js' +import { formatTaskResult } from './utils/formatTaskResult.js' + +const math = create(all) + +const scope = { + obj: { foo: { bar: { baz: 2 } } } +} + +const expr = 'obj?.foo?.["bar"]?.baz' +const compiled = math.parse(expr).compile(math) + +const compiledPlainJs = { + evaluate: function (scope) { + // eslint-disable-next-line + return scope.obj.foo['bar'].baz + } +} + +const exprOptionalChaining = 'obj?.foo?.["bar"]?.baz' +const compiledOptionalChaining = math.parse(exprOptionalChaining).compile(math) + +const compiledChainingPlainJs = { + evaluate: function (scope) { + // eslint-disable-next-line + return scope.obj?.foo?.['bar']?.baz + } +} + +const correctResult = 2 + +console.log('scope:', scope) +console.log('result:', correctResult) + +console.log('expression:', expr) +assertEqual(compiled.evaluate(scope), correctResult) +assertEqual(compiledPlainJs.evaluate(scope), correctResult) + +console.log('expression optional chaining:', exprOptionalChaining) +assertEqual(compiledOptionalChaining.evaluate(scope), correctResult) +assertEqual(compiledChainingPlainJs.evaluate(scope), correctResult) + +let total = 0 + +const bench = new Bench({ time: 100, iterations: 100 }) + .add('(plain js) evaluate', function () { + total += compiledPlainJs.evaluate(scope) + }) + .add('(mathjs) evaluate', function () { + total += compiled.evaluate(scope) + }) + .add('(plain js optional chaining) evaluate', function () { + total += compiledChainingPlainJs.evaluate(scope) + }) + .add('(mathjs optional chaining) evaluate', function () { + total += compiledOptionalChaining.evaluate(scope) + }) + +bench.addEventListener('cycle', (event) => console.log(formatTaskResult(bench, event.task))) +await bench.run() + +// we count at total to prevent the browsers from not executing +// the benchmarks ("dead code") when the results would not be used. +if (total > 1e6) { + console.log('') +} + +function assertEqual (actual, expected) { + assert.equal(actual, expected) +} diff --git a/test/unit-tests/expression/node/AccessorNode.test.js b/test/unit-tests/expression/node/AccessorNode.test.js index 292361ee1d..39b1b2c80c 100644 --- a/test/unit-tests/expression/node/AccessorNode.test.js +++ b/test/unit-tests/expression/node/AccessorNode.test.js @@ -10,6 +10,7 @@ const SymbolNode = math.SymbolNode const AccessorNode = math.AccessorNode const IndexNode = math.IndexNode const RangeNode = math.RangeNode +const ConditionalNode = math.ConditionalNode describe('AccessorNode', function () { it('should create a AccessorNode', function () { @@ -109,6 +110,32 @@ describe('AccessorNode', function () { assert.deepStrictEqual(expr.evaluate(scope), 42) }) + it('should compile a AccessorNode with an not existing property and optional chaining', function () { + const a = new SymbolNode('a') + const index = new IndexNode([new ConstantNode('b')]) + const n = new AccessorNode(a, index, true) + const expr = n.compile() + + const scope = { + a: undefined + } + assert.deepStrictEqual(expr.evaluate(scope), undefined) + }) + + it('should compile a nested AccessorNode with an not existing property and optional chaining', function () { + const a = new SymbolNode('a') + const index = new IndexNode([new ConstantNode('b')]) + const n = new AccessorNode(a, index, true) + const index2 = new IndexNode([new ConstantNode('c')]) + const n2 = new AccessorNode(n, index2, true) + const expr = n2.compile() + + const scope = { + a: undefined + } + assert.deepStrictEqual(expr.evaluate(scope), undefined) + }) + it('should throw a one-based index error when out of range (Array)', function () { const a = new SymbolNode('a') const index = new IndexNode([new ConstantNode(4)]) @@ -408,6 +435,16 @@ describe('AccessorNode', function () { assert.strictEqual(d.index.dimensions[1], n.index.dimensions[1]) }) + it('should clone an AccessorNode with optional chaining', function () { + const a = new SymbolNode('a') + const b = new ConstantNode(2) + const c = new ConstantNode(1) + const n = new AccessorNode(a, new IndexNode([b, c]), true) + + const d = n.clone() + assert.strictEqual(n.dotNotation, d.dotNotation) + }) + it('should test equality of an Node', function () { const a = new SymbolNode('a') const b = new SymbolNode('b') @@ -436,6 +473,9 @@ describe('AccessorNode', function () { const n2 = new AccessorNode(a, new IndexNode([])) assert.strictEqual(n2.toString(), 'a[]') + + const n3 = new AccessorNode(a, new IndexNode([]), true) + assert.strictEqual(n3.toString(), 'a?.[]') }) it('should stringify an AccessorNode with parentheses', function () { @@ -446,6 +486,15 @@ describe('AccessorNode', function () { assert.strictEqual(bar.toString(), '(a + b)["bar"]') }) + it('should stringify an AccessorNode with parentheses and optional chaining', function () { + const condition = new ConstantNode(1) + const obj1 = new SymbolNode('obj1') + const obj2 = new SymbolNode('obj2') + const add = new ConditionalNode(condition, obj1, obj2) + const bar = new AccessorNode(add, new IndexNode([new ConstantNode('bar')]), true) + assert.strictEqual(bar.toString(), '(1 ? obj1 : obj2)?.["bar"]') + }) + it('should stringify nested AccessorNode', function () { const a = new SymbolNode('a') const foo = new AccessorNode(a, new IndexNode([new ConstantNode('foo')])) @@ -453,7 +502,28 @@ describe('AccessorNode', function () { assert.strictEqual(bar.toString(), 'a["foo"]["bar"]') }) - it('should stringigy an AccessorNode with custom toString', function () { + it('should stringify nested AccessorNode using optional chaining', function () { + const a = new SymbolNode('a') + const foo = new AccessorNode(a, new IndexNode([new ConstantNode('foo')]), true) + const bar = new AccessorNode(foo, new IndexNode([new ConstantNode('bar')]), true) + assert.strictEqual(bar.toString(), 'a?.["foo"]?.["bar"]') + }) + + it('should stringify nested AccessorNode with dot-notation', function () { + const a = new SymbolNode('a') + const foo = new AccessorNode(a, new IndexNode([new ConstantNode('foo')], true)) + const bar = new AccessorNode(foo, new IndexNode([new ConstantNode('bar')], true)) + assert.strictEqual(bar.toString(), 'a.foo.bar') + }) + + it('should stringify nested AccessorNode with dot-notation using optional chaining', function () { + const a = new SymbolNode('a') + const foo = new AccessorNode(a, new IndexNode([new ConstantNode('foo')], true), true) + const bar = new AccessorNode(foo, new IndexNode([new ConstantNode('bar')], true), true) + assert.strictEqual(bar.toString(), 'a?.foo?.bar') + }) + + it('should stringify an AccessorNode with custom toString', function () { // Also checks if the custom functions get passed on to the children const customFunction = function (node, options) { if (node.type === 'AccessorNode') { @@ -551,7 +621,8 @@ describe('AccessorNode', function () { assert.deepStrictEqual(json, { mathjs: 'AccessorNode', index: node.index, - object: a + object: a, + optionalChaining: false }) const parsed = AccessorNode.fromJSON(json) diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index 309ad3512b..9ec806ed63 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -895,10 +895,48 @@ describe('parse', function () { assert.deepStrictEqual(parseAndEval('obj["foo"]', { obj: { foo: 2 } }), 2) }) + it('should get an object property using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.["foo"]', { obj: { foo: 2 } }), 2) + }) + + it('should return undefined accessing a property of undefined using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.["foo"]', { obj: undefined }), undefined) + }) + + it('should return undefined accessing a property of null using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.["foo"]', { obj: null }), undefined) + }) + it('should get a nested object property', function () { assert.deepStrictEqual(parseAndEval('obj["foo"]["bar"]', { obj: { foo: { bar: 2 } } }), 2) }) + it('should get a nested object property using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.["bar"]', { obj: { foo: { bar: 2 } } }), 2) + }) + + it('should return undefined accessing a nested property of undefined using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj["foo"]?.["bar"]', { obj: { foo: undefined } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]["bar"]', { obj: undefined }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.["bar"]', { obj: undefined }), undefined) + }) + + it('should return undefined accessing a nested property of null using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj["foo"]?.["bar"]', { obj: { foo: null } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]["bar"]', { obj: null }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.["bar"]', { obj: null }), undefined) + }) + + it('should throw an error accessing a nested property of undefined using optional chaining', function () { + assert.throws(function () { parseAndEval('obj["foo"]?.["bar"]', { obj: undefined }) }, TypeError) + assert.throws(function () { parseAndEval('obj?.["foo"]["bar"]', { obj: { foo: undefined } }) }, TypeError) + }) + + it('should throw an error accessing a nested null of null using optional chaining', function () { + assert.throws(function () { parseAndEval('obj["foo"]?.["bar"]', { obj: null }) }, TypeError) + assert.throws(function () { parseAndEval('obj?.["foo"]["bar"]', { obj: { foo: null } }) }, TypeError) + }) + it('should get a nested matrix subset from an object property', function () { assert.deepStrictEqual(parseAndEval('obj.foo[2]', { obj: { foo: [1, 2, 3] } }), 2) assert.deepStrictEqual(parseAndEval('obj.foo[end]', { obj: { foo: [1, 2, 3] } }), 3) @@ -969,14 +1007,56 @@ describe('parse', function () { assert.deepStrictEqual(parseAndEval('obj.foo', { obj: { foo: 2 } }), 2) }) + it('should get an object property with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo', { obj: { foo: 2 } }), 2) + }) + + it('should return undefined accessing a property of undefined with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo', { obj: undefined }), undefined) + }) + + it('should return undefined accessing a property of null with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo', { obj: null }), undefined) + }) + it('should get an object property from an object inside parentheses', function () { assert.deepStrictEqual(parseAndEval('(obj).foo', { obj: { foo: 2 } }), 2) }) + it('should get an object property from an object inside parentheses using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('(obj)?.foo', { obj: { foo: 2 } }), 2) + }) + it('should get a nested object property with dot notation', function () { assert.deepStrictEqual(parseAndEval('obj.foo.bar', { obj: { foo: { bar: 2 } } }), 2) }) + it('should get a nested object property with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo?.bar', { obj: { foo: { bar: 2 } } }), 2) + }) + + it('should return undefined accessing a nested property of undefined with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj.foo?.bar', { obj: { foo: undefined } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.foo?.bar', { obj: undefined }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.foo.bar', { obj: undefined }), undefined) + }) + + it('should return undefined accessing a nested property of null with dot notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj.foo?.bar', { obj: { foo: null } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.foo.bar', { obj: null }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.foo?.bar', { obj: null }), undefined) + }) + + it('should throw an error accessing a nested property of undefined with dot notation using optional chaining', function () { + assert.throws(function () { parseAndEval('obj.foo?.bar', { obj: undefined }) }, TypeError) + assert.throws(function () { parseAndEval('obj?.foo.bar', { obj: { foo: undefined } }) }, TypeError) + }) + + it('should throw an error accessing a nested property of null with dot notation using optional chaining', function () { + assert.throws(function () { parseAndEval('obj.foo?.bar', { obj: null }) }, TypeError) + assert.throws(function () { parseAndEval('obj?.foo.bar', { obj: { foo: null } }) }, TypeError) + }) + it('should get a nested object property e using dot notation', function () { // in the past, the parser was trying to parse '.e' as a number const scope = { a: { e: { x: 2 } } } @@ -996,6 +1076,66 @@ describe('parse', function () { assert.deepStrictEqual(parseAndEval('obj["fn"](2)', scope), 4) }) + it('should invoke a function in an object using optional chaining', function () { + const scope = { + obj: { + fn: function (x) { + return x * x + } + } + } + assert.deepStrictEqual(parseAndEval('obj?.fn(2)', scope), 4) + assert.deepStrictEqual(parseAndEval('obj?.["fn"](2)', scope), 4) + }) + + it('should return undefined when invoking an undefined function using optional chaining', function () { + const scope = { obj: undefined } + assert.deepStrictEqual(parseAndEval('obj?.fn(2)', scope), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["fn"](2)', scope), undefined) + }) + + it('should return undefined when invoking a null function using optional chaining', function () { + const scope = { obj: null } + assert.deepStrictEqual(parseAndEval('obj?.fn(2)', scope), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["fn"](2)', scope), undefined) + }) + + it('should get a object property from a function result using optional chaining', function () { + const scope = { + obj: { + fn: function (x) { + return { foo: x } + } + } + } + assert.deepStrictEqual(parseAndEval('obj.fn(2)?.foo', scope), 2) + assert.deepStrictEqual(parseAndEval('obj["fn"](2)?.foo', scope), 2) + }) + + it('should return undefined accessing an undefined function result using optional chaining', function () { + const scope = { + obj: { + fn: function () { + return undefined + } + } + } + assert.deepStrictEqual(parseAndEval('obj.fn(2)?.foo', scope), undefined) + assert.deepStrictEqual(parseAndEval('obj["fn"](2)?.foo', scope), undefined) + }) + + it('should return undefined accessing a null function result using optional chaining', function () { + const scope = { + obj: { + fn: function () { + return null + } + } + } + assert.deepStrictEqual(parseAndEval('obj.fn(2)?.foo', scope), undefined) + assert.deepStrictEqual(parseAndEval('obj["fn"](2)?.foo', scope), undefined) + }) + it('should apply implicit multiplication after a function call', function () { assert.deepStrictEqual(parseAndEval('sqrt(4)(1+2)'), 6) assert.deepStrictEqual(parseAndEval('sqrt(4)(1+2)(2)'), 12) @@ -1017,6 +1157,51 @@ describe('parse', function () { assert.deepStrictEqual(parseAndEval('obj["foo"].bar["baz"]', { obj: { foo: { bar: { baz: 2 } } } }), 2) }) + it('should get nested object property with mixed dot- and index-notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: { foo: { bar: { baz: 2 } } } }), 2) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: { foo: { bar: { baz: 2 } } } }), 2) + }) + + it('should return undefined accessing a property of undefined with mixed dot- and index-notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: undefined }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: undefined }), undefined) + + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: { foo: undefined } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: { foo: undefined } }), undefined) + + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: { foo: { bar: undefined } } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: { foo: { bar: undefined } } }), undefined) + }) + + it('should return undefined accessing a property of null with mixed dot- and index-notation using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: null }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: null }), undefined) + + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: { foo: null } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: { foo: null } }), undefined) + + assert.deepStrictEqual(parseAndEval('obj?.foo?.["bar"]?.baz', { obj: { foo: { bar: null } } }), undefined) + assert.deepStrictEqual(parseAndEval('obj?.["foo"]?.bar?.["baz"]', { obj: { foo: { bar: null } } }), undefined) + }) + + it('should throw an error accessing a nested property of undefined with mixed dot- and index-notation using optional chaining', function () { + assert.throws(function () { parseAndEval('obj.foo?.["bar"]?.baz', { obj: undefined }) }, TypeError) + assert.throws(function () { parseAndEval('obj.foo["bar"]?.baz', { obj: { foo: undefined } }) }, TypeError) + assert.throws(function () { parseAndEval('obj.foo["bar"].baz', { obj: { foo: { bar: undefined } } }) }, TypeError) + }) + + it('should throw an error accessing a nested property of null with mixed dot- and index-notation using optional chaining', function () { + assert.throws(function () { parseAndEval('obj.foo?.["bar"]?.baz', { obj: null }) }, TypeError) + assert.throws(function () { parseAndEval('obj.foo["bar"]?.baz', { obj: { foo: null } }) }, TypeError) + assert.throws(function () { parseAndEval('obj.foo["bar"].baz', { obj: { foo: { bar: null } } }) }, TypeError) + }) + + it('should set an object property with dot notation', function () { + const scope = { obj: {} } + parseAndEval('obj.foo = 2', scope) + assert.deepStrictEqual(scope, { obj: { foo: 2 } }) + }) + it('should set an object property with dot notation', function () { const scope = { obj: {} } parseAndEval('obj.foo = 2', scope) @@ -1062,6 +1247,10 @@ describe('parse', function () { assert.deepStrictEqual(parseAndEval('{foo:2}["foo"]'), 2) }) + it('should get a property from a just created object using optional chaining', function () { + assert.deepStrictEqual(parseAndEval('{foo:2}?.["foo"]'), 2) + }) + it('should parse an object containing a function assignment', function () { const obj = parseAndEval('{f: f(x)=x^2}') assert.deepStrictEqual(Object.keys(obj), ['f']) diff --git a/types/index.d.ts b/types/index.d.ts index f443b71924..ac3ab60518 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -185,11 +185,13 @@ export interface AccessorNode object: TObject index: IndexNode name: string + optionalChaining: boolean } export interface AccessorNodeCtor { new ( object: TObject, - index: IndexNode + index: IndexNode, + optionalChaining?: boolean ): AccessorNode }