mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
improvement: add Ash.Seed
module with seed helpers
This commit is contained in:
parent
118f62b55e
commit
1d50c7aa79
8 changed files with 600 additions and 16 deletions
|
@ -217,8 +217,6 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
|
|||
|
||||
* fix expression logic
|
||||
|
||||
* remove IO.inspect
|
||||
|
||||
* don't throw away timeout exit
|
||||
|
||||
* timeouts @ the engine, not the parent process
|
||||
|
|
|
@ -230,7 +230,11 @@ defmodule Ash.Actions.Destroy do
|
|||
else
|
||||
case Ash.DataLayer.destroy(resource, changeset) do
|
||||
:ok ->
|
||||
{:ok, record}
|
||||
{:ok,
|
||||
%{
|
||||
record
|
||||
| __meta__: %Ecto.Schema.Metadata{state: :deleted, schema: resource}
|
||||
}}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
|
|
|
@ -1119,6 +1119,14 @@ defmodule Ash.Changeset do
|
|||
end
|
||||
|
||||
def with_hooks(changeset, func) do
|
||||
if changeset.valid? do
|
||||
run_around_actions(changeset, func)
|
||||
else
|
||||
{:error, changeset.errors}
|
||||
end
|
||||
end
|
||||
|
||||
defp run_around_actions(%{around_action: []} = changeset, func) do
|
||||
{changeset, %{notifications: before_action_notifications}} =
|
||||
Enum.reduce_while(
|
||||
changeset.before_action,
|
||||
|
@ -1153,14 +1161,6 @@ defmodule Ash.Changeset do
|
|||
end
|
||||
)
|
||||
|
||||
if changeset.valid? do
|
||||
run_around_actions(changeset, func, before_action_notifications)
|
||||
else
|
||||
{:error, changeset.errors}
|
||||
end
|
||||
end
|
||||
|
||||
defp run_around_actions(%{around_action: []} = changeset, func, before_action_notifications) do
|
||||
case func.(changeset) do
|
||||
{:ok, result, instructions} ->
|
||||
run_after_actions(
|
||||
|
@ -1177,6 +1177,15 @@ defmodule Ash.Changeset do
|
|||
end
|
||||
end
|
||||
|
||||
defp run_around_actions(
|
||||
%{around_action: [around | rest]} = changeset,
|
||||
func
|
||||
) do
|
||||
around.(changeset, fn changeset ->
|
||||
run_around_actions(%{changeset | around_action: rest}, func)
|
||||
end)
|
||||
end
|
||||
|
||||
defp run_after_actions(result, changeset, before_action_notifications) do
|
||||
Enum.reduce_while(
|
||||
changeset.after_action,
|
||||
|
@ -2507,7 +2516,75 @@ defmodule Ash.Changeset do
|
|||
%{changeset | after_action: changeset.after_action ++ [func]}
|
||||
end
|
||||
|
||||
@doc "Adds an around_action hook to the changeset."
|
||||
@doc """
|
||||
Adds an around_action hook to the changeset.
|
||||
|
||||
Your function will get the changeset, and a callback that must be called with a changeset (that may be modified).
|
||||
The callback will return `{:ok, result, instructions}` or `{:error, error}`. You can modify these values, but the
|
||||
return value must be one of those types. Instructions contains the notifications in its `notifications` key, i.e
|
||||
`%{notifications: [%Ash.Resource.Notification{}, ...]}`.
|
||||
|
||||
The around_action calls happen first, and then (after they each resolve their callbacks) the `before_action`
|
||||
hooks are called, followed by the action itself ocurring at the data layer and then the `after_action` hooks being run.
|
||||
Then, the code that appeared *after* the callbacks were called is then run.
|
||||
|
||||
For example:
|
||||
```elixir
|
||||
changeset
|
||||
|> Ash.Changeset.around_action(fn changeset, callback ->
|
||||
IO.puts("first around: before")
|
||||
result = callback.(changeset)
|
||||
IO.puts("first around: after")
|
||||
end)
|
||||
|> Ash.Changeset.around_action(fn changeset, callback ->
|
||||
IO.puts("second around: before")
|
||||
result = callback.(changeset)
|
||||
IO.puts("second around: after")
|
||||
end)
|
||||
|> Ash.Changeset.before_action(fn changeset ->
|
||||
IO.puts("first before")
|
||||
changeset
|
||||
end)
|
||||
|> Ash.Changeset.before_action(fn changeset ->
|
||||
IO.puts("second before")
|
||||
changeset
|
||||
end)
|
||||
|> Ash.Changeset.after_action(fn changeset, result ->
|
||||
IO.puts("first after")
|
||||
{:ok, result}
|
||||
end)
|
||||
|> Ash.Changeset.after_action(fn changeset ->
|
||||
IO.puts("second after")
|
||||
{:ok, result}
|
||||
end)
|
||||
```
|
||||
|
||||
This would print:
|
||||
```
|
||||
first around: before
|
||||
second around: before
|
||||
first before
|
||||
second before
|
||||
<-- action happens here
|
||||
first after
|
||||
second after
|
||||
second around: after <-- Notice that because of the callbacks, the after of the around hooks is reversed from the before
|
||||
first around: after
|
||||
```
|
||||
|
||||
Warning: using this without understanding how it works can cause big problems.
|
||||
You *must* call the callback function that is provided to your hook, and the return value must
|
||||
contain the same structure that was given to you, i.e `{:ok, result_of_action, instructions}`.
|
||||
|
||||
You can almost always get the same effect by using `before_action`, setting some context on the changeset
|
||||
and reading it out in an `after_action` hook.
|
||||
"""
|
||||
@type around_result ::
|
||||
{:ok, Ash.Resource.record(), t(), %{notifications: list(Ash.Resource.Notification.t())}}
|
||||
| {:error, Ash.Error.t()}
|
||||
|
||||
@type around_callback :: (t() -> around_result)
|
||||
@spec around_action(t(), (t(), around_callback() -> around_result)) :: t()
|
||||
def around_action(changeset, func) do
|
||||
%{changeset | around_action: changeset.around_action ++ [func]}
|
||||
end
|
||||
|
|
|
@ -263,7 +263,8 @@ defmodule Ash.DataLayer.Ets do
|
|||
_resource
|
||||
) do
|
||||
with {:ok, records} <- get_records(resource, tenant),
|
||||
{:ok, filtered_records} <- filter_matches(records, filter, api) do
|
||||
{:ok, filtered_records} <-
|
||||
filter_matches(records, filter, api) do
|
||||
offset_records =
|
||||
filtered_records
|
||||
|> Sort.runtime_sort(sort)
|
||||
|
@ -400,7 +401,7 @@ defmodule Ash.DataLayer.Ets do
|
|||
record <- unload_relationships(resource, record),
|
||||
{:ok, _} <-
|
||||
put_or_insert_new(table, {pkey, record}, opts) do
|
||||
{:ok, record}
|
||||
{:ok, %{record | __meta__: %Ecto.Schema.Metadata{state: :loaded, schema: resource}}}
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
|
|
|
@ -233,8 +233,11 @@ defmodule Ash.DataLayer.Mnesia do
|
|||
end)
|
||||
|
||||
case result do
|
||||
{:atomic, _} -> {:ok, record}
|
||||
{:aborted, error} -> {:error, error}
|
||||
{:atomic, _} ->
|
||||
{:ok, %{record | __meta__: %Ecto.Schema.Metadata{state: :loaded, schema: resource}}}
|
||||
|
||||
{:aborted, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
215
lib/ash/seed.ex
Normal file
215
lib/ash/seed.ex
Normal file
|
@ -0,0 +1,215 @@
|
|||
defmodule Ash.Seed do
|
||||
def seed!(%{__meta__: %{state: :loaded}} = input) do
|
||||
input
|
||||
end
|
||||
|
||||
def seed!(%resource{} = input) do
|
||||
input =
|
||||
input
|
||||
|> Map.from_struct()
|
||||
|> Enum.reduce(%{}, fn
|
||||
{_, %Ash.NotLoaded{}}, acc ->
|
||||
acc
|
||||
|
||||
{_, nil}, acc ->
|
||||
acc
|
||||
|
||||
{key, value}, acc ->
|
||||
if Ash.Resource.Info.attribute(resource, key) ||
|
||||
Ash.Resource.Info.relationship(resource, key) do
|
||||
Map.put(acc, key, value)
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
seed!(
|
||||
resource,
|
||||
input
|
||||
)
|
||||
end
|
||||
|
||||
def seed!(records) when is_list(records) do
|
||||
Enum.map(records, &seed!/1)
|
||||
end
|
||||
|
||||
def seed!(resource, input) when is_list(input) do
|
||||
Enum.map(input, &seed!(resource, &1))
|
||||
end
|
||||
|
||||
def seed!(resource, %resource{} = input) do
|
||||
seed!(input)
|
||||
end
|
||||
|
||||
def seed!(resource, %other{}) do
|
||||
raise "Cannot seed #{inspect(resource)} with an input of type #{inspect(other)}"
|
||||
end
|
||||
|
||||
def seed!(resource, input) when is_map(input) do
|
||||
resource
|
||||
|> Ash.Changeset.new()
|
||||
|> change_attributes(input)
|
||||
|> change_relationships(input)
|
||||
|> Ash.Changeset.set_defaults(:create, true)
|
||||
|> create_via_data_layer()
|
||||
|> case do
|
||||
{:ok, result, _, _} ->
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
raise Ash.Error.to_error_class(error)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_via_data_layer(changeset) do
|
||||
Ash.Changeset.with_hooks(changeset, fn changeset ->
|
||||
Ash.DataLayer.create(changeset.resource, changeset)
|
||||
end)
|
||||
end
|
||||
|
||||
defp change_attributes(changeset, input) do
|
||||
Enum.reduce(input, changeset, fn {key, value}, changeset ->
|
||||
case Ash.Resource.Info.attribute(changeset.resource, key) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
attribute ->
|
||||
Ash.Changeset.force_change_attribute(changeset, attribute.name, value)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp change_relationships(changeset, input) do
|
||||
Enum.reduce(input, changeset, fn {key, value}, changeset ->
|
||||
case Ash.Resource.Info.relationship(changeset.resource, key) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
%{
|
||||
type: :belongs_to,
|
||||
source_field: source_field,
|
||||
destination_field: destination_field,
|
||||
destination: destination,
|
||||
name: name
|
||||
} ->
|
||||
Ash.Changeset.around_action(changeset, fn changeset, callback ->
|
||||
related = seed!(destination, value)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.force_change_attribute(
|
||||
source_field,
|
||||
Map.get(related, destination_field)
|
||||
)
|
||||
|> callback.()
|
||||
|> case do
|
||||
{:ok, result, changeset, instructions} ->
|
||||
{:ok, Map.put(result, name, related), changeset, instructions}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
|
||||
%{
|
||||
type: :has_many,
|
||||
source_field: source_field,
|
||||
destination_field: destination_field,
|
||||
destination: destination,
|
||||
name: name
|
||||
} ->
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, result ->
|
||||
related =
|
||||
value
|
||||
|> List.wrap()
|
||||
|> Enum.map(
|
||||
&update_or_seed!(
|
||||
&1,
|
||||
destination,
|
||||
Map.get(result, source_field),
|
||||
destination_field
|
||||
)
|
||||
)
|
||||
|
||||
{:ok, Map.put(result, name, related)}
|
||||
end)
|
||||
|
||||
%{
|
||||
type: :has_one,
|
||||
source_field: source_field,
|
||||
destination_field: destination_field,
|
||||
destination: destination,
|
||||
name: name
|
||||
} ->
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, result ->
|
||||
if value do
|
||||
related =
|
||||
update_or_seed!(
|
||||
value,
|
||||
destination,
|
||||
Map.get(result, source_field),
|
||||
destination_field
|
||||
)
|
||||
|
||||
{:ok, Map.put(result, name, related)}
|
||||
else
|
||||
{:ok, Map.put(result, name, nil)}
|
||||
end
|
||||
end)
|
||||
|
||||
%{
|
||||
type: :many_to_many,
|
||||
source_field: source_field,
|
||||
source_field_on_join_table: source_field_on_join_table,
|
||||
destination_field_on_join_table: destination_field_on_join_table,
|
||||
join_relationship: join_relationship,
|
||||
destination_field: destination_field,
|
||||
destination: destination,
|
||||
through: through,
|
||||
name: name
|
||||
} ->
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, result ->
|
||||
related = seed!(destination, List.wrap(value))
|
||||
|
||||
through =
|
||||
Enum.map(related, fn related ->
|
||||
seed!(through, %{
|
||||
source_field_on_join_table => Map.get(result, source_field),
|
||||
destination_field_on_join_table => Map.get(related, destination_field)
|
||||
})
|
||||
end)
|
||||
|
||||
{:ok, Map.merge(result, %{name => related, join_relationship => through})}
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_or_seed!(
|
||||
%resource{} = record,
|
||||
resource,
|
||||
field_value,
|
||||
field
|
||||
) do
|
||||
record = seed!(record)
|
||||
|
||||
changeset =
|
||||
record
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.force_change_attribute(field, field_value)
|
||||
|
||||
case Ash.DataLayer.update(resource, changeset) do
|
||||
{:ok, result} ->
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
raise Ash.Error.to_error_class(error)
|
||||
end
|
||||
end
|
||||
|
||||
defp update_or_seed!(input, resource, field_value, field) do
|
||||
seed!(
|
||||
resource,
|
||||
Map.put(input, field, field_value)
|
||||
)
|
||||
end
|
||||
end
|
|
@ -147,6 +147,10 @@ defmodule Ash.Test.Actions.LoadTest do
|
|||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets
|
||||
|
||||
ets do
|
||||
private? true
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :rating, :integer
|
||||
|
|
282
test/seed_test.exs
Normal file
282
test/seed_test.exs
Normal file
|
@ -0,0 +1,282 @@
|
|||
defmodule Ash.Test.SeedTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Ash.Seed
|
||||
require Ash.Query
|
||||
|
||||
defmodule Author do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets,
|
||||
authorizers: [
|
||||
Ash.Test.Authorizer
|
||||
]
|
||||
|
||||
ets do
|
||||
private?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :posts, Ash.Test.SeedTest.Post, destination_field: :author_id
|
||||
|
||||
has_one :latest_post, Ash.Test.SeedTest.Post,
|
||||
destination_field: :author_id,
|
||||
sort: [inserted_at: :desc]
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Post do
|
||||
@moduledoc false
|
||||
use Ash.Resource, data_layer: Ash.DataLayer.Ets
|
||||
|
||||
ets do
|
||||
private?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :title, :string
|
||||
attribute :contents, :string
|
||||
attribute :category, :string
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, Author
|
||||
|
||||
has_many :ratings, Ash.Test.SeedTest.Rating do
|
||||
api Ash.Test.SeedTest.Api2
|
||||
end
|
||||
|
||||
many_to_many :categories, Ash.Test.SeedTest.Category,
|
||||
through: Ash.Test.SeedTest.PostCategory,
|
||||
destination_field_on_join_table: :category_id,
|
||||
source_field_on_join_table: :post_id
|
||||
end
|
||||
end
|
||||
|
||||
defmodule PostCategory do
|
||||
@moduledoc false
|
||||
use Ash.Resource, data_layer: Ash.DataLayer.Ets
|
||||
|
||||
ets do
|
||||
private?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Post, primary_key?: true, required?: true
|
||||
|
||||
belongs_to :category, Ash.Test.SeedTest.Category,
|
||||
primary_key?: true,
|
||||
required?: true
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Category do
|
||||
@moduledoc false
|
||||
use Ash.Resource, data_layer: Ash.DataLayer.Ets
|
||||
|
||||
ets do
|
||||
private?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :name, :string
|
||||
end
|
||||
|
||||
relationships do
|
||||
many_to_many :posts, Post,
|
||||
through: PostCategory,
|
||||
destination_field_on_join_table: :post_id,
|
||||
source_field_on_join_table: :category_id
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Rating do
|
||||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets
|
||||
|
||||
ets do
|
||||
private? true
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :rating, :integer
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, Post do
|
||||
api Ash.Test.SeedTest.Category
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Registry do
|
||||
@moduledoc false
|
||||
use Ash.Registry
|
||||
|
||||
entries do
|
||||
entry(Author)
|
||||
entry(Post)
|
||||
entry(Category)
|
||||
entry(PostCategory)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Registry2 do
|
||||
@moduledoc false
|
||||
use Ash.Registry
|
||||
|
||||
entries do
|
||||
entry(Rating)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api do
|
||||
@moduledoc false
|
||||
use Ash.Api
|
||||
|
||||
resources do
|
||||
registry Registry
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Api2 do
|
||||
@moduledoc false
|
||||
use Ash.Api
|
||||
|
||||
resources do
|
||||
registry Registry2
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
start_supervised(
|
||||
{Ash.Test.Authorizer,
|
||||
strict_check: :authorized,
|
||||
check: {:error, Ash.Error.Forbidden.exception([])},
|
||||
strict_check_context: [:query]}
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "seed!/1" do
|
||||
test "it creates a single record with resource and input" do
|
||||
assert %Post{id: id, title: "seeded"} = seed!(Post, %{title: "seeded"})
|
||||
|
||||
assert post = Api.get!(Post, id)
|
||||
assert post.title == "seeded"
|
||||
end
|
||||
|
||||
test "it creates a single record with a struct" do
|
||||
assert %Post{id: id, title: "seeded"} = seed!(%Post{title: "seeded"})
|
||||
|
||||
assert post = Api.get!(Post, id)
|
||||
assert post.title == "seeded"
|
||||
end
|
||||
|
||||
test "it creates related entities" do
|
||||
assert %Post{
|
||||
id: id,
|
||||
title: "seeded",
|
||||
categories: [%Category{name: "foo"}, %Category{name: "bar"}],
|
||||
author: %Author{name: "ted dansen"},
|
||||
ratings: [%Rating{rating: 1}, %Rating{rating: 2}]
|
||||
} =
|
||||
seed!(%Post{
|
||||
title: "seeded",
|
||||
categories: [%Category{name: "foo"}, %Category{name: "bar"}],
|
||||
author: %Author{name: "ted dansen"},
|
||||
ratings: [%Rating{rating: 1}, %Rating{rating: 2}]
|
||||
})
|
||||
|
||||
assert %Post{
|
||||
id: ^id,
|
||||
title: "seeded",
|
||||
categories: categories,
|
||||
author: %Author{name: "ted dansen"},
|
||||
ratings: ratings
|
||||
} = Post |> Api.get!(id) |> Api.load!([:categories, :author, :ratings])
|
||||
|
||||
assert categories |> Enum.map(& &1.name) |> Enum.sort() == ["bar", "foo"]
|
||||
assert ratings |> Enum.map(& &1.rating) |> Enum.sort() == [1, 2]
|
||||
end
|
||||
|
||||
test "it reuses entities that have been loaded (doesnt try to create a copy)" do
|
||||
assert %Post{
|
||||
id: id,
|
||||
title: "seeded",
|
||||
categories: [%Category{name: "foo"}, %Category{name: "bar"}],
|
||||
author: %Author{name: "ted dansen"},
|
||||
ratings: [%Rating{rating: 1}, %Rating{rating: 2}]
|
||||
} =
|
||||
seed!(%Post{
|
||||
title: "seeded",
|
||||
categories: [%Category{name: "foo"}, %Category{name: "bar"}],
|
||||
author: %Author{name: "ted dansen"},
|
||||
ratings: [%Rating{rating: 1}, %Rating{rating: 2}]
|
||||
})
|
||||
|
||||
assert %Post{
|
||||
id: ^id,
|
||||
title: "seeded",
|
||||
categories: categories,
|
||||
author: author,
|
||||
ratings: ratings
|
||||
} = Post |> Api.get!(id) |> Api.load!([:categories, :author, :ratings])
|
||||
|
||||
assert %Post{id: id} =
|
||||
seed!(%Post{
|
||||
title: "seeded2",
|
||||
categories: categories,
|
||||
author: author,
|
||||
ratings: ratings
|
||||
})
|
||||
|
||||
assert %Post{
|
||||
id: ^id,
|
||||
title: "seeded2",
|
||||
categories: categories,
|
||||
author: author,
|
||||
ratings: ratings
|
||||
} = Post |> Api.get!(id) |> Api.load!([:categories, :author, :ratings])
|
||||
|
||||
assert categories |> Enum.map(& &1.name) |> Enum.sort() == ["bar", "foo"]
|
||||
assert ratings |> Enum.map(& &1.rating) |> Enum.sort() == [1, 2]
|
||||
assert author.name == "ted dansen"
|
||||
|
||||
assert Enum.count(Api.read!(Category)) == 2
|
||||
assert Enum.count(Api.read!(Rating)) == 2
|
||||
assert Enum.count(Api.read!(Author)) == 1
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue