feat: Auto build/load factory options. (#83)
All checks were successful
continuous-integration/drone/push Build is passing

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: #83
Co-authored-by: James Harton <james@harton.nz>
Co-committed-by: James Harton <james@harton.nz>
This commit is contained in:
James Harton 2024-05-29 09:50:43 +12:00 committed by James Harton
parent f46d9bb6b9
commit ef5d6462b9
7 changed files with 164 additions and 7 deletions

View file

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

View file

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

View file

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

View file

@ -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, []}

View file

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

View file

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

View file

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