From 1d50c7aa7902a626b289127448a4b5c68f363910 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Mon, 20 Jun 2022 17:01:28 -0400 Subject: [PATCH] improvement: add `Ash.Seed` module with seed helpers --- CHANGELOG.md | 2 - lib/ash/actions/destroy.ex | 6 +- lib/ash/changeset/changeset.ex | 95 +++++++++-- lib/ash/data_layer/ets.ex | 5 +- lib/ash/data_layer/mnesia.ex | 7 +- lib/ash/seed.ex | 215 +++++++++++++++++++++++++ test/actions/load_test.exs | 4 + test/seed_test.exs | 282 +++++++++++++++++++++++++++++++++ 8 files changed, 600 insertions(+), 16 deletions(-) create mode 100644 lib/ash/seed.ex create mode 100644 test/seed_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8710e3b..6e09bf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/ash/actions/destroy.ex b/lib/ash/actions/destroy.ex index a126c252..139816bc 100644 --- a/lib/ash/actions/destroy.ex +++ b/lib/ash/actions/destroy.ex @@ -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} diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index 9b05a0f5..696edc02 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -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 diff --git a/lib/ash/data_layer/ets.ex b/lib/ash/data_layer/ets.ex index 6e6354e7..a1c02214 100644 --- a/lib/ash/data_layer/ets.ex +++ b/lib/ash/data_layer/ets.ex @@ -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 diff --git a/lib/ash/data_layer/mnesia.ex b/lib/ash/data_layer/mnesia.ex index b8f17132..f8865c4f 100644 --- a/lib/ash/data_layer/mnesia.ex +++ b/lib/ash/data_layer/mnesia.ex @@ -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 diff --git a/lib/ash/seed.ex b/lib/ash/seed.ex new file mode 100644 index 00000000..d115fed8 --- /dev/null +++ b/lib/ash/seed.ex @@ -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 diff --git a/test/actions/load_test.exs b/test/actions/load_test.exs index f93c60e7..45ff78a9 100644 --- a/test/actions/load_test.exs +++ b/test/actions/load_test.exs @@ -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 diff --git a/test/seed_test.exs b/test/seed_test.exs new file mode 100644 index 00000000..051dacc1 --- /dev/null +++ b/test/seed_test.exs @@ -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