diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2ccbdb9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +## v3.0.0-rc.0 + +This is the first release candidate for v3.0.0. It contains the breaking +changes intended for the major version. Cut to allow downstream CI runs +to surface migration work before the final v3.0.0 tag. + +### Breaking changes + +- **`Context.new/2` no longer normalizes by default.** Both `lowercase_keys` + and `coerce_strings` now default to `false`. Original key casing is + preserved (variable lookup remains case-insensitive at evaluation time + via `Eval.case_insensitive_get/2`), and string values pass through + untouched. Callers that need v2 behavior must opt in explicitly: + `Context.new(ctx, lowercase_keys: true, coerce_strings: true)`. +- **`:skip_context_evaluation?` option has been removed.** Use + `coerce_strings: false` instead. +- **`Expression.error/1` has been removed.** Use `Expression.error_map/1` + to produce the legacy error map shape, or raise `Expression.Error` for + new code. +- **The entire V2 parser/evaluator has been removed.** All modules under + `lib/expression/v2/` (`Expression.V2`, `Expression.V2.Parser`, + `Expression.V2.Compile`, `Expression.V2.Context`, `Expression.V2.Eval`, + `Expression.V2.Callbacks`, `Expression.V2.Callbacks.Standard`, + `Expression.V2.Autodoc`) and `Expression.V2.Compat` are gone. Consumers + using V2 for static analysis (`Expression.V2.parse_block/1`, + `Expression.V2.Compile.to_quoted/1`) will need to migrate to V1's + AST surface or build their own analysis layer. + +### Added + +- **v2-compat mode on all evaluation entry points.** `evaluate!/4`, + `evaluate/4`, `evaluate_block!/4`, `evaluate_block/4`, + `evaluate_as_string!/4`, `evaluate_as_boolean!/4` and + `evaluate_template!/4` accept a trailing options list. `mode: :v2` + expands to `lowercase_keys: true, coerce_strings: true`, restoring the + v2 context normalization per evaluation; explicitly passed flags win + over the expansion. This lets a consumer run v2- and v3-semantics + evaluations side by side in the same VM. + +### Migration + +See `V3_PLAN.md` for the detailed plan and engage-specific impact notes. diff --git a/CLAUDE.md b/CLAUDE.md index f8643ca0..9de379e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,12 +40,11 @@ mix dialyzer --format dialyxir ## Architecture -### Two Parser Implementations -The library maintains both V1 and V2 parsers for compatibility: -- **V1** (`lib/expression/parser.ex`, `lib/expression/eval.ex`) - Original implementation -- **V2** (`lib/expression/v2/`) - An EXPERIMENTAL and SOON TO BE THROWN AWAY parser with `compile.ex`, `eval.ex`, `context.ex`. MAKE NO CHANGES TO V2 +The parser and evaluator live under `lib/expression/parser.ex` and +`lib/expression/eval.ex`. Parsing uses +[NimbleParsec](https://hexdocs.pm/nimble_parsec). -Both use [NimbleParsec](https://hexdocs.pm/nimble_parsec) for parsing. +(The experimental V2 parser/evaluator was removed in v3.0.0.) ### Core Modules - `Expression` (`lib/expression.ex`) - Main API: `evaluate!/3`, `evaluate_as_string!/3`, `parse!/1` diff --git a/V3_PLAN.md b/V3_PLAN.md new file mode 100644 index 00000000..048d333d --- /dev/null +++ b/V3_PLAN.md @@ -0,0 +1,155 @@ +# v3.0.0 Release Plan + +Branch: `release/3-0-0` (from `develop` at `cb7657c`) +Current version: `2.49.0` +Target version: `3.0.0` + +This plan turns the deferred items in `CRITICAL_REVIEW.md` (Phase 7 plus +related cleanups) into a concrete set of breaking changes for v3, with +consumer impact for each. Engage is the only known production consumer; +its dependencies on the current behavior are noted inline. + +--- + +## Scope + +### In scope (breaking) + +1. **Flip `Context.new/2` defaults**: `coerce_strings: false`, `lowercase_keys: false`. *(Section 10.2)* +2. **Remove `Expression.error/1`** (deprecated since v2.x). *(Section 10.1)* +3. **Remove the entire V2 namespace** including `Expression.V2.Compat`. *(Section 10.7)* +4. **Drop the `:skip_context_evaluation?` legacy alias** on `Context.new/2` (replaced by `coerce_strings:`). + +### In scope (additive) + +5. **v2-compat mode**: all evaluation entry points accept a trailing options list; `mode: :v2` expands to `lowercase_keys: true, coerce_strings: true`, restoring v2 context normalization per evaluation so consumers can run v2- and v3-semantics evaluations side by side in one VM. + +### Out of scope (defer to v3.x or v4) + +- Converting the `{:not_found, path}` sentinel to `Expression.Error{type: :not_found}` — deep evaluator change, large blast radius; tackle once v3 is stable. +- AST migration from keyword lists to structs — breaks every consumer that pattern-matches the AST (engage's `ElasticsearchConverter`). +- Splitting `Expression.Callbacks.Standard` — pure refactor, no v3 hook needed. +- Converting `guard_type!` raises from `RuntimeError` to `Expression.Error` — already shadowed in practice by the top-level `evaluate!` rescue/reraise; the only caller pattern-matching `RuntimeError` (engage `public_exceptions.ex:118`) is already non-functional against v2.49. + +--- + +## Per-change consumer impact + +### 1. Context default flips + +**Change** + +```elixir +# v2: lowercases keys, coerces strings +Context.new(%{"FirstName" => "Jane", "age" => "30"}) +#=> %{"firstname" => "Jane", "age" => 30} + +# v3: preserves both +Context.new(%{"FirstName" => "Jane", "age" => "30"}) +#=> %{"FirstName" => "Jane", "age" => "30"} +``` + +The evaluator already supports case-insensitive lookup (`Eval.case_insensitive_get/2`), so `@firstname` still resolves against `"FirstName"`. String coercion is gone — callers that relied on `"2020-12-13"` becoming a `Date` must call the relevant parsing function explicitly. + +**Migration** + +- Callers that need v2 behavior pass options explicitly: `Context.new(ctx, lowercase_keys: true, coerce_strings: true)`. +- Add a CHANGELOG note with both flags spelled out. + +**Engage impact** + +- Engage's contexts already use lowercase string keys (`"contact"`, `"number"`). `lowercase_keys: false` is a no-op for engage. ✅ +- String coercion is the risk: engage may rely on `"2020-12-13T..."` → `DateTime` conversion implicitly. Audit `Build.default_context/1` and any `Context.new/1` call sites in engage before merging. +- Engage test `test/turn/build/callbacks_test.exs:291` passes `[]` opts explicitly — stays correct, but its expectations may shift if the test data contains coercible strings. + +### 2. Remove `Expression.error/1` + +**Change** + +`Expression.error/1` is deleted. `Expression.error_map/1` (currently `@doc false`) becomes the public way to produce the legacy error map shape, but new code should raise `Expression.Error` instead. + +**Engage impact** + +- One call site: `lib/turn/build/callbacks.ex:188` (`Expression.error("Unable to generate token")`). Engage must switch to `Expression.error_map/1` or to raising/returning `Expression.Error`. Coordinate the engage PR with the v3 bump. + +### 3. Remove `Expression.V2.Compat` + +**Change** + +The module is deleted. `test/expression/v2/eval_compat_test.exs` is removed too. + +**Engage impact** + +- None. Engage's only `Compat` references are to `Turn.Elasticsearch.Index.Compat.Bulk` (unrelated). Verified. +- `flow_runner` does not reference it either (verified — no matches in `deps/`). + +### 4. Drop `:skip_context_evaluation?` alias + +**Change** + +`Context.new(ctx, skip_context_evaluation?: true)` no longer works; use `coerce_strings: false`. + +**Engage impact** + +- Audit: no engage call sites use `skip_context_evaluation?`. ✅ + +--- + +## Other v3 candidates worth considering + +These weren't in Phase 7 but are natural fits for a major bump. **Not committed** — flag for discussion before doing them. + +| Candidate | Reason for / against | +|---|---| +| Remove `evaluate_block`'s implicit `{:ok, _}` wrapping | Already consistent today — skip. | +| Stricter `evaluate_as_boolean!` (no truthiness coercion) | Would break flows. Skip unless requested. | +| Audit `op/3` operator coercion (review §2.3, "5" > "3") | Behavior change without API change. Risky. Skip for v3. | +| `String.to_atom/1` already fixed in Phase 1 | No-op. | + +--- + +## Implementation order + +Each step is its own commit; each commit ships green tests. + +1. **Bump `@version` to `3.0.0-rc.0`** in `mix.exs` plus a `CHANGELOG.md` skeleton. +2. **Remove `Expression.V2.Compat`** + its test file (low risk, isolated). +3. **Remove `Expression.error/1`**; keep `error_map/1` as `@doc true` public. +4. **Drop `:skip_context_evaluation?` alias** in `Context.new/2`. +5. **Flip `Context.new/2` defaults** to `lowercase_keys: false, coerce_strings: false`. Update doctests in `lib/expression/context.ex`. Run targeted tests on `test/expression/context_test.exs` and any test that builds contexts with mixed-case or string-coercible values. +6. **Tag `3.0.0-rc.0`** and run full engage CI against it. Address fallout (engage's `Expression.error/1` call site). +7. **Add the v2-compat mode** (`mode: :v2` options on all evaluation entry points) with a test corpus proving the v2 semantics. +8. **Bump `@version` to `3.0.0`** and finalize `CHANGELOG.md` once rc is green. + +--- + +## CHANGELOG skeleton + +```markdown +## v3.0.0 + +### Breaking changes + +- `Context.new/2` no longer lowercases keys or coerces string values by + default. Pass `lowercase_keys: true, coerce_strings: true` to restore + v2 behavior. Variable lookup remains case-insensitive at evaluation time. +- The `:skip_context_evaluation?` option on `Context.new/2` has been + removed. Use `coerce_strings: false` instead. +- `Expression.error/1` has been removed. Use `Expression.error_map/1` + for the legacy map shape, or raise `Expression.Error` for new code. +- `Expression.V2.Compat` has been removed. The shim returned V1 results + with V2 disabled — call `Expression` directly. +- All evaluation entry points accept a trailing options list; `mode: :v2` + restores the v2 context normalization per evaluation. + +### Migration + +See V3_MIGRATION.md (or a section here). +``` + +--- + +## Open questions for the user + +1. **Tag a `3.0.0-rc.0` first?** Lets engage CI run against the rc before we finalize. *(Done — rc.0 is tagged.)* +2. **Coordinate the engage PR before tagging v3.0.0?** Engage `callbacks.ex:188` needs to migrate off `Expression.error/1`. Either (a) land an engage-side fix to `error_map/1` first (compatible with v2.49 since `error_map/1` already exists), then bump engage to v3, or (b) tag v3 and let engage break until the engage PR lands. *(Resolved: (a) — engage migrated on v2.49.1 in turnhub/engage#13034.)* diff --git a/benchmark/blocks.exs b/benchmark/blocks.exs deleted file mode 100644 index 9e40aa67..00000000 --- a/benchmark/blocks.exs +++ /dev/null @@ -1,27 +0,0 @@ -# Run with `mix run benchmark/blocks.exs` in the console - - -Benchee.run( - %{ - "v1" => fn {expression, context} -> - Expression.evaluate_block(expression, context) - end, - "v2" => fn {expression, context} -> - Expression.V2.eval_block(expression, Expression.V2.Context.new(context)) - end - }, - inputs: %{ - "simple" => - { - "YEAR(contact.birthday)", - %{"contact" => %{"name" => "mary", "birthday" => ~U[1920-02-02T00:00:00+0000]}} - }, - "map" => {~S|map(0..10, &([&1, concatenate("Button ", &1)]))|, %{}}, - "if" => - {"if(something.false, 1, contact.bar)", %{ - "something" => %{"false" => false}, - "contact" => %{} - }}, - "arithmetic" => {"3 * (5 + 2)", %{}} - } -) diff --git a/benchmark/expressions.exs b/benchmark/expressions.exs deleted file mode 100644 index 27a23c49..00000000 --- a/benchmark/expressions.exs +++ /dev/null @@ -1,21 +0,0 @@ -# Run with `mix run benchmark/expressions.exs` in the console - -Benchee.run( - %{ - "v1" => fn {expression, context} -> Expression.evaluate(expression, context) end, - "v2" => fn {expression, context} -> - Expression.V2.eval(expression, Expression.V2.Context.new(context)) - end - }, - inputs: %{ - "simple" => { - "Hello @contact.name, you were born in @(YEAR(contact.birthday))", - %{"contact" => %{"name" => "mary", "birthday" => ~U[1920-02-02T00:00:00+0000]}} - }, - "map" => {~S|hi @map(0..10, &([&1, concatenate("Button ", &1)])) there|, %{}}, - "if" => - {"yebo @if(something.false, 1, contact.bar) yes", - %{"something" => %{"false" => false}, "contact" => %{}}}, - "arithmetic" => {"3 * (5 + 2) = @(3 * (5 + 2))", %{}} - } -) diff --git a/lib/expression.ex b/lib/expression.ex index 6ddba9da..e50ea59a 100644 --- a/lib/expression.ex +++ b/lib/expression.ex @@ -35,6 +35,17 @@ defmodule Expression do Hello @contact.name, you were born in @(YEAR(contact.birthday)) ``` + ## v2-compat mode + + All evaluation entry points accept a trailing options list. Passing + `mode: :v2` restores the v2 evaluation semantics that changed in v3 + (context key lowercasing and string value coercion): + + ``` + Expression.evaluate_as_string!("@date", %{"date" => "2020-12-13T23:34:45"}, mod, mode: :v2) + ``` + + See `t:evaluation_opts/0` for the individual flags. """ @type expression_type :: @@ -114,12 +125,44 @@ defmodule Expression do @spec time_struct?(String.t() | Time.t()) :: boolean def time_struct?(value), do: is_struct(value, Time) + @typedoc """ + Options accepted by the evaluation entry points. + + `mode: :v2` expands to the v2-compat flags: + `lowercase_keys: true, coerce_strings: true`. Explicitly passed flags win + over the expansion. `mode: :v3` (or omitting `:mode`) keeps the v3 + defaults. + + * `:lowercase_keys` / `:coerce_strings` - see `Expression.Context.new/2`. + + Note: v2's operator semantics need no compat flags — the parser collapses + `=` and `==` into the same operator in both v2 and v3, so the documented + v2 "date-only `=`" behavior was unreachable dead code and evaluation + semantics beyond context normalization are unchanged. + """ + @type evaluation_opts :: [ + mode: :v2 | :v3, + lowercase_keys: boolean(), + coerce_strings: boolean() + ] + + @v2_mode_opts [lowercase_keys: true, coerce_strings: true] + + @spec normalize_opts(evaluation_opts()) :: Keyword.t() + defp normalize_opts(opts) do + case Keyword.pop(opts, :mode) do + {:v2, rest} -> Keyword.merge(@v2_mode_opts, rest) + {_v3_or_nil, rest} -> rest + end + end + def evaluate_block!( expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ [] ) do + opts = normalize_opts(opts) ast = parse_expression!(expression) Eval.eval!([expression: ast], Context.new(context, opts), mod) rescue @@ -135,14 +178,15 @@ defmodule Expression do def evaluate_block(expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) do {:ok, evaluate_block!(expression, context, mod, opts)} rescue - e in Expression.Error -> {:error, e.message} - e in RuntimeError -> {:error, e.message} + e in Expression.Error -> {:error, e} end - def evaluate!(expression, context \\ %{}, mod \\ Expression.Callbacks) do + def evaluate!(expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) do + opts = normalize_opts(opts) + expression |> parse! - |> Eval.eval!(Context.new(context), mod) + |> Eval.eval!(Context.new(context, opts), mod) |> Eval.default_value() rescue e in Expression.Error -> @@ -157,22 +201,25 @@ defmodule Expression do @spec evaluate_as_string!( String.t() | Number.t() | nil, map(), - module() + module(), + evaluation_opts() ) :: String.t() - def evaluate_as_string!(expression, context \\ %{}, mod \\ Expression.Callbacks) + def evaluate_as_string!(expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) + + def evaluate_as_string!(nil, _context, _mod, _opts), do: "" - def evaluate_as_string!(nil, _context, _mod), do: "" + def evaluate_as_string!(expression, context, mod, opts) do + opts = normalize_opts(opts) - def evaluate_as_string!(expression, context, mod) do expression |> parse! - |> Eval.eval!(Context.new(context), mod) + |> Eval.eval!(Context.new(context, opts), mod) |> Eval.default_value(handle_not_found: true) |> stringify() end - def evaluate_as_boolean!(expression, context \\ %{}, mod \\ Expression.Callbacks) do - case evaluate!(expression, context, mod) do + def evaluate_as_boolean!(expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) do + case evaluate!(expression, context, mod, opts) do boolean when is_boolean(boolean) -> boolean @@ -199,24 +246,20 @@ defmodule Expression do def stringify(map) when is_map(map), do: "#{inspect(map)}" def stringify(other), do: to_string(other) - def evaluate(expression, context \\ %{}, mod \\ Expression.Callbacks) do - {:ok, evaluate!(expression, context, mod)} + def evaluate(expression, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) do + {:ok, evaluate!(expression, context, mod, opts)} rescue - e in Expression.Error -> {:error, e.message} - e in RuntimeError -> {:error, e.message} + e in Expression.Error -> {:error, e} end @doc """ - Generate an error map. + Build the legacy error map shape used by callbacks to signal recoverable + errors that should flow through evaluation as a value. - Deprecated: use `Expression.Error` exception struct instead. - This function will be removed in a future major version. + New code should raise `Expression.Error` instead. This helper exists for + callbacks that need to return an error sentinel without halting evaluation. """ - @deprecated "Use %Expression.Error{type: :function, message: message} instead" - @spec error(message :: term) :: %{required(String.t()) => term} - def error(message), do: error_map(message) - - @doc false + @spec error_map(message :: term) :: %{required(String.t()) => term} def error_map(message), do: %{ "__type__" => "expression/v1error", @@ -245,9 +288,9 @@ defmodule Expression do "1 + 1 = 2" """ - @spec evaluate_template!(String.t(), map(), module()) :: String.t() - def evaluate_template!(template, context \\ %{}, mod \\ Expression.Callbacks) do - evaluate_as_string!(template, context, mod) + @spec evaluate_template!(String.t(), map(), module(), evaluation_opts()) :: String.t() + def evaluate_template!(template, context \\ %{}, mod \\ Expression.Callbacks, opts \\ []) do + evaluate_as_string!(template, context, mod, opts) end defdelegate prewalk(ast, fun), to: Macro diff --git a/lib/expression/callbacks/standard.ex b/lib/expression/callbacks/standard.ex index 70e7f536..5e67eb36 100644 --- a/lib/expression/callbacks/standard.ex +++ b/lib/expression/callbacks/standard.ex @@ -195,7 +195,7 @@ defmodule Expression.Callbacks.Standard do expression: "datetime_add(datetime, offset, unit)", context: %{ "datetime" => ~U[2022-07-31 00:00:00Z], - "offset" => "1", + "offset" => 1, "unit" => "M" }, result: ~U[2022-08-31 00:00:00Z] @@ -229,7 +229,7 @@ defmodule Expression.Callbacks.Standard do "offset" => %{ "display" => "value for display key", "value" => "value for value key", - "__value__" => "1" + "__value__" => 1 }, "unit" => %{ "display" => "value for display key", @@ -1691,7 +1691,7 @@ defmodule Expression.Callbacks.Standard do "code" => %{ "display" => "value for display key", "value" => "value for value key", - "__value__" => "233" + "__value__" => 233 } }, result: "é" @@ -2282,7 +2282,7 @@ defmodule Expression.Callbacks.Standard do "string" => %{ "display" => "value for display key", "value" => "value for value key", - "__value__" => "1" + "__value__" => 1 } }, result: true @@ -2318,7 +2318,7 @@ defmodule Expression.Callbacks.Standard do "string" => %{ "display" => "value for display key", "value" => "value for value key", - "__value__" => "true" + "__value__" => true } }, result: true @@ -2346,6 +2346,213 @@ defmodule Expression.Callbacks.Standard do result: true def isstring(ctx, binary), do: is_binary(eval!(binary, ctx)) + @doc """ + Converts a value to an integer. + + Strings are parsed as integers, floats are truncated, + booleans become 1 or 0, and nil becomes 0. + + Returns an error map if the value cannot be converted. + """ + @expression_category "number" + @expression_doc doc: "Convert a string to an integer", + expression: "int(\"5\")", + result: 5 + @expression_doc doc: "Truncate a float to an integer", + expression: "int(5.7)", + result: 5 + @expression_doc doc: "Convert a boolean to an integer", + expression: "int(true)", + result: 1 + @expression_doc doc: "Convert false to 0", + expression: "int(false)", + result: 0 + @expression_doc doc: "Convert nil to 0", + expression: "int(nil)", + result: 0 + @expression_doc doc: "Return an error for non-numeric strings", + expression: "int(\"abc\")", + result: Expression.error_map("Unable to convert \"abc\" to an integer") + @expression_doc doc: "Convert from value in __value__ key if complex values are provided.", + expression: "int(value)", + context: %{ + "value" => %{ + "display" => "value for display key", + "value" => "value for value key", + "__value__" => "42" + } + }, + result: 42 + def int(ctx, value) do + value + |> eval!(ctx) + |> to_integer() + end + + @doc """ + Converts a value to a floating-point number. + + Strings are parsed as floats, integers are promoted, + booleans become 1.0 or 0.0, and nil becomes 0.0. + + Returns an error map if the value cannot be converted. + """ + @expression_category "number" + @expression_doc doc: "Convert a string to a float", + expression: "number(\"5.5\")", + result: 5.5 + @expression_doc doc: "Convert an integer string to a float", + expression: "number(\"5\")", + result: 5.0 + @expression_doc doc: "Convert an integer to a float", + expression: "number(5)", + result: 5.0 + @expression_doc doc: "Convert a boolean to a float", + expression: "number(true)", + result: 1.0 + @expression_doc doc: "Convert nil to a number", + expression: "number(nil)", + result: 0 + @expression_doc doc: "Return an error for non-numeric strings", + expression: "number(\"abc\")", + result: Expression.error_map("Unable to convert \"abc\" to a number") + @expression_doc doc: "Convert from value in __value__ key if complex values are provided.", + expression: "number(value)", + context: %{ + "value" => %{ + "display" => "value for display key", + "value" => "value for value key", + "__value__" => "3.14" + } + }, + result: 3.14 + def number(ctx, value) do + value + |> eval!(ctx) + |> to_number() + end + + @doc """ + Converts a value to a string. + + Numbers, booleans, dates, and other values are converted to their + string representation. Nil becomes an empty string. + """ + @expression_category "string" + @expression_doc doc: "Convert an integer to a string", + expression: "text(5)", + result: "5" + @expression_doc doc: "Convert a float to a string", + expression: "text(3.14)", + result: "3.14" + @expression_doc doc: "Convert a boolean to a string", + expression: "text(true)", + result: "true" + @expression_doc doc: "Convert nil to an empty string", + expression: "text(nil)", + result: "" + @expression_doc doc: "A string is returned as-is", + expression: "text(\"hello\")", + result: "hello" + @expression_doc doc: "Convert from value in __value__ key if complex values are provided.", + expression: "text(value)", + context: %{ + "value" => %{ + "display" => "value for display key", + "value" => "value for value key", + "__value__" => 42 + } + }, + result: "42" + def text(ctx, value) do + value = eval!(value, ctx) + + case value do + nil -> "" + v when is_binary(v) -> v + v -> to_string(v) + end + end + + @doc """ + Converts a value to a boolean. + + Nil, false, 0, 0.0, and empty string become false. + Everything else becomes true. + """ + @expression_category "logical" + @expression_doc doc: "Nil is false", + expression: "boolean(nil)", + result: false + @expression_doc doc: "Zero is false", + expression: "boolean(0)", + result: false + @expression_doc doc: "Empty string is false", + expression: "boolean(\"\")", + result: false + @expression_doc doc: "Non-zero number is true", + expression: "boolean(1)", + result: true + @expression_doc doc: "Non-empty string is true", + expression: "boolean(\"hello\")", + result: true + @expression_doc doc: "A boolean passes through", + expression: "boolean(true)", + result: true + @expression_doc doc: "Convert from value in __value__ key if complex values are provided.", + expression: "boolean(value)", + context: %{ + "value" => %{ + "display" => "value for display key", + "value" => "value for value key", + "__value__" => 1 + } + }, + result: true + def boolean(ctx, value) do + value = eval!(value, ctx) + + case value do + nil -> false + false -> false + 0 -> false + +0.0 -> false + "" -> false + _ -> true + end + end + + defp to_integer(v) when is_integer(v), do: v + defp to_integer(v) when is_float(v), do: trunc(v) + defp to_integer(true), do: 1 + defp to_integer(false), do: 0 + defp to_integer(nil), do: 0 + + defp to_integer(v) when is_binary(v) do + case Integer.parse(v) do + {int, ""} -> int + {int, "." <> _} -> int + _ -> Expression.error_map("Unable to convert #{inspect(v)} to an integer") + end + end + + defp to_integer(v), do: Expression.error_map("Unable to convert #{inspect(v)} to an integer") + + defp to_number(v) when is_float(v), do: v + defp to_number(v) when is_integer(v), do: v / 1 + defp to_number(true), do: 1.0 + defp to_number(false), do: 0.0 + defp to_number(nil), do: 0 + + defp to_number(v) when is_binary(v) do + case Float.parse(v) do + {float, ""} -> float + _ -> Expression.error_map("Unable to convert #{inspect(v)} to a number") + end + end + + defp to_number(v), do: Expression.error_map("Unable to convert #{inspect(v)} to a number") + defp search_words(haystack, words) do patterns = words @@ -3621,7 +3828,7 @@ defmodule Expression.Callbacks.Standard do @expression_doc expression: "has_number_lte(\"the number is 42.0\", \"40\")", result: false @expression_doc expression: "has_number_lte(\"the number is 40\", \"foo\")", result: false @expression_doc expression: "has_number_lte(\"four hundred\", \"foo\")", result: false - @expression_doc expression: "has_number_lte(\"@response\", 5)", + @expression_doc expression: "has_number_lte(response, 5)", context: %{"response" => 3}, result: true @expression_doc doc: diff --git a/lib/expression/context.ex b/lib/expression/context.ex index 45b6d04c..39cd321e 100644 --- a/lib/expression/context.ex +++ b/lib/expression/context.ex @@ -6,46 +6,49 @@ defmodule Expression.Context do ## Plain map context - `new/2` returns a plain map with lowercased string keys and - auto-coerced values. This is the legacy format and remains the - default for backwards compatibility. + `new/2` returns a plain map. By default it preserves original key + casing (variable lookup is case-insensitive at evaluation time) and + leaves string values untouched. Atom keys are stringified. iex> Expression.Context.new(%{foo: "bar"}) %{"foo" => "bar"} iex> Expression.Context.new(%{FOO: "bar"}) - %{"foo" => "bar"} + %{"FOO" => "bar"} iex> Expression.Context.new(%{foo: %{bar: "baz"}}) %{"foo" => %{"bar" => "baz"}} iex> Expression.Context.new(%{Foo: %{Bar: "baz"}}) - %{"foo" => %{"bar" => "baz"}} + %{"Foo" => %{"Bar" => "baz"}} iex> Expression.Context.new(%{foo: %{bar: 1}}) %{"foo" => %{"bar" => 1}} iex> Expression.Context.new(%{date: "2020-12-13T23:34:45"}) - %{"date" => ~U[2020-12-13 23:34:45.0Z]} + %{"date" => "2020-12-13T23:34:45"} iex> Expression.Context.new(%{boolean: "true"}) - %{"boolean" => true} + %{"boolean" => "true"} iex> Expression.Context.new(%{float: 1.234}) %{"float" => 1.234} - iex> now = DateTime.utc_now() - iex> ctx = Expression.Context.new(%{float: "1.234", nested: %{date: now}}) - iex> ctx["float"] - 1.234 - iex> now == ctx["nested"]["date"] - true - iex> Expression.Context.new(%{mixed: ["2020-12-13T23:34:45", 1, "true", "binary"]}) + + Pass `lowercase_keys: true` and/or `coerce_strings: true` to opt in + to v2-style normalization: + + iex> Expression.Context.new(%{FOO: "bar"}, lowercase_keys: true) + %{"foo" => "bar"} + iex> Expression.Context.new(%{date: "2020-12-13T23:34:45"}, coerce_strings: true) + %{"date" => ~U[2020-12-13 23:34:45.0Z]} + iex> Expression.Context.new(%{boolean: "true"}, coerce_strings: true) + %{"boolean" => true} + iex> Expression.Context.new(%{mixed: ["2020-12-13T23:34:45", 1, "true", "binary"]}, coerce_strings: true) %{"mixed" => [~U[2020-12-13 23:34:45.0Z], 1, true, "binary"]} ## Options `new/2` accepts the following options: - * `:lowercase_keys` - when `true` (default), all keys are lowercased. - Set to `false` to preserve original casing — the evaluator uses - case-insensitive lookup so expressions still resolve correctly. - * `:coerce_strings` - when `true` (default), string values are - auto-parsed to their typed equivalents (dates, booleans, numbers). - Set to `false` to preserve all string values as-is. - * `:skip_context_evaluation?` - legacy alias for `coerce_strings: false`. + * `:lowercase_keys` - when `true`, all keys are lowercased. Default + is `false`. Variable lookup remains case-insensitive at evaluation + time regardless of this setting. + * `:coerce_strings` - when `true`, string values are auto-parsed to + their typed equivalents (dates, booleans, numbers). Default is + `false`. ## Structured context with private state @@ -78,7 +81,7 @@ defmodule Expression.Context do def new(%__MODULE__{} = ctx, _opts), do: ctx def new(ctx, opts) when is_map(ctx) do - lowercase? = Keyword.get(opts, :lowercase_keys, true) + lowercase? = Keyword.get(opts, :lowercase_keys, false) ctx |> Enum.map(if lowercase?, do: &downcase_string_key/1, else: &stringify_key/1) @@ -121,13 +124,7 @@ defmodule Expression.Context do defp downcase_string_key({key, value}), do: {String.downcase(to_string(key)), value} defp stringify_key({key, value}), do: {to_string(key), value} - defp coerce_strings?(opts) do - # coerce_strings option takes precedence; fall back to legacy skip_context_evaluation? - case Keyword.get(opts, :coerce_strings) do - nil -> not Keyword.get(opts, :skip_context_evaluation?, false) - value -> value - end - end + defp coerce_strings?(opts), do: Keyword.get(opts, :coerce_strings, false) defp iterate({key, value}, opts) when is_map(value) or is_list(value) do {key, evaluate!(value, opts)} diff --git a/lib/expression/eval.ex b/lib/expression/eval.ex index 7918ec58..1987790d 100644 --- a/lib/expression/eval.ex +++ b/lib/expression/eval.ex @@ -170,11 +170,18 @@ defmodule Expression.Eval do defp read_key_from_subject(subject, index) when is_number(index) and (is_list(subject) or is_map(subject)), - do: get_in(subject, [Access.at(index)]) + do: get_in(subject, [Access.at(trunc(index))]) defp read_key_from_subject(subject, range) when is_struct(range, Range) and is_list(subject), do: Enum.slice(subject, range) + defp read_key_from_subject(subject, binary) when is_binary(binary) and is_list(subject) do + case Integer.parse(binary) do + {index, ""} -> get_in(subject, [Access.at(index)]) + _ -> nil + end + end + defp read_key_from_subject(subject, binary) when is_binary(binary) and is_map(subject), do: Map.get(subject, binary) @@ -205,7 +212,7 @@ defmodule Expression.Eval do do: DateTime.compare(a, b) == :eq def op(:=, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime), - do: Date.compare(a, b) == :eq + do: DateTime.compare(a, b) == :eq def op(:>, a, b) when is_struct(a, Date) and is_struct(b, Date), do: Date.compare(a, b) == :gt diff --git a/lib/expression/v2.ex b/lib/expression/v2.ex deleted file mode 100644 index 494a3b09..00000000 --- a/lib/expression/v2.ex +++ /dev/null @@ -1,261 +0,0 @@ -defmodule Expression.V2 do - @moduledoc """ - A second attempt at the parser, hopefully a little easier to read & maintain. - - `parse/1` parsed an Expression into AST. - `eval/3` evaluates the given AST using the context supplied. - - For details on how this is done please read `Expression.V2.Parser` and - `Expression.V2.Compile`. - - This parser & evaluator supports the following: - - * [strings](https://hexdocs.pm/elixir/typespecs.html#basic-types) either double or single quoted. - * [integers](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `1`, `2`, `40`, `55` - * [floats](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `3.141592653589793` - * [booleans](https://hexdocs.pm/elixir/typespecs.html#basic-types) which can be written in any mixed case such as `tRue` or `TRUE`, `False` etc - * `Range.t` such as `1..10`, also with steps `1..10//2` - * `Date.t` such as `2022-01-01` which is parsed into `~D[2022-01-01]` - * `Time.t` such as `10:30` which is parsed into `~T[10:30:00]` - * ISO formatted `DateTime.t` such as `2022-05-24T00:00:00` which is parsed into `~U[2022-05-24 00:00:00.0Z]` - * US formatted `DateTime.t` such as `01-02-2020 23:23:23` which is parsed into `~U[2020-02-01T23:23:23Z]` - * Lists of any of the above, such as `[1, 2, 3]` or `[1, 1.234, "john"]` - * Reading properties off of nested objects such as maps with a full stop, such as `contact.name` returning `"Doe"` from `%{"contact" => %{"name" => "Doe"}}` - * Reading attributes off of maps, such as `contact[the_key]` which returns `"Doe"` from `%{"contact" => %{"name" => "Doe"}, "the_key" => "name"}` - * Anonymous functions with `&` and `&1` as capture operators, `&(&1 + 1)` is an anonymous function that increments the input by 1. - - The result of a call to `eval/3` is a list of typed evaluated items. It is up to the integrating library to determine how - best to convert these into a final end user representation. - - # Examples - - iex> alias Expression.V2 - iex> V2.eval("the date is @date(2022, 2, 20)") - ["the date is ", ~D[2022-02-20]] - iex> V2.eval("the answer is @true") - ["the answer is ", true] - iex> V2.eval("22 divided by 7 is @(22 / 7)") - ["22 divided by 7 is ", 3.142857142857143] - iex> V2.eval( - ...> "Hello @proper(contact.name)! Looking forward to meet you @date(2023, 2, 20)", - ...> V2.Context.new(%{"contact" => %{"name" => "mary"}}) - ...> ) - ["Hello ", "Mary", "! Looking forward to meet you ", ~D[2023-02-20]] - iex> V2.eval("@map(1..3, &date(2023, 1, &1))") - [[~D[2023-01-01], ~D[2023-01-02], ~D[2023-01-03]]] - iex> V2.eval( - ...> "Here is the multiplication table of @number: @(map(1..10, &(&1 * number)))", - ...> V2.Context.new(%{"number" => 5}) - ...> ) - [ - "Here is the multiplication table of ", - 5, - ": ", - [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] - ] - - """ - - alias Expression.V2.Compile - alias Expression.V2.Context - alias Expression.V2.Parser - - @doc """ - Parse a string with expressions into AST for the compile step - """ - @spec parse(String.t()) :: - {:ok, [term]} - | {:error, reason :: String.t(), bad_parts :: String.t()} - def parse(expression) do - case Parser.parse(expression) do - {:ok, ast, "", _, _, _} -> - {:ok, ast} - - {:ok, _ast, remaining, _, _, _} -> - {:error, "Unable to parse remainder", remaining} - end - end - - @spec escape(String.t()) :: String.t() - def escape(expression) when is_binary(expression) do - String.replace(expression, ~r/@([a-z]+)(\(|\.)/i, "@@\\g{1}\\g{2}") - end - - @doc """ - This function is referenced by `Expression.V2.Compile` to - make access to values in Maps or Lists easier - """ - @spec read_attribute(map | list, binary | integer) :: term - def read_attribute(map, item) when is_map(map), do: Map.get(map, item) - - def read_attribute(list, index) when is_list(list) and is_integer(index), - do: Enum.at(list, index) - - def read_attribute(list, range) when is_list(list) and is_struct(range, Range), - do: Enum.slice(list, range) - - @doc """ - Parse a string with an expression block into AST for the compile step - """ - @spec parse_block(String.t()) :: - {:ok, [term]} | {:error, reason :: String.t(), bad_parts :: String.t()} - def parse_block(expression_block) do - case Parser.expression(expression_block) do - {:ok, ast, "", _, _, _} -> {:ok, ast} - {:ok, _ast, _remainder, _, _, _} -> {:error, "Unable to parse", expression_block} - {:error, _ast, remaining, _, _, _} -> {:error, "Unable to parse remainder", remaining} - end - end - - @doc """ - Evaluate a string with an expression block against a context - """ - @spec eval_block(String.t(), context :: Context.t()) :: - term | {:error, reason :: String.t(), bad_parts :: String.t()} - def eval_block(expression_block, context \\ Context.new()) - - def eval_block(expression_block, map_context) - when is_map(map_context) and not is_struct(map_context, Context) do - eval_block(expression_block, Context.new(map_context)) - end - - def eval_block(expression_block, context) do - with {:ok, ast} <- parse_block(expression_block) do - hd(eval_block_ast(ast, context)) - end - end - - @doc """ - Evaluate a string with expressions against a given context - """ - @spec eval(expression :: String.t(), context :: Context.t()) :: [term] - def eval(expression, context \\ Context.new()) - - def eval(expression, map_context) - when is_map(map_context) and not is_struct(map_context, Context) do - eval(expression, Context.new(map_context)) - end - - def eval(expression, context) when is_binary(expression) do - with {:ok, parsed_parts} <- parse(expression) do - eval_ast(parsed_parts, context) - end - end - - @doc """ - Evaluate a parsed Expression against a given context - """ - @spec eval_ast([term], context :: Context.t()) :: [term()] - def eval_ast(parsed_parts, context \\ Context.new()) do - Enum.flat_map(parsed_parts, fn - binary when is_binary(binary) -> [binary] - ast when is_list(ast) -> eval_block_ast(ast, context) - end) - end - - @doc """ - Evaluate the given AST against a given context - """ - @spec eval_block_ast([term], context :: Context.t()) :: [term] - def eval_block_ast(ast, context) when is_list(ast) do - function = Compile.compile(ast) - resp = function.(context) - - if is_binary(resp) do - # NOTE: if the response was a binary, the user is expecting a - # a string to be returned so make sure we do that. - [eval_as_string(resp, context)] - else - [resp] - end - end - - @doc """ - Evaluate an expression and cast all items to strings before joining - the full result into a single string value to be returned. - - This calls `eval/2` internally, maps the results with `default_value/2` - followed by `stringify/1` and then joins them. - """ - @spec eval_as_string(String.t(), Context.t()) :: String.t() - def eval_as_string(expression, context \\ Context.new()) do - {:ok, ast} = parse(expression) - - ast - |> eval_ast(context) - |> Enum.zip(ast) - |> Enum.map_join("", fn - {nil, [{"__property__", _parts} = property]} -> - "@" <> unwrap_property(property) - - {value, _ast} -> - value - |> default_value(context) - |> stringify() - end) - end - - defp unwrap_property({"__property__", parts}), - do: Enum.map_join(parts, ".", &unwrap_property/1) - - defp unwrap_property(parts) when is_list(parts), - do: Enum.map_join(parts, ".", &unwrap_property/1) - - defp unwrap_property(part), do: part - - @doc """ - Return the default value for a potentially complex value. - - Complex values can be Maps that have a `__value__` key, if that's - returned then we can to use the `__value__` value when eval'ing against - operators or functions. - """ - @spec default_value(term) :: term - def default_value(val, context \\ nil) - def default_value(%{"__value__" => default_value}, _context), do: default_value - def default_value(value, _context), do: value - - @spec stringify(term) :: String.t() - def stringify(items) when is_list(items), do: Enum.map_join(items, "", &stringify/1) - def stringify(binary) when is_binary(binary), do: binary - def stringify(%DateTime{} = date), do: DateTime.to_iso8601(date) - def stringify(%Date{} = date), do: Date.to_iso8601(date) - def stringify(%Time{} = time), do: Time.to_iso8601(time) - def stringify(map) when is_map(map), do: "#{inspect(map)}" - def stringify(other), do: to_string(other) - - @spec compile(expression :: String.t()) :: [term] - def compile(expression) when is_binary(expression) do - with {:ok, parts} <- parse(expression), - parts <- Enum.map([parts], &compile_block/1) do - hd(parts) - end - end - - def compile_block({function_name, arguments}) - when is_binary(function_name) and is_list(arguments) do - [{function_name, arguments}] - |> Compile.compile() - |> compile_block() - end - - def compile_block(final), do: final - - @doc """ - Return the code generated for the Abstract Syntax tree or - Expression string provided. - """ - @spec debug(String.t() | [term]) :: String.t() - def debug(expression) when is_binary(expression) do - with {:ok, ast, "", _, _, _} <- Parser.expression(expression) do - debug(ast) - end - end - - def debug(ast) do - ast - |> Compile.to_quoted() - |> Compile.wrap_in_context() - |> Macro.to_string() - end -end diff --git a/lib/expression/v2/autodoc.ex b/lib/expression/v2/autodoc.ex deleted file mode 100644 index 92e05f1b..00000000 --- a/lib/expression/v2/autodoc.ex +++ /dev/null @@ -1,277 +0,0 @@ -defmodule Expression.V2.Autodoc do - @moduledoc """ - - Extract `@expression_doc` attributes from modules defining callbacks - and automatically write doctests for those. - - Also inserts an `expression_docs()` function which returns a list of - all functions and their defined expression docs. - - The format is: - - ```elixir - @expression_doc doc: "Construct a date from year, month, and day integers", - expression: "@date(year, month, day)", - context: %{"year" => 2022, "month" => 1, "day" => 31}, - result: "2022-01-31T00:00:00Z" - ``` - - Where: - - * `doc` is the explanatory text added to the doctest. - * `expression` is the expression we want to test - * `fake_expression` can optionally be the expression we want to display but not test - * `context` is the context the expression is tested against - * `result` is the result we're expecting to get and are asserting against - * `fake_result` can be optionally supplied when the returning result varies - depending on factors we do not control, like for `now()` for example. - When this is used, the ExDoc tests are faked and won't actually test - anything so use sparingly. - - """ - defmacro __using__(_args) do - quote do - @expression_docs [] - Module.register_attribute(__MODULE__, :expression_doc, accumulate: true) - @on_definition Expression.V2.Autodoc - @before_compile Expression.V2.Autodoc - - import Expression.V2.Autodoc - end - end - - def __on_definition__(env, :def, name, args, _guards, _body), - do: annotate_method(env.module, name, args) - - def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: nil - - def annotate_method(module, function, args) do - if expression_doc = Module.delete_attribute(module, :expression_doc) do - update_annotations(module, function, args, expression_doc) - end - end - - # Ignore the expression_docs/0 function created by this macro - def update_annotations(module, :expression_docs, [], _), - do: Module.get_attribute(module, :expression_docs) - - def update_annotations(module, function, args, []) do - existing_expression_docs = Module.get_attribute(module, :expression_docs) - - {_line_number, doc} = get_existing_docstring(module) - - {function_name, function_type} = format_function_name(function) - - Module.put_attribute(module, :expression_docs, [ - {function_name, function_type, format_function_args(args), doc, []} - | existing_expression_docs - ]) - end - - def update_annotations(module, function, args, expression_docs) do - existing_expression_docs = Module.get_attribute(module, :expression_docs) - - {line_number, doc} = get_existing_docstring(module) - - expression_doc_tests = - expression_docs - |> Enum.reverse() - |> Enum.with_index(1) - |> Enum.map_join("\n", fn {expression_doc, index} -> - doc = expression_doc[:doc] - - {fake_expression?, expression} = get_expression(expression_doc) - - code_expression = expression_doc[:code_expression] || expression_doc[:expression] - context = expression_doc[:context] - - {doctest_prompt, result} = - if is_nil(expression_doc[:fake_result]) do - {"iex", expression_doc[:result]} - else - {"..$", expression_doc[:fake_result]} - end - - """ - ## Example #{index}: - #{if(doc, do: "\n> #{doc}\n", else: "")} - - When used in the following Stack expression it returns a #{format_result(result)}#{format_context(context)} - - ``` - > #{Enum.join(String.split(code_expression, "\n"), "\n> ")} - #{inspect(result)} - ``` - - When used as an expression in text, prepend it with an `@`: - - ```expression - > "... @#{expression} ..." - "#{stringify(result)}" - ``` - - #{unless(fake_expression?, do: generate_ex_doc(doctest_prompt, module, expression, context || %{}, result))} - - --- - - """ - end) - - updated_docs = - case doc do - nil -> expression_doc_tests - doc -> "#{doc}\n\n#{expression_doc_tests}" - end - - Module.put_attribute( - module, - :doc, - {line_number, updated_docs} - ) - - {function_name, function_type} = format_function_name(function) - - Module.put_attribute(module, :expression_docs, [ - {function_name, function_type, format_function_args(args), doc, - format_docs(expression_docs)} - | existing_expression_docs - ]) - end - - def get_expression(expression_doc) do - if is_nil(expression_doc[:fake_expression]) do - {false, expression_doc[:expression]} - else - {true, expression_doc[:fake_expression]} - end - end - - def generate_ex_doc(prompt \\ "iex", module, expression, context, result) do - """ - #{prompt}> # Evaluate a string with expressions - #{prompt}> import ExUnit.Assertions - #{prompt}> result = Expression.V2.eval( - ...> #{inspect("chat for @" <> expression <> " impact")}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{generate_assert(prompt, ["chat for ", result, " impact"])} - #{prompt}> - #{prompt}> # Evaluate a standalone expression block - #{prompt}> result = Expression.V2.eval_block( - ...> #{inspect(expression)}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{prompt}> - #{generate_assert(prompt, result)} - #{prompt}> - #{prompt}> # Evaluate a string with expressions into a single string - #{prompt}> Expression.V2.eval_as_string( - ...> #{inspect("@" <> expression)}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{inspect(stringify(result))} - """ - end - - def generate_assert(prompt, result) when is_nil(result) or result == false do - Enum.join(["#{prompt}> refute result", "#{inspect(result)}"], "\n ") - end - - def generate_assert(prompt, result) do - Enum.join( - [ - "#{prompt}> assert #{inspect(result)} = result", - "#{inspect(result)}" - ], - "\n " - ) - end - - def type_of(%Time{}), do: "Time" - def type_of(%Date{}), do: "Date" - def type_of(%DateTime{}), do: "DateTime" - def type_of(boolean) when is_boolean(boolean), do: "Boolean" - def type_of(nil) when is_nil(nil), do: "Null" - def type_of(integer) when is_integer(integer), do: "Integer" - def type_of(float) when is_float(float), do: "Float" - def type_of(binary) when is_binary(binary), do: "String" - def type_of(map) when is_map(map), do: "Map" - - def type_of(list) when is_list(list), - do: "List with values " <> Enum.map_join(list, ", ", &type_of/1) - - def stringify(%{"__value__" => value}), do: Expression.stringify(value) - def stringify(value), do: Expression.stringify(value) - - def get_existing_docstring(module) do - case Module.get_attribute(module, :doc) do - {line_number, doc} -> {line_number, doc} - nil -> {0, nil} - end - end - - def format_result(%{"__value__" => value} = result) when is_map(result) do - other_fields = - result - |> Map.drop(["__value__"]) - |> Enum.map(fn {key, value} -> - "* *#{key}* of type **#{type_of(value)}**" - end) - - """ - complex **#{type_of(value)}** type of default value: - ```elixir - #{inspect(value)} - ``` - with the following fields:\n\n#{Enum.join(other_fields, "\n")} - """ - end - - def format_result(result), do: " value of type **#{type_of(result)}**: `#{inspect(result)}`" - - def format_context(nil), do: "." - - def format_context(context) do - """ - when used with the following context: - - ```elixir - #{inspect(context)} - ``` - """ - end - - def format_function_name(name) do - name = to_string(name) - - cond do - String.ends_with?(name, "_vargs") -> {String.trim_trailing(name, "_vargs"), :vargs} - String.ends_with?(name, "_") -> {String.trim_trailing(name, "_"), :reserved} - true -> {name, :direct} - end - end - - def format_function_args(args) do - [_ctx_arg | function_args] = args - - Enum.map(function_args, fn - {name, _meta, _ignored} when is_atom(name) -> to_string(name) - literal -> to_string(literal) - end) - end - - def format_docs(docs) do - Enum.map(docs, &Enum.into(&1, %{})) - end - - defmacro __before_compile__(_env) do - quote do - @doc """ - Return a list of all functions annotated with @expression_docs - """ - def expression_docs do - Enum.reverse(@expression_docs) - end - end - end -end diff --git a/lib/expression/v2/callbacks.ex b/lib/expression/v2/callbacks.ex deleted file mode 100644 index d8262311..00000000 --- a/lib/expression/v2/callbacks.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule Expression.V2.Callbacks do - @moduledoc """ - Use this module to implement one's own callbacks. - The standard callbacks available are implemented in `Expression.V2.Callbacks.Standard`. - - ```elixir - defmodule MyCallbacks do - use Expression.V2.Callbacks - - @doc \"\"\" - Roll a dice and randomly return a number between 1 and 6. - \"\"\" - def dice_roll(ctx) do - Enum.random(1..6) - end - - end - ``` - """ - - alias Expression.V2.Callbacks.Standard - - @reserved_words ~w[and if or not] - - @doc """ - Convert a string function name into an atom meant to handle - that function - - Reserved words such as `and`, `if`, and `or` are automatically suffixed - with an `_` underscore. - """ - def atom_function_name(function_name) when function_name in @reserved_words, - do: atom_function_name("#{function_name}_") - - def atom_function_name(function_name) do - String.to_atom(function_name) - end - - @doc """ - Callback a function while evaluating the context against an expression. - - Callback functions in this module are either: - - 1. The function name as is - 2. The function name with an underscore suffix if the function name is a reserved word - 3. The function name suffixed with `_vargs` if the takes a variable set of arguments - """ - @spec callback( - module :: module, - context :: map, - function_name :: binary, - arguments :: [any] - ) :: any - def callback(module \\ Standard, context, function_name, arguments) do - case implements(module, function_name, arguments) do - {:exact, module, function_name} -> - apply( - module, - function_name, - [context] ++ Enum.map(arguments, &Expression.V2.default_value(&1, context)) - ) - - {:vargs, module, function_name} -> - apply(module, function_name, [ - context, - Enum.map(arguments, &Expression.V2.default_value(&1, context)) - ]) - - {:error, reason} -> - reason - end - end - - @spec implements(module, function_name :: String.t(), arguments :: [any]) :: - {:exact, module, function_name :: atom} - | {:vargs, module, function_name :: atom} - | {:error, reason :: String.t()} - def implements(module \\ Standard, function_name, arguments) do - exact_function_name = atom_function_name(function_name) - vargs_function_name = atom_function_name("#{function_name}_vargs") - - Code.ensure_loaded!(module) - - cond do - function_exported?(module, exact_function_name, length(arguments) + 1) -> - {:exact, module, exact_function_name} - - # Check if it's been implemented to accept a variable amount of arguments - function_exported?(module, vargs_function_name, 2) -> - {:vargs, module, vargs_function_name} - - # Check if the exact function signature has been implemented - function_exported?(Standard, exact_function_name, length(arguments) + 1) -> - {:exact, Standard, exact_function_name} - - # Check if it's been implemented to accept a variable amount of arguments - function_exported?(Standard, vargs_function_name, 2) -> - {:vargs, Standard, vargs_function_name} - - # Otherwise fail - true -> - {:error, "#{function_name} is not implemented."} - end - end - - defmacro __using__(_opts) do - quote do - def callback(module \\ __MODULE__, context, function_name, args) - - defdelegate callback(module, context, function_name, arguments), - to: Expression.V2.Callbacks - end - end -end diff --git a/lib/expression/v2/callbacks/standard.ex b/lib/expression/v2/callbacks/standard.ex deleted file mode 100644 index 0828d11d..00000000 --- a/lib/expression/v2/callbacks/standard.ex +++ /dev/null @@ -1,1340 +0,0 @@ -defmodule Expression.V2.Callbacks.Standard do - @moduledoc """ - Callback functions to be used in Expressions. - - This is the same idea as `Expression.Callbacks.Standard` but - it's in a rough shape, mostly to just prove that this all works. - """ - - use Expression.V2.Callbacks - use Expression.V2.Autodoc - - alias Expression.DateHelpers - - @punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/ - @doc """ - Defines a new date value - """ - @expression_doc doc: "Construct a date from year, month, and day integers", - expression: "date(year, month, day)", - context: %{ - "year" => 2022, - "month" => 1, - "day" => 31 - }, - result: ~D[2022-01-31] - def date(_ctx, year, month, day) do - fields = [ - calendar: Calendar.ISO, - year: year, - month: month, - day: day, - time_zone: "Etc/UTC", - zone_abbr: "UTC" - ] - - struct(Date, fields) - end - - @doc """ - Calculates a new datetime based on the offset and unit provided. - - The unit can be any of the following values: - - * "Y" for years - * "M" for months - * "W" for weeks - * "D" for days - * "h" for hours - * "m" for minutes - * "s" for seconds - - Specifying a negative offset results in date calculations back in time. - - """ - @expression_doc doc: "Calculates a new datetime based on the offset and unit provided.", - expression: "datetime_add(datetime, offset, unit)", - context: %{ - "datetime" => ~U[2022-07-31 00:00:00Z], - "offset" => 1, - "unit" => "M" - }, - result: ~U[2022-08-31 00:00:00Z] - @expression_doc doc: "Leap year handling in a leap year.", - expression: "datetime_add(date(2020, 02, 28), 1, \"D\")", - result: ~U[2020-02-29 00:00:00.000000Z] - @expression_doc doc: "Leap year handling outside of a leap year.", - expression: "datetime_add(date(2021, 02, 28), 1, \"D\")", - result: ~U[2021-03-01 00:00:00.000000Z] - @expression_doc doc: "Negative offsets", - expression: "datetime_add(date(2020, 02, 29), -1, \"D\")", - result: ~U[2020-02-28 00:00:00.000000Z] - def datetime_add(_ctx, datetime, offset, unit) do - datetime = DateHelpers.extract_datetimeish(datetime) - - case unit do - "Y" -> Timex.shift(datetime, years: offset) - "M" -> Timex.shift(datetime, months: offset) - "W" -> Timex.shift(datetime, weeks: offset) - "D" -> Timex.shift(datetime, days: offset) - "h" -> Timex.shift(datetime, hours: offset) - "m" -> Timex.shift(datetime, minutes: offset) - "s" -> Timex.shift(datetime, seconds: offset) - end - end - - @doc """ - Converts date stored in text to an actual date object and - formats it using `strftime` formatting. - - It will fallback to "%Y-%m-%d %H:%M:%S" if no formatting is supplied - - """ - @expression_doc doc: "Convert a date from a piece of text to a formatted date string", - expression: "datevalue(\"2022-01-01\")", - result: %{"__value__" => "2022-01-01 00:00:00", "date" => ~D[2022-01-01]} - @expression_doc doc: "Convert a date from a piece of text and read the date field", - expression: "datevalue(\"2022-01-02\").date", - result: ~D[2022-01-02] - @expression_doc doc: "Convert a date value and read the date field", - expression: "datevalue(date(2022, 1, 3)).date", - result: ~D[2022-01-03] - def datevalue(_ctx, date, format \\ "%Y-%m-%d %H:%M:%S") do - case DateHelpers.extract_dateish(date) do - nil -> %{"__value__" => "", "date" => nil} - date -> %{"__value__" => Timex.format!(date, format, :strftime), "date" => date} - end - end - - @doc """ - Returns only the day of the month of a date (1 to 31) - """ - @expression_doc doc: "Getting today's day of the month", - expression: "day(date(2022, 9, 10))", - result: 10 - @expression_doc doc: "Getting today's day of the month", - expression: "day(now())", - fake_result: DateTime.utc_now().day - def day(_ctx, %{day: day} = _date) do - day - end - - @doc """ - Moves a date by the given number of months - """ - @expression_doc doc: "Move the date in a date object by 1 month", - expression: "edate(right_now, 1)", - context: %{ - "right_now" => DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)) - }, - result: - Timex.shift(DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)), - months: 1 - ) - @expression_doc doc: "Move the date store in a piece of text by 1 month", - expression: "edate(\"2022-10-10\", 1)", - result: ~D[2022-11-10] - def edate(_ctx, date, months) do - DateHelpers.extract_dateish(date) |> Timex.shift(months: months) - end - - @doc """ - Returns only the hour of a datetime (0 to 23) - """ - @expression_doc doc: "Get the current hour", - expression: "hour(now())", - fake_result: DateTime.utc_now().hour - def hour(_ctx, %{hour: hour} = _date) do - hour - end - - @doc """ - Returns only the minute of a datetime (0 to 59) - """ - @expression_doc doc: "Get the current minute", - expression: "minute(now())", - fake_result: DateTime.utc_now().minute - def minute(_ctx, date) do - %{minute: minute} = DateHelpers.extract_datetimeish(date) - minute - end - - @doc """ - Returns only the month of a date (1 to 12) - """ - @expression_doc doc: "Get the current month", - expression: "month(now())", - fake_result: DateTime.utc_now().month - def month(_ctx, %{month: month} = _date) do - month - end - - @doc """ - Returns the current date time as UTC - - ``` - It is currently @NOW() - ``` - """ - @expression_doc doc: "return the current timestamp as a DateTime value", - expression: "now()", - fake_result: DateTime.utc_now() - @expression_doc doc: "return the current datetime and format it using `datevalue`", - expression: "datevalue(now(), \"%Y-%m-%d\")", - fake_result: %{ - "__value__" => DateTime.utc_now() |> Timex.format!("%Y-%m-%d", :strftime), - "date" => DateTime.utc_now() - } - def now(_ctx) do - DateTime.utc_now() - end - - @doc """ - Returns only the second of a datetime (0 to 59) - """ - @expression_doc expression: "second(now)", - context: %{"now" => DateTime.utc_now()}, - fake_result: DateTime.utc_now().second - def second(_ctx, %{second: second} = _date) do - second - end - - @doc """ - Defines a time value which can be used for time arithmetic - """ - @expression_doc expression: "time(12, 13, 14)", - result: %Time{hour: 12, minute: 13, second: 14} - def time(_ctx, hours, minutes, seconds) do - %Time{hour: hours, minute: minutes, second: seconds} - end - - @doc """ - Converts time stored in text to an actual time - """ - @expression_doc expression: "timevalue(\"2:30\")", - result: %Time{hour: 2, minute: 30, second: 0} - @expression_doc expression: "timevalue(\"2:30:55\")", - result: %Time{hour: 2, minute: 30, second: 55} - def timevalue(_ctx, expression) when is_binary(expression) do - parts = - expression - |> String.split(":") - |> Enum.map(&String.to_integer/1) - - defaults = [ - hour: 0, - minute: 0, - second: 0 - ] - - fields = - [:hour, :minute, :second] - |> Enum.zip(parts) - - struct(Time, Keyword.merge(defaults, fields)) - end - - @doc """ - Returns the current date - """ - @expression_doc expression: "today()", - fake_result: Date.utc_today() - def today(_ctx) do - Date.utc_today() - end - - @doc """ - Returns the day of the week of a date (1 for Sunday to 7 for Saturday) - """ - @expression_doc expression: "weekday(today)", - context: %{"today" => ~D[2022-11-06]}, - result: 1 - @expression_doc expression: "weekday(today)", - context: %{"today" => ~D[2022-11-01]}, - result: 3 - def weekday(_ctx, date) do - iso_week_day = Timex.weekday(date) - - if iso_week_day == 7 do - 1 - else - iso_week_day + 1 - end - end - - @doc """ - Returns only the year of a date - """ - @expression_doc expression: "year(now)", - context: %{"now" => DateTime.utc_now()}, - fake_result: DateTime.utc_now().year - def year(_ctx, date) do - %{year: year} = DateHelpers.extract_dateish(date) - year - end - - @doc """ - Returns `true` if and only if all its arguments evaluate to `true` - """ - @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)", - code_expression: "contact.gender = \"F\" and contact.age >= 18", - context: %{ - "contact" => %{ - "gender" => "F", - "age" => 32 - } - }, - result: true - @expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)", - code_expression: "contact.gender = \"F\" and contact.age >= 18", - context: %{ - "contact" => %{ - "gender" => "?", - "age" => 32 - } - }, - result: false - def and_vargs(_ctx, arguments) do - Enum.all?(arguments, & &1) - end - - @doc """ - Returns `false` if the argument supplied evaluates to truth-y - """ - @expression_doc expression: "not(false)", result: true - def not_(_ctx, argument) do - !argument - end - - @doc """ - Returns `true` if any argument is `true`. - Returns the first truthy value found or otherwise false. - - Accepts any amount of arguments for testing truthiness. - """ - @expression_doc doc: "Return true if any of the values are true", - expression: "or(true, false)", - code_expression: "true or false", - result: true - @expression_doc doc: "Return the first value that is truthy", - expression: "or(false, \"foo\")", - code_expression: "false or \"foo\"", - result: "foo" - @expression_doc expression: "or(true, true)", - code_expression: "true or true", - result: true - @expression_doc expression: "or(false, false)", - code_expression: "false or false", - result: false - @expression_doc expression: "or(a, b)", - context: %{"a" => false, "b" => "bee"}, - code_expression: "a or b", - result: "bee" - @expression_doc expression: "or(a, b)", - context: %{"a" => "a", "b" => false}, - code_expression: "a or b", - result: "a" - @expression_doc expression: "or(b, b)", - context: %{}, - code_expression: "b or b", - result: false - def or_vargs(_ctx, arguments) do - Enum.reduce_while(arguments, false, fn arg, acc -> - if(arg, do: {:halt, arg}, else: {:cont, acc}) - end) - end - - @doc """ - Returns the absolute value of a number - """ - @expression_doc expression: "abs(-1)", - result: 1 - def abs(_ctx, number) do - Kernel.abs(number) - end - - @doc """ - Returns the maximum value of all arguments - """ - @expression_doc expression: "max(1, 2, 3)", - result: 3 - def max_vargs(_ctx, arguments) do - Enum.max(arguments) - end - - @doc """ - Returns the minimum value of all arguments - """ - @expression_doc expression: "min(1, 2, 3)", - result: 1 - def min_vargs(_ctx, arguments) do - Enum.min(arguments) - end - - @doc """ - Returns the result of a number raised to a power - equivalent to the ^ operator - """ - @expression_doc expression: "power(2, 3)", - fake_result: 8.0 - def power(_ctx, a, b) do - :math.pow(a, b) - end - - @doc """ - Returns the sum of all arguments, equivalent to the + operator - - ``` - You have @SUM(contact.reports, contact.forms) reports and forms - ``` - """ - @expression_doc expression: "sum(1, 2, 3)", - result: 6 - def sum_vargs(_ctx, arguments) do - Enum.sum(arguments) - end - - @doc """ - Returns the character specified by a number - - ``` - > "As easy as @char(65), @char(66), @char(67)" - "As easy as A, B, C" - ``` - """ - @expression_doc expression: "char(65)", - result: "A" - def char(_ctx, code) do - <> - end - - @doc """ - Removes all non-printable characters from a text string - """ - @expression_doc expression: "clean(value)", - context: %{"value" => <<65, 0, 66, 0, 67>>}, - result: "ABC" - def clean(_ctx, binary) do - binary - |> String.graphemes() - |> Enum.filter(&String.printable?/1) - |> Enum.join("") - end - - @doc """ - Returns a numeric code for the first character in a text string - - ``` - > "The numeric code of A is @CODE(\\"A\\")" - "The numeric code of A is 65" - ``` - """ - @expression_doc expression: "code(\"A\")", - result: 65 - def code(_ctx, <>) do - code - end - - @doc """ - Joins text strings into one text string - - ``` - > "Your name is @CONCATENATE(contact.first_name, \\" \\", contact.last_name)" - "Your name is name surname" - ``` - """ - @expression_doc expression: "concatenate(contact.first_name, \" \", contact.last_name)", - context: %{ - "contact" => %{ - "first_name" => "name", - "last_name" => "surname" - } - }, - result: "name surname" - def concatenate_vargs(_ctx, arguments) do - Enum.join(arguments, "") - end - - @doc """ - Formats the given number in decimal format using a period and commas - - ``` - > You have @fixed(contact.balance, 2) in your account - "You have 4.21 in your account" - ``` - """ - @expression_doc expression: "fixed(4.209922, 2, false)", - result: "4.21" - @expression_doc expression: "fixed(4000.424242, 4, true)", - result: "4,000.4242" - @expression_doc expression: "fixed(3.7979, 2, false)", - result: "3.80" - @expression_doc expression: "fixed(3.7979, 2)", - result: "3.80" - def fixed(_ctx, number, precision, no_commas \\ false) - - def fixed(_ctx, number, precision, false), - do: Number.Delimit.number_to_delimited(number, precision: precision) - - def fixed(_ctx, number, precision, true), - do: - Number.Delimit.number_to_delimited(number, - precision: precision, - delimiter: ",", - separator: "." - ) - - @doc """ - Returns the first characters in a text string. This is Unicode safe. - """ - @expression_doc expression: "left(\"foobar\", 4)", - result: "foob" - - @expression_doc expression: - "left(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)", - result: "Умерла Мадлен Олбрай" - def left(_ctx, binary, size) do - String.slice(binary, 0, size) - end - - @doc """ - Returns the number of characters in a text string - """ - @expression_doc expression: "len(\"foo\")", - result: 3 - @expression_doc expression: "len(\"zoë\")", - result: 3 - def len(_ctx, binary) do - String.length(binary) - end - - @doc """ - Converts a text string to lowercase - """ - @expression_doc expression: "lower(\"Foo Bar\")", - result: "foo bar" - def lower(_ctx, binary) do - String.downcase(binary) - end - - @doc """ - Capitalizes the first letter of every word in a text string - """ - @expression_doc expression: "proper(\"foo bar\")", - result: "Foo Bar" - def proper(_ctx, binary) do - binary - |> String.split(" ") - |> Enum.map_join(" ", &String.capitalize/1) - end - - @doc """ - Repeats text a given number of times - """ - @expression_doc expression: "rept(\"*\", 10)", - result: "**********" - def rept(_ctx, value, amount) do - String.duplicate(value, amount) - end - - @doc """ - Returns the last characters in a text string. - This is Unicode safe. - """ - @expression_doc expression: "right(\"testing\", 3)", - result: "ing" - @expression_doc expression: - "right(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)", - result: "ту главы Госдепа США" - def right(_ctx, binary, size) do - String.slice(binary, -size, size) - end - - @doc """ - Substitutes new_text for old_text in a text string. If instance_num is given, then only that instance will be substituted - """ - @expression_doc expression: "substitute(\"I can't\", \"can't\", \"can do\")", - result: "I can do" - def substitute(_ctx, subject, pattern, replacement) do - String.replace(subject, pattern, replacement) - end - - @doc """ - Returns the unicode character specified by a number - """ - @expression_doc expression: "unichar(65)", result: "A" - @expression_doc expression: "unichar(233)", result: "é" - def unichar(_ctx, code) do - <> - end - - @doc """ - Returns a numeric code for the first character in a text string - """ - @expression_doc expression: "unicode(\"A\")", result: 65 - @expression_doc expression: "unicode(\"é\")", result: 233 - def unicode(_ctx, <>) do - code - end - - @doc """ - Converts a text string to uppercase - """ - @expression_doc expression: "upper(\"foo\")", - result: "FOO" - def upper(_ctx, binary) do - String.upcase(binary) - end - - @doc """ - Returns the first word in the given text - equivalent to WORD(text, 1) - """ - @expression_doc expression: "first_word(\"foo bar baz\")", - result: "foo" - def first_word(_ctx, binary) do - [word | _] = String.split(binary, " ") - word - end - - @doc """ - Formats a number as a percentage - """ - @expression_doc expression: "percent(2/10)", result: "20%" - @expression_doc expression: "percent(0.2)", result: "20%" - @expression_doc expression: "percent(d)", context: %{"d" => "0.2"}, result: "20%" - def percent(_ctx, float) do - with float when is_number(float) <- parse_float(float) do - Number.Percentage.number_to_percentage(float * 100, precision: 0) - end - end - - @doc """ - Formats digits in text for reading in TTS - """ - @expression_doc expression: "read_digits(\"+271\")", result: "plus two seven one" - def read_digits(_ctx, binary) do - map = %{ - "+" => "plus", - "0" => "zero", - "1" => "one", - "2" => "two", - "3" => "three", - "4" => "four", - "5" => "five", - "6" => "six", - "7" => "seven", - "8" => "eight", - "9" => "nine" - } - - binary - |> String.graphemes() - |> Enum.map(fn grapheme -> Map.get(map, grapheme, nil) end) - |> Enum.reject(&is_nil/1) - |> Enum.join(" ") - end - - @doc """ - Removes the first word from the given text. The remaining text will be unchanged - """ - @expression_doc expression: "remove_first_word(\"foo bar\")", result: "bar" - @expression_doc expression: "remove_first_word(\"foo-bar\", \"-\")", result: "bar" - def remove_first_word(_ctx, binary) do - separator = " " - tl(String.split(binary, separator)) |> Enum.join(separator) - end - - def remove_first_word(_ctx, binary, separator) do - tl(String.split(binary, separator)) |> Enum.join(separator) - end - - @doc """ - Extracts the nth word from the given text string. If stop is a negative number, - then it is treated as count backwards from the end of the text. If by_spaces is - specified and is `true` then the function splits the text into words only by spaces. - Otherwise the text is split by punctuation characters as well - """ - @expression_doc expression: "word(\"hello cow-boy\", 2)", result: "cow" - @expression_doc expression: "word(\"hello cow-boy\", 2, true)", result: "cow-boy" - @expression_doc expression: "word(\"hello cow-boy\", -1)", result: "boy" - def word(_ctx, binary, n) do - parts = String.split(binary, @punctuation_pattern) - - # This slicing seems off. - [part] = - if n < 0 do - Enum.slice(parts, n, 1) - else - Enum.slice(parts, n - 1, 1) - end - - part - end - - def word(_ctx, binary, n, by_spaces) do - splitter = if(by_spaces, do: " ", else: @punctuation_pattern) - parts = String.split(binary, splitter) - - # This slicing seems off. - [part] = - if n < 0 do - Enum.slice(parts, n, 1) - else - Enum.slice(parts, n - 1, 1) - end - - part - end - - @doc """ - Returns the number of words in the given text string. If by_spaces is specified and is `true` then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well - - ``` - > You entered @word_count("one two three") words - You entered 3 words - ``` - """ - @expression_doc expression: "word_count(\"hello cow-boy\")", result: 3 - @expression_doc expression: "word_count(\"hello cow-boy\", true)", result: 2 - def word_count(_ctx, binary) do - binary - |> String.split(@punctuation_pattern) - |> Enum.count() - end - - def word_count(_ctx, binary, by_spaces) do - splitter = if(by_spaces, do: " ", else: @punctuation_pattern) - - binary - |> String.split(splitter) - |> Enum.count() - end - - @doc """ - Extracts a substring of the words beginning at start, and up to but not-including stop. - If stop is omitted then the substring will be all words from start until the end of the text. - If stop is a negative number, then it is treated as count backwards from the end of the text. - If by_spaces is specified and is `true` then the function splits the text into words only by spaces. - Otherwise the text is split by punctuation characters as well - """ - @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2, 4)", - result: "expressions are" - @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2)", - result: "expressions are fun" - @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 1, -2)", - result: "FLOIP expressions" - @expression_doc expression: "word_slice(\"FLOIP expressions are fun\", -1)", - result: "fun" - def word_slice(_ctx, binary, start) do - parts = - binary - |> String.split(" ") - - cond do - start > 0 -> - parts - |> Enum.slice(start - 1, length(parts)) - |> Enum.join(" ") - - start < 0 -> - parts - |> Enum.slice(start..length(parts)) - |> Enum.join(" ") - end - end - - def word_slice(_ctx, binary, start, stop) do - cond do - stop > 0 -> - binary - |> String.split(@punctuation_pattern) - |> Enum.slice((start - 1)..(stop - 2)//1) - |> Enum.join(" ") - - stop < 0 -> - binary - |> String.split(@punctuation_pattern) - |> Enum.slice((start - 1)..(stop - 1)//1) - |> Enum.join(" ") - end - end - - def word_slice(_ctx, binary, start, stop, by_spaces) do - splitter = if(by_spaces, do: " ", else: @punctuation_pattern) - - case stop do - stop when stop > 0 -> - binary - |> String.split(splitter) - |> Enum.slice((start - 1)..(stop - 2)) - |> Enum.join(" ") - - stop when stop < 0 -> - binary - |> String.split(splitter) - |> Enum.slice((start - 1)..(stop - 1)) - |> Enum.join(" ") - end - end - - @doc """ - Returns `true` if the argument is a number. - """ - @expression_doc expression: "isnumber(1)", result: true - @expression_doc expression: "isnumber(1.0)", result: true - @expression_doc expression: "isnumber(\"1.0\")", result: true - @expression_doc expression: "isnumber(\"a\")", result: false - def isnumber(_ctx, var) do - case var do - var when is_float(var) or is_integer(var) -> - true - - var when is_binary(var) -> - String.match?(var, ~r/^\d+?.?\d+$/) - - _var -> - false - end - end - - @doc """ - Returns `true` if the argument is a boolean. - """ - @expression_doc expression: "isbool(true)", result: true - @expression_doc expression: "isbool(false)", result: true - @expression_doc expression: "isbool(1)", result: false - @expression_doc expression: "isbool(0)", result: false - @expression_doc expression: "isbool(\"true\")", result: false - @expression_doc expression: "isbool(\"false\")", result: false - def isbool(_ctx, var) do - var in [true, false] - end - - @doc """ - Returns `true` if the argument is a string. - """ - @expression_doc expression: "isstring(\"hello\")", result: true - @expression_doc expression: "isstring(false)", result: false - @expression_doc expression: "isstring(1)", result: false - def isstring(_ctx, binary), do: is_binary(binary) - - defp search_words(haystack, words) do - patterns = - words - |> String.split(" ") - |> Enum.map(&Regex.escape/1) - |> Enum.map(&Regex.compile!(&1, "i")) - - results = - patterns - |> Enum.map(&Regex.run(&1, haystack)) - |> Enum.map(fn - [match] -> match - nil -> nil - end) - |> Enum.reject(&is_nil/1) - - {patterns, results} - end - - @doc """ - Tests whether all the words are contained in text - - The words can be in any order and may appear more than once. - """ - @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"the fox\")", result: true - @expression_doc expression: "has_all_words(\"the quick brown FOX\", \"red fox\")", result: false - def has_all_words(_ctx, haystack, words) do - {patterns, results} = search_words(haystack, words) - # future match result: Enum.join(results, " ") - Enum.count(patterns) == Enum.count(results) - end - - @doc """ - Tests whether any of the words are contained in the text - - Only one of the words needs to match and it may appear more than once. - """ - @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"fox quick\")", - result: %{"__value__" => true, "match" => "Quick Fox"} - @expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"yellow\")", - result: %{"__value__" => false, "match" => nil} - def has_any_word(_ctx, haystack, words) do - haystack_words = String.split(haystack) - haystacks_lowercase = Enum.map(haystack_words, &String.downcase/1) - words_lowercase = String.split(words) |> Enum.map(&String.downcase/1) - - matched_indices = - haystacks_lowercase - |> Enum.with_index() - |> Enum.filter(fn {haystack_word, _index} -> - Enum.member?(words_lowercase, haystack_word) - end) - |> Enum.map(fn {_haystack_word, index} -> index end) - - matched_haystack_words = Enum.map(matched_indices, &Enum.at(haystack_words, &1)) - - match? = Enum.any?(matched_haystack_words) - - %{ - "__value__" => match?, - "match" => if(match?, do: Enum.join(matched_haystack_words, " "), else: nil) - } - end - - @doc """ - Tests whether text starts with beginning - - Both text values are trimmed of surrounding whitespace, but otherwise matching is - strict without any tokenization. - """ - @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", result: true - @expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", - result: false - @expression_doc expression: "has_beginning(\"The Quick Brown\", \"quick brown\")", result: false - def has_beginning(_ctx, text, beginning) do - case Regex.run(~r/^#{Regex.escape(beginning)}/i, to_string(text)) do - # future match result: first - [_first | _remainder] -> true - nil -> false - end - end - - @doc """ - Tests whether `expression` contains a date formatted according to our environment - - This is very naively implemented with a regular expression. - """ - @expression_doc expression: "has_date(\"the date is 15/01/2017\")", result: true - @expression_doc expression: "has_date(\"there is no date here, just a year 2017\")", - result: false - def has_date(_ctx, expression) do - !!DateHelpers.extract_dateish(expression) - end - - @doc """ - Tests whether `expression` is a date equal to `date_string` - """ - @expression_doc expression: "has_date_eq(\"the date is 15/01/2017\", \"2017-01-15\")", - result: true - @expression_doc expression: - "has_date_eq(\"there is no date here, just a year 2017\", \"2017-01-15\")", - result: false - def has_date_eq(_ctx, expression, date_string) do - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.extract_dateish(date_string) - # Future match result: found_date - found_date == test_date - end - - @doc """ - Tests whether `expression` is a date after the date `date_string` - """ - @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-01-01\")", - result: true - @expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-03-15\")", - result: false - def has_date_gt(_ctx, expression, date_string) do - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.extract_dateish(date_string) - # future match result: found_date - Date.compare(found_date, test_date) == :gt - end - - @doc """ - Tests whether `expression` contains a date before the date `date_string` - """ - @expression_doc expression: "has_date_lt(\"the date is 15/01/2017\", \"2017-06-01\")", - result: true - @expression_doc expression: "has_date_lt(\"the date is 15/01/2021\", \"2017-03-15\")", - result: false - def has_date_lt(_ctx, expression, date_string) do - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.extract_dateish(date_string) - # future match result: found_date - Date.compare(found_date, test_date) == :lt - end - - @doc """ - Tests whether an email is contained in text - """ - @expression_doc expression: "has_email(\"my email is foo1@bar.com, please respond\")", - result: true - @expression_doc expression: "has_email(\"i'm not sharing my email\")", result: false - def has_email(_ctx, expression) do - case Regex.run(~r/([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/, expression) do - # future match result: match - [_match | _] -> true - nil -> false - end - end - - @doc """ - Returns whether the contact is part of group with the passed in UUID - """ - @expression_doc expression: - "has_group(contact.groups, \"b7cf0d83-f1c9-411c-96fd-c511a4cfa86d\")", - context: %{ - "contact" => %{ - "groups" => [ - %{ - "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d" - } - ] - } - }, - result: true - @expression_doc expression: - "has_group(contact.groups, \"00000000-0000-0000-0000-000000000000\")", - context: %{ - "contact" => %{ - "groups" => [ - %{ - "uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d" - } - ] - } - }, - result: false - def has_group(_ctx, groups, uuid) do - group = Enum.find(groups, nil, &(&1["uuid"] == uuid)) - # future match result: group - !!group - end - - @spec extract_numberish(nil | number) :: nil | number - defp extract_numberish(nil), do: nil - defp extract_numberish(value) when is_number(value), do: value - - defp extract_numberish(expression) do - with [match] <- - Regex.run(~r/([0-9]+\.?[0-9]*)/u, replace_arabic_numerals(expression), capture: :first) do - parse_float(match) - end - end - - defp replace_arabic_numerals(expression) when is_binary(expression) do - replace_numerals(expression, %{ - "٠" => "0", - "١" => "1", - "٢" => "2", - "٣" => "3", - "٤" => "4", - "٥" => "5", - "٦" => "6", - "٧" => "7", - "٨" => "8", - "٩" => "9" - }) - end - - defp replace_numerals(expression, mapping) do - mapping - |> Enum.reduce(expression, fn {rune, replacement}, expression -> - String.replace(expression, rune, replacement) - end) - end - - @spec parse_float(number | String.t()) :: number | nil - def parse_float(number) when is_number(number), do: number - - def parse_float(binary) when is_binary(binary) do - case Float.parse(binary) do - {float, ""} -> float - _ -> nil - end - end - - @doc """ - Tests whether `expression` contains a number - """ - @expression_doc expression: "has_number(\"the number is 42 and 5\")", result: true - @expression_doc expression: "has_number(\"العدد ٤٢\")", result: true - @expression_doc expression: "has_number(\"٠.٥\")", result: true - @expression_doc expression: "has_number(\"0.6\")", result: true - @expression_doc expression: "has_number(\"\")", result: false - @expression_doc expression: "has_number(value)", context: %{"value" => nil}, result: false - - def has_number(_ctx, expression) do - number = extract_numberish(expression) - # future match result: number - !!number - end - - @doc """ - Tests whether `expression` contains a number equal to the value - """ - - @expression_doc expression: "has_number_eq(\"the number is 42\", 42)", result: true - @expression_doc expression: "has_number_eq(\"the number is 42\", 42.0)", result: true - @expression_doc expression: "has_number_eq(\"the number is 42\", \"42\")", result: true - @expression_doc expression: "has_number_eq(\"the number is 42.0\", \"42\")", result: true - @expression_doc expression: "has_number_eq(\"the number is 40\", \"42\")", result: false - @expression_doc expression: "has_number_eq(\"the number is 40\", \"foo\")", result: false - @expression_doc expression: "has_number_eq(\"four hundred\", \"foo\")", result: false - def has_number_eq(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - float == number - else - nil -> false - end - end - - @doc """ - Tests whether `expression` contains a number greater than min - """ - @expression_doc expression: "has_number_gt(\"the number is 42\", 40)", result: true - @expression_doc expression: "has_number_gt(\"the number is 42\", 40.0)", result: true - @expression_doc expression: "has_number_gt(\"the number is 42\", \"40\")", result: true - @expression_doc expression: "has_number_gt(\"the number is 42.0\", \"40\")", result: true - @expression_doc expression: "has_number_gt(\"the number is 40\", \"40\")", result: false - @expression_doc expression: "has_number_gt(\"the number is 40\", \"foo\")", result: false - @expression_doc expression: "has_number_gt(\"four hundred\", \"foo\")", result: false - def has_number_gt(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number > float - else - nil -> false - end - end - - @doc """ - Tests whether `expression` contains a number greater than or equal to min - """ - @expression_doc expression: "has_number_gte(\"the number is 42\", 42)", result: true - @expression_doc expression: "has_number_gte(\"the number is 42\", 42.0)", result: true - @expression_doc expression: "has_number_gte(\"the number is 42\", \"42\")", result: true - @expression_doc expression: "has_number_gte(\"the number is 42.0\", \"45\")", result: false - @expression_doc expression: "has_number_gte(\"the number is 40\", \"45\")", result: false - @expression_doc expression: "has_number_gte(\"the number is 40\", \"foo\")", result: false - @expression_doc expression: "has_number_gte(\"four hundred\", \"foo\")", result: false - def has_number_gte(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number >= float - else - nil -> false - end - end - - @doc """ - Tests whether `expression` contains a number less than max - """ - @expression_doc expression: "has_number_lt(\"the number is 42\", 44)", result: true - @expression_doc expression: "has_number_lt(\"the number is 42\", 44.0)", result: true - @expression_doc expression: "has_number_lt(\"the number is 42\", \"40\")", result: false - @expression_doc expression: "has_number_lt(\"the number is 42.0\", \"40\")", result: false - @expression_doc expression: "has_number_lt(\"the number is 40\", \"40\")", result: false - @expression_doc expression: "has_number_lt(\"the number is 40\", \"foo\")", result: false - @expression_doc expression: "has_number_lt(\"four hundred\", \"foo\")", result: false - def has_number_lt(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number < float - else - nil -> false - end - end - - @doc """ - Tests whether `expression` contains a number less than or equal to max - """ - @expression_doc expression: "has_number_lte(\"the number is 42\", 42)", result: true - @expression_doc expression: "has_number_lte(\"the number is 42\", 42.0)", result: true - @expression_doc expression: "has_number_lte(\"the number is 42\", \"42\")", result: true - @expression_doc expression: "has_number_lte(\"the number is 42.0\", \"40\")", result: false - @expression_doc expression: "has_number_lte(\"the number is 40\", \"foo\")", result: false - @expression_doc expression: "has_number_lte(\"four hundred\", \"foo\")", result: false - @expression_doc expression: "has_number_lte(response, 5)", - context: %{"response" => 3}, - result: true - def has_number_lte(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number <= float - else - nil -> false - end - end - - @doc """ - Tests whether the text contains only phrase - - The phrase must be the only text in the text to match - """ - @expression_doc expression: "has_only_phrase(\"Quick Brown\", \"quick brown\")", result: true - @expression_doc expression: "has_only_phrase(\"\", \" \")", result: true - @expression_doc expression: "has_only_phrase(\"The Quick Brown Fox\", \"quick brown\")", - result: false - - def has_only_phrase(_ctx, expression, phrase) do - result = Enum.map([expression, phrase], &String.downcase(String.trim(to_string(&1)))) - - case result do - # Future match result: expression - [same, same] -> true - _anything_else -> false - end - end - - @doc """ - Returns whether two text values are equal (case sensitive). In the case that they are, it will return the text as the match. - """ - @expression_doc expression: "has_only_text(\"foo\", \"foo\")", result: true - @expression_doc expression: "has_only_text(\"\", \"\")", result: true - @expression_doc expression: "has_only_text(\"foo\", \"FOO\")", result: false - def has_only_text(_ctx, expression_one, expression_two) do - expression_one == expression_two - end - - @doc """ - Tests whether `expression` matches the regex pattern - - Both text values are trimmed of surrounding whitespace and matching is case-insensitive. - """ - @expression_doc expression: "has_pattern(\"Buy cheese please\", \"buy (\\w+)\")", result: true - @expression_doc expression: "has_pattern(\"Sell cheese please\", \"buy (\\w+)\")", result: false - def has_pattern(_ctx, expression, pattern) do - with {:ok, regex} <- Regex.compile(String.trim(pattern), "i"), - [[_first | _remainder]] <- Regex.scan(regex, String.trim(expression), capture: :all) do - # Future match result: first - true - else - _ -> false - end - end - - @doc """ - Tests whether `expression` contains a phone number. - The optional country_code argument specifies the country to use for parsing. - """ - @expression_doc expression: "has_phone(\"my number is +12067799294 thanks\")", result: true - @expression_doc expression: "has_phone(\"my number is 2067799294 thanks\", \"US\")", - result: true - @expression_doc expression: "has_phone(\"my number is 206 779 9294 thanks\", \"US\")", - result: true - @expression_doc expression: "has_phone(\"my number is none of your business\", \"US\")", - result: false - def has_phone(_ctx, expression) do - letters_removed = Regex.replace(~r/[a-z]/i, expression, "") - - case ExPhoneNumber.parse(letters_removed, "") do - # Future match result: ExPhoneNumber.format(pn, :es164) - {:ok, _pn} -> true - _ -> false - end - end - - def has_phone(_ctx, expression, country_code) do - letters_removed = Regex.replace(~r/[a-z]/i, expression, "") - - case ExPhoneNumber.parse(letters_removed, country_code) do - # Future match result: ExPhoneNumber.format(pn, :es164) - {:ok, _pn} -> true - _ -> false - end - end - - @doc """ - Tests whether phrase is contained in `expression` - - The words in the test phrase must appear in the same order with no other words in between. - """ - @expression_doc expression: "has_phrase(\"the quick brown fox\", \"brown fox\")", result: true - @expression_doc expression: "has_phrase(\"the quick brown fox\", \"quick fox\")", result: false - @expression_doc expression: "has_phrase(\"the quick brown fox\", \"\")", result: true - def has_phrase(_ctx, expression, phrase) do - lower_expression = String.downcase(to_string(expression)) - lower_phrase = String.downcase(to_string(phrase)) - - String.contains?(lower_expression, lower_phrase) - end - - @doc """ - Tests whether there the `expression` has any characters in it - """ - @expression_doc expression: "has_text(\"quick brown\")", result: true - @expression_doc expression: "has_text(\"\")", result: false - @expression_doc expression: "has_text(\" \n\")", result: false - @expression_doc expression: "has_text(123)", result: true - def has_text(_ctx, expression) do - expression |> to_string() |> String.trim() != "" - end - - @doc """ - Tests whether `expression` contains a time. - """ - @expression_doc expression: "has_time(\"the time is 10:30\")", - result: %{"__value__" => true, "match" => ~T[10:30:00]} - @expression_doc expression: "has_time(\"the time is 10:00 pm\")", - result: %{"__value__" => true, "match" => ~T[10:00:00]} - @expression_doc expression: "has_time(\"the time is 10:30:45\")", - result: %{"__value__" => true, "match" => ~T[10:30:45]} - @expression_doc expression: "has_time(\"there is no time here, just the number 25\")", - result: false - def has_time(_ctx, expression) do - if time = DateHelpers.extract_timeish(expression) do - %{ - "__value__" => true, - "match" => time - } - else - false - end - end - - @doc """ - map over a list of items and apply the mapper function to every item, returning - the result. - """ - @expression_doc doc: "Map over the range of numbers, create a date in January for every number", - expression: "map(1..3, &date(2022, 1, &1))", - result: [~D[2022-01-01], ~D[2022-01-02], ~D[2022-01-03]] - @expression_doc doc: - "Map over the range of numbers, multiple each by itself and return the result", - expression: "map(1..3, &(&1 * &1))", - result: [1, 4, 9] - def map(_ctx, enumerable, mapper) do - Enum.map(enumerable, mapper) - end - - @doc """ - Return the division remainder of two integers. - """ - @expression_doc expression: "rem(4, 2)", - result: 0 - @expression_doc expression: "rem(85, 3)", - result: 1 - def rem(_ctx, integer1, integer2) do - rem(integer1, integer2) - end - - @doc """ - Appends an item or a list of items to a given list. - """ - @expression_doc expression: "append([\"A\", \"B\"], \"C\")", - result: ["A", "B", "C"] - @expression_doc expression: "append([\"A\", \"B\"], [\"C\", \"B\"])", - result: ["A", "B", "C", "B"] - def append(_ctx, list, payload) do - enumerable = if is_list(payload), do: payload, else: [payload] - - Enum.concat(list, enumerable) - end - - @doc """ - Deletes an element from a map by the given key. - """ - @expression_doc expression: "delete(patient, \"gender\")", - context: %{"patient" => %{"gender" => "?", "age" => 32}}, - result: %{"age" => 32} - def delete(_ctx, map, key) do - Map.delete(map, key) - end -end diff --git a/lib/expression/v2/compat.ex b/lib/expression/v2/compat.ex deleted file mode 100644 index 1c964a59..00000000 --- a/lib/expression/v2/compat.ex +++ /dev/null @@ -1,256 +0,0 @@ -defmodule Expression.V2.Compat do - @moduledoc """ - Deprecated compatibility module from the V1-to-V2 transition. - - All functions in this module delegate directly to their `Expression` - equivalents. Use `Expression.evaluate!/3`, `Expression.evaluate_as_string!/3`, - and `Expression.evaluate_block!/4` directly instead. - - This module will be removed in a future major version. - """ - require Logger - - @deprecated "Use Expression.evaluate_as_string!/3 directly" - def evaluate_as_string!( - expression, - context, - callback_module \\ Expression.Callbacks.Standard - ) - - def evaluate_as_string!(expression, context, callback_module) do - v1_resp = Expression.evaluate_as_string!(expression, context, callback_module) - - # v2_resp = - # V2.eval_as_string( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) - - # return_or_raise(expression, context, v1_resp, v2_resp) - v1_resp - end - - # def v1_module(Turn.Build.Callbacks), do: Turn.Build.CallbacksV1 - # def v1_module(V2.Callbacks.Standard), do: Expression.Callbacks.Standard - - def patch_v1_key(key), - do: - key - |> to_string() - |> String.downcase() - - def patch_v1_context(datetime) when is_struct(datetime, DateTime), do: datetime - - def patch_v1_context(date) when is_struct(date, Date), do: date - - def patch_v1_context(struct) when is_struct(struct) do - Map.from_struct(struct) - |> patch_v1_context() - end - - def patch_v1_context(list) when is_list(list), do: Enum.map(list, &patch_v1_context/1) - - def patch_v1_context(map) when is_map(map) do - map - |> Enum.map(fn {key, value} -> {patch_v1_key(key), patch_v1_context(value)} end) - |> Enum.into(%{}) - end - - def patch_v1_context(binary) when is_binary(binary) do - with :nope <- attempt_integer(binary), - :nope <- attempt_float(binary), - :nope <- attempt_datetime(binary), - :nope <- attempt_boolean(binary) do - binary - end - end - - def patch_v1_context(other), do: other - - def attempt_boolean(binary) do - potential_boolean = - binary - |> String.trim() - |> String.downcase() - - case potential_boolean do - "true" -> true - "false" -> false - _other -> :nope - end - end - - # Leading plus is still parsed as an integer, which we don't want - def attempt_integer("+" <> _), do: :nope - # Leading zero likely means a string code, not an integer - def attempt_integer("0" <> binary) when byte_size(binary) > 0, do: :nope - - def attempt_integer(binary) do - String.to_integer(binary) - rescue - ArgumentError -> :nope - end - - # Leading plus is still parsed as an integer, which we don't want - def attempt_float("+" <> _), do: :nope - - def attempt_float(binary) do - String.to_float(binary) - rescue - ArgumentError -> :nope - end - - def attempt_datetime(binary) do - case DateTime.from_iso8601(binary) do - {:ok, datetime, _} -> datetime - _other -> :nope - end - end - - @deprecated "Use Expression.evaluate!/3 directly" - def evaluate!(expression, context \\ %{}, callback_module \\ Expression.Callbacks.Standard) - - def evaluate!(expression, context, callback_module) do - v1_resp = Expression.evaluate!(expression, context, callback_module) - - # v2_resp = - # V2.eval( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) - # |> hd - - # return_or_raise(expression, context, v1_resp, v2_resp) - unpack_returned_value(v1_resp) - end - - @deprecated "Use Expression.evaluate_block!/4 directly" - def evaluate_block!( - expression, - context \\ %{}, - callback_module \\ Callbacks.Standard, - opts \\ [] - ) - - def evaluate_block!(expression, context, callback_module, opts) do - v1_resp = - Expression.evaluate_block(expression, context, callback_module, opts) - - # v2_resp = - # case V2.eval_block( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) do - # {:error, error, reason} -> {:error, error <> " " <> reason} - # value -> {:ok, value} - # end - - # cond do - # # Hack for handling random returns from `rand_between()` callback function - # # these will throw an error because they're designed to be different every time - # String.contains?(expression, "rand_between") -> - # return_or_raise(expression, context, v2_resp, v2_resp) - - # # Hack for handling `@if` expressions, in V2 these aren't evaluated. - # # See the note for this in `eval_compat_test.exs`. - # String.contains?(String.downcase(expression), ["@if", "@left"]) -> - # return_or_raise(expression, context, v2_resp, v2_resp) - - # true -> - # return_or_raise(expression, context, v1_resp, v2_resp) - # end - unpack_returned_value(v1_resp) - end - - def unpack_returned_value({:ok, val}), do: val - def unpack_returned_value({:error, reason}), do: reason - def unpack_returned_value(other), do: other - - def return_or_raise( - _expression, - _context, - {:not_found, _v1_path} = _v1_resp, - nil = _v2_resp - ) do - nil - end - - def return_or_raise(expression, context, {:ok, val1}, {:ok, val2}) do - return_or_raise(expression, context, val1, val2) - end - - def return_or_raise(_expression, _context, {:error, error1}, {:error, _error2}) do - error1 - end - - def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) - when byte_size(v1_resp) == 10 do - {:ok, v1_resp} = Date.from_iso8601(v1_resp) - {:ok, v2_resp} = Date.from_iso8601(v2_resp) - return_or_raise(expression, context, v1_resp, v2_resp) - end - - def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) do - {:ok, v1_resp, _} = DateTime.from_iso8601(v1_resp) - {:ok, v2_resp, _} = DateTime.from_iso8601(v2_resp) - return_or_raise(expression, context, v1_resp, v2_resp) - end - - def return_or_raise(expression, context, v1_resp, v2_resp) do - cond do - is_binary(v1_resp) and is_binary(v2_resp) -> - return_or_raise_binaries(expression, context, v1_resp, v2_resp) - - is_struct(v1_resp, DateTime) and is_struct(v2_resp, DateTime) -> - if DateTime.diff(v1_resp, v2_resp) <= :timer.seconds(1) do - v2_resp - else - log_error(expression, context, v1_resp, v2_resp) - end - - normalize_value(v1_resp) == normalize_value(v2_resp) -> - v2_resp - - true -> - log_error(expression, context, v1_resp, v2_resp) - end - end - - # To minimize random errors due to the V1 & V2 expressions being evaluated at different - # times we're truncating DateTime structs to the second to give the CPU some grace - # In the `return_or_raise` we confirm that it's still within a second though and return - # the original (non truncated) value - def normalize_value(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second) - def normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1) - - def normalize_value(map) when is_map(map) and not is_struct(map) do - map - |> Enum.map(fn {key, value} -> {key, normalize_value(value)} end) - |> Enum.into(%{}) - end - - def normalize_value(other), do: other - - def return_or_raise_binaries(expression, context, v1_resp, v2_resp) do - if String.jaro_distance(v1_resp, v2_resp) > 0.9 do - v2_resp - else - log_error(expression, context, v1_resp, v2_resp) - end - end - - def log_error(expression, context, v1_resp, v2_resp) do - Logger.error(""" - - ** Compatibility Error ** - - Expression: #{inspect(expression)} - Context: #{inspect(Map.drop(context, ["flow"]), pretty: true)} - - V1: #{inspect(v1_resp)} - V2: #{inspect(v2_resp)} - """) - - v1_resp - end -end diff --git a/lib/expression/v2/compile.ex b/lib/expression/v2/compile.ex deleted file mode 100644 index f3ffc5c4..00000000 --- a/lib/expression/v2/compile.ex +++ /dev/null @@ -1,219 +0,0 @@ -defmodule Expression.V2.Compile do - @moduledoc """ - An compiler for AST returned by Expression.V2.Parser. - - This reads the AST output returned by `Expression.V2.parse/1` and - compiles it to Elixir code. - - It does this by emitting valid Elixir AST, mimicking what `quote/2` does. - - The Elixir AST is then supplied to `Code.eval_quoted_with_env/3` without any - variable binding. What is returned is an anonymous function that accepts an - `Expression.V2.Context.t` struct and evaluates the code against that context. - - Any function calls are applied to the callback module referenced in the context. - So if an expression uses a function called `foo(1, 2, 3)` then the callback's - `callback/3` function will be called as follows: - - ```elixir - apply(context.callback_module, :callback, ["foo", [1, 2, 3]]) - ``` - - There is some special handling of some functions that have specific Elixir AST - syntax requirements. - - These are documented in the `to_quoted/2` function. - - All variables referenced by the expression are scoped to `context.vars`. - However the full context is supplied to any function calls, giving - functions the privilege of doing more than the `context.vars` scope alone - would allow them to do. - """ - - @built_ins ["*", "+", "-", "<>", ">", ">=", "<", "<=", "/", "^", "=="] - - @doc """ - Accepts AST as emitted by `Expression.V2.parse/1` and returns an anonymous function - that accepts a Context.t as an argument and returns the result of the expression - against the given Context. - - If the callback functions defined in the callback module are pure then this function - is also pure and is suitable for caching. - """ - @spec compile([any]) :: - (Expression.V2.Context.t() -> any) - def compile(ast) do - # convert to valid Elixir AST - quoted = wrap_in_context(to_quoted(ast)) - {term, _binding, _env} = Code.eval_quoted_with_env(quoted, [], Code.env_for_eval([])) - - term - end - - @doc """ - Wrap an AST block into an anonymous function that accepts - a single argument called context. - - This happens _after_ all the code generation completes. The code - generated expects a variable called `context` to exist, wrapping - it in this function ensures that it does. - - This is the anonymous function that is returned to the caller. - The caller is then responsible to call it with the correct context - variables. - """ - @spec wrap_in_context(Macro.t()) :: Macro.t() - def wrap_in_context(quoted) do - # Check to see if the generated AST makes a reference to the context. - # If that is the case then generate an AST that makes it available. - # If there are no references to the context then prefix the variable - # with an underscore to keep the compiler happy and not emit warnings - # at runtime - {quoted, uses_context?} = - Macro.prewalk(quoted, false, fn - {:context, _, _} = node, _acc -> {node, true} - other, acc -> {other, acc} - end) - - context_var = if uses_context?, do: :context, else: :_context - - {:fn, [], - [ - {:->, [], - [ - [{context_var, [], nil}], - {:__block__, [], - [ - quoted - ]} - ]} - ]} - end - - @doc """ - Convert the AST returned from `Expression.V2.parse/1` into valid Elixir AST - that can be used by `Code.eval_quoted_with_env/3`. - - There is some special handling here: - - 1. Lists are recursed to ensure that all list items are properly quoted. - 2. "\"Quoted strings\"" are unquoted and returned as regular strings to the AST. - 3. "Normal strings" are converted into Atoms and treated as such during eval. - 4. Literals such as numbers & booleans are left as is. - 5. Range.t items are converted to valid Elixir AST. - 6. `&` and `&1` captures are generated into valid Elixir AST captures. - 7. Any functions are generated as being function calls for the given callback module. - """ - @spec to_quoted([term] | term) :: Macro.t() - def to_quoted(ast) when is_list(ast) do - quoted_block = - Enum.reduce(ast, [], fn element, acc -> - [quoted(element) | acc] - end) - - {:__block__, [], quoted_block} - end - - defp quoted("\"" <> _ = binary) when is_binary(binary) do - binary - # Chop off the outer quoting - |> String.slice(1..-2//1) - # Remove the double quoting - |> String.replace("\\\"", "\"") - end - - defp quoted(number) when is_number(number), do: number - defp quoted(boolean) when is_boolean(boolean), do: boolean - - defp quoted({"__property__", [a, b]}) when is_binary(b) do - # When the property we're trying to read is a binary then we're doing - # `foo.bar` in an expression and we convert this to a `foo["bar"]` - {{:., [], [Access, :get]}, [], [quoted(a), b]} - end - - defp quoted({"__attribute__", [a, b]}) do - # Since Map keys in Expressions can either be integers or strings - # we use the helper in Expression.V2.read_attribute to read - # the correct value using Elixir function guards in compiled - # code rather than attempting to generate the AST for that here. - {{:., [], [{:__aliases__, [alias: false], [:Expression, :V2]}, :read_attribute]}, [], - [quoted(a), quoted(b)]} - end - - defp quoted({"if", [test, yes, no]}) do - # This is not handled as a callback function in the callback module - # because the arguments need to be evaluated lazily. - {:if, [], - [ - quoted(test), - [ - do: quoted(yes), - else: quoted(no) - ] - ]} - end - - defp quoted({"&", args}) do - {:&, [], Enum.map(args, "ed(&1))} - end - - defp quoted("&" <> index) do - {:&, [], [String.to_integer(index)]} - end - - defp quoted({function_name, arguments}) - when is_binary(function_name) and - function_name in @built_ins and - is_list(arguments) do - default_values = - Enum.map(arguments, fn - argument when is_integer(argument) -> - quoted(argument) - - "\"" <> _string = argument -> - quoted(argument) - - argument -> - {{:., [], [{:__aliases__, [alias: false], [:Expression, :V2]}, :default_value]}, [], - [quoted(argument), {:context, [], nil}]} - end) - - {String.to_existing_atom(function_name), [], default_values} - end - - defp quoted({function_name, arguments}) - when is_binary(function_name) and is_list(arguments) do - {:apply, [], - [ - context_dot_callback_module(), - :callback, - [{:context, [], nil}, function_name, Enum.map(arguments, "ed(&1))] - ]} - end - - defp quoted(list) when is_list(list) do - Enum.map(list, "ed(&1)) - end - - defp quoted(atom) when is_binary(atom) do - {{:., [], [Access, :get]}, [], [context_dot_vars(), atom]} - end - - defp quoted(%Range{first: first, last: last, step: step}) do - {:%, [], - [ - {:__aliases__, [], [:Range]}, - {:%{}, [], [first: first, last: last, step: step]} - ]} - end - - defp context_dot_callback_module do - # Short hand function to generate `context.callback_module` - {{:., [], [{:context, [], nil}, :callback_module]}, [no_parens: true], []} - end - - defp context_dot_vars do - # Short hand function to generate `context.vars` - {{:., [], [{:context, [], nil}, :vars]}, [no_parens: true], []} - end -end diff --git a/lib/expression/v2/context.ex b/lib/expression/v2/context.ex deleted file mode 100644 index bcde3dcb..00000000 --- a/lib/expression/v2/context.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Expression.V2.Context do - @moduledoc """ - The context supplied to a function generated by `Expression.V2.Compile.compile/1` - - This will be expanded with support for more attributes that a callback function - can access but normal Expression evaluation can not. - """ - defstruct vars: %{}, private: %{}, callback_module: Expression.V2.Callbacks.Standard - - @type t :: %__MODULE__{ - vars: map, - private: map, - callback_module: module - } - - def new(vars \\ %{}, callback_module \\ Expression.V2.Callbacks.Standard), - do: %__MODULE__{vars: vars, callback_module: callback_module} - - def private(ctx, key, value), do: %{ctx | private: Map.put(ctx.private, key, value)} -end diff --git a/lib/expression/v2/parser.ex b/lib/expression/v2/parser.ex deleted file mode 100644 index 98a42d04..00000000 --- a/lib/expression/v2/parser.ex +++ /dev/null @@ -1,441 +0,0 @@ -defmodule Expression.V2.Parser do - @moduledoc """ - A NimbleParsec parser for FLOIP expressions. - - FLOIP Expressions consist of plain text and of blocks. Plain text is returned untouched - but blocks are evaluated. - - Blocks are prefixed with an `@` sign. Blocks can either have expressions between brackets or - be used in a shorthand form when wanting to use a single function or variable substitution. - - As an example, the following are identical: - - * `@(now())` and `@now()` - * `@contact.name` and `@(contact.name)` - - However, a full expression needs to be within brackets: - - `Tomorrow's is @(today().day + 1)` - - This parses it into an Abstract Syntax Tree (AST) which follows a style much like a Lisp would. - It parses expressions in [Infix notation](https://en.wikipedia.org/wiki/Infix_notation) such as - `1 + 1` and parses it into lists where the operator is the first element and the second element - is the list of arguments for the operator. - - ``` - ["+", [1, 1]] - ``` - - Functions are expressed as: - - ``` - {"function name", [arg1, arg2]} - ``` - - Until we have a fixed scope of allowed functions, or if we can dynamically look up whether an - `atom` is a function or a variable reference, we will need to rely on tuples to represent functions - as otherwise the system has no means to distinguish the following AST as being a function call - or a list as a variable: - - ``` - ["echo", [1, 2, 3]] - ``` - - Without being able to say _ahead_ of time whether or not `echo/1` is a known function, the - system cannot reliable determine whether the result of this AST should be `["echo", [1, 2, 3]]` - or the result of `echo(1, 2, 3)`. - - Variable references are single values. - - ``` - "contact" - ``` - - This module provides two functions for parsing. `parse/2` which will parse a full FLOIP expression - including text and blocks, and `expression/2` which will parse expression blocks. - - Internally `parse/2` refers to the same parsers as `expression/2` for things that are expressions. - """ - import NimbleParsec - import Expression.DateHelpers - - # Booleans can be spelled in any mixed case - boolean_true = - choice([string("t"), string("T")]) - |> choice([string("r"), string("R")]) - |> choice([string("u"), string("U")]) - |> choice([string("e"), string("E")]) - |> replace(true) - - boolean_false = - choice([string("f"), string("F")]) - |> choice([string("a"), string("A")]) - |> choice([string("l"), string("L")]) - |> choice([string("s"), string("S")]) - |> choice([string("e"), string("E")]) - |> replace(false) - - boolean = - choice([ - boolean_true, - boolean_false - ]) - - int = - optional(string("-")) - |> concat(ascii_string([?0..?9], min: 1)) - |> reduce({Enum, :join, [""]}) - |> map({String, :to_integer, []}) - - # These are just regular floats, previous iteration used the - # Decimal library but that just made some simple arithmetic - # and comparisons more complicated than needed to be. - float = - optional(string("-")) - |> integer(min: 1) - |> string(".") - # Using ascii string here instead of integer/2 to prevent us chopping - # off leading zeros after the period. - |> concat(ascii_string([?0..?9], min: 1)) - |> reduce({Enum, :join, [""]}) - |> map({String, :to_float, []}) - - # This is inspired by the NimbleParsec docs - # https://hexdocs.pm/nimble_parsec/NimbleParsec.html#repeat_while/4 - defparsecp( - :double_quoted_string, - ascii_char([?"]) - |> repeat_while( - choice([ - string(~S(\")) |> replace("\\\""), - utf8_char([]) - ]), - {:not_double_quote, []} - ) - |> ascii_char([?"]) - |> reduce({List, :to_string, []}) - ) - - @doc false - defp not_double_quote(<>, context, _, _), do: {:halt, context} - defp not_double_quote(_, context, _, _), do: {:cont, context} - - defparsecp( - :single_quoted_string, - ignore(ascii_char([?'])) - |> repeat_while( - choice([ - string(~S(\')) |> replace("'"), - utf8_char([]) - ]), - {:not_single_quote, []} - ) - |> ignore(ascii_char([?'])) - |> reduce(:to_double_quoted_string) - ) - - # Helper to convert a 'hello' parsed by parsec(:single_quoted_string) into - # a "hello" - def to_double_quoted_string(charlist) when is_list(charlist), - do: inspect(to_string(charlist)) - - @doc false - def not_single_quote(<>, context, _, _), do: {:halt, context} - def not_single_quote(_, context, _, _), do: {:cont, context} - - # We support single & double quoted strings. - string_with_quotes = - choice([ - parsec(:single_quoted_string), - parsec(:double_quoted_string) - ]) - - # Atoms are names, these can be variable names or function names etc. - atom = - ascii_string([?a..?z, ?A..?Z, ?0..?9], min: 1) - |> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-], min: 0) - |> map({String, :downcase, []}) - |> reduce({Enum, :join, []}) - - whitespace = - choice([ - string(" "), - string("\n"), - string("\r") - ]) - - # Helper function to wrap parsers in, resulting in them ignoring - # surrounding whitespace. - # - # This has to be an anonymous function otherwise the compiler cannot - # find it during compilation steps. - ignore_surrounding_whitespace = fn p -> - ignore(repeat(whitespace)) - |> concat(p) - |> ignore(repeat(whitespace)) - end - - list = - ignore(string("[")) - |> wrap( - repeat( - parsec(:term_operator) - |> optional(ignore(ignore_surrounding_whitespace.(string(",")))) - ) - ) - |> ignore(string("]")) - - function_arguments = - ignore(string("(")) - |> concat( - ignore_surrounding_whitespace.( - wrap( - repeat( - parsec(:term_operator) - |> optional(ignore(ignore_surrounding_whitespace.(string(",")))) - ) - ) - ) - ) - |> ignore(string(")")) - - function = - atom - |> concat(function_arguments) - |> reduce(:as_function_tuple) - - lambda_capture = - string("&") - |> concat(integer(min: 1)) - |> reduce({Enum, :join, []}) - - lambda = - string("&") - |> optional(ignore(string(" "))) - |> choice([ - # either we get a block as a function - function_arguments, - # or we refer to a function directly - wrap(parsec(:term_operator)) - ]) - |> reduce(:as_function_tuple) - - @doc false - def as_function_tuple([binary]) when is_binary(binary), do: {binary, []} - - def as_function_tuple([binary, args]) when is_binary(binary) and is_list(args), - do: {binary, args} - - range = - integer(min: 1) - |> ignore(string("..")) - |> concat(integer(min: 1)) - |> optional( - ignore(string("//")) - |> concat(integer(min: 1)) - ) - |> reduce(:ensure_range) - - @doc false - def ensure_range([first, last, step]), do: Range.new(first, last, step) - def ensure_range([first, last]), do: Range.new(first, last) - - # A block is a expression that can be parsed and is surrounded - # by opening & closing brackets - block = - ignore(string("(")) - |> parsec(:term_operator) - |> ignore(string(")")) - - # A term is the lowest level types in an Expression - term = - times( - choice([ - label(datetime(), "a datetime"), - label(date(), "a date"), - label(time(), "a time"), - label(range, "a range"), - label(float, "a float"), - label(int, "an integer"), - label(string_with_quotes, "a quoted string"), - label(lambda_capture, "a capture"), - label(lambda, "an anonymous function"), - label(function, "a function"), - label(boolean, "a boolean"), - label(atom, "an atom") - ]), - min: 1 - ) - - # Properties are only allowed to be atoms - # So if we have a foo.bar, `bar` is property the atom - # If we allow more complicated types here it causes confusion - # because in `foo.false`, `false` would be parsed as a boolean type - property = - times( - replace(string("."), "__property__") - |> concat(atom), - min: 1 - ) - - # We allow parsec operators here to allow expressions - # to be evaluated and their result to be used as a key - # to lookup an attribute value - attribute = - times( - replace(string("["), "__attribute__") - |> concat(parsec(:term_operator)) - |> ignore(string("]")), - min: 1 - ) - - property_or_attribute = repeat(choice([property, attribute])) - - # A compound term is either a term or a term made up of terms - # using a list or a block which may or may not have a property - # or an attribute - # - # As an example `foo.bar` calls the property `bar` on term `foo`. - compound_term_with_property_or_attribute = - choice([ - label(list, "a list"), - label(block, "a group"), - term - ]) - |> optional(property_or_attribute) - - # The following operators determine the order of operations, as per - # https://en.wikipedia.org/wiki/Order_of_operations - - # Normally this would also have root but we don't have a shorthand for that - # and so rather than a list of options, this is just the exponent operator. - # This has higher precedence. - exponentiation_operator = string("^") - - # Multiplication & division is second - multiplication_division_operator = - choice([ - string("*"), - string("/") - ]) - - # Addition & subtraction are last - addition_subtraction_operator = - choice([ - string("+"), - string("-"), - string("<>"), - string(">="), - string(">"), - string("!="), - string("<="), - string("<"), - string("=="), - string("&"), - replace(string("="), "==") - ]) - - # Below are the precedence parsers, each gives the higher precedence - # a change to parse its things _before_ it itself attempts to do so. - # This is how the precedence is guaranteed. - - # First operator precedence parser - defparsecp( - :exponentiation, - compound_term_with_property_or_attribute - |> label("an expression") - |> repeat( - exponentiation_operator - |> label("an operator") - |> ignore_surrounding_whitespace.() - |> concat(compound_term_with_property_or_attribute) - ) - |> reduce(:fold_infixl) - ) - - # Second operator precedence parser - defparsecp( - :multiplication_division, - parsec(:exponentiation) - |> repeat( - multiplication_division_operator - |> ignore_surrounding_whitespace.() - |> concat(parsec(:exponentiation)) - ) - |> reduce(:fold_infixl) - ) - - # Third operator precedence parser - defparsecp( - :term_operator, - parsec(:multiplication_division) - |> repeat( - addition_subtraction_operator - |> ignore_surrounding_whitespace.() - |> concat(parsec(:multiplication_division)) - ) - |> reduce(:fold_infixl) - ) - - # Parses a block such as `@(1 + 1)` - expression_block = - ignore(string("@")) - |> concat(wrap(block)) - - # Parsed a short hand such as `@now()` - expression_shorthand = - ignore(string("@")) - |> concat(wrap(reduce(compound_term_with_property_or_attribute, :fold_infixl))) - - single_at = string("@") - - # @@ should be treated as an @ - escaped_at = - ignore(string("@")) - |> string("@") - - # Parses any old text as long as it doesn't have - # any @ expression markers - text = - empty() - |> lookahead_not(string("@")) - |> utf8_string([], 1) - |> times(min: 1) - |> reduce({Enum, :join, []}) - - @doc false - def fold_infixl(acc) do - acc - |> Enum.reverse() - |> Enum.chunk_every(2) - |> List.foldr([], fn - [l], [] -> l - [r, op], l -> {op, [l, r]} - end) - end - - @doc """ - Parse a block and return the AST - - ## Example - - iex> Expression.V2.Parser.expression("contact.age + 1") - {:ok, [{"+", [{"__property__", ["contact", "age"]}, 1]}], "", %{}, {1, 0}, 15} - - """ - defparsec(:expression, parsec(:term_operator)) - - @doc """ - Parse an expression and return the AST - - ## Example - - iex> Expression.V2.Parser.parse("hello @world the time is @now()") - {:ok, ["hello ", ["world"], " the time is ", [{"now", []}]], "", %{}, {1, 0}, 31} - - """ - defparsec( - :parse, - repeat(choice([text, escaped_at, expression_block, expression_shorthand, single_at])) - ) -end diff --git a/mix.exs b/mix.exs index 3076992e..43c32d77 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Expression.MixProject do use Mix.Project - @version "2.49.1" + @version "3.0.0-rc.0" def project do [ @@ -42,7 +42,6 @@ defmodule Expression.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:benchee, "~> 1.0", only: :dev}, {:credo, "~> 1.5", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, ">= 0.28.2", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 390f44c7..2f23de85 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,9 @@ %{ - "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, @@ -27,7 +25,6 @@ "number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, diff --git a/test/expression/context_test.exs b/test/expression/context_test.exs index 2280a427..f5296069 100644 --- a/test/expression/context_test.exs +++ b/test/expression/context_test.exs @@ -2,86 +2,112 @@ defmodule Expression.ContextTest do use ExUnit.Case alias Expression.Context - test "new context from a context containing a date string in dd/mm/yyyy" do - context = %{"block" => %{"value" => %{"birth_date" => "19/10/2002"}}} + describe "string coercion (coerce_strings: true)" do + test "context containing a date string in dd/mm/yyyy" do + context = %{"block" => %{"value" => %{"birth_date" => "19/10/2002"}}} - assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-19]}}} = - Context.new(context) - end + assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-19]}}} = + Context.new(context, coerce_strings: true) + end - test "new context from a context containing a date string in dd/mm/yyyy starting with 0" do - context = %{"block" => %{"value" => %{"birth_date" => "09/10/2002"}}} + test "context containing a date string in dd/mm/yyyy starting with 0" do + context = %{"block" => %{"value" => %{"birth_date" => "09/10/2002"}}} - assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-09]}}} = - Context.new(context) - end + assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-09]}}} = + Context.new(context, coerce_strings: true) + end - test "new context from a context containing a date string in dd-mm-yyyy" do - context = %{"block" => %{"value" => %{"birth_date" => "19-10-2002"}}} + test "context containing a date string in dd-mm-yyyy" do + context = %{"block" => %{"value" => %{"birth_date" => "19-10-2002"}}} - assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-19]}}} = - Context.new(context) - end + assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-19]}}} = + Context.new(context, coerce_strings: true) + end - test "new context from a context containing a date string in dd-mm-yyyy starting with 0" do - context = %{"block" => %{"value" => %{"birth_date" => "09-10-2002"}}} + test "context containing a date string in dd-mm-yyyy starting with 0" do + context = %{"block" => %{"value" => %{"birth_date" => "09-10-2002"}}} - assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-09]}}} = - Context.new(context) - end + assert %{"block" => %{"value" => %{"birth_date" => ~D[2002-10-09]}}} = + Context.new(context, coerce_strings: true) + end - test "new context from a context containing a datetime string" do - context = %{"block" => %{"value" => %{"program_start_date" => "2022-11-10T13:40:05.921378"}}} + test "context containing a datetime string" do + context = %{ + "block" => %{"value" => %{"program_start_date" => "2022-11-10T13:40:05.921378"}} + } - assert %{"block" => %{"value" => %{"program_start_date" => ~U[2022-11-10 13:40:05.921378Z]}}} = - Context.new(context) - end + assert %{ + "block" => %{"value" => %{"program_start_date" => ~U[2022-11-10 13:40:05.921378Z]}} + } = + Context.new(context, coerce_strings: true) + end + + test "context containing a datetime string with microseconds precision 7" do + context = %{ + "block" => %{"value" => %{"program_start_date" => "2022-11-10T13:40:05.9213782"}} + } + + # microseconds are truncated to precision 6 (Elixir's DateTime maximum) + assert %{ + "block" => %{"value" => %{"program_start_date" => ~U[2022-11-10 13:40:05.921378Z]}} + } = + Context.new(context, coerce_strings: true) + end + + test "context containing numbers" do + values = [ + %{"score" => 1234}, + %{"rate" => 1.1234567} + ] + + for context <- values do + # numbers are unchanged + assert Context.new(context, coerce_strings: true) == context + end + end + + test "zero as a string is parsed as a number" do + assert Context.new(%{"zero" => "0"}, coerce_strings: true) == %{"zero" => 0} + end - test "new context from a context containing a datetime string with microseconds precision 7" do - context = %{"block" => %{"value" => %{"program_start_date" => "2022-11-10T13:40:05.9213782"}}} + test "strings starting with zero are not parsed as numbers" do + values = [ + %{"national_id" => "01234567"}, + %{"code" => "01234abc"}, + %{"rate" => "0.1234567"}, + %{"password" => "0.123abc"} + ] - # Assert that the microseconds are truncated to precision 6 (the maximum precision supported by Elixir's DateTime) - assert %{"block" => %{"value" => %{"program_start_date" => ~U[2022-11-10 13:40:05.921378Z]}}} = - Context.new(context) + for context <- values do + assert Context.new(context, coerce_strings: true) == context + end + end end - test "new context from a context containing numbers" do - values = [ - %{"score" => 1234}, - %{"rate" => 1.1234567} - ] + describe "v3 default behavior (no coercion, no key lowercasing)" do + test "string values are preserved by default" do + context = %{"birth_date" => "19/10/2002", "active" => "true", "score" => "42"} - for context <- values do - # Assert that the number is not parsed assert Context.new(context) == context end - end - test "new context with zero as a string" do - # Assert that the string "0" is parsed as a number - assert Context.new(%{"zero" => "0"}) == %{"zero" => 0} - end + test "atom keys are stringified but case is preserved" do + assert Context.new(%{Name: "Jane", AGE: 30}) == %{"Name" => "Jane", "AGE" => 30} + end - test "new context from a context containing a string starting with zero" do - values = [ - %{"national_id" => "01234567"}, - %{"code" => "01234abc"}, - %{"rate" => "0.1234567"}, - %{"password" => "0.123abc"} - ] + test "datetime strings are not coerced by default" do + context = %{"date" => "2022-11-10T13:40:05.921378"} - for context <- values do - # Assert that the string starting with zero is not parsed as number assert Context.new(context) == context end end describe "lowercase_keys option" do - test "default: keys are lowercased" do - assert %{"name" => "Jane"} = Context.new(%{Name: "Jane"}) + test "default: original key casing is preserved" do + assert %{"Name" => "Jane"} = Context.new(%{Name: "Jane"}) end - test "lowercase_keys: true is the same as default" do + test "lowercase_keys: true lowercases keys" do assert %{"name" => "Jane"} = Context.new(%{Name: "Jane"}, lowercase_keys: true) end @@ -130,13 +156,14 @@ defmodule Expression.ContextTest do end describe "coerce_strings option" do - test "default: strings are coerced" do - assert %{"flag" => true} = Context.new(%{"flag" => "true"}) - assert %{"num" => 42} = Context.new(%{"num" => "42"}) + test "default: strings are preserved (no coercion)" do + assert %{"flag" => "true"} = Context.new(%{"flag" => "true"}) + assert %{"num" => "42"} = Context.new(%{"num" => "42"}) end - test "coerce_strings: true is the same as default" do + test "coerce_strings: true coerces booleans and numbers" do assert %{"flag" => true} = Context.new(%{"flag" => "true"}, coerce_strings: true) + assert %{"num" => 42} = Context.new(%{"num" => "42"}, coerce_strings: true) end test "coerce_strings: false preserves string values" do @@ -169,12 +196,6 @@ defmodule Expression.ContextTest do assert ctx["block"]["value"] == "42" end - - test "coerce_strings: false is backwards compat with skip_context_evaluation?" do - ctx1 = Context.new(%{"flag" => "True"}, coerce_strings: false) - ctx2 = Context.new(%{"flag" => "True"}, skip_context_evaluation?: true) - assert ctx1 == ctx2 - end end describe "combined options" do @@ -205,48 +226,4 @@ defmodule Expression.ContextTest do assert "Jane" == Expression.evaluate_as_string!("@firstname", ctx) end end - - describe "context is parsed correctly when using the `skip_context_evaluation?` option" do - test "string values in context that resemble booleans should not be parsed as booleans" do - # By default (without the flag) boolean-ish string values as parsed as booleans - assert %{"block" => %{"response" => true}} == - Context.new(%{ - "block" => %{"response" => "True"} - }) - - # With the flag set to true they are kept as strings - assert %{"block" => %{"response" => "True"}} == - Context.new(%{"block" => %{"response" => "True"}}, - skip_context_evaluation?: true - ) - - assert %{"block" => %{"response" => "true"}} == - Context.new( - %{"block" => %{"response" => "true"}}, - skip_context_evaluation?: true - ) - end - - test "string values in context that resemble numbers should not be parsed as numbers" do - assert %{ - "ref_buttons_7bef16" => %{ - "__value__" => "2", - "index" => 1, - "label" => "2", - "name" => "2" - } - } == - Context.new( - %{ - "ref_Buttons_7bef16" => %{ - "__value__" => "2", - "index" => 1, - "label" => "2", - "name" => "2" - } - }, - skip_context_evaluation?: true - ) - end - end end diff --git a/test/expression/eval_test.exs b/test/expression/eval_test.exs index af32900f..d96dd7a8 100644 --- a/test/expression/eval_test.exs +++ b/test/expression/eval_test.exs @@ -7,51 +7,32 @@ defmodule Expression.EvalTest do assert "bar" == Expression.evaluate_as_string!("@foo", %{"foo" => "bar"}) end - test "substitutions in substitutions" do + test "string literals containing @variable references are evaluated as templates" do + assert "hello" == Expression.evaluate_block!(~S("@my_var"), %{"my_var" => "hello"}) + end + + test "string literals without @references are returned as-is" do assert "string with quotes \" inside" == Expression.evaluate_block!(~S("string with quotes \" inside")) + end - # Note the escaping of the @IF here with an @ - # credo:disable-for-lines:2 Credo.Check.Readability.StringSigils - assert true == - Expression.evaluate_block!( - "block.response = \"@@IF(cursor + 1 < total_items, \\\"Next article ➡️\\\", \\\"⏮ First article\\\")\"", - %{ - "block" => %{ - "response" => - "@IF(cursor + 1 < total_items, \"Next article ➡️\", \"⏮ First article\")" - }, - "cursor" => "1", - "total_items" => "10" - } - ) - - assert "Your application was successful" == - Expression.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => true, - "confirm" => "successful", - "deny" => "unsuccessful" - } - ) + test "string literals in function args resolve @references" do + ctx = %{ + "conditional" => true, + "confirm" => "successful", + "deny" => "unsuccessful" + } - assert "Your application was unsuccessful" == + assert "was successful" == Expression.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => false, - "confirm" => "successful", - "deny" => "unsuccessful" - } + ~s|@if(conditional, "was @confirm", "was @deny")|, + ctx ) assert "true" == Expression.evaluate_as_string!( ~s|@or(answer == 1, has_all_words(answer, "red fox"))|, - %{ - "answer" => "1" - } + Expression.Context.new(%{"answer" => "1"}, coerce_strings: true) ) end @@ -59,6 +40,33 @@ defmodule Expression.EvalTest do assert "baz" == Expression.evaluate_as_string!("@foo.bar", %{"foo" => %{"bar" => "baz"}}) end + test "string index on list coerces to integer" do + assert %{"name" => "c"} == + Expression.evaluate_block!( + "items[index]", + %{ + "items" => [%{"name" => "a"}, %{"name" => "b"}, %{"name" => "c"}], + "index" => "2" + } + ) + end + + test "float index on list truncates to integer" do + assert "b" == + Expression.evaluate_block!( + "items[index]", + %{"items" => ["a", "b", "c"], "index" => 1.0} + ) + end + + test "non-numeric string index on list returns nil" do + assert nil == + Expression.evaluate_block!( + "items[index]", + %{"items" => ["a", "b"], "index" => "not_a_number"} + ) + end + test "attributes on things that cannot have attributes should return nil" do assert nil == Expression.evaluate_block!("foo.bar", %{"foo" => "not a map"}) end diff --git a/test/expression/v2/callbacks_test.exs b/test/expression/v2/callbacks_test.exs deleted file mode 100644 index 0f824609..00000000 --- a/test/expression/v2/callbacks_test.exs +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Expression.V2.CallbacksTest do - use ExUnit.Case, async: true - doctest Expression.V2.Callbacks, import: true - doctest Expression.V2.Callbacks.Standard, import: true -end diff --git a/test/expression/v2/eval_compat_test.exs b/test/expression/v2/eval_compat_test.exs deleted file mode 100644 index 4607cd29..00000000 --- a/test/expression/v2/eval_compat_test.exs +++ /dev/null @@ -1,235 +0,0 @@ -defmodule Expression.V2.EvalCompatTest do - @moduledoc """ - This test module is copied from `Expression.EvalTest` and the function calls - have been updated to use the `Expression.V2.Compat` module to test backwards - compatibility. - - The biggest changes are in the ones calling `V2.eval_ast/1` as the return from - that in V2 is a list. The `Compat` function handles that return type difference - for us though. - - Also, V1 did some weird stuff with escaping strings and comparisons between them. - I think I'm coming to the conclusion that that may have been a bad idea and so - I'm not retrofitting that particular use case. - """ - use ExUnit.Case, async: true - alias Expression.V2 - alias Expression.V2.Compat - alias Expression.V2.Parser - - test "substitution" do - assert "bar" == Compat.evaluate_as_string!("@foo", %{"foo" => "bar"}) - end - - @tag :skip - test "substitutions in substitutions" do - assert "string with quotes \" inside" == - Compat.evaluate_block!(~S("string with quotes \" inside")) - - # I'm not sure if this is desirable behaviour to begin with - # - # # credo:disable-for-lines:2 Credo.Check.Readability.StringSigils - # assert true == - # Compat.evaluate_block!( - # "block.response = \"@@IF(cursor + 1 < total_items, \\\"Next article ➡️\\\", \\\"⏮ First article\\\")\"", - # %{ - # "block" => %{ - # "response" => - # "@IF(cursor + 1 < total_items, \"Next article ➡️\", \"⏮ First article\")" - # }, - # "cursor" => "1", - # "total_items" => "10" - # } - # ) - - assert "Your application was successful" == - Compat.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => true, - "confirm" => "successful", - "deny" => "unsuccessful" - } - ) - - assert "Your application was unsuccessful" == - Compat.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => false, - "confirm" => "successful", - "deny" => "unsuccessful" - } - ) - end - - test "attributes on substitutions" do - assert "baz" == Compat.evaluate_as_string!("@foo.bar", %{"foo" => %{"bar" => "baz"}}) - end - - test "attributes with literals" do - assert "value" == - Compat.evaluate_as_string!("@foo.bar.123.baz", %{ - "foo" => %{ - "bar" => %{ - "123" => %{ - "baz" => "value" - } - } - } - }) - end - - test "functions" do - {:ok, ast, "", _, _, _} = Parser.parse(~s[@has_any_word("The Quick Brown Fox", "red fox")]) - - assert [%{"__value__" => true, "match" => "Fox"}] == V2.eval_ast(ast) - end - - test "if" do - {:ok, ast, "", _, _, _} = - Parser.parse("@if(image_response.status == 200,\nimage_response.body.id,\nfalse)") - - assert [false] == - V2.eval_ast( - ast, - V2.Context.new(%{ - "image_response" => %{"status" => 500, "body" => "Internal Server Error"} - }) - ) - end - - describe "lambdas" do - test "with map" do - {:ok, ast, "", _, _, _} = Parser.parse("@map(foo, &([&1, 'Button']))") - - assert [[[1, "Button"], [2, "Button"], [3, "Button"]]] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => [1, 2, 3]})) - end - - test "with functions" do - {:ok, ast, "", _, _, _} = Parser.parse("@map(1..3, &date(2022, 5, &1))") - - assert [ - [ - ~D[2022-05-01], - ~D[2022-05-02], - ~D[2022-05-03] - ] - ] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "with arithmetic" do - {:ok, ast, "", _, _, _} = Parser.parse("@(map(foo, &([&1, 'Button'])))") - - assert [[[1, "Button"], [2, "Button"], [3, "Button"]]] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => [1, 2, 3]})) - end - - test "lambda with joins" do - assert [["one", "Button one"], ["two", "Button two"], ["three", "Button three"]] == - Compat.evaluate!("@map(choices, &([&1, concatenate('Button ', &1)]))", %{ - "choices" => ["one", "two", "three"] - }) - end - end - - test "email addresses" do - assert "email info@one.two.three.four.five.six for more information" == - Compat.evaluate_as_string!( - "email info@one.two.three.four.five.six for more information", - %{} - ) - end - - test "attributes on functions" do - assert "Fox" == - Compat.evaluate_as_string!( - ~s[@has_any_word("The Quick Brown Fox", "red fox").match], - %{} - ) - end - - describe "lists" do - test "with integer indices" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[1]") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => [0, 1, 2]})) - end - - test "with binary keys" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo['a']") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => 1}})) - end - - test "with binary keys as variables" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[bar]") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => 1}, "bar" => "a"})) - end - - test "with binary keys as variables and strings" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[bar]['baz']") - - assert [1] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => %{"baz" => 1}}, "bar" => "a"})) - end - - test "with function" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[day(now())]") - today = DateTime.utc_now().day - assert [today] == V2.eval_ast(ast, V2.Context.new(%{"foo" => Enum.to_list(0..31)})) - end - - test "with range slices" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[1..3]") - assert [[1, 2, 3]] == V2.eval_ast(ast, V2.Context.new(%{"foo" => Enum.to_list(0..31)})) - end - end - - test "arithmetic" do - {:ok, ast, "", _, _, _} = Parser.parse("@(1 + 1)") - assert [2] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(1 + 2 * 3)") - assert [7] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1 + 2) * 3)") - assert [9] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1 + 2) / 3)") - assert [1.0] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(6 / 2 + 1)") - assert [4.0] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "arithmetic with decimals" do - {:ok, ast, "", _, _, _} = Parser.parse("@(1.5 + 1.5)") - assert [3.0] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(1.5 + 2.5 * 3.5)") - assert [10.25] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1.5 + 2.5) * 3.5)") - assert [14.00] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1.5 + 2.5) / 3.5)") - assert [1.1428571428571428] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(6.8 / 2.0 + 1.5)") - assert [4.9] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2.002 * 0.05)") - assert [0.10010] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 > 0.5)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 >= 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 < 0.5)") - assert [false] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 <= 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 == 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "text" do - assert "hello Bob" == - Compat.evaluate_as_string!("hello @contact.name", %{ - "contact" => %{"name" => "Bob"} - }) - end -end diff --git a/test/expression/v2/eval_test.exs b/test/expression/v2/eval_test.exs deleted file mode 100644 index c46a0485..00000000 --- a/test/expression/v2/eval_test.exs +++ /dev/null @@ -1,187 +0,0 @@ -defmodule Expression.V2.EvalTest do - use ExUnit.Case, async: true - doctest Expression.V2 - - alias Expression.V2 - alias Expression.V2.Compile - alias Expression.V2.Context - alias Expression.V2.Parser - - def eval(binary, vars \\ %{}, opts \\ []) do - {:ok, ast, "", _, _, _} = Parser.expression(binary) - context = Context.new(vars) - debug = opts[:debug] || false - - if debug do - IO.puts("ast: #{inspect(ast)}") - - ast - |> V2.debug() - |> IO.puts() - end - - case Compile.compile(ast) do - result when is_function(result) -> result.(context) - result -> result - end - end - - describe "map as context" do - test "eval_block accepts a map" do - assert V2.eval_block("contact.name", %{"contact" => %{"name" => "Mary"}}) == "Mary" - end - - test "eval accepts a map" do - assert V2.eval("hello @contact.name", %{"contact" => %{"name" => "Mary"}}) == [ - "hello ", - "Mary" - ] - end - end - - describe "eval_as_string" do - test "with missing vars" do - assert "hello @one.two.three" == V2.eval_as_string("hello @one.two.three") - end - - test "stringifies a time reached via context substitution" do - assert "11:00:00" == - V2.eval_as_string("@appointment", Context.new(%{"appointment" => ~T[11:00:00]})) - end - - test "stringifies a time embedded in surrounding text" do - assert "Your slot is 11:00:00 today" == - V2.eval_as_string( - "Your slot is @appointment today", - Context.new(%{"appointment" => ~T[11:00:00]}) - ) - end - end - - describe "eval" do - test "with missing vars" do - assert nil == V2.eval_block("one.two.three") - end - - test "vars" do - assert "bar" == eval("foo", %{"foo" => "bar"}) - end - - test "properties" do - assert "baz" == eval("foo.bar", %{"foo" => %{"bar" => "baz"}}) - end - - test "attributes as vars" do - assert "qux" == eval("foo[bar]", %{"foo" => %{"baz" => "qux"}, "bar" => "baz"}) - end - - test "attributes as literals" do - assert "qux" == eval("foo[\"baz\"]", %{"foo" => %{"baz" => "qux"}}) - end - - test "attribute and property combination" do - assert "hi" == - eval( - "after_hours.rows[random_row].label.bar", - %{ - "after_hours" => %{"rows" => [%{"label" => %{"bar" => "hi"}}]}, - "random_row" => 0 - } - ) - end - - test "indices on lists" do - assert "qux" == eval("foo[2]", %{"foo" => [1, 2, "qux"]}) - end - - test "function calls" do - assert "Hi" == eval("proper(\"hi\")") - end - - test "arithmetic" do - assert 5 == eval("1 * 5") - end - - test "arithmetic against default values" do - assert 50 == - eval( - "5 * foo", - %{ - "foo" => %{ - "__value__" => 10, - "other_attributes" => "ignored" - } - } - ) - end - - test "precedence" do - assert 12 == eval("2 * 5 + 2") - end - - test "groups" do - assert 21 == eval("3 * (5 + 2)") - end - - test "functions with vars" do - assert 50 == eval("day(date(2023, 2, 10)) * 5") - end - - test "functions with default values" do - assert 50 = eval("day(foo) * 5", %{"foo" => %{"__value__" => Date.new!(2023, 2, 10)}}) - end - - test "ints & floats" do - assert true == eval("1.0 <= 1") - assert true == eval("1.0 == 1") - assert true == eval("1.0 = 1") - assert false == eval("1.0 > 1") - assert true == eval("1.1 > 1") - end - - test "if" do - assert 1 == eval("if(something.true, 1, 0)", %{"something" => %{"true" => true}}) - - assert nil == - eval( - "if(something.false, 1, contact.bar)", - %{"something" => %{"false" => false}, "contact" => %{}} - ) - end - - test "complex values" do - assert %{"__value__" => true, "match" => "Fox"} == - eval(~S|has_any_word("The Quick Brown Fox", "red fox")|) - - assert "Fox" == - eval(~S|has_any_word("The Quick Brown Fox", "red fox").match|) - end - - test "lambda & map" do - ctx = %{"foo" => [1, 2, 3]} - assert [1, 2, 3] == eval("map(foo, &(&1))", ctx) - - assert [[1, "Button"], [2, "Button"], [3, "Button"]] = - eval("map(foo, &([&1, \"Button\"]))", ctx) - - assert [[1, "Button 1"], [2, "Button 2"], [3, "Button 3"]] = - eval(~S|map(foo, &([&1, concatenate("Button ", &1)]))|, ctx) - end - - test "lambda & map with single quoted strings" do - assert [ - ["one", "Button One"], - ["two", "Button Two"], - ["three", "Button Three"], - ["four", "Button Four"], - ["five", "Button Five"] - ] == - eval( - ~S|map(options, &([&1, concatenate('Button ', proper(&1))]))|, - %{ - "options" => ["one", "two", "three", "four", "five"] - } - ) - end - end -end diff --git a/test/expression/v2/parser_test.exs b/test/expression/v2/parser_test.exs deleted file mode 100644 index 1692cecc..00000000 --- a/test/expression/v2/parser_test.exs +++ /dev/null @@ -1,224 +0,0 @@ -defmodule Expression.V2.ParserTest do - use ExUnit.Case, async: true - doctest Expression.V2.Parser - - alias Expression.V2.Parser - - describe "mixed" do - test "expression/1 with plain text" do - assert {:ok, ["hi"], "", _, _, _} = Parser.parse("hi") - assert {:ok, ["hi ", [1]], "", _, _, _} = Parser.parse("hi @(1)") - end - - test "escaping @" do - assert {:ok, ["foo", "@", "bar.com"], "", _, _, _} = Parser.parse("foo@@bar.com") - end - - test "lone @" do - assert {:ok, ["foo ", "@", " something is bar"], "", _, _, _} = - Parser.parse("foo @ something is bar") - end - end - - describe "expression/1 primitives" do - test "int" do - assert {:ok, [[1]], "", _, _, _} = Parser.parse("@(1)") - end - - test "string" do - assert {:ok, [["\"hello\""]], "", _, _, _} = Parser.parse("@(\"hello\")") - end - - test "float" do - assert {:ok, [[1.123456789]], "", _, _, _} = Parser.parse("@(1.1234567890)") - end - - test "atom" do - assert {:ok, [["foo"]], "", _, _, _} = Parser.parse("@(foo)") - end - end - - describe "functions" do - test "expression/1" do - assert {:ok, ["hi ", [{"now", []}]], "", _, _, _} = Parser.parse("hi @now()") - assert {:ok, ["hi ", [{"now", [1, 2]}]], "", _, _, _} = Parser.parse("hi @now(1, 2)") - assert {:ok, ["hi ", [{"now", [1, 2]}]], "", _, _, _} = Parser.parse("hi @(now(1, 2))") - end - - test "expression/1 nested" do - assert {:ok, [[{"now", [1, 2, {"foo", [1, 2, 3]}]}]], "", _, _, _} = - Parser.parse("@(now(1, 2, foo(1, 2, 3)))") - end - end - - describe "groups" do - test "expression/1" do - assert {:ok, [[{"+", [1, 1]}]], "", _, _, _} = Parser.parse("@(1 + 1)") - end - - test "expression/1 with functions" do - assert {:ok, [[{"+", [{"+", [{"foo", []}, 1]}, 1]}]], "", _, _, _} = - Parser.parse("@(foo() + 1 + 1)") - end - - test "operator precedence" do - assert {:ok, [[{"+", [1, {"*", [2, 3]}]}]], "", _, _, _} = Parser.parse("@(1 + 2 * 3)") - end - - test "grouping" do - assert {:ok, [[{"*", [1, {"+", [2, 3]}]}]], "", _, _, _} = Parser.parse("@(1 * (2 + 3))") - end - - test "grouping with function calls" do - assert {:ok, [[{"+", [{"*", [1, {"+", [2, 3]}]}, {"foo", []}]}]], "", _, _, _} = - Parser.parse("@(1 * (2 + 3) + foo())") - end - - test "grouping in function arguments" do - assert {:ok, - [ - [ - {"function", [1, {"*", [{"+", [2, 3]}, 4]}, {"other_function", []}]} - ] - ], "", _, _, _} = Parser.parse("@(function(1, (2 + 3) * 4, other_function()))") - end - end - - describe "properties" do - test "direct" do - assert {:ok, [[{"__property__", ["foo", "bar"]}]], "", _, _, _} = Parser.parse("@(foo.bar)") - end - - test "nested" do - assert {:ok, - [ - [ - {"__property__", [{"__property__", ["foo", "bar"]}, "baz"]} - ] - ], "", _, _, _} = Parser.parse("@(foo.bar.baz)") - end - - test "when called on function results" do - assert {:ok, [[{"__property__", [{"function", []}, "bar"]}]], "", _, _, _} = - Parser.parse("@(function().bar)") - end - end - - describe "lists" do - test "plain" do - assert {:ok, [[[1, 2, 3]]], "", _, _, _} = Parser.parse("@([1,2,3])") - end - - test "in lambda" do - assert {:ok, [[{"map", ["foo", {"&", [["&1", "\"Button\""]]}]}]], "", _, _, _} = - Parser.parse("@map(foo, &([&1, \"Button\"]))") - end - end - - describe "ranges" do - test "range" do - assert {:ok, [[1..10]], "", _, _, _} = Parser.parse("@(1..10)") - end - - test "range with step" do - assert {:ok, [[1..10//5]], "", _, _, _} = Parser.parse("@(1..10//5)") - end - end - - describe "lambdas" do - test "parsing lambdas" do - assert {:ok, [[{"map", ["foo", {"&", ["&1"]}]}]], "", _, _, _} = - Parser.parse("@map(foo, &(&1))") - end - end - - describe "attributes" do - test "when nested" do - assert {:ok, - [ - [ - {"__attribute__", [{"__attribute__", ["foo", "bar"]}, "baz"]} - ] - ], "", _, _, _} = Parser.parse("@(foo[bar][baz])") - end - - test "when indexed" do - assert {:ok, [[{"__attribute__", ["foo", 1]}]], "", _, _, _} = Parser.parse("@(foo[1])") - end - - test "when string keys" do - assert {:ok, [[{"__attribute__", ["foo", "\"bar\""]}]], "", _, _, _} = - Parser.parse("@(foo[\"bar\"])") - end - - test "when function values" do - assert {:ok, [[{"__attribute__", ["foo", {"today", []}]}]], "", _, _, _} = - Parser.parse("@(foo[today()])") - end - - test "when called on function results" do - assert {:ok, [[{"__attribute__", [{"function", []}, 0]}]], "", _, _, _} = - Parser.parse("@(function()[0])") - end - - test "when called on properties" do - assert {:ok, - [ - [ - {"__attribute__", - [{"__property__", [{"__property__", ["foo", "bar"]}, "baz"]}, 0]} - ] - ], "", _, _, _} = Parser.parse("@(foo.bar.baz[0])") - end - end - - describe "shorthand" do - test "mixed with function" do - assert {:ok, ["hi ", [{"now", []}]], "", _, _, _} = Parser.parse("hi @now()") - end - - test "function" do - assert {:ok, [[{"now", []}]], "", _, _, _} = Parser.parse("@now()") - end - - test "function with arguments" do - assert {:ok, [[{"now", [1, 2.3]}]], "", _, _, _} = Parser.parse("@now(1, 2.3)") - end - - test "nested functions" do - assert {:ok, [[{"first", [{"second", [1, 2, 3]}, 4]}]], "", _, _, _} = - Parser.parse("@first(second(1, 2, 3), 4)") - end - - test "short hand properties" do - assert {:ok, [[{"__property__", ["bar", "com"]}]], "", _, _, _} = Parser.parse("@bar.com") - end - - test "short hand attributes" do - assert {:ok, [[{"__attribute__", ["bar", "com"]}]], "", _, _, _} = Parser.parse("@bar[com]") - end - end - - describe "error handling" do - test "bad expression" do - assert {:ok, ["foo"], " is bar", _, _, _} = Parser.expression("foo is bar") - end - - test "bad grammar" do - assert {:error, "expected an atom while processing an expression", "?", _, _, _} = - Parser.expression("?") - end - end - - describe "failing examples" do - test "*BOLD*" do - assert {:ok, - [ - "\nHi there ", - [{"__property__", ["contact", "whatsapp_profile_name"]}], - "\n\n*HANGMAN*)\n)\n" - ], "", _, _, _} = - Parser.parse("\nHi there @contact.whatsapp_profile_name\n\n*HANGMAN*)\n)\n") - end - end -end diff --git a/test/expression/v2_compat_test.exs b/test/expression/v2_compat_test.exs new file mode 100644 index 00000000..d9a35be8 --- /dev/null +++ b/test/expression/v2_compat_test.exs @@ -0,0 +1,159 @@ +defmodule Expression.V2CompatTest do + @moduledoc """ + Proves that `mode: :v2` on the evaluation entry points restores the v2 + evaluation semantics that changed in v3: + + 1. context keys are lowercased (`lowercase_keys: true`) + 2. string values are coerced to typed equivalents (`coerce_strings: true`) + + Also pins down what did NOT change: operator semantics are identical in + v2 and v3. In particular the parser collapses `=` and `==` into the same + operator (see `OperatorHelpers.eq/1`), so the "date-only `=`" behavior + documented for v2 was unreachable — DateTime equality always compares the + full value, in both versions and both modes. + """ + use ExUnit.Case, async: true + + @morning ~U[2023-01-15 10:00:00Z] + @evening ~U[2023-01-15 22:30:00Z] + + describe "context string coercion" do + test "evaluate!/4 coerces string values under mode: :v2" do + ctx = %{"age" => "30", "opted_in" => "true", "date" => "2020-12-13T23:34:45"} + + assert Expression.evaluate!("@age", ctx, Expression.Callbacks, mode: :v2) == 30 + assert Expression.evaluate!("@opted_in", ctx, Expression.Callbacks, mode: :v2) == true + + assert Expression.evaluate!("@date", ctx, Expression.Callbacks, mode: :v2) == + ~U[2020-12-13 23:34:45.0Z] + end + + test "evaluate!/4 preserves string values by default" do + ctx = %{"age" => "30", "opted_in" => "true", "date" => "2020-12-13T23:34:45"} + + assert Expression.evaluate!("@age", ctx) == "30" + assert Expression.evaluate!("@opted_in", ctx) == "true" + assert Expression.evaluate!("@date", ctx) == "2020-12-13T23:34:45" + end + + test "evaluate_as_string!/4 renders coerced datetimes in the v2 shape" do + ctx = %{"date" => "2020-12-13T23:34:45"} + + assert Expression.evaluate_as_string!("@date", ctx, Expression.Callbacks, mode: :v2) == + "2020-12-13T23:34:45.0Z" + + assert Expression.evaluate_as_string!("@date", ctx) == "2020-12-13T23:34:45" + end + + test "coerced values participate in comparisons under mode: :v2" do + ctx = %{"age" => "30"} + + assert Expression.evaluate!("@(age > 21)", ctx, Expression.Callbacks, mode: :v2) == true + end + end + + describe "context key lowercasing" do + test "maps resolved under mode: :v2 have lowercased keys" do + ctx = %{"Contact" => %{"Name" => "Jane"}} + + assert Expression.evaluate!("@contact", ctx, Expression.Callbacks, mode: :v2) == + %{"name" => "Jane"} + + assert Expression.evaluate!("@contact", ctx) == %{"Name" => "Jane"} + end + + test "variable lookup is case-insensitive in both modes" do + ctx = %{"Contact" => %{"Name" => "Jane"}} + + assert Expression.evaluate!("@contact.name", ctx, Expression.Callbacks, mode: :v2) == + "Jane" + + assert Expression.evaluate!("@contact.name", ctx) == "Jane" + end + end + + describe "operator semantics are mode-independent" do + test "`=` and `==` on DateTimes compare the full value in both modes" do + ctx = %{"a" => @morning, "b" => @evening, "same" => @morning} + + for opts <- [[], [mode: :v2]] do + assert Expression.evaluate!("@(a = b)", ctx, Expression.Callbacks, opts) == false + assert Expression.evaluate!("@(a == b)", ctx, Expression.Callbacks, opts) == false + assert Expression.evaluate!("@(a = same)", ctx, Expression.Callbacks, opts) == true + assert Expression.evaluate!("@(a == same)", ctx, Expression.Callbacks, opts) == true + end + end + end + + describe "entry point coverage" do + test "evaluate_block!/4 and evaluate_block/4 accept the mode" do + ctx = %{"age" => "30"} + + assert Expression.evaluate_block!("age", ctx, Expression.Callbacks, mode: :v2) == 30 + assert Expression.evaluate_block!("age", ctx) == "30" + + assert Expression.evaluate_block("age", ctx, Expression.Callbacks, mode: :v2) == + {:ok, 30} + end + + test "evaluate/4 accepts the mode" do + ctx = %{"age" => "30"} + + assert Expression.evaluate("@age", ctx, Expression.Callbacks, mode: :v2) == {:ok, 30} + assert Expression.evaluate("@age", ctx) == {:ok, "30"} + end + + test "evaluate_as_boolean!/4 accepts the mode" do + ctx = %{"opted_in" => "true"} + + assert Expression.evaluate_as_boolean!("@opted_in", ctx, Expression.Callbacks, mode: :v2) == + true + end + + test "evaluate_template!/4 accepts the mode" do + ctx = %{"date" => "2020-12-13T23:34:45"} + + assert Expression.evaluate_template!("on @date", ctx, Expression.Callbacks, mode: :v2) == + "on 2020-12-13T23:34:45.0Z" + end + + test "nested template literals see the v2-normalized context" do + ctx = %{"Date" => "2020-12-13T23:34:45"} + + assert Expression.evaluate_as_string!( + ~s[@if(true, "on @date", "")], + ctx, + Expression.Callbacks, + mode: :v2 + ) == "on 2020-12-13T23:34:45.0Z" + end + end + + describe "explicit flags vs mode expansion" do + test "explicitly passed flags override the mode expansion" do + ctx = %{"age" => "30", "Contact" => %{"Name" => "Jane"}} + + assert Expression.evaluate!("@age", ctx, Expression.Callbacks, + mode: :v2, + coerce_strings: false + ) == "30" + + assert Expression.evaluate!("@contact", ctx, Expression.Callbacks, + mode: :v2, + lowercase_keys: false + ) == %{"Name" => "Jane"} + end + + test "mode: :v3 keeps the v3 defaults" do + ctx = %{"age" => "30"} + + assert Expression.evaluate!("@age", ctx, Expression.Callbacks, mode: :v3) == "30" + end + + test "individual flags work without the mode sugar" do + ctx = %{"age" => "30"} + + assert Expression.evaluate!("@age", ctx, Expression.Callbacks, coerce_strings: true) == 30 + end + end +end diff --git a/test/expression/v2_test.exs b/test/expression/v2_test.exs deleted file mode 100644 index dad61e69..00000000 --- a/test/expression/v2_test.exs +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Expression.V2Test do - use ExUnit.Case, async: true - alias Expression.V2 - - describe "code gen" do - test "code gen using context" do - assert String.trim(""" - fn context -> - Expression.V2.default_value(context.vars["foo"], context) + - Expression.V2.default_value(context.vars["bar"], context) - end - """) == V2.debug("foo + bar") - end - - test "code gen not using context" do - assert String.trim(""" - fn _context -> ["one", "two"] end - """) == V2.debug(~S|["one", "two"]|) - end - - test "default values not used for integers and builtins" do - assert "fn _context -> 1 + 1 end" == V2.debug("1 + 1") - end - - test "default values not used for strings and builtins" do - assert String.trim(""" - fn _context -> "hello" == "bye" end - """) == - V2.debug(~S|"hello" == "bye"|) - end - - test "default values used for variables and builtins" do - assert String.trim(""" - fn context -> - Expression.V2.default_value(context.vars["a"], context) * - Expression.V2.default_value(context.vars["b"], context) - end - """) == V2.debug("a * b") - end - end -end diff --git a/test/expression_context_test.exs b/test/expression_context_test.exs index 91c54316..9c41511f 100644 --- a/test/expression_context_test.exs +++ b/test/expression_context_test.exs @@ -8,11 +8,14 @@ defmodule ExpressionContextTest do "integer" => 1, "string_integer" => 1 } == - Expression.Context.new(%{ - "string_integer" => "1", - "integer" => 1, - "trouble" => "_she_calls_me_princes___🤔" - }) + Expression.Context.new( + %{ + "string_integer" => "1", + "integer" => 1, + "trouble" => "_she_calls_me_princes___🤔" + }, + coerce_strings: true + ) end describe "Expression.Context.build/2" do @@ -29,7 +32,13 @@ defmodule ExpressionContextTest do end test "normalizes vars through Context.new" do - ctx = Expression.Context.build(%{Name: "Jane", date: "2020-12-13T23:34:45"}) + ctx = + Expression.Context.build( + %{Name: "Jane", date: "2020-12-13T23:34:45"}, + lowercase_keys: true, + coerce_strings: true + ) + assert ctx.vars["name"] == "Jane" assert %DateTime{} = ctx.vars["date"] end diff --git a/test/expression_test.exs b/test/expression_test.exs index 19c6d3ac..f94be779 100644 --- a/test/expression_test.exs +++ b/test/expression_test.exs @@ -220,7 +220,9 @@ defmodule ExpressionTest do "body" => ["hello", "bye"] }, "current_activity" => "0" - } + }, + Expression.Callbacks, + coerce_strings: true ) end @@ -612,17 +614,18 @@ defmodule ExpressionTest do end test "return an error tuple" do - assert {:error, "expression is not a number: `\"not a number\"`"} = + assert {:error, + %Expression.Error{message: "expression is not a number: `\"not a number\"`"}} = Expression.evaluate_block("block.value > 0", %{ "block" => %{"value" => "not a number"} }) end test "return an error tuple when variables are not defined" do - assert {:error, "attribute is not found: `value`"} = + assert {:error, %Expression.Error{message: "attribute is not found: `value`"}} = Expression.evaluate_block("block.value > 0", %{"block" => %{}}) - assert {:error, "attribute is not found: `block.value`"} = + assert {:error, %Expression.Error{message: "attribute is not found: `block.value`"}} = Expression.evaluate_block("block.value > 0", %{}) end @@ -658,17 +661,19 @@ defmodule ExpressionTest do end test "fixed/2 with a non-numeric value returns an error tuple instead of crashing" do - assert {:error, "expression is not a number: `\"not a number\"`"} = + assert {:error, + %Expression.Error{message: "expression is not a number: `\"not a number\"`"}} = Expression.evaluate_block("fixed(value, 2)", %{"value" => "not a number"}) end test "fixed/2 with a nil value returns an error tuple instead of crashing" do - assert {:error, "expression is not a number: `nil`"} = + assert {:error, %Expression.Error{message: "expression is not a number: `nil`"}} = Expression.evaluate_block("fixed(value, 2)", %{"value" => nil}) end test "fixed/3 with a non-numeric value returns an error tuple instead of crashing" do - assert {:error, "expression is not a number: `\"not a number\"`"} = + assert {:error, + %Expression.Error{message: "expression is not a number: `\"not a number\"`"}} = Expression.evaluate_block("fixed(value, 2, true)", %{"value" => "not a number"}) end end