Crate version: 0.14 Last updated: 2026-09-06
- Overview
- Derive Macros
- Attributes
- Type Inference Rules
- The
field_typeDSL - Usage Examples
- Internal Implementation
- Diagnostics
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:
| Macro | Generated method |
|---|---|
FieldTyped |
field_type() -> FieldType and try_field_type() -> Result<FieldType, SchemaError> |
AndaDBSchema |
pub 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!.
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
Generated code resolves schema items through the direct anda_db_schema
dependency (including renamed dependencies), or through anda_db::schema when
using the umbrella crate. Users do not need to import Schema, FieldType,
FieldEntry or FieldKey for the generated code.
#[proc_macro_derive(FieldTyped, attributes(field_type, cbor, serde))]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,
}
assert!(User::try_field_type().is_ok());#[proc_macro_derive(AndaDBSchema, attributes(field_type, unique, cbor, serde))]Builds a complete Schema via Schema::builder(). Declaring the _id: u64
field is required — the builder injects its metadata, while the struct
must serialize it. 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()
}
}| Attribute | Applies to | Effect |
|---|---|---|
#[field_type = "..."] |
FieldTyped, AndaDBSchema |
Override the inferred field type using the DSL. |
#[cbor(key = N)] |
FieldTyped only |
Use an integer CBOR map key for a nested field, matching cbor2::Cbor integer-keyed structs. |
#[unique] |
AndaDBSchema only |
Adds FieldEntry::with_unique() to the generated entry. |
#[serde(rename = "name")] |
both | Use 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)] |
both | The field never appears in serialized output and is excluded from the schema / type map. |
#[serde(flatten)] |
both | Rejected 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 comment |
AndaDBSchema only |
All /// lines on a field are joined with a space and emitted as FieldEntry::with_description(). |
Notes:
#[unique]is silently ignored byFieldTypedbecause that macro does not generate aSchema.- 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_withmay change the serialized shape — combine them with an explicit#[field_type = "..."]override when they do. FieldTypedprefers#[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.AndaDBSchemavalidates 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_idcolumn. Keys of nestedFieldTypedmaps are free-form, so case rules likecamelCaseare fine there, while onAndaDBSchemathey are only usable when the resulting names stay valid (snake_case/lowercase, or explicit renames).
When #[field_type] is not present, the macros walk the field's Rust
type and produce a FieldType token stream.
| Rust type | FieldType |
|---|---|
bool |
Bool |
i8, i16, i32, i64, isize |
I64 |
u8, u16, u32, u64, usize |
U64 |
f32 |
F32 |
f64 |
F64 |
| Rust type | FieldType |
|---|---|
String, &str |
Text |
Vec<u8>, [u8; N] |
Bytes |
Bytes, ByteArray, ByteBuf |
Bytes |
BytesB64, ByteArrayB64, ByteBufB64 |
Bytes |
serde_bytes::Bytes, serde_bytes::ByteArray, serde_bytes::ByteBuf |
Bytes |
Vec<u8> and [u8; N] have no byte-string specialization in serde: they
serialize one integer per byte, which the schema then coerces to Bytes. For
large binary payloads use serde_bytes::ByteBuf or ByteBufB64, which
serialize as a byte string; a 64 KiB Vec<u8> field makes
Document::try_from several hundred times slower than the same payload as
ByteBuf.
| Rust type | FieldType |
|---|---|
Vec<bf16>, [bf16; N] |
Vector |
Json, serde_json::Value |
Json |
Bare
bf16(orhalf::bf16) is not a valid field type. Wrap it in aVecto obtainVector. A scalar override requires both#[field_type = "F32"]and#[serde(serialize_with = "bf16::serialize_as_f32")], because the default bf16 serializer writes integer bit patterns rather than a float.
| Rust type | FieldType |
|---|---|
Vec<T>, VecDeque<T>, LinkedList<T>, BinaryHeap<T>, HashSet<T>, BTreeSet<T> |
Array(T) |
[T; N] (with T a supported non-byte/non-bf16 type) |
Array(T) |
(A, B, …) (two or more elements) |
Array([A, B, …]) — the tuple-like, fixed-length form |
(T,) (one element) |
compile error — one inner type already means a homogeneous array of any length |
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 keyi64::MIN - a bytes-like type (
Vec<u8>,[u8; N],Bytes,ByteArray,ByteBuf,*B64) → wildcard bytes keyb"*"
Any other key type is a compile error. Vec<u8> and [u8; N] keys reach
CBOR as integer arrays (serde has no byte-string specialization for them);
the schema side coerces that shape into Bytes keys, just as it does for
values.
| Rust type | FieldType |
|---|---|
Option<T> |
Option(T) |
Option<Option<T>> |
compile error — serde serializes Some(None) and None identically |
u128 / i128 |
compile error — AndaDB integers are 64-bit |
Box<T> / Arc<T> / Rc<T> / Cow<'_, T> |
the inner T (serde serializes these transparently) |
Any other path Foo (incl. Foo<G>) |
fallible construction for derived types; legacy field_type() methods remain supported |
Selected fully qualified paths are recognised explicitly even if the leading segment is not the type name:
serde_bytes::Bytes/ByteArray/ByteBuf→Bytesserde_json::Value→Jsonhalf::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.
The string passed to #[field_type = "..."] is parsed by
parse_field_type_str and accepts the grammar below (whitespace is normalized once before recursive parsing; nesting is bounded):
type := primitive | array | option | map
primitive := "Bytes" | "Text" | "U64" | "I64"
| "F64" | "F32" | "Bool" | "Json" | "Vector"
array := "Array<" type ">"
option := "Option<" type ">" -- type must not itself be an option
map := "Map<" map_key "," type ">"
map_key := "String" | "Text" | "I64" | "i8" | "i16" | "i32" | "i64" | "isize" | "Bytes"
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>"]The Rust spellings of the scalar types are accepted for every value type
in the DSL as synonyms of the FieldType names — String / str for
Text, u8 … u64 / usize for U64, i8 … i64 / isize for I64,
plus f32, f64 and bool — so an override can mirror the field's own
type, e.g. #[field_type = "Option<Array<u64>>"]. Map keys keep the
narrower set of the map_key rule above (String, Text, Bytes, I64
and i8 … isize), because those are the only key variants FieldKey has:
Map<str, T> and Map<u64, T> are compile errors. Option<Option<T>> is
rejected: serde serializes Some(None) and None identically.
| DSL string | FieldType |
|---|---|
"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})) |
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> (Rust spellings such as u64, i64,
f64, bool and String are accepted as synonyms)
Unsupported Map key type: '...'. Expected 'String', 'Text', 'I64' or 'Bytes'.
Invalid Map field type: '...'. Expected 'Map<KeyType, ValueType>'.
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(())
}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,
}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,
}Direct recursive fields through known containers (for example
Option<Box<Self>>) are rejected at compile time unless explicitly overridden.
Indirect recursion and type aliases are detected by try_field_type(), which
returns a SchemaError; schema() propagates that error for derived nested
types. The infallible field_type() wrapper panics on an invalid declaration.
A generic user-defined wrapper may intentionally hide its parameter from the
serialized shape, so that case is handled by the runtime construction guard.
"*" and i64::MIN cannot name fixed struct fields. Container serde tag and
into, container-level field_type, duplicate field_type/unique, and
non-marker forms of unique are rejected. Integer CBOR keys are rejected on
all top-level fields, including _id. Container #[cbor(array)] and
#[cbor(tag = ...)] are also rejected: both derives require untagged maps.
Borrowed and transparently wrapped map keys are inferred through their
serialized key types.
| Symbol | Responsibility |
|---|---|
named_fields |
Shared "struct with named fields" validation for both derives. |
parse_container_attrs |
Extract serde options; reject unsupported CBOR container shapes. |
parse_field_serde_attrs |
Extract rename (incl. directional form), skip* and flatten. |
RenameRule |
serde-compatible rename_all case conversion. |
effective_field_name |
Resolve the serialized name (explicit rename wins over rename_all). |
validate_schema_field_name |
Compile-time mirror of anda_db_schema::validate_field_name. |
resolve_field_type |
#[field_type] override or fall back to inference. |
find_field_type_attr |
Extract and parse #[field_type = "..."]. |
parse_field_type_str |
Compile the field_type DSL into a FieldType token stream. |
determine_field_type |
Infer FieldType directly from a syn::Type. |
is_u8_type |
Predicate for u8. |
is_string_type |
Predicate for String / str. |
is_bytes_type |
Predicate for the supported byte container types. |
is_bf16_type |
Predicate for bf16. |
is_u64_type |
Predicate 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.
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.
Pipeline executed by anda_db_schema_derive:
- Parse the input as
DeriveInput. - Reject anything that is not a struct with named fields, and reject
#[serde(transparent)]. - For every field:
- if the field is
_id, verify it isu64and 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_idcolumn and previously seen names; - resolve the field type via
#[field_type]ordetermine_field_type; - extract
///doc comments (joined with spaces) and#[unique], then composeFieldEntry::new(...)?[.with_description(...)][.with_unique()].
- if the field is
- Emit
impl <Struct> { pub fn schema() -> Result<Schema, SchemaError> { … } }. Per-field errors are emitted in place (as spannedcompile_error!s) so every offending field is reported in a single compilation pass.
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 generated fallible constructor uses a per-thread type-construction guard
and validates the finished declaration. 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.
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()
}
}All messages are spanned at the offending field, type or attribute. Types in
messages are rendered as Rust source (e.g. fn() -> u64), not as AST dumps.
| Message | Cause |
|---|---|
FieldTyped only supports structs |
Applied to an enum or union. |
FieldTyped only supports structs with named fields |
Applied to a tuple or unit struct. |
AndaDBSchema only supports structs |
Applied to an enum or union. |
AndaDBSchema only supports structs with named fields |
Applied to a tuple or unit struct. |
The '_id' field must be of type u64 |
The 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 key |
Another 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 (unit (), trait objects, bare functions, etc.). |
Option<Option<T>> cannot be described: … / Invalid field type: '...'. Option<Option<T>> is not supported: … |
Nested Option in a Rust type or in the DSL; serde cannot represent the inner level. |
\u128` is not representable: AndaDB integers are 64-bit …` |
u128 / i128 field without an override. |
Unable to determine element type for: ... |
Generic argument missing on a Vec / VecDeque / LinkedList / BinaryHeap / HashSet / BTreeSet. |
Unable to determine Option element type |
Generic 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>, [u8; N], 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. |
schema() ultimately calls into Schema::builder().build(), which can fail
with:
| Variant | Description |
|---|---|
SchemaError::FieldName |
Invalid field name. |
SchemaError::FieldType |
Invalid field type. |
SchemaError::Validation |
Schema-level validation failed. |
Document updated: 2026-09-06