mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
improvement: add iterative, streaming implementation for bulk updates
This commit is contained in:
parent
1cb5e2860d
commit
8c17434803
11 changed files with 1977 additions and 142 deletions
1228
lib/ash/actions/update/bulk.ex
Normal file
1228
lib/ash/actions/update/bulk.ex
Normal file
File diff suppressed because it is too large
Load diff
|
@ -387,47 +387,17 @@ defmodule Ash.Api do
|
||||||
"Shared create/update/destroy Options"
|
"Shared create/update/destroy Options"
|
||||||
)
|
)
|
||||||
|
|
||||||
@bulk_create_opts_schema [
|
@shared_bulk_opts_schema [
|
||||||
upsert?: [
|
|
||||||
type: :boolean,
|
|
||||||
default: false,
|
|
||||||
doc:
|
|
||||||
"If a conflict is found based on the primary key, the record is updated in the database (requires upsert support)"
|
|
||||||
],
|
|
||||||
max_concurrency: [
|
|
||||||
type: :non_neg_integer,
|
|
||||||
default: 0,
|
|
||||||
doc:
|
|
||||||
"If set to a value greater than 0, up to that many tasks will be started to run batches asynchronously"
|
|
||||||
],
|
|
||||||
assume_casted?: [
|
assume_casted?: [
|
||||||
type: :boolean,
|
type: :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
doc:
|
doc:
|
||||||
"Whether or not to cast attributes and arguments as input. This is an optimization for cases where the input is already casted and/or not in need of casting"
|
"Whether or not to cast attributes and arguments as input. This is an optimization for cases where the input is already casted and/or not in need of casting"
|
||||||
],
|
],
|
||||||
upsert_identity: [
|
|
||||||
type: :atom,
|
|
||||||
doc:
|
|
||||||
"The identity to use when detecting conflicts for `upsert?`, e.g. `upsert_identity: :full_name`. By default, the primary key is used. Has no effect if `upsert?: true` is not provided"
|
|
||||||
],
|
|
||||||
context: [
|
context: [
|
||||||
type: :map,
|
type: :map,
|
||||||
doc: "Context to set on each changeset"
|
doc: "Context to set on each changeset"
|
||||||
],
|
],
|
||||||
upsert_fields: [
|
|
||||||
type:
|
|
||||||
{:or,
|
|
||||||
[
|
|
||||||
{:literal, :replace_all},
|
|
||||||
{:tuple, [{:literal, :replace}, {:wrap_list, :atom}]},
|
|
||||||
{:tuple,
|
|
||||||
[{:literal, :replace_all_except}, {:wrap_list, :atom}]},
|
|
||||||
{:wrap_list, :atom}
|
|
||||||
]},
|
|
||||||
doc:
|
|
||||||
"The fields to upsert. If not set, the action's `upsert_fields` is used. Unlike singular `create`, `bulk_create` with `upsert?` requires that `upsert_fields` be specified explicitly in one of these two locations."
|
|
||||||
],
|
|
||||||
sorted?: [
|
sorted?: [
|
||||||
type: :boolean,
|
type: :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
@ -493,15 +463,77 @@ defmodule Ash.Api do
|
||||||
`before_transaction` and `after_transaction` hooks attached to changesets will have to be run
|
`before_transaction` and `after_transaction` hooks attached to changesets will have to be run
|
||||||
*inside* the transaction if you choose `transaction: :all`.
|
*inside* the transaction if you choose `transaction: :all`.
|
||||||
"""
|
"""
|
||||||
|
],
|
||||||
|
max_concurrency: [
|
||||||
|
type: :non_neg_integer,
|
||||||
|
default: 0,
|
||||||
|
doc:
|
||||||
|
"If set to a value greater than 0, up to that many tasks will be started to run batches asynchronously"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
@bulk_update_opts_schema [
|
||||||
|
resource: [
|
||||||
|
type: {:spark, Ash.Resource},
|
||||||
|
doc:
|
||||||
|
"The resource being updated. This must be provided if the input given is a stream, so we know ahead of time what the resource being updated is."
|
||||||
|
],
|
||||||
|
atomic_update: [
|
||||||
|
type: :map,
|
||||||
|
doc:
|
||||||
|
"A map of atomic updates to apply. See `Ash.Changeset.atomic_update/3` for more."
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|> merge_schemas(
|
|> merge_schemas(
|
||||||
Keyword.delete(@global_opts, :action),
|
Keyword.delete(@global_opts, :action),
|
||||||
"Global Options"
|
"Global options"
|
||||||
)
|
)
|
||||||
|> merge_schemas(
|
|> merge_schemas(
|
||||||
@shared_created_update_and_destroy_opts_schema,
|
@shared_created_update_and_destroy_opts_schema,
|
||||||
"Shared create/update/destroy Options"
|
"Shared create/update/destroy options"
|
||||||
|
)
|
||||||
|
|> merge_schemas(
|
||||||
|
@shared_bulk_opts_schema,
|
||||||
|
"Shared bulk options"
|
||||||
|
)
|
||||||
|
|
||||||
|
@bulk_create_opts_schema [
|
||||||
|
upsert?: [
|
||||||
|
type: :boolean,
|
||||||
|
default: false,
|
||||||
|
doc:
|
||||||
|
"If a conflict is found based on the primary key, the record is updated in the database (requires upsert support)"
|
||||||
|
],
|
||||||
|
upsert_identity: [
|
||||||
|
type: :atom,
|
||||||
|
doc:
|
||||||
|
"The identity to use when detecting conflicts for `upsert?`, e.g. `upsert_identity: :full_name`. By default, the primary key is used. Has no effect if `upsert?: true` is not provided"
|
||||||
|
],
|
||||||
|
upsert_fields: [
|
||||||
|
type:
|
||||||
|
{:or,
|
||||||
|
[
|
||||||
|
{:literal, :replace_all},
|
||||||
|
{:tuple, [{:literal, :replace}, {:wrap_list, :atom}]},
|
||||||
|
{:tuple,
|
||||||
|
[{:literal, :replace_all_except}, {:wrap_list, :atom}]},
|
||||||
|
{:wrap_list, :atom}
|
||||||
|
]},
|
||||||
|
doc:
|
||||||
|
"The fields to upsert. If not set, the action's `upsert_fields` is used. Unlike singular `create`, `bulk_create` with `upsert?` requires that `upsert_fields` be specified explicitly in one of these two locations."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|> merge_schemas(
|
||||||
|
Keyword.delete(@global_opts, :action),
|
||||||
|
"Global options"
|
||||||
|
)
|
||||||
|
|> merge_schemas(
|
||||||
|
@shared_created_update_and_destroy_opts_schema,
|
||||||
|
"Shared create/update/destroy options"
|
||||||
|
)
|
||||||
|
|> merge_schemas(
|
||||||
|
@shared_bulk_opts_schema,
|
||||||
|
"Shared bulk options"
|
||||||
)
|
)
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
@ -1264,24 +1296,19 @@ defmodule Ash.Api do
|
||||||
|
|
||||||
See `can/3` for more info.
|
See `can/3` for more info.
|
||||||
"""
|
"""
|
||||||
@callback can?(
|
|
||||||
query_or_changeset_or_action ::
|
|
||||||
Ash.Query.t()
|
|
||||||
| Ash.Changeset.t()
|
|
||||||
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()},
|
|
||||||
actor :: term,
|
|
||||||
opts :: Keyword.t()
|
|
||||||
) ::
|
|
||||||
boolean | no_return
|
|
||||||
|
|
||||||
@callback can?(
|
@callback can?(
|
||||||
query_or_changeset_or_action ::
|
query_or_changeset_or_action ::
|
||||||
Ash.Query.t()
|
Ash.Query.t()
|
||||||
| Ash.Changeset.t()
|
| Ash.Changeset.t()
|
||||||
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()},
|
| Ash.ActionInput.t()
|
||||||
actor :: term
|
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()}
|
||||||
|
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action(), input :: map},
|
||||||
|
actor :: term,
|
||||||
|
opts :: Keyword.t()
|
||||||
) ::
|
) ::
|
||||||
boolean | no_return
|
boolean | no_return
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns whether or not the user can perform the action, or `:maybe`, returning any errors.
|
Returns whether or not the user can perform the action, or `:maybe`, returning any errors.
|
||||||
|
|
||||||
|
@ -1303,24 +1330,21 @@ defmodule Ash.Api do
|
||||||
this mans adding field visibility calculations and altering the filter or the sort. For a changeset, this means only adding
|
this mans adding field visibility calculations and altering the filter or the sort. For a changeset, this means only adding
|
||||||
field visibility calculations. The default value is `false`.
|
field visibility calculations. The default value is `false`.
|
||||||
"""
|
"""
|
||||||
@callback can(
|
|
||||||
action_or_query_or_changeset ::
|
|
||||||
Ash.Query.t()
|
|
||||||
| Ash.Changeset.t()
|
|
||||||
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()},
|
|
||||||
actor :: term,
|
|
||||||
opts :: Keyword.t()
|
|
||||||
) ::
|
|
||||||
{:ok, boolean | :maybe} | {:error, term}
|
|
||||||
|
|
||||||
@callback can(
|
@callback can(
|
||||||
action_or_query_or_changeset ::
|
action_or_query_or_changeset ::
|
||||||
Ash.Query.t()
|
Ash.Query.t()
|
||||||
| Ash.Changeset.t()
|
| Ash.Changeset.t()
|
||||||
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()},
|
| Ash.ActionInput.t()
|
||||||
actor :: term
|
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action()}
|
||||||
|
| {Ash.Resource.t(), atom | Ash.Resource.Actions.action(), input :: map},
|
||||||
|
actor :: term,
|
||||||
|
opts :: Keyword.t()
|
||||||
) ::
|
) ::
|
||||||
{:ok, boolean | :maybe} | {:error, term}
|
{:ok, boolean | :maybe}
|
||||||
|
| {:ok, true, Ash.Changeset.t() | Ash.Query.t()}
|
||||||
|
| {:ok, false, Exception.t()}
|
||||||
|
| {:error, term}
|
||||||
|
|
||||||
@callback calculate(resource :: Ash.Resource.t(), calculation :: atom, opts :: Keyword.t()) ::
|
@callback calculate(resource :: Ash.Resource.t(), calculation :: atom, opts :: Keyword.t()) ::
|
||||||
{:ok, term} | {:error, term}
|
{:ok, term} | {:error, term}
|
||||||
|
@ -2290,6 +2314,81 @@ defmodule Ash.Api do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec bulk_update!(
|
||||||
|
Ash.Api.t(),
|
||||||
|
Enumerable.t(Ash.Resource.record()),
|
||||||
|
atom,
|
||||||
|
input :: map,
|
||||||
|
Keyword.t()
|
||||||
|
) ::
|
||||||
|
Ash.BulkResult.t() | no_return
|
||||||
|
def bulk_update!(api, stream_or_query, action, input, opts) do
|
||||||
|
api
|
||||||
|
|> bulk_update(stream_or_query, action, input, opts)
|
||||||
|
|> case do
|
||||||
|
%Ash.BulkResult{status: :error, errors: errors} when errors in [nil, []] ->
|
||||||
|
if opts[:return_errors?] do
|
||||||
|
raise Ash.Error.to_error_class(
|
||||||
|
Ash.Error.Unknown.UnknownError.exception(
|
||||||
|
error: "Something went wrong with bulk update, but no errors were produced."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
raise Ash.Error.to_error_class(
|
||||||
|
Ash.Error.Unknown.UnknownError.exception(
|
||||||
|
error:
|
||||||
|
"Something went wrong with bulk update, but no errors were produced due to `return_errors?` being set to `false`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
%Ash.BulkResult{status: :error, errors: errors} ->
|
||||||
|
raise Ash.Error.to_error_class(errors)
|
||||||
|
|
||||||
|
bulk_result ->
|
||||||
|
bulk_result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec bulk_update(
|
||||||
|
Ash.Api.t(),
|
||||||
|
Enumerable.t(Ash.Resource.record()),
|
||||||
|
atom,
|
||||||
|
input :: map,
|
||||||
|
Keyword.t()
|
||||||
|
) ::
|
||||||
|
Ash.BulkResult.t()
|
||||||
|
def bulk_update(api, query_or_stream, action, input, opts) do
|
||||||
|
case query_or_stream do
|
||||||
|
[] ->
|
||||||
|
result = %Ash.BulkResult{status: :success, errors: []}
|
||||||
|
|
||||||
|
result =
|
||||||
|
if opts[:return_records?] do
|
||||||
|
%{result | records: []}
|
||||||
|
else
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
if opts[:return_notifications?] do
|
||||||
|
%{result | notifications: []}
|
||||||
|
else
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
query_or_stream ->
|
||||||
|
case Spark.OptionsHelpers.validate(opts, @bulk_update_opts_schema) do
|
||||||
|
{:ok, opts} ->
|
||||||
|
Update.Bulk.run(api, query_or_stream, action, input, opts)
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
%Ash.BulkResult{status: :error, errors: [Ash.Error.to_ash_error(error)]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def update!(api, changeset, opts) do
|
def update!(api, changeset, opts) do
|
||||||
opts = Spark.OptionsHelpers.validate!(opts, @update_opts_schema)
|
opts = Spark.OptionsHelpers.validate!(opts, @update_opts_schema)
|
||||||
|
|
|
@ -359,6 +359,20 @@ defmodule Ash.Api.Interface do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bulk_update!(stream_or_query, action, input, opts \\ []) do
|
||||||
|
Api.bulk_update!(__MODULE__, stream_or_query, action, input, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_update(stream_or_query, action, input, opts \\ []) do
|
||||||
|
case Api.bulk_update(__MODULE__, stream_or_query, action, input, opts) do
|
||||||
|
{:error, error} ->
|
||||||
|
{:error, Ash.Error.to_error_class(error)}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def create!(changeset, params \\ []) do
|
def create!(changeset, params \\ []) do
|
||||||
Api.create!(__MODULE__, changeset, params)
|
Api.create!(__MODULE__, changeset, params)
|
||||||
end
|
end
|
||||||
|
|
|
@ -256,8 +256,6 @@ defmodule Ash.Changeset do
|
||||||
IO.warn("""
|
IO.warn("""
|
||||||
Changeset has already been validated for action #{inspect(changeset.__validated_for_action__)}.
|
Changeset has already been validated for action #{inspect(changeset.__validated_for_action__)}.
|
||||||
|
|
||||||
In the future, this will become an error.
|
|
||||||
|
|
||||||
For safety, we prevent any changes after that point because they will bypass validations or other action logic.. To proceed anyway,
|
For safety, we prevent any changes after that point because they will bypass validations or other action logic.. To proceed anyway,
|
||||||
you can use `#{unquote(alternative)}/#{unquote(arity)}`. However, you should prefer a pattern like the below, which makes
|
you can use `#{unquote(alternative)}/#{unquote(arity)}`. However, you should prefer a pattern like the below, which makes
|
||||||
any custom changes *before* calling the action.
|
any custom changes *before* calling the action.
|
||||||
|
@ -277,8 +275,6 @@ defmodule Ash.Changeset do
|
||||||
IO.warn("""
|
IO.warn("""
|
||||||
Changeset has already been validated for action #{inspect(changeset.__validated_for_action__)}.
|
Changeset has already been validated for action #{inspect(changeset.__validated_for_action__)}.
|
||||||
|
|
||||||
In the future, this will become an error.
|
|
||||||
|
|
||||||
For safety, we prevent any changes using `#{unquote(function)}/#{unquote(arity)}` after that point because they will bypass validations or other action logic.
|
For safety, we prevent any changes using `#{unquote(function)}/#{unquote(arity)}` after that point because they will bypass validations or other action logic.
|
||||||
Instead, you should change or set this value before calling the action, like so:
|
Instead, you should change or set this value before calling the action, like so:
|
||||||
|
|
||||||
|
@ -528,6 +524,8 @@ defmodule Ash.Changeset do
|
||||||
%{change: {module, change_opts}, where: where}, changeset ->
|
%{change: {module, change_opts}, where: where}, changeset ->
|
||||||
with {:atomic, atomic_changes} <- module.atomic(changeset, change_opts, context),
|
with {:atomic, atomic_changes} <- module.atomic(changeset, change_opts, context),
|
||||||
{:atomic, condition} <- atomic_condition(where, changeset) do
|
{:atomic, condition} <- atomic_condition(where, changeset) do
|
||||||
|
changeset = add_after_atomic(changeset, module, change_opts)
|
||||||
|
|
||||||
case condition do
|
case condition do
|
||||||
true ->
|
true ->
|
||||||
{:cont, atomic_update(changeset, atomic_changes)}
|
{:cont, atomic_update(changeset, atomic_changes)}
|
||||||
|
@ -558,8 +556,10 @@ defmodule Ash.Changeset do
|
||||||
end
|
end
|
||||||
|
|
||||||
%{validation: {module, validation_opts}, where: where}, changeset ->
|
%{validation: {module, validation_opts}, where: where}, changeset ->
|
||||||
with {:atomic, condition_expr, error_expr} <-
|
with {:atomic, fields, condition_expr, error_expr} <-
|
||||||
module.atomic(changeset, validation_opts),
|
module.atomic(changeset, validation_opts),
|
||||||
|
{:changing?, true} <-
|
||||||
|
{:changing?, Enum.any?(fields, &changing_attribute?(changeset, &1))},
|
||||||
{:atomic, condition} <- atomic_condition(where, changeset) do
|
{:atomic, condition} <- atomic_condition(where, changeset) do
|
||||||
case condition do
|
case condition do
|
||||||
true ->
|
true ->
|
||||||
|
@ -577,10 +577,28 @@ defmodule Ash.Changeset do
|
||||||
else
|
else
|
||||||
:not_atomic ->
|
:not_atomic ->
|
||||||
{:halt, :not_atomic}
|
{:halt, :not_atomic}
|
||||||
|
|
||||||
|
{:changing?, false} ->
|
||||||
|
{:cont, changeset}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp add_after_atomic(changeset, module, opts) do
|
||||||
|
if function_exported?(module, :after_atomic?, 3) do
|
||||||
|
after_action(changeset, fn changeset, result ->
|
||||||
|
context = %{
|
||||||
|
actor: changeset.context[:private][:actor],
|
||||||
|
tenant: changeset.tenant,
|
||||||
|
authorize?: changeset.context[:private][:authorize?],
|
||||||
|
tracer: changeset.context[:private][:tracer]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.after_atomic(changeset, opts, result, context)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp validate_atomically(changeset, condition_expr, error_expr) do
|
defp validate_atomically(changeset, condition_expr, error_expr) do
|
||||||
[first_pkey_field | _] = Ash.Resource.Info.primary_key(changeset.resource)
|
[first_pkey_field | _] = Ash.Resource.Info.primary_key(changeset.resource)
|
||||||
|
|
||||||
|
@ -612,7 +630,7 @@ defmodule Ash.Changeset do
|
||||||
Enum.reduce_while(where, {:atomic, true}, fn {module, validation_opts},
|
Enum.reduce_while(where, {:atomic, true}, fn {module, validation_opts},
|
||||||
{:atomic, condition} ->
|
{:atomic, condition} ->
|
||||||
case module.atomic(changeset, validation_opts) do
|
case module.atomic(changeset, validation_opts) do
|
||||||
{:atomic, expr, _as_error} ->
|
{:atomic, _, expr, _as_error} ->
|
||||||
new_expr =
|
new_expr =
|
||||||
if condition == true do
|
if condition == true do
|
||||||
expr
|
expr
|
||||||
|
@ -918,9 +936,9 @@ defmodule Ash.Changeset do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Adds atomic changes to the changeset
|
Adds multiple atomic changes to the changeset
|
||||||
|
|
||||||
i.e `Ash.Changeset.atomic_update(changeset, score: [Ash.Expr.expr(score + 1)])`
|
See `atomic_update/3` for more information.
|
||||||
"""
|
"""
|
||||||
@spec atomic_update(t(), map() | Keyword.t()) :: t()
|
@spec atomic_update(t(), map() | Keyword.t()) :: t()
|
||||||
def atomic_update(changeset, atomics) when is_list(atomics) or is_map(atomics) do
|
def atomic_update(changeset, atomics) when is_list(atomics) or is_map(atomics) do
|
||||||
|
@ -930,9 +948,44 @@ defmodule Ash.Changeset do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Adds an atomic change to the changeset
|
Adds an atomic change to the changeset.
|
||||||
|
|
||||||
i.e `Ash.Changeset.atomic_update(changeset, :score, [Ash.Expr.expr(score + 1)])`
|
Atomic changes are applied by the data layer, and as such have guarantees that are not
|
||||||
|
given by changes that are based on looking at the previous value and updating it. Here
|
||||||
|
is an example of a change that is not safe to do concurrently:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
change fn changeset, _ ->
|
||||||
|
Ash.Changeset.set_attribute(changeset, :score, changeset.data.score + 1)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
If two processes run this concurrently, they will both read the same value of `score`, and
|
||||||
|
set the new score to the same value. This means that one of the increments will be lost.
|
||||||
|
If you were to instead do this using `atomic_update`, you would get the correct result:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
Ash.Changeset.atomic_update(changeset, :score, [Ash.Expr.expr(score + 1)])
|
||||||
|
```
|
||||||
|
|
||||||
|
There are drawbacks/things to consider, however. The first is that atomic update results
|
||||||
|
are not known until after the action is run. The following functional validation would not
|
||||||
|
be able to enforce the score being less than 10, because the atomic happens after the validation.
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
validate fn changeset, _ ->
|
||||||
|
if Ash.Changeset.get_attribute(changeset, :score) < 10 do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, field: :score, message: "must be less than 10"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to use atomic updates, it is suggested to write module-based validations & changes,
|
||||||
|
and implement the appropriate atomic callbacks on those modules. All builtin validations and changes
|
||||||
|
implement these callbacks in addition to the standard callbacks. Validations will only be run atomically
|
||||||
|
when the entire action is being run atomically or if one of the relevant fields is being updated atomically.
|
||||||
"""
|
"""
|
||||||
@spec atomic_update(t(), atom(), {:atomic, Ash.Expr.t()} | Ash.Expr.t()) :: t()
|
@spec atomic_update(t(), atom(), {:atomic, Ash.Expr.t()} | Ash.Expr.t()) :: t()
|
||||||
def atomic_update(changeset, key, {:atomic, value}) do
|
def atomic_update(changeset, key, {:atomic, value}) do
|
||||||
|
@ -1512,7 +1565,7 @@ defmodule Ash.Changeset do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
|> unsafe_change_attribute(attribute.name, default(:create, attribute))
|
|> force_change_attribute(attribute.name, default(:create, attribute))
|
||||||
|> Map.update!(:defaults, fn defaults ->
|
|> Map.update!(:defaults, fn defaults ->
|
||||||
[attribute.name | defaults]
|
[attribute.name | defaults]
|
||||||
end)
|
end)
|
||||||
|
@ -1537,7 +1590,7 @@ defmodule Ash.Changeset do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
|> unsafe_change_attribute(attribute.name, default(:update, attribute))
|
|> force_change_attribute(attribute.name, default(:update, attribute))
|
||||||
|> Map.update!(:defaults, fn defaults ->
|
|> Map.update!(:defaults, fn defaults ->
|
||||||
[attribute.name | defaults]
|
[attribute.name | defaults]
|
||||||
end)
|
end)
|
||||||
|
@ -1605,7 +1658,7 @@ defmodule Ash.Changeset do
|
||||||
changeset
|
changeset
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
|> unsafe_change_attribute(attribute.name, default_value)
|
|> force_change_attribute(attribute.name, default_value)
|
||||||
|> Map.update!(:defaults, fn defaults ->
|
|> Map.update!(:defaults, fn defaults ->
|
||||||
[attribute.name | defaults]
|
[attribute.name | defaults]
|
||||||
end)
|
end)
|
||||||
|
@ -3520,7 +3573,8 @@ defmodule Ash.Changeset do
|
||||||
@doc "Returns true if an attribute exists in the changes"
|
@doc "Returns true if an attribute exists in the changes"
|
||||||
@spec changing_attribute?(t(), atom) :: boolean
|
@spec changing_attribute?(t(), atom) :: boolean
|
||||||
def changing_attribute?(changeset, attribute) do
|
def changing_attribute?(changeset, attribute) do
|
||||||
Map.has_key?(changeset.attributes, attribute)
|
Map.has_key?(changeset.attributes, attribute) ||
|
||||||
|
Keyword.has_key?(changeset.atomics, attribute)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Returns true if a relationship exists in the changes"
|
@doc "Returns true if a relationship exists in the changes"
|
||||||
|
@ -3866,20 +3920,6 @@ defmodule Ash.Changeset do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc "Calls `unsafe_change_attribute/3` for each key/value pair provided."
|
|
||||||
@spec unsafe_change_attributes(t(), map | Keyword.t()) :: t()
|
|
||||||
def unsafe_change_attributes(changeset, changes) do
|
|
||||||
Enum.reduce(changes, changeset, fn {key, value}, changeset ->
|
|
||||||
unsafe_change_attribute(changeset, key, value)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Changes an attribute even if it isn't writable, doing no type casting or validation"
|
|
||||||
@spec unsafe_change_attribute(t(), atom, any) :: t()
|
|
||||||
def unsafe_change_attribute(changeset, attribute, value) do
|
|
||||||
%{changeset | attributes: Map.put(changeset.attributes, attribute, value)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Adds a before_action hook to the changeset.
|
Adds a before_action hook to the changeset.
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,31 @@ defmodule Ash.Error.Invalid.NonStreamableAction do
|
||||||
@moduledoc "Used when Api.stream is used with an action that does not support keyset pagination"
|
@moduledoc "Used when Api.stream is used with an action that does not support keyset pagination"
|
||||||
use Ash.Error.Exception
|
use Ash.Error.Exception
|
||||||
|
|
||||||
def_ash_error([:resource, :action], class: :invalid)
|
def_ash_error([:resource, :action, :for_bulk_update], class: :invalid)
|
||||||
|
|
||||||
defimpl Ash.ErrorKind do
|
defimpl Ash.ErrorKind do
|
||||||
def id(_), do: Ash.UUID.generate()
|
def id(_), do: Ash.UUID.generate()
|
||||||
|
|
||||||
def code(_), do: "non_streamable_action"
|
def code(_), do: "non_streamable_action"
|
||||||
|
|
||||||
|
def message(%{for_bulk_update: action} = error) when not is_nil(action) do
|
||||||
|
"""
|
||||||
|
You are attempting to pair read action #{error.action.name} with bulk update
|
||||||
|
action #{action}, but #{inspect(error.resource)}.#{error.action.name} does not support streaming.
|
||||||
|
|
||||||
|
To enable it, keyset pagination to the action #{error.action.name}:
|
||||||
|
|
||||||
|
pagination keyset?: true, required?: false
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
def message(error) do
|
def message(error) do
|
||||||
"""
|
"""
|
||||||
Action #{inspect(error.resource)}.#{error.action.name} does not support streaming.
|
Action #{inspect(error.resource)}.#{error.action.name} does not support streaming.
|
||||||
|
|
||||||
To enable it, keyset pagination to the action #{error.action.name}:
|
To enable it, keyset pagination to the action #{error.action.name}:
|
||||||
|
|
||||||
pagination keyset?: true
|
pagination keyset?: true, required?: false
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -135,7 +135,7 @@ defmodule Ash.Resource.Change do
|
||||||
| {:non_atomic, Ash.Changeset.t()}
|
| {:non_atomic, Ash.Changeset.t()}
|
||||||
| {:error, term()}
|
| {:error, term()}
|
||||||
|
|
||||||
@callback after_atomic(Ash.Changeset.t(), Keyword.t(), Ash.Resource.record()) ::
|
@callback after_atomic(Ash.Changeset.t(), Keyword.t(), Ash.Resource.record(), context()) ::
|
||||||
{:ok, Ash.Resource.record()} | {:error, term()}
|
{:ok, Ash.Resource.record()} | {:error, term()}
|
||||||
|
|
||||||
@optional_callbacks before_batch: 3,
|
@optional_callbacks before_batch: 3,
|
||||||
|
@ -143,7 +143,7 @@ defmodule Ash.Resource.Change do
|
||||||
batch_change: 3,
|
batch_change: 3,
|
||||||
change: 3,
|
change: 3,
|
||||||
atomic: 3,
|
atomic: 3,
|
||||||
after_atomic: 3
|
after_atomic: 4
|
||||||
|
|
||||||
defmacro __using__(_) do
|
defmacro __using__(_) do
|
||||||
quote do
|
quote do
|
||||||
|
|
|
@ -48,7 +48,8 @@ defmodule Ash.Resource.Validation do
|
||||||
@callback atomic?() :: boolean
|
@callback atomic?() :: boolean
|
||||||
@callback atomic(changeset :: Ash.Changeset.t(), opts :: Keyword.t()) ::
|
@callback atomic(changeset :: Ash.Changeset.t(), opts :: Keyword.t()) ::
|
||||||
:ok
|
:ok
|
||||||
| {:atomic, condition_expr :: Ash.Expr.t(), error_expr :: Ash.Expr.t()}
|
| {:atomic, involved_fields :: list(atom), condition_expr :: Ash.Expr.t(),
|
||||||
|
error_expr :: Ash.Expr.t()}
|
||||||
| :not_atomic
|
| :not_atomic
|
||||||
| {:error, term()}
|
| {:error, term()}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ defmodule Ash.Resource.Validation.AttributeEquals do
|
||||||
def atomic(changeset, opts) do
|
def atomic(changeset, opts) do
|
||||||
field_value = Ash.Changeset.atomic_ref(changeset, opts[:attribute])
|
field_value = Ash.Changeset.atomic_ref(changeset, opts[:attribute])
|
||||||
|
|
||||||
{:atomic, Ash.Expr.expr(^field_value != ^opts[:value]),
|
{:atomic, [opts[:attribute]], Ash.Expr.expr(^field_value != ^opts[:value]),
|
||||||
Ash.Expr.expr(
|
Ash.Expr.expr(
|
||||||
error(^InvalidAttribute, %{
|
error(^InvalidAttribute, %{
|
||||||
field: ^opts[:attribute],
|
field: ^opts[:attribute],
|
||||||
|
|
|
@ -86,7 +86,7 @@ defmodule Ash.Test.Actions.BulkCreateTest do
|
||||||
|
|
||||||
identities do
|
identities do
|
||||||
identity :unique_title, :title do
|
identity :unique_title, :title do
|
||||||
pre_check_with Ash.Test.Actions.BulkCreateTest
|
pre_check_with Ash.Test.Actions.BulkCreateTest.Api
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
442
test/actions/bulk/bulk_update_test.exs
Normal file
442
test/actions/bulk/bulk_update_test.exs
Normal file
|
@ -0,0 +1,442 @@
|
||||||
|
defmodule Ash.Test.Actions.BulkUpdateTest do
|
||||||
|
@moduledoc false
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
defmodule AddAfterToTitle do
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
def change(changeset, _, %{bulk?: true}) do
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
def after_batch(results, _, _) do
|
||||||
|
Stream.map(results, fn {_changeset, result} ->
|
||||||
|
{:ok, %{result | title: result.title <> "_after"}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule AddBeforeToTitle do
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
def change(changeset, _, %{bulk?: true}) do
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
def before_batch(changesets, _, _) do
|
||||||
|
changesets
|
||||||
|
|> Stream.map(fn changeset ->
|
||||||
|
title = Ash.Changeset.get_attribute(changeset, :title)
|
||||||
|
Ash.Changeset.force_change_attribute(changeset, :title, "before_" <> title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Post do
|
||||||
|
@moduledoc false
|
||||||
|
use Ash.Resource,
|
||||||
|
data_layer: Ash.DataLayer.Ets,
|
||||||
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
|
ets do
|
||||||
|
private? true
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:create, :read, :update, :destroy]
|
||||||
|
|
||||||
|
update :update_with_change do
|
||||||
|
change fn changeset, _ ->
|
||||||
|
title = Ash.Changeset.get_attribute(changeset, :title)
|
||||||
|
Ash.Changeset.force_change_attribute(changeset, :title, title <> "_stuff")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_with_argument do
|
||||||
|
argument :a_title, :string do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
change set_attribute(:title2, arg(:a_title))
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_with_after_action do
|
||||||
|
change after_action(fn _changeset, result ->
|
||||||
|
{:ok, %{result | title: result.title <> "_stuff"}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_with_after_batch do
|
||||||
|
change AddAfterToTitle
|
||||||
|
change AddBeforeToTitle
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_with_after_transaction do
|
||||||
|
change after_transaction(fn _changeset, {:ok, result} ->
|
||||||
|
{:ok, %{result | title: result.title <> "_stuff"}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update_with_policy do
|
||||||
|
argument :authorize?, :boolean, allow_nil?: false
|
||||||
|
|
||||||
|
change set_context(%{authorize?: arg(:authorize?)})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_title, :title do
|
||||||
|
pre_check_with Ash.Test.Actions.BulkUpdateTest.Api
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
policies do
|
||||||
|
policy action(:update_with_policy) do
|
||||||
|
authorize_if context_equals(:authorize?, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id
|
||||||
|
attribute :title, :string, allow_nil?: false
|
||||||
|
attribute :title2, :string
|
||||||
|
attribute :title3, :string
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Api do
|
||||||
|
@moduledoc false
|
||||||
|
use Ash.Api
|
||||||
|
|
||||||
|
resources do
|
||||||
|
resource Post
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns updated records" do
|
||||||
|
assert %Ash.BulkResult{records: [%{title2: "updated value"}, %{title2: "updated value"}]} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update, %{title2: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs changes" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
records: [
|
||||||
|
%{title: "title1_stuff", title2: "updated value"},
|
||||||
|
%{title: "title2_stuff", title2: "updated value"}
|
||||||
|
]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update_with_change, %{title2: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
|> Map.update!(:records, fn records ->
|
||||||
|
Enum.sort_by(records, & &1.title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts arguments" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
records: [
|
||||||
|
%{title: "title1", title2: "updated value"},
|
||||||
|
%{title: "title2", title2: "updated value"}
|
||||||
|
]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update_with_argument, %{a_title: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
|> Map.update!(:records, fn records ->
|
||||||
|
Enum.sort_by(records, & &1.title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs after batch hooks" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
records: [
|
||||||
|
%{title: "before_title1_after", title2: "updated value"},
|
||||||
|
%{title: "before_title2_after", title2: "updated value"}
|
||||||
|
]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update_with_after_batch, %{title2: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
|> Map.update!(:records, fn records ->
|
||||||
|
Enum.sort_by(records, & &1.title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "will return error count" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
error_count: 2
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update(:update, %{title2: %{invalid: :value}},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "will return errors on request" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
error_count: 1,
|
||||||
|
errors: [%Ash.Changeset{}]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update(:update, %{title2: %{invalid: :value}},
|
||||||
|
resource: Post,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs after action hooks" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
records: [
|
||||||
|
%{title: "title1_stuff", title2: "updated value"},
|
||||||
|
%{title: "title2_stuff", title2: "updated value"}
|
||||||
|
]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update_with_after_action, %{title2: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
|> Map.update!(:records, fn records ->
|
||||||
|
Enum.sort_by(records, & &1.title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "runs after transaction hooks" do
|
||||||
|
assert %Ash.BulkResult{
|
||||||
|
records: [
|
||||||
|
%{title: "title1_stuff", title2: "updated value"},
|
||||||
|
%{title: "title2_stuff", title2: "updated value"}
|
||||||
|
]
|
||||||
|
} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update!(:update_with_after_transaction, %{title2: "updated value"},
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
|> Map.update!(:records, fn records ->
|
||||||
|
Enum.sort_by(records, & &1.title)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "authorization" do
|
||||||
|
test "policy success results in successes" do
|
||||||
|
assert %Ash.BulkResult{records: [_, _], errors: []} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update(
|
||||||
|
:update_with_policy,
|
||||||
|
%{title2: "updated value", authorize?: true},
|
||||||
|
authorize?: true,
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "policy failure results in failures" do
|
||||||
|
assert %Ash.BulkResult{errors: [_, _], records: []} =
|
||||||
|
Api.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
|
||||||
|
return_stream?: true,
|
||||||
|
return_records?: true
|
||||||
|
)
|
||||||
|
|> Stream.map(fn {:ok, result} ->
|
||||||
|
result
|
||||||
|
end)
|
||||||
|
|> Api.bulk_update(
|
||||||
|
:update_with_policy,
|
||||||
|
%{title2: "updated value", authorize?: false},
|
||||||
|
authorize?: true,
|
||||||
|
resource: Post,
|
||||||
|
return_records?: true,
|
||||||
|
return_errors?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# describe "streaming" do
|
||||||
|
# test "by default nothing is returned in the stream" do
|
||||||
|
# assert [] =
|
||||||
|
# [%{title: "title1", authorize?: true}, %{title: "title2", authorize?: true}]
|
||||||
|
# |> Api.bulk_create!(
|
||||||
|
# Post,
|
||||||
|
# :create_with_policy,
|
||||||
|
# authorize?: true,
|
||||||
|
# return_stream?: true
|
||||||
|
# )
|
||||||
|
# |> Enum.to_list()
|
||||||
|
# end
|
||||||
|
|
||||||
|
# test "by returning notifications, you get the notifications in the stream" do
|
||||||
|
# assert [{:notification, _}, {:notification, _}] =
|
||||||
|
# [%{title: "title1", authorize?: true}, %{title: "title2", authorize?: true}]
|
||||||
|
# |> Api.bulk_create!(
|
||||||
|
# Post,
|
||||||
|
# :create_with_policy,
|
||||||
|
# authorize?: true,
|
||||||
|
# return_stream?: true,
|
||||||
|
# notify?: true,
|
||||||
|
# return_notifications?: true
|
||||||
|
# )
|
||||||
|
# |> Enum.to_list()
|
||||||
|
# end
|
||||||
|
|
||||||
|
# test "by returning records, you get the records in the stream" do
|
||||||
|
# assert [{:ok, %{title: "title1"}}, {:ok, %{title: "title2"}}] =
|
||||||
|
# [%{title: "title1", authorize?: true}, %{title: "title2", authorize?: true}]
|
||||||
|
# |> Api.bulk_create!(
|
||||||
|
# Post,
|
||||||
|
# :create_with_policy,
|
||||||
|
# authorize?: true,
|
||||||
|
# return_stream?: true,
|
||||||
|
# return_records?: true
|
||||||
|
# )
|
||||||
|
# |> Enum.to_list()
|
||||||
|
# |> Enum.sort_by(fn
|
||||||
|
# {:ok, v} ->
|
||||||
|
# v.title
|
||||||
|
|
||||||
|
# _ ->
|
||||||
|
# nil
|
||||||
|
# end)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# test "by returning notifications and records, you get them both in the stream" do
|
||||||
|
# assert [
|
||||||
|
# {:notification, _},
|
||||||
|
# {:notification, _},
|
||||||
|
# {:ok, %{title: "title1"}},
|
||||||
|
# {:ok, %{title: "title2"}}
|
||||||
|
# ] =
|
||||||
|
# [%{title: "title1", authorize?: true}, %{title: "title2", authorize?: true}]
|
||||||
|
# |> Api.bulk_create!(
|
||||||
|
# Post,
|
||||||
|
# :create_with_policy,
|
||||||
|
# authorize?: true,
|
||||||
|
# notify?: true,
|
||||||
|
# return_stream?: true,
|
||||||
|
# return_notifications?: true,
|
||||||
|
# return_records?: true
|
||||||
|
# )
|
||||||
|
# |> Enum.to_list()
|
||||||
|
# |> Enum.sort_by(fn
|
||||||
|
# {:ok, v} ->
|
||||||
|
# v.title
|
||||||
|
|
||||||
|
# {:notification, _} ->
|
||||||
|
# true
|
||||||
|
|
||||||
|
# _ ->
|
||||||
|
# nil
|
||||||
|
# end)
|
||||||
|
# end
|
||||||
|
|
||||||
|
# test "any errors are also returned in the stream" do
|
||||||
|
# assert [
|
||||||
|
# {:error, %Ash.Changeset{}},
|
||||||
|
# {:notification, _},
|
||||||
|
# {:ok, %{title: "title1"}}
|
||||||
|
# ] =
|
||||||
|
# [
|
||||||
|
# %{title: "title1", authorize?: true},
|
||||||
|
# %{title: "title2", authorize?: false}
|
||||||
|
# ]
|
||||||
|
# |> Api.bulk_create!(
|
||||||
|
# Post,
|
||||||
|
# :create_with_policy,
|
||||||
|
# authorize?: true,
|
||||||
|
# notify?: true,
|
||||||
|
# return_stream?: true,
|
||||||
|
# return_notifications?: true,
|
||||||
|
# return_records?: true,
|
||||||
|
# return_errors?: true
|
||||||
|
# )
|
||||||
|
# |> Enum.to_list()
|
||||||
|
# |> Enum.sort_by(fn
|
||||||
|
# {:ok, v} ->
|
||||||
|
# v.title
|
||||||
|
|
||||||
|
# {:notification, _} ->
|
||||||
|
# true
|
||||||
|
|
||||||
|
# {:error, _} ->
|
||||||
|
# false
|
||||||
|
|
||||||
|
# _ ->
|
||||||
|
# nil
|
||||||
|
# end)
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Reference in a new issue