Skip to main content

linkrs_storage/edge/
schema.rs

1//! Edge schema types: record shape, direction/multiplicity enums and
2//! the validated `EdgeSchema` carried by the catalog.
3
4use super::record_form_policy::{validate_record_form_target, RecordForm};
5use crate::types::StoragePropertyDef;
6use linkrs_core::types::{EdgeStrategy, LabelId, VertexId};
7use linkrs_core::Value;
8use std::sync::Arc;
9
10#[derive(Debug, Clone)]
11pub struct EdgeRecord {
12    pub src_vid: VertexId,
13    pub dst_vid: VertexId,
14    pub rank: i64,
15    pub properties: Vec<(Arc<str>, Value)>,
16}
17
18/// Storage direction of an edge table, derived from the CSR strategies.
19///
20/// Both directions enabled pays double writes and double storage but serves
21/// forward and reverse traversal from local rows. Single-direction tables
22/// store only one leg and answer the missing direction as empty.
23#[derive(
24    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, Default,
25)]
26pub enum StorageDirection {
27    /// Outgoing and incoming legs stored.
28    #[default]
29    Both,
30    /// Only the outgoing leg stored.
31    OutOnly,
32    /// Only the incoming leg stored.
33    InOnly,
34}
35
36/// Cardinality contract of one direction, derived from the CSR strategy.
37///
38/// Single maps to one live edge per bound vertex, Multiple maps to many.
39/// The topology guard already rejects a second live edge on Single slots;
40/// this type makes the contract explicit for planning and error reporting.
41#[derive(
42    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, Default,
43)]
44pub enum EdgeMultiplicity {
45    /// At most one live edge per bound vertex.
46    One,
47    /// Any number of live edges per bound vertex.
48    #[default]
49    Many,
50}
51
52/// Consistency contract of the secondary property index.
53///
54/// Best-effort keeps the primary write authoritative and counts index
55/// failures as lag; Strong fails the primary write when the index write
56/// fails, for small-cardinality critical attributes.
57#[derive(
58    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, Default,
59)]
60pub enum IndexConsistency {
61    /// Primary write succeeds, index lag counted and rebuilt later.
62    #[default]
63    BestEffort,
64    /// Index failure fails the primary write.
65    Strong,
66}
67
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct EdgeSchema {
70    pub label_id: LabelId,
71    pub label_name: String,
72    pub src_label: LabelId,
73    pub dst_label: LabelId,
74    pub properties: Vec<StoragePropertyDef>,
75    pub oe_strategy: EdgeStrategy,
76    pub ie_strategy: EdgeStrategy,
77    pub schema_version: u64,
78    /// Persisted record form: how edges are stored on disk.
79    /// Determined once at table creation by the selector; never re-inferred
80    /// on load. Default is `Columnar`.
81    #[serde(default)]
82    pub record_form: RecordForm,
83}
84
85impl EdgeSchema {
86    /// Validate that the schema has at least one enabled direction.
87    /// Single-direction tables are supported: the write path stores only
88    /// the enabled leg and reads on the missing leg report empty.
89    ///
90    /// The single-plus-inline combination is intentionally not checked here:
91    /// table creation overwrites `record_form` from the configured
92    /// preference, so the input value carries no authority. Resolved schemas
93    /// are enforced at shard construction and on load instead.
94    pub fn validate(&self) -> linkrs_core::StorageResult<()> {
95        if self.oe_strategy == EdgeStrategy::None && self.ie_strategy == EdgeStrategy::None {
96            return Err(linkrs_core::StorageError::invalid_operation(format!(
97                "EdgeSchema '{}': at least one of oe_strategy and ie_strategy must be enabled",
98                self.label_name
99            )));
100        }
101        Ok(())
102    }
103
104    /// Validate a resolved schema carrying its authoritative record form.
105    ///
106    /// Central entry for all strategy-plus-form rules so construction, load
107    /// and migration report one conflict with one way out. Checks direction
108    /// presence, single-plus-inline pairing, pure arity and bundled
109    /// admission through the shared helpers.
110    pub fn validate_resolved(&self) -> linkrs_core::StorageResult<()> {
111        self.validate()?;
112        validate_record_form_target(
113            &self.properties,
114            self.oe_strategy,
115            self.ie_strategy,
116            self.record_form,
117        )
118    }
119
120    /// Storage direction derived from the enabled CSR strategies.
121    pub fn storage_direction(&self) -> StorageDirection {
122        match (
123            self.oe_strategy != EdgeStrategy::None,
124            self.ie_strategy != EdgeStrategy::None,
125        ) {
126            (true, true) => StorageDirection::Both,
127            (true, false) => StorageDirection::OutOnly,
128            (false, true) => StorageDirection::InOnly,
129            (false, false) => StorageDirection::Both,
130        }
131    }
132
133    /// Whether the outgoing leg is stored.
134    pub fn has_out(&self) -> bool {
135        self.oe_strategy != EdgeStrategy::None
136    }
137
138    /// Whether the incoming leg is stored.
139    pub fn has_in(&self) -> bool {
140        self.ie_strategy != EdgeStrategy::None
141    }
142
143    /// Cardinality contract of the outgoing direction.
144    pub fn out_multiplicity(&self) -> EdgeMultiplicity {
145        if self.oe_strategy == EdgeStrategy::Single {
146            EdgeMultiplicity::One
147        } else {
148            EdgeMultiplicity::Many
149        }
150    }
151
152    /// Cardinality contract of the incoming direction.
153    pub fn in_multiplicity(&self) -> EdgeMultiplicity {
154        if self.ie_strategy == EdgeStrategy::Single {
155            EdgeMultiplicity::One
156        } else {
157            EdgeMultiplicity::Many
158        }
159    }
160
161    /// Validate schema at creation time
162    /// Ensures property names are valid and edge types are well-formed
163    pub fn validate_on_creation(&self) -> linkrs_core::StorageResult<()> {
164        // Validate edge name
165        if self.label_name.is_empty() {
166            return Err(linkrs_core::StorageError::invalid_operation(
167                "Edge type name cannot be empty".to_string(),
168            ));
169        }
170
171        Self::validate_identifier_internal(&self.label_name)?;
172
173        // Validate strategy compatibility
174        self.validate()?;
175
176        // Validate property names are unique and valid
177        let mut seen_names = std::collections::HashSet::new();
178        for prop in &self.properties {
179            if !seen_names.insert(&prop.name) {
180                return Err(linkrs_core::StorageError::invalid_operation(format!(
181                    "Duplicate property name in edge type '{}': '{}'",
182                    self.label_name, prop.name
183                )));
184            }
185
186            // Validate property name format
187            if prop.name.is_empty() {
188                return Err(linkrs_core::StorageError::invalid_operation(format!(
189                    "Property name cannot be empty in edge type '{}'",
190                    self.label_name
191                )));
192            }
193
194            Self::validate_identifier_internal(&prop.name)?;
195
196            // Validate property data types are not Empty or Null
197            Self::validate_property_type_internal(&prop.data_type, &prop.name)?;
198        }
199
200        Ok(())
201    }
202
203    /// Validate that an identifier (name) follows valid rules
204    fn validate_identifier_internal(name: &str) -> linkrs_core::StorageResult<()> {
205        let first_char = match name.chars().next() {
206            Some(c) => c,
207            None => {
208                return Err(linkrs_core::StorageError::invalid_operation(
209                    "Identifier cannot be empty".to_string(),
210                ));
211            }
212        };
213
214        if !first_char.is_ascii_alphabetic() && first_char != '_' {
215            return Err(linkrs_core::StorageError::invalid_operation(format!(
216                "Identifier '{}' must start with ASCII letter or underscore, got '{}'",
217                name, first_char
218            )));
219        }
220
221        for (i, c) in name.chars().enumerate() {
222            if !c.is_ascii_alphanumeric() && c != '_' {
223                return Err(linkrs_core::StorageError::invalid_operation(format!(
224                    "Identifier '{}' contains invalid character '{}' at position {}. \
225                     Only ASCII letters, digits, and underscores are allowed.",
226                    name, c, i
227                )));
228            }
229        }
230
231        Ok(())
232    }
233
234    /// Validate that a property data type is allowed
235    fn validate_property_type_internal(
236        data_type: &linkrs_core::DataType,
237        prop_name: &str,
238    ) -> linkrs_core::StorageResult<()> {
239        use linkrs_core::DataType;
240
241        match data_type {
242            DataType::Empty => Err(linkrs_core::StorageError::invalid_operation(format!(
243                "Property '{}' cannot have type Empty - properties must have valid types",
244                prop_name
245            ))),
246            DataType::Null => Err(linkrs_core::StorageError::invalid_operation(format!(
247                "Property '{}' cannot have type Null - use nullable=true instead",
248                prop_name
249            ))),
250            _ => Ok(()),
251        }
252    }
253}