Skip to main content

selene_graph/
type_validator.rs

1//! Closed graph type validation.
2
3use std::fmt;
4
5use selene_core::{
6    Change, DbString, EdgeId, LabelSet, NodeId, PropertyMap, PropertyValueType, Value,
7    byte_string_fits_type, character_string_fits_type, decimal_fits_type,
8};
9
10use crate::graph::SeleneGraph;
11use crate::graph_types::{EdgeEndpointDef, GraphTypeDef, PropertyTypeDef, ValidationMode};
12
13mod unique;
14
15#[cfg(test)]
16pub(crate) use unique::unique_property_check_required;
17pub(crate) use unique::{validate_unique_property_changes, validate_unique_property_state};
18
19/// Identifier for a typed graph entity.
20#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
21pub enum EntityId {
22    /// Node entity.
23    Node(NodeId),
24    /// Edge entity.
25    Edge(EdgeId),
26}
27
28impl fmt::Display for EntityId {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Node(id) => write!(formatter, "node {id}"),
32            Self::Edge(id) => write!(formatter, "edge {id}"),
33        }
34    }
35}
36
37/// Closed graph type validation failure.
38#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, miette::Diagnostic)]
39#[non_exhaustive]
40pub enum TypeViolation {
41    /// Node labels do not match any node type.
42    #[error("node {id} has labels {labels:?}, which do not match any node type")]
43    #[diagnostic(code(SLENE_G_030))]
44    UnknownNodeLabel {
45        /// Node ID.
46        id: NodeId,
47        /// Observed node label set.
48        labels: LabelSet,
49    },
50
51    /// Edge label does not match any edge type.
52    #[error("edge {id} has label {label}, which does not match any edge type")]
53    #[diagnostic(code(SLENE_G_031))]
54    UnknownEdgeLabel {
55        /// Edge ID.
56        id: EdgeId,
57        /// Observed edge label.
58        label: DbString,
59    },
60
61    /// Edge endpoints do not match the declared edge type endpoints.
62    #[error(
63        "edge {id} label {label} expected endpoint types ({expected_source_type}, {expected_target_type}) but observed ({observed_source_type}, {observed_target_type})"
64    )]
65    #[diagnostic(code(SLENE_G_032))]
66    EdgeEndpointTypeMismatch {
67        /// Edge ID.
68        id: EdgeId,
69        /// Edge label.
70        label: DbString,
71        /// Expected source endpoint.
72        expected_source_type: EdgeEndpointDef,
73        /// Observed source node-type index.
74        observed_source_type: u32,
75        /// Expected target endpoint.
76        expected_target_type: EdgeEndpointDef,
77        /// Observed target node-type index.
78        observed_target_type: u32,
79    },
80
81    /// Required property is absent or null.
82    #[error("{entity_id} is missing required property {property} declared in {declared_in}")]
83    #[diagnostic(code(SLENE_G_033))]
84    MissingRequiredProperty {
85        /// Entity that violated the declaration.
86        entity_id: EntityId,
87        /// Missing property name.
88        property: DbString,
89        /// Node or edge type that declares the property.
90        declared_in: DbString,
91    },
92
93    /// Property value has the wrong runtime type.
94    #[error("{entity_id} property {property} expected {expected} but observed {observed}")]
95    #[diagnostic(code(SLENE_G_034))]
96    PropertyTypeMismatch {
97        /// Entity that violated the declaration.
98        entity_id: EntityId,
99        /// Property name.
100        property: DbString,
101        /// Expected property value type.
102        expected: PropertyValueType,
103        /// Observed runtime value type.
104        observed: &'static str,
105    },
106
107    /// `Value::Extended` is not a declarable closed-graph type.
108    #[error("{entity_id} property {property} uses a Value::Extended payload")]
109    #[diagnostic(code(SLENE_G_035))]
110    ExtensionValueRejected {
111        /// Entity that violated the declaration.
112        entity_id: EntityId,
113        /// Property name.
114        property: DbString,
115    },
116
117    /// Property is not declared by the matched node or edge type.
118    #[error("{entity_id} property {property} is not declared by the matched type")]
119    #[diagnostic(code(SLENE_G_036))]
120    UndeclaredProperty {
121        /// Entity that violated the declaration.
122        entity_id: EntityId,
123        /// Undeclared property name.
124        property: DbString,
125    },
126
127    /// Immutable property was updated or removed.
128    #[error("{entity_id} property {property} declared in {declared_in} is immutable")]
129    #[diagnostic(code(SLENE_G_037))]
130    ImmutablePropertyUpdate {
131        /// Entity that violated the declaration.
132        entity_id: EntityId,
133        /// Immutable property name.
134        property: DbString,
135        /// Node or edge type that declares the property.
136        declared_in: DbString,
137    },
138
139    /// Unique property value is already used by another entity of the declaring type.
140    #[error(
141        "{entity_id} property {property} declared in {declared_in} duplicates {conflicting_entity_id}"
142    )]
143    #[diagnostic(code(SLENE_G_038))]
144    UniquePropertyDuplicate {
145        /// Entity that violated the declaration.
146        entity_id: EntityId,
147        /// Existing entity carrying the same value.
148        conflicting_entity_id: EntityId,
149        /// Unique property name.
150        property: DbString,
151        /// Node or edge type that declares the property.
152        declared_in: DbString,
153    },
154}
155
156/// Non-fatal closed graph validation record.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct TypeWarning {
159    /// Relaxed type-model violation.
160    pub violation: TypeViolation,
161}
162
163/// Validate a single already-applied change against a graph type.
164///
165/// `graph` must be the post-change working snapshot. This lets update changes
166/// validate required properties and edge endpoint types from the same state
167/// that would publish on successful commit.
168pub fn validate_change(
169    change: &Change,
170    graph: &SeleneGraph,
171    type_def: &GraphTypeDef,
172) -> Result<Vec<TypeWarning>, TypeViolation> {
173    match change {
174        Change::NodeCreated { id, .. } => {
175            // Skip validation for entities the same transaction has since
176            // deleted: aborted-tx-IDs become permanent holes (D11), but a
177            // create-then-delete pair has no net effect and should not
178            // surface UnknownNodeLabel for a row that no longer exists.
179            if !graph.is_node_alive(*id) {
180                return Ok(Vec::new());
181            }
182            validate_node_state(*id, graph, type_def).map(|(_, warnings)| warnings)
183        }
184        Change::NodeUpdated {
185            id,
186            labels_diff,
187            properties_diff,
188        } => {
189            if !graph.is_node_alive(*id) {
190                return Ok(Vec::new());
191            }
192            let (node_type_index, mut warnings) = validate_node_state(*id, graph, type_def)?;
193            let node_type = &type_def.node_types[node_type_index as usize];
194            reject_immutable_property_update(
195                EntityId::Node(*id),
196                node_type.name.clone(),
197                &node_type.properties,
198                properties_diff,
199            )?;
200            if !labels_diff.is_empty() {
201                // A label change can invalidate every incident edge's
202                // (label, source_type, target_type) constraint without the
203                // edge itself producing a Change. Property-only updates cannot
204                // change endpoint type, so keep those commits O(1) in degree.
205                warnings.extend(revalidate_incident_edges(*id, graph, type_def)?);
206            }
207            Ok(warnings)
208        }
209        Change::EdgeCreated { id, .. } => {
210            if !graph.is_edge_alive(*id) {
211                return Ok(Vec::new());
212            }
213            validate_edge_state(*id, graph, type_def).map(|(_, warnings)| warnings)
214        }
215        Change::EdgeUpdated {
216            id,
217            properties_diff,
218        } => {
219            if !graph.is_edge_alive(*id) {
220                return Ok(Vec::new());
221            }
222            let (edge_type, warnings) = validate_edge_state(*id, graph, type_def)?;
223            reject_immutable_property_update(
224                EntityId::Edge(*id),
225                edge_type.name.clone(),
226                &edge_type.properties,
227                properties_diff,
228            )?;
229            Ok(warnings)
230        }
231        Change::NodePropertyRemoved { id, property } => {
232            if !graph.is_node_alive(*id) {
233                return Ok(Vec::new());
234            }
235            let (node_type_index, warnings) = validate_node_state(*id, graph, type_def)?;
236            let node_type = &type_def.node_types[node_type_index as usize];
237            reject_if_immutable(
238                EntityId::Node(*id),
239                node_type.name.clone(),
240                &node_type.properties,
241                property.clone(),
242            )?;
243            Ok(warnings)
244        }
245        Change::EdgePropertyRemoved { id, property } => {
246            if !graph.is_edge_alive(*id) {
247                return Ok(Vec::new());
248            }
249            let (edge_type, warnings) = validate_edge_state(*id, graph, type_def)?;
250            reject_if_immutable(
251                EntityId::Edge(*id),
252                edge_type.name.clone(),
253                &edge_type.properties,
254                property.clone(),
255            )?;
256            Ok(warnings)
257        }
258        Change::NodeLabelRemoved { id, .. } => {
259            if !graph.is_node_alive(*id) {
260                return Ok(Vec::new());
261            }
262            let (_, mut warnings) = validate_node_state(*id, graph, type_def)?;
263            warnings.extend(revalidate_incident_edges(*id, graph, type_def)?);
264            Ok(warnings)
265        }
266        // Truncation removes INSTANCES and keeps the bound type intact (the
267        // node/edge type still exists), and node-truncate cascades incident
268        // edges so the graph stays dangling-free — it can never violate GG02,
269        // exactly like NodeDeleted/EdgeDeleted. BRIEF-150 / audit Item 11.
270        // GraphReset sets bound_type = None in the same txn, so validate_change
271        // is never even invoked for it (the commit-time loop is gated on a Some
272        // bound_type). The arm is kept for exhaustiveness and is a no-op:
273        // wiping the whole graph + dropping the type can never violate GG02.
274        Change::NodeDeleted { .. }
275        | Change::EdgeDeleted { .. }
276        | Change::NodesOfTypeTruncated { .. }
277        | Change::EdgesOfTypeTruncated { .. }
278        | Change::GraphReset { .. }
279        | Change::SchemaChanged { .. } => Ok(Vec::new()),
280    }
281}
282
283fn revalidate_incident_edges(
284    node: NodeId,
285    graph: &SeleneGraph,
286    type_def: &GraphTypeDef,
287) -> Result<Vec<TypeWarning>, TypeViolation> {
288    let mut warnings = Vec::new();
289    if let Some(entry) = graph.outgoing_edges(node) {
290        for edge in entry.iter() {
291            if graph.is_edge_alive(edge.edge_id) {
292                warnings.extend(validate_edge_state(edge.edge_id, graph, type_def)?.1);
293            }
294        }
295    }
296    if let Some(entry) = graph.incoming_edges(node) {
297        for edge in entry.iter() {
298            if graph.is_edge_alive(edge.edge_id) {
299                warnings.extend(validate_edge_state(edge.edge_id, graph, type_def)?.1);
300            }
301        }
302    }
303    Ok(warnings)
304}
305
306/// Validate every alive node and edge in a materialized graph.
307pub fn validate_entity_state(
308    graph: &SeleneGraph,
309    type_def: &GraphTypeDef,
310) -> Result<Vec<TypeWarning>, TypeViolation> {
311    let mut warnings = Vec::new();
312    for row in graph.node_store.alive.iter() {
313        let id = graph
314            .node_id_for_row(crate::store::RowIndex::new(row))
315            .expect("alive node row has a mapped external id (BRIEF-Item-4a)");
316        warnings.extend(validate_node_state(id, graph, type_def)?.1);
317    }
318    for row in graph.edge_store.alive.iter() {
319        let id = graph
320            .edge_id_for_row(crate::store::RowIndex::new(row))
321            .expect("alive edge row has a mapped external id (BRIEF-Item-4a)");
322        warnings.extend(validate_edge_state(id, graph, type_def)?.1);
323    }
324    validate_unique_property_state(graph, type_def)?;
325    Ok(warnings)
326}
327
328fn validate_node_state(
329    id: NodeId,
330    graph: &SeleneGraph,
331    type_def: &GraphTypeDef,
332) -> Result<(u32, Vec<TypeWarning>), TypeViolation> {
333    // Borrow the live LabelSet/PropertyMap through; only the None (missing-row)
334    // path materializes an empty default, and only the error path clones the
335    // label set. On schema-changing commits this avoids deep-cloning every alive
336    // node's LabelSet + PropertyMap solely to read them.
337    let empty_labels = LabelSet::new();
338    let labels = graph.node_labels(id).unwrap_or(&empty_labels);
339    let node_type_index =
340        type_def
341            .find_node_type_index(labels)
342            .ok_or_else(|| TypeViolation::UnknownNodeLabel {
343                id,
344                labels: labels.clone(),
345            })?;
346    let node_type = &type_def.node_types[node_type_index as usize];
347    let empty_props = PropertyMap::new();
348    let properties = graph.node_properties(id).unwrap_or(&empty_props);
349    let warnings = validate_properties(
350        EntityId::Node(id),
351        node_type.name.clone(),
352        node_type.validation_mode,
353        &node_type.properties,
354        properties,
355    )?;
356    Ok((node_type_index, warnings))
357}
358
359fn validate_edge_state<'a>(
360    id: EdgeId,
361    graph: &SeleneGraph,
362    type_def: &'a GraphTypeDef,
363) -> Result<(&'a crate::graph_types::EdgeTypeDef, Vec<TypeWarning>), TypeViolation> {
364    let label = graph
365        .edge_label(id)
366        .cloned()
367        .ok_or(TypeViolation::UnknownEdgeLabel {
368            id,
369            label: selene_core::db_string("__selene_missing_edge_label")
370                .expect("static label admits"),
371        })?;
372    let (source, target) =
373        graph
374            .edge_endpoints(id)
375            .ok_or_else(|| TypeViolation::UnknownEdgeLabel {
376                id,
377                label: label.clone(),
378            })?;
379    let (source_type, mut warnings) = validate_node_state(source, graph, type_def)?;
380    let (target_type, target_warnings) = validate_node_state(target, graph, type_def)?;
381    warnings.extend(target_warnings);
382
383    let Some(edge_type) = type_def.find_edge_type(label.clone(), source_type, target_type) else {
384        let Some(expected) = type_def.first_edge_type_with_label(label.clone()) else {
385            return Err(TypeViolation::UnknownEdgeLabel { id, label });
386        };
387        return Err(TypeViolation::EdgeEndpointTypeMismatch {
388            id,
389            label,
390            expected_source_type: expected.source_node_type.clone(),
391            observed_source_type: source_type,
392            expected_target_type: expected.target_node_type.clone(),
393            observed_target_type: target_type,
394        });
395    };
396    let empty_props = PropertyMap::new();
397    let properties = graph.edge_properties(id).unwrap_or(&empty_props);
398    warnings.extend(validate_properties(
399        EntityId::Edge(id),
400        edge_type.name.clone(),
401        edge_type.validation_mode,
402        &edge_type.properties,
403        properties,
404    )?);
405    Ok((edge_type, warnings))
406}
407
408fn reject_immutable_property_update(
409    entity_id: EntityId,
410    declared_in: DbString,
411    declarations: &[PropertyTypeDef],
412    diff: &selene_core::PropertyDiff,
413) -> Result<(), TypeViolation> {
414    for (key, _) in &diff.set {
415        reject_if_immutable(entity_id, declared_in.clone(), declarations, key.clone())?;
416    }
417    for key in &diff.removed {
418        reject_if_immutable(entity_id, declared_in.clone(), declarations, key.clone())?;
419    }
420    Ok(())
421}
422
423fn reject_if_immutable(
424    entity_id: EntityId,
425    declared_in: DbString,
426    declarations: &[PropertyTypeDef],
427    property: DbString,
428) -> Result<(), TypeViolation> {
429    if declarations
430        .iter()
431        .any(|declaration| declaration.name == property && declaration.immutable)
432    {
433        return Err(TypeViolation::ImmutablePropertyUpdate {
434            entity_id,
435            property,
436            declared_in,
437        });
438    }
439    Ok(())
440}
441
442fn validate_properties(
443    entity_id: EntityId,
444    declared_in: DbString,
445    validation_mode: ValidationMode,
446    declarations: &[PropertyTypeDef],
447    properties: &PropertyMap,
448) -> Result<Vec<TypeWarning>, TypeViolation> {
449    let mut warnings = Vec::new();
450    for (key, value) in properties.iter() {
451        let Some(declaration) = declarations.iter().find(|decl| decl.name == *key) else {
452            let violation = TypeViolation::UndeclaredProperty {
453                entity_id,
454                property: key.clone(),
455            };
456            if validation_mode == ValidationMode::Warn {
457                warnings.push(TypeWarning { violation });
458                continue;
459            }
460            return Err(violation);
461        };
462        if matches!(value, Value::Extended { .. }) {
463            return Err(TypeViolation::ExtensionValueRejected {
464                entity_id,
465                property: key.clone(),
466            });
467        }
468        if matches!(value, Value::Null) {
469            if declaration.required {
470                return Err(TypeViolation::MissingRequiredProperty {
471                    entity_id,
472                    property: key.clone(),
473                    declared_in: declared_in.clone(),
474                });
475            }
476            continue;
477        }
478        if !property_value_matches(declaration, value) {
479            return Err(TypeViolation::PropertyTypeMismatch {
480                entity_id,
481                property: key.clone(),
482                expected: declaration.value_type,
483                observed: PropertyValueType::observed_name(value),
484            });
485        }
486    }
487
488    for declaration in declarations.iter().filter(|decl| decl.required) {
489        if properties
490            .get(&declaration.name)
491            .is_none_or(|value| matches!(value, Value::Null))
492        {
493            return Err(TypeViolation::MissingRequiredProperty {
494                entity_id,
495                property: declaration.name.clone(),
496                declared_in: declared_in.clone(),
497            });
498        }
499    }
500    Ok(warnings)
501}
502
503fn property_value_matches(declaration: &PropertyTypeDef, value: &Value) -> bool {
504    match declaration.value_type {
505        PropertyValueType::List => {
506            let Some(element_type) = declaration.list_element_type.as_ref() else {
507                return matches!(value, Value::List(_));
508            };
509            match value {
510                Value::List(values) => values.iter().all(|value| element_type.matches(value)),
511                _ => false,
512            }
513        }
514        // A RECORD-typed property accepts either record value form — the open
515        // `Value::Record` (the `RECORD{...}` constructor / by-name form) or the positional
516        // `Value::RecordTyped` — because the constructor always yields the open form
517        // regardless of the declared type. Structural conformance against a closed
518        // descriptor (or permissive acceptance for an open/bare `None` descriptor) is then
519        // decided by [`RecordFieldTypes::matches`].
520        // Why: closed/typed RECORD conformance per ISO 39075:2024 §4.15.4 (a closed record
521        // value must have the same field-name set as the descriptor and each field must
522        // match) → graph type violation G2000 (§4.13.2.1).
523        PropertyValueType::Record | PropertyValueType::RecordTyped => {
524            if !matches!(value, Value::Record(_) | Value::RecordTyped(_)) {
525                return false;
526            }
527            match declaration.record_field_types.as_ref() {
528                Some(fields) => fields.matches(value),
529                None => true,
530            }
531        }
532        PropertyValueType::Decimal => match declaration.decimal_type {
533            Some(decimal_type) => {
534                matches!(value, Value::Decimal(value) if decimal_fits_type(*value, decimal_type))
535            }
536            None => declaration.value_type.matches(value),
537        },
538        PropertyValueType::String => match declaration.character_string_type {
539            Some(character_string_type) => {
540                matches!(value, Value::String(value) if character_string_fits_type(value, character_string_type))
541            }
542            None => declaration.value_type.matches(value),
543        },
544        PropertyValueType::Bytes => match declaration.byte_string_type {
545            Some(byte_string_type) => {
546                matches!(value, Value::Bytes(value) if byte_string_fits_type(value, byte_string_type))
547            }
548            None => declaration.value_type.matches(value),
549        },
550        _ => declaration.value_type.matches(value),
551    }
552}
553
554#[cfg(test)]
555#[path = "type_validator_tests.rs"]
556mod tests;