Add support for scopes with get and set methods#2166
Conversation
There was a problem hiding this comment.
-
customs.jsdefines somegetSafePropertyandsetSafePropertywhich should be modified to recognized objects withgetandsetmethods. - Add functions for
hasSafeProperty(object, prop)andgetSafeProperties(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)orfor (… in scope)with the newly minted functions. - Add tests to
evaluate.test.js,simplify.test.jsandsecurity.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
5c74330 to
f08e9b3
Compare
|
Thanks @jhugman for your PR! I'll review it soon |
josdejong
left a comment
There was a problem hiding this comment.
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:
scopeshould beMapinstead ofObject- the
mathnamespace used in the expression parser (mathWithTransform) should beMapinstead ofObject. Parserandcli.jsshould useMapinstead ofObject(already in the PR)- Input like
math.evaluate('{a: 2, b: 3}')should createMapinstead ofObject. 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
Mapasscope. Keep everything backward compatible. (This PR) - Step 2: work out a proof of concept fully replacing usage of
Objectin the expression parser withMap. - 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.
60bdaa3 to
85fdf30
Compare
|
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 Getting these From there, a re-organization of At this point, Responding to your review, and some other questions:
Still to do:
I'm not quite finished, but the end is in sight. |
jhugman
left a comment
There was a problem hiding this comment.
Not quite ready, but would like your feedback @josdejong .
| return evalArg(scope, args, context) | ||
| })) | ||
| } | ||
| return function evalFunctionNode (scope, args, context) { |
There was a problem hiding this comment.
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 😁
There was a problem hiding this comment.
Ok, I made a switch statement. How are you benchmarking/profiling to make these micro-optimizing decisions?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
|
I really like where this is heading @jhugman!
|
Aside from the above documentation, I think this is ready for review! Thanks for your nice feedback so far. |
|
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. |
josdejong
left a comment
There was a problem hiding this comment.
@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?
| if (isMap(mapOrObject)) { | ||
| return mapOrObject | ||
| } | ||
| if (typeof mapOrObject === 'object') { |
There was a problem hiding this comment.
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
| } | ||
|
|
||
| /* | ||
| * These wrapper functions are temporary. They provide scaffolding around calling the methods |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think we can get rid of these, though they may come back as scaffold for near future refactors.
There was a problem hiding this comment.
Let's then clean them up for now, and when needed we can recreate them (it's not rocket science), OK?
There was a problem hiding this comment.
Ok, I've done this.
There was a problem hiding this comment.
(btw: I use the 👍 reaction as a check mark to myself that I've addressed the issue).
| @@ -0,0 +1,40 @@ | |||
| import { createEmptyMap, isMap } from './map.js' | |||
|
|
|||
| function assign (scope, ...objects) { | |||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
makes sense to rename the file scope.js to map.js, you're right! Maybe createSubScope can be renamed to createSubMap :)
There was a problem hiding this comment.
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({})) |
There was a problem hiding this comment.
Can you add some tests like isMap(null), isMap(undefined), isMap(42), etc?
| return evalArg(scope, args, context) | ||
| })) | ||
| } | ||
| return function evalFunctionNode (scope, args, context) { |
There was a problem hiding this comment.
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.
| return evalArg(scope, args, context) | ||
| })) | ||
| } | ||
| return function evalFunctionNode (scope, args, context) { |
There was a problem hiding this comment.
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
| @@ -0,0 +1,113 @@ | |||
| import { setSafeProperty, hasSafeProperty, getSafeProperty } from './customs.js' | |||
|
|
|||
| export class ObjectScopeWrapper { | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is renamed ObjectWrappingMap.
|
Ok, I think I've addressed everything today. |
59a608f to
0a04461
Compare
e4a5ed6 to
3f58aaf
Compare
|
Thanks @jhugman , looks good! I think everything is resolved and ready. On small thing: I do not see a unit test for |
|
… 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 😄 |
|
Published now in |
|
@josdejong Thank you for your help!
…On Sun, May 16, 2021 at 12:08 PM Jos de Jong ***@***.***> wrote:
Published now in v9.4.0, thanks again @jhugman
<https://github.com/jhugman>, nice job !
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#2166 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAAYICTG4RJDL2JNT62ELGTTN6YSPANCNFSM42WSLXDQ>
.
|
* 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>
Fixes #2143.
This wraps the scop
Objectin aObjectWrappingMapand finds all the places wheregetSafeProperty(and friends) was being used on the scope, and changes it togetMapProperty(and friends).It also adds an example, some tests and some documentation.
I think it shows a fairly clear path to:
mathobject 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.