Skip to content

Commit

Permalink
Pass opts to Repo.insert! (add function-level opts to strategies) (#411)
Browse files Browse the repository at this point in the history
What changed?
=============

We add the ability for a strategy to receive more options as a third
argument. These options could be a function-level override of the
strategy's options, or some other function-level options that we want to
pass to the underlying strategy implementation.

For example, the EctoStrategy can use those options and pass them to the
`Repo.insert/2` function:

```elixir
insert(:user, [name: "gandalf"], returning: true)
build(:user, name: "gandalf") |> insert(returning: true)
```

That allows people to have more control over inserting the factory into
the database. For example, we can now automatically get db-generated
values by passing `returning: true`. Or we can use `prefix` or
`on_conflict`.

For a full list of insert options, see [Repo.insert docs].

[Repo.insert docs]: https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2-options

Known less-than-good ergonomics
-------------------------------

Because both the second and third arguments are lists or maps, we need
to add the surrounding keyword list `[]` to the second argument when
we're using `insert/3`.

In the worst case, when we don't pass args to the factory but we pass
options to the repo, we're required to define an empty list:

```elixir
insert(:user, [], returning: true)
```

We also have to wrap previously unwrapped lists _when_ we use the third
opts argument:

```elixir
insert(:user, [name: "Aragorn"], returning: true)
```

That's just the nature of having two arguments that are both lists or
maps (factory args and repo opts).

It reads more nicely when it's an already built struct:

```elixir
build(:user) |> insert(returning :true)
build(:user, name: "james") |> insert(returning :true)
insert(%User{}, returning: true)
```

Also available for [strategy_func]_pair and _list functions
-------------------------------------------------------------

We also update the strategy's _pair and _list function generators to
allow for `opts` to be passed, since presumably users might want to do
something like this:

```elixir
insert_pair(:user, [name: "jane"], returning: true)
```

Current strategies in the wild that don't support this will not break.
If they implement a `handle_[function_name]/3`, then they can also
accept the third arguments, and their users can pass the third argument.
Otherwise, they can continue using the strategies as they already have.
  • Loading branch information
germsvel authored Feb 3, 2021
1 parent 124ac22 commit 9022395
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 12 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ defmodule MyApp.VideoFactory do
end
```

## Ecto Associations
## Ecto

### Ecto Associations

ExMachina will automatically save any associations when you call any of the
`insert` functions. This includes `belongs_to` and anything that is
Expand All @@ -404,6 +406,24 @@ end
Using `insert/2` in factory definitions may lead to performance issues and bugs,
as records will be saved unnecessarily.

### Passing options to Repo.insert!/2

`ExMachina.Ecto` uses
[`Repo.insert!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert!/2) to
insert records into the database. Sometimes you may want to pass options to deal
with multi-tenancy or return some values generated by the database. In those
cases, you can use `c:ExMachina.Ecto.insert/3`:

For example,

```elixir
# return values from the database
insert(:user, [name: "Jane"], returning: true)

# use a different prefix
insert(:user, [name: "Jane"], prefix: "other_tenant")
```

## Flexible Factories with Pipes

```elixir
Expand Down
21 changes: 20 additions & 1 deletion lib/ex_machina/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule ExMachina.Ecto do
nice things that make working with Ecto easier.
* It uses `ExMachina.EctoStrategy`, which adds `insert/1`, `insert/2`,
`insert_pair/2`, `insert_list/3`.
`insert/3` `insert_pair/2`, `insert_list/3`.
* Adds a `params_for` function that is useful for working with changesets or
sending params to API endpoints.
Expand Down Expand Up @@ -52,6 +52,25 @@ defmodule ExMachina.Ecto do
@callback insert(factory_name :: atom) :: any
@callback insert(factory_name :: atom, attrs :: keyword | map) :: any

@doc """
Builds a factory and inserts it into the database.
The first two arguments are the same as `c:ExMachina.build/2`. The last
argument is a set of options that will be passed to Ecto's
[`Repo.insert!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert!/2).
## Examples
# return all values from the database
insert(:user, [name: "Jane"], returning: true)
build(:user, name: "Jane") |> insert(returning: true)
# use a different prefix
insert(:user, [name: "Jane"], prefix: "other_tenant")
build(:user, name: "Jane") |> insert(prefix: "other_tenant")
"""
@callback insert(factory_name :: atom, attrs :: keyword | map, opts :: keyword | map) :: any

@doc """
Builds two factories and inserts them into the database.
Expand Down
14 changes: 12 additions & 2 deletions lib/ex_machina/ecto_strategy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ defmodule ExMachina.EctoStrategy do

def handle_insert(%{__meta__: %{__struct__: Ecto.Schema.Metadata}} = record, %{repo: repo}) do
record
|> cast
|> repo.insert!
|> cast()
|> repo.insert!()
end

def handle_insert(record, %{repo: _repo}) do
Expand All @@ -34,6 +34,16 @@ defmodule ExMachina.EctoStrategy do
raise "expected :repo to be given to ExMachina.EctoStrategy"
end

def handle_insert(
%{__meta__: %{__struct__: Ecto.Schema.Metadata}} = record,
%{repo: repo},
insert_options
) do
record
|> cast()
|> repo.insert!(insert_options)
end

defp cast(record) do
record
|> cast_all_fields
Expand Down
65 changes: 58 additions & 7 deletions lib/ex_machina/strategy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@ defmodule ExMachina.Strategy do
defmodule MyApp.JsonEncodeStrategy do
# The function_name will be used to generate functions in your factory
# This example adds json_encode/1, json_encode/2, json_encode_pair/2 and json_encode_list/3
# This example adds json_encode/1, json_encode/2, json_encode/3,
# json_encode_pair/2 and json_encode_list/3
use ExMachina.Strategy, function_name: :json_encode
# Define a function for handling the records.
# Takes the form of "handle_#{function_name}"
def handle_json_encode(record, _opts) do
Poison.encode!(record)
def handle_json_encode(record, %{encoder: encoder}) do
encoder.encode!(record)
end
# Optionally, define a function for handling records and taking in
# options at the function level
def handle_json_encode(record, %{encoder: encoder}, encoding_opts) do
encoder.encode!(record, encoding_opts)
end
end
defmodule MyApp.JsonFactory do
use ExMachina
use MyApp.JsonEncodeStrategy
use MyApp.JsonEncodeStrategy, encoder: Poison
def user_factory do
%User{name: "John"}
Expand All @@ -30,8 +37,9 @@ defmodule ExMachina.Strategy do
The arguments sent to the handling function are
1) The built record
2) The options passed to the strategy
1. The built record
2. The options passed to the strategy
3. The options passed to the function as a third argument
The options sent as the second argument are always converted to a map. The
options are anything you passed when you `use` your strategy in your factory,
Expand All @@ -42,6 +50,13 @@ defmodule ExMachina.Strategy do
See `ExMachina.EctoStrategy` in the ExMachina repo, and the docs for
`name_from_struct/1` for more examples.
The options sent as the third argument come directly from the options passed
to the function being called. These could be function-level overrides of the
options passed when you `use` the strategy, or they could be other
customizations needed at the level of the function.
See `c:ExMachina.Ecto.insert/3` for an example.
"""

@doc false
Expand All @@ -56,6 +71,19 @@ defmodule ExMachina.Strategy do
handle_response_function_name = :"handle_#{function_name}"

quote do
def unquote(function_name)(already_built_record, function_opts)
when is_map(already_built_record) do
opts =
Map.new(unquote(opts))
|> Map.merge(%{factory_module: __MODULE__})

apply(
unquote(custom_strategy_module),
unquote(handle_response_function_name),
[already_built_record, opts, function_opts]
)
end

def unquote(function_name)(already_built_record) when is_map(already_built_record) do
opts = Map.new(unquote(opts)) |> Map.merge(%{factory_module: __MODULE__})

Expand All @@ -66,16 +94,39 @@ defmodule ExMachina.Strategy do
)
end

def unquote(function_name)(factory_name, attrs \\ %{}) do
def unquote(function_name)(factory_name, attrs, opts) do
record = ExMachina.build(__MODULE__, factory_name, attrs)

unquote(function_name)(record, opts)
end

def unquote(function_name)(factory_name, attrs) do
record = ExMachina.build(__MODULE__, factory_name, attrs)

unquote(function_name)(record)
end

def unquote(function_name)(factory_name) do
record = ExMachina.build(__MODULE__, factory_name, %{})

unquote(function_name)(record)
end

def unquote(:"#{function_name}_pair")(factory_name, attrs, opts) do
unquote(:"#{function_name}_list")(2, factory_name, attrs, opts)
end

def unquote(:"#{function_name}_pair")(factory_name, attrs \\ %{}) do
unquote(:"#{function_name}_list")(2, factory_name, attrs)
end

def unquote(:"#{function_name}_list")(number_of_records, factory_name, attrs, opts) do
Stream.repeatedly(fn ->
unquote(function_name)(factory_name, attrs, opts)
end)
|> Enum.take(number_of_records)
end

def unquote(:"#{function_name}_list")(number_of_records, factory_name, attrs \\ %{}) do
Stream.repeatedly(fn ->
unquote(function_name)(factory_name, attrs)
Expand Down
20 changes: 20 additions & 0 deletions priv/test_repo/migrations/1_migrate_all.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,28 @@ defmodule ExMachina.TestRepo.Migrations.MigrateAll do
add(:name, :string)
add(:admin, :boolean)
add(:net_worth, :decimal)
add(:db_value, :string)
end

execute(~S"""
CREATE FUNCTION set_db_value()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.db_value := 'made in db';
RETURN NEW;
END;
$$;
""")

execute(~S"""
CREATE TRIGGER gen_db_value
BEFORE INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION set_db_value();
""")

create table(:publishers) do
add(:pub_number, :string)
end
Expand Down
45 changes: 44 additions & 1 deletion test/ex_machina/ecto_strategy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ defmodule ExMachina.EctoStrategyTest do
model = TestFactory.insert(%User{name: "John"})

new_user = ExMachina.TestRepo.one!(User)
assert model == new_user

assert model.id
assert model.name == new_user.name
end

test "insert/1 raises if a map is passed" do
Expand Down Expand Up @@ -145,6 +147,47 @@ defmodule ExMachina.EctoStrategyTest do
assert article.author == my_user
end

test "insert/3 allows options to be passed to the repo" do
with_args = TestFactory.insert(:user, [name: "Jane"], returning: true)
assert with_args.id
assert with_args.name == "Jane"
assert with_args.db_value

without_args = TestFactory.insert(:user, [], returning: true)
assert without_args.id
assert without_args.db_value

with_struct = TestFactory.build(:user) |> TestFactory.insert(returning: true)
assert with_struct.id
assert with_struct.db_value

without_opts = TestFactory.build(:user) |> TestFactory.insert()
assert without_opts.id
refute without_opts.db_value
end

test "insert_pair/3 allows options to be passed to the repo" do
[with_args | _] = TestFactory.insert_pair(:user, [name: "Jane"], returning: true)
assert with_args.id
assert with_args.name == "Jane"
assert with_args.db_value

[without_args | _] = TestFactory.insert_pair(:user, [], returning: true)
assert without_args.id
assert without_args.db_value
end

test "insert_list/4 allows options to be passed to the repo" do
[with_args | _] = TestFactory.insert_list(2, :user, [name: "Jane"], returning: true)
assert with_args.id
assert with_args.name == "Jane"
assert with_args.db_value

[without_args | _] = TestFactory.insert_list(2, :user, [], returning: true)
assert without_args.id
assert without_args.db_value
end

test "insert/1 raises a friendly error when casting invalid types" do
message = ~r/Failed to cast `:invalid` of type ExMachina.InvalidType/

Expand Down
10 changes: 10 additions & 0 deletions test/ex_machina/strategy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ defmodule ExMachina.StrategyTest do
def handle_json_encode(record, opts) do
send(self(), {:handle_json_encode, record, opts})
end

def handle_json_encode(record, opts, function_opts) do
send(self(), {:handle_json_encode, record, opts, function_opts})
end
end

defmodule JsonFactory do
Expand All @@ -28,6 +32,7 @@ defmodule ExMachina.StrategyTest do

test "defines functions based on the strategy name" do
strategy_options = %{foo: :bar, factory_module: JsonFactory}
function_options = [encode: true]

JsonFactory.build(:user) |> JsonFactory.json_encode()
built_user = JsonFactory.build(:user)
Expand All @@ -44,6 +49,11 @@ defmodule ExMachina.StrategyTest do
assert_received {:handle_json_encode, ^built_user, ^strategy_options}
refute_received {:handle_json_encode, _, _}

JsonFactory.json_encode(:user, [name: "Jane"], function_options)
built_user = JsonFactory.build(:user, name: "Jane")
assert_received {:handle_json_encode, ^built_user, ^strategy_options, ^function_options}
refute_received {:handle_json_encode, _, _}

JsonFactory.json_encode_pair(:user)
built_user = JsonFactory.build(:user)
assert_received {:handle_json_encode, ^built_user, ^strategy_options}
Expand Down
1 change: 1 addition & 0 deletions test/support/models/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule ExMachina.User do
field(:admin, :boolean)
field(:net_worth, :decimal)
field(:password, :string, virtual: true)
field(:db_value, :string)

has_many(:articles, ExMachina.Article, foreign_key: :author_id)
has_many(:editors, through: [:articles, :editor])
Expand Down

0 comments on commit 9022395

Please sign in to comment.