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