Skip to content

Commit

Permalink
feat: ecto association builder
Browse files Browse the repository at this point in the history
  • Loading branch information
cylkdev committed Mar 31, 2023
1 parent 72d618e commit 2f8cd35
Show file tree
Hide file tree
Showing 33 changed files with 1,154 additions and 299 deletions.
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erlang 25.1
elixir 1.13.4-otp-25
10 changes: 6 additions & 4 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Config

config :factory_ex, FactoryExTest.MyRepo,
username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("POSTGRES_PASSWORD") || "postgres",
config :factory_ex, ecto_repos: [FactoryEx.Support.Repo]
config :factory_ex, :sql_sandbox, true
config :factory_ex, FactoryEx.Support.Repo,
username: "postgres",
password: "postgres",
database: "factory_ex_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: String.to_integer(System.get_env("POSTGRES_POOL_SIZE", "10"))
pool_size: 5
94 changes: 76 additions & 18 deletions lib/factory_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ defmodule FactoryEx do
@build_definition [
keys: [
type: {:in, [:atom, :string, :camel_string]},
doc: "Sets the type of keys to have in the built object, can be one of `:atom`, `:string` or `:camel_string`"
doc:
"Sets the type of keys to have in the built object, can be one of `:atom`, `:string` or `:camel_string`"
],
relational: [
type: {:or, [{:list, :atom}, :keyword_list]},
doc: "Sets the ecto schema association fields to generate, can be a list of `:atom` or `:keyword_list`"
],
check_owner_key?: [
type: :boolean,
doc: "Sets the behaviour for handling associated parameters when the owner key is set, can be `true` or `false`. Defaults to `true`."
]
]

Expand All @@ -15,11 +24,11 @@ defmodule FactoryEx do
#{NimbleOptions.docs(@build_definition)}
"""

alias FactoryEx.Utils
alias FactoryEx.{AssociationBuilder, Utils}

@type build_opts :: [
keys: :atom | :string | :camel_string
]
keys: :atom | :string | :camel_string
]

@doc """
Callback that returns the schema module.
Expand Down Expand Up @@ -72,7 +81,9 @@ defmodule FactoryEx do
opts = NimbleOptions.validate!(opts, @build_definition)

params
|> Utils.expand_count_tuples()
|> module.build()
|> then(&AssociationBuilder.build_params(module, &1, opts))
|> Utils.deep_struct_to_map()
|> maybe_encode_keys(opts)
end
Expand All @@ -94,18 +105,20 @@ defmodule FactoryEx do
schema = module.schema()
Code.ensure_loaded(schema)

field = schema.__schema__(:fields)
field =
schema.__schema__(:fields)
|> Kernel.--([:updated_at, :inserted_at, :id])
|> Enum.reject(&(schema.__schema__(:type, &1) === :id))
|> Enum.random
|> Enum.random()

field_type = schema.__schema__(:type, field)

field_value = case field_type do
:integer -> "asdfd"
:string -> 1239
_ -> 4321
end
field_value =
case field_type do
:integer -> "asdfd"
:string -> 1239
_ -> 4321
end

Map.put(params, field, field_value)
end
Expand All @@ -124,11 +137,13 @@ defmodule FactoryEx do

def build(module, params, options) do
Code.ensure_loaded(module.schema())
validate = Keyword.get(options, :validate, true)
validate? = Keyword.get(options, :validate, true)

params
|> Utils.expand_count_tuples()
|> module.build()
|> maybe_changeset(module, validate)
|> then(&AssociationBuilder.build_params(module, &1, options))
|> maybe_create_changeset(module, validate?)
|> case do
%Ecto.Changeset{} = changeset -> Ecto.Changeset.apply_action!(changeset, :insert)
struct when is_struct(struct) -> struct
Expand All @@ -152,8 +167,10 @@ defmodule FactoryEx do
validate? = Keyword.get(options, :validate, true)

params
|> Utils.expand_count_tuples()
|> module.build()
|> maybe_changeset(module, validate?)
|> then(&AssociationBuilder.build_params(module, &1, options))
|> maybe_create_changeset(module, validate?)
|> module.repo().insert!(options)
end

Expand All @@ -176,20 +193,61 @@ defmodule FactoryEx do
module.repo().delete_all(module.schema(), options)
end

defp maybe_changeset(params, module, validate?) do
defp maybe_create_changeset(params, module, validate?) do
if validate? && schema?(module) do
params = Utils.deep_struct_to_map(params)

if create_changeset_defined?(module.schema()) do
module.schema().create_changeset(params)
params
|> module.schema().create_changeset()
|> maybe_put_assocs(params)
else
module.schema().changeset(struct(module.schema(), %{}), params)
module.schema()
|> struct(%{})
|> module.schema().changeset(params)
|> maybe_put_assocs(params)
end
else
struct!(module.schema, params)
deep_struct!(module.schema, params)
end
end

defp maybe_put_assocs(%{data: %module{}} = changeset, params) do
:associations
|> module.__schema__()
|> Enum.reduce(changeset, fn field, changeset ->
case Map.get(params, field) do
nil -> changeset
attrs -> Ecto.Changeset.put_assoc(changeset, field, attrs)
end
end)
end

defp deep_struct!(schema_module, params) when is_list(params) do
Enum.map(params, &deep_struct!(schema_module, &1))
end

defp deep_struct!(schema_module, params) do
Enum.reduce(params, struct!(schema_module, params), &convert_to_struct(&1, schema_module, &2))
end

defp convert_to_struct({field, attrs}, schema_module, acc) do
attrs =
case :associations
|> schema_module.__schema__()
|> Enum.find(&(&1 === field))
|> then(&schema_module.__schema__(:association, &1)) do
nil ->
attrs

ecto_assoc ->
deep_struct!(ecto_assoc.queryable, attrs)

end

Map.put(acc, field, attrs)
end

defp create_changeset_defined?(module) do
function_exported?(module, :create_changeset, 1)
end
Expand Down
35 changes: 35 additions & 0 deletions lib/factory_ex/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule FactoryEx.Application do
@moduledoc false

use Application

@impl true
def start(_, _) do
FactoryEx.SchemaCounter.start()

children = [
FactoryEx.FactoryStore
]

opts = [strategy: :one_for_one, name: FactoryEx.Supervisor]
Supervisor.start_link(children, opts)
end

def apps_that_depend_on(dep) do
:application.loaded_applications()
|> Enum.reduce([], fn {app, _, _}, acc ->
deps = Application.spec(app)[:applications]
(dep in deps && acc ++ [app]) || acc
end)
end

def find_app_modules(app, prefix) do
case :application.get_key(app, :modules) do
{:ok, modules} ->
prefix = Module.split(prefix)
Enum.filter(modules, &(&1 |> Module.split() |> FactoryEx.Utils.sublist?(prefix)))

_ -> raise "modules not found for app #{inspect(app)}."
end
end
end
175 changes: 175 additions & 0 deletions lib/factory_ex/association_builder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
defmodule FactoryEx.AssociationBuilder do
@moduledoc """
This module implements the api for auto generating Ecto Associations with factories.
To use this api you must pass keys (which can be type of `:list` or `:keyword_list`) to the
`relational` option. These keys are used by the `build_params/3` to find the appropriate
Factory for an Ecto Schema and invoke the `build_params/3` callback function. This allows you
to build any relational data structure declaratively.
In the following section we will explain how `build_params/3` works with relational keys:
- For each relational key, we try to fetch the the ecto schema's associations. If the field does
not exist for the given schema an error is raised otherwise the association is used to create
one or many params for the field based on the association's cardinality.
- If the `owner_key` of the association's schema field is not set, the factory's `build/1`
function will be invoked with the field's existing value. If the owner key is set the
field is skipped and the existing value will be kept. Any existing paramaters not passed
as a relational key will be kept. If this behaviour is not desired you can set the
`check_owner_field?` option to `false` and the parameters will be generated when the
owner key is set.
## Examples
# Create params for a one-to-one relationship
FactoryEx.AssociationBuilder.build_params(
FactoryEx.Support.Factory.Accounts.User,
%{pre_existing_params: "hello world!"},
relational: [:role, :team]
)
%{
pre_existing_params: "hello world!",
role: %{code: "Utah cats"},
team: %{name: "Macejkovic Group"}
}
# Create params for a one-to-many relationship
FactoryEx.AssociationBuilder.build_params(
FactoryEx.Support.Factory.Accounts.TeamOrganization,
%{teams: [%{}, %{}]},
relational: [:teams]
)
%{teams: [%{name: "Lindgren-Zemlak"}, %{name: "Kutch Group"}]}
# Create deep relational structure and override specific field values
FactoryEx.AssociationBuilder.build_params(
FactoryEx.Support.Factory.Accounts.TeamOrganization,
%{teams: [%{name: "team name goes here", users: [%{name: "first user name"}, %{}]}]},
relational: [teams: [users: [:labels, :role]]]
)
%{
teams: [
%{
name: "team name goes here",
users: [
%{
birthday: ~D[1992-10-04],
email: "[email protected]",
gender: "male",
labels: [%{label: "expedita"}],
location: "someplace",
name: "first user name",
role: %{code: "Iowa penguins"}
},
%{
birthday: ~D[1992-10-04],
email: "[email protected]",
gender: "male",
labels: [%{label: "exercitationem"}],
location: "someplace",
name: "Name Zulauf Jr.",
role: %{code: "New Hampshire dwarves"}
}
]
}
]
}
"""

