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

Introduce build_lazy/2 #406

Closed
wants to merge 2 commits into from
Closed

Introduce build_lazy/2 #406

wants to merge 2 commits into from

Commits on Dec 12, 2020

  1. Use a real struct (not named struct) in ExMachinaTest

    We used to have a struct factory defined as `%{__struct__: FooBar}` but
    it was confusing since the factory name was `struct_factory`.
    
    So we change to be `foo_bar_factory` and create a module with a struct
    called `FooBar`. This has the benefit of allowing us to assert
    `%FooBar{}` in tests -- something we couldn't do with the previous
    format.
    germsvel committed Dec 12, 2020
    Configuration menu
    Copy the full SHA
    648117c View commit details
    Browse the repository at this point in the history
  2. Introduce build_lazy/2

    The problem
    ------------
    
    People want a different email per account (especially if the email
    factory has a sequence), but they do this:
    
    ```elixir
    build_pair(:account, email: build(:email))
    ```
    
    The problem is that `build/2` is just function calling. So the above is
    equivalent to this:
    
    ```elixir
    email = build(:email)
    build_pair(:account, email: email)
    ```
    
    In other words, we get on email factory for all of the accounts.
    
    The problem is made worse when using it with Ecto. We can imagine the
    following scenario:
    
    ```elixir
    insert_pair(:account, user: build(:user))
    ```
    
    If the user factory has a uniqueness constraint, `insert_pair/2` will
    raise an error because we'll try to insert a user with the same value
    (even if using a sequence).
    
    Solution
    --------
    
    The solution is to delay evaluation of the factory. We do this by
    introducing a private struct `%ExMachina.InstanceTemplate{}` to hold the
    data necessary to build the instance of that factory.
    
    Note that this struct is private and liable to change, so users of the
    library shouldn't depend on it.
    
    The trick then lies in the `build/2` function. We make it a terminal
    function in that it will evaluate any lazy factories recursively. To do
    that, we update the `build/2` function to evaluate
    `ExMachina.InstanceTemplate` structs after building the `build/2`
    factory.
    
    In an early implementation of this feature, I evaluated the lazy
    factories when we first got the attributes in `build/2` (i.e. `attrs |>
    evaluate_lazy_factories() |> Enum.into(%{})`). But then I opted for
    evaluating the lazy factories after the factory is created because that
    allows us to use `build_lazy/2` from within factory definitions, and not
    just when combined with `build/2` or `build_*` functions.
    
    How it interacts with "full-control" factories
    ---------------------------------------------
    
    We evaluate lazy factories before passing the attrs to the factories
    that have full control. That means that we can still use `build_lazy`
    within `build/2` and `build_*` functions that have a factory with full
    control, but we cannot use `build_lazy/2` as part of the definition of a
    factory that has full control.
    
    In other words, we can do this:
    
    ```elixir
    def account_factory(attrs) do
      %{
        user: build(:user),
        ...
      }
    end
    
    build(:account, build_lazy(:user))
    ```
    
    But we cannot do this:
    
    ```elixir
    def account_factory(attrs) do
      %{
        user: build_lazy(:user),
        ...
      }
    end
    ```
    
    This seems like the best compromise we can make so that people can
    continue using factories with full control, but without the ability to
    define lazy factories in the definition -- since ExMachina does nothing
    after the factory is defined.
    
    Notes on the name `InstanceTemplate`
    -----------------------------------
    
    I call it `InstanceTemplate` because it's not exactly an instance of a
    factory. I only has the recipe to build a factory in that it has the
    factory `name` and the extra attributes. But, evaluating the instance
    template twice when the underlying factory has a sequence will not
    result in identical factories.
    
    In other words, given the factory:
    
    ```elixir
    def user_factory do
      %{email: sequence("email")}
    end
    ```
    
    Then evaluating the same build lazy template will not give use the same
    sequence:
    
    ```elixir
    template = build_lazy(:user)
    
    InstanceTemplate.evaluate(template) != InstanceTemplate.evaluate(template)
    ```
    
    Potential future work
    ---------------------
    
    This work opens the possibility of passing the built factory as an
    argument to `build_lazy` in a factory definition, but we'd have to
    modify how it works. I leave that for future work, if indeed that's
    something people want.
    
    Here's an example of how it could work:
    
    ```elixir
    def account_factory do
      %{
        private: true,
        profile: build_lazy(fn account ->
          build(:profile, private: account.private
        end)
      }
    end
    ```
    germsvel committed Dec 12, 2020
    Configuration menu
    Copy the full SHA
    ffb86d5 View commit details
    Browse the repository at this point in the history