From 5156d9c574e330d36fd2aed26b5f19bb396e9425 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Wed, 31 Jul 2024 11:55:57 +0200 Subject: [PATCH 1/3] feat: export util functions `isMap`, `isPartitionedMap`, and `isObjectWrappingMap` and improve the documentation of `scope` (see #3150) --- docs/expressions/customization.md | 11 ++++--- docs/expressions/expression_trees.md | 2 +- docs/expressions/parsing.md | 14 +++++++-- examples/advanced/custom_scope_objects.js | 36 ++++++++++------------- src/core/create.js | 26 +++++++++------- src/core/function/typed.js | 11 +++---- src/entry/typeChecks.js | 3 ++ src/expression/node/FunctionNode.js | 2 +- src/utils/is.js | 34 +++++++++++++++++++++ src/utils/map.js | 28 ++---------------- test/node-tests/doc.test.js | 3 ++ test/typescript-tests/testTypes.ts | 3 ++ test/unit-tests/core/typed.test.js | 34 ++++++++++++++++++++- test/unit-tests/expression/parse.test.js | 31 +++++++++++++++++-- test/unit-tests/utils/map.test.js | 3 +- types/index.d.ts | 17 +++++++++++ 16 files changed, 183 insertions(+), 75 deletions(-) diff --git a/docs/expressions/customization.md b/docs/expressions/customization.md index 2833f0b521..6fff36c65c 100644 --- a/docs/expressions/customization.md +++ b/docs/expressions/customization.md @@ -114,10 +114,13 @@ Where : - `args` is an Array with nodes of the parsed arguments. - `math` is the math namespace against which the expression was compiled. -- `scope` is a `Map` containing the variables defined in the scope passed - via `evaluate(scope)`. In case of using a custom defined function like - `f(x) = rawFunction(x) ^ 2`, the scope passed to `rawFunction` also contains - the current value of parameter `x`. +- `scope` is a [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) + interface containing the variables defined in the scope + passed via `evaluate(scope)`. The passed scope is always a `Map` interface, + and normally a `PartitionedMap` is used to separate local function variables + like `x` in a custom defined function `f(x) = rawFunction(x) ^ 2` from the + scope variables. Note that a `PartitionedMap` can recursively link to another + `PartitionedMap`. Raw functions must be imported in the `math` namespace, as they need to be processed at compile time. They are not supported when passed via a scope diff --git a/docs/expressions/expression_trees.md b/docs/expressions/expression_trees.md index e095019857..945f4fb9af 100644 --- a/docs/expressions/expression_trees.md +++ b/docs/expressions/expression_trees.md @@ -20,7 +20,7 @@ In this case, the expression `sqrt(2 + x)` is parsed as: ConstantNode 2 x SymbolNode ``` -Alternatively, this expression tree can be build by manually creating nodes: +Alternatively, this expression tree can be built by manually creating nodes: ```js const node1 = new math.ConstantNode(2) diff --git a/docs/expressions/parsing.md b/docs/expressions/parsing.md index b11eb8132d..e3503d94f5 100644 --- a/docs/expressions/parsing.md +++ b/docs/expressions/parsing.md @@ -22,9 +22,17 @@ math.evaluate([expr1, expr2, expr3, ...], scope) Function `evaluate` accepts a single expression or an array with expressions as the first argument and has an optional second argument -containing a scope with variables and functions. The scope can be a regular -JavaScript Object, or Map. The scope will be used to resolve symbols, and to write -assigned variables or function. +containing a `scope` with variables and functions. The scope can be a regular +JavaScript `Map` (recommended) or `Object`. The scope will be used to resolve +symbols, and to write assigned variables or function. + +When an `Object` is used as scope, mathjs will internally wrap it in an +`ObjectWrappingMap` interface since the internal functions can only use a `Map` +interface. In case of custom defined functions like `f(x) = x^2`, the scope +will be wrapped in a `PartitionedMap`, which reads and writes the function +variables (like `x` in this example) from a temporary map, and reads and writes +other variables from the original scope. The original scope is never copied, it +is only wrapped around when needed. The following code demonstrates how to evaluate expressions. diff --git a/examples/advanced/custom_scope_objects.js b/examples/advanced/custom_scope_objects.js index 5ca6cd18cc..d7c9823d2e 100644 --- a/examples/advanced/custom_scope_objects.js +++ b/examples/advanced/custom_scope_objects.js @@ -2,8 +2,8 @@ import { all, create } from '../../lib/esm/index.js' const math = create(all) -// The expression evaluator accepts an optional scope object. -// This is the symbol table for variable defintions and function declations. +// The expression evaluator accepts an optional scope Map or object that can +// be used to keep additional variables and functions. // Scope can be a bare object. function withObjectScope () { @@ -28,11 +28,11 @@ function withMapScope (scope, name) { math.evaluate('area(length, width) = length * width * scalar', scope) math.evaluate('A = area(x, y)', scope) - console.log(`Map-like scope (${name}):`, scope.localScope) + console.log(`Map-like scope (${name}):`, scope) } // This is a minimal set of functions to look like a Map. -class MapScope { +class CustomMap { constructor () { this.localScope = new Map() } @@ -61,7 +61,7 @@ class MapScope { * used in mathjs. * */ -class AdvancedMapScope extends MapScope { +class AdvancedCustomMap extends CustomMap { constructor (parent) { super() this.parentScope = parent @@ -91,25 +91,19 @@ class AdvancedMapScope extends MapScope { return this.localScope.clear() } - /** - * Creates a child scope from this one. This is used in function calls. - * - * @returns a new Map scope that has access to the symbols in the parent, but - * cannot overwrite them. - */ - createSubScope () { - return new AdvancedMapScope(this) - } - toString () { return this.localScope.toString() } } +// Use a plain JavaScript object withObjectScope() -// Where safety is important, scope can also be a Map -withMapScope(new Map(), 'simple Map') -// Where flexibility is important, scope can duck type appear to be a Map. -withMapScope(new MapScope(), 'MapScope example') -// Extra methods allow even finer grain control. -withMapScope(new AdvancedMapScope(), 'AdvancedScope example') + +// use a Map (recommended) +withMapScope(new Map(), 'Map example') + +// Use a custom Map implementation +withMapScope(new CustomMap(), 'CustomMap example') + +// Use a more advanced custom Map implementation +withMapScope(new AdvancedCustomMap(), 'AdvancedCustomMap example') diff --git a/src/core/create.js b/src/core/create.js index d7effce942..bb3a7bbe04 100644 --- a/src/core/create.js +++ b/src/core/create.js @@ -1,14 +1,14 @@ import typedFunction from 'typed-function' -import { deepFlatten, isLegacyFactory } from '../utils/object.js' -import * as emitter from './../utils/emitter.js' -import { importFactory } from './function/import.js' -import { configFactory } from './function/config.js' +import { ArgumentsError } from '../error/ArgumentsError.js' +import { DimensionError } from '../error/DimensionError.js' +import { IndexError } from '../error/IndexError.js' import { factory, isFactory } from '../utils/factory.js' import { isAccessorNode, isArray, isArrayNode, isAssignmentNode, + isBigInt, isBigNumber, isBlockNode, isBoolean, @@ -26,30 +26,33 @@ import { isHelp, isIndex, isIndexNode, + isMap, isMatrix, isNode, isNull, isNumber, isObject, isObjectNode, + isObjectWrappingMap, isOperatorNode, isParenthesisNode, + isPartitionedMap, isRange, isRangeNode, - isRelationalNode, isRegExp, + isRelationalNode, isResultSet, isSparseMatrix, isString, isSymbolNode, isUndefined, - isUnit, - isBigInt + isUnit } from '../utils/is.js' -import { ArgumentsError } from '../error/ArgumentsError.js' -import { DimensionError } from '../error/DimensionError.js' -import { IndexError } from '../error/IndexError.js' +import { deepFlatten, isLegacyFactory } from '../utils/object.js' +import * as emitter from './../utils/emitter.js' import { DEFAULT_CONFIG } from './config.js' +import { configFactory } from './function/config.js' +import { importFactory } from './function/import.js' /** * Create a mathjs instance from given factory functions and optionally config @@ -126,6 +129,9 @@ export function create (factories, config) { isDate, isRegExp, isObject, + isMap, + isPartitionedMap, + isObjectWrappingMap, isNull, isUndefined, diff --git a/src/core/function/typed.js b/src/core/function/typed.js index 9dce87da65..c2b69624a2 100644 --- a/src/core/function/typed.js +++ b/src/core/function/typed.js @@ -36,11 +36,14 @@ * @returns {function} The created typed-function. */ +import typedFunction from 'typed-function' +import { factory } from '../../utils/factory.js' import { isAccessorNode, isArray, isArrayNode, isAssignmentNode, + isBigInt, isBigNumber, isBlockNode, isBoolean, @@ -58,6 +61,7 @@ import { isHelp, isIndex, isIndexNode, + isMap, isMatrix, isNode, isNull, @@ -68,19 +72,16 @@ import { isParenthesisNode, isRange, isRangeNode, - isRelationalNode, isRegExp, + isRelationalNode, isResultSet, isSparseMatrix, isString, isSymbolNode, isUndefined, - isUnit, isBigInt + isUnit } from '../../utils/is.js' -import typedFunction from 'typed-function' import { digits } from '../../utils/number.js' -import { factory } from '../../utils/factory.js' -import { isMap } from '../../utils/map.js' // returns a new instance of typed-function let _createTyped = function () { diff --git a/src/entry/typeChecks.js b/src/entry/typeChecks.js index 0ccdb83dc0..caa9629f7e 100644 --- a/src/entry/typeChecks.js +++ b/src/entry/typeChecks.js @@ -29,6 +29,9 @@ export { isString, isUndefined, isObject, + isMap, + isPartitionedMap, + isObjectWrappingMap, isObjectNode, isOperatorNode, isParenthesisNode, diff --git a/src/expression/node/FunctionNode.js b/src/expression/node/FunctionNode.js index f79cc10d9d..768ea6c03d 100644 --- a/src/expression/node/FunctionNode.js +++ b/src/expression/node/FunctionNode.js @@ -92,7 +92,7 @@ export const createFunctionNode = /* #__PURE__ */ factory(name, dependencies, ({ * invoke a list with arguments on a node * @param {./Node | string} fn * Item resolving to a function on which to invoke - * the arguments, typically a SymboNode or AccessorNode + * the arguments, typically a SymbolNode or AccessorNode * @param {./Node[]} args */ constructor (fn, args) { diff --git a/src/utils/is.js b/src/utils/is.js index 7c053685ca..5bc6db84ce 100644 --- a/src/utils/is.js +++ b/src/utils/is.js @@ -12,6 +12,8 @@ // for security reasons, so these functions are not exposed in the expression // parser. +import { ObjectWrappingMap } from './map.js' + export function isNumber (x) { return typeof x === 'number' } @@ -125,6 +127,38 @@ export function isObject (x) { !isFraction(x)) } +/** + * Returns `true` if the passed object appears to be a Map (i.e. duck typing). + * + * Methods looked for are `get`, `set`, `keys` and `has`. + * + * @param {Map | object} object + * @returns + */ +export function isMap (object) { + // We can use the fast instanceof, or a slower duck typing check. + // The duck typing method needs to cover enough methods to not be confused with DenseMatrix. + if (!object) { + return false + } + return object instanceof Map || + object instanceof ObjectWrappingMap || + ( + typeof object.set === 'function' && + typeof object.get === 'function' && + typeof object.keys === 'function' && + typeof object.has === 'function' + ) +} + +export function isPartitionedMap (object) { + return isMap(object) && isMap(object.a) && isMap(object.b) +} + +export function isObjectWrappingMap (object) { + return isMap(object) && isObject(object.wrappedObject) +} + export function isNull (x) { return x === null } diff --git a/src/utils/map.js b/src/utils/map.js index baa898d5ba..30cd467754 100644 --- a/src/utils/map.js +++ b/src/utils/map.js @@ -1,5 +1,5 @@ -import { setSafeProperty, hasSafeProperty, getSafeProperty } from './customs.js' -import { isObject } from './is.js' +import { getSafeProperty, hasSafeProperty, setSafeProperty } from './customs.js' +import { isMap, isObject } from './is.js' /** * A map facade on a bare object. @@ -202,30 +202,6 @@ export function toObject (map) { return object } -/** - * Returns `true` if the passed object appears to be a Map (i.e. duck typing). - * - * Methods looked for are `get`, `set`, `keys` and `has`. - * - * @param {Map | object} object - * @returns - */ -export function isMap (object) { - // We can use the fast instanceof, or a slower duck typing check. - // The duck typing method needs to cover enough methods to not be confused with DenseMatrix. - if (!object) { - return false - } - return object instanceof Map || - object instanceof ObjectWrappingMap || - ( - typeof object.set === 'function' && - typeof object.get === 'function' && - typeof object.keys === 'function' && - typeof object.has === 'function' - ) -} - /** * Copies the contents of key-value pairs from each `objects` in to `map`. * diff --git a/test/node-tests/doc.test.js b/test/node-tests/doc.test.js index 99b63b12a3..dd73eb8534 100644 --- a/test/node-tests/doc.test.js +++ b/test/node-tests/doc.test.js @@ -181,6 +181,9 @@ const knownUndocumented = new Set([ 'isDate', 'isRegExp', 'isObject', + 'isMap', + 'isPartitionedMap', + 'isObjectWrappingMap', 'isNull', 'isUndefined', 'isAccessorNode', diff --git a/test/typescript-tests/testTypes.ts b/test/typescript-tests/testTypes.ts index 781c9620a8..7538cad14c 100644 --- a/test/typescript-tests/testTypes.ts +++ b/test/typescript-tests/testTypes.ts @@ -2293,6 +2293,9 @@ Factory Test math.isDate, math.isRegExp, math.isObject, + math.isMap, + math.isPartitionedMap, + math.isObjectWrappingMap, math.isNull, math.isUndefined, math.isAccessorNode, diff --git a/test/unit-tests/core/typed.test.js b/test/unit-tests/core/typed.test.js index 98dea5d0ec..39908b3c3b 100644 --- a/test/unit-tests/core/typed.test.js +++ b/test/unit-tests/core/typed.test.js @@ -1,6 +1,7 @@ import assert from 'assert' import math from '../../../src/defaultInstance.js' import Decimal from 'decimal.js' +import { ObjectWrappingMap, PartitionedMap } from '../../../src/utils/map.js' const math2 = math.create() describe('typed', function () { @@ -177,6 +178,37 @@ describe('typed', function () { assert.strictEqual(math.isNull(), false) }) + it('should test whether a value is an object', function () { + assert.strictEqual(math.isObject({}), true) + assert.strictEqual(math.isObject({ a: 2 }), true) + assert.strictEqual(math.isObject(Object.create({})), true) + assert.strictEqual(math.isObject(null), false) + assert.strictEqual(math.isObject([]), false) + assert.strictEqual(math.isObject(), false) + assert.strictEqual(math.isObject(undefined), false) + }) + + it('should test whether a value is a Map', function () { + assert.strictEqual(math.isMap({}), false) + assert.strictEqual(math.isMap(new Map()), true) + assert.strictEqual(math.isMap(new ObjectWrappingMap({})), true) + assert.strictEqual(math.isMap(new PartitionedMap(new Map(), new Map(), new Set(['x']))), true) + }) + + it('should test whether a value is a PartitionedMap', function () { + assert.strictEqual(math.isPartitionedMap({}), false) + assert.strictEqual(math.isPartitionedMap(new Map()), false) + assert.strictEqual(math.isPartitionedMap(new ObjectWrappingMap({})), false) + assert.strictEqual(math.isPartitionedMap(new PartitionedMap(new Map(), new Map(), new Set(['x']))), true) + }) + + it('should test whether a value is an ObjectWrappingMap', function () { + assert.strictEqual(math.isObjectWrappingMap({}), false) + assert.strictEqual(math.isObjectWrappingMap(new Map()), false) + assert.strictEqual(math.isObjectWrappingMap(new ObjectWrappingMap({})), true) + assert.strictEqual(math.isObjectWrappingMap(new PartitionedMap(new Map(), new Map(), new Set(['x']))), false) + }) + it('should test whether a value is undefined', function () { assert.strictEqual(math.isUndefined(undefined), true) assert.strictEqual(math.isUndefined(math.matrix()), false) @@ -201,7 +233,7 @@ describe('typed', function () { assert.strictEqual(math.isConstantNode(), false) }) - it('should test whether a value is a SymolNode', function () { + it('should test whether a value is a SymbolNode', function () { assert.strictEqual(math.isSymbolNode(new math.SymbolNode('')), true) assert.strictEqual(math.isSymbolNode(new math2.SymbolNode('')), true) assert.strictEqual(math.isSymbolNode({ isSymbolNode: true }), false) diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index 8964bc5362..cbd487f90b 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -1,8 +1,10 @@ // test parse import assert from 'assert' - -import { approxEqual, approxDeepEqual } from '../../../tools/approx.js' import math from '../../../src/defaultInstance.js' +import { isMap, isObjectWrappingMap, isPartitionedMap } from '../../../src/index.js' +import { PartitionedMap } from '../../../src/utils/map.js' + +import { approxDeepEqual, approxEqual } from '../../../tools/approx.js' const parse = math.parse const ConditionalNode = math.ConditionalNode @@ -1599,6 +1601,31 @@ describe('parse', function () { assert.strictEqual(scope.a, false) }) + it('should always pass a Map as scope to a rawArgs function', function () { + const myMath = math.create() + function myFunction (args, _math, _scope) { + return { + type: isObjectWrappingMap(_scope) + ? 'ObjectWrappingMap' + : isPartitionedMap(_scope) + ? 'PartitionedMap' + : isMap(_scope) + ? 'Map' + : 'unknown', + scope: _scope + } + } + myFunction.rawArgs = true + myMath.import({ myFunction }) + + assert.strictEqual(myMath.parse('myFunction()').evaluate({}).type, 'PartitionedMap') + const map = new Map() + assert.strictEqual(myMath.parse('myFunction()').evaluate(map).type, 'PartitionedMap') + assert.strictEqual(myMath.parse('myFunction()').evaluate(map).scope.a, map) + assert.strictEqual(myMath.parse('myFunction()').evaluate(new PartitionedMap(new Map(), new Map(), new Set('x'))).type, 'PartitionedMap') + assert.deepStrictEqual(myMath.parse('f(x) = myFunction(x); f(2)').evaluate(new Map()).entries[0].type, 'PartitionedMap') + }) + it('should parse logical xor', function () { assert.strictEqual(parseAndEval('2 xor 6'), false) assert.strictEqual(parseAndEval('2 xor 0'), true) diff --git a/test/unit-tests/utils/map.test.js b/test/unit-tests/utils/map.test.js index aeba77615d..09148f91d1 100644 --- a/test/unit-tests/utils/map.test.js +++ b/test/unit-tests/utils/map.test.js @@ -1,5 +1,6 @@ import assert from 'assert' -import { isMap, ObjectWrappingMap, toObject, createMap, assign, PartitionedMap } from '../../../src/utils/map.js' +import { isMap } from '../../../src/index.js' +import { assign, createMap, ObjectWrappingMap, PartitionedMap, toObject } from '../../../src/utils/map.js' describe('maps', function () { it('should provide isMap, a function to tell maps from non-maps', function () { diff --git a/types/index.d.ts b/types/index.d.ts index 981e68be41..b2983bc4b4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3381,6 +3381,14 @@ export interface MathJsInstance extends MathJsFactory { isObject(x: unknown): boolean + isMap(x: unknown): x is Map + + isPartitionedMap(x: unknown): x is PartitionedMap + + isObjectWrappingMap( + x: unknown + ): x is ObjectWrappingMap + isNull(x: unknown): x is null isUndefined(x: unknown): x is undefined @@ -4157,6 +4165,15 @@ export interface UnitDefinition { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Index {} +export interface PartitionedMap { + a: Map + b: Map +} + +export interface ObjectWrappingMap { + wrappedObject: Record +} + export interface EvalFunction { // eslint-disable-next-line @typescript-eslint/no-explicit-any evaluate(scope?: any): any From 443d2425570a3c45db597d70c41c0f6d57f402a5 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Wed, 31 Jul 2024 12:08:27 +0200 Subject: [PATCH 2/3] chore: fix broken unit tests --- test/unit-tests/expression/parse.test.js | 2 +- test/unit-tests/utils/map.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index cbd487f90b..c9cca399ea 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -1,7 +1,7 @@ // test parse import assert from 'assert' import math from '../../../src/defaultInstance.js' -import { isMap, isObjectWrappingMap, isPartitionedMap } from '../../../src/index.js' +import { isMap, isObjectWrappingMap, isPartitionedMap } from '../../../src/utils/is.js' import { PartitionedMap } from '../../../src/utils/map.js' import { approxDeepEqual, approxEqual } from '../../../tools/approx.js' diff --git a/test/unit-tests/utils/map.test.js b/test/unit-tests/utils/map.test.js index 09148f91d1..19f9fbc021 100644 --- a/test/unit-tests/utils/map.test.js +++ b/test/unit-tests/utils/map.test.js @@ -1,5 +1,5 @@ import assert from 'assert' -import { isMap } from '../../../src/index.js' +import { isMap } from '../../../src/utils/is.js' import { assign, createMap, ObjectWrappingMap, PartitionedMap, toObject } from '../../../src/utils/map.js' describe('maps', function () { From 15e3e570d9241d49dfa011655d3bf7ade3453f55 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Thu, 1 Aug 2024 10:46:35 +0200 Subject: [PATCH 3/3] docs: refine the explanation about scopes --- docs/expressions/parsing.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/expressions/parsing.md b/docs/expressions/parsing.md index e3503d94f5..5dd2ebce19 100644 --- a/docs/expressions/parsing.md +++ b/docs/expressions/parsing.md @@ -23,8 +23,10 @@ math.evaluate([expr1, expr2, expr3, ...], scope) Function `evaluate` accepts a single expression or an array with expressions as the first argument and has an optional second argument containing a `scope` with variables and functions. The scope can be a regular -JavaScript `Map` (recommended) or `Object`. The scope will be used to resolve -symbols, and to write assigned variables or function. +JavaScript `Map` (recommended), a plain JavaScript `object`, or any custom +class that implements the `Map` interface with methods `get`, `set`, `keys` +and `has`. The scope will be used to resolve symbols, and to write assigned +variables and functions. When an `Object` is used as scope, mathjs will internally wrap it in an `ObjectWrappingMap` interface since the internal functions can only use a `Map`