This commit is contained in:
Zach Daniel 2019-12-19 23:19:34 -05:00
parent 2f85a1385f
commit fc259c9012
No known key found for this signature in database
GPG key ID: A57053A671EE649E
19 changed files with 451 additions and 344 deletions

View file

@ -112,4 +112,6 @@ end
* Factor out shared relationship options into its own schema, and merge them, for clearer docs.
* Consider making a "params builder" so you can say things like `Ash.Params.add_side_load(params, [:foo, :bar, :baz])` and build params up over time.
* validate using composite primary keys using the `data_layer.can?(:composite_primary_key)`
* Think hard about the data_layer.can? pattern to make sure we're giving enough info, but not too much.
* Think hard about the data_layer.can? pattern to make sure we're giving enough info, but not too much.
* Use the sat solver at compile time to tell people when requests they've configured (and maybe all combinations of includes they've allowed?) couldn't possibly be allowed together.
* Support arbitrary "through" relationships

View file

@ -68,13 +68,16 @@ defmodule Ash.Actions.ChangesetHelpers do
{:ok, filter} ->
before_change(changeset, fn changeset ->
case api.get(destination, filter, authorize?: authorize?, user: user) do
{:ok, record} ->
{:ok, record} when not is_nil(record) ->
changeset
|> Ecto.Changeset.put_change(source_field, Map.get(record, destination_field))
|> after_change(fn _changeset, result ->
{:ok, Map.put(result, relationship.name, record)}
end)
{:ok, nil} ->
{:error, "not found"}
{:error, error} ->
{:error, error}
end
@ -178,7 +181,7 @@ defmodule Ash.Actions.ChangesetHelpers do
end
end
defp relate_items(api, to_relate, destination_field, destination_field_value, authorize?, user) do
defp relate_items(api, to_relate, _destination_field, destination_field_value, authorize?, user) do
Enum.reduce(to_relate, {:ok, []}, fn
to_be_related, {:ok, now_related} ->
case api.update(to_be_related,

View file

@ -1,6 +1,5 @@
defmodule Ash.Actions.Create do
alias Ash.Authorization.Authorizer
alias Ash.Actions.SideLoad
alias Ash.Actions.ChangesetHelpers
@spec run(Ash.api(), Ash.resource(), Ash.action(), Ash.params()) ::
@ -32,7 +31,7 @@ defmodule Ash.Actions.Create do
auth_request =
Ash.Authorization.Request.new(
resource: resource,
rules: action.rules,
authorization_steps: action.authorization_steps,
changeset: changeset
)

View file

@ -8,6 +8,14 @@ defmodule Ash.Actions.Read do
user = Keyword.get(params, :user, [])
side_loads = Keyword.get(params, :side_load, [])
# * We need to be able to perform relationship filters when there is no way to perform a join.
# * There will be unpredictable crossover between what data is fetched during authorization
# and the joins/relationship filters applied.
# * Thus: The first pass will be done without this cache, and as such will not have good
# performance characteristics.
# * The addition of a cache that keys on filters, and compares filters to determine if the data
# has been cached, should improve those performance characteristics significantly.
with %Ash.Filter{errors: []} = filter <-
Ash.Filter.parse(resource, filter),
{:ok, side_load_auths} <- SideLoad.process(resource, side_loads, filter),
@ -32,7 +40,11 @@ defmodule Ash.Actions.Read do
defp do_authorize(params, action, user, resource, filter, side_load_auths) do
if Keyword.get(params, :authorize?, false) do
auth_request =
Ash.Authorization.Request.new(resource: resource, rules: action.rules, filter: filter)
Ash.Authorization.Request.new(
resource: resource,
authorization_steps: action.rules,
filter: filter
)
Authorizer.authorize(user, [auth_request | side_load_auths])
else

View file

@ -1,6 +1,5 @@
defmodule Ash.Actions.Update do
alias Ash.Authorization.Authorizer
alias Ash.Actions.SideLoad
alias Ash.Actions.ChangesetHelpers
@spec run(Ash.api(), Ash.record(), Ash.action(), Ash.params()) ::
@ -38,7 +37,7 @@ defmodule Ash.Actions.Update do
auth_request =
Ash.Authorization.Request.new(
resource: resource,
rules: action.rules,
authorization_steps: action.authorization_steps,
changeset: changeset
)

View file

@ -168,7 +168,7 @@ defmodule Ash.Api.Interface do
raise Ash.Error.FrameworkError.exception(message: error)
end
defp unwrap_or_raise!({:error, %Ecto.Changeset{} = cs}) do
defp unwrap_or_raise!({:error, %Ecto.Changeset{}}) do
raise(Ash.Error.FrameworkError, message: "invalid changes")
end

View file

@ -1,187 +1,7 @@
defmodule Ash.Authorization.Authorizer do
alias Ash.Authorization.Rule
@type result :: :authorized | :forbidden | :undecided
def authorize(user, requests) do
def authorize(_user, _requests) do
:authorized
end
# def run_precheck(_, _) do
# :authorized
# end
# ######
# def authorize(user, :none, context, callback) do
# callback.(fn _user, _data, _rules, _context ->
# :authorized
# end)
# end
# def authorize(user, rules, context, callback) do
# case authorize_precheck(user, rules, context) do
# {%{prediction: :unknown}, per_check_data} ->
# callback.(fn user, data, rules, context ->
# do_authorize(user, data, rules, context, per_check_data)
# end)
# end
# end
# defp authorize_precheck(user, rules, context) do
# rules
# |> Enum.reduce({%{}, []}, fn rule, {instructions, per_check_data} ->
# {instructions, check_data} =
# rule
# |> precheck_result(user, context)
# |> List.wrap()
# |> Enum.reduce({instructions, %{}}, &handle_precheck_result/2)
# {instructions, [check_data | per_check_data]}
# end)
# |> predict_result(rules)
# end
# # Never call authorize w/o first calling authorize_precheck before
# # the operation
# defp do_authorize(user, data, rules, context, per_check_data) do
# {_decision, remaining_records} =
# rules
# |> Enum.zip(per_check_data)
# |> Enum.reduce({:undecided, data}, fn
# {rule, per_check_data}, {:undecided, data} ->
# rule_with_per_check_data =
# case per_check_data do
# %{decision: value} ->
# %{rule | check: fn _, _, _ -> value end}
# _ ->
# rule
# end
# full_context = Map.merge(context, Map.get(per_check_data, :context) || %{})
# checked_records = run_check(rule_with_per_check_data, user, data, full_context)
# if Enum.any?(checked_records, &(&1.__authorization_decision__ == :forbidden)) do
# {:forbiden, data}
# else
# remaining_records =
# Enum.reject(checked_records, &(&1.__authorization_decision__ == :authorized))
# if Enum.empty?(remaining_records) do
# {:allow, []}
# else
# {:undecided, remaining_records}
# end
# end
# _, {decision, data} ->
# {decision, data}
# end)
# if Enum.empty?(remaining_records) do
# :authorized
# else
# # Return some kind of information here?
# # Maybe full auth breakdown in dev envs?
# {:forbidden, nil}
# end
# end
# defp run_check(
# %{check: check, kind: kind},
# user,
# data,
# context
# ) do
# check_function =
# case check do
# {module, function, args} ->
# fn user, data, context ->
# apply(module, function, [user, data, context] ++ args)
# end
# function ->
# function
# end
# result = check_function.(user, data, context)
# Enum.map(data, fn item ->
# result =
# case result do
# true -> true
# false -> false
# ids -> item.id in ids
# end
# decision = Rule.result_to_decision(kind, result)
# Map.put(item, :__authorization_decision__, decision)
# end)
# end
# defp predict_result({instructions, per_check_data}, rules) do
# prediction = get_prediction(Enum.zip(rules, per_check_data))
# {Map.put(instructions, :prediction, prediction), per_check_data}
# end
# defp get_prediction([]), do: :unknown
# defp get_prediction([{rule, %{decision: value}} | rest]) do
# case Rule.result_to_decision(rule.kind, value) do
# :authorized -> :authorized
# :forbidden -> :forbidden
# :undecided -> get_prediction(rest)
# end
# end
# defp get_prediction([{rule, _} | rest]) do
# result_if_true = Rule.result_to_decision(rule.kind, true)
# result_if_false = Rule.result_to_decision(rule.kind, false)
# if result_if_true != :authorized and result_if_false != :authorized do
# :forbidden
# else
# get_prediction(rest)
# end
# end
# defp handle_precheck_result(nil, instructions_and_data), do: instructions_and_data
# defp handle_precheck_result(:ok, instructions_and_data), do: instructions_and_data
# defp handle_precheck_result({:context, context}, {instructions, data}) do
# {instructions, Map.update(data, :context, context, &Map.merge(&1, context))}
# end
# defp handle_precheck_result({:precheck, boolean}, {instructions, data})
# when is_boolean(boolean) do
# {instructions, Map.put(data, :precheck, boolean)}
# end
# defp handle_precheck_result({:side_load, relationship}, {instructions, data}) do
# new_instructions =
# instructions
# |> Map.put_new(:side_load, [])
# |> Map.update!(:side_load, &Keyword.put_new(&1, relationship, []))
# {new_instructions, data}
# end
# defp precheck_result(%{decision: nil}, _user, _context), do: nil
# defp precheck_result(%{decision: precheck}, user, context) do
# case precheck do
# {module, function, args} ->
# if function_exported?(module, function, Enum.count(args) + 2) do
# apply(module, function, [user, context] ++ args)
# else
# nil
# end
# function ->
# function.(user, context)
# end
# end
end

View file

@ -1,5 +1,11 @@
defmodule Ash.Authorization.Request do
defstruct [:resource, :rules, :filter]
defstruct [:resource, :authorization_steps, :filter]
@type t :: %__MODULE__{
resource: Ash.resource(),
authorization_steps: list(term),
filter: Ash.Filter.t()
}
def new(opts) do
struct!(__MODULE__, opts)

View file

@ -14,6 +14,8 @@ defmodule Ash.DataLayer.Ets do
@behaviour Ash.DataLayer
alias Ash.Filter.{Eq, In, And, Or}
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@data_layer Ash.DataLayer.Ets
@ -31,7 +33,7 @@ defmodule Ash.DataLayer.Ets do
end
defmodule Query do
defstruct [:resource, :filter, :limit, :sort, joins: [], offset: 0]
defstruct [:resource, :filter, :limit, :sort, relationships: %{}, joins: [], offset: 0]
end
@impl true
@ -41,6 +43,7 @@ defmodule Ash.DataLayer.Ets do
def can?({:filter, :in}), do: true
def can?({:filter, :eq}), do: true
def can?({:filter, :and}), do: true
def can?({:filter, :or}), do: true
def can?({:filter_related, _}), do: true
def can?(_), do: false
@ -61,17 +64,8 @@ defmodule Ash.DataLayer.Ets do
def can_query_async?(_), do: false
@impl true
def filter(query, filter, resource) do
# query = %{query | filter: query.filter || []}
# Enum.reduce(filter, {:ok, query}, fn
# _, {:error, error} ->
# {:error, error}
# {key, value}, {:ok, query} ->
# do_filter(query, key, value, resource)
# end)
{:ok, query}
def filter(query, filter, _resource) do
{:ok, %{query | filter: filter}}
end
@impl true
@ -79,22 +73,90 @@ defmodule Ash.DataLayer.Ets do
{:ok, %{query | sort: sort}}
end
defp do_filter(query, field, id, _resource) do
{:ok, %{query | filter: [{field, id} | query.filter]}}
end
@impl true
def run_query(
%Query{resource: resource, filter: filter, offset: offset, limit: limit, sort: sort},
_
) do
with {:ok, match_spec} <- filter_to_matchspec(resource, filter),
query_results =
filter
|> all_top_level_queries()
|> Enum.reduce({:ok, []}, fn query, {:ok, records} ->
case do_run_query(resource, query, limit + offset) do
{:ok, results} -> {:ok, records ++ results}
{:error, error} -> {:eror, error}
end
end)
case query_results do
{:ok, results} ->
final_results =
results
|> Enum.uniq_by(&Map.take(&1, Ash.primary_key(resource)))
|> do_sort(sort)
|> Enum.drop(offset)
|> Enum.take(limit)
{:ok, final_results}
{:error, error} ->
{:error, error}
end
end
defp do_run_query(resource, filter, limit) do
with %{errors: []} = filter <- relationships_to_attribute_filters(resource, filter),
{:ok, match_spec} <- filter_to_matchspec(resource, filter),
{:ok, table} <- wrap_or_create_table(resource),
{:ok, results} <- match_limit(table, match_spec, limit, offset),
records <- Enum.map(results, &elem(&1, 1)),
sorted <- do_sort(records, sort),
without_offset <- Enum.drop(sorted, offset) do
{:ok, without_offset}
{:ok, results} <- match_limit(table, match_spec, limit) do
{:ok, results}
else
%{errors: errors} ->
{:error, errors}
{:error, error} ->
{:error, error}
end
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(%{cardinality: :many_to_many} = rel, filter) do
with {:ok, results} <- do_run_query(rel.destination, filter, nil),
destination_values <- Enum.map(results, &Map.get(&1, rel.destination_field)),
%{errors: []} = through_query <-
Ash.Filter.parse(rel.through, [
{rel.destination_field_on_join_table, [in: destination_values]}
]),
{:ok, join_results} <- do_run_query(rel.through, through_query, nil) do
{rel.source_field,
[in: Enum.map(join_results, &Map.get(&1, rel.source_field_on_join_table))]}
else
%{errors: errors} -> {:error, errors}
{:error, error} -> {:error, error}
end
end
defp related_ids_filter(rel, filter) do
case do_run_query(rel.destination, filter, nil) do
{:ok, results} ->
{rel.source_field, [in: Enum.map(results, &Map.get(&1, rel.destination_field))]}
{:error, error} ->
{:error, error}
end
end
@ -127,41 +189,33 @@ defmodule Ash.DataLayer.Ets do
end)
end
# Id matching would be great here for performance, but
# for behaviour is technically unnecessary
defp filter_to_matchspec(resource, filter) do
filter = filter || []
{pkey_match, pkey_names} =
pkey_match =
resource
|> Ash.primary_key()
|> Enum.reduce({%{}, []}, fn
_attr, {:_, pkey_names} ->
{:_, pkey_names}
|> Enum.reduce(%{}, fn
_attr, :_ ->
:_
attr, {pkey_match, pkey_names} ->
with {:ok, field_filter} <- Keyword.fetch(filter, attr),
{:ok, value} <- Keyword.fetch(field_filter, :equal) do
{Map.put(pkey_match, attr, value), [attr | pkey_names]}
else
:error ->
{:_, [attr | pkey_names]}
attr, pkey_match ->
case Map.fetch(filter.attributes, attr) do
{:ok, %Eq{value: value}} ->
Map.put(pkey_match, attr, value)
_ ->
:_
end
end)
starting_matchspec = {{pkey_match, %{__struct__: resource}}, [], [:"$_"]}
filter
|> Kernel.||([])
|> Keyword.drop(pkey_names)
|> Enum.flat_map(fn {field, filter} ->
Enum.map(filter, fn {type, value} ->
{field, type, value}
end)
end)
|> Map.get(:attributes)
|> Enum.reduce({:ok, {starting_matchspec, %{}}}, fn
{key, type, value}, {:ok, {spec, bindings}} ->
do_filter_to_matchspec(resource, key, type, value, spec, bindings)
{field, value}, {:ok, {spec, bindings}} ->
do_filter_to_matchspec(resource, field, value, spec, bindings)
_, {:error, error} ->
{:error, error}
@ -172,13 +226,27 @@ defmodule Ash.DataLayer.Ets do
end
end
defp do_filter_to_matchspec(resource, key, type, value, spec, binding) do
cond do
attr = Ash.attribute(resource, key) ->
do_filter_to_matchspec_attribute(resource, attr.name, type, value, spec, binding)
defp all_top_level_queries(filter = %{ors: ors}) when is_list(ors) do
[filter | Enum.flat_map(ors, &all_top_level_queries/1)]
end
_rel = Ash.relationship(resource, key) ->
{:error, "relationship filtering not supported"}
defp all_top_level_queries(filter), do: [filter]
# defp attributes_to_expression(%{ors: [first | rest]} = filter, bindings, conditions) do
# {left, bindings, conditions} = attributes_to_conditions(first, bindings, conditions)
# {right, bindings, conditions} = attributes_to_conditions(rest, bindings, conditions)
# {this_expression, bindings, conditions} = attributes_to_conditions(%{filter | ors: []})
# {{:orelse, {:orelse, left, right}, this_expression}, bindings, conditions}
# end
# defp attributes_to_conditions(%{ors: [first]})
defp do_filter_to_matchspec(resource, field, value, spec, binding) do
cond do
attr = Ash.attribute(resource, field) ->
do_filter_to_matchspec_attribute(attr.name, value, spec, binding)
true ->
{:error, "unsupported filter"}
@ -186,9 +254,7 @@ defmodule Ash.DataLayer.Ets do
end
defp do_filter_to_matchspec_attribute(
_resource,
name,
type,
value,
{{id_match, struct_match}, conditions, matcher},
bindings
@ -196,7 +262,7 @@ defmodule Ash.DataLayer.Ets do
case Map.get(bindings, name) do
nil ->
binding = bindings |> Map.values() |> Enum.max(fn -> 0 end) |> Kernel.+(1)
condition = condition(type, value, binding)
condition = condition(value, binding)
new_spec =
{{id_match, Map.put(struct_match, name, :"$#{binding}")}, [condition | conditions],
@ -205,7 +271,7 @@ defmodule Ash.DataLayer.Ets do
{:ok, {new_spec, Map.put(bindings, name, binding)}}
binding ->
condition = condition(type, value, binding)
condition = condition(value, binding)
new_spec = {{id_match, struct_match}, [condition | conditions], matcher}
@ -213,24 +279,32 @@ defmodule Ash.DataLayer.Ets do
end
end
def condition(:equal, value, binding) do
def condition(%Eq{value: value}, binding) do
{:==, :"$#{binding}", value}
end
def condition(:in, [value], binding) do
condition(:equal, value, binding)
def condition(%In{values: []}, _binding) do
{:==, true, false}
end
def condition(:in, [value1, value2], binding) do
[{:orelse, {:"=:=", :"$#{binding}", value1}, {:"=:=", :"$#{binding}", value2}}]
def condition(%In{values: [value1, value2]}, binding) do
{:orelse, {:"=:=", :"$#{binding}", value1}, {:"=:=", :"$#{binding}", value2}}
end
def condition(:in, [value1 | rest], binding) do
{:orelse, condition(:equal, value1, binding), condition(:in, rest, binding)}
def condition(%In{values: [value1 | rest]}, binding) do
{:orelse, condition(%Eq{value: value1}, binding), condition(%In{values: rest}, binding)}
end
def condition(%Or{left: left, right: right}, binding) do
{:orelse, condition(left, binding), condition(right, binding)}
end
def condition(%And{left: left, right: right}, binding) do
{:andalso, condition(left, binding), condition(right, binding)}
end
def condition(:in, [], _binding) do
[{:==, false, true}]
{:==, false, true}
end
@impl true
@ -256,10 +330,10 @@ defmodule Ash.DataLayer.Ets do
create(resource, changeset)
end
defp match_limit(table, match_spec, limit, offset) do
defp match_limit(table, match_spec, limit) do
result =
if limit do
ETS.Set.select(table, [match_spec], limit + offset)
ETS.Set.select(table, [match_spec], limit)
else
ETS.Set.select(table, [match_spec])
end
@ -267,6 +341,7 @@ defmodule Ash.DataLayer.Ets do
case result do
{:ok, {matches, _}} -> {:ok, matches}
{:ok, :"$end_of_table"} -> {:ok, []}
{:ok, matches} -> {:ok, matches}
{:error, error} -> {:error, error}
end
end

View file

@ -1,7 +1,28 @@
defmodule Ash.Filter.And do
defstruct [:left, :right]
def new([first | [last | []]]), do: {:ok, %__MODULE__{left: first, right: last}}
def new([first | rest]), do: {:ok, %__MODULE__{left: first, right: new(rest)}}
def new(left, right), do: {:ok, %__MODULE__{left: left, right: right}}
def new(resource, attr_type, [first | [last | []]]) do
with {:ok, first} <- Ash.Filter.parse_predicates(resource, first, attr_type),
{:ok, right} <- Ash.Filter.parse_predicates(resource, last, attr_type) do
{:ok, %__MODULE__{left: first, right: right}}
end
end
def new(resource, attr_type, [first | rest]) do
case Ash.Filter.parse_predicates(resource, first, attr_type) do
{:ok, first} ->
{:ok, %__MODULE__{left: first, right: new(resource, attr_type, rest)}}
{:error, error} ->
{:error, error}
end
end
def new(resource, attr_type, {left, right}) do
new(resource, attr_type, [left, right])
end
def prebuilt_new(left, right) do
%__MODULE__{left: left, right: right}
end
end

View file

@ -1,5 +1,13 @@
defmodule Ash.Filter.Eq do
defstruct [:value]
def new(value), do: {:ok, %__MODULE__{value: value}}
def new(_resource, attr_type, value) do
case Ash.Type.cast_input(attr_type, value) do
{:ok, value} ->
{:ok, %__MODULE__{value: value}}
:error ->
{:error, "invalid value #{inspect(value)} for type #{inspect(attr_type)}"}
end
end
end

View file

@ -4,6 +4,7 @@ defmodule Ash.Filter do
:ors,
attributes: %{},
relationships: %{},
authorizations: [],
errors: []
]
@ -14,7 +15,8 @@ defmodule Ash.Filter do
ors: %__MODULE__{} | nil,
attributes: Keyword.t(),
relationships: Keyword.t(),
errors: list(String.t())
errors: list(String.t()),
authorizations: list(Ash.Authorization.Request.t())
}
@predicates %{
@ -24,17 +26,56 @@ defmodule Ash.Filter do
or: Ash.Filter.Or
}
@spec parse(Ash.resource(), Keyword.t()) :: t()
def parse(resource, filter) do
do_parse(resource, filter, %Ash.Filter{resource: resource})
@spec parse(Ash.resource(), Keyword.t(), rules :: list(term)) :: t()
def parse(resource, filter, authorization_steps \\ nil) do
authorization_steps =
authorization_steps || Ash.primary_action(resource, :read).authorization_steps
filter
|> do_parse(%Ash.Filter{resource: resource})
|> lift_ors()
|> add_authorization(
Ash.Authorization.Request.new(
resource: resource,
authorization_steps: authorization_steps,
filter: filter
)
)
end
defp do_parse(resource, filter_statement, filter) do
def add_to_filter(filter, additions) do
do_parse(additions, filter)
end
defp lift_ors(%Ash.Filter{
ors: [or_filter | rest],
relationships: rels,
attributes: attrs,
errors: errors,
authorizations: authorizations
})
when attrs == %{} and rels == %{} do
or_filter
|> Map.put(:ors, rest)
|> add_error(errors)
|> add_authorization(authorizations)
|> lift_ors()
end
defp lift_ors(filter), do: filter
defp do_parse(filter_statement, %{resource: resource} = filter) do
Enum.reduce(filter_statement, filter, fn
{key, value}, filter ->
cond do
key == :or || key == :and ->
add_expression_level_boolean_filter(filter, resource, key, value)
new_filter = add_expression_level_boolean_filter(filter, resource, key, value)
if Ash.data_layer(resource).can?({:filter, key}) do
new_filter
else
add_error(new_filter, "data layer does not support #{inspect(key)} filters")
end
attr = Ash.attribute(resource, key) ->
add_attribute_filter(filter, attr, value)
@ -57,9 +98,9 @@ defmodule Ash.Filter do
add_expression_level_boolean_filter(filter, resource, key, [left, right])
end
defp add_expression_level_boolean_filter(filter, resource, :and, expressions) do
defp add_expression_level_boolean_filter(filter, _resource, :and, expressions) do
Enum.reduce(expressions, filter, fn expression, filter ->
do_parse(resource, expression, filter)
do_parse(expression, filter)
end)
end
@ -68,8 +109,9 @@ defmodule Ash.Filter do
parsed_expression = parse(resource, expression)
filter
|> Map.update!(:ors, fn ors -> [parsed_expression | ors] end)
|> Map.update!(:ors, fn ors -> [parsed_expression | ors || []] end)
|> add_error(parsed_expression.errors)
|> add_authorization(parsed_expression.authorizations)
end)
end
@ -90,51 +132,78 @@ defmodule Ash.Filter do
predicate_name,
value
) do
case parse_predicate(resource, predicate_name, attr_type, value) do
{:ok, predicate} ->
new_attributes =
Map.update(
attributes,
attr_name,
predicate,
&Merge.merge(&1, predicate)
)
%{filter | attributes: new_attributes}
{:error, error} ->
add_error(filter, error)
end
end
def parse_predicates(resource, keyword, attr_type) do
Enum.reduce(keyword, {:ok, nil}, fn {predicate_name, value}, {:ok, existing_predicate} ->
case parse_predicate(resource, predicate_name, attr_type, value) do
{:ok, predicate} ->
if existing_predicate do
{:ok, Merge.merge(existing_predicate, predicate)}
else
{:ok, predicate}
end
{:error, error} ->
{:error, error}
end
end)
end
defp parse_predicate(resource, predicate_name, attr_type, value) do
with {:predicate_type, {:ok, predicate_type}} <-
{:predicate_type, Map.fetch(@predicates, predicate_name)},
{:data_layer_can?, _, true} <-
{:data_layer_can?, predicate_name,
Ash.data_layer(resource).can?({:filter, predicate_name})},
{:casted, {:ok, casted}} <- {:casted, Ash.Type.cast_input(attr_type, value)},
{:predicate, {:ok, predicate}} = {:predicate, predicate_type.new(casted)} do
new_attributes =
Map.update(
attributes,
attr_name,
predicate,
&Merge.merge(&1, predicate)
)
%{filter | attributes: new_attributes}
{:predicate, {:ok, predicate}} =
{:predicate, predicate_type.new(resource, attr_type, value)} do
{:ok, predicate}
else
{:predicate_type, :error} ->
add_error(filter, "No such filter type #{predicate_name}")
{:error, "No such filter type #{predicate_name}"}
{:casted, _} ->
add_error(filter, "Invalid value: #{inspect(value)} for #{inspect(attr_name)}")
{:error, "Invalid value: #{inspect(value)} for #{inspect(attr_type)}"}
{:predicate, {:error, error}} ->
add_error(filter, error)
{:error, error}
{:data_layer_can?, predicate_name, false} ->
add_error(filter, "data layer not capable of provided filter: #{predicate_name}")
{:error, "data layer not capable of provided filter: #{predicate_name}"}
end
end
def add_relationship_filter(
%{relationships: relationships} = filter,
%{destination: destination, name: name} = relationship,
value
) do
defp add_relationship_filter(
%{relationships: relationships} = filter,
%{destination: destination, name: name} = relationship,
value
) do
related_filter = parse(destination, value)
filter_with_errors = Enum.reduce(related_filter.errors, filter, &add_error(&2, &1))
new_relationships =
Map.update(relationships, name, related_filter, &Merge.merge(&1, related_filter))
filter_with_errors
filter
|> Map.put(:relationships, new_relationships)
|> add_relationship_compatibility_error(relationship)
|> add_error(related_filter.errors)
|> add_authorization(related_filter.authorizations)
end
defp add_relationship_compatibility_error(%{resource: resource} = filter, %{
@ -162,6 +231,13 @@ defmodule Ash.Filter do
end
end
defp add_authorization(%{authorizations: authorizations} = filter, authorizations)
when is_list(authorizations),
do: %{filter | authorizations: filter.authorizations ++ authorizations}
defp add_authorization(%{authorizations: authorizations} = filter, authorization),
do: %{filter | authorizations: [authorization | authorizations]}
defp add_error(%{errors: errors} = filter, errors) when is_list(errors),
do: %{filter | errors: filter.errors ++ errors}

View file

@ -1,6 +1,27 @@
defmodule Ash.Filter.In do
defstruct [:values]
def new([]), do: {:ok, %Ash.Filter.Impossible{cause: :empty_in}}
def new(values), do: {:ok, %__MODULE__{values: List.wrap(values)}}
def new(_resource, 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, "invalid value #{inspect(value)} for type #{inspect(attr_type)}"}
end
_, {:error, error} ->
{:error, error}
end)
case casted do
{:error, error} ->
{:error, error}
{:ok, values} ->
{:ok, %__MODULE__{values: values}}
end
end
end

View file

@ -18,6 +18,12 @@ defmodule Ash.Filter.InspectHelpers 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 ->
@ -40,44 +46,80 @@ defimpl Inspect, for: Ash.Filter do
import Inspect.Algebra
import Ash.Filter.InspectHelpers
def inspect(%{or: nil} = filter, opts) do
def inspect(%Ash.Filter{ors: ors, relationships: relationships, attributes: attributes}, opts)
when ors in [nil, []] and relationships in [nil, %{}] and attributes in [nil, %{}] do
if root?(opts) do
concat(["#Filter<", to_doc(nil, opts), ">"])
else
concat([to_doc(nil, opts)])
end
end
def inspect(filter, opts) do
rels =
filter
|> Map.get(:relationships)
|> Enum.map(fn {key, value} ->
to_doc(value, add_to_path(opts, key))
end)
|> case do
rels when rels == %{} ->
[]
rels ->
Enum.map(rels, fn {key, value} ->
to_doc(value, add_to_path(opts, key))
end)
end
attrs =
filter
|> Map.get(:attributes)
|> Enum.map(fn {key, value} ->
to_doc(value, put_attr(opts, key))
end)
|> Enum.concat(rels)
|> Enum.intersperse(" and ")
|> concat()
|> case do
attrs when attrs == %{} ->
[]
attrs ->
Enum.map(attrs, fn {key, value} ->
to_doc(value, put_attr(opts, key))
end)
end
and_container =
case attrs ++ rels do
[] ->
empty()
and_clauses ->
Inspect.Algebra.container_doc("(", and_clauses, ")", opts, fn term, _ -> term end,
break: :flex,
separator: " and "
)
end
all_container =
case Map.get(filter, :ors) do
nil ->
and_container
[] ->
and_container
ors ->
inspected_ors = Enum.map(ors, fn filter -> to_doc(filter, make_non_root(opts)) end)
Inspect.Algebra.container_doc(
"",
[and_container | inspected_ors],
"",
opts,
fn term, _ -> term end,
break: :strict,
separator: " or "
)
end
if root?(opts) do
concat(["#Filter< ", attrs, " >"])
concat(["#Filter<", all_container, ">"])
else
concat([attrs])
end
end
def inspect(%{or: or_filter} = filter, opts) do
filter_without_or = %{filter | or: nil}
if root?(opts) do
concat([
"#Ash.Filter<(",
to_doc(filter_without_or, opts),
") or (",
to_doc(or_filter, opts),
")>"
])
else
concat(["(", to_doc(filter_without_or, opts), ") or (", to_doc(or_filter, opts), ")"])
concat([all_container])
end
end
end

View file

@ -1,5 +1,6 @@
defmodule Ash.Filter.Merge do
alias Ash.Filter.{In, Eq, And, Impossible}
# alias Ash.Filter.{In, Eq, And, Impossible}
alias Ash.Filter.And
def merge(left, right) do
[left, right] = Enum.sort_by([left, right], fn %mod{} -> to_string(mod) end)
@ -45,7 +46,7 @@ defmodule Ash.Filter.Merge do
defp do_merge(left, right) do
# There is no way this can reasonably fail
{:ok, predicate} = And.new(left, right)
{:ok, predicate} = And.prebuilt_new(left, right)
predicate
end

View file

@ -1,7 +1,24 @@
defmodule Ash.Filter.Or do
defstruct [:left, :right]
def new([first | [last | []]]), do: {:ok, %__MODULE__{left: first, right: last}}
def new([first | rest]), do: {:ok, %__MODULE__{left: first, right: new(rest)}}
def new(left, right), do: {:ok, %__MODULE__{left: left, right: right}}
def new(resource, attr_type, [first | [last | []]]) do
with {:ok, first} <- Ash.Filter.parse_predicates(resource, first, attr_type),
{:ok, right} <- Ash.Filter.parse_predicates(resource, last, attr_type) do
{:ok, %__MODULE__{left: first, right: right}}
end
end
def new(resource, attr_type, [first | rest]) do
case Ash.Filter.parse_predicates(resource, first, attr_type) do
{:ok, first} ->
{:ok, %__MODULE__{left: first, right: new(resource, attr_type, rest)}}
{:error, error} ->
{:error, error}
end
end
def new(resource, attr_type, {left, right}) do
new(resource, attr_type, [left, right])
end
end

View file

@ -1,7 +1,6 @@
defmodule Ash.Resource.Actions.Create do
@moduledoc "The representation of a `create` action."
defstruct [:type, :name, :primary?, :authorization_steps]
alias Ash.Authorization.Rule
@type t :: %__MODULE__{
type: :create,

View file

@ -3,8 +3,6 @@ defmodule Ash.Resource.Actions.Read do
defstruct [:type, :name, :primary?, :authorization_steps, :paginate?]
alias Ash.Authorization.Rule
@type t :: %__MODULE__{
type: :read,
name: atom,

View file

@ -45,28 +45,36 @@ defmodule Ash.Test.Actions.SideLoadTest do
end
describe "side_loads" do
setup do
author = Api.create!(Author, attributes: %{name: "zerg"})
# setup do
# author = Api.create!(Author, attributes: %{name: "zerg"})
post1 =
Api.create!(Post, attributes: %{title: "post1"}, relationships: %{author: author.id})
# post1 =
# Api.create!(Post, attributes: %{title: "post1"}, relationships: %{author: author.id})
post2 =
Api.create!(Post, attributes: %{title: "post2"}, relationships: %{author: author.id})
# post2 =
# Api.create!(Post, attributes: %{title: "post2"}, relationships: %{author: author.id})
%{post1: post1, post2: post2}
# %{post1: post1, post2: post2}
# end
test "mything" do
Api.read!(Author, filter: [or: [[name: "zach"], [posts: [id: Ecto.UUID.generate()]]]])
end
test "it allows sideloading related data", %{post1: post1, post2: post2} do
%{results: [author]} =
Api.read!(Author, side_load: [posts: [:author]], filter: [posts: [id: post1.id]])
# test "it allows sideloading related data", %{post1: post1, post2: post2} do
# %{results: [author]} =
# Api.read!(Author, side_load: [posts: [:author]], filter: [posts: [id: post1.id]])
assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
Enum.sort([post1.id, post2.id])
# Api.read!(Author,
# filter: [name: "zach", posts: [id: post1.id, title: "foo", contents: "bar"]]
# )
for post <- author.posts do
assert post.author.id == author.id
end
end
# assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
# Enum.sort([post1.id, post2.id])
# for post <- author.posts do
# assert post.author.id == author.id
# end
# end
end
end