use crate::datatypes::values::{classify_value_set, ValueSetType};
use crate::datatypes::{DataFrame, Value};
use crate::graph::constraints::{ConstraintResult, UniqueConstraintKey};
use crate::graph::introspection::reporting::{ConnectionOperationReport, NodeOperationReport};
use crate::graph::mutation::batch::{
BatchProcessor, BatchStats, ConflictHandling, ConnectionBatchProcessor, NodeAction,
};
use crate::graph::mutation::delete_state::remove_doomed_nodes;
use crate::graph::mutation::edge_props::{
intern_edge_props, register_used_edge_property_names, resolve_edge_property_columns,
};
use crate::graph::mutation::rel_constraint_gate::{ConnectionBatchGate, RowFolding};
use crate::graph::schema::{
CompositeValue, CurrentSelection, DirGraph, InternedKey, TypeSchema, PROVISIONAL_KEY,
RESERVED_PROVENANCE_KEYS,
};
use crate::graph::storage::lookups::CombinedTypeLookup;
use crate::graph::storage::undo::BucketId;
use crate::graph::storage::{GraphRead, GraphWrite};
use petgraph::graph::{EdgeIndex, NodeIndex};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub struct AddPropertiesReport {
pub nodes_updated: usize,
pub properties_set: usize,
}
struct ConstraintColumns {
by_name: HashMap<String, usize>,
}
struct GateRow<'a> {
df_data: &'a DataFrame,
row_idx: usize,
id: &'a Value,
title: &'a Value,
id_field: &'a str,
title_field: &'a str,
}
impl ConstraintColumns {
fn for_batch(graph: &mut DirGraph, node_type: &str, df_data: &DataFrame) -> Option<Self> {
graph.clear_pending_constraint_violation();
(graph.has_unique_constraints()
|| graph.has_required_fields(node_type)
|| graph.type_has_property_type_constraints(node_type))
.then(|| Self::new(df_data))
}
fn gate_row_parked(
&self,
graph: &mut DirGraph,
node_type: &str,
row: GateRow<'_>,
existing_idx: Option<NodeIndex>,
batch_claims: &mut HashSet<(UniqueConstraintKey, CompositeValue)>,
) -> Result<(), String> {
let gated = self.gate_row(graph, node_type, row, existing_idx, batch_claims);
match gated {
Ok(()) => Ok(()),
Err(violation) => Err(graph.record_constraint_violation(*violation)),
}
}
fn new(df_data: &DataFrame) -> Self {
let by_name = df_data
.get_column_names()
.into_iter()
.filter_map(|name| df_data.get_column_index(&name).map(|idx| (name, idx)))
.collect();
Self { by_name }
}
fn read(&self, df_data: &DataFrame, row_idx: usize, property: &str) -> Option<Value> {
let column = *self.by_name.get(property)?;
match df_data.get_value_by_index(row_idx, column) {
Some(Value::Null) | None => None,
Some(value) => Some(value),
}
}
fn gate_row(
&self,
graph: &DirGraph,
node_type: &str,
row: GateRow<'_>,
existing_idx: Option<NodeIndex>,
batch_claims: &mut HashSet<(UniqueConstraintKey, CompositeValue)>,
) -> ConstraintResult<()> {
let read = |property: &str| -> Option<Value> {
if property == "id" || property == row.id_field {
return (!matches!(row.id, Value::Null)).then(|| row.id.clone());
}
if property == "title" || property == row.title_field {
return (!matches!(row.title, Value::Null)).then(|| row.title.clone());
}
self.read(row.df_data, row.row_idx, property)
};
graph.check_required_fields(node_type, read)?;
graph.check_property_types(node_type, read)?;
let claims = graph.unique_claims(node_type, read);
graph.check_unique_claims(&claims, existing_idx)?;
for claim in &claims {
if !batch_claims.insert((claim.key.clone(), claim.value.clone())) {
return Err(Box::new(graph.unique_batch_conflict(claim)));
}
}
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
fn gate_batch(
graph: &mut DirGraph,
node_type: &str,
df_data: &DataFrame,
columns: Option<&ConstraintColumns>,
pk_enforced: bool,
id_idx: usize,
title_idx: usize,
unique_id_field: &str,
title_field: &str,
) -> Result<(), String> {
if columns.is_none() && !pk_enforced {
return Ok(());
}
let mut batch_claims: HashSet<(UniqueConstraintKey, CompositeValue)> = HashSet::new();
let mut seen_pk_ids: HashSet<Value> = if pk_enforced {
HashSet::with_capacity(df_data.row_count())
} else {
HashSet::new()
};
for row_idx in 0..df_data.row_count() {
let Some(id) = df_data.get_value_by_index(row_idx, id_idx) else {
continue;
};
if matches!(id, Value::Null) {
continue;
}
if pk_enforced && !seen_pk_ids.insert(id.clone()) {
return Err(format!(
"duplicate primary key: node type '{node_type}' declares a primary key but \
the input has more than one row with id {id}. Deduplicate the input before \
add_nodes, or drop the primary-key declaration."
));
}
let Some(columns) = columns else {
continue;
};
let title = df_data
.get_value_by_index(row_idx, title_idx)
.unwrap_or(Value::Null);
let existing_idx = graph.id_indices.lookup(node_type, &id);
columns.gate_row_parked(
graph,
node_type,
GateRow {
df_data,
row_idx,
id: &id,
title: &title,
id_field: unique_id_field,
title_field,
},
existing_idx,
&mut batch_claims,
)?;
}
Ok(())
}
fn check_data_validity(df_data: &DataFrame, unique_id_field: &str) -> Result<(), String> {
if !df_data.verify_column(unique_id_field) {
let available_cols: Vec<_> = df_data.get_column_names();
return Err(format!(
"Column '{}' not found in DataFrame. Available columns: [{}]",
unique_id_field,
available_cols.join(", ")
));
}
Ok(())
}
fn get_column_types(df_data: &DataFrame) -> HashMap<String, String> {
let mut types = HashMap::new();
for col_name in df_data.get_column_names() {
if let Some(col_type) = df_data.get_column_type(&col_name) {
types.insert(col_name.clone(), col_type.to_string());
}
}
types
}
fn preflight_interner_names<'a>(
graph: &DirGraph,
names: impl IntoIterator<Item = &'a str>,
) -> Result<(), String> {
graph
.interner
.validate_names(names)
.map(|_| ())
.map_err(|e| e.to_string())
}
fn describe_skipped_rows(
errors: &mut Vec<String>,
skipped_null_id: usize,
skipped_parse_fail: usize,
unique_id_field: &str,
) {
if skipped_null_id > 0 {
errors.push(format!(
"Skipped {skipped_null_id} rows: null values in ID field '{unique_id_field}'"
));
}
if skipped_parse_fail > 0 {
errors.push(format!(
"Skipped {skipped_parse_fail} rows: no usable value in ID field \
'{unique_id_field}' — the cell was empty, or held something the column's \
stored key type cannot represent. If the ids are integers, pass \
column_types={{'{unique_id_field}': 'int64'}}; if they are text, pass \
column_types={{'{unique_id_field}': 'string'}}. Both change the stored key \
type, so name the one the ids actually are"
));
}
}
struct UpdateFold {
properties: Vec<String>,
capturing: bool,
seen: HashSet<NodeIndex>,
pre_images: Vec<crate::graph::dir_graph::indexes::UpdatedRowPreImage>,
}
impl UpdateFold {
const CAPTURE_RATIO: usize = 4;
fn for_batch(graph: &DirGraph, node_type: &str, rows: usize) -> Self {
let properties = graph.maintained_index_properties(node_type);
let maintains = !properties.is_empty() || graph.type_has_unique_constraints(node_type);
let members = graph.type_indices.get(node_type).map_or(0, |m| m.len());
Self {
properties,
capturing: maintains && rows.saturating_mul(Self::CAPTURE_RATIO) < members,
seen: HashSet::new(),
pre_images: Vec::new(),
}
}
fn observe(&mut self, graph: &mut DirGraph, node_type: &str, existing_idx: Option<NodeIndex>) {
let Some(node_idx) = existing_idx.filter(|_| self.capturing) else {
return;
};
if self.seen.insert(node_idx) {
self.pre_images.push(graph.capture_update_pre_image(
node_type,
node_idx,
&self.properties,
));
}
}
fn pre_images(
&self,
updated: usize,
) -> Option<&[crate::graph::dir_graph::indexes::UpdatedRowPreImage]> {
(self.capturing || updated == 0).then_some(&self.pre_images[..])
}
fn fold_or_rebuild(&self, graph: &mut DirGraph, node_type: &str, stats: BatchStats) {
let folded = self.pre_images(stats.updates).is_some_and(|pre_images| {
graph.fold_batch_into_user_indexes(node_type, stats.creates, pre_images)
});
if !folded {
graph.refresh_indexes_for_type(node_type);
}
}
}
struct RowBuilder<'a> {
node_type: &'a str,
interned_columns: &'a [(InternedKey, usize)],
provenance_stamps: &'a [(InternedKey, Value)],
property_count: usize,
should_update_title: bool,
conflict_mode: ConflictHandling,
}
impl RowBuilder<'_> {
fn properties(&self, df_data: &DataFrame, row_idx: usize) -> Vec<(InternedKey, Value)> {
let mut properties = Vec::with_capacity(self.property_count);
for (interned_key, col_idx) in self.interned_columns {
let value = df_data
.get_value_by_index(row_idx, *col_idx)
.unwrap_or(Value::Null);
if !matches!(value, Value::Null) {
properties.push((*interned_key, value));
}
}
for (key, value) in self.provenance_stamps {
properties.retain(|(interned, _)| interned != key);
properties.push((*key, value.clone()));
}
properties
}
fn action(
&self,
df_data: &DataFrame,
row_idx: usize,
id: Value,
title: Value,
existing_idx: Option<NodeIndex>,
) -> NodeAction {
let properties_interned = self.properties(df_data, row_idx);
match existing_idx {
Some(node_idx) => NodeAction::Update {
node_idx,
title: self.should_update_title.then_some(title),
properties: properties_interned,
conflict_mode: self.conflict_mode,
},
None => NodeAction::CreateInterned {
node_type: self.node_type.to_string(),
id,
title,
properties: properties_interned,
},
}
}
}
fn parse_conflict_mode(option: Option<&str>) -> Result<ConflictHandling, String> {
match option {
Some("replace") => Ok(ConflictHandling::Replace),
Some("skip") => Ok(ConflictHandling::Skip),
Some("preserve") => Ok(ConflictHandling::Preserve),
Some("sum") => Ok(ConflictHandling::Sum),
Some("update") | None => Ok(ConflictHandling::Update),
Some(other) => Err(format!(
"Unknown conflict handling mode: '{}'. Valid options: 'update' (default), 'replace', 'skip', 'preserve', 'sum'",
other
)),
}
}
fn note_loaded_id(max_loaded_id: &mut u32, id: &Value) {
match id {
Value::UniqueId(u) => *max_loaded_id = (*max_loaded_id).max(*u),
Value::Int64(i) if *i >= 0 && *i <= u32::MAX as i64 => {
*max_loaded_id = (*max_loaded_id).max(*i as u32)
}
_ => {}
}
}
fn install_node_type_metadata(
graph: &mut DirGraph,
node_type: &str,
df_data: &DataFrame,
unique_id_field: &str,
title_field: &str,
should_update_title: bool,
errors: &mut Vec<String>,
) {
let df_column_types = get_column_types(df_data);
if let Some(existing_meta) = graph.get_node_type_metadata(node_type) {
for (col_name, col_type) in &df_column_types {
if let Some(existing_type) = existing_meta.get(col_name) {
if existing_type != col_type {
errors.push(format!(
"Type mismatch for property '{}': existing schema has '{}', but data has '{}'",
col_name, existing_type, col_type
));
}
}
}
}
graph.upsert_node_type_metadata(node_type, df_column_types);
if unique_id_field != "id" {
graph
.id_field_aliases_mut()
.insert(node_type.to_string(), unique_id_field.to_string());
}
if should_update_title && title_field != "title" {
graph
.title_field_aliases_mut()
.insert(node_type.to_string(), title_field.to_string());
}
}
fn install_type_schema(
graph: &mut DirGraph,
node_type: &str,
property_columns: &[(String, usize)],
) -> Vec<(InternedKey, Value)> {
let mut schema_keys: Vec<InternedKey> = property_columns
.iter()
.map(|(col_name, _)| graph.interner.get_or_intern(col_name))
.collect();
let provenance_stamps: Vec<(InternedKey, Value)> = if graph.auto_timestamp_for(node_type) {
graph
.provenance_props()
.into_iter()
.map(|(name, value)| (graph.interner.get_or_intern(name), value))
.collect()
} else {
Vec::new()
};
schema_keys.extend(provenance_stamps.iter().map(|(key, _)| *key));
let type_schema = Arc::new(TypeSchema::from_keys(schema_keys));
let existing = graph.type_schemas.get(node_type).cloned();
if let Some(existing_schema) = existing {
let mut merged = (*existing_schema).clone();
for (_, key) in type_schema.iter() {
merged.add_key(key);
}
let merged_arc = Arc::new(merged);
graph
.type_schemas_mut()
.insert(node_type.to_string(), merged_arc);
} else {
graph
.type_schemas_mut()
.insert(node_type.to_string(), type_schema);
}
provenance_stamps
}
pub fn add_nodes(
graph: &mut DirGraph,
df_data: DataFrame,
node_type: String,
unique_id_field: String,
node_title_field: Option<String>,
conflict_handling: Option<String>,
) -> Result<NodeOperationReport, String> {
let _arena_guard = graph.graph.begin_query(); let mut interned_names = vec![node_type.as_str(), PROVISIONAL_KEY];
interned_names.extend(RESERVED_PROVENANCE_KEYS.iter().copied());
let column_names = df_data.get_column_names();
interned_names.extend(column_names.iter().map(String::as_str));
preflight_interner_names(graph, interned_names)?;
graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
let conflict_mode = parse_conflict_mode(conflict_handling.as_deref())?;
let should_update_title = node_title_field.is_some();
let title_field = node_title_field.unwrap_or_else(|| unique_id_field.clone());
check_data_validity(&df_data, &unique_id_field)?;
let mut errors = Vec::new();
graph.build_id_index(&node_type);
let id_idx = df_data
.get_column_index(&unique_id_field)
.ok_or_else(|| format!("Column '{}' not found", unique_id_field))?;
let title_idx = df_data
.get_column_index(&title_field)
.ok_or_else(|| format!("Column '{}' not found", title_field))?;
let constraint_columns = ConstraintColumns::for_batch(graph, &node_type, &df_data);
let pk_enforced = graph.primary_key_for(&node_type).is_some();
gate_batch(
graph,
&node_type,
&df_data,
constraint_columns.as_ref(),
pk_enforced,
id_idx,
title_idx,
&unique_id_field,
&title_field,
)?;
install_node_type_metadata(
graph,
&node_type,
&df_data,
&unique_id_field,
&title_field,
should_update_title,
&mut errors,
);
let property_columns: Vec<(String, usize)> = df_data
.get_column_names()
.into_iter()
.filter_map(|col_name| {
if col_name != unique_id_field && col_name != title_field {
df_data
.get_column_index(&col_name)
.map(|idx| (col_name, idx))
} else {
None
}
})
.collect();
let provenance_stamps = install_type_schema(graph, &node_type, &property_columns);
let interned_columns: Vec<(InternedKey, usize)> = property_columns
.iter()
.map(|(col_name, col_idx)| (graph.interner.get_or_intern(col_name), *col_idx))
.collect();
let property_count = property_columns.len();
let mut batch = BatchProcessor::new(df_data.row_count());
let mut skipped_count = 0;
let mut skipped_null_id = 0;
let mut skipped_parse_fail = 0;
let row_builder = RowBuilder {
node_type: &node_type,
interned_columns: &interned_columns,
provenance_stamps: &provenance_stamps,
property_count,
should_update_title,
conflict_mode,
};
let mut update_fold = UpdateFold::for_batch(graph, &node_type, df_data.row_count());
let mut max_loaded_id: u32 = 0;
for row_idx in 0..df_data.row_count() {
let id = match df_data.get_value_by_index(row_idx, id_idx) {
Some(Value::Null) => {
skipped_count += 1;
skipped_null_id += 1;
continue;
}
Some(id) => id,
None => {
skipped_count += 1;
skipped_parse_fail += 1;
continue;
}
};
note_loaded_id(&mut max_loaded_id, &id);
let title = df_data
.get_value_by_index(row_idx, title_idx)
.unwrap_or(Value::Null);
let existing_idx = graph.id_indices.lookup(&node_type, &id);
update_fold.observe(graph, &node_type, existing_idx);
let action = row_builder.action(&df_data, row_idx, id, title, existing_idx);
batch.add_action(action, graph)?;
}
graph.observe_explicit_id(&Value::UniqueId(max_loaded_id));
describe_skipped_rows(
&mut errors,
skipped_null_id,
skipped_parse_fail,
&unique_id_field,
);
let (stats, metrics) = batch.execute(graph)?;
if !graph.fold_appended_ids_into_index(&node_type, stats.creates) {
graph.id_indices.remove(&node_type);
graph.build_id_index(&node_type);
}
update_fold.fold_or_rebuild(graph, &node_type, stats);
let elapsed_ms = metrics.processing_time * 1000.0;
let mut report = NodeOperationReport::new(
"add_nodes".to_string(),
stats.creates,
stats.updates,
skipped_count,
elapsed_ms,
);
if !errors.is_empty() {
report = report.with_errors(errors);
}
graph.bump_version();
Ok(report)
}
#[derive(Debug, Clone)]
pub struct EdgeSpec {
pub source_type: String,
pub source_id: Value,
pub target_type: String,
pub target_id: Value,
pub edge_type: String,
pub properties: HashMap<String, Value>,
}
#[derive(Debug, Default, Clone)]
pub struct EdgeSpecReport {
pub connections_created: usize,
pub skipped_missing_endpoint: usize,
}
pub fn add_edges_from_specs(
graph: &mut DirGraph,
specs: Vec<EdgeSpec>,
) -> Result<EdgeSpecReport, String> {
if specs.is_empty() {
return Ok(EdgeSpecReport::default());
}
let _arena_guard = graph.graph.begin_query(); use std::collections::BTreeMap;
let mut interned_names = Vec::from(RESERVED_PROVENANCE_KEYS);
for spec in &specs {
interned_names.extend([
spec.source_type.as_str(),
spec.target_type.as_str(),
spec.edge_type.as_str(),
]);
interned_names.extend(spec.properties.keys().map(String::as_str));
}
preflight_interner_names(graph, interned_names)?;
graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
type EdgeRows = Vec<(Value, Value, HashMap<String, Value>)>;
let mut groups: BTreeMap<(String, String, String), EdgeRows> = BTreeMap::new();
for spec in specs {
groups
.entry((spec.source_type, spec.target_type, spec.edge_type))
.or_default()
.push((spec.source_id, spec.target_id, spec.properties));
}
let mut report = EdgeSpecReport::default();
let mut lookup_cache: HashMap<(String, String), CombinedTypeLookup> = HashMap::new();
for ((source_type, target_type, edge_type), edges) in groups {
let pair = (source_type.clone(), target_type.clone());
if !lookup_cache.contains_key(&pair) {
let lookup = CombinedTypeLookup::from_id_indices(
&graph.id_indices,
&graph.graph,
source_type.clone(),
target_type.clone(),
)?;
lookup_cache.insert(pair.clone(), lookup);
}
let lookup = &lookup_cache[&pair];
let mut batch = ConnectionBatchProcessor::new(edges.len());
let is_initial_load = !graph.connection_type_metadata.contains_key(&edge_type);
batch.set_skip_existence_check(is_initial_load);
for (source_id, target_id, props) in edges {
match (
lookup.check_source(&source_id),
lookup.check_target(&target_id),
) {
(Some(src_idx), Some(tgt_idx)) => {
let props: Vec<(InternedKey, Value)> = props
.into_iter()
.map(|(k, v)| (graph.interner.get_or_intern(&k), v))
.collect();
batch.add_connection(src_idx, tgt_idx, props, graph, &edge_type)?;
}
_ => report.skipped_missing_endpoint += 1,
}
}
update_schema_node(
graph,
&edge_type,
&source_type,
&target_type,
batch.schema_property_types(graph),
)?;
let (stats, _metrics) = batch.execute(graph, edge_type)?;
report.connections_created += stats.connections_created;
}
graph.bump_version();
Ok(report)
}
struct ResolvedEndpoints {
matched: Vec<(usize, NodeIndex, NodeIndex)>,
deferred: Vec<(usize, Value, Value)>,
missing_sources: Vec<Value>,
missing_targets: Vec<Value>,
null_source_rows: usize,
null_target_rows: usize,
}
fn resolve_endpoints(
graph: &DirGraph,
df_data: &DataFrame,
source_type: &str,
target_type: &str,
source_id_idx: usize,
target_id_idx: usize,
) -> Result<ResolvedEndpoints, String> {
if let Some(resolved) =
graph
.id_indices
.with_overlay_type_pair(source_type, target_type, |source, target| {
scan_endpoint_rows(
df_data,
source_id_idx,
target_id_idx,
|id| source.get(id),
|id| target.get(id),
)
})
{
return Ok(resolved);
}
let lookup = CombinedTypeLookup::from_id_indices(
&graph.id_indices,
&graph.graph,
source_type.to_string(),
target_type.to_string(),
)?;
Ok(scan_endpoint_rows(
df_data,
source_id_idx,
target_id_idx,
|id| lookup.check_source(id),
|id| lookup.check_target(id),
))
}
fn scan_endpoint_rows(
df_data: &DataFrame,
source_id_idx: usize,
target_id_idx: usize,
check_source: impl Fn(&Value) -> Option<NodeIndex>,
check_target: impl Fn(&Value) -> Option<NodeIndex>,
) -> ResolvedEndpoints {
let mut out = ResolvedEndpoints {
matched: Vec::with_capacity(df_data.row_count()),
deferred: Vec::new(),
missing_sources: Vec::new(),
missing_targets: Vec::new(),
null_source_rows: 0,
null_target_rows: 0,
};
let mut seen_missing_source: HashSet<Value> = HashSet::new();
let mut seen_missing_target: HashSet<Value> = HashSet::new();
for row_idx in 0..df_data.row_count() {
let source_id = match df_data.get_value_by_index(row_idx, source_id_idx) {
Some(Value::Null) | None => {
out.null_source_rows += 1;
continue;
}
Some(id) => id,
};
let target_id = match df_data.get_value_by_index(row_idx, target_id_idx) {
Some(Value::Null) | None => {
out.null_target_rows += 1;
continue;
}
Some(id) => id,
};
match (check_source(&source_id), check_target(&target_id)) {
(Some(source_idx), Some(target_idx)) => {
out.matched.push((row_idx, source_idx, target_idx))
}
(s_opt, t_opt) => {
if s_opt.is_none() && seen_missing_source.insert(source_id.clone()) {
out.missing_sources.push(source_id.clone());
}
if t_opt.is_none() && seen_missing_target.insert(target_id.clone()) {
out.missing_targets.push(target_id.clone());
}
out.deferred.push((row_idx, source_id, target_id));
}
}
}
out
}
fn resolve_pairs(
graph: &DirGraph,
source_type: &str,
target_type: &str,
rows: &[(usize, Value, Value)],
) -> Result<Vec<Option<(NodeIndex, NodeIndex)>>, String> {
fn walk(
rows: &[(usize, Value, Value)],
check_source: impl Fn(&Value) -> Option<NodeIndex>,
check_target: impl Fn(&Value) -> Option<NodeIndex>,
) -> Vec<Option<(NodeIndex, NodeIndex)>> {
rows.iter()
.map(|(_, source_id, target_id)| {
match (check_source(source_id), check_target(target_id)) {
(Some(s), Some(t)) => Some((s, t)),
_ => None,
}
})
.collect()
}
if let Some(resolved) =
graph
.id_indices
.with_overlay_type_pair(source_type, target_type, |source, target| {
walk(rows, |id| source.get(id), |id| target.get(id))
})
{
return Ok(resolved);
}
let lookup = CombinedTypeLookup::from_id_indices(
&graph.id_indices,
&graph.graph,
source_type.to_string(),
target_type.to_string(),
)?;
Ok(walk(
rows,
|id| lookup.check_source(id),
|id| lookup.check_target(id),
))
}
fn title_column_indices(
df_data: &DataFrame,
source_title_field: Option<&str>,
target_title_field: Option<&str>,
) -> (Option<usize>, Option<usize>) {
(
source_title_field.and_then(|field| df_data.get_column_index(field)),
target_title_field.and_then(|field| df_data.get_column_index(field)),
)
}
fn report_null_id_skips(errors: &mut Vec<String>, source: (usize, &str), target: (usize, &str)) {
for (skipped, field, side) in [
(source.0, source.1, "source"),
(target.0, target.1, "target"),
] {
if skipped > 0 {
errors.push(format!(
"Skipped {skipped} rows: null values in {side} ID field '{field}'"
));
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn add_connections(
graph: &mut DirGraph,
df_data: DataFrame,
connection_type: String,
source_type: String,
source_id_field: String,
target_type: String,
target_id_field: String,
source_title_field: Option<String>,
target_title_field: Option<String>,
conflict_handling: Option<String>,
) -> Result<ConnectionOperationReport, String> {
let _arena_guard = graph.graph.begin_query(); let column_names = df_data.get_column_names();
let mut interned_names = vec![
connection_type.as_str(),
source_type.as_str(),
target_type.as_str(),
PROVISIONAL_KEY,
];
interned_names.extend(RESERVED_PROVENANCE_KEYS.iter().copied());
interned_names.extend(column_names.iter().map(String::as_str));
preflight_interner_names(graph, interned_names)?;
graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
let conflict_mode = parse_conflict_mode(conflict_handling.as_deref())?;
let mut errors = Vec::new();
let available_cols: Vec<_> = df_data.get_column_names();
if !df_data.verify_column(&source_id_field) {
return Err(format!(
"Source ID column '{}' not found in DataFrame. Available columns: [{}]",
source_id_field,
available_cols.join(", ")
));
}
if !df_data.verify_column(&target_id_field) {
return Err(format!(
"Target ID column '{}' not found in DataFrame. Available columns: [{}]",
target_id_field,
available_cols.join(", ")
));
}
let source_id_idx = df_data
.get_column_index(&source_id_field)
.ok_or_else(|| format!("Source ID column '{}' not found", source_id_field))?;
let target_id_idx = df_data
.get_column_index(&target_id_field)
.ok_or_else(|| format!("Target ID column '{}' not found", target_id_field))?;
let (source_title_idx, target_title_idx) = title_column_indices(
&df_data,
source_title_field.as_deref(),
target_title_field.as_deref(),
);
let ResolvedEndpoints {
matched,
deferred,
missing_sources,
missing_targets,
null_source_rows: skipped_null_source,
null_target_rows: skipped_null_target,
} = resolve_endpoints(
graph,
&df_data,
&source_type,
&target_type,
source_id_idx,
target_id_idx,
)?;
let mut batch = ConnectionBatchProcessor::new(df_data.row_count());
batch.set_conflict_mode(conflict_mode);
let is_initial_load = !graph
.connection_type_metadata
.contains_key(&connection_type);
batch.set_skip_existence_check(is_initial_load);
let mut skipped_count = skipped_null_source + skipped_null_target;
let property_columns = resolve_edge_property_columns(
&df_data,
&source_id_field,
&target_id_field,
source_title_field.as_deref(),
target_title_field.as_deref(),
);
let extract_props = |row_idx: usize| -> Vec<(InternedKey, Value)> {
let mut properties = Vec::with_capacity(property_columns.len());
for (_, interned_key, col_idx) in &property_columns {
if let Some(value) = df_data.get_value_by_index(row_idx, *col_idx) {
if !matches!(value, Value::Null) {
properties.push((*interned_key, value));
}
}
}
properties
};
ConnectionBatchGate {
connection_type: &connection_type,
df_data: &df_data,
property_columns: &property_columns,
matched: &matched,
deferred: &deferred,
conflict_mode,
folding: RowFolding::for_load(is_initial_load),
}
.run(graph)?;
for (row_idx, source_idx, target_idx) in matched {
update_node_titles(
graph,
source_idx,
target_idx,
row_idx,
source_title_idx,
target_title_idx,
&df_data,
)?;
if let Err(e) = batch.add_connection(
source_idx,
target_idx,
extract_props(row_idx),
graph,
&connection_type,
) {
skipped_count += 1;
errors.push(format!("Failed to add connection: {}", e));
}
}
let mut stubs_vivified = 0usize;
if !missing_sources.is_empty() {
stubs_vivified += vivify_stubs(graph, &source_type, &missing_sources)?;
}
if !missing_targets.is_empty() {
stubs_vivified += vivify_stubs(graph, &target_type, &missing_targets)?;
}
if !deferred.is_empty() {
let replayed = resolve_pairs(graph, &source_type, &target_type, &deferred)?;
for ((row_idx, _, _), endpoints) in deferred.iter().zip(replayed) {
let row_idx = *row_idx;
let (source_idx, target_idx) = match endpoints {
Some(pair) => pair,
None => {
skipped_count += 1;
continue;
}
};
update_node_titles(
graph,
source_idx,
target_idx,
row_idx,
source_title_idx,
target_title_idx,
&df_data,
)?;
if let Err(e) = batch.add_connection(
source_idx,
target_idx,
extract_props(row_idx),
graph,
&connection_type,
) {
skipped_count += 1;
errors.push(format!("Failed to add connection: {}", e));
}
}
}
report_null_id_skips(
&mut errors,
(skipped_null_source, &source_id_field),
(skipped_null_target, &target_id_field),
);
register_used_edge_property_names(
&mut graph.interner,
&property_columns,
batch.get_schema_properties(),
);
update_schema_node(
graph,
&connection_type,
&source_type,
&target_type,
batch.schema_property_types(graph),
)?;
let (stats, metrics) = batch.execute(graph, connection_type)?;
if stats.connections_created > 0 {
graph.invalidate_edge_type_counts_cache();
}
let mut report = ConnectionOperationReport::new(
"add_connections".to_string(),
stats.connections_created,
skipped_count,
stats.properties_tracked,
metrics.processing_time * 1000.0,
);
report.stubs_vivified = stubs_vivified;
if !errors.is_empty() {
report = report.with_errors(errors);
}
graph.bump_version();
Ok(report)
}
fn vivify_stubs(graph: &mut DirGraph, node_type: &str, ids: &[Value]) -> Result<usize, String> {
let rows: Vec<Vec<Value>> = ids
.iter()
.map(|id| vec![id.clone(), Value::Boolean(true)])
.collect();
let df =
DataFrame::from_cypher_rows(vec!["id".to_string(), PROVISIONAL_KEY.to_string()], rows)?;
let report = add_nodes(
graph,
df,
node_type.to_string(),
"id".to_string(),
None,
Some("preserve".to_string()),
)?;
Ok(report.nodes_created)
}
const POSITIONAL_MAX_SHARE: usize = 32;
const POSITIONAL_MIN_BUCKET: usize = 1024;
fn doomed_bucket_positions(
graph: &mut DirGraph,
doomed_ids: &HashMap<String, Vec<(Value, NodeIndex)>>,
nodes_to_delete: &HashSet<NodeIndex>,
) -> HashMap<String, Vec<(usize, NodeIndex)>> {
let typed: usize = doomed_ids.values().map(Vec::len).sum();
if typed != nodes_to_delete.len() {
return HashMap::new();
}
let mut resolved = HashMap::with_capacity(doomed_ids.len());
for (node_type, entries) in doomed_ids {
let bucket_len = graph
.type_indices
.get(node_type)
.map(|members| members.len())
.unwrap_or(0);
if bucket_len > POSITIONAL_MIN_BUCKET
&& entries.len().saturating_mul(POSITIONAL_MAX_SHARE) > bucket_len
{
continue;
}
let members: Vec<NodeIndex> = entries.iter().map(|(_, idx)| *idx).collect();
if let Some(hits) = graph.type_indices.positions_of(node_type, &members) {
resolved.insert(node_type.clone(), hits);
}
}
resolved
}
fn journal_bucket_evictions(
graph: &mut DirGraph,
affected_types: &HashSet<String>,
nodes_to_delete: &HashSet<NodeIndex>,
bucket_positions: &HashMap<String, Vec<(usize, NodeIndex)>>,
) {
if graph.graph.undo_journal_mut().is_none() {
return;
}
let mut positional: Vec<(BucketId, usize, NodeIndex)> = Vec::new();
let mut evictions: Vec<(BucketId, Vec<NodeIndex>)> = Vec::new();
for node_type in affected_types {
if let Some(hits) = bucket_positions.get(node_type) {
positional.extend(
hits.iter()
.rev()
.map(|(pos, idx)| (BucketId::NodeType(node_type.clone()), *pos, *idx)),
);
} else if let Some(members) = graph.type_indices.get(node_type) {
evictions.push((BucketId::NodeType(node_type.clone()), members.to_vec()));
}
for (key, value_map) in &graph.property_indices {
if &key.0 != node_type {
continue;
}
for (value, members) in value_map.iter() {
if members.iter().any(|idx| nodes_to_delete.contains(idx)) {
evictions.push((
BucketId::PropertyValue {
key: key.clone(),
value: value.clone(),
},
members.clone(),
));
}
}
}
for (key, btree) in &graph.range_indices {
if &key.0 != node_type {
continue;
}
for (value, members) in btree.iter() {
if members.iter().any(|idx| nodes_to_delete.contains(idx)) {
evictions.push((
BucketId::RangeValue {
key: key.clone(),
value: value.clone(),
},
members.clone(),
));
}
}
}
for (key, comp_map) in &graph.composite_indices {
if &key.0 != node_type {
continue;
}
for (value, members) in comp_map.iter() {
if members.iter().any(|idx| nodes_to_delete.contains(idx)) {
evictions.push((
BucketId::CompositeTuple {
key: key.clone(),
value: value.clone(),
},
members.clone(),
));
}
}
}
}
if graph.has_secondary_labels {
for (label, members) in &graph.secondary_label_index {
evictions.push((BucketId::SecondaryLabel(*label), members.clone()));
}
}
if let Some(journal) = graph.graph.undo_journal_mut() {
for (bucket, pos, idx) in positional {
journal.note_bucket_removed(bucket, idx, pos);
}
for (bucket, members) in &evictions {
journal.note_bucket_retain(bucket, members.iter().copied(), nodes_to_delete);
}
}
}
pub(crate) fn detach_delete_nodes(
graph: &mut DirGraph,
nodes_to_delete: &HashSet<NodeIndex>,
) -> (usize, usize) {
if nodes_to_delete.is_empty() {
return (0, 0);
}
let mut deleted_edges: HashSet<EdgeIndex> = HashSet::new();
for &node_idx in nodes_to_delete {
let incident: Vec<EdgeIndex> = {
let _guard = graph.graph.begin_query();
graph
.graph
.edges_directed(node_idx, petgraph::Direction::Outgoing)
.chain(
graph
.graph
.edges_directed(node_idx, petgraph::Direction::Incoming),
)
.map(|e| e.id())
.collect()
};
for edge_idx in incident {
if deleted_edges.insert(edge_idx) {
GraphWrite::remove_edge(&mut graph.graph, edge_idx);
}
}
}
let edges_removed = deleted_edges.len();
if edges_removed > 0 {
graph.invalidate_edge_type_counts_cache();
graph.connection_types.clear();
}
let mut affected_types: HashSet<String> = HashSet::new();
let mut doomed_ids: HashMap<String, Vec<(Value, NodeIndex)>> = HashMap::new();
{
let _guard = graph.graph.begin_query();
for &node_idx in nodes_to_delete {
if let Some(node) = graph.graph.node_view(node_idx) {
let node_type = node.get_node_type_ref(&graph.interner).to_string();
let node_id = node.id().into_owned();
doomed_ids
.entry(node_type.clone())
.or_default()
.push((node_id, node_idx));
affected_types.insert(node_type);
}
}
}
let evictable: HashSet<String> = affected_types
.iter()
.filter(|node_type| {
let indexed = graph.id_indices.overlay_len(node_type);
let live = graph.type_indices.get(node_type).map(|m| m.len());
matches!((indexed, live), (Some(i), Some(l)) if i == l)
})
.cloned()
.collect();
remove_doomed_nodes(graph, nodes_to_delete);
let bucket_positions = doomed_bucket_positions(graph, &doomed_ids, nodes_to_delete);
journal_bucket_evictions(graph, &affected_types, nodes_to_delete, &bucket_positions);
for node_type in &affected_types {
match bucket_positions.get(node_type) {
Some(hits) => graph.type_indices.remove_positions(node_type, hits),
None => graph
.type_indices
.retain_in_type(node_type, |idx| !nodes_to_delete.contains(idx)),
}
match doomed_ids.get(node_type) {
Some(entries) if evictable.contains(node_type) => {
graph.id_indices.evict_entries(node_type, entries);
}
_ => {
graph.id_indices.remove(node_type);
}
}
let prop_keys: Vec<_> = graph
.property_indices
.keys()
.filter(|(nt, _)| nt == node_type)
.cloned()
.collect();
for key in prop_keys {
if let Some(value_map) = graph.property_indices.get_mut(&key) {
value_map.retain_members(|idx| !nodes_to_delete.contains(idx));
}
}
let comp_keys: Vec<_> = graph
.composite_indices
.keys()
.filter(|(nt, _)| nt == node_type)
.cloned()
.collect();
for key in comp_keys {
if let Some(value_map) = graph.composite_indices.get_mut(&key) {
value_map.retain_members(|idx| !nodes_to_delete.contains(idx));
}
}
let range_keys: Vec<_> = graph
.range_indices
.keys()
.filter(|(nt, _)| nt == node_type)
.cloned()
.collect();
for key in range_keys {
if let Some(value_map) = graph.range_indices.get_mut(&key) {
value_map.retain_members_pruning_empty(|idx| !nodes_to_delete.contains(idx));
}
}
graph.evict_unique_claims_for_nodes(node_type, nodes_to_delete);
}
if graph.has_secondary_labels {
graph.secondary_label_index.retain(|_, bucket| {
bucket.retain(|idx| !nodes_to_delete.contains(idx));
!bucket.is_empty()
});
if graph.secondary_label_index.is_empty() {
graph.has_secondary_labels = false;
}
}
(nodes_to_delete.len(), edges_removed)
}
#[allow(clippy::too_many_arguments)]
pub fn replace_connections(
graph: &mut DirGraph,
df_data: DataFrame,
connection_type: String,
source_type: String,
source_id_field: String,
target_type: String,
target_id_field: String,
source_title_field: Option<String>,
target_title_field: Option<String>,
conflict_handling: Option<String>,
) -> Result<ConnectionOperationReport, String> {
let _arena_guard = graph.graph.begin_query(); let column_names = df_data.get_column_names();
let mut interned_names = vec![
connection_type.as_str(),
source_type.as_str(),
target_type.as_str(),
PROVISIONAL_KEY,
];
interned_names.extend(RESERVED_PROVENANCE_KEYS.iter().copied());
interned_names.extend(column_names.iter().map(String::as_str));
preflight_interner_names(graph, interned_names)?;
graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
let available_cols: Vec<_> = df_data.get_column_names();
if !df_data.verify_column(&source_id_field) {
return Err(format!(
"Source ID column '{}' not found in DataFrame. Available columns: [{}]",
source_id_field,
available_cols.join(", ")
));
}
if !df_data.verify_column(&target_id_field) {
return Err(format!(
"Target ID column '{}' not found in DataFrame. Available columns: [{}]",
target_id_field,
available_cols.join(", ")
));
}
let conflict_mode = parse_conflict_mode(conflict_handling.as_deref())?;
let source_id_idx = df_data
.get_column_index(&source_id_field)
.ok_or_else(|| format!("Source ID column '{}' not found", source_id_field))?;
let target_id_idx = df_data
.get_column_index(&target_id_field)
.ok_or_else(|| format!("Target ID column '{}' not found", target_id_field))?;
let resolved = resolve_endpoints(
graph,
&df_data,
&source_type,
&target_type,
source_id_idx,
target_id_idx,
)?;
ConnectionBatchGate {
connection_type: &connection_type,
df_data: &df_data,
property_columns: &resolve_edge_property_columns(
&df_data,
&source_id_field,
&target_id_field,
source_title_field.as_deref(),
target_title_field.as_deref(),
),
matched: &resolved.matched,
deferred: &resolved.deferred,
conflict_mode,
folding: RowFolding::for_replace(graph, &connection_type),
}
.run(graph)?;
let mut stubs_vivified = 0usize;
if !resolved.missing_sources.is_empty() {
stubs_vivified += vivify_stubs(graph, &source_type, &resolved.missing_sources)?;
}
if !resolved.missing_targets.is_empty() {
stubs_vivified += vivify_stubs(graph, &target_type, &resolved.missing_targets)?;
}
drop(resolved);
let mut seen: HashSet<Value> = HashSet::new();
let mut distinct_sources: Vec<Value> = Vec::new();
for row in 0..df_data.row_count() {
if let Some(id) = df_data.get_value_by_index(row, source_id_idx) {
if matches!(id, Value::Null) {
continue;
}
if seen.insert(id.clone()) {
distinct_sources.push(id);
}
}
}
if graph.has_node_type(&source_type) {
let conn_key = InternedKey::from_str(&connection_type);
let mut to_remove: Vec<EdgeIndex> = Vec::new();
for id in &distinct_sources {
if let Some(node_idx) = graph.lookup_by_id_readonly(&source_type, id) {
for edge in graph.graph.edges_directed_filtered(
node_idx,
petgraph::Direction::Outgoing,
Some(conn_key),
) {
if edge.connection_type() == conn_key {
to_remove.push(edge.id());
}
}
}
}
if !to_remove.is_empty() {
for edge_idx in to_remove {
GraphWrite::remove_edge(&mut graph.graph, edge_idx);
}
graph.invalidate_edge_type_counts_cache();
graph.connection_types.clear();
}
}
let mut report = add_connections(
graph,
df_data,
connection_type,
source_type,
source_id_field,
target_type,
target_id_field,
source_title_field,
target_title_field,
conflict_handling,
)?;
report.stubs_vivified += stubs_vivified;
Ok(report)
}
pub fn purge_provisional_nodes(graph: &mut DirGraph) -> (usize, usize) {
let _arena_guard = graph.graph.begin_query(); let provisional_key = graph.interner.get_or_intern(PROVISIONAL_KEY);
let mut to_delete: HashSet<NodeIndex> = HashSet::new();
for node_idx in graph.graph.node_indices() {
if matches!(
GraphRead::get_node_property(&graph.graph, node_idx, provisional_key),
Some(Value::Boolean(true))
) {
to_delete.insert(node_idx);
}
}
detach_delete_nodes(graph, &to_delete)
}
fn update_node_titles(
graph: &mut DirGraph,
source_idx: NodeIndex,
target_idx: NodeIndex,
row_idx: usize,
source_title_idx: Option<usize>,
target_title_idx: Option<usize>,
df_data: &DataFrame,
) -> Result<(), String> {
if let Some(title_idx) = source_title_idx {
if let Some(title) = df_data.get_value_by_index(row_idx, title_idx) {
GraphWrite::set_node_title(&mut graph.graph, source_idx, title);
}
}
if let Some(title_idx) = target_title_idx {
if let Some(title) = df_data.get_value_by_index(row_idx, title_idx) {
GraphWrite::set_node_title(&mut graph.graph, target_idx, title);
}
}
Ok(())
}
fn update_schema_node(
graph: &mut DirGraph,
connection_type: &str,
source_type: &str,
target_type: &str,
prop_types: HashMap<String, String>,
) -> Result<(), String> {
if !graph.has_node_type(source_type) {
return Err(format!(
"Source type '{}' does not exist in graph",
source_type
));
}
if !graph.has_node_type(target_type) {
return Err(format!(
"Target type '{}' does not exist in graph",
target_type
));
}
graph.upsert_connection_type_metadata(connection_type, source_type, target_type, prop_types);
Ok(())
}
pub fn create_connections(
graph: &mut DirGraph,
selection: &CurrentSelection,
connection_type: String,
conflict_handling: Option<String>,
copy_properties: Option<HashMap<String, Vec<String>>>, source_type_filter: Option<String>, target_type_filter: Option<String>, ) -> Result<ConnectionOperationReport, String> {
let _arena_guard = graph.graph.begin_query(); graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
let conflict_mode = match conflict_handling.as_deref() {
Some("replace") => ConflictHandling::Replace,
Some("skip") => ConflictHandling::Skip,
Some("preserve") => ConflictHandling::Preserve,
Some("sum") => ConflictHandling::Sum,
Some("update") | None => ConflictHandling::Update,
Some(other) => {
return Err(format!(
"Unknown conflict handling mode: '{}'. Valid: 'update' (default), 'replace', 'skip', 'preserve', 'sum'",
other
))
}
};
let level_count = selection.get_level_count();
if level_count == 0 {
return Ok(ConnectionOperationReport::new(
"create_connections".to_string(),
0,
0,
0,
0.0,
));
}
let mut type_to_level: HashMap<String, usize> = HashMap::new();
for lvl_idx in 0..level_count {
if let Some(level) = selection.get_level(lvl_idx) {
for node_idx in level.iter_node_indices() {
if let Some(node) = graph.node_view(node_idx) {
type_to_level
.entry(node.node_type_str(&graph.interner).to_string())
.or_insert(lvl_idx);
}
}
}
}
let source_level = if let Some(ref st) = source_type_filter {
*type_to_level.get(st).ok_or_else(|| {
format!(
"source_type '{}' not found in traversal chain. Available: {:?}",
st,
type_to_level.keys().collect::<Vec<_>>()
)
})?
} else {
0
};
let target_level = if let Some(ref tt) = target_type_filter {
*type_to_level.get(tt).ok_or_else(|| {
format!(
"target_type '{}' not found in traversal chain. Available: {:?}",
tt,
type_to_level.keys().collect::<Vec<_>>()
)
})?
} else {
level_count - 1
};
if source_level >= target_level {
return Err(format!(
"source level ({}) must be before target level ({})",
source_level, target_level
));
}
let target_level_data = match selection.get_level(target_level) {
Some(level) if !level.is_empty() => level,
_ => {
return Ok(ConnectionOperationReport::new(
"create_connections".to_string(),
0,
0,
0,
0.0,
));
}
};
let mut batch = ConnectionBatchProcessor::new(target_level_data.node_count());
batch.set_conflict_mode(conflict_mode);
let mut skipped = 0;
let mut errors = Vec::new();
let mut detected_source_type = None;
let mut detected_target_type = None;
let parent_maps: Vec<HashMap<NodeIndex, Vec<NodeIndex>>> = if target_level - source_level > 1 {
let mut maps: Vec<HashMap<NodeIndex, Vec<NodeIndex>>> = vec![HashMap::new(); level_count];
for (lvl_idx, pmap) in maps.iter_mut().enumerate().skip(1) {
if let Some(level) = selection.get_level(lvl_idx) {
for (parent_opt, children) in level.iter_groups() {
if let Some(parent) = parent_opt {
for &child in children {
pmap.entry(child).or_default().push(*parent);
}
}
}
}
}
maps
} else {
Vec::new()
};
let walk_to_sources = |start_node: NodeIndex, start_level: usize| -> Vec<NodeIndex> {
if start_level == source_level {
return vec![start_node];
}
let mut current_nodes = vec![start_node];
for lvl in (source_level + 1..=start_level).rev() {
let mut next_nodes = Vec::new();
for node in ¤t_nodes {
if let Some(parents) = parent_maps[lvl].get(node) {
next_nodes.extend(parents);
}
}
if next_nodes.is_empty() {
return Vec::new(); }
current_nodes = next_nodes;
}
current_nodes
};
for (parent_opt, targets) in target_level_data.iter_groups() {
let Some(parent_idx) = parent_opt else {
skipped += targets.len();
continue;
};
let source_nodes = if target_level - source_level == 1 {
vec![*parent_idx]
} else {
walk_to_sources(*parent_idx, target_level - 1)
};
if source_nodes.is_empty() {
skipped += targets.len();
continue;
}
for &target_idx in targets {
if detected_target_type.is_none() {
let _arena_guard = graph.graph.begin_query();
if let Some(node) = graph.node_view(target_idx) {
detected_target_type = Some(node.node_type_str(&graph.interner).to_string());
}
}
for &source_idx in &source_nodes {
if detected_source_type.is_none() {
let _arena_guard = graph.graph.begin_query();
if let Some(node) = graph.node_view(source_idx) {
detected_source_type =
Some(node.node_type_str(&graph.interner).to_string());
}
}
let edge_props = if let Some(ref prop_spec) = copy_properties {
let _arena_guard = graph.graph.begin_query();
let mut props = HashMap::new();
for &node_idx in &[source_idx, target_idx] {
if let Some(node) = graph.graph.node_view(node_idx) {
let nt = node.node_type_str(&graph.interner);
if let Some(requested_props) = prop_spec.get(nt) {
if requested_props.is_empty() {
for (k, v) in node.property_pairs_named(&graph.interner) {
props.insert(k, v);
}
} else {
for prop_name in requested_props {
if let Some(val) = node.get_property(prop_name) {
props.insert(prop_name.clone(), val.into_owned());
}
}
}
}
}
}
props
} else {
HashMap::new()
};
let edge_props = intern_edge_props(edge_props, &mut graph.interner);
if let Err(e) = batch.add_connection(
source_idx,
target_idx,
edge_props,
graph,
&connection_type,
) {
skipped += 1;
errors.push(format!("Failed to add connection: {}", e));
continue;
}
}
}
}
if let (Some(source), Some(target)) = (detected_source_type, detected_target_type) {
update_schema_node(
graph,
&connection_type,
&source,
&target,
batch.schema_property_types(graph),
)?;
}
let (stats, metrics) = batch.execute(graph, connection_type)?;
let mut report = ConnectionOperationReport::new(
"create_connections".to_string(),
stats.connections_created,
skipped,
stats.properties_tracked,
metrics.processing_time * 1000.0,
);
if !errors.is_empty() {
report = report.with_errors(errors);
}
graph.bump_version();
Ok(report)
}
fn observed_type_string(nodes: &[(Option<NodeIndex>, Value)], validated: &[bool]) -> String {
let written = || {
nodes
.iter()
.zip(validated)
.filter(|(_, ok)| **ok)
.map(|((_, value), _)| value)
};
match classify_value_set(written()) {
ValueSetType::Uniform(col_type) => col_type.to_string(),
ValueSetType::Mixed => "mixed".to_string(),
ValueSetType::Shapeless | ValueSetType::Empty => "Unknown".to_string(),
}
}
pub fn update_node_properties(
graph: &mut DirGraph,
nodes: &[(Option<NodeIndex>, Value)],
property: &str,
) -> Result<NodeOperationReport, String> {
if nodes.is_empty() {
return Err("No nodes to update".to_string());
}
graph
.prepare_disk_mutation()
.map_err(|e| format!("disk mutation lease failed: {e}"))?;
let start_time = std::time::Instant::now();
let property_string = property.to_string();
let mut errors = Vec::new();
let mut node_types = HashMap::new();
let mut validated_nodes = Vec::with_capacity(nodes.len());
let mut skipped_count = 0;
for (node_idx_opt, _) in nodes {
if let Some(node_idx) = node_idx_opt {
if let Some(node_type) = GraphRead::node_type_of(&graph.graph, *node_idx) {
*node_types
.entry(graph.interner.resolve(node_type).to_string())
.or_insert(0) += 1;
validated_nodes.push(true);
} else {
validated_nodes.push(false);
skipped_count += 1;
errors.push(format!("Node index {:?} not found in graph", node_idx));
}
} else {
validated_nodes.push(false);
skipped_count += 1;
}
}
let type_string = observed_type_string(nodes, &validated_nodes);
for node_type in node_types.keys() {
if let Some(existing_meta) = graph.get_node_type_metadata(node_type) {
if let Some(existing_type) = existing_meta.get(&property_string) {
if existing_type != &type_string {
errors.push(format!(
"Type mismatch for property '{}': existing schema has '{}', but data has '{}'",
property_string, existing_type, type_string
));
}
}
}
let mut new_prop_types = HashMap::new();
new_prop_types.insert(property_string.clone(), type_string.clone());
graph.upsert_node_type_metadata(node_type, new_prop_types);
}
let batch_size = nodes.len();
let property_key = graph.interner.get_or_intern(&property_string);
let mut batch = BatchProcessor::new(batch_size);
for ((node_idx_opt, value), is_validated) in nodes.iter().zip(validated_nodes) {
if let Some(node_idx) = node_idx_opt {
if is_validated {
let action = NodeAction::Update {
node_idx: *node_idx,
title: None,
properties: vec![(property_key, value.clone())],
conflict_mode: ConflictHandling::Update,
};
if let Err(e) = batch.add_action(action, graph) {
errors.push(format!("Failed to update node property: {}", e));
skipped_count += 1;
}
} else {
skipped_count += 1;
errors.push(format!("Node index {:?} is out of bounds", node_idx));
}
} else {
skipped_count += 1;
}
}
let (stats, _metrics) = match batch.execute(graph) {
Ok(result) => result,
Err(e) => {
errors.push(format!("Failed to execute batch update: {}", e));
return Err(format!("Failed to execute batch update: {}", e));
}
};
if stats.updates == 0 && errors.is_empty() {
errors.push("No nodes were updated".to_string());
}
for node_type in node_types.keys() {
graph.refresh_indexes_for_type(node_type);
}
let elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0;
let mut report = NodeOperationReport::new(
"update_node_properties".to_string(),
0, stats.updates,
skipped_count,
elapsed_ms,
);
if !errors.is_empty() {
report = report.with_errors(errors);
}
graph.bump_version();
Ok(report)
}
#[cfg(test)]
#[path = "maintain_edge_spec_tests.rs"]
mod edge_spec_tests;
#[cfg(test)]
#[path = "maintain_connection_property_tests.rs"]
mod connection_property_tests;
#[cfg(test)]
#[path = "maintain_id_index_tests.rs"]
mod id_index_tests;
#[cfg(test)]
#[path = "maintain_replace_connections_tests.rs"]
mod replace_connections_tests;
#[cfg(test)]
#[path = "maintain_delete_id_index_tests.rs"]
mod delete_id_index_tests;
#[cfg(test)]
#[path = "maintain_incremental_index_tests.rs"]
mod incremental_index_tests;
#[cfg(test)]
#[path = "maintain_positional_delete_tests.rs"]
mod positional_delete_tests;
#[cfg(test)]
#[path = "maintain_property_type_tests.rs"]
mod property_type_tests;
#[cfg(test)]
#[path = "maintain_add_property_type_tests.rs"]
mod add_property_type_tests;