use linkrs_core::{DataType, StorageError, StorageResult, Value};
use std::sync::Arc;
use super::core::EdgeStore;
use crate::schema::ChangeDetails;
use crate::types::StoragePropertyDef;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingAddColumnState {
Prepared,
Filled,
}
#[derive(Debug, Clone)]
pub struct PendingAddColumn {
pub name: String,
pub data_type: DataType,
pub nullable: bool,
pub default_value: Option<Value>,
pub state: PendingAddColumnState,
}
impl EdgeStore {
pub fn pending_add_column(&self) -> Option<&PendingAddColumn> {
self.pending_add_column.as_ref()
}
pub fn prepare_add_property(
&mut self,
name: String,
data_type: DataType,
nullable: bool,
default_value: Option<Value>,
) -> StorageResult<()> {
if !self.is_open {
return Err(StorageError::storage_not_open());
}
if matches!(
self.schema.record_form,
crate::edge::RecordForm::Pure | crate::edge::RecordForm::Bundled
) {
return Err(StorageError::invalid_operation(
crate::edge::INLINE_FORM_SCHEMA_CHANGE_MSG.to_string(),
));
}
if self.pending_add_column.is_some()
|| self.pending_drop_column.is_some()
|| self.pending_rename_column.is_some()
{
return Err(StorageError::invalid_operation(
crate::edge::SCHEMA_CHANGE_PENDING_MSG.to_string(),
));
}
if self.properties.has_property(&name) {
return Err(StorageError::column_already_exists(name));
}
if self
.schema
.properties
.iter()
.any(|prop| prop.name.as_ref() == name.as_str())
{
return Err(StorageError::column_already_exists(name));
}
if let Some(default) = &default_value {
if default.data_type() != data_type {
return Err(StorageError::type_mismatch(data_type, default.data_type()));
}
}
self.pending_add_column = Some(PendingAddColumn {
name,
data_type,
nullable,
default_value,
state: PendingAddColumnState::Prepared,
});
Ok(())
}
pub fn fill_pending_add_property(&mut self) -> StorageResult<()> {
let pending = self.pending_add_column.as_ref().ok_or_else(|| {
StorageError::invalid_operation("no pending add-column change to fill".to_string())
})?;
if pending.state != PendingAddColumnState::Prepared {
return Err(StorageError::invalid_operation(
"pending add-column change is already filled".to_string(),
));
}
let name = pending.name.clone();
let data_type = pending.data_type.clone();
let nullable = pending.nullable;
let default_value = pending.default_value.clone();
self.properties
.add_property(name.clone(), data_type, nullable)?;
if let Some(default) = &default_value {
if let Err(error) = self.properties.backfill_column(&name, default) {
let _ = self.properties.remove_property(&name);
return Err(error);
}
}
if let Some(pending) = self.pending_add_column.as_mut() {
pending.state = PendingAddColumnState::Filled;
}
let name_arc: Arc<str> = name.as_str().into();
self.trace_all_owner_groups_for_columns(std::slice::from_ref(&name_arc));
Ok(())
}
pub fn publish_pending_add_property(&mut self) -> StorageResult<()> {
let (name, data_type, nullable, default_value) = match self.pending_add_column.as_ref() {
Some(pending) if pending.state == PendingAddColumnState::Filled => (
pending.name.clone(),
pending.data_type.clone(),
pending.nullable,
pending.default_value.clone(),
),
Some(_) => {
return Err(StorageError::invalid_operation(
"pending add-column change must be filled before publishing".to_string(),
));
}
None => {
return Err(StorageError::invalid_operation(
"no pending add-column change to publish".to_string(),
));
}
};
if let Some(dir) = self.wal_dir.clone() {
super::wal::append_ops(
&dir,
&[super::wal::EdgeWalOp::SchemaAdd {
name: name.clone(),
data_type: data_type.clone(),
nullable,
default: default_value.clone(),
}],
)?;
}
let prop_def = StoragePropertyDef::new(name.clone().into(), data_type.clone());
let new_idx = self.schema.properties.len();
self.schema.properties.push(prop_def);
self.property_index_cache
.insert(name.clone().into(), new_idx);
if let Err(error) = self.record_schema_change(ChangeDetails::PropertyAdded {
name: name.clone(),
data_type,
nullable,
default_value,
}) {
self.schema.properties.pop();
let name_key: Arc<str> = name.as_str().into();
self.property_index_cache.remove(&name_key);
return Err(error);
}
self.pending_add_column = None;
self.mark_properties_dirty();
Ok(())
}
pub fn abort_pending_add_property(&mut self) -> StorageResult<()> {
let pending = self.pending_add_column.take().ok_or_else(|| {
StorageError::invalid_operation("no pending add-column change to abort".to_string())
})?;
if pending.state == PendingAddColumnState::Filled {
self.properties.remove_property(&pending.name)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edge::edge_table::config::EdgeTableConfig;
use crate::edge::edge_table::iterator::EdgeTableScanIterator;
use crate::edge::{EdgeRecord, EdgeSchema, EdgeStrategy, RecordForm};
use crate::types::StoragePropertyDef;
fn make_table() -> EdgeStore {
let schema = EdgeSchema {
label_id: 0,
label_name: "knows".into(),
src_label: 0,
dst_label: 0,
properties: vec![StoragePropertyDef::new(
"weight".into(),
linkrs_core::types::DataType::Double,
)],
oe_strategy: EdgeStrategy::Multiple,
ie_strategy: EdgeStrategy::Multiple,
schema_version: 1,
record_form: RecordForm::default(),
};
EdgeStore::with_config(schema, EdgeTableConfig::default()).expect("table builds")
}
#[test]
fn pending_conflict_uses_the_shared_wording_everywhere() {
let mut table = make_table();
table
.prepare_add_property("score".into(), DataType::Int, true, None)
.expect("first prepare succeeds");
let drop_err = table
.prepare_drop_property("weight")
.expect_err("second staged change must be rejected");
let rename_err = table
.prepare_rename_property("weight", "mass")
.expect_err("second staged change must be rejected");
assert!(
drop_err
.to_string()
.contains(crate::edge::SCHEMA_CHANGE_PENDING_MSG),
"unexpected wording: {}",
drop_err
);
assert!(
rename_err
.to_string()
.contains(crate::edge::SCHEMA_CHANGE_PENDING_MSG),
"unexpected wording: {}",
rename_err
);
}
#[test]
fn staged_add_column_full_lifecycle() {
let mut table = make_table();
table
.prepare_add_property("score".into(), DataType::Int, false, Some(Value::Int(0)))
.expect("prepare should succeed");
assert_eq!(
table.pending_add_column().expect("pending exists").state,
PendingAddColumnState::Prepared
);
assert!(!table.properties.has_property("score"));
table
.fill_pending_add_property()
.expect("fill should succeed");
assert!(table.properties.has_property("score"));
table
.publish_pending_add_property()
.expect("publish should succeed");
assert!(table.pending_add_column().is_none());
assert!(table.schema.properties.iter().any(|p| &*p.name == "score"));
assert!(table.property_index_cache.contains_key(&Arc::from("score")));
table
.insert_edge(
0,
1,
0,
&[
("weight".into(), Value::Double(1.0)),
("score".into(), Value::Int(9)),
],
100,
)
.expect("insert with new column should succeed");
let rows = table.properties.row_count();
assert!(rows >= 1);
}
#[test]
fn abort_after_prepare_leaves_no_trace() {
let mut table = make_table();
table
.prepare_add_property("score".into(), DataType::Int, true, None)
.expect("prepare should succeed");
table
.abort_pending_add_property()
.expect("abort should succeed");
assert!(table.pending_add_column().is_none());
assert!(!table.properties.has_property("score"));
assert!(!table.schema.properties.iter().any(|p| &*p.name == "score"));
table
.prepare_add_property("score".into(), DataType::Int, true, None)
.expect("re-prepare should succeed");
}
#[test]
fn abort_after_fill_removes_physical_column() {
let mut table = make_table();
table
.prepare_add_property("score".into(), DataType::Int, true, None)
.expect("prepare should succeed");
table
.fill_pending_add_property()
.expect("fill should succeed");
assert!(table.properties.has_property("score"));
table
.abort_pending_add_property()
.expect("abort should succeed");
assert!(!table.properties.has_property("score"));
assert!(!table.schema.properties.iter().any(|p| &*p.name == "score"));
}
#[test]
fn illegal_transitions_are_rejected() {
let mut table = make_table();
assert!(table.fill_pending_add_property().is_err());
assert!(table.publish_pending_add_property().is_err());
assert!(table.abort_pending_add_property().is_err());
table
.prepare_add_property("score".into(), DataType::Int, true, None)
.expect("first prepare should succeed");
assert!(table
.prepare_add_property("other".into(), DataType::Int, true, None)
.is_err());
assert!(table.publish_pending_add_property().is_err());
table
.fill_pending_add_property()
.expect("fill should succeed");
assert!(table.fill_pending_add_property().is_err());
let mut dup = make_table();
assert!(dup
.prepare_add_property("weight".into(), DataType::Double, true, None)
.is_err());
assert!(dup
.prepare_add_property(
"score".into(),
DataType::Int,
true,
Some(Value::Double(1.0)),
)
.is_err());
}
#[test]
fn default_value_backfills_existing_rows() {
let mut table = make_table();
table
.insert_edge(0, 1, 0, &[("weight".into(), Value::Double(1.0))], 100)
.expect("insert should succeed");
table
.prepare_add_property("score".into(), DataType::Int, false, Some(Value::Int(7)))
.expect("prepare should succeed");
table
.fill_pending_add_property()
.expect("fill should succeed");
table
.publish_pending_add_property()
.expect("publish should succeed");
let records: Vec<EdgeRecord> = EdgeTableScanIterator::new(&table, 200).collect();
assert_eq!(records.len(), 1);
assert!(records[0]
.properties
.iter()
.any(|(k, v)| &**k == "score" && v == &Value::Int(7)));
}
#[test]
fn immediate_add_uses_the_staged_path() {
let mut table = make_table();
table
.add_property("score".into(), DataType::Int, true)
.expect("immediate add should succeed");
assert!(table.pending_add_column().is_none());
assert!(table.properties.has_property("score"));
assert!(table.schema.properties.iter().any(|p| &*p.name == "score"));
}
}