obj-db 1.1.2

Embedded document database. Stable file format, full ACID, single-file portability.
Documentation
//! #42 — `#[derive(obj::Document)]` generated-code contract.
//!
//! `obj-derive` is a frozen 1.0 surface, but `public-api/obj-derive.txt`
//! is only two lines (the macro entry point); the freeze gate cannot
//! see the GENERATED code, which is itself a format contract. The
//! generated `Document` / `Schema` impls decide:
//!
//! - the `COLLECTION` name and schema `VERSION`,
//! - the exact `indexes()` list (each `IndexSpec`'s name / kind /
//!   key-paths, and their order),
//! - the `historical_schemas()` registry from `#[obj(history(...))]`,
//! - and — transitively, via the `serde::Serialize` impl the derive
//!   relies on — the on-disk byte encoding of a stored record.
//!
//! This test pins all of those for one representative struct that
//! exercises every index kind (standard + unique + each + composite),
//! a non-default `#[obj(version = N)]`, and `#[obj(history(...))]`. A
//! future derive change that alters index emission, the schema
//! version, or the encoded bytes will break this test even when the
//! macro's public signature is unchanged.
//!
//! Only the public `obj` API is used, plus `obj_core::codec::{encode,
//! decode}` (a normal dependency of `obj`, already reachable from
//! these tests) to assert the BYTE-LEVEL on-disk record — the one
//! contract a self-consistent encode/decode round-trip alone cannot
//! pin.

#![forbid(unsafe_code)]

use obj::{Document, DynamicSchema, IndexKind, Schema};
use obj_core::codec::{decode, encode, DOC_HEADER_SIZE};
use obj_core::pager::checksum::crc32c;
use serde::{Deserialize, Serialize};

// ---------- Historical version referenced by `history(...)`. ----------

/// The v1 shape of `Account`. Carries a hand-written `Schema` so the
/// derive's `history(v1 = AccountV1)` can lift it into the registry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AccountV1 {
    owner: String,
    balance_cents: u64,
}

impl Schema for AccountV1 {
    fn schema() -> DynamicSchema {
        DynamicSchema::map([
            ("owner", DynamicSchema::String),
            ("balance_cents", DynamicSchema::U64),
        ])
    }
}

// ---------- The representative current type. ----------
//
// Exercises every supported derive surface in one struct:
// - struct-level `version` + `collection` overrides,
// - struct-level `history(...)`,
// - field-level `index` (standard), `index = unique`, `index = each`,
// - struct-level short-form composite `index = (..)`.

#[allow(clippy::struct_field_names)] // field names mirror the index spec; fixed by contract
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, obj::Document)]
#[obj(version = 2, collection = "accounts")]
#[obj(history(v1 = AccountV1))]
#[obj(index = ("owner", "opened_at"))]
struct Account {
    #[obj(index = unique)]
    owner: String,

    #[obj(index)]
    region: u32,

    #[obj(index = each)]
    tags: Vec<String>,

    opened_at: u64,
    balance_cents: u64,
}

/// A fixed sample value. Every byte of its encoding is pinned below,
/// so this MUST stay constant — changing it requires re-pinning the
/// expected bytes.
fn sample() -> Account {
    Account {
        owner: "ada".to_owned(),
        region: 7,
        tags: vec!["gold".to_owned(), "beta".to_owned()],
        opened_at: 1_700_000_000,
        balance_cents: 4_242,
    }
}

/// The `collection_id` the codec stamps into the record header. The
/// catalog assigns this at runtime; for a byte-level pin we choose a
/// fixed value and assert against it.
const SAMPLE_COLLECTION_ID: u32 = 3;

#[test]
fn collection_and_version_constants_are_pinned() {
    assert_eq!(<Account as Document>::COLLECTION, "accounts");
    assert_eq!(<Account as Document>::VERSION, 2);
}

#[test]
fn indexes_list_is_pinned_exactly() {
    let specs = <Account as Document>::indexes();
    // Field indexes emit first (declaration order), then struct-level
    // composites (declaration order) — the documented contract.
    assert_eq!(specs.len(), 4, "owner(unique) + region + tags + composite");

    assert_eq!(specs[0].name, "owner");
    assert_eq!(specs[0].kind, IndexKind::Unique);
    assert_eq!(specs[0].key_paths, vec!["owner".to_owned()]);

    assert_eq!(specs[1].name, "region");
    assert_eq!(specs[1].kind, IndexKind::Standard);
    assert_eq!(specs[1].key_paths, vec!["region".to_owned()]);

    assert_eq!(specs[2].name, "tags");
    assert_eq!(specs[2].kind, IndexKind::Each);
    assert_eq!(specs[2].key_paths, vec!["tags".to_owned()]);

    assert_eq!(specs[3].name, "owner__opened_at");
    assert_eq!(specs[3].kind, IndexKind::Composite);
    assert_eq!(
        specs[3].key_paths,
        vec!["owner".to_owned(), "opened_at".to_owned()],
    );
}

