Skip to main content

lora_store/memory/
constraint_enforce.rs

1//! Constraint enforcement primitives.
2//!
3//! The [`ConstraintCatalog`](super::ConstraintCatalog) records *what*
4//! constraints exist; this module knows *how* to check whether a piece
5//! of data — either an existing record or a proposed mutation —
6//! complies.
7//!
8//! Why it lives here: both the DDL pre-create scan ("does the current
9//! graph already violate the constraint we're about to register?") and
10//! the runtime mutation pre-check ("would this write violate any
11//! installed constraint?") need the same value-shape inspection. The
12//! executor calls the runtime hooks from `lora-executor`; the DDL path
13//! calls the scan from [`super::graph::InMemoryGraph::register_constraint`].
14//!
15//! Performance: the storage impl checks an atomic active-constraint
16//! counter before calling into this module, so workloads that never call
17//! `CREATE CONSTRAINT` skip the catalog lock entirely. Once constraints
18//! exist, the catalog read is held across validation of a single mutation;
19//! the writer mutex on the database serialises it against concurrent DDL.
20
21// Several helpers below are pub-within-store and used by the runtime
22// pre-check the executor will install in the next iteration; suppress
23// dead-code while that wiring is still pending.
24#![allow(dead_code)]
25
26use std::collections::HashSet;
27use std::fmt::Write as _;
28
29use thiserror::Error;
30
31use super::constraint_catalog::{
32    ConstraintCatalog, ConstraintDefinition, StoredConstraintKind, StoredPropertyType,
33    StoredPropertyTypeTerm, StoredScalarType, StoredVectorCoordType,
34};
35use super::index_catalog::StoredIndexEntity;
36use super::InMemoryGraph;
37use crate::types::{NodeId, Properties, PropertyValue, RelationshipId};
38
39/// Why a mutation was rejected by a constraint check. The codes match
40/// the GQLSTATUS-style shapes:
41///
42/// * 22N77 — property presence verification failed.
43/// * 22N78 — property type verification failed.
44/// * 22N79 — property uniqueness constraint violated.
45/// * 22N80 — index entry conflict (duplicate row found by the backing
46///   index during a CREATE CONSTRAINT scan).
47/// * 50N11 — generic "constraint creation failed" wrapper used at DDL
48///   time to surface the underlying 22N7x error.
49#[derive(Debug, Clone, Error)]
50pub enum ConstraintViolation {
51    #[error("[22N77] property presence verification failed. {kind} must have the property `{property}`",
52            kind = entity_kind_label(*entity, label))]
53    MissingProperty {
54        constraint: String,
55        entity: StoredIndexEntity,
56        label: String,
57        property: String,
58    },
59    #[error("[22N77] property presence verification failed. {kind} must have the properties: {properties}",
60            kind = entity_kind_label(*entity, label),
61            properties = properties.join(", "))]
62    MissingPropertiesForKey {
63        constraint: String,
64        entity: StoredIndexEntity,
65        label: String,
66        properties: Vec<String>,
67    },
68    #[error("[22N78] property type verification failed. {kind} must have property `{property}` with value type {expected}",
69            kind = entity_kind_label(*entity, label))]
70    WrongPropertyType {
71        constraint: String,
72        entity: StoredIndexEntity,
73        label: String,
74        property: String,
75        expected: String,
76    },
77    #[error("[22N79] property uniqueness constraint violated. {kind} already has property `{property}` with the supplied value",
78            kind = entity_kind_label(*entity, label))]
79    UniquenessViolated {
80        constraint: String,
81        entity: StoredIndexEntity,
82        label: String,
83        property: String,
84    },
85}
86
87impl ConstraintViolation {
88    pub const fn gql_status(&self) -> &'static str {
89        match self {
90            ConstraintViolation::MissingProperty { .. }
91            | ConstraintViolation::MissingPropertiesForKey { .. } => "22N77",
92            ConstraintViolation::WrongPropertyType { .. } => "22N78",
93            ConstraintViolation::UniquenessViolated { .. } => "22N79",
94        }
95    }
96
97    pub fn constraint_name(&self) -> &str {
98        match self {
99            ConstraintViolation::MissingProperty { constraint, .. }
100            | ConstraintViolation::MissingPropertiesForKey { constraint, .. }
101            | ConstraintViolation::WrongPropertyType { constraint, .. }
102            | ConstraintViolation::UniquenessViolated { constraint, .. } => constraint,
103        }
104    }
105}
106
107fn entity_kind_label(entity: StoredIndexEntity, label: &str) -> String {
108    match entity {
109        StoredIndexEntity::Node => format!("NODE with label `{label}`"),
110        StoredIndexEntity::Relationship => format!("RELATIONSHIP with type `{label}`"),
111    }
112}
113
114impl InMemoryGraph {
115    /// Check whether the *current* graph contains data that would
116    /// violate `def` if it were registered. Called from
117    /// `register_constraint` just before the catalog write commits.
118    ///
119    /// Returns the first violation we find; we don't enumerate all
120    /// failures because a single rejection is enough to refuse the
121    /// CREATE CONSTRAINT.
122    pub(super) fn validate_existing_data_for_constraint(
123        &self,
124        def: &ConstraintDefinition,
125    ) -> Result<(), ConstraintViolation> {
126        match def.entity {
127            StoredIndexEntity::Node => self.validate_existing_nodes_for_constraint(def),
128            StoredIndexEntity::Relationship => self.validate_existing_rels_for_constraint(def),
129        }
130    }
131
132    fn validate_existing_nodes_for_constraint(
133        &self,
134        def: &ConstraintDefinition,
135    ) -> Result<(), ConstraintViolation> {
136        let label = def.label.as_str();
137        let mut seen: HashSet<String> = HashSet::new();
138        for (_, node) in self.iter_nodes() {
139            if !node.labels.iter().any(|l| l == label) {
140                continue;
141            }
142            validate_record_against_constraint(def, &node.properties, &mut seen)?;
143        }
144        Ok(())
145    }
146
147    fn validate_existing_rels_for_constraint(
148        &self,
149        def: &ConstraintDefinition,
150    ) -> Result<(), ConstraintViolation> {
151        let rel_type = def.label.as_str();
152        let mut seen: HashSet<String> = HashSet::new();
153        for (_, rel) in self.iter_rels() {
154            if rel.rel_type != rel_type {
155                continue;
156            }
157            validate_record_against_constraint(def, &rel.properties, &mut seen)?;
158        }
159        Ok(())
160    }
161}
162
163fn validate_record_against_constraint(
164    def: &ConstraintDefinition,
165    properties: &Properties,
166    seen: &mut HashSet<String>,
167) -> Result<(), ConstraintViolation> {
168    // 1) Existence checks.
169    if def.kind.requires_existence() {
170        let missing: Vec<String> = def
171            .properties
172            .iter()
173            .filter(|p| !properties.contains_key(p.as_str()))
174            .cloned()
175            .collect();
176        if !missing.is_empty() {
177            return Err(if def.properties.len() == 1 {
178                ConstraintViolation::MissingProperty {
179                    constraint: def.name.clone(),
180                    entity: def.entity,
181                    label: def.label.clone(),
182                    property: def.properties[0].clone(),
183                }
184            } else {
185                ConstraintViolation::MissingPropertiesForKey {
186                    constraint: def.name.clone(),
187                    entity: def.entity,
188                    label: def.label.clone(),
189                    properties: def.properties.clone(),
190                }
191            });
192        }
193    }
194
195    // 2) Property type checks (single-property only by grammar).
196    if let StoredConstraintKind::PropertyType(target) = &def.kind {
197        let key = &def.properties[0];
198        if let Some(value) = properties.get(key.as_str()) {
199            if !value_matches_property_type(value, target) {
200                return Err(ConstraintViolation::WrongPropertyType {
201                    constraint: def.name.clone(),
202                    entity: def.entity,
203                    label: def.label.clone(),
204                    property: key.clone(),
205                    expected: target.to_string(),
206                });
207            }
208        }
209    }
210
211    // 3) Uniqueness checks. Only constraints that require uniqueness
212    // run this; we still skip records that don't carry the full
213    // property tuple (matches the "uniqueness only applies when
214    // all constrained properties are present" rule for plain
215    // uniqueness; key constraints always require existence so the
216    // tuple is guaranteed present by step 1).
217    if def.kind.requires_uniqueness() {
218        let tuple_present = def
219            .properties
220            .iter()
221            .all(|p| properties.contains_key(p.as_str()));
222        if tuple_present {
223            let key = property_tuple_key(def, properties);
224            if !seen.insert(key) {
225                let property_label = if def.properties.len() == 1 {
226                    def.properties[0].clone()
227                } else {
228                    def.properties.join(", ")
229                };
230                return Err(ConstraintViolation::UniquenessViolated {
231                    constraint: def.name.clone(),
232                    entity: def.entity,
233                    label: def.label.clone(),
234                    property: property_label,
235                });
236            }
237        }
238    }
239
240    Ok(())
241}
242
243/// Cheap stable string-encoding for a property tuple — sufficient as a
244/// `HashSet` key in the per-constraint pre-create scan. Not exposed to
245/// callers because the shape isn't durable.
246fn property_tuple_key(def: &ConstraintDefinition, properties: &Properties) -> String {
247    let mut out = String::with_capacity(64);
248    for (i, key) in def.properties.iter().enumerate() {
249        if i > 0 {
250            out.push('\u{1f}');
251        }
252        if let Some(value) = properties.get(key.as_str()) {
253            append_property_value_key(&mut out, value);
254        }
255    }
256    out
257}
258
259fn append_property_value_key(out: &mut String, value: &PropertyValue) {
260    match value {
261        PropertyValue::Null => out.push('N'),
262        PropertyValue::Bool(b) => {
263            out.push('B');
264            out.push(if *b { 'T' } else { 'F' });
265        }
266        PropertyValue::Int(i) => {
267            out.push('I');
268            out.push_str(&i.to_string());
269        }
270        PropertyValue::Float(f) => {
271            out.push('F');
272            out.push_str(&format!("{f:?}"));
273        }
274        PropertyValue::String(s) => {
275            out.push('S');
276            append_len_prefixed_str(out, s);
277        }
278        PropertyValue::Date(d) => {
279            out.push_str("D:");
280            append_len_prefixed_str(out, &format!("{d:?}"));
281        }
282        PropertyValue::Time(t) => {
283            out.push_str("T:");
284            append_len_prefixed_str(out, &format!("{t:?}"));
285        }
286        PropertyValue::LocalTime(t) => {
287            out.push_str("LT:");
288            append_len_prefixed_str(out, &format!("{t:?}"));
289        }
290        PropertyValue::DateTime(dt) => {
291            out.push_str("DT:");
292            append_len_prefixed_str(out, &format!("{dt:?}"));
293        }
294        PropertyValue::LocalDateTime(dt) => {
295            out.push_str("LDT:");
296            append_len_prefixed_str(out, &format!("{dt:?}"));
297        }
298        PropertyValue::Duration(d) => {
299            out.push_str("DUR:");
300            append_len_prefixed_str(out, &format!("{d:?}"));
301        }
302        PropertyValue::Point(p) => {
303            out.push_str("P:");
304            append_len_prefixed_str(out, &format!("{p:?}"));
305        }
306        PropertyValue::Vector(v) => {
307            out.push_str("V:");
308            append_len_prefixed_str(out, &v.to_key_string());
309        }
310        PropertyValue::List(items) => {
311            out.push('L');
312            append_len(out, items.len());
313            for item in items {
314                append_property_value_key(out, item);
315            }
316        }
317        PropertyValue::Map(entries) => {
318            out.push('M');
319            append_len(out, entries.len());
320            for (k, v) in entries {
321                append_len_prefixed_str(out, k);
322                append_property_value_key(out, v);
323            }
324        }
325        PropertyValue::Binary(b) => {
326            out.push_str("BIN:");
327            append_len(out, b.len());
328            for segment in b.chunks() {
329                for byte in segment {
330                    let _ = write!(out, "{byte:02x}");
331                }
332            }
333        }
334    }
335}
336
337fn append_len(out: &mut String, len: usize) {
338    out.push_str(&len.to_string());
339    out.push(':');
340}
341
342fn append_len_prefixed_str(out: &mut String, value: &str) {
343    append_len(out, value.len());
344    out.push_str(value);
345}
346
347/// True when `value` satisfies any branch of the target type. Used by
348/// both DDL-time scans and runtime mutation checks.
349pub fn value_matches_property_type(value: &PropertyValue, target: &StoredPropertyType) -> bool {
350    target
351        .alternatives
352        .iter()
353        .any(|term| value_matches_term(value, term))
354}
355
356fn value_matches_term(value: &PropertyValue, term: &StoredPropertyTypeTerm) -> bool {
357    match term {
358        StoredPropertyTypeTerm::Scalar(scalar) => value_matches_scalar(value, *scalar),
359        StoredPropertyTypeTerm::List { inner, not_null } => match value {
360            PropertyValue::List(items) => items.iter().all(|item| {
361                if matches!(item, PropertyValue::Null) {
362                    !*not_null
363                } else {
364                    value_matches_term(item, inner)
365                }
366            }),
367            _ => false,
368        },
369        StoredPropertyTypeTerm::Vector { coord, dimension } => match value {
370            PropertyValue::Vector(v) => vector_matches(v, *coord, *dimension),
371            _ => false,
372        },
373    }
374}
375
376fn value_matches_scalar(value: &PropertyValue, scalar: StoredScalarType) -> bool {
377    match (value, scalar) {
378        (PropertyValue::Bool(_), StoredScalarType::Boolean) => true,
379        (PropertyValue::String(_), StoredScalarType::String) => true,
380        (PropertyValue::Int(_), StoredScalarType::Integer) => true,
381        (PropertyValue::Float(_), StoredScalarType::Float) => true,
382        (PropertyValue::Date(_), StoredScalarType::Date) => true,
383        (PropertyValue::Time(_), StoredScalarType::ZonedTime) => true,
384        (PropertyValue::LocalTime(_), StoredScalarType::LocalTime) => true,
385        (PropertyValue::DateTime(_), StoredScalarType::ZonedDateTime) => true,
386        (PropertyValue::LocalDateTime(_), StoredScalarType::LocalDateTime) => true,
387        (PropertyValue::Duration(_), StoredScalarType::Duration) => true,
388        (PropertyValue::Point(_), StoredScalarType::Point) => true,
389        // Map / Any are rejected at DDL time, so they should never appear
390        // here; reaching this arm with one of them indicates an upstream
391        // bug — fail closed.
392        _ => false,
393    }
394}
395
396fn vector_matches(
397    vector: &crate::types::LoraVector,
398    coord: StoredVectorCoordType,
399    dimension: u32,
400) -> bool {
401    if vector.dimension != dimension as usize {
402        return false;
403    }
404    use crate::types::VectorValues;
405    use StoredVectorCoordType::*;
406    matches!(
407        (&vector.values, coord),
408        (VectorValues::Float64(_), Float64)
409            | (VectorValues::Float32(_), Float32)
410            | (VectorValues::Integer64(_), Int64)
411            | (VectorValues::Integer32(_), Int32)
412            | (VectorValues::Integer16(_), Int16)
413            | (VectorValues::Integer8(_), Int8)
414    )
415}
416
417#[derive(Clone, Copy)]
418enum NodeLabelMatcher<'a> {
419    AnyOf(&'a [String]),
420    One(&'a str),
421}
422
423impl NodeLabelMatcher<'_> {
424    fn contains(self, label: &str) -> bool {
425        match self {
426            NodeLabelMatcher::AnyOf(labels) => labels.iter().any(|l| l == label),
427            NodeLabelMatcher::One(candidate) => candidate == label,
428        }
429    }
430}
431
432#[derive(Clone, Copy)]
433enum ConstraintRecord<'a> {
434    Node {
435        labels: NodeLabelMatcher<'a>,
436        properties: &'a Properties,
437        skip: Option<NodeId>,
438    },
439    Relationship {
440        rel_type: &'a str,
441        properties: &'a Properties,
442        skip: Option<RelationshipId>,
443    },
444}
445
446impl<'a> ConstraintRecord<'a> {
447    fn applies_to(self, def: &ConstraintDefinition) -> bool {
448        match self {
449            ConstraintRecord::Node { labels, .. } => {
450                def.entity == StoredIndexEntity::Node && labels.contains(&def.label)
451            }
452            ConstraintRecord::Relationship { rel_type, .. } => {
453                def.entity == StoredIndexEntity::Relationship && def.label == rel_type
454            }
455        }
456    }
457
458    fn properties(self) -> &'a Properties {
459        match self {
460            ConstraintRecord::Node { properties, .. }
461            | ConstraintRecord::Relationship { properties, .. } => properties,
462        }
463    }
464
465    fn has_uniqueness_conflict(
466        self,
467        graph: &InMemoryGraph,
468        def: &ConstraintDefinition,
469        tuple: &[PropertyValue],
470    ) -> bool {
471        match self {
472            ConstraintRecord::Node { skip, .. } => {
473                any_other_node_with_tuple(graph, &def.label, &def.properties, tuple, skip)
474            }
475            ConstraintRecord::Relationship { skip, .. } => {
476                any_other_rel_with_tuple(graph, &def.label, &def.properties, tuple, skip)
477            }
478        }
479    }
480}
481
482fn check_record_constraints(
483    catalog: &ConstraintCatalog,
484    graph: &InMemoryGraph,
485    record: ConstraintRecord<'_>,
486) -> Result<(), ConstraintViolation> {
487    for def in catalog.iter() {
488        if !record.applies_to(def) {
489            continue;
490        }
491
492        let properties = record.properties();
493        let mut probe: HashSet<String> = HashSet::new();
494        validate_record_against_constraint(def, properties, &mut probe)?;
495
496        if def.kind.requires_uniqueness() {
497            if let Some(tuple) = constrained_tuple(def, properties) {
498                if record.has_uniqueness_conflict(graph, def, &tuple) {
499                    return Err(uniqueness_violation(def));
500                }
501            }
502        }
503    }
504    Ok(())
505}
506
507/// Public read-side check used by mutation paths: given a proposed
508/// node create (labels + properties), is it accepted by every
509/// installed constraint? Cheap when no constraints are registered
510/// (single `is_empty()` on the catalog).
511pub(crate) fn check_node_create(
512    catalog: &ConstraintCatalog,
513    graph: &InMemoryGraph,
514    labels: &[String],
515    properties: &Properties,
516) -> Result<(), ConstraintViolation> {
517    check_record_constraints(
518        catalog,
519        graph,
520        ConstraintRecord::Node {
521            labels: NodeLabelMatcher::AnyOf(labels),
522            properties,
523            skip: None,
524        },
525    )
526}
527
528pub(crate) fn check_relationship_create(
529    catalog: &ConstraintCatalog,
530    graph: &InMemoryGraph,
531    rel_type: &str,
532    properties: &Properties,
533) -> Result<(), ConstraintViolation> {
534    check_record_constraints(
535        catalog,
536        graph,
537        ConstraintRecord::Relationship {
538            rel_type,
539            properties,
540            skip: None,
541        },
542    )
543}
544
545fn any_other_node_with_tuple(
546    graph: &InMemoryGraph,
547    label: &str,
548    keys: &[String],
549    target: &[PropertyValue],
550    skip: Option<NodeId>,
551) -> bool {
552    for (id, node) in graph.iter_nodes() {
553        if Some(id) == skip {
554            continue;
555        }
556        if !node.labels.iter().any(|l| l == label) {
557            continue;
558        }
559        let matches = keys.iter().enumerate().all(|(idx, key)| {
560            node.properties
561                .get(key.as_str())
562                .map(|v| v == &target[idx])
563                .unwrap_or(false)
564        });
565        if matches {
566            return true;
567        }
568    }
569    false
570}
571
572fn any_other_rel_with_tuple(
573    graph: &InMemoryGraph,
574    rel_type: &str,
575    keys: &[String],
576    target: &[PropertyValue],
577    skip: Option<RelationshipId>,
578) -> bool {
579    for (id, rel) in graph.iter_rels() {
580        if Some(id) == skip {
581            continue;
582        }
583        if rel.rel_type != rel_type {
584            continue;
585        }
586        let matches = keys.iter().enumerate().all(|(idx, key)| {
587            rel.properties
588                .get(key.as_str())
589                .map(|v| v == &target[idx])
590                .unwrap_or(false)
591        });
592        if matches {
593            return true;
594        }
595    }
596    false
597}
598
599fn render_constraint_property_label(def: &ConstraintDefinition) -> String {
600    if def.properties.len() == 1 {
601        def.properties[0].clone()
602    } else {
603        def.properties.join(", ")
604    }
605}
606
607fn uniqueness_violation(def: &ConstraintDefinition) -> ConstraintViolation {
608    ConstraintViolation::UniquenessViolated {
609        constraint: def.name.clone(),
610        entity: def.entity,
611        label: def.label.clone(),
612        property: render_constraint_property_label(def),
613    }
614}
615
616fn missing_property_violation(def: &ConstraintDefinition, property: &str) -> ConstraintViolation {
617    if def.properties.len() == 1 {
618        ConstraintViolation::MissingProperty {
619            constraint: def.name.clone(),
620            entity: def.entity,
621            label: def.label.clone(),
622            property: property.to_string(),
623        }
624    } else {
625        ConstraintViolation::MissingPropertiesForKey {
626            constraint: def.name.clone(),
627            entity: def.entity,
628            label: def.label.clone(),
629            properties: def.properties.clone(),
630        }
631    }
632}
633
634fn constrained_tuple(
635    def: &ConstraintDefinition,
636    properties: &Properties,
637) -> Option<Vec<PropertyValue>> {
638    def.properties
639        .iter()
640        .map(|p| properties.get(p.as_str()).cloned())
641        .collect()
642}
643
644fn constrained_tuple_after_set(
645    def: &ConstraintDefinition,
646    properties: &Properties,
647    key: &str,
648    value: &PropertyValue,
649) -> Option<Vec<PropertyValue>> {
650    def.properties
651        .iter()
652        .map(|prop| {
653            if prop == key {
654                Some(value.clone())
655            } else {
656                properties.get(prop.as_str()).cloned()
657            }
658        })
659        .collect()
660}
661
662/// Mutation pre-check: about to `SET node.key = value`. Validates
663/// every node-level constraint whose schema covers any of the node's
664/// labels and any of its constrained properties touched by this write.
665pub(crate) fn check_node_set_property(
666    catalog: &ConstraintCatalog,
667    graph: &InMemoryGraph,
668    node_id: NodeId,
669    key: &str,
670    value: &PropertyValue,
671) -> Result<(), ConstraintViolation> {
672    let node = match graph.node_at(node_id) {
673        Some(n) => n,
674        None => return Ok(()), // mutation will fail downstream
675    };
676    for def in catalog.iter() {
677        if def.entity != StoredIndexEntity::Node {
678            continue;
679        }
680        if !node.labels.iter().any(|l| l == &def.label) {
681            continue;
682        }
683        if !def.properties.iter().any(|p| p == key) {
684            continue;
685        }
686        // Type check on the new value.
687        if let StoredConstraintKind::PropertyType(target) = &def.kind {
688            if !value_matches_property_type(value, target) {
689                return Err(ConstraintViolation::WrongPropertyType {
690                    constraint: def.name.clone(),
691                    entity: def.entity,
692                    label: def.label.clone(),
693                    property: key.to_string(),
694                    expected: target.to_string(),
695                });
696            }
697        }
698        // Uniqueness: build the post-set tuple and search the rest of
699        // the graph for an identical one.
700        if def.kind.requires_uniqueness() {
701            if let Some(tuple) = constrained_tuple_after_set(def, &node.properties, key, value) {
702                if any_other_node_with_tuple(
703                    graph,
704                    &def.label,
705                    &def.properties,
706                    &tuple,
707                    Some(node_id),
708                ) {
709                    return Err(uniqueness_violation(def));
710                }
711            }
712        }
713    }
714    Ok(())
715}
716
717/// Mutation pre-check: about to `REMOVE node.key`. Rejects when an
718/// existence / key constraint requires the property to remain present.
719pub(crate) fn check_node_remove_property(
720    catalog: &ConstraintCatalog,
721    graph: &InMemoryGraph,
722    node_id: NodeId,
723    key: &str,
724) -> Result<(), ConstraintViolation> {
725    let node = match graph.node_at(node_id) {
726        Some(n) => n,
727        None => return Ok(()),
728    };
729    for def in catalog.iter() {
730        if def.entity != StoredIndexEntity::Node {
731            continue;
732        }
733        if !node.labels.iter().any(|l| l == &def.label) {
734            continue;
735        }
736        if !def.kind.requires_existence() {
737            continue;
738        }
739        if !def.properties.iter().any(|p| p == key) {
740            continue;
741        }
742        return Err(missing_property_violation(def, key));
743    }
744    Ok(())
745}
746
747/// Mutation pre-check: about to replace the full property map on a
748/// node. Validate the final record shape, but skip the node itself
749/// when checking uniqueness.
750pub(crate) fn check_node_replace_properties(
751    catalog: &ConstraintCatalog,
752    graph: &InMemoryGraph,
753    node_id: NodeId,
754    properties: &Properties,
755) -> Result<(), ConstraintViolation> {
756    let node = match graph.node_at(node_id) {
757        Some(n) => n,
758        None => return Ok(()),
759    };
760    check_record_constraints(
761        catalog,
762        graph,
763        ConstraintRecord::Node {
764            labels: NodeLabelMatcher::AnyOf(&node.labels),
765            properties,
766            skip: Some(node_id),
767        },
768    )
769}
770
771pub(crate) fn check_relationship_set_property(
772    catalog: &ConstraintCatalog,
773    graph: &InMemoryGraph,
774    rel_id: RelationshipId,
775    key: &str,
776    value: &PropertyValue,
777) -> Result<(), ConstraintViolation> {
778    let rel = match graph.rel_at(rel_id) {
779        Some(r) => r,
780        None => return Ok(()),
781    };
782    for def in catalog.iter() {
783        if def.entity != StoredIndexEntity::Relationship {
784            continue;
785        }
786        if def.label != rel.rel_type {
787            continue;
788        }
789        if !def.properties.iter().any(|p| p == key) {
790            continue;
791        }
792        if let StoredConstraintKind::PropertyType(target) = &def.kind {
793            if !value_matches_property_type(value, target) {
794                return Err(ConstraintViolation::WrongPropertyType {
795                    constraint: def.name.clone(),
796                    entity: def.entity,
797                    label: def.label.clone(),
798                    property: key.to_string(),
799                    expected: target.to_string(),
800                });
801            }
802        }
803        if def.kind.requires_uniqueness() {
804            if let Some(tuple) = constrained_tuple_after_set(def, &rel.properties, key, value) {
805                if any_other_rel_with_tuple(
806                    graph,
807                    &def.label,
808                    &def.properties,
809                    &tuple,
810                    Some(rel_id),
811                ) {
812                    return Err(uniqueness_violation(def));
813                }
814            }
815        }
816    }
817    Ok(())
818}
819
820pub(crate) fn check_relationship_remove_property(
821    catalog: &ConstraintCatalog,
822    graph: &InMemoryGraph,
823    rel_id: RelationshipId,
824    key: &str,
825) -> Result<(), ConstraintViolation> {
826    let rel = match graph.rel_at(rel_id) {
827        Some(r) => r,
828        None => return Ok(()),
829    };
830    for def in catalog.iter() {
831        if def.entity != StoredIndexEntity::Relationship {
832            continue;
833        }
834        if def.label != rel.rel_type {
835            continue;
836        }
837        if !def.kind.requires_existence() {
838            continue;
839        }
840        if !def.properties.iter().any(|p| p == key) {
841            continue;
842        }
843        return Err(missing_property_violation(def, key));
844    }
845    Ok(())
846}
847
848/// Mutation pre-check: about to replace the full property map on a
849/// relationship. Validate the final record shape, but skip the
850/// relationship itself when checking uniqueness.
851pub(crate) fn check_relationship_replace_properties(
852    catalog: &ConstraintCatalog,
853    graph: &InMemoryGraph,
854    rel_id: RelationshipId,
855    properties: &Properties,
856) -> Result<(), ConstraintViolation> {
857    let rel = match graph.rel_at(rel_id) {
858        Some(r) => r,
859        None => return Ok(()),
860    };
861    check_record_constraints(
862        catalog,
863        graph,
864        ConstraintRecord::Relationship {
865            rel_type: &rel.rel_type,
866            properties,
867            skip: Some(rel_id),
868        },
869    )
870}
871
872/// Mutation pre-check: about to `SET n:Label` (add label). All
873/// existence / type / uniqueness constraints attached to `Label`
874/// suddenly start applying to this node; if any of them is violated
875/// the mutation is rejected.
876pub(crate) fn check_node_add_label(
877    catalog: &ConstraintCatalog,
878    graph: &InMemoryGraph,
879    node_id: NodeId,
880    label: &str,
881) -> Result<(), ConstraintViolation> {
882    let node = match graph.node_at(node_id) {
883        Some(n) => n,
884        None => return Ok(()),
885    };
886    check_record_constraints(
887        catalog,
888        graph,
889        ConstraintRecord::Node {
890            labels: NodeLabelMatcher::One(label),
891            properties: &node.properties,
892            skip: Some(node_id),
893        },
894    )
895}