diff --git a/lib/factory_ex.ex b/lib/factory_ex.ex index a073b40..804c3e2 100644 --- a/lib/factory_ex.ex +++ b/lib/factory_ex.ex @@ -1,26 +1,44 @@ 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`" - ] - ] - @moduledoc """ #{File.read!("./README.md")} ### FactoryEx.build options We can also specify options to `&FactoryEx.build/3` - #{NimbleOptions.docs(@build_definition)} + #{NimbleOptions.docs(FactoryEx.OptionsValidator.schema())} + """ + + @typedoc """ + Build Schema Options """ + @type build_schema_options :: [ + keys: :atom | :string | :camel_string, + only: list() | map(), + except: list() | map() + ] - alias FactoryEx.Utils + @typedoc """ + Association Param Build Options + """ + @type build_assoc_options :: [ + relational: :all | :atom + ] - @type build_opts :: [ - keys: :atom | :string | :camel_string + @typedoc """ + Schema Factory Build Options + """ + @type build_schema_factory_options :: [ + compile_schema_factory?: true | false, + factories: [module(), ...], + paths: [String.t(), ...], + schema_factory: map() ] + @typedoc """ + Build options for the Schema, Association, or Schema Factory + """ + @type build_options() :: build_schema_options() | build_assoc_options() | build_schema_factory_options() + @doc """ Callback that returns the schema module. """ @@ -44,153 +62,63 @@ defmodule FactoryEx do @optional_callbacks [build_struct: 1] @doc """ - Builds many parameters for a schema `changeset/2` function given the factory - `module` and an optional list/map of `params`. - """ - @spec build_many_params(pos_integer, module()) :: [map()] - @spec build_many_params(pos_integer, module(), keyword() | map()) :: [map()] - @spec build_many_params(pos_integer, module(), keyword() | map(), build_opts) :: [map()] - def build_many_params(count, module, params \\ %{}, opts \\ []) do - Enum.map(1..count, fn _ -> build_params(module, params, opts) end) - end - - @doc """ - Builds the parameters for a schema `changeset/2` function given the factory - `module` and an optional list/map of `params`. + Builds paramters and associations for a schema `changeset/2` function + given the factory `module` and an optional list/map of `params`. """ @spec build_params(module()) :: map() @spec build_params(module(), keyword() | map()) :: map() - @spec build_params(module(), keyword() | map(), build_opts) :: map() - def build_params(module, params \\ %{}, opts \\ []) - - def build_params(module, params, opts) when is_list(params) do - build_params(module, Map.new(params), opts) + @spec build_params(module(), keyword() | map(), build_options()) :: map() + def build_params(factory_module, params \\ %{}, options \\ []) + def build_params(factory_module, params, options) when is_map(params) do + options = FactoryEx.OptionsValidator.validate!(options) + + factory_module.schema() + |> FactoryEx.SchemaAssociations.build_params(params, options) + |> then(&FactoryEx.SchemaBuilder.build_params(factory_module, &1, options)) end - def build_params(module, params, opts) do - opts = NimbleOptions.validate!(opts, @build_definition) - - params - |> module.build() - |> Utils.deep_struct_to_map() - |> maybe_encode_keys(opts) - end - - defp maybe_encode_keys(params, []), do: params - - defp maybe_encode_keys(params, opts) do - case opts[:keys] do - nil -> params - :atom -> params - :string -> Utils.stringify_keys(params) - :camel_string -> Utils.camelize_keys(params) - end - end - - @spec build_invalid_params(module()) :: map() - def build_invalid_params(module) do - params = build_params(module) - schema = module.schema() - - field = schema.__schema__(:fields) - |> Kernel.--([:updated_at, :inserted_at, :id]) - |> Enum.reject(&(schema.__schema__(:type, &1) === :id)) - |> Enum.random - - field_type = schema.__schema__(:type, field) - - field_value = case field_type do - :integer -> "asdfd" - :string -> 1239 - _ -> 4321 - end - - Map.put(params, field, field_value) + def build_params(factory_module, params, options) when is_list(params) do + build_params(factory_module, Map.new(params), options) end @doc """ - Builds a schema given the factory `module` and an optional - list/map of `params`. + Builds many parameters and associations for a schema `changeset/2` function + given the factory `module` and an optional list/map of `params`. """ - @spec build(module()) :: Ecto.Schema.t() - @spec build(module(), keyword() | map()) :: Ecto.Schema.t() - def build(module, params \\ %{}, options \\ []) - - def build(module, params, options) when is_list(params) do - build(module, Map.new(params), options) - end - - def build(module, params, options) do - validate = Keyword.get(options, :validate, true) - - params - |> module.build() - |> maybe_changeset(module, validate) - |> case do - %Ecto.Changeset{} = changeset -> Ecto.Changeset.apply_action!(changeset, :insert) - struct when is_struct(struct) -> struct - end + @spec build_many_params(pos_integer(), module()) :: [map(), ...] + @spec build_many_params(pos_integer(), module(), keyword() | map()) :: [map(), ...] + @spec build_many_params(pos_integer(), module(), keyword() | map(), build_options()) :: [map(), ...] + def build_many_params(count, factory_module, params \\ %{}, options \\ []) + when is_integer(count) and count > 0 do + Enum.map(1..count, fn _ -> build_params(factory_module, params, options) end) end @doc """ - Inserts a schema given the factory `module` and an optional list/map of - `params`. Fails on error. + Builds the params for a schema's changeset function is a random field + given an incorrect value for the type. """ - @spec insert!(module()) :: Ecto.Schema.t() | no_return() - @spec insert!(module(), keyword() | map(), Keyword.t()) :: Ecto.Schema.t() | no_return() - def insert!(module, params \\ %{}, options \\ []) - - def insert!(module, params, options) when is_list(params) do - insert!(module, Map.new(params), options) - end - - def insert!(module, params, options) do - validate = Keyword.get(options, :validate, true) - - params - |> module.build() - |> maybe_changeset(module, validate) - |> module.repo().insert!(options) - end + @spec build_invalid_params(module()) :: map() + defdelegate build_invalid_params(factory_module), to: FactoryEx.SchemaBuilder, as: :build_invalid_params @doc """ - Insert as many as `count` schemas given the factory `module` and an optional + Builds a schema given the factory `module` and an optional list/map of `params`. """ - @spec insert_many!(pos_integer(), module()) :: [Ecto.Schema.t()] - @spec insert_many!(pos_integer(), module(), keyword() | map()) :: [Ecto.Schema.t()] - def insert_many!(count, module, params \\ %{}, options \\ []) when count > 0 do - Enum.map(1..count, fn _ -> insert!(module, params, options) end) + @spec build(module()) :: struct() | Ecto.Schema.t() + @spec build(module(), keyword() | map()) :: struct() | Ecto.Schema.t() + @spec build(module(), keyword() | map(), build_options()) :: struct() | Ecto.Schema.t() + def build(factory_module, params \\ %{}, options \\ []) + def build(factory_module, params, options) when is_map(params) do + options = FactoryEx.OptionsValidator.validate!(options) + + factory_module.schema() + |> FactoryEx.SchemaAssociations.build_params(params, options) + |> then(&FactoryEx.SchemaBuilder.build_params(factory_module, &1, options)) end - @doc """ - Removes all the instances of a schema from the database given its factory - `module`. - """ - @spec cleanup(module) :: {integer(), nil | [term()]} - def cleanup(module, options \\ []) do - module.repo().delete_all(module.schema(), options) + def build(factory_module, params, options) when is_list(params) do + build(factory_module, Map.new(params), options) end - defp maybe_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) - else - module.schema().changeset(struct(module.schema(), %{}), params) - end - else - struct!(module.schema, params) - end - end - defp create_changeset_defined?(module) do - function_exported?(module, :create_changeset, 1) - end - - defp schema?(module) do - function_exported?(module.schema(), :__schema__, 1) - end end diff --git a/lib/factory_ex/app_schema_factory.ex b/lib/factory_ex/app_schema_factory.ex new file mode 100644 index 0000000..3ff65ad --- /dev/null +++ b/lib/factory_ex/app_schema_factory.ex @@ -0,0 +1,35 @@ +defmodule FactoryEx.AppSchemaFactory do + defstruct [:app, :prefix, :factories] + + def create(app, prefix) do + app + |> FactoryEx.Utils.Application.find_modules(prefix) + |> Enum.reduce(%{}, &maybe_put_schema_factory/2) + |> then(&struct(__MODULE__, %{app: app, prefix: prefix, factories: &1})) + end + + defp maybe_put_schema_factory(module, acc) when is_atom(module) do + if FactoryEx.Utils.Code.ensure_function_exported?(module, :schema, 0) do + Map.put(acc, module.schema(), module) + else + acc + end + end + + def build_params(%__MODULE__{} = mapper, ecto_schema, params \\ %{}) do + case Map.get(mapper.factories, ecto_schema) do + nil -> + raise FactoryEx.Exceptions.RuntimeError, + message: """ + No Factory module found for `#{ecto_schema}`! Unable to build params. + Expected the `#{inspect(mapper.app)}` app to define the module with the prefix `#{inspect(mapper.prefix)}`. + """ + + factory -> factory.build(params) + end + end + + def build_many_params(count, %__MODULE__{} = mapper, ecto_schema, params \\ %{}) do + Enum.map(1..count, fn _ -> build_params(mapper, ecto_schema, params) end) + end +end diff --git a/lib/factory_ex/exceptions/argument_error.ex b/lib/factory_ex/exceptions/argument_error.ex new file mode 100644 index 0000000..e750e63 --- /dev/null +++ b/lib/factory_ex/exceptions/argument_error.ex @@ -0,0 +1,3 @@ +defmodule FactoryEx.Exceptions.ArgumentError do + defexception [:message] +end diff --git a/lib/factory_ex/exceptions/runtime_error.ex b/lib/factory_ex/exceptions/runtime_error.ex new file mode 100644 index 0000000..2ffb4b6 --- /dev/null +++ b/lib/factory_ex/exceptions/runtime_error.ex @@ -0,0 +1,3 @@ +defmodule FactoryEx.Exceptions.RuntimeError do + defexception [:message] +end diff --git a/lib/factory_ex/options_validator.ex b/lib/factory_ex/options_validator.ex new file mode 100644 index 0000000..846c673 --- /dev/null +++ b/lib/factory_ex/options_validator.ex @@ -0,0 +1,65 @@ +defmodule FactoryEx.OptionsValidator do + @moduledoc """ + FactoryEx Options Validator + + This module defines the NimbleOptions schema for validating options. + """ + + @build_schema_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`" + ], + except: [ + type: {:list, :any}, + doc: "Sets the keys to drop from an enumerable. Can be a list of `:atom` or `:map`" + ], + only: [ + type: {:list, :any}, + doc: "Sets the keys to take from an enumerable. Can be a list of `:atom` or `:map`" + ], + validate: [ + type: :boolean, + doc: "Validate the param keys with `struct/2` or `changeset/2`. Can be `true` or `false`" + ] + ] + + @build_assoc_definition [ + relational: [ + type: :any, + doc: "Sets the association fields that are autogenerated by the association loader, can be `:all` or a list of `:atom` of the key." + ] + ] + + @build_schema_factory_definition [ + app: [ + type: :atom, + doc: "Sets the app to load the factory modules. Can be a `:atom`." + ], + app: [ + type: :atom, + doc: "Sets the prefix of the factory modules to load. Can be a module `:atom`." + ] + ] + + @build_definition @build_schema_definition + ++ @build_assoc_definition + ++ @build_schema_factory_definition + + @doc """ + FactoryEx Build Options Schema + """ + @spec schema :: Keyword.t() + def schema, do: @build_definition + + @doc """ + Validate Build Options. + + Raises on error. + """ + @spec validate! :: list() + def validate!(options \\ []) do + NimbleOptions.validate!(options, @build_definition) + end + +end diff --git a/lib/factory_ex/schema_associations.ex b/lib/factory_ex/schema_associations.ex new file mode 100644 index 0000000..329bbaf --- /dev/null +++ b/lib/factory_ex/schema_associations.ex @@ -0,0 +1,100 @@ +defmodule FactoryEx.SchemaAssociations do + + @app :factory_ex + @prefix Factory + + def build_params(ecto_schema, params \\ %{}, options \\ []) do + app = options[:app] || @app + prefix = options[:prefix] || @prefix + schema_factory_map = FactoryEx.AppSchemaFactory.create(app, prefix) + + relational_fields = options[:relational] || [] + build_relational_ecto_associations(schema_factory_map, ecto_schema, params, relational_fields) + end + + defp build_relational_ecto_associations(schema_factory_map, ecto_schema, params, relational_fields) do + associations = FactoryEx.Utils.Ecto.Schema.Reflection.associations_reflections(ecto_schema) + Enum.reduce(relational_fields, params, &maybe_create_ecto_assoc_field_params(schema_factory_map, &1, ecto_schema, associations, &2)) + end + + defp maybe_create_ecto_assoc_field_params(schema_factory_map, {key, relational_fields}, ecto_schema, associations, acc) do + ecto_assoc = fetch_association!(ecto_schema, associations, key) + + params = Map.get(acc, key, %{}) + case Map.get(acc, ecto_assoc.owner_key) do + nil -> + assoc_params = build_relational_ecto_associations(schema_factory_map, ecto_assoc.queryable, params, relational_fields) + params = maybe_build_many_params( + schema_factory_map, + ecto_assoc.cardinality, + ecto_assoc.queryable, + params + ) + + params = + if is_list(params) do + Enum.map(params, &Map.merge(&1, assoc_params)) + else + Map.merge(params, assoc_params) + end + + Map.put(acc, key, params) + + _ -> acc + end + end + + defp maybe_create_ecto_assoc_field_params(schema_factory_map, key, ecto_schema, associations, acc) do + ecto_assoc = fetch_association!(ecto_schema, associations, key) + case Map.get(acc, ecto_assoc.owner_key) do + nil -> + params = Map.get(acc, key, %{}) + params = maybe_build_many_params( + schema_factory_map, + ecto_assoc.cardinality, + ecto_assoc.queryable, + params + ) + + Map.put(acc, key, params) + + _ -> acc + end + end + + defp maybe_build_many_params(schema_factory_map, cardinality, queryable, params) do + case cardinality do + :many -> + {count, params} = get_num_of_params(params) + FactoryEx.AppSchemaFactory.build_many_params(count, schema_factory_map, queryable, params) + + :one -> + FactoryEx.AppSchemaFactory.build_params(schema_factory_map, queryable, params) + end + end + + defp fetch_association!(ecto_schema, associations, key) do + case Keyword.get(associations, key) do + nil -> + keys = associations |> Keyword.keys() |> inspect() + raise FactoryEx.Exceptions.ArgumentError, + message: """ + `#{key}` is not a valid field for `#{inspect(ecto_schema)}`! + Expected one of the following keys: #{keys}. + """ + + ecto_assoc -> ecto_assoc + end + end + + defp get_num_of_params(params) do + case params do + nil -> {1, %{}} + x..y -> {Enum.random(x..y), %{}} + {x..y, params} -> {Enum.random(x..y), params} + {count, params} -> {count, params} + params -> {1, params} + end + end + +end diff --git a/lib/factory_ex/schema_builder.ex b/lib/factory_ex/schema_builder.ex new file mode 100644 index 0000000..7fd26ff --- /dev/null +++ b/lib/factory_ex/schema_builder.ex @@ -0,0 +1,186 @@ +defmodule FactoryEx.SchemaBuilder do + @moduledoc """ + Schema Parameters Builder + + This module implements the behaviour for creating and transforming a + schema's parameters given the factory module. + """ + + @type build_schema_options :: FactoryEx.build_schema_options() + + @ecto_schema_restricted_keys [:updated_at, :inserted_at, :id] + + @doc """ + Builds the parameters for a schema `changeset/2` function given the factory + `module` and an optional list/map of `params`. + """ + @spec build_params(module()) :: map() + @spec build_params(module(), keyword() | map()) :: map() + @spec build_params(module(), keyword() | map(), build_schema_options()) :: map() + def build_params(module, params \\ %{}, options \\ []) + def build_params(module, params, options) when is_map(params) do + params + |> module.build() + |> FactoryEx.Utils.Struct.deep_struct_to_map() + |> maybe_transform_keys(options) + |> maybe_validate_params(module, options) + end + + def build_params(module, params, options) when is_list(params) do + build_params(module, Map.new(params), options) + end + + defp maybe_validate_params(params, factory_module, options) do + ecto_schema = factory_module.schema() + case options[:validate] do + true -> + ecto_schema + |> create_schema_changeset(params) + |> validate_changeset!() + + _ -> + deep_map_to_schema_struct(ecto_schema, params) + end + end + + defp deep_map_to_schema_struct(ecto_schema, params) do + associations = FactoryEx.Utils.Ecto.Schema.Reflection.associations_reflections(ecto_schema) + params = Enum.reduce(params, %{}, &maybe_convert_map_to_struct(&1, associations, &2)) + struct!(ecto_schema, params) + end + + defp maybe_convert_map_to_struct({field, params}, associations, acc) when is_map(params) or is_list(params) do + params = + case Keyword.get(associations, field) do + nil -> params + ecto_assoc -> + if is_list(params) do + Enum.map(params, &deep_map_to_schema_struct(ecto_assoc.queryable, &1)) + else + deep_map_to_schema_struct(ecto_assoc.queryable, params) + end + end + + Map.put(acc, field, params) + end + + defp maybe_convert_map_to_struct({field, params}, _associations, acc) do + Map.put(acc, field, params) + end + + defp create_schema_changeset(ecto_schema, params) do + if fun_schema_defined?(ecto_schema) do + if fun_create_changeset_defined?(ecto_schema) do + params + |> ecto_schema.create_changeset() + |> changeset_schema_cast_assoc(ecto_schema) + else + ecto_schema + |> struct(%{}) + |> ecto_schema.changeset(params) + |> changeset_schema_cast_assoc(ecto_schema) + end + else + raise FactoryEx.Exceptions.ArgumentError, + message: """ + Invalid Argument Error! + + Expected `#{inspect(ecto_schema)}` to be a Ecto.Schema but `__schema__` key was not found! + Add `use Ecto.Schema` to the module or disable the `validate` option. + """ + end + end + + defp changeset_schema_cast_assoc(%Ecto.Changeset{} = changeset, schema) do + schema + |> FactoryEx.Utils.Ecto.Schema.Reflection.associations() + |> Enum.reduce(changeset, &Ecto.Changeset.cast_assoc(&2, &1)) + end + + defp validate_changeset!(%Ecto.Changeset{} = changeset) do + Ecto.Changeset.apply_action!(changeset, :insert) + end + + defp fun_create_changeset_defined?(module) do + FactoryEx.Utils.Code.ensure_function_exported?(module, :create_changeset, 1) + end + + defp fun_schema_defined?(ecto_schema) do + FactoryEx.Utils.Code.ensure_function_exported?(ecto_schema, :__schema__, 1) + end + + defp maybe_transform_keys(params, options) do + Enum.reduce(options, params, &convert_to_action(&1, &2)) + end + + defp convert_to_action({:keys, val}, params) do + case val do + :atom -> params + :string -> FactoryEx.Utils.Map.stringify_keys(params) + :camel_string -> FactoryEx.Utils.Map.camelize_keys(params) + end + end + + defp convert_to_action({key, val}, params) when key in [:only, :except] do + FactoryEx.Utils.Enum.deep_filter_keys(params, key, val) + end + + defp convert_to_action(_option, params) do + params + end + + @doc """ + Builds many parameters for a schema `changeset/2` function given the factory + `module` and an optional list/map of `params`. + """ + @spec build_many_params(pos_integer(), module()) :: [map(), ...] + @spec build_many_params(pos_integer(), module(), keyword() | map()) :: [map(), ...] + @spec build_many_params(pos_integer(), module(), keyword() | map(), build_schema_options()) :: [map(), ...] + def build_many_params(count, module, params \\ %{}, options \\ []) + def build_many_params(count, module, params, options) when is_map(params) do + Enum.map(1..count, fn _ -> build_params(module, params, options) end) + end + + def build_many_params(count, module, params, options) when is_list(params) do + build_many_params(count, module, Map.new(params), options) + end + + @doc """ + Builds schema params with an incorrect value + """ + @spec build_invalid_params(module()) :: map() + def build_invalid_params(module) do + params = build_params(module) + schema = module.schema() + + rand_field = + FactoryEx.Utils.Ecto.Schema.Reflection.fields(schema) + |> subtract_ecto_schema_restricted_keys() + |> Enum.reject(&field_type_is_id?(schema, &1)) + |> Enum.random() + + rand_field_value = + schema + |> FactoryEx.Utils.Ecto.Schema.Reflection.field_type(rand_field) + |> incorrect_type_value() + + Map.put(params, rand_field, rand_field_value) + end + + defp subtract_ecto_schema_restricted_keys(list) do + list -- @ecto_schema_restricted_keys + end + + defp field_type_is_id?(schema, field) do + FactoryEx.Utils.Ecto.Schema.Reflection.field_type(schema, field) === :id + end + + defp incorrect_type_value(type) do + case type do + :integer -> "asdfd" + :string -> 1239 + _ -> 4321 + end + end + +end diff --git a/lib/factory_ex/utils.ex b/lib/factory_ex/utils.ex deleted file mode 100644 index 10daec2..0000000 --- a/lib/factory_ex/utils.ex +++ /dev/null @@ -1,109 +0,0 @@ -defmodule FactoryEx.Utils do - @moduledoc false - - @struct_fields [:__meta__] - @whitelisted_modules [NaiveDateTime, DateTime, Date, Time] - @camelize_regex ~r/(?:^|[-_])|(?=[A-Z][a-z])/ - - @doc """ - Changes structs into maps all the way down, excluding - things like DateTime. - """ - @spec deep_struct_to_map(any) :: any - def deep_struct_to_map(%module{} = struct) when module in @whitelisted_modules do - struct - end - - def deep_struct_to_map(struct) when is_struct(struct) do - struct - |> Map.from_struct() - |> Map.drop(@struct_fields) - |> deep_struct_to_map() - end - - def deep_struct_to_map(map) when is_map(map) do - Map.new(map, fn {k, v} -> {k, deep_struct_to_map(v)} end) - end - - def deep_struct_to_map(list) when is_list(list) do - Enum.map(list, &deep_struct_to_map/1) - end - - def deep_struct_to_map(elem) do - elem - end - - def underscore_schema(ecto_schema) when is_atom(ecto_schema) do - ecto_schema |> inspect |> underscore_schema - end - - def underscore_schema(ecto_schema) do - ecto_schema |> String.replace(".", "") |> Macro.underscore - end - - def context_schema_name(ecto_schema) do - ecto_schema - |> String.split(".") - |> Enum.take(-2) - |> Enum.map_join("_", &Macro.underscore/1) - end - - @doc """ - Converts all string keys to string - - ### Example - - iex> FactoryEx.Utils.stringify_keys(%{"test" => 5, hello: 4}) - %{"test" => 5, "hello" => 4} - - iex> FactoryEx.Utils.stringify_keys([%{"a" => 5}, %{b: 2}]) - [%{"a" => 5}, %{"b" => 2}] - """ - @spec stringify_keys(Enum.t()) :: Enum.t() - def stringify_keys(map) do - transform_keys(map, fn - key when is_binary(key) -> key - key when is_atom(key) -> Atom.to_string(key) - end) - end - - @spec camelize_keys(Enum.t()) :: Enum.t() - def camelize_keys(map) do - transform_keys(map, fn - key when is_binary(key) -> camelize(key, :lower) - key when is_atom(key) -> camelize(to_string(key), :lower) - end) - end - - defp transform_keys(map, transform_fn) when is_map(map) do - Enum.into(map, %{}, fn {key, value} -> - {transform_fn.(key), transform_keys(value, transform_fn)} - end) - end - - defp transform_keys(list, transform_fn) when is_list(list) do - Enum.map(list, &transform_keys(&1, transform_fn)) - end - - defp transform_keys(item, _transform_fn), do: item - - def camelize(word, option \\ :upper) do - case Regex.split(@camelize_regex, to_string(word)) do - words -> - words - |> Enum.filter(&(&1 != "")) - |> camelize_list(option) - |> Enum.join() - end - end - - defp camelize_list([], _), do: [] - - defp camelize_list([h | tail], :lower) do - [String.downcase(h)] ++ camelize_list(tail, :upper) - end - - defp camelize_list([h | tail], :upper) do - [String.capitalize(h)] ++ camelize_list(tail, :upper) - end -end diff --git a/lib/factory_ex/utils/application.ex b/lib/factory_ex/utils/application.ex new file mode 100644 index 0000000..6a5c311 --- /dev/null +++ b/lib/factory_ex/utils/application.ex @@ -0,0 +1,18 @@ +defmodule FactoryEx.Utils.Application do + + def find_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.List.sublist?(prefix)) + + e -> + raise FactoryEx.Exceptions.RuntimeError, + message: """ + Some weird error happened when trying to find #{app} modules + #{inspect e, pretty: true} + """ + end + end + +end diff --git a/lib/factory_ex/utils/code.ex b/lib/factory_ex/utils/code.ex new file mode 100644 index 0000000..6ece4be --- /dev/null +++ b/lib/factory_ex/utils/code.ex @@ -0,0 +1,26 @@ +defmodule FactoryEx.Utils.Code do + @moduledoc false + + @doc """ + Ensure the module with the public function and arity is defined + + Note: `function_exported/3` does not load the module in case it is not loaded. + If the BEAM is running in `interactive` mode there is a chance this module has not + been loaded yet. `Code.ensure_loaded/1` is used to ensure the module is loaded + first. + + Docs: https://hexdocs.pm/elixir/1.12/Kernel.html#function_exported?/3 + """ + def ensure_function_exported?(module, fun, arity) do + case Code.ensure_loaded(module) do + {:module, module} -> function_exported?(module, fun, arity) + {:error, reason} -> + raise FactoryEx.Exceptions.ArgumentError, + message: """ + Code failed to load module `#{inspect(module)}` with reason: #{inspect(reason)}! + Ensure the module name is correct and it exists. + """ + end + end + +end diff --git a/lib/factory_ex/utils/ecto/schema/reflection.ex b/lib/factory_ex/utils/ecto/schema/reflection.ex new file mode 100644 index 0000000..d60c845 --- /dev/null +++ b/lib/factory_ex/utils/ecto/schema/reflection.ex @@ -0,0 +1,94 @@ +defmodule FactoryEx.Utils.Ecto.Schema.Reflection do + @moduledoc """ + Ecto Schema Reflections + + https://hexdocs.pm/ecto/Ecto.Schema.html#module-reflection + """ + + @doc """ + Returns all field types and associations reflections + """ + def introspect(ecto_schema) do + assoc_reflections = associations_reflections(ecto_schema) + ecto_schema |> all_fields_types() |> Kernel.++(assoc_reflections) + end + + @doc """ + Returns the types of the given virtual and non-virtual fields for the ecto_schema + """ + def all_fields_types(ecto_schema) do + virtual_fields_types = virtual_fields_types(ecto_schema) + ecto_schema |> fields_types() |> Kernel.++(virtual_fields_types) + end + + @doc """ + Returns a list of all association field names + """ + def associations(ecto_schema) do + ecto_schema.__schema__(:associations) + end + + @doc """ + Returns the association reflection of the given assoc + """ + def association_reflection(ecto_schema, assoc) do + ecto_schema.__schema__(:association, assoc) + end + + @doc """ + Returns the association reflections of the given modue + """ + def associations_reflections(ecto_schema) do + ecto_schema + |> associations() + |> Enum.map(&{&1, association_reflection(ecto_schema, &1)}) + end + + @doc """ + Returns a list of all embedded field names + """ + def embeds(ecto_schema) do + ecto_schema.__schema__(:embeds) + end + + @doc """ + Returns a list of all non-virtual field names + """ + def fields(ecto_schema) do + ecto_schema.__schema__(:fields) + end + + @doc """ + Returns a list of all virtual field names + """ + def virtual_fields(ecto_schema) do + ecto_schema.__schema__(:virtual_fields) + end + + def field_type(ecto_schema, field) do + ecto_schema.__schema__(:type, field) + end + + def virtual_field_type(ecto_schema, field) do + ecto_schema.__schema__(:virtual_type, field) + end + + @doc """ + Returns the types of the given non-virtual fields for the ecto_schema + """ + def fields_types(ecto_schema) do + ecto_schema + |> fields + |> Enum.map(&{&1, field_type(ecto_schema, &1)}) + end + + @doc """ + Returns the types of the given virtual fields for the ecto_schema + """ + def virtual_fields_types(ecto_schema) do + ecto_schema + |> virtual_fields + |> Enum.map(&{&1, virtual_field_type(ecto_schema, &1)}) + end + +end diff --git a/lib/factory_ex/utils/enum.ex b/lib/factory_ex/utils/enum.ex new file mode 100644 index 0000000..bb7e989 --- /dev/null +++ b/lib/factory_ex/utils/enum.ex @@ -0,0 +1,73 @@ +defmodule FactoryEx.Utils.Enum do + @moduledoc false + + @doc """ + Deep Filter Keys in a Map or List + + ### Example + iex> FactoryEx.Utils.Enum.deep_filter_keys([name: "jane", house: %{cost: 1_000, address: [[id: 1, value: "address"], [id: 2, value: "another address"]]}], :except, [house: [address: [:value]]]) + [house: %{address: [[id: 1], [id: 2]], cost: 1000}, name: "jane"] + + iex> FactoryEx.Utils.Enum.deep_filter_keys(%{name: "jane", house: %{cost: 1_000, address: %{id: 1, value: "address"}}}, :only, [house: [address: [:id]]]) + %{house: %{address: %{id: 1}}} + """ + def deep_filter_keys(enumerable, method, filters) do + Enum.reduce(filters, enumerable, &convert_to_deep_filter(method, &1, &2)) + end + + defp convert_to_deep_filter(method, keys, enumerable) do + acc = new_type_of(enumerable) + Enum.reduce(enumerable, acc, &transform_keys(&1, method, keys, &2)) + end + + defp transform_keys({key, val}, :only, filter, acc) do + case filter do + ^key -> put(acc, key, val) + + {^key, filter} -> + val = maybe_deep_filter_many_keys(val, :only, filter) + Enum.any?(val) && put(acc, key, val) || acc + + _ -> acc + end + end + + defp transform_keys({key, val}, :except, filter, acc) do + case filter do + ^key -> acc + + {^key, filter} -> + val = maybe_deep_filter_many_keys(val, :except, filter) + Enum.any?(val) && put(acc, key, val) || acc + + _ -> put(acc, key, val) + end + end + + defp transform_keys(_key_val, _method, _filter, acc) do + acc + end + + defp maybe_deep_filter_many_keys([head | _tail] = vals, method, filters) when is_map(head) or is_list(head) do + Enum.map(vals, &deep_filter_keys(&1, method, filters)) + end + + defp maybe_deep_filter_many_keys(val, method, filters) do + deep_filter_keys(val, method, filters) + end + + defp new_type_of(enumerable) do + case enumerable do + %{} -> %{} + _ -> [] + end + end + + defp put(enum, key, val) do + case enum do + %{} -> Map.put(enum, key, val) + _ -> Keyword.put(enum, key, val) + end + end + +end diff --git a/lib/factory_ex/utils/file.ex b/lib/factory_ex/utils/file.ex new file mode 100644 index 0000000..0f9cbfc --- /dev/null +++ b/lib/factory_ex/utils/file.ex @@ -0,0 +1,47 @@ +defmodule FactoryEx.Utils.File do + @moduledoc false + + @defmodule_regex ~r{defmodule \s+ ([^\s]+) }x + + @doc """ + Returns all files in the given paths. + + Supports path globbing patterns. + """ + def list(paths \\ []) do + Enum.reduce(paths, [], & &2 ++ Path.wildcard(&1)) + end + + @doc """ + Returns all modules defined in the files in the given paths + + By default modules need to be existing module atoms. + """ + def defined_module_names_in_files(paths \\ [], to_existing_atom? \\ true) do + paths + |> list() + |> Enum.reduce([], & &2 ++ module_names_from_file(&1)) + |> Enum.map(&maybe_safe_or_unsafe_to_atom(&1, to_existing_atom?)) + end + + def module_names_from_file(file) do + file + |> File.read!() + |> module_names_from_string() + end + + def module_names_from_string(str) do + @defmodule_regex + |> Regex.scan(str, capture: :all_but_first) + |> List.flatten() + end + + defp maybe_safe_or_unsafe_to_atom(module, to_existing_atom?) do + if to_existing_atom? === false do + FactoryEx.Utils.String.binary_to_module_atom(module) + else + FactoryEx.Utils.String.binary_to_existing_module_atom(module) + end + end + +end diff --git a/lib/factory_ex/utils/list.ex b/lib/factory_ex/utils/list.ex new file mode 100644 index 0000000..333e6ce --- /dev/null +++ b/lib/factory_ex/utils/list.ex @@ -0,0 +1,8 @@ +defmodule FactoryEx.Utils.List do + + def sublist?([], _), do: false + def sublist?(l1 = [_|t], l2) do + List.starts_with?(l1, l2) or sublist?(t, l2) + end + +end diff --git a/lib/factory_ex/utils/macro.ex b/lib/factory_ex/utils/macro.ex new file mode 100644 index 0000000..f597b38 --- /dev/null +++ b/lib/factory_ex/utils/macro.ex @@ -0,0 +1,26 @@ +defmodule FactoryEx.Utils.Macro do + @moduledoc false + + @doc """ + Convert an Ecto Schema Module to underscore + + ### Example + iex> FactoryEx.Utils.Macro.underscore_schema(FactoryEx.Support.Accounts.User) + "elixir_factory_ex_support_accounts_user" + """ + def underscore_schema(ecto_schema) when is_atom(ecto_schema) do + ecto_schema |> Atom.to_string() |> underscore_schema + end + + def underscore_schema(ecto_schema) do + ecto_schema |> String.replace(".", "") |> Macro.underscore + end + + def context_schema_name(ecto_schema) do + ecto_schema + |> String.split(".") + |> Enum.take(-2) + |> Enum.map_join("_", &Macro.underscore/1) + end + +end diff --git a/lib/factory_ex/utils/map.ex b/lib/factory_ex/utils/map.ex new file mode 100644 index 0000000..bd3dc2d --- /dev/null +++ b/lib/factory_ex/utils/map.ex @@ -0,0 +1,45 @@ +defmodule FactoryEx.Utils.Map do + @moduledoc false + + alias FactoryEx.Utils + + @doc """ + Converts all string keys to string + + ### Example + + iex> FactoryEx.Utils.stringify_keys(%{"test" => 5, hello: 4}) + %{"test" => 5, "hello" => 4} + + iex> FactoryEx.Utils.stringify_keys([%{"a" => 5}, %{b: 2}]) + [%{"a" => 5}, %{"b" => 2}] + """ + @spec stringify_keys(Enum.t()) :: Enum.t() + def stringify_keys(map) do + transform_keys(map, fn + key when is_binary(key) -> key + key when is_atom(key) -> Atom.to_string(key) + end) + end + + @spec camelize_keys(Enum.t()) :: Enum.t() + def camelize_keys(map) do + transform_keys(map, fn + key when is_binary(key) -> Utils.String.camelize(key, :lower) + key when is_atom(key) -> Utils.String.camelize(to_string(key), :lower) + end) + end + + defp transform_keys(map, transform_fn) when is_map(map) and not is_struct(map) do + Enum.into(map, %{}, fn {key, value} -> + {transform_fn.(key), transform_keys(value, transform_fn)} + end) + end + + defp transform_keys(list, transform_fn) when is_list(list) do + Enum.map(list, &transform_keys(&1, transform_fn)) + end + + defp transform_keys(item, _transform_fn), do: item + +end diff --git a/lib/factory_ex/utils/string.ex b/lib/factory_ex/utils/string.ex new file mode 100644 index 0000000..5359a9e --- /dev/null +++ b/lib/factory_ex/utils/string.ex @@ -0,0 +1,56 @@ +defmodule FactoryEx.Utils.String do + @moduledoc false + + @camelize_regex ~r/(?:^|[-_])|(?=[A-Z][a-z])/ + + @doc """ + Converts a word to camel case + + ### Example + iex> FactoryEx.Utils.String.camelize("hello_world") + "HelloWorld" + """ + def camelize(word, option \\ :upper) do + case Regex.split(@camelize_regex, to_string(word)) do + words -> + words + |> Enum.filter(&(&1 != "")) + |> camelize_list(option) + |> Enum.join() + end + end + + defp camelize_list([], _), do: [] + + defp camelize_list([h | tail], :lower) do + [String.downcase(h)] ++ camelize_list(tail, :upper) + end + + defp camelize_list([h | tail], :upper) do + [String.capitalize(h)] ++ camelize_list(tail, :upper) + end + + @doc """ + Convert a binary to a module atom without checking if the + atom already exists + + ### Example + iex> FactoryEx.Utils.String.binary_to_module_atom("test") + :"Elixir.test" + """ + def binary_to_module_atom(module) do + :erlang.binary_to_atom("Elixir.#{module}") + end + + @doc """ + Convert a binary to an existing module atom + + ### Example + iex> FactoryEx.Utils.String.binary_to_existing_module_atom("FactoryEx.Utils.String") + FactoryEx.Utils.String + """ + def binary_to_existing_module_atom(module) do + :erlang.binary_to_existing_atom("Elixir.#{module}") + end + +end diff --git a/lib/factory_ex/utils/struct.ex b/lib/factory_ex/utils/struct.ex new file mode 100644 index 0000000..7aac09b --- /dev/null +++ b/lib/factory_ex/utils/struct.ex @@ -0,0 +1,35 @@ +defmodule FactoryEx.Utils.Struct do + @moduledoc false + + @struct_fields [:__meta__] + @whitelisted_modules [NaiveDateTime, DateTime, Date, Time] + + @doc """ + Changes structs into maps all the way down, excluding + things like DateTime. + """ + @spec deep_struct_to_map(any) :: any + def deep_struct_to_map(%module{} = struct) when module in @whitelisted_modules do + struct + end + + def deep_struct_to_map(struct) when is_struct(struct) do + struct + |> Map.from_struct() + |> Map.drop(@struct_fields) + |> deep_struct_to_map() + end + + def deep_struct_to_map(map) when is_map(map) do + Map.new(map, fn {k, v} -> {k, deep_struct_to_map(v)} end) + end + + def deep_struct_to_map(list) when is_list(list) do + Enum.map(list, &deep_struct_to_map/1) + end + + def deep_struct_to_map(elem) do + elem + end + +end diff --git a/mix.exs b/mix.exs index a3dc63f..1740bd2 100644 --- a/mix.exs +++ b/mix.exs @@ -8,6 +8,7 @@ defmodule FactoryEx.MixProject do elixir: "~> 1.13", description: "Factories for elixir to help create data models at random, this works for any type of ecto structs", elixirc_paths: elixirc_paths(Mix.env()), + elixirc_options: [warnings_as_errors: true], start_permanent: Mix.env() == :prod, deps: deps(), docs: docs(),