Skip to content

Add support for scopes with get and set methods#2166

Merged
josdejong merged 32 commits into
josdejong:developfrom
jhugman:2143-map-like-scopes
May 16, 2021
Merged

Add support for scopes with get and set methods#2166
josdejong merged 32 commits into
josdejong:developfrom
jhugman:2143-map-like-scopes

Conversation

@jhugman
Copy link
Copy Markdown
Contributor

@jhugman jhugman commented Apr 10, 2021

Fixes #2143.

This wraps the scop Object in a ObjectWrappingMap and finds all the places where getSafeProperty (and friends) was being used on the scope, and changes it to getMapProperty (and friends).

It also adds an example, some tests and some documentation.

I think it shows a fairly clear path to:

  • making objects constructed by expressions to be Maps, instead of objects.
  • wrapping the math object passed through the AST as a Map instead of objects.

These should eliminate the blacklist approach to securing mathjs.

Additionally, it shows a reasonable path to making symbol lookup (including units) to be extensible.

Copy link
Copy Markdown
Contributor Author

@jhugman jhugman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • customs.js defines some getSafeProperty and setSafeProperty which should be modified to recognized objects with get and set methods.
  • Add functions for hasSafeProperty(object, prop) and getSafeProperties(object). Between these four functions we should be able to get and set, test membership, and enumerate entries.
  • Find and replace instances of if (… in scope) or for (… in scope) with the newly minted functions.
  • Add tests to evaluate.test.js, simplify.test.js and security.test.js.
  • Add an example of using a custom scope object.

I don't think this is ready for landing yet, (see checklist here), but it is breaking the build, so would appreciate some help with that, and a feedback review. @josdejong

Comment thread src/expression/node/Node.js
Comment thread test/unit-tests/expression/security.test.js
Comment thread src/expression/function/evaluate.js Outdated
@jhugman jhugman force-pushed the 2143-map-like-scopes branch from 5c74330 to f08e9b3 Compare April 10, 2021 15:00
Comment thread src/core/function/typed.js Outdated
Comment thread src/expression/node/SymbolNode.js
Comment thread src/expression/function/evaluate.js Outdated
@jhugman jhugman marked this pull request as ready for review April 11, 2021 15:43
@josdejong
Copy link
Copy Markdown
Owner

Thanks @jhugman for your PR! I'll review it soon

Copy link
Copy Markdown
Owner

@josdejong josdejong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, sorry for my late response, I try to keep up but sometimes I lack time or energy.

Over all this PR looks really good and well thought through, thanks a lot @jhugman! I made some comments in the code.

One important thing we need to decide upon is whether we already want to start giving users this deprecation warning, I think that may be a bit too early: I would like to have worked out a proof of concept of the evaluator purely based on Map instead of Object before introducing those warnings. That way we know for sure if it's feasible, and at that point we know what the impact is and what will be the breaking changes exactly.

I think completely replacing the internal usage of Object in the expression parser involves:

  • scope should be Map instead of Object
  • the math namespace used in the expression parser (mathWithTransform) should be Map instead of Object.
  • Parser and cli.js should use Map instead of Object (already in the PR)
  • Input like math.evaluate('{a: 2, b: 3}') should create Map instead of Object. This is very breaking!

I think it would be good to go about this in small steps, for example:

  • Step 1: first introduction of using Map as scope. Keep everything backward compatible. (This PR)
  • Step 2: work out a proof of concept fully replacing usage of Object in the expression parser with Map.
  • Step 3: based on the outcome of Step 2, work out a solution which replaces Object with Map everywhere internally, but still must be backward compatible. This version may give deprecation warnings, or we may decide that we want to introduce a config option to toggle using either Object or Map or something like that.
  • Step 4: drop the old code in a new breaking release. Create helper functions to ease migration where needed.

Comment thread bin/cli.js Outdated
Comment thread examples/advanced/custom_scope_objects.js
Comment thread src/core/function/typed.js Outdated
Comment thread src/expression/Parser.js Outdated
Comment thread src/expression/function/evaluate.js Outdated
Comment thread src/expression/node/Node.js
Comment thread src/utils/customs.js Outdated
Comment thread src/expression/function/evaluate.js
@jhugman jhugman force-pushed the 2143-map-like-scopes branch from 60bdaa3 to 85fdf30 Compare April 25, 2021 15:01
@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented Apr 25, 2021

Ok, I found a bug: I hadn't actually being doing the right thing with creating sub scopes.

I pulled on a thread, and it kept coming. A series of fairly mechanical refactors makes it look like there's a lot, but not much really.

Once I'd introduced hasSafeProperty and getSafeProperties in previous commits, I could find all the places using SafePropert*(scope to ScopePropert methods, which forward to the equivalent SafeProperty methods. This really unlocks some useful transformations.

Getting these ScopeProperty methods to check if the scope was MapLike, and error if not let me find all the entrance points to evaluate and simplify, wrap and object into a Map like object, moving most of the SafeProperty calls out of evaluate methods, and into the Object wrapper.

From there, a re-organization of Map functions and objects was relatively simple: all the working parts are in a new file ./src/utils/map.js.

