Skip to main content

marsdb_graph/
model.rs

1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4pub struct NodeId(pub u64);
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct EdgeId(pub u64);
8
9/// A node/edge property, as persisted to redb (via `postcard`, see
10/// `encode.rs`) and used directly as MarsDB's runtime scalar type -- there
11/// is no separate "wire" representation. New variants append at the end
12/// (postcard's derive encodes an enum discriminant by declaration order),
13/// never reorder/remove an existing one, or every already-stored property
14/// silently decodes as the wrong variant.
15///
16/// `Date`/`Duration` are Cypher's `DATE`/`DURATION` temporal types, added
17/// as first-class variants rather than reusing `Int`/`String` -- e.g.
18/// stashing a date as `Int(epoch_day)` would round-trip through storage
19/// fine, but a plain `Int` and a `Date` would then be indistinguishable
20/// once read back (Temporal4's "store a date, read it back, it must
21/// still print/compare/access-components as a date" scenarios need that
22/// distinction to survive the storage boundary). `LocalTime`/`Time`/
23/// `LocalDateTime`/`DateTime` (Cypher's other four temporal types) follow
24/// the same reasoning below. `Time` only accepts a *fixed* UTC offset --
25/// it carries no calendar date, so a named zone's DST-dependent offset
26/// has nothing to resolve against; `DateTime` accepts either a fixed
27/// offset or a named zone (`TzId`).
28///
29/// `Map` exists here for exactly one reason: a `$parameter`'s value can be
30/// map-shaped (`{name: 'Apa'}`, TCK's Map2/Map3), and query-time
31/// parameters flow in as `PropertyValue` (this is the one place a
32/// non-storable shape has to travel through). A node/edge *property*
33/// value is never actually a `Map` though -- real Cypher forbids storing
34/// one (`marsdb-query::executor::value_to_storable_property` rejects it
35/// outright before anything reaches `GraphStore`), so this variant is
36/// only ever constructed on the parameter-passing path, never persisted.
37#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
38pub enum PropertyValue {
39    Null,
40    Bool(bool),
41    Int(i64),
42    Float(f64),
43    String(String),
44    /// A calendar date with no time-of-day or timezone, stored as the
45    /// number of days since the Unix epoch (1970-01-01), proleptic
46    /// Gregorian. Plain `i32` (not a `chrono` type) -- keeps this crate's
47    /// storage format independent of any date library's own internal
48    /// representation (which is free to change across `chrono` versions),
49    /// and keeps comparison a plain integer compare. Conversion to/from
50    /// calendar year/month/day and ISO-8601 text lives in `marsdb-query`
51    /// (`temporal.rs`), not here -- this crate only stores the value, it
52    /// doesn't know Cypher's date grammar/semantics.
53    Date(i32),
54    /// An ISO-8601 duration (Cypher's `DURATION` type), kept in Neo4j's
55    /// own four-component normalized form rather than as a single scalar
56    /// -- months and days are *not* fungible with each other or with
57    /// seconds (a month is 28-31 days depending which month; without a
58    /// reference date, "3 months" has no fixed length in days at all), so
59    /// collapsing `duration({months: 1})` and `duration({days: 30})` into
60    /// one comparable number would silently be wrong once added to some
61    /// starting date. `nanos` always has the same sign as `seconds` (or is
62    /// `0`) -- i.e. `seconds*1_000_000_000 + nanos` is `total_nanoseconds`
63    /// truncated-towards-zero the same way Rust's integer division/`%`
64    /// already works, never a separately-signed remainder -- so
65    /// "-1.999 seconds" is `seconds: -1, nanos: -999_000_000`, not
66    /// `seconds: -2, nanos: 1_000_000`, which would make the same
67    /// duration representable two different ways.
68    Duration {
69        months: i64,
70        days: i64,
71        seconds: i64,
72        nanos: i32,
73    },
74    /// A time-of-day with no date or timezone, stored as nanoseconds since
75    /// midnight (`0..86_400_000_000_000`, always non-negative -- there's no
76    /// sign to carry the way `Date`'s epoch-day has). Cypher's `LOCAL TIME`.
77    LocalTime(i64),
78    /// A time-of-day with a *fixed* UTC offset (Cypher's `TIME`) -- named
79    /// timezones (`Europe/Stockholm`) aren't supported, only literal
80    /// `+HH:MM`-style offsets (see `marsdb-query::temporal`'s module docs
81    /// for the exact scope). `nanos_of_day` is the wall-clock reading (same
82    /// representation as `LocalTime`); `offset_seconds` is seconds *east*
83    /// of UTC. Comparison/equality use the UTC-equivalent instant-of-day
84    /// (`nanos_of_day - offset_seconds`), not the raw wall-clock reading --
85    /// two `Time`s at different offsets can represent the same instant.
86    Time {
87        nanos_of_day: i64,
88        offset_seconds: i32,
89    },
90    /// A calendar date + time-of-day with no timezone (Cypher's `LOCAL
91    /// DATETIME`), stored as a naive (zone-less) instant: whole seconds
92    /// since the Unix epoch (`epoch_seconds`, signed -- a pre-1970 value is
93    /// negative) plus a `0..999_999_999` nanosecond remainder that always
94    /// stays non-negative (the sign lives entirely in `epoch_seconds`,
95    /// mirroring `Duration`'s "no separately-signed remainder" invariant).
96    LocalDateTime {
97        epoch_seconds: i64,
98        nanos: i32,
99    },
100    /// A calendar date + time-of-day with a timezone (Cypher's
101    /// `DATETIME`) -- either a *fixed* UTC offset or a named IANA zone
102    /// (`Europe/Stockholm`). `epoch_seconds`/`nanos` are the *UTC
103    /// instant* (same convention as `LocalDateTime`); `zone` is kept
104    /// only for display/round-tripping the original wall-clock reading
105    /// -- comparison/equality use the instant alone, matching real
106    /// Cypher (two `DateTime`s at the same instant but different zones
107    /// are equal, even though they print differently). A `Named` zone's
108    /// real offset at this instant is *not* cached here (the same zone
109    /// has different offsets across a DST transition) -- it's re-derived
110    /// on demand via `chrono-tz` (`marsdb-query::temporal::resolve_
111    /// offset`), this crate only stores the value, it doesn't know
112    /// Cypher's timezone-resolution semantics.
113    DateTime {
114        epoch_seconds: i64,
115        nanos: i32,
116        zone: TzId,
117    },
118    /// A homogeneous array of scalars (real Cypher/Neo4j's own property
119    /// restriction: a stored list property can hold any of the scalar
120    /// variants above, all the same variant, never `Null`-mixed-with-a-
121    /// type, another `List`, or a map -- enforced where a `Value::List`
122    /// is converted to a storable `PropertyValue`, in `marsdb-query`, not
123    /// here; this crate just stores whatever `Vec<PropertyValue>` it's
124    /// given). Appended last (see this enum's own doc comment on why
125    /// variant order is a real, one-way storage-compat constraint).
126    List(Vec<PropertyValue>),
127    /// See this enum's own doc comment -- parameter-passing only, never a
128    /// real stored property value.
129    Map(BTreeMap<String, PropertyValue>),
130}
131
132/// A `DateTime`'s zone: a fixed UTC offset, or a named IANA timezone
133/// (`Europe/Stockholm`) whose real offset varies by instant (DST) and is
134/// resolved on demand, not stored -- see `PropertyValue::DateTime`'s doc
135/// comment.
136#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
137pub enum TzId {
138    Offset(i32),
139    Named(String),
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct Node {
144    pub id: NodeId,
145    pub labels: Vec<String>,
146    pub props: BTreeMap<String, PropertyValue>,
147}
148
149#[derive(Debug, Clone, PartialEq)]
150pub struct Edge {
151    pub id: EdgeId,
152    pub label: String,
153    pub src: NodeId,
154    pub dst: NodeId,
155    pub props: BTreeMap<String, PropertyValue>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum Direction {
160    Out,
161    In,
162}
163
164/// A traversal-hop candidate read directly from an adjacency multimap entry,
165/// without touching the `edges`/`nodes` tables.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct AdjEntry {
168    pub edge_id: EdgeId,
169    pub other: NodeId,
170    pub label_id: u32,
171}
172
173impl AdjEntry {
174    pub(crate) fn encode(&self) -> [u8; 20] {
175        let mut buf = [0u8; 20];
176        buf[0..8].copy_from_slice(&self.edge_id.0.to_be_bytes());
177        buf[8..16].copy_from_slice(&self.other.0.to_be_bytes());
178        buf[16..20].copy_from_slice(&self.label_id.to_be_bytes());
179        buf
180    }
181
182    pub(crate) fn decode(bytes: &[u8]) -> Result<Self, crate::GraphError> {
183        let bytes: &[u8; 20] = bytes.try_into().map_err(|_| {
184            crate::GraphError::CorruptData(format!(
185                "adjacency entry has {} bytes; expected 20",
186                bytes.len()
187            ))
188        })?;
189        let edge_id = u64::from_be_bytes(bytes[0..8].try_into().expect("fixed-size slice"));
190        let other = u64::from_be_bytes(bytes[8..16].try_into().expect("fixed-size slice"));
191        let label_id = u32::from_be_bytes(bytes[16..20].try_into().expect("fixed-size slice"));
192        Ok(AdjEntry {
193            edge_id: EdgeId(edge_id),
194            other: NodeId(other),
195            label_id,
196        })
197    }
198}