Skip to main content

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}