defmodule Fictitious do @moduledoc """ `Fictitious` is a tool that enables you to create a fictitious data or records. It helps you to create mock data for your unit test without having the hassle of preparing the data in an convoluted order according to their associations that they have. `Fictitious` will ensure that whatever ecto schema that you specified, you will get the schema's data created for you. ## Installation `Fictitious` only generates fictitious data hence it is recommended that you install them in test environment only but feel free to play around with `Fictitious` by having it installed in normal dev environment. Inside your `mix.exs` file add `{:fictitious, "~> 0.1.0", only: :test}` as one of your dependency: defp deps do [ ... {:fictitious, "~> 0.1.0", only: :test}, ... ] end Once you have it installed, you need to configure the `Fictitious` repo. To do so add the following configuration to your `test.exs` file: config :fictitious, :repo, default: YourApp.Repo Notice we put it inside `test.exs` file. This is because `Fictitious` is engineered to help you in creating mock data for your unit test hence most of the time you will definitely put this configuration in test environment only. In case your application has more than one `Repo` it is possible to configure more than one repo for `Fictitious` to use. In fact, you could configure as many as you want. To do so you could add more repos into the fictitious `:repo` configs as follow: config :fictitious, :repo, default: YourApp.Repo, second_repo: YourApp.SecondRepo, third_repo: YourApp.ThirdRepo The `default` repo will be used by default by `Fictitious`. ## How to Use ### Basics Given you have the following ecto schema inside your app: defmodule YourApp.Schema.Person do use Ecto.Schema import Ecto.Changeset schema "persons" do field :name, :string field :age, :integer field :email, :string timestamps() end ... end To generate fictitious data of a person you could simply call `fictionize/1` function as follow: iex> Fictitious.fictionize(YourApp.Schema.Person) {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 35, name: "2cBfxcqnB0B5iqhYvK83RamaDa8KM0PvPpT1kVao", age: 514, email: "2cBfxcqnB0B5iqhYvK83RamaDa8KM0PvPpT1kVao", inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} `fictionize/1` will simply generate fictitious value that is according to the field's type. The currently supported primitive types by `Fictitious` can be found in [official ecto documentation](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types). In case you want some fields to not be fully random, in fact you want to set them on your own, you could overwrite the values that are generated by `Fictitious` by providing the second argument: iex> Fictitious.fictionize(YourApp.Schema.Person, name: "some name", email: "some email") {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 652, name: "some name", age: 1241, email: "some email", inserted_at: ~U[2020-05-31 20:11:21Z], updated_at: ~U[2020-05-31 20:11:42Z] }} ### Schema with Changeset Validations The previous schema in `Basics` section was a rather simple schema. What happen if we now decided to modify the `persons` schema to add some field value validations in the `changeset/2` function as follow: defmodule YourApp.Schema.Person do use Ecto.Schema import Ecto.Changeset schema "persons" do field :name, :string field :age, :integer field :gender, :string field :email, :string timestamps() end @doc false def changeset(person, attrs) do person |> cast(attrs, [...]) |> validate_inclusion(:gender, ["MALE", "FEMALE"]) # check if :gender is either "MALE" or "FEMALE" |> validate_email_format() # custom function to check if :email has the correct email format end end Having `validate_inclusion/3` and `validate_email_format/1` in the changeset would make the value of `:gender` and `:email` to be totally random hence `Fictitious` will ignore any kind of validation in the changeset. Performing `fictionize/1` function to the new `persons` schema will still give you a fictitious value for `:gender` and `:email`: iex> Fictitious.fictionize(YourApp.Schema.Person) {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 1243, name: "2cBfxcqnB0B5iqhYvK83RamaDa8KM0PvPpT1kVao", age: 632, gender: "7U01hkeHYLLtSVNI3SPaSNSXrACVBsDRwFe13n6l7GzaAakcPkMtODZ2eiioqJHrWXITSLPMu7wJ8" email: "ixV5neQzcap5hq4dXycbt6Mj2fqgPLI3se6qXQbkmHOdoICyaX6", inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} In case you want the data to have the correct value for `:gender` and `:email` you need to specify them manually as previously has shown: iex> Fictitious.fictionize(YourApp.Schema.Person, gender: "MALE", email: "email@domain.com") {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 564545, name: "xIzommg1lpwgRNBQCcGXLXdxORM7gXGqVIkC3gDL2As1DhxmhdejE0tXR2ImlrXN7j72nDO3Y", age: 235111, gender: "MALE" email: "email@domain.com", inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} ### Associations The true comfort of `Fictitious` comes when you encounter ecto schemas that have `%Ecto.Association.BelongsTo{}` relations to other schemas. Given you have the following new `countries` schema as follow: defmodule YourApp.Schema.Country do use Ecto.Schema import Ecto.Changeset alias YourApp.Schema.Person schema "countries" do field :name, :string has_many :people, Person, foreign_key: :country_id timestamps() end ... end and in a `persons` schema we add `belongs_to` relation to `countries` as follow: defmodule YourApp.Schema.Person do use Ecto.Schema import Ecto.Changeset alias YourApp.Schema.Country schema "persons" do field :name, :string field :age, :integer field :gender, :string field :email, :string belongs_to :nationality, Country, references: :id, foreign_key: :country_id, type: :id timestamps() end end then depending on how you set the tables' relation in the DB, it is usually meant that for a person to exist it must belongs to a country hence before any person record could be created, you must at least has one country record. It is possible that a person could exist without a country if no constraint exist in the DB however this is the assumption that `Fictitous` will always make whenenver a schema has an `%Ecto.Association.BelongsTo{}` relation. It will always assume that since `persons` belongs to `countries` then a country record must exist first. calling `fictionize/1` to `YourApp.Schema.Country` will only makes a fictitious country: iex> Fictitious.fictionize(YourApp.Schema.Country) {:ok, %YourApp.Schema.Country{ __meta__: #Ecto.Schema.Metadata<:loaded, "countries">, id: 67, name: "B8LemwxB8ULP4NLUaFnKfwWkMmBYy8BTytkSN2PiL1UTO47yRM", people: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} however calling `fictionize/1` to `YourApp.Schema.Person` will creates a person by creating the country first: iex> {:ok, person} = Fictitious.fictionize(YourApp.Schema.Person) {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 725, name: "bElHKj9zVwnkLRpO4Y23yon9n80gm1yeAEL4PgtgkxBc0p2Y7C", age: 364, gender: "dF1O5Eq4ombjzah", email: "hpOXdOriGA9xaMhnwese40PqqL2Ine", nationality: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} iex> YourApp.Repo.preload(person, :nationality) %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 725, name: "bElHKj9zVwnkLRpO4Y23yon9n80gm1yeAEL4PgtgkxBc0p2Y7C", age: 364, gender: "dF1O5Eq4ombjzah", email: "hpOXdOriGA9xaMhnwese40PqqL2Ine", nationality: %YourApp.Schema.Country{ __meta__: #Ecto.Schema.Metadata<:loaded, "countries">, id: 401, name: "lcb1e86TY6RSccL6vPGjXOv43gnp1t", people: #Ecto.Association.NotLoaded inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] } inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] } Having the `belongs_to` associations to be created automatically removes the trouble of having to prepare other entities before we could create the wanted the entity. This is usually happens a lot of time during preparing unit test data hence this is one problem that `Fictitious` could solve and save us a lot of time. `Fictitious` ensures that you get the targeted or wanted entity to be created. In case you want the created fictitious person to belongs to the previously created fictitious country then there are two ways you could do that. First is by manually changing the `:country_id` as follows: iex> {:ok, country} = Fictitious.fictionize(YourApp.Schema.Country) {:ok, %YourApp.Schema.Country{ __meta__: #Ecto.Schema.Metadata<:loaded, "countries">, id: 666409, name: "PBFnUbyDokx2vRkPkabDATTc10782NoErPH2H82ZPgKGa", people: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} iex> {:ok, person} = Fictitious.fictionize(YourApp.Schema.Person, country_id: country.id) {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 5230, name: "FZcb5Q4zLOO4aMrdi1RblsEPpushgAn9zoPtfMbJWlsNe", age: 5768, gender: "FBTG2Ls4Fi9nD6oazpPjBqti5DfdmqyGTaQp5xlxjiH9B", email: "cgOACnmDFqbO5NxEZ0AUtwtjEfZBMcv3QzAq3esrcJHo7", nationality: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} or second, by passing the whole `%YourApp.Schema.Country{}` struct as follows: iex> {:ok, country} = Fictitious.fictionize(YourApp.Schema.Country) {:ok, %YourApp.Schema.Country{ __meta__: #Ecto.Schema.Metadata<:loaded, "countries">, id: 7914, name: "KZnSewzrsTUgKNuLFxW84f2D3vEgneyz7TdnDLVojcXy3", people: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} iex> {:ok, person} = Fictitious.fictionize(YourApp.Schema.Person, nationality: country) {:ok, %YourApp.Schema.Person{ __meta__: #Ecto.Schema.Metadata<:loaded, "persons">, id: 451, name: "ZFvtidsGOPh6OymYJk529bL2QT9KMZic2A0ietddl2RWy", age: 150940, gender: "rHZYpbDgJQokDX2vSpSfWUmELrTb9f", email: "xmcuHrJvotjAQz6itQnZtoMp", nationality: #Ecto.Association.NotLoaded, inserted_at: ~U[2020-04-31 06:19:27Z], updated_at: ~U[2020-04-31 06:19:27Z] }} ### Multiple Repos Given you configured the `Fictitious` repo as follow: config :fictitious, :repo, default: YourApp.Repo, second_repo: YourApp.SecondRepo then to use the second repo you could use `fictionize/2` or `fictionize/3` as follows: iex> Fictitious.fictionize(YourApp.Schema.Continent, :second_repo) iex> Fictitious.fictionize(YourApp.Schema.Continent, :second_repo, name: "overwrite name") """ import Ecto.Changeset defp repo(opts) do case opts do :default -> Application.get_env(:fictitious, :repo)[:default] _ -> Application.get_env(:fictitious, :repo)[opts] end end def fictionize(ecto_schema) do case ecto_schema do %Ecto.Association.BelongsTo{related: parent_ecto_schema} -> fictionize(parent_ecto_schema) _ -> ecto_schema |> fictitious_changeset(Map.merge(fictionize_independent_fields(ecto_schema), fictionize_belongs_to_association_fields(ecto_schema))) |> repo(:default).insert() |> case do {:ok, data} -> {:ok, data} {:error, error} -> {:error, error} end end end def fictionize(ecto_schema, repo) when is_atom(repo) and not is_nil(repo) do case ecto_schema do %Ecto.Association.BelongsTo{related: parent_ecto_schema} -> fictionize(parent_ecto_schema, repo) _ -> ecto_schema |> fictitious_changeset(Map.merge(fictionize_independent_fields(ecto_schema), fictionize_belongs_to_association_fields(ecto_schema, repo))) |> repo(repo).insert() |> case do {:ok, data} -> {:ok, data} {:error, error} -> {:error, error} end end end def fictionize(ecto_schema, opts) do case ecto_schema do %Ecto.Association.BelongsTo{related: parent_ecto_schema} -> cond do is_nil(opts) -> fictionize(parent_ecto_schema) is_list(opts) -> fictionize(parent_ecto_schema, opts) end _ -> ecto_schema |> fictitious_changeset(Map.merge(fictionize_independent_fields(ecto_schema, opts), fictionize_belongs_to_association_fields(ecto_schema, opts))) |> repo(:default).insert() |> case do {:ok, data} -> {:ok, data} {:error, error} -> {:error, error} end end end def fictionize(ecto_schema, repo, opts) when is_atom(repo) do case ecto_schema do %Ecto.Association.BelongsTo{related: parent_ecto_schema} -> cond do is_nil(opts) -> fictionize(parent_ecto_schema, repo) is_list(opts) -> fictionize(parent_ecto_schema, repo, opts) end _ -> ecto_schema |> fictitious_changeset(Map.merge(fictionize_independent_fields(ecto_schema, opts), fictionize_belongs_to_association_fields(ecto_schema, repo, opts))) |> repo(repo).insert() |> case do {:ok, data} -> {:ok, data} {:error, error} -> {:error, error} end end end ################################### # FICTIONIZER # ################################### defp fictitious_changeset(ecto_schema, attrs), do: cast(ecto_schema.__struct__, attrs, get_fields(ecto_schema)) defp fictionize_independent_field(ecto_schema, field), do: ecto_schema |> get_field_type(field) |> gen_random() defp fictionize_independent_fields(ecto_schema) do ecto_schema |> get_independent_fields() |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, fictionize_independent_field(ecto_schema, field)) end) end defp fictionize_independent_fields(predefined_value_map, opts) when is_map(predefined_value_map) and is_list(opts), do: predefined_value_map |> Map.keys() |> Enum.reduce(%{}, fn key, acc -> Map.put(acc, key, Keyword.get(opts, key) || Map.get(predefined_value_map, key)) end) defp fictionize_independent_fields(ecto_schema, opts) when is_list(opts), do: fictionize_independent_fields(fictionize_independent_fields(ecto_schema), opts) defp fictionize_belongs_to_association_field(ecto_schema, field), do: ecto_schema |> get_association_field_type(field) |> fictionize() defp fictionize_belongs_to_association_field(ecto_schema, field, opts) when is_list(opts), do: ecto_schema |> get_association_field_type(field) |> fictionize(Keyword.get(opts, field)) defp fictionize_belongs_to_association_field(ecto_schema, field, repo), do: ecto_schema |> get_association_field_type(field) |> fictionize(repo) defp fictionize_belongs_to_association_field(ecto_schema, field, repo, opts) when is_list(opts), do: ecto_schema |> get_association_field_type(field) |> fictionize(repo, Keyword.get(opts, field)) defp fictionize_belongs_to_association_fields(ecto_schema) do ecto_schema |> get_belongs_to_association_fields() |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, get_association_field_key(ecto_schema, field), ecto_schema |> fictionize_belongs_to_association_field(field) |> detupelize() |> Map.get(ecto_schema |> get_association_field_ecto_schema(field) |> get_primary_key_field()) ) end) end defp fictionize_belongs_to_association_fields(ecto_schema, repo) when is_atom(repo) do ecto_schema |> get_belongs_to_association_fields() |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, get_association_field_key(ecto_schema, field), ecto_schema |> fictionize_belongs_to_association_field(field, repo) |> detupelize() |> Map.get(ecto_schema |> get_association_field_ecto_schema(field) |> get_primary_key_field()) ) end) end defp fictionize_belongs_to_association_fields(ecto_schema, opts) when is_list(opts) do ecto_schema |> get_belongs_to_association_fields() |> Enum.reduce(%{}, fn field, acc -> association_field_key = get_association_field_key(ecto_schema, field) association_field_value = cond do not is_nil(Keyword.get(opts, association_field_key)) -> Keyword.get(opts, association_field_key) is_map(Keyword.get(opts, field)) -> association_ecto_schema = opts |> Keyword.get(field) |> Map.get(:__meta__) |> Map.get(:schema) primary_key = association_ecto_schema.__schema__(:primary_key) |> Enum.at(0) opts |> Keyword.get(field) |> Map.from_struct() |> Map.to_list() |> Keyword.get(primary_key) true -> nil end Map.put(acc, association_field_key, association_field_value || ecto_schema |> fictionize_belongs_to_association_field(field, opts) |> detupelize() |> Map.get(ecto_schema |> get_association_field_ecto_schema(field) |> get_primary_key_field()) ) end) end defp fictionize_belongs_to_association_fields(ecto_schema, repo, opts) when is_atom(repo) and is_list(opts) do ecto_schema |> get_belongs_to_association_fields() |> Enum.reduce(%{}, fn field, acc -> association_field_key = get_association_field_key(ecto_schema, field) association_field_value = cond do not is_nil(Keyword.get(opts, association_field_key)) -> Keyword.get(opts, association_field_key) is_map(Keyword.get(opts, field)) -> association_ecto_schema = opts |> Keyword.get(field) |> Map.get(:__meta__) |> Map.get(:schema) primary_key = association_ecto_schema.__schema__(:primary_key) |> Enum.at(0) opts |> Keyword.get(field) |> Map.from_struct() |> Map.to_list() |> Keyword.get(primary_key) true -> nil end Map.put(acc, association_field_key, association_field_value || ecto_schema |> fictionize_belongs_to_association_field(field, repo, opts) |> detupelize() |> Map.get(ecto_schema |> get_association_field_ecto_schema(field) |> get_primary_key_field()) ) end) end ################################### # ECTO SCHEMA REFLECTIONS # ################################### defp get_primary_key_field(ecto_schema), do: ecto_schema.__schema__(:primary_key) |> Enum.at(0) defp get_fields(ecto_schema), do: ecto_schema.__schema__(:fields) defp get_field_type(ecto_schema, field), do: ecto_schema.__schema__(:type, field) defp get_any_association_fields(ecto_schema), do: ecto_schema.__schema__(:associations) defp get_independent_fields(ecto_schema), do: ecto_schema |> get_fields() |> not_in(get_association_field_keys(ecto_schema)) defp get_belongs_to_association_fields(ecto_schema), do: ecto_schema |> get_any_association_fields() |> Enum.filter(fn field -> get_association_field_type(ecto_schema, field) |> is_belongs_to_association?() end) defp get_association_field_type(ecto_schema, field), do: ecto_schema.__schema__(:association, field) defp get_association_field_key(%Ecto.Association.Has{related_key: key}), do: key defp get_association_field_key(%Ecto.Association.BelongsTo{owner_key: key}), do: key defp get_association_field_key(%Ecto.Association.ManyToMany{}), do: :not_supported_yet defp get_association_field_key(ecto_schema, field), do: ecto_schema.__schema__(:association, field) |> get_association_field_key() defp get_association_field_keys(ecto_schema), do: ecto_schema |> get_any_association_fields() |> Enum.map(fn field -> get_association_field_key(ecto_schema, field) end) defp get_association_field_ecto_schema(ecto_schema, field), do: ecto_schema.__schema__(:association, field).related defp is_belongs_to_association?(%Ecto.Association.BelongsTo{}), do: true defp is_belongs_to_association?(_), do: false ################################### # RANDOM GENERATORS # ################################### defp gen_random(:id), do: Misc.Random.number() defp gen_random(:binary_id), do: <> defp gen_random(:integer), do: Misc.Random.number() defp gen_random(:float), do: Misc.Random.number() defp gen_random(:boolean), do: Misc.Random.number(1) |> Integer.mod(2) == 0 defp gen_random(:string), do: Misc.Random.number(2) |> Misc.Random.string() defp gen_random(:binary), do: <> defp gen_random({:array, inner_type}), do: [gen_random(inner_type)] defp gen_random(:map), do: %{gen_random(:integer) => gen_random(:integer)} defp gen_random({:map, inner_type}), do: %{gen_random(inner_type) => gen_random(inner_type)} defp gen_random(:decimal), do: Misc.Random.number() |> Decimal.new() defp gen_random(:date), do: Date.utc_today() defp gen_random(:time), do: Time.utc_now() defp gen_random(:time_usec), do: Time.utc_now() defp gen_random(:naive_datetime), do: DateTime.utc_now() |> DateTime.to_naive() defp gen_random(:naive_datetime_usec), do: DateTime.utc_now() |> DateTime.to_naive() defp gen_random(:utc_datetime), do: DateTime.utc_now() defp gen_random(:utc_datetime_usec), do: DateTime.utc_now() defp gen_random(:ksuid), do: Ksuid.generate() defp gen_random(_), do: Ksuid.generate() ################################### # HELPERS # ################################### defp detupelize({_status, data}), do: data defp not_in(input_list, comparing_list), do: Enum.reject(input_list, fn field -> field in comparing_list end) end