Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow delayed evaluation of attributes #408

Merged
merged 2 commits into from
Dec 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,46 @@ string_params_for(:comment, attrs)
string_params_with_assocs(:comment, attrs)
```

## Delayed evaluation of attributes

`build/2` is a function call. As such, it gets evaluated immediately. So this
code:

insert_pair(:account, user: build(:user))

Is equivalent to this:

user = build(:user)
insert_pair(:account, user: user) # same user for both accounts

Sometimes that presents a problem. Consider the following factory:

def user_factory do
%{name: "Gandalf", email: sequence(:email, "gandalf#{&1}@istari.com")}
end

If you want to build a separate `user` per `account`, then calling
`insert_pair(:account, user: build(:user))` will not give you the desired
result.

In those cases, you can delay the execution of the factory by passing it as an
anonymous function:

insert_pair(:account, user: fn -> build(:user) end)

You can also do that in a factory definition:

def account_factory do
%{user: fn -> build(:user) end}
end

You can even accept the parent record as an argument to the function:

def account_factory do
%{user: fn account -> build(:user, vip: account.premium) end}
end


## Full control of factory

By default, ExMachina will merge the attributes you pass into build/insert into
Expand All @@ -181,13 +221,17 @@ def custom_article_factory(attrs) do
title: title
}

# merge attributes at the end to emulate ExMachina default behavior
merge_attributes(article, attrs)
# merge attributes and evaluate lazy attributes at the end to emulate
# ExMachina's default behavior
article
|> merge_attributes(attrs)
|> evaluate_lazy_attributes()
end
```

**NOTE** that in this case ExMachina will _not_ merge the attributes into your
factory, and you will have to do this on your own if desired.
factory, and it will not evaluate lazy attributes. You will have to do this on
your own if desired.

### Non-map factories

Expand Down
78 changes: 72 additions & 6 deletions lib/ex_machina.ex
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ defmodule ExMachina do
This will defer to the `[factory_name]_factory/0` callback defined in the
factory module in which it is `use`d.

## Example
### Example

def user_factory do
%{name: "John Doe", admin: false}
Expand All @@ -163,21 +163,34 @@ defmodule ExMachina do
# Returns %{name: "John Doe", admin: true}
build(:user, admin: true)

## Full control of a factory's attributes

If you want full control over the factory attributes, you can define the
factory with `[factory_name]_factory/1`. Note that you will need to merge the
attributes passed if you want to emulate ExMachina's default behavior.
factory with `[factory_name]_factory/1`, taking in the attributes as the first
argument.

## Example
Caveats:

- ExMachina will no longer merge the attributes for your factory. If you want
to do that, you can merge the attributes with the `merge_attributes/2` helper.

- ExMachina will no longer evaluate lazy attributes. If you want to do that,
you can evaluate the lazy attributes with the `evaluate_lazy_attributes/1`
helper.

### Example

def article_factory(attrs) do
title = Map.get(attrs, :title, "default title")
slug = Article.title_to_slug(title)

article = %Article{title: title, slug: slug}

article
# merge attributes on your own
merge_attributes(article, attrs)
|> merge_attributes(attrs)
# evaluate any lazy attributes
|> evaluate_lazy_attributes()
end

# Returns %Article{title: "default title", slug: "default-title"}
Expand All @@ -192,14 +205,17 @@ defmodule ExMachina do
@doc false
def build(module, factory_name, attrs \\ %{}) do
attrs = Enum.into(attrs, %{})

function_name = build_function_name(factory_name)

cond do
factory_accepting_attributes_defined?(module, function_name) ->
apply(module, function_name, [attrs])

factory_without_attributes_defined?(module, function_name) ->
apply(module, function_name, []) |> merge_attributes(attrs)
apply(module, function_name, [])
|> merge_attributes(attrs)
|> evaluate_lazy_attributes()

true ->
raise UndefinedFactoryError, factory_name
Expand Down Expand Up @@ -245,6 +261,56 @@ defmodule ExMachina do
def merge_attributes(%{__struct__: _} = record, attrs), do: struct!(record, attrs)
def merge_attributes(record, attrs), do: Map.merge(record, attrs)

