Skip to main content

selene_graph/
graph_types.rs

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