Skip to main content

selene_graph/
graph_types.rs

1//! Closed graph type catalog definitions.
2
3mod endpoint;
4mod property_defaults;
5mod property_element_types;
6mod record_types;
7
8use std::collections::BTreeSet;
9
10use selene_core::{
11    ByteStringType, CharacterStringType, DbString, DecimalType, LabelSet, PropertyValueType,
12};
13use serde::{Deserialize, Serialize};
14
15pub use endpoint::EdgeEndpointDef;
16pub use property_defaults::{PropertyDefaultRecordField, PropertyDefaultValue};
17pub use property_element_types::PropertyElementType;
18use record_types::validate_record_field_types;
19pub use record_types::{RecordFieldType, RecordFieldTypeDef, RecordFieldTypes};
20
21use crate::error::{GraphError, GraphResult};
22
23/// Maximum supported nesting for catalog `LIST<T>` property element descriptors.
24pub const MAX_LIST_TYPE_NESTING: u32 = 64;
25
26/// Maximum supported nesting for catalog typed-`RECORD` field-type descriptors.
27///
28/// Shares the `LIST` budget: a single `depth` counter threads the heterogeneous
29/// `LIST`/`RECORD` nesting tower. Impl-defined per ISO 39075:2024 §4.15.4 (IL015),
30/// not an ISO constant.
31pub const MAX_RECORD_TYPE_NESTING: u32 = MAX_LIST_TYPE_NESTING;
32
33/// Definition of a closed graph type per ISO clause 18.
34#[derive(
35    Clone,
36    Debug,
37    Deserialize,
38    PartialEq,
39    rkyv::Archive,
40    rkyv::Deserialize,
41    rkyv::Serialize,
42    Serialize,
43)]
44pub struct GraphTypeDef {
45    /// Graph type name.
46    pub name: DbString,
47    /// Node-type elements in graph-type order.
48    pub node_types: Vec<NodeTypeDef>,
49    /// Edge-type elements in graph-type order.
50    pub edge_types: Vec<EdgeTypeDef>,
51}
52
53impl GraphTypeDef {
54    /// Validate this graph type's structural invariants.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`GraphError::Inconsistent`] when the type contains duplicate
59    /// names, invalid edge endpoint indexes, duplicate properties within a
60    /// node/edge type, duplicate edge triples, or an empty node label set.
61    pub fn validate(self) -> GraphResult<Self> {
62        self.validate_ref()?;
63        Ok(self)
64    }
65
66    /// Return the first node type matching `labels`.
67    #[must_use]
68    pub fn find_node_type(&self, labels: &LabelSet) -> Option<&NodeTypeDef> {
69        self.node_types
70            .iter()
71            .find(|node_type| &node_type.key_labels == labels)
72    }
73
74    /// Return the first node-type index matching `labels`.
75    #[must_use]
76    pub fn find_node_type_index(&self, labels: &LabelSet) -> Option<u32> {
77        self.node_types
78            .iter()
79            .position(|node_type| &node_type.key_labels == labels)
80            .and_then(|index| u32::try_from(index).ok())
81    }
82
83    /// Return the node-type index matching `name`.
84    #[must_use]
85    pub fn node_type_index_for(&self, name: DbString) -> Option<u32> {
86        self.node_types
87            .iter()
88            .position(|node_type| node_type.name == name)
89            .and_then(|index| u32::try_from(index).ok())
90    }
91
92    /// Return the edge type matching `label` and observed endpoint node types.
93    #[must_use]
94    pub fn find_edge_type(
95        &self,
96        label: DbString,
97        source_node_type: u32,
98        target_node_type: u32,
99    ) -> Option<&EdgeTypeDef> {
100        self.edge_types.iter().find(|edge_type| {
101            edge_type.label == label
102                && edge_type
103                    .source_node_type
104                    .matches_node_type(source_node_type)
105                && edge_type
106                    .target_node_type
107                    .matches_node_type(target_node_type)
108        })
109    }
110
111    /// Return the first edge type carrying `label`.
112    #[must_use]
113    pub fn first_edge_type_with_label(&self, label: DbString) -> Option<&EdgeTypeDef> {
114        self.edge_types
115            .iter()
116            .find(|edge_type| edge_type.label == label)
117    }
118
119    /// Return the edge-type index matching `name`.
120    #[must_use]
121    pub fn edge_type_index_for(&self, name: DbString) -> Option<u32> {
122        self.edge_types
123            .iter()
124            .position(|edge_type| edge_type.name == name)
125            .and_then(|index| u32::try_from(index).ok())
126    }
127
128    /// Return a copy with the named node type removed.
129    ///
130    /// Edge endpoint indexes are intentionally not rewritten. Callers that
131    /// cannot tolerate positional drift must reject the drop before using this
132    /// helper.
133    #[must_use]
134    pub fn without_node_type(&self, name: DbString) -> Option<Self> {
135        let index = self
136            .node_types
137            .iter()
138            .position(|node_type| node_type.name == name)?;
139        let mut next = self.clone();
140        next.node_types.remove(index);
141        Some(next)
142    }
143
144    /// Return a copy with the named edge type removed.
145    #[must_use]
146    pub fn without_edge_type(&self, name: DbString) -> Option<Self> {
147        let index = self
148            .edge_types
149            .iter()
150            .position(|edge_type| edge_type.name == name)?;
151        let mut next = self.clone();
152        next.edge_types.remove(index);
153        Some(next)
154    }
155
156    /// Validate the type without consuming it.
157    ///
158    /// Same checks as [`GraphTypeDef::validate`]; preferred when callers
159    /// already hold a reference (recovery, [`crate::SharedGraph::from_graph`]
160    /// re-validation) and cannot move the value.
161    pub fn validate_ref(&self) -> GraphResult<()> {
162        ensure_unique_names(
163            "node type",
164            self.node_types
165                .iter()
166                .map(|node_type| node_type.name.clone()),
167        )?;
168        ensure_unique_names(
169            "edge type",
170            self.edge_types
171                .iter()
172                .map(|edge_type| edge_type.name.clone()),
173        )?;
174
175        let mut seen_label_sets = BTreeSet::new();
176        for node_type in &self.node_types {
177            if node_type.key_labels.is_empty() {
178                return Err(GraphError::Inconsistent {
179                    reason: format!("node type {} has an empty label set", node_type.name),
180                });
181            }
182            // Why: find_node_type_index uses first-match semantics, so two
183            // node types with identical key_labels would leave the second
184            // unreachable AND cause edge / property validation to dispatch
185            // against the wrong type. Reject ambiguity at type-construction
186            // time rather than letting it manifest as silent mis-typing.
187            let label_key: Vec<DbString> = node_type.key_labels.iter().cloned().collect();
188            if !seen_label_sets.insert(label_key) {
189                return Err(GraphError::Inconsistent {
190                    reason: format!(
191                        "node type {} duplicates the key_labels of an earlier node type",
192                        node_type.name
193                    ),
194                });
195            }
196            ensure_unique_names(
197                "node property",
198                node_type
199                    .properties
200                    .iter()
201                    .map(|property| property.name.clone()),
202            )?;
203            validate_property_element_types(node_type.name.clone(), &node_type.properties)?;
204        }
205
206        let node_type_count = self.node_types.len();
207        for (index, edge_type) in self.edge_types.iter().enumerate() {
208            ensure_endpoint_index(
209                node_type_count,
210                &edge_type.source_node_type,
211                edge_type.name.clone(),
212            )?;
213            ensure_endpoint_index(
214                node_type_count,
215                &edge_type.target_node_type,
216                edge_type.name.clone(),
217            )?;
218            ensure_unique_names(
219                "edge property",
220                edge_type
221                    .properties
222                    .iter()
223                    .map(|property| property.name.clone()),
224            )?;
225            validate_property_element_types(edge_type.name.clone(), &edge_type.properties)?;
226            if self.edge_types[..index].iter().any(|previous| {
227                previous.label == edge_type.label
228                    && previous
229                        .source_node_type
230                        .overlaps(&edge_type.source_node_type)
231                    && previous
232                        .target_node_type
233                        .overlaps(&edge_type.target_node_type)
234            }) {
235                return Err(GraphError::Inconsistent {
236                    reason: format!(
237                        "ambiguous edge type endpoints ({}, {}, {})",
238                        edge_type.label, edge_type.source_node_type, edge_type.target_node_type
239                    ),
240                });
241            }
242        }
243        Ok(())
244    }
245}
246
247fn validate_property_element_types(
248    type_name: DbString,
249    properties: &[PropertyTypeDef],
250) -> GraphResult<()> {
251    for property in properties {
252        if property.decimal_type.is_some() && property.value_type != PropertyValueType::Decimal {
253            return Err(GraphError::Inconsistent {
254                reason: format!(
255                    "property {} on type {type_name} declares decimal precision for non-DECIMAL value type {}",
256                    property.name, property.value_type
257                ),
258            });
259        }
260        if property.character_string_type.is_some()
261            && property.value_type != PropertyValueType::String
262        {
263            return Err(GraphError::Inconsistent {
264                reason: format!(
265                    "property {} on type {type_name} declares character-string length for non-STRING value type {}",
266                    property.name, property.value_type
267                ),
268            });
269        }
270        if property.byte_string_type.is_some() && property.value_type != PropertyValueType::Bytes {
271            return Err(GraphError::Inconsistent {
272                reason: format!(
273                    "property {} on type {type_name} declares byte-string length for non-BYTES value type {}",
274                    property.name, property.value_type
275                ),
276            });
277        }
278        if property.value_type == PropertyValueType::List {
279            let Some(element_type) = property.list_element_type.as_ref() else {
280                // Legacy snapshots written before typed LIST<T> descriptors
281                // stored only the coarse LIST tag. Keep that shape valid so
282                // recovery preserves existing closed graph schemas; new GQL
283                // catalog DDL always fills the descriptor.
284                continue;
285            };
286            validate_property_element_type(
287                type_name.clone(),
288                property.name.clone(),
289                element_type,
290                1,
291            )?;
292        } else if property.value_type == PropertyValueType::RecordTyped {
293            // Bare RecordTyped is permissive (mirrors legacy untyped LIST): with no
294            // declared field structure there is nothing to validate.
295            if let Some(fields) = property.record_field_types.as_ref() {
296                validate_record_field_types(type_name.clone(), property.name.clone(), fields, 1)?;
297            }
298        } else if property.list_element_type.is_some() {
299            return Err(GraphError::Inconsistent {
300                reason: format!(
301                    "property {} on type {type_name} declares a list element type for non-LIST value type {}",
302                    property.name, property.value_type
303                ),
304            });
305        } else if property.record_field_types.is_some() {
306            return Err(GraphError::Inconsistent {
307                reason: format!(
308                    "property {} on type {type_name} declares record field types for non-RECORD value type {}",
309                    property.name, property.value_type
310                ),
311            });
312        }
313    }
314    Ok(())
315}
316
317fn validate_property_element_type(
318    type_name: DbString,
319    property_name: DbString,
320    element_type: &PropertyElementType,
321    depth: u32,
322) -> GraphResult<()> {
323    if depth > MAX_LIST_TYPE_NESTING {
324        return Err(GraphError::Inconsistent {
325            reason: format!(
326                "property {property_name} on type {type_name} exceeds LIST nesting limit"
327            ),
328        });
329    }
330    match element_type {
331        PropertyElementType::NotNull(inner) => {
332            validate_property_element_type(type_name, property_name, inner, depth)
333        }
334        PropertyElementType::Scalar(
335            PropertyValueType::List | PropertyValueType::Record | PropertyValueType::RecordTyped,
336        ) => Err(GraphError::Inconsistent {
337            reason: format!(
338                "property {property_name} on type {type_name} uses unsupported LIST element type {}",
339                element_type.value_type()
340            ),
341        }),
342        PropertyElementType::Scalar(_)
343        | PropertyElementType::CharacterString(_)
344        | PropertyElementType::Decimal(_)
345        | PropertyElementType::ByteString(_) => Ok(()),
346        PropertyElementType::List(inner) => {
347            validate_property_element_type(type_name, property_name, inner, depth + 1)
348        }
349    }
350}
351
352/// Node-type element.
353#[derive(
354    Clone,
355    Debug,
356    Deserialize,
357    PartialEq,
358    rkyv::Archive,
359    rkyv::Deserialize,
360    rkyv::Serialize,
361    Serialize,
362)]
363pub struct NodeTypeDef {
364    /// Node type name.
365    pub name: DbString,
366    /// Defining label set for this node type.
367    pub key_labels: LabelSet,
368    /// Declared properties.
369    pub properties: Vec<PropertyTypeDef>,
370    /// Validation mode for undeclared-property writes.
371    pub validation_mode: ValidationMode,
372}
373
374/// Edge-type element.
375#[derive(
376    Clone,
377    Debug,
378    Deserialize,
379    PartialEq,
380    rkyv::Archive,
381    rkyv::Deserialize,
382    rkyv::Serialize,
383    Serialize,
384)]
385pub struct EdgeTypeDef {
386    /// Edge type name.
387    pub name: DbString,
388    /// Edge label.
389    pub label: DbString,
390    /// Source endpoint definition.
391    pub source_node_type: EdgeEndpointDef,
392    /// Target endpoint definition.
393    pub target_node_type: EdgeEndpointDef,
394    /// Declared properties.
395    pub properties: Vec<PropertyTypeDef>,
396    /// Validation mode for undeclared-property writes.
397    pub validation_mode: ValidationMode,
398}
399
400/// Property declaration for a closed graph type.
401#[derive(
402    Clone,
403    Debug,
404    Deserialize,
405    PartialEq,
406    rkyv::Archive,
407    rkyv::Deserialize,
408    rkyv::Serialize,
409    Serialize,
410)]
411pub struct PropertyTypeDef {
412    /// Property name.
413    pub name: DbString,
414    /// Declared value type.
415    pub value_type: PropertyValueType,
416    /// Declared element type when [`PropertyTypeDef::value_type`] is `List`.
417    pub list_element_type: Option<PropertyElementType>,
418    /// `true` means NOT NULL / required.
419    pub required: bool,
420    /// Default value materialized when the property is omitted on create.
421    pub default: Option<PropertyDefaultValue>,
422    /// Whether updates to this property are forbidden after creation.
423    pub immutable: bool,
424    /// Whether non-null property values must be unique within the declaring type.
425    pub unique: bool,
426    /// Declared decimal precision/scale when [`PropertyTypeDef::value_type`] is
427    /// `Decimal`.
428    pub decimal_type: Option<DecimalType>,
429    /// Declared character-string length when [`PropertyTypeDef::value_type`] is
430    /// `String`.
431    pub character_string_type: Option<CharacterStringType>,
432    /// Declared byte-string length when [`PropertyTypeDef::value_type`] is `Bytes`.
433    pub byte_string_type: Option<ByteStringType>,
434    /// Declared field types when [`PropertyTypeDef::value_type`] is `RecordTyped`.
435    /// `Some` only for closed/typed `RECORD` declarations; `None` for open `Record`
436    /// and every non-record value type (symmetric to
437    /// [`PropertyTypeDef::list_element_type`]).
438    pub record_field_types: Option<RecordFieldTypes>,
439}
440
441/// Closed-graph validation mode.
442#[derive(
443    Clone,
444    Copy,
445    Debug,
446    Default,
447    Deserialize,
448    Eq,
449    Hash,
450    PartialEq,
451    rkyv::Archive,
452    rkyv::Deserialize,
453    rkyv::Serialize,
454    Serialize,
455)]
456pub enum ValidationMode {
457    /// Reject undeclared-property writes.
458    #[default]
459    Strict,
460    /// Allow undeclared-property writes and record a warning.
461    Warn,
462}
463
464/// Behavior of a `DROP NODE TYPE` / `DROP EDGE TYPE` statement when the type
465/// still has surviving instances or inbound type dependencies.
466///
467/// `Restrict` (the default when no behavior keyword is written) is the
468/// Seam-B fix from the deletion-reclamation audit (Item 3): dropping a type
469/// whose instances still exist would otherwise leave orphan instances whose
470/// declared type no longer exists (a silent graph-type-consistency violation
471/// on a closed GG02 graph). `Restrict` makes that rejection explicit and early.
472/// `Cascade` (selene-db `IM_DROP_CASCADE` vendor extension) truncates the
473/// instances first, then drops the type, atomically in one transaction.
474#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
475pub enum DropBehavior {
476    /// Reject the drop when instances or inbound type dependencies remain; the
477    /// type is not dropped and no `Change` is recorded (no partial state).
478    #[default]
479    Restrict,
480    /// Truncate every instance of the type first (reusing the
481    /// `Mutator::truncate_*` funnel), then drop the type — both in one
482    /// transaction.
483    Cascade,
484}
485
486fn ensure_unique_names(
487    kind: &'static str,
488    names: impl Iterator<Item = DbString>,
489) -> GraphResult<()> {
490    let mut seen = BTreeSet::new();
491    for name in names {
492        if !seen.insert(name.clone()) {
493            return Err(GraphError::Inconsistent {
494                reason: format!("duplicate {kind} name {name}"),
495            });
496        }
497    }
498    Ok(())
499}
500
501fn ensure_node_type_index(count: usize, index: u32, edge_name: DbString) -> GraphResult<()> {
502    if usize::try_from(index).is_ok_and(|index| index < count) {
503        return Ok(());
504    }
505    Err(GraphError::Inconsistent {
506        reason: format!(
507            "edge type {edge_name} references node type index {index}, but only {count} node types exist"
508        ),
509    })
510}
511
512fn ensure_endpoint_index(
513    count: usize,
514    endpoint: &EdgeEndpointDef,
515    edge_name: DbString,
516) -> GraphResult<()> {
517    match endpoint {
518        EdgeEndpointDef::Any => Ok(()),
519        EdgeEndpointDef::NodeType(index) => ensure_node_type_index(count, *index, edge_name),
520        EdgeEndpointDef::OneOf(indices) => {
521            // Defense in depth: the `EdgeEndpointDef::one_of` constructor
522            // already canonicalizes, but raw struct construction or rkyv/serde
523            // decoding can bypass it. Reject malformed OneOf payloads here so
524            // a single check at graph-type construction covers every path.
525            if indices.len() < 2 {
526                return Err(GraphError::Inconsistent {
527                    reason: format!(
528                        "edge type {edge_name} has a OneOf endpoint with {} indices; OneOf must enumerate at least two distinct node types (singletons must collapse to NodeType)",
529                        indices.len()
530                    ),
531                });
532            }
533            for window in indices.windows(2) {
534                if window[0] >= window[1] {
535                    return Err(GraphError::Inconsistent {
536                        reason: format!(
537                            "edge type {edge_name} has a OneOf endpoint that is not sorted and deduplicated ({}, {})",
538                            window[0], window[1]
539                        ),
540                    });
541                }
542            }
543            for index in indices {
544                ensure_node_type_index(count, *index, edge_name.clone())?;
545            }
546            Ok(())
547        }
548    }
549}
550
551#[cfg(test)]
552#[path = "graph_types_tests.rs"]
553mod tests;
554
555#[cfg(test)]
556#[path = "graph_types_property_default_tests.rs"]
557mod property_default_tests;