feat: boolean filter refactor (#78)

feat: predicate behaviour
This commit is contained in:
Zach Daniel 2020-06-18 22:59:30 -04:00 committed by GitHub
parent 95423869cf
commit 1033677259
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1124 additions and 1628 deletions

View file

@ -117,7 +117,7 @@
## Refactoring Opportunities
#
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 12]},
{Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 13]},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapInto, []},

View file

@ -175,6 +175,15 @@ defmodule Ash do
|> Enum.find(&(&1.name == name))
end
def related(resource, []), do: resource
def related(resource, [path | rest]) do
case relationship(resource, path) do
%{destination: destination} -> related(destination, rest)
nil -> nil
end
end
@doc "The data layer of the resource, or nil if it does not have one"
@spec data_layer(resource()) :: data_layer()
def data_layer(resource) do

View file

@ -3,6 +3,7 @@ defmodule Ash.Actions.Read do
alias Ash.Actions.SideLoad
alias Ash.Engine
alias Ash.Engine.Request
alias Ash.Filter
require Logger
def run(query, _action, opts \\ []) do
@ -40,31 +41,37 @@ defmodule Ash.Actions.Read do
end
defp requests(query, action, opts) do
filter_requests =
if Keyword.has_key?(opts, :actor) || opts[:authorize?] do
Filter.read_requests(query.filter)
else
[]
end
request =
Request.new(
resource: query.resource,
api: query.api,
query: query,
action: action,
data: data_field(opts, query.filter, query.resource, query.data_layer_query),
data: data_field(opts, filter_requests, query.resource, query.data_layer_query),
path: [:data],
name: "#{action.type} - `#{action.name}`"
)
[request | Map.get(query.filter || %{}, :requests, [])]
[request | filter_requests]
end
defp data_field(params, filter, resource, query) do
defp data_field(params, filter_requests, resource, query) do
if params[:initial_data] do
List.wrap(params[:initial_data])
else
Request.resolve(
[[:data, :query]],
Ash.Filter.optional_paths(filter),
fn %{data: %{query: ash_query}} = data ->
fetch_filter = Ash.Filter.request_filter_for_fetch(ash_query.filter, data)
relationship_filter_paths = Enum.map(filter_requests, &[&1.path, :authorization_filter])
with {:ok, query} <- Ash.DataLayer.filter(query, fetch_filter, resource),
Request.resolve(
[[:data, :query] | relationship_filter_paths],
fn %{data: %{query: ash_query}} ->
with {:ok, query} <- Ash.DataLayer.filter(query, ash_query.filter, resource),
{:ok, query} <- Ash.DataLayer.limit(query, ash_query.limit, resource),
{:ok, query} <- Ash.DataLayer.offset(query, ash_query.offset, resource) do
Ash.DataLayer.run_query(query, resource)

View file

@ -170,20 +170,20 @@ defmodule Ash.Actions.Relationships do
) do
relationship_name = relationship.name
filter =
{possible?, filter} =
case identifiers do
[single_identifier] ->
if Keyword.keyword?(single_identifier) do
single_identifier
{true, single_identifier}
else
[single_identifier]
{true, [single_identifier]}
end
[] ->
[__impossible__: true]
{false, []}
many ->
[or: many]
{true, [or: many]}
end
query =
@ -198,10 +198,15 @@ defmodule Ash.Actions.Relationships do
action: Ash.primary_action!(relationship.destination, :read),
query: query,
path: [:relationships, relationship_name, type],
authorize?: possible?,
data:
Request.resolve(fn _data ->
if possible? do
query
|> api.read()
else
[]
end
end),
name: "read prior to write related #{relationship.name}"
)
@ -270,7 +275,7 @@ defmodule Ash.Actions.Relationships do
{:ok, %{replace: identifier}}
{:error, _} ->
{:error, "Relationship change invalid for #{relationship.name} 2"}
{:error, "Relationship change invalid map for #{relationship.name}"}
end
end
@ -283,7 +288,7 @@ defmodule Ash.Actions.Relationships do
{:ok, %{replace: identifiers}}
{:error, _} ->
{:error, "Relationship change invalid for #{relationship.name} 2"}
{:error, "Relationship change invalid list for #{relationship.name}"}
end
end

View file

@ -432,7 +432,7 @@ defmodule Ash.Actions.SideLoad do
root_filter =
case data do
[] ->
[__impossible__: true]
false
[%resource{} = item] ->
item
@ -588,8 +588,8 @@ defmodule Ash.Actions.SideLoad do
end
defp put_nested_relationship(request_filter, path, value, records? \\ true)
defp put_nested_relationship(_, _, [], true), do: [__impossible__: true]
defp put_nested_relationship(_, _, nil, true), do: [__impossible__: true]
defp put_nested_relationship(_, _, [], true), do: false
defp put_nested_relationship(_, _, nil, true), do: false
defp put_nested_relationship(_, _, [], false), do: []
defp put_nested_relationship(_, _, nil, false), do: []

View file

@ -10,12 +10,10 @@ defmodule Ash.DataLayer do
to ensure that the engine only ever tries to interact with the data layer in ways
that it supports.
"""
@type filter_type :: :eq | :in
@type feature() ::
:transact
| :async_engine
| {:filter, filter_type}
| {:filter_related, Ash.relationship_cardinality()}
| {:filter_predicate, struct}
| :upsert
| :composite_primary_key

View file

@ -5,7 +5,8 @@ defmodule Ash.DataLayer.Ets do
This is used for testing. *Do not use this data layer in production*
"""
alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or}
alias Ash.Filter.{Expression, Not, Predicate}
alias Ash.Filter.Predicate.{Eq, In}
@behaviour Ash.DataLayer
@ -40,18 +41,10 @@ defmodule Ash.DataLayer.Ets do
not private?(resource)
end
def can?(_, :transact), do: false
def can?(_, :composite_primary_key), do: true
def can?(_, :upsert), do: true
def can?(_, {:filter, :in}), do: true
def can?(_, {:filter, :not_in}), do: true
def can?(_, {:filter, :not_eq}), do: true
def can?(_, {:filter, :eq}), do: true
def can?(_, {:filter, :and}), do: true
def can?(_, {:filter, :or}), do: true
def can?(_, {:filter, :not}), do: true
def can?(_, {:filter_related, _}), do: true
def can?(_, {:filter_predicate, %In{}}), do: true
def can?(_, {:filter_predicate, %Eq{}}), do: true
def can?(_, _), do: false
@impl true
@ -80,21 +73,13 @@ defmodule Ash.DataLayer.Ets do
{:ok, %{query | sort: sort}}
end
@impl true
def run_query(%Query{filter: %Ash.Filter{impossible?: true}}, _), do: {:ok, []}
@impl true
def run_query(
%Query{resource: resource, filter: filter, offset: offset, limit: limit, sort: sort},
_resource
) do
with {:ok, table} <- wrap_or_create_table(resource),
{:ok, records} <- ETS.Set.to_list(table) do
filtered_records =
records
|> Enum.map(&elem(&1, 1))
|> filter_matches(filter)
with {:ok, records} <- get_records(resource),
filtered_records <- filter_matches(records, filter) do
offset_records =
filtered_records
|> do_sort(sort)
@ -113,110 +98,106 @@ defmodule Ash.DataLayer.Ets do
end
end
defp get_records(resource) do
with {:ok, table} <- wrap_or_create_table(resource),
{:ok, record_tuples} <- ETS.Set.to_list(table) do
{:ok, Enum.map(record_tuples, &elem(&1, 1))}
end
end
defp filter_matches(records, nil), do: records
defp filter_matches(records, filter) do
Enum.filter(records, &matches_filter?(&1, filter))
Enum.filter(records, &matches_filter?(&1, filter.expression))
end
defp matches_filter?(record, %{ands: [first | rest]} = filter) do
matches_filter?(record, first) and matches_filter?(record, %{filter | ands: rest})
end
defp matches_filter?(record, %{not: not_filter} = filter) when not is_nil(not_filter) do
not matches_filter?(record, not_filter) and matches_filter?(record, %{filter | not: nil})
end
defp matches_filter?(record, %{ors: [first | rest]} = filter) do
matches_filter?(record, first) or matches_filter?(record, %{filter | ors: rest})
end
defp matches_filter?(record, %{resource: resource} = filter) do
resource
|> relationships_to_attribute_filters(filter)
|> Map.get(:attributes)
|> Enum.all?(fn {key, predicate} ->
matches_predicate?(Map.get(record, key), predicate)
end)
end
# alias Ash.Filter.{In, NotIn}
defp matches_predicate?(value, %Eq{value: predicate_value}) do
value == predicate_value
end
defp matches_predicate?(value, %NotEq{value: predicate_value}) do
value != predicate_value
end
defp matches_predicate?(value, %In{values: predicate_value}) do
value in predicate_value
end
defp matches_predicate?(value, %NotIn{values: predicate_value}) do
value not in predicate_value
end
defp matches_predicate?(value, %And{left: left, right: right}) do
matches_predicate?(value, left) and matches_predicate?(value, right)
end
defp matches_predicate?(value, %Or{left: left, right: right}) do
matches_predicate?(value, left) or matches_predicate?(value, right)
end
defp relationships_to_attribute_filters(_, %{relationships: relationships} = filter)
when relationships in [nil, %{}] do
filter
end
defp relationships_to_attribute_filters(resource, %{relationships: relationships} = filter) do
Enum.reduce(relationships, filter, fn {rel, related_filter}, filter ->
relationship = Ash.relationship(resource, rel)
{field, parsed_related_filter} = related_ids_filter(relationship, related_filter)
Ash.Filter.add_to_filter(filter, [{field, parsed_related_filter}])
end)
end
defp related_ids_filter(%{type: :many_to_many} = rel, filter) do
destination_query = %Query{
resource: rel.destination,
filter: filter
defp matches_filter?(
record,
%Predicate{
predicate: predicate,
attribute: %{name: name},
relationship_path: []
}
) do
matches_predicate?(record, name, predicate)
end
with {:ok, results} <- run_query(destination_query, rel.destination),
destination_values <- Enum.map(results, &Map.get(&1, rel.destination_field)),
%{errors: []} = through_filter <-
Ash.Filter.parse(
rel.through,
[
{rel.destination_field_on_join_table, [in: destination_values]}
],
filter.api
),
{:ok, join_results} <-
run_query(%Query{resource: rel.through, filter: through_filter}, rel.through) do
{rel.source_field,
[in: Enum.map(join_results, &Map.get(&1, rel.source_field_on_join_table))]}
defp matches_filter?(
record,
%Predicate{
predicate: predicate,
attribute: %{name: name},
relationship_path: path
}
) do
record
|> get_related(path)
|> Enum.any?(&matches_predicate?(&1, name, predicate))
end
defp matches_filter?(record, %Expression{op: :and, left: left, right: right}) do
matches_filter?(record, left) && matches_filter?(record, right)
end
defp matches_filter?(record, %Expression{op: :or, left: left, right: right}) do
matches_filter?(record, left) || matches_filter?(record, right)
end
defp matches_filter?(record, %Not{expression: expression}) do
not matches_filter?(record, expression)
end
defp get_related(record_or_records, []), do: List.wrap(record_or_records)
defp get_related(%resource{} = record, [first | rest]) do
relationship = Ash.relationship(resource, first)
source_value = Map.get(record, relationship.source_field)
related =
if is_nil(Map.get(record, relationship.source_field)) do
[]
else
%{errors: errors} -> {:error, errors}
{:error, error} -> {:error, error}
case Ash.relationship(resource, first) do
%{type: :many_to_many} = relationship ->
{:ok, through_records} = get_records(relationship.through)
{:ok, destination_records} = get_records(relationship.destination)
through_records
|> Enum.reject(&is_nil(Map.get(&1, relationship.destination_field_on_join_table)))
|> Enum.flat_map(fn through_record ->
if Map.get(through_record, relationship.source_field_on_join_table) ==
source_value do
Enum.filter(destination_records, fn destination_record ->
Map.get(through_record, relationship.destination_field_on_join_table) ==
Map.get(destination_record, relationship.destination_field)
end)
else
[]
end
end)
relationship ->
{:ok, destination_records} = get_records(relationship.destination)
Enum.filter(destination_records, fn destination_record ->
Map.get(destination_record, relationship.destination_field) == source_value
end)
end
end
defp related_ids_filter(rel, filter) do
query = %Query{
resource: rel.destination,
filter: filter
}
related
|> List.wrap()
|> Enum.flat_map(&get_related(&1, rest))
end
case run_query(query, rel.destination) do
{:ok, results} ->
{rel.source_field, [in: Enum.map(results, &Map.get(&1, rel.destination_field))]}
defp matches_predicate?(record, field, %Eq{value: predicate_value}) do
Map.fetch(record, field) == {:ok, predicate_value}
end
{:error, error} ->
{:error, error}
defp matches_predicate?(record, field, %In{values: predicate_values}) do
case Map.fetch(record, field) do
{:ok, value} -> value in predicate_values
:error -> false
end
end

View file

@ -30,13 +30,6 @@ defmodule Ash.Engine do
authorize? = opts[:authorize?] || Keyword.has_key?(opts, :actor)
actor = opts[:actor]
requests =
if authorize? do
requests
else
Enum.reject(requests, & &1.skip_unless_authorize?)
end
case Request.validate_requests(requests) do
:ok ->
requests =

View file

@ -45,7 +45,7 @@ defmodule Ash.Engine.Request do
:api,
:query,
:write_to_data?,
:skip_unless_authorize?,
:strict_check_only?,
:verbose?,
:state,
:actor,
@ -106,7 +106,7 @@ defmodule Ash.Engine.Request do
query: query,
api: opts[:api],
name: opts[:name],
skip_unless_authorize?: opts[:skip_unless_authorize?],
strict_check_only?: opts[:strict_check_only?],
state: :strict_check,
actor: opts[:actor],
verbose?: opts[:verbose?] || false,
@ -392,6 +392,8 @@ defmodule Ash.Engine.Request do
end
defp do_strict_check(authorizer, request, notifications \\ []) do
strict_check_only? = request.strict_check_only?
case missing_strict_check_dependencies?(authorizer, request) do
[] ->
case strict_check_authorizer(authorizer, request) do
@ -404,6 +406,9 @@ defmodule Ash.Engine.Request do
|> set_authorizer_state(authorizer, :authorized)
|> try_resolve([request.path ++ [:query]], false, false)
{:filter_and_continue, _, _} when strict_check_only? ->
{:error, "Request must pass strict check"}
{:filter_and_continue, filter, new_authorizer_state} ->
new_request =
request
@ -412,6 +417,9 @@ defmodule Ash.Engine.Request do
{:ok, new_request}
{:continue, _} when strict_check_only? ->
{:error, "Request must pass strict check"}
{:continue, authorizer_state} ->
{:ok, set_authorizer_state(request, authorizer, authorizer_state)}

View file

@ -45,12 +45,21 @@ defmodule Ash.Error do
end
def choose_error(errors) do
[error | _other_errors] = Enum.sort_by(errors, &Map.get(@error_class_indices, &1.class))
[error | other_errors] =
Enum.sort_by(errors, fn error ->
# the second element here sorts errors that are already parent errors
{Map.get(@error_class_indices, error.class),
@error_modules[error.class] != error.__struct__}
end)
parent_error_module = @error_modules[error.class]
if parent_error_module == error.__struct__ do
parent_error_module.exception(errors: (error.errors || []) ++ other_errors)
else
parent_error_module.exception(errors: errors)
end
end
def error_messages(errors) do
errors

View file

@ -11,12 +11,12 @@ defmodule Ash.Error.Filter.InvalidFilterValue do
def class(_), do: :invalid
def message(%{field: field, value: value, filter: filter}) do
"Invalid filter value #{inspect(value)} supplied for #{inspect(field)}#{inspect(filter)}"
def message(%{value: value, filter: filter}) do
"Invalid filter value `#{inspect(value)}` supplied in: `#{inspect(filter)}`"
end
def description(%{field: field, filter: filter, value: value}) do
"Invalid filter value #{inspect(value)} supplied for #{inspect(field)}#{inspect(filter)}"
def description(%{filter: filter, value: value}) do
"Invalid filter value `#{inspect(value)}` supplied in: `#{inspect(filter)}`"
end
def stacktrace(_), do: nil

View file

@ -1,54 +0,0 @@
defmodule Ash.Filter.And do
@moduledoc false
defstruct [:left, :right]
def new(resource, attr_name, attr_type, [first | [last | []]]) do
with {:ok, first} <- Ash.Filter.parse_predicates(resource, first, attr_name, attr_type),
{:ok, right} <- Ash.Filter.parse_predicates(resource, last, attr_name, attr_type) do
if first == right do
{:ok, first}
else
{:ok, %__MODULE__{left: first, right: right}}
end
end
end
def new(resource, attr_name, attr_type, [first | rest]) do
case Ash.Filter.parse_predicates(resource, first, attr_name, attr_type) do
{:ok, first} ->
case new(resource, attr_name, attr_type, rest) do
{:ok, right} when right == first ->
{:ok, right}
{:ok, right} ->
{:ok, %__MODULE__{left: first, right: right}}
{:error, error} ->
{:error, error}
end
{:error, error} ->
{:error, error}
end
end
def new(resource, attr_name, attr_type, {left, right}) do
new(resource, attr_name, attr_type, [left, right])
end
def prebuilt_new(left, right) do
if left == right do
left
else
%__MODULE__{left: left, right: right}
end
end
end
defimpl Inspect, for: Ash.Filter.And do
import Inspect.Algebra
def inspect(%{left: left, right: right}, opts) do
concat([to_doc(left, opts), " and ", to_doc(right, opts)])
end
end

View file

@ -1,30 +0,0 @@
defmodule Ash.Filter.Eq do
@moduledoc false
defstruct [:value]
alias Ash.Error.Filter.InvalidFilterValue
def new(_resource, attr_name, attr_type, value) do
case Ash.Type.cast_input(attr_type, value) do
{:ok, value} ->
{:ok, %__MODULE__{value: value}}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{value: value},
value: value,
field: attr_name
)}
end
end
end
defimpl Inspect, for: Ash.Filter.Eq do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{value: value}, opts) do
concat([attr(opts), " == ", to_doc(value, opts)])
end
end

