feat: Factory DSL and param building.
This commit is contained in:
parent
52e3457d4f
commit
8a466a106b
|
@ -17,7 +17,7 @@
|
|||
# {:compiler, false},
|
||||
|
||||
## ...or have command & args adjusted (e.g. enable skip comments for sobelow)
|
||||
# {:sobelow, "mix sobelow --config"},
|
||||
{:sobelow, false},
|
||||
|
||||
## ...or reordered (e.g. to see output from dialyzer before others)
|
||||
# {:dialyzer, order: -1},
|
||||
|
@ -28,6 +28,6 @@
|
|||
## custom new tools may be added (Mix tasks or arbitrary commands)
|
||||
# {:my_task, "mix my_task", env: %{"MIX_ENV" => "prod"}},
|
||||
# {:my_tool, ["my_tool", "arg with spaces"]}
|
||||
# {:spark_formatter, "mix spark.formatter --check"}
|
||||
{:spark_formatter, "mix spark.formatter --check"}
|
||||
]
|
||||
]
|
||||
|
|
17
.doctor.exs
Normal file
17
.doctor.exs
Normal file
|
@ -0,0 +1,17 @@
|
|||
%Doctor.Config{
|
||||
ignore_modules: [
|
||||
~r/^Inspect\./,
|
||||
~r/^Support\./
|
||||
],
|
||||
ignore_paths: [],
|
||||
min_module_doc_coverage: 40,
|
||||
min_module_spec_coverage: 0,
|
||||
min_overall_doc_coverage: 50,
|
||||
min_overall_spec_coverage: 0,
|
||||
min_overall_moduledoc_coverage: 100,
|
||||
exception_moduledoc_required: true,
|
||||
raise: false,
|
||||
reporter: Doctor.Reporters.Full,
|
||||
struct_type_spec_required: true,
|
||||
umbrella: false
|
||||
}
|
|
@ -506,8 +506,8 @@ steps:
|
|||
- mc alias set store ${S3_ENDPOINT} ${ACCESS_KEY} ${SECRET_KEY}
|
||||
- mc mb -p store/docs.harton.nz
|
||||
- mc anonymous set download store/docs.harton.nz
|
||||
- mc mirror store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
|
||||
- mc mirror store/docs.harton.nz/$${DRONE_REPO}
|
||||
- mc mirror doc/ store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
|
||||
- mc mirror doc/ store/docs.harton.nz/$${DRONE_REPO}
|
||||
|
||||
# - name: hex release
|
||||
# image: code.harton.nz/james/asdf_container:latest
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
spark_locals_without_parens = []
|
||||
spark_locals_without_parens = [attribute: 2, attribute: 3, factory: 1, factory: 2, factory: 3]
|
||||
|
||||
[
|
||||
import_deps: [:spark],
|
||||
import_deps: [:ash, :spark],
|
||||
inputs: [
|
||||
"*.{ex,exs}",
|
||||
"{config,lib,test}/**/*.{ex,exs}"
|
||||
|
|
31
README.md
31
README.md
|
@ -6,25 +6,26 @@
|
|||
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:
|
||||
|
||||
```elixir
|
||||
defmodule Character do
|
||||
use Ash.Resource, extension: [Smokestack.Resource]
|
||||
defmodule MyApp.Factory do
|
||||
use Smokestack
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :name, :string
|
||||
attribute :affiliation, :string
|
||||
factory Character do
|
||||
attribute :name, &Faker.StarWars.character/0
|
||||
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
|
||||
end
|
||||
|
||||
factory do
|
||||
default do
|
||||
attribute :name, &Faker.StarWars.character/0
|
||||
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
|
||||
end
|
||||
factory Character, :trek do
|
||||
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
|
||||
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
|
||||
end
|
||||
end
|
||||
|
||||
variant :trek do
|
||||
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
|
||||
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
|
||||
end
|
||||
defmodule MyApp.CharacterTest do
|
||||
use MyApp.DataCase
|
||||
use MyApp.Factory
|
||||
|
||||
test "it can build a character" do
|
||||
assert character = MyApp.Factory.build!(Character)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
defmodule Smokestack do
|
||||
alias Spark.{Dsl, Dsl.Extension}
|
||||
|
||||
@moduledoc """
|
||||
Documentation for `Smokestack`.
|
||||
|
||||
<!--- ash-hq-hide-start --> <!--- -->
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Extension.doc_index(Smokestack.Dsl.sections())}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Extension.doc(Smokestack.Dsl.sections())}
|
||||
|
||||
<!--- ash-hq-hide-stop --> <!--- -->
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Hello world.
|
||||
use Dsl, default_extensions: [extensions: [Smokestack.Dsl]]
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Smokestack.hello()
|
||||
:world
|
||||
|
||||
"""
|
||||
def hello do
|
||||
:world
|
||||
end
|
||||
@type t :: module
|
||||
end
|
||||
|
|
109
lib/smokestack/builder.ex
Normal file
109
lib/smokestack/builder.ex
Normal file
|
@ -0,0 +1,109 @@
|
|||
defmodule Smokestack.Builder do
|
||||
@moduledoc """
|
||||
Handles the building of parameters and records.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias Smokestack.{Dsl.Attribute, Dsl.Info, Template}
|
||||
|
||||
@param_option_defaults [keys: :atom, as: :map]
|
||||
|
||||
@typedoc "Options that can be passed to `params/4`."
|
||||
@type param_options :: [param_keys_option | param_as_option]
|
||||
|
||||
@typedoc "Key type in the result. Defaults to `#{inspect(@param_option_defaults[:keys])}`."
|
||||
@type param_keys_option :: {:keys, :atom | :string | :dasherise}
|
||||
|
||||
@typedoc "Result type. Defaults to `#{inspect(@param_option_defaults[:as])}`"
|
||||
@type param_as_option :: {:as, :map | :list}
|
||||
|
||||
@type param_result ::
|
||||
%{required(String.t()) => any}
|
||||
| %{required(atom) => any}
|
||||
| [{String.t(), any}]
|
||||
| [{atom, any}]
|
||||
|
||||
@doc """
|
||||
Build parameters for a resource with a factory.
|
||||
"""
|
||||
@spec params(Smokestack.t(), Resource.t(), atom, param_options) ::
|
||||
{:ok, param_result} | {:error, any}
|
||||
def params(factory_module, resource, variant \\ :default, overrides \\ %{}, options \\ [])
|
||||
when is_atom(factory_module) and is_atom(resource) and is_atom(variant) and is_list(options) do
|
||||
with {:ok, factory} <- get_factory(factory_module, resource, variant),
|
||||
{:ok, params} <- build_params(factory, overrides, options) do
|
||||
params =
|
||||
params
|
||||
|> maybe_stringify_keys(options)
|
||||
|> maybe_dasherise_keys(options)
|
||||
|> maybe_listify_result(options)
|
||||
|
||||
{:ok, params}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Raising version of `params/2..5`."
|
||||
@spec params!(Smokestack.t(), Resource.t(), atom, param_options) :: param_result | no_return
|
||||
def params!(factory_module, resource, variant \\ :default, overrides \\ %{}, options \\ []) do
|
||||
case params(factory_module, resource, variant, overrides, options) do
|
||||
{:ok, params} -> params
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
defp get_factory(factory_module, resource, variant) do
|
||||
with :error <- Info.factory(factory_module, resource, variant) do
|
||||
{:error,
|
||||
ArgumentError.exception(
|
||||
message: "Factory for `#{inspect(resource)}` variant `#{inspect(variant)}` not found."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_params(factory, overrides, options) do
|
||||
factory
|
||||
|> Map.get(:attributes, [])
|
||||
|> Enum.filter(&is_struct(&1, Attribute))
|
||||
|> Enum.reduce({:ok, %{}}, fn attr, {:ok, attrs} ->
|
||||
case Map.fetch(overrides, attr.name) do
|
||||
{:ok, override} ->
|
||||
{:ok, Map.put(attrs, attr.name, override)}
|
||||
|
||||
:error ->
|
||||
value = Template.generate(attr.generator, attrs, options)
|
||||
{:ok, Map.put(attrs, attr.name, value)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_stringify_keys(attrs, options) do
|
||||
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :string do
|
||||
Map.new(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(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
|
33
lib/smokestack/dsl.ex
Normal file
33
lib/smokestack/dsl.ex
Normal file
|
@ -0,0 +1,33 @@
|
|||
defmodule Smokestack.Dsl do
|
||||
alias Spark.Dsl.{Extension, Section}
|
||||
alias Smokestack.Dsl.{Factory, Transformer, Verifier}
|
||||
|
||||
@section %Section{
|
||||
name: :smokestack,
|
||||
top_level?: true,
|
||||
entities: Factory.__entities__()
|
||||
}
|
||||
|
||||
@moduledoc """
|
||||
The DSL definition for the Smokestack DSL.
|
||||
|
||||
<!--- ash-hq-hide-start --> <!--- -->
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Extension.doc_index([@section])}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Extension.doc([@section])}
|
||||
|
||||
<!--- ash-hq-hide-stop --> <!--- -->
|
||||
"""
|
||||
|
||||
use Extension,
|
||||
sections: [@section],
|
||||
transformers: [Transformer],
|
||||
verifiers: [Verifier]
|
||||
end
|
43
lib/smokestack/dsl/attribute.ex
Normal file
43
lib/smokestack/dsl/attribute.ex
Normal file
|
@ -0,0 +1,43 @@
|
|||
defmodule Smokestack.Dsl.Attribute do
|
||||
@moduledoc """
|
||||
The `attribute ` DSL entity.
|
||||
|
||||
See `d:Smokestack.factory.default.attribute` for more information.
|
||||
"""
|
||||
|
||||
defstruct generator: nil, name: nil
|
||||
|
||||
alias Ash.Resource
|
||||
alias Spark.Dsl.Entity
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
generator:
|
||||
mfa | (-> any) | (Resource.record() -> any) | (Resource.record(), keyword -> any),
|
||||
name: atom
|
||||
}
|
||||
|
||||
@doc false
|
||||
@spec __entities__ :: [Entity.t()]
|
||||
def __entities__,
|
||||
do: [
|
||||
%Entity{
|
||||
name: :attribute,
|
||||
target: __MODULE__,
|
||||
args: [:name, :generator],
|
||||
schema: [
|
||||
name: [
|
||||
type: :atom,
|
||||
required: true,
|
||||
doc: "The name of the target attribute"
|
||||
],
|
||||
generator: [
|
||||
type: {:or, [{:mfa_or_fun, 0}, {:mfa_or_fun, 1}, {:mfa_or_fun, 2}]},
|
||||
required: true,
|
||||
doc: """
|
||||
A function which can generate an appropriate value for the attribute.œ
|
||||
"""
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
46
lib/smokestack/dsl/factory.ex
Normal file
46
lib/smokestack/dsl/factory.ex
Normal file
|
@ -0,0 +1,46 @@
|
|||
defmodule Smokestack.Dsl.Factory do
|
||||
@moduledoc """
|
||||
The `factory` DSL entity.
|
||||
|
||||
See `d:Smokestack.factory` for more information.
|
||||
"""
|
||||
|
||||
defstruct attributes: [], resource: nil, variant: :default
|
||||
|
||||
alias Ash.Resource
|
||||
alias Smokestack.Dsl.{Attribute, Template}
|
||||
alias Spark.Dsl.Entity
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
attributes: [Attribute.t()],
|
||||
resource: Resource.t(),
|
||||
variant: atom
|
||||
}
|
||||
|
||||
@doc false
|
||||
@spec __entities__ :: [Entity.t()]
|
||||
def __entities__,
|
||||
do: [
|
||||
%Entity{
|
||||
name: :factory,
|
||||
describe: "Define factories for a resource",
|
||||
target: __MODULE__,
|
||||
args: [:resource, {:optional, :variant, :default}],
|
||||
imports: [Template],
|
||||
schema: [
|
||||
resource: [
|
||||
type: {:behaviour, Ash.Resource},
|
||||
required: true,
|
||||
doc: "An Ash Resource"
|
||||
],
|
||||
variant: [
|
||||
type: :atom,
|
||||
required: false,
|
||||
doc: "The name of a factory variant",
|
||||
default: :default
|
||||
]
|
||||
],
|
||||
entities: [attributes: Attribute.__entities__()]
|
||||
}
|
||||
]
|
||||
end
|
23
lib/smokestack/dsl/info.ex
Normal file
23
lib/smokestack/dsl/info.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule Smokestack.Dsl.Info do
|
||||
@moduledoc """
|
||||
Introspection of Smokestack DSLs.
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias Smokestack.Dsl.Factory
|
||||
alias Spark.Dsl.Extension
|
||||
|
||||
@doc """
|
||||
Retrieve a variant for a specific resource.
|
||||
"""
|
||||
@spec factory(Smokestack.t(), Resource.t(), atom) :: {:ok, Factory.t()} | :error
|
||||
def factory(factory, resource, variant) do
|
||||
factory
|
||||
|> Extension.get_entities([:smokestack])
|
||||
|> Enum.find(&(is_struct(&1, Factory) && &1.resource == resource && &1.variant == variant))
|
||||
|> case do
|
||||
nil -> :error
|
||||
factory -> {:ok, factory}
|
||||
end
|
||||
end
|
||||
end
|
53
lib/smokestack/dsl/template.ex
Normal file
53
lib/smokestack/dsl/template.ex
Normal file
|
@ -0,0 +1,53 @@
|
|||
defmodule Smokestack.Dsl.Template do
|
||||
@moduledoc """
|
||||
Templates which assist in the generation of values.œ
|
||||
"""
|
||||
|
||||
alias Smokestack.Template
|
||||
|
||||
@type mapper :: nil | (any -> any)
|
||||
@type element :: any
|
||||
|
||||
defguardp is_mapper(fun) when is_nil(fun) or is_function(fun, 1)
|
||||
|
||||
@doc """
|
||||
Randomly select between a list of options.
|
||||
"""
|
||||
@spec choose(Enumerable.t(element), mapper) :: Template.t()
|
||||
def choose(options, mapper \\ nil) when is_mapper(mapper),
|
||||
do: %Template.Choose{options: options, mapper: mapper}
|
||||
|
||||
@doc """
|
||||
Cycle sequentially between a list of options.
|
||||
"""
|
||||
@spec cycle(Enumerable.t(element), mapper) :: Template.t()
|
||||
def cycle(options, mapper \\ nil) when is_mapper(mapper),
|
||||
do: %Template.Cycle{options: options, mapper: mapper}
|
||||
|
||||
@doc """
|
||||
Generate sequential values.
|
||||
"""
|
||||
@spec sequence(mapper, [{:start, number} | {:step, number}]) :: Template.t()
|
||||
def sequence(mapper \\ nil, sequence_options \\ []) when is_mapper(mapper) do
|
||||
sequence_options
|
||||
|> Map.new()
|
||||
|> Map.put(:mapper, mapper)
|
||||
|> Enum.reject(&is_nil(elem(&1, 1)))
|
||||
|> then(&struct(Template.Sequence, &1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Call a generator a number of times.
|
||||
"""
|
||||
@spec n_times(pos_integer | Range.t(pos_integer, pos_integer), Template.t(), mapper) ::
|
||||
Template.t()
|
||||
def n_times(n, generator, mapper \\ nil)
|
||||
|
||||
def n_times(n, generator, mapper) when is_integer(n) and n > 0 and is_mapper(mapper),
|
||||
do: %Template.NTimes{n: n, generator: generator, mapper: mapper}
|
||||
|
||||
def n_times(range, generator, mapper)
|
||||
when is_struct(range, Range) and is_integer(range.first) and is_integer(range.last) and
|
||||
is_integer(range.step),
|
||||
do: %Template.NTimes{n: range, generator: generator, mapper: mapper}
|
||||
end
|
12
lib/smokestack/dsl/transformer.ex
Normal file
12
lib/smokestack/dsl/transformer.ex
Normal file
|
@ -0,0 +1,12 @@
|
|||
defmodule Smokestack.Dsl.Transformer do
|
||||
@moduledoc false
|
||||
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
use Transformer
|
||||
|
||||
@doc false
|
||||
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
|
||||
def transform(dsl_state) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
189
lib/smokestack/dsl/verifier.ex
Normal file
189
lib/smokestack/dsl/verifier.ex
Normal file
|
@ -0,0 +1,189 @@
|
|||
defmodule Smokestack.Dsl.Verifier do
|
||||
@moduledoc false
|
||||
alias Ash.Resource.Info
|
||||
alias Smokestack.{Dsl.Attribute, Dsl.Factory, Template}
|
||||
alias Spark.{Dsl, Dsl.Verifier, Error.DslError}
|
||||
use Verifier
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec verify(Dsl.t()) :: :ok | {:error, DslError.t()}
|
||||
def verify(dsl_state) do
|
||||
error_info = %{module: Verifier.get_persisted(dsl_state, :module), path: [:smokestack]}
|
||||
|
||||
factories =
|
||||
dsl_state
|
||||
|> Verifier.get_entities([:smokestack])
|
||||
|> Enum.filter(&is_struct(&1, Factory))
|
||||
|
||||
with :ok <- verify_unique_factories(factories, error_info) do
|
||||
Enum.reduce_while(factories, :ok, fn factory, :ok ->
|
||||
case verify_factory(factory, error_info) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_unique_factories(factories, error_info) do
|
||||
factories
|
||||
|> Enum.map(&{&1.resource, &1.variant})
|
||||
|> Enum.frequencies()
|
||||
|> Enum.reject(&(elem(&1, 1) == 1))
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|> case do
|
||||
[] ->
|
||||
:ok
|
||||
|
||||
duplicates ->
|
||||
message =
|
||||
duplicates
|
||||
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|
||||
|> Enum.map(fn {resource, variants} -> {resource, Enum.uniq(variants)} end)
|
||||
|> Enum.sort_by(&elem(&1, 0))
|
||||
|> Enum.reduce(
|
||||
"Multiple factories defined for the following:",
|
||||
fn {resource, variants}, message ->
|
||||
variants =
|
||||
variants
|
||||
|> Enum.sort()
|
||||
|> Enum.map_join(", ", &"`#{&1}`")
|
||||
|
||||
message <>
|
||||
"\n - `#{inspect(resource)}`: #{variants}"
|
||||
end
|
||||
)
|
||||
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: error_info.module,
|
||||
path: Enum.reverse(error_info.path),
|
||||
message: message
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_factory(factory, error_info) do
|
||||
error_info =
|
||||
Map.merge(error_info, %{resource: factory.resource, path: [:factory | error_info.path]})
|
||||
|
||||
with :ok <- verify_unique_attributes(factory, error_info) do
|
||||
factory
|
||||
|> Map.get(:attributes, [])
|
||||
|> Enum.filter(&is_struct(&1, Attribute))
|
||||
|> Enum.reduce_while(:ok, fn attribute, :ok ->
|
||||
case verify_attribute(attribute, error_info) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_unique_attributes(factory, error_info) do
|
||||
factory
|
||||
|> Map.get(:attributes, {})
|
||||
|> Enum.filter(&is_struct(&1, Attribute))
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.frequencies()
|
||||
|> Enum.reject(&(elem(&1, 1) == 1))
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|> case do
|
||||
[] ->
|
||||
:ok
|
||||
|
||||
duplicates ->
|
||||
duplicates =
|
||||
duplicates
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
|> Enum.map_join(", ", &"`#{&1}`")
|
||||
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: error_info.module,
|
||||
path: Enum.reverse(error_info.path),
|
||||
message:
|
||||
"Duplicate attributes for factory `#{inspect(factory.resource)}`/`#{factory.variant}`: " <>
|
||||
duplicates
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_attribute(attribute, error_info) do
|
||||
error_info = %{error_info | path: [:attribute | error_info.path]}
|
||||
|
||||
with :ok <- verify_attribute_in_resource(attribute, error_info) do
|
||||
verify_attribute_generator(attribute, error_info)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_attribute_in_resource(attribute, error_info) do
|
||||
case Info.attribute(error_info.resource, attribute.name) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: error_info.module,
|
||||
path: Enum.reverse(error_info.path),
|
||||
message:
|
||||
"No attribute named `#{inspect(attribute.name)}` defined on the `#{inspect(error_info.resource)}` resource."
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_attribute_generator(attribute, _error_info)
|
||||
when is_function(attribute.generator, 0),
|
||||
do: :ok
|
||||
|
||||
defp verify_attribute_generator(attribute, _error_info)
|
||||
when is_function(attribute.generator, 1),
|
||||
do: :ok
|
||||
|
||||
defp verify_attribute_generator(attribute, _error_info)
|
||||
when is_function(attribute.generator, 2),
|
||||
do: :ok
|
||||
|
||||
defp verify_attribute_generator(attribute, error_info) when is_struct(attribute.generator) do
|
||||
case Template.impl_for(attribute.generator) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: error_info.module,
|
||||
path: Enum.reverse(error_info.path),
|
||||
message:
|
||||
"Protocol `Smokestack.Template` not implemented for `#{inspect(attribute.generator.__struct__)}`."
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_attribute_generator(%{generator: {m, f, a}}, error_info)
|
||||
when {m, f, a}
|
||||
when is_atom(m) and is_atom(f) and is_list(a) do
|
||||
min_arity = length(a)
|
||||
max_arity = min_arity + 2
|
||||
|
||||
m.info(:functions)
|
||||
|> Enum.any?(fn {name, arity} ->
|
||||
name == f && arity >= min_arity && arity <= max_arity
|
||||
end)
|
||||
|> case do
|
||||
true ->
|
||||
:ok
|
||||
|
||||
false ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
module: error_info.module,
|
||||
path: Enum.reverse(error_info.path),
|
||||
message: "No exported function matching `#{inspect(m)}.#{f}/#{min_arity}..#{max_arity}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
27
lib/smokestack/template.ex
Normal file
27
lib/smokestack/template.ex
Normal file
|
@ -0,0 +1,27 @@
|
|||
defprotocol Smokestack.Template do
|
||||
@moduledoc """
|
||||
A protocol for generating values from templates.
|
||||
"""
|
||||
|
||||
@type mapper :: nil | (any -> any)
|
||||
|
||||
@doc """
|
||||
Initialise the template, if required.
|
||||
"""
|
||||
@spec init(t) :: t
|
||||
def init(template)
|
||||
|
||||
@doc """
|
||||
Generate a value from the template.
|
||||
"""
|
||||
@spec generate(t, map, keyword) :: any
|
||||
def generate(template, record, options)
|
||||
end
|
||||
|
||||
defimpl Smokestack.Template, for: Function do
|
||||
def init(template), do: template
|
||||
|
||||
def generate(fun, _record, _options) when is_function(fun, 0), do: fun.()
|
||||
def generate(fun, record, _options) when is_function(fun, 1), do: fun.(record)
|
||||
def generate(fun, record, options) when is_function(fun, 2), do: fun.(record, options)
|
||||
end
|
15
lib/smokestack/template/choose.ex
Normal file
15
lib/smokestack/template/choose.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
defmodule Smokestack.Template.Choose do
|
||||
@moduledoc false
|
||||
defstruct options: [], mapper: nil
|
||||
|
||||
@type t :: %__MODULE__{options: Enumerable.t(any), mapper: Smokestack.Template.mapper()}
|
||||
|
||||
defimpl Smokestack.Template do
|
||||
def init(choose), do: choose
|
||||
|
||||
def generate(choose, _, _) when is_function(choose.mapper, 1),
|
||||
do: choose.options |> Enum.random() |> choose.mapper.()
|
||||
|
||||
def generate(choose, _, _), do: Enum.random(choose.options)
|
||||
end
|
||||
end
|
41
lib/smokestack/template/cycle.ex
Normal file
41
lib/smokestack/template/cycle.ex
Normal file
|
@ -0,0 +1,41 @@
|
|||
defmodule Smokestack.Template.Cycle do
|
||||
@moduledoc false
|
||||
defstruct options: [], count: 0, mapper: nil, agent: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
options: Enumerable.t(any),
|
||||
count: non_neg_integer,
|
||||
mapper: Smokestack.Template.mapper(),
|
||||
agent: nil | pid
|
||||
}
|
||||
|
||||
defimpl Smokestack.Template do
|
||||
def init(cycle) do
|
||||
{:ok, pid} = Agent.start_link(fn -> 0 end)
|
||||
|
||||
{options, count} =
|
||||
Enum.reduce(cycle.options, {[], 0}, fn option, {options, count} ->
|
||||
{[option | options], count + 1}
|
||||
end)
|
||||
|
||||
%{cycle | options: Enum.reverse(options), count: count, agent: pid}
|
||||
end
|
||||
|
||||
def generate(cycle, _record, _options) when is_function(cycle.mapper, 1) do
|
||||
count = Agent.get_and_update(cycle.agent, &{&1, &1 + 1})
|
||||
index = rem(count, cycle.count)
|
||||
|
||||
cycle.options
|
||||
|> Enum.at(index)
|
||||
|> cycle.mapper.()
|
||||
end
|
||||
|
||||
def generate(cycle, _record, _options) do
|
||||
count = Agent.get_and_update(cycle.agent, &{&1, &1 + 1})
|
||||
index = rem(count, cycle.count)
|
||||
|
||||
cycle.options
|
||||
|> Enum.at(index)
|
||||
end
|
||||
end
|
||||
end
|
37
lib/smokestack/template/n_times.ex
Normal file
37
lib/smokestack/template/n_times.ex
Normal file
|
@ -0,0 +1,37 @@
|
|||
defmodule Smokestack.Template.NTimes do
|
||||
@moduledoc false
|
||||
defstruct n: 1, generator: nil, mapper: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
n: non_neg_integer,
|
||||
generator: Smokestack.Template.t(),
|
||||
mapper: Smokestack.Template.mapper()
|
||||
}
|
||||
|
||||
defimpl Smokestack.Template do
|
||||
def init(ntimes), do: ntimes
|
||||
|
||||
def generate(ntimes, record, options) when is_integer(ntimes.n) do
|
||||
0..ntimes.n
|
||||
|> Enum.map(fn _ ->
|
||||
ntimes.generator
|
||||
|> Smokestack.Template.generate(record, options)
|
||||
|> maybe_map(ntimes.mapper)
|
||||
end)
|
||||
end
|
||||
|
||||
def generate(ntimes, record, options) when is_struct(ntimes.n, Range) do
|
||||
ntimes.n
|
||||
|> Enum.random()
|
||||
|> then(&(0..&1))
|
||||
|> Enum.map(fn _ ->
|
||||
ntimes.generator
|
||||
|> Smokestack.Template.generate(record, options)
|
||||
|> maybe_map(ntimes.mapper)
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_map(value, fun) when is_function(fun, 1), do: fun.(value)
|
||||
defp maybe_map(value, _), do: value
|
||||
end
|
||||
end
|
26
lib/smokestack/template/sequence.ex
Normal file
26
lib/smokestack/template/sequence.ex
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule Smokestack.Template.Sequence do
|
||||
@moduledoc false
|
||||
defstruct mapper: nil, start: 1, step: 1, agent: nil
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
mapper: Smokestack.Template.mapper(),
|
||||
start: number,
|
||||
step: number,
|
||||
agent: nil | pid
|
||||
}
|
||||
|
||||
defimpl Smokestack.Template do
|
||||
def init(sequence) do
|
||||
{:ok, pid} = Agent.start_link(fn -> sequence.start end)
|
||||
%{sequence | agent: pid}
|
||||
end
|
||||
|
||||
def generate(sequence, _record, _options) when is_function(sequence.mapper, 1) do
|
||||
count = Agent.get_and_update(sequence.agent, &{&1, &1 + sequence.step})
|
||||
sequence.mapper.(count)
|
||||
end
|
||||
|
||||
def generate(sequence, _record, _options),
|
||||
do: Agent.get_and_update(sequence.agent, &{&1, &1 + sequence.step})
|
||||
end
|
||||
end
|
14
mix.exs
14
mix.exs
|
@ -13,12 +13,15 @@ defmodule Smokestack.MixProject do
|
|||
version: @version,
|
||||
elixir: "~> 1.15",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
consolidate_protocols: Mix.env() != :test,
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
deps: deps(),
|
||||
description: @moduledoc,
|
||||
package: package(),
|
||||
source_url: "https://code.harton.nz/james/smokestack",
|
||||
homepage_url: "https://code.harton.nz/james/smokestack",
|
||||
aliases: aliases()
|
||||
aliases: aliases(),
|
||||
dialyzer: [plt_add_apps: [:faker]]
|
||||
]
|
||||
end
|
||||
|
||||
|
@ -32,7 +35,6 @@ defmodule Smokestack.MixProject do
|
|||
]
|
||||
end
|
||||
|
||||
# Run "mix help compile.app" to learn about applications.
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
|
@ -40,8 +42,6 @@ defmodule Smokestack.MixProject do
|
|||
]
|
||||
end
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
opts = [only: ~w[dev test]a, runtime: false]
|
||||
|
||||
|
@ -54,6 +54,7 @@ defmodule Smokestack.MixProject do
|
|||
{:earmark, ">= 0.0.0", opts},
|
||||
{:ex_check, "~> 0.15", opts},
|
||||
{:ex_doc, ">= 0.0.0", opts},
|
||||
{:faker, "~> 0.17", opts},
|
||||
{:git_ops, "~> 2.6", opts},
|
||||
{:mix_audit, "~> 2.1", opts}
|
||||
]
|
||||
|
@ -61,7 +62,10 @@ defmodule Smokestack.MixProject do
|
|||
|
||||
defp aliases do
|
||||
[
|
||||
"spark.formatter": "spark.formatter --extensions=Smokestack.Resource,Smokestack.Factory"
|
||||
"spark.formatter": "spark.formatter --extensions=Smokestack.Dsl"
|
||||
]
|
||||
end
|
||||
|
||||
defp elixirc_paths(env) when env in ~w[dev test]a, do: ~w[lib test/support]
|
||||
defp elixirc_paths(_), do: ~w[lib]
|
||||
end
|
||||
|
|
1
mix.lock
1
mix.lock
|
@ -14,6 +14,7 @@
|
|||
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
|
||||
"ex_check": {:hex, :ex_check, "0.15.0", "074b94c02de11c37bba1ca82ae5cc4926e6ccee862e57a485b6ba60fca2d8dc1", [:mix], [], "hexpm", "33848031a0c7e4209c3b4369ce154019788b5219956220c35ca5474299fb6a0e"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.30.4", "e8395c8e3c007321abb30a334f9f7c0858d80949af298302daf77553468c0c39", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9a19f0c50ffaa02435668f5242f2b2a61d46b541ebf326884505dfd3dd7af5e4"},
|
||||
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
|
||||
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
|
||||
"git_ops": {:hex, :git_ops, "2.6.0", "e0791ee1cf5db03f2c61b7ebd70e2e95cba2bb9b9793011f26609f22c0900087", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "b98fca849b18aaf490f4ac7d1dd8c6c469b0cc3e6632562d366cab095e666ffe"},
|
||||
|
|
17
test/smokestack/builder_test.exs
Normal file
17
test/smokestack/builder_test.exs
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule Smokestack.BuilderTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Smokestack.Builder
|
||||
alias Support.{Factory, Post}
|
||||
|
||||
describe "params/2..5" do
|
||||
test "it builds params" do
|
||||
assert {:ok, params} = Builder.params(Factory, Post)
|
||||
assert params |> Map.keys() |> Enum.sort() == ~w[body tags title]a
|
||||
assert is_binary(params.body)
|
||||
assert Enum.all?(params.tags, &is_binary/1)
|
||||
assert is_binary(params.title)
|
||||
end
|
||||
end
|
||||
end
|
86
test/smokestack/dsl_test.exs
Normal file
86
test/smokestack/dsl_test.exs
Normal file
|
@ -0,0 +1,86 @@
|
|||
defmodule Smokestack.DslTest do
|
||||
use ExUnit.Case, async: true
|
||||
alias Spark.Error.DslError
|
||||
|
||||
defmodule Post do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets,
|
||||
validate_api_inclusion?: false
|
||||
|
||||
ets do
|
||||
private? true
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :title, :string
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Factory do
|
||||
@moduledoc false
|
||||
use Smokestack
|
||||
|
||||
factory Post do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
|
||||
factory Post, :lorem do
|
||||
attribute :title, &Faker.Lorem.sentence/0
|
||||
end
|
||||
end
|
||||
|
||||
test "it compiles" do
|
||||
assert Factory.spark_is() == Smokestack
|
||||
end
|
||||
|
||||
test "files with multiple default factories for the same resource fail" do
|
||||
assert_raise DslError, fn ->
|
||||
defmodule MultiDefaultFactory do
|
||||
@moduledoc false
|
||||
use Smokestack
|
||||
|
||||
factory Post do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
|
||||
factory Post do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "files with multiple named factories for the same resource fail" do
|
||||
assert_raise DslError, fn ->
|
||||
defmodule MultiNamedFactory do
|
||||
@moduledoc false
|
||||
use Smokestack
|
||||
|
||||
factory Post, :with_title do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
|
||||
factory Post, :with_title do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "factories with duplicate attributes fail" do
|
||||
assert_raise DslError, fn ->
|
||||
defmodule MultiAttributeFactory do
|
||||
@moduledoc false
|
||||
use Smokestack
|
||||
|
||||
factory Post do
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
attribute :title, &Faker.Company.catch_phrase/0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
test/smokestack/template/choose_test.exs
Normal file
31
test/smokestack/template/choose_test.exs
Normal file
|
@ -0,0 +1,31 @@
|
|||
defmodule Smokestack.Template.ChooseTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
alias Smokestack.{Template, Template.Choose}
|
||||
|
||||
describe "Smokestack.Template.init/1" do
|
||||
test "it doesn't do anything" do
|
||||
choose = %Choose{options: [1, 2, 3], mapper: &Function.identity/1}
|
||||
|
||||
assert ^choose = Template.init(choose)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Smokestack.Template.generate/3" do
|
||||
test "it chooses a random option" do
|
||||
value =
|
||||
%Choose{options: [1, 2, 3]}
|
||||
|> Template.generate(nil, nil)
|
||||
|
||||
assert value in [1, 2, 3]
|
||||
end
|
||||
|
||||
test "it can map the chosen value" do
|
||||
value =
|
||||
%Choose{options: [1, 2, 3], mapper: &(&1 * 3)}
|
||||
|> Template.generate(nil, nil)
|
||||
|
||||
assert value in [3, 6, 9]
|
||||
end
|
||||
end
|
||||
end
|
36
test/smokestack/template/cycle_test.exs
Normal file
36
test/smokestack/template/cycle_test.exs
Normal file
|
@ -0,0 +1,36 @@
|
|||
defmodule Smokestack.Template.CycleTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
alias Smokestack.{Template, Template.Cycle}
|
||||
|
||||
describe "Smokestack.Template.init/1" do
|
||||
test "it starts an agent" do
|
||||
cycle = Template.init(%Cycle{options: [:a, :b, :c]})
|
||||
assert is_pid(cycle.agent)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Smokestack.Template.generate/3" do
|
||||
test "it cycles through it's options" do
|
||||
cycle = Template.init(%Cycle{options: [:a, :b, :c]})
|
||||
|
||||
assert :a = Template.generate(cycle, nil, nil)
|
||||
assert :b = Template.generate(cycle, nil, nil)
|
||||
assert :c = Template.generate(cycle, nil, nil)
|
||||
assert :a = Template.generate(cycle, nil, nil)
|
||||
assert :b = Template.generate(cycle, nil, nil)
|
||||
assert :c = Template.generate(cycle, nil, nil)
|
||||
end
|
||||
|
||||
test "it can map it's options" do
|
||||
cycle = Template.init(%Cycle{options: ~w[a b c], mapper: &String.upcase/1})
|
||||
|
||||
assert "A" = Template.generate(cycle, nil, nil)
|
||||
assert "B" = Template.generate(cycle, nil, nil)
|
||||
assert "C" = Template.generate(cycle, nil, nil)
|
||||
assert "A" = Template.generate(cycle, nil, nil)
|
||||
assert "B" = Template.generate(cycle, nil, nil)
|
||||
assert "C" = Template.generate(cycle, nil, nil)
|
||||
end
|
||||
end
|
||||
end
|
28
test/smokestack/template/sequence_test.exs
Normal file
28
test/smokestack/template/sequence_test.exs
Normal file
|
@ -0,0 +1,28 @@
|
|||
defmodule Smokestack.Template.SequenceTest do
|
||||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
alias Smokestack.{Template, Template.Sequence}
|
||||
|
||||
describe "Smokestack.Template.init/1" do
|
||||
test "it starts an agent" do
|
||||
sequence =
|
||||
%Sequence{}
|
||||
|> Template.init()
|
||||
|
||||
assert is_pid(sequence.agent)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Smokestack.Template.generate/3" do
|
||||
test "it generates sequential values" do
|
||||
sequence =
|
||||
%Sequence{}
|
||||
|> Template.init()
|
||||
|
||||
assert 1 = Template.generate(sequence, nil, nil)
|
||||
assert 2 = Template.generate(sequence, nil, nil)
|
||||
assert 3 = Template.generate(sequence, nil, nil)
|
||||
assert 4 = Template.generate(sequence, nil, nil)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,8 +1,4 @@
|
|||
defmodule SmokestackTest do
|
||||
use ExUnit.Case
|
||||
doctest Smokestack
|
||||
|
||||
test "greets the world" do
|
||||
assert Smokestack.hello() == :world
|
||||
end
|
||||
end
|
||||
|
|
8
test/support/api.ex
Normal file
8
test/support/api.ex
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule Support.Api do
|
||||
@moduledoc false
|
||||
use Ash.Api, validate_config_inclusion?: false
|
||||
|
||||
resources do
|
||||
allow_unregistered? true
|
||||
end
|
||||
end
|
28
test/support/author.ex
Normal file
28
test/support/author.ex
Normal file
|
@ -0,0 +1,28 @@
|
|||
defmodule Support.Author do
|
||||
@moduledoc false
|
||||
|
||||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets,
|
||||
validate_api_inclusion?: false
|
||||
|
||||
ets do
|
||||
private? true
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :name, :string
|
||||
attribute :email, :ci_string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :posts, Support.Post
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
end
|
39
test/support/factory.ex
Normal file
39
test/support/factory.ex
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule Support.Factory do
|
||||
@moduledoc false
|
||||
|
||||
use Smokestack
|
||||
|
||||
factory Support.Author do
|
||||
attribute :name, &Faker.Person.name/0
|
||||
attribute :email, &Faker.Internet.email/0
|
||||
end
|
||||
|
||||
factory Support.Author, :trek do
|
||||
attribute :name, choose(["JL", "Doc Holoday", "BLT", "Cal Hudson"])
|
||||
|
||||
attribute :email, fn
|
||||
%{name: "JL"} -> "captain@entrepreneur.starfleet"
|
||||
%{name: "Doc Holoday"} -> "cheifmed@voyager.starfleet"
|
||||
%{name: "BLT"} -> "cheifeng@voyager.starfleet"
|
||||
%{name: "Cal Hudson"} -> "cal@maquis.stfu"
|
||||
end
|
||||
end
|
||||
|
||||
factory Support.Post do
|
||||
attribute :title, &Faker.Commerce.product_name/0
|
||||
attribute :tags, n_times(3..20, &Faker.Lorem.word/0)
|
||||
attribute :body, &Faker.Markdown.markdown/0
|
||||
end
|
||||
|
||||
factory Support.Post, :trek do
|
||||
attribute :title,
|
||||
choose([
|
||||
"On the safety of conference attendance",
|
||||
"Who would win? Q vs Kevin Uxbridge - an analysis",
|
||||
"Improvised tools for warp core maintenance",
|
||||
"Cardassia Prime: Hot or Not?"
|
||||
])
|
||||
|
||||
attribute :body, &Faker.Markdown.markdown/0
|
||||
end
|
||||
end
|
42
test/support/post.ex
Normal file
42
test/support/post.ex
Normal file
|
@ -0,0 +1,42 @@
|
|||
defmodule Support.Post do
|
||||
@moduledoc false
|
||||
|
||||
use Ash.Resource,
|
||||
data_layer: Ash.DataLayer.Ets,
|
||||
validate_api_inclusion?: false
|
||||
|
||||
ets do
|
||||
private? true
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :title, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :sub_title, :string
|
||||
|
||||
attribute :tags, {:array, :ci_string} do
|
||||
constraints items: [
|
||||
match: ~r/^[a-zA-Z]+$/,
|
||||
casing: :lower
|
||||
]
|
||||
end
|
||||
|
||||
attribute :body, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, Support.Author
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue