fix: various runtime expression evaluation fixes

the issue is that the expression parser didn't support the fact
that some operators accept `nil` values and other operators do not.
This commit is contained in:
Zach Daniel 2023-12-03 13:42:19 -05:00
parent 0ae14bdf71
commit 08a72acc6b
23 changed files with 476 additions and 336 deletions

View file

@ -1119,7 +1119,8 @@ defmodule Ash.Actions.Read do
{:ok, result} <-
Ash.Expr.eval(
expression,
resource: ash_query.resource
resource: ash_query.resource,
unknown_on_unknown_refs?: true
) do
{in_query,
[
@ -2374,7 +2375,8 @@ defmodule Ash.Actions.Read do
}
end
defp add_calc_context(calc, actor, authorize?, tenant, tracer) do
@doc false
def add_calc_context(calc, actor, authorize?, tenant, tracer) do
%{
calc
| context:
@ -2390,6 +2392,21 @@ defmodule Ash.Actions.Read do
}
end
@doc false
def add_calc_context(calc, map) do
%{
calc
| context:
Map.merge(
Map.take(
map,
[:actor, :authorize?, :tenant, :tracer]
),
calc.context
)
}
end
# TODO: Make more generic?
defp query_unique_for_calc(query) do
query

View file

@ -94,6 +94,7 @@ defmodule Ash.DataLayer do
| :offset
| :transact
| :filter
| :composite_type
| {:lock, lock_type()}
| {:filter_expr, struct}
| {:filter_relationship, Ash.Resource.Relationships.relationship()}

View file

@ -358,6 +358,15 @@ defmodule Ash.DataLayer.Ets do
_resource,
parent \\ nil
) do
used_aggregates =
calculations
|> List.wrap()
|> Enum.flat_map(fn {calc, expr} ->
expr
|> Ash.Filter.used_aggregates(:all)
|> Enum.map(&Ash.Actions.Read.add_calc_context(&1, calc.context))
end)
with {:ok, records} <- get_records(resource, tenant),
{:ok, records} <-
filter_matches(records, filter, api, parent),
@ -366,7 +375,8 @@ defmodule Ash.DataLayer.Ets do
records <- Sort.runtime_sort(records, sort, api: api),
records <- Enum.drop(records, offset || []),
records <- do_limit(records, limit),
{:ok, records} <- do_add_aggregates(records, api, resource, aggregates),
{:ok, records} <-
do_add_aggregates(records, api, resource, aggregates ++ used_aggregates),
{:ok, records} <-
do_add_calculations(records, resource, calculations, api) do
{:ok, records}
@ -596,61 +606,6 @@ defmodule Ash.DataLayer.Ets do
end
end
# def do_add_calculations(records, _resource, [], _api), do: {:ok, records}
# def do_add_calculations(records, resource, calculations, api) do
# Enum.reduce_while(records, {:ok, []}, fn record, {:ok, records} ->
# calculations
# |> IO.inspect()
# |> Enum.reduce_while({:ok, record}, fn {calculation, expression}, {:ok, record} ->
# case Ash.Expr.eval_hydrated(expression, record: record, resource: resource, api: api) do
# {:ok, value} ->
# if calculation.load do
# {:cont, {:ok, Map.put(record, calculation.load, value)}}
# else
# {:cont,
# {:ok,
# Map.update!(
# record,
# :calculations,
# &Map.put(&1, calculation.name, value)
# )}}
# end
# :unknown ->
# if calculation.load do
# {:cont, {:ok, Map.put(record, calculation.load, nil)}}
# else
# {:cont,
# {:ok,
# Map.update!(
# record,
# :calculations,
# &Map.put(&1, calculation.name, nil)
# )}}
# end
# {:error, error} ->
# {:halt, {:error, error}}
# end
# end)
# |> case do
# {:ok, record} ->
# {:cont, {:ok, [record | records]}}
# {:error, error} ->
# {:halt, {:error, error}}
# end
# end)
# |> case do
# {:ok, records} ->
# {:ok, Enum.reverse(records)}
# {:error, error} ->
# {:error, Ash.Error.to_ash_error(error)}
# end
# end
@doc false
def do_add_aggregates(records, _api, _resource, []), do: {:ok, records}
@ -689,7 +644,7 @@ defmodule Ash.DataLayer.Ets do
field = field || Enum.at(Ash.Resource.Info.primary_key(query.resource), 0)
value =
aggregate_value(sorted, kind, field, uniq?, default_value)
aggregate_value(sorted, kind, field, uniq?, default_value) |> IO.inspect()
if load do
{:cont, {:ok, Map.put(record, load, value)}}

View file

