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}