Compare commits

...

4 commits

Author SHA1 Message Date
James Harton a69e595f65
chore: release version v0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-09 19:33:36 +12:00
James Harton a0cab56172
chore: enable publishing to hex. 2023-09-09 19:32:11 +12:00
James Harton 744e555577
docs: improve the readme and moduledocs.
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-08 21:28:26 +12:00
James Harton 01c9e73b5b refactor: Split builders into composable chunks. (#6)
All checks were successful
continuous-integration/drone/push Build is passing
This makes the design a little easier to understand and change.

Also implements building of many params/records as a side-effect.

Reviewed-on: https://code.harton.nz/james/smokestack/pulls/6
Co-authored-by: James Harton <james@harton.nz>
Co-committed-by: James Harton <james@harton.nz>
2023-09-08 07:25:39 +12:00
23 changed files with 986 additions and 443 deletions

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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