This commit is contained in:
Zach Daniel 2019-12-10 00:08:59 -05:00
parent 82cd7c00b1
commit d29afca057
No known key found for this signature in database
GPG key ID: A57053A671EE649E
21 changed files with 372 additions and 161 deletions

View file

@ -53,7 +53,6 @@ end
* Make our router cabaple of describing its routes in `mix phx.routes` Chris McCord says that we could probably power that, seeing as phoenix controls both APIs, and that capability could be added to `Plug.Router`
* Finish the serializer
* Make primary key type configurable, and support composite primary keys
* Make a DSL for join tables to support complex validation/hooks into how they work, support more than just table names in `join_through`
* DSL level validations! Things like includes validating that their chain exists. All DSL structs should be strictly validated when they are created.
* Especially at compile time, we should *never* ignore or skip invalid options. If an option is present and invalid, an error is raised.
@ -99,4 +98,8 @@ end
* Add `can?(:bulk_update)` to data layers, so we can more efficiently update relationships
* Figure out under what circumstances we can bulk fetch when reading before updating many_to_many and to_many relationships, and do so.
* most relationship stuff can't be done w/o primary keys
* includer errors are super obscure because you can't tell what action they are about
* includer errors are super obscure because you can't tell what action they are about
* Allow encoding database-level constraints into the resource, like "nullable: false" or something. This will let us validate things like not leaving orphans when bulk updating a many to many
* Validate filters, now that there can be duplicates. Doesn't make sense to provide two "exact equals" filters
* Eventually data_layers should state what raw types they support, and the filters they support on those raw types
* Raise on composite primary key if data layer can't do it

View file

@ -55,16 +55,15 @@ defmodule Ash.Actions.ChangesetHelpers do
%{
destination: destination,
destination_field: destination_field,
source_field: source_field,
name: rel_name
},
source_field: source_field
} = relationship,
identifier,
authorize?,
user
) do
case Filter.value_to_primary_key_filter(destination, identifier) do
{:error, _error} ->
Ecto.Changeset.add_error(changeset, rel_name, "Invalid primary key supplied")
Ecto.Changeset.add_error(changeset, relationship.name, "Invalid primary key supplied")
{:ok, filter} ->
before_change(changeset, fn changeset ->
@ -73,7 +72,7 @@ defmodule Ash.Actions.ChangesetHelpers do
changeset
|> Ecto.Changeset.put_change(source_field, Map.get(record, destination_field))
|> after_change(fn _changeset, result ->
{:ok, Map.put(result, rel_name, record)}
{:ok, Map.put(result, relationship.name, record)}
end)
{:error, error} ->
@ -88,16 +87,15 @@ defmodule Ash.Actions.ChangesetHelpers do
%{
destination: destination,
destination_field: destination_field,
source_field: source_field,
name: rel_name
},
source_field: source_field
} = relationship,
identifier,
authorize?,
user
) do
case Filter.value_to_primary_key_filter(destination, identifier) do
{:error, _error} ->
Ecto.Changeset.add_error(changeset, rel_name, "Invalid primary key supplied")
Ecto.Changeset.add_error(changeset, relationship.name, "Invalid primary key supplied")
{:ok, filter} ->
after_change(changeset, fn _changeset, result ->
@ -107,12 +105,29 @@ defmodule Ash.Actions.ChangesetHelpers do
api.get(destination, filter, %{authorize?: authorize?, user: user}),
{:ok, updated_record} <-
api.update(record, %{attributes: %{destination_field => value}}) do
{:ok, Map.put(result, rel_name, updated_record)}
{:ok, Map.put(result, relationship.name, updated_record)}
end
end)
end
end
# ChangesetHelpers.many_to_many_assoc_on_create(changeset, rel, value, authorize?, user)
def many_to_many_assoc_update(changeset, %{name: rel_name}, identifier, _, _)
when not is_list(identifier) do
Ecto.Changeset.add_error(changeset, rel_name, "Invalid value")
end
# def many_to_many_assoc_update(
# changeset,
# %{through: through, name: rel_name},
# identifiers,
# authorize?,
# user
# ) do
# # case Filter.value_to_primary_key_filter
# # case(values_to_primary_key_filters(destination, identifiers))
# end
def has_many_assoc_update(changeset, %{name: rel_name}, identifier, _, _)
when not is_list(identifier) do
Ecto.Changeset.add_error(changeset, rel_name, "Invalid value")
@ -123,35 +138,36 @@ defmodule Ash.Actions.ChangesetHelpers do
%{
destination: destination,
destination_field: destination_field,
source_field: source_field,
name: rel_name
},
source_field: source_field
} = relationship,
identifiers,
authorize?,
user
) do
case values_to_primary_key_filters(destination, identifiers) do
{:error, _error} ->
Ecto.Changeset.add_error(changeset, rel_name, "Invalid primary key supplied")
Ecto.Changeset.add_error(changeset, relationship.name, "Invalid primary key supplied")
{:ok, filters} ->
after_change(changeset, fn _changeset, %resource{} = result ->
value = Map.get(result, source_field)
params = %{
filter: [from_related: {result, relationship}],
paginate?: false,
authorize?: authorize?,
user: user
}
with {:ok, %{results: related}} <-
api.read(destination, %{
filter: %{from_related: {result, rel_name}},
paginate?: false,
authorize?: authorize?,
user: user
}),
api.read(destination, params),
{:ok, to_relate} <-
get_to_relate(api, filters, destination, authorize?, user),
to_clear <- get_no_longer_present(resource, related, to_relate),
:ok <- clear_related(api, resource, to_clear, destination_field, authorize?, user),
{:ok, now_related} <-
relate_items(api, to_relate, destination_field, value, authorize?, user) do
Map.put(result, rel_name, now_related)
{:ok, Map.put(result, relationship.name, now_related)}
end
end)
end
@ -159,13 +175,13 @@ defmodule Ash.Actions.ChangesetHelpers 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} ->
{to_be_related, updates}, {:ok, now_related} ->
case api.update(to_be_related, %{
attributes: %{destination_field => destination_field_value},
attributes: Map.put(updates, destination_field, destination_field_value),
authorize?: authorize?,
user: user
}) do
{:ok, newly_related} -> [newly_related | now_related]
{:ok, newly_related} -> {:ok, [newly_related | now_related]}
{:error, error} -> {:error, error}
end
@ -196,7 +212,9 @@ defmodule Ash.Actions.ChangesetHelpers do
to_relate_pkeys =
to_relate
|> Enum.map(&Map.take(&1, primary_key))
|> Enum.map(fn {to_relate_item, _updates} ->
Map.take(to_relate_item, primary_key)
end)
|> MapSet.new()
Enum.reject(currently_related, fn related_item ->
@ -205,9 +223,12 @@ defmodule Ash.Actions.ChangesetHelpers do
end
defp get_to_relate(api, filters, destination, authorize?, user) do
Enum.reduce(filters, {:ok, nil}, fn
filter, {:ok, _} ->
api.get(destination, filter, %{authorize?: authorize?, user: user})
Enum.reduce(filters, {:ok, []}, fn
{filter, updates}, {:ok, to_relate} ->
case api.get(destination, filter, %{authorize?: authorize?, user: user}) do
{:ok, to_relate_item} -> {:ok, [{to_relate_item, updates} | to_relate]}
{:error, errors} -> {:error, errors}
end
_, {:error, error} ->
{:error, error}
@ -218,8 +239,18 @@ defmodule Ash.Actions.ChangesetHelpers do
Enum.reduce(identifiers, {:ok, []}, fn
identifier, {:ok, filters} ->
case Filter.value_to_primary_key_filter(destination, identifier) do
{:ok, filter} -> [filter | filters]
{:error, error} -> {:error, error}
{:ok, filter} ->
updates =
if is_map(identifier) do
Map.drop(identifier, Ash.primary_key(destination))
else
%{}
end
{:ok, [{filter, updates} | filters]}
{:error, error} ->
{:error, error}
end
_, {:error, error} ->

View file

@ -126,8 +126,8 @@ defmodule Ash.Actions.Create do
%{type: :has_many} = rel ->
ChangesetHelpers.has_many_assoc_update(changeset, rel, value, authorize?, user)
# %{type: :many_to_many} = rel ->
# many_to_many_assoc_update(changeset, rel, value, repo)
%{type: :many_to_many} = rel ->
ChangesetHelpers.many_to_many_assoc_on_create(changeset, rel, value, authorize?, user)
_ ->
Ecto.Changeset.add_error(changeset, relationship, "No such relationship")

View file

@ -5,6 +5,33 @@ defmodule Ash.Actions.Filter do
@type filter_type :: :equal
def is_filter_subset?(resource, source_filter, candidate) do
candidate = process(resource, candidate)
Enum.reduce(candidate, true, fn
{candidate_key, candidate_value}, true ->
case source_filter[candidate_key] do
{:in, list} ->
list_subset_of?(list, candidate_value)
filter_value ->
filter_value == candidate_value
end
_, false ->
false
end)
end
defp list_subset_of?(source_list, candidate_list) do
candidate_set =
candidate_list
|> List.wrap()
|> MapSet.new()
Enum.all?(source_list, &MapSet.member?(candidate_set, &1))
end
@spec filter_types() :: list(filter_type())
def filter_types() do
@filter_types
@ -41,88 +68,153 @@ defmodule Ash.Actions.Filter do
do_value_to_primary_key_filter(resource, [field], %{field => value})
end
defp do_value_to_primary_key_filter(_, _, _), do: {:error, "Invalid primary key"}
defp do_value_to_primary_key_filter(_, _, _), do: {:error, ["Invalid primary key"]}
# This logic will need to get more complex as the ability to customize filter handling arises
# as well as when complex filter types are added
def process(resource, filter) do
state = %{errors: [], authorization: [], filter: []}
filter
|> Enum.reduce({%{}, []}, fn {name, value}, {acc, errors} ->
process_filter(resource, name, value, {acc, errors})
|> Enum.reduce(state, fn {name, value}, state ->
process_filter(resource, name, value, state)
end)
|> case do
{filter, []} -> {:ok, filter}
{_, errors} -> {:error, errors}
%{filter: filter, errors: [], authorization: authorization} -> {:ok, filter, authorization}
%{errors: errors} -> {:error, errors}
end
end
# TODO: Look into making `from_related` accept a full filter statement for the source entity,
# so you can say `%{filter: %{from_related: %{owner: %{name: "zach"}}}}. This would let us optimize
# so you can say `%{filter: [from_related: [owner: [name: "zach"]]]}. This would let us optimize
# and predict query results better, as well as represent the request to "get" those entities we
# are filtering against as an ash request, so that authorization happens for free :D
defp process_filter(_resource, :from_related, {[], relationship}, {filter, errors})
when is_list(relationship) do
{Map.put(filter, :__impossible__, true), errors}
defp process_filter(resource, :from_related, {item, relationship}, state)
when not is_list(item) do
process_filter(resource, :from_related, {[item], relationship}, state)
end
defp process_filter(resource, :from_related, {related, relationship_name}, {filter, errors})
when is_atom(relationship_name) do
case Ash.relationship(resource, relationship_name) do
nil ->
{filter, ["no such relationship: #{relationship_name}" | errors]}
defp process_filter(_resource, :from_related, {_, relationship}, state)
when is_atom(relationship) do
add_error(state, "Must provide relationship struct, not relationship name")
end
relationship ->
{Map.put(filter, :from_related, {related, relationship}), errors}
defp process_filter(
_resource,
:from_related,
{%_source_resource{}, %{type: :many_to_many} = _rel},
state
) do
add_error(state, "We don't support many to many filters yet")
end
defp process_filter(
resource,
:from_related,
{related, rel},
state
) do
case related do
[] ->
process_filter(resource, rel.destination_field, [in: []], state)
[related] ->
process_filter(resource, rel.destination_field, Map.get(related, rel.source_field), state)
[_ | _] = related ->
values = Enum.map(related, &Map.get(&1, rel.source_field))
process_filter(resource, rel.destination_field, [in: values], state)
end
end
defp process_filter(_resource, :from_related, {_related, relationship}, {filter, errors})
when is_atom(relationship) do
{filter,
[
"Must provide structs, or a relationship struct. Cannot pass ids and an atom relationship. #{
relationship
}"
| errors
]}
defp process_filter(resource, field, value, state) when not is_list(value) do
process_filter(resource, field, [equal: value], state)
end
defp process_filter(_resource, :from_related, {related, relationship}, {filter, errors}) do
{Map.put(filter, :from_related, {related, relationship}), errors}
end
# {:from_related, {[_ | _] = related, %{} = relationship}} ->
# {Map.put(filter, :from_related, {related, relationship}), errors}
defp process_filter(resource, field, value, {filter, errors}) do
defp process_filter(resource, field, value, state) do
cond do
attr = Ash.attribute(resource, field) ->
process_attribute_filter(resource, attr, value, {filter, errors})
Enum.reduce(value, state, fn {key, val}, state ->
do_process_filter(resource, attr.name, attr.type, key, val, state)
end)
rel = Ash.relationship(resource, field) ->
process_relationship_filter(resource, rel, value, {filter, errors})
case rel do
%{type: :many_to_many} ->
add_error(state, "no filtering on many to many")
%{source_field: source_field} ->
process_filter(resource, source_field, [equal: value], state)
end
true ->
{filter, ["Unsupported filter: #{inspect(field)}" | errors]}
add_error(state, "unknown filter #{field}")
end
end
defp process_attribute_filter(resource, %{name: name, type: type}, value, {filter, errors}) do
with {:ok, casted} <- Ash.Type.cast_input(type, value),
filters <- Ash.Type.supported_filter_types(type, Ash.data_layer(resource)),
{:supported, true} <- {:supported, :equal in filters} do
{Map.put(filter, name, casted), errors}
defp do_process_filter(resource, field, field_type, filter_type, value, state) do
with {:ok, casted} <- Ash.Type.cast_input(field_type, value),
{:supported?, true} <- {:supported?, supports_filter?(resource, field_type, filter_type)} do
%{state | filter: add_filter(state.filter, field, filter_type, casted)}
else
:error ->
{filter, ["Invalid value: #{inspect(value)} for #{inspect(name)}" | errors]}
add_error(state, "Invalid value: #{inspect(value)} for #{inspect(field)}")
{:supported, false} ->
{filter, ["Cannot filter #{inspect(name)} for equality." | errors]}
{:supported?, false} ->
add_error(state, "Cannot use filter type #{filter_type} on #{inspect(field)}.")
end
end
defp process_relationship_filter(_resource, %{name: name}, value, {filter, errors}) do
# TODO: type validate, potentially expand list of ids into a boolean filter statement
{Map.put(filter, name, value), errors}
defp supports_filter?(resource, type, filter_type) do
Ash.Type.supports_filter?(type, filter_type, Ash.data_layer(resource))
end
defp add_filter(filter, field, :equal, value) do
cond do
colliding_equal_filter?(filter, field, value) ->
filter
|> Keyword.put(:__impossible__, true)
|> Keyword.put(field, [{:equal, value} | filter[field]])
colliding_in_filter?(filter, field, value) ->
filter
|> Keyword.put(:__impossible__, true)
|> Keyword.put(field, [{:equal, value} | filter[field]])
true ->
Keyword.put(filter, field, equal: value)
end
end
# defp process_relationship_filter(_resource, %{name: name}, value, {filter, errors}) do
# # TODO: type validate, potentially expand list of ids into a boolean filter statement
# {filter, ["no relationship filters" | errors]}
# end
defp colliding_equal_filter?(filter, name, casted) do
case Keyword.fetch(filter, name) do
:error ->
false
{:ok, filter} ->
Enum.any?(filter, fn {key, value} ->
key == :equal and value != casted
end)
end
end
defp colliding_in_filter?(filter, name, casted) do
case Keyword.fetch(filter, name) do
:error ->
false
{:ok, filter} ->
Enum.any?(filter, fn {key, value} ->
key == :in and casted not in value
end)
end
end
defp add_error(state, error), do: %{state | errors: [error | state.errors]}
end

View file

@ -47,12 +47,13 @@ defmodule Ash.Actions.Read do
defp do_run(resource, action, api, params) do
with query <- Ash.DataLayer.resource_to_query(resource),
{:ok, filter} <- Ash.Actions.Filter.process(resource, Map.get(params, :filter, %{})),
{:ok, sort} <- Ash.Actions.Sort.process(resource, Map.get(params, :sort, [])),
{:ok, filtered_query} <- Ash.DataLayer.filter(query, filter, resource),
{:ok, sorted_query} <- Ash.DataLayer.sort(filtered_query, sort, resource),
{:ok, filter, authorization} <-
Ash.Actions.Filter.process(resource, Map.get(params, :filter, %{})),
{:ok, sorted_query} <- Ash.DataLayer.sort(query, sort, resource),
{:ok, filtered_query} <- Ash.DataLayer.filter(sorted_query, filter, resource),
{:ok, paginator} <-
Ash.Actions.Paginator.paginate(api, resource, action, sorted_query, params),
Ash.Actions.Paginator.paginate(api, resource, action, filtered_query, params),
{:ok, found} <- Ash.DataLayer.run_query(paginator.query, resource) do
{:ok, %{paginator | results: found}}
else

View file

@ -108,7 +108,7 @@ defmodule Ash.Actions.Update do
ChangesetHelpers.has_many_assoc_update(changeset, rel, value, authorize?, user)
# %{type: :many_to_many} = rel ->
# many_to_many_assoc_update(changeset, rel, value, repo)
# ChangesetHelpers.many_to_many_assoc_update(changeset, rel, value, authorize?, user)
_ ->
Ecto.Changeset.add_error(changeset, relationship, "No such relationship")

View file

@ -42,7 +42,7 @@ defmodule Ash.Api do
Then you can interact through that Api with the actions that those resources expose.
For example: `MyApp.Api.create(OneResource, %{attributes: %{name: "thing"}})`, or
`MyApp.Api.read(OneResource, %{filter: %{name: "thing"}})`. Corresponding actions must
`MyApp.Api.read(OneResource, %{filter: [name: "thing"]})`. Corresponding actions must
be defined in your resources in order to call them through the Api.
"""
defmacro __using__(opts) do

View file

@ -9,6 +9,8 @@ defmodule Ash.Authorization.Check.RelationshipAccess do
"""
use Ash.Authorization.Check
alias Ash.Actions.Filter
def init(opts) do
with {:key, {:ok, relationship}} <- {:key, Keyword.fetch(opts, :relationship)},
{:is_nil, false} <- {:is_nil, is_nil(relationship)},
@ -93,16 +95,12 @@ defmodule Ash.Authorization.Check.RelationshipAccess do
def precheck(user, %{resource: resource, params: params}, opts) do
relationship_name = opts[:relationship]
relationship = Ash.relationship(resource, relationship_name)
user_id = user.id
source_field = relationship.source_field
filter = Map.get(params, :filter, [])
cond do
match?(%{filter: %{^relationship_name => ^user_id}}, params) ->
{:precheck, true}
relationship.type != :many_to_many &&
match?(%{filter: %{^source_field => ^user_id}}, params) ->
Filter.is_filter_subset?(resource, filter, [{relationship_name, user_id}]) ->
{:precheck, true}
opts[:enforce_access?] ->

View file

@ -42,10 +42,12 @@ defmodule Ash.Authorization.Check.UserField do
{:precheck, value_will_equal_field?}
end
def precheck(user, context, opts) do
def precheck(user, context = %{resource: resource}, opts) do
user_value = Map.get(user, opts[:user_field])
record_field = opts[:record_field]
filter = Map.get(context, :filter, [])
{:precheck, match?(%{params: %{filter: %{^record_field => ^user_value}}}, context)}
{:precheck,
Ash.Actions.Filter.is_filter_subset?(resource, filter, [{record_field, user_value}])}
end
end

View file

@ -1,5 +1,6 @@
defmodule Ash.DataLayer do
@type feature() :: :transact | :query_async
@type filter_type :: :equal | :in
@type feature() :: :transact | :query_async | {:filter, filter_type}
@callback filter(Ash.query(), Ash.filter(), resource :: Ash.resource()) ::
{:ok, Ash.query()} | {:error, Ash.error()}

View file

@ -37,6 +37,10 @@ defmodule Ash.DataLayer.Ets do
@impl true
def can?(:query_async), do: false
def can?(:transact), do: false
def can?({:filter, :equal}), do: true
def can?({:filter, :in}), do: true
def can?(:composite_primary_key), do: true
def can?(_), do: false
@impl true
def resource_to_query(resource) do
@ -56,6 +60,8 @@ defmodule Ash.DataLayer.Ets do
@impl true
def filter(query, filter, resource) do
query = %{query | filter: query.filter || []}
Enum.reduce(filter, {:ok, query}, fn
_, {:error, error} ->
{:error, error}
@ -71,7 +77,7 @@ defmodule Ash.DataLayer.Ets do
end
defp do_filter(query, field, id, _resource) do
{:ok, %{query | filter: Map.put(query.filter || %{}, field, id)}}
{:ok, %{query | filter: [{field, id} | query.filter]}}
end
@impl true
@ -118,11 +124,10 @@ defmodule Ash.DataLayer.Ets do
end)
end
# Id matching will have to be smarter when new filter types
# are added. Id matching naturally only supports equality
# filters
# Id matching would be great here for performance, but
# for behaviour is technically unnecessary
defp filter_to_matchspec(resource, filter) do
filter = filter || %{}
filter = filter || []
{pkey_match, pkey_names} =
resource
@ -132,20 +137,28 @@ defmodule Ash.DataLayer.Ets do
{:_, pkey_names}
attr, {pkey_match, pkey_names} ->
case Map.fetch(filter, attr) do
{:ok, value} -> {Map.put(pkey_match, attr, value), [attr | pkey_names]}
:error -> {:_, [attr | 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]}
end
end)
starting_matchspec = {{pkey_match, %{__struct__: resource}}, [], [:"$_"]}
filter
|> Kernel.||(%{})
|> Map.drop(pkey_names)
|> Enum.reduce({:ok, {starting_matchspec, 1}}, fn
{key, value}, {:ok, {spec, binding}} ->
do_filter_to_matchspec(resource, key, value, spec, binding)
|> Kernel.||([])
|> Keyword.drop(pkey_names)
|> Enum.flat_map(fn {field, filter} ->
Enum.map(filter, fn {type, value} ->
{field, type, value}
end)
end)
|> Enum.reduce({:ok, {starting_matchspec, %{}}}, fn
{key, type, value}, {:ok, {spec, bindings}} ->
do_filter_to_matchspec(resource, key, type, value, spec, bindings)
_, {:error, error} ->
{:error, error}
@ -156,10 +169,10 @@ defmodule Ash.DataLayer.Ets do
end
end
defp do_filter_to_matchspec(resource, key, value, spec, binding) do
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, value, spec, binding)
do_filter_to_matchspec_attribute(resource, attr.name, type, value, spec, binding)
_rel = Ash.relationship(resource, key) ->
{:error, "relationship filtering not supported"}
@ -171,17 +184,38 @@ defmodule Ash.DataLayer.Ets do
defp do_filter_to_matchspec_attribute(
_resource,
%{name: name},
name,
type,
value,
{{id_match, struct_match}, conditions, matcher},
binding
bindings
) do
condition = {:==, :"$#{binding}", value}
case Map.get(bindings, name) do
nil ->
binding = bindings |> Map.values() |> Enum.max(fn -> 0 end) |> Kernel.+(1)
condition = condition(type, value, binding)
new_spec =
{{id_match, Map.put(struct_match, name, :"$#{binding}")}, [condition | conditions], matcher}
new_spec =
{{id_match, Map.put(struct_match, name, :"$#{binding}")}, [condition | conditions],
matcher}
{:ok, {new_spec, binding + 1}}
{:ok, {new_spec, Map.put(bindings, name, binding)}}
binding ->
condition = condition(type, value, binding)
new_spec = {{id_match, struct_match}, [condition | conditions], matcher}
{:ok, new_spec, bindings}
end
end
def condition(:equal, value, binding) do
{:==, :"$#{binding}", value}
end
def condition(:in, value, binding) do
{:in, value, :"$#{binding}"}
end
@impl true

View file

@ -9,13 +9,15 @@ defmodule Ash.Resource.Relationships.BelongsTo do
:define_field?,
:field_type,
:destination_field,
:source_field
:source_field,
:source
]
@type t :: %__MODULE__{
type: :belongs_to,
cardinality: :one,
name: atom,
source: Ash.resource(),
destination: Ash.resource(),
primary_key?: boolean,
define_field?: boolean,
@ -55,16 +57,19 @@ defmodule Ash.Resource.Relationships.BelongsTo do
def opt_schema(), do: @opt_schema
@spec new(
resource :: Ash.resource(),
name :: atom,
related_resource :: Ash.resource(),
opts :: Keyword.t()
) :: {:ok, t()} | {:error, term}
def new(name, related_resource, opts \\ []) do
def new(resource, name, related_resource, opts \\ []) do
# Don't call functions on the resource! We don't want it to compile here
case Ashton.validate(opts, @opt_schema) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
source: resource,
type: :belongs_to,
cardinality: :one,
field_type: opts[:field_type],

View file

@ -5,12 +5,14 @@ defmodule Ash.Resource.Relationships.HasMany do
:cardinality,
:destination,
:destination_field,
:source_field
:source_field,
:source
]
@type t :: %__MODULE__{
type: :has_many,
cardinality: :many,
source: Ash.resource(),
name: atom,
type: Ash.Type.t(),
destination: Ash.resource(),
@ -38,16 +40,19 @@ defmodule Ash.Resource.Relationships.HasMany do
def opt_schema(), do: @opt_schema
@spec new(
resource :: Ash.resource(),
name :: atom,
related_resource :: Ash.resource(),
opts :: Keyword.t()
) :: {:ok, t()} | {:error, term}
def new(resource_type, name, related_resource, opts \\ []) do
def new(resource, resource_type, name, related_resource, opts \\ []) do
# Don't call functions on the resource! We don't want it to compile here
case Ashton.validate(opts, @opt_schema) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
source: resource,
type: :has_many,
cardinality: :many,
destination: related_resource,

View file

@ -3,6 +3,7 @@ defmodule Ash.Resource.Relationships.HasOne do
defstruct [
:name,
:type,
:source,
:cardinality,
:destination,
:destination_field,
@ -12,6 +13,7 @@ defmodule Ash.Resource.Relationships.HasOne do
@type t :: %__MODULE__{
type: :has_one,
cardinality: :one,
source: Ash.resource(),
name: atom,
type: Ash.Type.t(),
destination: Ash.resource(),
@ -39,18 +41,21 @@ defmodule Ash.Resource.Relationships.HasOne do
def opt_schema(), do: @opt_schema
@spec new(
resource :: Ash.resource(),
resource_type :: String.t(),
name :: atom,
related_resource :: Ash.resource(),
opts :: Keyword.t()
) :: {:ok, t()} | {:error, term}
@doc false
def new(resource_type, name, related_resource, opts \\ []) do
def new(resource, resource_type, name, related_resource, opts \\ []) do
# Don't call functions on the resource! We don't want it to compile here
case Ashton.validate(opts, @opt_schema) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
source: resource,
type: :has_one,
cardinality: :one,
destination: related_resource,

View file

@ -2,6 +2,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
defstruct [
:name,
:type,
:source,
:through,
:cardinality,
:destination,
@ -14,8 +15,9 @@ defmodule Ash.Resource.Relationships.ManyToMany do
@type t :: %__MODULE__{
type: :many_to_many,
cardinality: :many,
source: Ash.resource(),
name: atom,
through: Ash.resource() | String.t(),
through: Ash.resource(),
destination: Ash.resource(),
source_field: atom,
destination_field: atom,
@ -29,7 +31,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
destination_field_on_join_table: :atom,
source_field: :atom,
destination_field: :atom,
through: [:atom, :string]
through: :atom
],
defaults: [
source_field: :id,
@ -39,8 +41,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do
:through
],
describe: [
through:
"Either a string representing a table/generic name for the join table or a module name of a resource.",
through: "The resource to use as the join table.",
source_field_on_join_table:
"The field on the join table that should line up with `source_field` on this resource. Default: [resource_name]_id",
destination_field_on_join_table:
@ -56,18 +57,21 @@ defmodule Ash.Resource.Relationships.ManyToMany do
def opt_schema(), do: @opt_schema
@spec new(
resource :: Ash.resource(),
resource_name :: String.t(),
name :: atom,
related_resource :: Ash.resource(),
opts :: Keyword.t()
) :: {:ok, t()} | {:error, term}
def new(resource_name, name, related_resource, opts \\ []) do
def new(resource, resource_name, name, related_resource, opts \\ []) do
# Don't call functions on the resource! We don't want it to compile here
case Ashton.validate(opts, @opt_schema) do
{:ok, opts} ->
{:ok,
%__MODULE__{
name: name,
type: :many_to_many,
source: resource,
cardinality: :many,
through: opts[:through],
destination: related_resource,

View file

@ -55,6 +55,7 @@ defmodule Ash.Resource.Relationships do
relationship =
Ash.Resource.Relationships.HasOne.new(
__MODULE__,
@resource_type,
relationship_name,
destination,
@ -65,7 +66,7 @@ defmodule Ash.Resource.Relationships do
{:ok, relationship} ->
@relationships relationship
{:error, [{key, message}]} ->
{:error, [{key, message} | _]} ->
raise Ash.Error.ResourceDslError,
message: message,
option: key,
@ -111,7 +112,12 @@ defmodule Ash.Resource.Relationships do
end
relationship =
Ash.Resource.Relationships.BelongsTo.new(relationship_name, destination, config)
Ash.Resource.Relationships.BelongsTo.new(
__MODULE__,
relationship_name,
destination,
config
)
case relationship do
{:ok, relationship} ->
@ -128,7 +134,7 @@ defmodule Ash.Resource.Relationships do
@relationships relationship
{:error, [{key, message}]} ->
{:error, [{key, message} | _]} ->
raise Ash.Error.ResourceDslError,
message: message,
option: key,
@ -170,6 +176,7 @@ defmodule Ash.Resource.Relationships do
relationship =
Ash.Resource.Relationships.HasMany.new(
__MODULE__,
@resource_type,
relationship_name,
destination,
@ -180,7 +187,7 @@ defmodule Ash.Resource.Relationships do
{:ok, relationship} ->
@relationships relationship
{:error, [{key, message}]} ->
{:error, [{key, message} | _]} ->
raise Ash.Error.ResourceDslError,
message: message,
option: key,
@ -213,6 +220,7 @@ defmodule Ash.Resource.Relationships do
quote do
relationship =
Ash.Resource.Relationships.ManyToMany.new(
__MODULE__,
@name,
unquote(relationship_name),
unquote(resource),
@ -223,7 +231,7 @@ defmodule Ash.Resource.Relationships do
{:ok, relationship} ->
@relationships relationship
{:error, [{key, message}]} ->
{:error, [{key, message} | _]} ->
raise Ash.Error.ResourceDslError,
message: message,
option: key,

View file

@ -27,16 +27,15 @@ defmodule Ash.Type do
@type t :: module | atom
@doc """
Returns a list of filter types supported by this type. By default, a type supports only the `:equal` filter
"""
@spec supported_filter_types(t, Ash.data_layer()) ::
list(Ash.Actions.Filter.filter_type())
def supported_filter_types(type, _data_layer) when type in @builtin_names do
@builtins[type][:filters]
@spec supports_filter?(t(), Ash.DataLayer.filter_type(), Ash.data_layer()) :: boolean
def supports_filter?(type, filter_type, data_layer) when type in @builtin_names do
data_layer.can?({:filter, filter_type}) and filter_type in @builtins[type][:filters]
end
def supported_filter_types(type, data_layer), do: type.supported_filter_types(data_layer)
def supports_filter?(type, filter_type, data_layer) do
data_layer.can?({:filter, filter_type}) and
filter_type in type.supported_filter_types(data_layer)
end
@doc """
Determines whether or not this value can be sorted.
@ -171,7 +170,7 @@ defmodule Ash.Type do
def ecto_type(), do: EctoType
@impl true
def supported_filter_types(_data_layer), do: [:equal]
def supported_filter_types(_data_layer), do: [:equal, :in]
@impl true
def sortable?(_data_layer), do: true

View file

@ -16,7 +16,7 @@ defmodule Ash.Test.Actions.CreateTest do
end
relationships do
belongs_to :author, __MODULE__.Author
belongs_to :author, Ash.Test.Actions.CreateTest.Author
end
end
@ -27,6 +27,7 @@ defmodule Ash.Test.Actions.CreateTest do
actions do
read :default
create :default
update :default
end
attributes do
@ -35,6 +36,8 @@ defmodule Ash.Test.Actions.CreateTest do
relationships do
has_one :profile, Profile
has_many :posts, Ash.Test.Actions.CreateTest.Post
end
end
@ -50,6 +53,7 @@ defmodule Ash.Test.Actions.CreateTest do
actions do
read :default
create :default
update :default
end
attributes do
@ -125,6 +129,19 @@ defmodule Ash.Test.Actions.CreateTest do
end
end
describe "creating with a has_many relationship" do
test "allows creating with a has_many relationship" do
post = Api.create!(Post, %{attributes: %{title: "sup"}})
Api.create!(Author, %{
attributes: %{title: "foobar"},
relationships: %{
posts: [post.id]
}
})
end
end
describe "creating with belongs_to relationships" do
test "allows creating with belongs_to relationship" do
author = Api.create!(Author, %{attributes: %{bio: "best dude"}})
@ -162,7 +179,7 @@ defmodule Ash.Test.Actions.CreateTest do
}).author_id == author.id
end
test "it responds with the relationshi filled in" do
test "it responds with the relationship filled in" do
author = Api.create!(Author, %{attributes: %{bio: "best dude"}})
assert Api.create!(Post, %{

View file

@ -103,7 +103,7 @@ defmodule Ash.Test.Actions.ReadTest do
test "it raises on an error" do
assert_raise(Ash.Error.FrameworkError, "Invalid value: 10 for :title", fn ->
Api.read!(Post, %{filter: %{title: 10}})
Api.read!(Post, %{filter: [title: 10]})
end)
end
end
@ -117,15 +117,15 @@ defmodule Ash.Test.Actions.ReadTest do
end
test "a filter that matches nothing returns no results" do
assert {:ok, %{results: []}} = Api.read(Post, %{filter: %{contents: "not_yeet"}})
assert {:ok, %{results: []}} = Api.read(Post, %{filter: [contents: "not_yeet"]})
end
test "a filter returns only matching records", %{post1: post1} do
assert {:ok, %{results: [^post1]}} = Api.read(Post, %{filter: %{title: post1.title}})
assert {:ok, %{results: [^post1]}} = Api.read(Post, %{filter: [title: post1.title]})
end
test "a filter returns multiple records if they match", %{post1: post1, post2: post2} do
assert {:ok, %{results: [_, _] = results}} = Api.read(Post, %{filter: %{contents: "yeet"}})
assert {:ok, %{results: [_, _] = results}} = Api.read(Post, %{filter: [contents: "yeet"]})
assert post1 in results
assert post2 in results

View file

@ -15,7 +15,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
test "it creates a relationship" do
defposts do
relationships do
many_to_many :foobars, Foobar, through: "some_table"
many_to_many :foobars, Foobar, through: SomeResource
end
end
@ -28,7 +28,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
name: :foobars,
source_field: :id,
source_field_on_join_table: :posts_id,
through: "some_table",
through: SomeResource,
type: :many_to_many
}
] = Ash.relationships(Post)
@ -36,12 +36,18 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
end
describe "validation" do
test "you can pass a string to `through`" do
defposts do
relationships do
many_to_many :foobars, Foobar, through: "some_table"
test "it fails if you pass a string to `through`" do
assert_raise(
Ash.Error.ResourceDslError,
"option through at relationships -> many_to_many -> foobars must be atom",
fn ->
defposts do
relationships do
many_to_many :foobars, Foobar, through: "some_table"
end
end
end
end
)
end
test "you can pass a module to `through`" do
@ -59,7 +65,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
fn ->
defposts do
relationships do
many_to_many :foobars, Foobar, through: "table", source_field_on_join_table: "what"
many_to_many :foobars, Foobar, through: FooBars, source_field_on_join_table: "what"
end
end
end
@ -74,7 +80,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
defposts do
relationships do
many_to_many :foobars, Foobar,
through: "table",
through: FooBar,
destination_field_on_join_table: "what"
end
end
@ -90,7 +96,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
defposts do
relationships do
many_to_many :foobars, Foobar,
through: "table",
through: FooBar,
source_field: "what"
end
end
@ -106,7 +112,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do
defposts do
relationships do
many_to_many :foobars, Foobar,
through: "table",
through: FooBars,
destination_field: "what"
end
end

View file

@ -68,8 +68,8 @@ defmodule Ash.Test.Type.TypeTest do
# As we add more filter types, we may want to test their multiplicity here
post = Api.create!(Post, %{attributes: %{title: "foobar"}})
assert_raise(Ash.Error.FrameworkError, "Cannot filter :title for equality.", fn ->
Api.read!(Post, %{filter: %{title: post.title}})
assert_raise(Ash.Error.FrameworkError, "Cannot use filter type equal on :title.", fn ->
Api.read!(Post, %{filter: [title: post.title]})
end)
end