Current section
Files
Jump to
Current section
Files
lib/mishras.factory.ex
use Protoss
defprotocol Mishras.Factory do
@moduledoc """
A protocol for creating test data factories for Ecto schemas.
This protocol provides a powerful and flexible way to create test data with
automatic handling of associations, embeds, and primary key generation.
## Overview
The Mishras.Factory protocol allows you to define factories for your Ecto schemas
that can automatically:
- Generate primary keys (both integer and binary_id)
- Handle associations (belongs_to, has_one, has_many, many_to_many)
- Process embedded schemas (embeds_one, embeds_many)
- Support different modes for building vs inserting data
## Implementation
To implement a factory for a schema, you need to define the `build_map/2` callback:
defimpl Mishras.Factory, for: MyApp.User do
use Mishras
def build_map(_mode, _attrs) do
%{
name: "John Doe",
email: "john@example.com"
}
end
end
### Modes
The factory supports two modes, which are passed into the `build_map` callback:
- `:build` - Creates structs without database insertion
- `:insert` - Creates and inserts records into the database
### Dependent data
When building maps, it may be necessary to have some fields depend on other fields.
In this case, the fields are passed as the second `attrs` argument.
## Configuration
In order to properly integrate with Ecto, configure your repo in your application config:
config :mishras, repo: MyApp.Repo
## Optional Callbacks
### `autogenerate_id/1`
Override the default primary key generation behavior:
def autogenerate_id(attrs) do
Map.put(attrs, :id, MyApp.IdGenerator.generate())
end
### `insert/1`
Override the default insert behavior for custom persistence logic. The attrs
passed to this callback have already been processed by `build_map/2`:
def insert(attrs) do
@for
|> struct()
|> changeset(attrs)
|> MyApp.Repo.insert!(returning: true)
end
This is useful when you need custom insert options, want to use a different
repo, or need to perform additional operations during insertion.
### `relation_type/1`
Control how associations and embeds are handled. Returns either `:cast` (default)
or `:put`:
def relation_type(_relation), do: :put
Or handle different relations differently:
def relation_type(:comments), do: :put
def relation_type(_), do: :cast
The difference between `:cast` and `:put`:
- `:cast` - Relations are passed as maps to `cast_assoc`/`cast_embed`. The
changeset function of the related schema validates and transforms the data.
This is the default and works with most changesets that use `cast_assoc`
or `cast_embed`.
- `:put` - Relations are built as structs/changesets and passed to `put_assoc`/
`put_embed`. Use this when your changeset uses `put_assoc` or `put_embed`
instead of the cast variants.
"""
@doc false
def changeset(object, attrs)
after
alias Ecto.Changeset
@typedoc """
The mode in which the factory is operating.
- `:build` - Creating structs without database insertion
- `:insert` - Creating and inserting records into the database
"""
@type mode :: :build | :insert
@typedoc """
The strategy for handling relations (associations and embeds).
- `:cast` - Pass data as maps for `cast_assoc`/`cast_embed` (default)
- `:put` - Pass data as structs/changesets for `put_assoc`/`put_embed`
"""
@type relation_type :: :cast | :put
@doc """
Returns a map of default attributes for building a schema.
This is the primary callback that must be implemented. It should return
a map containing default values for the schema's fields.
## Parameters
- `mode` - Either `:build` or `:insert`, indicating how the data will be used
- `attrs` - The attributes passed by the caller, useful for dependent fields
## Examples
# Using attrs for dependent fields
def build_map(_mode, attrs) do
%{
first_name: "John",
last_name: "Doe",
full_name: "\#{attrs[:first_name] || "John"} \#{attrs[:last_name] || "Doe"}"
}
end
"""
@callback build_map(mode, attrs :: map) :: map
@doc """
Customizes primary key generation for the schema.
By default, Mishras generates random integer IDs or UUIDs based on the
schema's autogenerate configuration. Override this callback to provide
custom ID generation logic.
## Parameters
- `attrs` - The current attributes map
## Returns
The attributes map with the primary key added.
## Examples
def autogenerate_id(attrs) do
Map.put(attrs, :id, MyApp.Snowflake.generate())
end
"""
@callback autogenerate_id(attrs :: map) :: map
@doc """
Customizes the insert behavior for the schema.
Override this callback when you need custom persistence logic, such as
using different insert options, a different repo, or performing additional
operations during insertion.
## Parameters
- `attrs` - The attributes map produced by `build_map/2` merged with caller overrides
## Returns
The inserted struct.
## Examples
def insert(attrs) do
@for
|> struct()
|> changeset(attrs)
|> MyApp.Repo.insert!(returning: true, on_conflict: :replace_all)
end
"""
@callback insert(attrs :: map) :: struct
@doc """
Specifies how a relation should be handled when building data.
This callback controls whether relations (associations and embeds) are
processed for `cast_assoc`/`cast_embed` or `put_assoc`/`put_embed`.
## Parameters
- `relation` - The name of the association or embed as an atom
## Returns
- `:cast` - Data will be passed as maps (for `cast_assoc`/`cast_embed`)
- `:put` - Data will be passed as structs/changesets (for `put_assoc`/`put_embed`)
## Examples
# Use :put for all relations
def relation_type(_relation), do: :put
# Different handling per relation
def relation_type(:comments), do: :put
def relation_type(:profile), do: :cast
def relation_type(_), do: :cast
"""
@callback relation_type(relation :: atom) :: relation_type
@optional_callbacks autogenerate_id: 1, insert: 1, relation_type: 1
@doc """
Builds a struct from the given schema with the provided attributes.
This function creates a struct without inserting it into the database.
It automatically generates primary keys and handles associations/embeds.
## Parameters
- `schema` - The Ecto schema module
- `attrs` - A map or keyword list of attributes to override defaults
## Examples
user = Mishras.Factory.build(MyApp.User, %{name: "Jane"})
# Returns a %MyApp.User{} struct
## Returns
A struct of the given schema type with all fields populated.
"""
def build(schema, attrs \\ []) do
schema
|> struct()
|> changeset(build_map(schema, :build, attrs))
|> Changeset.apply_action!(:build)
end
defp add_primary_key(attrs, mode, schema, impl) do
# if the schema impl has an autogenerate_id function, we should call it.
cond do
function_exported?(impl, :autogenerate_id, 1) ->
impl.autogenerate_id(attrs)
mode == :build ->
case schema.__schema__(:autogenerate_id) do
{name, _, :id} -> Map.put(attrs, name, Enum.random(1..32767))
{name, _, :binary_id} -> Map.put(attrs, name, Ecto.UUID.generate())
nil -> attrs
end
:else ->
attrs
end
end
@repo Application.compile_env!(:mishras, :repo)
@doc """
Inserts a record into the database using the given schema and attributes.
This function creates a struct and immediately inserts it into the configured
repository. It automatically generates primary keys and handles associations/embeds.
## Parameters
- `schema` - The Ecto schema module
- `attrs` - A map or keyword list of attributes to override defaults
## Examples
user = Mishras.Factory.insert(MyApp.User, %{email: "jane@example.com"})
# Returns a persisted %MyApp.User{} struct with database ID
## Returns
A struct of the given schema type that has been persisted to the database.
## Raises
Raises if the insertion fails due to validation errors or database constraints.
"""
def insert(schema, attrs \\ []) do
impl = Module.concat(Mishras.Factory, schema)
attrs = build_map(schema, :insert, attrs)
if function_exported?(impl, :insert, 1) do
impl.insert(attrs)
else
schema
|> struct()
|> changeset(attrs)
|> then(&apply(@repo, :insert!, [&1, []]))
end
end
defp build_map(schema, mode, attrs) do
impl = Module.concat(Mishras.Factory, schema)
attrs
|> Map.new()
|> add_primary_key(mode, schema, impl)
|> then(&Map.merge(impl.build_map(mode, &1), &1))
|> expand_associations(mode, schema, impl)
|> expand_embeds(schema, impl)
end
defp expand_associations(attrs, mode, schema, impl) do
:associations
|> schema.__schema__()
|> Enum.reduce(attrs, &expand_association(&2, &1, mode, schema, impl))
end
defp get_relation_type(impl, relation) do
if function_exported?(impl, :relation_type, 1) do
impl.relation_type(relation)
else
:cast
end
end
defp relation_from_struct(:cast, _schema, object), do: Map.from_struct(object)
defp relation_from_struct(:put, schema, object), do: schema.changeset(object, %{})
defp relation_from_map(:cast, schema, map) do
impl = Module.concat(Mishras.Factory, schema)
Map.merge(impl.build_map(:build, map), map)
end
defp relation_from_map(:put, schema, map), do: build(schema, map)
defp expand_association(attrs, assoc, mode, schema, impl) do
next_mode = assoc_mode(mode)
relation_type = get_relation_type(impl, assoc)
case {attrs, schema.__schema__(:association, assoc)} do
{attrs,
%{cardinality: :one, owner_key: key, relationship: :parent, queryable: assoc_schema}}
when is_map_key(attrs, key) ->
if mode == :insert do
found = apply(@repo, :get!, [assoc_schema, attrs[key], []])
attrs
|> Map.delete(key)
|> Map.put(assoc, relation_from_struct(relation_type, assoc_schema, found))
else
# ablate the attrs key
Map.delete(attrs, assoc)
end
{%{^assoc => %assoc_schema{} = object}, %{cardinality: :one, queryable: assoc_schema}} ->
Map.replace!(attrs, assoc, relation_from_struct(relation_type, assoc_schema, object))
{%{^assoc => assoc_map}, %{cardinality: :one, queryable: assoc_schema}} ->
Map.replace!(attrs, assoc, build_map(assoc_schema, next_mode, assoc_map))
{%{^assoc => object_list}, %{cardinality: :many, queryable: assoc_schema}}
when is_list(object_list) ->
object_list
|> Enum.map(fn
object when is_struct(object, assoc_schema) ->
relation_from_struct(relation_type, assoc_schema, object)
object when is_map(object) ->
build_map(assoc_schema, next_mode, object)
end)
|> then(&Map.replace!(attrs, assoc, &1))
# if the association is not present, we just return the attrs unchanged.
{attrs, _} ->
attrs
end
end
defp assoc_mode(:build), do: :build
defp assoc_mode(:insert), do: :map
defp assoc_mode(:map), do: :map
defp expand_embeds(attrs, schema, impl) do
:embeds
|> schema.__schema__()
|> Enum.reduce(attrs, &expand_embed(&2, &1, schema, impl))
end
defp expand_embed(attrs, embed, schema, impl) do
relation_type = get_relation_type(impl, embed)
case {attrs, schema.__schema__(:embed, embed)} do
{%{^embed => %embed_mod{} = object}, %{cardinality: :one, related: embed_mod}} ->
Map.replace!(attrs, embed, relation_from_struct(relation_type, embed_mod, object))
{%{^embed => embed_map}, %{cardinality: :one, related: embed_mod}} ->
Map.replace!(attrs, embed, relation_from_map(relation_type, embed_mod, embed_map))
{%{^embed => object_list}, %{cardinality: :many, related: embed_mod}}
when is_list(object_list) ->
object_list
|> Enum.map(fn
object when is_struct(object, embed_mod) ->
relation_from_struct(relation_type, embed_mod, object)
object when is_map(object) ->
relation_from_map(relation_type, embed_mod, object)
end)
|> then(&Map.replace!(attrs, embed, &1))
{attrs, _} ->
attrs
end
end
end