diff --git a/.formatter.exs b/.formatter.exs index 8b1926c..f5932ff 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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], diff --git a/lib/smokestack.ex b/lib/smokestack.ex index e6fe6a6..b84d461 100644 --- a/lib/smokestack.ex +++ b/lib/smokestack.ex @@ -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 diff --git a/lib/smokestack/builder.ex b/lib/smokestack/builder.ex deleted file mode 100644 index 1d704e2..0000000 --- a/lib/smokestack/builder.ex +++ /dev/null @@ -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 diff --git a/lib/smokestack/dsl.ex b/lib/smokestack/dsl.ex index 5ecdcfe..a07da2a 100644 --- a/lib/smokestack/dsl.ex +++ b/lib/smokestack/dsl.ex @@ -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 """ diff --git a/lib/smokestack/dsl/factory.ex b/lib/smokestack/dsl/factory.ex index 97305d1..0d1a85e 100644 --- a/lib/smokestack/dsl/factory.ex +++ b/lib/smokestack/dsl/factory.ex @@ -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, diff --git a/lib/smokestack/dsl/info.ex b/lib/smokestack/dsl/info.ex index aa50bc5..e65e6c4 100644 --- a/lib/smokestack/dsl/info.ex +++ b/lib/smokestack/dsl/info.ex @@ -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 diff --git a/lib/smokestack/dsl/transformer.ex b/lib/smokestack/dsl/transformer.ex index 0093f5f..a3a5f8f 100644 --- a/lib/smokestack/dsl/transformer.ex +++ b/lib/smokestack/dsl/transformer.ex @@ -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 diff --git a/lib/smokestack/param_builder.ex b/lib/smokestack/param_builder.ex new file mode 100644 index 0000000..d419bba --- /dev/null +++ b/lib/smokestack/param_builder.ex @@ -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 diff --git a/lib/smokestack/record_builder.ex b/lib/smokestack/record_builder.ex new file mode 100644 index 0000000..b049059 --- /dev/null +++ b/lib/smokestack/record_builder.ex @@ -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 diff --git a/test/smokestack/builder_test.exs b/test/smokestack/builder_test.exs deleted file mode 100644 index affacf4..0000000 --- a/test/smokestack/builder_test.exs +++ /dev/null @@ -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 diff --git a/test/smokestack/param_builder_test.exs b/test/smokestack/param_builder_test.exs new file mode 100644 index 0000000..4f2d708 --- /dev/null +++ b/test/smokestack/param_builder_test.exs @@ -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 diff --git a/test/smokestack/record_builder_test.exs b/test/smokestack/record_builder_test.exs new file mode 100644 index 0000000..872066b --- /dev/null +++ b/test/smokestack/record_builder_test.exs @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex index 64e32d9..a5d4ed8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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 diff --git a/test/support/post.ex b/test/support/post.ex index 3cb4c95..8afce4a 100644 --- a/test/support/post.ex +++ b/test/support/post.ex @@ -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