View file

@ -0,0 +1,44 @@
defmodule Ash.Filter.Expression do
@moduledoc "Represents a boolean expression"
defstruct [:op, :left, :right]
def new(_, nil, nil), do: nil
def new(_, nil, right), do: right
def new(_, left, nil), do: left
def new(op, %__MODULE__{op: left_op} = left, %__MODULE__{op: op} = right) when left_op != op do
%__MODULE__{op: op, left: right, right: left}
end
def new(op, left, right) do
%__MODULE__{op: op, left: left, right: right}
end
end
defimpl Inspect, for: Ash.Filter.Expression do
import Inspect.Algebra
def inspect(
%{left: left, right: right, op: op},
opts
) do
container_type = container_type(opts)
opts = put_container_type(opts, op)
if container_type && op != container_type do
concat(["(", to_doc(left, opts), " ", to_string(op), " ", to_doc(right, opts), ")"])
else
concat([to_doc(left, opts), " ", to_string(op), " ", to_doc(right, opts)])
end
end
defp container_type(opts) do
opts.custom_options[:container_type]
end
defp put_container_type(opts, container_type) do
%{opts | custom_options: Keyword.put(opts.custom_options, :container_type, container_type)}
end
end

File diff suppressed because it is too large Load diff

View file

@ -1,56 +0,0 @@
defmodule Ash.Filter.In do
@moduledoc false
defstruct [:values]
alias Ash.Error.Filter.InvalidFilterValue
alias Ash.Filter.Eq
def new(resource, attr_name, attr_type, [value]) do
Eq.new(resource, attr_name, attr_type, value)
end
def new(_resource, attr_name, attr_type, values) do
casted =
values
|> List.wrap()
|> Enum.reduce({:ok, []}, fn
value, {:ok, casted} ->
case Ash.Type.cast_input(attr_type, value) do
{:ok, value} ->
{:ok, [value | casted]}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{values: values},
value: value,
field: attr_name
)}
end
_, {:error, error} ->
{:error, error}
end)
case casted do
{:error, error} ->
{:error, error}
{:ok, values} ->
{:ok, %__MODULE__{values: values}}
end
|> case do
{:ok, %{values: values} = in_operator} -> {:ok, %{in_operator | values: Enum.uniq(values)}}
{:error, error} -> {:error, error}
end
end
end
defimpl Inspect, for: Ash.Filter.In do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{values: values}, opts) do
concat([attr(opts), " in ", to_doc(values, opts)])
end
end

View file

@ -1,43 +0,0 @@
defmodule Ash.Filter.InspectHelpers do
@moduledoc false
def attr(opts) do
case path(opts) do
path when path in [nil, []] ->
to_string(get_attr(opts))
path ->
Enum.join(path || [], ".") <> "." <> to_string(get_attr(opts))
end
end
def root?(opts) do
path(opts) == nil && get_attr(opts) == nil
end
def put_attr(%{custom_options: custom} = opts, attr) do
%{opts | custom_options: Keyword.put(custom, :attr, attr)}
end
def make_non_root(%{custom_options: custom} = opts) do
new_options = Keyword.put(custom, :path, custom[:path] || [])
%{opts | custom_options: new_options}
end
def add_to_path(%{custom_options: custom} = opts, path_item) do
new_options =
Keyword.update(custom, :path, [to_string(path_item)], fn path ->
[path_item | path]
end)
%{opts | custom_options: new_options}
end
defp get_attr(opts) do
opts.custom_options[:attr]
end
defp path(opts) do
opts.custom_options[:path]
end
end

View file

@ -1,14 +0,0 @@
defmodule Ash.Filter.Merge do
@moduledoc false
alias Ash.Filter.And
def merge(left, right) do
[left, right] = Enum.sort_by([left, right], fn %mod{} -> to_string(mod) end)
do_merge(left, right)
end
defp do_merge(left, right) do
And.prebuilt_new(left, right)
end
end

26
lib/ash/filter/not.ex Normal file
View file

@ -0,0 +1,26 @@
defmodule Ash.Filter.Not do
@moduledoc "Represents the negation of the contained expression"
defstruct [:expression]
alias Ash.Filter.Expression
def new(nil), do: nil
def new(expression) do
%__MODULE__{expression: expression}
end
defimpl Inspect do
import Inspect.Algebra
def inspect(%{expression: expression}, opts) do
case expression do
%Expression{op: :and} ->
concat(["not ", "(", to_doc(expression, opts), ")"])
_ ->
concat(["not ", to_doc(expression, opts)])
end
end
end
end

View file

@ -1,30 +0,0 @@
defmodule Ash.Filter.NotEq do
@moduledoc false
defstruct [:value]
alias Ash.Error.Filter.InvalidFilterValue
def new(_resource, attr_name, attr_type, value) do
case Ash.Type.cast_input(attr_type, value) do
{:ok, value} ->
{:ok, %__MODULE__{value: value}}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{value: value},
value: value,
field: attr_name
)}
end
end
end
defimpl Inspect, for: Ash.Filter.NotEq do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{value: value}, opts) do
concat([attr(opts), " != ", to_doc(value, opts)])
end
end

View file

@ -1,52 +0,0 @@
defmodule Ash.Filter.NotIn do
@moduledoc false
defstruct [:values]
alias Ash.Error.Filter.InvalidFilterValue
alias Ash.Filter.NotEq
def new(resource, attr_name, attr_type, [value]) do
NotEq.new(resource, attr_name, attr_type, value)
end
def new(_resource, attr_name, attr_type, values) do
casted =
values
|> List.wrap()
|> Enum.reduce({:ok, []}, fn
value, {:ok, casted} ->
case Ash.Type.cast_input(attr_type, value) do
{:ok, value} ->
{:ok, [value | casted]}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{values: values},
value: value,
field: attr_name
)}
end
_, {:error, error} ->
{:error, error}
end)
case casted do
{:error, error} ->
{:error, error}
{:ok, values} ->
{:ok, %__MODULE__{values: values}}
end
end
end
defimpl Inspect, for: Ash.Filter.NotIn do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{values: values}, opts) do
concat([attr(opts), " not in ", to_doc(values, opts)])
end
end

View file

@ -1,41 +0,0 @@
defmodule Ash.Filter.Or do
@moduledoc false
defstruct [:left, :right]
def new(resource, attr_name, attr_type, [first | [last | []]]) do
with {:ok, first} <- Ash.Filter.parse_predicates(resource, first, attr_name, attr_type),
{:ok, right} <- Ash.Filter.parse_predicates(resource, last, attr_name, attr_type) do
{:ok, %__MODULE__{left: first, right: right}}
end
end
def new(resource, attr_name, attr_type, [first | rest]) do
case Ash.Filter.parse_predicates(resource, first, attr_name, attr_type) do
{:ok, first} ->
{:ok, %__MODULE__{left: first, right: new(resource, attr_name, attr_type, rest)}}
{:error, error} ->
{:error, error}
end
end
def new(resource, attr_name, attr_type, {left, right}) do
new(resource, attr_name, attr_type, [left, right])
end
def prebuilt_new(left, right) do
if left == right do
left
else
%__MODULE__{left: left, right: right}
end
end
end
defimpl Inspect, for: Ash.Filter.Or do
import Inspect.Algebra
def inspect(%{left: left, right: right}, opts) do
concat([to_doc(left, opts), " or ", to_doc(right, opts)])
end
end

148
lib/ash/filter/predicate.ex Normal file
View file

@ -0,0 +1,148 @@
defmodule Ash.Filter.Predicate do
@moduledoc "Represents a filter predicate"
defstruct [:attribute, :relationship_path, :predicate]
alias Ash.Filter
alias Ash.Filter.{Expression, Not}
@type predicate :: struct
@type comparison ::
:unknown
| :right_excludes_left
| :left_excludes_right
| :right_includes_left
| :left_includes_right
| :mutually_inclusive
# A simplification value for the right term
| {:simplify, term}
| {:simplify, term, term}
@type t :: %__MODULE__{
attribute: Ash.attribute(),
relationship_path: list(atom),
predicate: predicate
}
@callback new(Ash.resource(), Ash.attribute(), term) :: {:ok, struct} | {:error, term}
@callback compare(predicate(), predicate()) :: comparison()
defmacro __using__(_opts) do
quote do
@behaviour Ash.Filter.Predicate
@spec compare(Ash.Filter.Predicate.predicate(), Ash.Filter.Predicate.predicate()) ::
Ash.Filter.Predicate.comparison() | :unknown
def compare(_, _), do: :unknown
defoverridable compare: 2
end
end
@spec compare(predicate(), predicate()) :: comparison
def compare(%__MODULE__{predicate: left} = pred, right) do
case compare(left, right) do
{:simplify, simplification} ->
simplification =
Filter.map(simplification, fn
%struct{} = expr when struct in [__MODULE__, Not, Expression] ->
expr
other ->
wrap_in_predicate(pred, other)
end)
{:simplify, simplification}
other ->
other
end
end
def compare(left, %__MODULE__{predicate: right}), do: compare(left, right)
def compare(left, right) do
if left.__struct__ == right.__struct__ do
with {:right_to_left, :unknown} <- {:right_to_left, left.__struct__.compare(left, right)},
{:left_to_right, :unknown} <- {:left_to_right, right.__struct__.compare(left, right)} do
:mutually_exclusive
else
{:right_to_left, {:simplify, left, _}} -> {:simplify, left}
{:left_to_right, {:simplify, _, right}} -> {:simplify, right}
{_, other} -> other
end
else
with {:right_to_left, :unknown} <- {:right_to_left, left.__struct__.compare(left, right)},
{:right_to_left, :unknown} <- {:right_to_left, right.__struct__.compare(left, right)},
{:left_to_right, :unknown} <- {:left_to_right, right.__struct__.compare(left, right)},
{:left_to_right, :unknown} <- {:left_to_right, left.__struct__.compare(left, right)} do
:mutually_exclusive
else
{:right_to_left, {:simplify, left, _}} -> {:simplify, left}
{:left_to_right, {:simplify, _, right}} -> {:simplify, right}
{_, other} -> other
end
end
end
defp wrap_in_predicate(predicate, %struct{} = other) do
if Ash.implements_behaviour?(struct, Ash.Filter.Predicate) do
%{predicate | predicate: other}
else
other
end
end
def new(resource, attribute, predicate, value, relationship_path) do
case predicate.new(resource, attribute, value) do
{:ok, predicate} ->
if Ash.data_layer_can?(resource, {:filter_predicate, predicate}) do
{:ok,
%__MODULE__{
attribute: attribute,
predicate: predicate,
relationship_path: relationship_path
}}
else
{:error, "Data layer does not support filtering with #{inspect(predicate)}"}
end
{:error, error} ->
{:error, error}
end
end
def add_inspect_path(inspect_opts, field) do
case inspect_opts.custom_options[:relationship_path] do
empty when empty in [nil, []] -> to_string(field)
path -> Enum.join(path, ".") <> "." <> to_string(field)
end
end
defimpl Inspect do
import Inspect.Algebra
def inspect(
%{relationship_path: relationship_path, predicate: predicate},
opts
) do
opts = %{
opts
| syntax_colors: [
atom: :yellow,
binary: :green,
boolean: :pink,
list: :cyan,
map: :magenta,
number: :red,
regex: :violet,
tuple: :white
],
custom_options: Keyword.put(opts.custom_options, :relationship_path, relationship_path)
}
to_doc(predicate, opts)
end
end
end

