Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
James Harton | a69e595f65 | ||
James Harton | a0cab56172 | ||
James Harton | 744e555577 | ||
James Harton | 01c9e73b5b |
45
.drone.yml
45
.drone.yml
|
@ -355,26 +355,25 @@ steps:
|
|||
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
|
||||
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}
|
||||
|
||||
# - name: hex release
|
||||
# image: code.harton.nz/james/asdf_container:latest
|
||||
# pull: "always"
|
||||
# when:
|
||||
# event:
|
||||
# - tag
|
||||
# refs:
|
||||
# include:
|
||||
# - refs/tags/v*
|
||||
# depends_on:
|
||||
# - build artifacts
|
||||
# environment:
|
||||
# MIX_ENV: test
|
||||
# HEX_HOME: /drone/src/.hex
|
||||
# MIX_HOME: /drone/src/.mix
|
||||
# REBAR_BASE_DIR: /drone/src/.rebar3
|
||||
# ASDF_DATA_DIR: /drone/src/.asdf
|
||||
# ASDF_DIR: /root/.asdf
|
||||
# HEX_API_KEY:
|
||||
# from_secret: HEX_API_KEY
|
||||
# commands:
|
||||
# - . $ASDF_DIR/asdf.sh
|
||||
# - mix hex.publish --yes
|
||||
- name: hex release
|
||||
image: code.harton.nz/james/asdf_container:latest
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
refs:
|
||||
include:
|
||||
- refs/tags/v*
|
||||
depends_on:
|
||||
- build artifacts
|
||||
environment:
|
||||
MIX_ENV: test
|
||||
HEX_HOME: /drone/src/.hex
|
||||
MIX_HOME: /drone/src/.mix
|
||||
REBAR_BASE_DIR: /drone/src/.rebar3
|
||||
ASDF_DATA_DIR: /drone/src/.asdf
|
||||
ASDF_DIR: /root/.asdf
|
||||
HEX_API_KEY:
|
||||
from_secret: HEX_API_KEY
|
||||
commands:
|
||||
- . $ASDF_DIR/asdf.sh
|
||||
- mix hex.publish --yes
|
||||
|
|
|
@ -5,6 +5,11 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
|
|||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v0.3.1](https://code.harton.nz/james/smokestack/compare/v0.3.0...v0.3.1) (2023-09-09)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v0.3.0](https://code.harton.nz/james/smokestack/compare/v0.2.0...v0.3.0) (2023-09-05)
|
||||
|
||||
|
||||
|
|
12
README.md
12
README.md
|
@ -25,7 +25,7 @@ defmodule MyApp.CharacterTest do
|
|||
use MyApp.Factory
|
||||
|
||||
test "it can build a character" do
|
||||
assert character = MyApp.Factory.build!(Character)
|
||||
assert character = insert!(Character)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
@ -43,7 +43,15 @@ def deps do
|
|||
end
|
||||
```
|
||||
|
||||
Since the package hasn't been published, there are no docs available on [HexDocs](https://hexdocs.pm/), but you can access the latest version [here](https://docs.harton.nz/james/smokestack).
|
||||
Since the package hasn't been published, there are no docs available on
|
||||
[HexDocs](https://hexdocs.pm/), but you can access the latest version
|
||||
[here](https://docs.harton.nz/james/smokestack).
|
||||
|
||||
## Github Mirror
|
||||
|
||||
This repository is mirrored [on Github](https://github.com/jimsynz/smokestack)
|
||||
from it's primary location [on my Forejo instance](https://code.harton.nz/james/smokestack).
|
||||
Feel free to raise issues and open PRs on Github.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -2,6 +2,88 @@ defmodule Smokestack do
|
|||
alias Spark.{Dsl, Dsl.Extension}
|
||||
|
||||
@moduledoc """
|
||||
Smokestack provides a way to define test factories for your
|
||||
[Ash Resources](https://ash-hq.org/docs/module/ash/latest/ash-resource)
|
||||
using a convenient DSL:
|
||||
|
||||
```
|
||||
defmodule MyApp.Factory do
|
||||
use Smokestack
|
||||
|
||||
factory Character do
|
||||
attribute :name, &Faker.StarWars.character/0
|
||||
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
|
||||
end
|
||||
end
|
||||
|
||||
defmodule MyApp.CharacterTest do
|
||||
use MyApp.DataCase
|
||||
use MyApp.Factory
|
||||
|
||||
test "it can build a character" do
|
||||
assert character = insert!(Character)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Variants
|
||||
|
||||
Sometimes you need to make slightly different factories to build a resource
|
||||
in a specific state for your test scenario.
|
||||
|
||||
Here's an example defining an alternate `:trek` variant for the character
|
||||
factory defined above:
|
||||
|
||||
```
|
||||
factory Character, :trek do
|
||||
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
|
||||
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
|
||||
end
|
||||
```
|
||||
|
||||
## Building resource records
|
||||
|
||||
You can use `insert/2` and `insert!/2` to build and insert records. Records
|
||||
are inserted using `Ash.Seed.seed/2`, which means that they bypass the usual
|
||||
Ash lifecycle (actions, validations, etc).
|
||||
|
||||
### Options
|
||||
|
||||
- `load`: an atom, list of atoms or keyword list of the same listing
|
||||
relationships, calculations and aggregates that should be loaded
|
||||
after the record is created.
|
||||
- `count`: rather than inserting just a single record, you can specify a
|
||||
number of records to be inserted. A list of records will be returned.
|
||||
- `build`: an atom, list of atoms or keyword list of the same describing
|
||||
relationships which you would like built alongside the record. If the
|
||||
related resource has a variant which matches the current one, it will be
|
||||
used, and if not the `:default` variant will be.
|
||||
- `attrs`: A map or keyword list of attributes you would like to set directly
|
||||
on the created record, rather than using the value provided by the factory.
|
||||
|
||||
## Building parameters
|
||||
|
||||
As well as inserting records directly you can use `params/2` and `params!/2`
|
||||
to build parameters for use testing controller actions, HTTP requests, etc.
|
||||
|
||||
### Options
|
||||
|
||||
- `encode`: rather than returning a map or maps, provide an encoder module
|
||||
to serialise the parameters. Commonly you would use `Jason` or `Poison`.
|
||||
- `nest`: rather than returning a map or maps directly, wrap the result in
|
||||
an outer map using the provided key.
|
||||
- `key_case`: change the case of the keys into one of the many cases
|
||||
supported by [recase](https://hex.pm/packages/recase).
|
||||
- `key_type`: specify whether the returned map or maps should use string or
|
||||
atom keys (ignored when using the `encode` option).
|
||||
- `count`: rather than returning just a single map, you can specify a
|
||||
number of results to be returned. A list of maps will be returned.
|
||||
- `build`: an atom, list of atoms or keyword list of the same describing
|
||||
relationships which you would like built within the result. If the
|
||||
related resource has a variant which matches the current one, it will be
|
||||
used, and if not the `:default` variant will be.
|
||||
- `attrs`: A map or keyword list of attributes you would like to set directly
|
||||
on the result, rather than using the value provided by the factory.
|
||||
|
||||
<!--- ash-hq-hide-start --> <!--- -->
|
||||
|
||||
|
@ -20,21 +102,29 @@ defmodule Smokestack do
|
|||
|
||||
use Dsl, default_extensions: [extensions: [Smokestack.Dsl]]
|
||||
alias Ash.Resource
|
||||
alias Smokestack.{ParamBuilder, RecordBuilder}
|
||||
alias Smokestack.{Builder, Dsl.Info, ParamBuilder, RecordBuilder}
|
||||
|
||||
@type t :: module
|
||||
|
||||
@type recursive_atom_list :: atom | [atom | {atom, recursive_atom_list()}]
|
||||
@type param_option :: variant_option | ParamBuilder.option()
|
||||
@type insert_option :: variant_option | RecordBuilder.option()
|
||||
|
||||
@typedoc """
|
||||
Choose a factory variant to use. Defaults to `:default`.
|
||||
"""
|
||||
@type variant_option :: {:variant, atom}
|
||||
|
||||
@doc """
|
||||
Runs a factory and uses it to build a map or list of results.
|
||||
Runs a factory and uses it to build a parameters suitable for simulating a
|
||||
request.
|
||||
|
||||
Automatically implemented by modules which `use Smokestack`.
|
||||
|
||||
See `Smokestack.ParamBuilder.build/2` for more information.
|
||||
"""
|
||||
@callback params(Resource.t(), ParamBuilder.param_options()) ::
|
||||
{:ok, ParamBuilder.param_result()} | {:error, any}
|
||||
@callback params(Resource.t(), [param_option]) ::
|
||||
{:ok, ParamBuilder.result()} | {:error, any}
|
||||
|
||||
@doc """
|
||||
Raising version of `params/2`.
|
||||
|
@ -43,18 +133,17 @@ defmodule Smokestack do
|
|||
|
||||
See `Smokestack.ParamBuilder.build/3` for more information.
|
||||
"""
|
||||
@callback params!(Resource.t(), ParamBuilder.param_options()) ::
|
||||
ParamBuilder.param_result() | no_return
|
||||
@callback params!(Resource.t(), [param_option]) :: ParamBuilder.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 Ash resources into their data layers.
|
||||
|
||||
Automatically implemented by modules which `use Smokestack`.
|
||||
|
||||
See `Smokestack.RecordBuilder.build/3` for more information.
|
||||
"""
|
||||
@callback insert(Resource.t(), RecordBuilder.insert_options()) ::
|
||||
{:ok, Resource.record()} | {:error, any}
|
||||
@callback insert(Resource.t(), [insert_option]) ::
|
||||
{:ok, RecordBuilder.result()} | {:error, any}
|
||||
|
||||
@doc """
|
||||
Raising version of `insert/4`.
|
||||
|
@ -63,8 +152,7 @@ defmodule Smokestack do
|
|||
|
||||
See `Smokestack.RecordBuilder.build/3` for more information.
|
||||
"""
|
||||
@callback insert!(Resource.t(), RecordBuilder.insert_options()) ::
|
||||
Resource.record() | no_return
|
||||
@callback insert!(Resource.t(), [insert_option]) :: RecordBuilder.result() | no_return
|
||||
|
||||
@doc false
|
||||
defmacro __using__(opts) do
|
||||
|
@ -73,49 +161,75 @@ defmodule Smokestack do
|
|||
@behaviour Smokestack
|
||||
|
||||
@doc """
|
||||
Execute the matching factory and return a map or list of params.
|
||||
Runs a factory and uses it to build a parameters suitable for simulating a
|
||||
request.
|
||||
|
||||
See `Smokestack.ParamBuilder.build/3` for more information.
|
||||
See `c:Smokestack.build/2` for more information.
|
||||
"""
|
||||
@spec params(Resource.t(), ParamBuilder.param_options()) ::
|
||||
{:ok, ParamBuilder.param_result()} | {:error, any}
|
||||
def params(resource, options \\ []),
|
||||
do: ParamBuilder.build(__MODULE__, resource, options)
|
||||
@spec params(Resource.t(), [Smokestack.param_option()]) ::
|
||||
{:ok, ParamBuilder.result()} | {:error, any}
|
||||
def params(resource, options \\ []) do
|
||||
{variant, options} = Keyword.pop(options, :variant, :default)
|
||||
|
||||
with {:ok, factory} <- Info.factory(__MODULE__, resource, variant) do
|
||||
Builder.build(ParamBuilder, factory, options)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Raising version of `params/2`.
|
||||
|
||||
See `Smokestack.ParamBuilder.build/3` for more information.
|
||||
See `c:Smokestack.build/3` for more information.
|
||||
"""
|
||||
@spec params!(Resource.t(), ParamBuilder.param_options()) ::
|
||||
ParamBuilder.param_result() | no_return
|
||||
def params!(resource, options \\ []),
|
||||
do: ParamBuilder.build!(__MODULE__, resource, options)
|
||||
@spec params!(Resource.t(), [Smokestack.param_option()]) ::
|
||||
ParamBuilder.result() | no_return
|
||||
def params!(resource, options \\ []) do
|
||||
case params(resource, options) do
|
||||
{:ok, result} -> result
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Execute the matching factory and return an inserted Ash Resource record.
|
||||
|
||||
See `Smokestack.RecordBuilder.build/3` for more information.
|
||||
See `c:Smokestack.insert/2` for more information.
|
||||
"""
|
||||
@spec insert(Resource.t(), RecordBuilder.insert_options()) ::
|
||||
{:ok, Resource.record()} | {:error, any}
|
||||
def insert(resource, options \\ []),
|
||||
do: RecordBuilder.build(__MODULE__, resource, options)
|
||||
@spec insert(Resource.t(), [Smokestack.insert_option()]) ::
|
||||
{:ok, RecordBuilder.result()} | {:error, any}
|
||||
def insert(resource, options \\ []) do
|
||||
{variant, options} = Keyword.pop(options, :variant, :default)
|
||||
|
||||
with {:ok, factory} <- Info.factory(__MODULE__, resource, variant) do
|
||||
Builder.build(RecordBuilder, factory, options)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Raising version of `insert/2`.
|
||||
|
||||
See `Smokestack.RecordBuilder.build/3` for more information.
|
||||
See `c:Smokestack.insert/2` for more information.
|
||||
"""
|
||||
@spec insert!(Resource.t(), RecordBuilder.insert_options()) ::
|
||||
Resource.record() | no_return
|
||||
def insert!(resource, options \\ []),
|
||||
do: RecordBuilder.build!(__MODULE__, resource, options)
|
||||
@spec insert!(Resource.t(), [Smokestack.insert_option()]) ::
|
||||
RecordBuilder.result() | no_return
|
||||
def insert!(resource, options \\ []) do
|
||||
case insert(resource, options) do
|
||||
{:ok, result} -> result
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
import __MODULE__
|
||||
end
|
||||
end
|
||||
|
||||
defoverridable params: 2,
|
||||
params!: 2,
|
||||
insert: 2,
|
||||
insert!: 2
|
||||
insert!: 2,
|
||||
__using__: 1
|
||||
end
|
||||
] ++ super(opts)
|
||||
end
|
||||
|
|
33
lib/smokestack/builder.ex
Normal file
33
lib/smokestack/builder.ex
Normal file
|
@ -0,0 +1,33 @@
|
|||
defmodule Smokestack.Builder do
|
||||
@moduledoc """
|
||||
A generic behaviour for "building things".
|
||||
"""
|
||||
|
||||
alias Smokestack.Dsl.Factory
|
||||
alias Spark.OptionsHelpers
|
||||
|
||||
@type result :: any
|
||||
@type error :: any
|
||||
@type t :: module
|
||||
|
||||
@doc """
|
||||
Given a Factory entity and some options build something.
|
||||
"""
|
||||
@callback build(Factory.t(), Keyword.t()) :: {:ok, result} | {:error, error}
|
||||
|
||||
@doc """
|
||||
Provide a schema for validating options.
|
||||
"""
|
||||
@callback option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, any}
|
||||
|
||||
@doc """
|
||||
Given a builder and a factory, validate it's options and call the builder.
|
||||
"""
|
||||
@spec build(t, Factory.t(), Keyword.t()) :: {:ok, result} | {:error, error}
|
||||
def build(builder, factory, options) do
|
||||
with {:ok, schema} <- builder.option_schema(factory),
|
||||
{:ok, options} <- OptionsHelpers.validate(options, schema) do
|
||||
builder.build(factory, options)
|
||||
end
|
||||
end
|
||||
end
|
71
lib/smokestack/builders/factory_builder.ex
Normal file
71
lib/smokestack/builders/factory_builder.ex
Normal file
|
@ -0,0 +1,71 @@
|
|||
defmodule Smokestack.FactoryBuilder do
|
||||
@moduledoc """
|
||||
Executes a factory and returns it's result.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias Smokestack.{Builder, Dsl.Attribute, Dsl.Factory, Template}
|
||||
alias Spark.OptionsHelpers
|
||||
@behaviour Builder
|
||||
|
||||
@type option :: attrs_option
|
||||
|
||||
@typedoc """
|
||||
Manually specify some attributes.
|
||||
"""
|
||||
@type attrs_option :: {:attrs, Enumerable.t({atom, any})}
|
||||
|
||||
@type result :: %{optional(atom) => any}
|
||||
@type error :: any
|
||||
|
||||
@doc """
|
||||
Execute the named factory, if possible.
|
||||
"""
|
||||
@impl true
|
||||
@spec build(Factory.t(), [option]) :: {:ok, result} | {:error, error}
|
||||
def build(factory, options) do
|
||||
overrides = options[:attrs]
|
||||
|
||||
factory
|
||||
|> Map.get(:attributes, [])
|
||||
|> Enum.filter(&is_struct(&1, Attribute))
|
||||
|> Enum.reduce({:ok, %{}}, fn
|
||||
attr, {:ok, attrs} when is_map_key(overrides, attr.name) ->
|
||||
{:ok, Map.put(attrs, attr.name, Map.get(overrides, attr.name))}
|
||||
|
||||
attr, {:ok, attrs} ->
|
||||
generator = maybe_initialise_generator(attr)
|
||||
value = Template.generate(generator, attrs, options)
|
||||
{:ok, Map.put(attrs, attr.name, value)}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec option_schema(Factory.t()) ::
|
||||
{:ok, OptionsHelpers.schema()} | {:error, error}
|
||||
def option_schema(factory) do
|
||||
attr_keys =
|
||||
factory.resource
|
||||
|> Resource.Info.attributes()
|
||||
|> Enum.map(&{&1.name, [type: :any, required: false]})
|
||||
|
||||
{:ok,
|
||||
[
|
||||
attrs: [
|
||||
type: :map,
|
||||
required: false,
|
||||
default: %{},
|
||||
keys: attr_keys
|
||||
]
|
||||
]}
|
||||
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
|
||||
end
|
56
lib/smokestack/builders/many_builder.ex
Normal file
56
lib/smokestack/builders/many_builder.ex
Normal file
|
@ -0,0 +1,56 @@
|
|||
defmodule Smokestack.ManyBuilder do
|
||||
@moduledoc """
|
||||
Handles repeatedly building.
|
||||
"""
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Factory, RelatedBuilder}
|
||||
alias Spark.OptionsHelpers
|
||||
@behaviour Builder
|
||||
|
||||
@type option :: count_option | RelatedBuilder.option()
|
||||
|
||||
@typedoc """
|
||||
How many times should we call the builder?
|
||||
"""
|
||||
@type count_option :: {:count, pos_integer()}
|
||||
|
||||
@type result :: [RelatedBuilder.result()]
|
||||
@type error :: RelatedBuilder.error() | Exception.t()
|
||||
|
||||
@doc """
|
||||
Run the factory a number of times.
|
||||
"""
|
||||
@impl true
|
||||
@spec build(Factory.t(), [option]) :: {:ok, result} | {:error, error}
|
||||
def build(factory, options) do
|
||||
{how_many, options} = Keyword.pop(options, :count, 1)
|
||||
|
||||
do_build(factory, how_many, options)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
|
||||
def option_schema(factory) do
|
||||
with {:ok, schema} <- RelatedBuilder.option_schema(factory) do
|
||||
{:ok, Keyword.put(schema, :count, type: :pos_integer, required: true)}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build(factory, how_many, options) when how_many > 0 and is_integer(how_many) do
|
||||
1..how_many
|
||||
|> Enum.reduce_while({:ok, []}, fn _, {:ok, results} ->
|
||||
case Builder.build(RelatedBuilder, factory, options) do
|
||||
{:ok, attrs} -> {:cont, {:ok, [attrs | results]}}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_build(_factory, how_many, _options) do
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Invalid `count` option: `#{inspect(how_many)}`: Must be a positive integer."
|
||||
)}
|
||||
end
|
||||
end
|
191
lib/smokestack/builders/param_builder.ex
Normal file
191
lib/smokestack/builders/param_builder.ex
Normal file
|
@ -0,0 +1,191 @@
|
|||
defmodule Smokestack.ParamBuilder do
|
||||
@moduledoc """
|
||||
Handles the building of parameters.
|
||||
"""
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Factory, ManyBuilder, RelatedBuilder}
|
||||
alias Spark.OptionsHelpers
|
||||
@behaviour Builder
|
||||
|
||||
@type option ::
|
||||
encode_option
|
||||
| key_case_option
|
||||
| key_type_option
|
||||
| nest_option
|
||||
| ManyBuilder.option()
|
||||
| RelatedBuilder.option()
|
||||
|
||||
@typedoc """
|
||||
Encode the result using the specified encoder.
|
||||
|
||||
Smokestack will call `encode/1` on the module provided with the generated
|
||||
result (or results). For example set `encode: Jason` or `encode: Poison`
|
||||
to encode the results as a JSON string.
|
||||
|
||||
The module's encode function should return an ok/error tuple.
|
||||
"""
|
||||
@type encode_option :: {:encode, module}
|
||||
|
||||
@typedoc """
|
||||
Nest the result within the specified key in the output.
|
||||
"""
|
||||
@type nest_option :: {:nest, String.t() | atom}
|
||||
|
||||
@typedoc """
|
||||
Format the keys in the specified case. Defaults to `:snake`
|
||||
"""
|
||||
@type key_case_option ::
|
||||
{:key_case,
|
||||
:camel
|
||||
| :constant
|
||||
| :dot
|
||||
| :header
|
||||
| :kebab
|
||||
| :name
|
||||
| :pascal
|
||||
| :path
|
||||
| {:path, separator :: String.t()}
|
||||
| :sentence
|
||||
| :snake
|
||||
| :title}
|
||||
|
||||
@typedoc """
|
||||
Convert the keys into the specified type. Defaults to `:atom`.
|
||||
"""
|
||||
@type key_type_option :: {:key_type, :string | :atom}
|
||||
|
||||
@type result :: %{required(String.t() | atom) => any}
|
||||
@type error :: any | ManyBuilder.error() | RelatedBuilder.error()
|
||||
|
||||
@doc """
|
||||
Run the factory and return a map or list-of-maps of params.
|
||||
"""
|
||||
@impl true
|
||||
@spec build(Factory.t(), [option]) :: {:ok, result} | {:error, error}
|
||||
def build(factory, options) do
|
||||
{count, options} = Keyword.pop(options, :count)
|
||||
do_build(factory, options, count)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
|
||||
def option_schema(factory) do
|
||||
with {:ok, schema0} <- RelatedBuilder.option_schema(factory),
|
||||
{:ok, schema1} <- ManyBuilder.option_schema(factory) do
|
||||
schema1 =
|
||||
Keyword.update!(schema1, :count, fn current ->
|
||||
current
|
||||
|> Keyword.update!(:type, &{:or, [&1, nil]})
|
||||
|> Keyword.put(:default, nil)
|
||||
|> Keyword.put(:required, false)
|
||||
end)
|
||||
|
||||
schema =
|
||||
schema0
|
||||
|> Keyword.merge(schema1)
|
||||
|> Keyword.merge(
|
||||
encode: [
|
||||
type: {:or, [nil, :module]},
|
||||
required: false
|
||||
],
|
||||
nest: [
|
||||
type: {:or, [:atom, :string]},
|
||||
required: false
|
||||
],
|
||||
key_case: [
|
||||
type:
|
||||
{:or,
|
||||
[
|
||||
{:tuple, [{:literal, :path}, :string]},
|
||||
{:in,
|
||||
[
|
||||
:camel,
|
||||
:constant,
|
||||
:dot,
|
||||
:header,
|
||||
:kebab,
|
||||
:name,
|
||||
:pascal,
|
||||
:path,
|
||||
:sentence,
|
||||
:snake,
|
||||
:title
|
||||
]}
|
||||
]},
|
||||
required: false,
|
||||
default: :snake
|
||||
],
|
||||
key_type: [
|
||||
type: {:in, [:string, :atom]},
|
||||
required: false,
|
||||
default: :atom
|
||||
]
|
||||
)
|
||||
|
||||
{:ok, schema}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build(factory, options, count) when is_integer(count) and count > 0 do
|
||||
{my_opts, their_opts} = split_options(options)
|
||||
their_opts = Keyword.put(their_opts, :count, count)
|
||||
|
||||
with {:ok, attr_list} <- Builder.build(ManyBuilder, factory, their_opts) do
|
||||
attr_list
|
||||
|> convert_keys(my_opts)
|
||||
|> maybe_nest_result(my_opts[:nest])
|
||||
|> maybe_encode_result(my_opts[:encode])
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build(factory, options, _) do
|
||||
{my_opts, their_opts} = split_options(options)
|
||||
|
||||
with {:ok, attrs} <- Builder.build(RelatedBuilder, factory, their_opts) do
|
||||
attrs
|
||||
|> convert_keys(my_opts)
|
||||
|> maybe_nest_result(my_opts[:nest])
|
||||
|> maybe_encode_result(my_opts[:encode])
|
||||
end
|
||||
end
|
||||
|
||||
defp split_options(options), do: Keyword.split(options, ~w[encode key_case key_type nest]a)
|
||||
|
||||
defp convert_keys(attr_list, options) when is_list(attr_list),
|
||||
do: Enum.map(attr_list, &convert_keys(&1, options))
|
||||
|
||||
defp convert_keys(attrs, options) do
|
||||
Map.new(attrs, fn {key, value} ->
|
||||
key =
|
||||
key
|
||||
|> recase(options[:key_case] || :snake)
|
||||
|> cast(options[:key_type] || :atom)
|
||||
|
||||
{key, value}
|
||||
end)
|
||||
end
|
||||
|
||||
defp recase(key, style) when is_atom(key), do: key |> to_string() |> recase(style)
|
||||
defp recase(key, :camel), do: Recase.to_camel(key)
|
||||
defp recase(key, :constant), do: Recase.to_constant(key)
|
||||
defp recase(key, :dot), do: Recase.to_dot(key)
|
||||
defp recase(key, :header), do: Recase.to_header(key)
|
||||
defp recase(key, :kebab), do: Recase.to_kebab(key)
|
||||
defp recase(key, :name), do: Recase.to_name(key)
|
||||
defp recase(key, :pascal), do: Recase.to_pascal(key)
|
||||
defp recase(key, :path), do: Recase.to_path(key)
|
||||
defp recase(key, {:path, separator}), do: Recase.to_path(key, separator)
|
||||
defp recase(key, :sentence), do: Recase.to_sentence(key)
|
||||
defp recase(key, :snake), do: Recase.to_snake(key)
|
||||
defp recase(key, :title), do: Recase.to_title(key)
|
||||
|
||||
defp cast(key, :atom), do: String.to_atom(key)
|
||||
defp cast(key, :string), do: key
|
||||
|
||||
defp maybe_nest_result(result, nil), do: result
|
||||
defp maybe_nest_result(result, key) when is_atom(key) or is_binary(key), do: %{key => result}
|
||||
|
||||
defp maybe_encode_result(result, nil), do: {:ok, result}
|
||||
defp maybe_encode_result(result, encoder) when is_atom(encoder), do: encoder.encode(result)
|
||||
end
|
132
lib/smokestack/builders/record_builder.ex
Normal file
132
lib/smokestack/builders/record_builder.ex
Normal file
|
@ -0,0 +1,132 @@
|
|||
defmodule Smokestack.RecordBuilder do
|
||||
@moduledoc """
|
||||
Handles the insertion of new records.
|
||||
"""
|
||||
|
||||
alias Ash.{Resource, Seed}
|
||||
alias Smokestack.{Builder, Dsl.Factory, ManyBuilder, RelatedBuilder}
|
||||
alias Spark.OptionsHelpers
|
||||
@behaviour Builder
|
||||
|
||||
@type option :: load_option | ManyBuilder.option() | RelatedBuilder.option()
|
||||
|
||||
@typedoc "A nested keyword list of associations, calculations and aggregates to load"
|
||||
@type load_option :: {:load, Smokestack.recursive_atom_list()}
|
||||
|
||||
@type result :: Resource.record() | [Resource.record()]
|
||||
@type error ::
|
||||
RelatedBuilder.error()
|
||||
| ManyBuilder.error()
|
||||
| Ash.Error.t()
|
||||
|
||||
@doc """
|
||||
Run the factory and insert a record, or records.
|
||||
"""
|
||||
@impl true
|
||||
@spec build(Factory.t(), [option]) :: {:ok, result} | {:error, error}
|
||||
def build(factory, options) do
|
||||
{count, options} = Keyword.pop(options, :count)
|
||||
do_build(factory, options, count)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
|
||||
def option_schema(factory) do
|
||||
with {:ok, schema0} <- RelatedBuilder.option_schema(factory),
|
||||
{:ok, schema1} <- ManyBuilder.option_schema(factory) do
|
||||
loadable_names =
|
||||
factory.resource
|
||||
|> Resource.Info.relationships()
|
||||
|> Enum.concat(Resource.Info.aggregates(factory.resource))
|
||||
|> Enum.concat(Resource.Info.calculations(factory.resource))
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.uniq()
|
||||
|
||||
schema1 =
|
||||
Keyword.update!(schema1, :count, fn current ->
|
||||
current
|
||||
|> Keyword.update!(:type, &{:or, [&1, nil]})
|
||||
|> Keyword.put(:default, nil)
|
||||
|> Keyword.put(:required, false)
|
||||
end)
|
||||
|
||||
schema =
|
||||
[
|
||||
load: [
|
||||
type:
|
||||
{:or,
|
||||
[
|
||||
{:wrap_list, {:in, loadable_names}},
|
||||
{:keyword_list,
|
||||
Enum.map(
|
||||
loadable_names,
|
||||
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
|
||||
)}
|
||||
]},
|
||||
required: false,
|
||||
default: []
|
||||
]
|
||||
]
|
||||
|> Keyword.merge(schema0)
|
||||
|> Keyword.merge(schema1)
|
||||
|
||||
{:ok, schema}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build(factory, options, count) when is_integer(count) and count > 0 do
|
||||
{load, options} = Keyword.pop(options, :load, [])
|
||||
options = Keyword.put(options, :count, count)
|
||||
|
||||
with {:ok, attr_list} <- Builder.build(ManyBuilder, factory, options),
|
||||
{:ok, record_list} <- seed(attr_list, factory) do
|
||||
maybe_load(record_list, factory, load)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build(factory, options, _count) do
|
||||
{load, options} = Keyword.pop(options, :load, [])
|
||||
|
||||
with {:ok, attrs} <- Builder.build(RelatedBuilder, factory, options),
|
||||
{:ok, record} <- seed(attrs, factory) do
|
||||
maybe_load(record, factory, load)
|
||||
end
|
||||
end
|
||||
|
||||
defp seed(attr_list, factory) when is_list(attr_list) do
|
||||
records =
|
||||
factory.resource
|
||||
|> Seed.seed!(attr_list)
|
||||
|> Enum.map(&set_meta(&1, factory))
|
||||
|
||||
{:ok, records}
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp seed(attrs, factory) do
|
||||
record =
|
||||
factory.resource
|
||||
|> Seed.seed!(attrs)
|
||||
|> set_meta(factory)
|
||||
|
||||
{:ok, record}
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
defp set_meta(record, factory) do
|
||||
record
|
||||
|> Resource.put_metadata(:factory, factory.module)
|
||||
|> Resource.put_metadata(:variant, factory.variant)
|
||||
end
|
||||
|
||||
defp maybe_load(record_or_records, _factory, []), do: {:ok, record_or_records}
|
||||
|
||||
defp maybe_load(_record_or_records, factory, _load) when is_nil(factory.api),
|
||||
do: {:error, "Unable to perform `load` operation without an API."}
|
||||
|
||||
defp maybe_load(record_or_records, factory, load),
|
||||
do: factory.api.load(record_or_records, load, [])
|
||||
end
|
129
lib/smokestack/builders/related_builder.ex
Normal file
129
lib/smokestack/builders/related_builder.ex
Normal file
|
@ -0,0 +1,129 @@
|
|||
defmodule Smokestack.RelatedBuilder do
|
||||
@moduledoc """
|
||||
Recursively build the factory and any related factories that have been
|
||||
requested.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias Smokestack.{Builder, Dsl.Factory, Dsl.Info, FactoryBuilder}
|
||||
alias Spark.OptionsHelpers
|
||||
@behaviour Builder
|
||||
|
||||
@type option :: build_option | FactoryBuilder.option()
|
||||
|
||||
@typedoc """
|
||||
A nested keyword list of associations that should also be built.
|
||||
"""
|
||||
@type build_option :: {:build, Smokestack.recursive_atom_list()}
|
||||
|
||||
@type result :: %{optional(atom) => any}
|
||||
@type error :: FactoryBuilder.error() | Exception.t()
|
||||
|
||||
@doc """
|
||||
Build related factories, if required.
|
||||
"""
|
||||
@impl true
|
||||
@spec build(Factory.t(), [option]) :: {:ok, result} | {:error, error}
|
||||
def build(factory, options) do
|
||||
with {:ok, attrs} <- Builder.build(FactoryBuilder, factory, Keyword.delete(options, :build)) do
|
||||
maybe_build_related(factory, attrs, options)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec option_schema(Factory.t()) :: {:ok, OptionsHelpers.schema()} | {:error, error}
|
||||
def option_schema(factory) do
|
||||
with {:ok, factory_schema} <- FactoryBuilder.option_schema(factory) do
|
||||
relationship_names =
|
||||
factory.resource
|
||||
|> Resource.Info.relationships()
|
||||
|> Enum.map(& &1.name)
|
||||
|
||||
schema = [
|
||||
build: [
|
||||
type:
|
||||
{:or,
|
||||
[
|
||||
{:wrap_list, {:in, relationship_names}},
|
||||
{:keyword_list,
|
||||
Enum.map(
|
||||
relationship_names,
|
||||
&{&1, type: {:or, [:atom, :keyword_list]}, required: false}
|
||||
)}
|
||||
]},
|
||||
required: false,
|
||||
default: []
|
||||
]
|
||||
]
|
||||
|
||||
{:ok, Keyword.merge(factory_schema, schema)}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_build_related(factory, attrs, 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, attrs}, fn {relationship, nested_builds}, {:ok, attrs} ->
|
||||
case build_related(
|
||||
attrs,
|
||||
relationship,
|
||||
factory,
|
||||
Keyword.put(options, :build, nested_builds)
|
||||
) do
|
||||
{:ok, attrs} -> {:cont, {:ok, attrs}}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_related(attrs, relationship, factory, options) do
|
||||
ash_relationship = Resource.Info.relationship(factory.resource, relationship)
|
||||
build_related(attrs, relationship, factory, options, ash_relationship)
|
||||
end
|
||||
|
||||
defp build_related(_attrs, relationship, factory, _options, nil),
|
||||
do:
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message:
|
||||
"Relationship `#{inspect(relationship)}` is not defined on resource `#{inspect(factory.resource)}`."
|
||||
)}
|
||||
|
||||
defp build_related(attrs, _, factory, options, relationship) do
|
||||
related_options =
|
||||
options
|
||||
|> Keyword.put(:attrs, %{})
|
||||
|
||||
with {:ok, related_factory} <- find_related_factory(relationship.destination, factory),
|
||||
{:ok, related_attrs} <-
|
||||
Builder.build(__MODULE__, related_factory, related_options) do
|
||||
case relationship.cardinality do
|
||||
:one ->
|
||||
{:ok, Map.put(attrs, relationship.name, related_attrs)}
|
||||
|
||||
:many ->
|
||||
{:ok, Map.put(attrs, relationship.name, [related_attrs])}
|
||||
end
|
||||
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:
|
||||
"No factory variant named `#{inspect(factory.variant)}` or `:default` found on `#{inspect(factory.resource)}`."
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,7 +10,8 @@ defmodule Smokestack.Dsl.Info do
|
|||
@doc """
|
||||
Retrieve a variant for a specific resource.
|
||||
"""
|
||||
@spec factory(Smokestack.t(), Resource.t(), atom) :: {:ok, Factory.t()} | {:error, any}
|
||||
@spec factory(Smokestack.t(), Resource.t(), atom) ::
|
||||
{:ok, Factory.t()} | {:error, Exception.t()}
|
||||
def factory(factory, resource, variant) do
|
||||
factory
|
||||
|> Extension.get_entities([:smokestack])
|
||||
|
|
|
@ -1,201 +0,0 @@
|
|||
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
|
|
@ -1,89 +0,0 @@
|
|||
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
|
7
mix.exs
7
mix.exs
|
@ -1,7 +1,7 @@
|
|||
defmodule Smokestack.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
@version "0.3.0"
|
||||
@version "0.3.1"
|
||||
|
||||
@moduledoc """
|
||||
Test factories for Ash resources.
|
||||
|
@ -47,7 +47,6 @@ defmodule Smokestack.MixProject do
|
|||
|
||||
[
|
||||
{:ash, "~> 2.13"},
|
||||
{:spark, "~> 1.1"},
|
||||
{:credo, "~> 1.7", opts},
|
||||
{:dialyxir, "~> 1.3", opts},
|
||||
{:doctor, "~> 0.21", opts},
|
||||
|
@ -56,7 +55,9 @@ defmodule Smokestack.MixProject do
|
|||
{:ex_doc, ">= 0.0.0", opts},
|
||||
{:faker, "~> 0.17", opts},
|
||||
{:git_ops, "~> 2.6", opts},
|
||||
{:mix_audit, "~> 2.1", opts}
|
||||
{:mix_audit, "~> 2.1", opts},
|
||||
{:recase, "~> 0.7"},
|
||||
{:spark, "~> 1.1"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
1
mix.lock
1
mix.lock
|
@ -26,6 +26,7 @@
|
|||
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
|
||||
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
|
||||
"recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"},
|
||||
"sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"},
|
||||
"spark": {:hex, :spark, "1.1.22", "68ba00f9acb4c8bc2c93ef82249493687ddf0f0a4f7e79c3c0e22b06719add56", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "b798b95990eed8f2409df47b818b5dbcd00e9b5c30d0355465d0b04bbf9b5c4c"},
|
||||
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
|
||||
|
|
20
test/smokestack/builders/factory_builder_test.exs
Normal file
20
test/smokestack/builders/factory_builder_test.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Smokestack.FactoryBuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Info, FactoryBuilder}
|
||||
alias Support.{Factory, Post}
|
||||
|
||||
test "it can build attributes from a factory" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, attrs} = Builder.build(FactoryBuilder, factory, [])
|
||||
assert byte_size(attrs[:title]) > 0
|
||||
end
|
||||
|
||||
test "it allows attributes to be overridden" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
|
||||
assert {:ok, %{title: "wat"}} =
|
||||
Builder.build(FactoryBuilder, factory, attrs: %{title: "wat"})
|
||||
end
|
||||
end
|
20
test/smokestack/builders/many_builder_test.exs
Normal file
20
test/smokestack/builders/many_builder_test.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Smokestack.ManyBuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Info, ManyBuilder}
|
||||
alias Support.{Factory, Post}
|
||||
|
||||
test "it can build a factory more than once" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, results} = Builder.build(ManyBuilder, factory, count: 2)
|
||||
assert length(results) == 2
|
||||
assert Enum.all?(results, &(byte_size(&1.title) > 0))
|
||||
end
|
||||
|
||||
test "it errors when asked to build less than one instance" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:error, reason} = Builder.build(ManyBuilder, factory, count: 0)
|
||||
assert Exception.message(reason) =~ ~r/expected positive integer/i
|
||||
end
|
||||
end
|
56
test/smokestack/builders/param_builder_test.exs
Normal file
56
test/smokestack/builders/param_builder_test.exs
Normal file
|
@ -0,0 +1,56 @@
|
|||
defmodule Smokestack.ParamBuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.{Dsl.Info, ParamBuilder}
|
||||
alias Support.{Factory, Post}
|
||||
|
||||
describe "build/2..5" do
|
||||
test "it builds params" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, params} = ParamBuilder.build(factory, [])
|
||||
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 `:encode` option" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, params} = ParamBuilder.build(factory, encode: Jason)
|
||||
assert is_binary(params)
|
||||
assert {:ok, params} = Jason.decode(params)
|
||||
assert params["body"]
|
||||
assert Enum.all?(params["tags"], &is_binary/1)
|
||||
assert params["title"]
|
||||
end
|
||||
|
||||
test "it honours the `:key_case` option" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, params} = ParamBuilder.build(factory, key_case: :kebab)
|
||||
assert params[:"sub-title"]
|
||||
end
|
||||
|
||||
test "it honours the `:key_type` option" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, params} = ParamBuilder.build(factory, key_type: :string)
|
||||
assert params["title"]
|
||||
end
|
||||
|
||||
test "it honours the `:nest` option" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, params} = ParamBuilder.build(factory, nest: :data)
|
||||
assert params[:data][:title]
|
||||
end
|
||||
|
||||
test "it honours all options at once" do
|
||||
assert {:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
|
||||
assert {:ok, params} =
|
||||
ParamBuilder.build(factory, encode: Jason, key_case: :camel, nest: :data)
|
||||
|
||||
assert {:ok, params} = Jason.decode(params)
|
||||
assert params["data"]["subTitle"]
|
||||
end
|
||||
end
|
||||
end
|
63
test/smokestack/builders/record_builder_test.exs
Normal file
63
test/smokestack/builders/record_builder_test.exs
Normal file
|
@ -0,0 +1,63 @@
|
|||
defmodule Smokestack.RecordBuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Info, RecordBuilder}
|
||||
alias Support.{Author, Factory, Post}
|
||||
|
||||
test "it can build a single record" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, record} = Builder.build(RecordBuilder, factory, [])
|
||||
assert is_struct(record, Post)
|
||||
assert record.__meta__.state == :loaded
|
||||
end
|
||||
|
||||
test "it can build multiple records" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, records} = Builder.build(RecordBuilder, factory, count: 2)
|
||||
assert length(records) == 2
|
||||
assert Enum.all?(records, &(is_struct(&1, Post) && &1.__meta__.state == :loaded))
|
||||
end
|
||||
|
||||
test "it can build directly related records" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, record} = Builder.build(RecordBuilder, factory, build: :author)
|
||||
assert is_struct(record.author, Author)
|
||||
assert record.author.__meta__.state == :loaded
|
||||
end
|
||||
|
||||
test "it can build indirectly related records" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, record} = Builder.build(RecordBuilder, factory, build: [author: :posts])
|
||||
assert [%Post{} = post] = record.author.posts
|
||||
assert post.__meta__.state == :loaded
|
||||
end
|
||||
|
||||
test "it can load calculations" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, record} = Builder.build(RecordBuilder, factory, load: :full_title)
|
||||
assert record.full_title == record.title <> ": " <> record.sub_title
|
||||
end
|
||||
|
||||
test "it can load aggregates" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
|
||||
assert {:ok, record} =
|
||||
Builder.build(RecordBuilder, factory,
|
||||
load: [author: :count_of_posts],
|
||||
build: [author: :posts]
|
||||
)
|
||||
|
||||
assert record.author.count_of_posts == 2
|
||||
end
|
||||
|
||||
test "it can load relationships" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
|
||||
assert {:ok, record} =
|
||||
Builder.build(RecordBuilder, factory, build: :author, load: [author: :posts])
|
||||
|
||||
assert [post] = record.author.posts
|
||||
assert post.id == record.id
|
||||
end
|
||||
end
|
20
test/smokestack/builders/related_builder_test.exs
Normal file
20
test/smokestack/builders/related_builder_test.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Smokestack.RelatedBuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.{Builder, Dsl.Info, RelatedBuilder}
|
||||
alias Support.{Factory, Post}
|
||||
|
||||
test "it can build attributes from directly related factories" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, attrs} = Builder.build(RelatedBuilder, factory, build: :author)
|
||||
assert byte_size(attrs[:author][:name]) > 0
|
||||
end
|
||||
|
||||
test "it can build attributes from indirectly related factories" do
|
||||
{:ok, factory} = Info.factory(Factory, Post, :default)
|
||||
assert {:ok, attrs} = Builder.build(RelatedBuilder, factory, build: [author: :posts])
|
||||
assert [post] = attrs[:author][:posts]
|
||||
assert byte_size(post[:title]) > 0
|
||||
end
|
||||
end
|
|
@ -1,58 +0,0 @@
|
|||
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
|
|
@ -1,33 +0,0 @@
|
|||
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
|
|
@ -25,4 +25,8 @@ defmodule Support.Author do
|
|||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
|
||||
aggregates do
|
||||
count :count_of_posts, :posts
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue