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