View file

@ -0,0 +1,41 @@
defmodule Ash.Filter.Predicate.Eq do
@moduledoc false
defstruct [:field, :value]
use Ash.Filter.Predicate
alias Ash.Error.Filter.InvalidFilterValue
alias Ash.Filter.Predicate
def new(_resource, attribute, value) do
case Ash.Type.cast_input(attribute.type, value) do
{:ok, value} ->
{:ok, %__MODULE__{field: attribute.name, value: value}}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{field: attribute.name, value: value},
value: value,
field: attribute.name
)}
end
end
def compare(%__MODULE__{value: value}, %__MODULE__{value: value}), do: :mutually_inclusive
def compare(%__MODULE__{value: _}, %__MODULE__{value: _}), do: :mutually_exclusive
def compare(_, _), do: :unknown
defimpl Inspect do
import Inspect.Algebra
def inspect(predicate, opts) do
concat([
Predicate.add_inspect_path(opts, predicate.field),
" == ",
to_doc(predicate.value, opts)
])
end
end
end

View file

@ -0,0 +1,72 @@
defmodule Ash.Filter.Predicate.In do
@moduledoc false
defstruct [:field, :values]
use Ash.Filter.Predicate
alias Ash.Error.Filter.InvalidFilterValue
alias Ash.Filter.Expression
alias Ash.Filter.Predicate
alias Ash.Filter.Predicate.Eq
def new(_resource, attribute, []),
do: {:ok, %__MODULE__{field: attribute.name, values: MapSet.new([])}}
def new(resource, attribute, [value]), do: {:ok, Eq.new(resource, attribute, value)}
def new(_resource, attribute, values) when is_list(values) do
Enum.reduce_while(values, {:ok, %__MODULE__{field: attribute.name, values: []}}, fn value,
{:ok,
predicate} ->
case Ash.Type.cast_input(attribute.type, value) do
{:ok, casted} ->
{:cont, {:ok, %{predicate | values: [casted | predicate.values]}}}
:error ->
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{field: attribute.name, values: values},
value: value,
field: attribute.name
)}
end
end)
end
def new(_resource, attribute, values) do
{:error,
InvalidFilterValue.exception(
filter: %__MODULE__{field: attribute.name, values: values},
value: values,
field: attribute.name
)}
end
def compare(%__MODULE__{} = left, %__MODULE__{} = right) do
{:simplify, in_to_or_equals(left), in_to_or_equals(right)}
end
def compare(%__MODULE__{} = in_expr, _) do
{:simplify, in_to_or_equals(in_expr)}
end
def compare(_, _), do: :unknown
defp in_to_or_equals(%{field: field, values: values}) do
Enum.reduce(values, nil, fn value, expression ->
Expression.new(:or, expression, %Eq{field: field, value: value})
end)
end
defimpl Inspect do
import Inspect.Algebra
def inspect(predicate, opts) do
concat([
Predicate.add_inspect_path(opts, predicate.field),
" in ",
to_doc(predicate.values, opts)
])
end
end
end

View file

@ -24,6 +24,20 @@ defmodule Ash.Query do
import Inspect.Algebra
def inspect(query, opts) do
opts = %{
opts
| syntax_colors: [
atom: :yellow,
binary: :green,
boolean: :pink,
list: :cyan,
map: :magenta,
number: :red,
regex: :violet,
tuple: :white
]
}
error_doc =
if Enum.empty?(query.errors) do
empty()
@ -59,7 +73,7 @@ defmodule Ash.Query do
{:ok, resource} ->
%__MODULE__{
api: api,
filter: Ash.Filter.parse(resource, [], api),
filter: nil,
resource: resource
}
|> set_data_layer_query()
@ -67,7 +81,7 @@ defmodule Ash.Query do
:error ->
%__MODULE__{
api: api,
filter: Ash.Filter.parse(resource, [], api),
filter: nil,
resource: resource
}
|> add_error(:resource, "does not exist")
@ -230,16 +244,19 @@ defmodule Ash.Query do
new_filter =
case query.filter do
nil ->
filter
{:ok, filter}
existing_filter ->
Ash.Filter.add_to_filter(existing_filter, filter)
end
new_filter.errors
|> Enum.reduce(query, &add_error(&2, :filter, &1))
|> Map.put(:filter, new_filter)
|> set_data_layer_query()
case new_filter do
{:ok, filter} ->
set_data_layer_query(%{query | filter: filter})
{:error, error} ->
add_error(query, :filter, error)
end
end
def filter(query, statement) do
@ -247,46 +264,17 @@ defmodule Ash.Query do
if query.filter do
Ash.Filter.add_to_filter(query.filter, statement)
else
Ash.Filter.parse(query.resource, statement, query.api)
Ash.Filter.parse(query.api, query.resource, statement)
end
filter.errors
|> Enum.reduce(query, &add_error(&2, :filter, &1))
case filter do
{:ok, filter} ->
query
|> Map.put(:filter, filter)
|> set_data_layer_query()
end
def reject(query, statement) when is_list(statement) do
filter(query, not: statement)
end
def reject(query, %Ash.Filter{} = filter) do
case query.filter do
nil ->
new_filter =
query.resource
|> Ash.Filter.parse([], query.api)
|> Map.put(:not, filter)
query
|> Map.put(:filter, new_filter)
|> set_data_layer_query()
existing_filter ->
new_filter_not =
case existing_filter.not do
nil ->
filter
existing_not_filter ->
%{existing_not_filter | ands: [filter | existing_not_filter.ands]}
end
new_filter = %{existing_filter | not: new_filter_not}
query
|> Map.put(:filter, new_filter)
|> set_data_layer_query()
{:error, error} ->
add_error(query, :filter, error)
end
end
@ -349,6 +337,10 @@ defmodule Ash.Query do
end
end
defp maybe_filter(query, %{filter: nil}, _) do
{:ok, query}
end
defp maybe_filter(query, ash_query, opts) do
case Ash.DataLayer.filter(query, ash_query.filter, ash_query.resource) do
{:ok, filtered} ->

View file

@ -52,18 +52,6 @@ defmodule Ash.Type do
value
end
@spec supports_filter?(Ash.resource(), t(), Ash.DataLayer.filter_type(), Ash.data_layer()) ::
boolean
def supports_filter?(resource, type, filter_type, _data_layer) when type in @builtin_names do
Ash.data_layer_can?(resource, {:filter, filter_type}) and
filter_type in @builtins[type][:filters]
end
def supports_filter?(resource, type, filter_type, data_layer) do
Ash.data_layer_can?(resource, {:filter, filter_type}) and
filter_type in type.supported_filter_types(data_layer)
end
@doc """
Determines whether or not this value can be sorted.
"""

View file

