# Fetch, Update, and Delete APIs
This guide is the high-level map of Lazarus's read, update, and delete surface.
## Read APIs
Lazarus does not add separate read helpers. Instead, it changes the behavior of
ordinary Repo reads by filtering out soft-deleted rows unless you opt in with
`with_deleted: true`.
### Default behavior
Schema-backed reads hide soft-deleted rows:
```elixir
Repo.get(Post, id)
Repo.all(Post)
from(post in Post, select: post.id)
|> Repo.all()
```
### Opting into deleted rows
Pass `with_deleted: true` when you intentionally want deleted rows included:
```elixir
Repo.get(Post, id, with_deleted: true)
Repo.all(Post, with_deleted: true)
from(post in Post, where: post.author_id == ^author_id)
|> Repo.all(with_deleted: true)
```
The same opt works for preloads:
```elixir
post =
Repo.get!(Post, id, with_deleted: true)
|> Repo.preload(:comments, with_deleted: true)
account =
Repo.get!(Account, id)
|> Repo.preload([posts: [:comments]], with_deleted: true)
```
Joined schema-aware sources follow the same `with_deleted` behavior as query
roots. This means, with `with_deleted: false`, Lazarus will recurse into
supported deeper queries to exclude soft-deleted records. Otherwise, if
`with_deleted: true`, results of the root and joined queries will include
soft-delete records, essentially disabling any Lazarus filters.
Schema-less roots and joins raise by default unless you pass
`allow_schema_less_sources: true`. Raw fragments and direct SQL calls raise by
default unless you pass `allow_raw_sql: true`.
Use
[repo-level `bypass_schemas` / `bypass_tables`](repo-module-setup.md#bypassing-schemas-and-tables)
for third-party sources Lazarus should always leave alone. Repo-level bypasses
apply to configured schema and table sources; when a bypassed source is the
query root, Lazarus leaves that Ecto query alone, including fragments. They do
not allow direct SQL calls.
See [Query Support](query-support.md) for the full query-shape rules.
## Update APIs
For schemas with `soft_deletes()`, Lazarus changes updates so soft-deleted rows
are ignored by default. In practice, already-deleted rows behave like rows that
were physically deleted unless you pass `with_deleted: true`.
### Update one row
For schemas with `soft_deletes()`, `Repo.update/2` and `Repo.update!/2` calls
affect only active rows by default.
```elixir
post =
Repo.get!(Post, id)
|> Ecto.Changeset.change(title: "Updated title")
Repo.update(post)
```
If the row was already soft-deleted, Lazarus treats the update as stale. That
means the usual Ecto stale options still apply:
```elixir
Repo.update(changeset, stale_error_field: :deleted_at)
Repo.update(changeset, stale_error_field: :deleted_at, stale_error_message: "was deleted")
Repo.update(changeset, allow_stale: true)
```
Invalid changesets still use Ecto's normal validation error path. Unchanged
non-forced changesets keep Ecto's no-op behavior. `force: true` counts as an
update attempt and does not allow updating a soft-deleted row.
To intentionally update a soft-deleted row, pass `with_deleted: true`:
```elixir
post =
Repo.get!(Post, id, with_deleted: true)
|> Ecto.Changeset.change(title: "Administrative correction")
Repo.update(post, with_deleted: true)
```
Schemas without Lazarus soft-delete fields and
[bypassed schemas](repo-module-setup.md#bypassing-schemas-and-tables) keep Ecto
update behavior.
### Insert or update
`Repo.insert_or_update/2` and `Repo.insert_or_update!/2` follow the same rule
when the changeset data is loaded: the update side ignores soft-deleted records
unless `with_deleted: true` is passed.
```elixir
loaded_post
|> Ecto.Changeset.change(title: "Updated title")
|> Repo.insert_or_update()
```
Built changesets still insert normally:
```elixir
%Post{}
|> Ecto.Changeset.change(title: "New post")
|> Repo.insert_or_update()
```
### Update many rows
`Repo.update_all/3` uses Lazarus query filtering. For schema-aware sources,
active rows are updated by default and soft-deleted rows are skipped.
```elixir
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all(set: [status: "archived"])
```
Pass `with_deleted: true` when a bulk update should include soft-deleted rows:
```elixir
from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all([set: [status: "archived"]], with_deleted: true)
```
Lazarus follows Ecto's bulk-update return shape: `{count, nil}` without a
`select`, and `{count, returned}` when a `select` is present. The `count` and
returned rows reflect what was actually updated.
Joined schema-aware sources, subqueries, CTEs, and update expressions follow the
same query-shape rules as reads. Prefer schema-aware sources for bulk updates.
Schema-less update targets raise by default; `allow_schema_less_sources: true`
is an explicit escape hatch, and those updates run unfiltered because Lazarus
has no schema metadata to identify soft-deleted rows. Raw SQL fragments require
`allow_raw_sql: true`.
See [Query Support](query-support.md) for the exact update query rules.
## Delete APIs
### Soft-delete one row
Use `Repo.soft_delete/2` when you want to keep the row and mark it deleted.
```elixir
{:ok, deleted_post} = Repo.soft_delete(post, reason: "Deleted by user")
```
Pass `cascade: true` when eligible associations should also be deleted:
```elixir
{:ok, deleted_post} = Repo.soft_delete(post, cascade: true)
```
See [Cascade Soft-Deletes](cascade-soft-deletes.md) for more details.
Use `Repo.soft_delete!/2` when you want the same behavior but prefer a raising
API.
```elixir
deleted_post = Repo.soft_delete!(post)
deleted_post = Repo.soft_delete!(post, reason: "Moderator action")
deleted_post = Repo.soft_delete!(post, reason: "Moderator action", reload_after_delete: true)
```
Under the bonnet, all soft deletes (single row `soft_delete` and bulk
`soft_delete_all`) use the `update_all` function. Single-row `soft_delete`
builds a primary-key query, runs it through the same bulk path, and returns
`{:error, :not_found}` when no active row was updated because the row was
already soft-deleted or no longer exists.
Because of that, we do not automatically get the most up-to-date row data as a
return value, like we would from a single-row operation such as `update` or
`delete`.
By default, single-row soft delete (`Repo.soft_delete/2`) returns an in-memory
copy of the loaded struct with soft-delete fields applied and loaded
associations reset to `Ecto.Association.NotLoaded`. That avoids a follow-up
read, keeping soft-deletes cheap and soft-delete fields up-to-date.
If you absolutely need fresh data, you can pass `reload_after_delete: true`,
which will perform an additional `Repo.get!/3` with `with_deleted: true` after
the update succeeds. This keeps the data returned fresh, at the cost of an
additional database call.
```elixir
{:ok, deleted_post} = Repo.soft_delete(post, reload_after_delete: true)
```
For the exact return values and option list, see `Lazarus.soft_delete/3`,
`Lazarus.soft_delete!/3`, and the injected Repo docs described in
`Lazarus.Repo`.
### Soft-delete many rows
Use `Repo.soft_delete_all/2` when you want to mark every row in a schema-aware
query as deleted.
```elixir
{2, nil} =
Repo.soft_delete_all(
from(post in Post, where: post.author_id == ^author_id),
reason: "Bulk cleanup"
)
{2, titles} =
Repo.soft_delete_all(
from(post in Post,
where: post.author_id == ^author_id,
select: post.title
)
)
```
Lazarus follows Ecto's bulk-update return shape here: `{count, nil}` without a
`select`, and `{count, returned}` when a `select` is present.
`Repo.soft_delete_all/2` requires a schema-aware root query. See
[Query Support](query-support.md) for the allowed query shapes.
Pass `cascade: true` when the bulk soft delete should also traverse eligible
associations:
```elixir
Repo.soft_delete_all(query, cascade: true)
```
See [Cascade Soft-Deletes](cascade-soft-deletes.md) for more details.
### Hard-delete one row
Use `Repo.hard_delete/2` when you explicitly want ordinary physical delete
semantics.
```elixir
{:ok, _post} = Repo.hard_delete(post)
deleted_post = Repo.hard_delete!(post)
```
Use `Repo.hard_delete!/2` when you prefer the raising variant.
### Hard-delete many rows
Use `Repo.hard_delete_all/2` for explicit bulk physical deletes.
```elixir
{3, nil} =
Repo.hard_delete_all(
from(row in "audit_logs", where: field(row, :inserted_at) < ^cutoff)
)
{3, ids} =
Repo.hard_delete_all(
from(post in Post,
where: post.author_id == ^author_id,
select: post.id
)
)
```
Like ordinary Ecto bulk deletes, the return value is `{count, nil}` without a
`select`, and `{count, returned}` when a `select` is present.
Hard delete is the escape hatch, so it remains available for schema-aware
queries and schema-less table queries that Ecto can delete from.
### Ordinary `Repo.delete*` calls
Outside bypassed schemas and tables, Lazarus disables direct `Repo.delete*` and
`Repo.delete_all*` calls:
- `Repo.delete/1`
- `Repo.delete/2`
- `Repo.delete!/1`
- `Repo.delete!/2`
- `Repo.delete_all/1`
- `Repo.delete_all/2`
Use `Repo.soft_delete*` or `Repo.hard_delete*` instead.
## Repo APIs vs `Lazarus` Helpers
The `Repo.*` functions are the usual application-facing API:
- ordinary Ecto read functions such as `Repo.get/3`, `Repo.all/2`, and
`Repo.one/2`
- Ecto update functions with Lazarus soft-delete behavior, such as
`Repo.update/2`, `Repo.update!/2`, `Repo.update_all/3`,
`Repo.insert_or_update/2`, and `Repo.insert_or_update!/2`
- `Repo.soft_delete/2`
- `Repo.soft_delete!/2`
- `Repo.soft_delete_all/2`
- `Repo.hard_delete/2`
- `Repo.hard_delete!/2`
- `Repo.hard_delete_all/2`
The `Lazarus.*` helpers are the repo-explicit equivalents for cases where you
want to pass the Repo module yourself:
- `Lazarus.soft_delete(Repo, struct_or_changeset, opts)`
- `Lazarus.soft_delete!(Repo, struct_or_changeset, opts)`
- `Lazarus.soft_delete_all(Repo, queryable, opts)`
For most application code, the Repo API is the natural choice. The explicit
`Lazarus.*` helpers are more useful in shared helpers, tests, or integrations
that should not assume a specific Repo module has already imported the injected
functions. There are no repo-explicit Lazarus helpers for updates; use the Repo
update functions listed above, with the soft-delete behavior added by
`use Lazarus`.