Fetch, Update, and Delete APIs

Copy Markdown View Source

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:

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:

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:

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 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 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.

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:

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:

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 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.

loaded_post
|> Ecto.Changeset.change(title: "Updated title")
|> Repo.insert_or_update()

Built changesets still insert normally:

%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.

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:

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 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.

{:ok, deleted_post} = Repo.soft_delete(post, reason: "Deleted by user")

Pass cascade: true when eligible associations should also be deleted:

{:ok, deleted_post} = Repo.soft_delete(post, cascade: true)

See Cascade Soft-Deletes for more details.

Use Repo.soft_delete!/2 when you want the same behavior but prefer a raising API.

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.

{: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.

{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 for the allowed query shapes.

Pass cascade: true when the bulk soft delete should also traverse eligible associations:

Repo.soft_delete_all(query, cascade: true)

See Cascade Soft-Deletes for more details.

Hard-delete one row

Use Repo.hard_delete/2 when you explicitly want ordinary physical delete semantics.

{: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.

{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.