Compare commits

...

4 commits

Author SHA1 Message Date
James Harton fa368e7181 chore: release version v0.2.0 2023-08-28 21:15:37 +00:00
James Harton 87b318343f
improvement: reduce arity of builder functions.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-08-29 09:13:38 +12:00
James Harton a2b2d79906
feat: handing building and loading relationships. 2023-08-29 09:02:46 +12:00
Renovate Bot 9615398029 chore(deps): update dependency elixir to v1.15.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2023-08-29 00:23:10 +12:00
17 changed files with 502 additions and 357 deletions

View file

@ -1,4 +1,11 @@
spark_locals_without_parens = [attribute: 2, attribute: 3, factory: 1, factory: 2, factory: 3]
spark_locals_without_parens = [
api: 1,
attribute: 2,
attribute: 3,
factory: 1,
factory: 2,
factory: 3
]
[
import_deps: [:ash, :spark],

View file

@ -1,2 +1,2 @@
elixir 1.15.4
elixir 1.15.5
erlang 26.0.2

View file

@ -5,6 +5,19 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
<!-- changelog -->
## [v0.2.0](https://code.harton.nz/james/smokestack/compare/v0.1.1...v0.2.0) (2023-08-28)
### Features:
* handing building and loading relationships.
### Improvements:
* reduce arity of builder functions.
## [v0.1.1](https://code.harton.nz/james/smokestack/compare/v0.1.0...v0.1.1) (2023-08-18)

View file

@ -20,38 +20,40 @@ defmodule Smokestack do
use Dsl, default_extensions: [extensions: [Smokestack.Dsl]]
alias Ash.Resource
alias Smokestack.Builder
alias Smokestack.{ParamBuilder, RecordBuilder}
@type t :: module
@type recursive_atom_list :: atom | [atom | {atom, recursive_atom_list()}]
@doc """
Runs a factory and uses it to build a map or list of results.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.params/5` for more information.
See `Smokestack.ParamBuilder.build/2` for more information.
"""
@callback params(Resource.t(), map, atom, Builder.param_options()) ::
{:ok, Builder.param_result()} | {:error, any}
@callback params(Resource.t(), ParamBuilder.param_options()) ::
{:ok, ParamBuilder.param_result()} | {:error, any}
@doc """
Raising version of `params/4`.
Raising version of `params/2`.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.params/5` for more information.
See `Smokestack.ParamBuilder.build/3` for more information.
"""
@callback params!(Resource.t(), map, atom, Builder.param_options()) ::
Builder.param_result() | no_return
@callback params!(Resource.t(), ParamBuilder.param_options()) ::
ParamBuilder.param_result() | no_return
@doc """
Runs a factory and uses it to insert an Ash Resource into it's data layer.
Runs a factory and uses it to insert an Ash Resource into it's data layer.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.insert/5` for more information.
See `Smokestack.RecordBuilder.build/3` for more information.
"""
@callback insert(Resource.t(), map, atom, Builder.insert_options()) ::
@callback insert(Resource.t(), RecordBuilder.insert_options()) ::
{:ok, Resource.record()} | {:error, any}
@doc """
@ -59,31 +61,11 @@ defmodule Smokestack do
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.insert/5` for more information.
See `Smokestack.RecordBuilder.build/3` for more information.
"""
@callback insert!(Resource.t(), map, atom, Builder.insert_options()) ::
@callback insert!(Resource.t(), RecordBuilder.insert_options()) ::
Resource.record() | no_return
@doc """
Runs a factory a number of times and returns a list of created records.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.insert_many/5` for more information.
"""
@callback insert_many(Resource.t(), pos_integer, atom, Builder.insert_options()) ::
{:ok, [Resource.record()]} | {:error, any}
@doc """
Raising version of `insert_many/4`.
Automatically implemented by modules which `use Smokestack`.
See `Smokestack.Builder.insert_many/5` for more information.
"""
@callback insert_many!(Resource.t(), pos_integer, atom, Builder.insert_options()) ::
[Resource.record()] | no_return
@doc false
defmacro __using__(opts) do
[
@ -93,69 +75,47 @@ defmodule Smokestack do
@doc """
Execute the matching factory and return a map or list of params.
See `Smokestack.Builder.params/5` for more information.
See `Smokestack.ParamBuilder.build/3` for more information.
"""
@spec params(Resource.t(), map, atom, Builder.param_options()) ::
{:ok, Builder.param_result()} | {:error, any}
def params(resource, overrides \\ %{}, variant \\ :default, options \\ []),
do: Builder.params(__MODULE__, resource, overrides, variant, options)
@spec params(Resource.t(), ParamBuilder.param_options()) ::
{:ok, ParamBuilder.param_result()} | {:error, any}
def params(resource, options \\ []),
do: ParamBuilder.build(__MODULE__, resource, options)
@doc """
Raising version of `params/4`.
Raising version of `params/2`.
See `Smokestack.Builder.params/5` for more information.
See `Smokestack.ParamBuilder.build/3` for more information.
"""
@spec params!(Resource.t(), map, atom, Builder.param_options()) ::
Builder.param_result() | no_return
def params!(resource, overrides \\ %{}, variant \\ :default, options \\ []),
do: Builder.params!(__MODULE__, resource, overrides, variant, options)
@spec params!(Resource.t(), ParamBuilder.param_options()) ::
ParamBuilder.param_result() | no_return
def params!(resource, options \\ []),
do: ParamBuilder.build!(__MODULE__, resource, options)
@doc """
Execute the matching factory and return an inserted Ash Resource record.
See `Smokestack.Builder.insert/5` for more information.
See `Smokestack.RecordBuilder.build/3` for more information.
"""
@spec insert(Resource.t(), map, atom, Builder.insert_options()) ::
@spec insert(Resource.t(), RecordBuilder.insert_options()) ::
{:ok, Resource.record()} | {:error, any}
def insert(resource, overrides \\ %{}, variant \\ :default, options \\ []),
do: Builder.insert(__MODULE__, resource, overrides, variant, options)
def insert(resource, options \\ []),
do: RecordBuilder.build(__MODULE__, resource, options)
@doc """
Raising version of `insert/4`.
Raising version of `insert/2`.
See `Smokestack.Builder.insert/5` for more information.
See `Smokestack.RecordBuilder.build/3` for more information.
"""
@spec insert!(Resource.t(), map, atom, Builder.insert_options()) ::
@spec insert!(Resource.t(), RecordBuilder.insert_options()) ::
Resource.record() | no_return
def insert!(resource, overrides \\ %{}, variant \\ :default, options \\ []),
do: Builder.insert!(__MODULE__, resource, overrides, variant, options)
def insert!(resource, options \\ []),
do: RecordBuilder.build!(__MODULE__, resource, options)
@doc """
Execute the matching factory a number of times and return a list of Ash Resource records.
See `Smokestack.Builder.insert_many/5` for more information.
"""
@spec insert_many(Resource.t(), pos_integer, atom, Builder.insert_options()) ::
{:ok, [Resource.record()]} | {:error, any}
def insert_many(resource, count, variant \\ :default, options \\ []),
do: Builder.insert_many(__MODULE__, resource, count, variant, options)
@doc """
Raising version of `insert_many/4`.
See `Smokestack.Builder.insert_many/5` for more information.
"""
@spec insert_many!(Resource.t(), pos_integer, atom, Builder.insert_options()) ::
[Resource.record()] | no_return
def insert_many!(resource, count, variant \\ :default, options \\ []),
do: Builder.insert_many!(__MODULE__, resource, count, variant, options)
defoverridable params: 4,
params!: 4,
insert: 4,
insert!: 4,
insert_many: 4,
insert_many!: 4
defoverridable params: 2,
params!: 2,
insert: 2,
insert!: 2
end
] ++ super(opts)
end

View file

@ -1,216 +0,0 @@
defmodule Smokestack.Builder do
@moduledoc """
Handles the building of parameters and records.
"""
alias Ash.{Resource, Seed}
alias Smokestack.{Dsl.Attribute, Dsl.Info, Template}
@param_option_defaults [keys: :atom, as: :map]
@typedoc "Options that can be passed to `params/4`."
@type param_options :: [param_keys_option | param_as_option | build_option]
@typedoc "Key type in the result. Defaults to `#{inspect(@param_option_defaults[:keys])}`."
@type param_keys_option :: {:keys, :atom | :string | :dasherise}
@typedoc "Result type. Defaults to `#{inspect(@param_option_defaults[:as])}`"
@type param_as_option :: {:as, :map | :list}
@type param_result ::
%{required(atom | String.t()) => any}
| [{atom | String.t(), any}]
@type insert_options :: [build_option]
@typedoc "A nested keyword list of associations that should also be built"
@type build_option :: {:build, Keyword.t(atom | Keyword.t())}
@type insert_result :: Resource.record()
@doc """
Build parameters for a resource with a factory.
"""
@spec params(Smokestack.t(), Resource.t(), map, atom, param_options) ::
{:ok, param_result} | {:error, any}
def params(factory_module, resource, overrides \\ %{}, variant \\ :default, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_atom(variant) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params} <- build_params(factory, overrides, options) do
params =
params
|> maybe_stringify_keys(options)
|> maybe_dasherise_keys(options)
|> maybe_listify_result(options)
{:ok, params}
end
end
@doc "Raising version of `params/2..5`."
@spec params!(Smokestack.t(), Resource.t(), map, atom, param_options) ::
param_result | no_return
def params!(factory_module, resource, overrides \\ %{}, variant \\ :default, options \\ []) do
case params(factory_module, resource, overrides, variant, options) do
{:ok, params} -> params
{:error, reason} -> raise reason
end
end
@doc """
Build a resource and insert it into it's datalayer.
"""
@spec insert(Smokestack.t(), Resource.t(), map, atom, insert_options) ::
{:ok, insert_result} | {:error, any}
def insert(factory_module, resource, overrides \\ %{}, variant \\ :default, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_atom(Variant) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params} <- build_params(factory, overrides, options) do
record =
resource
|> Seed.seed!(params)
|> Resource.put_metadata(:factory, factory_module)
|> Resource.put_metadata(:variant, variant)
{:ok, record}
end
rescue
error -> {:error, error}
end
@doc "Raising version of `insert/2..5`"
@spec insert!(Smokestack.t(), Resource.t(), map, atom, insert_options) ::
insert_result | no_return
def insert!(factory_module, resource, overrides \\ %{}, variant \\ :default, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_atom(variant) and
is_map(overrides) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params} <- build_params(factory, overrides, options) do
resource
|> Seed.seed!(params)
|> Resource.put_metadata(:factory, factory_module)
|> Resource.put_metadata(:variant, variant)
else
{:error, reason} -> raise reason
end
end
@doc """
Build a number of resources and insert them into their datalayer.
"""
@spec insert_many(Smokestack.t(), Resource.t(), pos_integer, atom, insert_options) ::
{:ok, [insert_result]} | {:error, any}
def insert_many(factory_module, resource, count, variant \\ :default, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_integer(count) and count > 0 and
is_atom(variant) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params_list} <- build_many_params(factory, count, options) do
records =
resource
|> Seed.seed!(params_list)
|> Enum.map(fn record ->
record
|> Resource.put_metadata(:factory, factory_module)
|> Resource.put_metadata(:variant, variant)
end)
{:ok, records}
end
rescue
error -> {:error, error}
end
@doc "Raising version of `insert_many/5`."
@spec insert_many!(Smokestack.t(), Resource.t(), pos_integer, atom, insert_options) ::
[insert_result] | no_return
def insert_many!(factory_module, resource, count, variant \\ :default, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_integer(count) and count > 0 and
is_atom(variant) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params_list} <- build_many_params(factory, count, options) do
resource
|> Seed.seed!(params_list)
|> Enum.map(fn record ->
record
|> Resource.put_metadata(:factory, factory_module)
|> Resource.put_metadata(:variant, variant)
end)
else
{:error, reason} -> raise reason
end
end
defp build_many_params(factory, count, options) do
Enum.reduce_while(1..count, {:ok, []}, fn _, {:ok, params_list} ->
case build_params(factory, %{}, options) do
{:ok, params} -> {:cont, {:ok, [params | params_list]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp get_factory(factory_module, resource, variant) do
with :error <- Info.factory(factory_module, resource, variant) do
{:error,
ArgumentError.exception(
message: "Factory for `#{inspect(resource)}` variant `#{inspect(variant)}` not found."
)}
end
end
defp build_params(factory, overrides, options) do
factory
|> Map.get(:attributes, [])
|> Enum.filter(&is_struct(&1, Attribute))
|> Enum.reduce({:ok, %{}}, fn attr, {:ok, attrs} ->
case Map.fetch(overrides, attr.name) do
{:ok, override} ->
{:ok, Map.put(attrs, attr.name, override)}
:error ->
generator = maybe_initialise_generator(attr)
value = Template.generate(generator, attrs, options)
{:ok, Map.put(attrs, attr.name, value)}
end
end)
end
defp maybe_initialise_generator(attr) do
with nil <- Process.get(attr.__identifier__),
generator <- Template.init(attr.generator) do
Process.put(attr.__identifier__, generator)
generator
end
end
defp maybe_stringify_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :string do
Map.new(attrs, fn {key, value} -> {Atom.to_string(key), value} end)
else
attrs
end
end
defp maybe_dasherise_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :dasherise do
Map.new(attrs, fn {key, value} ->
key =
key
|> Atom.to_string()
|> String.replace("_", "-")
{key, value}
end)
else
attrs
end
end
defp maybe_listify_result(attrs, options) do
if Keyword.get(options, :as, @param_option_defaults[:as]) == :list do
Enum.to_list(attrs)
else
attrs
end
end
end

View file

@ -5,7 +5,14 @@ defmodule Smokestack.Dsl do
@section %Section{
name: :smokestack,
top_level?: true,
entities: Factory.__entities__()
entities: Factory.__entities__(),
schema: [
api: [
type: {:behaviour, Ash.Api},
required: false,
doc: "The default Ash API to use when evaluating loads"
]
]
}
@moduledoc """

View file

@ -5,7 +5,12 @@ defmodule Smokestack.Dsl.Factory do
See `d:Smokestack.factory` for more information.
"""
defstruct __identifier__: nil, attributes: [], resource: nil, variant: :default
defstruct __identifier__: nil,
api: nil,
attributes: [],
module: nil,
resource: nil,
variant: :default
alias Ash.Resource
alias Smokestack.Dsl.{Attribute, Template}
@ -13,8 +18,10 @@ defmodule Smokestack.Dsl.Factory do
@type t :: %__MODULE__{
__identifier__: any,
api: nil,
attributes: [Attribute.t()],
resource: Resource.t(),
module: module,
variant: atom
}
@ -30,6 +37,11 @@ defmodule Smokestack.Dsl.Factory do
imports: [Template],
identifier: {:auto, :unique_integer},
schema: [
api: [
type: {:behaviour, Ash.Api},
required: false,
doc: "The Ash API to use when evaluating loads"
],
resource: [
type: {:behaviour, Ash.Resource},
required: true,

View file

@ -10,14 +10,20 @@ defmodule Smokestack.Dsl.Info do
@doc """
Retrieve a variant for a specific resource.
"""
@spec factory(Smokestack.t(), Resource.t(), atom) :: {:ok, Factory.t()} | :error
@spec factory(Smokestack.t(), Resource.t(), atom) :: {:ok, Factory.t()} | {:error, any}
def factory(factory, resource, variant) do
factory
|> Extension.get_entities([:smokestack])
|> Enum.find(&(is_struct(&1, Factory) && &1.resource == resource && &1.variant == variant))
|> case do
nil -> :error
factory -> {:ok, factory}
nil ->
{:error,
ArgumentError.exception(
message: "Factory for `#{inspect(resource)}` variant `#{inspect(variant)}` not found."
)}
factory ->
{:ok, factory}
end
end
end

View file

@ -1,12 +1,35 @@
defmodule Smokestack.Dsl.Transformer do
@moduledoc false
alias Smokestack.Dsl.Factory
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
use Transformer
@doc false
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
def transform(dsl_state) do
module = Transformer.get_persisted(dsl_state, :module)
api = Transformer.get_option(dsl_state, [:smokestack], :api)
dsl_state =
dsl_state
|> Transformer.get_entities([:smokestack])
|> Enum.reduce(dsl_state, fn
entity, dsl_state when is_struct(entity, Factory) ->
entity =
entity
|> Map.put(:module, module)
|> Map.update(:api, api, fn
nil -> api
api -> api
end)
Transformer.replace_entity(dsl_state, [:smokestack], entity)
_, dsl_state ->
dsl_state
end)
{:ok, dsl_state}
end
end

View file

@ -0,0 +1,201 @@
defmodule Smokestack.ParamBuilder do
@moduledoc """
Handles the building of parameters.
"""
alias Ash.Resource
alias Smokestack.{Dsl.Attribute, Dsl.Factory, Dsl.Info, Template}
@param_option_defaults %{keys: :atom, as: :map, build: [], attrs: %{}, variant: :default}
@typedoc "Options that can be passed to `params/4`."
@type param_options :: [param_keys_option | param_as_option | build_option | param_variant]
@typedoc "Key type in the result. Defaults to `#{inspect(@param_option_defaults[:keys])}`."
@type param_keys_option :: {:keys, :atom | :string | :dasherise}
@typedoc "Result type. Defaults to `#{inspect(@param_option_defaults[:as])}`"
@type param_as_option :: {:as, :map | :list}
@typedoc "Choose a specific factory variant. Defaults to `:default`."
@type param_variant :: {:variant, atom}
@typedoc "Specify attribute overrides."
@type param_attrs :: {:attrs, Enumerable.t({atom, any})}
@type param_result ::
%{required(atom | String.t()) => any}
| [{atom | String.t(), any}]
@typedoc "A nested keyword list of associations that should also be built"
@type build_option :: {:build, Smokestack.recursive_atom_list()}
@doc """
Build parameters for a resource with a factory.
"""
@spec build(Smokestack.t(), Resource.t(), param_options) :: {:ok, param_result} | {:error, any}
def build(factory_module, resource, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_list(options) do
with {:ok, options} <- validate_options(options),
{:ok, factory} <- Info.factory(factory_module, resource, options[:variant]) do
build_factory(factory, options)
end
end
@doc "Raising version of `build/2..5`."
@spec build!(Smokestack.t(), Resource.t(), param_options) :: param_result | no_return
def build!(factory_module, resource, options \\ []) do
case build(factory_module, resource, options) do
{:ok, params} -> params
{:error, reason} -> raise reason
end
end
@doc false
@spec build_factory(Factory.t(), param_options) :: {:ok, param_result()} | {:error, any}
def build_factory(factory, options \\ []) do
with {:ok, params} <- build_params(factory, options) do
params =
params
|> maybe_stringify_keys(options)
|> maybe_dasherise_keys(options)
|> maybe_listify_result(options)
{:ok, params}
end
end
@doc false
@spec validate_options(Enumerable.t({atom, any})) :: {:ok, param_options()}
def validate_options(options) do
opt_map = Map.new(options)
Enum.reduce(@param_option_defaults, {:ok, []}, fn
{key, _}, {:ok, options} when is_map_key(opt_map, key) ->
{:ok, [{key, Map.get(opt_map, key)} | options]}
{key, value}, {:ok, options} ->
{:ok, [{key, value} | options]}
end)
end
defp build_params(factory, options) do
overrides = Map.new(options[:attrs])
factory
|> Map.get(:attributes, [])
|> Enum.filter(&is_struct(&1, Attribute))
|> Enum.reduce(%{}, fn
attr, attrs when is_map_key(overrides, attr.name) ->
Map.put(attrs, attr.name, Map.get(overrides, attr.name))
attr, attrs ->
generator = maybe_initialise_generator(attr)
value = Template.generate(generator, attrs, options)
Map.put(attrs, attr.name, value)
end)
|> maybe_build_related(factory, options)
end
defp maybe_build_related(params, factory, options) do
options
|> Keyword.get(:build, [])
|> List.wrap()
|> Enum.map(fn
{key, value} -> {key, value}
key when is_atom(key) -> {key, []}
end)
|> Enum.reduce_while({:ok, params}, fn {relationship, nested_builds}, {:ok, params} ->
case build_related(
params,
relationship,
factory,
Keyword.put(options, :build, nested_builds)
) do
{:ok, params} -> {:cont, {:ok, params}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp build_related(params, relationship, factory, options) do
ash_relationship = Resource.Info.relationship(factory.resource, relationship)
build_related(params, relationship, factory, options, ash_relationship)
end
defp build_related(_params, relationship, factory, _options, nil),
do:
{:error,
"Relationship `#{inspect(relationship)}` not defined in resource `#{inspect(factory.resource)}`."}
defp build_related(params, _, factory, options, relationship)
when relationship.cardinality == :one do
with {:ok, related_factory} <-
find_related_factory(relationship.destination, factory),
{:ok, related_params} <- build_params(related_factory, Keyword.put(options, :attrs, %{})) do
{:ok, Map.put(params, relationship.name, related_params)}
end
end
defp build_related(params, _, factory, options, relationship)
when relationship.cardinality == :many do
with {:ok, related_factory} <-
find_related_factory(relationship.destination, factory),
{:ok, related_params} <- build_params(related_factory, Keyword.put(options, :attrs, %{})) do
{:ok, Map.put(params, relationship.name, [related_params])}
end
end
defp find_related_factory(resource, factory) when factory.variant == :default,
do: Info.factory(factory.module, resource, :default)
defp find_related_factory(resource, factory) do
with {:error, _} <- Info.factory(factory.module, resource, factory.variant),
{:error, _} <- Info.factory(factory.module, resource, :default) do
{:error,
ArgumentError.exception(
message:
"Factory for `#{inspect(resource)}` no variant named `#{inspect(factory.variant)}` or `:default` found."
)}
end
end
defp maybe_initialise_generator(attr) do
with nil <- Process.get(attr.__identifier__),
generator <- Template.init(attr.generator) do
Process.put(attr.__identifier__, generator)
generator
end
end
defp maybe_stringify_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :string do
Map.new(attrs, fn {key, value} -> {Atom.to_string(key), value} end)
else
attrs
end
end
defp maybe_dasherise_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :dasherise do
Map.new(attrs, fn {key, value} ->
key =
key
|> Atom.to_string()
|> String.replace("_", "-")
{key, value}
end)
else
attrs
end
end
defp maybe_listify_result(attrs, options) do
if Keyword.get(options, :as, @param_option_defaults[:as]) == :list do
Enum.to_list(attrs)
else
attrs
end
end
end

View file

@ -0,0 +1,89 @@
defmodule Smokestack.RecordBuilder do
@moduledoc """
Handles the insertion of new records.
"""
alias Ash.{Resource, Seed}
alias Smokestack.{Dsl.Info, ParamBuilder}
@insert_option_defaults %{load: [], attrs: %{}, variant: :default}
@type insert_options :: ParamBuilder.param_options() | [load_option()]
@typedoc "A nested keyword list of associations, calculations and aggregates to load"
@type load_option :: {:load, Smokestack.recursive_atom_list()}
@type insert_result :: Resource.record()
@doc """
Insert a resource record with a factory.
"""
@spec build(Smokestack.t(), Resource.t(), insert_options) ::
{:ok, insert_result()} | {:error, any}
def build(factory_module, resource, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_list(options) do
with {:ok, insert_opts, param_opts} <- split_options(options),
{:ok, factory} <- Info.factory(factory_module, resource, insert_opts[:variant]),
{:ok, params} <- ParamBuilder.build_factory(factory, param_opts),
{:ok, record} <- do_seed(resource, params, factory_module, insert_opts[:variant]) do
maybe_load(factory, record, insert_opts)
end
end
@doc "Raising version of `build/3`"
@spec build!(Smokestack.t(), Resource.t(), insert_options) ::
insert_result() | no_return
def build!(factory_module, resource, options \\ []) do
case build(factory_module, resource, options) do
{:ok, params} -> params
{:error, reason} -> raise reason
end
end
defp maybe_load(factory, record, options) do
options
|> Keyword.get(:load, [])
|> List.wrap()
|> case do
[] ->
{:ok, record}
_loads when is_nil(factory.api) ->
{:error, "Unable to perform `load` operation without an API."}
loads ->
factory.api.load(record, loads, [])
end
end
defp do_seed(resource, params, factory_module, variant) do
record =
resource
|> Seed.seed!(params)
|> Resource.put_metadata(:factory, factory_module)
|> Resource.put_metadata(:variant, variant)
{:ok, record}
rescue
error -> {:error, error}
end
defp split_options(options) do
with {:ok, param_options} <- ParamBuilder.validate_options(options),
{:ok, insert_options} <- validate_options(options) do
{:ok, insert_options, param_options}
end
end
defp validate_options(options) do
opt_map = Map.new(options)
Enum.reduce(@insert_option_defaults, {:ok, []}, fn
{key, _}, {:ok, options} when is_map_key(opt_map, key) ->
{:ok, [{key, Map.get(opt_map, key)} | options]}
{key, value}, {:ok, options} ->
{:ok, [{key, value} | options]}
end)
end
end

View file

@ -1,7 +1,7 @@
defmodule Smokestack.MixProject do
use Mix.Project
@version "0.1.1"
@version "0.2.0"
@moduledoc """
Test factories for Ash resources.

View file

@ -1,54 +0,0 @@
defmodule Smokestack.BuilderTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.Builder
alias Support.{Factory, Post}
describe "params/2..5" do
test "it builds params" do
assert {:ok, params} = Builder.params(Factory, Post)
assert params |> Map.keys() |> Enum.sort() == ~w[body sub_title tags title]a
assert is_binary(params.body)
assert Enum.all?(params.tags, &is_binary/1)
assert is_binary(params.title)
end
test "it honours the `as: :list` option" do
assert {:ok, params} = Builder.params(Factory, Post, %{}, :default, as: :list)
assert is_list(params)
assert is_binary(params[:body])
assert Enum.all?(params[:tags], &is_binary/1)
assert is_binary(params[:title])
end
test "it honours the `keys: :string` option" do
assert {:ok, params} = Builder.params(Factory, Post, %{}, :default, keys: :string)
assert is_binary(params["body"])
assert Enum.all?(params["tags"], &is_binary/1)
assert is_binary(params["title"])
end
test "it honours the `keys: :dasherise` option" do
assert {:ok, params} = Builder.params(Factory, Post, %{}, :default, keys: :dasherise)
assert is_binary(params["sub-title"])
end
end
describe "insert/2..5" do
test "it inserts the resource" do
assert {:ok, record} = Builder.insert(Factory, Post)
assert is_struct(record, Post)
assert record.inserted_at
assert is_binary(record.title)
assert is_binary(record.sub_title)
assert Enum.all?(record.tags, &is_struct(&1, Ash.CiString))
end
end
describe "insert_many/3..5" do
assert {:ok, records} = Builder.insert_many(Factory, Post, 3)
assert length(records) == 3
assert Enum.all?(records, &is_struct(&1, Post))
end
end

View file

@ -0,0 +1,58 @@
defmodule Smokestack.ParamBuilderTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.ParamBuilder
alias Support.{Author, Factory, Post}
describe "build/2..5" do
test "it builds params" do
assert {:ok, params} = ParamBuilder.build(Factory, Post)
assert params |> Map.keys() |> Enum.sort() == ~w[body sub_title tags title]a
assert params.body
assert Enum.all?(params.tags, &is_binary/1)
assert params.title
end
test "it honours the `as: :list` option" do
assert {:ok, params} = ParamBuilder.build(Factory, Post, as: :list)
assert is_list(params)
assert params[:body]
assert Enum.all?(params[:tags], &is_binary/1)
assert params[:title]
end
test "it honours the `keys: :string` option" do
assert {:ok, params} = ParamBuilder.build(Factory, Post, keys: :string)
assert params["body"]
assert Enum.all?(params["tags"], &is_binary/1)
assert params["title"]
end
test "it honours the `keys: :dasherise` option" do
assert {:ok, params} = ParamBuilder.build(Factory, Post, keys: :dasherise)
assert params["sub-title"]
end
test "it honours the `build` option for single relationships" do
assert {:ok, params} = ParamBuilder.build(Factory, Post, build: :author)
assert params.author.name
assert params.author.email
end
test "it honours the `build` option for many relationships" do
assert {:ok, params} = ParamBuilder.build(Factory, Author, build: :posts)
assert [post] = params.posts
assert post.title
end
test "it honours nested `build` options" do
assert {:ok, params} =
ParamBuilder.build(Factory, Author, build: [posts: [:author]])
assert [post] = params.posts
assert post.author.name
assert params.name != post.author.name
end
end
end

View file

@ -0,0 +1,33 @@
defmodule Smokestack.RecordBuilderTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.RecordBuilder
describe "record/2..5" do
test "it can insert a record" do
assert {:ok, record} = RecordBuilder.build(Support.Factory, Support.Post)
assert record.__struct__ == Support.Post
assert record.__meta__.state == :loaded
refute record.author.__struct__ == Support.Author
end
test "it can insert related records" do
assert {:ok, record} =
RecordBuilder.build(Support.Factory, Support.Post, build: [:author])
assert record.__struct__ == Support.Post
assert record.__meta__.state == :loaded
assert record.author.__struct__ == Support.Author
assert record.author.__meta__.state == :loaded
end
test "it can perform a load on a record" do
assert {:ok, record} =
RecordBuilder.build(Support.Factory, Support.Post, load: :full_title)
assert record.__struct__ == Support.Post
assert record.full_title =~ ":"
end
end
end

View file

@ -20,6 +20,8 @@ defmodule Support.Factory do
end
factory Support.Post do
api Support.Api
attribute :title, &Faker.Commerce.product_name/0
attribute :tags, n_times(3..20, &Faker.Lorem.word/0)
attribute :body, &Faker.Markdown.markdown/0

View file

@ -39,4 +39,8 @@ defmodule Support.Post do
actions do
defaults [:create, :read, :update, :destroy]
end
calculations do
calculate :full_title, :string, concat([:title, :sub_title], ": ")
end
end