Skip to main content

linkrs_storage/edge/csr_with_properties/
schema.rs

1use super::CsrWithProperties;
2use crate::edge::property_schema::PropertySchema;
3use crate::vertex::column::Column;
4use linkrs_core::{DataType, StorageError, StorageResult, Value};
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8impl CsrWithProperties {
9    /// Build the columnar store from its schema.
10    ///
11    /// `property_schema` owns every column name: `column_index`, the name
12    /// index and the dump format all read from it. `Column` also carries a
13    /// name, but only to label decode errors, so it is filled here and
14    /// refreshed on rename without any other code reading it.
15    pub fn new(property_schema: Vec<PropertySchema>) -> Self {
16        let mut property_columns = Vec::with_capacity(property_schema.len());
17        for schema in &property_schema {
18            let col = Column::new(
19                schema.name.to_string(),
20                schema.prop_id,
21                schema.data_type.clone(),
22                schema.nullable,
23            );
24            property_columns.push(col);
25        }
26        let next_prop_id = property_schema
27            .iter()
28            .map(|s| s.prop_id)
29            .max()
30            .unwrap_or(-1)
31            .saturating_add(1)
32            .max(property_schema.len() as i32);
33        let mut store = Self {
34            property_schema,
35            property_columns,
36            column_index: HashMap::new(),
37            prop_id_index: HashMap::new(),
38            visibility: Vec::new(),
39            edge_map_segments: Vec::new(),
40            edge_map_len: 0,
41            row_to_edge: Vec::new(),
42            free_list: Vec::new(),
43            row_count: 0,
44            dirty_columns: HashSet::new(),
45            next_prop_id,
46            history_floor: 0,
47            inline: false,
48        };
49        store.rebuild_schema_indexes();
50        store
51    }
52
53    /// Schema-only stub for the inline record forms.
54    ///
55    /// Keeps the schema with its name/id indexes for validation and WAL
56    /// naming, but holds no rows: every row read or write fails loudly so a
57    /// missed dispatch can never fork a second property truth beside the
58    /// CSR value column.
59    pub fn inline_stub(property_schema: Vec<PropertySchema>) -> Self {
60        let mut store = Self::new(property_schema);
61        store.inline = true;
62        store
63    }
64
65    /// Whether this store is a schema-only inline stub.
66    pub fn is_inline_stub(&self) -> bool {
67        self.inline
68    }
69
70    pub(crate) fn reject_inline(&self) -> StorageResult<()> {
71        if self.inline {
72            return Err(StorageError::invalid_operation(
73                "columnar property access on an inline-form table".to_string(),
74            ));
75        }
76        Ok(())
77    }
78
79    /// Rebuild the column position indexes after a schema mutation.
80    pub(crate) fn rebuild_schema_indexes(&mut self) {
81        self.column_index.clear();
82        self.prop_id_index.clear();
83        for (idx, schema) in self.property_schema.iter().enumerate() {
84            self.column_index.insert(schema.name.clone(), idx);
85            self.prop_id_index.insert(schema.prop_id, idx);
86        }
87    }
88
89    /// Column name for one stable identifier, if the column exists.
90    pub fn column_name_by_prop_id(&self, prop_id: i32) -> Option<&str> {
91        self.prop_id_index
92            .get(&prop_id)
93            .and_then(|&idx| self.property_schema.get(idx))
94            .map(|schema| &*schema.name)
95    }
96
97    /// Clear per-column dirt after a successful checkpoint.
98    pub fn clear_dirty_columns(&mut self) {
99        self.dirty_columns.clear();
100    }
101
102    pub fn property_schema(&self) -> &[PropertySchema] {
103        &self.property_schema
104    }
105
106    pub fn has_property(&self, name: &str) -> bool {
107        self.column_index.contains_key(name)
108    }
109
110    pub fn get_property_id(&self, name: &str) -> Option<crate::types::PropertyId> {
111        self.column_index
112            .get(name)
113            .map(|&idx| crate::types::PropertyId::new(self.property_schema[idx].prop_id as u16))
114    }
115
116    pub fn add_property(
117        &mut self,
118        name: String,
119        data_type: DataType,
120        nullable: bool,
121    ) -> StorageResult<crate::types::PropertyId> {
122        if self.has_property(&name) {
123            return Err(StorageError::column_already_exists(name));
124        }
125        let prop_id = self.next_prop_id;
126        self.next_prop_id = self.next_prop_id.saturating_add(1);
127        let schema =
128            PropertySchema::new(name.clone(), prop_id, data_type.clone()).nullable(nullable);
129        self.property_schema.push(schema);
130        let col = Column::new(name.clone(), prop_id, data_type, nullable);
131        let rows = self.visibility.len();
132        if rows > 0 {
133            col.resize(rows);
134        }
135        self.property_columns.push(col);
136        let idx = self.property_schema.len() - 1;
137        self.column_index.insert(Arc::from(name.as_str()), idx);
138        self.mark_column_dirty_at(idx);
139        Ok(crate::types::PropertyId::new(prop_id as u16))
140    }
141
142    pub fn remove_property(&mut self, name: &str) -> StorageResult<()> {
143        let idx = self
144            .column_index
145            .get(name)
146            .copied()
147            .ok_or_else(|| StorageError::column_not_found(name.to_string()))?;
148        self.property_schema.remove(idx);
149        self.property_columns.remove(idx);
150        self.rebuild_schema_indexes();
151        // Positions above the removed column shift down by one; the dirt set
152        // moves with them instead of keeping stale positions.
153        let mut shifted = HashSet::new();
154        for pos in self.dirty_columns.drain() {
155            if pos == idx {
156                continue;
157            }
158            shifted.insert(if pos > idx { pos - 1 } else { pos });
159        }
160        self.dirty_columns = shifted;
161        Ok(())
162    }
163
164    /// Clone one physical column for the staged drop snapshot.
165    pub fn column_cloned(&self, name: &str) -> Option<crate::vertex::column::Column> {
166        self.column_index
167            .get(name)
168            .and_then(|&idx| self.property_columns.get(idx).cloned())
169    }
170
171    /// Whether one column holds unrefreshed writes.
172    pub fn has_column_dirt(&self, name: &str) -> bool {
173        self.column_index
174            .get(name)
175            .is_some_and(|idx| self.dirty_columns.contains(idx))
176    }
177
178    /// Names of columns mutated since the last checkpoint. Drives
179    /// dirty-column incremental persistence: clean columns reuse the last
180    /// flushed encoding instead of paying re-export and re-encode.
181    pub fn dirty_column_names(&self) -> Vec<Arc<str>> {
182        self.dirty_columns
183            .iter()
184            .filter_map(|idx| self.property_schema.get(*idx))
185            .map(|schema| schema.name.clone())
186            .collect()
187    }
188
189    /// Put back a column removed by a failed drop publish at its exact
190    /// schema position, restoring its dirt mark when it had one.
191    pub fn restore_property_at(
192        &mut self,
193        index: usize,
194        schema: PropertySchema,
195        column: crate::vertex::column::Column,
196        had_column_dirt: bool,
197    ) {
198        let at = index.min(self.property_schema.len());
199        self.property_schema.insert(at, schema);
200        let at = at.min(self.property_columns.len());
201        self.property_columns.insert(at, column);
202        self.rebuild_schema_indexes();
203        // Positions at or above the restored column shift up by one before
204        // the restored dirt mark lands, keeping every position exact.
205        let mut shifted = HashSet::new();
206        for pos in self.dirty_columns.drain() {
207            shifted.insert(if pos >= at { pos + 1 } else { pos });
208        }
209        self.dirty_columns = shifted;
210        if had_column_dirt {
211            self.dirty_columns.insert(at);
212        }
213    }
214
215    pub fn rename_property(&mut self, old_name: &str, new_name: &str) -> StorageResult<()> {
216        if self.has_property(new_name) {
217            return Err(StorageError::column_already_exists(new_name.to_string()));
218        }
219        let idx = self
220            .column_index
221            .get(old_name)
222            .copied()
223            .ok_or_else(|| StorageError::column_not_found(old_name.to_string()))?;
224        self.property_schema[idx].name = new_name.into();
225        // Mirror into the column so its decode errors keep naming the column
226        // the reader asked for.
227        if let Some(col) = self.property_columns.get_mut(idx) {
228            col.name = new_name.to_string();
229        }
230        // Dirt is tracked by position, so a rename moves no marks.
231        self.rebuild_schema_indexes();
232        Ok(())
233    }
234
235    /// Backfill one column with a default value on every existing row.
236    ///
237    /// Used by the staged add-column fill step. Unknown columns are an
238    /// explicit error; a single row failure aborts with the error.
239    pub fn backfill_column(&mut self, column: &str, default: &Value) -> StorageResult<()> {
240        let rows = self.visibility.len();
241        let idx = self
242            .column_index
243            .get(column)
244            .copied()
245            .ok_or_else(|| StorageError::column_not_found(column.to_string()))?;
246        let col = &mut self.property_columns[idx];
247        for row in 0..rows {
248            col.set(row, Some(default))?;
249        }
250        self.mark_column_dirty_at(idx);
251        Ok(())
252    }
253
254    /// Restore stable column identifiers from shard payloads after a merge.
255    ///
256    /// Shard dumps carry the flushed identifiers; the fresh merge target
257    /// starts from dense indexes, so identifiers are re-applied here to keep
258    /// undo parameters keyed by id valid across checkpoints. Unknown names
259    /// are skipped. The allocator moves past the maximum restored id.
260    pub fn restore_prop_ids(&mut self, ids: &HashMap<Arc<str>, i32>) {
261        for (idx, schema) in self.property_schema.iter_mut().enumerate() {
262            if let Some(id) = ids.get(schema.name.as_ref()) {
263                schema.prop_id = *id;
264                if let Some(col) = self.property_columns.get_mut(idx) {
265                    col.col_id = *id;
266                }
267            }
268        }
269        let max_id = self
270            .property_schema
271            .iter()
272            .map(|s| s.prop_id)
273            .max()
274            .unwrap_or(-1);
275        self.next_prop_id = max_id
276            .saturating_add(1)
277            .max(self.property_schema.len() as i32);
278    }
279
280    /// Stable identifier for one column in this shard, if present.
281    pub fn prop_id_of(&self, name: &str) -> Option<i32> {
282        self.column_index
283            .get(name)
284            .map(|&idx| self.property_schema[idx].prop_id)
285    }
286}