Skip to main content

README.md

# Lazarus

[![Elixir CI](https://github.com/kopasz/ecto_lazarus/actions/workflows/elixir.yml/badge.svg?branch=main)](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)