Current section

Files

Jump to
mishras lib mishras.factory.ex
Raw

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
"""
@doc false
def changeset(object, attrs)
after
alias Ecto.Changeset
@type mode :: :build | :insert
@callback build_map(mode, attrs :: map) :: map
@callback autogenerate_id(attrs :: map) :: map
@optional_callbacks autogenerate_id: 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
schema
|> struct()
|> changeset(build_map(schema, :insert, attrs))
|> then(&apply(@repo, :insert!, [&1, []]))
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)
|> expand_embeds(schema)
end
defp expand_associations(attrs, mode, schema) do
:associations
|> schema.__schema__()
|> Enum.reduce(attrs, &expand_association(&2, &1, mode, schema))
end
defp expand_association(attrs, assoc, mode, schema) do
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, found)
else
# ablate the attrs key
Map.delete(attrs, assoc)
end
{%{^assoc => %assoc_schema{} = object}, %{cardinality: :one, queryable: assoc_schema}} ->
Map.replace!(attrs, assoc, assoc_schema.changeset(object, %{}))
{%{^assoc => assoc_map}, %{cardinality: :one, queryable: assoc_schema}} ->
Map.replace!(attrs, assoc, build_map(assoc_schema, 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) ->
assoc_schema.changeset(object, %{})
object when is_map(object) ->
build(assoc_schema, 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 expand_embeds(attrs, schema) do
:embeds
|> schema.__schema__()
|> Enum.reduce(attrs, &expand_embed(&2, &1, schema))
end
defp expand_embed(attrs, embed, schema) do
case {attrs, schema.__schema__(:embed, embed)} do
{%{^embed => %embed_mod{} = object}, %{cardinality: :one, related: embed_mod}} ->
Map.replace!(attrs, embed, embed_mod.changeset(object, %{}))
{%{^embed => embed_map}, %{cardinality: :one, related: embed_mod}} ->
Map.replace!(attrs, embed, build(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) ->
embed_mod.changeset(object, %{})
object when is_map(object) ->
build(embed_mod, object)
end)
|> then(&Map.replace!(attrs, embed, &1))
{attrs, _} ->
attrs
end
end
end