feat: refactor changes into changesets

This commit is contained in:
Zach Daniel 2020-07-12 02:25:53 -04:00
parent 06d960db04
commit 2cf41b966e
No known key found for this signature in database
GPG key ID: C377365383138D4B
54 changed files with 1848 additions and 1433 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,4 @@
defmodule Ash.Api.ResourceReference do
@moduledoc false
@moduledoc "Represents a resource in an API"
defstruct [:resource]
end

View 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

View 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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 =

View file

@ -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

View file

@ -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 ->

View file

@ -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

View file

@ -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

View 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

View file

@ -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)}"

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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__{

View file

@ -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]

View file

@ -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]

View file

@ -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]

View file

@ -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,

View file

@ -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(),

View file

@ -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(),

View file

@ -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)

View file

@ -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

View file

@ -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
]
]

View file

@ -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} ->

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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