@ -1,20 +1,36 @@
defmodule Ash.SatSolver do
@moduledoc false
alias Ash.Filter
alias Ash.Filter.{Expression, Not, Predicate}
def strict_filter_subset(filter, candidate) do
filter_expr = filter_to_expr(filter)
candidate_expr = filter_to_expr(candidate)
case {filter, candidate} do
{%{expression: nil}, %{expression: nil}} ->
true
together = join_expr(filter_expr, candidate_expr, :and)
{%{expression: nil}, _candidate_expr} ->
true
separate = join_expr(negate(filter_expr), candidate_expr, :and)
{_filter_expr, %{expression: nil}} ->
false
case solve_expression(together) do
{filter, candidate} ->
do_strict_filter_subset(filter, candidate)
end
end
defp do_strict_filter_subset(filter, candidate) do
case add_comparisons_and_solve_expression(
Expression.new(:and, filter.expression, candidate.expression)
) do
{:error, :unsatisfiable} ->
false
{:ok, _} ->
case solve_expression(separate) do
case add_comparisons_and_solve_expression(
Expression.new(:and, Not.new(filter.expression), candidate.expression)
) do
{:error, :unsatisfiable} ->
true
@ -24,75 +40,107 @@ defmodule Ash.SatSolver do
end
end
defp negate(nil), do: nil
defp negate(expr), do: {:not, expr}
defp filter_to_expr(nil), do: nil
defp filter_to_expr(%{impossible?: true}), do: false
defp filter_to_expr(false), do: false
defp filter_to_expr(true), do: true
defp filter_to_expr(%Filter{expression: expression}), do: filter_to_expr(expression)
defp filter_to_expr(%Predicate{} = predicate), do: predicate
defp filter_to_expr(%Not{expression: expression}), do: {:not, filter_to_expr(expression)}
defp filter_to_expr(%{
attributes: attributes,
relationships: relationships,
not: not_filter,
ors: ors,
ands: ands,
path: path
}) do
expr =
Enum.reduce(attributes, nil, fn {attr, statement}, expr ->
join_expr(
expr,
tag_statement(statement_to_expr(statement), %{path: path, attr: attr}),
:and
)
defp filter_to_expr(%Expression{op: op, left: left, right: right}) do
{op, filter_to_expr(left), filter_to_expr(right)}
end
def add_comparisons_and_solve_expression(expression) do
all_predicates =
Filter.reduce(expression, [], fn
%Predicate{} = predicate, predicates ->
[predicate | predicates]
_, predicates ->
predicates
end)
expr =
Enum.reduce(relationships, expr, fn {relationship, relationship_filter}, expr ->
join_expr(expr, {relationship, filter_to_expr(relationship_filter)}, :and)
simplified =
Filter.map(expression, fn
%Predicate{} = predicate ->
predicate
|> find_simplification(all_predicates)
|> case do
nil ->
predicate
{:simplify, simplification} ->
simplification
end
other ->
other
end)
expr = join_expr(negate(filter_to_expr(not_filter)), expr, :and)
if simplified == expression do
all_predicates =
Filter.reduce(expression, [], fn
%Predicate{} = predicate, predicates ->
[predicate | predicates]
expr =
Enum.reduce(ors, expr, fn or_filter, expr ->
join_expr(filter_to_expr(or_filter), expr, :or)
_, predicates ->
predicates
end)
|> Enum.uniq()
comparison_expressions =
all_predicates
|> Enum.reduce([], fn predicate, new_expressions ->
all_predicates
|> Enum.filter(fn other_predicate ->
other_predicate != predicate &&
other_predicate.relationship_path == predicate.relationship_path &&
other_predicate.attribute.name == predicate.attribute.name
end)
|> Enum.reduce(new_expressions, fn other_predicate, new_expressions ->
case Predicate.compare(predicate, other_predicate) do
inclusive when inclusive in [:right_includes_left, :mutually_inclusive] ->
[{:not, {:and, {:not, other_predicate}, predicate}} | new_expressions]
exclusive when exclusive in [:right_excludes_left, :mutually_exclusive] ->
[{:not, {:and, other_predicate, predicate}} | new_expressions]
{:simplify, _} ->
# Filter should be fully simplified here
raise "What"
_other ->
# If we can't tell, we assume they are exclusive statements
[{:not, {:and, other_predicate, predicate}} | new_expressions]
end
end)
end)
|> Enum.uniq()
expression = filter_to_expr(expression)
expression =
Enum.reduce(comparison_expressions, expression, fn comparison_expression, expression ->
{:and, comparison_expression, expression}
end)
Enum.reduce(ands, expr, fn and_filter, expr ->
join_expr(filter_to_expr(and_filter), expr, :and)
solve_expression(expression)
else
add_comparisons_and_solve_expression(simplified)
end
end
defp find_simplification(predicate, predicates) do
predicates
|> Enum.find_value(fn other_predicate ->
case Predicate.compare(predicate, other_predicate) do
{:simplify, simplification} -> {:simplify, simplification}
_ -> false
end
end)
end
defp statement_to_expr(%Ash.Filter.NotIn{values: values}) do
{:not, %Ash.Filter.In{values: values}}
end
defp statement_to_expr(%Ash.Filter.NotEq{value: value}) do
{:not, %Ash.Filter.Eq{value: value}}
end
defp statement_to_expr(%Ash.Filter.And{left: left, right: right}) do
{:and, statement_to_expr(left), statement_to_expr(right)}
end
defp statement_to_expr(%Ash.Filter.Or{left: left, right: right}) do
{:or, statement_to_expr(left), statement_to_expr(right)}
end
defp statement_to_expr(statement), do: statement
defp tag_statement({:not, value}, tag), do: {:not, tag_statement(value, tag)}
defp tag_statement({joiner, left_value, right_value}, tag) when joiner in [:and, :or],
do: {joiner, tag_statement(left_value, tag), tag_statement(right_value, tag)}
defp tag_statement(statement, tag), do: {statement, tag}
defp join_expr(nil, right, _joiner), do: right
defp join_expr(left, nil, _joiner), do: left
defp join_expr(left, right, joiner), do: {joiner, left, right}
def solve_expression(expression) do
expression_with_constants = {:and, true, {:and, {:not, false}, expression}}

View file

@ -153,12 +153,16 @@ defmodule Ash.Test.Actions.ReadTest do
end
test "it raises on an error" do
assert_raise(Ash.Error.Invalid, ~r/Invalid filter value 10 supplied for :title == 10/, fn ->
assert_raise(
Ash.Error.Invalid,
~r/Invalid filter value `10` supplied in: `title == 10`/,
fn ->
Post
|> Api.query()
|> Ash.Query.filter(title: 10)
|> Api.read!()
end)
end
)
end
end

View file

@ -2,6 +2,8 @@ defmodule Ash.Test.Filter.FilterTest do
@moduledoc false
use ExUnit.Case, async: true
alias Ash.Filter
defmodule Profile do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
@ -249,4 +251,61 @@ defmodule Ash.Test.Filter.FilterTest do
|> Api.read!()
end
end
describe "filter subset logic" do
test "can detect a filter is a subset of itself" do
filter = Filter.parse!(Api, Post, %{points: 1})
assert Filter.strict_subset_of?(filter, filter)
end
test "can detect a filter is a subset of itself *and* something else" do
filter = Filter.parse!(Api, Post, points: 1)
candidate = Filter.add_to_filter!(filter, title: "Title")
assert Filter.strict_subset_of?(filter, candidate)
end
test "can detect a filter is not a subset of itself *or* something else" do
filter = Filter.parse!(Api, Post, points: 1)
candidate = Filter.add_to_filter!(filter, :or, title: "Title")
refute Filter.strict_subset_of?(filter, candidate)
end
test "can detect a filter is a subset based on a simplification" do
filter = Filter.parse!(Api, Post, points: [in: [1, 2]])
candidate = Filter.parse!(Api, Post, points: 1)
assert Filter.strict_subset_of?(filter, candidate)
end
test "can detect a filter is not a subset based on a simplification" do
filter = Filter.parse!(Api, Post, points: [in: [1, 2]])
candidate = Filter.parse!(Api, Post, points: 3)
refute Filter.strict_subset_of?(filter, candidate)
end
test "can detect a more complicated scenario" do
filter = Filter.parse!(Api, Post, or: [points: [in: [1, 2, 3]], points: 4, points: 5])
candidate = Filter.parse!(Api, Post, or: [points: 1, points: 3, points: 5])
assert Filter.strict_subset_of?(filter, candidate)
end
test "understands unrelated negations" do
filter = Filter.parse!(Api, Post, or: [points: [in: [1, 2, 3]], points: 4, points: 5])
candidate =
Filter.parse!(Api, Post, or: [points: 1, points: 3, points: 5], not: [points: 7])
assert Filter.strict_subset_of?(filter, candidate)
end
end
end