@doc """
Builds Ecto Association parameters.
"""
@spec build_params(module(), map(), Keyword.t()) :: map()
def build_params(factory_module, params \\ %{}, options \\ []) do
schema = factory_module.schema()
assoc_fields = Keyword.get(options, :relational, [])
check_owner_key? = Keyword.get(options, :check_owner_key?, true)

convert_fields_to_params(schema, params, assoc_fields, check_owner_key?)
end

defp convert_fields_to_params(schema, params, assoc_fields, check_owner_key?) do
Enum.reduce(assoc_fields, params, &create_schema_params(schema, &1, &2, check_owner_key?))
end

defp create_schema_params(schema, {field, assoc_fields}, params, check_owner_key?) do
schema
|> fetch_assoc!(field)
|> create_one_or_many_params(params, field, assoc_fields, check_owner_key?)
|> case do
nil -> params
assoc_params -> Map.put(params, field, assoc_params)
end
end

defp create_schema_params(schema, field, params, check_owner_key?) do
create_schema_params(schema, {field, []}, params, check_owner_key?)
end

defp create_one_or_many_params(
%{cardinality: :many, queryable: queryable} = assoc,
params,
field,
assoc_fields,
check_owner_key?
) do
if check_owner_key? and owner_key_is_set?(assoc, params) do
Map.get(params, field)
else
params = Map.get(params, field, [%{}])
Enum.map(params, fn params -> factory_build(queryable, params, assoc_fields, check_owner_key?) end)
end
end

defp create_one_or_many_params(
%{cardinality: :one, queryable: queryable} = assoc,
params,
field,
assoc_fields,
check_owner_key?
) do
if check_owner_key? and owner_key_is_set?(assoc, params) do
Map.get(params, field)
else
params = Map.get(params, field, %{})
factory_build(queryable, params, assoc_fields, check_owner_key?)
end
end

defp owner_key_is_set?(assoc, params) do
case Map.get(params, assoc.owner_key) do
nil -> false
_ -> true
end
end

defp factory_build(queryable, params, assoc_fields, check_owner_key?) do
parent = FactoryEx.FactoryStore.build_params(queryable, params)
assoc = convert_fields_to_params(queryable, params, assoc_fields, check_owner_key?)

Map.merge(parent, assoc)
end

defp fetch_assoc!(schema, field) do
assocs = schema.__schema__(:associations)

if Enum.member?(assocs, field) do
schema.__schema__(:association, field)
else
raise """
The field '#{inspect(field)}' you entered was not found on schema '#{inspect(schema)}'.
Did you mean one of the following fields?
#{inspect(assocs)}
To fix this error:
- Ensure the field exists on the schema '#{inspect(schema)}'.
- Return a schema from the `schema/0` callback function that contains the field '#{inspect(field)}'.
"""
end
end
end
Loading

0 comments on commit 2f8cd35

Please sign in to comment.