modelvault_core/schema.rs
1//! Collection identity, field paths, logical [`Type`] values, and the [`DbModel`] marker trait.
2
3use std::borrow::Cow;
4
5use crate::error::{DbError, SchemaError};
6
7pub use crate::schema_compat::classify_schema_update;
8
9/// Stable numeric id for a registered collection (assigned at create time, starting at `1`).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct CollectionId(pub u32);
12
13/// Monotonic schema version for one collection (starts at `1` on create; bumps on each new version).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct SchemaVersion(pub u32);
16
17/// Dot-style path segments for a field (v1 rows use single-segment top-level names only).
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
19pub struct FieldPath(pub Vec<Cow<'static, str>>);
20
21impl FieldPath {
22 /// Build a path from non-empty UTF-8 segments (rejects empty paths or empty segments).
23 pub fn new(parts: impl IntoIterator<Item = Cow<'static, str>>) -> Result<Self, DbError> {
24 let parts: Vec<Cow<'static, str>> = parts.into_iter().collect();
25 // Use `|` so both checks are evaluated; `llvm-cov` line coverage otherwise marks `||` as partial.
26 if parts.is_empty() | parts.iter().any(|p| p.is_empty()) {
27 return Err(DbError::Schema(SchemaError::InvalidFieldPath));
28 }
29 Ok(Self(parts))
30 }
31}
32
33pub(crate) fn validate_field_defs(fields: &[FieldDef]) -> Result<(), DbError> {
34 // Basic path validation (in case callers constructed `FieldPath` directly).
35 for f in fields {
36 if f.path.0.is_empty() | f.path.0.iter().any(|s| s.is_empty()) {
37 return Err(DbError::Schema(SchemaError::InvalidFieldPath));
38 }
39 }
40
41 // Duplicates.
42 let mut seen: std::collections::HashSet<&FieldPath> = std::collections::HashSet::new();
43 for f in fields {
44 if !seen.insert(&f.path) {
45 return Err(DbError::Schema(SchemaError::InvalidFieldPath));
46 }
47 }
48
49 // Parent/child conflicts (e.g. `a` and `a.b`).
50 for (i, a) in fields.iter().enumerate() {
51 for b in fields.iter().skip(i + 1) {
52 let pa = &a.path.0;
53 let pb = &b.path.0;
54 let min = pa.len().min(pb.len());
55 // Non-short-circuit `&` so llvm-cov counts both comparisons on this line.
56 if (pa.len() != pb.len()) & (pa[..min] == pb[..min]) {
57 return Err(DbError::Schema(SchemaError::InvalidFieldPath));
58 }
59 }
60 }
61
62 Ok(())
63}
64
65/// Logical type of a field in the catalog (mirrors encoding in record payloads where supported).
66#[derive(Debug, Clone, PartialEq)]
67pub enum Type {
68 /// Boolean.
69 Bool,
70 /// Signed 64-bit integer.
71 Int64,
72 /// Unsigned 64-bit integer.
73 Uint64,
74 /// IEEE-754 double.
75 Float64,
76 /// UTF-8 string.
77 String,
78 /// Raw bytes.
79 Bytes,
80 /// 16-byte UUID (canonical record encoding uses tagged bytes).
81 Uuid,
82 /// Signed epoch milliseconds (or engine-defined timestamp unit).
83 Timestamp,
84 /// Value may be absent (`None`).
85 Optional(Box<Type>),
86 /// Homogeneous list.
87 List(Box<Type>),
88 /// Fixed set of nested fields (struct-like).
89 Object(Vec<FieldDef>),
90 /// Tagged union of string variants.
91 Enum(Vec<String>),
92}
93
94/// Declarative constraint on a field (0.6+). Evaluated on insert after type checks.
95#[derive(Debug, Clone, PartialEq)]
96pub enum Constraint {
97 /// Minimum inclusive for signed integers (`Int64`).
98 MinI64(i64),
99 /// Maximum inclusive for signed integers (`Int64`).
100 MaxI64(i64),
101 /// Minimum inclusive for unsigned integers (`Uint64`).
102 MinU64(u64),
103 /// Maximum inclusive for unsigned integers (`Uint64`).
104 MaxU64(u64),
105 /// Minimum inclusive for floats (`Float64`).
106 MinF64(f64),
107 /// Maximum inclusive for floats (`Float64`).
108 MaxF64(f64),
109 /// Minimum UTF-8 byte length (`String`) or element count (`List`).
110 MinLength(u64),
111 /// Maximum UTF-8 byte length (`String`) or element count (`List`).
112 MaxLength(u64),
113 /// Rust regex syntax (applied to `String`).
114 Regex(String),
115 /// Loose email shape check (`String`).
116 Email,
117 /// `http`/`https` URL prefix check (`String`).
118 Url,
119 /// Non-empty string, bytes, or list.
120 NonEmpty,
121}
122
123/// One field’s path, type, and optional constraints within a collection schema.
124#[derive(Debug, Clone, PartialEq)]
125pub struct FieldDef {
126 pub path: FieldPath,
127 pub ty: Type,
128 pub constraints: Vec<Constraint>,
129}
130
131impl FieldDef {
132 pub fn new(path: FieldPath, ty: Type) -> Self {
133 Self {
134 path,
135 ty,
136 constraints: Vec::new(),
137 }
138 }
139}
140
141/// Kind of secondary index.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum IndexKind {
144 /// Enforces a uniqueness constraint: one primary key per indexed value.
145 Unique,
146 /// Non-unique index: many primary keys per indexed value.
147 NonUnique,
148}
149
150/// Secondary index definition for one collection schema.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct IndexDef {
153 /// Stable identifier within a collection schema (e.g. `"email_unique"`).
154 pub name: String,
155 /// Field path whose scalar value is indexed (may be nested, e.g. `["profile","timezone"]`).
156 pub path: FieldPath,
157 pub kind: IndexKind,
158}
159
160/// High-level description of a collection (name, version, fields); used by tooling and derives.
161#[derive(Debug, Clone, PartialEq)]
162pub struct CollectionSchema {
163 pub name: String,
164 pub version: SchemaVersion,
165 pub fields: Vec<FieldDef>,
166 pub id: Option<CollectionId>,
167}
168
169/// Marker trait for Rust types that map to ModelVault collection records.
170///
171/// Implement via `#[derive(DbModel)]` from the optional `modelvault-derive` crate (re-exported by the
172/// `modelvault` facade when the **`derive`** feature is enabled).
173pub trait DbModel {
174 fn collection_name() -> &'static str;
175 fn fields() -> Vec<FieldDef>;
176 fn primary_field() -> &'static str;
177 fn indexes() -> Vec<IndexDef> {
178 Vec::new()
179 }
180}
181
182/// Compatibility classification for a proposed schema update.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum SchemaChange {
185 /// Update is safe to apply without rewriting existing data.
186 Safe,
187 /// Update is supported, but existing data must be rewritten/backfilled first.
188 NeedsMigration {
189 reason: String,
190 /// Top-level field name to backfill when adding a new required single-segment field.
191 backfill_top_level_field: Option<String>,
192 /// Full path to backfill when adding a new required field (any segment count).
193 backfill_field_path: Option<FieldPath>,
194 },
195 /// Update is not supported/safe and should be rejected by default.
196 Breaking { reason: String },
197}
198
199// (schema compatibility policy lives in `schema_compat.rs`; re-exported above)
200
201#[cfg(test)]
202mod tests {
203 include!(concat!(
204 env!("CARGO_MANIFEST_DIR"),
205 "/tests/unit/src_schema_tests.rs"
206 ));
207}