defmodule Mix.Tasks.FactoryEx.Gen do
@moduledoc """
This can be used to generate factories for usage with ecto `--repo` is required
### Example
```bash
$ mix factory_ex.gen --repo MyApp.Repo MyApp.Accounts.User
$ mix factory_ex.gen --repo MyApp.Repo MyApp.Accounts.User MyApp.Accounts.Role
```
### Options
- `dirname` - Set directory name to generate into `../my_app/test/support/factory/`
- `force` - Force create files, no confirmations
- `quiet` - No output messages
"""
use Mix.Task
alias Mix.FactoryExHelpers
@blacklist_fields [:updated_at, :inserted_at]
@faker_mod_blacklist [
Faker.Name
]
@faker_functions (case :application.get_key(:faker, :modules) do
{:ok, modules} ->
modules
|> Enum.filter(fn module ->
module_level = module
|> inspect
|> String.codepoints
|> Enum.filter(&(&1 === "."))
|> length
module_level === 1
end)
|> Enum.map(fn module ->
functions = module.__info__(:functions)
|> Enum.filter(fn {_name, arity} -> arity === 0 end)
|> Enum.map(fn {name, _arity} -> name end)
{module, functions}
end)
|> Enum.filter(fn {_module, functions} -> length(functions) > 0 end)
e ->
throw "Some weird error happened when trying to find faker functions\n#{inspect e, pretty: true}"
end)
def run(args) do
FactoryExHelpers.ensure_not_in_umbrella!("factory_ex.gen.factory")
{opts, extra_args, _} = OptionParser.parse(args,
switches: [
dirname: :string,
app_name: :string,
force: :boolean,
quiet: :boolean,
repo: :string
]
)
if opts[:app_name] and opts[:dirname] do
raise to_string(IO.ANSI.format([
:red, "Only one of ", :bright, "app_name", :reset,
:red, " or ", :bright, "dirname", :reset,
:red, " should be supplied"
]))
end
if validate_repo?(opts[:repo]) do
ecto_schemas = Enum.map(extra_args, &FactoryExHelpers.string_to_module/1)
ensure_schema_counter_start_added(opts)
Enum.each(ecto_schemas, &generate_factory(&1, opts[:repo], opts))
end
end
defp validate_repo?(repo) do
if repo do
FactoryExHelpers.string_to_module(repo)
true
else
Mix.shell().error(to_string(IO.ANSI.format([
:reset, :red, "Must provide ", :bright, "--repo", :reset,
:red, " when using factory_ex.gen", :reset
])))
false
end
end
def ensure_schema_counter_start_added(opts) do
directory = cond do
opts[:dirname] -> opts[:dirname]
opts[:app_name] -> Path.expand(Path.join(["../", opts[:app_name]]))
true -> "."
end
"#{directory}/**/test_helper.exs"
|> Path.wildcard
|> Enum.each(fn path ->
path = Path.expand(path)
contents = File.read!(path)
if not String.contains?(contents, "FactoryEx.SchemaCounter.start()") do
path = Path.relative_to_cwd(path)
Mix.shell().info([:green, "* injecting FactoryEx.SchemaCounter.start() into ", :reset, path])
File.write!(path, contents <> "\nFactoryEx.SchemaCounter.start()", opts)
end
end)
end
def generate_factory(ecto_schema, repo, opts) do
schema_fields = ecto_schema
|> FactoryExHelpers.schema_fields()
|> Kernel.--(FactoryExHelpers.schema_primary_key(ecto_schema) ++ @blacklist_fields)
|> FactoryExHelpers.with_field_types(ecto_schema)
|> Enum.reject(fn {_, type} -> type === :id end)
Mix.Generator.create_file(
schema_factory_path(ecto_schema, opts),
factory_template(ecto_schema, repo, schema_fields, opts)
)
end
defp schema_factory_path(ecto_schema, opts) do
dirname = cond do
opts[:dirname] -> opts[:dirname]
opts[:app_name] -> Path.expand(Path.join(["../", opts[:app_name], "test/support/factory"]))
true -> Path.expand("./test/support/factory/")
end
[context, schema] = ecto_schema |> inspect |> String.split(".") |> Enum.take(-2)
dirname = Path.join(dirname, Macro.underscore(context))
file_name = "#{Macro.underscore(schema)}.ex"
if not File.dir?(dirname) do
File.mkdir_p!(dirname)
end
dirname
|> Path.join(file_name)
|> Path.relative_to_cwd
end
defp factory_template(ecto_schema, repo, schema_fields, _opts) do
Code.format_string!("""
defmodule #{ecto_schema_factory_module(ecto_schema)} do
@behaviour FactoryEx
@impl FactoryEx
def schema, do: #{inspect(ecto_schema)}
@impl FactoryEx
def repo, do: #{repo}
@impl FactoryEx
def build(args \\\\ %{}) do
Map.merge(%{
#{Enum.map_join(schema_fields, ",\n", &template_schema_field(&1, ecto_schema))}
}, args)
end
end
""")
end
defp ecto_schema_factory_module(ecto_schema) do
[root_module | nested_modules] = ecto_schema |> inspect |> String.split(".")
other_modules = nested_modules
|> Enum.join(".")
|> String.replace(~r/^Support\./, "") # This is just to support tests
"#{root_module}.Support.Factory.#{other_modules}"
end
defp template_schema_field({field, type}, ecto_schema) do
"#{field}: #{build_random_field(type, field, ecto_schema)}"
end
defp build_random_field(:integer, field, ecto_schema) do
schema_name = ecto_schema
|> inspect()
|> String.split(".")
|> Enum.map_join("_", &Macro.underscore/1)
"FactoryEx.SchemaCounter.next(\"#{schema_name}_#{field}\")"
end
defp build_random_field(:string, field, ecto_schema) do
ecto_schema = inspect(ecto_schema)
field_name = "#{FactoryEx.Utils.context_schema_name(ecto_schema)}_#{field}"
case find_faker_function_with_type(field, :string) do
{module, function} ->
"\"\#{#{inspect(module)}.#{function}()\}_\#{FactoryEx.SchemaCounter.next(\"#{field_name}\")\}\""
nil -> "to_string(FactoryEx.SchemaCounter.next(\"#{field_name}\"))"
end
end
defp build_random_field(:naive_datetime_usec, _field, _ecto_schema) do
"10..30 |> Enum.random() |> Faker.DateTime.backward |> DateTime.to_naive"
end
defp build_random_field(:naive_datetime, _field, _ecto_schema) do
"10..30 |> Enum.random() |> Faker.DateTime.backward |> DateTime.truncate(:second) |> DateTime.to_naive"
end
defp build_random_field(:utc_datetime_usec, _field, _ecto_schema) do
"Faker.DateTime.backward(Enum.random(10..30))"
end
defp build_random_field(:utc_datetime, _field, _ecto_schema) do
"Enum.random(10..30) |> Faker.DateTime.backward |> NaiveDateTime.truncate(:second)"
end
defp build_random_field(:date, _field, _ecto_schema) do
"Faker.Date.backward(Enum.random(100..400))"
end
defp build_random_field({:parameterized, Ecto.Enum, %{mappings: mappings}}, _field, _ecto_schema) do
enum_list = mappings |> Keyword.keys |> Enum.map_join(", ", &(":#{&1}"))
"Enum.random([#{enum_list}])"
end
defp find_faker_function_with_type(field, type) do
field
|> matching_faker_functions
|> Enum.find(fn {module, function} ->
if module not in @faker_mod_blacklist do
faker_fn_return_type = module
|> apply(function, [])
|> resolve_type
faker_fn_return_type === type
end
end)
end
defp resolve_type(%NaiveDateTime{}) do
:naive_datetime_usec
end
defp resolve_type(%DateTime{}) do
:datetime_usec
end
defp resolve_type(%Date{}) do
:date
end
defp resolve_type(%Time{}) do
:time
end
defp resolve_type([]) do
:array
end
defp resolve_type(list) when is_list(list) do
{:array, resolve_type(hd(list))}
end
defp resolve_type(int) when is_integer(int) do
:integer
end
defp resolve_type(float) when is_float(float) do
:float
end
defp resolve_type(binary) when is_binary(binary) do
:string
end
defp resolve_type({_, _, _} = _decimal) do
:unsupported
end
def matching_faker_functions(field) do
@faker_functions
|> Enum.flat_map(fn {module, functions} ->
Enum.map(functions, fn function_name ->
module_name = module |> inspect |> String.replace("Faker", "")
score = String.jaro_distance(to_string(function_name), to_string(field)) +
(String.jaro_distance(to_string(module_name), to_string(field)) / 2)
{module, function_name, score}
end)
end)
|> Enum.sort_by(fn {_mod, _fn_name, score} -> score end, :desc)
|> Enum.map(fn {module, fn_name, _distance} -> {module, fn_name} end)
end
end