zeroSteiner / rule-engine

A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects.
https://zerosteiner.github.io/rule-engine/
BSD 3-Clause "New" or "Revised" License
433 stars 54 forks source link

How to define a custom function #70

Closed amrabed closed 11 months ago

amrabed commented 11 months ago

It's not clear from the documentation how/where to define a new custom function when the rule is to be applied to an object. Would you please provide an example of such a case or point me to where I can find one?

Also, this part of the documentation about FUNCTION is a bit confusing:

Additional functions can be either added them to the evaluated object or by extending the builtin symbols. It is only possible to call a function from within the rule text. Functions can not be defined as other data types can be.

zeroSteiner commented 11 months ago

You define a function in Python like any other function and then you pass it into the thing that's being evaluated. That is to say that functions are passed in the same way as any other value.

>>> def add(a, b):
...     return a+b
... 
>>> thing = {'add': add}
>>> 
exiting the edit console...
rule > add(1, 2)
result: 
Decimal('3')
rule > 

Additional functions can be either added them to the evaluated object or by extending the builtin symbols. It is only possible to call a function from within the rule text. Functions can not be defined as other data types can be.

Which statement there is confusing? If you have suggestions on how to clarify the docs, let me know.

amrabed commented 11 months ago

Thanks for your reply, but that does not seem to work for the object case. Here is an example:

from rule_engine import Context, Rule, resolve_attribute

CONDITION = (
    "is_valid_thing(things, 'thing1') ? things['thing1'] : is_valid_thing(things, 'thing2') ? things['thing2'] : null"
)

def is_valid_thing(things: dict, name: str) -> bool:
    return name in things and things[name]["isValid"]

class Container:
    def __init__(self, things: dict):
        self.things = things
        self.is_valid_thing = is_valid_thing

if __name__ == '__main__':
    thing1 = {"name": "thing1", "isValid": True}
    thing2 = {"name": "thing2", "isValid": False}
    container = Container({"thing1": thing1, "thing2": thing2})
    thing = Rule(CONDITION, context=Context(resolver=resolve_attribute)).evaluate(container)
    print(thing)

I am getting this error:

rule_engine.errors.EvaluationError: data type mismatch (not a callable value)

complaining about this line in my code:

    thing = Rule(CONDITION, context=Context(resolver=resolve_attribute)).evaluate(container)

Trace:

    thing = Rule(CONDITION, context=Context(resolver=resolve_attribute)).evaluate(container)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/rule_engine/engine.py", line 546, in evaluate
    return self.statement.evaluate(thing)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/rule_engine/ast.py", line 1111, in evaluate
    return self.expression.evaluate(thing)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/rule_engine/ast.py", line 1149, in evaluate
    case = (self.case_true if self.condition.evaluate(thing) else self.case_false)
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/rule_engine/ast.py", line 1050, in evaluate
    raise errors.EvaluationError('data type mismatch (not a callable value)')
rule_engine.errors.EvaluationError: data type mismatch (not a callable value)

Not sure what I am missing or doing wrong here. I have also tried to define it as a member function and a static member function, but none seems to work

Replacing my condition with this one (not using a function) seems to be working fine though:

CONDITION = (
    "'thing1' in things and things['thing1']['isValid'] ? things['thing1'] : "
    "'thing2' in things and things['thing2']['isValid'] ? things['thing2'] : null"
)
zeroSteiner commented 11 months ago

Thanks for the test case. I'll look into it this weekend. It's possible there's a bug because what you're doing looks correct.

zeroSteiner commented 11 months ago

This isn't a problem with the function definition being on an object instead of a container. If the condition is simply is_valid_thing(things, 'thing1'), it works as intended. There appears to be a parsing error possibly related to the order of operations with either the function call or the embedded ternary operator. Things also work as intended if you add quotes to false expression of the first ternary operator like this: is_valid_thing(things, 'thing1') ? things['thing1'] : (is_valid_thing(things, 'thing2') ? things['thing2'] : null).

zeroSteiner commented 11 months ago

Yeah this was an issue with the precedence of the parenthesis in the parser. They had not been defined with a rule and thus weren't being parsed correctly leading to the issue you identified.

zeroSteiner commented 11 months ago

This issue was fixed in version 4.0.1 which is now available on PyPi. Thank you for bringing it to my attention.

amrabed commented 11 months ago

Thank you so much for looking into this and for the quick fix. Will test the update today