Skip to content

Commit

Permalink
Add unit tests for code coverage and fix bugs
Browse files Browse the repository at this point in the history
  • Loading branch information
vereis committed Mar 6, 2024
1 parent 627be93 commit 2544a9e
Show file tree
Hide file tree
Showing 14 changed files with 875 additions and 20 deletions.
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ config :ecto_model, EctoModel.Repo,
database: System.fetch_env!("POSTGRES_DB"),
username: System.fetch_env!("POSTGRES_USER"),
password: System.fetch_env!("POSTGRES_PASSWORD"),
pool: Ecto.Adapters.SQL.Sandbox,
hostname: "localhost"
15 changes: 15 additions & 0 deletions lib/ecto_model/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,20 @@ if Mix.env() == :test do
use Ecto.Repo,
otp_app: :ecto_model,
adapter: Ecto.Adapters.Postgres

use EctoMiddleware

@dialyzer {:nowarn_function, middleware: 2}
def middleware(_resource, _action) do
[EctoModel.SoftDelete, EctoMiddleware.Super]
end

def soft_delete!(resource, opts \\ []) do
EctoModel.SoftDelete.soft_delete!(resource, Keyword.put(opts, :repo, __MODULE__))
end

def soft_delete(resource, opts \\ []) do
EctoModel.SoftDelete.soft_delete(resource, Keyword.put(opts, :repo, __MODULE__))
end
end
end
100 changes: 89 additions & 11 deletions lib/ecto_model/soft_delete.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ defmodule EctoModel.SoftDelete do
In future, we may add support for automatically delegating hard delete operations to transparently behave transparently as soft deletes in an
opt in basis.
You can also optionally add the following code to your `MyApp.Repo` module to enable easy soft delete operations:
```elixir
def soft_delete!(resource, opts \\ []) do
EctoModel.SoftDelete.soft_delete!(resource, Keyword.put(opts, :repo, __MODULE__))
end
def soft_delete(resource, opts \\ []) do
EctoModel.SoftDelete.soft_delete(resource, Keyword.put(opts, :repo, __MODULE__))
end
```
2) You need to `use EctoModel.SoftDelete` in your schema, and configure the `field` and `type` options.
The specified field and type must match what is defined on said schema, though there are compile time validations provided for you to ensure
Expand Down Expand Up @@ -81,6 +93,8 @@ defmodule EctoModel.SoftDelete do
type = __MODULE__.soft_delete_type!(opts[:type])

quote location: :keep do
@after_compile unquote(__MODULE__)

def soft_delete_config,
do: %unquote(__MODULE__).Config{field: unquote(field), type: unquote(type)}

Expand Down Expand Up @@ -120,24 +134,24 @@ defmodule EctoModel.SoftDelete do
end

defp field_not_configured(schema, %Config{} = config) when is_atom(schema) do
raise """
raise ArgumentError, """
The `#{inspect(schema)}` schema is configured to implement soft deletes via the
`:#{inspect(config.field)}` field, but this field does not exist on said schema.
`#{inspect(config.field)}` field, but this field does not exist on said schema.
Please ensure that the `:#{inspect(config.field)}` field is defined on the schema,
with the type `:#{inspect(config.type)}`, or change the configuration to point
Please ensure that the `#{inspect(config.field)}` field is defined on the schema,
with the type `#{inspect(config.type)}`, or change the configuration to point
to a different field via the `field: field_name :: atom()` when `use`-ing
`inspect(#{__MODULE__})`
"""
end

defp field_type_mismatch(schema, %Config{} = config) when is_atom(schema) do
raise """
raise ArgumentError, """
The `#{inspect(schema)}` schema is configured to implement soft deletes via the
`:#{inspect(config.field)}` field of type `:#{inspect(config.type)}`, but this field
is defined on the schema with a different type.
`#{inspect(config.field)}` field of type `#{inspect(config.type)}`,
but this field has the wrong type.
Please ensure that the `:#{inspect(config.field)}` field is defined on the schema,
Please ensure that the `#{inspect(config.field)}` field is defined on the schema,
with the type `:#{inspect(config.type)}`, or change the configuration to point
to a different field via the `type: type_name :: atom()` when `use`-ing
`inspect(#{__MODULE__})`
Expand Down Expand Up @@ -212,11 +226,15 @@ defmodule EctoModel.SoftDelete do
def apply_filter!(schema, query) when is_atom(schema) do
import Ecto.Query

# This clause is only ever going to be reached if someone does something naughty!
# coveralls-ignore-start
unless function_exported?(schema, :soft_delete_config, 0) do
raise ArgumentError,
message: "The `#{inspect(schema)}` schema is not configured to implement soft deletes."
end

# coveralls-ignore-stop

case schema.soft_delete_config() do
%Config{type: :boolean} = config ->
from(x in query,
Expand All @@ -234,24 +252,33 @@ defmodule EctoModel.SoftDelete do
resource
end

# TODO: this fallback clause will never be reached until `EctoMiddleware` supports `delete_all/2`
# coveralls-ignore-start
def middleware(%Ecto.Query{} = queryable, resolution) do
schema =
case queryable.from.source do
{_table, schema} when is_atom(schema) -> schema
_otherwise -> nil
{_table, schema} when is_atom(schema) ->
schema

_otherwise ->
nil
end

:ok = maybe_validate_repo_action!(schema, resolution.action)

queryable
end

# coveralls-ignore-stop

def middleware(%schema{} = resource, resolution)
when resolution.action in [:delete, :delete!, :delete_all] do
:ok = maybe_validate_repo_action!(schema, resolution.action)
resource
end

# TODO: this clause will never be reached until `EctoMiddleware` supports `delete_all/2`
# coveralls-ignore-start
def middleware(schema, resolution) when is_atom(schema) and resolution.action == :delete_all do
:ok = maybe_validate_repo_action!(schema, resolution.action)
schema
Expand All @@ -261,9 +288,11 @@ defmodule EctoModel.SoftDelete do
resource
end

# coveralls-ignore-stop

defp maybe_validate_repo_action!(schema, action)
when is_atom(schema) and action in [:delete, :delete!, :delete_all] do
if function_exported?(schema, :soft_delete?, 0) && schema.soft_delete?() do
if function_exported?(schema, :soft_delete_config, 0) && schema.soft_delete_config() do
raise ArgumentError,
message: """
You are trying to delete a schema that uses soft deletes. Please use `Repo.soft_delete/2` instead.
Expand All @@ -272,4 +301,53 @@ defmodule EctoModel.SoftDelete do

:ok
end

@doc " See `Ecto.Repo.soft_delete/2` for more information."
@spec soft_delete!(resource :: struct(), opts :: Keyword.t()) :: struct() | no_return()
def soft_delete!(resource, opts \\ []) do
{:ok, resource} = soft_delete(resource, opts)
resource
end

@doc """
Will soft delete a given resource, and persist the changes to the database, based on that resource's configured
soft delete field and type.
Will raise if given an entity that does not opt into soft deletes.
# TODO: we will need to implement something more fully fledged to support `delete_all/2` and the like
"""
@spec soft_delete!(resource :: struct(), opts :: Keyword.t()) ::
{:ok, struct()} | {:error, term()}
def soft_delete(%schema{} = resource, opts \\ []) do
# coveralls-ignore-start
unless opts[:repo] do
raise ArgumentError,
message: "You must provide a `:repo` option when delegating to, or using `soft_delete/2`"
end

# coveralls-ignore-stop

unless function_exported?(schema, :soft_delete_config, 0) do
raise ArgumentError,
message:
"The `#{inspect(schema)}` schema is not configured to implement soft deletes, please use `Repo.delete/2` instead."
end

case schema.soft_delete_config() do
%Config{type: :boolean} = config ->
resource
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(config.field, true)
|> opts[:repo].update(opts)

%Config{type: type} = config when type in [:utc_datetime, :datetime] ->
now = DateTime.truncate(DateTime.utc_now(), :second)

