Skip to content

Instantly share code, notes, and snippets.

@ahmadshah
Last active January 6, 2021 15:21
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save ahmadshah/83a695ac66d98a833d6d576815e6931d to your computer and use it in GitHub Desktop.
Save ahmadshah/83a695ac66d98a833d6d576815e6931d to your computer and use it in GitHub Desktop.
Ecto Soft Delete

Soft Delete Ecto Repo

The goal is to support soft delete functionality in Ecto.Repo. With the suggestion by @imranismail, another repo is created and the remaining functionalities are delegate to the original MyApp.Repo.

The new repo get/2 and all/1 functions will exclude the soft deleted record by default. delete/1 and delete_all/1 will update the delete_at column by default instead of deleting.

Example

MyApp.Repo.get(MyApp.User, 1) //will return nil if record is in soft delete state

MyApp.Repo.get(MyApp.User, 1, [with_thrash: true]) //will return the soft deleted record

MyApp.Repo.all(MyApp.User) //will exclude soft deleted records

MyApp.Repo.all(MyApp.User, [with_thrash: true]) //will include soft deleted records

MyApp.Repo.delete(user) //will update the deleted_at column

MyApp.Repo.delete(user, [force: true]) //will permanently delete the record

MyApp.Repo.delete_all(MyApp.User) //will updated the deleted_at columns

MyApp.Repo.delete_all(MyApp.User, [force: true]) //will permanently delete all records

MyApp.Repo.restore(MyApp.User, 1) //will restore back the soft deleted record
defmodule MyApp.CustomRepo do
@behaviour MyApp.SoftDelete
import Ecto.Query, only: [where: 2]
import Ecto.DateTime, only: [utc: 0]
import Ecto.Changeset, only: [change: 2]
import Ecto.Queryable, only: [to_query: 1]
alias MyApp.Repo
require Ecto.Query
def all(queryable, opts \\ [])
def all(queryable, opts) when length(opts) == 0 do
queryable = exclude_thrash(queryable)
Repo.all(queryable)
end
def all(queryable, opts) when length(opts) > 0 do
case with_thrash_option?(opts) do
true ->
queryable = exclude_thrash(queryable, false)
opts = Keyword.drop(opts, [:with_thrash])
Repo.all(queryable, opts)
_ ->
opts = Keyword.drop(opts, [:with_thrash])
all(queryable, opts)
end
end
def get(queryable, id, opts \\ [])
def get(queryable, id, opts) when length(opts) == 0 do
queryable = exclude_thrash(queryable)
Repo.get(queryable, id, opts)
end
def get(queryable, id, opts) when length(opts) > 0 do
case with_thrash_option?(opts) do
true ->
queryable = exclude_thrash(queryable, false)
opts = Keyword.drop(opts, [:with_thrash])
Repo.get(queryable, id, opts)
_ ->
opts = Keyword.drop(opts, [:with_thrash])
get(queryable, id, opts)
end
end
def delete(struct, opts \\ [])
def delete(struct, opts) when length(opts) == 0 do
changeset = change(struct, deleted_at: utc())
Repo.update(changeset)
end
def delete(struct, opts) when length(opts) > 0 do
case with_force_option?(opts) do
true ->
opts = Keyword.drop(opts, [:force])
Repo.delete(struct, opts)
_ -> delete(struct)
end
end
def delete_all(queryable, opts \\ [])
def delete_all(queryable, opts) when length(opts) == 0 do
Repo.update_all(queryable, set: [deleted_at: utc()])
end
def delete_all(queryable, opts) when length(opts) > 0 do
case with_force_option?(opts) do
true ->
opts = Keyword.drop(opts, [:force])
Repo.delete_all(queryable, opts)
_ ->
delete_all(queryable)
end
end
def restore(queryable, id) do
changeset = change(get!(queryable, id), deleted_at: nil)
update(changeset)
end
defp schema_fields(%{from: {_source, schema}}) when schema != nil, do: schema.__schema__(:fields)
defp field_exists?(queryable, column) do
query = to_query(queryable)
fields = schema_fields(query)
Enum.member?(fields, column)
end
defp exclude_thrash(queryable, exclude \\ true) do
case field_exists?(queryable, :deleted_at) do
false -> queryable
true ->
cond do
exclude -> where(queryable, fragment("deleted_at IS NULL"))
!exclude-> queryable
end
end
end
defp with_thrash_option?(opts), do: Keyword.get(opts, :with_thrash)
defp with_force_option?(opts), do: Keyword.get(opts, :force)
defdelegate config(), to: MyApp.Repo
defdelegate get!(queryable, id, opts \\ []), to: MyApp.Repo
defdelegate get_by(queryable, clauses, opts \\ []), to: MyApp.Repo
defdelegate get_by!(queryable, clauses, opts \\ []), to: MyApp.Repo
defdelegate in_transaction?(), to: MyApp.Repo
defdelegate insert(struct, opts \\ []), to: MyApp.Repo
defdelegate insert!(struct, opts \\ []), to: MyApp.Repo
defdelegate insert_all(schema_or_source, entries, opts \\ []), to: MyApp.Repo
defdelegate insert_or_update(changeset, opts \\ []), to: MyApp.Repo
defdelegate insert_or_update!(changeset, opts \\ []), to: MyApp.Repo
defdelegate one(queryable, opts \\ []), to: MyApp.Repo
defdelegate one!(queryable, opts \\ []), to: MyApp.Repo
defdelegate preload(struct_or_structs, preloads, opts \\ []), to: MyApp.Repo
defdelegate rollback(value), to: MyApp.Repo
defdelegate start_link(opts \\ []), to: MyApp.Repo
defdelegate stop(pid, timeout \\ 5000), to: MyApp.Repo
defdelegate transaction(fun_or_multi, opts \\ []), to: MyApp.Repo
defdelegate update(struct, opts \\ []), to: MyApp.Repo
defdelegate update!(struct, opts \\ []), to: MyApp.Repo
defdelegate update_all(queryable, updates, opts \\ []), to: MyApp.Repo
defdelegate delete!(struct, opts \\ []), to: MyApp.Repo
end
defmodule MyApp.SoftDelete do
@callback restore(Ecto.Queryable.t, integer) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
end
defmodule User do
use Ecto.Schema
schema "users" do
field :email, :string
field :password, :string
field :deleted_at, Ecto.DateTime, null: true
timestamps
end
end
@seymores
Copy link

Does this excludes deleted items from query?

@ahmadshah
Copy link
Author

ahmadshah commented Aug 24, 2016

Not yet. Still working on that but am not sure if I want to override the Ecto.Repo.get/2 and Ecto.Repo.all/1.

@imranismail
Copy link

imranismail commented Aug 24, 2016

@ahmadshah instead of overriding or adding functions to Repo I'd suggest to write another repo module that composes the original Repo say EnhancedRepo and reimplement delete_all/2 and delete/2 with soft delete behavior + reimplement get/2 and all/1 with proper clauses.

Use defdelegate for those functions that you want to retain their original behaviour.

Then in web.ex maybe do this alias App.EnhancedRepo, as: Repo

@ahmadshah
Copy link
Author

@imranismail thats a good suggestion. will look into that and tidy up the quick hacks 👍

@eduardonunesp
Copy link

eduardonunesp commented Jul 17, 2017

@ahmadshah Could you please create a Github repo for that? I found it very interesting to be a Hex package

@chabroA
Copy link

chabroA commented Apr 19, 2018

Hi @ahmadshah, how do you handle cascading soft delete? Let's say I have a user and a group table with a user belonging to a group. When I soft delete a group, I would like all the users in that group to be soft deleted as well (having the same behaviour as with the on_delete: :delete_all option in the ecto migration).

@tanweerdev
Copy link

@chabroA that's a must have feature

@atonse
Copy link

atonse commented May 24, 2019

This is failing with ecto 3.0.

To fix, add this alternate schema_fields/1:

defp schema_fields({_source, schema}) when schema != nil, do: schema.__schema__(:fields)

And change line 2 of field_exists?/2 to:

fields = schema_fields(query.from.source)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment