From 3872194c68e9a72fb43a6fc825f9033dba3ff1f0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Lazo Date: Mon, 6 Mar 2023 18:18:14 -0500 Subject: [PATCH] feat: add OnePiece.Ecto package --- apps/one_piece_ecto/.formatter.exs | 5 + apps/one_piece_ecto/.gitignore | 26 +++ apps/one_piece_ecto/README.md | 21 +++ .../lib/one_piece/ecto/schema.ex | 153 ++++++++++++++++++ apps/one_piece_ecto/mix.exs | 110 +++++++++++++ apps/one_piece_ecto/priv/plts/.gitkeep | 0 .../test/one_piece/ecto/schema_test.exs | 42 +++++ apps/one_piece_ecto/test/support/messages.ex | 82 ++++++++++ apps/one_piece_ecto/test/test_helper.exs | 1 + 9 files changed, 440 insertions(+) create mode 100644 apps/one_piece_ecto/.formatter.exs create mode 100644 apps/one_piece_ecto/.gitignore create mode 100644 apps/one_piece_ecto/README.md create mode 100644 apps/one_piece_ecto/lib/one_piece/ecto/schema.ex create mode 100644 apps/one_piece_ecto/mix.exs create mode 100644 apps/one_piece_ecto/priv/plts/.gitkeep create mode 100644 apps/one_piece_ecto/test/one_piece/ecto/schema_test.exs create mode 100644 apps/one_piece_ecto/test/support/messages.ex create mode 100644 apps/one_piece_ecto/test/test_helper.exs diff --git a/apps/one_piece_ecto/.formatter.exs b/apps/one_piece_ecto/.formatter.exs new file mode 100644 index 0000000..86d2bdb --- /dev/null +++ b/apps/one_piece_ecto/.formatter.exs @@ -0,0 +1,5 @@ +[ + line_length: 120, + import_deps: [:ecto], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/one_piece_ecto/.gitignore b/apps/one_piece_ecto/.gitignore new file mode 100644 index 0000000..4459deb --- /dev/null +++ b/apps/one_piece_ecto/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +one_piece_ecto-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/apps/one_piece_ecto/README.md b/apps/one_piece_ecto/README.md new file mode 100644 index 0000000..77a801a --- /dev/null +++ b/apps/one_piece_ecto/README.md @@ -0,0 +1,21 @@ +# OnePieceEcto + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `one_piece_ecto` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:one_piece_ecto, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/apps/one_piece_ecto/lib/one_piece/ecto/schema.ex b/apps/one_piece_ecto/lib/one_piece/ecto/schema.ex new file mode 100644 index 0000000..753d3c1 --- /dev/null +++ b/apps/one_piece_ecto/lib/one_piece/ecto/schema.ex @@ -0,0 +1,153 @@ +defmodule OnePiece.Ecto.Schema do + @moduledoc """ + Extends a `Ecto.Schema` module with functionality. + """ + + alias Ecto.Changeset + + @doc """ + Extends a `Ecto.Schema` module with some functionality. + + defmodule MySchema do + use Ecto.Schema + use OnePiece.Ecto.Schema + + embedded_schema do + field :title, :string + # ... + end + end + + The following functions are available in the module now: + + `new/1`: **overridable** struct factory function. It takes an attribute map + and runs the `changeset/2`. + + `new!/1`: **overridable** like `new/1` raising an error when the validation + fails. + + `changeset/2`: **overridable** function. It takes a struct and the attributes + and returns a `Ecto.Changeset`. + The default implementation apply a deeply-nested casting over all the fields + using `Ecto.Changeset.cast/4` and `Ecto.Changeset.cast_embed/4`. + When `@enforce_keys` is defined, it will apply `Ecto.Changeset.validate_required/3` + to the list of fields. + When overriding the function, allows you have full control over the validation + layer, deactivating all the nested-casting. + """ + @spec __using__(opts :: []) :: any() + defmacro __using__(_opts \\ []) do + quote do + @before_compile OnePiece.Ecto.Schema + + @doc """ + Creates a `t:t/0`. + """ + @spec new(attrs :: map()) :: {:ok, %__MODULE__{}} + def new(attrs) do + OnePiece.Ecto.Schema.__new__(__MODULE__, attrs) + end + + @doc """ + Creates a `t:t/0`. + """ + @spec new!(attrs :: map()) :: %__MODULE__{} + def new!(attrs) do + OnePiece.Ecto.Schema.__new__!(__MODULE__, attrs) + end + + @doc """ + Returns an `t:Ecto.Changeset.t/0` for a given `t:t/0` model. + """ + @spec changeset(model :: %__MODULE__{}, attrs :: map()) :: Ecto.Changeset.t() + def changeset(model, attrs) do + OnePiece.Ecto.Schema.__changeset__(model, attrs) + end + + defoverridable new: 1, new!: 1, changeset: 2 + end + end + + defmacro __before_compile__(env) do + enforced_keys = get_enforced_keys(env) + + quote unquote: false, bind_quoted: [enforced_keys: enforced_keys] do + def __enforced_keys__ do + unquote(enforced_keys) + end + + for the_key <- enforced_keys do + def __enforced_keys__?(unquote(the_key)) do + true + end + end + + def __enforced_keys__?(_) do + false + end + end + end + + defp get_enforced_keys(env) do + enforce_keys = Module.get_attribute(env.module, :enforce_keys) || [] + enforce_keys ++ get_primary_key_name(env) + end + + defp get_primary_key_name(env) do + case Module.get_attribute(env.module, :primary_key) do + {field_name, _, _} -> [field_name] + _ -> [] + end + end + + def __new__(struct_module, attrs) do + struct_module + |> apply_changeset(attrs) + |> Changeset.apply_action(:new) + end + + def __new__!(struct_module, attrs) do + struct_module + |> apply_changeset(attrs) + |> Changeset.apply_action!(:new!) + end + + def __changeset__(%struct_module{} = model, attrs) do + embeds = struct_module.__schema__(:embeds) + fields = struct_module.__schema__(:fields) + + changeset = + message + |> Changeset.cast(attrs, fields -- embeds) + |> Changeset.validate_required(struct_module.__enforced_keys__() -- embeds) + + Enum.reduce( + embeds, + changeset, + &cast_embed(&1, &2, struct_module, attrs) + ) + end + + defp cast_embed(field, changeset, struct_module, attrs) do + case is_struct(attrs[field]) do + false -> + Changeset.cast_embed(changeset, field, required: struct_module.__enforced_keys__?(field)) + + true -> + # credo:disable-for-next-line Credo.Check.Design.TagTODO + # TODO: Validate that the struct is of the correct type. + # It may be the case that you passed a completely different struct as the value. We could `cast_embed` + # always and fix the `Changeset.cast(attrs, fields -- embeds)` by converting the `attrs` into a map. But it + # would be a bit more expensive since it will run the casting for a field that was already casted. + # Checking the struct types MAY be enough but taking into consideration `embeds_many` could complicated + # things. For now, we'll just assume that the user knows what they're doing. + Changeset.put_change(changeset, field, attrs[field]) + end + end + + defp apply_changeset(struct_module, attrs) do + struct_module + |> struct() + |> struct_module.changeset(attrs) + end +end diff --git a/apps/one_piece_ecto/mix.exs b/apps/one_piece_ecto/mix.exs new file mode 100644 index 0000000..a7278d2 --- /dev/null +++ b/apps/one_piece_ecto/mix.exs @@ -0,0 +1,110 @@ +defmodule OnePiece.Ecto.MixProject do + use Mix.Project + + @app :one_piece_ecto + @version "0.1.0" + @elixir_version "~> 1.13" + @source_url "https://github.com/straw-hat-team/beam-monorepo" + + def project do + [ + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + name: "OnePiece.Ecto", + description: "Extend Ecto package", + app: @app, + version: @version, + elixir: @elixir_version, + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases(), + test_coverage: test_coverage(), + preferred_cli_env: preferred_cli_env(), + package: package(), + docs: docs(), + dialyzer: dialyzer() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:ecto, "~> 3.6"}, + + # Tools + {:dialyxir, ">= 0.0.0", only: [:dev], runtime: false}, + {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, + {:excoveralls, ">= 0.0.0", only: [:test], runtime: false}, + {:ex_doc, ">= 0.0.0", only: [:dev], runtime: false} + ] + end + + defp aliases do + [ + test: ["test --trace"] + ] + end + + defp test_coverage do + [tool: ExCoveralls] + end + + defp preferred_cli_env do + [ + "coveralls.html": :test, + "coveralls.json": :test, + coveralls: :test + ] + end + + defp dialyzer do + [ + plt_core_path: "priv/plts", + ignore_warnings: ".dialyzer_ignore.exs" + ] + end + + defp package do + [ + name: @app, + files: [ + ".formatter.exs", + "lib", + "mix.exs", + "README*", + "LICENSE*" + ], + maintainers: ["Yordis Prieto"], + licenses: ["MIT"], + links: %{ + "GitHub" => @source_url + } + ] + end + + defp docs do + [ + main: "readme", + homepage_url: @source_url, + source_url_pattern: "#{@source_url}/blob/#{@app}@v#{@version}/apps/#{@app}/%{path}#L%{line}", + skip_undefined_reference_warnings_on: ["CHANGELOG.md"], + extras: [ + "README.md", + "CHANGELOG.md" + ], + groups_for_extras: [ + "How-to": ~r/docs\/how-to\/.?/, + Explanations: ~r/docs\/explanations\/.?/, + References: ~r/docs\/references\/.?/ + ] + ] + end +end diff --git a/apps/one_piece_ecto/priv/plts/.gitkeep b/apps/one_piece_ecto/priv/plts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/one_piece_ecto/test/one_piece/ecto/schema_test.exs b/apps/one_piece_ecto/test/one_piece/ecto/schema_test.exs new file mode 100644 index 0000000..a3d54a7 --- /dev/null +++ b/apps/one_piece_ecto/test/one_piece/ecto/schema_test.exs @@ -0,0 +1,42 @@ +defmodule OnePiece.Ecto.SchemaTest do + use ExUnit.Case, async: true + + describe "new/1" do + test "creates a struct" do + assert {:ok, %TestSupport.MessageOne{title: nil}} = TestSupport.MessageOne.new(%{}) + end + + test "validates a key enforce" do + {:error, changeset} = TestSupport.MessageTwo.new(%{}) + assert %{title: ["can't be blank"]} = TestSupport.errors_on(changeset) + end + + test "validates a key enforce for embed fields" do + {:error, changeset} = TestSupport.MessageThree.new(%{}) + assert %{target: ["can't be blank"]} = TestSupport.errors_on(changeset) + end + + test "validates casting embed fields" do + assert {:ok, %TestSupport.MessageThree{target: %TestSupport.MessageOne{title: "Hello, World!"}}} = + TestSupport.MessageThree.new(%{target: %{title: "Hello, World!"}}) + end + + test "bypass casting structs" do + assert {:ok, %TestSupport.MessageThree{target: %TestSupport.MessageOne{title: "Hello, World!"}}} = + TestSupport.MessageThree.new(%{target: %TestSupport.MessageOne{title: "Hello, World!"}}) + end + + test "validates casting embed fields with a wrong value" do + {:error, changeset} = TestSupport.MessageThree.new(%{target: "a wrong value"}) + assert %{target: ["is invalid"]} = TestSupport.errors_on(changeset) + end + end + + describe "new!/1" do + test "raises an error when a validation fails" do + assert_raise Ecto.InvalidChangesetError, fn -> + TestSupport.MessageTwo.new!(%{}) + end + end + end +end diff --git a/apps/one_piece_ecto/test/support/messages.ex b/apps/one_piece_ecto/test/support/messages.ex new file mode 100644 index 0000000..f672171 --- /dev/null +++ b/apps/one_piece_ecto/test/support/messages.ex @@ -0,0 +1,82 @@ +defmodule TestSupport do + @moduledoc false + + defmodule MessageOne do + @moduledoc false + + use Ecto.Schema + use OnePiece.Commanded.ValueObject + + embedded_schema do + field :title, :string + end + end + + defmodule MessageTwo do + @moduledoc false + + use Ecto.Schema + use OnePiece.Commanded.ValueObject + + @enforce_keys [:title] + embedded_schema do + field :title, :string + end + end + + defmodule MessageThree do + @moduledoc false + + use Ecto.Schema + use OnePiece.Commanded.ValueObject + + @enforce_keys [:target] + embedded_schema do + embeds_one(:target, MessageOne) + end + end + + defmodule MyEntityOne do + @moduledoc false + + use OnePiece.Commanded.Entity, identifier: :uuid + + @enforce_keys [:name] + embedded_schema do + field :name, :string + end + end + + defmodule MyCommandOne do + @moduledoc false + + use Ecto.Schema, aggregate_identifier: :uuid + + embedded_schema do + end + end + + defmodule MyEventOne do + @moduledoc false + + use Ecto.Schema + + @primary_key {:uuid, :string, autogenerate: false} + + embedded_schema do + field :name, :string + end + end + + defmodule MyEventTwo do + @moduledoc false + + use Ecto.Schema, aggregate_identifier: :uuid + + @primary_key {:uuid, :string, autogenerate: false} + + embedded_schema do + field :name, :string + end + end +end diff --git a/apps/one_piece_ecto/test/test_helper.exs b/apps/one_piece_ecto/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/one_piece_ecto/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()