At this point, SafeProperty functions from customs.js are only protecting math objects, and in-language created objects. I'm not going to attempt that here, but there's a good path forward now: we can treat the methods on math as a different namespace/scope, and internal implementations of in language objects and arrays can diverge their unsafe Javascript counterparts.

Responding to your review, and some other questions:

  • I've renamed MapLike to Map. In general, I migrated from SafeProperty to ScopeProperty to MapProperty.
  • We can inline all calls to MapProperty functions to their map methods. I'd keep them around for now until the migration from internal objects and math namespace has been completed.
  • I don't understand why parser has a scope. It seems to come up in the security tests quite a lot, but not many places else.

Still to do:

  • Tests for utils/map.js
  • Micro-optimizing isMap as suggested.
  • Write some documentation as suggested.
  • Consider renaming ObjectWrapperScope as ObjectWrappingMap.
  • Look at review comments around cli.js
  • Convert Parser.js to use a Map as suggested.

I'm not quite finished, but the end is in sight.

Copy link
Copy Markdown
Contributor Author

@jhugman jhugman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite ready, but would like your feedback @josdejong .

Comment thread bin/cli.js Outdated
Comment thread bin/cli.js Outdated
Comment thread src/expression/keywords.js
Comment thread src/expression/node/AssignmentNode.js
Comment thread src/expression/node/FunctionNode.js
Comment thread src/expression/node/Node.js Outdated
Comment thread src/expression/node/SymbolNode.js
@jhugman jhugman requested a review from josdejong April 25, 2021 16:11
Comment thread src/expression/node/FunctionNode.js Outdated
return evalArg(scope, args, context)
}))
}
return function evalFunctionNode (scope, args, context) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three cases for 1, 2, or more than 2 arguments where here for performance: looping over a map is slower than just calling 1 or 2 functions. It would be nice to simplify the code, but please let's only do that after verifying that it doesn't have negative impact on the performance. It may be a good idea to add a short comment explaining this 😁

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I made a switch statement. How are you benchmarking/profiling to make these micro-optimizing decisions?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no full-fledged benchmark suite, but there are some tests for this in the folder test/benchmark. In this case we can run test/benchmark/expression_parser.js against both the current develop branch and this PR and see if there is any noticeable difference.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the benchmark files are broken because they're loaded as ES module instead of CommonJS (still using require), will fix that in develop

@josdejong
Copy link
Copy Markdown
Owner

I really like where this is heading @jhugman!

Consider renaming ObjectWrapperScope as ObjectWrappingMap

ObjectWrapperScope is a really nice solution! I like the name ObjectWrappingMap better indeed :)

@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented May 3, 2021

Aside from the above documentation, I think this is ready for review! Thanks for your nice feedback so far.

@jhugman jhugman requested a review from josdejong May 7, 2021 20:21
@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented May 8, 2021

Ok! I've added the last bit, a section in the documentation.

I think this is in a place where it should be sqashed and landed before moving forward with the next block of work for de-objectifying mathjs.

@josdejong When you have some time could you review again, and we can work towards the Big Green Button for me.

Copy link
Copy Markdown
Owner

@josdejong josdejong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jhugman this looks very solid, thanks for the updates. Using a Map interface everywhere works out really nicely, like in Parser.js!

I've updated the benchmark ./test/benchmark/expression_parser.js to make it work and have a proper comparison with plain JS using Map, updating the following code to:

const expr = '2 + 3 * sin(pi / 4) - 4x'
const scope = new Map([
  ['x', 2]
])
const compiled = math.parse(expr).compile(math)

function undefinedSymbol(name) {
  throw new Error('Undefined symbol "' + name + '"')
}

const sin = getSafeProperty(math, 'sin')
const pi = getSafeProperty(math, 'pi')
const compiledPlainJs = {
  evaluate: function (scope) {
    return 2 + 3 * (scope.has('sin') ? scope.get('sin') : sin)((scope.has('pi') ? scope.get('pi') : pi) / 4) - 4 * (scope.has('x') ? scope.get('x') : undefinedSymbol('x'))
  }
}

It looks like you're PR makes the expression parser slightly faster than it was before, at least there is no regression in performance 🎉

I've added some inline feedbacks in the latest version of the PR and closed some outdated conversations, can you have a look?

Comment thread src/utils/map.js Outdated
if (isMap(mapOrObject)) {
return mapOrObject
}
if (typeof mapOrObject === 'object') {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to use the isObject util function here.

typeof mapOrObject === 'object' is also true when mapOrObject is null, one of those JS pitfalls

Comment thread src/utils/map.js
Comment thread src/utils/map.js Outdated
}

