lib/ecto_factory.ex

defmodule EctoFactory do
  @moduledoc """
  EctoFactory is a super easy way to generate fake data for an Ecto Schema.
  It requires zero setup and generates random data based on the fields you've defined.
  """

  import Enum, only: [random: 1]
  import Application, only: [get_env: 2]

  @doc """
  Create a struct of the passed in factory

  After configuring a factory

      config :ecto_factory, factories: [
        user: User,

        user_with_default_username: { User,
          username: "mrmicahcooper"
        }
      ]

  You can build a struct with the attributes from your factory as defaults.

      EctoFactory.schema(:user_with_default_username)

      %User{
        age: 124309132# random number
        username: "mrmicahcooper"
      }

  And you can pass in your own attributes of course:


      EctoFactory.schema(:user, age: 99, username: "hashrocket")

      %User{
        age: 99,
        username: "hashrocket"
      }

  """
  @spec schema(atom() | Ecto.Schema, keyword() | map()) :: Ecto.Schema
  def schema(factory_name, attrs \\ []) do
    {schema, attributes} = build_attrs(factory_name, attrs)
    struct(schema, attributes)
  end

  @doc """
  Create a map with randomly generated of the passed in Ecto schema. The keys of this map are `atoms`
  """
  @spec build(atom() | Ecto.Schema, keyword() | map()) :: map()
  def build(factory_name, attrs \\ []) do
    build_attrs(factory_name, attrs)
    |> elem(1)
    |> Enum.into(%{})
  end

  @doc """
  Create a map with randomly generated of the passed in Ecto schema. The keys of this map are `String`s
  """
  @spec attrs(atom() | Ecto.Schema, keyword() | map()) :: map()
  def attrs(factory_name, attrs \\ []) do
    build_attrs(factory_name, attrs)
    |> elem(1)
    |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end)
  end

  # Generators for the standard ecto types
  @doc """
  Generate a random value. `gen/1` will return a value of the atom or tuple to pass it.
  """
  @spec gen(atom() | tuple()) ::
          integer()
          | binary()
          | float()
          | boolean()
          | list()
          | Time
          | NaiveDateTime
          | Date
          | DateTime

  def gen(:id), do: random(1..9_999_999)
  def gen(:binary_id), do: Ecto.UUID.generate()
  def gen(:integer), do: random(1..9_999_999)
  def gen(:float), do: (random(99..99999) / random(1..99)) |> Float.round(2)
  def gen(:decimal), do: gen(:float)
  def gen(:boolean), do: random([true, false])
  def gen(:binary), do: Ecto.UUID.generate()
  def gen(:date), do: Date.utc_today()
  def gen(:time), do: Time.utc_now()
  def gen(:time_usec), do: gen(:time)

  def gen(:naive_datetime) do
    NaiveDateTime.utc_now()
    |> NaiveDateTime.truncate(:second)
  end

  def gen(:naive_datetime_usec), do: gen(:naive_datetime)
  def gen(:utc_datetime), do: DateTime.utc_now()
  def gen(:utc_datetime_usec), do: gen(:utc_datetime)
  def gen(:string), do: random_string(20)

  def gen(:array), do: gen({:array, :string})
  def gen({:array, type}), do: 1..random(2..9) |> Enum.map(fn _ -> gen(type) end)

  def gen(:map), do: gen({:map, :string})

  def gen({:map, type}) do
    for(key <- gen({:array, :string}), into: %{}, do: {key, gen(type)})
  end

  # Special generators for helpful things
  def gen(:email), do: "#{gen(:string)}@#{gen(:string)}.#{gen(:string)}"

  def gen(:url) do
    host = random_string(3..6) <> "." <> random_string(4..8)
    tld = ~w[com co.uk ninja ca biz org gov software io us rome.it ai] |> random()
    "https://#{host}.#{tld}"
  end

  # Module names are atoms
  # When the schema passes in a module, ask it for it's Ecto type
  #
  # schema "users" do
  #   field(:balance, EctoURI)
  # end
  #
  # Ecto Types must identify themselves with a type() function
  # https://hexdocs.pm/ecto/Ecto.Type.html
  def gen(ecto_type_module) when is_atom(ecto_type_module), do: gen(ecto_type_module.type())

  # fallback to nil - this should probably raise
  def gen(_), do: nil

  defp build_attrs(factory_name, attributes) do
    Code.ensure_loaded(factory_name)
    {schema, defaults} =
      if function_exported?(factory_name, :__changeset__, 0) do
        {factory_name, []}
      else
        factory(factory_name)
      end

    attributes = Enum.into(attributes, [])

    non_generated_keys =
      defaults
      |> Kernel.++(attributes)
      |> Keyword.keys()
      |> Kernel.++(schema.__schema__(:primary_key))

    attrs =
      schema.__changeset__()
      |> Map.drop(non_generated_keys)
      |> Enum.map(&cast/1)
      |> Keyword.merge(defaults)
      |> Keyword.merge(attributes)
      |> Enum.map(&gen_attribute/1)

    {schema, attrs}
  end

  defp gen_attribute({key, value}) when is_atom(value) and value not in [true, false, nil],
    do: {key, gen(value)}

  defp gen_attribute({key, {_, _} = value}), do: {key, gen(value)}
  defp gen_attribute(key_value), do: key_value

  defp cast({key, {:assoc, %{cardinality: :many}}}), do: {key, nil}
  defp cast({key, {:assoc, %{cardinality: :one}}}), do: {key, nil}
  defp cast({key, data_type}), do: {key, gen(data_type)}

  defp factory(factory_name) do
    case get_env(:ecto_factory, :factories)[factory_name] do
      nil -> raise(EctoFactory.MissingFactory, factory_name)
      {schema, defaults} -> {schema, defaults}
      {schema} -> {schema, []}
    end
  end

  defp random_string(min..max) do
    for(_ <- 1..random(min..max), into: "", do: <<random(?a..?z)>>)
  end

  defp random_string(max_length) do
    for(_ <- 1..random(8..max_length), into: "", do: <<random(?a..?z)>>)
  end
end