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, true)?;
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, true)?;
158        }
159        Ok(())
160    }
161}
162
163fn validate_record_against_constraint(
164    def: &ConstraintDefinition,
165    properties: &Properties,
166    seen: &mut HashSet<String>,
167    check_existence: bool,
168) -> Result<(), ConstraintViolation> {
169    // 1) Existence checks.
170    if check_existence && def.kind.requires_existence() {
171        let missing: Vec<String> = def
172            .properties
173            .iter()
174            .filter(|p| !properties.contains_key(p.as_str()))
175            .cloned()
176            .collect();
177        if !missing.is_empty() {
178            return Err(if def.properties.len() == 1 {
179                ConstraintViolation::MissingProperty {
180                    constraint: def.name.clone(),
181                    entity: def.entity,
182                    label: def.label.clone(),
183                    property: def.properties[0].clone(),
184                }
185            } else {
186                ConstraintViolation::MissingPropertiesForKey {
187                    constraint: def.name.clone(),
188                    entity: def.entity,
189                    label: def.label.clone(),
190                    properties: def.properties.clone(),
191                }
192            });
193        }
194    }
195
196    // 2) Property type checks (single-property only by grammar).
197    if let StoredConstraintKind::PropertyType(target) = &def.kind {
198        let key = &def.properties[0];
199        if let Some(value) = properties.get(key.as_str()) {
200            if !value_matches_property_type(value, target) {
201                return Err(ConstraintViolation::WrongPropertyType {
202                    constraint: def.name.clone(),
203                    entity: def.entity,
204                    label: def.label.clone(),
205                    property: key.clone(),
206                    expected: target.to_string(),
207                });
208            }
209        }
210    }
211
212    // 3) Uniqueness checks. Only constraints that require uniqueness
213    // run this; we still skip records that don't carry the full
214    // property tuple (matches the "uniqueness only applies when
215    // all constrained properties are present" rule for plain
216    // uniqueness; key constraints always require existence so the
217    // tuple is guaranteed present by step 1).
218    if def.kind.requires_uniqueness() {
219        let tuple_present = def
220            .properties
221            .iter()
222            .all(|p| properties.contains_key(p.as_str()));
223        if tuple_present {
224            let key = property_tuple_key(def, properties);
225            if !seen.insert(key) {
226                let property_label = if def.properties.len() == 1 {
227                    def.properties[0].clone()
228                } else {
229                    def.properties.join(", ")
230                };
231                return Err(ConstraintViolation::UniquenessViolated {
232                    constraint: def.name.clone(),
233                    entity: def.entity,
234                    label: def.label.clone(),
235                    property: property_label,
236                });
237            }
238        }
239    }
240
241    Ok(())
242}
243
244/// Cheap stable string-encoding for a property tuple — sufficient as a
245/// `HashSet` key in the per-constraint pre-create scan. Not exposed to
246/// callers because the shape isn't durable.
247fn property_tuple_key(def: &ConstraintDefinition, properties: &Properties) -> String {
248    let mut out = String::with_capacity(64);
249    for (i, key) in def.properties.iter().enumerate() {
250        if i > 0 {
251            out.push('\u{1f}');
252        }
253        if let Some(value) = properties.get(key.as_str()) {
254            append_property_value_key(&mut out, value);
255        }
256    }
257    out
258}
259
260fn append_property_value_key(out: &mut String, value: &PropertyValue) {
261    match value {
262        PropertyValue::Null => out.push('N'),
263        PropertyValue::Bool(b) => {
264            out.push('B');
265            out.push(if *b { 'T' } else { 'F' });
266        }
267        PropertyValue::Int(i) => {
268            out.push('I');
269            out.push_str(&i.to_string());
270        }
271        PropertyValue::Float(f) => {
272            out.push('F');
273            out.push_str(&format!("{f:?}"));
274        }
275        PropertyValue::String(s) => {
276            out.push('S');
277            append_len_prefixed_str(out, s);
278        }
279        PropertyValue::Date(d) => {
280            out.push_str("D:");
281            append_len_prefixed_str(out, &format!("{d:?}"));
282        }
283        PropertyValue::Time(t) => {
284            out.push_str("T:");
285            append_len_prefixed_str(out, &format!("{t:?}"));
286        }
287        PropertyValue::LocalTime(t) => {
288            out.push_str("LT:");
289            append_len_prefixed_str(out, &format!("{t:?}"));
290        }
291        PropertyValue::DateTime(dt) => {
292            out.push_str("DT:");
293            append_len_prefixed_str(out, &format!("{dt:?}"));
294        }
295        PropertyValue::LocalDateTime(dt) => {
296            out.push_str("LDT:");
297            append_len_prefixed_str(out, &format!("{dt:?}"));
298        }
299        PropertyValue::Duration(d) => {
300            out.push_str("DUR:");
301            append_len_prefixed_str(out, &format!("{d:?}"));
302        }
303        PropertyValue::Point(p) => {
304            out.push_str("P:");
305            append_len_prefixed_str(out, &format!("{p:?}"));
306        }
307        PropertyValue::Vector(v) => {
308            out.push_str("V:");
309            append_len_prefixed_str(out, &v.to_key_string());
310        }
311        PropertyValue::List(items) => {
312            out.push('L');
313            append_len(out, items.len());
314            for item in items {
315                append_property_value_key(out, item);
316            }
317        }
318        PropertyValue::Map(entries) => {
319            out.push('M');
320            append_len(out, entries.len());
321            for (k, v) in entries {
322                append_len_prefixed_str(out, k);
323                append_property_value_key(out, v);
324            }
325        }
326        PropertyValue::Binary(b) => {
327            out.push_str("BIN:");
328            append_len(out, b.len());
329            for segment in b.chunks() {
330                for byte in segment {
331                    let _ = write!(out, "{byte:02x}");
332                }
333            }
334        }
335    }
336}
337
338fn append_len(out: &mut String, len: usize) {
339    out.push_str(&len.to_string());
340    out.push(':');
341}
342
343fn append_len_prefixed_str(out: &mut String, value: &str) {
344    append_len(out, value.len());
345    out.push_str(value);
346}
347
348/// True when `value` satisfies any branch of the target type. Used by
349/// both DDL-time scans and runtime mutation checks.
350pub fn value_matches_property_type(value: &PropertyValue, target: &StoredPropertyType) -> bool {
351    target
352        .alternatives
353        .iter()
354        .any(|term| value_matches_term(value, term))
355}
356
357fn value_matches_term(value: &PropertyValue, term: &StoredPropertyTypeTerm) -> bool {
358    match term {
359        StoredPropertyTypeTerm::Scalar(scalar) => value_matches_scalar(value, *scalar),
360        StoredPropertyTypeTerm::List { inner, not_null } => match value {
361            PropertyValue::List(items) => items.iter().all(|item| {
362                if matches!(item, PropertyValue::Null) {
363                    !*not_null
364                } else {
365                    value_matches_term(item, inner)
366                }
367            }),
368            _ => false,
369        },
370        StoredPropertyTypeTerm::Vector { coord, dimension } => match value {
371            PropertyValue::Vector(v) => vector_matches(v, *coord, *dimension),
372            _ => false,
373        },
374    }
375}
376
377fn value_matches_scalar(value: &PropertyValue, scalar: StoredScalarType) -> bool {
378    match (value, scalar) {
379        (PropertyValue::Bool(_), StoredScalarType::Boolean) => true,
380        (PropertyValue::String(_), StoredScalarType::String) => true,
381        (PropertyValue::Int(_), StoredScalarType::Integer) => true,
382        (PropertyValue::Float(_), StoredScalarType::Float) => true,
383        (PropertyValue::Date(_), StoredScalarType::Date) => true,
384        (PropertyValue::Time(_), StoredScalarType::ZonedTime) => true,
385        (PropertyValue::LocalTime(_), StoredScalarType::LocalTime) => true,
386        (PropertyValue::DateTime(_), StoredScalarType::ZonedDateTime) => true,
387        (PropertyValue::LocalDateTime(_), StoredScalarType::LocalDateTime) => true,
388        (PropertyValue::Duration(_), StoredScalarType::Duration) => true,
389        (PropertyValue::Point(_), StoredScalarType::Point) => true,
390        // Map / Any are rejected at DDL time, so they should never appear
391        // here; reaching this arm with one of them indicates an upstream
392        // bug — fail closed.
393        _ => false,
394    }
395}
396
397fn vector_matches(
398    vector: &crate::types::LoraVector,
399    coord: StoredVectorCoordType,
400    dimension: u32,
401) -> bool {
402    if vector.dimension != dimension as usize {
403        return false;
404    }
405    use crate::types::VectorValues;
406    use StoredVectorCoordType::*;
407    matches!(
408        (&vector.values, coord),
409        (VectorValues::Float64(_), Float64)
410            | (VectorValues::Float32(_), Float32)
411            | (VectorValues::Integer64(_), Int64)
412            | (VectorValues::Integer32(_), Int32)
413            | (VectorValues::Integer16(_), Int16)
414            | (VectorValues::Integer8(_), Int8)
415    )
416}
417
418#[derive(Clone, Copy)]
419enum NodeLabelMatcher<'a> {
420    AnyOf(&'a [String]),
421    One(&'a str),
422}
423
424impl NodeLabelMatcher<'_> {
425    fn contains(self, label: &str) -> bool {
426        match self {
427            NodeLabelMatcher::AnyOf(labels) => labels.iter().any(|l| l == label),
428            NodeLabelMatcher::One(candidate) => candidate == label,
429        }
430    }
431}
432
433#[derive(Clone, Copy)]
434enum ConstraintRecord<'a> {
435    Node {
436        labels: NodeLabelMatcher<'a>,
437        properties: &'a Properties,
438        skip: Option<NodeId>,
439    },
440    Relationship {
441        rel_type: &'a str,
442        properties: &'a Properties,
443        skip: Option<RelationshipId>,
444    },
445}
446
447impl<'a> ConstraintRecord<'a> {
448    fn applies_to(self, def: &ConstraintDefinition) -> bool {
449        match self {
450            ConstraintRecord::Node { labels, .. } => {
451                def.entity == StoredIndexEntity::Node && labels.contains(&def.label)
452            }
453            ConstraintRecord::Relationship { rel_type, .. } => {
454                def.entity == StoredIndexEntity::Relationship && def.label == rel_type
455            }
456        }
457    }
458
459    fn properties(self) -> &'a Properties {
460        match self {
461            ConstraintRecord::Node { properties, .. }
462            | ConstraintRecord::Relationship { properties, .. } => properties,
463        }
464    }
465
466    fn has_uniqueness_conflict(
467        self,
468        graph: &InMemoryGraph,
469        def: &ConstraintDefinition,
470        tuple: &[PropertyValue],
471    ) -> bool {
472        match self {
473            ConstraintRecord::Node { skip, .. } => {
474                any_other_node_with_tuple(graph, &def.label, &def.properties, tuple, skip)
475            }
476            ConstraintRecord::Relationship { skip, .. } => {
477                any_other_rel_with_tuple(graph, &def.label, &def.properties, tuple, skip)
478            }
479        }
480    }
481}
482
483fn check_record_constraints(
484    catalog: &ConstraintCatalog,
485    graph: &InMemoryGraph,
486    record: ConstraintRecord<'_>,
487) -> Result<(), ConstraintViolation> {
488    check_record_constraints_with(catalog, graph, record, true)
489}
490
491fn check_record_constraints_with(
492    catalog: &ConstraintCatalog,
493    graph: &InMemoryGraph,
494    record: ConstraintRecord<'_>,
495    check_existence: bool,
496) -> Result<(), ConstraintViolation> {
497    for def in catalog.iter() {
498        if !record.applies_to(def) {
499            continue;
500        }
501
502        let properties = record.properties();
503        let mut probe: HashSet<String> = HashSet::new();
504        validate_record_against_constraint(def, properties, &mut probe, check_existence)?;
505
506        if def.kind.requires_uniqueness() {
507            if let Some(tuple) = constrained_tuple(def, properties) {
508                if record.has_uniqueness_conflict(graph, def, &tuple) {
509                    return Err(uniqueness_violation(def));
510                }
511            }
512        }
513    }
514    Ok(())
515}
516
517/// Public read-side check used by mutation paths: given a proposed
518/// node create (labels + properties), is it accepted by every
519/// installed constraint? Cheap when no constraints are registered
520/// (single `is_empty()` on the catalog).
521pub(crate) fn check_node_create(
522    catalog: &ConstraintCatalog,
523    graph: &InMemoryGraph,
524    labels: &[String],
525    properties: &Properties,
526) -> Result<(), ConstraintViolation> {
527    check_record_constraints(
528        catalog,
529        graph,
530        ConstraintRecord::Node {
531            labels: NodeLabelMatcher::AnyOf(labels),
532            properties,
533            skip: None,
534        },
535    )
536}
537
538pub(crate) fn check_relationship_create(
539    catalog: &ConstraintCatalog,
540    graph: &InMemoryGraph,
541    rel_type: &str,
542    properties: &Properties,
543) -> Result<(), ConstraintViolation> {
544    check_record_constraints(
545        catalog,
546        graph,
547        ConstraintRecord::Relationship {
548            rel_type,
549            properties,
550            skip: None,
551        },
552    )
553}
554
555/// [`check_node_create`] without the existence checks, for a statement
556/// that checks existence once it has finished (a later `SET` in the same
557/// statement may still supply the property). Pair with
558/// [`check_node_existence`].
559pub(crate) fn check_node_create_deferred(
560    catalog: &ConstraintCatalog,
561    graph: &InMemoryGraph,
562    labels: &[String],
563    properties: &Properties,
564) -> Result<(), ConstraintViolation> {
565    check_record_constraints_with(
566        catalog,
567        graph,
568        ConstraintRecord::Node {
569            labels: NodeLabelMatcher::AnyOf(labels),
570            properties,
571            skip: None,
572        },
573        false,
574    )
575}
576
577/// Relationship counterpart of [`check_node_create_deferred`].
578pub(crate) fn check_relationship_create_deferred(
579    catalog: &ConstraintCatalog,
580    graph: &InMemoryGraph,
581    rel_type: &str,
582    properties: &Properties,
583) -> Result<(), ConstraintViolation> {
584    check_record_constraints_with(
585        catalog,
586        graph,
587        ConstraintRecord::Relationship {
588            rel_type,
589            properties,
590            skip: None,
591        },
592        false,
593    )
594}
595
596/// Existence (and key) constraints on a node as it stands now. A node
597/// that no longer exists passes.
598pub(crate) fn check_node_existence(
599    catalog: &ConstraintCatalog,
600    graph: &InMemoryGraph,
601    node_id: NodeId,
602) -> Result<(), ConstraintViolation> {
603    let Some(node) = graph.node_at(node_id) else {
604        return Ok(());
605    };
606    for def in catalog.iter() {
607        if def.entity != StoredIndexEntity::Node
608            || !def.kind.requires_existence()
609            || !node.labels.iter().any(|l| l == &def.label)
610        {
611            continue;
612        }
613        if let Some(missing) = def
614            .properties
615            .iter()
616            .find(|p| !node.properties.contains_key(p.as_str()))
617        {
618            return Err(missing_property_violation(def, missing));
619        }
620    }
621    Ok(())
622}
623
624/// Relationship counterpart of [`check_node_existence`].
625pub(crate) fn check_relationship_existence(
626    catalog: &ConstraintCatalog,
627    graph: &InMemoryGraph,
628    rel_id: RelationshipId,
629) -> Result<(), ConstraintViolation> {
630    let Some(rel) = graph.rel_at(rel_id) else {
631        return Ok(());
632    };
633    for def in catalog.iter() {
634        if def.entity != StoredIndexEntity::Relationship
635            || !def.kind.requires_existence()
636            || rel.rel_type != def.label
637        {
638            continue;
639        }
640        if let Some(missing) = def
641            .properties
642            .iter()
643            .find(|p| !rel.properties.contains_key(p.as_str()))
644        {
645            return Err(missing_property_violation(def, missing));
646        }
647    }
648    Ok(())
649}
650
651/// Whether another node with `label` already holds `target` for `keys`.
652///
653/// Uniqueness constraints own a backing range index, which keeps the
654/// label-scoped hash index for the first key active. Looking the value up
655/// there makes each check O(matches) instead of a scan of every node in
656/// the graph, which made loading N constrained nodes O(N^2). When the
657/// value has no index image (temporal, spatial, vector) or the index is
658/// not active, fall back to scanning the label's nodes only.
659fn any_other_node_with_tuple(
660    graph: &InMemoryGraph,
661    label: &str,
662    keys: &[String],
663    target: &[PropertyValue],
664    skip: Option<NodeId>,
665) -> bool {
666    let tuple_matches = |node: &crate::NodeRecord| {
667        node.labels.iter().any(|l| l == label)
668            && keys.iter().enumerate().all(|(idx, key)| {
669                node.properties
670                    .get(key.as_str())
671                    .map(|v| v == &target[idx])
672                    .unwrap_or(false)
673            })
674    };
675    let candidates = match (keys.first(), target.first()) {
676        (Some(key), Some(value)) => graph.indexed_node_ids(label, key, value),
677        _ => None,
678    };
679    let candidates =
680        candidates.unwrap_or_else(|| crate::GraphStorage::node_ids_by_label(graph, label));
681    candidates
682        .into_iter()
683        .filter(|id| Some(*id) != skip)
684        .any(|id| graph.node_at(id).is_some_and(tuple_matches))
685}
686
687/// Relationship counterpart of [`any_other_node_with_tuple`].
688fn any_other_rel_with_tuple(
689    graph: &InMemoryGraph,
690    rel_type: &str,
691    keys: &[String],
692    target: &[PropertyValue],
693    skip: Option<RelationshipId>,
694) -> bool {
695    let tuple_matches = |rel: &crate::RelationshipRecord| {
696        rel.rel_type == rel_type
697            && keys.iter().enumerate().all(|(idx, key)| {
698                rel.properties
699                    .get(key.as_str())
700                    .map(|v| v == &target[idx])
701                    .unwrap_or(false)
702            })
703    };
704    let candidates = match (keys.first(), target.first()) {
705        (Some(key), Some(value)) => graph.indexed_rel_ids(rel_type, key, value),
706        _ => None,
707    };
708    let candidates =
709        candidates.unwrap_or_else(|| crate::GraphStorage::rel_ids_by_type(graph, rel_type));
710    candidates
711        .into_iter()
712        .filter(|id| Some(*id) != skip)
713        .any(|id| graph.rel_at(id).is_some_and(tuple_matches))
714}
715
716fn render_constraint_property_label(def: &ConstraintDefinition) -> String {
717    if def.properties.len() == 1 {
718        def.properties[0].clone()
719    } else {
720        def.properties.join(", ")
721    }
722}
723
724fn uniqueness_violation(def: &ConstraintDefinition) -> ConstraintViolation {
725    ConstraintViolation::UniquenessViolated {
726        constraint: def.name.clone(),
727        entity: def.entity,
728        label: def.label.clone(),
729        property: render_constraint_property_label(def),
730    }
731}
732
733fn missing_property_violation(def: &ConstraintDefinition, property: &str) -> ConstraintViolation {
734    if def.properties.len() == 1 {
735        ConstraintViolation::MissingProperty {
736            constraint: def.name.clone(),
737            entity: def.entity,
738            label: def.label.clone(),
739            property: property.to_string(),
740        }
741    } else {
742        ConstraintViolation::MissingPropertiesForKey {
743            constraint: def.name.clone(),
744            entity: def.entity,
745            label: def.label.clone(),
746            properties: def.properties.clone(),
747        }
748    }
749}
750
751fn constrained_tuple(
752    def: &ConstraintDefinition,
753    properties: &Properties,
754) -> Option<Vec<PropertyValue>> {
755    def.properties
756        .iter()
757        .map(|p| properties.get(p.as_str()).cloned())
758        .collect()
759}
760
761fn constrained_tuple_after_set(
762    def: &ConstraintDefinition,
763    properties: &Properties,
764    key: &str,
765    value: &PropertyValue,
766) -> Option<Vec<PropertyValue>> {
767    def.properties
768        .iter()
769        .map(|prop| {
770            if prop == key {
771                Some(value.clone())
772            } else {
773                properties.get(prop.as_str()).cloned()
774            }
775        })
776        .collect()
777}
778
779/// Mutation pre-check: about to `SET node.key = value`. Validates
780/// every node-level constraint whose schema covers any of the node's
781/// labels and any of its constrained properties touched by this write.
782pub(crate) fn check_node_set_property(
783    catalog: &ConstraintCatalog,
784    graph: &InMemoryGraph,
785    node_id: NodeId,
786    key: &str,
787    value: &PropertyValue,
788) -> Result<(), ConstraintViolation> {
789    let node = match graph.node_at(node_id) {
790        Some(n) => n,
791        None => return Ok(()), // mutation will fail downstream
792    };
793    for def in catalog.iter() {
794        if def.entity != StoredIndexEntity::Node {
795            continue;
796        }
797        if !node.labels.iter().any(|l| l == &def.label) {
798            continue;
799        }
800        if !def.properties.iter().any(|p| p == key) {
801            continue;
802        }
803        // Type check on the new value.
804        if let StoredConstraintKind::PropertyType(target) = &def.kind {
805            if !value_matches_property_type(value, target) {
806                return Err(ConstraintViolation::WrongPropertyType {
807                    constraint: def.name.clone(),
808                    entity: def.entity,
809                    label: def.label.clone(),
810                    property: key.to_string(),
811                    expected: target.to_string(),
812                });
813            }
814        }
815        // Uniqueness: build the post-set tuple and search the rest of
816        // the graph for an identical one.
817        if def.kind.requires_uniqueness() {
818            if let Some(tuple) = constrained_tuple_after_set(def, &node.properties, key, value) {
819                if any_other_node_with_tuple(
820                    graph,
821                    &def.label,
822                    &def.properties,
823                    &tuple,
824                    Some(node_id),
825                ) {
826                    return Err(uniqueness_violation(def));
827                }
828            }
829        }
830    }
831    Ok(())
832}
833
834/// Mutation pre-check: about to `REMOVE node.key`. Rejects when an
835/// existence / key constraint requires the property to remain present.
836pub(crate) fn check_node_remove_property(
837    catalog: &ConstraintCatalog,
838    graph: &InMemoryGraph,
839    node_id: NodeId,
840    key: &str,
841) -> Result<(), ConstraintViolation> {
842    let node = match graph.node_at(node_id) {
843        Some(n) => n,
844        None => return Ok(()),
845    };
846    for def in catalog.iter() {
847        if def.entity != StoredIndexEntity::Node {
848            continue;
849        }
850        if !node.labels.iter().any(|l| l == &def.label) {
851            continue;
852        }
853        if !def.kind.requires_existence() {
854            continue;
855        }
856        if !def.properties.iter().any(|p| p == key) {
857            continue;
858        }
859        return Err(missing_property_violation(def, key));
860    }
861    Ok(())
862}
863
864/// Mutation pre-check: about to replace the full property map on a
865/// node. Validate the final record shape, but skip the node itself
866/// when checking uniqueness.
867pub(crate) fn check_node_replace_properties(
868    catalog: &ConstraintCatalog,
869    graph: &InMemoryGraph,
870    node_id: NodeId,
871    properties: &Properties,
872) -> Result<(), ConstraintViolation> {
873    let node = match graph.node_at(node_id) {
874        Some(n) => n,
875        None => return Ok(()),
876    };
877    check_record_constraints(
878        catalog,
879        graph,
880        ConstraintRecord::Node {
881            labels: NodeLabelMatcher::AnyOf(&node.labels),
882            properties,
883            skip: Some(node_id),
884        },
885    )
886}
887
888pub(crate) fn check_relationship_set_property(
889    catalog: &ConstraintCatalog,
890    graph: &InMemoryGraph,
891    rel_id: RelationshipId,
892    key: &str,
893    value: &PropertyValue,
894) -> Result<(), ConstraintViolation> {
895    let rel = match graph.rel_at(rel_id) {
896        Some(r) => r,
897        None => return Ok(()),
898    };
899    for def in catalog.iter() {
900        if def.entity != StoredIndexEntity::Relationship {
901            continue;
902        }
903        if def.label != rel.rel_type {
904            continue;
905        }
906        if !def.properties.iter().any(|p| p == key) {
907            continue;
908        }
909        if let StoredConstraintKind::PropertyType(target) = &def.kind {
910            if !value_matches_property_type(value, target) {
911                return Err(ConstraintViolation::WrongPropertyType {
912                    constraint: def.name.clone(),
913                    entity: def.entity,
914                    label: def.label.clone(),
915                    property: key.to_string(),
916                    expected: target.to_string(),
917                });
918            }
919        }
920        if def.kind.requires_uniqueness() {
921            if let Some(tuple) = constrained_tuple_after_set(def, &rel.properties, key, value) {
922                if any_other_rel_with_tuple(
923                    graph,
924                    &def.label,
925                    &def.properties,
926                    &tuple,
927                    Some(rel_id),
928                ) {
929                    return Err(uniqueness_violation(def));
930                }
931            }
932        }
933    }
934    Ok(())
935}
936
937pub(crate) fn check_relationship_remove_property(
938    catalog: &ConstraintCatalog,
939    graph: &InMemoryGraph,
940    rel_id: RelationshipId,
941    key: &str,
942) -> Result<(), ConstraintViolation> {
943    let rel = match graph.rel_at(rel_id) {
944        Some(r) => r,
945        None => return Ok(()),
946    };
947    for def in catalog.iter() {
948        if def.entity != StoredIndexEntity::Relationship {
949            continue;
950        }
951        if def.label != rel.rel_type {
952            continue;
953        }
954        if !def.kind.requires_existence() {
955            continue;
956        }
957        if !def.properties.iter().any(|p| p == key) {
958            continue;
959        }
960        return Err(missing_property_violation(def, key));
961    }
962    Ok(())
963}
964
965/// Mutation pre-check: about to replace the full property map on a
966/// relationship. Validate the final record shape, but skip the
967/// relationship itself when checking uniqueness.
968pub(crate) fn check_relationship_replace_properties(
969    catalog: &ConstraintCatalog,
970    graph: &InMemoryGraph,
971    rel_id: RelationshipId,
972    properties: &Properties,
973) -> Result<(), ConstraintViolation> {
974    let rel = match graph.rel_at(rel_id) {
975        Some(r) => r,
976        None => return Ok(()),
977    };
978    check_record_constraints(
979        catalog,
980        graph,
981        ConstraintRecord::Relationship {
982            rel_type: &rel.rel_type,
983            properties,
984            skip: Some(rel_id),
985        },
986    )
987}
988
989/// Mutation pre-check: about to `SET n:Label` (add label). All
990/// existence / type / uniqueness constraints attached to `Label`
991/// suddenly start applying to this node; if any of them is violated
992/// the mutation is rejected.
993pub(crate) fn check_node_add_label(
994    catalog: &ConstraintCatalog,
995    graph: &InMemoryGraph,
996    node_id: NodeId,
997    label: &str,
998) -> Result<(), ConstraintViolation> {
999    let node = match graph.node_at(node_id) {
1000        Some(n) => n,
1001        None => return Ok(()),
1002    };
1003    check_record_constraints(
1004        catalog,
1005        graph,
1006        ConstraintRecord::Node {
1007            labels: NodeLabelMatcher::One(label),
1008            properties: &node.properties,
1009            skip: Some(node_id),
1010        },
1011    )
1012}