Skip to main content

icydb_model/node/
mod.rs

1//! Schema node graph for validated canister/entity/type definitions.
2//!
3//! This module owns the typed node descriptors used by schema validation,
4//! derive code generation, and visitor traversal.
5
6mod arg;
7mod canister;
8mod constraint;
9mod def;
10mod entity;
11mod r#enum;
12mod field;
13mod index;
14mod item;
15mod list;
16mod map;
17mod newtype;
18mod normalizer;
19mod primary_key;
20mod record;
21mod relation;
22mod schema;
23mod set;
24mod store;
25mod tuple;
26mod r#type;
27mod validator;
28mod value;
29
30use crate::{
31    prelude::*,
32    visit::{Event, Visitor},
33};
34use std::any::Any;
35use thiserror::Error as ThisError;
36
37pub use arg::*;
38pub use canister::*;
39pub use constraint::*;
40pub use def::*;
41pub use entity::*;
42pub use r#enum::*;
43pub use field::*;
44pub use index::*;
45pub use item::*;
46pub use list::*;
47pub use map::*;
48pub use newtype::*;
49pub use normalizer::*;
50pub use primary_key::*;
51pub use record::*;
52pub use relation::*;
53pub use schema::*;
54pub use set::*;
55pub use store::*;
56pub use tuple::*;
57pub use r#type::*;
58pub use validator::*;
59pub use value::*;
60
61///
62/// NodeError
63///
64/// Error raised when schema-node lookup or downcasting crosses an invalid
65/// boundary.
66///
67
68#[derive(Debug, ThisError)]
69pub enum NodeError {
70    #[error("{0} is an incorrect node type")]
71    IncorrectNodeType(String),
72
73    #[error("path not found: {0}")]
74    PathNotFound(String),
75}
76
77///
78/// NODE TRAITS
79///
80
81///
82/// MacroNode
83///
84/// Shared trait implemented by every concrete schema node descriptor.
85/// `as_any` keeps type erasure and downcasting local to the schema-node
86/// boundary instead of leaking it into callers.
87///
88
89pub(crate) trait MacroNode: Any {
90    fn as_any(&self) -> &dyn Any;
91}
92
93///
94/// ValidateNode
95///
96/// Trait implemented by schema nodes that validate local invariants against
97/// the surrounding schema graph.
98///
99
100pub(crate) trait ValidateNode {
101    fn validate(&self) -> Result<(), ErrorTree> {
102        Ok(())
103    }
104}
105
106///
107/// VisitableNode
108///
109/// Trait implemented by schema nodes that participate in recursive visitor
110/// traversal with canonical route-key ordering.
111///
112
113pub(crate) trait VisitableNode: ValidateNode {
114    // Route key contributes one node-local path segment to the visitor path.
115    fn route_key(&self) -> String {
116        String::new()
117    }
118
119    // Drive the enter/children/exit visitor sequence for this node.
120    fn accept<V: Visitor>(&self, visitor: &mut V) {
121        visitor.push(&self.route_key());
122        visitor.visit(self, Event::Enter);
123        self.drive(visitor);
124        visitor.visit(self, Event::Exit);
125        visitor.pop();
126    }
127
128    // Visit child nodes in canonical order.
129    fn drive<V: Visitor>(&self, _: &mut V) {}
130}
131
132// Add one source-protocol name construction failure to the authoring diagnostic tree.
133pub(crate) fn validate_source_name<K>(
134    errs: &mut ErrorTree,
135    kind: &str,
136    name: &str,
137    constructor: impl FnOnce(String) -> Result<K, icydb_schema::SchemaContractError>,
138) {
139    if let Err(error) = constructor(name.to_string()) {
140        err!(errs, "invalid {kind} name '{name}': {error}");
141    }
142}
143
144pub(crate) fn validate_stable_key_segment(errs: &mut ErrorTree, label: &str, value: &str) {
145    if !stable_key_segment_is_canonical(value) {
146        err!(
147            errs,
148            "{label} `{value}` must use lowercase ASCII letters, digits, and underscores",
149        );
150    }
151}
152
153pub(crate) fn validate_stable_key(errs: &mut ErrorTree, label: &str, value: &str) {
154    if !stable_key_is_canonical(value) {
155        err!(
156            errs,
157            "{label} `{value}` must be at most 128 bytes, must use lowercase ASCII segments beginning with a letter, must use dots as separators, must use underscores instead of hyphens, must end in .v1, and must not start with canic.",
158        );
159    }
160}
161
162#[must_use]
163pub fn stable_key_segment_is_canonical(value: &str) -> bool {
164    let mut bytes = value.bytes();
165    bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
166        && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
167}
168
169#[must_use]
170pub(crate) fn stable_key_is_canonical(value: &str) -> bool {
171    if value.len() > 128 || value.starts_with("canic.") {
172        return false;
173    }
174
175    let mut saw_segment = false;
176    let mut last_segment = "";
177    for segment in value.split('.') {
178        if !stable_key_segment_is_canonical(segment) {
179            return false;
180        }
181        saw_segment = true;
182        last_segment = segment;
183    }
184
185    saw_segment && last_segment == "v1"
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn stable_key_segment_policy_requires_a_lowercase_letter_prefix() {
194        for segment in ["db", "demo_rpg", "store_1", "v1"] {
195            assert!(stable_key_segment_is_canonical(segment));
196        }
197
198        for segment in [
199            "",
200            "1db",
201            "_db",
202            "Demo",
203            "demo-rpg",
204            "demo.rpg",
205            "canic.owned",
206        ] {
207            assert!(!stable_key_segment_is_canonical(segment));
208        }
209    }
210
211    #[test]
212    fn full_stable_key_policy_rejects_reserved_and_malformed_keys() {
213        assert!(stable_key_is_canonical("icydb.demo_rpg.characters.data.v1"));
214
215        for key in [
216            "canic.demo_rpg.characters.data.v1",
217            "icydb.demo_rpg.characters.data",
218            "icydb.demo-rpg.characters.data.v1",
219            "icydb.demo_rpg..data.v1",
220            "icydb.Demo.characters.data.v1",
221            "icydb.demo_rpg.characters.data.v2",
222            "icydb.1demo.characters.data.v1",
223            "icydb._demo.characters.data.v1",
224        ] {
225            assert!(!stable_key_is_canonical(key), "key should fail: {key}");
226        }
227
228        let maximum = format!("icydb.{}.data.v1", "a".repeat(114));
229        assert_eq!(maximum.len(), 128);
230        assert!(stable_key_is_canonical(&maximum));
231
232        let oversized = format!("icydb.{}.data.v1", "a".repeat(115));
233        assert_eq!(oversized.len(), 129);
234        assert!(!stable_key_is_canonical(&oversized));
235    }
236}