Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update MathJS to v12.4.3 and handle the new approach to scope/partitioned map #1371

Merged
merged 5 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions v3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
"iframe-phone": "^1.3.1",
"leaflet": "^1.9.4",
"lodash": "^4.17.21",
"mathjs": "12.3.1",
"mathjs": "12.4.3",
"mime": "^4.0.4",
"mobx": "^6.13.0",
"mobx-react-lite": "^4.0.7",
Expand Down
8 changes: 0 additions & 8 deletions v3/src/models/formula/formula-mathjs-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,6 @@ export class FormulaMathJsScope {
throw new Error("It's not allowed to clear values in the formula scope.")
}

// MathJS requests a separate subscope for every parsed and evaluated function call.
createSubScope () {
// In regular MathJS use case, a subscope is used to create a new scope for a function call. It needs to ensure
// that variables from subscope cannot overwrite variables from the parent scope. However, since we don't allow
// any variables to be set in the formula scope, we can reuse the same scope instance.
return this
}

// --- Custom functions used by our formulas or formula manager --

setExtraScope(extraScope?: Map<string, FValue>) {
Expand Down
9 changes: 8 additions & 1 deletion v3/src/models/formula/formula-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ export type EvaluateFunc = (...args: FValue[]) => FValue

export type EvaluateFuncWithAggregateContextSupport = (...args: (FValueOrArray)[]) => FValueOrArray

export type EvaluateRawFunc = (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => FValueOrArray
// MathJS v12.3.2 introduced the concept of PartitionedMap, replacing the previous concepts of scopes and sub-scopes.
// The parent scope is still available as the `a` property of the PartitionedMap.
// For more information, see: https://github.com/josdejong/mathjs/pull/3150
export type MathJSPartitionedMap = { a: CurrentScope, b: Map<string, any>}

export type CurrentScope = MathJSPartitionedMap | FormulaMathJsScope

export type EvaluateRawFunc = (args: MathNode[], mathjs: any, currentScope: CurrentScope) => FValueOrArray

export type CaseList = ICase[] | "ALL_CASES"

Expand Down
13 changes: 7 additions & 6 deletions v3/src/models/formula/functions/aggregate-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,25 +151,26 @@ describe("cachedAggregateFnFactory", () => {
setCached: (key: string, val: any) => cache.set(key, val),
getCached: (key: string) => cache.get(key)
} as any as FormulaMathJsScope
const currentScope = { a: scope, b: new Map() }

expect(cachedFn([ parse("1"), parse("2") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(1)

expect(cachedFn([ parse("1"), parse("2") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(1) // Same arguments as in the previous call, cache should be used.

expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(2) // Different arguments, so cache should not be used.

expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(2) // Same arguments as in the previous call, cache should be used.

// Update scope.getCaseAggregateGroupId
;(scope.getCaseAggregateGroupId as jest.Mock).mockImplementation(() => "newTestGroup")
expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(3) // New caseAggregateGroupId, so cache should not be used.

expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, scope)).toEqual(123)
expect(cachedFn([ parse("1"), parse("2"), parse("3") ], null, currentScope)).toEqual(123)
expect(fn).toHaveBeenCalledTimes(3) // Same arguments and caseAggregateGroupId, cache should be used.
})
})
18 changes: 10 additions & 8 deletions v3/src/models/formula/functions/aggregate-functions.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { MathNode, mad, max, mean, median, min, sum } from "mathjs"
import { FormulaMathJsScope } from "../formula-mathjs-scope"
import { FValue, FValueOrArray } from "../formula-types"
import { UNDEF_RESULT, evaluateNode, isNumber, isValueTruthy } from "./function-utils"
import { CurrentScope, FValue, FValueOrArray } from "../formula-types"
import { UNDEF_RESULT, evaluateNode, getRootScope, isNumber, isValueTruthy } from "./function-utils"

// Almost every aggregate function can be cached in the same way.
export const cachedAggregateFnFactory =
(fnName: string, fn: (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => FValueOrArray) => {
return (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
(fnName: string, fn: (args: MathNode[], mathjs: any, currentScope: CurrentScope) => FValueOrArray) => {
return (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
const cacheKey = `${fnName}(${args.toString()})-${scope.getCaseAggregateGroupId()}`
const cachedValue = scope.getCached(cacheKey)
if (cachedValue !== undefined) {
return cachedValue
}
const result = fn(args, mathjs, scope)
const result = fn(args, mathjs, currentScope)
scope.setCached(cacheKey, result)
return result
}
Expand All @@ -21,7 +21,8 @@ export const cachedAggregateFnFactory =
// Note that aggregate functions like mean, max, min, etc., all have exactly the same signature and implementation.
// The only difference is the final math operation applies to the expression results.
const aggregateFnWithFilterFactory = (fn: (values: number[]) => FValue) => {
return (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
return (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
const [ expression, filter ] = args
let expressionValues = evaluateNode(expression, scope)
if (!Array.isArray(expressionValues)) {
Expand Down Expand Up @@ -96,7 +97,8 @@ export const aggregateFunctions = {
// arguments, the default caching method would calculate incorrect cache key. Hence, caching is implemented directly
// in the function body.
cachedEvaluateFactory: undefined,
evaluateRaw: (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
evaluateRaw: (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
const [expression, filter] = args
if (!expression) {
// Special case: count() without arguments returns number of children cases. Note that this cannot be cached
Expand Down
40 changes: 39 additions & 1 deletion v3/src/models/formula/functions/function-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { isValueTruthy, equal, evaluateNode } from "./function-utils"
import { isValueTruthy, equal, evaluateNode, isPartitionedMap, isMap, getRootScope } from "./function-utils"
import { parse } from "mathjs"
import { CurrentScope } from "../formula-types"
import { FormulaMathJsScope } from "../formula-mathjs-scope"

describe("isValueTruthy", () => {
it("should return true for truthy values", () => {
Expand Down Expand Up @@ -42,3 +44,39 @@ describe("evaluateNode", () => {
expect(evaluateNode(node)).toBe(3)
})
})

describe("isPartitionedMap", () => {
it("should return true for partitioned map", () => {
const map = { a: {}, b: new Map() }
expect(isPartitionedMap(map)).toBe(true)
})

it("should return false for non-partitioned map", () => {
const map = new Map()
expect(isPartitionedMap(map)).toBe(false)
})
})

describe("isMap", () => {
it("should return true for map", () => {
const map = new Map()
expect(isMap(map)).toBe(true)
})

it("should return false for non-map", () => {
const map = {}
expect(isMap(map)).toBe(false)
})
})

describe("getRootScope", () => {
it("should return root scope for partitioned map", () => {
const scope = { a: {}, b: new Map() }
expect(getRootScope(scope as any as CurrentScope)).toBe(scope.a)
})

it("should return scope for non-partitioned map", () => {
const scope = {} as any as FormulaMathJsScope
expect(getRootScope(scope)).toBe(scope)
})
})
21 changes: 21 additions & 0 deletions v3/src/models/formula/functions/function-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MathNode } from "mathjs"
import { FormulaMathJsScope } from "../formula-mathjs-scope"
import { isValueNonEmpty } from "../../../utilities/math-utils"
import { CurrentScope, MathJSPartitionedMap } from "../formula-types"
export { isNumber, isValueNonEmpty } from "../../../utilities/math-utils"

export const UNDEF_RESULT = ""
Expand Down Expand Up @@ -33,3 +34,23 @@ equal = (a: any, b: any): boolean => {
export const evaluateNode = (node: MathNode, scope?: FormulaMathJsScope) => {
return node.compile().evaluate(scope)
}

// This function is used to get the root scope (an instance of our FormulaMathJSScope) within custom MathJS functions.
// When the formula expression is executed, the initially passed scope can be wrapped in MathJS's PartitionedMap, which
// is used to store temporary values. This function retrieves the root scope from the PartitionedMap.
// This approach has been recommended by the MathJS maintainer. For more details, see:
// https://github.com/josdejong/mathjs/pull/3150#issuecomment-2248101774
export const getRootScope = (currentScope: CurrentScope): FormulaMathJsScope => {
return isPartitionedMap(currentScope) ? getRootScope(currentScope.a) : currentScope
}

export const isPartitionedMap = (map: any): map is MathJSPartitionedMap => {
// We could also check isMap(map.a), but that makes tests and mocking more difficult.
// The current check is probably specific enough to be safe.
return map && typeof map.a === "object" && isMap(map.b)
}

export const isMap = (map: any) => {
const requiredMapMethods = ['get', 'set', 'has', 'delete', 'entries']
return map && typeof map === 'object' && requiredMapMethods.every(method => typeof map[method] === 'function')
}
11 changes: 6 additions & 5 deletions v3/src/models/formula/functions/lookup-functions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { ConstantNode, MathNode } from "mathjs"
import { FormulaMathJsScope } from "../formula-mathjs-scope"
import { DisplayNameMap, FValue, ILookupDependency } from "../formula-types"
import { CurrentScope, DisplayNameMap, FValue, ILookupDependency } from "../formula-types"
import { rmCanonicalPrefix } from "../utils/name-mapping-utils"
import { UNDEF_RESULT, equal, evaluateNode } from "./function-utils"
import { UNDEF_RESULT, equal, evaluateNode, getRootScope } from "./function-utils"
import { isConstantStringNode } from "../utils/mathjs-utils"
import { t } from "../../../utilities/translation/translate"
import type { IDataSet } from "../../data/data-set"
Expand Down Expand Up @@ -32,7 +31,8 @@ export const lookupFunctions = {
attrNameArg.value = displayNameMap.dataSet[dataSetName]?.attribute[attrName] || attrName
}
},
evaluateRaw: (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
evaluateRaw: (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
const functionName = "lookupByIndex"
const numOfReqArgs = lookupFunctions.lookupByIndex.numOfRequiredArguments
if (args.length !== numOfReqArgs) {
Expand Down Expand Up @@ -91,7 +91,8 @@ export const lookupFunctions = {
keyAttrNameArg.value = displayNameMap.dataSet[dataSetName]?.attribute[keyAttrName] || keyAttrName
}
},
evaluateRaw: (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
evaluateRaw: (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
const functionName = "lookupByKey"
const numOfReqArgs = lookupFunctions.lookupByKey.numOfRequiredArguments
if (args.length !== numOfReqArgs) {
Expand Down
21 changes: 12 additions & 9 deletions v3/src/models/formula/functions/math.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { MathNode, SymbolNode, parse } from "mathjs"
import { FormulaMathJsScope } from "../formula-mathjs-scope"
import {
evaluateRawWithAggregateContext, evaluateRawWithDefaultArg, evaluateToEvaluateRaw, evaluateWithAggregateContextSupport
} from "./math"
import { FValue, FValueOrArray } from "../formula-types"
import { FValue, FValueOrArray, MathJSPartitionedMap } from "../formula-types"

describe("evaluateRawWithAggregateContext", () => {
it("should call provided function within withAggregateContext", () => {
Expand All @@ -17,11 +16,12 @@ describe("evaluateRawWithAggregateContext", () => {
scope.aggregateContext = false
}
}
const currentScope = { a: scope, b: new Map() } as any as MathJSPartitionedMap
let result = false
const mockFn = jest.fn(() => { result = scope.aggregateContext; return "" })

evaluateRawWithAggregateContext(mockFn)(args, mathjs, scope as any as FormulaMathJsScope)
expect(mockFn).toHaveBeenCalledWith(args, mathjs, scope)
evaluateRawWithAggregateContext(mockFn)(args, mathjs, currentScope)
expect(mockFn).toHaveBeenCalledWith(args, mathjs, currentScope)
expect(result).toBeTruthy()
})
})
Expand All @@ -33,11 +33,12 @@ describe("evaluateRawWithDefaultArg", () => {
const scope = {
defaultArgumentNode: { name: "default" }
}
const currentScope = { a: scope, b: new Map() } as any as MathJSPartitionedMap
const mockFn = jest.fn((_args: MathNode[]) => (_args[0] as SymbolNode).name)

const numOfReqArgs = 1
const res = evaluateRawWithDefaultArg(mockFn, numOfReqArgs)(args, mathjs, scope as any as FormulaMathJsScope)
expect(mockFn).toHaveBeenCalledWith([scope.defaultArgumentNode], mathjs, scope)
const res = evaluateRawWithDefaultArg(mockFn, numOfReqArgs)(args, mathjs, currentScope)
expect(mockFn).toHaveBeenCalledWith([scope.defaultArgumentNode], mathjs, currentScope)
expect(res).toEqual("default")
})

Expand All @@ -47,11 +48,12 @@ describe("evaluateRawWithDefaultArg", () => {
const scope = {
defaultArgumentNode: { name: "default" }
}
const currentScope = { a: scope, b: new Map() } as any as MathJSPartitionedMap
const mockFn = jest.fn((_args: MathNode[]) => (_args[0] as SymbolNode).name)

const res =
evaluateRawWithDefaultArg(mockFn, 1)(args as any as MathNode[], mathjs, scope as any as FormulaMathJsScope)
expect(mockFn).toHaveBeenCalledWith(args, mathjs, scope)
evaluateRawWithDefaultArg(mockFn, 1)(args as any as MathNode[], mathjs, currentScope)
expect(mockFn).toHaveBeenCalledWith(args, mathjs, currentScope)
expect(res).toEqual("provided")
})
})
Expand All @@ -61,9 +63,10 @@ describe("evaluateToEvaluateRaw", () => {
const args = [ parse("1"), parse("2") ]
const mathjs = {}
const scope = {}
const currentScope = { a: scope, b: new Map() } as any as MathJSPartitionedMap
const mockFn = jest.fn((a: FValueOrArray, b: FValueOrArray) => Number(a) + Number(b))

const res = evaluateToEvaluateRaw(mockFn)(args as any as MathNode[], mathjs, scope as any as FormulaMathJsScope)
const res = evaluateToEvaluateRaw(mockFn)(args as any as MathNode[], mathjs, currentScope)
expect(mockFn).toHaveBeenCalledWith(1, 2)
expect(res).toEqual(3)
})
Expand Down
22 changes: 12 additions & 10 deletions v3/src/models/formula/functions/math.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { create, all, MathNode } from 'mathjs'
import { FormulaMathJsScope } from '../formula-mathjs-scope'
import {
CODAPMathjsFunctionRegistry, EvaluateFunc, EvaluateFuncWithAggregateContextSupport, EvaluateRawFunc, FValue,
FValueOrArray
CODAPMathjsFunctionRegistry, CurrentScope, EvaluateFunc, EvaluateFuncWithAggregateContextSupport, EvaluateRawFunc,
FValue, FValueOrArray
} from '../formula-types'
import { evaluateNode } from './function-utils'
import { evaluateNode, getRootScope } from './function-utils'
import { arithmeticFunctions } from './arithmetic-functions'
import { dateFunctions } from './date-functions'
import { stringFunctions } from './string-functions'
Expand All @@ -18,23 +17,26 @@ export const math = create(all)

// Each aggregate function needs to be evaluated with `withAggregateContext` method.
export const evaluateRawWithAggregateContext = (fn: EvaluateRawFunc): EvaluateRawFunc => {
return (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
return (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
// withAggregateContext returns result of the callback function
return scope.withAggregateContext(() => fn(args, mathjs, scope))
return scope.withAggregateContext(() => fn(args, mathjs, currentScope))
}
}

export const evaluateRawWithDefaultArg = (fn: EvaluateRawFunc, numOfRequiredArguments: number): EvaluateRawFunc => {
return (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
return (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
if (scope.defaultArgumentNode && args.length < numOfRequiredArguments) {
return fn([...args, scope.defaultArgumentNode], mathjs, scope)
return fn([...args, scope.defaultArgumentNode], mathjs, currentScope)
}
return fn(args, mathjs, scope)
return fn(args, mathjs, currentScope)
}
}

export const evaluateToEvaluateRaw = (fn: EvaluateFuncWithAggregateContextSupport): EvaluateRawFunc => {
return (args: MathNode[], mathjs: any, scope: FormulaMathJsScope) => {
return (args: MathNode[], mathjs: any, currentScope: CurrentScope) => {
const scope = getRootScope(currentScope)
return fn(...(args.map(arg => evaluateNode(arg, scope))))
}
}
Expand Down
Loading
Loading