lib/ecto_stream_factory/ecto_stream_factory.ex

defmodule EctoStreamFactory do
  @moduledoc """
  Defines a factory that can be used in tests and database seeds files.

  ## Examples

      defmodule MyApp.Factory do
        use EctoStreamFactory, repo: MyApp.Repo

        def user_generator do
          gen all name <- string(:alphanumeric, min_length: 1),
                  age <- integer(15..80) do
            %User{name: name, age: age}
          end
        end

        def post_generator do
          gen all author <- user_generator(),
                  body <- string(:alphanumeric, min_length: 10) do
            %Post{author: author, body: body}
          end
        end
      end
  """

  alias EctoStreamFactory.Factory
  alias Ecto.Schema

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      use ExUnitProperties

      @repo Factory.get_repo(__MODULE__, opts)

      defguardp is_valid_generator_name(value) when is_atom(value) or is_binary(value)
      defguardp is_valid_amount(value) when is_integer(value) and value > 0

      def build(generator_name, attrs \\ []) when is_valid_generator_name(generator_name) do
        Factory.build(__MODULE__, generator_name, attrs)
      end

      def build!(generator_name, attrs \\ []) when is_valid_generator_name(generator_name) do
        Factory.build!(__MODULE__, generator_name, attrs)
      end

      def build_list(amount, generator_name, attrs \\ [])
          when is_valid_generator_name(generator_name) and
                 is_valid_amount(amount) do
        Factory.build_list(__MODULE__, amount, generator_name, attrs)
      end

      def build_list!(amount, generator_name, attrs \\ [])
          when is_valid_generator_name(generator_name) and
                 is_valid_amount(amount) do
        Factory.build_list!(__MODULE__, amount, generator_name, attrs)
      end

      def insert(generator_name, attrs \\ [], opts \\ [])
          when is_valid_generator_name(generator_name) do
        Factory.insert(__MODULE__, @repo, generator_name, attrs, opts)
      end

      def insert!(generator_name, attrs \\ [], opts \\ [])
          when is_valid_generator_name(generator_name) do
        Factory.insert!(__MODULE__, @repo, generator_name, attrs, opts)
      end

      def insert_list(amount, generator_name, attrs \\ [], opts \\ [])
          when is_valid_generator_name(generator_name) and is_valid_amount(amount) do
        Factory.insert_list(__MODULE__, @repo, amount, generator_name, attrs, opts)
      end

      def insert_list!(amount, generator_name, attrs \\ [], opts \\ [])
          when is_valid_generator_name(generator_name) and is_valid_amount(amount) do
        Factory.insert_list!(__MODULE__, @repo, amount, generator_name, attrs, opts)
      end
    end
  end

  @type generator_name() :: atom() | String.t()

  @type overwrite_value :: term() | (pos_integer() -> term())

  @typedoc """
  Keyword list or map where values can be of any literal types or one arity functions that generate sequential data.
  """
  @type overwrites() :: [{atom(), overwrite_value()}] | %{term() => overwrite_value()}

  @type amount() :: pos_integer()

  @typedoc """
  Same as options for `c:Ecto.Repo.insert/2`.
  """
  @type insert_opts() :: Keyword.t()

  @doc """
  Takes a single instance from a StreamData [gen/1](`ExUnitProperties.gen/1`) generator.

  If the generator returs a struct, map or keyword list, it also merges the result with `overwrites`.

  ## Examples

      iex> Factory.build(:user)
      %User{id: nil, name: "a", age: 33}

      iex> Factory.build(:user, name: "foo")
      %User{id: nil, name: "foo", age: 49}
  """
  @callback build(generator_name(), overwrites()) :: term()

  @doc """
  Same as `c:build/2`, but raises an error if some key in `overwrites` does not exist in the generated struct, map or keyword list.

  ## Examples

      iex> Factory.build!(:user)
      %User{id: nil, name: "b", age: 28}

      iex> Factory.build!(:user, name: "foo", weight: 101)
      ** (EctoStreamFactory.MissingKeyError) MyApp.Factory.user_generator does not generate :weight field.
  """
  @doc since: "0.2.0"
  @callback build!(generator_name(), overwrites()) :: term()

  @doc ~S"""
  Same as `c:build/2`, but instantiates a list of structs.

  ## Examples

      iex> Factory.build_list(2, :user, name: fn n -> "user#{n}" end)
      [%User{id: nil, name: "user1"}, %User{id: nil, name: "user2"}]
  """
  @callback build_list(amount(), generator_name(), overwrites) :: nonempty_list(term())

  @doc ~S"""
  Same as `c:build_list/3`, but raises an error if some key in `overwites` does not exist in the generated entities.
  """
  @doc since: "0.2.0"
  @callback build_list!(amount(), generator_name(), overwrites) :: nonempty_list(term())

  @doc """
  Similar to `c:build/2`, but expects a generator to return an Ecto.Schema struct to insert it into the database.

  ## Examples

      iex> Factory.insert(:user, [email: "duplicated@example.com"], on_conflict: :nothing)
      %User{id: 2, email: "duplicated@example.com"}
  """
  @callback insert(generator_name(), overwrites(), insert_opts()) :: Schema.t()

  @doc """
  Same as `c:insert/3`, but raises an error if some key in `overwites` does not exist in the generated struct.
  """
  @doc since: "0.2.0"
  @callback insert!(generator_name(), overwrites(), insert_opts()) :: Schema.t()

  @doc """
  Same as `c:insert/3`, but inserts a list of structs.

  ## Examples

      iex> Factory.insert_list(3, :user, role: "admin")
      [%User{id: 3, role: "admin"}, %User{id: 4, role: "admin"}, %User{id: 5, role: "admin"}]
  """
  @callback insert_list(amount(), generator_name(), overwrites(), insert_opts()) ::
              nonempty_list(Schema.t())

  @doc ~S"""
  Same as `c:insert_list/4`, but raises an error if some key in `overwites` does not exist in the generated structs.
  """
  @doc since: "0.2.0"
  @callback insert_list!(amount(), generator_name(), overwrites(), insert_opts()) ::
              nonempty_list(Schema.t())
end