Current section
Files
Jump to
Current section
Files
lib/sow.ex
defmodule Sow do
@moduledoc """
A library for synchronizing code-defined fixtures with a database.
## Usage
defmodule MyApp.Fixtures.Countries do
use Sow,
schema: MyApp.Country,
keys: [:code]
def records do
[
%{code: "NO", name: "Norway"},
%{code: "SE", name: "Sweden"}
]
end
end
## belongs_to (sync dependency first)
defmodule MyApp.Fixtures.Organizations do
use Sow,
schema: MyApp.Organization,
keys: [:slug]
def records do
[
%{
slug: "org-norway",
country: Sow.belongs_to(MyApp.Fixtures.Countries, :code, "NO")
}
]
end
end
## has_many (sync children after parent)
defmodule MyApp.Fixtures.Products do
use Sow,
schema: MyApp.Product,
keys: [:org_id, :type]
def records do
%{
type: :subscription,
variants: Sow.has_many(MyApp.Fixtures.ProductVariants, foreign_key: :product_id)
}
end
end
## many_to_many
For many_to_many, the related records are synced first, then passed to `put_assoc`:
defmodule MyApp.Fixtures.Products do
use Sow,
schema: MyApp.Product,
keys: [:slug]
def records do
%{
slug: "premium",
tags: [
Sow.many_to_many(MyApp.Fixtures.Tags, :slug, "featured"),
Sow.many_to_many(MyApp.Fixtures.Tags, :slug, "new")
]
}
end
end
## Auto-detect with assoc (shorthand)
Use `Sow.assoc/1-3` to auto-detect the association type from the Ecto schema:
defmodule MyApp.Fixtures.Products do
use Sow,
schema: MyApp.Product,
keys: [:slug]
def records do
%{
slug: "premium",
# Auto-detects belongs_to from schema
organization: Sow.assoc(MyApp.Fixtures.Organizations, :slug, "org"),
# Auto-detects many_to_many from schema
tags: [
Sow.assoc(MyApp.Fixtures.Tags, :slug, "featured")
],
# Auto-detects has_many from schema
variants: Sow.assoc(MyApp.Fixtures.ProductVariants)
}
end
end
Note: Your schema's changeset must handle many_to_many with `put_assoc`:
def changeset(product, attrs) do
product
|> cast(attrs, [:slug, :name])
|> maybe_put_assoc(:tags, attrs)
end
defp maybe_put_assoc(changeset, key, attrs) do
case Map.get(attrs, key) do
nil -> changeset
assoc -> put_assoc(changeset, key, assoc)
end
end
"""
@type keys :: [atom()]
@type record :: map()
@doc """
Returns the fixture records to sync.
Can return a single map or a list of maps.
The callback name can be customized with the `:callback` option:
use Sow, schema: MyApp.Country, keys: [:code], callback: :modify
def modify, do: [...]
"""
@callback records() :: record() | [record()]
defmacro __using__(opts) do
callback = Keyword.get(opts, :callback, :records)
quote do
@behaviour Sow
@sow_schema unquote(opts[:schema])
@sow_keys unquote(opts[:keys]) || @sow_schema.__schema__(:primary_key)
@sow_callback unquote(callback)
@doc false
def __sow_config__ do
%Sow.Config{
schema: @sow_schema,
keys: @sow_keys,
module: __MODULE__,
callback: @sow_callback
}
end
@doc """
Sync this fixture's records to the database.
## Options
* `:prune` - if `true`, deletes records in the database that are not
defined in the fixture. Defaults to `false`.
## Examples
# Sync without pruning (default)
Countries.sync(Repo)
# Sync and delete records not in fixtures
Countries.sync(Repo, prune: true)
"""
def sync(repo \\ Sow.default_repo(), opts \\ []) do
Sow.Sync.sync(__MODULE__, repo, opts)
end
end
end
@doc """
Marks a belongs_to association. The referenced fixture is synced first,
and its ID is assigned to `{field_name}_id`.
## Examples
# Sync the Countries fixture, use the first record's ID
%{country: Sow.belongs_to(MyApp.Fixtures.Countries)}
# Sync Countries fixture, find by code "NO", use that record's ID
%{country: Sow.belongs_to(MyApp.Fixtures.Countries, :code, "NO")}
"""
def belongs_to(fixture_module) do
%Sow.Relation{module: fixture_module}
end
def belongs_to(fixture_module, lookup_key, lookup_value) do
%Sow.Relation{module: fixture_module, lookup: {lookup_key, lookup_value}}
end
@doc """
Marks a has_many association. Children are synced after the parent,
with the parent's ID injected via the foreign_key.
## Examples
%{
variants: Sow.has_many(MyApp.Fixtures.ProductVariants, foreign_key: :product_id)
}
"""
def has_many(fixture_module, opts) do
%Sow.Nested{
module: fixture_module,
foreign_key: Keyword.fetch!(opts, :foreign_key),
keys: Keyword.get(opts, :keys)
}
end
@doc """
Marks a has_many association with inline records (no separate fixture module needed).
Children are synced after the parent, with the parent's ID injected via the foreign_key.
## Options
* `:schema` - (required) the Ecto schema module for the nested records
* `:foreign_key` - (required) the foreign key field to inject parent's ID
* `:keys` - (optional) search keys for upsert, defaults to schema's primary key
## Examples
%{
slug: "premium",
variants: Sow.has_many_inline(
[
%{sku: "SMALL", name: "Small"},
%{sku: "LARGE", name: "Large"}
],
schema: MyApp.ProductVariant,
foreign_key: :product_id,
keys: [:product_id, :sku]
)
}
Records can also contain relations:
flow_stages: Sow.has_many_inline(
[
%{position: 1, stage: Sow.belongs_to(StageFixture, :type, :select_client)},
%{position: 2, stage: Sow.belongs_to(StageFixture, :type, :payment)}
],
schema: MyApp.FlowStage,
foreign_key: :flow_id,
keys: [:flow_id, :stage_id]
)
"""
def has_many_inline(records, opts) when is_list(records) do
%Sow.Nested{
records: records,
schema: Keyword.fetch!(opts, :schema),
foreign_key: Keyword.fetch!(opts, :foreign_key),
keys: Keyword.get(opts, :keys)
}
end
@doc """
Marks a many_to_many association. The referenced fixture is synced first,
and the model is passed to `put_assoc` in the changeset.
Use as a list for multiple associations:
%{
tags: [
Sow.many_to_many(MyApp.Fixtures.Tags, :slug, "featured"),
Sow.many_to_many(MyApp.Fixtures.Tags, :slug, "new")
]
}
"""
def many_to_many(fixture_module) do
%Sow.Relation{module: fixture_module, assoc: true}
end
def many_to_many(fixture_module, lookup_key, lookup_value) do
%Sow.Relation{module: fixture_module, lookup: {lookup_key, lookup_value}, assoc: true}
end
@doc """
Auto-detect association type from the Ecto schema.
This is a shorthand that inspects the schema to determine whether the field
is belongs_to, has_many, or many_to_many, and handles it appropriately.
## Examples
# Auto-detect and sync
%{country: Sow.assoc(MyApp.Fixtures.Countries)}
# With lookup
%{country: Sow.assoc(MyApp.Fixtures.Countries, :code, "NO")}
# Works for all association types
%{
organization: Sow.assoc(Organizations, :slug, "org"), # belongs_to
tags: [Sow.assoc(Tags, :slug, "featured")], # many_to_many
variants: Sow.assoc(ProductVariants) # has_many
}
"""
def assoc(fixture_module) do
%Sow.Relation{module: fixture_module, auto: true}
end
def assoc(fixture_module, lookup_key, lookup_value) do
%Sow.Relation{module: fixture_module, lookup: {lookup_key, lookup_value}, auto: true}
end
@doc """
Runtime database lookup to get a field value from an existing record.
Unlike `belongs_to` which syncs a fixture, `lookup` queries the database
directly for an existing record and extracts a field (default: `:id`).
## Examples
# Simple lookup - get country.id where code = "NO"
country_id: Sow.lookup(MyApp.Country, :code, "NO")
# With custom field extraction
country_name: Sow.lookup(MyApp.Country, :code, "NO", field: :name)
# Multiple match criteria
org_id: Sow.lookup(MyApp.Organization, %{country_id: 1, name: "ACME"})
# Chained lookups - resolve country_id first, then find organization
org_id: Sow.lookup(MyApp.Organization, %{
country_id: Sow.lookup(MyApp.Country, :code, "NO"),
name: "ACME"
})
"""
# lookup/2 - map match without opts
def lookup(schema, match) when is_map(match) do
%Sow.Lookup{schema: schema, match: match, field: :id}
end
# lookup/3 - key/value without opts, or map match with opts
def lookup(schema, key, value) when is_atom(key) do
%Sow.Lookup{schema: schema, match: {key, value}, field: :id}
end
def lookup(schema, match, opts) when is_map(match) and is_list(opts) do
%Sow.Lookup{schema: schema, match: match, field: Keyword.get(opts, :field, :id)}
end
# lookup/4 - key/value with opts
def lookup(schema, key, value, opts) when is_atom(key) and is_list(opts) do
%Sow.Lookup{schema: schema, match: {key, value}, field: Keyword.get(opts, :field, :id)}
end
@doc """
Returns the default repo configured in application env.
"""
def default_repo do
Application.get_env(:sow, :repo) ||
raise "No default repo configured. Set config :sow, :repo, MyApp.Repo"
end
@doc """
Sync multiple fixture modules in dependency order.
"""
def sync_all(modules, repo \\ default_repo()) do
Sow.Graph.sync_in_order(modules, repo)
end
end