diff --git a/lib/ash/error/query/invalid_filter_reference.ex b/lib/ash/error/query/invalid_filter_reference.ex new file mode 100644 index 00000000..37f317b2 --- /dev/null +++ b/lib/ash/error/query/invalid_filter_reference.ex @@ -0,0 +1,24 @@ +defmodule Ash.Error.Query.InvalidFilterReference do + @moduledoc "Used when an invalid reference is used in a filter" + use Ash.Error.Exception + + def_ash_error([:field, :simple_equality?], class: :invalid) + + defimpl Ash.ErrorKind do + def id(_), do: Ash.UUID.generate() + + def code(_), do: "invalid_filter_reference" + + def class(_), do: :invalid + + def message(%{field: field, simple_equality?: true}) do + "#{field} cannot be referenced in filters, except by simple equality" + end + + def message(%{field: field}) do + "#{field} cannot be referenced in filters" + end + + def stacktrace(_), do: nil + end +end diff --git a/lib/ash/filter/filter.ex b/lib/ash/filter/filter.ex index 047418a2..3e3dc070 100644 --- a/lib/ash/filter/filter.ex +++ b/lib/ash/filter/filter.ex @@ -302,14 +302,18 @@ defmodule Ash.Filter do |> Enum.flat_map(fn ref -> field = ref.attribute - case field.filterable? do - true -> + # This handles manually added calcualtions and aggregates + case Map.fetch(field, :filterable?) do + :error -> [] - false -> + {:ok, true} -> + [] + + {:ok, false} -> [Ash.Error.Query.InvalidFilterReference.exception(field: field.name)] - :simple_equality -> + {:ok, :simple_equality} -> if ref.simple_equality? do [] else @@ -326,7 +330,7 @@ defmodule Ash.Filter do multiple_filter_errors = refs |> Enum.filter(fn ref -> - ref.attribute.filterable? == :simple_equality + Map.fetch(ref.attribute, :filterable?) == {:ok, :simple_equality} end) |> Enum.group_by(& &1.attribute.name) |> Enum.flat_map(fn @@ -1840,7 +1844,8 @@ defmodule Ash.Filter do aggregate.relationship_path, aggregate_query, aggregate.field, - aggregate.default + aggregate.default, + aggregate.filterable? ) do case parse_predicates(nested_statement, query_aggregate, context) do {:ok, nested_statement} -> @@ -1902,7 +1907,8 @@ defmodule Ash.Filter do module, opts, resource_calculation.type, - Map.put(args, :context, context.query_context) + Map.put(args, :context, context.query_context), + resource_calculation.filterable? ) do case parse_predicates(nested_statement, calculation, context) do {:ok, nested_statement} -> @@ -2071,7 +2077,8 @@ defmodule Ash.Filter do module, opts, resource_calculation.type, - Map.put(args, :context, context.query_context) + Map.put(args, :context, context.query_context), + resource_calculation.filterable? ) do {:ok, %Ref{ @@ -2178,7 +2185,8 @@ defmodule Ash.Filter do module, opts, resource_calculation.type, - Map.put(args, :context, context.query_context) + Map.put(args, :context, context.query_context), + resource_calculation.filterable? ) do {:ok, %{ref | attribute: calculation, resource: related}} else @@ -2199,7 +2207,8 @@ defmodule Ash.Filter do aggregate.relationship_path, aggregate_query, aggregate.field, - aggregate.default + aggregate.default, + aggregate.filterable? ) do {:ok, %{ref | attribute: query_aggregate, resource: related}} else diff --git a/lib/ash/query/aggregate.ex b/lib/ash/query/aggregate.ex index d3593926..8d0cc7ca 100644 --- a/lib/ash/query/aggregate.ex +++ b/lib/ash/query/aggregate.ex @@ -10,7 +10,8 @@ defmodule Ash.Query.Aggregate do :kind, :type, :authorization_filter, - :load + :load, + filterable?: true ] @type t :: %__MODULE__{} @@ -27,7 +28,7 @@ defmodule Ash.Query.Aggregate do @doc false def kinds, do: @kinds - def new(resource, name, kind, relationship, query, field, default \\ nil) do + def new(resource, name, kind, relationship, query, field, default \\ nil, filterable? \\ true) do field_type = if field do related = Ash.Resource.Info.related(resource, relationship) @@ -46,7 +47,8 @@ defmodule Ash.Query.Aggregate do field: field, kind: kind, type: type, - query: query + query: query, + filterable?: filterable? }} end end diff --git a/lib/ash/query/calculation.ex b/lib/ash/query/calculation.ex index eee49a32..a12a9e52 100644 --- a/lib/ash/query/calculation.ex +++ b/lib/ash/query/calculation.ex @@ -10,12 +10,13 @@ defmodule Ash.Query.Calculation do context: %{}, select: [], sequence: 0, - allow_async?: false + allow_async?: false, + filterable?: true ] @type t :: %__MODULE__{} - def new(name, module, opts, type, context \\ %{}) do + def new(name, module, opts, type, context \\ %{}, filterable? \\ true) do case module.init(opts) do {:ok, opts} -> {:ok, @@ -24,7 +25,8 @@ defmodule Ash.Query.Calculation do module: module, type: type, opts: opts, - context: context + context: context, + filterable?: filterable? }} {:error, error} -> diff --git a/lib/ash/query/function/get_path.ex b/lib/ash/query/function/get_path.ex new file mode 100644 index 00000000..671d553b --- /dev/null +++ b/lib/ash/query/function/get_path.ex @@ -0,0 +1,83 @@ +defmodule Ash.Query.Function.GetPath do + @moduledoc """ + Gets the value at the provided path in the value, which must be a map or embed. + + If you are using a datalayer that provides a `type` function (like AshPostgres), it is a good idea to + wrap your call in that function, e.g `type(author[:bio][:title], :string)`, since data layers that depend + on knowing types may not be able to infer the type from the path. Ash may eventually be able to figure out + the type, in the case that the path consists of only embedded attributes. + + If an atom key is provided, access is *indiscriminate* of atoms vs strings. The atom key is checked first. + If a string key is provided, that is the only thing that is checked. If the value will or may be a struct, be sure to use atoms. + + The data layer may handle this differently, for example, AshPostgres only checks + strings at the data layer (because thats all it can be in the database anyway). + + Available in query expressions using bracket syntax, e.g `foo[:bar][:baz]`. + """ + use Ash.Query.Function, name: :get_path, predicate?: true, no_inspect?: true + + def args, + do: [ + [:map, {:array, :any}] + ] + + def new([%__MODULE__{arguments: [inner_left, inner_right]} = get_path, right]) + when is_list(inner_right) and is_list(right) do + {:ok, %{get_path | arguments: [inner_left, inner_right ++ right]}} + end + + def new([_, right]) when not (is_list(right) or is_atom(right) or is_binary(right)) do + {:error, "#{inspect(right)} is not a valid path to get"} + end + + def new([left, right]) when not is_list(right) do + new([left, [right]]) + end + + def new([left, right]) do + super([left, right]) + end + + def evaluate(%{arguments: [%{} = obj, path]}) when is_list(path) do + Enum.reduce_while(path, {:known, obj}, fn key, {:known, obj} -> + if is_map(obj) do + value = + if is_atom(key) do + Map.get(obj, key) || Map.get(obj, to_string(key)) + else + Map.get(obj, to_string(key)) + end + + case value do + nil -> + {:halt, {:known, nil}} + + value -> + {:cont, {:known, value}} + end + else + {:halt, :unknown} + end + end) + end + + def evaluate(_), do: :unknown + + defimpl Inspect do + import Inspect.Algebra + + def inspect(%{arguments: [value, path]}, opts) do + path_items = + path + |> Enum.map(fn item -> + concat(["[", to_doc(item, opts), "]"]) + end) + |> concat() + + value + |> to_doc(opts) + |> concat(path_items) + end + end +end diff --git a/lib/ash/query/query.ex b/lib/ash/query/query.ex index f66a79e8..c331917b 100644 --- a/lib/ash/query/query.ex +++ b/lib/ash/query/query.ex @@ -966,7 +966,8 @@ defmodule Ash.Query do aggregate.relationship_path, aggregate_query, aggregate.field, - aggregate.default + aggregate.default, + aggregate.filterable? ) do query_aggregate = %{query_aggregate | load: field} new_aggregates = Map.put(query.aggregates, aggregate.name, query_aggregate) @@ -1379,7 +1380,15 @@ defmodule Ash.Query do atom | list(atom), Keyword.t() | nil ) :: t() - def aggregate(query, name, type, relationship, agg_query \\ nil) do + def aggregate( + query, + name, + type, + relationship, + agg_query \\ nil, + default \\ nil, + filterable? \\ true + ) do {field, agg_query} = Keyword.pop(agg_query || [], :field) query = to_query(query) @@ -1395,7 +1404,16 @@ defmodule Ash.Query do build(Ash.Resource.Info.related(query.resource, relationship), options) end - case Aggregate.new(query.resource, name, type, relationship, agg_query, field) do + case Aggregate.new( + query.resource, + name, + type, + relationship, + agg_query, + field, + default, + filterable? + ) do {:ok, aggregate} -> new_aggregates = Map.put(query.aggregates, aggregate.name, aggregate)