Skip to main content

cyrs_schema/
in_memory.rs

1//! In-memory [`SchemaProvider`] — a concrete, data-shaped schema the CLI,
2//! LSP, and agent all share (spec 0001 §8, spec 0002 §12).
3//!
4//! [`InMemorySchema`] is the target of the TOML loader in [`crate::file`]
5//! and the canonical schema used by tests and the `cypher schema load`
6//! subcommand. It is intentionally small — three `BTreeMap`s and a
7//! `BTreeMap` of parameter declarations — so that iteration order is
8//! deterministic (spec 0001 §17.14).
9
10use std::collections::BTreeMap;
11
12use smol_str::SmolStr;
13
14use crate::{
15    Cardinality, EndpointDecl, FunctionSignature, ParamDecl, ProcedureSignature, PropertyDecl,
16    SchemaProvider,
17};
18
19/// A declared relationship type. The file-format counterpart to
20/// [`EndpointDecl`]: one rel type may permit many `(start, end)` label
21/// pairings, all at many-to-many at v0 (spec 0002 §6).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RelDecl {
24    /// Relationship type name (e.g. `KNOWS`).
25    pub name: SmolStr,
26    /// Allowed source labels. Empty = polymorphic (any label).
27    pub start_labels: Vec<SmolStr>,
28    /// Allowed target labels. Empty = polymorphic (any label).
29    pub end_labels: Vec<SmolStr>,
30    /// Properties declared on the relationship.
31    pub properties: Vec<PropertyDecl>,
32}
33
34/// A concrete, in-memory [`SchemaProvider`].
35///
36/// Fields are `pub(crate)` — construction goes through
37/// [`InMemorySchema::builder`] or the TOML loader in [`crate::file`].
38/// This keeps the invariants in one place (unique label / rel type
39/// names, deterministic iteration).
40#[derive(Debug, Default, Clone)]
41pub struct InMemorySchema {
42    pub(crate) labels: BTreeMap<SmolStr, Vec<PropertyDecl>>,
43    pub(crate) rel_types: BTreeMap<SmolStr, RelDecl>,
44    pub(crate) parameters: BTreeMap<SmolStr, ParamDecl>,
45    pub(crate) schema_name: Option<SmolStr>,
46    pub(crate) description: Option<String>,
47}
48
49impl InMemorySchema {
50    /// Start a new builder. Use [`InMemorySchemaBuilder::build`] to
51    /// finalise.
52    #[must_use]
53    pub fn builder() -> InMemorySchemaBuilder {
54        InMemorySchemaBuilder::default()
55    }
56
57    /// All label names, in sorted order.
58    #[must_use]
59    pub fn label_names(&self) -> Vec<SmolStr> {
60        self.labels.keys().cloned().collect()
61    }
62
63    /// All relationship type names, in sorted order.
64    #[must_use]
65    pub fn rel_type_names(&self) -> Vec<SmolStr> {
66        self.rel_types.keys().cloned().collect()
67    }
68
69    /// All declared rel types, in sorted order.
70    pub fn rel_types(&self) -> impl Iterator<Item = &RelDecl> {
71        self.rel_types.values()
72    }
73
74    /// All declared parameters, in sorted order.
75    pub fn parameters(&self) -> impl Iterator<Item = &ParamDecl> {
76        self.parameters.values()
77    }
78
79    /// Optional human-readable schema name from the `[meta]` block.
80    #[must_use]
81    pub fn schema_name(&self) -> Option<&str> {
82        self.schema_name.as_deref()
83    }
84
85    /// Optional human-readable description from the `[meta]` block.
86    #[must_use]
87    pub fn description(&self) -> Option<&str> {
88        self.description.as_deref()
89    }
90
91    /// Count of labels.
92    #[must_use]
93    pub fn label_count(&self) -> usize {
94        self.labels.len()
95    }
96
97    /// Count of rel types.
98    #[must_use]
99    pub fn rel_type_count(&self) -> usize {
100        self.rel_types.len()
101    }
102
103    /// Count of parameters.
104    #[must_use]
105    pub fn parameter_count(&self) -> usize {
106        self.parameters.len()
107    }
108}
109
110impl SchemaProvider for InMemorySchema {
111    fn labels(&self) -> Vec<SmolStr> {
112        self.label_names()
113    }
114
115    fn relationship_types(&self) -> Vec<SmolStr> {
116        self.rel_type_names()
117    }
118
119    fn node_properties(&self, label: &str) -> Option<Vec<PropertyDecl>> {
120        self.labels.get(label).cloned()
121    }
122
123    fn relationship_properties(&self, rel_type: &str) -> Option<Vec<PropertyDecl>> {
124        self.rel_types.get(rel_type).map(|r| r.properties.clone())
125    }
126
127    fn relationship_endpoints(&self, rel_type: &str) -> Vec<EndpointDecl> {
128        let Some(r) = self.rel_types.get(rel_type) else {
129            return Vec::new();
130        };
131        if r.start_labels.is_empty() || r.end_labels.is_empty() {
132            return Vec::new();
133        }
134        let mut out = Vec::with_capacity(r.start_labels.len() * r.end_labels.len());
135        for from in &r.start_labels {
136            for to in &r.end_labels {
137                out.push(EndpointDecl {
138                    from: from.clone(),
139                    to: to.clone(),
140                    cardinality: Cardinality::ManyToMany,
141                });
142            }
143        }
144        out
145    }
146
147    fn inverse_of(&self, _rel_type: &str) -> Option<SmolStr> {
148        None
149    }
150
151    fn function(&self, _name: &str) -> Option<FunctionSignature> {
152        None
153    }
154
155    fn procedure(&self, _name: &str) -> Option<ProcedureSignature> {
156        None
157    }
158
159    fn schema_digest(&self) -> [u8; 32] {
160        // Deterministic, content-addressed. A simple FNV-1a variant
161        // walks the canonical BTreeMap iteration; callers who want
162        // cryptographic strength wrap with SHA-256 at their boundary.
163        let mut acc: [u8; 32] = [0; 32];
164        let mut h = fnv1a_64_init();
165        for (name, props) in &self.labels {
166            fnv1a_64_str(&mut h, "L:");
167            fnv1a_64_str(&mut h, name);
168            for p in props {
169                fnv1a_64_str(&mut h, "p:");
170                fnv1a_64_str(&mut h, &p.name);
171                fnv1a_64_str(&mut h, if p.required { "!" } else { "?" });
172            }
173        }
174        for (name, r) in &self.rel_types {
175            fnv1a_64_str(&mut h, "R:");
176            fnv1a_64_str(&mut h, name);
177            for l in &r.start_labels {
178                fnv1a_64_str(&mut h, "s:");
179                fnv1a_64_str(&mut h, l);
180            }
181            for l in &r.end_labels {
182                fnv1a_64_str(&mut h, "e:");
183                fnv1a_64_str(&mut h, l);
184            }
185            for p in &r.properties {
186                fnv1a_64_str(&mut h, "p:");
187                fnv1a_64_str(&mut h, &p.name);
188            }
189        }
190        for (name, p) in &self.parameters {
191            fnv1a_64_str(&mut h, "P:");
192            fnv1a_64_str(&mut h, name);
193            if let Some(d) = &p.default {
194                fnv1a_64_str(&mut h, "=");
195                fnv1a_64_str(&mut h, d);
196            }
197        }
198        // Smear the 64-bit hash across the 32-byte buffer deterministically.
199        for (i, byte) in acc.iter_mut().enumerate() {
200            let rot = u32::try_from(i).unwrap_or(0).wrapping_mul(7);
201            *byte = u8::try_from(h.rotate_left(rot) & 0xff).unwrap_or(0);
202        }
203        acc
204    }
205}
206
207fn fnv1a_64_init() -> u64 {
208    0xcbf2_9ce4_8422_2325
209}
210
211fn fnv1a_64_str(h: &mut u64, s: &str) {
212    for b in s.as_bytes() {
213        *h ^= u64::from(*b);
214        *h = h.wrapping_mul(0x100_0000_01b3);
215    }
216}
217
218// ============================================================
219// Builder
220// ============================================================
221
222/// Builder for [`InMemorySchema`].
223///
224/// All mutation goes through this builder so the target struct remains
225/// invariant-preserving: unique label / rel type / parameter names,
226/// deterministic iteration order (spec 0001 §17.14).
227#[derive(Debug, Default)]
228pub struct InMemorySchemaBuilder {
229    inner: InMemorySchema,
230    duplicate_label: Option<SmolStr>,
231    duplicate_rel_type: Option<SmolStr>,
232    duplicate_parameter: Option<SmolStr>,
233}
234
235impl InMemorySchemaBuilder {
236    /// Declare a label. The first insertion wins; subsequent
237    /// insertions with the same name are recorded and surfaced by
238    /// [`InMemorySchemaBuilder::build`].
239    #[must_use]
240    pub fn add_label(mut self, name: SmolStr, properties: Vec<PropertyDecl>) -> Self {
241        if self.inner.labels.contains_key(&name) {
242            self.duplicate_label.get_or_insert(name);
243            return self;
244        }
245        self.inner.labels.insert(name, properties);
246        self
247    }
248
249    /// Declare a relationship type. The first insertion wins;
250    /// subsequent insertions are recorded and surfaced by
251    /// [`InMemorySchemaBuilder::build`].
252    #[must_use]
253    pub fn add_rel_type(mut self, rel: RelDecl) -> Self {
254        if self.inner.rel_types.contains_key(&rel.name) {
255            self.duplicate_rel_type.get_or_insert(rel.name.clone());
256            return self;
257        }
258        self.inner.rel_types.insert(rel.name.clone(), rel);
259        self
260    }
261
262    /// Declare a query parameter.
263    #[must_use]
264    pub fn add_parameter(mut self, param: ParamDecl) -> Self {
265        if self.inner.parameters.contains_key(&param.name) {
266            self.duplicate_parameter.get_or_insert(param.name.clone());
267            return self;
268        }
269        self.inner.parameters.insert(param.name.clone(), param);
270        self
271    }
272
273    /// Set the schema name from a `[meta]` block.
274    #[must_use]
275    pub fn schema_name(mut self, name: Option<SmolStr>) -> Self {
276        self.inner.schema_name = name;
277        self
278    }
279
280    /// Set the description from a `[meta]` block.
281    #[must_use]
282    pub fn description(mut self, desc: Option<String>) -> Self {
283        self.inner.description = desc;
284        self
285    }
286
287    /// Finalise the builder.
288    ///
289    /// Returns the first duplicate label name encountered (if any),
290    /// the first duplicate rel type name, or the built schema.
291    /// Reference validation (`rel_type` endpoints referring to declared
292    /// labels) lives in the TOML loader, not the builder — the
293    /// builder has no view of the original source ordering.
294    pub fn build(self) -> Result<InMemorySchema, BuilderError> {
295        if let Some(n) = self.duplicate_label {
296            return Err(BuilderError::DuplicateLabel(n));
297        }
298        if let Some(n) = self.duplicate_rel_type {
299            return Err(BuilderError::DuplicateRelType(n));
300        }
301        if let Some(n) = self.duplicate_parameter {
302            return Err(BuilderError::DuplicateParameter(n));
303        }
304        Ok(self.inner)
305    }
306}
307
308/// Errors produced by [`InMemorySchemaBuilder::build`].
309#[derive(Debug, Clone, PartialEq, Eq)]
310#[allow(clippy::enum_variant_names)] // spec 0002 §11 — all three variants are "Duplicate*" by design.
311pub enum BuilderError {
312    /// Same label name declared twice.
313    DuplicateLabel(SmolStr),
314    /// Same rel type name declared twice.
315    DuplicateRelType(SmolStr),
316    /// Same parameter name declared twice.
317    DuplicateParameter(SmolStr),
318}
319
320impl core::fmt::Display for BuilderError {
321    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
322        match self {
323            Self::DuplicateLabel(n) => write!(f, "duplicate label `{n}`"),
324            Self::DuplicateRelType(n) => write!(f, "duplicate rel type `{n}`"),
325            Self::DuplicateParameter(n) => write!(f, "duplicate parameter `{n}`"),
326        }
327    }
328}
329
330impl std::error::Error for BuilderError {}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn empty_builder_builds_empty_schema() {
338        let s = InMemorySchema::builder().build().expect("builds");
339        assert_eq!(s.label_count(), 0);
340        assert_eq!(s.rel_type_count(), 0);
341        assert_eq!(s.parameter_count(), 0);
342    }
343
344    #[test]
345    fn builder_preserves_sorted_iteration() {
346        let s = InMemorySchema::builder()
347            .add_label(SmolStr::new("Zebra"), vec![])
348            .add_label(SmolStr::new("Apple"), vec![])
349            .add_label(SmolStr::new("Mango"), vec![])
350            .build()
351            .expect("builds");
352        assert_eq!(
353            s.label_names(),
354            vec![
355                SmolStr::new("Apple"),
356                SmolStr::new("Mango"),
357                SmolStr::new("Zebra"),
358            ]
359        );
360    }
361
362    #[test]
363    fn duplicate_label_surfaces_as_error() {
364        let err = InMemorySchema::builder()
365            .add_label(SmolStr::new("X"), vec![])
366            .add_label(SmolStr::new("X"), vec![])
367            .build()
368            .expect_err("duplicate");
369        assert_eq!(err, BuilderError::DuplicateLabel(SmolStr::new("X")));
370    }
371
372    #[test]
373    fn rel_endpoints_cross_product_for_declared_labels() {
374        let s = InMemorySchema::builder()
375            .add_label(SmolStr::new("A"), vec![])
376            .add_label(SmolStr::new("B"), vec![])
377            .add_rel_type(RelDecl {
378                name: SmolStr::new("R"),
379                start_labels: vec![SmolStr::new("A")],
380                end_labels: vec![SmolStr::new("A"), SmolStr::new("B")],
381                properties: vec![],
382            })
383            .build()
384            .expect("builds");
385        let ends = s.relationship_endpoints("R");
386        assert_eq!(ends.len(), 2);
387        assert!(
388            ends.iter()
389                .all(|e| e.cardinality == Cardinality::ManyToMany)
390        );
391    }
392
393    #[test]
394    fn rel_endpoints_empty_when_polymorphic() {
395        let s = InMemorySchema::builder()
396            .add_rel_type(RelDecl {
397                name: SmolStr::new("R"),
398                start_labels: vec![],
399                end_labels: vec![],
400                properties: vec![],
401            })
402            .build()
403            .expect("builds");
404        assert!(s.relationship_endpoints("R").is_empty());
405    }
406
407    #[test]
408    fn schema_digest_is_deterministic() {
409        let build = || {
410            InMemorySchema::builder()
411                .add_label(
412                    SmolStr::new("Person"),
413                    vec![PropertyDecl {
414                        name: SmolStr::new("name"),
415                        ty: crate::PropertyType::String,
416                        required: true,
417                    }],
418                )
419                .build()
420                .expect("builds")
421        };
422        assert_eq!(build().schema_digest(), build().schema_digest());
423    }
424
425    #[test]
426    fn schema_digest_changes_on_observable_change() {
427        let a = InMemorySchema::builder()
428            .add_label(SmolStr::new("A"), vec![])
429            .build()
430            .expect("builds");
431        let b = InMemorySchema::builder()
432            .add_label(SmolStr::new("B"), vec![])
433            .build()
434            .expect("builds");
435        assert_ne!(a.schema_digest(), b.schema_digest());
436    }
437}