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

Fix argument evaluation issue in conditional expressions #218

Conversation

lorenzosinisi
Copy link
Contributor

@lorenzosinisi lorenzosinisi commented Aug 27, 2024

The goal is to implement "lazy" evaluation of functions, specifically for conditional statements like @if. This is particularly useful when dealing with potentially nil values.

Current behavior:

@if(status, LEFT(status, 2), false) # this will fail because LEFT will be applied before IF
@if(IS_STRING(status), LEFT(status, 2), false) # even introducing a guard like "IS_STRING" this will fail because LEFT will be applied before IF
@if(LEN(status) > 0, LEFT(status, 2), false) # this will also fail because LEFT will be applied before IF

And the same will go for RIGHT, any String.split function etc.

If status is nil, this function currently throws an error because all arguments are evaluated eagerly, including LEFT(status, 2), which fails on a nil input.

Desired behavior:
The @if function should only evaluate the second argument (LEFT(status, 2)) if the first argument (status) is truthy. If status is falsy (including nil), it should immediately return the third argument (false) without evaluating the second argument.

Implementation approach:

  1. Store a custom callback module in the reduction context.
  2. Instead of modifying all existing functions to accept an additional argument, add a new key "CUSTOM_CALLBACK" to the context map.
  3. This callback will be responsible for lazy evaluation of arguments.

Benefits:

  1. Prevents errors when working with potentially nil values.
  2. Improves performance by avoiding unnecessary computations.
  3. Allows for more complex conditional logic without risking errors.

This approach maintains compatibility with existing functions while adding the capability for lazy evaluation where needed.

Previously, expressions like:

`@if(status,\nLEFT(status, 2),\nfalse)`

would error out due to evaluating all arguments, even when not needed. This behavior prevented conditional checks like "if X is nil, do this otherwise do that".

With this change, argument evaluation is fully delegated to each function, allowing proper conditional checks. The custom callback function is now set as a custom context attribute, enabling nested functions to use it as needed.
@lorenzosinisi
Copy link
Contributor Author

What do you think @smn ? Is this acceptable?

@@ -70,9 +69,8 @@ defmodule Expression.Eval do
def eval!({:function, opts}, context, mod) do
function_name = opts[:name] || raise "Functions need a name"
args = opts[:args] || []
arguments = Enum.reduce_while(args, [], &args_reducer(&1, function_name, context, mod, &2))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It should also make the library a lot faster because now it can short-circuit

Copy link
Contributor

Choose a reason for hiding this comment

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

it's interesting as the intention of using reduce while here was to only evaluate until one of the things resolved to true. I'm happy that it's now solved but I'm unsure why it's solved. I'm going to need to spend some more time on this to make sure I understand.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It seems like the only reason to do this here is:

  • avoid issues with the "or" but I also don't understand why fixing it here
  • avoid having to pass the custom callback as argument of every function both in the standard lib and custom callback module functions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given that the custom callback module does not change between function calls, I didn't see it as problematic to add it here and so pass it along every call to eval! in the Expression.Callbacks.EvalHelpers. Just like we store temporary values in the context with __value__, __captures, __type__ etc.

It is basically the implementation of the state monad

{:ok, ast, "", _, _, _} =
Parser.parse("@if(status,\nLEFT(status, 2),\nfalse)")

assert false ==
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Without this change, this AST will break because it will try to "LEFT" "nil" but this is not needed because if will evaluate to false and so return it

Copy link
Contributor

@Djack1010 Djack1010 Aug 28, 2024

Choose a reason for hiding this comment

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

Thanks for this contribution! I was looking into a similar problem.
Could we extend this solution to and/or boolean evaluations? I've been trying a lazy evaluation of and with a nil value, but it seems to be failing. Here’s an example test:

test "and with nil values" do
  {:ok, ast, "", _, _, _} = Parser.parse("@and(isstring(status), LEFT(status, 2))")

  assert false == Eval.eval!(ast, %{"status" => nil})
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @Djack1010, I don't think there is a good way to fix the "and" because unfortunately every argument of the function needs to be evaluated and not return error in order to check if the function can return true or false. This fix would only work for OR and IFs

|> Expression.Eval.eval!(ctx)
|> Expression.Eval.eval!(
ctx,
Map.get(ctx, "__CUSTOM_CALLBACK__", Expression.Callbacks.Standard)
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels like we need to give this some more thought, it's an entirely different means of specifying the callback module. In other places we're passing it in explicitly as an optional argument.

Maybe this helper just isn't useful enough to be defined in a separate module, would that prevent us having to supply the callback module as an argument? Especially because the eval! function is used in the callback module primarily.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assumed that this was done before the implantation of “or” in the same way. I think it is fixed because essentially this code was duplicating what “or” is doing in the body of its function 🤔And yes I agree that definining the callback like that changes things quite a bit but I haven’t found a better way to pass it as argument to all functions without changing all of their arity (and so make it also incompatible with all existing callback modules), any idea of how to still solve the issue with the test and keep it as argument?

Copy link
Contributor

CLA Assistant Lite bot:
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


1 out of 2 committers have signed the CLA.
@smn
@lorenzosinisi
You can retrigger this bot by commenting recheck in this Pull Request

@smn smn mentioned this pull request Sep 26, 2024
@smn
Copy link
Contributor

smn commented Sep 26, 2024

I believe #223 shows that this is fixed, but please confirm.

@smn
Copy link
Contributor

smn commented Oct 7, 2024

@lorenzosinisi I'm closing this PR again but please feel free to re-open if you feel the issue has not been properly addressed. Thanks again for your contribution!

@smn smn closed this Oct 7, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Oct 7, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants