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::fmt::Write as _;
27use std::hash::{BuildHasher, BuildHasherDefault};
28
29use thiserror::Error;
30
31use super::constraint_catalog::{
32    ConstraintCatalog, ConstraintDefinition, StoredConstraintKind, StoredPropertyType,
33    StoredPropertyTypeTerm, StoredScalarType, StoredVectorCoordType,
34};
35use super::cow::{FastHashMap, KeyHasher};
36use super::index_catalog::StoredIndexEntity;
37use super::InMemoryGraph;
38use crate::types::{NodeId, Properties, PropertyValue, RelationshipId};
39
40/// Why a mutation was rejected by a constraint check. The codes match
41/// the GQLSTATUS-style shapes:
42///
43/// * 22N77 — property presence verification failed.
44/// * 22N78 — property type verification failed.
45/// * 22N79 — property uniqueness constraint violated.
46/// * 22N80 — index entry conflict (duplicate row found by the backing
47///   index during a CREATE CONSTRAINT scan).
48/// * 50N11 — generic "constraint creation failed" wrapper used at DDL
49///   time to surface the underlying 22N7x error.
50#[derive(Debug, Clone, Error)]
51pub enum ConstraintViolation {
52    #[error("[22N77] property presence verification failed. {kind} must have the property `{property}`",
53            kind = entity_kind_label(*entity, label))]
54    MissingProperty {
55        constraint: String,
56        entity: StoredIndexEntity,
57        label: String,
58        property: String,
59    },
60    #[error("[22N77] property presence verification failed. {kind} must have the properties: {properties}",
61            kind = entity_kind_label(*entity, label),
62            properties = properties.join(", "))]
63    MissingPropertiesForKey {
64        constraint: String,
65        entity: StoredIndexEntity,
66        label: String,
67        properties: Vec<String>,
68    },
69    #[error("[22N78] property type verification failed. {kind} must have property `{property}` with value type {expected}",
70            kind = entity_kind_label(*entity, label))]
71    WrongPropertyType {
72        constraint: String,
73        entity: StoredIndexEntity,
74        label: String,
75        property: String,
76        expected: String,
77    },
78    #[error("[22N79] property uniqueness constraint violated. {kind} already has property `{property}` with the supplied value",
79            kind = entity_kind_label(*entity, label))]
80    UniquenessViolated {
81        constraint: String,
82        entity: StoredIndexEntity,
83        label: String,
84        property: String,
85    },
86}
87
88impl ConstraintViolation {
89    pub const fn gql_status(&self) -> &'static str {
90        match self {
91            ConstraintViolation::MissingProperty { .. }
92            | ConstraintViolation::MissingPropertiesForKey { .. } => "22N77",
93            ConstraintViolation::WrongPropertyType { .. } => "22N78",
94            ConstraintViolation::UniquenessViolated { .. } => "22N79",
95        }
96    }
97
98    pub fn constraint_name(&self) -> &str {
99        match self {
100            ConstraintViolation::MissingProperty { constraint, .. }
101            | ConstraintViolation::MissingPropertiesForKey { constraint, .. }
102            | ConstraintViolation::WrongPropertyType { constraint, .. }
103            | ConstraintViolation::UniquenessViolated { constraint, .. } => constraint,
104        }
105    }
106}
107
108fn entity_kind_label(entity: StoredIndexEntity, label: &str) -> String {
109    match entity {
110        StoredIndexEntity::Node => format!("NODE with label `{label}`"),
111        StoredIndexEntity::Relationship => format!("RELATIONSHIP with type `{label}`"),
112    }
113}
114
115impl InMemoryGraph {
116    /// Check whether the *current* graph contains data that would
117    /// violate `def` if it were registered. Called from
118    /// `register_constraint` just before the catalog write commits.
119    ///
120    /// Returns the first violation we find; we don't enumerate all
121    /// failures because a single rejection is enough to refuse the
122    /// CREATE CONSTRAINT.
123    pub(super) fn validate_existing_data_for_constraint(
124        &self,
125        def: &ConstraintDefinition,
126    ) -> Result<(), ConstraintViolation> {
127        match def.entity {
128            StoredIndexEntity::Node => self.validate_existing_nodes_for_constraint(def),
129            StoredIndexEntity::Relationship => self.validate_existing_rels_for_constraint(def),
130        }
131    }
132
133    fn validate_existing_nodes_for_constraint(
134        &self,
135        def: &ConstraintDefinition,
136    ) -> Result<(), ConstraintViolation> {
137        let label = def.label.as_str();
138        let mut seen = SeenTuples::default();
139        for (id, node) in self.iter_nodes() {
140            if !node.labels().strs().any(|l| l == label) {
141                continue;
142            }
143            let properties = node.properties().to_owned();
144            validate_record_against_constraint(def, &properties, true)?;
145            seen.insert(def, id, &properties, |other| {
146                self.node_at(other).map(|n| n.properties().to_owned())
147            })?;
148        }
149        Ok(())
150    }
151
152    fn validate_existing_rels_for_constraint(
153        &self,
154        def: &ConstraintDefinition,
155    ) -> Result<(), ConstraintViolation> {
156        let rel_type = def.label.as_str();
157        let mut seen = SeenTuples::default();
158        for (id, rel) in self.iter_rels() {
159            if rel.rel_type() != rel_type {
160                continue;
161            }
162            let properties = rel.properties().to_owned();
163            validate_record_against_constraint(def, &properties, true)?;
164            seen.insert(def, id, &properties, |other| {
165                self.rel_at(other).map(|r| r.properties().to_owned())
166            })?;
167        }
168        Ok(())
169    }
170}
171
172/// Property tuples a pre-create scan has met, for a uniqueness check.
173/// Holds a hash of each tuple's key and the record it came from, not
174/// the key itself: a 1M-node scan kept a million key strings alive at
175/// once, about three times the backing index it is about to build. A
176/// matching hash is confirmed by rebuilding the earlier record's key,
177/// so a hash collision never reads as a duplicate.
178#[derive(Default)]
179struct SeenTuples {
180    /// Key hash -> the first record with that hash.
181    first: FastHashMap<u64, u64>,
182    /// Later records whose key hashed like an earlier, different key.
183    collided: Vec<(u64, u64)>,
184    /// Reused for every key the scan builds.
185    key: String,
186}
187
188impl SeenTuples {
189    /// Record `id`'s tuple, or fail if an earlier record had the same one.
190    /// Records without the whole tuple are skipped: plain uniqueness only
191    /// applies when every constrained property is present, and a key
192    /// constraint's existence check has already failed them.
193    /// `properties_of` reads an earlier record's properties back.
194    fn insert(
195        &mut self,
196        def: &ConstraintDefinition,
197        id: u64,
198        properties: &Properties,
199        properties_of: impl Fn(u64) -> Option<Properties>,
200    ) -> Result<(), ConstraintViolation> {
201        if !def.kind.requires_uniqueness()
202            || !def
203                .properties
204                .iter()
205                .all(|p| properties.contains_key(p.as_str()))
206        {
207            return Ok(());
208        }
209        self.key.clear();
210        write_property_tuple_key(&mut self.key, def, properties);
211        let hash = BuildHasherDefault::<KeyHasher>::default().hash_one(self.key.as_str());
212        self.insert_hashed(def, hash, id, properties_of)
213    }
214
215    /// [`Self::insert`] once `self.key` holds `id`'s key and `hash` its hash.
216    fn insert_hashed(
217        &mut self,
218        def: &ConstraintDefinition,
219        hash: u64,
220        id: u64,
221        properties_of: impl Fn(u64) -> Option<Properties>,
222    ) -> Result<(), ConstraintViolation> {
223        let Some(&first) = self.first.get(&hash) else {
224            self.first.insert(hash, id);
225            return Ok(());
226        };
227        let earlier = std::iter::once(first).chain(
228            self.collided
229                .iter()
230                .filter(|(h, _)| *h == hash)
231                .map(|&(_, other)| other),
232        );
233        let mut other_key = String::new();
234        for other in earlier {
235            other_key.clear();
236            if let Some(other_properties) = properties_of(other) {
237                write_property_tuple_key(&mut other_key, def, &other_properties);
238            }
239            if other_key == self.key {
240                return Err(uniqueness_violation(def));
241            }
242        }
243        self.collided.push((hash, id));
244        Ok(())
245    }
246}
247
248fn validate_record_against_constraint(
249    def: &ConstraintDefinition,
250    properties: &Properties,
251    check_existence: bool,
252) -> Result<(), ConstraintViolation> {
253    // 1) Existence checks.
254    if check_existence && def.kind.requires_existence() {
255        let missing: Vec<String> = def
256            .properties
257            .iter()
258            .filter(|p| !properties.contains_key(p.as_str()))
259            .cloned()
260            .collect();
261        if !missing.is_empty() {
262            return Err(if def.properties.len() == 1 {
263                ConstraintViolation::MissingProperty {
264                    constraint: def.name.clone(),
265                    entity: def.entity,
266                    label: def.label.clone(),
267                    property: def.properties[0].clone(),
268                }
269            } else {
270                ConstraintViolation::MissingPropertiesForKey {
271                    constraint: def.name.clone(),
272                    entity: def.entity,
273                    label: def.label.clone(),
274                    properties: def.properties.clone(),
275                }
276            });
277        }
278    }
279
280    // 2) Property type checks (single-property only by grammar).
281    if let StoredConstraintKind::PropertyType(target) = &def.kind {
282        let key = &def.properties[0];
283        if let Some(value) = properties.get(key.as_str()) {
284            if !value_matches_property_type(value, target) {
285                return Err(ConstraintViolation::WrongPropertyType {
286                    constraint: def.name.clone(),
287                    entity: def.entity,
288                    label: def.label.clone(),
289                    property: key.clone(),
290                    expected: target.to_string(),
291                });
292            }
293        }
294    }
295
296    Ok(())
297}
298
299/// Cheap stable string-encoding for a property tuple, appended to `out`:
300/// two tuples are equal for the pre-create uniqueness scan exactly when
301/// their keys are. Not exposed to callers because the shape isn't durable.
302fn write_property_tuple_key(out: &mut String, def: &ConstraintDefinition, properties: &Properties) {
303    for (i, key) in def.properties.iter().enumerate() {
304        if i > 0 {
305            out.push('\u{1f}');
306        }
307        if let Some(value) = properties.get(key.as_str()) {
308            append_property_value_key(out, value);
309        }
310    }
311}
312
313fn append_property_value_key(out: &mut String, value: &PropertyValue) {
314    match value {
315        PropertyValue::Null => out.push('N'),
316        PropertyValue::Bool(b) => {
317            out.push('B');
318            out.push(if *b { 'T' } else { 'F' });
319        }
320        PropertyValue::Int(i) => {
321            out.push('I');
322            out.push_str(&i.to_string());
323        }
324        PropertyValue::Float(f) => {
325            out.push('F');
326            out.push_str(&format!("{f:?}"));
327        }
328        PropertyValue::String(s) => {
329            out.push('S');
330            append_len_prefixed_str(out, s);
331        }
332        PropertyValue::Date(d) => {
333            out.push_str("D:");
334            append_len_prefixed_str(out, &format!("{d:?}"));
335        }
336        PropertyValue::Time(t) => {
337            out.push_str("T:");
338            append_len_prefixed_str(out, &format!("{t:?}"));
339        }
340        PropertyValue::LocalTime(t) => {
341            out.push_str("LT:");
342            append_len_prefixed_str(out, &format!("{t:?}"));
343        }
344        PropertyValue::DateTime(dt) => {
345            out.push_str("DT:");
346            append_len_prefixed_str(out, &format!("{dt:?}"));
347        }
348        PropertyValue::LocalDateTime(dt) => {
349            out.push_str("LDT:");
350            append_len_prefixed_str(out, &format!("{dt:?}"));
351        }
352        PropertyValue::Duration(d) => {
353            out.push_str("DUR:");
354            append_len_prefixed_str(out, &format!("{d:?}"));
355        }
356        PropertyValue::Point(p) => {
357            out.push_str("P:");
358            append_len_prefixed_str(out, &format!("{p:?}"));
359        }
360        PropertyValue::Vector(v) => {
361            out.push_str("V:");
362            append_len_prefixed_str(out, &v.to_key_string());
363        }
364        PropertyValue::List(items) => {
365            out.push('L');
366            append_len(out, items.len());
367            for item in items {
368                append_property_value_key(out, item);
369            }
370        }
371        PropertyValue::Map(entries) => {
372            out.push('M');
373            append_len(out, entries.len());
374            for (k, v) in entries {
375                append_len_prefixed_str(out, k);
376                append_property_value_key(out, v);
377            }
378        }
379        PropertyValue::Binary(b) => {
380            out.push_str("BIN:");
381            append_len(out, b.len());
382            for segment in b.chunks() {
383                for byte in segment {
384                    let _ = write!(out, "{byte:02x}");
385                }
386            }
387        }
388    }
389}
390
391fn append_len(out: &mut String, len: usize) {
392    out.push_str(&len.to_string());
393    out.push(':');
394}
395
396fn append_len_prefixed_str(out: &mut String, value: &str) {
397    append_len(out, value.len());
398    out.push_str(value);
399}
400
401/// True when `value` satisfies any branch of the target type. Used by
402/// both DDL-time scans and runtime mutation checks.
403pub fn value_matches_property_type(value: &PropertyValue, target: &StoredPropertyType) -> bool {
404    target
405        .alternatives
406        .iter()
407        .any(|term| value_matches_term(value, term))
408}
409
410fn value_matches_term(value: &PropertyValue, term: &StoredPropertyTypeTerm) -> bool {
411    match term {
412        StoredPropertyTypeTerm::Scalar(scalar) => value_matches_scalar(value, *scalar),
413        StoredPropertyTypeTerm::List { inner, not_null } => match value {
414            PropertyValue::List(items) => items.iter().all(|item| {
415                if matches!(item, PropertyValue::Null) {
416                    !*not_null
417                } else {
418                    value_matches_term(item, inner)
419                }
420            }),
421            _ => false,
422        },
423        StoredPropertyTypeTerm::Vector { coord, dimension } => match value {
424            PropertyValue::Vector(v) => vector_matches(v, *coord, *dimension),
425            _ => false,
426        },
427    }
428}
429
430fn value_matches_scalar(value: &PropertyValue, scalar: StoredScalarType) -> bool {
431    match (value, scalar) {
432        (PropertyValue::Bool(_), StoredScalarType::Boolean) => true,
433        (PropertyValue::String(_), StoredScalarType::String) => true,
434        (PropertyValue::Int(_), StoredScalarType::Integer) => true,
435        (PropertyValue::Float(_), StoredScalarType::Float) => true,
436        (PropertyValue::Date(_), StoredScalarType::Date) => true,
437        (PropertyValue::Time(_), StoredScalarType::ZonedTime) => true,
438        (PropertyValue::LocalTime(_), StoredScalarType::LocalTime) => true,
439        (PropertyValue::DateTime(_), StoredScalarType::ZonedDateTime) => true,
440        (PropertyValue::LocalDateTime(_), StoredScalarType::LocalDateTime) => true,
441        (PropertyValue::Duration(_), StoredScalarType::Duration) => true,
442        (PropertyValue::Point(_), StoredScalarType::Point) => true,
443        // Map / Any are rejected at DDL time, so they should never appear
444        // here; reaching this arm with one of them indicates an upstream
445        // bug — fail closed.
446        _ => false,
447    }
448}
449
450fn vector_matches(
451    vector: &crate::types::LoraVector,
452    coord: StoredVectorCoordType,
453    dimension: u32,
454) -> bool {
455    if vector.dimension != dimension as usize {
456        return false;
457    }
458    use crate::types::VectorValues;
459    use StoredVectorCoordType::*;
460    matches!(
461        (&vector.values, coord),
462        (VectorValues::Float64(_), Float64)
463            | (VectorValues::Float32(_), Float32)
464            | (VectorValues::Integer64(_), Int64)
465            | (VectorValues::Integer32(_), Int32)
466            | (VectorValues::Integer16(_), Int16)
467            | (VectorValues::Integer8(_), Int8)
468    )
469}
470
471#[derive(Clone, Copy)]
472enum NodeLabelMatcher<'a> {
473    AnyOf(&'a [String]),
474    Names(&'a [crate::Name]),
475    One(&'a str),
476}
477
478impl NodeLabelMatcher<'_> {
479    fn contains(self, label: &str) -> bool {
480        match self {
481            NodeLabelMatcher::AnyOf(labels) => labels.iter().any(|l| l == label),
482            NodeLabelMatcher::Names(labels) => labels.iter().any(|l| l == label),
483            NodeLabelMatcher::One(candidate) => candidate == label,
484        }
485    }
486}
487
488#[derive(Clone, Copy)]
489enum ConstraintRecord<'a> {
490    Node {
491        labels: NodeLabelMatcher<'a>,
492        properties: &'a Properties,
493        skip: Option<NodeId>,
494    },
495    Relationship {
496        rel_type: &'a str,
497        properties: &'a Properties,
498        skip: Option<RelationshipId>,
499    },
500}
501
502impl<'a> ConstraintRecord<'a> {
503    fn applies_to(self, def: &ConstraintDefinition) -> bool {
504        match self {
505            ConstraintRecord::Node { labels, .. } => {
506                def.entity == StoredIndexEntity::Node && labels.contains(&def.label)
507            }
508            ConstraintRecord::Relationship { rel_type, .. } => {
509                def.entity == StoredIndexEntity::Relationship && def.label == rel_type
510            }
511        }
512    }
513
514    fn properties(self) -> &'a Properties {
515        match self {
516            ConstraintRecord::Node { properties, .. }
517            | ConstraintRecord::Relationship { properties, .. } => properties,
518        }
519    }
520
521    fn has_uniqueness_conflict(
522        self,
523        graph: &InMemoryGraph,
524        def: &ConstraintDefinition,
525        tuple: &[PropertyValue],
526    ) -> bool {
527        match self {
528            ConstraintRecord::Node { skip, .. } => {
529                any_other_node_with_tuple(graph, &def.label, &def.properties, tuple, skip)
530            }
531            ConstraintRecord::Relationship { skip, .. } => {
532                any_other_rel_with_tuple(graph, &def.label, &def.properties, tuple, skip)
533            }
534        }
535    }
536}
537
538fn check_record_constraints(
539    catalog: &ConstraintCatalog,
540    graph: &InMemoryGraph,
541    record: ConstraintRecord<'_>,
542) -> Result<(), ConstraintViolation> {
543    check_record_constraints_with(catalog, graph, record, true)
544}
545
546fn check_record_constraints_with(
547    catalog: &ConstraintCatalog,
548    graph: &InMemoryGraph,
549    record: ConstraintRecord<'_>,
550    check_existence: bool,
551) -> Result<(), ConstraintViolation> {
552    for def in catalog.iter() {
553        if !record.applies_to(def) {
554            continue;
555        }
556
557        let properties = record.properties();
558        validate_record_against_constraint(def, properties, check_existence)?;
559
560        if def.kind.requires_uniqueness() {
561            if let Some(tuple) = constrained_tuple(def, properties) {
562                if record.has_uniqueness_conflict(graph, def, &tuple) {
563                    return Err(uniqueness_violation(def));
564                }
565            }
566        }
567    }
568    Ok(())
569}
570
571/// Public read-side check used by mutation paths: given a proposed
572/// node create (labels + properties), is it accepted by every
573/// installed constraint? Cheap when no constraints are registered
574/// (single `is_empty()` on the catalog).
575pub(crate) fn check_node_create(
576    catalog: &ConstraintCatalog,
577    graph: &InMemoryGraph,
578    labels: &[String],
579    properties: &Properties,
580) -> Result<(), ConstraintViolation> {
581    check_record_constraints(
582        catalog,
583        graph,
584        ConstraintRecord::Node {
585            labels: NodeLabelMatcher::AnyOf(labels),
586            properties,
587            skip: None,
588        },
589    )
590}
591
592pub(crate) fn check_relationship_create(
593    catalog: &ConstraintCatalog,
594    graph: &InMemoryGraph,
595    rel_type: &str,
596    properties: &Properties,
597) -> Result<(), ConstraintViolation> {
598    check_record_constraints(
599        catalog,
600        graph,
601        ConstraintRecord::Relationship {
602            rel_type,
603            properties,
604            skip: None,
605        },
606    )
607}
608
609/// [`check_node_create`] without the existence checks, for a statement
610/// that checks existence once it has finished (a later `SET` in the same
611/// statement may still supply the property). Pair with
612/// [`check_node_existence`].
613pub(crate) fn check_node_create_deferred(
614    catalog: &ConstraintCatalog,
615    graph: &InMemoryGraph,
616    labels: &[String],
617    properties: &Properties,
618) -> Result<(), ConstraintViolation> {
619    check_record_constraints_with(
620        catalog,
621        graph,
622        ConstraintRecord::Node {
623            labels: NodeLabelMatcher::AnyOf(labels),
624            properties,
625            skip: None,
626        },
627        false,
628    )
629}
630
631/// Relationship counterpart of [`check_node_create_deferred`].
632pub(crate) fn check_relationship_create_deferred(
633    catalog: &ConstraintCatalog,
634    graph: &InMemoryGraph,
635    rel_type: &str,
636    properties: &Properties,
637) -> Result<(), ConstraintViolation> {
638    check_record_constraints_with(
639        catalog,
640        graph,
641        ConstraintRecord::Relationship {
642            rel_type,
643            properties,
644            skip: None,
645        },
646        false,
647    )
648}
649
650/// Existence (and key) constraints on a node as it stands now. A node
651/// that no longer exists passes.
652pub(crate) fn check_node_existence(
653    catalog: &ConstraintCatalog,
654    graph: &InMemoryGraph,
655    node_id: NodeId,
656) -> Result<(), ConstraintViolation> {
657    let Some(node) = graph.node_at(node_id) else {
658        return Ok(());
659    };
660    for def in catalog.iter() {
661        if def.entity != StoredIndexEntity::Node
662            || !def.kind.requires_existence()
663            || !node.labels().strs().any(|l| l == def.label)
664        {
665            continue;
666        }
667        if let Some(missing) = def
668            .properties
669            .iter()
670            .find(|p| !node.properties().contains_key(p.as_str()))
671        {
672            return Err(missing_property_violation(def, missing));
673        }
674    }
675    Ok(())
676}
677
678/// Relationship counterpart of [`check_node_existence`].
679pub(crate) fn check_relationship_existence(
680    catalog: &ConstraintCatalog,
681    graph: &InMemoryGraph,
682    rel_id: RelationshipId,
683) -> Result<(), ConstraintViolation> {
684    let Some(rel) = graph.rel_at(rel_id) else {
685        return Ok(());
686    };
687    for def in catalog.iter() {
688        if def.entity != StoredIndexEntity::Relationship
689            || !def.kind.requires_existence()
690            || rel.rel_type() != def.label
691        {
692            continue;
693        }
694        if let Some(missing) = def
695            .properties
696            .iter()
697            .find(|p| !rel.properties().contains_key(p.as_str()))
698        {
699            return Err(missing_property_violation(def, missing));
700        }
701    }
702    Ok(())
703}
704
705/// Whether another node with `label` already holds `target` for `keys`.
706///
707/// Uniqueness constraints own a backing range index, which keeps the
708/// label-scoped hash index for the first key active. Looking the value up
709/// there makes each check O(matches) instead of a scan of every node in
710/// the graph, which made loading N constrained nodes O(N^2). When the
711/// value has no index image (temporal, spatial, vector) or the index is
712/// not active, fall back to scanning the label's nodes only.
713fn any_other_node_with_tuple(
714    graph: &InMemoryGraph,
715    label: &str,
716    keys: &[String],
717    target: &[PropertyValue],
718    skip: Option<NodeId>,
719) -> bool {
720    let tuple_matches = |node: crate::NodeRef<'_>| {
721        node.has_label(label)
722            && keys.iter().enumerate().all(|(idx, key)| {
723                node.properties()
724                    .get(key.as_str())
725                    .is_some_and(|v| v == target[idx])
726            })
727    };
728    let candidates = match (keys.first(), target.first()) {
729        (Some(key), Some(value)) => graph.indexed_node_ids(label, key, value),
730        _ => None,
731    };
732    let candidates =
733        candidates.unwrap_or_else(|| crate::GraphStorage::node_ids_by_label(graph, label));
734    candidates
735        .into_iter()
736        .filter(|id| Some(*id) != skip)
737        .any(|id| graph.node_at(id).is_some_and(tuple_matches))
738}
739
740/// Relationship counterpart of [`any_other_node_with_tuple`].
741fn any_other_rel_with_tuple(
742    graph: &InMemoryGraph,
743    rel_type: &str,
744    keys: &[String],
745    target: &[PropertyValue],
746    skip: Option<RelationshipId>,
747) -> bool {
748    let tuple_matches = |rel: crate::RelRef<'_>| {
749        rel.rel_type() == rel_type
750            && keys.iter().enumerate().all(|(idx, key)| {
751                rel.properties()
752                    .get(key.as_str())
753                    .is_some_and(|v| v == target[idx])
754            })
755    };
756    let candidates = match (keys.first(), target.first()) {
757        (Some(key), Some(value)) => graph.indexed_rel_ids(rel_type, key, value),
758        _ => None,
759    };
760    let candidates =
761        candidates.unwrap_or_else(|| crate::GraphStorage::rel_ids_by_type(graph, rel_type));
762    candidates
763        .into_iter()
764        .filter(|id| Some(*id) != skip)
765        .any(|id| graph.rel_at(id).is_some_and(tuple_matches))
766}
767
768fn render_constraint_property_label(def: &ConstraintDefinition) -> String {
769    if def.properties.len() == 1 {
770        def.properties[0].clone()
771    } else {
772        def.properties.join(", ")
773    }
774}
775
776fn uniqueness_violation(def: &ConstraintDefinition) -> ConstraintViolation {
777    ConstraintViolation::UniquenessViolated {
778        constraint: def.name.clone(),
779        entity: def.entity,
780        label: def.label.clone(),
781        property: render_constraint_property_label(def),
782    }
783}
784
785fn missing_property_violation(def: &ConstraintDefinition, property: &str) -> ConstraintViolation {
786    if def.properties.len() == 1 {
787        ConstraintViolation::MissingProperty {
788            constraint: def.name.clone(),
789            entity: def.entity,
790            label: def.label.clone(),
791            property: property.to_string(),
792        }
793    } else {
794        ConstraintViolation::MissingPropertiesForKey {
795            constraint: def.name.clone(),
796            entity: def.entity,
797            label: def.label.clone(),
798            properties: def.properties.clone(),
799        }
800    }
801}
802
803fn constrained_tuple(
804    def: &ConstraintDefinition,
805    properties: &Properties,
806) -> Option<Vec<PropertyValue>> {
807    def.properties
808        .iter()
809        .map(|p| properties.get(p.as_str()).cloned())
810        .collect()
811}
812
813fn constrained_tuple_after_set(
814    def: &ConstraintDefinition,
815    properties: crate::PropsRef<'_>,
816    key: &str,
817    value: &PropertyValue,
818) -> Option<Vec<PropertyValue>> {
819    // Reads only the constrained properties of the stored record.
820    def.properties
821        .iter()
822        .map(|prop| {
823            if prop == key {
824                Some(value.clone())
825            } else {
826                properties.get(prop.as_str()).map(|v| v.to_owned())
827            }
828        })
829        .collect()
830}
831
832/// Mutation pre-check: about to `SET node.key = value`. Validates
833/// every node-level constraint whose schema covers any of the node's
834/// labels and any of its constrained properties touched by this write.
835pub(crate) fn check_node_set_property(
836    catalog: &ConstraintCatalog,
837    graph: &InMemoryGraph,
838    node_id: NodeId,
839    key: &str,
840    value: &PropertyValue,
841) -> Result<(), ConstraintViolation> {
842    let node = match graph.node_at(node_id) {
843        Some(n) => n,
844        None => return Ok(()), // mutation will fail downstream
845    };
846    for def in catalog.iter() {
847        if def.entity != StoredIndexEntity::Node {
848            continue;
849        }
850        if !node.labels().strs().any(|l| l == def.label) {
851            continue;
852        }
853        if !def.properties.iter().any(|p| p == key) {
854            continue;
855        }
856        // Type check on the new value.
857        if let StoredConstraintKind::PropertyType(target) = &def.kind {
858            if !value_matches_property_type(value, target) {
859                return Err(ConstraintViolation::WrongPropertyType {
860                    constraint: def.name.clone(),
861                    entity: def.entity,
862                    label: def.label.clone(),
863                    property: key.to_string(),
864                    expected: target.to_string(),
865                });
866            }
867        }
868        // Uniqueness: build the post-set tuple and search the rest of
869        // the graph for an identical one.
870        if def.kind.requires_uniqueness() {
871            if let Some(tuple) = constrained_tuple_after_set(def, node.properties(), key, value) {
872                if any_other_node_with_tuple(
873                    graph,
874                    &def.label,
875                    &def.properties,
876                    &tuple,
877                    Some(node_id),
878                ) {
879                    return Err(uniqueness_violation(def));
880                }
881            }
882        }
883    }
884    Ok(())
885}
886
887/// Mutation pre-check: about to `REMOVE node.key`. Rejects when an
888/// existence / key constraint requires the property to remain present.
889pub(crate) fn check_node_remove_property(
890    catalog: &ConstraintCatalog,
891    graph: &InMemoryGraph,
892    node_id: NodeId,
893    key: &str,
894) -> Result<(), ConstraintViolation> {
895    let node = match graph.node_at(node_id) {
896        Some(n) => n,
897        None => return Ok(()),
898    };
899    for def in catalog.iter() {
900        if def.entity != StoredIndexEntity::Node {
901            continue;
902        }
903        if !node.labels().strs().any(|l| l == def.label) {
904            continue;
905        }
906        if !def.kind.requires_existence() {
907            continue;
908        }
909        if !def.properties.iter().any(|p| p == key) {
910            continue;
911        }
912        return Err(missing_property_violation(def, key));
913    }
914    Ok(())
915}
916
917/// Mutation pre-check: about to replace the full property map on a
918/// node. Validate the final record shape, but skip the node itself
919/// when checking uniqueness.
920pub(crate) fn check_node_replace_properties(
921    catalog: &ConstraintCatalog,
922    graph: &InMemoryGraph,
923    node_id: NodeId,
924    properties: &Properties,
925) -> Result<(), ConstraintViolation> {
926    check_node_replace_properties_with(catalog, graph, node_id, properties, true)
927}
928
929/// [`check_node_replace_properties`] without the existence checks, for a
930/// statement that checks existence once it has finished (`SET n = {…},
931/// n.required = …`). Pair with [`check_node_existence`].
932pub(crate) fn check_node_replace_properties_deferred(
933    catalog: &ConstraintCatalog,
934    graph: &InMemoryGraph,
935    node_id: NodeId,
936    properties: &Properties,
937) -> Result<(), ConstraintViolation> {
938    check_node_replace_properties_with(catalog, graph, node_id, properties, false)
939}
940
941fn check_node_replace_properties_with(
942    catalog: &ConstraintCatalog,
943    graph: &InMemoryGraph,
944    node_id: NodeId,
945    properties: &Properties,
946    check_existence: bool,
947) -> Result<(), ConstraintViolation> {
948    let labels = match graph.node_at(node_id) {
949        Some(node) => node.labels().to_owned(),
950        None => return Ok(()),
951    };
952    check_record_constraints_with(
953        catalog,
954        graph,
955        ConstraintRecord::Node {
956            labels: NodeLabelMatcher::Names(&labels),
957            properties,
958            skip: Some(node_id),
959        },
960        check_existence,
961    )
962}
963
964pub(crate) fn check_relationship_set_property(
965    catalog: &ConstraintCatalog,
966    graph: &InMemoryGraph,
967    rel_id: RelationshipId,
968    key: &str,
969    value: &PropertyValue,
970) -> Result<(), ConstraintViolation> {
971    let rel = match graph.rel_at(rel_id) {
972        Some(r) => r,
973        None => return Ok(()),
974    };
975    for def in catalog.iter() {
976        if def.entity != StoredIndexEntity::Relationship {
977            continue;
978        }
979        if def.label != rel.rel_type() {
980            continue;
981        }
982        if !def.properties.iter().any(|p| p == key) {
983            continue;
984        }
985        if let StoredConstraintKind::PropertyType(target) = &def.kind {
986            if !value_matches_property_type(value, target) {
987                return Err(ConstraintViolation::WrongPropertyType {
988                    constraint: def.name.clone(),
989                    entity: def.entity,
990                    label: def.label.clone(),
991                    property: key.to_string(),
992                    expected: target.to_string(),
993                });
994            }
995        }
996        if def.kind.requires_uniqueness() {
997            if let Some(tuple) = constrained_tuple_after_set(def, rel.properties(), key, value) {
998                if any_other_rel_with_tuple(
999                    graph,
1000                    &def.label,
1001                    &def.properties,
1002                    &tuple,
1003                    Some(rel_id),
1004                ) {
1005                    return Err(uniqueness_violation(def));
1006                }
1007            }
1008        }
1009    }
1010    Ok(())
1011}
1012
1013pub(crate) fn check_relationship_remove_property(
1014    catalog: &ConstraintCatalog,
1015    graph: &InMemoryGraph,
1016    rel_id: RelationshipId,
1017    key: &str,
1018) -> Result<(), ConstraintViolation> {
1019    let rel = match graph.rel_at(rel_id) {
1020        Some(r) => r,
1021        None => return Ok(()),
1022    };
1023    for def in catalog.iter() {
1024        if def.entity != StoredIndexEntity::Relationship {
1025            continue;
1026        }
1027        if def.label != rel.rel_type() {
1028            continue;
1029        }
1030        if !def.kind.requires_existence() {
1031            continue;
1032        }
1033        if !def.properties.iter().any(|p| p == key) {
1034            continue;
1035        }
1036        return Err(missing_property_violation(def, key));
1037    }
1038    Ok(())
1039}
1040
1041/// Mutation pre-check: about to replace the full property map on a
1042/// relationship. Validate the final record shape, but skip the
1043/// relationship itself when checking uniqueness.
1044pub(crate) fn check_relationship_replace_properties(
1045    catalog: &ConstraintCatalog,
1046    graph: &InMemoryGraph,
1047    rel_id: RelationshipId,
1048    properties: &Properties,
1049) -> Result<(), ConstraintViolation> {
1050    check_relationship_replace_properties_with(catalog, graph, rel_id, properties, true)
1051}
1052
1053/// Relationship counterpart of [`check_node_replace_properties_deferred`].
1054pub(crate) fn check_relationship_replace_properties_deferred(
1055    catalog: &ConstraintCatalog,
1056    graph: &InMemoryGraph,
1057    rel_id: RelationshipId,
1058    properties: &Properties,
1059) -> Result<(), ConstraintViolation> {
1060    check_relationship_replace_properties_with(catalog, graph, rel_id, properties, false)
1061}
1062
1063fn check_relationship_replace_properties_with(
1064    catalog: &ConstraintCatalog,
1065    graph: &InMemoryGraph,
1066    rel_id: RelationshipId,
1067    properties: &Properties,
1068    check_existence: bool,
1069) -> Result<(), ConstraintViolation> {
1070    let rel = match graph.rel_at(rel_id) {
1071        Some(r) => r,
1072        None => return Ok(()),
1073    };
1074    check_record_constraints_with(
1075        catalog,
1076        graph,
1077        ConstraintRecord::Relationship {
1078            rel_type: rel.rel_type(),
1079            properties,
1080            skip: Some(rel_id),
1081        },
1082        check_existence,
1083    )
1084}
1085
1086/// Mutation pre-check: about to `SET n:Label` (add label). All
1087/// existence / type / uniqueness constraints attached to `Label`
1088/// suddenly start applying to this node; if any of them is violated
1089/// the mutation is rejected.
1090pub(crate) fn check_node_add_label(
1091    catalog: &ConstraintCatalog,
1092    graph: &InMemoryGraph,
1093    node_id: NodeId,
1094    label: &str,
1095) -> Result<(), ConstraintViolation> {
1096    let node = match graph.node_at(node_id) {
1097        Some(n) => n,
1098        None => return Ok(()),
1099    };
1100    check_record_constraints(
1101        catalog,
1102        graph,
1103        ConstraintRecord::Node {
1104            labels: NodeLabelMatcher::One(label),
1105            properties: &node.properties().to_owned(),
1106            skip: Some(node_id),
1107        },
1108    )
1109}
1110
1111/// [`check_node_add_label`] without the existence checks, for a statement
1112/// that checks existence once it has finished. Pair with
1113/// [`check_node_existence`].
1114pub(crate) fn check_node_add_label_deferred(
1115    catalog: &ConstraintCatalog,
1116    graph: &InMemoryGraph,
1117    node_id: NodeId,
1118    label: &str,
1119) -> Result<(), ConstraintViolation> {
1120    let Some(node) = graph.node_at(node_id) else {
1121        return Ok(());
1122    };
1123    check_record_constraints_with(
1124        catalog,
1125        graph,
1126        ConstraintRecord::Node {
1127            labels: NodeLabelMatcher::One(label),
1128            properties: &node.properties().to_owned(),
1129            skip: Some(node_id),
1130        },
1131        false,
1132    )
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138    use crate::intern;
1139
1140    fn unique_on(property: &str) -> ConstraintDefinition {
1141        ConstraintDefinition {
1142            name: "u".into(),
1143            kind: StoredConstraintKind::Unique,
1144            entity: StoredIndexEntity::Node,
1145            label: "L".into(),
1146            properties: vec![property.into()],
1147            owned_index: Some("u".into()),
1148        }
1149    }
1150
1151    fn with(property: &str, value: PropertyValue) -> Properties {
1152        let mut properties = Properties::new();
1153        properties.insert(intern(property), value);
1154        properties
1155    }
1156
1157    /// Records 0..n carry `values[id]` under `k`; `SeenTuples` sees them
1158    /// all under one forced hash, so every insert after the first takes
1159    /// the collision path.
1160    fn insert_colliding(values: &[PropertyValue]) -> Result<(), ConstraintViolation> {
1161        let def = unique_on("k");
1162        let records: Vec<Properties> = values.iter().map(|v| with("k", v.clone())).collect();
1163        let mut seen = SeenTuples::default();
1164        for (id, properties) in records.iter().enumerate() {
1165            seen.key.clear();
1166            write_property_tuple_key(&mut seen.key, &def, properties);
1167            seen.insert_hashed(&def, 7, id as u64, |other| {
1168                records.get(other as usize).cloned()
1169            })?;
1170        }
1171        Ok(())
1172    }
1173
1174    #[test]
1175    fn a_shared_hash_is_not_a_duplicate() {
1176        // Int 1 and Float 1.0 stay distinct, as they did as key strings.
1177        let values = [
1178            PropertyValue::Int(1),
1179            PropertyValue::Float(1.0),
1180            PropertyValue::String("1".into()),
1181            PropertyValue::Int(2),
1182        ];
1183        assert!(insert_colliding(&values).is_ok());
1184    }
1185
1186    #[test]
1187    fn an_equal_tuple_behind_a_collision_is_found() {
1188        let values = [
1189            PropertyValue::Int(1),
1190            PropertyValue::Int(2),
1191            PropertyValue::Int(3),
1192            PropertyValue::Int(2),
1193        ];
1194        let err = insert_colliding(&values).unwrap_err();
1195        assert_eq!(err.gql_status(), "22N79");
1196    }
1197
1198    #[test]
1199    fn records_without_the_tuple_are_skipped() {
1200        let def = unique_on("k");
1201        let mut seen = SeenTuples::default();
1202        for id in 0..3 {
1203            seen.insert(&def, id, &with("other", PropertyValue::Int(1)), |_| None)
1204                .unwrap();
1205        }
1206        assert!(seen.first.is_empty());
1207    }
1208}