Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: autogenerate schema associations with factories #5

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2f8cd35
feat: ecto association builder
cylkdev Mar 31, 2023
0e7dbbc
cleanup
cylkdev Mar 31, 2023
50cf31b
add insert test
cylkdev Mar 31, 2023
17e190f
only handle eum for expand count tuples
cylkdev Apr 1, 2023
e02da66
change task to cache and remove application
cylkdev Jul 1, 2023
6d3c4be
add some documentation
cylkdev Jul 2, 2023
62d8e12
make credo happy
cylkdev Jul 2, 2023
f52d10f
cleanup
cylkdev Jul 2, 2023
ae19c96
make more readable
cylkdev Jul 2, 2023
1e9c667
Update nimble_options to 1.0 (#9)
ackerdev Apr 5, 2023
ae512a4
unpin elixir_cache version
cylkdev Jul 17, 2023
bc2e350
chore: improve error message
cylkdev Jul 26, 2023
ed05dd3
fix factory cache in umbrella app
cylkdev Aug 7, 2023
f53c3ad
add already_started function
cylkdev Oct 30, 2023
f2ff4da
fix conflicts
cylkdev Oct 30, 2023
4b8b38c
bump nimbleoptions to 0.5
cylkdev Oct 30, 2023
208e3b7
Merge branch 'main' into feat/ecto-association-builder
cylkdev Oct 30, 2023
be4a96f
update docs and improve error handling
cylkdev Nov 2, 2023
7710b12
bump nimble_options and elixir_cache versions
cylkdev Dec 18, 2023
0259ec3
ignore structs
cylkdev Jan 30, 2024
def44f2
support nimble options 0.4 or 1.0
cylkdev Jan 30, 2024
5ad100f
change elixir cache to 0.3
cylkdev Jan 30, 2024
c7bc590
update dep to test
cylkdev Sep 10, 2024
af047af
remove deps constraints
cylkdev Sep 10, 2024
accf827
update deps based on guidelines for library handling
cylkdev Sep 10, 2024
cf219ec
set faker test only
cylkdev Sep 11, 2024
af64fa3
add faker to dev
cylkdev Sep 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
elixir 1.13
erlang 24.2.2
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,69 @@ $ mix factory_ex.gen --repo FactoryEx.Support.Repo FactoryEx.Support.{Accounts.{
```

To read more info run `mix factory_ex.gen`

### Build Relational Associations

FactoryEx makes it possible to create associated records with your factories. This is
similar to creating to creating records with Ecto.Changeset `cast_assoc` or `put_assoc`
with the addition of using your factory to generate the parameters. To use this feature
you must pass the `relational` option. The `relational` option accepts a list of keys
that map to associations, for example if a Team has many users you would pass
`[relational: [:users]]`:

```elixir
FactoryEx.AssociationBuilder.build_params(
FactoryEx.Support.Factory.Accounts.Team,
%{},
relational: [:users]
)
```

The goal of this feature is to reduce boilerplate code in your test. In this example we
create a team with 3 users that each have a label and a role:

```elixir
setup do
team = FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Team)

user_one = FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.User, %{team_id: team.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Label, %{user_id: user_one.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Role, %{user_id: user_one.id})

user_two = FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.User, %{team_id: team.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Label, %{user_id: user_two.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Role, %{user_id: user_two.id})

user_three = FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.User, %{team_id: team.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Label, %{user_id: user_three.id})
FactoryEx.insert!(FactoryEx.Support.Factory.Accounts.Role, %{user_id: user_three.id})
end
```

With the relational feature this can be written as:

```elixir
setup do
team =
FactoryEx.insert!(
FactoryEx.Support.Factory.Accounts.Team,
%{users: {3, %{}}},
relational: [users: [:labels, :role]]
)
end
```

You can create many associations by specifying a tuple of `{count, params}` which is expanded
to a list of params before building the params with a factory. For example if you pass a
tuple of `{2, %{name: "John"}}` it will be expanded to `[%{name: "John"}, %{name: "John"}]`.
The count tuples can be added as elements inside a list or as values in the map of
parameters. You can also manually add parameters which is useful for setting specific values
while creating a specific amount. For example given three items if you wanted to customize
the name for one you can do `[%{}, %{name: "custom"}, %{}]` or `[{2, %{}}, %{name: "custom"}]`.

By default parameters are validated by Ecto.Changeset. If this behavior is not desired you can
set the `validate` option to false which converts params to structs only.

While this can simplify the amount of boilerplate you have to write it comes with a trade off
of creating large complex objects that can hurt readability and/or make accessing specific
data harder.
22 changes: 15 additions & 7 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import Config

config :factory_ex, FactoryExTest.MyRepo,
username: System.get_env("POSTGRES_USER") || "postgres",
password: System.get_env("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"))
if Mix.env() == :test do
config :factory_ex, ecto_repos: [FactoryEx.Support.Repo]
config :factory_ex, repo: 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: 10,
show_sensitive_data_on_connection_error: true,
log: :debug,
stacktrace: true
end
92 changes: 73 additions & 19 deletions lib/factory_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ 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, :any}, :keyword_list]},
doc: "Sets the ecto schema association fields to generate, can be a list of `:atom` or `:keyword_list`"
]
]

Expand All @@ -15,11 +20,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 +77,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 +101,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 @@ -127,8 +136,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)
|> case do
%Ecto.Changeset{} = changeset -> Ecto.Changeset.apply_action!(changeset, :insert)
struct when is_struct(struct) -> struct
Expand All @@ -149,11 +160,13 @@ defmodule FactoryEx do

def insert!(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)
|> module.repo().insert!(options)
end

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

defp maybe_changeset(params, module, validate?) do
if validate? && schema?(module) 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
Loading