Skip to main content

Document

Derive Macro Document 

Source
#[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:

KindAttributeBehaviour
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:

  1. Bump VERSION on every breaking change.
  2. Register a schema for every prior version in historical_schemas(). The codec walks the on-disk postcard payload through that schema to produce the structured Dynamic view your migrate body reads.
  3. migrate returns Self. 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:

  • COLLECTION defaults to the unqualified type name as a string; #[obj(collection = "explicit_name")] overrides.
  • VERSION defaults to 1; #[obj(version = N)] overrides.
  • indexes() is omitted (the trait default Vec::new() is used) when the struct carries no index-related attributes; otherwise the derive emits a Vec<::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.