# Lazarus
[](https://github.com/kopasz/ecto_lazarus/actions/workflows/elixir.yml)
A soft-delete library for Ecto/Elixir with guard rails.
Lazarus is a strict soft-delete safety layer for Ecto, not just a convenience wrapper. It is for applications where soft-delete correctness matters more than unrestricted query flexibility.
## Motivation
Soft deletion is one of those patterns that looks simple until a team has to live with it at scale. Across multiple teams and projects, I kept seeing the same class of bugs surface around deletion logic: records being hard-deleted accidentally, soft-deleted records leaking back into queries, and edge cases slipping through in more complex queries through subqueries, joins, and unions. This package came out of that frustration: it aims to make soft-delete behavior predictable and reduce the number of ways developers can get it wrong.
## Features
- Direct `Repo.delete*` and `Repo.delete_all*` are deprecated and overridden to raise, in favour of forcing explicit soft and hard delete paths (e.g. `Repo.soft_delete(...)`, `Repo.hard_delete(...)`)
- Reads (`Repo.get/2`, `Repo.all/1`, etc.) skip soft-deleted rows by default. To include soft-deleted rows, pass `with_deleted: true`
- Updates (`Repo.update/2`, `Repo.update_all/3`, etc.) skip soft-deleted rows by default. To update soft-deleted rows intentionally, pass `with_deleted: true`
- Soft-deleted rows stay hidden through complex Ecto query shapes like joins, subqueries, CTEs, and unions
- Soft deletes can cascade associations where relationships define
`on_delete: :delete_all` behaviour
- Automatic soft-delete for delete-triggering Ecto association replacement flows such as `on_replace: :delete` and `on_replace: :delete_if_exists`
- Schema and migration helpers
## Limitations / Considerations
- Lazarus may issue additional database queries to enforce safety, especially around updates, single-row soft deletes, and cascading deletes. This is an intentional correctness tradeoff that can affect performance-sensitive paths
- Raw SQL and schema-less Ecto sources are restricted because Lazarus cannot safely inspect or filter what it cannot understand
- Third-party libraries that expect ordinary Repo behavior may need source bypasses or a separate unwrapped Repo
- Wrapping a Repo changes default behavior: reads are filtered, updates skip soft-deleted rows, and direct `Repo.delete*` and `Repo.delete_all*` calls are disabled
- Soft-delete fields, associations, and cascade behavior need to be represented in Ecto schemas. Database foreign keys alone are not enough for Lazarus to infer soft-delete cascade rules
- Soft-deleted rows still exist in the database, so unique constraints, indexes, foreign keys, reporting queries, and direct SQL need to account for them
See [Overcoming Limitations](guides/overcoming-limitations.md) for the full list of limitations and escape hatches.
## Compatibility
- Adapter: tested only with Postgres
- Elixir: 1.15 - 1.20
- Ecto: 3.13 - 3.14
## Installation
```elixir
def deps do
[
{:lazarus, "~> 1.0"}
]
end
```
## Quick Start
### 1. Wire your Repo
```elixir
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
use Lazarus
end
```
That does three things:
- Blocks direct `Repo.delete*` and `Repo.delete_all*` calls unless the schema or table is bypassed
- Adds explicit `Repo.soft_delete*` and `Repo.hard_delete*` functions
- Hides soft-deleted rows from normal reads and skips them in updates by default
See [Repo Module Setup](guides/repo-module-setup.md) guide for more info
### 2. Add soft-delete fields to your schema
```elixir
defmodule MyApp.Post do
use Ecto.Schema
use Lazarus.Schema
schema "posts" do
soft_deletes()
end
end
```
This adds `deleted_at` and `deletion_reason` fields by default.
See [Schema Setup](guides/schema-setup.md) guide for more info
### 3. Add matching columns in a migration
```elixir
defmodule MyApp.Repo.Migrations.AddSoftDeletesToPosts do
use Ecto.Migration
use Lazarus.Migrations
def change do
alter table(:posts) do
soft_deletes()
end
end
end
```
This adds `deleted_at` and `deletion_reason` fields by default.
See [Migration Setup](guides/migration-setup.md) guide for more info
### 4. Read and update active rows normally
```elixir
Repo.get(Post, id)
Repo.all(Post)
post
|> Ecto.Changeset.change(title: "Updated")
|> Repo.update()
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all(set: [status: "archived"])
```
Soft-deleted rows are hidden from reads and skipped by updates.
See [Fetch, Update, and Delete APIs](guides/fetch-and-delete-apis.md) and [Query Support](guides/query-support.md) guides for more info
### 5. Use the explicit delete APIs
```elixir
Repo.soft_delete(post, reason: "deleted by user")
Repo.soft_delete_all(Post, reason: "cleanup job")
Repo.hard_delete(post)
Repo.hard_delete_all(Post)
```
See [Fetch, Update, and Delete APIs](guides/fetch-and-delete-apis.md) and [Query Support](guides/query-support.md) guides for more info
### 6. Include deleted rows only when you mean to
```elixir
Repo.get(Post, id, with_deleted: true)
Repo.all(Post, with_deleted: true)
post
|> Ecto.Changeset.change(title: "Restore note")
|> Repo.update(with_deleted: true)
```
See [Fetch, Update, and Delete APIs](guides/fetch-and-delete-apis.md) and [Query Support](guides/query-support.md) guides for more info
## Cascade soft-deletes
When you soft-delete record(s), you can cascade soft-delete relationships.
Cascading is opt-in. Pass `cascade: true` to follow association metadata
recursively:
- `on_delete: :delete_all` soft-deletes the child branch
- `on_delete: :nilify_all` is a noop for soft deletes because the parent row still exists
- `on_delete: :nothing` is a noop
If a child branch is soft-deleted, the related schema needs a `deleted_at`
field (see [schema setup](guides/schema-setup.md)). Branches listed in
`@hard_delete_on_cascade` are physically deleted instead, and their descendants
follow hard-delete cascade rules.
### Example
Suppose we have a `Post` which has many `Comment`'s
If we want `Repo.soft_delete(post, cascade: true)` to cascade soft-delete all
comments that belong to that post, we'd need to set them up like so:
```elixir
# comment.ex
schema "comments" do
soft_deletes()
end
# post.ex
schema "posts" do
has_many :comments, Comment, on_delete: :delete_all
end
```
Then enable cascading at the call site:
```elixir
Repo.soft_delete(post, cascade: true)
```
See more info: [Cascade Soft-Deletes](guides/cascade-soft-deletes.md)
## Association replacement
Ecto can call `Repo.delete/2` internally during association management, for example through `cast_assoc/3` or `put_assoc/4` when an association uses a delete-triggering `on_replace` strategy such as `:delete` or `:delete_if_exists`.
Lazarus intercepts that flow:
- If the child schema has a `deleted_at` field and the parent schema does not opt that association into `@hard_delete_on_replace`, it is **soft-deleted**
- Otherwise, it is **hard-deleted**
That means delete-triggering `on_replace` flows stay data-preserving whenever the child schema is soft-delete-aware, but keep the default hard-delete behaviour when they are not.
See more info: [Assoc Replace](guides/assoc-replace.md)