rent
Learn by example

12. Rich values

Declare storage types and reusable value objects once in RSL:

enum AdvancedState {
  READY @map("ready")
  DONE  @map("done")
}

type Address {
  street String
  city   String @map("city_name") @default("Ferris")
}

model AdvancedValue {
  id             BigInt   @id
  active         Boolean
  signed_value   BigInt
  unsigned_value UInt
  ratio          Float
  label          String
  state          AdvancedState @default(READY)
  happened_at    DateTime
  edited_at      DateTime @updatedAt
  day            DateTime @rent.type(postgres: "date", mysql: "date")
  starts_at      DateTime @rent.type(postgres: "time(6) without time zone", mysql: "time(6)")
  local_at       DateTime @rent.type(postgres: "timestamp(6) without time zone", mysql: "datetime(6)")
  document       Json
  address        Address
  bytes_value    Bytes
  blob_value     Blob
  uuid_value     Uuid @default(uuid(7))
  optional_bytes Bytes?
}

Generated builders retain those Rust types across writes and reads:

use rent::chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};

let happened_at: DateTime<Utc> = "2026-09-06T12:34:56Z".parse()?;
let day: NaiveDate = "2026-09-06".parse()?;
let starts_at: NaiveTime = "12:34:56".parse()?;
let local_at: NaiveDateTime = "2026-09-06T12:34:56".parse()?;

let value = client
    .create_advanced_value()
    .id(1)
    .active(true)
    .signed_value(-42)
    .unsigned_value(42_u64)
    .ratio(3.5)
    .label("portable")
    .happened_at(happened_at)
    .day(day)
    .starts_at(starts_at)
    .local_at(local_at)
    .document(json!({"nested": [1, true, "rent"]}))
    .address(Address {
        street: "10 Rust Way".to_owned(),
        city: "Ferris".to_owned(),
    })
    .bytes_value([0_u8, 1, 127, 255])
    .blob_value([9_u8, 8, 7])
    .clear_optional_bytes()
    .save()
    .await?;

Rent generates Address as a normal serializable Rust type and stores it through the database's JSON-compatible representation. Rent also handles backend-specific scalar encoding and rejects unsigned values that cannot be represented safely by the target database.

DateTime fields use DateTime<Utc>, so you can pass Utc::now() directly or parse an external timestamp once at your application's boundary. Setters, filters, projections, and cursors retain the type. PostgreSQL binds native timestamps; MySQL and MariaDB store UTC in DATETIME(6); SQLite stores canonical UTC text that sorts chronologically. See dates and times for calendar-only values, local timestamps, and precision.

The create also omits uuid_value and edited_at. Rent generates a UUID v7 and a current UTC timestamp when the write executes. @updatedAt refreshes the timestamp on field updates; an empty, unversioned update leaves it unchanged. An explicit value takes precedence, which is useful when importing historical records. These defaults do not require a database extension. See automatic values for the distinction between client defaults and database defaults.

The create omits state: @default(READY) supplies the mapped stored value ready. Declare defaults using schema variants, not their database spellings. The executable chapter and each SQL-backend contract verify that the database-supplied default survives a write/read round trip.

The returned state is an enum, not a string. Import its generated type for updates and filters:

use generated::enums::AdvancedState;

let updated = client
    .update_advanced_value_one(&value)
    .state(AdvancedState::Done)
    .save_one()
    .await?;

let completed = client
    .advanced_value()
    .state_in([AdvancedState::Done])
    .all()
    .await?;

AdvancedState::Done is stored and serialized as "done". Parse external strings explicitly with "done".parse::<AdvancedState>()?. Unknown values return an error; misspelled Rust variants and string arguments to enum setters fail at compile time. See Types and storage for nullable enums, enum lists, and deployment considerations.

Evolving value objects

A value object stays a normal Rust struct, while @map controls its JSON keys. The chapter also runs this small profile model through the generated client:

model UserProfile {
  id      BigInt  @id
  address Address @default("{\"street\":\"Unknown\"}")
  version BigInt @rent.version

  @@map("rent_composite_profiles")
}

The database supplies the whole address default when the create omits it. During decoding, the missing city_name member receives "Ferris" from Address.city's literal default. This also lets older stored objects receive a newly declared member default without an immediate data backfill. It does not rewrite existing JSON.

use generated::composite_types::Address;

let profile = client.create_user_profile().id(1).save().await?;

let updated = client
    .update_user_profile_one(&profile)
    .address(Address {
        street: "10 Rust Way".to_owned(),
        city: "Ada".to_owned(),
    })
    .save_one()
    .await?;

The write stores city_name, while Rust code continues to use city. The version protects the whole profile update from stale writes. Updating an address replaces that value object; it is not an implicit merge. Explicit JSON null does not receive a missing-member default: it is valid only for an optional member. The chapter and SQL matrix test mapped storage, decoding defaults, typed projections, stale-write rejection, and rollback using the same workflow.

Native PostgreSQL enums

For database-enforced PostgreSQL enum labels and ordering, see native enums. The generated Rust enum API stays the same: setters, filters, projections, and cursors accept enum variants.

Run the native enum application against a disposable PostgreSQL server:

cargo run -p rent --example native_enum_posts

Set DATABASE_URL to a local test server whose role can create databases. The example creates and removes its own database and verifies publication states, nullable values, typed projections, keyset pages, bulk inserts, optimistic locking, transaction rollback, cross-tenant conflict rejection, and relationships keyed by a unique enum label with tenant-scoped writes and eager loading. The SQL matrix runs the same workflow through nextest.

Typed collections

Lists retain their element types in Rust and use JSON-compatible storage on every SQL backend:

model ValueLists {
  id        BigInt     @id
  authors   Uuid[]     @default([])
  instants  DateTime[] @default([])
  amounts   Decimal[]  @default([])
  payloads  Bytes[]    @default([])
  documents Json[]     @default([])

  @@map("value_lists")
}

The executable chapter creates and reads each collection:

use rent::chrono::Utc;
use rent::rust_decimal::Decimal;
use rent::serde_json::json;
use rent::uuid::Uuid;

let author = Uuid::new_v4();
let instant = Utc::now();
let amount: Decimal = "12.50".parse()?;

let collection = client
    .create_value_lists()
    .id(1)
    .authors(vec![author])
    .instants(vec![instant])
    .amounts(vec![amount])
    .payloads(vec![vec![0_u8, 255]])
    .documents(vec![json!({"source": "import"}), json!(null)])
    .save()
    .await?;

let authors: Vec<Uuid> = client
    .value_lists()
    .id_eq(collection.id)
    .select_authors()
    .only()
    .await?;

Bytes[] is a collection of byte buffers (Vec<Vec<u8>>), not one buffer. Json[] can mix objects, strings, numbers, arrays, and JSON null. A decimal list serializes decimal strings to preserve precision; timestamps serialize UTC strings with their fractional precision. These are JSON values, not native SQL arrays or relationships. Updating a list replaces the collection. To attach users or other records, declare a relationship instead. See typed JSON lists for defaults and validation.

The complete scalar matrix is crates/rent/examples/tutorial_11_rich_types/schema.rsl. Run cargo run -p rent --example tutorial_11_rich_types. The same type round trips run under nextest.

On this page