Skip to content

Commit

Permalink
feat: add OnePiece.Ecto package
Browse files Browse the repository at this point in the history
  • Loading branch information
yordis committed Mar 15, 2023
1 parent 6b4cb0b commit 3872194
Show file tree
Hide file tree
Showing 9 changed files with 440 additions and 0 deletions.
5 changes: 5 additions & 0 deletions apps/one_piece_ecto/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
line_length: 120,
import_deps: [:ecto],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
26 changes: 26 additions & 0 deletions apps/one_piece_ecto/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
21 changes: 21 additions & 0 deletions apps/one_piece_ecto/README.md
Original file line number Diff line number Diff line change
@@ -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 <https://hexdocs.pm/one_piece_ecto>.

153 changes: 153 additions & 0 deletions apps/one_piece_ecto/lib/one_piece/ecto/schema.ex
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions apps/one_piece_ecto/mix.exs
Original file line number Diff line number Diff line change
@@ -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
Empty file.
42 changes: 42 additions & 0 deletions apps/one_piece_ecto/test/one_piece/ecto/schema_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 3872194

Please sign in to comment.