Skip to main content

cyrs_schema/
lib.rs

1//! `cyrs-schema` — the schema surface consumers implement (spec 0001 §8).
2//!
3//! This crate defines the [`SchemaProvider`] trait and its supporting
4//! types. It has no runtime behaviour: it exists so the `cyrs-sema`
5//! pass and the LSP can consult a consumer-owned schema without the
6//! consumer pulling Cypher-internal types.
7//!
8//! Consumers implement [`SchemaProvider`] against their own storage
9//! (graph database catalog, TOML spec, JSON document, etc.). The crate
10//! intentionally says nothing about where schema data comes from.
11//!
12//! # Invariants
13//!
14//! - [`SchemaProvider::schema_digest`] is a content-addressed fingerprint.
15//!   Must change on every observable schema change; must be stable across
16//!   identical schemas. Used as a Salsa input (spec §11.2).
17//! - Label, relationship-type, and property names are Cypher-identifier
18//!   strings. Escaping is the caller's responsibility.
19
20#![forbid(unsafe_code)]
21#![doc(html_root_url = "https://docs.rs/cyrs-schema/0.0.1")]
22
23use smol_str::SmolStr;
24
25mod standard_library;
26pub use standard_library::StandardLibrary;
27
28mod in_memory;
29pub use in_memory::{BuilderError, InMemorySchema, InMemorySchemaBuilder, RelDecl};
30
31#[cfg(feature = "file")]
32pub mod file;
33
34pub mod diff;
35pub mod lint;
36
37// ============================================================
38// SchemaProvider
39// ============================================================
40
41/// The single trait consumers implement to feed schema into the front-end.
42///
43/// The trait is object-safe; the front-end uses `dyn SchemaProvider`
44/// internally so a single schema can be shared across Salsa queries. A
45/// consumer may cache on their side — the trait assumes method calls are
46/// cheap but does not require it.
47pub trait SchemaProvider: Send + Sync + 'static {
48    /// All declared labels. Order is not semantic; callers sort if they
49    /// need deterministic output.
50    fn labels(&self) -> Vec<SmolStr>;
51
52    /// All declared relationship types.
53    fn relationship_types(&self) -> Vec<SmolStr>;
54
55    /// Convenience predicate: does the schema declare `name` as a label?
56    fn has_label(&self, name: &str) -> bool {
57        self.labels().iter().any(|l| l == name)
58    }
59
60    /// Convenience predicate: does the schema declare `name` as a
61    /// relationship type?
62    fn has_relationship_type(&self, name: &str) -> bool {
63        self.relationship_types().iter().any(|r| r == name)
64    }
65
66    /// Properties declared on a node with this label.
67    ///
68    /// - `None` — the label is unknown.
69    /// - `Some(empty)` — the label is known but no properties are declared
70    ///   (schema-less node, or purely structural).
71    fn node_properties(&self, label: &str) -> Option<Vec<PropertyDecl>>;
72
73    /// Properties declared on a relationship of this type.
74    fn relationship_properties(&self, rel_type: &str) -> Option<Vec<PropertyDecl>>;
75
76    /// Declared endpoint pairs for a relationship type. Empty = endpoint-
77    /// polymorphic; the semantic pass then skips endpoint checks.
78    fn relationship_endpoints(&self, rel_type: &str) -> Vec<EndpointDecl>;
79
80    /// Declared inverse relationship type, if any. Consumers that model
81    /// typed inverses return them here; others return `None`.
82    fn inverse_of(&self, rel_type: &str) -> Option<SmolStr>;
83
84    /// Look up a function signature. Used by typecheck and by completion.
85    fn function(&self, name: &str) -> Option<FunctionSignature>;
86
87    /// Look up a procedure signature for `CALL <proc>`.
88    fn procedure(&self, name: &str) -> Option<ProcedureSignature>;
89
90    /// A content-addressed digest of the schema's observable surface.
91    /// MUST change whenever any declaration visible through this trait
92    /// changes.
93    fn schema_digest(&self) -> [u8; 32];
94}
95
96// ============================================================
97// Types
98// ============================================================
99
100/// A declared property on a label or relationship type.
101///
102/// Marked `#[non_exhaustive]` (cy-2i9.1) so new fields (e.g.
103/// `documentation`, `default`) can land without forcing a SemVer-major
104/// release.  External crates construct via [`PropertyDecl::new`].
105#[derive(Debug, Clone, PartialEq, Eq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107#[non_exhaustive]
108pub struct PropertyDecl {
109    /// Property name as it appears in `n.prop` expressions.
110    pub name: SmolStr,
111    /// Declared type (spec §8.2).  Consumers that don't care about
112    /// typing can surface `PropertyType::Any`.
113    pub ty: PropertyType,
114    /// `true` when the schema requires every instance to carry this
115    /// property (nullable otherwise).
116    pub required: bool,
117}
118
119impl PropertyDecl {
120    /// Construct a [`PropertyDecl`].
121    ///
122    /// This is the SemVer-stable constructor; external crates should
123    /// prefer it over struct literals so the struct can grow fields
124    /// without forcing a SemVer-major release.
125    #[must_use]
126    pub fn new(name: impl Into<SmolStr>, ty: PropertyType, required: bool) -> Self {
127        Self {
128            name: name.into(),
129            ty,
130            required,
131        }
132    }
133}
134
135/// The propertable-value type language. Intentionally simpler than the
136/// full Cypher value type — schemas describe what values are *stored*.
137///
138/// Variants map 1:1 to spec §8.2's type lattice; the variant names
139/// are self-documenting.  `#[allow(missing_docs)]` applies to the
140/// primitive variants; variants with nontrivial invariants
141/// (`Enum`, `Opaque`) keep their own docstrings.
142//
143// NOTE (cy-2i9.1): heavily matched across `cyrs-sema`.  Marking
144// `#[non_exhaustive]` would force wildcard arms at every cross-crate
145// match site; deferred to a follow-up bead.  See `docs/stability.md`.
146#[derive(Debug, Clone, PartialEq, Eq)]
147#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
148#[allow(missing_docs)]
149pub enum PropertyType {
150    String,
151    Int,
152    Float,
153    Bool,
154    Date,
155    Datetime,
156    List(Box<PropertyType>),
157    /// A closed enum carrying its name and variant names.
158    ///
159    /// Spec §8.2 shape: `Enum(SmolStr, Vec<SmolStr>)` — tuple variant.
160    Enum(SmolStr, Vec<SmolStr>),
161    /// An opaque typed value the consumer chooses not to model
162    /// structurally.
163    ///
164    /// **Unification invariant (spec §8.2):** `Opaque(n)` unifies with
165    /// `Opaque(n)` (same symbolic name) and with [`PropertyType::Any`];
166    /// every other pairing is a type error. This rule lives in the
167    /// unification layer (`cyrs-sema`); the shape here only carries the
168    /// symbolic name. Two opaque types with different names never unify.
169    Opaque(SmolStr),
170    /// Fallback: any property value. Equivalent to "type unknown".
171    ///
172    /// Not in spec §8.2's normative 9-variant set; retained as an
173    /// internal fallback for [`ReturnTy::Dynamic`] cloning. Consumers
174    /// should prefer the typed variants.
175    Any,
176}
177
178/// Declared endpoint shape for a relationship type.
179#[derive(Debug, Clone, PartialEq, Eq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181pub struct EndpointDecl {
182    /// Source label of a matching pattern (left-hand side of the arrow).
183    pub from: SmolStr,
184    /// Target label of a matching pattern (right-hand side of the arrow).
185    pub to: SmolStr,
186    /// Multiplicity between `from` and `to` endpoints.
187    pub cardinality: Cardinality,
188}
189
190/// Relationship multiplicity between two label endpoints.
191///
192/// Marked `#[non_exhaustive]` (cy-2i9.1) so new cardinality forms can
193/// land without forcing a SemVer-major release.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196#[allow(missing_docs)]
197#[non_exhaustive]
198pub enum Cardinality {
199    OneToOne,
200    OneToMany,
201    ManyToOne,
202    ManyToMany,
203}
204
205/// A function catalog entry.
206///
207/// The return type is modelled as a closure so consumers can express
208/// signature-dependent return inference (e.g., `coalesce(T, T) -> T`).
209/// Signature-independent functions return a constant.
210///
211/// **Spec deviation note (§8.2):** the spec's shorthand is
212/// `variadic: Option<Type>`; we use [`ParamDecl`] so the trailing
213/// variadic parameter can carry a name and default consistently with
214/// `params`. Only `variadic.ty` is semantically significant; `name` is
215/// diagnostic-only and `default` is unused.
216pub struct FunctionSignature {
217    /// Function name as it appears in a `count(…)` call.
218    pub name: SmolStr,
219    /// Fixed-arity parameter list in declaration order.
220    pub params: Vec<ParamDecl>,
221    /// Optional trailing variadic parameter (spec §8.2 shorthand).
222    pub variadic: Option<ParamDecl>,
223    /// How the return type is computed (constant vs argument-derived).
224    pub return_ty: ReturnTy,
225    /// Purity / determinism flags used by the sema purity checker.
226    pub categories: FnCategories,
227}
228
229impl core::fmt::Debug for FunctionSignature {
230    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
231        f.debug_struct("FunctionSignature")
232            .field("name", &self.name)
233            .field("params", &self.params)
234            .field("variadic", &self.variadic)
235            .field("categories", &self.categories)
236            .finish_non_exhaustive()
237    }
238}
239
240impl Clone for FunctionSignature {
241    fn clone(&self) -> Self {
242        Self {
243            name: self.name.clone(),
244            params: self.params.clone(),
245            variadic: self.variadic.clone(),
246            return_ty: match &self.return_ty {
247                ReturnTy::Constant(t) => ReturnTy::Constant(t.clone()),
248                ReturnTy::Dynamic(_) => ReturnTy::Constant(PropertyType::Any),
249            },
250            categories: self.categories,
251        }
252    }
253}
254
255/// Closure type for dynamic return-type inference.
256pub type DynamicReturnFn = Box<dyn Fn(&[PropertyType]) -> PropertyType + Send + Sync>;
257
258/// How a function's return type is computed.
259pub enum ReturnTy {
260    /// Independent of argument types.
261    Constant(PropertyType),
262    /// Derived from argument types. The closure receives the caller's
263    /// argument types (possibly `Any` where unknown) and returns the
264    /// computed return type.
265    Dynamic(DynamicReturnFn),
266}
267
268impl core::fmt::Debug for ReturnTy {
269    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
270        match self {
271            Self::Constant(t) => f.debug_tuple("Constant").field(t).finish(),
272            Self::Dynamic(_) => f.debug_tuple("Dynamic").field(&"<fn>").finish(),
273        }
274    }
275}
276
277/// Purity / determinism / aggregation flags for a function.  The
278/// sema pass uses these to decide which syntactic positions a
279/// function may appear in (aggregates in `RETURN`, pure functions
280/// in `WHERE`, etc.).
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283pub struct FnCategories {
284    /// `true` iff the function has no side effects.
285    pub pure: bool,
286    /// `true` iff the function is an aggregate (e.g. `count`, `sum`).
287    pub aggregate: bool,
288    /// `true` iff identical inputs always produce identical outputs.
289    pub deterministic: bool,
290}
291
292/// A procedure signature. Procedures have a mode and a `YIELD` column
293/// list in addition to inputs.
294#[derive(Debug, Clone)]
295pub struct ProcedureSignature {
296    /// Procedure name as invoked by `CALL <name>(…)`.
297    pub name: SmolStr,
298    /// Input parameters in declaration order.
299    pub params: Vec<ParamDecl>,
300    /// Columns produced by `YIELD`; each row of the call produces a
301    /// record with these fields.
302    pub yields: Vec<YieldDecl>,
303    /// Read / Write / Schema classification (spec §8.2).
304    pub mode: ProcMode,
305}
306
307/// Procedure access mode (spec §8.2).  Used by sema to gate
308/// procedures in read-only contexts.
309///
310/// Marked `#[non_exhaustive]` (cy-2i9.1) so new modes can land without
311/// forcing a SemVer-major release.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
314#[allow(missing_docs)]
315#[non_exhaustive]
316pub enum ProcMode {
317    Read,
318    Write,
319    Schema,
320}
321
322/// A single parameter of a function or procedure signature.
323#[derive(Debug, Clone, PartialEq, Eq)]
324#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
325pub struct ParamDecl {
326    /// Parameter name.  Diagnostic-only for variadic parameters.
327    pub name: SmolStr,
328    /// Declared parameter type.
329    pub ty: PropertyType,
330    /// Optional default value as a source-level literal.
331    pub default: Option<SmolStr>,
332}
333
334/// A single output column of a `YIELD` clause on a procedure call.
335#[derive(Debug, Clone, PartialEq, Eq)]
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337pub struct YieldDecl {
338    /// Column name as it appears after `YIELD`.
339    pub name: SmolStr,
340    /// Declared column type.
341    pub ty: PropertyType,
342}
343
344// ============================================================
345// Empty schema
346// ============================================================
347
348/// A `SchemaProvider` that reports nothing. Useful for schema-free mode
349/// and for unit tests that do not want to construct a full schema.
350#[derive(Debug, Default)]
351pub struct EmptySchema;
352
353impl SchemaProvider for EmptySchema {
354    fn labels(&self) -> Vec<SmolStr> {
355        Vec::new()
356    }
357    fn relationship_types(&self) -> Vec<SmolStr> {
358        Vec::new()
359    }
360    fn node_properties(&self, _: &str) -> Option<Vec<PropertyDecl>> {
361        None
362    }
363    fn relationship_properties(&self, _: &str) -> Option<Vec<PropertyDecl>> {
364        None
365    }
366    fn relationship_endpoints(&self, _: &str) -> Vec<EndpointDecl> {
367        Vec::new()
368    }
369    fn inverse_of(&self, _: &str) -> Option<SmolStr> {
370        None
371    }
372    fn function(&self, _: &str) -> Option<FunctionSignature> {
373        None
374    }
375    fn procedure(&self, _: &str) -> Option<ProcedureSignature> {
376        None
377    }
378    fn schema_digest(&self) -> [u8; 32] {
379        [0u8; 32]
380    }
381}
382
383// ============================================================
384// Static assertions
385// ============================================================
386
387/// Compile-time check that [`SchemaProvider`] is object-safe (spec §8.1).
388/// Referencing `&dyn SchemaProvider` forces the compiler to verify
389/// object-safety; the function itself is never called.
390#[doc(hidden)]
391pub fn _assert_object_safe(_: &dyn SchemaProvider) {}
392
393/// Compile-time check that [`EmptySchema`] satisfies the trait's
394/// `Send + Sync + 'static` bounds.
395const _: fn() = || {
396    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
397    assert_send_sync_static::<EmptySchema>();
398};
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn empty_schema_knows_nothing() {
406        let s = EmptySchema;
407        assert!(s.labels().is_empty());
408        assert!(!s.has_label("Person"));
409        assert_eq!(s.schema_digest(), [0u8; 32]);
410    }
411
412    #[test]
413    fn property_type_spec_variants_construct() {
414        // Spec §8.2: nine normative variants must exist and be
415        // constructible. Any additional variants (e.g., `Any`) are
416        // implementation-internal fallbacks.
417        let specs: [PropertyType; 9] = [
418            PropertyType::String,
419            PropertyType::Int,
420            PropertyType::Float,
421            PropertyType::Bool,
422            PropertyType::Date,
423            PropertyType::Datetime,
424            PropertyType::List(Box::new(PropertyType::Int)),
425            PropertyType::Enum(SmolStr::new("Color"), vec![SmolStr::new("Red")]),
426            PropertyType::Opaque(SmolStr::new("Uuid")),
427        ];
428        assert_eq!(specs.len(), 9);
429        // Round-trip clone + equality on each.
430        for t in &specs {
431            assert_eq!(t, &t.clone());
432        }
433    }
434
435    #[test]
436    fn property_type_any_fallback_distinct_from_spec_variants() {
437        let any = PropertyType::Any;
438        assert_ne!(any, PropertyType::String);
439        assert_ne!(any, PropertyType::Opaque(SmolStr::new("X")));
440    }
441
442    #[test]
443    fn cardinality_has_exactly_four_variants() {
444        // Exhaustive match: adding a variant without updating this test
445        // is a signal that spec §8.2 has grown.
446        let all: [Cardinality; 4] = [
447            Cardinality::OneToOne,
448            Cardinality::OneToMany,
449            Cardinality::ManyToOne,
450            Cardinality::ManyToMany,
451        ];
452        for (i, c) in all.iter().enumerate() {
453            match c {
454                Cardinality::OneToOne
455                | Cardinality::OneToMany
456                | Cardinality::ManyToOne
457                | Cardinality::ManyToMany => {}
458            }
459            assert_eq!(*c, all[i]);
460        }
461    }
462
463    #[test]
464    fn proc_mode_has_exactly_three_variants() {
465        let all: [ProcMode; 3] = [ProcMode::Read, ProcMode::Write, ProcMode::Schema];
466        for m in &all {
467            match m {
468                ProcMode::Read | ProcMode::Write | ProcMode::Schema => {}
469            }
470        }
471        assert_eq!(all[0], ProcMode::Read);
472        assert_ne!(ProcMode::Read, ProcMode::Write);
473    }
474
475    #[test]
476    fn procedure_signature_read_mode_clones_and_compares_on_mode() {
477        let sig = ProcedureSignature {
478            name: SmolStr::new("db.ping"),
479            params: vec![],
480            yields: vec![YieldDecl {
481                name: SmolStr::new("ok"),
482                ty: PropertyType::Bool,
483            }],
484            mode: ProcMode::Read,
485        };
486        let cloned = sig.clone();
487        assert_eq!(cloned.mode, ProcMode::Read);
488        assert_eq!(cloned.yields.len(), 1);
489        assert_eq!(cloned.yields[0].ty, PropertyType::Bool);
490    }
491
492    #[test]
493    fn endpoint_decl_shape() {
494        let e = EndpointDecl {
495            from: SmolStr::new("Person"),
496            to: SmolStr::new("Company"),
497            cardinality: Cardinality::ManyToMany,
498        };
499        assert_eq!(e.clone(), e);
500    }
501
502    #[test]
503    fn property_decl_shape() {
504        let p = PropertyDecl {
505            name: SmolStr::new("age"),
506            ty: PropertyType::Int,
507            required: true,
508        };
509        assert!(p.required);
510        assert_eq!(p.clone(), p);
511    }
512
513    #[test]
514    fn function_signature_clone_preserves_params_and_categories() {
515        // Constant-return path.
516        let c = FunctionSignature {
517            name: SmolStr::new("size"),
518            params: vec![ParamDecl {
519                name: SmolStr::new("x"),
520                ty: PropertyType::List(Box::new(PropertyType::Any)),
521                default: None,
522            }],
523            variadic: None,
524            return_ty: ReturnTy::Constant(PropertyType::Int),
525            categories: FnCategories {
526                pure: true,
527                aggregate: false,
528                deterministic: true,
529            },
530        };
531        let cloned = c.clone();
532        assert_eq!(cloned.name, c.name);
533        assert_eq!(cloned.params, c.params);
534        assert_eq!(cloned.categories, c.categories);
535        match cloned.return_ty {
536            ReturnTy::Constant(PropertyType::Int) => {}
537            _ => panic!("expected Constant(Int)"),
538        }
539    }
540
541    #[test]
542    fn function_signature_clone_dynamic_collapses_to_any() {
543        // Documented Clone path: ReturnTy::Dynamic cannot clone its
544        // closure, so it collapses to Constant(Any). Schema lookups
545        // should avoid cloning dynamic signatures on the hot path.
546        let d = FunctionSignature {
547            name: SmolStr::new("coalesce"),
548            params: vec![],
549            variadic: Some(ParamDecl {
550                name: SmolStr::new("args"),
551                ty: PropertyType::Any,
552                default: None,
553            }),
554            return_ty: ReturnTy::Dynamic(Box::new(|tys| {
555                tys.first().cloned().unwrap_or(PropertyType::Any)
556            })),
557            categories: FnCategories {
558                pure: true,
559                aggregate: false,
560                deterministic: true,
561            },
562        };
563        let cloned = d.clone();
564        assert_eq!(cloned.params, d.params);
565        assert_eq!(cloned.variadic, d.variadic);
566        assert_eq!(cloned.categories, d.categories);
567        match cloned.return_ty {
568            ReturnTy::Constant(PropertyType::Any) => {}
569            _ => panic!("Dynamic clone must collapse to Constant(Any)"),
570        }
571    }
572}