@doc """
Helper function to evaluate lazy attributes that are passed into a factory.

## Example

# custom factory
def article_factory(attrs) do
%{title: "title"}
|> merge_attributes(attrs)
|> evaluate_lazy_attributes()
end

def author_factory do
%{name: sequence("gandalf")}
end

# => returns [
# %{title: "title", author: %{name: "gandalf0"},
# %{title: "title", author: %{name: "gandalf0"}
# ]
build_pair(:article, author: build(:author))

# => returns [
# %{title: "title", author: %{name: "gandalf0"},
# %{title: "title", author: %{name: "gandalf1"}
# ]
build_pair(:article, author: fn -> build(:author) end)
"""
@spec evaluate_lazy_attributes(struct | map) :: struct | map
def evaluate_lazy_attributes(%{__struct__: record} = factory) do
struct!(
record,
factory |> Map.from_struct() |> do_evaluate_lazy_attributes(factory)
)
end

def evaluate_lazy_attributes(attrs) when is_map(attrs) do
do_evaluate_lazy_attributes(attrs, attrs)
end

defp do_evaluate_lazy_attributes(attrs, parent_factory) do
attrs
|> Enum.map(fn
{k, v} when is_function(v, 1) -> {k, v.(parent_factory)}
{k, v} when is_function(v) -> {k, v.()}
{_, _} = tuple -> tuple
end)
|> Enum.into(%{})
end

@doc """
Builds two factories.

Expand Down
28 changes: 17 additions & 11 deletions priv/test_repo/migrations/1_migrate_all.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@ defmodule ExMachina.TestRepo.Migrations.MigrateAll do

def change do
create table(:users) do
add :name, :string
add :admin, :boolean
add :net_worth, :decimal
add(:name, :string)
add(:admin, :boolean)
add(:net_worth, :decimal)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autoformatter added all the parentheses.

end

create table(:publishers) do
add(:pub_number, :string)
end

create(unique_index(:publishers, [:pub_number]))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this uniqueness constraint to properly test the scenario in ecto where insert_pair(:account, publisher: build(:publisher)) would raise a constraint error.


create table(:articles) do
add :title, :string
add :author_id, :integer
add :editor_id, :integer
add :publisher_id, :integer
add :visits, :decimal
add(:title, :string)
add(:author_id, :integer)
add(:editor_id, :integer)
add(:publisher_id, :integer)
add(:visits, :decimal)
end

create table(:comments) do
add :article_id, :integer
add :author, :map
add :links, {:array, :map}, default: []
add(:article_id, :integer)
add(:author, :map)
add(:links, {:array, :map}, default: [])
end
end
end
19 changes: 19 additions & 0 deletions test/ex_machina/ecto_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule ExMachina.EctoTest do
use ExMachina.EctoCase

alias ExMachina.Article
alias ExMachina.Publisher
alias ExMachina.TestFactory
alias ExMachina.User

Expand Down Expand Up @@ -65,6 +67,23 @@ defmodule ExMachina.EctoTest do
test "insert_list/3 handles the number 0" do
assert [] = TestFactory.insert_list(0, :user)
end

test "lazy records get evaluated with insert/2 and insert_* functions" do
assert %Article{publisher: %Publisher{}} =
TestFactory.insert(:article, publisher: fn -> TestFactory.build(:publisher) end)

[%Article{publisher: publisher1}, %Article{publisher: publisher2}] =
TestFactory.insert_pair(:article, publisher: fn -> TestFactory.build(:publisher) end)

assert publisher1 != publisher2

[publisher1, publisher2, publisher3] =
TestFactory.insert_list(3, :article, publisher: fn -> TestFactory.build(:publisher) end)

assert publisher1.author != publisher2.author
assert publisher2.author != publisher3.author
assert publisher3.author != publisher1.author
end
end

describe "params_for/2" do
Expand Down