#[test]
fn historical_schemas_registry_is_pinned() {
    let history = <Account as Document>::historical_schemas();
    assert_eq!(history.len(), 1, "one historical version registered");
    assert_eq!(history[0].0, 1, "v1 entry");
    assert_eq!(history[0].1, AccountV1::schema());
}

#[test]
fn current_schema_matches_field_layout() {
    // The derive auto-emits `Schema` for the current type (because
    // `history(...)` is present). Pin the field set + types.
    let schema = <Account as Schema>::schema();
    assert_eq!(
        schema,
        DynamicSchema::map([
            ("owner", DynamicSchema::String),
            ("region", DynamicSchema::U64),
            ("tags", DynamicSchema::seq(DynamicSchema::String)),
            ("opened_at", DynamicSchema::U64),
            ("balance_cents", DynamicSchema::U64),
        ]),
    );
}

#[test]
fn encoded_record_round_trips() {
    let value = sample();
    let bytes = encode(&value, SAMPLE_COLLECTION_ID).expect("encode");
    let back: Account = decode(&bytes, SAMPLE_COLLECTION_ID).expect("decode");
    assert_eq!(back, value, "decode(encode(x)) must equal x");
}

#[test]
fn encoded_record_header_fields_are_pinned() {
    let value = sample();
    let bytes = encode(&value, SAMPLE_COLLECTION_ID).expect("encode");
    assert!(bytes.len() > DOC_HEADER_SIZE);
    // Header is 16 bytes, all little-endian (see codec::header).
    let collection_id = u32::from_le_bytes(bytes[0..4].try_into().expect("4"));
    let type_version = u32::from_le_bytes(bytes[4..8].try_into().expect("4"));
    let payload_len = u32::from_le_bytes(bytes[8..12].try_into().expect("4"));
    let payload_crc32c = u32::from_le_bytes(bytes[12..16].try_into().expect("4"));
    let payload = &bytes[DOC_HEADER_SIZE..];

    assert_eq!(collection_id, SAMPLE_COLLECTION_ID);
    assert_eq!(type_version, 2, "header pins Account::VERSION = 2");
    assert_eq!(payload_len as usize, payload.len());
    assert_eq!(payload_crc32c, crc32c(payload), "header CRC pins payload");
}

#[test]
fn encoded_payload_bytes_are_pinned() {
    // The serde-derived postcard payload is the frozen on-disk
    // encoding. Pin it byte-for-byte so a future serde/derive/postcard
    // change that alters the layout is caught here.
    //
    // postcard layout for `Account` (field order = declaration order):
    //   owner: "ada"        → len(3) + b"ada"
    //   region: 7u32        → varint 0x07
    //   tags: ["gold","beta"] → len(2) + len(4)+b"gold" + len(4)+b"beta"
    //   opened_at: 1_700_000_000u64 → varint
    //   balance_cents: 4_242u64     → varint
    let value = sample();
    let bytes = encode(&value, SAMPLE_COLLECTION_ID).expect("encode");
    let payload = &bytes[DOC_HEADER_SIZE..];
    assert_eq!(
        payload, EXPECTED_PAYLOAD,
        "encoded payload diverged from the frozen golden bytes",
    );
}

/// Frozen postcard payload of `sample()`. Captured from the current
/// build; a change here means the on-disk encoding moved.
///
/// Breakdown (postcard, declaration order):
///   `[3]`                         owner len = 3
///   `[97, 100, 97]`               b"ada"
///   `[7]`                         region = 7u32 (varint)
///   `[2]`                         tags len = 2
///   `[4, 103, 111, 108, 100]`     len(4) + b"gold"
///   `[4, 98, 101, 116, 97]`       len(4) + b"beta"
///   `[128, 226, 207, 170, 6]`     `opened_at` = `1_700_000_000u64` (varint)
///   `[146, 33]`                   `balance_cents` = `4_242u64` (varint)
const EXPECTED_PAYLOAD: &[u8] = &[
    3, 97, 100, 97, 7, 2, 4, 103, 111, 108, 100, 4, 98, 101, 116, 97, 128, 226, 207, 170, 6, 146,
    33,
];