From ef5d6462b9b80a88a567795be6f1c4c62790c2b5 Mon Sep 17 00:00:00 2001 From: James Harton Date: Wed, 29 May 2024 09:50:43 +1200 Subject: [PATCH] feat: Auto build/load factory options. (#83) New factory DSL options: 1. `auto_build` allows you to provide a list of relationships which must also be built when building that factory. 2. `auto_load` allows you to provide a load statement for relationships and calculations that must be loaded when building that factory. Reviewed-on: https://harton.dev/james/smokestack/pulls/83 Co-authored-by: James Harton Co-committed-by: James Harton --- .formatter.exs | 2 + documentation/dsls/DSL:-Smokestack.md | 6 ++ lib/smokestack/builders/record_builder.ex | 15 ++-- lib/smokestack/builders/related_builder.ex | 2 + lib/smokestack/dsl/factory.ex | 16 +++++ lib/smokestack/dsl/verifier.ex | 82 +++++++++++++++++++++- test/smokestack/dsl_test.exs | 48 +++++++++++++ 7 files changed, 164 insertions(+), 7 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 3754057..15dcc99 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,6 +3,8 @@ spark_locals_without_parens = [ after_build: 2, attribute: 2, attribute: 3, + auto_build: 1, + auto_load: 1, before_build: 1, before_build: 2, domain: 1, diff --git a/documentation/dsls/DSL:-Smokestack.md b/documentation/dsls/DSL:-Smokestack.md index 2cc4c64..cd15aa1 100644 --- a/documentation/dsls/DSL:-Smokestack.md +++ b/documentation/dsls/DSL:-Smokestack.md @@ -54,6 +54,10 @@ Define factories for a resource * `:variant` (`t:atom/0`) - The name of a factory variant The default value is `:default`. +* `:auto_build` (one or a list of `t:atom/0`) - A list of relationships that should always be built when building this factory The default value is `[]`. + +* `:auto_load` - An Ash "load statement" to always apply when building this factory The default value is `[]`. + ##### after_build @@ -166,6 +170,8 @@ Define factories for a resource | Name | Type | Default | Docs | |------|------|---------|------| | [`domain`](#smokestack-factory-domain){: #smokestack-factory-domain } | `module` | | The Ash Domain to use when evaluating loads | +| [`auto_build`](#smokestack-factory-auto_build){: #smokestack-factory-auto_build } | `atom \| list(atom)` | `[]` | A list of relationships that should always be built when building this factory | +| [`auto_load`](#smokestack-factory-auto_load){: #smokestack-factory-auto_load } | `atom \| keyword \| list(atom \| keyword)` | `[]` | An Ash "load statement" to always apply when building this factory | ## smokestack.factory.after_build diff --git a/lib/smokestack/builders/record_builder.ex b/lib/smokestack/builders/record_builder.ex index e698022..61dc3aa 100644 --- a/lib/smokestack/builders/record_builder.ex +++ b/lib/smokestack/builders/record_builder.ex @@ -102,7 +102,7 @@ defmodule Smokestack.RecordBuilder do {:ok, record_list} <- seed(attr_list, factory) do record_list |> maybe_hook(factory) - |> maybe_load(factory, load) + |> maybe_load(factory, List.wrap(load)) end end @@ -113,7 +113,7 @@ defmodule Smokestack.RecordBuilder do {:ok, record} <- seed(attrs, factory) do record |> maybe_hook(factory) - |> maybe_load(factory, load) + |> maybe_load(factory, List.wrap(load)) end end @@ -145,13 +145,18 @@ defmodule Smokestack.RecordBuilder do |> Resource.put_metadata(:variant, factory.variant) end - defp maybe_load(record_or_records, _factory, []), do: {:ok, record_or_records} + defp maybe_load(record_or_records, %{auto_load: []}, []), do: {:ok, record_or_records} defp maybe_load(_record_or_records, factory, _load) when is_nil(factory.domain), do: {:error, "Unable to perform `load` operation without an Domain."} - defp maybe_load(record_or_records, factory, load), - do: Ash.load(record_or_records, load, domain: factory.domain) + defp maybe_load(record_or_records, factory, load) do + load = + factory.auto_load + |> Enum.concat(load) + + Ash.load(record_or_records, load, domain: factory.domain) + end defp maybe_hook(records, factory) when is_list(records) do Enum.map(records, fn record -> diff --git a/lib/smokestack/builders/related_builder.ex b/lib/smokestack/builders/related_builder.ex index 351a852..b80696d 100644 --- a/lib/smokestack/builders/related_builder.ex +++ b/lib/smokestack/builders/related_builder.ex @@ -145,6 +145,8 @@ defmodule Smokestack.RelatedBuilder do options |> Keyword.get(:build, []) |> List.wrap() + |> Enum.concat(factory.auto_build) + |> List.wrap() |> Enum.map(fn {key, value} -> {key, value} key when is_atom(key) -> {key, []} diff --git a/lib/smokestack/dsl/factory.ex b/lib/smokestack/dsl/factory.ex index d4b1081..2c5222a 100644 --- a/lib/smokestack/dsl/factory.ex +++ b/lib/smokestack/dsl/factory.ex @@ -8,6 +8,8 @@ defmodule Smokestack.Dsl.Factory do defstruct __identifier__: nil, after_build: [], attributes: [], + auto_load: [], + auto_build: [], before_build: [], domain: nil, module: nil, @@ -22,6 +24,8 @@ defmodule Smokestack.Dsl.Factory do __identifier__: any, after_build: [AfterBuild.t()], attributes: [Attribute.t()], + auto_load: [atom] | Keyword.t(), + auto_build: [atom], before_build: [BeforeBuild.t()], domain: nil, module: module, @@ -56,6 +60,18 @@ defmodule Smokestack.Dsl.Factory do required: false, doc: "The name of a factory variant", default: :default + ], + auto_build: [ + type: {:wrap_list, :atom}, + required: false, + doc: "A list of relationships that should always be built when building this factory", + default: [] + ], + auto_load: [ + type: {:wrap_list, {:or, [:atom, :keyword_list]}}, + required: false, + doc: "An Ash \"load statement\" to always apply when building this factory", + default: [] ] ], entities: [ diff --git a/lib/smokestack/dsl/verifier.ex b/lib/smokestack/dsl/verifier.ex index 39e7ac2..e7cf112 100644 --- a/lib/smokestack/dsl/verifier.ex +++ b/lib/smokestack/dsl/verifier.ex @@ -9,7 +9,11 @@ defmodule Smokestack.Dsl.Verifier do @impl true @spec verify(Dsl.t()) :: :ok | {:error, DslError.t()} def verify(dsl_state) do - error_info = %{module: Verifier.get_persisted(dsl_state, :module), path: [:smokestack]} + error_info = %{ + module: Verifier.get_persisted(dsl_state, :module), + path: [:smokestack], + dsl_state: dsl_state + } factories = dsl_state @@ -68,7 +72,9 @@ defmodule Smokestack.Dsl.Verifier do error_info = Map.merge(error_info, %{resource: factory.resource, path: [:factory | error_info.path]}) - with :ok <- verify_unique_attributes(factory, error_info) do + with :ok <- verify_unique_attributes(factory, error_info), + :ok <- verify_auto_build(factory, error_info), + :ok <- verify_auto_load(factory, error_info) do factory |> Map.get(:attributes, []) |> Enum.filter(&is_struct(&1, Attribute)) @@ -186,4 +192,76 @@ defmodule Smokestack.Dsl.Verifier do )} end end + + defp verify_auto_build(factory, error_info) do + error_info = %{error_info | path: [:auto_build | error_info.path]} + + Enum.reduce_while(factory.auto_build, :ok, fn relationship, :ok -> + error_info = %{error_info | path: [relationship | error_info.path]} + + with {:ok, relationship} <- verify_relationship(factory.resource, relationship, error_info), + :ok <- verify_factory_exists(relationship.destination, error_info) do + {:cont, :ok} + else + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp verify_relationship(resource, relationship, error_info) do + case Info.relationship(resource, relationship) do + nil -> + {:error, + DslError.exception( + module: error_info.module, + path: Enum.reverse(error_info.path), + message: + "The resource `#{inspect(resource)}` has no relationship named `#{inspect(relationship)}`." + )} + + relationship -> + {:ok, relationship} + end + end + + defp verify_factory_exists(resource, error_info) do + factory_exists? = + error_info.dsl_state + |> Verifier.get_entities([:smokestack]) + |> Enum.any?(&(is_struct(&1, Factory) && &1.resource == resource)) + + if factory_exists? do + :ok + else + {:error, + DslError.exception( + module: error_info.module, + path: Enum.reverse(error_info.path), + message: "No factories defined for resource `#{inspect(resource)}`." + )} + end + end + + defp verify_auto_load(factory, error_info) do + error_info = %{error_info | path: [:auto_load | error_info.path]} + + Enum.reduce_while(factory.auto_load, :ok, fn load, :ok -> + error_info = %{error_info | path: [load | error_info.path]} + + with nil <- Info.calculation(factory.resource, load), + nil <- Info.aggregate(factory.resource, load), + nil <- Info.relationship(factory.resource, load) do + {:halt, + {:error, + DslError.exception( + module: error_info.module, + path: Enum.reverse(error_info.path), + message: + "Expected an aggregate, calculation or relationship named `#{inspect(load)}` on resource `#{inspect(factory.resource)}`" + )}} + else + _ -> {:cont, :ok} + end + end) + end end diff --git a/test/smokestack/dsl_test.exs b/test/smokestack/dsl_test.exs index 3d23c78..f22619a 100644 --- a/test/smokestack/dsl_test.exs +++ b/test/smokestack/dsl_test.exs @@ -1,6 +1,7 @@ defmodule Smokestack.DslTest do use ExUnit.Case, async: true alias Spark.Error.DslError + alias Support.Author defmodule Post do @moduledoc false @@ -18,6 +19,18 @@ defmodule Smokestack.DslTest do attribute :title, :string end + + relationships do + belongs_to :author, Author + end + + calculations do + calculate :title_first_word, :string, expr(title |> string_split() |> at(0)) + end + + actions do + defaults [:read] + end end defmodule Factory do @@ -145,4 +158,39 @@ defmodule Smokestack.DslTest do assert %Post{__metadata__: %{wat: true}} = AfterBuildFactory.insert!(Post) end + + test "auto builds can be specified in the factory" do + defmodule AutoBuildFactory do + @moduledoc false + use Smokestack + + factory Post do + attribute :title, &Faker.Company.catch_phrase/0 + auto_build :author + end + + factory Author do + attribute :name, &Faker.Internet.email/0 + attribute :email, &Faker.Person.name/0 + end + end + + assert %Post{author: %Author{}} = AutoBuildFactory.insert!(Post) + end + + test "auto loads can be specified in the factory" do + defmodule AutoLoadFactory do + @moduledoc false + use Smokestack + + factory Post do + attribute :title, &Faker.Company.catch_phrase/0 + auto_load :title_first_word + domain Support.Domain + end + end + + assert post = AutoLoadFactory.insert!(Post) + assert post.title_first_word == post.title |> String.split(" ") |> List.first() + end end