Packages
Explicit cascade, restrict, nilify, and do-nothing semantics for Ash relationships.
Current section
Files
Jump to
Current section
Files
ash_referential_actions
README.md
README.md
# AshReferentialActions
Explicit referential actions for Ash relationships.
## Installation
Add the dependency to your `mix.exs`:
```elixir
{:ash_referential_actions, "~> 0.1.1"}
```
## Why
`belongs_to`, `has_many`, and `has_one` describe cardinality but not lifecycle. A related record may be owned, may prevent deletion, may lose its foreign key, or may require no lifecycle behavior. AshReferentialActions makes that choice mandatory and derives soft-archive and optional PostgreSQL behavior from it.
## DSL
Declare the same action on both sides of an attributable relationship:
```elixir
# A comment is owned by its post
cascade_belongs_to :post, Post, allow_nil?: false
cascade_has_many :comments, Comment
# A product cannot disappear while an order item references it
restrict_belongs_to :product, Product, allow_nil?: false
restrict_has_many :order_items, OrderItem
# A task remains when its assignee disappears
nilify_belongs_to :assignee, User, allow_nil?: true
nilify_has_many :assigned_tasks, Task, destination_attribute: :assignee_id
# Normal relationship with no lifecycle behavior
do_nothing_has_many :published_posts, Post, filter: expr(published)
```
Resources using the extension cannot declare plain attributable `belongs_to`, `has_many`, or `has_one`. Use one of `cascade_*`, `restrict_*`, `nilify_*`, or `do_nothing_*`.
Plain reverse relationships to destination resources that do not use AshReferentialActions are exempt. This primarily supports relationships generated by extensions such as AshPaperTrail.
Use normal Ash relationship options. `nilify_belongs_to` requires `allow_nil?: true`.
## Adapters
### Soft archive
```elixir
use Ash.Resource,
extensions: [AshReferentialActions.Archival]
```
The archival adapter also installs `AshArchival.Resource` and:
- generates `archive_related` from `cascade_has_many` and `cascade_has_one`
- rejects an archive while a live `restrict_has_many/has_one` record exists
- generates a private nilify update action and clears live `nilify_has_many/has_one` foreign keys
- rejects new cascade/restrict/nilify references to archived targets
- validates cascade destinations and ordering
The live-target guard batches its reads for bulk creates and streamed bulk updates:
one read before and one after each batch, per guarded relationship, domain, and
tenant with keys to check. For example, 5,000 creates with one relationship and
`batch_size: 100` require 100 guard reads. Required read-action pagination does
not truncate the keys being checked, and PostgreSQL reads retain `FOR SHARE` locks.
Batches with record hooks, managed relationships, action-level `after_batch`, or
resource-level `before_batch` callbacks retain individual guard checks to preserve
hook ordering. Ordinary resource-level changes (including direct attribute changes
and `after_batch` callbacks) do not disable batching. The guard reserves its original
hook positions until `before_batch`, so hooks installed by global changes cannot move ahead of it.
Single actions retain their existing hooks. Atomic updates that do not change
guarded keys do not run guard reads. Ash's transaction and error options continue
to determine partial success and rollback behavior.
### PostgreSQL physical delete
```elixir
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshReferentialActions.Postgres]
```
The PostgreSQL adapter generates migration reference behavior:
- cascade -> `on_delete: :delete`
- restrict -> `on_delete: :restrict`
- nilify -> `on_delete: :nilify`
Do not enable this adapter merely because an application uses PostgreSQL. Applications that only soft-archive records should normally use the archival adapter alone and retain restrictive database foreign keys as a safety net.
The adapters can be combined when both soft archive and physical delete must share the same semantics.
## Generated nilify action
For:
```elixir
nilify_belongs_to :assignee, User, allow_nil?: true
```
AshReferentialActions generates a private update action named from the source attribute, for example:
```elixir
:__ash_referential_actions_nilify_assignee_id__
```
The action accepts no input and only sets `assignee_id` to nil. The target's archival change invokes it through the matching reverse relationship with Ash's `cascade_update` change.
## Guarantees
Compile-time verification rejects:
- unmarked attributable relationships
- missing or mismatched forward/reverse actions
- nilify relationships whose foreign key is non-nullable
- filtered or manual lifecycle reverse relationships
- lifecycle targets that do not use AshReferentialActions
- invalid cascade destinations or cascade order
- PostgreSQL reference behavior that conflicts with the declared action