Skip to main content

grust_core/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap},
3    fmt,
4};
5
6use async_trait::async_trait;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8
9pub type Result<T> = std::result::Result<T, GrustError>;
10pub type Props = BTreeMap<String, Value>;
11
12#[derive(Debug, thiserror::Error)]
13pub enum GrustError {
14    #[error("backend error: {0}")]
15    Backend(String),
16    #[error("schema error: {0}")]
17    Schema(String),
18    #[error("unsupported graph feature: {0}")]
19    Unsupported(String),
20    #[error("Cypher syntax error: {0}")]
21    CypherSyntax(String),
22    #[error("Cypher unresolved identity: {0}")]
23    CypherUnresolvedIdentity(String),
24    #[error("Cypher unsupported cardinality: {0}")]
25    CypherUnsupportedCardinality(String),
26    #[error("Cypher execution error: {0}")]
27    CypherExecution(String),
28    #[error("serialization error: {0}")]
29    Serialization(String),
30}
31
32macro_rules! string_newtype {
33    ($(#[$meta:meta])* $name:ident) => {
34        $(#[$meta])*
35        #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
36        pub struct $name(String);
37
38        impl $name {
39            pub fn new(value: impl Into<String>) -> Self {
40                Self(value.into())
41            }
42
43            pub fn as_str(&self) -> &str {
44                &self.0
45            }
46
47            pub fn into_string(self) -> String {
48                self.0
49            }
50        }
51
52        impl fmt::Display for $name {
53            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54                f.write_str(&self.0)
55            }
56        }
57
58        impl From<String> for $name {
59            fn from(value: String) -> Self {
60                Self::new(value)
61            }
62        }
63
64        impl From<&str> for $name {
65            fn from(value: &str) -> Self {
66                Self::new(value)
67            }
68        }
69
70        impl From<&String> for $name {
71            fn from(value: &String) -> Self {
72                Self::new(value.clone())
73            }
74        }
75
76        impl From<&$name> for $name {
77            fn from(value: &$name) -> Self {
78                value.clone()
79            }
80        }
81    };
82}
83
84string_newtype!(
85    /// Stable application-level node identifier.
86    NodeId
87);
88string_newtype!(
89    /// Optional application-level edge identifier.
90    EdgeId
91);
92string_newtype!(
93    /// Node or edge label.
94    Label
95);
96
97/// Validated RFC 3339 date-time string used by [`Value::DateTime`].
98#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
99pub struct RfcDate(String);
100
101impl RfcDate {
102    /// Parses and stores an RFC 3339 date-time such as
103    /// `2026-06-12T09:30:00Z` or `2026-06-12T09:30:00.123+02:00`.
104    pub fn parse(value: impl Into<String>) -> Result<Self> {
105        let value = value.into();
106        if is_rfc3339_datetime(&value) {
107            Ok(Self(value))
108        } else {
109            Err(GrustError::Schema(format!(
110                "'{value}' is not an RFC 3339 date-time"
111            )))
112        }
113    }
114
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118
119    pub fn into_string(self) -> String {
120        self.0
121    }
122}
123
124impl fmt::Display for RfcDate {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130impl Serialize for RfcDate {
131    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
132    where
133        S: Serializer,
134    {
135        serializer.serialize_str(self.as_str())
136    }
137}
138
139impl<'de> Deserialize<'de> for RfcDate {
140    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
141    where
142        D: Deserializer<'de>,
143    {
144        let value = String::deserialize(deserializer)?;
145        Self::parse(value).map_err(de::Error::custom)
146    }
147}
148
149/// Fixed-point decimal: the value is `mantissa × 10^(−scale)`. This mirrors SQL
150/// `DECIMAL(38, scale)` (Postgres/Spark cap precision at 38 digits, which is what
151/// an `i128` mantissa holds), and is lossless within that range — unlike `Float`.
152///
153/// Values are normalized on construction: trailing fractional zeros are dropped
154/// (`1.50` and `1.5` are equal), and `0` always has scale `0`. Equality and
155/// ordering are therefore by numeric value.
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
157pub struct Decimal {
158    mantissa: i128,
159    scale: u32,
160}
161
162impl Decimal {
163    /// Parses a decimal numeral such as `3.14`, `-0.001`, or `42`. Rejects empty
164    /// input, malformed numerals, and values exceeding 38 significant digits.
165    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
166        let raw = value.as_ref().trim();
167        let bad = || GrustError::Schema(format!("'{raw}' is not a decimal numeral"));
168        if raw.is_empty() {
169            return Err(bad());
170        }
171        let (neg, body) = match raw.strip_prefix('-') {
172            Some(rest) => (true, rest),
173            None => (false, raw.strip_prefix('+').unwrap_or(raw)),
174        };
175        let (int_part, frac_part) = match body.split_once('.') {
176            Some((i, f)) => (i, f),
177            None => (body, ""),
178        };
179        if int_part.is_empty() && frac_part.is_empty() {
180            return Err(bad());
181        }
182        if !int_part.bytes().all(|b| b.is_ascii_digit())
183            || !frac_part.bytes().all(|b| b.is_ascii_digit())
184        {
185            return Err(bad());
186        }
187        let digits: String = int_part.chars().chain(frac_part.chars()).collect();
188        let mantissa: i128 = if digits.is_empty() {
189            0
190        } else {
191            digits.parse().map_err(|_| {
192                GrustError::Schema(format!("decimal '{raw}' exceeds 38 significant digits"))
193            })?
194        };
195        let mantissa = if neg { -mantissa } else { mantissa };
196        Ok(Self::normalize(mantissa, frac_part.len() as u32))
197    }
198
199    /// Builds a decimal from a raw mantissa and scale (`mantissa × 10^−scale`).
200    pub fn from_parts(mantissa: i128, scale: u32) -> Self {
201        Self::normalize(mantissa, scale)
202    }
203
204    fn normalize(mut mantissa: i128, mut scale: u32) -> Self {
205        while scale > 0 && mantissa % 10 == 0 {
206            mantissa /= 10;
207            scale -= 1;
208        }
209        if mantissa == 0 {
210            scale = 0;
211        }
212        Self { mantissa, scale }
213    }
214
215    pub fn mantissa(&self) -> i128 {
216        self.mantissa
217    }
218
219    pub fn scale(&self) -> u32 {
220        self.scale
221    }
222
223    /// Canonical string form (no superfluous trailing zeros).
224    pub fn to_canonical_string(&self) -> String {
225        if self.scale == 0 {
226            return self.mantissa.to_string();
227        }
228        let neg = self.mantissa < 0;
229        let digits = self.mantissa.unsigned_abs().to_string();
230        let scale = self.scale as usize;
231        let s = if digits.len() <= scale {
232            format!("0.{:0>width$}", digits, width = scale)
233        } else {
234            let point = digits.len() - scale;
235            format!("{}.{}", &digits[..point], &digits[point..])
236        };
237        if neg {
238            format!("-{s}")
239        } else {
240            s
241        }
242    }
243
244    /// Aligns two decimals to a common scale, returning their scaled mantissas.
245    /// Returns `None` on `i128` overflow (operands beyond the 38-digit range).
246    fn aligned(&self, other: &Self) -> Option<(i128, i128, u32)> {
247        let scale = self.scale.max(other.scale);
248        let lift = |m: i128, s: u32| 10i128.checked_pow(scale - s).and_then(|f| m.checked_mul(f));
249        Some((lift(self.mantissa, self.scale)?, lift(other.mantissa, other.scale)?, scale))
250    }
251
252    pub fn checked_add(&self, other: &Self) -> Option<Self> {
253        let (a, b, scale) = self.aligned(other)?;
254        Some(Self::normalize(a.checked_add(b)?, scale))
255    }
256
257    pub fn checked_sub(&self, other: &Self) -> Option<Self> {
258        let (a, b, scale) = self.aligned(other)?;
259        Some(Self::normalize(a.checked_sub(b)?, scale))
260    }
261
262    pub fn checked_mul(&self, other: &Self) -> Option<Self> {
263        let mantissa = self.mantissa.checked_mul(other.mantissa)?;
264        Some(Self::normalize(mantissa, self.scale + other.scale))
265    }
266
267    /// Lossy conversion to `f64` (for coercion into float arithmetic).
268    pub fn to_f64(&self) -> f64 {
269        self.mantissa as f64 / 10f64.powi(self.scale as i32)
270    }
271}
272
273impl Ord for Decimal {
274    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
275        match self.aligned(other) {
276            Some((a, b, _)) => a.cmp(&b),
277            // Overflow on alignment: fall back to comparing the f64 magnitudes,
278            // which is monotone enough to separate out-of-range operands.
279            None => self
280                .to_f64()
281                .partial_cmp(&other.to_f64())
282                .unwrap_or(std::cmp::Ordering::Equal),
283        }
284    }
285}
286
287impl PartialOrd for Decimal {
288    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293impl fmt::Display for Decimal {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        f.write_str(&self.to_canonical_string())
296    }
297}
298
299impl Serialize for Decimal {
300    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
301    where
302        S: Serializer,
303    {
304        serializer.serialize_str(&self.to_canonical_string())
305    }
306}
307
308impl<'de> Deserialize<'de> for Decimal {
309    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
310    where
311        D: Deserializer<'de>,
312    {
313        let value = String::deserialize(deserializer)?;
314        Self::parse(value).map_err(de::Error::custom)
315    }
316}
317
318/// Calendar/clock duration with month, day, second, and nanosecond components —
319/// the model GQL/ISO 8601 uses (months and days are not fixed-length, so they are
320/// kept distinct from seconds rather than collapsed). Construct from an ISO 8601
321/// duration string such as `P1Y2M10DT2H30M` via [`Duration::parse`].
322///
323/// Ordering is structural (months, then days, then seconds, then nanos): a
324/// deterministic total order for sorting, not a calendar-normalized comparison.
325#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
326pub struct Duration {
327    pub months: i64,
328    pub days: i64,
329    pub seconds: i64,
330    pub nanos: i32,
331}
332
333impl Duration {
334    /// Parses an ISO 8601 duration: `P[nY][nM][nW][nD][T[nH][nM][nS]]`. Years map
335    /// to 12 months and weeks to 7 days; the seconds field may be fractional
336    /// (down to nanosecond precision). At least one component is required.
337    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
338        let raw = value.as_ref().trim();
339        let bad = || GrustError::Schema(format!("'{raw}' is not an ISO 8601 duration"));
340        let body = raw.strip_prefix('P').ok_or_else(bad)?;
341        let (date_part, time_part) = match body.split_once('T') {
342            Some((d, t)) => (d, Some(t)),
343            None => (body, None),
344        };
345        let mut dur = Duration::default();
346        let mut any = false;
347        // Date components: Y, M, W, D (integer only).
348        let mut num = String::new();
349        for ch in date_part.chars() {
350            if ch.is_ascii_digit() || ch == '-' {
351                num.push(ch);
352            } else {
353                let n: i64 = num.parse().map_err(|_| bad())?;
354                num.clear();
355                any = true;
356                match ch {
357                    'Y' => dur.months += n * 12,
358                    'M' => dur.months += n,
359                    'W' => dur.days += n * 7,
360                    'D' => dur.days += n,
361                    _ => return Err(bad()),
362                }
363            }
364        }
365        if !num.is_empty() {
366            return Err(bad());
367        }
368        // Time components: H, M, S (S may be fractional).
369        if let Some(time_part) = time_part {
370            let mut tok = String::new();
371            for ch in time_part.chars() {
372                if ch.is_ascii_digit() || ch == '-' || ch == '.' {
373                    tok.push(ch);
374                } else {
375                    any = true;
376                    match ch {
377                        'H' => dur.seconds += tok.parse::<i64>().map_err(|_| bad())? * 3600,
378                        'M' => dur.seconds += tok.parse::<i64>().map_err(|_| bad())? * 60,
379                        'S' => {
380                            let (secs, nanos) = parse_fractional_seconds(&tok).ok_or_else(bad)?;
381                            dur.seconds += secs;
382                            dur.nanos += nanos;
383                        }
384                        _ => return Err(bad()),
385                    }
386                    tok.clear();
387                }
388            }
389            if !tok.is_empty() {
390                return Err(bad());
391            }
392        }
393        if !any {
394            return Err(bad());
395        }
396        Ok(dur.carry_nanos())
397    }
398
399    fn carry_nanos(mut self) -> Self {
400        if self.nanos.abs() >= 1_000_000_000 {
401            self.seconds += (self.nanos / 1_000_000_000) as i64;
402            self.nanos %= 1_000_000_000;
403        }
404        self
405    }
406
407    /// Canonical ISO 8601 string (`PT0S` for the zero duration).
408    pub fn to_iso_string(&self) -> String {
409        let mut out = String::from("P");
410        if self.months != 0 {
411            out.push_str(&format!("{}M", self.months));
412        }
413        if self.days != 0 {
414            out.push_str(&format!("{}D", self.days));
415        }
416        if self.seconds != 0 || self.nanos != 0 {
417            out.push('T');
418            if self.nanos != 0 {
419                let frac = format!("{:09}", self.nanos.unsigned_abs());
420                let frac = frac.trim_end_matches('0');
421                out.push_str(&format!("{}.{}S", self.seconds, frac));
422            } else {
423                out.push_str(&format!("{}S", self.seconds));
424            }
425        }
426        if out == "P" {
427            out.push_str("T0S");
428        }
429        out
430    }
431
432    pub fn checked_add(&self, other: &Self) -> Option<Self> {
433        Some(
434            Self {
435                months: self.months.checked_add(other.months)?,
436                days: self.days.checked_add(other.days)?,
437                seconds: self.seconds.checked_add(other.seconds)?,
438                nanos: self.nanos.checked_add(other.nanos)?,
439            }
440            .carry_nanos(),
441        )
442    }
443
444    pub fn negated(&self) -> Self {
445        Self {
446            months: -self.months,
447            days: -self.days,
448            seconds: -self.seconds,
449            nanos: -self.nanos,
450        }
451    }
452}
453
454/// Parses a possibly-fractional seconds token into whole seconds + nanoseconds.
455fn parse_fractional_seconds(tok: &str) -> Option<(i64, i32)> {
456    match tok.split_once('.') {
457        None => Some((tok.parse().ok()?, 0)),
458        Some((whole, frac)) => {
459            if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
460                return None;
461            }
462            let secs: i64 = if whole.is_empty() || whole == "-" {
463                0
464            } else {
465                whole.parse().ok()?
466            };
467            let frac9: String = frac.chars().chain(std::iter::repeat('0')).take(9).collect();
468            let mut nanos: i32 = frac9.parse().ok()?;
469            if whole.starts_with('-') {
470                nanos = -nanos;
471            }
472            Some((secs, nanos))
473        }
474    }
475}
476
477impl fmt::Display for Duration {
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        f.write_str(&self.to_iso_string())
480    }
481}
482
483impl Serialize for Duration {
484    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
485    where
486        S: Serializer,
487    {
488        serializer.serialize_str(&self.to_iso_string())
489    }
490}
491
492impl<'de> Deserialize<'de> for Duration {
493    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
494    where
495        D: Deserializer<'de>,
496    {
497        let value = String::deserialize(deserializer)?;
498        Self::parse(value).map_err(de::Error::custom)
499    }
500}
501
502#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
503#[serde(tag = "type", content = "value", rename_all = "snake_case")]
504pub enum Value {
505    Null,
506    Bool(bool),
507    Int(i64),
508    Float(f64),
509    String(String),
510    /// An RFC 3339 date-time, e.g. `2026-06-12T09:30:00Z`. Construct with
511    /// [`Value::datetime`] to get format validation.
512    DateTime(RfcDate),
513    /// A lossless fixed-point decimal (SQL DECIMAL-style). Construct with
514    /// [`Value::decimal`].
515    Decimal(Decimal),
516    /// An ISO 8601 calendar/clock duration. Construct with [`Value::duration`].
517    Duration(Duration),
518    StringArray(Vec<String>),
519    IntArray(Vec<i64>),
520    FloatArray(Vec<f64>),
521    Json(serde_json::Value),
522}
523
524impl Value {
525    /// Creates a `Value::DateTime`, validating that the input is an RFC 3339
526    /// date-time such as `2026-06-12T09:30:00Z` or
527    /// `2026-06-12T09:30:00.123+02:00`.
528    pub fn datetime(value: impl Into<String>) -> Result<Self> {
529        RfcDate::parse(value).map(Self::DateTime)
530    }
531
532    /// Creates a `Value::Decimal`, validating the decimal numeral (e.g. `3.14`).
533    pub fn decimal(value: impl AsRef<str>) -> Result<Self> {
534        Decimal::parse(value).map(Self::Decimal)
535    }
536
537    /// Creates a `Value::Duration` from an ISO 8601 duration (e.g. `P1Y2MT3H`).
538    pub fn duration(value: impl AsRef<str>) -> Result<Self> {
539        Duration::parse(value).map(Self::Duration)
540    }
541
542    pub fn as_decimal(&self) -> Option<&Decimal> {
543        match self {
544            Self::Decimal(value) => Some(value),
545            _ => None,
546        }
547    }
548
549    pub fn as_duration(&self) -> Option<&Duration> {
550        match self {
551            Self::Duration(value) => Some(value),
552            _ => None,
553        }
554    }
555
556    pub fn as_str(&self) -> Option<&str> {
557        match self {
558            Self::String(value) => Some(value),
559            _ => None,
560        }
561    }
562
563    pub fn as_datetime(&self) -> Option<&str> {
564        match self {
565            Self::DateTime(value) => Some(value.as_str()),
566            _ => None,
567        }
568    }
569
570    pub fn as_string_array(&self) -> Option<&[String]> {
571        match self {
572            Self::StringArray(values) => Some(values),
573            _ => None,
574        }
575    }
576
577    pub fn as_int_array(&self) -> Option<&[i64]> {
578        match self {
579            Self::IntArray(values) => Some(values),
580            _ => None,
581        }
582    }
583
584    pub fn as_float_array(&self) -> Option<&[f64]> {
585        match self {
586            Self::FloatArray(values) => Some(values),
587            _ => None,
588        }
589    }
590
591    /// Converts to a plain (untagged) JSON value. `DateTime` becomes a JSON
592    /// string, so a `to_json`/`from_json` round trip yields `Value::String`.
593    pub fn to_json(&self) -> serde_json::Value {
594        match self {
595            Self::Null => serde_json::Value::Null,
596            Self::Bool(value) => serde_json::Value::Bool(*value),
597            Self::Int(value) => serde_json::Value::from(*value),
598            Self::Float(value) => serde_json::Value::from(*value),
599            Self::String(value) => serde_json::Value::String(value.clone()),
600            Self::DateTime(value) => serde_json::Value::String(value.as_str().to_string()),
601            Self::Decimal(value) => serde_json::Value::String(value.to_canonical_string()),
602            Self::Duration(value) => serde_json::Value::String(value.to_iso_string()),
603            Self::StringArray(values) => serde_json::Value::from(values.clone()),
604            Self::IntArray(values) => serde_json::Value::from(values.clone()),
605            Self::FloatArray(values) => serde_json::Value::from(values.clone()),
606            Self::Json(value) => value.clone(),
607        }
608    }
609
610    /// Converts from JSON, accepting both plain values and the tagged
611    /// `{"type": ..., "value": ...}` form that `Value`'s serde representation
612    /// produces.
613    pub fn from_json(value: serde_json::Value) -> Self {
614        if let serde_json::Value::Object(mapping) = &value
615            && mapping.contains_key("type")
616            && mapping.contains_key("value")
617            && let Ok(tagged) = serde_json::from_value(value.clone())
618        {
619            return tagged;
620        }
621        Self::from(value)
622    }
623}
624
625/// Validates the RFC 3339 date-time shape `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM)`.
626fn is_rfc3339_datetime(value: &str) -> bool {
627    let bytes = value.as_bytes();
628    if bytes.len() < 20 {
629        return false;
630    }
631    let digit = |i: usize| bytes[i].is_ascii_digit();
632    let all_digits = |range: std::ops::Range<usize>| range.clone().all(digit);
633    let pair = |i: usize| (bytes[i] - b'0') * 10 + (bytes[i + 1] - b'0');
634
635    if !(all_digits(0..4)
636        && bytes[4] == b'-'
637        && all_digits(5..7)
638        && bytes[7] == b'-'
639        && all_digits(8..10)
640        && bytes[10] == b'T'
641        && all_digits(11..13)
642        && bytes[13] == b':'
643        && all_digits(14..16)
644        && bytes[16] == b':'
645        && all_digits(17..19))
646    {
647        return false;
648    }
649    let year = bytes[0..4]
650        .iter()
651        .fold(0u16, |acc, digit| acc * 10 + u16::from(digit - b'0'));
652    let month = pair(5);
653    let day = pair(8);
654    let leap_year = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
655    let max_day = match month {
656        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
657        4 | 6 | 9 | 11 => 30,
658        2 if leap_year => 29,
659        2 => 28,
660        _ => return false,
661    };
662    if !(day >= 1 && day <= max_day && pair(11) < 24 && pair(14) < 60 && pair(17) <= 60) {
663        return false;
664    }
665
666    let mut i = 19;
667    if bytes[i] == b'.' {
668        let start = i + 1;
669        i = start;
670        while i < bytes.len() && bytes[i].is_ascii_digit() {
671            i += 1;
672        }
673        if i == start {
674            return false;
675        }
676    }
677    match bytes.get(i) {
678        Some(b'Z') => i + 1 == bytes.len(),
679        Some(b'+' | b'-') => {
680            i + 6 == bytes.len()
681                && all_digits(i + 1..i + 3)
682                && bytes[i + 3] == b':'
683                && all_digits(i + 4..i + 6)
684                && pair(i + 1) < 24
685                && pair(i + 4) < 60
686        }
687        _ => false,
688    }
689}
690
691impl From<String> for Value {
692    fn from(value: String) -> Self {
693        Self::String(value)
694    }
695}
696
697impl From<&str> for Value {
698    fn from(value: &str) -> Self {
699        Self::String(value.to_string())
700    }
701}
702
703impl From<&String> for Value {
704    fn from(value: &String) -> Self {
705        Self::String(value.clone())
706    }
707}
708
709impl From<Vec<String>> for Value {
710    fn from(value: Vec<String>) -> Self {
711        Self::StringArray(value)
712    }
713}
714
715impl From<Vec<i64>> for Value {
716    fn from(value: Vec<i64>) -> Self {
717        Self::IntArray(value)
718    }
719}
720
721impl From<Vec<f64>> for Value {
722    fn from(value: Vec<f64>) -> Self {
723        Self::FloatArray(value)
724    }
725}
726
727impl From<bool> for Value {
728    fn from(value: bool) -> Self {
729        Self::Bool(value)
730    }
731}
732
733impl From<i64> for Value {
734    fn from(value: i64) -> Self {
735        Self::Int(value)
736    }
737}
738
739impl From<i32> for Value {
740    fn from(value: i32) -> Self {
741        Self::Int(i64::from(value))
742    }
743}
744
745impl From<usize> for Value {
746    fn from(value: usize) -> Self {
747        Self::Int(value as i64)
748    }
749}
750
751impl From<f64> for Value {
752    fn from(value: f64) -> Self {
753        Self::Float(value)
754    }
755}
756
757impl From<serde_json::Value> for Value {
758    fn from(value: serde_json::Value) -> Self {
759        match value {
760            serde_json::Value::Null => Self::Null,
761            serde_json::Value::Bool(value) => Self::Bool(value),
762            serde_json::Value::Number(value) => {
763                if let Some(value) = value.as_i64() {
764                    Self::Int(value)
765                } else if let Some(value) = value.as_f64() {
766                    Self::Float(value)
767                } else {
768                    Self::Json(serde_json::Value::Number(value))
769                }
770            }
771            serde_json::Value::String(value) => Self::String(value),
772            serde_json::Value::Array(values) => {
773                let ints = values
774                    .iter()
775                    .filter_map(serde_json::Value::as_i64)
776                    .collect::<Vec<_>>();
777                if !values.is_empty() && ints.len() == values.len() {
778                    return Self::IntArray(ints);
779                }
780                let floats = values
781                    .iter()
782                    .filter_map(serde_json::Value::as_f64)
783                    .collect::<Vec<_>>();
784                if !values.is_empty() && floats.len() == values.len() {
785                    return Self::FloatArray(floats);
786                }
787                let strings = values
788                    .iter()
789                    .filter_map(|value| value.as_str().map(ToString::to_string))
790                    .collect::<Vec<_>>();
791                if strings.len() == values.len() {
792                    Self::StringArray(strings)
793                } else {
794                    Self::Json(serde_json::Value::Array(values))
795                }
796            }
797            serde_json::Value::Object(value) => Self::Json(serde_json::Value::Object(value)),
798        }
799    }
800}
801
802#[cfg(feature = "typed-garde")]
803pub mod typed {
804    use serde::{Serialize, de::DeserializeOwned};
805
806    use crate::{
807        Edge, EdgeId, Graph, GraphBuilder, GrustError, Node, NodeId, Props, PutOutcome, Result,
808        Value,
809    };
810
811    pub use garde;
812    #[cfg(feature = "typed-zod-rs")]
813    pub use zod_rs;
814
815    pub trait TypedNode: garde::Validate + Serialize {
816        const LABEL: &'static str;
817
818        fn node_id(&self) -> NodeId;
819
820        fn node_props(&self) -> Result<Props> {
821            props_from_serialize(self)
822        }
823
824        fn from_node(node: &Node) -> Result<Self>
825        where
826            Self: Sized + DeserializeOwned,
827            Self::Context: Default,
828        {
829            let ctx = Self::Context::default();
830            Self::from_node_with(node, &ctx)
831        }
832
833        fn from_node_with(node: &Node, ctx: &Self::Context) -> Result<Self>
834        where
835            Self: Sized + DeserializeOwned,
836        {
837            if node.label.as_str() != Self::LABEL {
838                return Err(GrustError::Schema(format!(
839                    "node '{}' has label '{}', expected '{}'",
840                    node.id.as_str(),
841                    node.label.as_str(),
842                    Self::LABEL
843                )));
844            }
845            let typed: Self =
846                serde_json::from_value(props_to_plain_json(&node.props)).map_err(|err| {
847                    GrustError::Serialization(format!("typed node decode error: {err}"))
848                })?;
849            typed
850                .validate_with(ctx)
851                .map_err(|err| validation_error(Self::LABEL, err))?;
852            let typed_id = typed.node_id();
853            if typed_id != node.id {
854                return Err(GrustError::Schema(format!(
855                    "typed node '{}' decoded id '{}', expected '{}'",
856                    Self::LABEL,
857                    typed_id.as_str(),
858                    node.id.as_str()
859                )));
860            }
861            Ok(typed)
862        }
863    }
864
865    pub trait TypedEdge: garde::Validate + Serialize {
866        const LABEL: &'static str;
867
868        fn source_node_id(&self) -> NodeId;
869
870        fn target_node_id(&self) -> NodeId;
871
872        fn edge_id(&self) -> Option<EdgeId> {
873            None
874        }
875
876        fn edge_props(&self) -> Result<Props> {
877            props_from_serialize(self)
878        }
879
880        fn from_edge(edge: &Edge) -> Result<Self>
881        where
882            Self: Sized + DeserializeOwned,
883            Self::Context: Default,
884        {
885            let ctx = Self::Context::default();
886            Self::from_edge_with(edge, &ctx)
887        }
888
889        fn from_edge_with(edge: &Edge, ctx: &Self::Context) -> Result<Self>
890        where
891            Self: Sized + DeserializeOwned,
892        {
893            if edge.label.as_str() != Self::LABEL {
894                return Err(GrustError::Schema(format!(
895                    "edge from '{}' to '{}' has label '{}', expected '{}'",
896                    edge.from.as_str(),
897                    edge.to.as_str(),
898                    edge.label.as_str(),
899                    Self::LABEL
900                )));
901            }
902            let typed: Self =
903                serde_json::from_value(props_to_plain_json(&edge.props)).map_err(|err| {
904                    GrustError::Serialization(format!("typed edge decode error: {err}"))
905                })?;
906            typed
907                .validate_with(ctx)
908                .map_err(|err| validation_error(Self::LABEL, err))?;
909            if typed.source_node_id() != edge.from || typed.target_node_id() != edge.to {
910                return Err(GrustError::Schema(format!(
911                    "typed edge '{}' decoded endpoints '{}' -> '{}', expected '{}' -> '{}'",
912                    Self::LABEL,
913                    typed.source_node_id().as_str(),
914                    typed.target_node_id().as_str(),
915                    edge.from.as_str(),
916                    edge.to.as_str()
917                )));
918            }
919            if let Some(decoded_id) = typed.edge_id()
920                && edge
921                    .id
922                    .as_ref()
923                    .is_some_and(|edge_id| edge_id != &decoded_id)
924            {
925                return Err(GrustError::Schema(format!(
926                    "typed edge '{}' decoded id '{}', expected '{}'",
927                    Self::LABEL,
928                    decoded_id.as_str(),
929                    edge.id.as_ref().expect("edge id checked").as_str()
930                )));
931            }
932            Ok(typed)
933        }
934    }
935
936    #[derive(Clone, Debug, Default)]
937    pub struct TypedGraphBuilder {
938        builder: GraphBuilder,
939    }
940
941    impl TypedGraphBuilder {
942        pub fn new() -> Self {
943            Self::default()
944        }
945
946        pub fn from_builder(builder: GraphBuilder) -> Self {
947            Self { builder }
948        }
949
950        pub fn from_graph(graph: Graph) -> Self {
951            let mut builder = GraphBuilder::new();
952            for node in graph.nodes {
953                builder.add_node(node);
954            }
955            for edge in graph.edges {
956                builder.add_edge(edge);
957            }
958            Self { builder }
959        }
960
961        pub fn add_raw_node(&mut self, node: Node) -> NodeId {
962            self.builder.add_node(node)
963        }
964
965        pub fn add_raw_edge(&mut self, edge: Edge) -> PutOutcome {
966            self.builder.add_edge(edge)
967        }
968
969        pub fn add_node<T>(&mut self, node: &T) -> Result<NodeId>
970        where
971            T: TypedNode,
972            T::Context: Default,
973        {
974            node.validate()
975                .map_err(|err| validation_error(T::LABEL, err))?;
976            self.add_validated_node(node)
977        }
978
979        pub fn add_node_with<T>(&mut self, node: &T, ctx: &T::Context) -> Result<NodeId>
980        where
981            T: TypedNode,
982        {
983            node.validate_with(ctx)
984                .map_err(|err| validation_error(T::LABEL, err))?;
985            self.add_validated_node(node)
986        }
987
988        pub fn add_edge<T>(&mut self, edge: &T) -> Result<PutOutcome>
989        where
990            T: TypedEdge,
991            T::Context: Default,
992        {
993            edge.validate()
994                .map_err(|err| validation_error(T::LABEL, err))?;
995            self.add_validated_edge(edge)
996        }
997
998        pub fn add_edge_with<T>(&mut self, edge: &T, ctx: &T::Context) -> Result<PutOutcome>
999        where
1000            T: TypedEdge,
1001        {
1002            edge.validate_with(ctx)
1003                .map_err(|err| validation_error(T::LABEL, err))?;
1004            self.add_validated_edge(edge)
1005        }
1006
1007        #[cfg(feature = "typed-zod-rs")]
1008        pub fn add_node_from_json<T, S>(
1009            &mut self,
1010            schema: &S,
1011            value: &serde_json::Value,
1012        ) -> Result<NodeId>
1013        where
1014            T: TypedNode + DeserializeOwned,
1015            T::Context: Default,
1016            S: zod_rs::Schema<serde_json::Value>,
1017        {
1018            let node = parse_typed_json::<T, S>(schema, value)?;
1019            self.add_validated_node(&node)
1020        }
1021
1022        #[cfg(feature = "typed-zod-rs")]
1023        pub fn add_node_from_json_with<T, S>(
1024            &mut self,
1025            schema: &S,
1026            value: &serde_json::Value,
1027            ctx: &T::Context,
1028        ) -> Result<NodeId>
1029        where
1030            T: TypedNode + DeserializeOwned,
1031            S: zod_rs::Schema<serde_json::Value>,
1032        {
1033            let node = parse_typed_json_with::<T, S>(schema, value, ctx)?;
1034            self.add_validated_node(&node)
1035        }
1036
1037        #[cfg(feature = "typed-zod-rs")]
1038        pub fn add_edge_from_json<T, S>(
1039            &mut self,
1040            schema: &S,
1041            value: &serde_json::Value,
1042        ) -> Result<PutOutcome>
1043        where
1044            T: TypedEdge + DeserializeOwned,
1045            T::Context: Default,
1046            S: zod_rs::Schema<serde_json::Value>,
1047        {
1048            let edge = parse_typed_json::<T, S>(schema, value)?;
1049            self.add_validated_edge(&edge)
1050        }
1051
1052        #[cfg(feature = "typed-zod-rs")]
1053        pub fn add_edge_from_json_with<T, S>(
1054            &mut self,
1055            schema: &S,
1056            value: &serde_json::Value,
1057            ctx: &T::Context,
1058        ) -> Result<PutOutcome>
1059        where
1060            T: TypedEdge + DeserializeOwned,
1061            S: zod_rs::Schema<serde_json::Value>,
1062        {
1063            let edge = parse_typed_json_with::<T, S>(schema, value, ctx)?;
1064            self.add_validated_edge(&edge)
1065        }
1066
1067        #[must_use = "discarding this means the typed graph was not built"]
1068        pub fn build(self) -> Graph {
1069            self.builder.build()
1070        }
1071
1072        pub fn into_builder(self) -> GraphBuilder {
1073            self.builder
1074        }
1075
1076        fn add_validated_node<T>(&mut self, node: &T) -> Result<NodeId>
1077        where
1078            T: TypedNode,
1079        {
1080            let node_id = node.node_id();
1081            let mut props = node.node_props()?;
1082            props
1083                .entry("id".to_string())
1084                .or_insert_with(|| Value::from(node_id.as_str()));
1085            let graph_node = Node::new(T::LABEL, node_id, props);
1086            Ok(self.builder.add_node(graph_node))
1087        }
1088
1089        fn add_validated_edge<T>(&mut self, edge: &T) -> Result<PutOutcome>
1090        where
1091            T: TypedEdge,
1092        {
1093            let mut graph_edge = Edge::new(
1094                T::LABEL,
1095                edge.source_node_id(),
1096                edge.target_node_id(),
1097                edge.edge_props()?,
1098            );
1099            graph_edge.id = edge.edge_id();
1100            Ok(self.builder.add_edge(graph_edge))
1101        }
1102    }
1103
1104    pub fn props_from_serialize<T>(value: &T) -> Result<Props>
1105    where
1106        T: Serialize + ?Sized,
1107    {
1108        let serialized = serde_json::to_value(value)
1109            .map_err(|err| GrustError::Serialization(format!("typed props error: {err}")))?;
1110        let serde_json::Value::Object(fields) = serialized else {
1111            return Err(GrustError::Schema(
1112                "typed graph values must serialize as JSON objects".to_string(),
1113            ));
1114        };
1115
1116        Ok(fields
1117            .into_iter()
1118            .map(|(key, value)| (key, Value::from(value)))
1119            .collect())
1120    }
1121
1122    fn props_to_plain_json(props: &Props) -> serde_json::Value {
1123        serde_json::Value::Object(
1124            props
1125                .iter()
1126                .map(|(key, value)| (key.clone(), value.to_json()))
1127                .collect(),
1128        )
1129    }
1130
1131    #[cfg(feature = "typed-zod-rs")]
1132    pub fn parse_typed_json<T, S>(schema: &S, value: &serde_json::Value) -> Result<T>
1133    where
1134        T: DeserializeOwned + garde::Validate,
1135        T::Context: Default,
1136        S: zod_rs::Schema<serde_json::Value>,
1137    {
1138        let ctx = T::Context::default();
1139        parse_typed_json_with(schema, value, &ctx)
1140    }
1141
1142    #[cfg(feature = "typed-zod-rs")]
1143    pub fn parse_typed_json_with<T, S>(
1144        schema: &S,
1145        value: &serde_json::Value,
1146        ctx: &T::Context,
1147    ) -> Result<T>
1148    where
1149        T: DeserializeOwned + garde::Validate,
1150        S: zod_rs::Schema<serde_json::Value>,
1151    {
1152        schema
1153            .safe_parse(value)
1154            .map_err(|err| GrustError::Schema(format!("zod-rs validation failed: {err}")))?;
1155        let typed: T = serde_json::from_value(value.clone())
1156            .map_err(|err| GrustError::Serialization(format!("typed JSON decode error: {err}")))?;
1157        typed
1158            .validate_with(ctx)
1159            .map_err(|err| GrustError::Schema(format!("typed validation failed: {err}")))?;
1160        Ok(typed)
1161    }
1162
1163    fn validation_error(label: &str, err: garde::Report) -> GrustError {
1164        GrustError::Schema(format!("{label} validation failed: {err}"))
1165    }
1166}
1167
1168#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1169pub struct Node {
1170    pub id: NodeId,
1171    pub label: Label,
1172    pub props: Props,
1173}
1174
1175impl Node {
1176    pub fn new(label: impl Into<Label>, id: impl Into<NodeId>, props: impl Into<Props>) -> Self {
1177        let id = id.into();
1178        let mut props = props.into();
1179        props
1180            .entry("id".to_string())
1181            .or_insert_with(|| Value::from(id.as_str()));
1182        Self {
1183            id,
1184            label: label.into(),
1185            props,
1186        }
1187    }
1188}
1189
1190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1191pub struct Edge {
1192    pub id: Option<EdgeId>,
1193    pub from: NodeId,
1194    pub to: NodeId,
1195    pub label: Label,
1196    pub props: Props,
1197}
1198
1199impl Edge {
1200    pub fn new(
1201        label: impl Into<Label>,
1202        from: impl Into<NodeId>,
1203        to: impl Into<NodeId>,
1204        props: impl Into<Props>,
1205    ) -> Self {
1206        Self {
1207            id: None,
1208            from: from.into(),
1209            to: to.into(),
1210            label: label.into(),
1211            props: props.into(),
1212        }
1213    }
1214
1215    pub fn with_id(mut self, id: impl Into<EdgeId>) -> Self {
1216        self.id = Some(id.into());
1217        self
1218    }
1219}
1220
1221/// Normalizes an edge label into an uppercase backend relationship type.
1222///
1223/// Non-ASCII-alphanumeric characters become underscores. Empty labels fall
1224/// back to `RELATED_TO`.
1225pub fn relationship_type(value: &str) -> String {
1226    let relationship = value
1227        .chars()
1228        .map(|ch| {
1229            if ch.is_ascii_alphanumeric() {
1230                ch.to_ascii_uppercase()
1231            } else {
1232                '_'
1233            }
1234        })
1235        .collect::<String>();
1236    if relationship.is_empty() {
1237        "RELATED_TO".to_string()
1238    } else {
1239        relationship
1240    }
1241}
1242
1243/// Normalizes arbitrary schema text into a lower_snake_case backend identifier.
1244///
1245/// This helper is for SQL-like backends that require identifiers to start with a
1246/// non-digit ASCII alphanumeric or underscore character after normalization.
1247pub fn schema_identifier(value: &str) -> Result<String> {
1248    let identifier = value
1249        .chars()
1250        .map(|ch| {
1251            if ch.is_ascii_alphanumeric() {
1252                ch.to_ascii_lowercase()
1253            } else {
1254                '_'
1255            }
1256        })
1257        .collect::<String>();
1258    if identifier.is_empty()
1259        || identifier
1260            .chars()
1261            .next()
1262            .is_some_and(|ch| ch.is_ascii_digit())
1263    {
1264        return Err(GrustError::Schema(format!(
1265            "invalid schema identifier '{value}'"
1266        )));
1267    }
1268    Ok(identifier)
1269}
1270
1271/// Returns the stable key used by tabular/export backends for an edge.
1272///
1273/// Explicit edge IDs win. Otherwise the structural key joins `from`, `label`,
1274/// and `to` with U+001F (Unit Separator). Callers that accept arbitrary IDs or
1275/// labels should reject U+001F before relying on reversibility.
1276pub fn edge_key(edge: &Edge) -> String {
1277    edge.id
1278        .as_ref()
1279        .map(EdgeId::as_str)
1280        .map(ToString::to_string)
1281        .unwrap_or_else(|| {
1282            let from = edge.from.as_str();
1283            let label = edge.label.as_str();
1284            let to = edge.to.as_str();
1285            let mut key = String::with_capacity(from.len() + label.len() + to.len() + 2);
1286            key.push_str(from);
1287            key.push('\u{1f}');
1288            key.push_str(label);
1289            key.push('\u{1f}');
1290            key.push_str(to);
1291            key
1292        })
1293}
1294
1295#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1296pub struct Graph {
1297    pub nodes: Vec<Node>,
1298    pub edges: Vec<Edge>,
1299}
1300
1301impl Graph {
1302    pub fn new(nodes: Vec<Node>, edges: Vec<Edge>) -> Self {
1303        Self { nodes, edges }
1304    }
1305
1306    pub fn from_yaml(yaml: &str) -> Result<Self> {
1307        yaml::graph_from_yaml(yaml)
1308    }
1309
1310    pub fn to_yaml(&self) -> Result<String> {
1311        yaml::graph_to_yaml(self)
1312    }
1313
1314    pub fn from_json(json: &str) -> Result<Self> {
1315        json::graph_from_json(json)
1316    }
1317
1318    pub fn to_json(&self) -> Result<String> {
1319        json::graph_to_json(self)
1320    }
1321
1322    pub fn from_xml(xml: &str) -> Result<Self> {
1323        xml::graph_from_xml(xml)
1324    }
1325
1326    pub fn to_xml(&self) -> Result<String> {
1327        xml::graph_to_xml(self)
1328    }
1329
1330    pub fn builder() -> GraphBuilder {
1331        GraphBuilder::new()
1332    }
1333}
1334
1335/// Dense, reusable indexes over a [`Graph`].
1336///
1337/// This is backend-neutral: it validates edge endpoints, maps node ids to
1338/// stable vertex indexes, and stores edge adjacency by both node id and vertex
1339/// index. Higher-level crates can use it for local analytics or query planning
1340/// without rebuilding the same maps.
1341#[derive(Clone, Debug, PartialEq)]
1342pub struct GraphIndex {
1343    vertex_by_id: HashMap<NodeId, usize>,
1344    outgoing_by_vertex: Vec<Vec<usize>>,
1345    incoming_by_vertex: Vec<Vec<usize>>,
1346    edge_endpoints: Vec<(usize, usize)>,
1347}
1348
1349impl GraphIndex {
1350    pub fn new(graph: &Graph) -> Result<Self> {
1351        let mut vertex_by_id = HashMap::with_capacity(graph.nodes.len());
1352        for (index, vertex) in graph.nodes.iter().enumerate() {
1353            if vertex_by_id.insert(vertex.id.clone(), index).is_some() {
1354                return Err(GrustError::Schema(format!(
1355                    "duplicate vertex id '{}'",
1356                    vertex.id.as_str()
1357                )));
1358            }
1359        }
1360
1361        let mut outgoing_by_vertex = vec![Vec::<usize>::new(); graph.nodes.len()];
1362        let mut incoming_by_vertex = vec![Vec::<usize>::new(); graph.nodes.len()];
1363        let mut edge_endpoints = Vec::<(usize, usize)>::with_capacity(graph.edges.len());
1364
1365        for (edge_index, edge) in graph.edges.iter().enumerate() {
1366            let Some(&from_index) = vertex_by_id.get(&edge.from) else {
1367                return Err(GrustError::Schema(format!(
1368                    "edge source '{}' is not present in vertices",
1369                    edge.from.as_str()
1370                )));
1371            };
1372            let Some(&to_index) = vertex_by_id.get(&edge.to) else {
1373                return Err(GrustError::Schema(format!(
1374                    "edge destination '{}' is not present in vertices",
1375                    edge.to.as_str()
1376                )));
1377            };
1378
1379            outgoing_by_vertex[from_index].push(edge_index);
1380            incoming_by_vertex[to_index].push(edge_index);
1381            edge_endpoints.push((from_index, to_index));
1382        }
1383
1384        Ok(Self {
1385            vertex_by_id,
1386            outgoing_by_vertex,
1387            incoming_by_vertex,
1388            edge_endpoints,
1389        })
1390    }
1391
1392    pub fn vertex_index(&self, id: &NodeId) -> Option<usize> {
1393        self.vertex_by_id.get(id).copied()
1394    }
1395
1396    pub fn require_vertex_index(&self, id: &NodeId) -> Result<usize> {
1397        self.vertex_index(id)
1398            .ok_or_else(|| GrustError::Schema(format!("vertex '{}' is not present", id.as_str())))
1399    }
1400
1401    pub fn outgoing_edges(&self, id: &NodeId) -> &[usize] {
1402        self.vertex_index(id)
1403            .map(|index| self.outgoing_by_vertex(index))
1404            .unwrap_or(&[])
1405    }
1406
1407    pub fn incoming_edges(&self, id: &NodeId) -> &[usize] {
1408        self.vertex_index(id)
1409            .map(|index| self.incoming_by_vertex(index))
1410            .unwrap_or(&[])
1411    }
1412
1413    pub fn outgoing_by_vertex(&self, index: usize) -> &[usize] {
1414        self.outgoing_by_vertex
1415            .get(index)
1416            .map(Vec::as_slice)
1417            .unwrap_or(&[])
1418    }
1419
1420    pub fn incoming_by_vertex(&self, index: usize) -> &[usize] {
1421        self.incoming_by_vertex
1422            .get(index)
1423            .map(Vec::as_slice)
1424            .unwrap_or(&[])
1425    }
1426
1427    pub fn edge_endpoints(&self, edge_index: usize) -> (usize, usize) {
1428        self.edge_endpoints[edge_index]
1429    }
1430
1431    pub fn edge_endpoints_slice(&self) -> &[(usize, usize)] {
1432        &self.edge_endpoints
1433    }
1434
1435    pub fn out_degree(&self, index: usize) -> usize {
1436        self.outgoing_by_vertex[index].len()
1437    }
1438
1439    pub fn in_degree(&self, index: usize) -> usize {
1440        self.incoming_by_vertex[index].len()
1441    }
1442
1443    pub fn degree(&self, index: usize) -> usize {
1444        self.in_degree(index) + self.out_degree(index)
1445    }
1446}
1447
1448mod graph_doc {
1449    use std::collections::{BTreeMap, BTreeSet};
1450
1451    use serde::{Deserialize, Serialize};
1452
1453    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
1454
1455    #[derive(Debug, Serialize, Deserialize)]
1456    pub(super) struct GraphDoc {
1457        #[serde(default)]
1458        pub(super) nodes: Vec<NodeDoc>,
1459        #[serde(default)]
1460        pub(super) edges: Vec<EdgeDoc>,
1461    }
1462
1463    #[derive(Debug, Serialize, Deserialize)]
1464    pub(super) struct NodeDoc {
1465        pub(super) id: NodeId,
1466        pub(super) label: Label,
1467        #[serde(default, deserialize_with = "deserialize_props")]
1468        pub(super) props: Props,
1469    }
1470
1471    #[derive(Debug, Serialize, Deserialize)]
1472    pub(super) struct NodeDocOut {
1473        pub(super) id: NodeId,
1474        pub(super) label: Label,
1475        #[serde(default)]
1476        pub(super) props: Props,
1477    }
1478
1479    #[derive(Debug, Serialize, Deserialize)]
1480    pub(super) struct EdgeDoc {
1481        #[serde(default)]
1482        pub(super) id: Option<EdgeId>,
1483        pub(super) label: Label,
1484        pub(super) from: NodeId,
1485        pub(super) to: NodeId,
1486        #[serde(default, deserialize_with = "deserialize_props")]
1487        pub(super) props: Props,
1488    }
1489
1490    #[derive(Debug, Serialize, Deserialize)]
1491    pub(super) struct EdgeDocOut {
1492        #[serde(default)]
1493        pub(super) id: Option<EdgeId>,
1494        pub(super) label: Label,
1495        pub(super) from: NodeId,
1496        pub(super) to: NodeId,
1497        #[serde(default)]
1498        pub(super) props: Props,
1499    }
1500
1501    pub(super) fn graph_from_doc(doc: GraphDoc) -> super::Result<Graph> {
1502        let mut ids = BTreeSet::new();
1503        for node in &doc.nodes {
1504            if !ids.insert(node.id.clone()) {
1505                return Err(GrustError::Schema(format!(
1506                    "duplicate node id '{}'",
1507                    node.id
1508                )));
1509            }
1510        }
1511
1512        let mut edges = Vec::with_capacity(doc.edges.len());
1513        for edge in doc.edges {
1514            if !ids.contains(&edge.from) {
1515                return Err(GrustError::Schema(format!(
1516                    "edge '{}' references unknown from node '{}'",
1517                    edge.label, edge.from
1518                )));
1519            }
1520            if !ids.contains(&edge.to) {
1521                return Err(GrustError::Schema(format!(
1522                    "edge '{}' references unknown to node '{}'",
1523                    edge.label, edge.to
1524                )));
1525            }
1526
1527            let mut graph_edge = Edge::new(edge.label, edge.from, edge.to, edge.props);
1528            graph_edge.id = edge.id;
1529            edges.push(graph_edge);
1530        }
1531
1532        let nodes = doc
1533            .nodes
1534            .into_iter()
1535            .map(|node| Node::new(node.label, node.id, node.props))
1536            .collect();
1537
1538        Ok(Graph::new(nodes, edges))
1539    }
1540
1541    pub(super) fn graph_to_doc(graph: &Graph) -> GraphDocOut {
1542        GraphDocOut {
1543            nodes: graph
1544                .nodes
1545                .iter()
1546                .map(|node| NodeDocOut {
1547                    id: node.id.clone(),
1548                    label: node.label.clone(),
1549                    props: without_generated_id(&node.props, &node.id),
1550                })
1551                .collect(),
1552            edges: graph
1553                .edges
1554                .iter()
1555                .map(|edge| EdgeDocOut {
1556                    id: edge.id.clone(),
1557                    label: edge.label.clone(),
1558                    from: edge.from.clone(),
1559                    to: edge.to.clone(),
1560                    props: edge.props.clone(),
1561                })
1562                .collect(),
1563        }
1564    }
1565
1566    fn without_generated_id(props: &Props, id: &NodeId) -> Props {
1567        let mut props = props.clone();
1568        if props.get("id") == Some(&Value::from(id.as_str())) {
1569            props.remove("id");
1570        }
1571        props
1572    }
1573
1574    fn deserialize_props<'de, D>(deserializer: D) -> std::result::Result<Props, D::Error>
1575    where
1576        D: serde::Deserializer<'de>,
1577    {
1578        let raw = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
1579        raw.into_iter()
1580            .map(|(key, value)| {
1581                value_from_json(value)
1582                    .map(|value| (key, value))
1583                    .map_err(serde::de::Error::custom)
1584            })
1585            .collect()
1586    }
1587
1588    fn value_from_json(value: serde_json::Value) -> std::result::Result<Value, String> {
1589        if let serde_json::Value::Object(mapping) = &value
1590            && mapping.contains_key("type")
1591            && mapping.contains_key("value")
1592        {
1593            return serde_json::from_value(value)
1594                .map_err(|err| format!("invalid tagged Grust value: {err}"));
1595        }
1596
1597        Ok(Value::from_json(value))
1598    }
1599
1600    #[derive(Debug, Serialize, Deserialize)]
1601    pub(super) struct GraphDocOut {
1602        pub(super) nodes: Vec<NodeDocOut>,
1603        pub(super) edges: Vec<EdgeDocOut>,
1604    }
1605}
1606
1607mod yaml {
1608    use crate::{Graph, GrustError};
1609
1610    pub(super) fn graph_from_yaml(yaml: &str) -> super::Result<Graph> {
1611        let doc: super::graph_doc::GraphDoc = serde_yaml::from_str(yaml)
1612            .map_err(|err| GrustError::Serialization(format!("YAML parse error: {err}")))?;
1613        super::graph_doc::graph_from_doc(doc)
1614    }
1615
1616    pub(super) fn graph_to_yaml(graph: &Graph) -> super::Result<String> {
1617        serde_yaml::to_string(&super::graph_doc::graph_to_doc(graph))
1618            .map_err(|err| GrustError::Serialization(format!("YAML serialization error: {err}")))
1619    }
1620}
1621
1622mod json {
1623    use crate::{Graph, GrustError};
1624
1625    pub(super) fn graph_from_json(json: &str) -> super::Result<Graph> {
1626        let doc: super::graph_doc::GraphDoc = serde_json::from_str(json)
1627            .map_err(|err| GrustError::Serialization(format!("JSON parse error: {err}")))?;
1628        super::graph_doc::graph_from_doc(doc)
1629    }
1630
1631    pub(super) fn graph_to_json(graph: &Graph) -> super::Result<String> {
1632        serde_json::to_string_pretty(&super::graph_doc::graph_to_doc(graph))
1633            .map_err(|err| GrustError::Serialization(format!("JSON serialization error: {err}")))
1634    }
1635}
1636
1637mod xml {
1638    use serde::{Deserialize, Serialize};
1639
1640    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
1641
1642    #[derive(Debug, Serialize, Deserialize)]
1643    #[serde(rename = "graph")]
1644    struct GraphXml {
1645        #[serde(default)]
1646        nodes: NodesXml,
1647        #[serde(default)]
1648        edges: EdgesXml,
1649    }
1650
1651    #[derive(Debug, Default, Serialize, Deserialize)]
1652    struct NodesXml {
1653        #[serde(rename = "node", default)]
1654        items: Vec<NodeXml>,
1655    }
1656
1657    #[derive(Debug, Default, Serialize, Deserialize)]
1658    struct EdgesXml {
1659        #[serde(rename = "edge", default)]
1660        items: Vec<EdgeXml>,
1661    }
1662
1663    #[derive(Debug, Serialize, Deserialize)]
1664    struct NodeXml {
1665        id: NodeId,
1666        label: Label,
1667        #[serde(default)]
1668        props: PropsXml,
1669    }
1670
1671    #[derive(Debug, Serialize, Deserialize)]
1672    struct EdgeXml {
1673        #[serde(default, skip_serializing_if = "Option::is_none")]
1674        id: Option<EdgeId>,
1675        label: Label,
1676        from: NodeId,
1677        to: NodeId,
1678        #[serde(default)]
1679        props: PropsXml,
1680    }
1681
1682    #[derive(Debug, Default, Serialize, Deserialize)]
1683    struct PropsXml {
1684        #[serde(rename = "prop", default)]
1685        items: Vec<PropXml>,
1686    }
1687
1688    #[derive(Debug, Serialize, Deserialize)]
1689    struct PropXml {
1690        key: String,
1691        value: Value,
1692    }
1693
1694    pub(super) fn graph_from_xml(xml: &str) -> super::Result<Graph> {
1695        let doc: GraphXml = quick_xml::de::from_str(xml)
1696            .map_err(|err| GrustError::Serialization(format!("XML parse error: {err}")))?;
1697        super::graph_doc::graph_from_doc(doc.into())
1698    }
1699
1700    pub(super) fn graph_to_xml(graph: &Graph) -> super::Result<String> {
1701        quick_xml::se::to_string(&GraphXml::from(graph))
1702            .map_err(|err| GrustError::Serialization(format!("XML serialization error: {err}")))
1703    }
1704
1705    impl From<GraphXml> for super::graph_doc::GraphDoc {
1706        fn from(value: GraphXml) -> Self {
1707            Self {
1708                nodes: value.nodes.items.into_iter().map(Into::into).collect(),
1709                edges: value.edges.items.into_iter().map(Into::into).collect(),
1710            }
1711        }
1712    }
1713
1714    impl From<NodeXml> for super::graph_doc::NodeDoc {
1715        fn from(value: NodeXml) -> Self {
1716            Self {
1717                id: value.id,
1718                label: value.label,
1719                props: value.props.into(),
1720            }
1721        }
1722    }
1723
1724    impl From<EdgeXml> for super::graph_doc::EdgeDoc {
1725        fn from(value: EdgeXml) -> Self {
1726            Self {
1727                id: value.id,
1728                label: value.label,
1729                from: value.from,
1730                to: value.to,
1731                props: value.props.into(),
1732            }
1733        }
1734    }
1735
1736    impl From<PropsXml> for Props {
1737        fn from(value: PropsXml) -> Self {
1738            value
1739                .items
1740                .into_iter()
1741                .map(|prop| (prop.key, prop.value))
1742                .collect()
1743        }
1744    }
1745
1746    impl From<&Graph> for GraphXml {
1747        fn from(graph: &Graph) -> Self {
1748            Self {
1749                nodes: NodesXml {
1750                    items: graph.nodes.iter().map(NodeXml::from).collect(),
1751                },
1752                edges: EdgesXml {
1753                    items: graph.edges.iter().map(EdgeXml::from).collect(),
1754                },
1755            }
1756        }
1757    }
1758
1759    impl From<&Node> for NodeXml {
1760        fn from(node: &Node) -> Self {
1761            let props = super::graph_doc::graph_to_doc(&Graph::new(vec![node.clone()], Vec::new()))
1762                .nodes
1763                .into_iter()
1764                .next()
1765                .expect("node exists")
1766                .props;
1767            Self {
1768                id: node.id.clone(),
1769                label: node.label.clone(),
1770                props: props.into(),
1771            }
1772        }
1773    }
1774
1775    impl From<&Edge> for EdgeXml {
1776        fn from(edge: &Edge) -> Self {
1777            Self {
1778                id: edge.id.clone(),
1779                label: edge.label.clone(),
1780                from: edge.from.clone(),
1781                to: edge.to.clone(),
1782                props: edge.props.clone().into(),
1783            }
1784        }
1785    }
1786
1787    impl From<Props> for PropsXml {
1788        fn from(value: Props) -> Self {
1789            Self {
1790                items: value
1791                    .into_iter()
1792                    .map(|(key, value)| PropXml { key, value })
1793                    .collect(),
1794            }
1795        }
1796    }
1797}
1798
1799#[derive(Clone, Debug, Default, Eq, PartialEq)]
1800pub enum EdgePolicy {
1801    AllowDuplicates,
1802    #[default]
1803    DedupeByFromLabelTo,
1804}
1805
1806#[derive(Clone, Debug, Default)]
1807pub struct GraphBuilder {
1808    nodes: BTreeMap<NodeId, Node>,
1809    edges: Vec<Edge>,
1810    edge_keys: BTreeSet<(NodeId, Label, NodeId)>,
1811    edge_policy: EdgePolicy,
1812}
1813
1814impl GraphBuilder {
1815    pub fn new() -> Self {
1816        Self::default()
1817    }
1818
1819    pub fn edge_policy(mut self, edge_policy: EdgePolicy) -> Self {
1820        self.edge_policy = edge_policy;
1821        self
1822    }
1823
1824    pub fn node<'a>(
1825        &'a mut self,
1826        label: impl Into<Label>,
1827        id: impl Into<NodeId>,
1828    ) -> NodeBuilder<'a> {
1829        NodeBuilder {
1830            builder: self,
1831            label: label.into(),
1832            id: id.into(),
1833            props: Props::new(),
1834        }
1835    }
1836
1837    pub fn edge<'a>(
1838        &'a mut self,
1839        label: impl Into<Label>,
1840        from: impl Into<NodeId>,
1841        to: impl Into<NodeId>,
1842    ) -> EdgeBuilder<'a> {
1843        EdgeBuilder {
1844            builder: self,
1845            id: None,
1846            label: label.into(),
1847            from: from.into(),
1848            to: to.into(),
1849            props: Props::new(),
1850        }
1851    }
1852
1853    /// Adds a node, merging with any existing node that has the same id.
1854    ///
1855    /// If a node with the same id and the same label already exists, the new
1856    /// props are merged in (new values win). If a node with the same id but a
1857    /// different label exists, the new node replaces it entirely (last write
1858    /// wins, matching `GraphStore::put_node` overwrite semantics).
1859    pub fn add_node(&mut self, node: Node) -> NodeId {
1860        let id = node.id.clone();
1861        self.nodes
1862            .entry(id.clone())
1863            .and_modify(|existing| {
1864                if existing.label == node.label {
1865                    existing.props.extend(node.props.clone());
1866                } else {
1867                    *existing = node.clone();
1868                }
1869            })
1870            .or_insert(node);
1871        id
1872    }
1873
1874    /// Adds an edge, reporting whether it was stored or dropped by the
1875    /// builder's [`EdgePolicy`].
1876    pub fn add_edge(&mut self, edge: Edge) -> PutOutcome {
1877        match self.edge_policy {
1878            EdgePolicy::AllowDuplicates => {
1879                self.edges.push(edge);
1880                PutOutcome::Inserted
1881            }
1882            EdgePolicy::DedupeByFromLabelTo => {
1883                let key = (edge.from.clone(), edge.label.clone(), edge.to.clone());
1884                if self.edge_keys.insert(key) {
1885                    self.edges.push(edge);
1886                    PutOutcome::Inserted
1887                } else {
1888                    PutOutcome::Deduped
1889                }
1890            }
1891        }
1892    }
1893
1894    #[must_use = "discarding this means the graph was not built"]
1895    pub fn build(self) -> Graph {
1896        Graph {
1897            nodes: self.nodes.into_values().collect(),
1898            edges: self.edges,
1899        }
1900    }
1901}
1902
1903pub struct NodeBuilder<'a> {
1904    builder: &'a mut GraphBuilder,
1905    label: Label,
1906    id: NodeId,
1907    props: Props,
1908}
1909
1910impl<'a> NodeBuilder<'a> {
1911    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1912        self.props.insert(key.into(), value.into());
1913        self
1914    }
1915
1916    pub fn props(mut self, props: Props) -> Self {
1917        self.props.extend(props);
1918        self
1919    }
1920
1921    #[must_use = "discarding this means the node was not added to the builder"]
1922    pub fn finish(self) -> NodeId {
1923        let node = Node::new(self.label, self.id, self.props);
1924        self.builder.add_node(node)
1925    }
1926}
1927
1928pub struct EdgeBuilder<'a> {
1929    builder: &'a mut GraphBuilder,
1930    id: Option<EdgeId>,
1931    label: Label,
1932    from: NodeId,
1933    to: NodeId,
1934    props: Props,
1935}
1936
1937impl<'a> EdgeBuilder<'a> {
1938    pub fn id(mut self, id: impl Into<EdgeId>) -> Self {
1939        self.id = Some(id.into());
1940        self
1941    }
1942
1943    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1944        self.props.insert(key.into(), value.into());
1945        self
1946    }
1947
1948    pub fn props(mut self, props: Props) -> Self {
1949        self.props.extend(props);
1950        self
1951    }
1952
1953    #[must_use = "discarding this means the edge was not added to the builder"]
1954    pub fn finish(self) -> PutOutcome {
1955        let mut edge = Edge::new(self.label, self.from, self.to, self.props);
1956        edge.id = self.id;
1957        self.builder.add_edge(edge)
1958    }
1959}
1960
1961#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1962pub struct GraphSchema {
1963    pub nodes: Vec<NodeType>,
1964    pub edges: Vec<EdgeType>,
1965    pub constraints: Vec<GraphConstraint>,
1966}
1967
1968impl GraphSchema {
1969    pub fn builder() -> GraphSchemaBuilder {
1970        GraphSchemaBuilder::default()
1971    }
1972
1973    pub fn node_type(&self, label: &Label) -> Option<&NodeType> {
1974        self.nodes
1975            .iter()
1976            .find(|node_type| &node_type.label == label)
1977    }
1978
1979    pub fn edge_type(&self, label: &Label) -> Option<&EdgeType> {
1980        self.edges
1981            .iter()
1982            .find(|edge_type| &edge_type.label == label)
1983    }
1984
1985    pub fn constraints_for_label(&self, label: &Label) -> Vec<&GraphConstraint> {
1986        self.constraints
1987            .iter()
1988            .filter(|constraint| constraint.label() == label)
1989            .collect()
1990    }
1991
1992    pub fn validate_graph(&self, graph: &Graph) -> Result<()> {
1993        for node in &graph.nodes {
1994            self.validate_node(node)?;
1995        }
1996        let labels: BTreeMap<&NodeId, &Label> = graph
1997            .nodes
1998            .iter()
1999            .map(|node| (&node.id, &node.label))
2000            .collect();
2001        for edge in &graph.edges {
2002            self.validate_edge_with(edge, |id| labels.get(id).copied())?;
2003        }
2004        self.validate_edge_uniqueness(graph)?;
2005        self.validate_unique_property_constraints(graph)
2006    }
2007
2008    /// Enforces each edge type's [`EdgeUniqueness`]: at most one edge of the
2009    /// type between a given endpoint pair (unordered for undirected types).
2010    fn validate_edge_uniqueness(&self, graph: &Graph) -> Result<()> {
2011        let mut seen = BTreeSet::new();
2012        for edge in &graph.edges {
2013            let Some(edge_type) = self.edge_type(&edge.label) else {
2014                continue;
2015            };
2016            if edge_type.uniqueness == EdgeUniqueness::None {
2017                continue;
2018            }
2019            let (a, b) = if edge_type.directed || edge.from <= edge.to {
2020                (&edge.from, &edge.to)
2021            } else {
2022                (&edge.to, &edge.from)
2023            };
2024            if !seen.insert((edge.label.clone(), a.clone(), b.clone())) {
2025                return Err(GrustError::Schema(format!(
2026                    "duplicate edge '{}' between '{}' and '{}' violates {:?} uniqueness",
2027                    edge.label.as_str(),
2028                    a.as_str(),
2029                    b.as_str(),
2030                    edge_type.uniqueness
2031                )));
2032            }
2033        }
2034        Ok(())
2035    }
2036
2037    fn validate_unique_property_constraints(&self, graph: &Graph) -> Result<()> {
2038        for constraint in &self.constraints {
2039            match constraint {
2040                GraphConstraint::NodePropertyUnique { label, key } => {
2041                    let mut seen: Vec<(&NodeId, &Value)> = Vec::new();
2042                    for node in graph.nodes.iter().filter(|node| &node.label == label) {
2043                        let Some(value) = node.props.get(key) else {
2044                            continue;
2045                        };
2046                        if let Some((existing_id, _)) =
2047                            seen.iter().find(|(_, existing)| *existing == value)
2048                        {
2049                            return Err(GrustError::Schema(format!(
2050                                "node '{}' with label '{}' duplicates unique constrained property '{}' from node '{}'",
2051                                node.id.as_str(),
2052                                label.as_str(),
2053                                key,
2054                                existing_id.as_str()
2055                            )));
2056                        }
2057                        seen.push((&node.id, value));
2058                    }
2059                }
2060                GraphConstraint::EdgePropertyUnique { label, key } => {
2061                    let mut seen: Vec<(String, &Value)> = Vec::new();
2062                    for edge in graph.edges.iter().filter(|edge| &edge.label == label) {
2063                        let Some(value) = edge.props.get(key) else {
2064                            continue;
2065                        };
2066                        if let Some((existing_key, _)) =
2067                            seen.iter().find(|(_, existing)| *existing == value)
2068                        {
2069                            return Err(GrustError::Schema(format!(
2070                                "edge '{}' duplicates unique constrained property '{}' from edge '{}'",
2071                                edge_key(edge),
2072                                key,
2073                                existing_key
2074                            )));
2075                        }
2076                        seen.push((edge_key(edge), value));
2077                    }
2078                }
2079                GraphConstraint::NodePropertyRequired { .. }
2080                | GraphConstraint::EdgePropertyRequired { .. } => {}
2081            }
2082        }
2083        Ok(())
2084    }
2085
2086    pub fn validate_node(&self, node: &Node) -> Result<()> {
2087        let node_type = self.node_type(&node.label).ok_or_else(|| {
2088            GrustError::Schema(format!("schema has no node type '{}'", node.label.as_str()))
2089        })?;
2090        validate_props(
2091            &node.props,
2092            &node_type.fields,
2093            &format!("node '{}'", node.id.as_str()),
2094        )?;
2095        for constraint in &self.constraints {
2096            if let GraphConstraint::NodePropertyRequired { label, key } = constraint
2097                && label == &node.label
2098                && !node.props.contains_key(key)
2099            {
2100                return Err(GrustError::Schema(format!(
2101                    "node '{}' with label '{}' is missing required constrained property '{}'",
2102                    node.id.as_str(),
2103                    node.label.as_str(),
2104                    key
2105                )));
2106            }
2107        }
2108        Ok(())
2109    }
2110
2111    pub fn validate_edge(&self, edge: &Edge, graph: &Graph) -> Result<()> {
2112        self.validate_edge_with(edge, |id| {
2113            graph
2114                .nodes
2115                .iter()
2116                .find(|node| &node.id == id)
2117                .map(|node| &node.label)
2118        })
2119    }
2120
2121    /// Validates an edge using a label lookup instead of a full `Graph`, so
2122    /// stores can validate against their own node index without cloning.
2123    pub fn validate_edge_with<'a>(
2124        &self,
2125        edge: &Edge,
2126        lookup: impl Fn(&NodeId) -> Option<&'a Label>,
2127    ) -> Result<()> {
2128        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
2129            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
2130        })?;
2131
2132        let from_label = lookup(&edge.from).ok_or_else(|| {
2133            GrustError::Schema(format!(
2134                "edge '{}' references unknown from node '{}'",
2135                edge.label.as_str(),
2136                edge.from.as_str()
2137            ))
2138        })?;
2139        let to_label = lookup(&edge.to).ok_or_else(|| {
2140            GrustError::Schema(format!(
2141                "edge '{}' references unknown to node '{}'",
2142                edge.label.as_str(),
2143                edge.to.as_str()
2144            ))
2145        })?;
2146
2147        let from_matches =
2148            |label: &Label| edge_type.from.is_empty() || edge_type.from.contains(label);
2149        let to_matches = |label: &Label| edge_type.to.is_empty() || edge_type.to.contains(label);
2150        // Undirected edge types accept their endpoint labels in either
2151        // orientation.
2152        let endpoints_ok = (from_matches(from_label) && to_matches(to_label))
2153            || (!edge_type.directed && from_matches(to_label) && to_matches(from_label));
2154        if !endpoints_ok {
2155            if !from_matches(from_label) {
2156                return Err(GrustError::Schema(format!(
2157                    "edge '{}' cannot start from node label '{}'",
2158                    edge.label.as_str(),
2159                    from_label.as_str()
2160                )));
2161            }
2162            return Err(GrustError::Schema(format!(
2163                "edge '{}' cannot end at node label '{}'",
2164                edge.label.as_str(),
2165                to_label.as_str()
2166            )));
2167        }
2168
2169        validate_props(
2170            &edge.props,
2171            &edge_type.fields,
2172            &format!("edge '{}'", edge.label.as_str()),
2173        )?;
2174        self.validate_edge_required_constraints(edge)
2175    }
2176
2177    /// Validates an edge's label and props against the schema without
2178    /// checking endpoint nodes, for stores that persist a single edge and
2179    /// cannot cheaply resolve its endpoints.
2180    pub fn validate_edge_props(&self, edge: &Edge) -> Result<()> {
2181        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
2182            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
2183        })?;
2184        validate_props(
2185            &edge.props,
2186            &edge_type.fields,
2187            &format!("edge '{}'", edge.label.as_str()),
2188        )?;
2189        self.validate_edge_required_constraints(edge)
2190    }
2191
2192    fn validate_edge_required_constraints(&self, edge: &Edge) -> Result<()> {
2193        for constraint in &self.constraints {
2194            if let GraphConstraint::EdgePropertyRequired { label, key } = constraint
2195                && label == &edge.label
2196                && !edge.props.contains_key(key)
2197            {
2198                return Err(GrustError::Schema(format!(
2199                    "edge '{}' from '{}' to '{}' is missing required constrained property '{}'",
2200                    edge.label.as_str(),
2201                    edge.from.as_str(),
2202                    edge.to.as_str(),
2203                    key
2204                )));
2205            }
2206        }
2207        Ok(())
2208    }
2209}
2210
2211#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2212pub struct NodeType {
2213    pub label: Label,
2214    pub fields: Vec<Field>,
2215}
2216
2217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2218pub struct EdgeType {
2219    pub label: Label,
2220    pub from: Vec<Label>,
2221    pub to: Vec<Label>,
2222    pub fields: Vec<Field>,
2223    pub directed: bool,
2224    pub uniqueness: EdgeUniqueness,
2225}
2226
2227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2228pub enum GraphConstraint {
2229    NodePropertyUnique { label: Label, key: String },
2230    NodePropertyRequired { label: Label, key: String },
2231    EdgePropertyUnique { label: Label, key: String },
2232    EdgePropertyRequired { label: Label, key: String },
2233}
2234
2235impl GraphConstraint {
2236    pub fn label(&self) -> &Label {
2237        match self {
2238            Self::NodePropertyUnique { label, .. }
2239            | Self::NodePropertyRequired { label, .. }
2240            | Self::EdgePropertyUnique { label, .. }
2241            | Self::EdgePropertyRequired { label, .. } => label,
2242        }
2243    }
2244
2245    pub fn key(&self) -> &str {
2246        match self {
2247            Self::NodePropertyUnique { key, .. }
2248            | Self::NodePropertyRequired { key, .. }
2249            | Self::EdgePropertyUnique { key, .. }
2250            | Self::EdgePropertyRequired { key, .. } => key,
2251        }
2252    }
2253}
2254
2255#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2256pub enum GraphConstraintCapability {
2257    #[default]
2258    MetadataOnly,
2259    ValidateBeforeWrite,
2260    EnforcedByBackend,
2261}
2262
2263/// Backend-native DDL support for a portable graph constraint.
2264///
2265/// This is intentionally separate from [`GraphConstraintCapability`].
2266/// A backend may validate a constraint before writes without having native DDL,
2267/// or it may create a query index that helps lookups but does not enforce the
2268/// constraint. Callers that need database-enforced guarantees should require
2269/// [`GraphNativeConstraintCapability::NativeConstraint`] and use
2270/// [`GraphStore::apply_native_constraint`] explicitly.
2271#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2272pub enum GraphNativeConstraintCapability {
2273    /// The backend has no native DDL mapping for this constraint.
2274    #[default]
2275    Unsupported,
2276    /// The backend can create a native index that may improve related lookups
2277    /// but does not enforce the constraint.
2278    NativeIndex,
2279    /// The backend can create a native constraint or equivalent database
2280    /// object that enforces the portable constraint.
2281    NativeConstraint,
2282}
2283
2284#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2285pub struct GraphNativeConstraintRequest {
2286    pub constraint: GraphConstraint,
2287    pub if_not_exists: bool,
2288}
2289
2290#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2291pub struct GraphNativeConstraintReport {
2292    pub applied: usize,
2293    pub skipped: usize,
2294}
2295
2296#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2297pub struct Field {
2298    pub name: String,
2299    pub ty: FieldType,
2300    pub required: bool,
2301}
2302
2303#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2304pub enum FieldType {
2305    String,
2306    Int,
2307    Float,
2308    Bool,
2309    DateTime,
2310    StringArray,
2311    IntArray,
2312    FloatArray,
2313    Json,
2314}
2315
2316/// How many edges of one type may exist between a pair of nodes.
2317///
2318/// `validate_graph` enforces `FromTo` and `FromLabelTo` identically — at most
2319/// one edge of the type between a given endpoint pair (unordered when the
2320/// type is undirected). The distinction is a storage-key hint for backends
2321/// that keep all edge labels in one table: `FromLabelTo` keys include the
2322/// label, `FromTo` keys do not.
2323#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2324pub enum EdgeUniqueness {
2325    None,
2326    FromTo,
2327    FromLabelTo,
2328}
2329
2330#[derive(Clone, Debug, Default)]
2331pub struct GraphSchemaBuilder {
2332    nodes: Vec<NodeType>,
2333    edges: Vec<EdgeType>,
2334    constraints: Vec<GraphConstraint>,
2335}
2336
2337impl GraphSchemaBuilder {
2338    pub fn node(mut self, label: impl Into<Label>, fields: impl Into<Vec<Field>>) -> Self {
2339        self.nodes.push(NodeType {
2340            label: label.into(),
2341            fields: fields.into(),
2342        });
2343        self
2344    }
2345
2346    pub fn edge(
2347        mut self,
2348        label: impl Into<Label>,
2349        from: impl Into<Vec<Label>>,
2350        to: impl Into<Vec<Label>>,
2351        fields: impl Into<Vec<Field>>,
2352    ) -> Self {
2353        self.edges.push(EdgeType {
2354            label: label.into(),
2355            from: from.into(),
2356            to: to.into(),
2357            fields: fields.into(),
2358            directed: true,
2359            uniqueness: EdgeUniqueness::FromLabelTo,
2360        });
2361        self
2362    }
2363
2364    pub fn edge_type(mut self, edge_type: EdgeType) -> Self {
2365        self.edges.push(edge_type);
2366        self
2367    }
2368
2369    pub fn constraint(mut self, constraint: GraphConstraint) -> Self {
2370        self.constraints.push(constraint);
2371        self
2372    }
2373
2374    pub fn unique_node_property(self, label: impl Into<Label>, key: impl Into<String>) -> Self {
2375        self.constraint(GraphConstraint::NodePropertyUnique {
2376            label: label.into(),
2377            key: key.into(),
2378        })
2379    }
2380
2381    pub fn required_node_property(self, label: impl Into<Label>, key: impl Into<String>) -> Self {
2382        self.constraint(GraphConstraint::NodePropertyRequired {
2383            label: label.into(),
2384            key: key.into(),
2385        })
2386    }
2387
2388    pub fn unique_edge_property(self, label: impl Into<Label>, key: impl Into<String>) -> Self {
2389        self.constraint(GraphConstraint::EdgePropertyUnique {
2390            label: label.into(),
2391            key: key.into(),
2392        })
2393    }
2394
2395    pub fn required_edge_property(self, label: impl Into<Label>, key: impl Into<String>) -> Self {
2396        self.constraint(GraphConstraint::EdgePropertyRequired {
2397            label: label.into(),
2398            key: key.into(),
2399        })
2400    }
2401
2402    pub fn build(self) -> GraphSchema {
2403        GraphSchema {
2404            nodes: self.nodes,
2405            edges: self.edges,
2406            constraints: self.constraints,
2407        }
2408    }
2409}
2410
2411impl Field {
2412    pub fn required(name: impl Into<String>, ty: FieldType) -> Self {
2413        Self {
2414            name: name.into(),
2415            ty,
2416            required: true,
2417        }
2418    }
2419
2420    pub fn optional(name: impl Into<String>, ty: FieldType) -> Self {
2421        Self {
2422            name: name.into(),
2423            ty,
2424            required: false,
2425        }
2426    }
2427}
2428
2429fn validate_props(props: &Props, fields: &[Field], context: &str) -> Result<()> {
2430    for field in fields {
2431        match props.get(&field.name) {
2432            Some(value) => validate_field_value(value, &field.ty, context, &field.name)?,
2433            None if field.required => {
2434                return Err(GrustError::Schema(format!(
2435                    "{context} missing required field '{}'",
2436                    field.name
2437                )));
2438            }
2439            None => {}
2440        }
2441    }
2442    Ok(())
2443}
2444
2445fn validate_field_value(
2446    value: &Value,
2447    ty: &FieldType,
2448    context: &str,
2449    field_name: &str,
2450) -> Result<()> {
2451    let matches = match (value, ty) {
2452        (Value::String(_), FieldType::String)
2453        | (Value::Int(_), FieldType::Int)
2454        | (Value::Float(_), FieldType::Float)
2455        | (Value::Bool(_), FieldType::Bool)
2456        | (Value::DateTime(_), FieldType::DateTime)
2457        | (Value::StringArray(_), FieldType::StringArray)
2458        | (Value::IntArray(_), FieldType::IntArray)
2459        | (Value::FloatArray(_), FieldType::FloatArray)
2460        | (_, FieldType::Json) => true,
2461        // Plain strings remain valid date-times for backward compatibility,
2462        // but must still parse as RFC 3339.
2463        (Value::String(value), FieldType::DateTime) => is_rfc3339_datetime(value),
2464        _ => false,
2465    };
2466    if matches {
2467        Ok(())
2468    } else {
2469        Err(GrustError::Schema(format!(
2470            "{context} field '{field_name}' expected {ty:?}, got {value:?}"
2471        )))
2472    }
2473}
2474
2475#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2476pub struct Traversal {
2477    pub start: Start,
2478    pub steps: Vec<Step>,
2479    pub limit: Option<u32>,
2480}
2481
2482impl Traversal {
2483    pub fn from_node(id: impl Into<NodeId>) -> Self {
2484        Self {
2485            start: Start::Node(id.into()),
2486            steps: Vec::new(),
2487            limit: None,
2488        }
2489    }
2490
2491    pub fn out(mut self, edge: impl Into<Label>) -> Self {
2492        self.steps.push(Step {
2493            direction: Direction::Out,
2494            edge: Some(edge.into()),
2495            node: None,
2496        });
2497        self
2498    }
2499
2500    pub fn in_(mut self, edge: impl Into<Label>) -> Self {
2501        self.steps.push(Step {
2502            direction: Direction::In,
2503            edge: Some(edge.into()),
2504            node: None,
2505        });
2506        self
2507    }
2508
2509    pub fn both(mut self, edge: impl Into<Label>) -> Self {
2510        self.steps.push(Step {
2511            direction: Direction::Both,
2512            edge: Some(edge.into()),
2513            node: None,
2514        });
2515        self
2516    }
2517
2518    /// Constrains the target node label of the most recent step.
2519    ///
2520    /// # Panics
2521    ///
2522    /// Panics if called before any step has been added with `out`, `in_`, or
2523    /// `both`, since there is no step for the label to apply to.
2524    pub fn to(mut self, node: impl Into<Label>) -> Self {
2525        let step = self
2526            .steps
2527            .last_mut()
2528            .expect("Traversal::to() must follow out(), in_(), or both()");
2529        step.node = Some(node.into());
2530        self
2531    }
2532
2533    pub fn limit(mut self, limit: u32) -> Self {
2534        self.limit = Some(limit);
2535        self
2536    }
2537}
2538
2539#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2540pub enum Start {
2541    Node(NodeId),
2542    NodesByLabel(Label),
2543    NodesByProperty {
2544        label: Label,
2545        key: String,
2546        value: Value,
2547    },
2548}
2549
2550#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2551pub struct Step {
2552    pub direction: Direction,
2553    pub edge: Option<Label>,
2554    pub node: Option<Label>,
2555}
2556
2557#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2558pub enum Direction {
2559    Out,
2560    In,
2561    Both,
2562}
2563
2564#[derive(Clone, Debug, Default, PartialEq)]
2565pub struct EdgeQuery {
2566    pub from: Option<NodeId>,
2567    pub to: Option<NodeId>,
2568    pub label: Option<Label>,
2569}
2570
2571/// Outcome of writing a single node or edge.
2572///
2573/// `Inserted` and `Updated` are precise only for stores that can cheaply
2574/// distinguish create from replace. Remote upsert-oriented backends commonly
2575/// return `Upserted` because doing otherwise would require an extra read or a
2576/// backend-specific write primitive. Portable callers should treat all three
2577/// written outcomes as success and should not rely on `Inserted` or `Updated`
2578/// unless they are intentionally targeting a backend that documents those
2579/// precise outcomes.
2580#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2581pub enum PutOutcome {
2582    /// The element did not exist before and was created.
2583    Inserted,
2584    /// An element with the same identity existed and was overwritten or
2585    /// merged.
2586    Updated,
2587    /// The element was written by an upsert and the backend cannot tell
2588    /// whether it was an insert or an update.
2589    Upserted,
2590    /// The element was dropped by a dedupe policy and nothing was written.
2591    Deduped,
2592}
2593
2594impl PutOutcome {
2595    /// True unless the element was deduped away.
2596    pub fn written(self) -> bool {
2597        !matches!(self, Self::Deduped)
2598    }
2599}
2600
2601/// Counts of nodes and edges written by a bulk load. Each count reflects an
2602/// upsert applied to the backend, not distinct newly created elements.
2603#[derive(Clone, Debug, Default, Eq, PartialEq)]
2604pub struct LoadReport {
2605    pub nodes: usize,
2606    pub edges: usize,
2607}
2608
2609#[async_trait]
2610pub trait GraphStore: Send + Sync {
2611    /// Applies backend schema metadata.
2612    ///
2613    /// `GraphSchema` is a portable declaration of expected node labels, edge
2614    /// labels, fields, endpoint labels, direction, and uniqueness. The default
2615    /// implementation is a no-op for schemaless stores. Backend implementations
2616    /// may use the schema for validation, typed native tables, views, indexes,
2617    /// generated query shapes, or database-native schema definitions.
2618    ///
2619    /// Applying a schema does not imply the same enforcement guarantee on every
2620    /// backend. Callers that need portable preflight validation should call
2621    /// [`GraphSchema::validate_graph`] before writing, or use
2622    /// [`GraphStore::put_typed_graph`], which does that validation before
2623    /// applying the backend schema and writing the graph. Individual backends
2624    /// document whether they also validate each subsequent write at runtime.
2625    async fn apply_schema(&self, _schema: &GraphSchema) -> Result<()> {
2626        Ok(())
2627    }
2628
2629    /// Reports how this backend treats a portable graph constraint.
2630    ///
2631    /// The default is metadata-only: the backend may remember or lower the
2632    /// constraint as schema metadata, but callers should not assume runtime
2633    /// enforcement. Backends that validate through [`GraphSchema`] before each
2634    /// write can report [`GraphConstraintCapability::ValidateBeforeWrite`].
2635    /// Backends with database-native guarantees can report
2636    /// [`GraphConstraintCapability::EnforcedByBackend`].
2637    fn constraint_capability(&self, _constraint: &GraphConstraint) -> GraphConstraintCapability {
2638        GraphConstraintCapability::MetadataOnly
2639    }
2640
2641    /// Reports whether this backend can turn a portable graph constraint into
2642    /// backend-native DDL.
2643    ///
2644    /// This is an explicit opt-in surface. [`GraphStore::apply_schema`] remains
2645    /// the portable schema/validation hook and must not be assumed to create
2646    /// backend-native constraints. The default implementation reports no native
2647    /// support.
2648    fn native_constraint_capability(
2649        &self,
2650        _constraint: &GraphConstraint,
2651    ) -> GraphNativeConstraintCapability {
2652        GraphNativeConstraintCapability::Unsupported
2653    }
2654
2655    /// Applies one backend-native constraint or index request.
2656    ///
2657    /// Backends should implement this only when they can describe the native
2658    /// object they create and its enforcement behavior through
2659    /// [`GraphNativeConstraintCapability`]. The default returns an explicit
2660    /// unsupported error so callers cannot mistake metadata-only schema
2661    /// application for native DDL.
2662    async fn apply_native_constraint(
2663        &self,
2664        request: GraphNativeConstraintRequest,
2665    ) -> Result<GraphNativeConstraintReport> {
2666        match self.native_constraint_capability(&request.constraint) {
2667            GraphNativeConstraintCapability::Unsupported => Err(GrustError::Unsupported(format!(
2668                "backend-native DDL is not supported for graph constraint {:?}",
2669                request.constraint
2670            ))),
2671            GraphNativeConstraintCapability::NativeIndex
2672            | GraphNativeConstraintCapability::NativeConstraint => Err(GrustError::Unsupported(
2673                "backend advertises native graph constraint support but does not implement apply_native_constraint"
2674                    .to_string(),
2675            )),
2676        }
2677    }
2678
2679    /// Writes one node.
2680    ///
2681    /// The returned [`PutOutcome`] reports the most precise result the backend
2682    /// can provide. Remote upsert backends generally return
2683    /// [`PutOutcome::Upserted`] for both inserts and updates.
2684    async fn put_node(&self, node: &Node) -> Result<PutOutcome>;
2685
2686    /// Writes one edge.
2687    ///
2688    /// The returned [`PutOutcome`] reports the most precise result the backend
2689    /// can provide. Remote upsert backends generally return
2690    /// [`PutOutcome::Upserted`] for both inserts and updates.
2691    async fn put_edge(&self, edge: &Edge) -> Result<PutOutcome>;
2692
2693    async fn put_graph(&self, graph: &Graph) -> Result<LoadReport> {
2694        let mut report = LoadReport::default();
2695        for node in &graph.nodes {
2696            if self.put_node(node).await?.written() {
2697                report.nodes += 1;
2698            }
2699        }
2700        for edge in &graph.edges {
2701            if self.put_edge(edge).await?.written() {
2702                report.edges += 1;
2703            }
2704        }
2705        Ok(report)
2706    }
2707
2708    async fn put_typed_graph(&self, schema: &GraphSchema, graph: &Graph) -> Result<LoadReport> {
2709        schema.validate_graph(graph)?;
2710        self.apply_schema(schema).await?;
2711        self.put_graph(graph).await
2712    }
2713
2714    async fn get_node(&self, id: &NodeId) -> Result<Option<Node>>;
2715
2716    /// Reads multiple nodes by ID.
2717    ///
2718    /// The default implementation preserves the input order and calls
2719    /// [`GraphStore::get_node`] once per ID. Backends with a native batch-read
2720    /// path should override this to avoid per-node round trips during traversal
2721    /// and other fan-out reads.
2722    async fn get_nodes(&self, ids: &[NodeId]) -> Result<Vec<Node>> {
2723        let mut nodes = Vec::new();
2724        for id in ids {
2725            if let Some(node) = self.get_node(id).await? {
2726                nodes.push(node);
2727            }
2728        }
2729        Ok(nodes)
2730    }
2731
2732    async fn get_edges(&self, query: EdgeQuery) -> Result<Vec<Edge>>;
2733    async fn traverse(&self, traversal: Traversal) -> Result<Vec<Node>>;
2734}
2735
2736#[async_trait]
2737pub trait GraphAdminStore: GraphStore {
2738    async fn bootstrap(&self) -> Result<()> {
2739        Ok(())
2740    }
2741
2742    async fn clear(&self) -> Result<()>;
2743}
2744
2745/// A single incremental change to a graph, for delta-oriented pipelines.
2746#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2747pub enum GraphMutation {
2748    UpsertNode(Node),
2749    PatchNode {
2750        id: NodeId,
2751        props: Props,
2752    },
2753    PatchMatchingNodes {
2754        label: Option<Label>,
2755        props: Props,
2756        predicates: Vec<GraphPropertyPredicate>,
2757        patch: Props,
2758    },
2759    UpdateMatchingNodeProperty {
2760        label: Option<Label>,
2761        props: Props,
2762        predicates: Vec<GraphPropertyPredicate>,
2763        target_key: String,
2764        source_key: String,
2765        op: GraphNumericOp,
2766        operand: Value,
2767    },
2768    PatchEdge {
2769        from: NodeId,
2770        label: Label,
2771        to: NodeId,
2772        id: Option<EdgeId>,
2773        props: Props,
2774    },
2775    PatchMatchingEdges {
2776        relationship: GraphRelationshipMatch,
2777        patch: Props,
2778    },
2779    UpdateMatchingEdgeProperty {
2780        relationship: GraphRelationshipMatch,
2781        target_key: String,
2782        source_key: String,
2783        op: GraphNumericOp,
2784        operand: Value,
2785    },
2786    RemoveNodeProps {
2787        id: NodeId,
2788        keys: Vec<String>,
2789    },
2790    RemoveMatchingNodeProps {
2791        label: Option<Label>,
2792        props: Props,
2793        predicates: Vec<GraphPropertyPredicate>,
2794        keys: Vec<String>,
2795    },
2796    RemoveEdgeProps {
2797        from: NodeId,
2798        label: Label,
2799        to: NodeId,
2800        id: Option<EdgeId>,
2801        keys: Vec<String>,
2802    },
2803    RemoveMatchingEdgeProps {
2804        relationship: GraphRelationshipMatch,
2805        keys: Vec<String>,
2806    },
2807    DeleteMatchingNodes {
2808        label: Option<Label>,
2809        props: Props,
2810        predicates: Vec<GraphPropertyPredicate>,
2811    },
2812    DeleteNode(NodeId),
2813    UpsertEdge(Edge),
2814    UpsertEdgesFromNodeMatches {
2815        kind: GraphMutationPlanKind,
2816        from: GraphNodeMatch,
2817        to: GraphNodeMatch,
2818        label: Label,
2819        props: Props,
2820        edge_id_policy: GraphRowEdgeIdPolicy,
2821    },
2822    DeleteEdge {
2823        from: NodeId,
2824        label: Label,
2825        to: NodeId,
2826    },
2827    DeleteMatchingEdges {
2828        relationship: GraphRelationshipMatch,
2829    },
2830    DeleteRelationshipRows {
2831        relationship: GraphRelationshipMatch,
2832        delete_edges: bool,
2833        endpoint_nodes: Vec<GraphRelationshipEndpoint>,
2834    },
2835    /// Cross-variable correlated node property update (Unit 10b/W3).
2836    SetMatchingNodeFromNode {
2837        target_label: Option<Label>,
2838        target_props: Props,
2839        target_predicates: Vec<GraphPropertyPredicate>,
2840        target_key: String,
2841        source_label: Option<Label>,
2842        source_props: Props,
2843        source_predicates: Vec<GraphPropertyPredicate>,
2844        source_key: String,
2845        op: Option<GraphNumericOp>,
2846        operand: Value,
2847        correlation: GraphWriteCorrelation,
2848        cardinality: GraphMutationCardinality,
2849    },
2850}
2851
2852#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2853pub enum GraphMutationAtomicity {
2854    OrderedNonAtomic,
2855    Transactional,
2856}
2857
2858#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2859pub enum GraphMutationPlanKind {
2860    Create,
2861    Merge,
2862}
2863
2864#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2865pub enum GraphRowEdgeIdPolicy {
2866    ExplicitOnly,
2867    GenerateForCreate,
2868    GenerateForCreateAndMerge,
2869}
2870
2871#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2872pub enum GraphRelationshipEndpoint {
2873    From,
2874    To,
2875}
2876
2877impl Default for GraphRowEdgeIdPolicy {
2878    fn default() -> Self {
2879        Self::ExplicitOnly
2880    }
2881}
2882
2883#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2884pub enum GraphMutationCardinality {
2885    SingleIdentity,
2886    BoundedMany,
2887    UnboundedMany,
2888}
2889
2890pub fn generated_row_edge_id(from: &NodeId, label: &Label, to: &NodeId, props: &Props) -> EdgeId {
2891    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
2892    const FNV_PRIME: u64 = 0x100000001b3;
2893
2894    fn write_part(hash: &mut u64, value: &str) {
2895        for byte in value.as_bytes() {
2896            *hash ^= u64::from(*byte);
2897            *hash = hash.wrapping_mul(FNV_PRIME);
2898        }
2899        *hash ^= 0xff;
2900        *hash = hash.wrapping_mul(FNV_PRIME);
2901    }
2902
2903    let mut hash = FNV_OFFSET;
2904    write_part(&mut hash, from.as_str());
2905    write_part(&mut hash, label.as_str());
2906    write_part(&mut hash, to.as_str());
2907    for (key, value) in props {
2908        write_part(&mut hash, key);
2909        write_part(&mut hash, &value.to_json().to_string());
2910    }
2911    EdgeId::new(format!("edge-{hash:016x}"))
2912}
2913
2914#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2915pub enum GraphNumericOp {
2916    Add,
2917    Subtract,
2918    Multiply,
2919    Divide,
2920}
2921
2922pub fn evaluate_numeric_update(
2923    current: &Value,
2924    op: GraphNumericOp,
2925    operand: &Value,
2926) -> Result<Value> {
2927    match (current, operand) {
2928        (Value::Int(lhs), Value::Int(rhs)) if op != GraphNumericOp::Divide => {
2929            let value = match op {
2930                GraphNumericOp::Add => lhs.checked_add(*rhs),
2931                GraphNumericOp::Subtract => lhs.checked_sub(*rhs),
2932                GraphNumericOp::Multiply => lhs.checked_mul(*rhs),
2933                GraphNumericOp::Divide => unreachable!("division handled as floating point"),
2934            }
2935            .ok_or_else(|| GrustError::CypherExecution("numeric expression overflow".into()))?;
2936            Ok(Value::Int(value))
2937        }
2938        (Value::Int(lhs), Value::Int(rhs)) => numeric_float_result(*lhs as f64, op, *rhs as f64),
2939        (Value::Int(lhs), Value::Float(rhs)) => numeric_float_result(*lhs as f64, op, *rhs),
2940        (Value::Float(lhs), Value::Int(rhs)) => numeric_float_result(*lhs, op, *rhs as f64),
2941        (Value::Float(lhs), Value::Float(rhs)) => numeric_float_result(*lhs, op, *rhs),
2942        (Value::Null, _) | (_, Value::Null) => Err(GrustError::CypherExecution(
2943            "numeric expression cannot read null values".into(),
2944        )),
2945        _ => Err(GrustError::CypherExecution(
2946            "numeric expression requires integer or float values".into(),
2947        )),
2948    }
2949}
2950
2951fn numeric_float_result(lhs: f64, op: GraphNumericOp, rhs: f64) -> Result<Value> {
2952    let value = match op {
2953        GraphNumericOp::Add => lhs + rhs,
2954        GraphNumericOp::Subtract => lhs - rhs,
2955        GraphNumericOp::Multiply => lhs * rhs,
2956        GraphNumericOp::Divide => {
2957            if rhs == 0.0 {
2958                return Err(GrustError::CypherExecution(
2959                    "numeric expression division by zero".into(),
2960                ));
2961            }
2962            lhs / rhs
2963        }
2964    };
2965    if value.is_finite() {
2966        Ok(Value::Float(value))
2967    } else {
2968        Err(GrustError::CypherExecution(
2969            "numeric expression produced a non-finite float".into(),
2970        ))
2971    }
2972}
2973
2974#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2975pub enum GraphPredicateOp {
2976    Equal,
2977    NotEqual,
2978    IsNull,
2979    IsNotNull,
2980    StartsWith,
2981    NotStartsWith,
2982    StartsWithAny,
2983    NotStartsWithAny,
2984    EndsWith,
2985    NotEndsWith,
2986    EndsWithAny,
2987    NotEndsWithAny,
2988    Contains,
2989    NotContains,
2990    ContainsAny,
2991    NotContainsAny,
2992    In,
2993    NotIn,
2994    GreaterThan,
2995    GreaterThanOrEqual,
2996    LessThan,
2997    LessThanOrEqual,
2998}
2999
3000#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
3001pub struct GraphPropertyPredicate {
3002    pub key: String,
3003    pub op: GraphPredicateOp,
3004    pub value: Value,
3005}
3006
3007impl GraphPropertyPredicate {
3008    pub fn matches(&self, actual: Option<&Value>) -> bool {
3009        if matches!(self.op, GraphPredicateOp::IsNull) {
3010            return actual.is_none_or(|value| matches!(value, Value::Null));
3011        }
3012        let Some(actual) = actual else {
3013            return false;
3014        };
3015        match self.op {
3016            GraphPredicateOp::Equal => actual == &self.value,
3017            GraphPredicateOp::NotEqual => actual != &self.value,
3018            GraphPredicateOp::IsNull => matches!(actual, Value::Null),
3019            GraphPredicateOp::IsNotNull => !matches!(actual, Value::Null),
3020            GraphPredicateOp::StartsWith => string_predicate_values(actual, &self.value)
3021                .is_some_and(|(actual, needle)| actual.starts_with(needle)),
3022            GraphPredicateOp::NotStartsWith => string_predicate_values(actual, &self.value)
3023                .is_some_and(|(actual, needle)| !actual.starts_with(needle)),
3024            GraphPredicateOp::StartsWithAny => string_list_predicate_values(actual, &self.value)
3025                .is_some_and(|(actual, needles)| {
3026                    needles.iter().any(|needle| actual.starts_with(needle))
3027                }),
3028            GraphPredicateOp::NotStartsWithAny => string_list_predicate_values(actual, &self.value)
3029                .is_some_and(|(actual, needles)| {
3030                    needles.iter().all(|needle| !actual.starts_with(needle))
3031                }),
3032            GraphPredicateOp::EndsWith => string_predicate_values(actual, &self.value)
3033                .is_some_and(|(actual, needle)| actual.ends_with(needle)),
3034            GraphPredicateOp::NotEndsWith => string_predicate_values(actual, &self.value)
3035                .is_some_and(|(actual, needle)| !actual.ends_with(needle)),
3036            GraphPredicateOp::EndsWithAny => string_list_predicate_values(actual, &self.value)
3037                .is_some_and(|(actual, needles)| {
3038                    needles.iter().any(|needle| actual.ends_with(needle))
3039                }),
3040            GraphPredicateOp::NotEndsWithAny => string_list_predicate_values(actual, &self.value)
3041                .is_some_and(|(actual, needles)| {
3042                    needles.iter().all(|needle| !actual.ends_with(needle))
3043                }),
3044            GraphPredicateOp::Contains => string_predicate_values(actual, &self.value)
3045                .is_some_and(|(actual, needle)| actual.contains(needle)),
3046            GraphPredicateOp::NotContains => string_predicate_values(actual, &self.value)
3047                .is_some_and(|(actual, needle)| !actual.contains(needle)),
3048            GraphPredicateOp::ContainsAny => string_list_predicate_values(actual, &self.value)
3049                .is_some_and(|(actual, needles)| {
3050                    needles.iter().any(|needle| actual.contains(needle))
3051                }),
3052            GraphPredicateOp::NotContainsAny => string_list_predicate_values(actual, &self.value)
3053                .is_some_and(|(actual, needles)| {
3054                    needles.iter().all(|needle| !actual.contains(needle))
3055                }),
3056            GraphPredicateOp::In => list_predicate_values(&self.value)
3057                .is_some_and(|values| values.iter().any(|value| actual == value)),
3058            GraphPredicateOp::NotIn => list_predicate_values(&self.value)
3059                .is_some_and(|values| values.iter().all(|value| actual != value)),
3060            GraphPredicateOp::GreaterThan
3061            | GraphPredicateOp::GreaterThanOrEqual
3062            | GraphPredicateOp::LessThan
3063            | GraphPredicateOp::LessThanOrEqual => compare_ordered_values(actual, &self.value)
3064                .is_some_and(|ordering| match self.op {
3065                    GraphPredicateOp::GreaterThan => ordering.is_gt(),
3066                    GraphPredicateOp::GreaterThanOrEqual => ordering.is_gt() || ordering.is_eq(),
3067                    GraphPredicateOp::LessThan => ordering.is_lt(),
3068                    GraphPredicateOp::LessThanOrEqual => ordering.is_lt() || ordering.is_eq(),
3069                    GraphPredicateOp::Equal
3070                    | GraphPredicateOp::NotEqual
3071                    | GraphPredicateOp::IsNull
3072                    | GraphPredicateOp::IsNotNull
3073                    | GraphPredicateOp::StartsWith
3074                    | GraphPredicateOp::NotStartsWith
3075                    | GraphPredicateOp::StartsWithAny
3076                    | GraphPredicateOp::NotStartsWithAny
3077                    | GraphPredicateOp::EndsWith
3078                    | GraphPredicateOp::NotEndsWith
3079                    | GraphPredicateOp::EndsWithAny
3080                    | GraphPredicateOp::NotEndsWithAny
3081                    | GraphPredicateOp::Contains
3082                    | GraphPredicateOp::NotContains
3083                    | GraphPredicateOp::ContainsAny
3084                    | GraphPredicateOp::NotContainsAny
3085                    | GraphPredicateOp::In
3086                    | GraphPredicateOp::NotIn => unreachable!(),
3087                }),
3088        }
3089    }
3090}
3091
3092fn string_predicate_values<'a>(actual: &'a Value, value: &'a Value) -> Option<(&'a str, &'a str)> {
3093    match (actual, value) {
3094        (Value::String(actual), Value::String(needle)) => Some((actual.as_str(), needle.as_str())),
3095        _ => None,
3096    }
3097}
3098
3099fn string_list_predicate_values<'a>(
3100    actual: &'a Value,
3101    value: &'a Value,
3102) -> Option<(&'a str, Vec<&'a str>)> {
3103    match (actual, value) {
3104        (Value::String(actual), Value::StringArray(needles)) => Some((
3105            actual.as_str(),
3106            needles.iter().map(String::as_str).collect(),
3107        )),
3108        _ => None,
3109    }
3110}
3111
3112fn list_predicate_values(value: &Value) -> Option<Vec<Value>> {
3113    match value {
3114        Value::StringArray(values) => Some(values.iter().map(Value::from).collect()),
3115        Value::IntArray(values) => Some(values.iter().copied().map(Value::Int).collect()),
3116        Value::FloatArray(values) => Some(values.iter().copied().map(Value::Float).collect()),
3117        Value::Json(serde_json::Value::Array(values)) => values
3118            .iter()
3119            .map(|value| match value {
3120                serde_json::Value::Bool(value) => Some(Value::Bool(*value)),
3121                serde_json::Value::Number(value) => value
3122                    .as_i64()
3123                    .map(Value::Int)
3124                    .or_else(|| value.as_f64().map(Value::Float)),
3125                serde_json::Value::String(value) => Some(Value::from(value)),
3126                serde_json::Value::Null
3127                | serde_json::Value::Array(_)
3128                | serde_json::Value::Object(_) => None,
3129            })
3130            .collect(),
3131        _ => None,
3132    }
3133}
3134
3135fn compare_ordered_values(lhs: &Value, rhs: &Value) -> Option<std::cmp::Ordering> {
3136    match (lhs, rhs) {
3137        (Value::Int(lhs), Value::Int(rhs)) => Some(lhs.cmp(rhs)),
3138        (Value::Int(lhs), Value::Float(rhs)) => (*lhs as f64).partial_cmp(rhs),
3139        (Value::Float(lhs), Value::Int(rhs)) => lhs.partial_cmp(&(*rhs as f64)),
3140        (Value::Float(lhs), Value::Float(rhs)) => lhs.partial_cmp(rhs),
3141        (Value::String(lhs), Value::String(rhs)) => Some(lhs.cmp(rhs)),
3142        _ => None,
3143    }
3144}
3145
3146#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
3147pub struct GraphNodeMatch {
3148    pub label: Option<Label>,
3149    pub props: Props,
3150    pub predicates: Vec<GraphPropertyPredicate>,
3151}
3152
3153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
3154pub struct GraphRelationshipMatch {
3155    pub from: GraphNodeMatch,
3156    pub label: Label,
3157    pub to: GraphNodeMatch,
3158    pub id: Option<EdgeId>,
3159    pub props: Props,
3160    pub predicates: Vec<GraphPropertyPredicate>,
3161}
3162
3163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
3164pub enum GraphMutationPlanOp {
3165    UpsertNode {
3166        kind: GraphMutationPlanKind,
3167        node: Node,
3168    },
3169    PatchNode {
3170        id: NodeId,
3171        props: Props,
3172    },
3173    PatchMatchingNodes {
3174        label: Option<Label>,
3175        props: Props,
3176        predicates: Vec<GraphPropertyPredicate>,
3177        patch: Props,
3178        cardinality: GraphMutationCardinality,
3179    },
3180    UpdateMatchingNodeProperty {
3181        label: Option<Label>,
3182        props: Props,
3183        predicates: Vec<GraphPropertyPredicate>,
3184        target_key: String,
3185        source_key: String,
3186        op: GraphNumericOp,
3187        operand: Value,
3188        cardinality: GraphMutationCardinality,
3189    },
3190    PatchEdge {
3191        from: NodeId,
3192        label: Label,
3193        to: NodeId,
3194        id: Option<EdgeId>,
3195        props: Props,
3196    },
3197    PatchMatchingEdges {
3198        relationship: GraphRelationshipMatch,
3199        patch: Props,
3200        cardinality: GraphMutationCardinality,
3201    },
3202    UpdateMatchingEdgeProperty {
3203        relationship: GraphRelationshipMatch,
3204        target_key: String,
3205        source_key: String,
3206        op: GraphNumericOp,
3207        operand: Value,
3208        cardinality: GraphMutationCardinality,
3209    },
3210    RemoveNodeProps {
3211        id: NodeId,
3212        keys: Vec<String>,
3213    },
3214    RemoveMatchingNodeProps {
3215        label: Option<Label>,
3216        props: Props,
3217        predicates: Vec<GraphPropertyPredicate>,
3218        keys: Vec<String>,
3219        cardinality: GraphMutationCardinality,
3220    },
3221    RemoveEdgeProps {
3222        from: NodeId,
3223        label: Label,
3224        to: NodeId,
3225        id: Option<EdgeId>,
3226        keys: Vec<String>,
3227    },
3228    RemoveMatchingEdgeProps {
3229        relationship: GraphRelationshipMatch,
3230        keys: Vec<String>,
3231        cardinality: GraphMutationCardinality,
3232    },
3233    DeleteMatchingNodes {
3234        label: Option<Label>,
3235        props: Props,
3236        predicates: Vec<GraphPropertyPredicate>,
3237        cardinality: GraphMutationCardinality,
3238    },
3239    UpsertEdge {
3240        kind: GraphMutationPlanKind,
3241        edge: Edge,
3242    },
3243    UpsertEdgesFromNodeMatches {
3244        kind: GraphMutationPlanKind,
3245        from: GraphNodeMatch,
3246        to: GraphNodeMatch,
3247        label: Label,
3248        props: Props,
3249        edge_id_policy: GraphRowEdgeIdPolicy,
3250        cardinality: GraphMutationCardinality,
3251    },
3252    DeleteNode(NodeId),
3253    DeleteEdge {
3254        from: NodeId,
3255        label: Label,
3256        to: NodeId,
3257    },
3258    DeleteMatchingEdges {
3259        relationship: GraphRelationshipMatch,
3260        cardinality: GraphMutationCardinality,
3261    },
3262    DeleteRelationshipRows {
3263        relationship: GraphRelationshipMatch,
3264        delete_edges: bool,
3265        endpoint_nodes: Vec<GraphRelationshipEndpoint>,
3266        target_count: usize,
3267        cardinality: GraphMutationCardinality,
3268    },
3269    /// Cross-variable correlated node property update (Unit 10b/W3): for each
3270    /// matched `(target, source)` node pair, set
3271    /// `target[target_key] = source[source_key]` optionally combined with
3272    /// `op`/`operand`. `correlation` selects how the pairs are formed.
3273    SetMatchingNodeFromNode {
3274        target_label: Option<Label>,
3275        target_props: Props,
3276        target_predicates: Vec<GraphPropertyPredicate>,
3277        target_key: String,
3278        source_label: Option<Label>,
3279        source_props: Props,
3280        source_predicates: Vec<GraphPropertyPredicate>,
3281        source_key: String,
3282        op: Option<GraphNumericOp>,
3283        operand: Value,
3284        correlation: GraphWriteCorrelation,
3285        cardinality: GraphMutationCardinality,
3286    },
3287}
3288
3289/// How a cross-variable write correlates its target and source node matches
3290/// (Unit 10b/W3).
3291#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3292pub enum GraphWriteCorrelation {
3293    /// Cartesian product of the target and source matches.
3294    Cartesian,
3295    /// Pairs linked by `(target)-[:label]->(source)`.
3296    OutgoingRelationship { label: Label },
3297    /// Pairs linked by `(target)<-[:label]-(source)`.
3298    IncomingRelationship { label: Label },
3299}
3300
3301#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
3302pub struct GraphMutationPlan {
3303    pub operations: Vec<GraphMutationPlanOp>,
3304}
3305
3306impl GraphMutationPlan {
3307    pub fn new(operations: Vec<GraphMutationPlanOp>) -> Self {
3308        Self { operations }
3309    }
3310
3311    pub fn push(&mut self, operation: GraphMutationPlanOp) {
3312        self.operations.push(operation);
3313    }
3314
3315    pub fn report(&self) -> GraphMutationReport {
3316        let mut report = GraphMutationReport::default();
3317        for operation in &self.operations {
3318            report.record(operation);
3319        }
3320        report
3321    }
3322
3323    pub fn into_mutations(self) -> Vec<GraphMutation> {
3324        self.operations
3325            .into_iter()
3326            .map(GraphMutation::from)
3327            .collect()
3328    }
3329}
3330
3331/// Count-oriented mutation reporting.
3332///
3333/// Reports created from a [`GraphMutationPlan`] can know exact changed
3334/// node/edge counts only for single-identity operations. Matched or
3335/// row-producing operations increment their coarse operation counters at
3336/// planning time, while executors fill in `matched_rows` and granular
3337/// `changed_*` / `*_patches` / `*_deletes` / `*_upserts` counters after they
3338/// materialize the backend row set.
3339#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
3340pub struct GraphMutationReport {
3341    pub creates: usize,
3342    pub merges: usize,
3343    pub deletes: usize,
3344    pub patches: usize,
3345    pub property_removes: usize,
3346    pub matched_rows: usize,
3347    pub changed_nodes: usize,
3348    pub changed_edges: usize,
3349    pub node_upserts: usize,
3350    pub edge_upserts: usize,
3351    pub node_deletes: usize,
3352    pub edge_deletes: usize,
3353    pub node_patches: usize,
3354    pub edge_patches: usize,
3355    pub node_property_removes: usize,
3356    pub edge_property_removes: usize,
3357    /// Upserts the executor could classify as inserting a new node, i.e. no
3358    /// element with the same identity existed before the write.
3359    ///
3360    /// Only backends that can cheaply distinguish insert from update populate
3361    /// these (for example the in-memory store). Upsert-oriented backends that
3362    /// return [`PutOutcome::Upserted`] leave them at `0` and report the totals
3363    /// through `node_upserts` / `edge_upserts` instead, so a zero here does not
3364    /// mean "no inserts" — check the backend's classification ability first.
3365    pub node_inserts: usize,
3366    /// Upserts the executor could classify as updating an existing node.
3367    pub node_updates: usize,
3368    /// Upserts the executor could classify as inserting a new edge.
3369    pub edge_inserts: usize,
3370    /// Upserts the executor could classify as updating an existing edge.
3371    pub edge_updates: usize,
3372}
3373
3374impl GraphMutationReport {
3375    pub fn record(&mut self, operation: &GraphMutationPlanOp) {
3376        match operation {
3377            GraphMutationPlanOp::UpsertNode { kind, .. }
3378            | GraphMutationPlanOp::UpsertEdge { kind, .. } => {
3379                match kind {
3380                    GraphMutationPlanKind::Create => self.creates += 1,
3381                    GraphMutationPlanKind::Merge => self.merges += 1,
3382                }
3383                match operation {
3384                    GraphMutationPlanOp::UpsertNode { .. } => {
3385                        self.node_upserts += 1;
3386                        self.changed_nodes += 1;
3387                    }
3388                    GraphMutationPlanOp::UpsertEdge { .. } => {
3389                        self.edge_upserts += 1;
3390                        self.changed_edges += 1;
3391                    }
3392                    _ => {}
3393                }
3394            }
3395            GraphMutationPlanOp::UpsertEdgesFromNodeMatches { kind, .. } => match kind {
3396                GraphMutationPlanKind::Create => self.creates += 1,
3397                GraphMutationPlanKind::Merge => self.merges += 1,
3398            },
3399            GraphMutationPlanOp::PatchNode { .. } => {
3400                self.patches += 1;
3401                self.node_patches += 1;
3402                self.changed_nodes += 1;
3403            }
3404            GraphMutationPlanOp::PatchMatchingNodes { .. } => {
3405                self.patches += 1;
3406            }
3407            GraphMutationPlanOp::UpdateMatchingNodeProperty { .. } => {
3408                self.patches += 1;
3409            }
3410            GraphMutationPlanOp::SetMatchingNodeFromNode { .. } => {
3411                self.patches += 1;
3412            }
3413            GraphMutationPlanOp::PatchEdge { .. } => {
3414                self.patches += 1;
3415                self.edge_patches += 1;
3416                self.changed_edges += 1;
3417            }
3418            GraphMutationPlanOp::PatchMatchingEdges { .. } => {
3419                self.patches += 1;
3420            }
3421            GraphMutationPlanOp::UpdateMatchingEdgeProperty { .. } => {
3422                self.patches += 1;
3423            }
3424            GraphMutationPlanOp::RemoveNodeProps { .. } => {
3425                self.property_removes += 1;
3426                self.node_property_removes += 1;
3427                self.changed_nodes += 1;
3428            }
3429            GraphMutationPlanOp::RemoveMatchingNodeProps { .. } => {
3430                self.property_removes += 1;
3431            }
3432            GraphMutationPlanOp::RemoveEdgeProps { .. } => {
3433                self.property_removes += 1;
3434                self.edge_property_removes += 1;
3435                self.changed_edges += 1;
3436            }
3437            GraphMutationPlanOp::RemoveMatchingEdgeProps { .. } => {
3438                self.property_removes += 1;
3439            }
3440            GraphMutationPlanOp::DeleteMatchingNodes { .. } => {
3441                self.deletes += 1;
3442            }
3443            GraphMutationPlanOp::DeleteNode(_) => {
3444                self.deletes += 1;
3445                self.node_deletes += 1;
3446                self.changed_nodes += 1;
3447            }
3448            GraphMutationPlanOp::DeleteEdge { .. } => {
3449                self.deletes += 1;
3450                self.edge_deletes += 1;
3451                self.changed_edges += 1;
3452            }
3453            GraphMutationPlanOp::DeleteMatchingEdges { .. } => {
3454                self.deletes += 1;
3455            }
3456            GraphMutationPlanOp::DeleteRelationshipRows { target_count, .. } => {
3457                self.deletes += *target_count;
3458            }
3459        }
3460    }
3461}
3462
3463impl From<GraphMutationPlanOp> for GraphMutation {
3464    fn from(operation: GraphMutationPlanOp) -> Self {
3465        match operation {
3466            GraphMutationPlanOp::UpsertNode { node, .. } => Self::UpsertNode(node),
3467            GraphMutationPlanOp::PatchNode { id, props } => Self::PatchNode { id, props },
3468            GraphMutationPlanOp::PatchMatchingNodes {
3469                label,
3470                props,
3471                predicates,
3472                patch,
3473                ..
3474            } => Self::PatchMatchingNodes {
3475                label,
3476                props,
3477                predicates,
3478                patch,
3479            },
3480            GraphMutationPlanOp::UpdateMatchingNodeProperty {
3481                label,
3482                props,
3483                predicates,
3484                target_key,
3485                source_key,
3486                op,
3487                operand,
3488                ..
3489            } => Self::UpdateMatchingNodeProperty {
3490                label,
3491                props,
3492                predicates,
3493                target_key,
3494                source_key,
3495                op,
3496                operand,
3497            },
3498            GraphMutationPlanOp::PatchEdge {
3499                from,
3500                label,
3501                to,
3502                id,
3503                props,
3504            } => Self::PatchEdge {
3505                from,
3506                label,
3507                to,
3508                id,
3509                props,
3510            },
3511            GraphMutationPlanOp::PatchMatchingEdges {
3512                relationship,
3513                patch,
3514                ..
3515            } => Self::PatchMatchingEdges {
3516                relationship,
3517                patch,
3518            },
3519            GraphMutationPlanOp::UpdateMatchingEdgeProperty {
3520                relationship,
3521                target_key,
3522                source_key,
3523                op,
3524                operand,
3525                ..
3526            } => Self::UpdateMatchingEdgeProperty {
3527                relationship,
3528                target_key,
3529                source_key,
3530                op,
3531                operand,
3532            },
3533            GraphMutationPlanOp::RemoveNodeProps { id, keys } => Self::RemoveNodeProps { id, keys },
3534            GraphMutationPlanOp::RemoveEdgeProps {
3535                from,
3536                label,
3537                to,
3538                id,
3539                keys,
3540            } => Self::RemoveEdgeProps {
3541                from,
3542                label,
3543                to,
3544                id,
3545                keys,
3546            },
3547            GraphMutationPlanOp::RemoveMatchingEdgeProps {
3548                relationship, keys, ..
3549            } => Self::RemoveMatchingEdgeProps { relationship, keys },
3550            GraphMutationPlanOp::RemoveMatchingNodeProps {
3551                label,
3552                props,
3553                predicates,
3554                keys,
3555                ..
3556            } => Self::RemoveMatchingNodeProps {
3557                label,
3558                props,
3559                predicates,
3560                keys,
3561            },
3562            GraphMutationPlanOp::DeleteMatchingNodes {
3563                label,
3564                props,
3565                predicates,
3566                ..
3567            } => Self::DeleteMatchingNodes {
3568                label,
3569                props,
3570                predicates,
3571            },
3572            GraphMutationPlanOp::UpsertEdge { edge, .. } => Self::UpsertEdge(edge),
3573            GraphMutationPlanOp::UpsertEdgesFromNodeMatches {
3574                kind,
3575                from,
3576                to,
3577                label,
3578                props,
3579                edge_id_policy,
3580                ..
3581            } => Self::UpsertEdgesFromNodeMatches {
3582                kind,
3583                from,
3584                to,
3585                label,
3586                props,
3587                edge_id_policy,
3588            },
3589            GraphMutationPlanOp::DeleteNode(id) => Self::DeleteNode(id),
3590            GraphMutationPlanOp::DeleteEdge { from, label, to } => {
3591                Self::DeleteEdge { from, label, to }
3592            }
3593            GraphMutationPlanOp::DeleteMatchingEdges { relationship, .. } => {
3594                Self::DeleteMatchingEdges { relationship }
3595            }
3596            GraphMutationPlanOp::DeleteRelationshipRows {
3597                relationship,
3598                delete_edges,
3599                endpoint_nodes,
3600                ..
3601            } => Self::DeleteRelationshipRows {
3602                relationship,
3603                delete_edges,
3604                endpoint_nodes,
3605            },
3606            GraphMutationPlanOp::SetMatchingNodeFromNode {
3607                target_label,
3608                target_props,
3609                target_predicates,
3610                target_key,
3611                source_label,
3612                source_props,
3613                source_predicates,
3614                source_key,
3615                op,
3616                operand,
3617                correlation,
3618                cardinality,
3619            } => Self::SetMatchingNodeFromNode {
3620                target_label,
3621                target_props,
3622                target_predicates,
3623                target_key,
3624                source_label,
3625                source_props,
3626                source_predicates,
3627                source_key,
3628                op,
3629                operand,
3630                correlation,
3631                cardinality,
3632            },
3633        }
3634    }
3635}
3636
3637/// Executes already-resolved Cypher/graph mutation plans.
3638///
3639/// This trait intentionally accepts [`GraphMutationPlan`] rather than Cypher
3640/// text. Parser ownership can stay with a backend or adapter until there are
3641/// enough consumers to justify a shared parser crate, while stores that
3642/// understand Grust mutation semantics can still share execution behavior.
3643#[async_trait]
3644pub trait CypherMutationExecutor: GraphMutationStore {
3645    async fn execute_cypher_mutation_plan(
3646        &self,
3647        plan: &GraphMutationPlan,
3648    ) -> Result<GraphMutationReport> {
3649        let mut report = plan.report();
3650        for operation in &plan.operations {
3651            match operation {
3652                GraphMutationPlanOp::DeleteMatchingNodes { .. } => {
3653                    return Err(GrustError::CypherExecution(
3654                        "matched node deletes require backend-specific query support".to_string(),
3655                    ));
3656                }
3657                GraphMutationPlanOp::PatchMatchingNodes { .. } => {
3658                    return Err(GrustError::CypherExecution(
3659                        "matched node patches require backend-specific query support".to_string(),
3660                    ));
3661                }
3662                GraphMutationPlanOp::UpdateMatchingNodeProperty { .. } => {
3663                    return Err(GrustError::CypherExecution(
3664                        "matched node expression updates require backend-specific query support"
3665                            .to_string(),
3666                    ));
3667                }
3668                GraphMutationPlanOp::RemoveMatchingNodeProps { .. } => {
3669                    return Err(GrustError::CypherExecution(
3670                        "matched node property removals require backend-specific query support"
3671                            .to_string(),
3672                    ));
3673                }
3674                GraphMutationPlanOp::PatchMatchingEdges { .. } => {
3675                    return Err(GrustError::CypherExecution(
3676                        "matched edge patches require backend-specific query support".to_string(),
3677                    ));
3678                }
3679                GraphMutationPlanOp::UpdateMatchingEdgeProperty { .. } => {
3680                    return Err(GrustError::CypherExecution(
3681                        "matched edge expression updates require backend-specific query support"
3682                            .to_string(),
3683                    ));
3684                }
3685                GraphMutationPlanOp::RemoveMatchingEdgeProps { .. } => {
3686                    return Err(GrustError::CypherExecution(
3687                        "matched edge property removals require backend-specific query support"
3688                            .to_string(),
3689                    ));
3690                }
3691                GraphMutationPlanOp::DeleteMatchingEdges { .. } => {
3692                    return Err(GrustError::CypherExecution(
3693                        "matched edge deletes require backend-specific query support".to_string(),
3694                    ));
3695                }
3696                GraphMutationPlanOp::UpsertEdgesFromNodeMatches { .. } => {
3697                    return Err(GrustError::CypherExecution(
3698                        "row-producing edge upserts require backend-specific query support"
3699                            .to_string(),
3700                    ));
3701                }
3702                GraphMutationPlanOp::UpsertNode { node, .. } => {
3703                    classify_node_upsert(self.put_node(node).await?, &mut report);
3704                }
3705                GraphMutationPlanOp::UpsertEdge { edge, .. } => {
3706                    classify_edge_upsert(self.put_edge(edge).await?, &mut report);
3707                }
3708                _ => {
3709                    let mutation = GraphMutation::from(operation.clone());
3710                    self.apply_mutations(std::slice::from_ref(&mutation))
3711                        .await?;
3712                }
3713            }
3714        }
3715        Ok(report)
3716    }
3717}
3718
3719/// Records a single-node upsert outcome into a report's precise insert/update
3720/// counters. [`PutOutcome::Upserted`] and [`PutOutcome::Deduped`] carry no
3721/// insert-vs-update information and leave the counters unchanged.
3722pub fn classify_node_upsert(outcome: PutOutcome, report: &mut GraphMutationReport) {
3723    match outcome {
3724        PutOutcome::Inserted => report.node_inserts += 1,
3725        PutOutcome::Updated => report.node_updates += 1,
3726        PutOutcome::Upserted | PutOutcome::Deduped => {}
3727    }
3728}
3729
3730/// Records a single-edge upsert outcome into a report's precise insert/update
3731/// counters. See [`classify_node_upsert`].
3732pub fn classify_edge_upsert(outcome: PutOutcome, report: &mut GraphMutationReport) {
3733    match outcome {
3734        PutOutcome::Inserted => report.edge_inserts += 1,
3735        PutOutcome::Updated => report.edge_updates += 1,
3736        PutOutcome::Upserted | PutOutcome::Deduped => {}
3737    }
3738}
3739
3740/// Incremental mutation support for stores that can delete elements.
3741///
3742/// Deletes are idempotent: removing an element that does not exist is not an
3743/// error.
3744#[async_trait]
3745pub trait GraphMutationStore: GraphStore {
3746    fn mutation_atomicity(&self) -> GraphMutationAtomicity {
3747        GraphMutationAtomicity::OrderedNonAtomic
3748    }
3749
3750    /// Deletes a node and all edges incident to it.
3751    async fn delete_node(&self, id: &NodeId) -> Result<()>;
3752
3753    /// Deletes the edge(s) matching `(from, label, to)`.
3754    async fn delete_edge(&self, from: &NodeId, label: &Label, to: &NodeId) -> Result<()>;
3755
3756    /// Applies mutations in order, stopping at the first error.
3757    ///
3758    /// The default implementation calls the single-mutation methods one at a
3759    /// time and is not atomic: if a later mutation fails, earlier successful
3760    /// mutations are not rolled back. Backends with transaction support should
3761    /// override this method and apply the whole slice in one transaction.
3762    async fn apply_mutations(&self, mutations: &[GraphMutation]) -> Result<()> {
3763        for mutation in mutations {
3764            match mutation {
3765                GraphMutation::UpsertNode(node) => {
3766                    self.put_node(node).await?;
3767                }
3768                GraphMutation::PatchNode { id, props } => {
3769                    if let Some(mut node) = self.get_node(id).await? {
3770                        for (key, value) in props {
3771                            node.props.insert(key.clone(), value.clone());
3772                        }
3773                        self.put_node(&node).await?;
3774                    }
3775                }
3776                GraphMutation::PatchMatchingNodes { .. } => {
3777                    return Err(GrustError::Unsupported(
3778                        "matched node patches require backend-specific query support".to_string(),
3779                    ));
3780                }
3781                GraphMutation::UpdateMatchingNodeProperty { .. } => {
3782                    return Err(GrustError::Unsupported(
3783                        "matched node expression updates require backend-specific query support"
3784                            .to_string(),
3785                    ));
3786                }
3787                GraphMutation::SetMatchingNodeFromNode { .. } => {
3788                    return Err(GrustError::Unsupported(
3789                        "cross-variable correlated updates require backend-specific query support"
3790                            .to_string(),
3791                    ));
3792                }
3793                GraphMutation::PatchEdge {
3794                    from,
3795                    label,
3796                    to,
3797                    id,
3798                    props,
3799                } => {
3800                    let mut edges = self
3801                        .get_edges(EdgeQuery {
3802                            from: Some(from.clone()),
3803                            to: Some(to.clone()),
3804                            label: Some(label.clone()),
3805                        })
3806                        .await?;
3807                    if let Some(id) = id {
3808                        edges.retain(|edge| edge.id.as_ref() == Some(id));
3809                    }
3810                    match edges.len() {
3811                        0 => {}
3812                        1 => {
3813                            let mut edge = edges.remove(0);
3814                            for (key, value) in props {
3815                                edge.props.insert(key.clone(), value.clone());
3816                            }
3817                            self.put_edge(&edge).await?;
3818                        }
3819                        count => {
3820                            return Err(GrustError::CypherUnsupportedCardinality(format!(
3821                                "edge patch matched {count} edges; add an explicit edge id"
3822                            )));
3823                        }
3824                    }
3825                }
3826                GraphMutation::PatchMatchingEdges { .. } => {
3827                    return Err(GrustError::Unsupported(
3828                        "matched edge patches require backend-specific query support".to_string(),
3829                    ));
3830                }
3831                GraphMutation::UpdateMatchingEdgeProperty { .. } => {
3832                    return Err(GrustError::Unsupported(
3833                        "matched edge expression updates require backend-specific query support"
3834                            .to_string(),
3835                    ));
3836                }
3837                GraphMutation::RemoveNodeProps { id, keys } => {
3838                    if let Some(mut node) = self.get_node(id).await? {
3839                        for key in keys {
3840                            node.props.remove(key);
3841                        }
3842                        self.put_node(&node).await?;
3843                    }
3844                }
3845                GraphMutation::RemoveMatchingNodeProps { .. } => {
3846                    return Err(GrustError::Unsupported(
3847                        "matched node property removal requires backend-specific query support"
3848                            .to_string(),
3849                    ));
3850                }
3851                GraphMutation::RemoveEdgeProps {
3852                    from,
3853                    label,
3854                    to,
3855                    id,
3856                    keys,
3857                } => {
3858                    let mut edges = self
3859                        .get_edges(EdgeQuery {
3860                            from: Some(from.clone()),
3861                            to: Some(to.clone()),
3862                            label: Some(label.clone()),
3863                        })
3864                        .await?;
3865                    if let Some(id) = id {
3866                        edges.retain(|edge| edge.id.as_ref() == Some(id));
3867                    }
3868                    match edges.len() {
3869                        0 => {}
3870                        1 => {
3871                            let mut edge = edges.remove(0);
3872                            for key in keys {
3873                                edge.props.remove(key);
3874                            }
3875                            self.put_edge(&edge).await?;
3876                        }
3877                        count => {
3878                            return Err(GrustError::CypherUnsupportedCardinality(format!(
3879                                "edge property removal matched {count} edges; add an explicit edge id"
3880                            )));
3881                        }
3882                    }
3883                }
3884                GraphMutation::RemoveMatchingEdgeProps { .. } => {
3885                    return Err(GrustError::Unsupported(
3886                        "matched edge property removal requires backend-specific query support"
3887                            .to_string(),
3888                    ));
3889                }
3890                GraphMutation::DeleteMatchingNodes { .. } => {
3891                    return Err(GrustError::Unsupported(
3892                        "matched node deletes require backend-specific query support".to_string(),
3893                    ));
3894                }
3895                GraphMutation::DeleteNode(id) => self.delete_node(id).await?,
3896                GraphMutation::UpsertEdge(edge) => {
3897                    self.put_edge(edge).await?;
3898                }
3899                GraphMutation::UpsertEdgesFromNodeMatches { .. } => {
3900                    return Err(GrustError::Unsupported(
3901                        "row-producing edge upserts require backend-specific query support"
3902                            .to_string(),
3903                    ));
3904                }
3905                GraphMutation::DeleteEdge { from, label, to } => {
3906                    self.delete_edge(from, label, to).await?
3907                }
3908                GraphMutation::DeleteMatchingEdges { .. } => {
3909                    return Err(GrustError::Unsupported(
3910                        "matched edge deletes require backend-specific query support".to_string(),
3911                    ));
3912                }
3913                GraphMutation::DeleteRelationshipRows { .. } => {
3914                    return Err(GrustError::Unsupported(
3915                        "relationship-row deletes require backend-specific query support"
3916                            .to_string(),
3917                    ));
3918                }
3919            }
3920        }
3921        Ok(())
3922    }
3923}
3924
3925pub mod prelude {
3926    pub use crate::{
3927        CypherMutationExecutor, Decimal, Direction, Duration, Edge, EdgeId, EdgePolicy, EdgeQuery, EdgeType,
3928        EdgeUniqueness, Field, FieldType, Graph, GraphAdminStore, GraphBuilder, GraphConstraint,
3929        GraphConstraintCapability, GraphIndex, GraphMutation, GraphMutationAtomicity,
3930        GraphMutationCardinality, GraphMutationPlan, GraphMutationPlanKind, GraphMutationPlanOp,
3931        GraphMutationReport, GraphMutationStore, GraphNativeConstraintCapability,
3932        GraphNativeConstraintReport, GraphNativeConstraintRequest, GraphNodeMatch, GraphNumericOp,
3933        GraphPredicateOp, GraphPropertyPredicate, GraphRelationshipEndpoint,
3934        GraphRelationshipMatch, GraphRowEdgeIdPolicy, GraphSchema, GraphSchemaBuilder, GraphStore,
3935        GraphWriteCorrelation,
3936        GrustError, Label, LoadReport, Node, NodeId, NodeType, Props, PutOutcome, Result, RfcDate,
3937        Start, Step, Traversal, Value, classify_edge_upsert, classify_node_upsert, edge_key,
3938        evaluate_numeric_update, generated_row_edge_id, relationship_type, schema_identifier,
3939    };
3940
3941    #[cfg(feature = "typed-garde")]
3942    pub use crate::typed::{TypedEdge, TypedGraphBuilder, TypedNode, garde, props_from_serialize};
3943
3944    #[cfg(feature = "typed-zod-rs")]
3945    pub use crate::typed::{parse_typed_json, parse_typed_json_with, zod_rs};
3946}
3947
3948#[cfg(test)]
3949mod tests;