feat: build related parameters when requested. (#4)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://code.harton.nz/james/smokestack/pulls/4 Co-authored-by: James Harton <james@harton.nz> Co-committed-by: James Harton <james@harton.nz>
This commit is contained in:
parent
9615398029
commit
0354cb9fab
14 changed files with 487 additions and 355 deletions
|
@ -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],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 """
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
201
lib/smokestack/param_builder.ex
Normal file
201
lib/smokestack/param_builder.ex
Normal 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
|
89
lib/smokestack/record_builder.ex
Normal file
89
lib/smokestack/record_builder.ex
Normal 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
|
|
@ -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
|
58
test/smokestack/param_builder_test.exs
Normal file
58
test/smokestack/param_builder_test.exs
Normal 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
|
33
test/smokestack/record_builder_test.exs
Normal file
33
test/smokestack/record_builder_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue