obj_core/index/spec.rs
1//! [`IndexKind`] + [`IndexSpec`] — runtime declaration of a secondary
2//! index.
3//!
4//! `IndexSpec` is the *runtime* declaration a `Document` type emits
5//! from `Document::indexes()` (#57); the on-disk reflection is the
6//! `IndexDescriptor` in [`crate::catalog`]. The spec is small and
7//! pure-data so it composes cleanly with the derive macro that lands
8//! in M9.
9//!
10//! # Power-of-ten posture
11//!
12//! - **Rule 5.** [`IndexSpec::validate`] is the runtime boundary that
13//! pairs with the kind-specific type invariants (`Composite`
14//! requires ≥ 2 paths, `Each` requires exactly 1 path, etc.). The
15//! constructor helpers (`IndexSpec::unique`, `::each`, ...) call
16//! `validate` so a caller cannot construct an invalid spec.
17//! - **Rule 7.** No `unwrap` / `expect` — every fallible path
18//! returns [`crate::Result`].
19//! - **Rule 9.** `IndexKind` is a concrete enum, NOT `dyn IndexKind` —
20//! static dispatch through `match`.
21
22#![forbid(unsafe_code)]
23
24use serde::{Deserialize, Serialize};
25
26use crate::error::{Error, Result};
27
28/// What kind of secondary index a given [`IndexSpec`] declares.
29///
30/// The on-disk numeric discriminants are pinned by
31/// `docs/format.md` § Indexes — `IndexKind`. The `#[repr(u8)]`
32/// attribute mirrors the spec so a future format-version that
33/// streams the kind as a single byte (instead of postcard's
34/// variant-index varint) can do so without a migration.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[repr(u8)]
37#[non_exhaustive]
38pub enum IndexKind {
39 /// Non-unique scalar index. One B-tree entry per document — the
40 /// encoded key carries the document's `Id` as an 8-byte
41 /// big-endian suffix to keep the B-tree key globally unique
42 /// (the M4 B+tree rejects duplicate keys).
43 Standard = 0,
44 /// Unique scalar index. Encoded key is the user value alone (no
45 /// `Id` suffix); collisions are surfaced as
46 /// [`crate::Error::UniqueConstraintViolation`].
47 Unique = 1,
48 /// Multi-value index over a `Vec<T>` field. Emits one entry per
49 /// element of the indexed sequence. Like `Standard`, the
50 /// document's `Id` is appended to each encoded user key.
51 Each = 2,
52 /// Multi-field composite index. The B-tree key is the
53 /// concatenation of every encoded field in `key_paths` order,
54 /// prefixed by a single envelope tag byte. Composite indexes
55 /// also append the `Id` suffix (composite + unique is not a
56 /// supported combination in M7 — Composite is non-unique by
57 /// construction).
58 Composite = 3,
59}
60
61/// A runtime index declaration.
62///
63/// `Document::indexes()` returns a `Vec<IndexSpec>`; the catalog
64/// reconciler in #57 compares this list against the catalog's stored
65/// [`crate::catalog::IndexDescriptor`] rows and declares or drops
66/// the difference.
67///
68/// # Construction
69///
70/// Prefer the kind-specific constructors over building the struct
71/// literal — they enforce the per-kind path-count invariants:
72///
73/// ```
74/// use obj_core::index::IndexSpec;
75///
76/// // Standard / Unique / Each take exactly one field path.
77/// let by_email_unique = IndexSpec::unique("by_email", "email").expect("valid");
78/// let by_status = IndexSpec::standard("by_status", "status").expect("valid");
79/// let by_tag = IndexSpec::each("by_tag", "tags").expect("valid");
80///
81/// // Composite requires two or more.
82/// let by_customer_time = IndexSpec::composite(
83/// "by_customer_time",
84/// &["customer_id", "placed_at"],
85/// ).expect("valid");
86/// ```
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[non_exhaustive]
89pub struct IndexSpec {
90 /// User-visible name. Stable across reopens; the catalog uses it
91 /// to match a runtime spec against an on-disk descriptor.
92 pub name: String,
93 /// Discriminator. See [`IndexKind`].
94 pub kind: IndexKind,
95 /// Field path(s) within the document. Single-element for
96 /// `Standard` / `Unique` / `Each`; ≥ 2 for `Composite`.
97 pub key_paths: Vec<String>,
98}
99
100impl IndexSpec {
101 /// Construct a [`IndexKind::Standard`] spec.
102 ///
103 /// # Errors
104 ///
105 /// Returns [`Error::InvalidArgument`] if `name` or `path` is empty.
106 pub fn standard<N: Into<String>, P: Into<String>>(name: N, path: P) -> Result<Self> {
107 Self::scalar(IndexKind::Standard, name.into(), path.into())
108 }
109
110 /// Construct a [`IndexKind::Unique`] spec.
111 ///
112 /// # Errors
113 ///
114 /// As [`IndexSpec::standard`].
115 pub fn unique<N: Into<String>, P: Into<String>>(name: N, path: P) -> Result<Self> {
116 Self::scalar(IndexKind::Unique, name.into(), path.into())
117 }
118
119 /// Construct a [`IndexKind::Each`] spec. The indexed field MUST
120 /// be a sequence-valued field at extract time; if it is not,
121 /// `extract_index_keys` (#56) errors with
122 /// [`Error::IndexFieldTypeMismatch`].
123 ///
124 /// # Errors
125 ///
126 /// As [`IndexSpec::standard`].
127 pub fn each<N: Into<String>, P: Into<String>>(name: N, path: P) -> Result<Self> {
128 Self::scalar(IndexKind::Each, name.into(), path.into())
129 }
130
131 /// Construct a [`IndexKind::Composite`] spec.
132 ///
133 /// # Errors
134 ///
135 /// Returns [`Error::InvalidArgument`] if `name` is empty, if
136 /// `paths` has fewer than two entries, or if any path is empty.
137 pub fn composite<N: Into<String>>(name: N, paths: &[&str]) -> Result<Self> {
138 let owned: Vec<String> = paths.iter().map(|s| (*s).to_owned()).collect();
139 let spec = Self {
140 name: name.into(),
141 kind: IndexKind::Composite,
142 key_paths: owned,
143 };
144 spec.validate()?;
145 Ok(spec)
146 }
147
148 /// Construct a spec from its individual parts.
149 ///
150 /// Unlike the kind-specific constructors, this accepts an
151 /// arbitrary [`IndexKind`] plus a `key_paths` vector and is the
152 /// general entry point callers reach for when reconstructing a
153 /// spec from an on-disk [`crate::catalog::IndexDescriptor`] (where
154 /// the kind is data, not a compile-time choice). The result is
155 /// [`validate`](IndexSpec::validate)d, so a malformed
156 /// descriptor surfaces as an error rather than a silently-wrong
157 /// spec.
158 ///
159 /// # Errors
160 ///
161 /// Returns [`Error::InvalidArgument`] if `name` is empty, any path
162 /// is empty, or the path count disagrees with the kind.
163 pub fn from_parts<N: Into<String>>(
164 name: N,
165 kind: IndexKind,
166 key_paths: Vec<String>,
167 ) -> Result<Self> {
168 let spec = Self {
169 name: name.into(),
170 kind,
171 key_paths,
172 };
173 spec.validate()?;
174 Ok(spec)
175 }
176
177 /// Validate the spec's shape against the per-kind invariants.
178 ///
179 /// Called by every constructor; safe to call again on a
180 /// round-tripped (postcard-decoded) spec as a defense-in-depth
181 /// check before the catalog stamps the descriptor.
182 ///
183 /// # Errors
184 ///
185 /// - [`Error::InvalidArgument`] if `name` is empty, any path is
186 /// empty, or the path count disagrees with the kind.
187 pub fn validate(&self) -> Result<()> {
188 // Validate at runtime (no debug_assert: this method is the
189 // explicit "validate" entrypoint and MUST return an error
190 // rather than panic, even in debug builds, so postcard-
191 // decoded specs and test cases that probe the failure paths
192 // can surface their problem cleanly).
193 if self.name.is_empty() {
194 return Err(Error::InvalidArgument("index name must be non-empty"));
195 }
196 if self.key_paths.iter().any(String::is_empty) {
197 return Err(Error::InvalidArgument("index key path must be non-empty"));
198 }
199 match self.kind {
200 IndexKind::Standard | IndexKind::Unique | IndexKind::Each => {
201 if self.key_paths.len() != 1 {
202 return Err(Error::InvalidArgument(
203 "Standard/Unique/Each indexes require exactly one key path",
204 ));
205 }
206 }
207 IndexKind::Composite => {
208 if self.key_paths.len() < 2 {
209 return Err(Error::InvalidArgument(
210 "Composite indexes require at least two key paths",
211 ));
212 }
213 }
214 }
215 Ok(())
216 }
217
218 /// Helper that constructs one of the three scalar-shaped specs
219 /// (`Standard`, `Unique`, `Each`) and validates.
220 fn scalar(kind: IndexKind, name: String, path: String) -> Result<Self> {
221 let spec = Self {
222 name,
223 kind,
224 key_paths: vec![path],
225 };
226 spec.validate()?;
227 Ok(spec)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn scalar_constructors_set_kind_and_single_path() {
237 let s = IndexSpec::standard("by_x", "x").expect("standard");
238 assert_eq!(s.kind, IndexKind::Standard);
239 assert_eq!(s.key_paths, vec!["x".to_owned()]);
240
241 let u = IndexSpec::unique("by_email", "email").expect("unique");
242 assert_eq!(u.kind, IndexKind::Unique);
243 assert_eq!(u.key_paths, vec!["email".to_owned()]);
244
245 let e = IndexSpec::each("by_tag", "tags").expect("each");
246 assert_eq!(e.kind, IndexKind::Each);
247 assert_eq!(e.key_paths, vec!["tags".to_owned()]);
248 }
249
250 #[test]
251 fn composite_requires_two_or_more_paths() {
252 let ok = IndexSpec::composite("by_ct", &["c", "t"]).expect("ok");
253 assert_eq!(ok.kind, IndexKind::Composite);
254 assert_eq!(ok.key_paths, vec!["c".to_owned(), "t".to_owned()]);
255
256 let err = IndexSpec::composite("by_one", &["only"]).expect_err("too few");
257 assert!(matches!(err, Error::InvalidArgument(_)));
258 }
259
260 #[test]
261 fn empty_name_or_path_rejected() {
262 let err = IndexSpec::standard("", "x").expect_err("empty name");
263 assert!(matches!(err, Error::InvalidArgument(_)));
264 let err = IndexSpec::standard("by_x", "").expect_err("empty path");
265 assert!(matches!(err, Error::InvalidArgument(_)));
266 let err = IndexSpec::composite("c", &["", "y"]).expect_err("empty middle path");
267 assert!(matches!(err, Error::InvalidArgument(_)));
268 }
269
270 #[test]
271 fn validate_idempotent() {
272 let s = IndexSpec::standard("by_x", "x").expect("ok");
273 s.validate().expect("re-validate");
274 s.validate().expect("re-validate again");
275 }
276
277 #[test]
278 fn postcard_round_trip() {
279 let s = IndexSpec::composite("by_ct", &["c", "t"]).expect("ok");
280 let bytes = postcard::to_allocvec(&s).expect("encode");
281 let back: IndexSpec = postcard::from_bytes(&bytes).expect("decode");
282 assert_eq!(s, back);
283 back.validate().expect("post-decode validate");
284 }
285}