Skip to content

Commit

Permalink
Implement EctoModel.SoftDelete
Browse files Browse the repository at this point in the history
  • Loading branch information
vereis committed Mar 5, 2024
1 parent 7431052 commit 627be93
Show file tree
Hide file tree
Showing 3 changed files with 280 additions and 1 deletion.
275 changes: 275 additions & 0 deletions lib/ecto_model/soft_delete.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
defmodule EctoModel.SoftDelete do
@moduledoc """
Module responsible for allowing your schemas to opt into soft delete functionality.
## Usage
There are two things that need to happen in order to make a schema soft deletable:
1) You need to ensure your `MyApp.Repo` module is using the `EctoMiddleware` behaviour, and you add the `EctoModel.SoftDelete` middleware to
the `middleware/2` callback before the `EctoMiddleware.Super` middleware.
This will enable `EctoModel.SoftDelete` to raise errors if users try to hard delete records when schemas have opted into soft deletes.
In future, we may add support for automatically delegating hard delete operations to transparently behave transparently as soft deletes in an
opt in basis.
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
this remains in sync with your schema's natural evolution.
Additionally, if your schema also opts into implementing the `EctoModel.Queryable` behaviour, we automatically provide a `base_query/0`
implementation to will apply the neccessary filters to automatically filter out soft deleted records from query results.
If you need to specify a custom `base_query/0` implementation, you can do so while still inheriting the default behaviour provided when
using this module by calling `super()` in your custom implementation like so:
```elixir
@impl EctoModel.Queryable
def base_query do
from x in ^super(), where: x.show_by_default != false
end
```
A full example of how to use `EctoModel.SoftDelete` is as follows:
```elixir
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
use EctoMiddleware
def middleware(_resource, _resolution) do
[EctoModel.SoftDelete, EctoMiddleware.Super]
end
end
defmodule MyApp.User do
use Ecto.Schema
use EctoModel.SoftDelete, field: :deleted_at, type: :utc_datetime
schema "users" do
field(:name, :string)
field(:email, :string)
field(:deleted_at, :utc_datetime)
end
end
```
"""

@type soft_delete_type :: :utc_datetime | :datetime | :boolean

# TODO: implement support for `delete_all/2` in `EctoMiddleware`
@delete_callbacks [:delete, :delete!]
@supported_types [:utc_datetime, :datetime, :boolean]

defmodule Config do
@moduledoc false
@type t :: %__MODULE__{field: atom(), type: EctoModel.SoftDelete.soft_delete_type()}
defstruct field: :deleted_at, type: :utc_datetime
end

@doc "After compile hook responsible for validating that a schema is properly configured for soft deletes."
def __after_compile__(env, _bytecode) do

Check warning on line 73 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 26.1 / Elixir 1.15.7

Functions should have a @SPEC type specification.

Check warning on line 73 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 24.2 / Elixir 1.13.4

Functions should have a @SPEC type specification.
module = env.module
:ok = __MODULE__.validate_schema_fields!(module)
end

@doc "Persists the configuration for soft deletes on the schema, as well as providing a default impl. for `EctoModel.Queryable.base_query/0`."
defmacro __using__(opts) do
field = __MODULE__.soft_delete_field!(opts[:field])
type = __MODULE__.soft_delete_type!(opts[:type])

quote location: :keep do
def soft_delete_config,

Check warning on line 84 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 26.1 / Elixir 1.15.7

Functions should have a @SPEC type specification.

Check warning on line 84 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 24.2 / Elixir 1.13.4

Functions should have a @SPEC type specification.
do: %unquote(__MODULE__).Config{field: unquote(field), type: unquote(type)}

@impl EctoModel.Queryable
def base_query do
import Ecto.Query
unquote(__MODULE__).apply_filter!(__MODULE__, __MODULE__)
end

defoverridable base_query: 0
end
end

@doc false
@spec validate_schema_fields!(schema :: module()) :: :ok | no_return()
# Internal only, exposed as a public function as this is intended to be called by the `__after_compile__/2` callback from another module.

Check warning on line 99 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 26.1 / Elixir 1.15.7

Line is too long (max is 120, was 139).

Check warning on line 99 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 24.2 / Elixir 1.13.4

Line is too long (max is 120, was 139).
# Validates configuration for soft deletes on a schema is valid and matches schema definition.
def validate_schema_fields!(schema) do
callbacks = [soft_delete_config: 0, __schema__: 1]

if Enum.all?(callbacks, fn {fun, arity} -> function_exported?(schema, fun, arity) end) do
%Config{} = config = schema.soft_delete_config()

cond do
config.field not in schema.__schema__(:fields) ->
field_not_configured(schema, config)

schema.__schema__(:type, config.field) != config.type ->
field_type_mismatch(schema, config)

true ->
:ok
end
end

:ok
end

