mirror of
https://github.com/ash-project/ash.git
synced 2024-09-19 13:03:02 +12:00
feat: refactor changes into changesets
This commit is contained in:
parent
06d960db04
commit
2cf41b966e
54 changed files with 1848 additions and 1433 deletions
|
@ -24,6 +24,7 @@ locals_without_parens = [
|
|||
has_many: 3,
|
||||
has_one: 2,
|
||||
has_one: 3,
|
||||
join_relationship: 1,
|
||||
many_to_many: 2,
|
||||
many_to_many: 3,
|
||||
primary?: 1,
|
||||
|
|
|
@ -9,6 +9,7 @@ defmodule Ash do
|
|||
- [Resource Documentation](Ash.Resource.html)
|
||||
- [DSL Documentation](Ash.Dsl.html)
|
||||
- [Code API documentation](Ash.Api.Interface.html)
|
||||
- [Getting Started Guide](getting_started.html)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
@ -75,6 +76,7 @@ defmodule Ash do
|
|||
@type action :: Create.t() | Read.t() | Update.t() | Destroy.t()
|
||||
@type query :: Ash.Query.t()
|
||||
@type actor :: Ash.record()
|
||||
@type changeset :: Ash.Changeset.t()
|
||||
|
||||
require Ash.Dsl.Extension
|
||||
alias Ash.Dsl.Extension
|
||||
|
|
|
@ -5,40 +5,21 @@ defmodule Ash.Actions.Create do
|
|||
alias Ash.Actions.{Relationships, SideLoad}
|
||||
require Logger
|
||||
|
||||
def run(api, resource, action, opts) do
|
||||
attributes = Keyword.get(opts, :attributes, %{})
|
||||
relationships = Keyword.get(opts, :relationships, %{})
|
||||
def run(api, changeset, action, opts) do
|
||||
side_load = opts[:side_load] || []
|
||||
upsert? = opts[:upsert?] || false
|
||||
resource = changeset.resource
|
||||
|
||||
engine_opts =
|
||||
opts
|
||||
|> Keyword.take([:verbose?, :actor, :authorize?])
|
||||
|> Keyword.put(:transaction?, true)
|
||||
|
||||
action =
|
||||
if is_atom(action) and not is_nil(action) do
|
||||
Ash.action(resource, action, :read)
|
||||
else
|
||||
action
|
||||
end
|
||||
|
||||
with :ok <- check_upsert_support(resource, upsert?),
|
||||
{:ok, side_load_query} <- side_loads_as_query(api, resource, side_load),
|
||||
{:ok, relationships} <-
|
||||
Relationships.validate_not_changing_relationship_and_source_field(
|
||||
relationships,
|
||||
attributes,
|
||||
resource
|
||||
),
|
||||
{:ok, attributes, relationships} <-
|
||||
Relationships.field_changes_into_relationship_changes(
|
||||
relationships,
|
||||
attributes,
|
||||
resource
|
||||
),
|
||||
%{valid?: true} = changeset <-
|
||||
changeset(api, resource, attributes, relationships),
|
||||
with %{valid?: true} = changeset <-
|
||||
Relationships.handle_relationship_changes(%{changeset | api: api}),
|
||||
:ok <- check_upsert_support(changeset.resource, upsert?),
|
||||
{:ok, side_load_query} <-
|
||||
side_loads_as_query(changeset.api, changeset.resource, side_load),
|
||||
side_load_requests <-
|
||||
SideLoad.requests(side_load_query),
|
||||
%{
|
||||
|
@ -48,7 +29,6 @@ defmodule Ash.Actions.Create do
|
|||
do_run_requests(
|
||||
changeset,
|
||||
upsert?,
|
||||
relationships,
|
||||
engine_opts,
|
||||
action,
|
||||
resource,
|
||||
|
@ -57,8 +37,8 @@ defmodule Ash.Actions.Create do
|
|||
) do
|
||||
{:ok, SideLoad.attach_side_loads(created, state)}
|
||||
else
|
||||
%Ecto.Changeset{} = changeset ->
|
||||
{:error, Ash.Error.Changeset.changeset_to_errors(resource, changeset)}
|
||||
%Ash.Changeset{errors: errors} ->
|
||||
{:error, Ash.Error.to_ash_error(errors)}
|
||||
|
||||
%{errors: errors} ->
|
||||
{:error, Ash.Error.to_ash_error(errors)}
|
||||
|
@ -68,16 +48,9 @@ defmodule Ash.Actions.Create do
|
|||
end
|
||||
end
|
||||
|
||||
def changeset(api, resource, attributes, relationships) do
|
||||
resource
|
||||
|> prepare_create_attributes(attributes)
|
||||
|> Relationships.handle_relationship_changes(api, relationships, :create)
|
||||
end
|
||||
|
||||
defp do_run_requests(
|
||||
changeset,
|
||||
upsert?,
|
||||
relationships,
|
||||
engine_opts,
|
||||
action,
|
||||
resource,
|
||||
|
@ -88,19 +61,14 @@ defmodule Ash.Actions.Create do
|
|||
Request.new(
|
||||
api: api,
|
||||
resource: resource,
|
||||
changeset:
|
||||
Relationships.changeset(
|
||||
changeset,
|
||||
api,
|
||||
relationships
|
||||
),
|
||||
changeset: Relationships.changeset(changeset),
|
||||
action: action,
|
||||
data: nil,
|
||||
path: [:data],
|
||||
name: "#{action.type} - `#{action.name}`: prepare"
|
||||
)
|
||||
|
||||
relationship_read_requests = Map.get(changeset, :__requests__, [])
|
||||
relationship_read_requests = changeset.requests
|
||||
|
||||
commit_request =
|
||||
Request.new(
|
||||
|
@ -115,27 +83,13 @@ defmodule Ash.Actions.Create do
|
|||
Request.resolve(
|
||||
[[:commit, :changeset]],
|
||||
fn %{commit: %{changeset: changeset}} ->
|
||||
result =
|
||||
Ash.Changeset.with_hooks(changeset, fn changeset ->
|
||||
if upsert? do
|
||||
Ash.DataLayer.upsert(resource, changeset)
|
||||
else
|
||||
Ash.DataLayer.create(resource, changeset)
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, result} ->
|
||||
changeset
|
||||
|> Map.get(:__after_changes__, [])
|
||||
|> Enum.reduce_while({:ok, result}, fn func, {:ok, result} ->
|
||||
case func.(changeset, result) do
|
||||
{:ok, result} -> {:cont, {:ok, result}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
),
|
||||
path: [:commit],
|
||||
|
@ -173,93 +127,4 @@ defmodule Ash.Actions.Create do
|
|||
%{errors: errors} -> {:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_create_attributes(resource, attributes) do
|
||||
allowed_keys =
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
{attributes_with_defaults, unwritable_attributes} =
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reduce({%{}, []}, fn attribute, {new_attributes, unwritable_attributes} ->
|
||||
provided_value = fetch_attr(attributes, attribute.name)
|
||||
provided? = match?({:ok, _}, provided_value)
|
||||
|
||||
cond do
|
||||
provided? && !attribute.writable? ->
|
||||
{new_attributes, [attribute | unwritable_attributes]}
|
||||
|
||||
provided? ->
|
||||
{:ok, value} = provided_value
|
||||
{Map.put(new_attributes, attribute.name, value), unwritable_attributes}
|
||||
|
||||
is_nil(attribute.default) ->
|
||||
{new_attributes, unwritable_attributes}
|
||||
|
||||
true ->
|
||||
{Map.put(new_attributes, attribute.name, default(attribute)), unwritable_attributes}
|
||||
end
|
||||
end)
|
||||
|
||||
changeset =
|
||||
resource
|
||||
|> struct()
|
||||
|> Ecto.Changeset.cast(attributes_with_defaults, allowed_keys, empty_values: [])
|
||||
|> Map.put(:action, :create)
|
||||
|> Map.put(:__ash_relationships__, %{})
|
||||
|
||||
changeset =
|
||||
Enum.reduce(
|
||||
unwritable_attributes,
|
||||
changeset,
|
||||
&Ecto.Changeset.add_error(&2, &1.name, "attribute is not writable")
|
||||
)
|
||||
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reject(&Map.get(&1, :allow_nil?))
|
||||
|> Enum.reject(&Map.get(&1, :default))
|
||||
|> Enum.reduce(changeset, fn attr, changeset ->
|
||||
case Ecto.Changeset.get_field(changeset, attr.name) do
|
||||
nil -> Ecto.Changeset.add_error(changeset, attr.name, "must not be nil")
|
||||
_value -> changeset
|
||||
end
|
||||
end)
|
||||
|> validate_constraints(resource)
|
||||
end
|
||||
|
||||
defp validate_constraints(changeset, resource) do
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reduce(changeset, fn attribute, changeset ->
|
||||
with {:ok, value} <- Map.fetch(changeset.changes, attribute.name),
|
||||
{:error, error} <-
|
||||
Ash.Type.apply_constraints(attribute.type, value, attribute.constraints) do
|
||||
error
|
||||
|> List.wrap()
|
||||
|> Enum.reduce(changeset, fn error, changeset ->
|
||||
Ecto.Changeset.add_error(changeset, attribute.name, error)
|
||||
end)
|
||||
else
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp default(%{default: {:constant, value}}), do: value
|
||||
defp default(%{default: {mod, func, args}}), do: apply(mod, func, args)
|
||||
defp default(%{default: function}), do: function.()
|
||||
|
||||
defp fetch_attr(map, name) do
|
||||
case Map.fetch(map, name) do
|
||||
{:ok, value} ->
|
||||
{:ok, value}
|
||||
|
||||
:error ->
|
||||
Map.fetch(map, to_string(name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,13 +11,6 @@ defmodule Ash.Actions.Destroy do
|
|||
|> Keyword.take([:verbose?, :actor, :authorize?])
|
||||
|> Keyword.put(:transaction?, true)
|
||||
|
||||
action =
|
||||
if is_atom(action) and not is_nil(action) do
|
||||
Ash.action(resource, action, :read)
|
||||
else
|
||||
action
|
||||
end
|
||||
|
||||
authorization_request =
|
||||
Request.new(
|
||||
resource: resource,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,6 @@
|
|||
defmodule Ash.Actions.SideLoad do
|
||||
@moduledoc false
|
||||
|
||||
alias Ash.Actions.PrimaryKeyHelpers
|
||||
alias Ash.Engine
|
||||
alias Ash.Engine.Request
|
||||
|
||||
|
@ -73,8 +72,9 @@ defmodule Ash.Actions.SideLoad do
|
|||
|
||||
def side_load([%resource{} | _] = data, side_load_query, opts) do
|
||||
api = side_load_query.api
|
||||
pkey = Ash.primary_key(resource)
|
||||
|
||||
{:ok, pkey_filters} = PrimaryKeyHelpers.values_to_primary_key_filters(resource, data)
|
||||
pkey_filters = Enum.map(data, &Map.take(&1, pkey))
|
||||
|
||||
new_query = Ash.Query.filter(side_load_query, or: pkey_filters)
|
||||
|
||||
|
@ -103,12 +103,11 @@ defmodule Ash.Actions.SideLoad do
|
|||
lead_path = :lists.droplast(key)
|
||||
|
||||
case last_relationship do
|
||||
%{type: :many_to_many, name: name} ->
|
||||
%{type: :many_to_many} ->
|
||||
attach_many_to_many_side_loads(
|
||||
data,
|
||||
lead_path,
|
||||
last_relationship,
|
||||
name,
|
||||
side_loads,
|
||||
value
|
||||
)
|
||||
|
@ -155,10 +154,8 @@ defmodule Ash.Actions.SideLoad do
|
|||
end)
|
||||
end
|
||||
|
||||
defp attach_many_to_many_side_loads(data, lead_path, last_relationship, name, side_loads, value) do
|
||||
join_association = String.to_existing_atom(to_string(name) <> "_join_assoc")
|
||||
|
||||
join_path = lead_path ++ [join_association]
|
||||
defp attach_many_to_many_side_loads(data, lead_path, last_relationship, side_loads, value) do
|
||||
join_path = lead_path ++ [last_relationship.join_relationship]
|
||||
|
||||
join_data =
|
||||
side_loads
|
||||
|
@ -348,10 +345,7 @@ defmodule Ash.Actions.SideLoad do
|
|||
end
|
||||
|
||||
defp join_relationship(relationship) do
|
||||
Ash.relationship(
|
||||
relationship.source,
|
||||
String.to_existing_atom(to_string(relationship.name) <> "_join_assoc")
|
||||
)
|
||||
Ash.relationship(relationship.source, relationship.join_relationship)
|
||||
end
|
||||
|
||||
defp join_relationship_path(path, join_relationship) do
|
||||
|
|
|
@ -7,9 +7,7 @@ defmodule Ash.Actions.Update do
|
|||
|
||||
@spec run(Ash.api(), Ash.record(), Ash.action(), Keyword.t()) ::
|
||||
{:ok, Ash.record()} | {:error, Ecto.Changeset.t()} | {:error, Ash.error()}
|
||||
def run(api, %resource{} = record, action, opts) do
|
||||
attributes = Keyword.get(opts, :attributes, %{})
|
||||
relationships = Keyword.get(opts, :relationships, %{})
|
||||
def run(api, changeset, action, opts) do
|
||||
side_load = opts[:side_load] || []
|
||||
|
||||
engine_opts =
|
||||
|
@ -17,33 +15,17 @@ defmodule Ash.Actions.Update do
|
|||
|> Keyword.take([:verbose?, :actor, :authorize?])
|
||||
|> Keyword.put(:transaction?, true)
|
||||
|
||||
action =
|
||||
if is_atom(action) and not is_nil(action) do
|
||||
Ash.action(resource, action, :read)
|
||||
else
|
||||
action
|
||||
end
|
||||
resource = changeset.resource
|
||||
|
||||
with {:ok, side_load_query} <- side_loads_as_query(api, resource, side_load),
|
||||
{:ok, relationships} <-
|
||||
Relationships.validate_not_changing_relationship_and_source_field(
|
||||
relationships,
|
||||
attributes,
|
||||
resource
|
||||
),
|
||||
{:ok, attributes, relationships} <-
|
||||
Relationships.field_changes_into_relationship_changes(
|
||||
relationships,
|
||||
attributes,
|
||||
resource
|
||||
),
|
||||
%{valid?: true} = changeset <- changeset(record, api, attributes, relationships),
|
||||
with %{valid?: true} = changeset <-
|
||||
Relationships.handle_relationship_changes(%{changeset | api: api}),
|
||||
{:ok, side_load_query} <-
|
||||
side_loads_as_query(changeset.api, changeset.resource, side_load),
|
||||
side_load_requests <-
|
||||
SideLoad.requests(side_load_query),
|
||||
%{data: %{commit: updated}, errors: []} = state <-
|
||||
%{data: %{commit: %^resource{} = updated}, errors: []} = state <-
|
||||
do_run_requests(
|
||||
changeset,
|
||||
relationships,
|
||||
engine_opts,
|
||||
action,
|
||||
resource,
|
||||
|
@ -52,8 +34,8 @@ defmodule Ash.Actions.Update do
|
|||
) do
|
||||
{:ok, SideLoad.attach_side_loads(updated, state)}
|
||||
else
|
||||
%Ecto.Changeset{} = changeset ->
|
||||
{:error, Ash.Error.Changeset.changeset_to_errors(resource, changeset)}
|
||||
%Ash.Changeset{errors: errors} ->
|
||||
{:error, Ash.Error.to_ash_error(errors)}
|
||||
|
||||
%{errors: errors} ->
|
||||
{:error, Ash.Error.to_ash_error(errors)}
|
||||
|
@ -63,31 +45,8 @@ defmodule Ash.Actions.Update do
|
|||
end
|
||||
end
|
||||
|
||||
def changeset(%resource{} = record, api, attributes, relationships) do
|
||||
record
|
||||
|> prepare_update_attributes(attributes)
|
||||
|> Relationships.handle_relationship_changes(api, relationships, :update)
|
||||
|> validate_constraints(resource)
|
||||
end
|
||||
|
||||
defp validate_constraints(changeset, resource) do
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reduce(changeset, fn attribute, changeset ->
|
||||
with {:ok, value} <- Map.fetch(changeset.changes, attribute.name),
|
||||
{:error, error} <-
|
||||
Ash.Type.apply_constraints(attribute.type, value, attribute.constraints) do
|
||||
Ecto.Changeset.add_error(changeset, attribute.name, error)
|
||||
else
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_run_requests(
|
||||
changeset,
|
||||
relationships,
|
||||
engine_opts,
|
||||
action,
|
||||
resource,
|
||||
|
@ -97,12 +56,7 @@ defmodule Ash.Actions.Update do
|
|||
authorization_request =
|
||||
Request.new(
|
||||
api: api,
|
||||
changeset:
|
||||
Relationships.changeset(
|
||||
changeset,
|
||||
api,
|
||||
relationships
|
||||
),
|
||||
changeset: Relationships.changeset(changeset),
|
||||
action: action,
|
||||
resource: resource,
|
||||
data: changeset.data,
|
||||
|
@ -123,26 +77,16 @@ defmodule Ash.Actions.Update do
|
|||
Request.resolve(
|
||||
[[:data, :changeset]],
|
||||
fn %{data: %{changeset: changeset}} ->
|
||||
resource
|
||||
|> Ash.DataLayer.update(changeset)
|
||||
|> case do
|
||||
{:ok, result} ->
|
||||
changeset
|
||||
|> Map.get(:__after_changes__, [])
|
||||
|> Enum.reduce_while({:ok, result}, fn func, {:ok, result} ->
|
||||
case func.(changeset, result) do
|
||||
{:ok, result} -> {:cont, {:ok, result}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
Ash.Changeset.with_hooks(changeset, fn changeset ->
|
||||
Ash.DataLayer.update(resource, changeset)
|
||||
end)
|
||||
end
|
||||
),
|
||||
path: [:commit],
|
||||
name: "#{action.type} - `#{action.name}` commit"
|
||||
)
|
||||
|
||||
relationship_requests = Map.get(changeset, :__requests__, [])
|
||||
relationship_requests = changeset.requests
|
||||
|
||||
Engine.run(
|
||||
[authorization_request | [commit_request | relationship_requests]] ++ side_load_requests,
|
||||
|
@ -164,77 +108,4 @@ defmodule Ash.Actions.Update do
|
|||
%{errors: errors} -> {:error, errors}
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_update_attributes(%resource{} = record, attributes) do
|
||||
allowed_keys =
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.filter(& &1.writable?)
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
{attributes, unwritable_attributes} =
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reduce({%{}, []}, fn attribute, {new_attributes, unwritable_attributes} ->
|
||||
provided_value = fetch_attr(attributes, attribute.name)
|
||||
provided? = match?({:ok, _}, provided_value)
|
||||
|
||||
cond do
|
||||
provided? && !attribute.writable? ->
|
||||
{new_attributes, [attribute | unwritable_attributes]}
|
||||
|
||||
provided? ->
|
||||
{:ok, value} = provided_value
|
||||
{Map.put(new_attributes, attribute.name, value), unwritable_attributes}
|
||||
|
||||
is_nil(attribute.update_default) ->
|
||||
{new_attributes, unwritable_attributes}
|
||||
|
||||
true ->
|
||||
{Map.put(new_attributes, attribute.name, update_default(attribute)),
|
||||
unwritable_attributes}
|
||||
end
|
||||
end)
|
||||
|
||||
changeset =
|
||||
record
|
||||
|> Ecto.Changeset.cast(attributes, allowed_keys, empty_values: [])
|
||||
|> Map.put(:action, :update)
|
||||
|
||||
changeset =
|
||||
Enum.reduce(
|
||||
unwritable_attributes,
|
||||
changeset,
|
||||
&Ecto.Changeset.add_error(&2, &1.name, "attribute is not writable")
|
||||
)
|
||||
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.reject(&Map.get(&1, :allow_nil?))
|
||||
|> Enum.reduce(changeset, fn attr, changeset ->
|
||||
case Ecto.Changeset.fetch_change(changeset, attr.name) do
|
||||
{:ok, nil} ->
|
||||
Ecto.Changeset.add_error(changeset, attr.name, "must not be nil")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_attr(map, name) do
|
||||
case Map.fetch(map, name) do
|
||||
{:ok, value} ->
|
||||
{:ok, value}
|
||||
|
||||
:error ->
|
||||
Map.fetch(map, to_string(name))
|
||||
end
|
||||
end
|
||||
|
||||
defp update_default(%{default: {:constant, value}}), do: value
|
||||
defp update_default(%{default: {mod, func, args}}), do: apply(mod, func, args)
|
||||
|
||||
defp update_default(%{default: function}) when is_function(function, 0),
|
||||
do: function.()
|
||||
end
|
||||
|
|
|
@ -17,14 +17,14 @@ 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(query)`. Corresponding actions must
|
||||
be defined in your resources in order to call them through the Api.
|
||||
For example: `MyApp.Api.create(changeset)`, or `MyApp.Api.read(query)`. Corresponding
|
||||
actions must be defined in your resources in order to call them through the Api.
|
||||
"""
|
||||
|
||||
import Ash.OptionsHelpers, only: [merge_schemas: 3]
|
||||
|
||||
alias Ash.Actions.{Create, Destroy, Read, SideLoad, Update}
|
||||
alias Ash.Dsl.Transformer
|
||||
alias Ash.Error.NoSuchResource
|
||||
|
||||
@global_opts [
|
||||
|
@ -67,16 +67,6 @@ defmodule Ash.Api do
|
|||
|> merge_schemas(@global_opts, "Global Options")
|
||||
|
||||
@shared_create_and_update_opts_schema [
|
||||
attributes: [
|
||||
type: {:custom, Ash.OptionsHelpers, :map, []},
|
||||
default: %{},
|
||||
doc: "Changes to be applied to attribute values"
|
||||
],
|
||||
relationships: [
|
||||
type: {:custom, Ash.OptionsHelpers, :map, []},
|
||||
default: %{},
|
||||
doc: "Changes to be applied to relationship values"
|
||||
],
|
||||
side_load: [
|
||||
type: :any,
|
||||
doc:
|
||||
|
@ -250,21 +240,35 @@ defmodule Ash.Api do
|
|||
|
||||
use Supervisor
|
||||
|
||||
def start_link(:set_state) do
|
||||
# This exists to ensure we call `set_state` *after* resources have been built
|
||||
Supervisor.start_link(__MODULE__, :set_state, name: __MODULE__.SetState)
|
||||
end
|
||||
|
||||
def start_link(args) do
|
||||
# This exists to ensure we call `set_state` *after* resources have been built
|
||||
Supervisor.start_link(__MODULE__, args, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_init_arg) do
|
||||
def init(:set_state) do
|
||||
# This exists to ensure we call `set_state` *after* resources have been built
|
||||
Extension.set_state(true)
|
||||
|
||||
children =
|
||||
__MODULE__
|
||||
|> Ash.Api.resources()
|
||||
|> Enum.map(fn resource ->
|
||||
{resource, [api: __MODULE__]}
|
||||
end)
|
||||
:ignore
|
||||
end
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
def init(_init_arg) do
|
||||
children =
|
||||
raw_dsl()
|
||||
|> Transformer.get_entities([:resources], Ash.Api.Dsl)
|
||||
|> Enum.map(fn resource_reference ->
|
||||
{resource_reference.resource, [api: __MODULE__]}
|
||||
end)
|
||||
# This exists to ensure we call `set_state` *after* resources have been built
|
||||
|> Kernel.++([{__MODULE__, :set_state}])
|
||||
|
||||
children
|
||||
|> Supervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
||||
use Ash.Api.Interface
|
||||
|
@ -435,45 +439,45 @@ defmodule Ash.Api do
|
|||
end
|
||||
|
||||
@doc false
|
||||
@spec create!(Ash.api(), Ash.resource(), Keyword.t()) ::
|
||||
@spec create!(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
||||
Ash.record() | {:error, Ash.error()}
|
||||
def create!(api, resource, opts) do
|
||||
def create!(api, changeset, opts) do
|
||||
opts = NimbleOptions.validate!(opts, @create_opts_schema)
|
||||
|
||||
api
|
||||
|> create(resource, opts)
|
||||
|> create(changeset, opts)
|
||||
|> unwrap_or_raise!()
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec create(Ash.api(), Ash.resource(), Keyword.t()) ::
|
||||
@spec create(Ash.api(), Ash.changeset(), Keyword.t()) ::
|
||||
{:ok, Ash.resource()} | {:error, Ash.error()}
|
||||
def create(api, resource, opts) do
|
||||
def create(api, changeset, opts) do
|
||||
with {:ok, opts} <- NimbleOptions.validate(opts, @create_opts_schema),
|
||||
{:ok, resource} <- Ash.Api.resource(api, resource),
|
||||
{:ok, action} <- get_action(resource, opts, :create) do
|
||||
Create.run(api, resource, action, opts)
|
||||
{:ok, _resource} <- Ash.Api.resource(api, changeset.resource),
|
||||
{:ok, action} <- get_action(changeset.resource, opts, :create) do
|
||||
Create.run(api, changeset, action, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec update!(Ash.api(), Ash.record(), Keyword.t()) :: Ash.resource() | no_return()
|
||||
def update!(api, record, opts) do
|
||||
@spec update!(Ash.api(), Ash.changeset(), Keyword.t()) :: Ash.resource() | no_return()
|
||||
def update!(api, changeset, opts) do
|
||||
opts = NimbleOptions.validate!(opts, @update_opts_schema)
|
||||
|
||||
api
|
||||
|> update(record, opts)
|
||||
|> update(changeset, opts)
|
||||
|> unwrap_or_raise!()
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec update(Ash.api(), Ash.record(), Keyword.t()) ::
|
||||
{:ok, Ash.record()} | {:error, Ash.error()}
|
||||
def update(api, %resource{} = record, opts) do
|
||||
def update(api, changeset, opts) do
|
||||
with {:ok, opts} <- NimbleOptions.validate(opts, @update_opts_schema),
|
||||
{:ok, resource} <- Ash.Api.resource(api, resource),
|
||||
{:ok, action} <- get_action(resource, opts, :update) do
|
||||
Update.run(api, record, action, opts)
|
||||
{:ok, _resource} <- Ash.Api.resource(api, changeset.resource),
|
||||
{:ok, action} <- get_action(changeset.resource, opts, :update) do
|
||||
Update.run(api, changeset, action, opts)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -498,12 +502,27 @@ defmodule Ash.Api do
|
|||
end
|
||||
|
||||
defp get_action(resource, params, type) do
|
||||
case params[:action] || Ash.primary_action(resource, type) do
|
||||
nil ->
|
||||
{:error, "no action provided, and no primary action found for #{to_string(type)}"}
|
||||
|
||||
action ->
|
||||
case Keyword.fetch(params, :action) do
|
||||
{:ok, %_{} = action} ->
|
||||
{:ok, action}
|
||||
|
||||
{:ok, action} ->
|
||||
case Ash.action(resource, action, type) do
|
||||
nil -> {:error, "no such action #{inspect(params[:action])}"}
|
||||
action -> {:ok, action}
|
||||
end
|
||||
|
||||
:error ->
|
||||
case Ash.primary_action(resource, type) do
|
||||
nil ->
|
||||
{:error,
|
||||
"no action provided, and no primary #{to_string(type)} action found for resource #{
|
||||
inspect(resource)
|
||||
}"}
|
||||
|
||||
action ->
|
||||
{:ok, action}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -33,7 +33,8 @@ defmodule Ash.Api.Dsl do
|
|||
|
||||
@transformers [
|
||||
Ash.Api.Transformers.EnsureResourcesCompiled,
|
||||
Ash.Api.Transformers.ValidateRelatedResourceInclusion
|
||||
Ash.Api.Transformers.ValidateRelatedResourceInclusion,
|
||||
Ash.Api.Transformers.ValidateRelationshipAttributes
|
||||
]
|
||||
|
||||
use Ash.Dsl.Extension, sections: [@resources], transformers: @transformers
|
||||
|
|
|
@ -49,26 +49,26 @@ defmodule Ash.Api.Interface do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def create!(resource, params \\ []) do
|
||||
Api.create!(__MODULE__, resource, params)
|
||||
def create!(changeset, params \\ []) do
|
||||
Api.create!(__MODULE__, changeset, params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def create(resource, params \\ []) do
|
||||
case Api.create(__MODULE__, resource, params) do
|
||||
def create(changeset, params \\ []) do
|
||||
case Api.create(__MODULE__, changeset, params) do
|
||||
{:ok, instance} -> {:ok, instance}
|
||||
{:error, error} -> {:error, List.wrap(error)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update!(record, params \\ []) do
|
||||
Api.update!(__MODULE__, record, params)
|
||||
def update!(changeset, params \\ []) do
|
||||
Api.update!(__MODULE__, changeset, params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(record, params \\ []) do
|
||||
case Api.update(__MODULE__, record, params) do
|
||||
def update(changeset, params \\ []) do
|
||||
case Api.update(__MODULE__, changeset, params) do
|
||||
{:ok, instance} -> {:ok, instance}
|
||||
{:error, error} -> {:error, List.wrap(error)}
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Ash.Api.ResourceReference do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a resource in an API"
|
||||
defstruct [:resource]
|
||||
end
|
||||
|
|
54
lib/ash/api/transformers/validate_relationship_attributes.ex
Normal file
54
lib/ash/api/transformers/validate_relationship_attributes.ex
Normal file
|
@ -0,0 +1,54 @@
|
|||
defmodule Ash.Api.Transformers.ValidateRelationshipAttributes do
|
||||
@moduledoc """
|
||||
Validates the all relationships point to valid fields
|
||||
"""
|
||||
use Ash.Dsl.Transformer
|
||||
|
||||
alias Ash.Dsl.Transformer
|
||||
|
||||
@extension Ash.Api.Dsl
|
||||
|
||||
def transform(_api, dsl) do
|
||||
dsl
|
||||
|> Transformer.get_entities([:resources], @extension)
|
||||
|> Enum.map(& &1.resource)
|
||||
|> Enum.each(fn resource ->
|
||||
attribute_names =
|
||||
resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
resource
|
||||
|> Ash.relationships()
|
||||
|> Enum.each(&validate_relationship(&1, attribute_names))
|
||||
end)
|
||||
|
||||
{:ok, dsl}
|
||||
end
|
||||
|
||||
defp validate_relationship(relationship, attribute_names) do
|
||||
unless relationship.source_field in attribute_names do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
path: [:relationships, relationship.name],
|
||||
message:
|
||||
"Relationship `#{relationship.name}` expects source field `#{relationship.source_field}` to be defined"
|
||||
end
|
||||
|
||||
destination_attributes =
|
||||
relationship.destination
|
||||
|> Ash.attributes()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
unless relationship.destination_field in destination_attributes do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
path: [:relationships, relationship.name],
|
||||
message:
|
||||
"Relationship `#{relationship.name}` expects destination field `#{
|
||||
relationship.destination_field
|
||||
}` to be defined on #{inspect(relationship.destination)}"
|
||||
end
|
||||
end
|
||||
|
||||
def after?(Ash.Api.Transformers.EnsureResourcesCompiled), do: true
|
||||
def after?(_), do: false
|
||||
end
|
617
lib/ash/changeset/changeset.ex
Normal file
617
lib/ash/changeset/changeset.ex
Normal file
|
@ -0,0 +1,617 @@
|
|||
defmodule Ash.Changeset do
|
||||
@moduledoc """
|
||||
Changesets are used to create and update data in Ash.
|
||||
|
||||
Create a changeset with `create/2` or `update/2`, and alter the attributes
|
||||
and relationships using the functions provided in this module. Nothing in this module
|
||||
actually incurs changes in a data layer. To commit a changeset, see `c:Ash.Api.create/2`
|
||||
and `c:Ash.Api.update/2`.
|
||||
"""
|
||||
defstruct [
|
||||
:data,
|
||||
:action_type,
|
||||
:resource,
|
||||
:api,
|
||||
after_action: [],
|
||||
before_action: [],
|
||||
errors: [],
|
||||
valid?: true,
|
||||
attributes: %{},
|
||||
relationships: %{},
|
||||
change_dependencies: [],
|
||||
requests: []
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
alias Ash.Error.{
|
||||
Changes.InvalidAttribute,
|
||||
Changes.InvalidRelationship,
|
||||
Changes.NoSuchAttribute,
|
||||
Changes.NoSuchRelationship,
|
||||
NoSuchResource
|
||||
}
|
||||
|
||||
@doc "Return a changeset meant for creating an instance of a resource"
|
||||
@spec create(Ash.resource(), map) :: t
|
||||
def create(resource, initial_attributes \\ %{}) do
|
||||
if Ash.Resource.resource?(resource) do
|
||||
%__MODULE__{resource: resource, action_type: :create, data: struct(resource)}
|
||||
|> before_action(&set_create_defaults/1)
|
||||
|> change_attributes(initial_attributes)
|
||||
else
|
||||
%__MODULE__{resource: resource, action_type: :create, data: struct(resource)}
|
||||
|> add_error(NoSuchResource.exception(resource: resource))
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Return a changeset meant for updating an instance of a resource"
|
||||
@spec update(Ash.record(), map) :: t
|
||||
def update(%resource{} = record, initial_attributes \\ %{}) do
|
||||
if Ash.Resource.resource?(resource) do
|
||||
%__MODULE__{resource: resource, data: record, action_type: :update}
|
||||
|> before_action(&set_update_defaults/1)
|
||||
|> change_attributes(initial_attributes)
|
||||
else
|
||||
%__MODULE__{resource: resource, action_type: :create, data: struct(resource)}
|
||||
|> add_error(NoSuchResource.exception(resource: resource))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Wraps a function in the before/after action hooks of a changeset.
|
||||
|
||||
The function takes a changeset and if it returns
|
||||
`{:ok, result}`, the result will be passed through the after
|
||||
action hooks.
|
||||
"""
|
||||
@spec with_hooks(t(), (t() -> {:ok, Ash.record()} | {:error, term})) ::
|
||||
{:ok, term} | {:error, term}
|
||||
def with_hooks(changeset, func) do
|
||||
changeset =
|
||||
Enum.reduce_while(changeset.before_action, changeset, fn before_action, changeset ->
|
||||
case before_action.(changeset) do
|
||||
%{valid?: true} = changeset -> {:cont, changeset}
|
||||
changeset -> {:halt, changeset}
|
||||
end
|
||||
end)
|
||||
|
||||
if changeset.valid? do
|
||||
case func.(changeset) do
|
||||
{:ok, result} ->
|
||||
Enum.reduce_while(
|
||||
changeset.after_action,
|
||||
{:ok, result},
|
||||
fn after_action, {:ok, result} ->
|
||||
case after_action.(changeset, result) do
|
||||
{:ok, new_result} -> {:cont, {:ok, new_result}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
else
|
||||
{:error, changeset.errors}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets the changing value or the original value of an attribute"
|
||||
@spec get_attribute(t, atom) :: term
|
||||
def get_attribute(changeset, attribute) do
|
||||
case fetch_change(changeset, attribute) do
|
||||
{:ok, value} ->
|
||||
value
|
||||
|
||||
:error ->
|
||||
get_data(changeset, attribute)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets the new value for an attribute, or `:error` if it is not being changed"
|
||||
@spec fetch_change(t, atom) :: {:ok, any} | :error
|
||||
def fetch_change(changeset, attribute) do
|
||||
Map.fetch(changeset.attributes, attribute)
|
||||
end
|
||||
|
||||
@doc "Gets the original value for an attribute"
|
||||
@spec get_data(t, atom) :: {:ok, any} | :error
|
||||
def get_data(changeset, attribute) do
|
||||
Map.get(changeset.data, attribute)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Appends a record of list of records to a relationship. Stacks with previous removals/additions.
|
||||
|
||||
Cannot be used with `belongs_to` or `has_one` relationships.
|
||||
See `replace_relationship/3` for manipulating those relationships.
|
||||
"""
|
||||
@spec append_to_relationship(t, atom, list(Ash.record()) | Ash.record()) :: t()
|
||||
def append_to_relationship(changeset, relationship, record_or_records) do
|
||||
case Ash.relationship(changeset.resource, relationship) do
|
||||
nil ->
|
||||
error =
|
||||
NoSuchRelationship.exception(
|
||||
resource: changeset.resource,
|
||||
name: relationship
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{cardinality: :one, type: type} = relationship ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Cannot append to a #{type} relationship"
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{writable?: false} = relationship ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Relationship is not editable"
|
||||
)
|
||||
|
||||
{:error, error}
|
||||
|
||||
relationship ->
|
||||
case primary_key(relationship, List.wrap(record_or_records)) do
|
||||
{:ok, primary_keys} ->
|
||||
relationships =
|
||||
changeset.relationships
|
||||
|> Map.put_new(relationship.name, %{})
|
||||
|> add_to_relationship_key_and_reconcile(relationship, :add, primary_keys)
|
||||
|
||||
%{changeset | relationships: relationships}
|
||||
|
||||
{:error, error} ->
|
||||
add_error(changeset, error)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a record of list of records to a relationship. Stacks with previous removals/additions.
|
||||
|
||||
Cannot be used with `belongs_to` or `has_one` relationships.
|
||||
See `replace_relationship/3` for manipulating those relationships.
|
||||
"""
|
||||
@spec remove_from_relationship(t, atom, list(Ash.record()) | Ash.record()) :: t()
|
||||
def remove_from_relationship(changeset, relationship, record_or_records) do
|
||||
case Ash.relationship(changeset.resource, relationship) do
|
||||
nil ->
|
||||
error =
|
||||
NoSuchRelationship.exception(
|
||||
resource: changeset.resource,
|
||||
name: relationship
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{cardinality: :one, type: type} = relationship ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Cannot remove from a #{type} relationship"
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{writable?: false} = relationship ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Relationship is not editable"
|
||||
)
|
||||
|
||||
{:error, error}
|
||||
|
||||
relationship ->
|
||||
case primary_key(relationship, List.wrap(record_or_records)) do
|
||||
{:ok, primary_keys} ->
|
||||
relationships =
|
||||
changeset.relationships
|
||||
|> Map.put_new(relationship.name, %{})
|
||||
|> add_to_relationship_key_and_reconcile(relationship, :remove, primary_keys)
|
||||
|
||||
%{changeset | relationships: relationships}
|
||||
|
||||
{:error, error} ->
|
||||
add_error(changeset, error)
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp add_to_relationship_key_and_reconcile(relationships, relationship, key, to_add) do
|
||||
Map.update!(relationships, relationship.name, fn relationship_changes ->
|
||||
relationship_changes
|
||||
|> Map.put_new(key, [])
|
||||
|> Map.update!(key, &Kernel.++(to_add, &1))
|
||||
|> reconcile_relationship_changes()
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Replaces the value of a relationship. Any previous additions/removals are cleared.
|
||||
|
||||
For a `has_many` or `many_to_many` relationship, this means removing any currently related
|
||||
records that are not present in the replacement list, and creating any that do not exist
|
||||
in the data layer.
|
||||
|
||||
For a `belongs_to` or `has_one`, replace with a `nil` value to unset a relationship.
|
||||
"""
|
||||
@spec replace_relationship(t(), atom(), Ash.record() | list(Ash.record())) :: t()
|
||||
def replace_relationship(changeset, relationship, record_or_records) do
|
||||
case Ash.relationship(changeset.resource, relationship) do
|
||||
nil ->
|
||||
error =
|
||||
NoSuchRelationship.exception(
|
||||
resource: changeset.resource,
|
||||
name: relationship
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{writable?: false} = relationship ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Relationship is not editable"
|
||||
)
|
||||
|
||||
{:error, error}
|
||||
|
||||
%{cardinality: :one, type: type}
|
||||
when is_list(record_or_records) and length(record_or_records) > 1 ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Cannot replace a #{type} relationship with multiple records"
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
relationship ->
|
||||
record =
|
||||
if relationship.cardinality == :one do
|
||||
if is_list(record_or_records) do
|
||||
List.first(record_or_records)
|
||||
else
|
||||
record_or_records
|
||||
end
|
||||
else
|
||||
List.wrap(record_or_records)
|
||||
end
|
||||
|
||||
case primary_key(relationship, record) do
|
||||
{:ok, primary_key} ->
|
||||
relationships =
|
||||
Map.put(changeset.relationships, relationship.name, %{replace: primary_key})
|
||||
|
||||
%{changeset | relationships: relationships}
|
||||
|
||||
{:error, error} ->
|
||||
add_error(changeset, error)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Returns true if an attribute exists in the changes"
|
||||
@spec changing_attribute?(t(), atom) :: boolean
|
||||
def changing_attribute?(changeset, attribute) do
|
||||
Map.has_key?(changeset.attributes, attribute)
|
||||
end
|
||||
|
||||
@doc "Change an attribute only if is not currently being changed"
|
||||
@spec change_new_attribute(t(), atom, term) :: t()
|
||||
def change_new_attribute(changeset, attribute, value) do
|
||||
if changing_attribute?(changeset, attribute) do
|
||||
changeset
|
||||
else
|
||||
change_attribute(changeset, attribute, value)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Change an attribute if is not currently being changed, by calling the provided function
|
||||
|
||||
Use this if you want to only perform some expensive calculation for an attribute value
|
||||
only if there isn't already a change for that attribute
|
||||
"""
|
||||
@spec change_new_attribute_lazy(t(), atom, (() -> any)) :: t()
|
||||
def change_new_attribute_lazy(changeset, attribute, func) do
|
||||
if changing_attribute?(changeset, attribute) do
|
||||
changeset
|
||||
else
|
||||
change_attribute(changeset, attribute, func.())
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Calls `change_attribute/3` for each key/value pair provided"
|
||||
@spec change_attributes(t(), map | Keyword.t()) :: t()
|
||||
def change_attributes(changeset, changes) do
|
||||
Enum.reduce(changes, changeset, fn {key, value}, changeset ->
|
||||
change_attribute(changeset, key, value)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Adds a change to the changeset, unless the value matches the existing value"
|
||||
def change_attribute(changeset, attribute, value) do
|
||||
case Ash.attribute(changeset.resource, attribute) do
|
||||
nil ->
|
||||
error =
|
||||
NoSuchAttribute.exception(
|
||||
resource: changeset.resource,
|
||||
name: attribute
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
%{writable?: false} = attribute ->
|
||||
add_attribute_invalid_error(changeset, attribute, "Attribute is not writable")
|
||||
|
||||
attribute ->
|
||||
with {:ok, casted} <- Ash.Type.cast_input(attribute.type, value),
|
||||
:ok <- validate_allow_nil(attribute, casted),
|
||||
:ok <- Ash.Type.apply_constraints(attribute.type, casted, attribute.constraints) do
|
||||
data_value = Map.get(changeset.data, attribute.name)
|
||||
|
||||
cond do
|
||||
is_nil(data_value) and is_nil(casted) ->
|
||||
changeset
|
||||
|
||||
Ash.Type.equal?(attribute.type, casted, data_value) ->
|
||||
changeset
|
||||
|
||||
true ->
|
||||
%{changeset | attributes: Map.put(changeset.attributes, attribute.name, casted)}
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
add_attribute_invalid_error(changeset, attribute)
|
||||
|
||||
{:error, error_or_errors} ->
|
||||
error_or_errors
|
||||
|> List.wrap()
|
||||
|> Enum.reduce(changeset, &add_attribute_invalid_error(&2, attribute, &1))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Calls `force_change_attributes/2` for each key/value pair provided"
|
||||
@spec force_change_attributes(t(), map) :: t()
|
||||
def force_change_attributes(changeset, changes) do
|
||||
Enum.reduce(changes, changeset, fn {key, value}, changeset ->
|
||||
force_change_attribute(changeset, key, value)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Changes an attribute even if it isn't writable"
|
||||
@spec force_change_attribute(t(), atom, any) :: t()
|
||||
def force_change_attribute(changeset, attribute, value) do
|
||||
case Ash.attribute(changeset.resource, attribute) do
|
||||
nil ->
|
||||
error =
|
||||
NoSuchAttribute.exception(
|
||||
resource: changeset.resource,
|
||||
name: attribute
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
|
||||
attribute ->
|
||||
with {:ok, casted} <- Ash.Type.cast_input(attribute.type, value),
|
||||
:ok <- Ash.Type.apply_constraints(attribute.type, casted, attribute.constraints) do
|
||||
data_value = Map.get(changeset.data, attribute.name)
|
||||
|
||||
cond do
|
||||
is_nil(data_value) and is_nil(casted) ->
|
||||
changeset
|
||||
|
||||
Ash.Type.equal?(attribute.type, casted, data_value) ->
|
||||
changeset
|
||||
|
||||
true ->
|
||||
%{changeset | attributes: Map.put(changeset.attributes, attribute.name, casted)}
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
add_attribute_invalid_error(changeset, attribute)
|
||||
|
||||
{:error, error_or_errors} ->
|
||||
error_or_errors
|
||||
|> List.wrap()
|
||||
|> Enum.reduce(changeset, &add_attribute_invalid_error(&2, attribute, &1))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Adds a before_action hook to the changeset."
|
||||
@spec before_action(t(), (t() -> t())) :: t()
|
||||
def before_action(changeset, func) do
|
||||
%{changeset | before_action: [func | changeset.before_action]}
|
||||
end
|
||||
|
||||
@doc "Adds an after_action hook to the changeset."
|
||||
@spec after_action(t(), (t(), Ash.record() -> {:ok, Ash.record()} | {:error, term})) :: t()
|
||||
def after_action(changeset, func) do
|
||||
%{changeset | after_action: [func | changeset.after_action]}
|
||||
end
|
||||
|
||||
@doc "Returns the original data with attribute changes merged."
|
||||
@spec apply_attributes(t()) :: Ash.record()
|
||||
def apply_attributes(changeset) do
|
||||
Enum.reduce(changeset.attributes, changeset.data, fn {attribute, value}, data ->
|
||||
Map.put(data, attribute, value)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Adds an error to the changesets errors list, and marks the change as `valid?: false`"
|
||||
@spec add_error(t(), Ash.error()) :: t()
|
||||
def add_error(changeset, error) do
|
||||
%{changeset | errors: [error | changeset.errors], valid?: false}
|
||||
end
|
||||
|
||||
defp reconcile_relationship_changes(%{replace: _, add: add} = changes) do
|
||||
changes
|
||||
|> Map.delete(:add)
|
||||
|> Map.update!(:replace, fn replace ->
|
||||
replace ++ add
|
||||
end)
|
||||
|> reconcile_relationship_changes()
|
||||
end
|
||||
|
||||
defp reconcile_relationship_changes(%{replace: _, remove: remove} = changes) do
|
||||
changes
|
||||
|> Map.delete(:remove)
|
||||
|> Map.update!(:replace, fn replace ->
|
||||
Enum.reject(replace, &(&1 in remove))
|
||||
end)
|
||||
|> reconcile_relationship_changes()
|
||||
end
|
||||
|
||||
defp reconcile_relationship_changes(changes) do
|
||||
changes
|
||||
|> update_if_present(:replace, &uniq_if_list/1)
|
||||
|> update_if_present(:remove, &uniq_if_list/1)
|
||||
|> update_if_present(:add, &uniq_if_list/1)
|
||||
end
|
||||
|
||||
defp uniq_if_list(list) when is_list(list), do: Enum.uniq(list)
|
||||
defp uniq_if_list(other), do: other
|
||||
|
||||
defp update_if_present(map, key, func) do
|
||||
if Map.has_key?(map, key) do
|
||||
Map.update!(map, key, func)
|
||||
else
|
||||
map
|
||||
end
|
||||
end
|
||||
|
||||
defp primary_key(_, nil), do: {:ok, nil}
|
||||
|
||||
defp primary_key(relationship, records) when is_list(records) do
|
||||
case Ash.primary_key(relationship.destination) do
|
||||
[_field] ->
|
||||
multiple_primary_keys(relationship, records)
|
||||
|
||||
_ ->
|
||||
case single_primary_key(relationship, records) do
|
||||
{:ok, keys} ->
|
||||
{:ok, keys}
|
||||
|
||||
{:error, _} ->
|
||||
do_primary_key(relationship, records)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp primary_key(relationship, record) do
|
||||
do_primary_key(relationship, record)
|
||||
end
|
||||
|
||||
defp do_primary_key(relationship, record) when is_map(record) do
|
||||
primary_key = Ash.primary_key(relationship.destination)
|
||||
|
||||
if Enum.all?(primary_key, &Map.has_key?(record, &1)) do
|
||||
pkey = Map.take(record, Ash.primary_key(relationship.destination))
|
||||
|
||||
{:ok, pkey}
|
||||
else
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Invalid identifier #{inspect(record)}"
|
||||
)
|
||||
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_primary_key(relationship, record) do
|
||||
single_primary_key(relationship, record)
|
||||
end
|
||||
|
||||
defp multiple_primary_keys(relationship, values) do
|
||||
Enum.reduce_while(values, {:ok, []}, fn record, {:ok, primary_keys} ->
|
||||
case do_primary_key(relationship, record) do
|
||||
{:ok, pkey} -> {:cont, {:ok, [pkey | primary_keys]}}
|
||||
{:error, error} -> {:halt, {:error, error}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp single_primary_key(relationship, value) do
|
||||
with [field] <- Ash.primary_key(relationship.destination),
|
||||
attribute <- Ash.attribute(relationship.destination, field),
|
||||
{:ok, casted} <- Ash.Type.cast_input(attribute.type, value) do
|
||||
{:ok, %{field => casted}}
|
||||
else
|
||||
_ ->
|
||||
error =
|
||||
InvalidRelationship.exception(
|
||||
relationship: relationship.name,
|
||||
message: "Invalid identifier #{inspect(value)}"
|
||||
)
|
||||
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changes_depend_on(changeset, dependency) do
|
||||
%{changeset | change_dependencies: [dependency | changeset.change_dependencies]}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def add_requests(changeset, requests) when is_list(requests) do
|
||||
Enum.reduce(requests, changeset, &add_requests(&2, &1))
|
||||
end
|
||||
|
||||
def add_requests(changeset, request) do
|
||||
%{changeset | requests: [request | changeset.requests]}
|
||||
end
|
||||
|
||||
defp set_create_defaults(changeset) do
|
||||
changeset.resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.filter(& &1.default)
|
||||
|> Enum.reduce(changeset, fn attribute, changeset ->
|
||||
change_new_attribute_lazy(changeset, attribute.name, fn -> default(attribute.default) end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_update_defaults(changeset) do
|
||||
changeset.resource
|
||||
|> Ash.attributes()
|
||||
|> Enum.filter(& &1.update_default)
|
||||
|> Enum.reduce(changeset, fn attribute, changeset ->
|
||||
change_new_attribute_lazy(changeset, attribute.name, fn ->
|
||||
default(attribute.update_default)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_allow_nil(%{allow_nil?: false}, nil), do: {:error, "must not be nil"}
|
||||
defp validate_allow_nil(_, _), do: :ok
|
||||
|
||||
defp add_attribute_invalid_error(changeset, attribute, message \\ nil) do
|
||||
error =
|
||||
InvalidAttribute.exception(
|
||||
field: attribute.name,
|
||||
type: attribute.type,
|
||||
message: message
|
||||
)
|
||||
|
||||
add_error(changeset, error)
|
||||
end
|
||||
|
||||
defp default({:constant, value}), do: value
|
||||
defp default({mod, func, args}), do: apply(mod, func, args)
|
||||
defp default(function) when is_function(function, 0), do: function.()
|
||||
end
|
|
@ -16,6 +16,7 @@ defmodule Ash.DataLayer do
|
|||
| {:filter_predicate, Ash.Type.t(), struct}
|
||||
| {:sort, Ash.Type.t()}
|
||||
| :upsert
|
||||
| :delete_with_query
|
||||
| :composite_primary_key
|
||||
|
||||
@callback custom_filters(Ash.resource()) :: map()
|
||||
|
@ -33,11 +34,11 @@ defmodule Ash.DataLayer do
|
|||
@callback resource_to_query(Ash.resource()) :: Ash.data_layer_query()
|
||||
@callback run_query(Ash.data_layer_query(), Ash.resource()) ::
|
||||
{:ok, list(Ash.resource())} | {:error, term}
|
||||
@callback create(Ash.resource(), changeset :: Ecto.Changeset.t()) ::
|
||||
@callback create(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.resource()} | {:error, term}
|
||||
@callback upsert(Ash.resource(), changeset :: Ecto.Changeset.t()) ::
|
||||
@callback upsert(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.resource()} | {:error, term}
|
||||
@callback update(Ash.resource(), changeset :: Ecto.Changeset.t()) ::
|
||||
@callback update(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.resource()} | {:error, term}
|
||||
@callback destroy(record :: Ash.record()) :: :ok | {:error, term}
|
||||
@callback transaction(Ash.resource(), (() -> term)) :: {:ok, term} | {:error, term}
|
||||
|
@ -58,13 +59,13 @@ defmodule Ash.DataLayer do
|
|||
Ash.data_layer(resource).resource_to_query(resource)
|
||||
end
|
||||
|
||||
@spec update(Ash.resource(), Ecto.Changeset.t()) ::
|
||||
@spec update(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.record()} | {:error, term}
|
||||
def update(resource, changeset) do
|
||||
Ash.data_layer(resource).update(resource, changeset)
|
||||
end
|
||||
|
||||
@spec create(Ash.resource(), Ecto.Changeset.t()) ::
|
||||
@spec create(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.record()} | {:error, term}
|
||||
def create(resource, changeset) do
|
||||
Ash.data_layer(resource).create(resource, changeset)
|
||||
|
@ -79,7 +80,7 @@ defmodule Ash.DataLayer do
|
|||
end
|
||||
end
|
||||
|
||||
@spec upsert(Ash.resource(), Ecto.Changeset.t()) ::
|
||||
@spec upsert(Ash.resource(), Ash.changeset()) ::
|
||||
{:ok, Ash.record()} | {:error, term}
|
||||
def upsert(resource, changeset) do
|
||||
Ash.data_layer(resource).upsert(resource, changeset)
|
||||
|
|
|
@ -46,6 +46,7 @@ defmodule Ash.DataLayer.Ets do
|
|||
def can?(_, :upsert), do: true
|
||||
def can?(_, :boolean_filter), do: true
|
||||
def can?(_, :transact), do: false
|
||||
def can?(_, :delete_with_query), do: false
|
||||
def can?(_, {:filter_predicate, _, %In{}}), do: true
|
||||
def can?(_, {:filter_predicate, _, %Eq{}}), do: true
|
||||
def can?(_, {:filter_predicate, _, %LessThan{}}), do: true
|
||||
|
@ -176,11 +177,11 @@ defmodule Ash.DataLayer.Ets do
|
|||
resource
|
||||
|> Ash.primary_key()
|
||||
|> Enum.into(%{}, fn attr ->
|
||||
{attr, Ecto.Changeset.get_field(changeset, attr)}
|
||||
{attr, Ash.Changeset.get_attribute(changeset, attr)}
|
||||
end)
|
||||
|
||||
with {:ok, table} <- wrap_or_create_table(resource),
|
||||
record <- Ecto.Changeset.apply_changes(changeset),
|
||||
record <- Ash.Changeset.apply_attributes(changeset),
|
||||
{:ok, _} <- ETS.Set.put(table, {pkey, record}) do
|
||||
{:ok, record}
|
||||
else
|
||||
|
|
|
@ -76,6 +76,7 @@ defmodule Ash.DataLayer.Mnesia do
|
|||
def can?(_, :upsert), do: true
|
||||
def can?(_, :boolean_filter), do: true
|
||||
def can?(_, :transact), do: true
|
||||
def can?(_, :delete_with_query), do: false
|
||||
def can?(_, {:filter_predicate, _, %In{}}), do: true
|
||||
def can?(_, {:filter_predicate, _, %Eq{}}), do: true
|
||||
def can?(_, {:filter_predicate, _, %LessThan{}}), do: true
|
||||
|
@ -165,7 +166,7 @@ defmodule Ash.DataLayer.Mnesia do
|
|||
|
||||
@impl true
|
||||
def create(resource, changeset) do
|
||||
record = Ecto.Changeset.apply_changes(changeset)
|
||||
record = Ash.Changeset.apply_attributes(changeset)
|
||||
|
||||
pkey =
|
||||
resource
|
||||
|
|
|
@ -125,6 +125,7 @@ defmodule Ash.Dsl do
|
|||
],
|
||||
target: Ash.Resource.Relationships.ManyToMany,
|
||||
schema: Ash.Resource.Relationships.ManyToMany.opt_schema(),
|
||||
transform: {Ash.Resource.Relationships.ManyToMany, :transform, []},
|
||||
args: [:name, :destination]
|
||||
}
|
||||
|
||||
|
@ -260,8 +261,7 @@ defmodule Ash.Dsl do
|
|||
Ash.Resource.Transformers.BelongsToSourceField,
|
||||
Ash.Resource.Transformers.CreateJoinRelationship,
|
||||
Ash.Resource.Transformers.CachePrimaryKey,
|
||||
Ash.Resource.Transformers.SetPrimaryActions,
|
||||
Ash.Resource.Transformers.ValidateRelationshipAttributes
|
||||
Ash.Resource.Transformers.SetPrimaryActions
|
||||
]
|
||||
|
||||
use Ash.Dsl.Extension,
|
||||
|
|
|
@ -213,10 +213,6 @@ defmodule Ash.Dsl.Extension do
|
|||
quote generated: true, bind_quoted: [runtime?: runtime?], location: :keep do
|
||||
alias Ash.Dsl.Transformer
|
||||
|
||||
unless runtime? do
|
||||
Module.put_attribute(__MODULE__, :after_compile, Ash.Dsl.Extension)
|
||||
end
|
||||
|
||||
ash_dsl_config =
|
||||
if runtime? do
|
||||
@ash_dsl_config
|
||||
|
@ -235,37 +231,20 @@ defmodule Ash.Dsl.Extension do
|
|||
|
||||
:persistent_term.put({__MODULE__, :extensions}, @extensions)
|
||||
|
||||
transformers_to_run =
|
||||
@extensions
|
||||
|> Enum.flat_map(& &1.transformers())
|
||||
|> Transformer.sort()
|
||||
|> Enum.reject(fn transformer ->
|
||||
transformer.compile_time_only? && runtime?
|
||||
end)
|
||||
|
||||
new_dsl_config =
|
||||
if runtime? do
|
||||
Ash.Dsl.Extension.write_dsl_to_persistent_term(__MODULE__, ash_dsl_config)
|
||||
|
||||
Ash.Dsl.Extension.run_transformers(
|
||||
__MODULE__,
|
||||
transformers_to_run,
|
||||
ash_dsl_config
|
||||
else
|
||||
{transformers_to_skip, transformers_to_run} =
|
||||
@extensions
|
||||
|> Enum.flat_map(& &1.transformers())
|
||||
|> Transformer.sort()
|
||||
|> Enum.split_with(fn transformer ->
|
||||
transformer.compile_time_only? && runtime?
|
||||
end)
|
||||
|
||||
{after_compile_transformers, transformers_to_run} =
|
||||
Enum.split_with(transformers_to_run, &(&1.after_compile? || runtime?))
|
||||
|
||||
unless runtime? do
|
||||
Module.put_attribute(
|
||||
__MODULE__,
|
||||
:after_compile_transformers,
|
||||
after_compile_transformers
|
||||
)
|
||||
end
|
||||
|
||||
Ash.Dsl.Extension.run_transformers(
|
||||
__MODULE__,
|
||||
transformers_to_run,
|
||||
ash_dsl_config
|
||||
)
|
||||
end
|
||||
)
|
||||
|
||||
unless runtime? do
|
||||
Module.put_attribute(__MODULE__, :ash_dsl_config, new_dsl_config)
|
||||
|
@ -273,14 +252,6 @@ defmodule Ash.Dsl.Extension do
|
|||
end
|
||||
end
|
||||
|
||||
def __after_compile__(env, _bytecode) do
|
||||
Ash.Dsl.Extension.run_transformers(
|
||||
env.module,
|
||||
Enum.reverse(Module.get_attribute(env.module, :after_compile_transformers, [])),
|
||||
Module.get_attribute(env.module, :ash_dsl_config, %{})
|
||||
)
|
||||
end
|
||||
|
||||
def run_transformers(mod, transformers, ash_dsl_config) do
|
||||
Enum.reduce(transformers, ash_dsl_config, fn transformer, dsl ->
|
||||
result =
|
||||
|
|
|
@ -19,7 +19,6 @@ defmodule Ash.Dsl.Transformer do
|
|||
@callback before?(module) :: boolean
|
||||
@callback after?(module) :: boolean
|
||||
@callback compile_time_only? :: boolean
|
||||
@callback after_compile? :: boolean
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
|
@ -28,9 +27,8 @@ defmodule Ash.Dsl.Transformer do
|
|||
def before?(_), do: false
|
||||
def after?(_), do: false
|
||||
def compile_time_only?, do: false
|
||||
def after_compile?, do: false
|
||||
|
||||
defoverridable before?: 1, after?: 1, compile_time_only?: 0, after_compile?: 0
|
||||
defoverridable before?: 1, after?: 1, compile_time_only?: 0
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -3,13 +3,12 @@ defmodule Ash.Engine.Request do
|
|||
|
||||
defmodule UnresolvedField do
|
||||
@moduledoc false
|
||||
defstruct [:resolver, deps: [], optional_deps: [], data?: false]
|
||||
defstruct [:resolver, deps: [], data?: false]
|
||||
|
||||
def new(dependencies, optional_deps, func) do
|
||||
def new(dependencies, func) do
|
||||
%__MODULE__{
|
||||
resolver: func,
|
||||
deps: deps(dependencies),
|
||||
optional_deps: deps(optional_deps)
|
||||
deps: deps(dependencies)
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -66,8 +65,8 @@ defmodule Ash.Engine.Request do
|
|||
alias Ash.Actions.PrimaryKeyHelpers
|
||||
alias Ash.Authorizer
|
||||
|
||||
def resolve(dependencies \\ [], optional_dependencies \\ [], func) do
|
||||
UnresolvedField.new(dependencies, optional_dependencies, func)
|
||||
def resolve(dependencies \\ [], func) do
|
||||
UnresolvedField.new(dependencies, func)
|
||||
end
|
||||
|
||||
def new(opts) do
|
||||
|
@ -121,8 +120,6 @@ defmodule Ash.Engine.Request do
|
|||
def next(request) do
|
||||
case do_next(request) do
|
||||
{:complete, new_request, notifications, dependencies} ->
|
||||
dependencies = new_dependencies(dependencies)
|
||||
|
||||
if request.state != :complete do
|
||||
{:complete, new_request, notifications, dependencies}
|
||||
else
|
||||
|
@ -130,8 +127,6 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
|
||||
{:waiting, new_request, notifications, dependencies} ->
|
||||
dependencies = new_dependencies(dependencies)
|
||||
|
||||
{:wait, new_request, notifications, dependencies}
|
||||
|
||||
{:continue, new_request, notifications} ->
|
||||
|
@ -172,7 +167,7 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
|
||||
def do_next(%{state: :fetch_data} = request) do
|
||||
case try_resolve_local(request, :data, false, true) do
|
||||
case try_resolve_local(request, :data, true) do
|
||||
{:skipped, _, _, _} ->
|
||||
{:error, "unreachable case", request}
|
||||
|
||||
|
@ -226,7 +221,7 @@ defmodule Ash.Engine.Request do
|
|||
else
|
||||
Enum.reduce_while(request.dependencies_to_send, {:complete, request, [], []}, fn
|
||||
{field, _paths}, {:complete, request, notifications, deps} ->
|
||||
case try_resolve_local(request, field, true, false) do
|
||||
case try_resolve_local(request, field, false) do
|
||||
{:skipped, new_request, new_notifications, other_deps} ->
|
||||
{:cont,
|
||||
{:complete, new_request, new_notifications ++ notifications, other_deps ++ deps}}
|
||||
|
@ -248,10 +243,10 @@ defmodule Ash.Engine.Request do
|
|||
{:stop, :dependency_failed, request}
|
||||
end
|
||||
|
||||
def send_field(request, receiver_path, field, optional?) do
|
||||
def send_field(request, receiver_path, field) do
|
||||
log(request, "Attempting to provide #{inspect(field)} for #{inspect(receiver_path)}")
|
||||
|
||||
case store_dependency(request, receiver_path, field, optional?) do
|
||||
case store_dependency(request, receiver_path, field) do
|
||||
{:value, value, new_request} ->
|
||||
{:ok, new_request, [{receiver_path, request.path, field, value}]}
|
||||
|
||||
|
@ -262,9 +257,7 @@ defmodule Ash.Engine.Request do
|
|||
{:ok, new_request, notifications}
|
||||
|
||||
{:waiting, new_request, notifications, waiting_for} ->
|
||||
dependency_requests = new_dependencies(waiting_for)
|
||||
|
||||
{:waiting, new_request, notifications, dependency_requests}
|
||||
{:waiting, new_request, notifications, waiting_for}
|
||||
|
||||
{:error, error, new_request} ->
|
||||
log(request, "Error resolving #{field}: #{inspect(error)}")
|
||||
|
@ -281,22 +274,6 @@ defmodule Ash.Engine.Request do
|
|||
{:continue, new_request}
|
||||
end
|
||||
|
||||
defp new_dependencies(waiting_for) do
|
||||
Enum.map(
|
||||
waiting_for,
|
||||
fn
|
||||
{:optional, dep} ->
|
||||
{dep, true}
|
||||
|
||||
{:required, dep} ->
|
||||
{dep, false}
|
||||
|
||||
other ->
|
||||
{other, false}
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp set_authorized(%{authorized?: false, resource: resource} = request) do
|
||||
authorized? =
|
||||
resource
|
||||
|
@ -314,10 +291,10 @@ defmodule Ash.Engine.Request do
|
|||
%{request | dependency_data: Map.put(request.dependency_data, dep, value)}
|
||||
end
|
||||
|
||||
def store_dependency(request, receiver_path, field, optional?, internal? \\ false) do
|
||||
request = do_store_dependency(request, field, receiver_path, optional?)
|
||||
def store_dependency(request, receiver_path, field, internal? \\ false) do
|
||||
request = do_store_dependency(request, field, receiver_path)
|
||||
|
||||
case try_resolve_local(request, field, optional?, internal?) do
|
||||
case try_resolve_local(request, field, internal?) do
|
||||
{:skipped, new_request, notifications, []} ->
|
||||
log(request, "Field #{field} was skipped, no additional dependencies")
|
||||
{:ok, new_request, notifications}
|
||||
|
@ -343,35 +320,13 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
end
|
||||
|
||||
defp do_store_dependency(request, field, receiver_path, optional?) do
|
||||
tagged_path =
|
||||
if optional? do
|
||||
{:optional, receiver_path}
|
||||
else
|
||||
{:required, receiver_path}
|
||||
end
|
||||
|
||||
optional_str =
|
||||
if optional? do
|
||||
" optional "
|
||||
else
|
||||
" "
|
||||
end
|
||||
|
||||
log(request, "storing#{optional_str}dependency on #{field} from #{inspect(receiver_path)}")
|
||||
defp do_store_dependency(request, field, receiver_path) do
|
||||
log(request, "storing dependency on #{field} from #{inspect(receiver_path)}")
|
||||
|
||||
new_deps_to_send =
|
||||
Map.update(request.dependencies_to_send, field, [tagged_path], fn paths ->
|
||||
if optional? do
|
||||
if Enum.any?(paths, fn {_, path} -> path == receiver_path end) do
|
||||
paths
|
||||
else
|
||||
[tagged_path | paths]
|
||||
end
|
||||
else
|
||||
paths = Enum.reject(paths, fn {_, path} -> path == receiver_path end)
|
||||
[tagged_path | paths]
|
||||
end
|
||||
Map.update(request.dependencies_to_send, field, [receiver_path], fn paths ->
|
||||
paths = Enum.reject(paths, &Kernel.==(&1, receiver_path))
|
||||
[receiver_path | paths]
|
||||
end)
|
||||
|
||||
%{request | dependencies_to_send: new_deps_to_send}
|
||||
|
@ -427,7 +382,7 @@ defmodule Ash.Engine.Request do
|
|||
&add_to_or_parse(&1, filter, request.resource)
|
||||
)
|
||||
|> set_authorizer_state(authorizer, :authorized)
|
||||
|> try_resolve([request.path ++ [:query]], false, false)
|
||||
|> try_resolve([request.path ++ [:query]], false)
|
||||
|
||||
{:filter_and_continue, _, _} when strict_check_only? ->
|
||||
{:error, "Request must pass strict check"}
|
||||
|
@ -457,7 +412,7 @@ defmodule Ash.Engine.Request do
|
|||
request.path ++ [dep]
|
||||
end)
|
||||
|
||||
case try_resolve(request, deps, false, true) do
|
||||
case try_resolve(request, deps, true) do
|
||||
{:ok, new_request, new_notifications, []} ->
|
||||
do_strict_check(authorizer, new_request, notifications ++ new_notifications)
|
||||
|
||||
|
@ -518,7 +473,11 @@ defmodule Ash.Engine.Request do
|
|||
{:filter, filter} ->
|
||||
request
|
||||
|> set_authorizer_state(authorizer, :authorized)
|
||||
|> Map.update(:authorization_filter, filter, &Ash.Filter.add_to_filter(&1, filter))
|
||||
|> Map.update(
|
||||
:authorization_filter,
|
||||
filter,
|
||||
&Ash.Filter.add_to_filter(&1, filter)
|
||||
)
|
||||
|> runtime_filter(authorizer, filter)
|
||||
|
||||
{:error, error} ->
|
||||
|
@ -531,7 +490,7 @@ defmodule Ash.Engine.Request do
|
|||
request.path ++ [dep]
|
||||
end)
|
||||
|
||||
case try_resolve(request, deps, false, true) do
|
||||
case try_resolve(request, deps, true) do
|
||||
{:ok, new_request, new_notifications, []} ->
|
||||
do_check(authorizer, new_request, notifications ++ new_notifications)
|
||||
|
||||
|
@ -549,11 +508,7 @@ defmodule Ash.Engine.Request do
|
|||
{:ok, request} ->
|
||||
request
|
||||
|> set_authorizer_state(authorizer, :authorized)
|
||||
|> try_resolve(
|
||||
[request.path ++ [:data], request.path ++ [:query]],
|
||||
false,
|
||||
false
|
||||
)
|
||||
|> try_resolve([request.path ++ [:data], request.path ++ [:query]], false)
|
||||
|
||||
{:error, _error} ->
|
||||
{:error, "Error while authorizing"}
|
||||
|
@ -596,7 +551,7 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
end
|
||||
|
||||
defp try_resolve(request, deps, optional?, internal?) do
|
||||
defp try_resolve(request, deps, internal?) do
|
||||
Enum.reduce_while(deps, {:ok, request, [], []}, fn dep,
|
||||
{:ok, request, notifications, skipped} ->
|
||||
case get_dependency_data(request, dep) do
|
||||
|
@ -604,14 +559,14 @@ defmodule Ash.Engine.Request do
|
|||
{:cont, {:ok, request, notifications, skipped}}
|
||||
|
||||
:error ->
|
||||
do_try_resolve(request, notifications, skipped, dep, optional?, internal?)
|
||||
do_try_resolve(request, notifications, skipped, dep, internal?)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_try_resolve(request, notifications, skipped, dep, optional?, internal?) do
|
||||
defp do_try_resolve(request, notifications, skipped, dep, internal?) do
|
||||
if local_dep?(request, dep) do
|
||||
case try_resolve_local(request, List.last(dep), optional?, internal?) do
|
||||
case try_resolve_local(request, List.last(dep), internal?) do
|
||||
{:skipped, request, new_notifications, other_deps} ->
|
||||
{:cont, {:ok, request, new_notifications ++ notifications, skipped ++ other_deps}}
|
||||
|
||||
|
@ -619,29 +574,23 @@ defmodule Ash.Engine.Request do
|
|||
{:cont, {:ok, request, new_notifications ++ notifications, skipped ++ other_deps}}
|
||||
|
||||
{:error, error} ->
|
||||
log(request, "Error resolving optional dependency #{inspect(dep)}: #{inspect(error)}")
|
||||
|
||||
if optional? do
|
||||
{:cont, {:ok, request, notifications, skipped}}
|
||||
else
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
{:halt, {:error, error}}
|
||||
end
|
||||
else
|
||||
{:cont, {:ok, request, notifications, [dep | skipped]}}
|
||||
end
|
||||
end
|
||||
|
||||
defp try_resolve_local(request, field, optional?, internal?) do
|
||||
defp try_resolve_local(request, field, internal?) do
|
||||
authorized? = Enum.all?(Map.values(request.authorizer_state), &(&1 == :authorized))
|
||||
|
||||
# Don't fetch honor requests for dat until the request is authorized
|
||||
if field in [:data, :query] and not authorized? and not internal? do
|
||||
try_resolve_dependencies_of(request, field, internal?, optional?)
|
||||
try_resolve_dependencies_of(request, field, internal?)
|
||||
else
|
||||
case Map.get(request, field) do
|
||||
%UnresolvedField{} = unresolved ->
|
||||
do_try_resolve_local(request, field, unresolved, optional?, internal?)
|
||||
do_try_resolve_local(request, field, unresolved, internal?)
|
||||
|
||||
value ->
|
||||
notify_existing_value(request, field, value, internal?)
|
||||
|
@ -649,16 +598,13 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
end
|
||||
|
||||
defp try_resolve_dependencies_of(request, field, internal?, optional?) do
|
||||
defp try_resolve_dependencies_of(request, field, internal?) do
|
||||
case Map.get(request, field) do
|
||||
%UnresolvedField{deps: deps, optional_deps: optional_deps} ->
|
||||
with {:ok, new_request, optional_notifications, remaining_optional} <-
|
||||
try_resolve(request, optional_deps, true, internal?),
|
||||
{:ok, new_request, required_notifications, remaining_deps} <-
|
||||
try_resolve(new_request, deps, optional?, internal?) do
|
||||
{:skipped, new_request, optional_notifications ++ required_notifications,
|
||||
remaining_deps ++ remaining_optional}
|
||||
else
|
||||
%UnresolvedField{deps: deps} ->
|
||||
case try_resolve(request, deps, internal?) do
|
||||
{:ok, new_request, notifications, remaining_deps} ->
|
||||
{:skipped, new_request, notifications, remaining_deps}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
|
@ -678,14 +624,12 @@ defmodule Ash.Engine.Request do
|
|||
end
|
||||
end
|
||||
|
||||
defp do_try_resolve_local(request, field, unresolved, optional?, internal?) do
|
||||
%{deps: deps, optional_deps: optional_deps, resolver: resolver} = unresolved
|
||||
defp do_try_resolve_local(request, field, unresolved, internal?) do
|
||||
%{deps: deps, resolver: resolver} = unresolved
|
||||
|
||||
with {:ok, new_request, optional_notifications, _remaining_optional} <-
|
||||
try_resolve(request, optional_deps, true, internal?),
|
||||
{:ok, new_request, required_notifications, []} <-
|
||||
try_resolve(new_request, deps, optional?, internal?) do
|
||||
resolver_context = resolver_context(new_request, deps ++ optional_deps)
|
||||
with {:ok, new_request, notifications, []} <-
|
||||
try_resolve(request, deps, internal?) do
|
||||
resolver_context = resolver_context(new_request, deps)
|
||||
|
||||
log(request, "resolving #{field}")
|
||||
|
||||
|
@ -697,8 +641,7 @@ defmodule Ash.Engine.Request do
|
|||
|
||||
notifications =
|
||||
Enum.concat([
|
||||
optional_notifications,
|
||||
required_notifications,
|
||||
notifications,
|
||||
new_notifications
|
||||
])
|
||||
|
||||
|
@ -736,7 +679,11 @@ defmodule Ash.Engine.Request do
|
|||
| dependencies_to_send: Map.delete(request.dependencies_to_send, field)
|
||||
}
|
||||
|
||||
notifications = Enum.map(paths, fn path -> {path, request.path, field, value} end)
|
||||
notifications =
|
||||
Enum.map(paths, fn path ->
|
||||
{path, request.path, field, value}
|
||||
end)
|
||||
|
||||
{new_request, notifications}
|
||||
|
||||
:error ->
|
||||
|
|
|
@ -70,14 +70,13 @@ defmodule Ash.Engine.RequestHandler do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_cast({:send_field, receiver_path, _pid, dep, optional?}, state) do
|
||||
def handle_cast({:send_field, receiver_path, _pid, dep}, state) do
|
||||
field = List.last(dep)
|
||||
|
||||
case Request.send_field(
|
||||
state.request,
|
||||
receiver_path,
|
||||
field,
|
||||
optional?
|
||||
field
|
||||
) do
|
||||
{:waiting, new_request, notifications, dependency_requests} ->
|
||||
new_state = %{state | request: new_request}
|
||||
|
@ -120,14 +119,14 @@ defmodule Ash.Engine.RequestHandler do
|
|||
end)
|
||||
end
|
||||
|
||||
def register_dependency(state, {dep, optional?}) do
|
||||
def register_dependency(state, dep) do
|
||||
path = :lists.droplast(dep)
|
||||
|
||||
destination_pid = Map.get(state.pid_info, path) || state.runner_pid
|
||||
|
||||
log(state, "registering dependency: #{inspect(dep)}")
|
||||
|
||||
if not optional? and destination_pid != state.runner_pid do
|
||||
if destination_pid != state.runner_pid do
|
||||
Process.link(destination_pid)
|
||||
end
|
||||
|
||||
|
@ -137,17 +136,15 @@ defmodule Ash.Engine.RequestHandler do
|
|||
|
||||
GenServer.cast(
|
||||
destination_pid,
|
||||
{:send_field, state.request.path, self(), dep, optional?}
|
||||
{:send_field, state.request.path, self(), dep}
|
||||
)
|
||||
|
||||
unless optional? do
|
||||
log(state, "Registering hard dependency on #{inspect(path)} - #{field}")
|
||||
log(state, "Registering dependency on #{inspect(path)} - #{field}")
|
||||
|
||||
GenServer.cast(
|
||||
state.engine_pid,
|
||||
{:register_dependency, state.request.path, self(), dep}
|
||||
)
|
||||
end
|
||||
GenServer.cast(
|
||||
state.engine_pid,
|
||||
{:register_dependency, state.request.path, self(), dep}
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
@ -180,13 +180,13 @@ defmodule Ash.Engine.Runner do
|
|||
end
|
||||
end
|
||||
|
||||
defp fake_handle_cast({:send_field, receiver_path, pid, dep, optional?}, state) do
|
||||
defp fake_handle_cast({:send_field, receiver_path, pid, dep}, state) do
|
||||
log(state, "notifying #{inspect(receiver_path)} of #{inspect(dep)}")
|
||||
path = :lists.droplast(dep)
|
||||
field = List.last(dep)
|
||||
request = Enum.find(state.requests, &(&1.path == path))
|
||||
|
||||
case Request.send_field(request, receiver_path, field, optional?) do
|
||||
case Request.send_field(request, receiver_path, field) do
|
||||
{:waiting, new_request, notifications, dependencies} ->
|
||||
new_dependencies = build_dependencies(new_request, dependencies)
|
||||
|
||||
|
@ -203,9 +203,7 @@ defmodule Ash.Engine.Runner do
|
|||
|> notify(notifications)
|
||||
|
||||
{:error, error, new_request} ->
|
||||
unless optional? do
|
||||
Engine.send_wont_receive(pid, receiver_path, new_request.path, field)
|
||||
end
|
||||
Engine.send_wont_receive(pid, receiver_path, new_request.path, field)
|
||||
|
||||
state
|
||||
|> add_error(new_request.path, error)
|
||||
|
@ -229,8 +227,8 @@ defmodule Ash.Engine.Runner do
|
|||
end
|
||||
|
||||
defp build_dependencies(request, dependencies) do
|
||||
Enum.map(dependencies, fn {dep, optional?} ->
|
||||
{request.path, dep, optional?}
|
||||
Enum.map(dependencies, fn dep ->
|
||||
{request.path, dep}
|
||||
end)
|
||||
end
|
||||
|
||||
|
@ -252,7 +250,7 @@ defmodule Ash.Engine.Runner do
|
|||
{state, notifications, more_dependencies} =
|
||||
dependencies
|
||||
|> Enum.uniq()
|
||||
|> Enum.reduce({state, notifications, []}, fn {request_path, dep, optional?},
|
||||
|> Enum.reduce({state, notifications, []}, fn {request_path, dep},
|
||||
{state, notifications, dependencies} ->
|
||||
request = Enum.find(state.requests, &(&1.path == request_path))
|
||||
path = :lists.droplast(dep)
|
||||
|
@ -262,7 +260,7 @@ defmodule Ash.Engine.Runner do
|
|||
nil ->
|
||||
pid = Map.get(state.pid_info, path)
|
||||
|
||||
GenServer.cast(pid, {:send_field, path, self(), dep, optional?})
|
||||
GenServer.cast(pid, {:send_field, path, self(), dep})
|
||||
|
||||
{state, notifications, dependencies}
|
||||
|
||||
|
@ -273,8 +271,7 @@ defmodule Ash.Engine.Runner do
|
|||
dependencies,
|
||||
depended_on_request,
|
||||
request,
|
||||
field,
|
||||
optional?
|
||||
field
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
@ -294,10 +291,9 @@ defmodule Ash.Engine.Runner do
|
|||
dependencies,
|
||||
depended_on_request,
|
||||
request,
|
||||
field,
|
||||
optional?
|
||||
field
|
||||
) do
|
||||
case Request.send_field(depended_on_request, request.path, field, optional?) do
|
||||
case Request.send_field(depended_on_request, request.path, field) do
|
||||
{:ok, new_request, new_notifications} ->
|
||||
{replace_request(state, new_request), notifications ++ new_notifications, dependencies}
|
||||
|
||||
|
@ -313,15 +309,8 @@ defmodule Ash.Engine.Runner do
|
|||
state
|
||||
|> replace_request(%{new_request | state: :error})
|
||||
|> add_error(new_request.path, error)
|
||||
|
||||
new_state =
|
||||
if optional? do
|
||||
new_state
|
||||
else
|
||||
new_state
|
||||
|> add_error(request.path, "dependency failed")
|
||||
|> replace_request(%{new_request | state: :error, error: error})
|
||||
end
|
||||
|> add_error(request.path, "dependency failed")
|
||||
|> replace_request(%{new_request | state: :error, error: error})
|
||||
|
||||
{new_state, notifications, dependencies}
|
||||
end
|
||||
|
|
31
lib/ash/error/changes/invalid_relationship.ex
Normal file
31
lib/ash/error/changes/invalid_relationship.ex
Normal file
|
@ -0,0 +1,31 @@
|
|||
defmodule Ash.Error.Changes.InvalidRelationship do
|
||||
@moduledoc "Used when an invalid value is provided for a relationship change"
|
||||
use Ash.Error
|
||||
|
||||
def_ash_error([:relationship, :message], class: :invalid)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
||||
def code(_), do: "invalid_relationship"
|
||||
|
||||
def message(error) do
|
||||
"Invalid value provided#{for_relationship(error)}#{do_message(error)}"
|
||||
end
|
||||
|
||||
def description(error) do
|
||||
"Invalid value provided#{for_relationship(error)}#{do_message(error)}"
|
||||
end
|
||||
|
||||
defp for_relationship(%{relationship: relationship}) when not is_nil(relationship),
|
||||
do: " for #{relationship}"
|
||||
|
||||
defp for_relationship(_), do: ""
|
||||
|
||||
defp do_message(%{message: message}) when not is_nil(message) do
|
||||
": #{message}."
|
||||
end
|
||||
|
||||
defp do_message(_), do: "."
|
||||
end
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Error.Changes.InvalidValue do
|
||||
@moduledoc "Used when an invalid value is provided for a change"
|
||||
defmodule Ash.Error.Changes.InvalidAttribute do
|
||||
@moduledoc "Used when an invalid value is provided for an attribute change"
|
||||
use Ash.Error
|
||||
|
||||
def_ash_error([:field, :type, :message], class: :invalid)
|
||||
|
@ -7,7 +7,7 @@ defmodule Ash.Error.Changes.InvalidValue do
|
|||
defimpl Ash.ErrorKind do
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
||||
def code(_), do: "invalid_change_value"
|
||||
def code(_), do: "invalid_attribute"
|
||||
|
||||
def message(error) do
|
||||
"Invalid value#{for_type(error)}provided#{for_field(error)}#{do_message(error)}"
|
||||
|
|
20
lib/ash/error/changes/no_such_attribute.ex
Normal file
20
lib/ash/error/changes/no_such_attribute.ex
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Ash.Error.Changes.NoSuchAttribute do
|
||||
@moduledoc "Used when a change is provided for an attribute that does not exist"
|
||||
use Ash.Error
|
||||
|
||||
def_ash_error([:resource, :name], class: :invalid)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
||||
def code(_), do: "no_such_attribute"
|
||||
|
||||
def message(error) do
|
||||
"No such attribute #{error.name} for resource #{inspect(error.resource)}"
|
||||
end
|
||||
|
||||
def description(error) do
|
||||
"No such attribute #{error.name} for resource #{inspect(error.resource)}"
|
||||
end
|
||||
end
|
||||
end
|
20
lib/ash/error/changes/no_such_relationship.ex
Normal file
20
lib/ash/error/changes/no_such_relationship.ex
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Ash.Error.Changes.NoSuchRelationship do
|
||||
@moduledoc "Used when a change is provided for an relationship that does not exist"
|
||||
use Ash.Error
|
||||
|
||||
def_ash_error([:resource, :name], class: :invalid)
|
||||
|
||||
defimpl Ash.ErrorKind do
|
||||
def id(_), do: Ecto.UUID.generate()
|
||||
|
||||
def code(_), do: "no_such_relationship"
|
||||
|
||||
def message(error) do
|
||||
"No such relationship #{error.name} for resource #{inspect(error.resource)}"
|
||||
end
|
||||
|
||||
def description(error) do
|
||||
"No such relationship #{error.name} for resource #{inspect(error.resource)}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,36 +0,0 @@
|
|||
defmodule Ash.Error.Changeset do
|
||||
@moduledoc false
|
||||
alias Ash.Error.Changes.{InvalidValue, UnknownError}
|
||||
|
||||
def changeset_to_errors(resource, changeset) do
|
||||
errors = Ecto.Changeset.traverse_errors(changeset, & &1)
|
||||
|
||||
Enum.flat_map(errors, fn {field, errors} ->
|
||||
Enum.map(errors, &to_error(resource, field, &1))
|
||||
end)
|
||||
end
|
||||
|
||||
defp to_error(_resource, field, {"is invalid", [type: type, validation: :cast]}) do
|
||||
InvalidValue.exception(field: field, type: type)
|
||||
end
|
||||
|
||||
defp to_error(_resource, field, {error, _}) when is_bitstring(error) do
|
||||
InvalidValue.exception(field: field, message: error)
|
||||
end
|
||||
|
||||
defp to_error(_resource, field, error) do
|
||||
if Exception.exception?(error) do
|
||||
error.exception(field: field)
|
||||
else
|
||||
UnknownError.exception(field: field, error: error)
|
||||
end
|
||||
end
|
||||
|
||||
def traverse_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -46,6 +46,9 @@ defmodule Ash.Resource do
|
|||
@doc false
|
||||
alias Ash.Dsl.Extension
|
||||
|
||||
Module.register_attribute(__MODULE__, :is_ash_resource, persist: true, accumulate: false)
|
||||
@is_ash_resource true
|
||||
|
||||
:persistent_term.put({__MODULE__, :data_layer}, @data_layer)
|
||||
:persistent_term.put({__MODULE__, :authorizers}, @authorizers)
|
||||
|
||||
|
@ -75,4 +78,10 @@ defmodule Ash.Resource do
|
|||
Ash.Schema.define_schema()
|
||||
end
|
||||
end
|
||||
|
||||
def resource?(module) when is_atom(module) do
|
||||
module.module_info(:attributes)[:is_ash_resource] == [true]
|
||||
end
|
||||
|
||||
def resource?(_), do: false
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Resource.Actions.Create do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a create action on a resource."
|
||||
defstruct [:name, :primary?, type: :create]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Resource.Actions.Destroy do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a destroy action on a resource."
|
||||
|
||||
defstruct [:name, :primary?, type: :destroy]
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Resource.Actions.Read do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a read action on a resource."
|
||||
|
||||
defstruct [:name, :primary?, type: :read]
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Resource.Actions.Update do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a update action on a resource."
|
||||
|
||||
defstruct [:name, :primary?, type: :update]
|
||||
|
||||
|
|
|
@ -53,7 +53,8 @@ defmodule Ash.Resource.Attribute do
|
|||
generated?: [
|
||||
type: :boolean,
|
||||
default: false,
|
||||
doc: "Whether or not the value may be generated by the data layer"
|
||||
doc:
|
||||
"Whether or not the value may be generated by the data layer. If it is, the data layer will know to read the value back after writing."
|
||||
],
|
||||
writable?: [
|
||||
type: :boolean,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Ash.Resource.Relationships.BelongsTo do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a belongs_to relationship on a resource"
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:destination,
|
||||
|
@ -9,6 +10,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
:destination_field,
|
||||
:source_field,
|
||||
:source,
|
||||
:writable?,
|
||||
cardinality: :one,
|
||||
type: :belongs_to
|
||||
]
|
||||
|
@ -16,6 +18,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do
|
|||
@type t :: %__MODULE__{
|
||||
type: :belongs_to,
|
||||
cardinality: :one,
|
||||
writable?: boolean,
|
||||
name: atom,
|
||||
source: Ash.resource(),
|
||||
destination: Ash.resource(),
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
defmodule Ash.Resource.Relationships.HasMany do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a has_many relationship on a resource"
|
||||
defstruct [
|
||||
:name,
|
||||
:destination,
|
||||
:destination_field,
|
||||
:source_field,
|
||||
:source,
|
||||
:writable?,
|
||||
cardinality: :many,
|
||||
type: :has_many
|
||||
]
|
||||
|
@ -14,6 +15,7 @@ defmodule Ash.Resource.Relationships.HasMany do
|
|||
type: :has_many,
|
||||
cardinality: :many,
|
||||
source: Ash.resource(),
|
||||
writable?: boolean,
|
||||
name: atom,
|
||||
type: Ash.Type.t(),
|
||||
destination: Ash.resource(),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Ash.Resource.Relationships.HasOne do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a has_one relationship on a resource"
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:source,
|
||||
|
@ -7,6 +8,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
:destination_field,
|
||||
:source_field,
|
||||
:allow_orphans?,
|
||||
:writable?,
|
||||
cardinality: :one,
|
||||
type: :has_one
|
||||
]
|
||||
|
@ -15,6 +17,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
type: :has_one,
|
||||
cardinality: :one,
|
||||
source: Ash.resource(),
|
||||
writable?: boolean,
|
||||
name: atom,
|
||||
type: Ash.Type.t(),
|
||||
destination: Ash.resource(),
|
||||
|
@ -23,8 +26,8 @@ defmodule Ash.Resource.Relationships.HasOne do
|
|||
allow_orphans?: boolean
|
||||
}
|
||||
|
||||
import Ash.Resource.Relationships.SharedOptions
|
||||
alias Ash.OptionsHelpers
|
||||
import Ash.Resource.Relationships.SharedOptions
|
||||
|
||||
@global_opts shared_options()
|
||||
|> OptionsHelpers.make_required!(:destination_field)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
defmodule Ash.Resource.Relationships.ManyToMany do
|
||||
@moduledoc false
|
||||
@moduledoc "Represents a many_to_many relationship on a resource"
|
||||
defstruct [
|
||||
:name,
|
||||
:source,
|
||||
|
@ -9,6 +9,8 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
:destination_field,
|
||||
:source_field_on_join_table,
|
||||
:destination_field_on_join_table,
|
||||
:join_relationship,
|
||||
:writable?,
|
||||
cardinality: :many,
|
||||
type: :many_to_many
|
||||
]
|
||||
|
@ -17,9 +19,11 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
type: :many_to_many,
|
||||
cardinality: :many,
|
||||
source: Ash.resource(),
|
||||
writable?: boolean,
|
||||
name: atom,
|
||||
through: Ash.resource(),
|
||||
destination: Ash.resource(),
|
||||
join_relationship: atom,
|
||||
source_field: atom,
|
||||
destination_field: atom,
|
||||
source_field_on_join_table: atom,
|
||||
|
@ -51,6 +55,12 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
type: :atom,
|
||||
required: true,
|
||||
doc: "The resource to use as the join resource."
|
||||
],
|
||||
join_relationship: [
|
||||
type: :atom,
|
||||
required: false,
|
||||
doc:
|
||||
"The has_many relationship to the join table. Defaults to <relationship_name>_join_assoc"
|
||||
]
|
||||
],
|
||||
@global_opts,
|
||||
|
@ -59,4 +69,11 @@ defmodule Ash.Resource.Relationships.ManyToMany do
|
|||
|
||||
@doc false
|
||||
def opt_schema, do: @opt_schema
|
||||
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
def transform(%{join_relationship: nil, name: name} = relationship) do
|
||||
{:ok, %{relationship | join_relationship: String.to_atom("#{name}_join_assoc")}}
|
||||
end
|
||||
|
||||
def transform(relationship), do: {:ok, relationship}
|
||||
end
|
||||
|
|
|
@ -19,6 +19,11 @@ defmodule Ash.Resource.Relationships.SharedOptions do
|
|||
type: :atom,
|
||||
doc:
|
||||
"The field on this resource that should match the `destination_field` on the related resource."
|
||||
],
|
||||
writable?: [
|
||||
type: :boolean,
|
||||
doc: "Whether or not the relationship may be edited.",
|
||||
default: true
|
||||
]
|
||||
]
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ defmodule Ash.Resource.Transformers.BelongsToAttribute do
|
|||
case Transformer.build_entity(@extension, [:attributes], :attribute,
|
||||
name: relationship.source_field,
|
||||
type: relationship.field_type,
|
||||
writable?: false,
|
||||
primary_key?: relationship.primary_key?
|
||||
) do
|
||||
{:ok, attribute} ->
|
||||
|
|
|
@ -11,24 +11,19 @@ defmodule Ash.Resource.Transformers.CreateJoinRelationship do
|
|||
|
||||
@extension Ash.Dsl
|
||||
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
def transform(_resource, dsl_state) do
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:relationships], @extension)
|
||||
|> Enum.filter(&(&1.type == :many_to_many))
|
||||
|> Enum.reject(fn relationship ->
|
||||
has_many_name = to_string(relationship.name) <> "_join_assoc"
|
||||
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:relationships], @extension)
|
||||
|> Enum.find(&(to_string(&1.name) == has_many_name))
|
||||
|> Enum.find(&(&1.name == relationship.join_relationship))
|
||||
end)
|
||||
|> Enum.reduce({:ok, dsl_state}, fn relationship, {:ok, dsl_state} ->
|
||||
has_many_name = String.to_atom(to_string(relationship.name) <> "_join_assoc")
|
||||
|
||||
{:ok, relationship} =
|
||||
Transformer.build_entity(@extension, [:relationships], :has_many,
|
||||
name: has_many_name,
|
||||
name: relationship.join_relationship,
|
||||
destination: relationship.through,
|
||||
destination_field: relationship.source_field_on_join_table,
|
||||
source_field: relationship.source_field
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
defmodule Ash.Resource.Transformers.ValidateRelationshipAttributes do
|
||||
@moduledoc """
|
||||
Sets the default `source_field` for belongs_to attributes
|
||||
"""
|
||||
use Ash.Dsl.Transformer
|
||||
|
||||
alias Ash.Dsl.Transformer
|
||||
|
||||
@extension Ash.Dsl
|
||||
|
||||
def after_compile?, do: true
|
||||
|
||||
# sobelow_skip ["DOS.BinToAtom"]
|
||||
def transform(_resource, dsl_state) do
|
||||
attribute_names =
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:attributes], @extension)
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
dsl_state
|
||||
|> Transformer.get_entities([:relationships], @extension)
|
||||
|> Enum.each(&validate_relationship(&1, attribute_names))
|
||||
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
|
||||
defp validate_relationship(relationship, attribute_names) do
|
||||
case Code.ensure_compiled(relationship.destination) do
|
||||
{:error, _} ->
|
||||
# If the resource doesn't exist/can't be compiled
|
||||
# a different error will catch that. This failure
|
||||
# most likely implies that we are compiling resources
|
||||
# in the same file as eachother.
|
||||
:ok
|
||||
|
||||
{:module, _module} ->
|
||||
unless relationship.source_field in attribute_names do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
path: [:relationships, relationship.name],
|
||||
message:
|
||||
"Relationship `#{relationship.name}` expects source field `#{
|
||||
relationship.source_field
|
||||
}` to be defined"
|
||||
end
|
||||
|
||||
destination_attributes =
|
||||
relationship.destination
|
||||
|> Ash.attributes()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
unless relationship.destination_field in destination_attributes do
|
||||
raise Ash.Error.ResourceDslError,
|
||||
path: [:relationships, relationship.name],
|
||||
message:
|
||||
"Relationship `#{relationship.name}` expects destination field `#{
|
||||
relationship.destination_field
|
||||
}` to be defined on #{inspect(relationship.destination)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -30,6 +30,8 @@ defmodule Ash.Type.Integer do
|
|||
def integer(value) when is_integer(value), do: {:ok, value}
|
||||
def integer(_), do: {:error, "must be an integer"}
|
||||
|
||||
def apply_constraints(nil, _), do: :ok
|
||||
|
||||
def apply_constraints(value, constraints) do
|
||||
errors =
|
||||
Enum.reduce(constraints, [], fn
|
||||
|
|
|
@ -31,6 +31,8 @@ defmodule Ash.Type.String do
|
|||
@impl true
|
||||
def constraints, do: @constraints
|
||||
|
||||
def apply_constraints(nil, _), do: :ok
|
||||
|
||||
def apply_constraints(value, constraints) do
|
||||
errors =
|
||||
Enum.reduce(constraints, [], fn
|
||||
|
|
|
@ -155,10 +155,7 @@ defmodule Ash.Type do
|
|||
{:ok, value}
|
||||
|
||||
:error ->
|
||||
{:error, %{message: "is invalid"}}
|
||||
|
||||
{:error, message} when is_bitstring(message) ->
|
||||
{:error, %{message: message}}
|
||||
{:error, "is invalid"}
|
||||
|
||||
{:error, other} ->
|
||||
{:error, other}
|
||||
|
|
9
mix.exs
9
mix.exs
|
@ -47,9 +47,8 @@ defmodule Ash.MixProject do
|
|||
entrypoint: [
|
||||
Ash,
|
||||
Ash.Api,
|
||||
Ash.Resource,
|
||||
Ash.Dsl,
|
||||
Ash.Query
|
||||
Ash.Query,
|
||||
Ash.Changeset
|
||||
],
|
||||
type: ~r/Ash.Type/,
|
||||
data_layer: ~r/Ash.DataLayer/,
|
||||
|
@ -64,7 +63,9 @@ defmodule Ash.MixProject do
|
|||
"api dsl transformers": ~r/Ash.Api.Transformers/,
|
||||
"api dsl": ~r/Ash.Api.Dsl/,
|
||||
"filter predicates": ~r/Ash.Filter.Predicate/,
|
||||
filter: ~r/Ash.Filter/
|
||||
filter: ~r/Ash.Filter/,
|
||||
"resource introspection": ~r/Ash.Resource/,
|
||||
"api introspection": ~r/Ash.Api/
|
||||
]
|
||||
]
|
||||
end
|
||||
|
|
|
@ -66,12 +66,13 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
attributes do
|
||||
attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0
|
||||
attribute :name, :string
|
||||
attribute :bio, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_one :profile, Profile, destination_field: :author_id
|
||||
|
||||
has_many :posts, Ash.Test.Actions.CreateTest.Post, destination_field: :author
|
||||
has_many :posts, Ash.Test.Actions.CreateTest.Post, destination_field: :author_id
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -125,6 +126,7 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
attribute :tag2, :string, default: &PostDefaults.garbage2/0
|
||||
attribute :tag3, :string, default: {PostDefaults, :garbage3, []}
|
||||
attribute :list_attribute, {:array, :integer}
|
||||
attribute :date, :date
|
||||
|
||||
attribute :list_attribute_with_constraints, {:array, :integer},
|
||||
constraints: [
|
||||
|
@ -157,40 +159,83 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "simple creates" do
|
||||
test "allows creating a record with valid attributes" do
|
||||
assert %Post{title: "foo", contents: "bar"} =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foo", contents: "bar", date: Date.utc_today()}
|
||||
)
|
||||
Post
|
||||
|> create()
|
||||
|> change_attributes(%{
|
||||
title: "foo",
|
||||
contents: "bar",
|
||||
date: Date.utc_today()
|
||||
})
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "constant default values are set properly" do
|
||||
assert %Post{tag: "garbage"} = Api.create!(Post, attributes: %{title: "foo"})
|
||||
assert %Post{tag: "garbage"} =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foo")
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "constant functions values are set properly" do
|
||||
assert %Post{tag2: "garbage2"} = Api.create!(Post, attributes: %{title: "foo"})
|
||||
assert %Post{tag2: "garbage2"} =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foo")
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "constant module/function values are set properly" do
|
||||
assert %Post{tag3: "garbage3"} = Api.create!(Post, attributes: %{title: "foo"})
|
||||
assert %Post{tag3: "garbage3"} =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foo")
|
||||
|> Api.create!()
|
||||
end
|
||||
end
|
||||
|
||||
describe "creating many to many relationships" do
|
||||
test "allows creating with a many_to_many relationship" do
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post2 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title2")
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Post, relationships: %{related_posts: [post2.id, post3.id]})
|
||||
post3 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title3")
|
||||
|> Api.create!()
|
||||
|
||||
Post
|
||||
|> create()
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "it updates the join table properly" do
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post2 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title2")
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Post, relationships: %{related_posts: [post2.id, post3.id]})
|
||||
post3 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title3")
|
||||
|> Api.create!()
|
||||
|
||||
Post
|
||||
|> create()
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.create!()
|
||||
|
||||
assert [_, _] =
|
||||
PostLink
|
||||
|
@ -199,12 +244,25 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
end
|
||||
|
||||
test "it responds with the relationship filled in" do
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post2 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title2")
|
||||
|> Api.create!()
|
||||
|
||||
assert Enum.sort(
|
||||
Api.create!(Post, relationships: %{related_posts: [post2.id, post3.id]}).related_posts
|
||||
) ==
|
||||
post3 =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "title3")
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Post
|
||||
|> create()
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.create!()
|
||||
|
||||
assert Enum.sort(post.related_posts) ==
|
||||
Enum.sort([
|
||||
Api.get!(Post, post2.id),
|
||||
Api.get!(Post, post3.id)
|
||||
|
@ -214,34 +272,48 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
|
||||
describe "creating with has_one relationships" do
|
||||
test "allows creating with has_one relationship" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:name, "fred")
|
||||
|> replace_relationship(:profile, profile)
|
||||
end
|
||||
|
||||
test "it sets the relationship on the destination record accordingly" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:name, "fred")
|
||||
|> replace_relationship(:profile, profile)
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.get!(Profile, profile.id).author_id == author.id
|
||||
end
|
||||
|
||||
test "it responds with the relationshi filled in" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:name, "fred")
|
||||
|> replace_relationship(:profile, profile)
|
||||
|> Api.create!()
|
||||
|
||||
assert author.profile.author_id == author.id
|
||||
end
|
||||
|
@ -249,100 +321,125 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
|
||||
describe "creating with a has_many relationship" do
|
||||
test "allows creating with a has_many relationship" do
|
||||
post = Api.create!(Post, attributes: %{title: "sup"})
|
||||
post =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "sup")
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Author,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
posts: [post.id]
|
||||
}
|
||||
)
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:name, "foobar")
|
||||
|> replace_relationship(:posts, [post])
|
||||
|> Api.create!()
|
||||
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"})
|
||||
author =
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
)
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foobar")
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
end
|
||||
|
||||
test "it sets the relationship on the destination record accordingly" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
)
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foobar")
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.get!(Post, post.id).author_id == author.id
|
||||
end
|
||||
|
||||
test "it responds with the relationship field filled in" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
).author_id == author.id
|
||||
post =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foobar")
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
assert post.author_id == author.id
|
||||
end
|
||||
|
||||
test "it responds with the relationship filled in" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create()
|
||||
|> change_attribute(:bio, "best dude")
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
).author == author
|
||||
post =
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:title, "foobar")
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
assert post.author == author
|
||||
end
|
||||
end
|
||||
|
||||
describe "list type" do
|
||||
test "it can store a list" do
|
||||
assert Api.create!(Post,
|
||||
attributes: %{list_attribute: [1, 2, 3, 4]}
|
||||
)
|
||||
assert Post
|
||||
|> create()
|
||||
|> change_attribute(:list_attribute, [1, 2, 3, 4])
|
||||
|> Api.create!()
|
||||
end
|
||||
end
|
||||
|
||||
describe "list type constraints" do
|
||||
test "it honors min_length" do
|
||||
assert_raise Ash.Error.Invalid, ~r/must have more than 2 items/, fn ->
|
||||
Api.create!(Post,
|
||||
attributes: %{list_attribute_with_constraints: []}
|
||||
)
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:list_attribute_with_constraints, [])
|
||||
|> Api.create!()
|
||||
end
|
||||
end
|
||||
|
||||
test "it honors max_length" do
|
||||
assert_raise Ash.Error.Invalid, ~r/must have fewer than 10 items/, fn ->
|
||||
Api.create!(Post,
|
||||
attributes: %{
|
||||
list_attribute_with_constraints: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||
}
|
||||
)
|
||||
list = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:list_attribute_with_constraints, list)
|
||||
|> Api.create!()
|
||||
end
|
||||
end
|
||||
|
||||
test "it honors item constraints" do
|
||||
assert_raise Ash.Error.Invalid, ~r/must be less than `10` at index 0/, fn ->
|
||||
Api.create!(Post,
|
||||
attributes: %{
|
||||
list_attribute_with_constraints: [28, 2, 4]
|
||||
}
|
||||
)
|
||||
list = [28, 2, 4]
|
||||
|
||||
Post
|
||||
|> create()
|
||||
|> change_attribute(:list_attribute_with_constraints, list)
|
||||
|> Api.create!()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -350,7 +447,10 @@ defmodule Ash.Test.Actions.CreateTest do
|
|||
describe "unauthorized create" do
|
||||
test "it does not create the record" do
|
||||
assert_raise(Ash.Error.Forbidden, fn ->
|
||||
Api.create!(Authorized, attributes: %{name: "foo"}, authorize?: true)
|
||||
Authorized
|
||||
|> create()
|
||||
|> change_attribute(:name, "foo")
|
||||
|> Api.create!(authorize?: true)
|
||||
end)
|
||||
|
||||
assert [] = Api.read!(Authorized)
|
||||
|
|
|
@ -100,9 +100,14 @@ defmodule Ash.Test.Actions.DestroyTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "simple destroy" do
|
||||
test "allows destroying a record" do
|
||||
post = Api.create!(Post, attributes: %{title: "foo", contents: "bar"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "foo", contents: "bar"})
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.destroy!(post) == :ok
|
||||
|
||||
|
@ -110,7 +115,10 @@ defmodule Ash.Test.Actions.DestroyTest do
|
|||
end
|
||||
|
||||
test "the destroy does not happen if it is unauthorized" do
|
||||
author = Api.create!(Author, attributes: %{name: "foobar"})
|
||||
author =
|
||||
Author
|
||||
|> create(%{name: "foobar"})
|
||||
|> Api.create!()
|
||||
|
||||
assert_raise(Ash.Error.Forbidden, fn ->
|
||||
Api.destroy!(author, authorize?: true)
|
||||
|
|
|
@ -21,7 +21,7 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
end
|
||||
|
||||
relationships do
|
||||
has_many :posts, Ash.Test.Actions.ReadTest.Post, destination_field: :author
|
||||
has_many :posts, Ash.Test.Actions.ReadTest.Post, destination_field: :author1_id
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -60,9 +60,15 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "api.get/3" do
|
||||
setup do
|
||||
{:ok, post} = Api.create(Post, attributes: %{title: "test", contents: "yeet"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post: post}
|
||||
end
|
||||
|
||||
|
@ -79,7 +85,11 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "api.get!/3" do
|
||||
setup do
|
||||
{:ok, post} = Api.create(Post, attributes: %{title: "test", contents: "yeet"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post: post}
|
||||
end
|
||||
|
||||
|
@ -108,8 +118,15 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "api.read/2" do
|
||||
setup do
|
||||
{:ok, post1} = Api.create(Post, attributes: %{title: "test", contents: "yeet"})
|
||||
{:ok, post2} = Api.create(Post, attributes: %{title: "test1", contents: "yeet2"})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "test1", contents: "yeet2"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post1: post1, post2: post2}
|
||||
end
|
||||
|
@ -139,8 +156,15 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "api.read!/2" do
|
||||
setup do
|
||||
{:ok, post1} = Api.create(Post, attributes: %{title: "test", contents: "yeet"})
|
||||
{:ok, post2} = Api.create(Post, attributes: %{title: "test1", contents: "yeet2"})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "test1", contents: "yeet2"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post1: post1, post2: post2}
|
||||
end
|
||||
|
@ -164,8 +188,15 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "filters" do
|
||||
setup do
|
||||
{:ok, post1} = Api.create(Post, attributes: %{title: "test", contents: "yeet"})
|
||||
{:ok, post2} = Api.create(Post, attributes: %{title: "test1", contents: "yeet"})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "test1", contents: "yeet"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post1: post1, post2: post2}
|
||||
end
|
||||
|
@ -197,14 +228,22 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "relationship filters" do
|
||||
setup do
|
||||
author1 = Api.create!(Author, attributes: %{name: "bruh"})
|
||||
author2 = Api.create!(Author, attributes: %{name: "bruh"})
|
||||
author1 =
|
||||
Author
|
||||
|> create(%{name: "bruh"})
|
||||
|> Api.create!()
|
||||
|
||||
{:ok, post} =
|
||||
Api.create(Post,
|
||||
attributes: %{title: "test", contents: "yeet"},
|
||||
relationships: %{author1: author1.id, author2: author2.id}
|
||||
)
|
||||
author2 =
|
||||
Author
|
||||
|> create(%{name: "bruh"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "test", contents: "yeet"})
|
||||
|> replace_relationship(:author1, author1)
|
||||
|> replace_relationship(:author2, author2)
|
||||
|> Api.create!()
|
||||
|
||||
%{post: post, author1: author1, author2: author2}
|
||||
end
|
||||
|
@ -226,8 +265,15 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
|
||||
describe "sort" do
|
||||
setup do
|
||||
{:ok, post1} = Api.create(Post, attributes: %{title: "abc", contents: "abc"})
|
||||
{:ok, post2} = Api.create(Post, attributes: %{title: "xyz", contents: "abc"})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "abc", contents: "abc"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "xyz", contents: "abc"})
|
||||
|> Api.create!()
|
||||
|
||||
%{post1: post1, post2: post2}
|
||||
end
|
||||
|
@ -253,7 +299,10 @@ defmodule Ash.Test.Actions.ReadTest do
|
|||
end
|
||||
|
||||
test "a nested sort sorts accordingly", %{post1: post1, post2: post2} do
|
||||
{:ok, middle_post} = Api.create(Post, attributes: %{title: "abc", contents: "xyz"})
|
||||
middle_post =
|
||||
Post
|
||||
|> create(%{title: "abc", contents: "xyz"})
|
||||
|> Api.create!()
|
||||
|
||||
assert {:ok, [^post1, ^middle_post, ^post2]} =
|
||||
Post
|
||||
|
|
|
@ -123,15 +123,26 @@ defmodule Ash.Test.Actions.SideLoadTest do
|
|||
:ok
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "side_loads" do
|
||||
test "it allows sideloading related data" do
|
||||
author = Api.create!(Author, attributes: %{name: "zerg"})
|
||||
author =
|
||||
Author
|
||||
|> create(%{name: "zerg"})
|
||||
|> Api.create!()
|
||||
|
||||
post1 =
|
||||
Api.create!(Post, attributes: %{title: "post1"}, relationships: %{author: author.id})
|
||||
Post
|
||||
|> create(%{title: "post1"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Api.create!(Post, attributes: %{title: "post2"}, relationships: %{author: author.id})
|
||||
Post
|
||||
|> create(%{title: "post2"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
[author] =
|
||||
Author
|
||||
|
@ -148,14 +159,21 @@ defmodule Ash.Test.Actions.SideLoadTest do
|
|||
end
|
||||
|
||||
test "it allows sideloading many to many relationships" do
|
||||
category1 = Api.create!(Category, attributes: %{name: "lame"})
|
||||
category2 = Api.create!(Category, attributes: %{name: "cool"})
|
||||
category1 =
|
||||
Category
|
||||
|> create(%{name: "lame"})
|
||||
|> Api.create!()
|
||||
|
||||
category2 =
|
||||
Category
|
||||
|> create(%{name: "cool"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "post1"},
|
||||
relationships: %{categories: [category1, category2]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "post1"})
|
||||
|> replace_relationship(:categories, [category1, category2])
|
||||
|> Api.create!()
|
||||
|
||||
[post] =
|
||||
Post
|
||||
|
@ -169,14 +187,21 @@ defmodule Ash.Test.Actions.SideLoadTest do
|
|||
end
|
||||
|
||||
test "it allows sideloading nested many to many relationships" do
|
||||
category1 = Api.create!(Category, attributes: %{name: "lame"})
|
||||
category2 = Api.create!(Category, attributes: %{name: "cool"})
|
||||
category1 =
|
||||
Category
|
||||
|> create(%{name: "lame"})
|
||||
|> Api.create!()
|
||||
|
||||
category2 =
|
||||
Category
|
||||
|> create(%{name: "cool"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "post1"},
|
||||
relationships: %{categories: [category1, category2]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "post1"})
|
||||
|> replace_relationship(:categories, [category1, category2])
|
||||
|> Api.create!()
|
||||
|
||||
[post] =
|
||||
Post
|
||||
|
|
|
@ -137,42 +137,87 @@ defmodule Ash.Test.Actions.UpdateTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "simple updates" do
|
||||
test "allows updating a record with valid attributes" do
|
||||
post = Api.create!(Post, attributes: %{title: "foo", contents: "bar"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "foo", contents: "bar"})
|
||||
|> Api.create!()
|
||||
|
||||
assert %Post{title: "bar", contents: "foo"} =
|
||||
Api.update!(post, attributes: %{title: "bar", contents: "foo"})
|
||||
post |> update(%{title: "bar", contents: "foo"}) |> Api.update!()
|
||||
end
|
||||
end
|
||||
|
||||
describe "updating many to many relationships" do
|
||||
test "allows updating with a many_to_many relationship" do
|
||||
post = Api.create!(Post, attributes: %{title: "title"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "title"})
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(post, relationships: %{related_posts: [post2.id, post3.id]})
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "title2"})
|
||||
|> Api.create!()
|
||||
|
||||
post3 =
|
||||
Post
|
||||
|> create(%{title: "title3"})
|
||||
|> Api.create!()
|
||||
|
||||
post
|
||||
|> update()
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.update!()
|
||||
end
|
||||
|
||||
test "it updates the join table properly" do
|
||||
post = Api.create!(Post, attributes: %{title: "title"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "title"})
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(post, relationships: %{related_posts: [post2.id, post3.id]})
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "title2"})
|
||||
|> Api.create!()
|
||||
|
||||
post3 =
|
||||
Post
|
||||
|> create(%{title: "title3"})
|
||||
|> Api.create!()
|
||||
|
||||
post
|
||||
|> update()
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.update!()
|
||||
|
||||
assert [_, _] = Api.read!(PostLink)
|
||||
end
|
||||
|
||||
test "it responds with the relationship filled in" do
|
||||
post = Api.create!(Post, attributes: %{title: "title"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "title3"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "title"})
|
||||
|> Api.create!()
|
||||
|
||||
assert Enum.sort(
|
||||
Api.update!(post, relationships: %{related_posts: [post2.id, post3.id]}).related_posts
|
||||
) ==
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "title2"})
|
||||
|> Api.create!()
|
||||
|
||||
post3 =
|
||||
Post
|
||||
|> create(%{title: "title3"})
|
||||
|> Api.create!()
|
||||
|
||||
new_post =
|
||||
post |> update() |> replace_relationship(:related_posts, [post2, post3]) |> Api.update!()
|
||||
|
||||
assert Enum.sort(new_post.related_posts) ==
|
||||
Enum.sort([
|
||||
Api.get!(Post, post2.id),
|
||||
Api.get!(Post, post3.id)
|
||||
|
@ -182,168 +227,243 @@ defmodule Ash.Test.Actions.UpdateTest do
|
|||
|
||||
describe "updating with has_one relationships" do
|
||||
test "allows updating with has_one relationship" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile2 = Api.create!(Profile, attributes: %{bio: "second best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create(%{bio: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
profile2 =
|
||||
Profile
|
||||
|> create(%{bio: "second best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "fred"})
|
||||
|> replace_relationship(:profile, profile)
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(author, relationships: %{profile: profile2.id})
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:profile, profile2)
|
||||
|> Api.update!()
|
||||
end
|
||||
|
||||
test "it sets the relationship on the destination record accordingly" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile2 = Api.create!(Profile, attributes: %{bio: "second best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create(%{bio: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
profile2 =
|
||||
Profile
|
||||
|> create(%{bio: "second best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "fred"})
|
||||
|> replace_relationship(:profile, profile)
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(author, relationships: %{profile: profile2.id})
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:profile, profile2)
|
||||
|> Api.update!()
|
||||
|
||||
assert Api.get!(Profile, profile.id).author_id == nil
|
||||
assert Api.get!(Profile, profile2.id).author_id == author.id
|
||||
end
|
||||
|
||||
test "it responds with the relationship filled in" do
|
||||
profile = Api.create!(Profile, attributes: %{bio: "best dude"})
|
||||
profile2 = Api.create!(Profile, attributes: %{bio: "second best dude"})
|
||||
profile =
|
||||
Profile
|
||||
|> create(%{bio: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
profile2 =
|
||||
Profile
|
||||
|> create(%{bio: "second best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{name: "fred"},
|
||||
relationships: %{profile: profile.id}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "fred"})
|
||||
|> replace_relationship(:profile, profile)
|
||||
|> Api.create!()
|
||||
|
||||
updated_author =
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:profile, profile2)
|
||||
|> Api.update!()
|
||||
|
||||
updated_author = Api.update!(author, relationships: %{profile: profile2.id})
|
||||
assert updated_author.profile == %{profile2 | author_id: author.id}
|
||||
end
|
||||
end
|
||||
|
||||
describe "updating with a has_many relationship" do
|
||||
test "allows updating with a has_many relationship" do
|
||||
post = Api.create!(Post, attributes: %{title: "sup"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "sup2"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "sup"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "sup2"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
posts: [post.id]
|
||||
}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "foobar"})
|
||||
|> replace_relationship(:posts, [post])
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(author,
|
||||
relationships: %{
|
||||
posts: [post.id, post2.id]
|
||||
}
|
||||
)
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:posts, [post, post2])
|
||||
|> Api.update!()
|
||||
end
|
||||
|
||||
test "it sets the relationship on the destination records accordingly" do
|
||||
post = Api.create!(Post, attributes: %{title: "sup"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "sup2"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "sup"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "sup2"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
posts: [post.id]
|
||||
}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "foobar"})
|
||||
|> replace_relationship(:posts, [post])
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.update!(author,
|
||||
relationships: %{
|
||||
posts: [post2.id]
|
||||
}
|
||||
)
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:posts, [post2.id])
|
||||
|> Api.update!()
|
||||
|
||||
assert Api.get!(Post, post.id).author_id == nil
|
||||
assert Api.get!(Post, post2.id).author_id == author.id
|
||||
end
|
||||
|
||||
test "it responds with the relationship field filled in" do
|
||||
post = Api.create!(Post, attributes: %{title: "sup"})
|
||||
post2 = Api.create!(Post, attributes: %{title: "sup2"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "sup"})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "sup2"})
|
||||
|> Api.create!()
|
||||
|
||||
author =
|
||||
Api.create!(Author,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
posts: [post.id]
|
||||
}
|
||||
)
|
||||
Author
|
||||
|> create(%{name: "foobar"})
|
||||
|> replace_relationship(:posts, [post])
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.update!(author,
|
||||
relationships: %{
|
||||
posts: [post2.id]
|
||||
}
|
||||
).posts == [Api.get!(Post, post2.id)]
|
||||
updated_author =
|
||||
author
|
||||
|> update()
|
||||
|> replace_relationship(:posts, [post2])
|
||||
|> Api.update!()
|
||||
|
||||
assert updated_author.posts == [Api.get!(Post, post2.id)]
|
||||
end
|
||||
end
|
||||
|
||||
describe "updating with belongs_to relationships" do
|
||||
test "allows updating with belongs_to relationship" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author2 = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create(%{name: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author2 =
|
||||
Author
|
||||
|> create(%{name: "best dude2"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "foobar"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(post, relationships: %{author: author2.id})
|
||||
post
|
||||
|> update()
|
||||
|> replace_relationship(:author, author2)
|
||||
|> Api.update!()
|
||||
end
|
||||
|
||||
test "sets the relationship on the destination records accordingly" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author2 = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create(%{name: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author2 =
|
||||
Author
|
||||
|> create(%{name: "best dude2"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "foobar"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
Api.update!(post, relationships: %{author: author2.id})
|
||||
post
|
||||
|> update()
|
||||
|> replace_relationship(:author, author2)
|
||||
|> Api.update!()
|
||||
|
||||
assert Api.get!(Author, author2.id, side_load: [:posts]).posts == [Api.get!(Post, post.id)]
|
||||
end
|
||||
|
||||
test "it responds with the relationship field filled in" do
|
||||
author = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author2 = Api.create!(Author, attributes: %{bio: "best dude"})
|
||||
author =
|
||||
Author
|
||||
|> create(%{name: "best dude"})
|
||||
|> Api.create!()
|
||||
|
||||
author2 =
|
||||
Author
|
||||
|> create(%{name: "best dude2"})
|
||||
|> Api.create!()
|
||||
|
||||
post =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "foobar"},
|
||||
relationships: %{
|
||||
author: author.id
|
||||
}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "foobar"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
assert Api.update!(post, relationships: %{author: author2.id}).author ==
|
||||
updated_post = post |> update() |> replace_relationship(:author, author2) |> Api.update!()
|
||||
|
||||
assert updated_post.author ==
|
||||
Api.get!(Author, author2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "unauthorized update" do
|
||||
test "it does not update the record" do
|
||||
record = Api.create!(Authorized, attributes: %{name: "bar"})
|
||||
record =
|
||||
Authorized
|
||||
|> create(%{name: "bar"})
|
||||
|> Api.create!()
|
||||
|
||||
assert_raise(Ash.Error.Forbidden, fn ->
|
||||
Api.update!(record, attributes: %{name: "foo"}, authorize?: true)
|
||||
record
|
||||
|> update(%{name: "foo"})
|
||||
|> Api.update!(authorize?: true)
|
||||
end)
|
||||
|
||||
assert Api.get!(Authorized, record.id).name == "bar"
|
||||
|
|
|
@ -134,12 +134,17 @@ defmodule Ash.Test.Filter.FilterInteractionTest do
|
|||
end)
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
test "mnesia data layer sanity test" do
|
||||
post = Api.create!(Post, attributes: %{title: "best"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "best"})
|
||||
|> Api.create!()
|
||||
|
||||
assert [^post] = Api.read!(Post)
|
||||
|
||||
Api.update!(post, attributes: %{title: "worst"})
|
||||
post |> update(%{title: "worst"}) |> Api.update!()
|
||||
|
||||
new_post = %{post | title: "worst"}
|
||||
|
||||
|
@ -152,15 +157,20 @@ defmodule Ash.Test.Filter.FilterInteractionTest do
|
|||
|
||||
describe "cross data layer filtering" do
|
||||
test "it properly filters with a simple filter" do
|
||||
author = Api.create!(User, attributes: %{name: "best author"})
|
||||
author =
|
||||
User
|
||||
|> create(%{name: "best author"})
|
||||
|> Api.create!()
|
||||
|
||||
post1 =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "best"},
|
||||
relationships: %{author: author}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "best"})
|
||||
|> replace_relationship(:author, author)
|
||||
|> Api.create!()
|
||||
|
||||
Api.create!(Post, attributes: %{title: "worst"})
|
||||
Post
|
||||
|> create(%{title: "worst"})
|
||||
|> Api.create!()
|
||||
|
||||
post1 = Api.reload!(post1)
|
||||
|
||||
|
@ -172,14 +182,20 @@ defmodule Ash.Test.Filter.FilterInteractionTest do
|
|||
end
|
||||
|
||||
test "parallelizable filtering of related resources with a data layer that cannot join" do
|
||||
post2 = Api.create!(Post, attributes: %{title: "two"})
|
||||
Api.create!(Post, attributes: %{title: "three"})
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "two"})
|
||||
|> Api.create!()
|
||||
|
||||
Post
|
||||
|> create(%{title: "three"})
|
||||
|> Api.create!()
|
||||
|
||||
post1 =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "one"},
|
||||
relationships: %{related_posts: [post2]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "one"})
|
||||
|> replace_relationship(:related_posts, [post2])
|
||||
|> Api.create!()
|
||||
|
||||
query =
|
||||
Post
|
||||
|
@ -191,14 +207,21 @@ defmodule Ash.Test.Filter.FilterInteractionTest do
|
|||
end
|
||||
|
||||
test "parallelizable filter with filtered side loads" do
|
||||
post2 = Api.create!(Post, attributes: %{title: "two"})
|
||||
post3 = Api.create!(Post, attributes: %{title: "three"})
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "two"})
|
||||
|> Api.create!()
|
||||
|
||||
post3 =
|
||||
Post
|
||||
|> create(%{title: "three"})
|
||||
|> Api.create!()
|
||||
|
||||
post1 =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "one"},
|
||||
relationships: %{related_posts: [post2, post3]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "one"})
|
||||
|> replace_relationship(:related_posts, [post2, post3])
|
||||
|> Api.create!()
|
||||
|
||||
posts_query =
|
||||
Post
|
||||
|
|
|
@ -129,10 +129,19 @@ defmodule Ash.Test.Filter.FilterTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
describe "simple attribute filters" do
|
||||
setup do
|
||||
post1 = Api.create!(Post, attributes: %{title: "title1", contents: "contents1", points: 1})
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2", contents: "contents2", points: 2})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "title1", contents: "contents1", points: 1})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "title2", contents: "contents2", points: 2})
|
||||
|> Api.create!()
|
||||
|
||||
%{post1: post1, post2: post2}
|
||||
end
|
||||
|
@ -203,32 +212,51 @@ defmodule Ash.Test.Filter.FilterTest do
|
|||
|
||||
describe "relationship filters" do
|
||||
setup do
|
||||
post1 = Api.create!(Post, attributes: %{title: "title1", contents: "contents1", points: 1})
|
||||
post2 = Api.create!(Post, attributes: %{title: "title2", contents: "contents2", points: 2})
|
||||
post1 =
|
||||
Post
|
||||
|> create(%{title: "title1", contents: "contents1", points: 1})
|
||||
|> Api.create!()
|
||||
|
||||
post2 =
|
||||
Post
|
||||
|> create(%{title: "title2", contents: "contents2", points: 2})
|
||||
|> Api.create!()
|
||||
|
||||
post3 =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "title3", contents: "contents3", points: 3},
|
||||
relationships: %{related_posts: [post1, post2]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "title3", contents: "contents3", points: 3})
|
||||
|> replace_relationship(:related_posts, [post1, post2])
|
||||
|> Api.create!()
|
||||
|
||||
post4 =
|
||||
Api.create!(Post,
|
||||
attributes: %{title: "title4", contents: "contents3", points: 4},
|
||||
relationships: %{related_posts: [post3]}
|
||||
)
|
||||
Post
|
||||
|> create(%{title: "title4", contents: "contents4", points: 4})
|
||||
|> replace_relationship(:related_posts, [post3])
|
||||
|> Api.create!()
|
||||
|
||||
profile1 = Api.create!(Profile, attributes: %{bio: "dope"})
|
||||
profile1 =
|
||||
Profile
|
||||
|> create(%{bio: "dope"})
|
||||
|> Api.create!()
|
||||
|
||||
user1 =
|
||||
Api.create!(User,
|
||||
attributes: %{name: "broseph"},
|
||||
relationships: %{posts: [post1, post2], profile: profile1}
|
||||
)
|
||||
User
|
||||
|> create(%{name: "broseph"})
|
||||
|> replace_relationship(:posts, [post1, post2])
|
||||
|> replace_relationship(:profile, profile1)
|
||||
|> Api.create!()
|
||||
|
||||
user2 = Api.create!(User, attributes: %{name: "broseph"}, relationships: %{posts: [post2]})
|
||||
user2 =
|
||||
User
|
||||
|> create(%{name: "broseph"})
|
||||
|> replace_relationship(:posts, [post2])
|
||||
|> Api.create!()
|
||||
|
||||
profile2 = Api.create!(Profile, attributes: %{bio: "dope2"}, relationships: %{user: user2})
|
||||
profile2 =
|
||||
Profile
|
||||
|> create(%{bio: "dope2"})
|
||||
|> replace_relationship(:user, user2)
|
||||
|> Api.create!()
|
||||
|
||||
%{
|
||||
post1: Api.reload!(post1),
|
||||
|
|
|
@ -158,7 +158,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
|
|||
)
|
||||
end
|
||||
|
||||
test "fails if the destination resource doesn't have the correct field" do
|
||||
test "fails in api initialization if the destination resource doesn't have the correct field" do
|
||||
assert_raise(
|
||||
Ash.Error.ResourceDslError,
|
||||
~r/Relationship `post` expects source field `post_id` to be defined/,
|
||||
|
@ -168,6 +168,14 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do
|
|||
belongs_to :post, __MODULE__, define_field?: false
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
use Ash.Api
|
||||
|
||||
resources do
|
||||
resource Post
|
||||
end
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
|
|
@ -70,17 +70,22 @@ defmodule Ash.Test.Type.TypeTest do
|
|||
end
|
||||
end
|
||||
|
||||
import Ash.Changeset
|
||||
|
||||
test "it accepts valid data" do
|
||||
post = Api.create!(Post, attributes: %{title: "foobar"})
|
||||
post =
|
||||
Post
|
||||
|> create(%{title: "foobar"})
|
||||
|> Api.create!()
|
||||
|
||||
assert post.title == "foobar"
|
||||
end
|
||||
|
||||
test "it rejects invalid data" do
|
||||
# As we add informative errors, this test will fail and we will know to test those
|
||||
# more informative errors.
|
||||
assert_raise(Ash.Error.Invalid, ~r/is too long, max_length is 10/, fn ->
|
||||
Api.create!(Post, attributes: %{title: "foobarbazbuzbiz"})
|
||||
Post
|
||||
|> create(%{title: "foobarbazbuzbiz"})
|
||||
|> Api.create!()
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue