diff --git a/README.md b/README.md index a1792bd..582a52d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/lib/ex_machina/ecto.ex b/lib/ex_machina/ecto.ex index 23a2336..bdb9593 100644 --- a/lib/ex_machina/ecto.ex +++ b/lib/ex_machina/ecto.ex @@ -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. @@ -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. diff --git a/lib/ex_machina/ecto_strategy.ex b/lib/ex_machina/ecto_strategy.ex index 99e3dea..de4d502 100644 --- a/lib/ex_machina/ecto_strategy.ex +++ b/lib/ex_machina/ecto_strategy.ex @@ -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 @@ -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 diff --git a/lib/ex_machina/strategy.ex b/lib/ex_machina/strategy.ex index df209ee..3ca22df 100644 --- a/lib/ex_machina/strategy.ex +++ b/lib/ex_machina/strategy.ex @@ -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"} @@ -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, @@ -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 @@ -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__}) @@ -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) diff --git a/priv/test_repo/migrations/1_migrate_all.exs b/priv/test_repo/migrations/1_migrate_all.exs index c6455f6..5373dd4 100644 --- a/priv/test_repo/migrations/1_migrate_all.exs +++ b/priv/test_repo/migrations/1_migrate_all.exs @@ -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 diff --git a/test/ex_machina/ecto_strategy_test.exs b/test/ex_machina/ecto_strategy_test.exs index 68345e7..d8888eb 100644 --- a/test/ex_machina/ecto_strategy_test.exs +++ b/test/ex_machina/ecto_strategy_test.exs @@ -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 @@ -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/ diff --git a/test/ex_machina/strategy_test.exs b/test/ex_machina/strategy_test.exs index 2ccbc30..b11e18b 100644 --- a/test/ex_machina/strategy_test.exs +++ b/test/ex_machina/strategy_test.exs @@ -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 @@ -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) @@ -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} diff --git a/test/support/models/user.ex b/test/support/models/user.ex index 5f7cf6e..afcf834 100644 --- a/test/support/models/user.ex +++ b/test/support/models/user.ex @@ -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])