defp field_not_configured(schema, %Config{} = config) when is_atom(schema) do
raise """
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.
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 """
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.
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__})`
"""
end

@doc false
@spec soft_delete_field!(field :: atom() | nil) :: atom()
# Handlers for configuring the `field` option when `use`-ing `EctoModel.SoftDelete`
def soft_delete_field!(nil), do: :deleted_at
def soft_delete_field!(field) when is_atom(field), do: field

@doc false
@spec soft_delete_type!(type :: atom()) :: soft_delete_type() | no_return()
# Handlers for configuring the `type` option when `use`-ing `EctoModel.SoftDelete`
def soft_delete_type!(nil), do: :utc_datetime
def soft_delete_type!(type) when type in @supported_types, do: type

def soft_delete_type!(type),
do: raise(ArgumentError, message: "Unsupported soft delete type: #{inspect(type)}")

@doc """
Given a schema that has been configured to implement soft deletes, this function will apply the neccessary
filters to the query to ensure that soft deleted records are not included in the result set.
Note that the strategy used for soft deletes is determined by the `type` option when `use`-ing `EctoModel.SoftDelete`,
and we will apply the appropriate filter against the `field` option when `use`-ing `EctoModel.SoftDelete`.
For example, if a schema is configured to implement soft deletes like so:
```elixir
defmodule MyApp.User do
use Ecto.Schema
use EctoModel.SoftDelete, field: :deleted_at, type: :utc_datetime
schema "users" do
field(:name, :string)
field(:email, :string)
field(:deleted_at, :utc_datetime)
end
end
```
Then the `apply_filter!/2` function will apply the following filter to the query:
```elixir
from(x in query, where: is_nil(x.deleted_at))
```
However, if the schema is configured to implement soft deletes like so:
```elixir
defmodule MyApp.User do
use Ecto.Schema
use EctoModel.SoftDelete, field: :deleted, type: :boolean
schema "users" do
field(:name, :string)
field(:email, :string)
field(:deleted, :boolean)
end
end
```
Then the `apply_filter!/2` function will apply the following filter to the query:
```elixir
from(x in query, where: is_nil(x.deleted) or x.deleted == false)
```
"""
@spec apply_filter!(schema :: module(), query :: Ecto.Query.t() | atom()) :: Ecto.Query.t()
def apply_filter!(schema, query) when is_atom(schema) do
import Ecto.Query

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

case schema.soft_delete_config() do
%Config{type: :boolean} = config ->
from(x in query,
where: is_nil(field(x, ^config.field)) or field(x, ^config.field) == false
)

%Config{type: _datetime} = config ->
from(x in query, where: is_nil(field(x, ^config.field)))
end
end

@behaviour EctoMiddleware

Check warning on line 231 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 26.1 / Elixir 1.15.7

behaviour must appear before public function

Check warning on line 231 in lib/ecto_model/soft_delete.ex

View workflow job for this annotation

GitHub Actions / OTP 24.2 / Elixir 1.13.4

behaviour must appear before public function
@impl EctoMiddleware
def middleware(resource, resolution) when resolution.action not in @delete_callbacks do
resource
end

def middleware(%Ecto.Query{} = queryable, resolution) do
schema =
case queryable.from.source do
{_table, schema} when is_atom(schema) -> schema
_otherwise -> nil
end

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

queryable
end

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

def middleware(schema, resolution) when is_atom(schema) and resolution.action == :delete_all do
:ok = maybe_validate_repo_action!(schema, resolution.action)
schema
end

def middleware(resource, _resolution) do
resource
end

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
raise ArgumentError,
message: """
You are trying to delete a schema that uses soft deletes. Please use `Repo.soft_delete/2` instead.
"""
end

:ok
end
end
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ defmodule EctoModel.MixProject do
# Ecto Model's actual dependencies
{:jason, "~> 1.1"},
{:ecto, "~> 3.6"},
{:ecto_middleware, "~> 1.0"},
{:ecto_hooks, "~> 1.2"},

# Adapter Dependencies, should be supplied by host app but these
# are nice to have for tests.
Expand Down
4 changes: 3 additions & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
"dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"},
"earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"},
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
"ecto_hooks": {:hex, :ecto_hooks, "1.2.0", "07bbdae9497d0ee9741057b184f184751d686d22ec3c1ecb219d259ae31e4193", [:mix], [{:ecto_middleware, "~> 1.0", [hex: :ecto_middleware, repo: "hexpm", optional: false]}], "hexpm", "f42bf33846e6ace50bed2ad660c74db19b1609dbdea28954aacccc00223a6132"},
"ecto_middleware": {:hex, :ecto_middleware, "1.0.0", "78f12b9f9ee0e60e07e89d773c818bc5044b49ad6c47f485a1b3a3b49957be78", [:mix], [], "hexpm", "b1a9dda52cf912898a59e4ca7cc41587f9c3d590ebe3fe8a83e8bbb91d4f6e6a"},
"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"},
"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"},
"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"},
"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"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
Expand Down

0 comments on commit 627be93

Please sign in to comment.