Rich values
An address belongs together: street, city, and an optional unit. Declare it as a value object instead of passing an unstructured JSON map around your application:
type Address {
street String
city String
unit String?
}Rent generates an Address Rust struct. A model field of that type stores the
object as JSON and reads it back as Address. Use a relationship instead when the
address needs its own row and identity.
Combine typed values in a model
The larger model below brings the other scalar types together. Its Address
adds a stored JSON key and a default city. Later sections explain those choices.
You can start with the fields your application needs and return for dates,
collections, or specialized types:
enum AdvancedState {
READY
DONE
}
type Address {
street String
city String @map("city_name") @default("Ferris")
unit String?
}
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(),
unit: None,
})
.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. Omitting this field lets the
database supply the default, which reads back as the generated enum value.
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.
Use it in a profile model:
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: "Portland".to_owned(),
unit: None,
})
.save_one()
.await?;The write stores city_name, while Rust code continues to use city, and leaves out unit because it has no value. 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.
Search and filter a value object
On PostgreSQL, keep a document as one typed value and declare the search paths in its index. A generated column can expose a derived value for ordinary filters without a second application-maintained field:
type ArticleBody {
text String @map("text'content")
metadata SearchMetadata?
}
type SearchMetadata {
summary String? @map("summary.text")
}
model Article {
id BigInt @id
title String
body String
typed_body ArticleBody? @map("payload")
text_length BigInt @rent.generated("COALESCE(length(payload ->> 'text''content'), 0)")
@@fulltext([title, body, typed_body.text, typed_body.metadata.summary])
}The schema paths use Rust member names. Rent follows their mapped JSON keys when building the search vector.
The generated expression is SQL, so it uses the physical payload column and mapped text'content key.
text_length has a typed read and filter API but no create or update setter. PostgreSQL recalculates both the
length and search vector when the document changes.
use generated::composite_types::{ArticleBody, SearchMetadata};
let article = client
.create_article()
.id(4)
.title("Review")
.body("")
.typed_body(ArticleBody {
text: "Quartz".to_owned(),
metadata: Some(SearchMetadata {
summary: Some("Opal".to_owned()),
}),
})
.save()
.await?;
let matches = client
.article()
.text_length_gt(0)
.search("quartz")
.all()
.await?;Replacing the value object removes its old search terms. Clearing typed_body makes text_length zero and
contributes no terms from that object. The scalar title and body remain searchable. Optional nested members
work the same way. Search indexes refer directly to stored text or typed JSON members, because PostgreSQL
cannot build a generated search vector from another generated column.
The default english configuration stems words: run also matches running. Declare config: "simple"
for exact tokens without stemming. See full-text search for
supported paths and search queries for query syntax.
Variants that carry data
When one variant of an enum needs data the others do not, declare it on that variant:
/// How a tournament pairs its players.
enum TournamentFormat {
SINGLE_ELIMINATION
ROUND_ROBIN
SWISS { rounds UInt }
}The generated enum holds the data in its variant, Swiss { rounds: u64 }, so a Swiss tournament always has its round
count. Find stored Swiss tournaments by variant:
let swiss = client
.tournament()
.format_is(TournamentFormatVariant::Swiss)
.only()
.await?;The column is JSON. A plain variant is stored as its label and a variant with data as an object keyed by its label,
{"swiss": {"rounds": 6}}, which is also how serde writes it. See
enum variants that carry data.
Your own types in a JSON column
A JSON column can hold an application type, including an enum whose variants carry data. Name it with
@rent.rustType, and the generated field is that type:
model FlagSetting {
id BigInt @id
rules Json @rent.rustType("crate::flag_rules::Rules")
fallback Json? @rent.rustType("crate::flag_rules::Rule")
}#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Rule {
Percentage { value: u8, sticky: bool },
UserList { user_ids: Vec<String> },
Everyone,
}client
.update_flag_setting()
.id_eq(1)
.fallback(Rule::Everyone)
.save()
.await?;Rent encodes the value on write and decodes it on read, so no conversion code sits between the column and your
type. Keep Rule and Rules in a module accessible to the generated client. See
typed JSON columns for the placement rule when the client
has its own crate.
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.
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.
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")
}Set the collections with their Rust values, then read a typed projection:
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.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
rent generate
cargo runThe project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.
Cargo.toml
[package]
name = "rent-lesson-11-rich-types"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-11-rich-types"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
uuid = { version = "1.26.0", features = ["serde", "v4", "v7"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
model ValueLists {
id BigInt @id
authors Uuid[] @default([])
instants DateTime[] @default([])
amounts Decimal[] @default([])
payloads Bytes[] @default([])
documents Json[] @default([])
@@map("value_lists")
}
enum AdvancedState {
READY
DONE
}
type Address {
street String
city String @map("city_name") @default("Ferris")
unit String?
}
model UserProfile {
id BigInt @id
address Address @default("{\"street\":\"Unknown\"}")
version BigInt @rent.version
@@index([version(sort: Desc), id])
@@map("rent_composite_profiles")
}
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?
@@map("advanced_values")
}
model FlagSetting {
id BigInt @id
rules Json @rent.rustType("crate::flag_rules::Rules")
fallback Json? @rent.rustType("crate::flag_rules::Rule")
@@map("flag_settings")
}
/// How a tournament pairs its players.
enum TournamentFormat {
SINGLE_ELIMINATION
ROUND_ROBIN
SWISS { rounds UInt }
}
model Tournament {
id BigInt @id
format TournamentFormat
@@map("tournaments")
}main.rs
use anyhow::{Result, ensure};
use rent::chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use rent::driver::DatabasePool;
use rent::rust_decimal::Decimal;
use rent::serde_json::json;
use rent::sql::Dialect;
use rent::uuid::Uuid;
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
#[path = "composite_workflow.rs"]
mod composite_workflow;
#[path = "flag_rules.rs"]
mod flag_rules;
use generated::enums::AdvancedState;
async fn exercise() -> Result<()> {
let pool = DatabasePool::connect(Dialect::Sqlite, "sqlite::memory:").await?;
pool.execute("CREATE TABLE advanced_values (id INTEGER PRIMARY KEY, active INTEGER NOT NULL, signed_value INTEGER NOT NULL, unsigned_value INTEGER NOT NULL, ratio REAL NOT NULL, label TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'ready', happened_at TEXT NOT NULL, edited_at TEXT NOT NULL, day TEXT NOT NULL, starts_at TEXT NOT NULL, local_at TEXT NOT NULL, document TEXT NOT NULL, address TEXT NOT NULL, bytes_value BLOB NOT NULL, blob_value BLOB NOT NULL, uuid_value TEXT NOT NULL, optional_bytes BLOB)")
.await?;
pool.execute("CREATE TABLE value_lists (id INTEGER PRIMARY KEY, authors TEXT NOT NULL DEFAULT '[]', instants TEXT NOT NULL DEFAULT '[]', amounts TEXT NOT NULL DEFAULT '[]', payloads TEXT NOT NULL DEFAULT '[]', documents TEXT NOT NULL DEFAULT '[]')").await?;
let client = generated::client::Client::new(pool.clone());
let before = Utc::now();
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("2026-09-06T12:34:56Z".parse::<DateTime<Utc>>()?)
.day(day)
.starts_at(starts_at)
.local_at(local_at)
.document(json!({"nested": [1, true, "rent"]}))
.address(generated::composite_types::Address {
street: "10 Rust Way".to_owned(),
city: "Ferris".to_owned(),
unit: None,
})
.bytes_value([0_u8, 1, 127, 255])
.blob_value([9_u8, 8, 7])
.clear_optional_bytes()
.save()
.await?;
ensure!(value.active && value.signed_value == -42 && value.unsigned_value == 42);
ensure!(value.state == AdvancedState::Ready);
ensure!(value.day == day && value.starts_at == starts_at && value.local_at == local_at);
ensure!(
client
.advanced_value()
.day_in([day])
.starts_at_eq(starts_at)
.local_at_eq(local_at)
.count()
.await?
== 1
);
ensure!(value.document == json!({"nested": [1, true, "rent"]}));
ensure!(value.address.city == "Ferris");
ensure!(value.bytes_value == [0, 1, 127, 255]);
let uuid = value.uuid_value;
ensure!(uuid.get_version_num() == 7 && value.optional_bytes.is_none());
ensure!(value.edited_at >= before && value.edited_at <= Utc::now());
let loaded_uuid = client.advanced_value().uuid_value_eq(uuid).select_uuid_value().only().await?;
ensure!(loaded_uuid == uuid);
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?;
ensure!(updated.state == AdvancedState::Done && completed.len() == 1);
ensure!(updated.edited_at >= value.edited_at);
ensure!(client.update_advanced_value_one(&updated).save().await? == 1);
ensure!(client.advanced_value().id_eq(updated.id).only().await? == updated);
exercise_lists(&client).await?;
exercise_typed_json(&client, &pool).await?;
exercise_variant_data(&client, &pool).await?;
composite_workflow::run(&pool).await?;
Ok(())
}
async fn exercise_variant_data(
client: &generated::client::Client,
pool: &DatabasePool,
) -> Result<()> {
use generated::composite_types::{TournamentFormat, TournamentFormatVariant};
pool.execute("CREATE TABLE tournaments (id INTEGER PRIMARY KEY, format TEXT NOT NULL)")
.await?;
client
.create_tournament()
.id(1)
.format(TournamentFormat::RoundRobin)
.save()
.await?;
client
.create_tournament()
.id(2)
.format(TournamentFormat::Swiss {
rounds: 6,
})
.save()
.await?;
let swiss = client.tournament().format_is(TournamentFormatVariant::Swiss).only().await?;
ensure!(
swiss.format
== TournamentFormat::Swiss {
rounds: 6
}
);
ensure!(rent::serde_json::to_value(&swiss.format)? == json!({"swiss": {"rounds": 6}}));
Ok(())
}
async fn exercise_typed_json(
client: &generated::client::Client,
pool: &DatabasePool,
) -> Result<()> {
use flag_rules::{Rule, Rules};
pool.execute("CREATE TABLE flag_settings (id INTEGER PRIMARY KEY, rules TEXT NOT NULL, fallback TEXT)")
.await?;
let rules = Rules {
rules: vec![
Rule::UserList {
user_ids: vec!["maya".to_owned()],
},
Rule::Percentage {
value: 25,
sticky: true,
},
],
default_enabled: false,
};
client.create_flag_setting().id(1).rules(rules.clone()).save().await?;
client.update_flag_setting().id_eq(1).fallback(Rule::Everyone).save().await?;
let flag = client.flag_setting().id_eq(1).only().await?;
ensure!(flag.rules == rules);
ensure!(matches!(flag.rules.rules.first(), Some(Rule::UserList { .. })));
ensure!(flag.fallback == Some(Rule::Everyone));
Ok(())
}
async fn exercise_lists(client: &generated::client::Client) -> Result<()> {
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?;
ensure!(collection.authors == [author]);
ensure!(collection.instants == [instant]);
ensure!(collection.amounts == [amount]);
ensure!(collection.payloads == [vec![0, 255]]);
ensure!(collection.documents == [json!({"source": "import"}), json!(null)]);
let authors: Vec<Uuid> = client.value_lists().id_eq(1).select_authors().only().await?;
ensure!(authors == [author]);
let empty = client.create_value_lists().id(2).save().await?;
ensure!(empty.authors.is_empty() && empty.instants.is_empty() && empty.amounts.is_empty());
ensure!(empty.payloads.is_empty() && empty.documents.is_empty());
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
exercise().await
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn example_runs() -> anyhow::Result<()> {
super::exercise().await
}
}composite_workflow.rs
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
use rent::serde_json::{from_value, json, to_value};
use rent::sql::{Dialect, ast::RenderedQuery};
use super::generated::{client::Client, composite_types::Address, user_profile::UserProfile};
#[path = "cursor_workflow.rs"]
mod cursor_workflow;
/// Runs the shared value-object application against an isolated test fixture table.
///
/// # Errors
///
/// Propagates database, decoding, invariant, and cleanup failures.
pub async fn run(database: &DatabasePool) -> Result<()> {
let result = exercise(database).await;
let cleanup = database.execute("DROP TABLE IF EXISTS rent_composite_profiles").await;
result?;
cleanup?;
Ok(())
}
async fn exercise(database: &DatabasePool) -> Result<()> {
let sql_type = match database.dialect() {
Dialect::Postgres => "jsonb",
Dialect::MySql => "json",
Dialect::Sqlite => "text",
};
database.execute(&format!("CREATE TABLE rent_composite_profiles (id bigint PRIMARY KEY, address {sql_type} NOT NULL DEFAULT ('{{\"street\":\"Unknown\"}}'), version bigint NOT NULL DEFAULT 1)")).await?;
let client = Client::new(database.clone());
let profile = client.create_user_profile().id(1).save().await?;
ensure!(profile.address.street == "Unknown" && profile.address.city == "Ferris");
let address: Address = from_value(json!({"street": "10 Rust Way", "city_name": "Portland"}))?;
let updated = client.update_user_profile_one(&profile).address(address).save_one().await?;
ensure!(updated.address.city == "Portland" && updated.version > profile.version);
ensure!(
client
.update_user_profile_one(&profile)
.address(updated.address.clone())
.save_one()
.await
.is_err()
);
let selected: Address = client.user_profile().id_eq(1).select_address().only().await?;
ensure!(selected == updated.address);
ensure!(
client
.user_profile()
.id_eq(1)
.select((UserProfile::id, UserProfile::address))
.only()
.await?
== (1, selected.clone())
);
ensure!(to_value(&selected)? == json!({"street": "10 Rust Way", "city_name": "Portland"}));
let sql = if database.dialect() == Dialect::MySql {
"SELECT CAST(address AS CHAR) FROM rent_composite_profiles WHERE id = 1"
} else {
"SELECT CAST(address AS TEXT) FROM rent_composite_profiles WHERE id = 1"
};
let stored = database.fetch_strings(&RenderedQuery::from_static(sql)).await?;
ensure!(stored.first().is_some_and(|value| value.contains("city_name")));
ensure!(
stored.first().is_some_and(|value| !value.contains("unit")),
"an absent optional member is left out of the stored document, not written as null"
);
let transaction = client.tx().await?;
transaction
.client()
.update_user_profile_one(&updated)
.address(Address {
street: "Temporary".to_owned(),
city: "Rollback".to_owned(),
unit: Some("4B".to_owned()),
})
.save_one()
.await?;
transaction.rollback().await?;
ensure!(client.user_profile().id_eq(1).only().await? == updated);
database
.execute("INSERT INTO rent_composite_profiles (id, address) VALUES (2, '{\"street\":\"Imported\"}')")
.await?;
let imported = client.user_profile().id_eq(2).only().await?;
ensure!(
imported.address.city == "Ferris",
"missing JSON members receive literal defaults on read"
);
database
.execute(
"UPDATE rent_composite_profiles SET address = '{\"street\":\"Imported\",\"city_name\":null}' WHERE id = 2",
)
.await?;
ensure!(
client.user_profile().id_eq(2).only().await.is_err(),
"explicit invalid null must not receive a default"
);
cursor_workflow::exercise(&client, database).await?;
Ok(())
}cursor_workflow.rs
use super::super::generated::client::Client;
use anyhow::{Result, ensure};
use rent::driver::DatabasePool;
pub async fn exercise(
client: &Client,
database: &DatabasePool,
) -> Result<()> {
for count in [0_i64, 1, 2, 7, 31] {
let mut expected = (1..=count).map(|id| (id % 3 + 1, id)).collect::<Vec<_>>();
expected.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.cmp(&right.1)));
for permutation in 0..3 {
database.execute("DELETE FROM rent_composite_profiles").await?;
let mut insertion = (1..=count).collect::<Vec<_>>();
match permutation {
1 => insertion.reverse(),
2 => insertion.sort_by_key(|id| id * 17 % 37),
_ => {}
}
for id in insertion {
database.execute(&format!("INSERT INTO rent_composite_profiles (id, address, version) VALUES ({id}, '{{\"street\":\"Fixture\"}}', {})", id % 3 + 1)).await?;
}
for page_size in [1, 2, 3, 8, 32] {
let mut cursor = None;
let mut observed = Vec::new();
let mut ended = false;
// The cap makes a stuck cursor a prompt failure, not a hanging tutorial/test.
for _ in 0..=count + 1 {
let page = client.user_profile().page_by_version_and_id(page_size, cursor.as_ref()).await?;
observed.extend(page.items.into_iter().map(|row| (row.version, row.id)));
if !page.has_more {
ended = true;
break;
}
ensure!(page.next.is_some(), "has_more requires a continuation cursor");
cursor = page.next;
}
ensure!(
ended && observed == expected,
"mixed directions lost or repeated rows: count={count}, page={page_size}, insertion={permutation}"
);
}
}
}
Ok(())
}flag_rules.rs
use rent::serde::{Deserialize, Serialize};
/// One targeting rule; each variant carries its own data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "rent::serde", tag = "type", rename_all = "snake_case")]
pub enum Rule {
Percentage {
value: u8,
sticky: bool,
},
UserList {
user_ids: Vec<String>,
},
Everyone,
}
/// A flag's rules, evaluated in order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(crate = "rent::serde")]
pub struct Rules {
pub rules: Vec<Rule>,
pub default_enabled: bool,
}