Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Behaviors

The 0.7.0 Pre-tempering deliverable. A #[derive(FerraModel)] struct can enrol cross-cutting behaviors that the framework injects in lockstep across the entire stack — fields, SQL, default projections, hypermedia links, and OpenAPI emission — from a single container-level attribute.

This page is the authoritative user-guide for the behaviors surface. A reader with only this page plus standard Rust knowledge produces a working #[behavior(...)] declaration on the first attempt — no ADR and no framework source is required.


Mental model

A behavior is a cohesive cohort of cross-cutting modifications the framework applies to a model: the fields it carries, the SQL the framework emits, the projections the framework derives, the hypermedia links the framework attaches, and the OpenAPI operations the framework documents — all at once, from one attribute line.

The two behaviors that ship at this release:

NameEffect
soft_deleteAdds a deleted_at column. Every framework-generated SELECT filters rows whose stamp is non-null. The standard DELETE rewrites to an UPDATE deleted_at = NOW(). A new POST /{resource}/{id}/restore action sets the stamp back to NULL.
timestampableAdds created_at and updated_at columns. Every framework-generated INSERT stamps both. Every framework-generated UPDATE advances updated_at. Submitted values for either field in a create/update body are silently ignored.

A model that declares zero #[behavior(...)] attributes observes byte-identical 0.6.5 behaviour. Adding a behavior is strictly additive to the model’s surface — the existing routes, the existing projections, and the existing OpenAPI emission all continue to exist; the behavior layers new structure on top.


The attribute

#[behavior(name1, name2, ...)]
  • Container-level — applied to the struct declaration, not to individual fields.
  • Closed vocabulary at this release: soft_delete, timestampable. An unknown name is a compile-time error.
  • Stackable — the attribute can appear multiple times on the same struct; the framework dedupes by set-union.
  • Order-irrelevant#[behavior(soft_delete, timestampable)] and #[behavior(timestampable, soft_delete)] produce byte-identical ModelMeta.
  • No arguments at this release — each name is a bare identifier.

#[behavior(soft_delete)]

Worked example

use ferra::*;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(soft_delete)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

The framework’s ModelMeta for Film carries:

  • A behaviors slice containing BehaviorKind::SoftDelete.
  • An automatically injected deleted_at: Option<jiff::Timestamp> field at the end of the user’s field list, flagged read_only: true so the default-read projection includes it and the default-write projection excludes it.
  • An auto-emitted OperationMeta::new("restore", "POST", "restore") on operations.

The migration

The column shape is hand-written into your sea-orm-migration migration file at 0.7.0 (the ferra-anvil migration create scaffolder lands at 0.13.0 and emits the same shape automatically based on M::meta().behaviors):

ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;

That is the entire migration surface — the framework’s runtime SQL composes the predicate (WHERE deleted_at IS NULL) and the rewrites (DELETE → UPDATE deleted_at = NOW()) on top of this single column.

What changes on the wire

  • DELETE /films/{id} on a live row → 204 No Content, no physical delete; the row’s deleted_at is set to NOW().
  • DELETE /films/{id} on an already-soft-deleted row → 404 Not Found (the framework’s SELECT predicate filters the row out before the handler reaches it).
  • GET /films/{id} on a soft-deleted row → 404 Not Found.
  • GET /films → soft-deleted rows are absent from the collection.
  • The 1-to-1 read response shape is unchanged — your existing JSON consumers see deleted_at: null on every live row (the field is in the default-read projection by construction).

Restoring a row

POST /films/{id}/restore
  • On a soft-deleted row → 200 OK with the row’s HAL ItemResponse. The deleted_at is set back to NULL.
  • On a row that never existed → 404 Not Found.
  • On a row that is already live (deleted_at IS NULL) → 404 Not Found (idempotent — re-restoring a live row is a no-op as far as the client is concerned).
  • The route honours Prefer: return=minimal per the framework’s standard preference header rules: with the header → 204 No Content, Preference-Applied: return=minimal, empty body.

The restore action mounts under the standard Tower stack — CORS deny / body cap / mutation rate-limit / tracing — exactly like the standard CRUD mutation routes. It composes with per-projection URL prefixes by simple concatenation:

POST /admin/films/{id}/restore   ← when the model has an `admin` projection

_links.restore.href

Every live row’s HAL response carries:

{
  "data": { "id": "550e8400-...", "title": "Casablanca", "deleted_at": null },
  "_links": {
    "self":       { "href": "https://api.example/films/550e8400-..." },
    "collection": { "href": "https://api.example/films" },
    "restore":    { "href": "https://api.example/films/550e8400-.../restore" }
  }
}

The _links.restore.href is present only on live rows — soft-deleted rows are filtered before they ever reach the link-emission stage, so a client cannot observe a _links.restore.href on a row whose stamp is non-null. The URL composes through any configured base_path automatically (see Foundry → Transport-layer overrides → base_path).

The AllowsHardDelete opt-in marker

The framework’s standard DELETE /{r}/{id} handler rewrites to UPDATE deleted_at = NOW() on every soft-delete model. A developer who registers a custom processor that bypasses this and issues DELETE FROM films WHERE ... directly is bypassing the soft-delete safety contract.

The framework requires the bypass to be explicit:

use ferra::AllowsHardDelete;

pub struct PurgeProcessor;

impl FerraProcessor<Film> for PurgeProcessor {
    // ... developer's processor logic, including the physical DELETE ...
}

// The opt-in:
impl AllowsHardDelete for PurgeProcessor {}

Without the impl AllowsHardDelete line, Foundry::build() panics at boot with a verbatim message naming the model, the processor type, and the exact impl line the developer must add. The framework refuses to wire the application together until the bypass is acknowledged.

A custom processor that does NOT issue a physical DELETE — for example, one that mutates deleted_at directly via UPDATE films SET deleted_at = NOW() WHERE id = $1 — does NOT require the marker. The marker gates only the physical-delete path.

Tip: directly mutating deleted_at from a custom processor is permitted without the AllowsHardDelete marker, but it bypasses the framework’s restore action and the HAL link emission. The framework provides the restore operation as the supported way to flip the stamp back; direct mutation is your responsibility.

Edge case — identifier collision with a soft-deleted row

A POST /films that supplies an id matching an existing soft-deleted row returns the framework’s standard 409 conflict response:

{
  "type": "https://ferra.rs/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "..."
}

This is the standard duplicate-key shape unchanged — the soft-deleted row continues to occupy the UNIQUE (id) index, and an INSERT colliding on it is a primary-key collision regardless of stamp state. The framework’s behaviour matches the database’s natural semantics.

If your application needs identifier reuse across hard-deleted-and-restored cycles, apply a partial unique index in the same migration:

-- Allow re-use of soft-deleted identifiers:
CREATE UNIQUE INDEX films_id_live_only ON films (id) WHERE deleted_at IS NULL;
ALTER TABLE films DROP CONSTRAINT films_pkey;
ALTER TABLE films ADD CONSTRAINT films_pkey PRIMARY KEY (id) DEFERRABLE INITIALLY IMMEDIATE;

The partial-unique-index pattern is an opt-in schema customisation; the framework does not emit it automatically.


#[behavior(timestampable)]

Worked example

use ferra::*;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(timestampable)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

The framework injects created_at: jiff::Timestamp and updated_at: jiff::Timestamp at the end of the field list, both flagged read_only: true (present in the default-read, absent from the default-write).

The migration

ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

NOT NULL DEFAULT NOW() ensures that pre-existing rows in the table receive a value at migration time. The framework’s runtime SQL stamps both columns on every INSERT and advances updated_at on every UPDATE, so the DEFAULT NOW() only matters for the migration path.

What changes on the wire

  • POST /films → the response carries created_at and updated_at populated to the server’s current time. The request body cannot set them; values submitted in either field are silently ignored.
  • PATCH /films/{id} → the response carries the advanced updated_at; created_at is unchanged.
  • GET /films/{id} and GET /films → both fields appear on every row.

The “silently ignored on submit” behaviour follows from the read_only: true flag: the default-write projection excludes both fields, and the deserialisation rejects unknown fields when strict-projection-write is on (the framework’s default). Submitting a body with an explicit created_at value either returns a 422 (strict mode) or silently drops it (lenient mode) — the persisted value is always the framework’s NOW() stamp.


Combined behaviors

#[behavior(soft_delete, timestampable)] enrols both. The framework injects all three fields and the restore action. The composition behaves as the union of the two individual behaviors:

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(soft_delete, timestampable)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

Composition rules:

  • DELETE /films/{id} rewrites to UPDATE films SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL. Soft-delete refreshes updated_at because the soft-delete is logically a write.
  • POST /films/{id}/restore emits UPDATE films SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND deleted_at IS NOT NULL. Restore refreshes updated_at; created_at stays at the original creation time.
  • The order of names is irrelevant — #[behavior(timestampable, soft_delete)] produces the same ModelMeta as #[behavior(soft_delete, timestampable)].

The combined migration:

ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

Common pitfalls

Manually declaring an injected column on the struct

#[behavior(soft_delete)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
    pub deleted_at: Option<jiff::Timestamp>,   // ← compile error
}

The framework emits FRG-323 at proc-macro time:

error: field `deleted_at` conflicts with the soft_delete behavior's auto-injected field
help: remove the field declaration; the behavior injects it automatically

The same diagnostic applies to created_at and updated_at under #[behavior(timestampable)]. Behaviors own their injected column shape; if you need a column whose name happens to match one of the framework’s reserved names but holds different data, choose a different column name.

Forgetting the AllowsHardDelete marker on a custom processor

A FerraProcessor registered against a soft-delete model that issues a physical DELETE triggers a boot-time panic:

the model `Film` is enrolled in the soft-delete behavior AND a custom processor
`PurgeProcessor` is registered against it. The processor MUST opt in via
`impl AllowsHardDelete for PurgeProcessor {}` to acknowledge that a physical
delete bypasses the soft-delete safety contract. See
https://ferra.rs/guide/behaviors#the-allowshard-delete-opt-in-marker.

Add the marker (impl AllowsHardDelete for PurgeProcessor {}) or rewrite the processor to soft-delete instead. The check runs at Foundry::build() — it does not require a request to surface.

Submitting a timestamp value in a request body

A POST /films request whose body carries created_at: "2020-01-01T00:00:00Z" does NOT plant the past date — the framework silently ignores submitted values for behavior-injected timestamps. The persisted value is the server’s NOW() at the moment of the INSERT. This is by construction (the field is excluded from the default-write projection).

If your application needs to record a different “as-of” date (e.g., for retroactive imports), declare a separate column with a non-reserved name (recorded_at, effective_at, …) and write it normally — the framework’s behaviors own only the names listed in the closed vocabulary above.

Unknown behavior name

#[behavior(softdelete)]    // typo — missing underscore

The framework emits FRG-320 at proc-macro time:

error: unrecognised behavior name `softdelete`
help: did you mean `soft_delete`?
note: recognised behaviors: soft_delete, timestampable.
      See docs/user-guide/behaviors.md.

The recognised vocabulary is closed; future releases add new names additively (the #[non_exhaustive] BehaviorKind enum makes the additions non-breaking by construction).

Empty #[behavior()]

#[behavior()]    // empty

The framework emits FRG-321:

error: behavior attribute requires at least one name
help: remove the attribute or supply at least one of: soft_delete, timestampable

An empty attribute is rejected because it has no effect and most likely indicates an editor template that was not filled in.

Non-identifier syntax inside the attribute

#[behavior(soft_delete = true)]      // key/value — not the grammar
#[behavior("soft_delete")]            // string literal — not the grammar

Both fire FRG-322 at proc-macro time. The grammar is bare comma-separated identifiers; key/value pairs and string literals are reserved for future widening.


Forward compatibility

The behaviors namespace is #[non_exhaustive] at the IR level — future releases add behaviors additively without breaking existing call sites. Future releases will likely add behaviors such as audit_log, tenanted, versioned, and other model-level concerns — the namespace is designed for additive growth and no specific shape is contracted at this release. Argument-bearing behaviors (e.g., #[behavior(versioned(strategy = "snapshot"))]) are explicitly not part of the 0.7.0 surface; the grammar widens additively when the first argument-bearing behavior lands.