improvement: add Ash.Seed module with seed helpers

This commit is contained in:
Zach Daniel 2022-06-20 17:01:28 -04:00
parent 118f62b55e
commit 1d50c7aa79
8 changed files with 600 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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