Skip to content

Commit

Permalink
Rename to elixir-dx/refactory and traits (was infer-beam/refinery and…
Browse files Browse the repository at this point in the history
… refinements)
  • Loading branch information
arnodirlam committed May 13, 2024
1 parent 7216854 commit 68f7177
Show file tree
Hide file tree
Showing 23 changed files with 463 additions and 421 deletions.
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
inputs: [
"*.{ex,exs}",
"{config,lib}/**/*.{ex,exs}",
"test/{refinery,support}/**/*.{ex,exs}",
"test/{refactory,support}/**/*.{ex,exs}",
"test/*.{ex,exs}"
],
import_deps: [:ecto],
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ erl_crash.dump
*.ez

# Ignore package tarball (built via "mix hex.build").
refinery-*.tar
refactory-*.tar

# Temporary files, for example, from tests.
/tmp/
Expand Down
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Refinements modules via `use Refinery, repo: MyApp.Repo`
- Default refinement for each Ecto schema type
- Custom refinements
- Traits modules via `use Refactory, repo: MyApp.Repo`
- Default trait for each Ecto schema type
- Custom traits
- Initial docs
115 changes: 109 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,127 @@
# Refinery
# Refactory

An Elixir library to generate test data recursively with refinements
An Elixir library to generate test data recursively with traits

## Installation

The package can be installed
by adding `refinery` to your list of dependencies in `mix.exs`:
by adding `refactory` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:refinery, "~> 0.1.0", github: "infer-beam/refinery", only: :test}
{:refactory, "~> 0.1.0", only: :test}
]
end
```

Documentation can found at <https://infer-beam.github.io/refinery/>.
Documentation can found at <https://hexdocs.pm/refactory/>.

<!-- MODULEDOC -->
Refactory allows generating Ecto records with nested overrides for your tests.

## Factory module

To start using Refactory, first define a factory module:

```
defmodule MyApp.Factory do
use Refactory, repo: MyApp.Repo
end
```

## Usage

The factory module has two functions:

- `build/2` generates an Ecto record with the given traits applied
- `create/2` inserts an Ecto record into the database

## Traits

A trait can be
- a `Map` in which each key-value pair is either
- a field with its value
- an association with a trait (for `belongs_to`, `has_one`, and `embeds_one`)
- _soon:_ an association with a list of traits (for `has_many` and `embeds_many`)
- a custom trait defined in the factory module (see below)
- a `Tuple` with multiple traits to be applied

## Basic example

```
defmodule MyApp.Factory do
use Refactory, repo: MyApp.Repo
end
MyApp.Factory.build(MyApp.List, %{
title: "Refined List",
created_by_user: %{email: "test@email.org"}
})
%MyApp.List{
title: "Refined List",
created_by_user: %MyApp.User{
email: "test@email.org"
}
}
```

## Default traits

Default traits can be defined in the factory module.
They are always applied first.

```
defmodule MyApp.Factory do
use Refactory, repo: MyApp.Repo
def trait(MyApp.List, :default) do
%{
title: "Default Title"
}
end
end
MyApp.Factory.build(MyApp.List)
%MyApp.List{title: "Default Title"}
```

## Custom traits

Custom traits can be defined in the factory module and then used by their name.

```
defmodule MyApp.Factory do
use Refactory, repo: MyApp.Repo
def trait(MyApp.List, :default) do
%{
title: "Default Title"
}
end
def trait(MyApp.List, :with_admin_user) do
%{
created_by_user: %{
role: :admin
}
}
end
end
MyApp.Factory.build(MyApp.List, :with_admin_user)
%MyApp.List{title: "Default Title", created_by_user: %MyApp.User{role: :admin}}
```

<!-- MODULEDOC -->

## Special thanks

This project is sponsored and kindly supported by [Team Engine](https://www.teamengine.co.uk/).

If you'd like to join us working on [Infer](https://github.com/infer-beam/infer) and Refinery as a contractor, please [reach out](https://tinyurl.com/engine-infer-dev2).
If you'd like to join us working on [Dx](https://github.com/elixir-dx/dx) and Refactory as a contractor, please reach out to @arnodirlam.
4 changes: 2 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import Config

# You can configure your application as:
#
# config :refinery, key: :value
# config :refactory, key: :value
#
# and access this configuration in your application as:
#
# Application.get_env(:refinery, :key)
# Application.get_env(:refactory, :key)
#
# You can also configure a 3rd-party app:
#
Expand Down
8 changes: 4 additions & 4 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Config

config :refinery, Refinery.Test.Repo,
config :refactory, Refactory.Test.Repo,
hostname: "localhost",
username: "postgres",
password: "postgres",
database: "refinery_test",
pool: Ecto.Adapters.SQL.Sandbox,
priv: "test/schema"

config :refinery,
ecto_repos: [Refinery.Test.Repo],
repo: Refinery.Test.Repo
config :refactory,
ecto_repos: [Refactory.Test.Repo],
repo: Refactory.Test.Repo

config :logger, level: :warn
151 changes: 151 additions & 0 deletions lib/refactory.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
defmodule Refactory do
@external_resource Path.expand("./README.md")
@moduledoc File.read!(Path.expand("./README.md"))
|> String.split("<!-- MODULEDOC -->")
|> Enum.at(1)

defmacro __using__(opts) do
quote location: :keep do
def refinery_repo() do
unquote(opts[:repo])
end

def create(type, traits \\ %{}) do
Refactory.create(__MODULE__, type, traits)
end

def build(type, traits \\ %{}) do
Refactory.build(__MODULE__, type, traits)
end
end
end

@doc """
Inserts an Ecto record with the given traits applied into the database
"""
def create(module, type, traits \\ %{}) do
repo = module.refinery_repo()
build(module, type, traits) |> repo.insert!()
end

@doc """
Generates an Ecto record with the given traits applied
"""
def build(module, type, traits \\ %{}) do
case resolve_refinement(module, type, {:default, traits}) do
record = %{__struct__: ^module} ->
record

record = %{__struct__: _module} ->
raise ArgumentError, "Expected a struct of type #{module}. Got #{inspect(record)}"

attrs ->
do_build(module, type, attrs)
end
end

defp do_build(module, type, attrs) do
record = struct!(type, attrs)

# set associations in record
Enum.reduce(attrs, record, fn {name, traits}, record ->
assoc =
case ecto_association(type, name) || ecto_embed(type, name) do
%Ecto.Association.BelongsTo{related: type} -> {:build_one, type}
%Ecto.Association.Has{cardinality: :one, related: type} -> {:build_one, type}
%Ecto.Embedded{cardinality: :one, related: type} -> {:build_one, type}
_ -> :skip
end

case {assoc, traits} do
{{:build_one, type}, %type{}} ->
record

{{:build_one, type}, %other_type{}} ->
raise ArgumentError,
"Expected value of type #{type} for #{type}.#{name}. Got #{other_type}"

{{:build_one, _type}, nil} ->
Map.put(record, name, nil)

{{:build_one, type}, _} ->
associated_record = build(module, type, traits)
Map.put(record, name, associated_record)

{:skip, _} ->
record
end
end)
end

defp resolve_refinement(module, type, :default) do
module.trait(type, :default)
rescue
_e in [UndefinedFunctionError, FunctionClauseError] -> %{}
e -> reraise e, __STACKTRACE__
end

defp resolve_refinement(module, type, trait) do
module.trait(type, trait)
rescue
_e in [UndefinedFunctionError, FunctionClauseError] ->
merge_refinements(module, type, trait, %{})

e ->
reraise e, __STACKTRACE__
end

defp merge_refinements(module, type, traits, result) when is_tuple(traits) do
traits
|> Tuple.to_list()
|> Enum.reduce(result, &deep_merge(&2, resolve_refinement(module, type, &1), false, true))
end

defp merge_refinements(_module, _type, traits, result) when is_map(traits) do
deep_merge(result, traits, false)
end

defp merge_refinements(module, type, trait, _result) do
raise ArgumentError, "Unknown trait for #{type} in #{module}: #{inspect(trait)}"
end

defp ecto_association(type, name), do: type.__schema__(:association, name)
defp ecto_embed(type, name), do: type.__schema__(:embed, name)

defp deep_merge(left, right, concat_lists? \\ true, struct_overrides? \\ false) do
Map.merge(left, right, &deep_resolve(&1, &2, &3, concat_lists?, struct_overrides?))
end

defp deep_resolve(_key, _left, %{__struct__: _type} = right, _concat_lists?, true) do
right
end

defp deep_resolve(
_key,
%{__struct__: type} = left,
%{__struct__: type} = right,
_concat_lists?,
_struct_overrides?
) do
struct!(type, deep_merge(Map.from_struct(left), Map.from_struct(right)))
end

defp deep_resolve(_key, %{__struct__: type}, _right, _concat_lists?, _struct_overrides?) do
raise ArgumentError, "#{type} cannot be merged with non-#{type}."
end

defp deep_resolve(_key, _left, %{__struct__: type}, _concat_lists?, _struct_overrides?) do
raise ArgumentError, "Non-#{type} cannot be merged with #{type}."
end

defp deep_resolve(_key, %{} = left, %{} = right, _concat_lists?, _struct_overrides?) do
deep_merge(left, right)
end

defp deep_resolve(_key, left, right, true, _struct_overrides?)
when is_list(left) and is_list(right) do
left ++ right
end

defp deep_resolve(_key, _left, right, _concat_lists?, _struct_overrides?), do: right
end
Loading

0 comments on commit 68f7177

Please sign in to comment.