andadbderive Technical Documentation

June 19, 2026 · View on GitHub

Crate version: 0.5 Last updated: 2026-06-11


Table of Contents

  1. Overview
  2. Derive Macros
  3. Attributes
  4. Type Inference Rules
  5. The field_type DSL
  6. Usage Examples
  7. Internal Implementation
  8. Diagnostics

1. Overview

1.1 Purpose

anda_db_derive is a procedural-macro crate that generates AndaDB schema glue code from ordinary Rust structs. Two derives are exported and re-exported through anda_db_schema:

MacroGenerated method
FieldTypedpub fn field_type() -> FieldType
AndaDBSchemapub fn schema() -> Result<Schema, SchemaError>

Both macros operate on structs with named fields. Tuple structs, unit structs, enums and unions are rejected with a compile_error!.

1.2 Crate layout

rs/anda_db_derive/
├── src/
│   ├── lib.rs           # public macro entry points
│   ├── schema.rs        # AndaDBSchema implementation
│   ├── field_typed.rs   # FieldTyped implementation
│   └── common.rs        # shared parsing / type-inference helpers
├── Cargo.toml
└── README.md

1.3 Required scope

Generated code references FieldType, FieldKey, FieldEntry, Schema and SchemaError by bare name. The recommended pattern is to import them via the anda_db_schema prelude (or a glob import) at the call site.


2. Derive Macros

2.1 FieldTyped

#[proc_macro_derive(FieldTyped, attributes(field_type, cbor))]

For each named field, the macro emits one (key, FieldType) tuple and packs them into a FieldType::Map. The resulting field_type() is what AndaDBSchema (and determine_field_type) call when they encounter a nested user-defined struct.

use anda_db_schema::{FieldType, FieldTyped};

#[derive(FieldTyped)]
struct User {
    id: u64,
    name: String,
    age: u32,
}

// Expanded:
impl User {
    pub fn field_type() -> FieldType {
        FieldType::Map(
            vec![
                ("id".into(),   FieldType::U64),
                ("name".into(), FieldType::Text),
                ("age".into(),  FieldType::U64),
            ]
            .into_iter()
            .collect(),
        )
    }
}

2.2 AndaDBSchema

#[proc_macro_derive(AndaDBSchema, attributes(field_type, unique))]

Builds a complete Schema via Schema::builder(). Declaring the _id: u64 field is optional — the builder injects the primary-key column either way. When declared, the macro validates at compile time that it is u64 and that serde keeps serializing it as "_id" (beware rename_all rules), and skips it during code generation.

use anda_db_derive::AndaDBSchema;
use anda_db_schema::{FieldEntry, FieldType, Schema, SchemaError};

#[derive(AndaDBSchema)]
struct Article {
    /// Article unique identifier
    _id: u64,
    /// Article title
    title: String,
    /// Article content
    content: String,
    /// View count
    views: u64,
}

// Expanded (abbreviated):
impl Article {
    pub fn schema() -> Result<Schema, SchemaError> {
        let mut builder = Schema::builder();
        builder.add_field(
            FieldEntry::new("title".to_string(), FieldType::Text)?
                .with_description("Article title".to_string()),
        )?;
        builder.add_field(
            FieldEntry::new("content".to_string(), FieldType::Text)?
                .with_description("Article content".to_string()),
        )?;
        builder.add_field(
            FieldEntry::new("views".to_string(), FieldType::U64)?
                .with_description("View count".to_string()),
        )?;
        builder.build()
    }
}

3. Attributes

AttributeApplies toEffect
#[field_type = "..."]FieldTyped, AndaDBSchemaOverride the inferred field type using the DSL.
#[cbor(key = N)]FieldTyped onlyUse an integer CBOR map key for a nested field, matching cbor2::Cbor integer-keyed structs.
#[unique]AndaDBSchema onlyAdds FieldEntry::with_unique() to the generated entry.
#[serde(rename = "name")]bothUse the serialized name as the schema field name (directional form: the serialize half is used).
#[serde(rename_all = "...")]both (container level)Apply serde's case rule to all fields without an explicit rename, mirroring serde's precedence.
#[serde(skip)] / #[serde(skip_serializing)]bothThe field never appears in serialized output and is excluded from the schema / type map.
#[serde(flatten)]bothRejected with a compile error: flattened keys cannot be described by a per-field schema entry.
#[serde(transparent)]both (container level)Rejected with a compile error: the struct serializes as its inner field, not as a map.
/// doc commentAndaDBSchema onlyAll /// lines on a field are joined with a space and emitted as FieldEntry::with_description().

Notes:

  • #[unique] is silently ignored by FieldTyped because that macro does not generate a Schema.
  • Multiple /// lines are concatenated; empty doc lines are dropped before joining.
  • Only the first serde(rename = "...") is honoured; other serde syntax that fails to parse is skipped without raising an error. #[serde(with = "...")] / serialize_with may change the serialized shape — combine them with an explicit #[field_type = "..."] override when they do.
  • FieldTyped prefers #[cbor(key = N)] over serde names for nested struct fields. This lets CBOR-native value types such as CWT-style claims model integer labels without losing schema validation.
  • AndaDBSchema validates every resulting field name against AndaDB's naming rules ([a-z0-9_], at most 64 bytes) at compile time, and also rejects duplicate names and collisions with the reserved _id column. Keys of nested FieldTyped maps are free-form, so case rules like camelCase are fine there, while on AndaDBSchema they are only usable when the resulting names stay valid (snake_case / lowercase, or explicit renames).

4. Type Inference Rules

When #[field_type] is not present, the macros walk the field's Rust type and produce a FieldType token stream.

4.1 Primitives

Rust typeFieldType
boolBool
i8, i16, i32, i64, isizeI64
u8, u16, u32, u64, usizeU64
f32F32
f64F64

4.2 Strings and bytes

Rust typeFieldType
String, &strText
Vec<u8>, [u8; N]Bytes
Bytes, ByteArray, ByteBufBytes
BytesB64, ByteArrayB64, ByteBufB64Bytes
serde_bytes::Bytes, serde_bytes::ByteArray, serde_bytes::ByteBufBytes

4.3 Vectors and JSON

Rust typeFieldType
Vec<bf16>, [bf16; N]Vector
Json, serde_json::ValueJson

Bare bf16 (or half::bf16) is not a valid field type. Wrap it in a Vec to obtain Vector, or annotate the field with #[field_type = "F32"] if a scalar is desired.

4.4 Collections

Rust typeFieldType
Vec<T>, HashSet<T>, BTreeSet<T>Array(T)
[T; N] (with T a supported non-byte/non-bf16 type)Array(T)
HashMap<K, V>, BTreeMap<K, V>, serde_json::Map<…>Map({"*" => V})

For maps the key K must be one of:

  • a string-like type (String, &str) → wildcard text key "*"
  • a signed integer type (i8, i16, i32, i64, isize) → wildcard integer key i64::MIN
  • a bytes-like type (Vec<u8>, Bytes, ByteArray, ByteBuf, *B64) → wildcard bytes key b"*"

Any other key type is a compile error.

4.5 Optionality, smart pointers and user-defined types

Rust typeFieldType
Option<T>Option(T)
Box<T> / Arc<T> / Rc<T> / Cow<'_, T>the inner T (serde serializes these transparently)
Any other path Foo (incl. Foo<G>)<Foo>::field_type()must derive FieldTyped

Selected fully qualified paths are recognised explicitly even if the leading segment is not the type name:

  • serde_bytes::Bytes / ByteArray / ByteBufBytes
  • serde_json::ValueJson
  • half::bf16 → compile error (with guidance)

Parenthesized types ((String)) and the invisible groups produced by macro_rules! substitution are unwrapped transparently, so macro-generated structs infer the same way as hand-written ones.


5. The field_type DSL

The string passed to #[field_type = "..."] is parsed by parse_field_type_str and accepts the grammar below (whitespace anywhere is ignored):

type        := primitive | array | option | map
primitive   := "Bytes" | "Text" | "U64" | "I64"
             | "F64"   | "F32"  | "Bool" | "Json" | "Vector"
array       := "Array<" type ">"
option      := "Option<" type ">"
map         := "Map<" map_key "," type ">"
map_key     := "String" | "Text" | "I64" | "Bytes"

5.1 String / Text equivalence

String and Text are synonyms for map keys: FieldType only has a Text variant, but Map<String, T> reads more naturally for users coming from HashMap<String, _>. Both:

#[field_type = "Map<String, Json>"]
#[field_type = "Map<Text, Json>"]

expand to the same wildcard Map({"*" => Json}).

Signed integer map keys use I64 and expand to the integer wildcard key i64::MIN:

#[field_type = "Map<I64, Text>"]

5.2 Examples

DSL stringFieldType
"Bytes"Bytes
"Array<U64>"Array(U64)
"Option<Text>"Option(Text)
"Map<String, Json>"Map({"*" => Json})
"Map<Text, Array<U64>>"Map({"*" => Array(U64)})
"Map<I64, Text>"Map({i64::MIN => Text})
"Map<Bytes, F64>"Map({b"*" => F64})
"Option<Map<Bytes, F64>>"Option(Map({b"*" => F64}))

5.3 Diagnostic guarantees

Unrecognised input produces a compile_error! at the original macro span:

Unsupported field type: '...'. Supported types: Bytes, Text, U64, I64,
F64, F32, Bool, Json, Vector, Array<T>, Option<T>, Map<String, T>,
Map<Text, T>, Map<I64, T>, Map<Bytes, T>
Unsupported Map key type: '...'. Expected 'String', 'Text', 'I64' or 'Bytes'.
Invalid Map field type: '...'. Expected 'Map<KeyType, ValueType>'.

6. Usage Examples

6.1 Full schema with mixed features

use anda_db_derive::AndaDBSchema;
use anda_db_schema::{Document, FieldEntry, FieldType, Fv, Schema, SchemaError, bf16};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(AndaDBSchema, Serialize, Deserialize, Debug)]
struct Article {
    /// AndaDB-managed primary key
    _id: u64,
    /// Article title (uniquely indexed)
    #[unique]
    title: String,
    /// Article body
    content: String,
    /// Cumulative view counter
    views: u64,
    /// Publication flag
    published: bool,
    /// Optional list of tags
    tags: Option<Vec<String>>,
    /// Free-form author metadata
    author_meta: Option<serde_json::Value>,
    /// Embedding vector
    embedding: Vec<bf16>,
}

fn main() -> Result<(), SchemaError> {
    let schema = Arc::new(Article::schema()?);

    let mut doc = Document::new(schema.clone());
    doc.set_id(1);
    doc.set_field("title",     Fv::Text("Hello World".into()))?;
    doc.set_field("content",   Fv::Text("This is the body".into()))?;
    doc.set_field("views",     Fv::U64(0))?;
    doc.set_field("published", Fv::Bool(true))?;
    doc.set_field("embedding", Fv::Vector(vec![bf16::from_f32(0.1); 512]))?;
    Ok(())
}

6.2 Nesting a FieldTyped struct

use anda_db_derive::{AndaDBSchema, FieldTyped};
use anda_db_schema::FieldType;

#[derive(FieldTyped, Debug)]
struct GeoLocation {
    latitude: f64,
    longitude: f64,
}

#[derive(AndaDBSchema)]
struct Place {
    _id: u64,
    name: String,
    /// `GeoLocation::field_type()` is invoked at expansion time.
    location: GeoLocation,
}

6.3 Overriding inferred types

use anda_db_derive::AndaDBSchema;
use ic_auth_types::Xid;

#[derive(AndaDBSchema)]
struct Transaction {
    _id: u64,
    /// Treat the `Xid` newtype as a fixed-length byte field.
    #[field_type = "Bytes"]
    tx_id: Xid,
    /// Optional list of byte ids — three nested DSL constructs in one go.
    #[field_type = "Option<Array<Bytes>>"]
    inputs: Option<Vec<Xid>>,
    amount: u64,
}

7. Internal Implementation

7.1 common.rs

SymbolResponsibility
named_fieldsShared "struct with named fields" validation for both derives.
parse_container_serde_attrsExtract rename_all (incl. directional form) and transparent.
parse_field_serde_attrsExtract rename (incl. directional form), skip* and flatten.
RenameRuleserde-compatible rename_all case conversion.
effective_field_nameResolve the serialized name (explicit rename wins over rename_all).
validate_schema_field_nameCompile-time mirror of anda_db_schema::validate_field_name.
resolve_field_type#[field_type] override or fall back to inference.
find_field_type_attrExtract and parse #[field_type = "..."].
parse_field_type_strCompile the field_type DSL into a FieldType token stream.
determine_field_typeInfer FieldType directly from a syn::Type.
is_u8_typePredicate for u8.
is_string_typePredicate for String / str.
is_bytes_typePredicate for the supported byte container types.
is_bf16_typePredicate for bf16.
is_u64_typePredicate used to validate the _id: u64 requirement.

All fallible helpers return syn::Result with errors spanned at the offending field, type or attribute, so diagnostics point at the user's code instead of the #[derive(...)] line.

Map parsing details

parse_field_type_str finds the top-level comma inside Map<...> by counting angle-bracket depth. This is what allows nested types such as Map<Text, Array<U64>>, Map<I64, Text>, or Option<Map<Bytes, F64>> to parse correctly. Whitespace is trimmed on both sides of every separator, so writes like Map< Text , Array<U64> > are also accepted.

