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    DeleteRelationshipRows {
2447        relationship: GraphRelationshipMatch,
2448        delete_edges: bool,
2449        endpoint_nodes: Vec<GraphRelationshipEndpoint>,
2450    },
2451}
2452
2453#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2454pub enum GraphMutationAtomicity {
2455    OrderedNonAtomic,
2456    Transactional,
2457}
2458
2459#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2460pub enum GraphMutationPlanKind {
2461    Create,
2462    Merge,
2463}
2464
2465#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2466pub enum GraphRowEdgeIdPolicy {
2467    ExplicitOnly,
2468    GenerateForCreate,
2469    GenerateForCreateAndMerge,
2470}
2471
2472#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2473pub enum GraphRelationshipEndpoint {
2474    From,
2475    To,
2476}
2477
2478impl Default for GraphRowEdgeIdPolicy {
2479    fn default() -> Self {
2480        Self::ExplicitOnly
2481    }
2482}
2483
2484#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2485pub enum GraphMutationCardinality {
2486    SingleIdentity,
2487    BoundedMany,
2488    UnboundedMany,
2489}
2490
2491pub fn generated_row_edge_id(from: &NodeId, label: &Label, to: &NodeId, props: &Props) -> EdgeId {
2492    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
2493    const FNV_PRIME: u64 = 0x100000001b3;
2494
2495    fn write_part(hash: &mut u64, value: &str) {
2496        for byte in value.as_bytes() {
2497            *hash ^= u64::from(*byte);
2498            *hash = hash.wrapping_mul(FNV_PRIME);
2499        }
2500        *hash ^= 0xff;
2501        *hash = hash.wrapping_mul(FNV_PRIME);
2502    }
2503
2504    let mut hash = FNV_OFFSET;
2505    write_part(&mut hash, from.as_str());
2506    write_part(&mut hash, label.as_str());
2507    write_part(&mut hash, to.as_str());
2508    for (key, value) in props {
2509        write_part(&mut hash, key);
2510        write_part(&mut hash, &value.to_json().to_string());
2511    }
2512    EdgeId::new(format!("edge-{hash:016x}"))
2513}
2514
2515#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2516pub enum GraphNumericOp {
2517    Add,
2518    Subtract,
2519    Multiply,
2520    Divide,
2521}
2522
2523pub fn evaluate_numeric_update(
2524    current: &Value,
2525    op: GraphNumericOp,
2526    operand: &Value,
2527) -> Result<Value> {
2528    match (current, operand) {
2529        (Value::Int(lhs), Value::Int(rhs)) if op != GraphNumericOp::Divide => {
2530            let value = match op {
2531                GraphNumericOp::Add => lhs.checked_add(*rhs),
2532                GraphNumericOp::Subtract => lhs.checked_sub(*rhs),
2533                GraphNumericOp::Multiply => lhs.checked_mul(*rhs),
2534                GraphNumericOp::Divide => unreachable!("division handled as floating point"),
2535            }
2536            .ok_or_else(|| GrustError::CypherExecution("numeric expression overflow".into()))?;
2537            Ok(Value::Int(value))
2538        }
2539        (Value::Int(lhs), Value::Int(rhs)) => numeric_float_result(*lhs as f64, op, *rhs as f64),
2540        (Value::Int(lhs), Value::Float(rhs)) => numeric_float_result(*lhs as f64, op, *rhs),
2541        (Value::Float(lhs), Value::Int(rhs)) => numeric_float_result(*lhs, op, *rhs as f64),
2542        (Value::Float(lhs), Value::Float(rhs)) => numeric_float_result(*lhs, op, *rhs),
2543        (Value::Null, _) | (_, Value::Null) => Err(GrustError::CypherExecution(
2544            "numeric expression cannot read null values".into(),
2545        )),
2546        _ => Err(GrustError::CypherExecution(
2547            "numeric expression requires integer or float values".into(),
2548        )),
2549    }
2550}
2551
2552fn numeric_float_result(lhs: f64, op: GraphNumericOp, rhs: f64) -> Result<Value> {
2553    let value = match op {
2554        GraphNumericOp::Add => lhs + rhs,
2555        GraphNumericOp::Subtract => lhs - rhs,
2556        GraphNumericOp::Multiply => lhs * rhs,
2557        GraphNumericOp::Divide => {
2558            if rhs == 0.0 {
2559                return Err(GrustError::CypherExecution(
2560                    "numeric expression division by zero".into(),
2561                ));
2562            }
2563            lhs / rhs
2564        }
2565    };
2566    if value.is_finite() {
2567        Ok(Value::Float(value))
2568    } else {
2569        Err(GrustError::CypherExecution(
2570            "numeric expression produced a non-finite float".into(),
2571        ))
2572    }
2573}
2574
2575#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2576pub enum GraphPredicateOp {
2577    Equal,
2578    NotEqual,
2579    IsNull,
2580    IsNotNull,
2581    StartsWith,
2582    NotStartsWith,
2583    StartsWithAny,
2584    NotStartsWithAny,
2585    EndsWith,
2586    NotEndsWith,
2587    EndsWithAny,
2588    NotEndsWithAny,
2589    Contains,
2590    NotContains,
2591    ContainsAny,
2592    NotContainsAny,
2593    In,
2594    NotIn,
2595    GreaterThan,
2596    GreaterThanOrEqual,
2597    LessThan,
2598    LessThanOrEqual,
2599}
2600
2601#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2602pub struct GraphPropertyPredicate {
2603    pub key: String,
2604    pub op: GraphPredicateOp,
2605    pub value: Value,
2606}
2607
2608impl GraphPropertyPredicate {
2609    pub fn matches(&self, actual: Option<&Value>) -> bool {
2610        if matches!(self.op, GraphPredicateOp::IsNull) {
2611            return actual.is_none_or(|value| matches!(value, Value::Null));
2612        }
2613        let Some(actual) = actual else {
2614            return false;
2615        };
2616        match self.op {
2617            GraphPredicateOp::Equal => actual == &self.value,
2618            GraphPredicateOp::NotEqual => actual != &self.value,
2619            GraphPredicateOp::IsNull => matches!(actual, Value::Null),
2620            GraphPredicateOp::IsNotNull => !matches!(actual, Value::Null),
2621            GraphPredicateOp::StartsWith => string_predicate_values(actual, &self.value)
2622                .is_some_and(|(actual, needle)| actual.starts_with(needle)),
2623            GraphPredicateOp::NotStartsWith => string_predicate_values(actual, &self.value)
2624                .is_some_and(|(actual, needle)| !actual.starts_with(needle)),
2625            GraphPredicateOp::StartsWithAny => string_list_predicate_values(actual, &self.value)
2626                .is_some_and(|(actual, needles)| {
2627                    needles.iter().any(|needle| actual.starts_with(needle))
2628                }),
2629            GraphPredicateOp::NotStartsWithAny => string_list_predicate_values(actual, &self.value)
2630                .is_some_and(|(actual, needles)| {
2631                    needles.iter().all(|needle| !actual.starts_with(needle))
2632                }),
2633            GraphPredicateOp::EndsWith => string_predicate_values(actual, &self.value)
2634                .is_some_and(|(actual, needle)| actual.ends_with(needle)),
2635            GraphPredicateOp::NotEndsWith => string_predicate_values(actual, &self.value)
2636                .is_some_and(|(actual, needle)| !actual.ends_with(needle)),
2637            GraphPredicateOp::EndsWithAny => string_list_predicate_values(actual, &self.value)
2638                .is_some_and(|(actual, needles)| {
2639                    needles.iter().any(|needle| actual.ends_with(needle))
2640                }),
2641            GraphPredicateOp::NotEndsWithAny => string_list_predicate_values(actual, &self.value)
2642                .is_some_and(|(actual, needles)| {
2643                    needles.iter().all(|needle| !actual.ends_with(needle))
2644                }),
2645            GraphPredicateOp::Contains => string_predicate_values(actual, &self.value)
2646                .is_some_and(|(actual, needle)| actual.contains(needle)),
2647            GraphPredicateOp::NotContains => string_predicate_values(actual, &self.value)
2648                .is_some_and(|(actual, needle)| !actual.contains(needle)),
2649            GraphPredicateOp::ContainsAny => string_list_predicate_values(actual, &self.value)
2650                .is_some_and(|(actual, needles)| {
2651                    needles.iter().any(|needle| actual.contains(needle))
2652                }),
2653            GraphPredicateOp::NotContainsAny => string_list_predicate_values(actual, &self.value)
2654                .is_some_and(|(actual, needles)| {
2655                    needles.iter().all(|needle| !actual.contains(needle))
2656                }),
2657            GraphPredicateOp::In => list_predicate_values(&self.value)
2658                .is_some_and(|values| values.iter().any(|value| actual == value)),
2659            GraphPredicateOp::NotIn => list_predicate_values(&self.value)
2660                .is_some_and(|values| values.iter().all(|value| actual != value)),
2661            GraphPredicateOp::GreaterThan
2662            | GraphPredicateOp::GreaterThanOrEqual
2663            | GraphPredicateOp::LessThan
2664            | GraphPredicateOp::LessThanOrEqual => compare_ordered_values(actual, &self.value)
2665                .is_some_and(|ordering| match self.op {
2666                    GraphPredicateOp::GreaterThan => ordering.is_gt(),
2667                    GraphPredicateOp::GreaterThanOrEqual => ordering.is_gt() || ordering.is_eq(),
2668                    GraphPredicateOp::LessThan => ordering.is_lt(),
2669                    GraphPredicateOp::LessThanOrEqual => ordering.is_lt() || ordering.is_eq(),
2670                    GraphPredicateOp::Equal
2671                    | GraphPredicateOp::NotEqual
2672                    | GraphPredicateOp::IsNull
2673                    | GraphPredicateOp::IsNotNull
2674                    | GraphPredicateOp::StartsWith
2675                    | GraphPredicateOp::NotStartsWith
2676                    | GraphPredicateOp::StartsWithAny
2677                    | GraphPredicateOp::NotStartsWithAny
2678                    | GraphPredicateOp::EndsWith
2679                    | GraphPredicateOp::NotEndsWith
2680                    | GraphPredicateOp::EndsWithAny
2681                    | GraphPredicateOp::NotEndsWithAny
2682                    | GraphPredicateOp::Contains
2683                    | GraphPredicateOp::NotContains
2684                    | GraphPredicateOp::ContainsAny
2685                    | GraphPredicateOp::NotContainsAny
2686                    | GraphPredicateOp::In
2687                    | GraphPredicateOp::NotIn => unreachable!(),
2688                }),
2689        }
2690    }
2691}
2692
2693fn string_predicate_values<'a>(actual: &'a Value, value: &'a Value) -> Option<(&'a str, &'a str)> {
2694    match (actual, value) {
2695        (Value::String(actual), Value::String(needle)) => Some((actual.as_str(), needle.as_str())),
2696        _ => None,
2697    }
2698}
2699
2700fn string_list_predicate_values<'a>(
2701    actual: &'a Value,
2702    value: &'a Value,
2703) -> Option<(&'a str, Vec<&'a str>)> {
2704    match (actual, value) {
2705        (Value::String(actual), Value::StringArray(needles)) => Some((
2706            actual.as_str(),
2707            needles.iter().map(String::as_str).collect(),
2708        )),
2709        _ => None,
2710    }
2711}
2712
2713fn list_predicate_values(value: &Value) -> Option<Vec<Value>> {
2714    match value {
2715        Value::StringArray(values) => Some(values.iter().map(Value::from).collect()),
2716        Value::IntArray(values) => Some(values.iter().copied().map(Value::Int).collect()),
2717        Value::FloatArray(values) => Some(values.iter().copied().map(Value::Float).collect()),
2718        Value::Json(serde_json::Value::Array(values)) => values
2719            .iter()
2720            .map(|value| match value {
2721                serde_json::Value::Bool(value) => Some(Value::Bool(*value)),
2722                serde_json::Value::Number(value) => value
2723                    .as_i64()
2724                    .map(Value::Int)
2725                    .or_else(|| value.as_f64().map(Value::Float)),
2726                serde_json::Value::String(value) => Some(Value::from(value)),
2727                serde_json::Value::Null
2728                | serde_json::Value::Array(_)
2729                | serde_json::Value::Object(_) => None,
2730            })
2731            .collect(),
2732        _ => None,
2733    }
2734}
2735
2736fn compare_ordered_values(lhs: &Value, rhs: &Value) -> Option<std::cmp::Ordering> {
2737    match (lhs, rhs) {
2738        (Value::Int(lhs), Value::Int(rhs)) => Some(lhs.cmp(rhs)),
2739        (Value::Int(lhs), Value::Float(rhs)) => (*lhs as f64).partial_cmp(rhs),
2740        (Value::Float(lhs), Value::Int(rhs)) => lhs.partial_cmp(&(*rhs as f64)),
2741        (Value::Float(lhs), Value::Float(rhs)) => lhs.partial_cmp(rhs),
2742        (Value::String(lhs), Value::String(rhs)) => Some(lhs.cmp(rhs)),
2743        _ => None,
2744    }
2745}
2746
2747#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
2748pub struct GraphNodeMatch {
2749    pub label: Option<Label>,
2750    pub props: Props,
2751    pub predicates: Vec<GraphPropertyPredicate>,
2752}
2753
2754#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2755pub struct GraphRelationshipMatch {
2756    pub from: GraphNodeMatch,
2757    pub label: Label,
2758    pub to: GraphNodeMatch,
2759    pub id: Option<EdgeId>,
2760    pub props: Props,
2761    pub predicates: Vec<GraphPropertyPredicate>,
2762}
2763
2764#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2765pub enum GraphMutationPlanOp {
2766    UpsertNode {
2767        kind: GraphMutationPlanKind,
2768        node: Node,
2769    },
2770    PatchNode {
2771        id: NodeId,
2772        props: Props,
2773    },
2774    PatchMatchingNodes {
2775        label: Option<Label>,
2776        props: Props,
2777        predicates: Vec<GraphPropertyPredicate>,
2778        patch: Props,
2779        cardinality: GraphMutationCardinality,
2780    },
2781    UpdateMatchingNodeProperty {
2782        label: Option<Label>,
2783        props: Props,
2784        predicates: Vec<GraphPropertyPredicate>,
2785        target_key: String,
2786        source_key: String,
2787        op: GraphNumericOp,
2788        operand: Value,
2789        cardinality: GraphMutationCardinality,
2790    },
2791    PatchEdge {
2792        from: NodeId,
2793        label: Label,
2794        to: NodeId,
2795        id: Option<EdgeId>,
2796        props: Props,
2797    },
2798    PatchMatchingEdges {
2799        relationship: GraphRelationshipMatch,
2800        patch: Props,
2801        cardinality: GraphMutationCardinality,
2802    },
2803    UpdateMatchingEdgeProperty {
2804        relationship: GraphRelationshipMatch,
2805        target_key: String,
2806        source_key: String,
2807        op: GraphNumericOp,
2808        operand: Value,
2809        cardinality: GraphMutationCardinality,
2810    },
2811    RemoveNodeProps {
2812        id: NodeId,
2813        keys: Vec<String>,
2814    },
2815    RemoveMatchingNodeProps {
2816        label: Option<Label>,
2817        props: Props,
2818        predicates: Vec<GraphPropertyPredicate>,
2819        keys: Vec<String>,
2820        cardinality: GraphMutationCardinality,
2821    },
2822    RemoveEdgeProps {
2823        from: NodeId,
2824        label: Label,
2825        to: NodeId,
2826        id: Option<EdgeId>,
2827        keys: Vec<String>,
2828    },
2829    RemoveMatchingEdgeProps {
2830        relationship: GraphRelationshipMatch,
2831        keys: Vec<String>,
2832        cardinality: GraphMutationCardinality,
2833    },
2834    DeleteMatchingNodes {
2835        label: Option<Label>,
2836        props: Props,
2837        predicates: Vec<GraphPropertyPredicate>,
2838        cardinality: GraphMutationCardinality,
2839    },
2840    UpsertEdge {
2841        kind: GraphMutationPlanKind,
2842        edge: Edge,
2843    },
2844    UpsertEdgesFromNodeMatches {
2845        kind: GraphMutationPlanKind,
2846        from: GraphNodeMatch,
2847        to: GraphNodeMatch,
2848        label: Label,
2849        props: Props,
2850        edge_id_policy: GraphRowEdgeIdPolicy,
2851        cardinality: GraphMutationCardinality,
2852    },
2853    DeleteNode(NodeId),
2854    DeleteEdge {
2855        from: NodeId,
2856        label: Label,
2857        to: NodeId,
2858    },
2859    DeleteMatchingEdges {
2860        relationship: GraphRelationshipMatch,
2861        cardinality: GraphMutationCardinality,
2862    },
2863    DeleteRelationshipRows {
2864        relationship: GraphRelationshipMatch,
2865        delete_edges: bool,
2866        endpoint_nodes: Vec<GraphRelationshipEndpoint>,
2867        target_count: usize,
2868        cardinality: GraphMutationCardinality,
2869    },
2870}
2871
2872#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
2873pub struct GraphMutationPlan {
2874    pub operations: Vec<GraphMutationPlanOp>,
2875}
2876
2877impl GraphMutationPlan {
2878    pub fn new(operations: Vec<GraphMutationPlanOp>) -> Self {
2879        Self { operations }
2880    }
2881
2882    pub fn push(&mut self, operation: GraphMutationPlanOp) {
2883        self.operations.push(operation);
2884    }
2885
2886    pub fn report(&self) -> GraphMutationReport {
2887        let mut report = GraphMutationReport::default();
2888        for operation in &self.operations {
2889            report.record(operation);
2890        }
2891        report
2892    }
2893
2894    pub fn into_mutations(self) -> Vec<GraphMutation> {
2895        self.operations
2896            .into_iter()
2897            .map(GraphMutation::from)
2898            .collect()
2899    }
2900}
2901
2902/// Count-oriented mutation reporting.
2903///
2904/// Reports created from a [`GraphMutationPlan`] can know exact changed
2905/// node/edge counts only for single-identity operations. Matched or
2906/// row-producing operations increment their coarse operation counters at
2907/// planning time, while executors fill in `matched_rows` and granular
2908/// `changed_*` / `*_patches` / `*_deletes` / `*_upserts` counters after they
2909/// materialize the backend row set.
2910#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2911pub struct GraphMutationReport {
2912    pub creates: usize,
2913    pub merges: usize,
2914    pub deletes: usize,
2915    pub patches: usize,
2916    pub property_removes: usize,
2917    pub matched_rows: usize,
2918    pub changed_nodes: usize,
2919    pub changed_edges: usize,
2920    pub node_upserts: usize,
2921    pub edge_upserts: usize,
2922    pub node_deletes: usize,
2923    pub edge_deletes: usize,
2924    pub node_patches: usize,
2925    pub edge_patches: usize,
2926    pub node_property_removes: usize,
2927    pub edge_property_removes: usize,
2928    /// Upserts the executor could classify as inserting a new node, i.e. no
2929    /// element with the same identity existed before the write.
2930    ///
2931    /// Only backends that can cheaply distinguish insert from update populate
2932    /// these (for example the in-memory store). Upsert-oriented backends that
2933    /// return [`PutOutcome::Upserted`] leave them at `0` and report the totals
2934    /// through `node_upserts` / `edge_upserts` instead, so a zero here does not
2935    /// mean "no inserts" — check the backend's classification ability first.
2936    pub node_inserts: usize,
2937    /// Upserts the executor could classify as updating an existing node.
2938    pub node_updates: usize,
2939    /// Upserts the executor could classify as inserting a new edge.
2940    pub edge_inserts: usize,
2941    /// Upserts the executor could classify as updating an existing edge.
2942    pub edge_updates: usize,
2943}
2944
2945impl GraphMutationReport {
2946    pub fn record(&mut self, operation: &GraphMutationPlanOp) {
2947        match operation {
2948            GraphMutationPlanOp::UpsertNode { kind, .. }
2949            | GraphMutationPlanOp::UpsertEdge { kind, .. } => {
2950                match kind {
2951                    GraphMutationPlanKind::Create => self.creates += 1,
2952                    GraphMutationPlanKind::Merge => self.merges += 1,
2953                }
2954                match operation {
2955                    GraphMutationPlanOp::UpsertNode { .. } => {
2956                        self.node_upserts += 1;
2957                        self.changed_nodes += 1;
2958                    }
2959                    GraphMutationPlanOp::UpsertEdge { .. } => {
2960                        self.edge_upserts += 1;
2961                        self.changed_edges += 1;
2962                    }
2963                    _ => {}
2964                }
2965            }
2966            GraphMutationPlanOp::UpsertEdgesFromNodeMatches { kind, .. } => match kind {
2967                GraphMutationPlanKind::Create => self.creates += 1,
2968                GraphMutationPlanKind::Merge => self.merges += 1,
2969            },
2970            GraphMutationPlanOp::PatchNode { .. } => {
2971                self.patches += 1;
2972                self.node_patches += 1;
2973                self.changed_nodes += 1;
2974            }
2975            GraphMutationPlanOp::PatchMatchingNodes { .. } => {
2976                self.patches += 1;
2977            }
2978            GraphMutationPlanOp::UpdateMatchingNodeProperty { .. } => {
2979                self.patches += 1;
2980            }
2981            GraphMutationPlanOp::PatchEdge { .. } => {
2982                self.patches += 1;
2983                self.edge_patches += 1;
2984                self.changed_edges += 1;
2985            }
2986            GraphMutationPlanOp::PatchMatchingEdges { .. } => {
2987                self.patches += 1;
2988            }
2989            GraphMutationPlanOp::UpdateMatchingEdgeProperty { .. } => {
2990                self.patches += 1;
2991            }
2992            GraphMutationPlanOp::RemoveNodeProps { .. } => {
2993                self.property_removes += 1;
2994                self.node_property_removes += 1;
2995                self.changed_nodes += 1;
2996            }
2997            GraphMutationPlanOp::RemoveMatchingNodeProps { .. } => {
2998                self.property_removes += 1;
2999            }
3000            GraphMutationPlanOp::RemoveEdgeProps { .. } => {
3001                self.property_removes += 1;
3002                self.edge_property_removes += 1;
3003                self.changed_edges += 1;
3004            }
3005            GraphMutationPlanOp::RemoveMatchingEdgeProps { .. } => {
3006                self.property_removes += 1;
3007            }
3008            GraphMutationPlanOp::DeleteMatchingNodes { .. } => {
3009                self.deletes += 1;
3010            }
3011            GraphMutationPlanOp::DeleteNode(_) => {
3012                self.deletes += 1;
3013                self.node_deletes += 1;
3014                self.changed_nodes += 1;
3015            }
3016            GraphMutationPlanOp::DeleteEdge { .. } => {
3017                self.deletes += 1;
3018                self.edge_deletes += 1;
3019                self.changed_edges += 1;
3020            }
3021            GraphMutationPlanOp::DeleteMatchingEdges { .. } => {
3022                self.deletes += 1;
3023            }
3024            GraphMutationPlanOp::DeleteRelationshipRows { target_count, .. } => {
3025                self.deletes += *target_count;
3026            }
3027        }
3028    }
3029}
3030
3031impl From<GraphMutationPlanOp> for GraphMutation {
3032    fn from(operation: GraphMutationPlanOp) -> Self {
3033        match operation {
3034            GraphMutationPlanOp::UpsertNode { node, .. } => Self::UpsertNode(node),
3035            GraphMutationPlanOp::PatchNode { id, props } => Self::PatchNode { id, props },
3036            GraphMutationPlanOp::PatchMatchingNodes {
3037                label,
3038                props,
3039                predicates,
3040                patch,
3041                ..
3042            } => Self::PatchMatchingNodes {
3043                label,
3044                props,
3045                predicates,
3046                patch,
3047            },
3048            GraphMutationPlanOp::UpdateMatchingNodeProperty {
3049                label,
3050                props,
3051                predicates,
3052                target_key,
3053                source_key,
3054                op,
3055                operand,
3056                ..
3057            } => Self::UpdateMatchingNodeProperty {
3058                label,
3059                props,
3060                predicates,
3061                target_key,
3062                source_key,
3063                op,
3064                operand,
3065            },
3066            GraphMutationPlanOp::PatchEdge {
3067                from,
3068                label,
3069                to,
3070                id,
3071                props,
3072            } => Self::PatchEdge {
3073                from,
3074                label,
3075                to,
3076                id,
3077                props,
3078            },
3079            GraphMutationPlanOp::PatchMatchingEdges {
3080                relationship,
3081                patch,
3082                ..
3083            } => Self::PatchMatchingEdges {
3084                relationship,
3085                patch,
3086            },
3087            GraphMutationPlanOp::UpdateMatchingEdgeProperty {
3088                relationship,
3089                target_key,
3090                source_key,
3091                op,
3092                operand,
3093                ..
3094            } => Self::UpdateMatchingEdgeProperty {
3095                relationship,
3096                target_key,
3097                source_key,
3098                op,
3099                operand,
3100            },
3101            GraphMutationPlanOp::RemoveNodeProps { id, keys } => Self::RemoveNodeProps { id, keys },
3102            GraphMutationPlanOp::RemoveEdgeProps {
3103                from,
3104                label,
3105                to,
3106                id,
3107                keys,
3108            } => Self::RemoveEdgeProps {
3109                from,
3110                label,
3111                to,
3112                id,
3113                keys,
3114            },
3115            GraphMutationPlanOp::RemoveMatchingEdgeProps {
3116                relationship, keys, ..
3117            } => Self::RemoveMatchingEdgeProps { relationship, keys },
3118            GraphMutationPlanOp::RemoveMatchingNodeProps {
3119                label,
3120                props,
3121                predicates,
3122                keys,
3123                ..
3124            } => Self::RemoveMatchingNodeProps {
3125                label,
3126                props,
3127                predicates,
3128                keys,
3129            },
3130            GraphMutationPlanOp::DeleteMatchingNodes {
3131                label,
3132                props,
3133                predicates,
3134                ..
3135            } => Self::DeleteMatchingNodes {
3136                label,
3137                props,
3138                predicates,
3139            },
3140            GraphMutationPlanOp::UpsertEdge { edge, .. } => Self::UpsertEdge(edge),
3141            GraphMutationPlanOp::UpsertEdgesFromNodeMatches {
3142                kind,
3143                from,
3144                to,
3145                label,
3146                props,
3147                edge_id_policy,
3148                ..
3149            } => Self::UpsertEdgesFromNodeMatches {
3150                kind,
3151                from,
3152                to,
3153                label,
3154                props,
3155                edge_id_policy,
3156            },
3157            GraphMutationPlanOp::DeleteNode(id) => Self::DeleteNode(id),
3158            GraphMutationPlanOp::DeleteEdge { from, label, to } => {
3159                Self::DeleteEdge { from, label, to }
3160            }
3161            GraphMutationPlanOp::DeleteMatchingEdges { relationship, .. } => {
3162                Self::DeleteMatchingEdges { relationship }
3163            }
3164            GraphMutationPlanOp::DeleteRelationshipRows {
3165                relationship,
3166                delete_edges,
3167                endpoint_nodes,
3168                ..
3169            } => Self::DeleteRelationshipRows {
3170                relationship,
3171                delete_edges,
3172                endpoint_nodes,
3173            },
3174        }
3175    }
3176}
3177
3178/// Executes already-resolved Cypher/graph mutation plans.
3179///
3180/// This trait intentionally accepts [`GraphMutationPlan`] rather than Cypher
3181/// text. Parser ownership can stay with a backend or adapter until there are
3182/// enough consumers to justify a shared parser crate, while stores that
3183/// understand Grust mutation semantics can still share execution behavior.
3184#[async_trait]
3185pub trait CypherMutationExecutor: GraphMutationStore {
3186    async fn execute_cypher_mutation_plan(
3187        &self,
3188        plan: &GraphMutationPlan,
3189    ) -> Result<GraphMutationReport> {
3190        let mut report = plan.report();
3191        for operation in &plan.operations {
3192            match operation {
3193                GraphMutationPlanOp::DeleteMatchingNodes { .. } => {
3194                    return Err(GrustError::CypherExecution(
3195                        "matched node deletes require backend-specific query support".to_string(),
3196                    ));
3197                }
3198                GraphMutationPlanOp::PatchMatchingNodes { .. } => {
3199                    return Err(GrustError::CypherExecution(
3200                        "matched node patches require backend-specific query support".to_string(),
3201                    ));
3202                }
3203                GraphMutationPlanOp::UpdateMatchingNodeProperty { .. } => {
3204                    return Err(GrustError::CypherExecution(
3205                        "matched node expression updates require backend-specific query support"
3206                            .to_string(),
3207                    ));
3208                }
3209                GraphMutationPlanOp::RemoveMatchingNodeProps { .. } => {
3210                    return Err(GrustError::CypherExecution(
3211                        "matched node property removals require backend-specific query support"
3212                            .to_string(),
3213                    ));
3214                }
3215                GraphMutationPlanOp::PatchMatchingEdges { .. } => {
3216                    return Err(GrustError::CypherExecution(
3217                        "matched edge patches require backend-specific query support".to_string(),
3218                    ));
3219                }
3220                GraphMutationPlanOp::UpdateMatchingEdgeProperty { .. } => {
3221                    return Err(GrustError::CypherExecution(
3222                        "matched edge expression updates require backend-specific query support"
3223                            .to_string(),
3224                    ));
3225                }
3226                GraphMutationPlanOp::RemoveMatchingEdgeProps { .. } => {
3227                    return Err(GrustError::CypherExecution(
3228                        "matched edge property removals require backend-specific query support"
3229                            .to_string(),
3230                    ));
3231                }
3232                GraphMutationPlanOp::DeleteMatchingEdges { .. } => {
3233                    return Err(GrustError::CypherExecution(
3234                        "matched edge deletes require backend-specific query support".to_string(),
3235                    ));
3236                }
3237                GraphMutationPlanOp::UpsertEdgesFromNodeMatches { .. } => {
3238                    return Err(GrustError::CypherExecution(
3239                        "row-producing edge upserts require backend-specific query support"
3240                            .to_string(),
3241                    ));
3242                }
3243                GraphMutationPlanOp::UpsertNode { node, .. } => {
3244                    classify_node_upsert(self.put_node(node).await?, &mut report);
3245                }
3246                GraphMutationPlanOp::UpsertEdge { edge, .. } => {
3247                    classify_edge_upsert(self.put_edge(edge).await?, &mut report);
3248                }
3249                _ => {
3250                    let mutation = GraphMutation::from(operation.clone());
3251                    self.apply_mutations(std::slice::from_ref(&mutation))
3252                        .await?;
3253                }
3254            }
3255        }
3256        Ok(report)
3257    }
3258}
3259
3260/// Records a single-node upsert outcome into a report's precise insert/update
3261/// counters. [`PutOutcome::Upserted`] and [`PutOutcome::Deduped`] carry no
3262/// insert-vs-update information and leave the counters unchanged.
3263pub fn classify_node_upsert(outcome: PutOutcome, report: &mut GraphMutationReport) {
3264    match outcome {
3265        PutOutcome::Inserted => report.node_inserts += 1,
3266        PutOutcome::Updated => report.node_updates += 1,
3267        PutOutcome::Upserted | PutOutcome::Deduped => {}
3268    }
3269}
3270
3271/// Records a single-edge upsert outcome into a report's precise insert/update
3272/// counters. See [`classify_node_upsert`].
3273pub fn classify_edge_upsert(outcome: PutOutcome, report: &mut GraphMutationReport) {
3274    match outcome {
3275        PutOutcome::Inserted => report.edge_inserts += 1,
3276        PutOutcome::Updated => report.edge_updates += 1,
3277        PutOutcome::Upserted | PutOutcome::Deduped => {}
3278    }
3279}
3280
3281/// Incremental mutation support for stores that can delete elements.
3282///
3283/// Deletes are idempotent: removing an element that does not exist is not an
3284/// error.
3285#[async_trait]
3286pub trait GraphMutationStore: GraphStore {
3287    fn mutation_atomicity(&self) -> GraphMutationAtomicity {
3288        GraphMutationAtomicity::OrderedNonAtomic
3289    }
3290
3291    /// Deletes a node and all edges incident to it.
3292    async fn delete_node(&self, id: &NodeId) -> Result<()>;
3293
3294    /// Deletes the edge(s) matching `(from, label, to)`.
3295    async fn delete_edge(&self, from: &NodeId, label: &Label, to: &NodeId) -> Result<()>;
3296
3297    /// Applies mutations in order, stopping at the first error.
3298    ///
3299    /// The default implementation calls the single-mutation methods one at a
3300    /// time and is not atomic: if a later mutation fails, earlier successful
3301    /// mutations are not rolled back. Backends with transaction support should
3302    /// override this method and apply the whole slice in one transaction.
3303    async fn apply_mutations(&self, mutations: &[GraphMutation]) -> Result<()> {
3304        for mutation in mutations {
3305            match mutation {
3306                GraphMutation::UpsertNode(node) => {
3307                    self.put_node(node).await?;
3308                }
3309                GraphMutation::PatchNode { id, props } => {
3310                    if let Some(mut node) = self.get_node(id).await? {
3311                        for (key, value) in props {
3312                            node.props.insert(key.clone(), value.clone());
3313                        }
3314                        self.put_node(&node).await?;
3315                    }
3316                }
3317                GraphMutation::PatchMatchingNodes { .. } => {
3318                    return Err(GrustError::Unsupported(
3319                        "matched node patches require backend-specific query support".to_string(),
3320                    ));
3321                }
3322                GraphMutation::UpdateMatchingNodeProperty { .. } => {
3323                    return Err(GrustError::Unsupported(
3324                        "matched node expression updates require backend-specific query support"
3325                            .to_string(),
3326                    ));
3327                }
3328                GraphMutation::PatchEdge {
3329                    from,
3330                    label,
3331                    to,
3332                    id,
3333                    props,
3334                } => {
3335                    let mut edges = self
3336                        .get_edges(EdgeQuery {
3337                            from: Some(from.clone()),
3338                            to: Some(to.clone()),
3339                            label: Some(label.clone()),
3340                        })
3341                        .await?;
3342                    if let Some(id) = id {
3343                        edges.retain(|edge| edge.id.as_ref() == Some(id));
3344                    }
3345                    match edges.len() {
3346                        0 => {}
3347                        1 => {
3348                            let mut edge = edges.remove(0);
3349                            for (key, value) in props {
3350                                edge.props.insert(key.clone(), value.clone());
3351                            }
3352                            self.put_edge(&edge).await?;
3353                        }
3354                        count => {
3355                            return Err(GrustError::CypherUnsupportedCardinality(format!(
3356                                "edge patch matched {count} edges; add an explicit edge id"
3357                            )));
3358                        }
3359                    }
3360                }
3361                GraphMutation::PatchMatchingEdges { .. } => {
3362                    return Err(GrustError::Unsupported(
3363                        "matched edge patches require backend-specific query support".to_string(),
3364                    ));
3365                }
3366                GraphMutation::UpdateMatchingEdgeProperty { .. } => {
3367                    return Err(GrustError::Unsupported(
3368                        "matched edge expression updates require backend-specific query support"
3369                            .to_string(),
3370                    ));
3371                }
3372                GraphMutation::RemoveNodeProps { id, keys } => {
3373                    if let Some(mut node) = self.get_node(id).await? {
3374                        for key in keys {
3375                            node.props.remove(key);
3376                        }
3377                        self.put_node(&node).await?;
3378                    }
3379                }
3380                GraphMutation::RemoveMatchingNodeProps { .. } => {
3381                    return Err(GrustError::Unsupported(
3382                        "matched node property removal requires backend-specific query support"
3383                            .to_string(),
3384                    ));
3385                }
3386                GraphMutation::RemoveEdgeProps {
3387                    from,
3388                    label,
3389                    to,
3390                    id,
3391                    keys,
3392                } => {
3393                    let mut edges = self
3394                        .get_edges(EdgeQuery {
3395                            from: Some(from.clone()),
3396                            to: Some(to.clone()),
3397                            label: Some(label.clone()),
3398                        })
3399                        .await?;
3400                    if let Some(id) = id {
3401                        edges.retain(|edge| edge.id.as_ref() == Some(id));
3402                    }
3403                    match edges.len() {
3404                        0 => {}
3405                        1 => {
3406                            let mut edge = edges.remove(0);
3407                            for key in keys {
3408                                edge.props.remove(key);
3409                            }
3410                            self.put_edge(&edge).await?;
3411                        }
3412                        count => {
3413                            return Err(GrustError::CypherUnsupportedCardinality(format!(
3414                                "edge property removal matched {count} edges; add an explicit edge id"
3415                            )));
3416                        }
3417                    }
3418                }
3419                GraphMutation::RemoveMatchingEdgeProps { .. } => {
3420                    return Err(GrustError::Unsupported(
3421                        "matched edge property removal requires backend-specific query support"
3422                            .to_string(),
3423                    ));
3424                }
3425                GraphMutation::DeleteMatchingNodes { .. } => {
3426                    return Err(GrustError::Unsupported(
3427                        "matched node deletes require backend-specific query support".to_string(),
3428                    ));
3429                }
3430                GraphMutation::DeleteNode(id) => self.delete_node(id).await?,
3431                GraphMutation::UpsertEdge(edge) => {
3432                    self.put_edge(edge).await?;
3433                }
3434                GraphMutation::UpsertEdgesFromNodeMatches { .. } => {
3435                    return Err(GrustError::Unsupported(
3436                        "row-producing edge upserts require backend-specific query support"
3437                            .to_string(),
3438                    ));
3439                }
3440                GraphMutation::DeleteEdge { from, label, to } => {
3441                    self.delete_edge(from, label, to).await?
3442                }
3443                GraphMutation::DeleteMatchingEdges { .. } => {
3444                    return Err(GrustError::Unsupported(
3445                        "matched edge deletes require backend-specific query support".to_string(),
3446                    ));
3447                }
3448                GraphMutation::DeleteRelationshipRows { .. } => {
3449                    return Err(GrustError::Unsupported(
3450                        "relationship-row deletes require backend-specific query support"
3451                            .to_string(),
3452                    ));
3453                }
3454            }
3455        }
3456        Ok(())
3457    }
3458}
3459
3460pub mod prelude {
3461    pub use crate::{
3462        CypherMutationExecutor, Direction, Edge, EdgeId, EdgePolicy, EdgeQuery, EdgeType,
3463        EdgeUniqueness, Field, FieldType, Graph, GraphAdminStore, GraphBuilder, GraphConstraint,
3464        GraphConstraintCapability, GraphIndex, GraphMutation, GraphMutationAtomicity,
3465        GraphMutationCardinality, GraphMutationPlan, GraphMutationPlanKind, GraphMutationPlanOp,
3466        GraphMutationReport, GraphMutationStore, GraphNativeConstraintCapability,
3467        GraphNativeConstraintReport, GraphNativeConstraintRequest, GraphNodeMatch, GraphNumericOp,
3468        GraphPredicateOp, GraphPropertyPredicate, GraphRelationshipEndpoint,
3469        GraphRelationshipMatch, GraphRowEdgeIdPolicy, GraphSchema, GraphSchemaBuilder, GraphStore,
3470        GrustError, Label, LoadReport, Node, NodeId, NodeType, Props, PutOutcome, Result, RfcDate,
3471        Start, Step, Traversal, Value, classify_edge_upsert, classify_node_upsert, edge_key,
3472        evaluate_numeric_update, generated_row_edge_id, relationship_type, schema_identifier,
3473    };
3474
3475    #[cfg(feature = "typed-garde")]
3476    pub use crate::typed::{TypedEdge, TypedGraphBuilder, TypedNode, garde, props_from_serialize};
3477
3478    #[cfg(feature = "typed-zod-rs")]
3479    pub use crate::typed::{parse_typed_json, parse_typed_json_with, zod_rs};
3480}
3481
3482#[cfg(test)]
3483mod tests;