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