7.2 schema.rs

Pipeline executed by anda_db_schema_derive:

  1. Parse the input as DeriveInput.
  2. Reject anything that is not a struct with named fields, and reject #[serde(transparent)].
  3. For every field:
    • if the field is _id, verify it is u64 and still serializes as "_id", then skip code generation;
    • skip fields marked #[serde(skip)] / #[serde(skip_serializing)], reject #[serde(flatten)];
    • resolve the serialized name (explicit rename > rename_all > Rust identifier) and validate it against AndaDB's naming rules, the reserved _id column and previously seen names;
    • resolve the field type via #[field_type] or determine_field_type;
    • extract /// doc comments (joined with spaces) and #[unique], then compose FieldEntry::new(...)?[.with_description(...)][.with_unique()].
  4. Emit impl <Struct> { pub fn schema() -> Result<Schema, SchemaError> { … } }. Per-field errors are emitted in place (as spanned compile_error!s) so every offending field is reported in a single compilation pass.

7.3 field_typed.rs

Same parse / validation prelude as schema.rs (minus the AndaDB naming restrictions — nested map keys are free-form). For each serialized field the macro produces a (key, <field_type>) tuple and collects them into a single FieldType::Map. The key is normally the serde serialized field name; when a field has #[cbor(key = N)], the generated key is FieldKey::from(N) so the schema mirrors CBOR integer-keyed maps.

7.4 Worked example

Input:

#[derive(AndaDBSchema)]
struct User {
    _id: u64,
    /// User's email
    #[unique]
    email: String,
}

Expansion:

impl User {
    pub fn schema() -> Result<Schema, SchemaError> {
        let mut builder = Schema::builder();
        builder.add_field(
            FieldEntry::new("email".to_string(), FieldType::Text)?
                .with_description("User's email".to_string())
                .with_unique(),
        )?;
        builder.build()
    }
}

8. Diagnostics

8.1 Compile-time errors

All messages are spanned at the offending field, type or attribute. Types in messages are rendered as Rust source (e.g. (u64, u64)), not as AST dumps.

MessageCause
FieldTyped only supports structsApplied to an enum or union.
FieldTyped only supports structs with named fieldsApplied to a tuple or unit struct.
AndaDBSchema only supports structsApplied to an enum or union.
AndaDBSchema only supports structs with named fieldsApplied to a tuple or unit struct.
The '_id' field must be of type u64The struct declares _id with a type other than u64.
serde renames _id to "...", but the primary key must serialize as "_id"; …rename_all/rename changes how _id serializes.
field "..." serializes as "_id", which collides with the auto-generated primary keyAnother field is renamed to _id.
schema field name "..." is not a valid AndaDB field name (...)The serialized name violates [a-z0-9_]{1,64} (AndaDBSchema only).
duplicate schema field name "..." (after serde renaming)Two fields end up with the same serialized name.
#[serde(flatten)] is not supported: …Flattened keys cannot be described by a per-field schema entry.
… does not support #[serde(transparent)]: …Transparent structs do not serialize as maps.
unknown #[serde(rename_all = "...")] rule; …Unrecognised case rule (would silently desync schema and data otherwise).
Unsupported field type: '...'. Supported types: …DSL string in #[field_type] was not recognised.
Unsupported Map key type: '...'. Expected 'String', 'Text', 'I64' or 'Bytes'.Unsupported key in Map<K, V> DSL.
Invalid Map field type: '...'. Expected 'Map<KeyType, ValueType>'.DSL Map<…> string lacks a comma-separated key/value pair.
Unsupported type: \...`. Consider: …`Inference failed (tuples, trait objects, etc.).
Unable to determine Vec element type for: ...Generic argument missing on a Vec / HashSet / BTreeSet.
Unable to determine Option element typeGeneric argument missing on an Option.
Unable to determine the inner type of: ...Generic argument missing on a Box / Arc / Rc / Cow.
Map key type must be String, signed integer, or bytes (e.g., Vec<u8>, ByteArray, ByteBuf), found: …HashMap/BTreeMap key inferred as something neither string-, signed integer-, nor bytes-like.
Standalone \bf16` is not supported as a field type. Use `Vec` …`Bare bf16 field without Vec/override.

8.2 Runtime errors

schema() ultimately calls into Schema::builder().build(), which can fail with:

VariantDescription
SchemaError::FieldNameInvalid field name.
SchemaError::FieldTypeInvalid field type.
SchemaError::ValidationSchema-level validation failed.

Document updated: 2026-06-11