#[derive(Document)]
{
// Attributes available to this derive:
#[obj]
}
Expand description
#[derive(obj::Document)] proc-macro re-export.
Lives in the sibling obj-derive crate; re-exported here so
users only have to depend on obj to use the derive. The trait
itself is still obj_core::Document re-exported above —
proc-macros and traits share a single name namespace and Rust
resolves the two by use-site (#[derive(Document)] vs impl Document for ...).
The derive fills in Document::COLLECTION (default: the type
name) and Document::VERSION (default: 1). The struct still
needs serde derives — the macro intentionally does not emit them
so you stay in control of serde-level attributes
(#[serde(rename = ...)], etc.).
§Examples
Derive with defaults:
use obj::Db;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, obj::Document)]
struct Order {
customer_id: u64,
total_cents: u64,
}
let dir = tempfile::tempdir()?;
let db = Db::open(dir.path().join("orders.obj"))?;
// `Document::COLLECTION` defaulted to "Order".
assert_eq!(<Order as obj::Document>::COLLECTION, "Order");
assert_eq!(<Order as obj::Document>::VERSION, 1);
let id = db.insert(Order { customer_id: 1, total_cents: 4_200 })?;
let back: Option<Order> = db.get::<Order>(id)?;
assert_eq!(back.map(|o| o.total_cents), Some(4_200));Override the defaults with #[obj(...)]:
use obj::Db;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, obj::Document)]
#[obj(collection = "people", version = 2)]
struct Customer {
name: String,
}
assert_eq!(<Customer as obj::Document>::COLLECTION, "people");
assert_eq!(<Customer as obj::Document>::VERSION, 2);
let dir = tempfile::tempdir()?;
let db = Db::open(dir.path().join("people.obj"))?;
let id = db.insert(Customer { name: "Ada".to_owned() })?;
let back: Customer = db
.get::<Customer>(id)?
.ok_or(obj::Error::InvalidArgument("just inserted"))?;
assert_eq!(back.name, "Ada");Multiple #[obj(...)] attributes compose, and key=value pairs
may share a single attribute. Both shapes produce the same impl.
§Declaring indexes
Four kinds map to the same IndexSpec shape:
| Kind | Attribute | Behaviour |
|---|---|---|
| Standard | #[obj(index)] | B-tree index; duplicates allowed. |
| Unique | #[obj(index = unique)] | Uniqueness enforced at write time. |
| Each | #[obj(index = each)] | Indexes every element of a Vec<T> field. |
| Composite | #[obj(index_composite(fields = ("a", "b")))] | One index over a tuple of fields. |
use obj::Db;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, obj::Document)]
#[obj(collection = "customers_idx_doc")]
#[obj(index_composite(fields = ("region", "tier"), name = "by_region_tier"))]
struct Customer {
#[obj(index)]
customer_id: u64,
#[obj(index = unique)]
email: String,
#[obj(index = each)]
tags: Vec<String>,
region: String,
tier: String,
}
let dir = tempfile::tempdir()?;
let db = Db::open(dir.path().join("indexes.obj"))?;
let _id = db.insert(Customer {
customer_id: 1,
email: "ada@example.com".to_owned(),
tags: vec!["red".to_owned(), "blue".to_owned()],
region: "us-east".to_owned(),
tier: "gold".to_owned(),
})?;
// Unique-index point lookup. O(log n), no collection scan.
let by_email: Option<Customer> = db
.find_unique::<Customer>("email", "ada@example.com")?;
assert!(by_email.is_some());§Hand-implementing Document
The derive is sugar over a trait. Implement the trait directly
when you need full control — for example to share a
historical_schemas() body across many types, or to compute the
indexes() list at runtime:
use obj::{Db, Document, IndexSpec};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Customer { email: String }
impl Document for Customer {
const COLLECTION: &'static str = "customers_hand_doc";
const VERSION: u32 = 1;
fn indexes() -> Vec<IndexSpec> {
vec![IndexSpec::unique("email", "email").expect("static spec")]
}
}
let dir = tempfile::tempdir()?;
let _db = Db::open(dir.path().join("hand-idx.obj"))?;The reconciler runs on the first
WriteTxn::collection::<T>() call per
process per collection: it declares specs absent from the
catalog, flips active descriptors absent from indexes() to
DroppedPending, and leaves matches alone. Reconciliation
rides the user’s WAL transaction — a rolled-back insert leaves
no half-created index behind.
§Schema evolution
Schema evolution is (version bump) + (historical_schemas) + (migrate). Old records read through the new type are migrated
in memory; their on-disk bytes are not rewritten until the next
update / upsert. The collection therefore scales to billions
of docs without a stop-the-world rebuild on every schema change.
use obj::{Db, Document};
use obj_core::codec::{Dynamic, DynamicSchema};
use serde::{Deserialize, Serialize};
// v1 wrote `Customer { name, email }`.
// v2 adds `tier` with a default of "standard".
#[derive(Debug, Serialize, Deserialize)]
struct Customer {
name: String,
email: String,
tier: String,
}
impl Document for Customer {
const COLLECTION: &'static str = "customers_evo_doc";
const VERSION: u32 = 2;
fn historical_schemas() -> Vec<(u32, DynamicSchema)> {
vec![(
1,
DynamicSchema::map([
("name", DynamicSchema::String),
("email", DynamicSchema::String),
]),
)]
}
fn migrate(dynamic: Dynamic, from_version: u32) -> obj::Result<Self> {
if from_version != 1 {
return Err(obj::Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
});
}
Ok(Customer {
name: dynamic.get_str("name")?.to_owned(),
email: dynamic.get_str("email")?.to_owned(),
tier: "standard".to_owned(),
})
}
}
let dir = tempfile::tempdir()?;
let db = Db::open(dir.path().join("evo.obj"))?;
let id = db.insert(Customer {
name: "Ada".to_owned(),
email: "ada@example.com".to_owned(),
tier: "gold".to_owned(),
})?;
let back: Customer = db
.get::<Customer>(id)?
.ok_or(obj::Error::InvalidArgument("just inserted"))?;
assert_eq!(back.tier, "gold");The rules are mechanical:
- Bump
VERSIONon every breaking change. - Register a schema for every prior version in
historical_schemas(). The codec walks the on-disk postcard payload through that schema to produce the structuredDynamicview yourmigratebody reads. migratereturnsSelf. Default values for new fields are the migration’s responsibility — there is no implicit default.
A stored record whose type_version is newer than
Self::VERSION surfaces Error::SchemaVersionFromFuture; an
older type_version with no registered schema surfaces
Error::SchemaNotRegistered. For multi-version chains,
tombstoned fields, and enum-variant migration recipes, see the
integration tests:
historical_schemas.rs, tombstone_migration.rs,
enum_migration.rs, and lazy_migration.rs.
Derive macro for obj::Document.
Emits impl ::obj::Document for <Ident> { ... } with sensible
defaults:
COLLECTIONdefaults to the unqualified type name as a string;#[obj(collection = "explicit_name")]overrides.VERSIONdefaults to1;#[obj(version = N)]overrides.indexes()is omitted (the trait defaultVec::new()is used) when the struct carries no index-related attributes; otherwise the derive emits aVec<::obj::IndexSpec>in field-declaration order.
All emitted paths are absolute (::obj::Document,
::obj::IndexSpec) so the derive is hygienic against local items
that shadow these names.