@ -231,6 +231,15 @@ defmodule Ash.DataLayer.Mnesia do
},
_resource
) do
used_aggregates =
calculations
|> List.wrap()
|> Enum.flat_map(fn {calc, expr} ->
expr
|> Ash.Filter.used_aggregates(:all)
|> Enum.map(&Ash.Actions.Read.add_calc_context(&1, calc.context))
end)
with {:atomic, records} <-
Mnesia.transaction(fn ->
Mnesia.select(table(resource), [{:_, [], [:"$_"]}])
@ -242,9 +251,19 @@ defmodule Ash.DataLayer.Mnesia do
filtered |> Sort.runtime_sort(sort, api: api) |> Enum.drop(offset || 0),
limited_records <- do_limit(offset_records, limit),
{:ok, records} <-
Ash.DataLayer.Ets.do_add_aggregates(limited_records, api, resource, aggregates),
Ash.DataLayer.Ets.do_add_aggregates(
limited_records,
api,
resource,
aggregates ++ used_aggregates
),
{:ok, records} <-
Ash.DataLayer.Ets.do_add_calculations(records, resource, calculations, api) do
Ash.DataLayer.Ets.do_add_calculations(
records,
resource,
calculations,
api
) do
{:ok, records}
else
{:error, error} ->

View file

@ -45,7 +45,8 @@ defmodule Ash.Expr do
expression,
opts[:parent],
opts[:resource],
opts[:api]
opts[:api],
opts[:unknown_on_unknown_refs?]
)
end

View file

@ -197,7 +197,6 @@ defmodule Ash.Filter do
def builtins, do: @builtins
def builtin_functions, do: @functions
def builtin_operators, do: @operators
def builtin_predicate_operators, do: Enum.filter(@operators, & &1.predicate?())
defmodule Simple do
@moduledoc "Represents a simplified filter, with a simple list of predicates"

View file

@ -13,7 +13,7 @@ defmodule Ash.Filter.Runtime do
layer like `ash_postgres`, certain expressions will behave unpredictably.
"""
alias Ash.Query.{BooleanExpression, Call, Not, Ref}
alias Ash.Query.{BooleanExpression, Not, Ref}
@doc """
Removes any records that don't match the filter. Automatically loads
@ -218,7 +218,14 @@ defmodule Ash.Filter.Runtime do
end
@doc false
def load_and_eval(record, expression, parent \\ nil, resource \\ nil, api \\ nil) do
def load_and_eval(
record,
expression,
parent \\ nil,
resource \\ nil,
api \\ nil,
unknown_on_unknown_refs? \\ false
) do
if api && record do
{refs_to_load, refs} =
expression
@ -261,7 +268,7 @@ defmodule Ash.Filter.Runtime do
|> Enum.map(&path_to_load(resource, &1, refs))
|> case do
[] ->
do_match(record, expression, parent, resource)
do_match(record, expression, parent, resource, unknown_on_unknown_refs?)
need_to_load ->
query =
@ -271,28 +278,40 @@ defmodule Ash.Filter.Runtime do
case api.load(record, query) do
{:ok, loaded} ->
do_match(loaded, expression, parent, resource)
do_match(loaded, expression, parent, resource, unknown_on_unknown_refs?)
other ->
other
end
end
else
do_match(record, expression, parent, resource)
do_match(record, expression, parent, resource, unknown_on_unknown_refs?)
end
end
@doc false
def do_match(record, expression, parent \\ nil, resource \\ nil)
def do_match(
record,
expression,
parent \\ nil,
resource \\ nil,
unknown_on_unknown_refs? \\ false
)
def do_match(record, %Ash.Filter.Simple{predicates: predicates}, parent, resource) do
def do_match(
record,
%Ash.Filter.Simple{predicates: predicates},
parent,
resource,
unknown_on_unknown_refs?
) do
{:ok,
Enum.all?(predicates, fn predicate ->
do_match(record, predicate, parent, resource) == {:ok, true}
do_match(record, predicate, parent, resource, unknown_on_unknown_refs?) == {:ok, true}
end)}
end
def do_match(record, expression, parent, resource) do
def do_match(record, expression, parent, resource, unknown_on_unknown_refs?) do
hydrated =
case record do
%resource{} ->
@ -314,97 +333,21 @@ defmodule Ash.Filter.Runtime do
end
end
case hydrated do
{:ok, expression} ->
case expression do
%Ash.Filter{expression: expression} ->
do_match(record, expression, parent, resource)
%op{__operator__?: true, left: left, right: right} ->
with {:ok, [left, right]} <-
resolve_exprs([left, right], record, parent, resource),
{:op, {:ok, %op{} = new_operator}} <-
{:op, Ash.Query.Operator.try_cast_with_ref(op, left, right)},
{:known, val} <-
op.evaluate(new_operator) do
{:ok, val}
else
{:op, {:error, error}} ->
{:error, error}
{:op, {:ok, expr}} ->
do_match(record, expr, parent, resource)
{:error, error} ->
{:error, error}
:unknown ->
:unknown
_value ->
:unknown
end
%func{__function__?: true, arguments: arguments} = function ->
with {:ok, args} <- resolve_exprs(arguments, record, parent, resource),
{:args, args} when not is_nil(args) <-
{:args, try_cast_arguments(func.args(), args)},
{:known, val} <- func.evaluate(%{function | arguments: args}) do
{:ok, val}
else
{:args, nil} ->
{:error,
"Could not cast function arguments for #{func.name()}/#{Enum.count(arguments)}"}
{:error, error} ->
{:error, error}
:unknown ->
:unknown
_ ->
:unknown
end
%Not{expression: nil} ->
with {:ok, hydrated} <- hydrated do
case resolve_expr(hydrated, record, parent, resource, unknown_on_unknown_refs?) do
:unknown ->
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
%Not{expression: expression} ->
case do_match(record, expression, parent, resource) do
:unknown ->
:unknown
{:ok, value} ->
{:ok, value}
{:ok, nil} ->
{:ok, nil}
{:ok, match?} ->
{:ok, !match?}
{:error, error} ->
{:error, error}
end
%Ash.Query.Exists{} = expr ->
resolve_expr(expr, record, parent, resource)
%Ash.Query.Parent{} = expr ->
resolve_expr(expr, parent, nil, resource)
%BooleanExpression{op: op, left: left, right: right} ->
expression_matches(op, left, right, record, parent)
%Call{} = call ->
raise "Unresolvable filter component: #{inspect(call)}"
%Ref{} = ref ->
resolve_expr(ref, record, parent, resource)
other ->
resolve_expr(other, record, parent, resource)
end
{:error, error} ->
{:error, error}
other ->
other
end
end
end
@ -423,13 +366,18 @@ defmodule Ash.Filter.Runtime do
)
end
defp resolve_exprs(exprs, record, parent, resource) do
defp resolve_exprs(exprs, record, parent, resource, unknown_on_unknown_refs?) do
exprs
|> Enum.reduce_while({:ok, []}, fn expr, {:ok, exprs} ->
case resolve_expr(expr, record, parent, resource) do
{:ok, resolved} -> {:cont, {:ok, [resolved | exprs]}}
{:error, error} -> {:halt, {:error, error}}
:unknown -> {:halt, :unknown}
case resolve_expr(expr, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, resolved} ->
{:cont, {:ok, [resolved | exprs]}}
{:error, error} ->
{:halt, {:error, error}}
:unknown ->
{:halt, :unknown}
end
end)
|> case do
@ -439,8 +387,19 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr({key, value}, record, parent, resource) when is_atom(key) do
case resolve_expr(value, record, parent, resource) do
defp resolve_expr(
%Ash.Filter{expression: expression},
record,
parent,
resource,
unknown_on_unknown_refs?
) do
resolve_expr(expression, record, parent, resource, unknown_on_unknown_refs?)
end
defp resolve_expr({key, value}, record, parent, resource, unknown_on_unknown_refs?)
when is_atom(key) do
case resolve_expr(value, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, resolved} ->
{:ok, {key, resolved}}
@ -449,22 +408,30 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(%Ref{} = ref, record, parent, resource) do
resolve_ref(ref, record, parent, resource)
defp resolve_expr(%Ref{} = ref, record, parent, resource, unknown_on_unknown_refs?) do
resolve_ref(ref, record, parent, resource, unknown_on_unknown_refs?)
end
defp resolve_expr(
%BooleanExpression{op: :and, left: left, right: right},
record,
parent,
resource
resource,
unknown_on_unknown_refs?
) do
with {:ok, left_resolved} <- resolve_expr(left, record, parent, resource),
{:ok, right_resolved} <- resolve_expr(right, record, parent, resource) do
if is_nil(left_resolved) || is_nil(right_resolved) do
{:ok, nil}
else
{:ok, !!left_resolved and !!right_resolved}
with {:ok, left_resolved} <-
resolve_expr(left, record, parent, resource, unknown_on_unknown_refs?),
{:ok, right_resolved} <-
resolve_expr(right, record, parent, resource, unknown_on_unknown_refs?) do
cond do
is_nil(left_resolved) ->
{:ok, nil}
is_nil(right_resolved) ->
{:ok, nil}
true ->
{:ok, !!left_resolved and !!right_resolved}
end
end
end
@ -473,41 +440,67 @@ defmodule Ash.Filter.Runtime do
%BooleanExpression{op: :or, left: left, right: right},
record,
parent,
resource
resource,
unknown_on_unknown_refs?
) do
with {:ok, left_resolved} <- resolve_expr(left, record, parent, resource),
{:ok, right_resolved} <- resolve_expr(right, record, parent, resource) do
if is_nil(left_resolved) || is_nil(right_resolved) do
{:ok, nil}
else
{:ok, !!left_resolved or !!right_resolved}
with {:ok, left_resolved} <-
resolve_expr(left, record, parent, resource, unknown_on_unknown_refs?),
{:ok, right_resolved} <-
resolve_expr(right, record, parent, resource, unknown_on_unknown_refs?) do
cond do
left_resolved ->
{:ok, !!left_resolved}
is_nil(right_resolved) ->
{:ok, right_resolved}
true ->
{:ok, !!right_resolved}
end
end
end
defp resolve_expr(%Not{expression: expression}, record, parent, resource) do
case resolve_expr(expression, record, parent, resource) do
defp resolve_expr(
%Not{expression: expression},
record,
parent,
resource,
unknown_on_unknown_refs?
) do
case resolve_expr(expression, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, nil} -> {:ok, nil}
{:ok, resolved} -> {:ok, !resolved}
other -> other
end
end
defp resolve_expr(%Ash.Query.Parent{expr: expr}, _, parent, resource) do
resolve_expr(expr, parent, nil, resource)
defp resolve_expr(%Ash.Query.Parent{expr: expr}, _, parent, resource, unknown_on_unknown_refs?) do
resolve_expr(expr, parent, nil, resource, unknown_on_unknown_refs?)
end
defp resolve_expr(%Ash.Query.Exists{}, nil, _parent, _resource), do: :unknown
defp resolve_expr(%Ash.Query.Exists{}, nil, _parent, _resource, unknown_on_unknown_refs?) do
if is_nil(unknown_on_unknown_refs?) do
raise "WHAT"
end
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
end
defp resolve_expr(
%Ash.Query.Exists{at_path: [], path: path, expr: expr},
record,
_parent,
resource
resource,
unknown_on_unknown_refs?
) do
record
|> flatten_relationships([path])
|> load_unflattened(path)
|> get_related(path)
|> get_related(path, unknown_on_unknown_refs?)
|> case do
:unknown ->
:unknown
@ -516,7 +509,7 @@ defmodule Ash.Filter.Runtime do
related
|> List.wrap()
|> Enum.reduce_while({:ok, false}, fn related, {:ok, false} ->
case resolve_expr(expr, related, record, resource) do
case resolve_expr(expr, related, record, resource, unknown_on_unknown_refs?) do
{:ok, falsy} when falsy in [nil, false] ->
{:cont, {:ok, false}}
@ -530,10 +523,16 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(%Ash.Query.Exists{at_path: at_path} = exists, record, parent, resource) do
defp resolve_expr(
%Ash.Query.Exists{at_path: at_path} = exists,
record,
parent,
resource,
unknown_on_unknown_refs?
) do
record
|> flatten_relationships([at_path])
|> get_related(at_path)
|> get_related(at_path, unknown_on_unknown_refs?)
|> case do
:unknown ->
:unknown
@ -541,13 +540,22 @@ defmodule Ash.Filter.Runtime do
related ->
related
|> Enum.reduce_while({:ok, false}, fn related, {:ok, false} ->
case resolve_expr(%{exists | at_path: []}, related, parent, resource) do
case resolve_expr(
%{exists | at_path: []},
related,
parent,
resource,
unknown_on_unknown_refs?
) do
{:ok, true} ->
{:halt, {:ok, true}}
{:ok, _} ->
{:cont, {:ok, false}}
:unknown ->
{:halt, :unknown}
other ->
{:halt, other}
end
@ -555,22 +563,33 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(%mod{__predicate__?: _, left: left, right: right}, record, parent, resource) do
with {:ok, [left, right]} <- resolve_exprs([left, right], record, parent, resource),
{:op, {:ok, %mod{} = new_pred}} <-
defp resolve_expr(
%mod{__predicate__?: _, left: left, right: right},
record,
parent,
resource,
unknown_on_unknown_refs?
) do
with {:ok, [left, right]} <-
resolve_exprs([left, right], record, parent, resource, unknown_on_unknown_refs?),
{:op, {:ok, new_pred}} <-
{:op, Ash.Query.Operator.try_cast_with_ref(mod, left, right)},
{:known, val} <- mod.evaluate(new_pred) do
{:known, val} <-
evaluate(new_pred, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, val}
else
{:op, {:error, error}} ->
{:error, error}
{:op, {:ok, expr}} ->
resolve_expr(expr, record, parent, resource)
resolve_expr(expr, record, parent, resource, unknown_on_unknown_refs?)
{:error, error} ->
{:error, error}
{:op, :unknown} ->
:unknown
:unknown ->
:unknown
@ -579,11 +598,18 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(%mod{__predicate__?: _, arguments: args} = pred, record, parent, resource) do
with {:ok, args} <- resolve_exprs(args, record, parent, resource),
defp resolve_expr(
%mod{__predicate__?: _, arguments: args} = pred,
record,
parent,
resource,
unknown_on_unknown_refs?
) do
with {:ok, args} <- resolve_exprs(args, record, parent, resource, unknown_on_unknown_refs?),
{:args, args} when not is_nil(args) <-
{:args, try_cast_arguments(mod.args(), args)},
{:known, val} <- mod.evaluate(%{pred | arguments: args}) do
{:known, val} <-
evaluate(%{pred | arguments: args}, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, val}
else
{:args, nil} ->
@ -600,10 +626,11 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(list, record, parent, resource) when is_list(list) do
defp resolve_expr(list, record, parent, resource, unknown_on_unknown_refs?)
when is_list(list) do
list
|> Enum.reduce_while({:ok, []}, fn item, {:ok, acc} ->
case resolve_expr(item, record, parent, resource) do
case resolve_expr(item, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, result} ->
{:cont, {:ok, [result | acc]}}
@ -620,10 +647,11 @@ defmodule Ash.Filter.Runtime do
end
end
defp resolve_expr(map, record, parent, resource) when is_map(map) and not is_struct(map) do
defp resolve_expr(map, record, parent, resource, unknown_on_unknown_refs?)
when is_map(map) and not is_struct(map) do
Enum.reduce_while(map, {:ok, %{}}, fn {key, value}, {:ok, acc} ->
with {:ok, key} <- resolve_expr(key, record, parent, resource),
{:ok, value} <- resolve_expr(value, record, parent, resource) do
with {:ok, key} <- resolve_expr(key, record, parent, resource, unknown_on_unknown_refs?),
{:ok, value} <- resolve_expr(value, record, parent, resource, unknown_on_unknown_refs?) do
{:cont, {:ok, Map.put(acc, key, value)}}
else
other ->
@ -632,7 +660,7 @@ defmodule Ash.Filter.Runtime do
end)
end
defp resolve_expr(other, _, _, _), do: {:ok, other}
defp resolve_expr(other, _, _, _, _), do: {:ok, other}
defp try_cast_arguments(:var_args, args) do
Enum.map(args, fn _ -> :any end)
@ -659,7 +687,8 @@ defmodule Ash.Filter.Runtime do
},
record,
parent,
resource
resource,
unknown_on_unknown_refs?
) do
if function_exported?(module, :expression, 2) do
expression = module.expression(opts, context)
@ -688,7 +717,7 @@ defmodule Ash.Filter.Runtime do
with {:ok, hydrated} <- hydrated do
hydrated
|> Ash.Filter.prefix_refs(relationship_path)
|> resolve_expr(record, parent, resource)
|> resolve_expr(record, parent, resource, unknown_on_unknown_refs?)
end
else
# We need to rewrite this
@ -704,17 +733,29 @@ defmodule Ash.Filter.Runtime do
{:ok, [result]} ->
{:ok, result}
:unknown when unknown_on_unknown_refs? ->
:unknown
_ ->
{:ok, nil}
end
else
:unknown
if unknown_on_unknown_refs? do
:unknown
else
raise "WHAT"
{:ok, nil}
end
end
end
end
defp resolve_ref(_ref, nil, _, _resource) do
:unknown
defp resolve_ref(_ref, nil, _, _resource, unknown_on_unknown_refs?) do
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
end
defp resolve_ref(
@ -727,12 +768,17 @@ defmodule Ash.Filter.Runtime do
},
record,
_parent,
_resource
_resource,
unknown_on_unknown_refs?
) do
if load do
case Map.get(record, load) do
%Ash.NotLoaded{} ->
:unknown
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
other ->
{:ok, other}
@ -743,7 +789,11 @@ defmodule Ash.Filter.Runtime do
{:ok, value}
:error ->
:unknown
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
end
end
end
@ -752,7 +802,8 @@ defmodule Ash.Filter.Runtime do
%Ref{attribute: attribute, relationship_path: path},
record,
_parent,
_resource
_resource,
unknown_on_unknown_refs?
) do
name =
case attribute do
@ -761,62 +812,56 @@ defmodule Ash.Filter.Runtime do
end
record
|> get_related(path)
|> get_related(path, unknown_on_unknown_refs?)
|> case do
:unknown ->
:unknown
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
[] ->
{:ok, nil}
[%struct{} = record] ->
[%struct{} = record | _] ->
if Spark.Dsl.is?(struct, Ash.Resource) do
if Ash.Resource.Info.attribute(struct, name) do
if Ash.Resource.selected?(record, name) do
{:ok, Map.get(record, name)}
else
:unknown
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
end
else
if Ash.Resource.loaded?(record, name) do
{:ok, Map.get(record, name)}
else
:unknown
if unknown_on_unknown_refs? do
:unknown
else
{:ok, nil}
end
end
end
else
{:ok, Map.get(record, name)}
end
[record] ->
[record | _] ->
{:ok, Map.get(record, name)}
%struct{} = record ->
if Spark.Dsl.is?(struct, Ash.Resource) do
if Ash.Resource.Info.attribute(struct, name) do
if Ash.Resource.selected?(record, name) do
{:ok, Map.get(record, name)}
else
:unknown
end
else
if Ash.Resource.loaded?(record, name) do
{:ok, Map.get(record, name)}
else
:unknown
end
end
else
{:ok, Map.get(record, name)}
end
record ->
{:ok, Map.get(record, name)}
_ ->
{:ok, nil}
end
|> or_default(attribute)
end
defp resolve_ref(_value, _record, _, _), do: :unknown
defp resolve_ref(_value, _record, _, _, true), do: :unknown
defp resolve_ref(_value, _record, _, _, _), do: {:ok, nil}
defp or_default({:ok, nil}, %Ash.Resource.Aggregate{default: default})
when not is_nil(default) do
@ -887,77 +932,39 @@ defmodule Ash.Filter.Runtime do
{first, [path_to_load(related, rest, further_refs)] ++ to_load}
end
defp expression_matches(:and, left, right, record, parent) do
case do_match(record, left, parent) do
{:ok, false} ->
{:ok, false}
{:ok, nil} ->
{:ok, nil}
{:ok, true} ->
case do_match(record, right, parent) do
{:ok, false} ->
{:ok, false}
{:ok, nil} ->
{:ok, nil}
{:ok, _} ->
{:ok, true}
:unknown ->
:unknown
end
:unknown ->
:unknown
end
end
defp expression_matches(:or, left, right, record, parent) do
case do_match(record, left, parent) do
{:ok, falsy} when falsy in [nil, false] ->
case do_match(record, right, parent) do
{:ok, falsy} when falsy in [nil, false] ->
{:ok, false}
{:ok, _} ->
{:ok, true}
:unknown ->
:unknown
end
{:ok, _} ->
{:ok, true}
:unknown ->
:unknown
end
end
@doc false
def get_related(nil, _), do: []
def get_related(source, path, unknown_on_unknown_refs? \\ false)
def get_related(%Ash.NotLoaded{}, []) do
:unknown
def get_related(nil, _, unknown_on_unknown_refs?) do
if unknown_on_unknown_refs? do
:unknown
else
[]
end
end
def get_related(record, []) do
record
def get_related(%Ash.NotLoaded{}, [], unknown_on_unknown_refs?) do
if unknown_on_unknown_refs? do
:unknown
else
[]
end
end
def get_related(records, paths) when is_list(records) do
def get_related(record, [], _) do
List.wrap(record)
end
def get_related(records, paths, unknown_on_unknown_refs?) when is_list(records) do
records
|> Enum.reduce_while([], fn
:unknown, _records ->
{:halt, :unknown}
:unknown, records ->
{:cont, records}
record, records ->
case get_related(record, paths) do
case get_related(record, paths, unknown_on_unknown_refs?) do
:unknown ->
{:halt, :unknown}
{:cont, records}
related ->
{:cont, [related | records]}
@ -974,13 +981,13 @@ defmodule Ash.Filter.Runtime do
end
end
def get_related(record, [key | rest]) do
def get_related(record, [key | rest], unknown_on_unknown_refs?) do
case Map.get(record, key) do
nil ->
[]
value ->
case get_related(value, rest) do
case get_related(value, rest, unknown_on_unknown_refs?) do
:unknown ->
:unknown
@ -992,4 +999,29 @@ defmodule Ash.Filter.Runtime do
defp parent_stack(nil), do: []
defp parent_stack(%resource{}), do: [resource]
defp evaluate(
%{__function__?: true} = func,
_record,
_parent,
_resource,
_unknown_on_unknown_refs?
),
do: Ash.Query.Function.evaluate(func)
defp evaluate(
%{__operator__?: true} = op,
_record,
_parent,
_resource,
_unknown_on_unknown_refs?
),
do: Ash.Query.Operator.evaluate(op)
defp evaluate(other, record, parent, resource, unknown_on_unknown_refs?) do
case resolve_expr(other, record, parent, resource, unknown_on_unknown_refs?) do
{:ok, value} -> {:known, value}
other -> other
end
end
end

View file

@ -89,7 +89,10 @@ defmodule Ash.Policy.FilterCheck do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: query.resource)
Ash.Expr.eval_hydrated(hydrated,
resource: query.resource,
unknown_on_unknown_refs?: true
)
{:error, error} ->
{:halt, {:error, error}}
@ -107,7 +110,7 @@ defmodule Ash.Policy.FilterCheck do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: resource)
Ash.Expr.eval_hydrated(hydrated, resource: resource, unknown_on_unknown_refs?: true)
{:error, error} ->
{:error, error}
@ -135,7 +138,11 @@ defmodule Ash.Policy.FilterCheck do
nil
end
Ash.Expr.eval_hydrated(hydrated, record: data, resource: resource)
Ash.Expr.eval_hydrated(hydrated,
record: data,
resource: resource,
unknown_on_unknown_refs?: true
)
{:error, error} ->
{:halt, {:error, error}}
@ -150,7 +157,11 @@ defmodule Ash.Policy.FilterCheck do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: resource, resource: resource)
Ash.Expr.eval_hydrated(hydrated,
resource: resource,
resource: resource,
unknown_on_unknown_refs?: true
)
{:error, error} ->
{:halt, {:error, error}}

View file

@ -74,7 +74,7 @@ defmodule Ash.Policy.FilterCheckWithContext do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: resource)
Ash.Expr.eval_hydrated(hydrated, resource: resource, unknown_on_unknown_refs?: true)
{:error, error} ->
{:error, error}
@ -92,7 +92,7 @@ defmodule Ash.Policy.FilterCheckWithContext do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: resource)
Ash.Expr.eval_hydrated(hydrated, resource: resource, unknown_on_unknown_refs?: true)
{:error, error} ->
{:error, error}
@ -120,7 +120,11 @@ defmodule Ash.Policy.FilterCheckWithContext do
nil
end
Ash.Expr.eval_hydrated(hydrated, record: data, resource: resource)
Ash.Expr.eval_hydrated(hydrated,
record: data,
resource: resource,
unknown_on_unknown_refs?: true
)
{:error, error} ->
{:error, error}
@ -135,7 +139,7 @@ defmodule Ash.Policy.FilterCheckWithContext do
public?: false
}) do
{:ok, hydrated} ->
Ash.Expr.eval_hydrated(hydrated, resource: resource)
Ash.Expr.eval_hydrated(hydrated, resource: resource, unknown_on_unknown_refs?: true)
{:error, error} ->
{:error, error}

View file

@ -13,13 +13,31 @@ defmodule Ash.Query.Function do
The number and types of arguments supported.
"""
@callback args() :: [arg]
@callback name() :: atom
@callback new(list(term)) :: {:ok, term} | {:error, String.t() | Exception.t()}
@callback evaluate(func :: map) :: :unknown | {:known, term}
@callback partial_evaluate(func) :: func when func: map
@callback eager_evaluate?() :: boolean()
@callback private?() :: boolean
@doc """
If `true`, will be allowed to evaluate `nil` inputs.
If `false` (the default), any `nil` inputs will cause a `nil` return.
"""
@callback evaluate_nil_inputs?() :: boolean()
@optional_callbacks partial_evaluate: 1
@doc "Evaluate the operator with provided inputs"
def evaluate(%mod{arguments: arguments} = func) do
if Enum.any?(arguments, &is_nil/1) && !mod.evaluate_nil_inputs?() do
{:known, nil}
else
mod.evaluate(func)
end
end
def new(mod, args) do
args = List.wrap(args)
@ -163,7 +181,10 @@ defmodule Ash.Query.Function do
defmacro __using__(opts) do
quote do
@behaviour Ash.Filter.Predicate
@behaviour Ash.Query.Function
if unquote(opts[:predicate?] || false) do
@behaviour Ash.Filter.Predicate
end
alias Ash.Query.Ref
@ -175,19 +196,25 @@ defmodule Ash.Query.Function do
__predicate__?: unquote(opts[:predicate?] || false)
]
@impl Ash.Query.Function
def name, do: unquote(opts[:name])
@impl Ash.Query.Function
def new(args), do: {:ok, struct(__MODULE__, arguments: args)}
@impl Ash.Query.Function
def evaluate(_), do: :unknown
def predicate?, do: unquote(opts[:predicate?] || false)
@impl Ash.Query.Function
def eager_evaluate?, do: unquote(Keyword.get(opts, :eager_evaluate?, true))
@impl Ash.Query.Function
def evaluate_nil_inputs?, do: false
@impl Ash.Query.Function
def private?, do: false
defoverridable new: 1, evaluate: 1, private?: 0
defoverridable new: 1, evaluate: 1, private?: 0, evaluate_nil_inputs?: 0
unless unquote(opts[:no_inspect?]) do
defimpl Inspect do

View file

@ -7,6 +7,8 @@ defmodule Ash.Query.Function.If do
def args, do: [[:boolean, :any], [:boolean, :any, :any]]
def evaluate_nil_inputs?, do: true
def new([condition, block]) do
args =
if Keyword.keyword?(block) && Keyword.has_key?(block, :do) do
@ -24,6 +26,7 @@ defmodule Ash.Query.Function.If do
def new([true, block, _else_block]), do: {:ok, block}
def new([false, _block, else_block]), do: {:ok, else_block}
def new([nil, _block, else_block]), do: {:ok, else_block}
def new([condition, block, else_block]) do
super([condition, block, else_block])

View file

@ -6,6 +6,8 @@ defmodule Ash.Query.Function.IsNil do
def args, do: [[:any]]
def evaluate_nil_inputs?, do: true
def new([arg]) do
Ash.Query.Operator.new(Ash.Query.Operator.IsNil, arg, true)
end

View file

@ -55,6 +55,7 @@ defmodule Ash.Query.Operator.Basic do
types: unquote(opts[:types] || [:same, :any])
if unquote(opts[:no_nils]) do
@impl Ash.Query.Operator
def evaluate(%{left: left, right: right}) do
if is_nil(left) || is_nil(right) do
{:known, nil}
@ -64,12 +65,19 @@ defmodule Ash.Query.Operator.Basic do
do_evaluate(unquote(opts[:symbol]), left, right)
end
end
@impl Ash.Query.Operator
def evaluate_nil_inputs?, do: false
else
@impl Ash.Query.Operator
def evaluate(%{left: left, right: right}) do
# delegate to function to avoid dialyzer warning
# that this can only ever be one value (for each module we define)
do_evaluate(unquote(opts[:symbol]), left, right)
end
@impl Ash.Query.Operator
def evaluate_nil_inputs?, do: true
end
defp do_evaluate(:<>, %Ash.CiString{string: left}, %Ash.CiString{string: right}) do

View file

@ -24,6 +24,7 @@ defmodule Ash.Query.Operator.Eq do
{:known, Comp.equal?(left, right)}
end
@impl Ash.Filter.Predicate
def bulk_compare(predicates) do
predicates
|> Enum.filter(&match?(%struct{} when struct == __MODULE__, &1))

View file

@ -17,6 +17,7 @@ defmodule Ash.Query.Operator.GreaterThan do
{:known, Comp.greater_than?(left, right)}
end
@impl Ash.Filter.Predicate
def simplify(%__MODULE__{left: %Ref{} = ref, right: %Date{} = value}) do
{:ok, op} = Ash.Query.Operator.new(Ash.Query.Operator.LessThan, ref, Date.add(value, 1))

View file

@ -16,6 +16,7 @@ defmodule Ash.Query.Operator.GreaterThanOrEqual do
def evaluate(%{left: left, right: right}),
do: {:known, Comp.greater_or_equal?(left, right)}
@impl Ash.Filter.Predicate
def simplify(%__MODULE__{left: %Ref{} = ref, right: value}) do
{:ok, op} = Ash.Query.Operator.new(Ash.Query.Operator.LessThan, ref, value)

View file

@ -28,6 +28,7 @@ defmodule Ash.Query.Operator.In do
{:known, Enum.any?(right, &Comp.equal?(&1, left))}
end
@impl Ash.Filter.Predicate
def compare(%__MODULE__{left: left, right: %MapSet{} = left_right}, %__MODULE__{
left: left,
right: %MapSet{} = right_right

View file

@ -17,6 +17,10 @@ defmodule Ash.Query.Operator.IsNil do
super(left, right)
end
@impl Ash.Query.Operator
def evaluate_nil_inputs?, do: true
@impl Ash.Query.Operator
def evaluate(%{right: nil}), do: {:known, nil}
def evaluate(%{left: left, right: is_nil?}) do
@ -26,19 +30,30 @@ defmodule Ash.Query.Operator.IsNil do
def to_string(%{left: left, right: right}, opts) do
import Inspect.Algebra
text =
if right do
" is nil"
else
" is not nil"
end
cond do
right == true ->
concat([
to_doc(left, opts),
" is nil"
])
concat([
to_doc(left, opts),
text
])
right == false ->
concat([
to_doc(left, opts),
" is not nil"
])
true ->
concat([
" is_nil(",
to_doc(left, opts),
") == ",
to_doc(right, opts)
])
end
end
@impl Ash.Filter.Predicate
def compare(%__MODULE__{left: %Ref{} = same_ref, right: true}, %Ash.Query.Operator.Eq{
left: %Ref{} = same_ref,
right: nil

View file

@ -28,6 +28,7 @@ defmodule Ash.Query.Operator.LessThan do
{:known, Comp.less_than?(left, right)}
end
@impl Ash.Filter.Predicate
def bulk_compare(all_predicates) do
all_predicates
|> Enum.group_by(& &1.left)

View file

@ -17,6 +17,7 @@ defmodule Ash.Query.Operator.LessThanOrEqual do
{:known, Comp.less_or_equal?(left, right)}
end
@impl Ash.Filter.Predicate
def simplify(%__MODULE__{left: %Ref{} = same_ref, right: %Date{} = value}) do
{:ok, op} = Ash.Query.Operator.new(Ash.Query.Operator.LessThan, same_ref, Date.add(value, 1))

View file

@ -20,6 +20,7 @@ defmodule Ash.Query.Operator.NotEq do
{:known, Comp.not_equal?(left, right)}
end
@impl Ash.Filter.Predicate
def simplify(%__MODULE__{left: left, right: right}) do
%Not{expression: %Eq{left: left, right: right}}
end

View file

@ -27,6 +27,38 @@ defmodule Ash.Query.Operator do
"""
@callback to_string(struct, Inspect.Opts.t()) :: term
@doc """
Evaluates the operator in Elixir
"""
@callback evaluate(term) :: term
@doc """
If `true`, will be allowed to evaluate `nil` inputs.
If `false` (the default), any `nil` inputs will cause a `nil` return.
"""
@callback evaluate_nil_inputs?() :: boolean()
@doc """
The types accepted by the operator. Defaults to `[:same, :any]`, which is any values of the same type.
"""
@callback types() :: [
:any | :same | [Ash.Type.t() | {Ash.Type.t(), constraints :: Keyword.t()}]
]
@doc "Evaluate the operator with provided inputs"
def evaluate(%mod{left: left, right: right} = op) when is_nil(left) or is_nil(right) do
if mod.evaluate_nil_inputs?() do
mod.evaluate(op)
else
{:known, nil}
end
end
def evaluate(%mod{} = op) do
mod.evaluate(op)
end
@doc "Create a new operator. Pass the module and the left and right values"
def new(mod, %Ref{} = left, right) do
try_cast_with_ref(mod, left, right)
@ -289,23 +321,26 @@ defmodule Ash.Query.Operator do
@behaviour Ash.Filter.Predicate
end
@behaviour Ash.Query.Operator
alias Ash.Query.Ref
import Inspect.Algebra
def operator, do: unquote(opts[:operator])
def name, do: unquote(opts[:name] || opts[:operator])
def predicate? do
unquote(opts[:predicate?])
end
@impl Ash.Query.Operator
def types do
unquote(opts[:types] || [:same, :any])
end
@impl Ash.Query.Operator
def new(left, right), do: {:ok, struct(__MODULE__, left: left, right: right)}
import Inspect.Algebra
@impl Ash.Query.Operator
def evaluate_nil_inputs?, do: false
@impl Ash.Query.Operator
def to_string(%{left: left, right: right, operator: operator}, opts) do
concat([
to_doc(left, opts),
@ -316,7 +351,7 @@ defmodule Ash.Query.Operator do
])
end
defoverridable to_string: 2, new: 2
defoverridable to_string: 2, new: 2, evaluate_nil_inputs?: 0
defimpl Inspect do
def inspect(%mod{} = op, opts) do

View file

@ -39,7 +39,11 @@ defmodule Ash.Resource.Calculation.Expression do
public?: false
}) do
{:ok, expression} ->
case Ash.Expr.eval_hydrated(expression, record: record, resource: resource) do
case Ash.Expr.eval_hydrated(expression,
record: record,
resource: resource,
unknown_on_unknown_refs?: true
) do
{:ok, value} ->
value = try_cast_stored(value, context[:ash][:type], context[:ash][:constraints])
{:cont, {:ok, [value | values]}}