resource
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(config.field, now)
|> opts[:repo].update(opts)
end
end
end
27 changes: 27 additions & 0 deletions lib/ecto_model/test/dog.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# coveralls-ignore-start
if Mix.env() == :test do
defmodule EctoModel.Dog do
use Ecto.Schema
use EctoModel.Queryable
use EctoModel.SoftDelete, field: :deleted_at, type: :utc_datetime
import Ecto.Changeset

schema "dogs" do
field(:breed, :string)
field(:name, :string)
field(:date_of_birth, :date)
field(:notes, :string)
field(:deleted_at, :utc_datetime)

belongs_to(:owner, EctoModel.Owner)

timestamps()
end

def changeset(owner, attrs) do
owner
|> cast(attrs, [:breed, :name, :date_of_birth, :notes, :owner_id, :deleted_at])
|> validate_required([:breed, :name, :date_of_birth, :owner_id])
end
end
end
21 changes: 21 additions & 0 deletions lib/ecto_model/test/owner.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# coveralls-ignore-start
if Mix.env() == :test do
defmodule EctoModel.Owner do
use Ecto.Schema
use EctoModel.Queryable
import Ecto.Changeset

schema "owners" do
field(:name, :string)
field(:email, :string)
field(:phone, :string)
has_many(:dogs, EctoModel.Dog)
end

def changeset(owner, attrs) do
owner
|> cast(attrs, [:name, :email, :phone])
|> validate_required([:name, :email, :phone])
end
end
end
25 changes: 25 additions & 0 deletions lib/ecto_model/test/vaccination.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# coveralls-ignore-start
if Mix.env() == :test do
defmodule EctoModel.Vaccination do
use Ecto.Schema
use EctoModel.Queryable
use EctoModel.SoftDelete, field: :deleted, type: :boolean

import Ecto.Changeset

schema "vaccinations" do
field(:name, :string)
field(:date, :date)
field(:deleted, :boolean)
belongs_to(:dog, EctoModel.Dog)

timestamps()
end

def changeset(vaccination, attrs) do
vaccination
|> cast(attrs, [:name, :date, :dog_id, :deleted])
|> validate_required([:name, :date, :dog_id])
end
end
end
7 changes: 6 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ defmodule EctoModel.MixProject do
homepage_url: "https://github.com/vetspire/ecto_model",
docs: [
main: "EctoModel"
]
],
elixirc_paths: elixirc_paths(Mix.env())
]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

def application do
[
mod: {EctoModel.Application, []},
Expand Down Expand Up @@ -67,6 +71,7 @@ defmodule EctoModel.MixProject do
# are nice to have for tests.
{:postgrex, "~> 0.15", only: :test},
{:ecto_sql, "~> 3.6", only: :test},
{:ex_machina, "~> 2.7.0", only: :test},

# Runtime dependencies for tests / linting
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"},
"ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"},
"excoveralls": {:hex, :excoveralls, "0.15.0", "ac941bf85f9f201a9626cc42b2232b251ad8738da993cf406a4290cacf562ea4", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9631912006b27eca30a2f3c93562bc7ae15980afb014ceb8147dc5cdd8f376f1"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
Expand Down
21 changes: 20 additions & 1 deletion priv/repo/migrations/20221221123643_bootstrap_testing_env.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,28 @@ defmodule EctoModel.Repo.Migrations.BootstrapTables do
use Ecto.Migration

def change do
create table(:dog) do
create table(:owners) do
add(:name, :string, null: false)
add(:email, :string, null: false)
add(:phone, :string, null: false)
end

create table(:dogs) do
add(:breed, :string, null: false)
add(:name, :map, null: false)
add(:date_of_birth, :date, null: false)
add(:notes, :text)
add(:owner_id, references(:owners, on_delete: :delete_all), null: false)
add(:deleted_at, :utc_datetime)

timestamps()
end

create table(:vaccinations) do
add(:name, :string, null: false)
add(:date, :date, null: false)
add(:dog_id, references(:dogs, on_delete: :delete_all), null: false)
add(:deleted, :boolean)

timestamps()
end
Expand Down
Loading

0 comments on commit 2544a9e

Please sign in to comment.