/*
* These wrapper functions are temporary. They provide scaffolding around calling the methods
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are those "temporary" functions like setMapProperty still needed at this point, or can the be replaced with a direct call map.set(...)? Since the expression parser now works against a Map interface everywhere, there should be no need for wrapper functions anymore I think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can get rid of these, though they may come back as scaffold for near future refactors.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's then clean them up for now, and when needed we can recreate them (it's not rocket science), OK?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've done this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(btw: I use the 👍 reaction as a check mark to myself that I've addressed the issue).

Comment thread src/utils/scope.js Outdated
@@ -0,0 +1,40 @@
import { createEmptyMap, isMap } from './map.js'

function assign (scope, ...objects) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor detail, but this functionality like assign and createSubScope are not "scope" specific, they are very generic util functions for objects in general. We could move them to utils/object.js maybe and rename "scope" to "target", etc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered about this.

assign could live in utils/object or utils/map, though my preference is map: if it's analogue is Object.assign(target, ...objs). This assign, target is always a Map, even though objs is (Map|Object)[].

createSubScope does feels scope specific, which is why I put it here. It should forward to the scope if it can.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to rename the file scope.js to map.js, you're right! Maybe createSubScope can be renamed to createSubMap :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved assign to map.js, and changed the body of createSubScope, and changed the examples/custom_scope_object.js example. Hopefully this should be instructive.

it('should provide isMap, a function to tell maps from non-maps', () => {
assert.ok(isMap(new Map()))
assert.ok(!isMap([]))
assert.ok(!isMap({}))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add some tests like isMap(null), isMap(undefined), isMap(42), etc?

Comment thread test/unit-tests/utils/map.test.js
Comment thread src/expression/node/FunctionNode.js Outdated
return evalArg(scope, args, context)
}))
}
return function evalFunctionNode (scope, args, context) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no full-fledged benchmark suite, but there are some tests for this in the folder test/benchmark. In this case we can run test/benchmark/expression_parser.js against both the current develop branch and this PR and see if there is any noticeable difference.

Comment thread src/expression/node/FunctionNode.js Outdated
return evalArg(scope, args, context)
}))
}
return function evalFunctionNode (scope, args, context) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the benchmark files are broken because they're loaded as ES module instead of CommonJS (still using require), will fix that in develop

Comment thread src/utils/map.js Outdated
@@ -0,0 +1,113 @@
import { setSafeProperty, hasSafeProperty, getSafeProperty } from './customs.js'

export class ObjectScopeWrapper {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some more thoughts on the name: shouldn't it reflect that is a Map? I still think ObjectWrappingMap or ObjectWrapperMap is quite clear, but if you want to keep it like this (ObjectScopeWrapper) I'm fine with that too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is renamed ObjectWrappingMap.

@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented May 12, 2021

Ok, I think I've addressed everything today.

@jhugman jhugman force-pushed the 2143-map-like-scopes branch from 59a608f to 0a04461 Compare May 12, 2021 23:09
@jhugman jhugman force-pushed the 2143-map-like-scopes branch from e4a5ed6 to 3f58aaf Compare May 15, 2021 14:02
@josdejong
Copy link
Copy Markdown
Owner

Thanks @jhugman , looks good!

I think everything is resolved and ready. On small thing: I do not see a unit test for isMap(null) and isMap(undefined) for example, or am I overlooking them?

@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented May 16, 2021 via email

@josdejong
Copy link
Copy Markdown
Owner

https://github.com/josdejong/mathjs/pull/2166/files#diff-5c678b2623ae91fc4802b34d1bde80ff249f70d89c1e352e1348adb463415accR26-R29

a unit test for isMap(null) and isMap(undefined) for example, or am I overlooking them?

Ahh, yes, that's perfect 👍 . Ready to merge then 😄

@josdejong josdejong merged commit e80995d into josdejong:develop May 16, 2021
@josdejong
Copy link
Copy Markdown
Owner

Published now in v9.4.0, thanks again @jhugman, nice job !

@jhugman
Copy link
Copy Markdown
Contributor Author

jhugman commented May 16, 2021 via email

@jhugman jhugman deleted the 2143-map-like-scopes branch June 26, 2021 12:09
joshhansen pushed a commit to LearnSomethingTeam/mathjs that referenced this pull request Sep 16, 2021
* Add support for scopes with get and set methods

* Fix build for node v12

* Fixup cli and parser tests

* Add tests for simplify and evaluate

* Add example for a custom scope object

* Function calls need child scopes

* Transitionary step: Separate Safe and Scope Property calls

* Renamed identifiers in FunctionNode

* Evaluate with ObjectScopeWrapper

* Simplify tests passing

* Assume all scopes are map-like. Except parser

* Remove isMapLike check in customs.*SafeProperty() methods

* Change MapLike to Map

* Move keywords from an Object to a Set

* Move ScopeProperty functions in to scope.js

* Removed deprecation warning

* Rename scope.js to map.js

* Rename ScopeProperty to MapProperty

* Add tests and docs for map.js

* Put back the micro-optimization of function calls

* Use Map in the parser

* Called scope methods directly in cli.js

* Coercing of scope into a Map is done in Node, not evaluate

* Move createSubScope to its own file

* Fixup following self-review

* Add scope docs

* Final self-review changes

* Address reviewer comments

* Remove MapProperty witness marks

* Converted broken benchmark possibly lost in a rebase

* Use bare map as scope in benchmark

Co-authored-by: Jos de Jong <wjosdejong@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for Map like scope objects

2 participants