use super::super::ast::*;
use super::super::result::*;
use super::columnar_write::{
set_via_column_master, write_column_master, ColumnMasterWrite, MasterCell, PriorCell,
};
use super::edge_property_write::{remove_edge_property, set_edge_property};
use super::identity_fields::{
check_identity_uniqueness, create_identity, merge_expected_props, remove_write_field,
CreatedIdentity, IdentityAliases,
};
use super::set_row::{apply_node_property_set, NodePropertySet, SetMemos};
use super::{clause_display_name, schema_ddl, CypherExecutor};
use crate::datatypes::values::Value;
use crate::graph::algorithms::Interrupt;
use crate::graph::schema::{DirGraph, EdgeData, InternedKey};
use crate::graph::storage::{GraphRead, GraphWrite};
use petgraph::graph::NodeIndex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
pub fn is_mutation_query(query: &CypherQuery) -> bool {
query.clauses.iter().any(clause_is_mutation)
}
pub(crate) fn clause_is_mutation(clause: &Clause) -> bool {
match clause {
Clause::Create(_)
| Clause::Set(_)
| Clause::Delete(_)
| Clause::Remove(_)
| Clause::Merge(_) => true,
Clause::CallSubquery { body, .. } => is_mutation_query(body),
Clause::Call(call) => {
super::procedure_registry::is_mutating_procedure(&call.procedure_name.to_lowercase())
}
Clause::Union(u) => is_mutation_query(&u.query),
Clause::Foreach { .. } => true,
Clause::Schema(SchemaCommand::ShowIndexes)
| Clause::Schema(SchemaCommand::ShowProcedures { .. })
| Clause::Schema(SchemaCommand::ShowFunctions { .. })
| Clause::Schema(SchemaCommand::Constraint(ConstraintCommand::Show)) => false,
Clause::Schema(_) => true,
_ => false,
}
}
fn execute_cdc_lifecycle_call(
graph: &mut DirGraph,
call: &crate::graph::languages::cypher::ast::CallClause,
params: &HashMap<String, Value>,
interrupt: &Interrupt,
budget: &super::budget::ExecutionBudget,
) -> Result<ResultSet, String> {
let proc_name = call.procedure_name.to_lowercase();
let yield_items = super::call_clause::resolve_yield_items(
&proc_name,
&call.procedure_name,
&call.yield_items,
)?;
let params_map = {
let executor = CypherExecutor::with_params(graph, params, interrupt.deadline)
.with_cancel(interrupt.cancel)
.with_budget(budget.clone());
executor.extract_call_params(&call.parameters)?
};
let rows = super::cdc_procedures::execute_mutating_procedure(
graph,
&proc_name,
¶ms_map,
&yield_items,
)?;
Ok(ResultSet {
columns: yield_items
.iter()
.map(|item| item.alias.clone().unwrap_or_else(|| item.name.clone()))
.collect(),
rows,
lazy_return_items: None,
})
}
pub fn execute_mutable(
graph: &mut DirGraph,
query: &CypherQuery,
params: HashMap<String, Value>,
interrupt: Interrupt,
) -> Result<CypherResult, String> {
execute_mutable_bounded(graph, query, params, interrupt, None)
}
pub(super) struct MutationCtx<'a> {
pub params: &'a HashMap<String, Value>,
pub interrupt: &'a Interrupt,
pub budget: &'a super::budget::ExecutionBudget,
pub profiling: bool,
pub csv_import: &'a super::load_csv::CsvImportPolicy,
pub leading: &'a [Clause],
}
struct FinalizeCtx {
params: HashMap<String, Value>,
interrupt: Interrupt,
budget: super::budget::ExecutionBudget,
}
impl MutationCtx<'_> {
fn declared_before(&self, clauses: &[Clause], i: usize) -> std::collections::HashSet<String> {
use crate::graph::languages::cypher::planner::simplification::declared_variables;
let mut declared = declared_variables(self.leading);
declared.extend(declared_variables(&clauses[..i]));
declared
}
}
#[inline]
fn check_interrupt_periodic(interrupt: &Interrupt, iteration: usize) -> Result<(), String> {
if iteration & (super::INTERRUPT_POLL_INTERVAL - 1) == 0 && interrupt.exceeded() {
return Err("Query interrupted".to_string());
}
Ok(())
}
pub(crate) fn execute_mutable_bounded(
graph: &mut DirGraph,
query: &CypherQuery,
params: HashMap<String, Value>,
interrupt: Interrupt,
max_rows: Option<usize>,
) -> Result<CypherResult, String> {
execute_mutable_with_csv(
graph,
query,
params,
interrupt,
max_rows,
&super::load_csv::CsvImportPolicy::Denied,
)
}
pub(crate) fn execute_mutable_with_csv(
graph: &mut DirGraph,
query: &CypherQuery,
params: HashMap<String, Value>,
interrupt: Interrupt,
max_rows: Option<usize>,
csv_import: &super::load_csv::CsvImportPolicy,
) -> Result<CypherResult, String> {
let _arena_guard = graph.graph.begin_query();
let budget = super::budget::ExecutionBudget::new(max_rows);
let mut stats = MutationStats::default();
let profiling = query.profile;
let mut profile_stats: Vec<ClauseStats> = Vec::new();
let result_set = if let Some(Clause::LoadCsv(load)) = query.clauses.first() {
let suffix = &query.clauses[1..];
let ctx = MutationCtx {
params: ¶ms,
interrupt: &interrupt,
budget: &budget,
profiling,
csv_import,
leading: &query.clauses[..1],
};
let source = {
let executor = CypherExecutor::with_params(graph, ¶ms, interrupt.deadline)
.with_cancel(interrupt.cancel)
.with_budget(budget.clone());
executor.evaluate_expression(&load.source, &ResultRow::new())?
};
let barrier = super::load_csv::batching_barrier(suffix);
super::load_csv::drive(load, &source, csv_import, barrier.as_deref(), |seed| {
let mut batch_profile = Vec::new();
let out =
run_clause_pipeline(graph, suffix, seed, &ctx, &mut stats, &mut batch_profile)?;
merge_profile(&mut profile_stats, batch_profile);
Ok(out)
})?
} else {
let ctx = MutationCtx {
params: ¶ms,
interrupt: &interrupt,
budget: &budget,
profiling,
csv_import,
leading: &[],
};
run_clause_pipeline(
graph,
&query.clauses,
ResultSet::new(),
&ctx,
&mut stats,
&mut profile_stats,
)?
};
finalize_mutation(
graph,
query,
FinalizeCtx {
params,
interrupt,
budget,
},
result_set,
stats,
profiling.then_some(profile_stats),
)
}
pub(super) fn merge_profile(acc: &mut Vec<ClauseStats>, batch: Vec<ClauseStats>) {
for (index, entry) in batch.into_iter().enumerate() {
match acc.get_mut(index) {
Some(existing) => {
existing.rows_in += entry.rows_in;
existing.rows_out += entry.rows_out;
existing.elapsed_us += entry.elapsed_us;
}
None => acc.push(entry),
}
}
}
fn run_clause_pipeline(
graph: &mut DirGraph,
clauses: &[Clause],
seed: ResultSet,
ctx: &MutationCtx<'_>,
stats: &mut MutationStats,
profile_stats: &mut Vec<ClauseStats>,
) -> Result<ResultSet, String> {
let params = ctx.params;
let interrupt = ctx.interrupt;
let budget = ctx.budget;
let profiling = ctx.profiling;
let mut result_set = seed;
let mut stream_established = !ctx.leading.is_empty();
for (i, clause) in clauses.iter().enumerate() {
if interrupt.exceeded() {
return Err("Query interrupted".to_string());
}
if !stream_established && clause_needs_implicit_row(clause) {
result_set.rows.push(ResultRow::new());
}
let rows_in = if profiling { result_set.rows.len() } else { 0 };
let start = if profiling {
Some(Instant::now())
} else {
None
};
if stream_established
&& result_set.rows.is_empty()
&& matches!(clause, Clause::Match(_) | Clause::OptionalMatch(_))
{
if let Some(s) = start {
profile_stats.push(ClauseStats {
clause_name: clause_display_name(clause),
rows_in,
rows_out: 0,
elapsed_us: s.elapsed().as_micros() as u64,
});
}
continue;
}
match clause {
Clause::Create(create) => {
result_set = execute_create(graph, create, result_set, params, stats, interrupt)?;
}
Clause::Set(set) => {
execute_set(graph, set, &result_set, params, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
}
Clause::Delete(del) => {
execute_delete(graph, del, &result_set, stats, interrupt)?;
}
Clause::Remove(rem) => {
execute_remove(graph, rem, &result_set, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
}
Clause::Merge(merge) => {
result_set = execute_merge(graph, merge, result_set, params, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
}
Clause::Foreach {
variable,
list,
body,
} => {
execute_foreach(
graph,
variable,
list,
body,
&result_set,
params,
stats,
interrupt,
budget,
)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
}
Clause::CallSubquery { import, body } => {
let executor = CypherExecutor::with_params(graph, params, interrupt.deadline)
.with_cancel(interrupt.cancel)
.with_budget(budget.clone())
.with_csv_import(ctx.csv_import.clone());
let declared = ctx.declared_before(clauses, i);
result_set = executor.execute_call_subquery(import, body, result_set, &declared)?;
}
Clause::Call(call)
if super::procedure_registry::is_mutating_procedure(
&call.procedure_name.to_lowercase(),
) =>
{
result_set = execute_cdc_lifecycle_call(graph, call, params, interrupt, budget)?;
}
Clause::Schema(command) => {
schema_ddl::execute_schema_mutation(graph, command, stats, interrupt)?;
}
_ => {
let executor = CypherExecutor::with_params(graph, params, interrupt.deadline)
.with_cancel(interrupt.cancel)
.with_budget(budget.clone())
.with_csv_import(ctx.csv_import.clone());
result_set = executor.execute_single_clause(clause, result_set)?;
}
}
budget.check_rows(result_set.rows.len(), &clause_display_name(clause))?;
let mutation_units = stats
.nodes_created
.checked_add(stats.relationships_created)
.and_then(|n| n.checked_add(stats.properties_set))
.and_then(|n| n.checked_add(stats.nodes_deleted))
.and_then(|n| n.checked_add(stats.relationships_deleted))
.and_then(|n| n.checked_add(stats.properties_removed))
.ok_or_else(|| "Mutation work counter overflow".to_string())?;
budget.check_work(mutation_units, "mutation clauses")?;
if let Some(s) = start {
profile_stats.push(ClauseStats {
clause_name: clause_display_name(clause),
rows_in,
rows_out: result_set.rows.len(),
elapsed_us: s.elapsed().as_micros() as u64,
});
}
stream_established = true;
}
Ok(result_set)
}
fn clause_needs_implicit_row(clause: &Clause) -> bool {
matches!(
clause,
Clause::With(_)
| Clause::Unwind(_)
| Clause::Create(_)
| Clause::Merge(_)
| Clause::Foreach { .. }
)
}
fn finalize_mutation(
graph: &mut DirGraph,
query: &CypherQuery,
ctx: FinalizeCtx,
result_set: ResultSet,
stats: MutationStats,
profile: Option<Vec<ClauseStats>>,
) -> Result<CypherResult, String> {
GraphWrite::flush_pending_writes(&mut graph.graph);
let has_return = query.clauses.iter().any(|c| matches!(c, Clause::Return(_)));
if has_return || !result_set.columns.is_empty() {
let FinalizeCtx {
params,
interrupt,
budget,
} = ctx;
let executor = CypherExecutor::with_params(graph, ¶ms, interrupt.deadline)
.with_cancel(interrupt.cancel)
.with_budget(budget);
let mut result = executor.finalize_result(result_set)?;
result.stats = Some(stats);
result.profile = profile;
Ok(result)
} else {
Ok(CypherResult {
columns: Vec::new(),
rows: Vec::new(),
stats: Some(stats),
profile,
diagnostics: None,
lazy: None,
})
}
}
#[allow(clippy::too_many_arguments)]
fn execute_foreach(
graph: &mut DirGraph,
variable: &str,
list: &Expression,
body: &[Clause],
outer: &ResultSet,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
interrupt: &Interrupt,
budget: &super::budget::ExecutionBudget,
) -> Result<(), String> {
for (row_idx, row) in outer.rows.iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
let list_val = {
let executor = CypherExecutor::with_params(graph, params, interrupt.deadline)
.with_cancel(interrupt.cancel);
executor.evaluate_expression(list, row)?
};
let items = match list_val {
Value::List(items) => items,
Value::Null => continue,
other => {
return Err(format!("FOREACH expects a list, got {}", other.type_name()));
}
};
budget.check_work(items.len(), "FOREACH")?;
for (item_idx, item) in items.into_iter().enumerate() {
check_interrupt_periodic(interrupt, item_idx)?;
let mut elem_row = row.clone();
elem_row.projected.insert(variable.to_string(), item);
let mut elem_set = ResultSet {
rows: vec![elem_row],
columns: outer.columns.clone(),
lazy_return_items: None,
};
for bclause in body {
elem_set = apply_foreach_body_clause(
graph, bclause, elem_set, params, stats, interrupt, budget,
)?;
}
}
}
Ok(())
}
fn apply_foreach_body_clause(
graph: &mut DirGraph,
clause: &Clause,
result_set: ResultSet,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
interrupt: &Interrupt,
budget: &super::budget::ExecutionBudget,
) -> Result<ResultSet, String> {
match clause {
Clause::Create(create) => {
execute_create(graph, create, result_set, params, stats, interrupt)
}
Clause::Set(set) => {
execute_set(graph, set, &result_set, params, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
Ok(result_set)
}
Clause::Delete(del) => {
execute_delete(graph, del, &result_set, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
Ok(result_set)
}
Clause::Remove(rem) => {
execute_remove(graph, rem, &result_set, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
Ok(result_set)
}
Clause::Merge(merge) => {
let rs = execute_merge(graph, merge, result_set, params, stats, interrupt)?;
GraphWrite::flush_pending_writes(&mut graph.graph);
Ok(rs)
}
Clause::Foreach {
variable,
list,
body,
} => {
execute_foreach(
graph,
variable,
list,
body,
&result_set,
params,
stats,
interrupt,
budget,
)?;
Ok(result_set)
}
other => Err(format!(
"FOREACH body may only contain update clauses, got {}",
clause_display_name(other)
)),
}
}
pub(super) fn enforce_write_scope(graph: &DirGraph, node_type: &str) -> Result<(), String> {
if let Some(scope) = &graph.active_write_scope {
if !scope.contains(node_type) {
return Err(format!(
"write scope violation: node type '{}' is not in the allowed write set ({})",
node_type,
{
let mut types: Vec<&str> = scope.iter().map(|s| s.as_str()).collect();
types.sort_unstable();
types.join(", ")
}
));
}
}
Ok(())
}
fn execute_create(
graph: &mut DirGraph,
create: &CreateClause,
existing: ResultSet,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
interrupt: &Interrupt,
) -> Result<ResultSet, String> {
let source_rows = existing.rows;
let mut new_rows = Vec::with_capacity(source_rows.len());
for (row_idx, row) in source_rows.iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
let mut new_row = row.clone();
let mut element_nodes: Vec<Option<NodeIndex>> = Vec::new();
for pattern in &create.patterns {
element_nodes.clear();
element_nodes.resize(pattern.elements.len(), None);
for (pos, element) in pattern.elements.iter().enumerate() {
if let CreateElement::Node(node_pat) = element {
if let Some(var) = node_pat.variable.as_deref() {
if let Some(&bound) = new_row.node_bindings.get(var) {
element_nodes[pos] = Some(bound);
continue;
}
}
let node_idx = create_node(graph, node_pat, &new_row, params, stats)?;
element_nodes[pos] = Some(node_idx);
if let Some(var) = node_pat.variable.as_deref() {
new_row.node_bindings.insert(var.to_string(), node_idx);
}
}
}
create_pattern_edges(graph, pattern, &element_nodes, &mut new_row, params, stats)?;
}
new_rows.push(new_row);
}
if stats.relationships_created > 0 {
graph.invalidate_edge_type_counts_cache();
graph.ensure_disk_edges_built()?;
}
Ok(ResultSet {
rows: new_rows,
columns: existing.columns,
lazy_return_items: None,
})
}
fn create_pattern_edges(
graph: &mut DirGraph,
pattern: &CreatePattern,
element_nodes: &[Option<NodeIndex>],
new_row: &mut ResultRow,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
) -> Result<(), String> {
let mut i = 1;
while i < pattern.elements.len() {
if let CreateElement::Edge(edge_pat) = &pattern.elements[i] {
let source_idx = resolve_create_node_idx(pattern, element_nodes, i - 1)?;
let target_idx = resolve_create_node_idx(pattern, element_nodes, i + 1)?;
let (actual_source, actual_target) = match edge_pat.direction {
CreateEdgeDirection::Outgoing => (source_idx, target_idx),
CreateEdgeDirection::Incoming => (target_idx, source_idx),
};
let src_type = graph
.node_view(actual_source)
.map(|n| n.get_node_type_ref(&graph.interner).to_string())
.unwrap_or_default();
let tgt_type = graph
.node_view(actual_target)
.map(|n| n.get_node_type_ref(&graph.interner).to_string())
.unwrap_or_default();
if graph.schema_locked {
crate::graph::mutation::validation::validate_edge_creation(
&edge_pat.connection_type,
&src_type,
&tgt_type,
&graph.connection_type_metadata,
&graph.node_type_metadata,
)?;
}
let mut edge_props = HashMap::new();
{
let executor = CypherExecutor::with_params(graph, params, None);
for (key, expr) in &edge_pat.properties {
let val = executor.evaluate_expression(expr, new_row)?;
edge_props.insert(key.clone(), val);
}
}
graph.inject_edge_provenance(&edge_pat.connection_type, &mut edge_props);
if graph.has_rel_constraints() {
graph.check_rel_row(&edge_pat.connection_type, |property| {
edge_props.get(property).cloned()
})?;
}
graph.register_connection_type(edge_pat.connection_type.clone());
let prop_types: HashMap<String, String> = edge_props
.iter()
.map(|(k, v)| (k.clone(), v.type_name().to_string()))
.collect();
graph.upsert_connection_type_metadata(
&edge_pat.connection_type,
&src_type,
&tgt_type,
prop_types,
);
stats.relationships_created += 1;
let edge_data = EdgeData::new(
edge_pat.connection_type.clone(),
edge_props,
&mut graph.interner,
);
let edge_index =
GraphWrite::add_edge(&mut graph.graph, actual_source, actual_target, edge_data);
if let Some(ref var) = edge_pat.variable {
new_row.edge_bindings.insert(
var.clone(),
EdgeBinding {
source: actual_source,
target: actual_target,
edge_index,
},
);
}
}
i += 2; }
Ok(())
}
fn create_node(
graph: &mut DirGraph,
node_pat: &CreateNodePattern,
row: &ResultRow,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
) -> Result<petgraph::graph::NodeIndex, String> {
let mut properties = HashMap::new();
{
let executor = CypherExecutor::with_params(graph, params, None);
for (key, expr) in &node_pat.properties {
let val = executor.evaluate_expression(expr, row)?;
properties.insert(key.clone(), val);
}
}
let label = node_pat.label.clone().unwrap_or_else(|| "Node".to_string());
let aliases = IdentityAliases::for_type(graph, &label);
let CreatedIdentity {
id,
title,
title_supplied,
} = create_identity(graph, node_pat, &label, &aliases, &mut properties)?;
check_identity_uniqueness(graph, &label, &id)?;
let constraint_read = |property: &str| -> Option<Value> {
if aliases.id_field() == Some(property) {
return (!matches!(id, Value::Null)).then(|| id.clone());
}
if aliases.title_field() == Some(property) {
return (title_supplied && !matches!(title, Value::Null)).then(|| title.clone());
}
match property {
"id" => (!matches!(id, Value::Null)).then(|| id.clone()),
"title" => (!matches!(title, Value::Null)).then(|| title.clone()),
other => match properties.get(other) {
Some(Value::Null) | None => None,
Some(value) => Some(value.clone()),
},
}
};
let required = graph.check_required_fields(&label, constraint_read);
if let Err(violation) = required {
return Err(graph.record_constraint_violation(*violation));
}
let typed = graph.check_property_types(&label, constraint_read);
if let Err(violation) = typed {
return Err(graph.record_constraint_violation(*violation));
}
let unique_claims = graph.unique_claims(&label, constraint_read);
let unique = graph.check_unique_claims(&unique_claims, None);
if let Err(violation) = unique {
return Err(graph.record_constraint_violation(*violation));
}
let pk_id = Some(id.clone());
enforce_write_scope(graph, &label)?;
if graph.schema_locked {
crate::graph::mutation::validation::validate_node_creation(
&label,
&properties,
&graph.node_type_metadata,
graph.schema_definition.as_ref(),
)?;
}
let node_idx = graph.insert_node_routed(id, title, &label, properties);
let bucket_was_new = !graph.type_indices.contains_key(&label);
graph.type_indices.push_to_type(&label, node_idx);
if let Some(journal) = graph.graph.undo_journal_mut() {
journal.note_bucket_appended(
crate::graph::storage::undo::BucketId::NodeType(label.clone()),
node_idx,
bucket_was_new,
);
}
match pk_id {
Some(idv) if graph.id_indices.contains_key(&label) => {
graph
.id_indices
.entry_or_default(label.clone())
.insert(idv, node_idx);
}
_ => {
graph.id_indices.remove(&label);
}
}
graph.update_property_indices_for_add(&label, node_idx);
graph.commit_unique_claims(&unique_claims, node_idx);
ensure_type_metadata(graph, &label);
for extra in &node_pat.extra_labels {
let key = graph.interner.get_or_intern(extra);
graph.add_node_label(node_idx, key);
}
stats.nodes_created += 1;
Ok(node_idx)
}
fn ensure_type_metadata(graph: &mut DirGraph, node_type: &str) {
if graph.node_type_metadata.contains_key(node_type) {
return;
}
graph
.node_type_metadata_mut()
.entry(node_type.to_string())
.or_default();
}
fn value_type_name(v: &Value) -> String {
v.type_name().to_string()
}
fn get_create_node_variable(element: &CreateElement) -> Option<&str> {
match element {
CreateElement::Node(np) => np.variable.as_deref(),
_ => None,
}
}
fn resolve_create_node_idx(
pattern: &CreatePattern,
element_nodes: &[Option<NodeIndex>],
pos: usize,
) -> Result<NodeIndex, String> {
match pattern.elements.get(pos) {
Some(CreateElement::Node(node_pat)) => {
element_nodes.get(pos).copied().flatten().ok_or_else(|| {
match node_pat.variable.as_deref() {
Some(name) => format!("Unbound variable '{}' in CREATE edge", name),
None => "Unresolved anonymous node in CREATE edge".to_string(),
}
})
}
_ => Err("CREATE edge endpoints must be node patterns".to_string()),
}
}
fn auto_timestamp_type_of(graph: &DirGraph, node_idx: NodeIndex) -> Option<String> {
let node_type = {
let _arena_guard = graph.graph.begin_query();
graph
.graph
.node_view(node_idx)
.map(|n| n.node_type_str(&graph.interner).to_string())
};
node_type.filter(|nt| graph.auto_timestamp_for(nt))
}
fn is_null_write_target(row: &ResultRow, variable: &str) -> bool {
!row.node_bindings.contains_key(variable)
&& !row.edge_bindings.contains_key(variable)
&& !row.path_bindings.contains_key(variable)
&& matches!(row.projected.get(variable), None | Some(Value::Null))
}
fn existing_property_keys(
graph: &crate::graph::dir_graph::DirGraph,
row: &ResultRow,
variable: &str,
) -> Vec<String> {
let _arena_guard = graph.graph.begin_query();
if let Some(node_idx) = row.node_bindings.get(variable) {
let mut keys: Vec<String> = graph
.graph
.node_view(*node_idx)
.map(|node| {
node.properties_cloned(&graph.interner)
.into_keys()
.collect()
})
.unwrap_or_default();
if graph
.graph
.node_view(*node_idx)
.is_some_and(|node| !matches!(*node.title(), Value::Null))
&& !keys.iter().any(|key| key == "name" || key == "title")
{
keys.push("name".to_string());
}
keys
} else if let Some(edge) = row.edge_bindings.get(variable) {
graph
.graph
.edge_weight(edge.edge_index)
.map(|edge| {
edge.properties_cloned(&graph.interner)
.into_keys()
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
}
}
pub(super) fn set_node_property_direct(
graph: &mut crate::graph::dir_graph::DirGraph,
node_idx: NodeIndex,
property: &str,
value: Value,
) -> bool {
if graph.graph.node_weight(node_idx).is_none() {
return false;
}
let set_title = |graph: &mut crate::graph::dir_graph::DirGraph, v: Value| {
GraphWrite::set_node_title(&mut graph.graph, node_idx, v);
};
match property {
"title" => set_title(graph, value),
"name" => {
set_title(graph, value.clone());
let key = graph.interner.get_or_intern("name");
GraphWrite::set_node_property(&mut graph.graph, node_idx, key, value);
}
_ => {
let key = graph.interner.get_or_intern(property);
GraphWrite::set_node_property(&mut graph.graph, node_idx, key, value);
}
}
true
}
fn execute_set(
graph: &mut DirGraph,
set: &SetClause,
result_set: &ResultSet,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
interrupt: &Interrupt,
) -> Result<(), String> {
let mut nodes_to_stamp: std::collections::HashMap<NodeIndex, String> =
std::collections::HashMap::new();
let mut edges_to_stamp: std::collections::HashSet<petgraph::graph::EdgeIndex> =
std::collections::HashSet::new();
let mut memos = SetMemos::default();
for (row_idx, row) in result_set.rows.iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
for item in &set.items {
match item {
SetItem::Property {
variable,
property,
expression,
} => {
if set_edge_property(
graph,
row,
(variable, property, expression),
params,
stats,
&mut edges_to_stamp,
)? {
continue;
}
if property == "id" {
return Err("Cannot SET node id — it is immutable".to_string());
}
if property == "type" || property == "node_type" || property == "label" {
return Err("Cannot SET node type via property assignment".to_string());
}
let Some(node_idx) = row.node_bindings.get(variable) else {
if is_null_write_target(row, variable) {
continue;
}
return Err(format!(
"Variable '{}' not bound to a node in SET",
variable
));
};
let value = {
let executor = CypherExecutor::with_params(graph, params, None);
executor.evaluate_expression(expression, row)?
};
apply_node_property_set(
graph,
NodePropertySet {
node_idx: *node_idx,
property: property.as_str(),
value,
},
&mut memos,
stats,
&mut nodes_to_stamp,
)?;
}
SetItem::Map {
variable,
expression,
replace,
} => {
let value = {
let executor = CypherExecutor::with_params(graph, params, None);
executor.evaluate_expression(expression, row)?
};
let Value::Map(map) = value else {
return Err(format!(
"SET {} {} expects a map expression",
variable,
if *replace { "=" } else { "+=" }
));
};
if !row.node_bindings.contains_key(variable)
&& !row.edge_bindings.contains_key(variable)
{
if is_null_write_target(row, variable) {
continue;
}
return Err(format!("Variable '{}' is not bound in SET", variable));
}
let one_row = ResultSet {
rows: vec![row.clone()],
columns: Vec::new(),
lazy_return_items: None,
};
if *replace {
let existing_keys = existing_property_keys(graph, row, variable);
let removals: Vec<RemoveItem> = existing_keys
.into_iter()
.filter(|key| {
if key == "name" || key == "title" {
!map.contains_key("name") && !map.contains_key("title")
} else {
!map.contains_key(key)
}
})
.map(|property| RemoveItem::Property {
variable: variable.clone(),
property,
})
.collect();
if !removals.is_empty() {
execute_remove(
graph,
&RemoveClause { items: removals },
&one_row,
stats,
interrupt,
)?;
}
}
let properties: Vec<SetItem> = map
.into_iter()
.map(|(property, value)| SetItem::Property {
variable: variable.clone(),
property: property.to_string(),
expression: Expression::Literal(value),
})
.collect();
if !properties.is_empty() {
execute_set(
graph,
&SetClause { items: properties },
&one_row,
params,
stats,
interrupt,
)?;
}
}
SetItem::Label {
variable, label, ..
} => {
let Some(&node_idx) = row.node_bindings.get(variable) else {
if is_null_write_target(row, variable) {
continue;
}
return Err(format!(
"Variable '{}' not bound to a node in SET",
variable
));
};
let key = graph.interner.get_or_intern(label);
if graph.add_node_label(node_idx, key) {
stats.properties_set += 1;
if let Some(nt) = auto_timestamp_type_of(graph, node_idx) {
nodes_to_stamp.insert(node_idx, nt);
}
}
}
}
}
}
stamp_node_provenance(graph, &nodes_to_stamp);
if !edges_to_stamp.is_empty() {
let interned: Vec<(InternedKey, Value)> = graph
.provenance_props()
.into_iter()
.map(|(k, v)| (graph.interner.get_or_intern(k), v))
.collect();
for edge_index in &edges_to_stamp {
if let Some(EdgeData {
properties: edge_props,
..
}) = GraphWrite::edge_weight_mut(&mut graph.graph, *edge_index)
{
for (key, val) in &interned {
if let Some((_, existing)) = edge_props.iter_mut().find(|(ek, _)| ek == key) {
*existing = val.clone();
} else {
edge_props.push((*key, val.clone()));
}
}
}
}
}
Ok(())
}
fn stamp_node_provenance(graph: &mut DirGraph, nodes_to_stamp: &HashMap<NodeIndex, String>) {
if !nodes_to_stamp.is_empty() {
let prov = graph.provenance_props();
for (node_idx, node_type) in nodes_to_stamp {
let columnar_row_id = {
let _arena_guard = graph.graph.begin_query();
graph
.graph
.node_weight(*node_idx)
.and_then(|n| n.properties.columnar_row_id())
};
let type_key = InternedKey::from_str(node_type);
for &(pname, ref pval) in &prov {
let key = graph.interner.get_or_intern(pname);
let needs_key = graph
.type_schemas
.get(node_type)
.is_some_and(|schema| schema.slot(key).is_none());
if needs_key {
if let Some(schema_arc) = graph.type_schemas_mut().get_mut(node_type) {
Arc::make_mut(schema_arc).add_key(key);
}
}
let wrote = set_via_column_master(
graph,
ColumnMasterWrite {
node_idx: *node_idx,
node_type,
type_key,
property: pname,
key,
value: pval,
row_id: columnar_row_id,
},
);
if !wrote {
GraphWrite::set_node_property(&mut graph.graph, *node_idx, key, pval.clone());
}
}
}
let stamped_types: std::collections::HashSet<&String> = nodes_to_stamp.values().collect();
let prop_types: HashMap<String, String> = prov
.iter()
.map(|(pname, pval)| (pname.to_string(), value_type_name(pval)))
.collect();
for node_type in stamped_types {
graph.upsert_node_type_metadata(node_type, prop_types.clone());
}
}
}
fn execute_delete(
graph: &mut DirGraph,
delete: &DeleteClause,
result_set: &ResultSet,
stats: &mut MutationStats,
interrupt: &Interrupt,
) -> Result<(), String> {
use std::collections::HashSet;
let mut nodes_to_delete: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
let mut deleted_edges: HashSet<petgraph::graph::EdgeIndex> = HashSet::new();
for (row_idx, row) in result_set.rows.iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
for expr in &delete.expressions {
let var_name = match expr {
Expression::Variable(name) => name,
other => return Err(format!("DELETE expects variable names, got {:?}", other)),
};
if let Some(&node_idx) = row.node_bindings.get(var_name) {
nodes_to_delete.insert(node_idx);
} else if let Some(edge_binding) = row.edge_bindings.get(var_name) {
deleted_edges.insert(edge_binding.edge_index);
} else {
match row.projected.get(var_name) {
Some(Value::NodeRef(i)) => {
nodes_to_delete.insert(petgraph::graph::NodeIndex::new(*i as usize));
}
Some(Value::Node(nv)) => {
nodes_to_delete.insert(petgraph::graph::NodeIndex::new(nv.id as usize));
}
_ => {}
}
}
}
}
if !delete.detach {
let _arena_guard = graph.graph.begin_query();
for (node_count, &node_idx) in nodes_to_delete.iter().enumerate() {
check_interrupt_periodic(interrupt, node_count)?;
let has_edges = graph
.graph
.edges_directed(node_idx, petgraph::Direction::Outgoing)
.any(|e| !deleted_edges.contains(&e.id()))
|| graph
.graph
.edges_directed(node_idx, petgraph::Direction::Incoming)
.any(|e| !deleted_edges.contains(&e.id()));
if has_edges {
let name = graph
.graph
.node_view(node_idx)
.map(|n| {
n.get_field_ref("name")
.or_else(|| n.get_field_ref("title"))
.map(|v| match v.as_ref() {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_else(|| format!("index {}", node_idx.index()))
})
.unwrap_or_else(|| "unknown".to_string());
return Err(format!(
"Cannot delete node '{}' because it still has relationships. Use DETACH DELETE to delete the node and all its relationships.",
name
));
}
}
}
for edge_index in deleted_edges.iter().copied() {
GraphWrite::remove_edge(&mut graph.graph, edge_index);
stats.relationships_deleted += 1;
}
if stats.relationships_deleted > 0 {
graph.invalidate_edge_type_counts_cache();
graph.connection_types.clear();
}
let (nodes_deleted, edges_removed) =
crate::graph::mutation::maintain::detach_delete_nodes(graph, &nodes_to_delete);
stats.nodes_deleted += nodes_deleted;
stats.relationships_deleted += edges_removed;
Ok(())
}
fn execute_remove(
graph: &mut DirGraph,
remove: &RemoveClause,
result_set: &ResultSet,
stats: &mut MutationStats,
interrupt: &Interrupt,
) -> Result<(), String> {
for (row_idx, row) in result_set.rows.iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
for item in &remove.items {
match item {
RemoveItem::Property { variable, property } => {
if remove_edge_property(graph, row, variable, property, stats)? {
continue;
}
if property == "id" {
return Err("Cannot REMOVE node id — it is immutable".to_string());
}
if property == "type" || property == "node_type" || property == "label" {
return Err("Cannot REMOVE node type".to_string());
}
let Some(node_idx) = row.node_bindings.get(variable) else {
if is_null_write_target(row, variable) {
continue;
}
return Err(format!(
"Variable '{}' not bound to a node in REMOVE",
variable
));
};
let node_type_str = graph
.node_view(*node_idx)
.map(|n| n.get_node_type_ref(&graph.interner).to_string())
.unwrap_or_default();
let write_field = remove_write_field(graph, &node_type_str, property.as_str())?;
let constraint_plan = graph
.plan_property_write(&node_type_str, *node_idx, property, None)
.map_err(|violation| violation.to_string())?;
let is_disk = graph.graph.is_disk();
let mut cleared_via_master = None;
if !is_disk && write_field != "name" && write_field != "title" {
let columnar_row_id = {
let _arena_guard = graph.graph.begin_query();
graph
.graph
.node_weight(*node_idx)
.and_then(|n| n.properties.columnar_row_id())
};
if let Some(row_id) = columnar_row_id {
let key = graph.interner.get_or_intern(write_field);
cleared_via_master = write_column_master(
graph,
MasterCell {
node_type: &node_type_str,
type_key: InternedKey::from_str(&node_type_str),
node_idx: *node_idx,
row_id,
key,
value: &Value::Null,
},
PriorCell::Read,
);
}
}
let removed_value = if let Some(prior) = cleared_via_master {
prior
} else if graph.graph.node_weight(*node_idx).is_some() {
if write_field == "name" || write_field == "title" {
let old = graph.graph.get_node_title(*node_idx).unwrap_or(Value::Null);
GraphWrite::set_node_title(&mut graph.graph, *node_idx, Value::Null);
let key = InternedKey::from_str("name");
GraphWrite::remove_node_property(&mut graph.graph, *node_idx, key);
(!matches!(old, Value::Null)).then_some(old)
} else {
let key = InternedKey::from_str(write_field);
GraphWrite::remove_node_property(&mut graph.graph, *node_idx, key)
}
} else {
None
};
if let Some(old_val) = removed_value {
stats.properties_removed += 1;
graph.update_property_indices_for_remove(
&node_type_str,
*node_idx,
property,
&old_val,
);
}
graph.apply_property_write_plan(&constraint_plan, *node_idx);
}
RemoveItem::Label {
variable, label, ..
} => {
let Some(&node_idx) = row.node_bindings.get(variable) else {
if is_null_write_target(row, variable) {
continue;
}
return Err(format!(
"Variable '{}' not bound to a node in REMOVE",
variable
));
};
let key = graph.interner.get_or_intern(label);
if graph.remove_node_label(node_idx, key)? {
stats.properties_removed += 1;
}
}
}
}
}
Ok(())
}
fn execute_merge(
graph: &mut DirGraph,
merge: &MergeClause,
existing: ResultSet,
params: &HashMap<String, Value>,
stats: &mut MutationStats,
interrupt: &Interrupt,
) -> Result<ResultSet, String> {
let source_rows = existing.rows;
let mut new_rows = Vec::with_capacity(source_rows.len());
for (row_idx, mut new_row) in source_rows.into_iter().enumerate() {
check_interrupt_periodic(interrupt, row_idx)?;
{
let executor = CypherExecutor::with_params(graph, params, None);
for element in &merge.pattern.elements {
let properties = match element {
CreateElement::Node(node) => &node.properties,
CreateElement::Edge(edge) => &edge.properties,
};
for (name, expression) in properties {
if matches!(
executor.evaluate_expression(expression, &new_row)?,
Value::Null
) {
return Err(format!("MERGE cannot use null for property '{}'", name));
}
}
}
}
let matched = try_match_merge_pattern(graph, &merge.pattern, &new_row, params)?;
if let Some(bound_row) = matched {
for (var, idx) in &bound_row.node_bindings {
new_row.node_bindings.insert(var.clone(), *idx);
}
for (var, binding) in &bound_row.edge_bindings {
new_row.edge_bindings.insert(var.clone(), *binding);
}
if let Some(ref set_items) = merge.on_match {
let set_clause = SetClause {
items: set_items.clone(),
};
let temp_rs = ResultSet {
rows: vec![new_row.clone()],
columns: Vec::new(),
lazy_return_items: None,
};
execute_set(graph, &set_clause, &temp_rs, params, stats, interrupt)?;
}
} else {
let create_clause = CreateClause {
patterns: vec![merge.pattern.clone()],
};
let temp_rs = ResultSet {
rows: vec![new_row.clone()],
columns: existing.columns.clone(),
lazy_return_items: None,
};
let created = execute_create(graph, &create_clause, temp_rs, params, stats, interrupt)?;
if let Some(created_row) = created.rows.into_iter().next() {
for (var, idx) in created_row.node_bindings {
new_row.node_bindings.insert(var, idx);
}
for (var, binding) in created_row.edge_bindings {
new_row.edge_bindings.insert(var, binding);
}
}
if let Some(ref set_items) = merge.on_create {
let set_clause = SetClause {
items: set_items.clone(),
};
let temp_rs = ResultSet {
rows: vec![new_row.clone()],
columns: Vec::new(),
lazy_return_items: None,
};
execute_set(graph, &set_clause, &temp_rs, params, stats, interrupt)?;
}
}
new_rows.push(new_row);
}
Ok(ResultSet {
rows: new_rows,
columns: existing.columns,
lazy_return_items: None,
})
}
fn try_match_merge_pattern(
graph: &DirGraph,
pattern: &CreatePattern,
row: &ResultRow,
params: &HashMap<String, Value>,
) -> Result<Option<ResultRow>, String> {
let executor = CypherExecutor::with_params(graph, params, None);
match pattern.elements.len() {
1 => {
if let CreateElement::Node(node_pat) = &pattern.elements[0] {
if let Some(ref var) = node_pat.variable {
if let Some(&existing_idx) = row.node_bindings.get(var) {
if graph.graph.node_view(existing_idx).is_some() {
let mut result_row = ResultRow::new();
result_row.node_bindings.insert(var.clone(), existing_idx);
return Ok(Some(result_row));
}
}
}
let label = node_pat.label.as_deref().unwrap_or("Node");
let label_has_secondary = graph.has_secondary_labels
&& graph
.secondary_label_index
.contains_key(&crate::graph::schema::InternedKey::from_str(label));
let expected_props = merge_expected_props(&executor, node_pat, row, graph)?;
let node_matches_all = |idx: NodeIndex, props: &[(&str, Value)]| -> bool {
if let Some(node) = graph.graph.node_view(idx) {
props.iter().all(|(key, expected)| {
let value = if *key == "name" || *key == "title" {
node.get_field_ref("title")
} else {
node.get_field_ref(key)
};
value.as_deref() == Some(expected)
})
} else {
false
}
};
let build_result = |idx: NodeIndex| -> ResultRow {
let mut result_row = ResultRow::new();
if let Some(ref var) = node_pat.variable {
result_row.node_bindings.insert(var.clone(), idx);
}
result_row
};
if !label_has_secondary {
if let Some((_, id_value)) = expected_props.iter().find(|(k, _)| *k == "id") {
if let Some(idx) = graph.lookup_by_id_readonly(label, id_value) {
if expected_props.len() == 1 || node_matches_all(idx, &expected_props) {
return Ok(Some(build_result(idx)));
}
}
return Ok(None);
}
if expected_props.len() == 1 {
let (key, ref value) = expected_props[0];
let index_key = if key == "name" || key == "title" {
"title"
} else {
key
};
if let Some(candidates) = graph.lookup_by_index(label, index_key, value) {
for &idx in &candidates {
if node_matches_all(idx, &expected_props) {
return Ok(Some(build_result(idx)));
}
}
return Ok(None);
}
}
if expected_props.len() >= 2 {
let mut indexable: Vec<(&str, &Value)> = expected_props
.iter()
.filter(|(k, _)| *k != "id" && *k != "name" && *k != "title")
.map(|(k, v)| (*k, v))
.collect();
if indexable.len() >= 2 {
indexable.sort_by(|a, b| a.0.cmp(b.0));
let names: Vec<String> =
indexable.iter().map(|(k, _)| k.to_string()).collect();
let values: Vec<Value> =
indexable.iter().map(|(_, v)| (*v).clone()).collect();
if let Some(candidates) =
graph.lookup_by_composite_index(label, &names, &values)
{
for &idx in &candidates {
if node_matches_all(idx, &expected_props) {
return Ok(Some(build_result(idx)));
}
}
return Ok(None);
}
}
}
}
for idx in graph.nodes_with_label(label) {
if node_matches_all(idx, &expected_props) {
return Ok(Some(build_result(idx)));
}
}
Ok(None)
} else {
Err("MERGE pattern must start with a node".to_string())
}
}
3 => {
let source_var = get_create_node_variable(&pattern.elements[0]);
let target_var = get_create_node_variable(&pattern.elements[2]);
let source_idx = source_var
.and_then(|v| row.node_bindings.get(v).copied())
.ok_or("MERGE path: source node must be bound by prior MATCH")?;
let target_idx = target_var
.and_then(|v| row.node_bindings.get(v).copied())
.ok_or("MERGE path: target node must be bound by prior MATCH")?;
if let CreateElement::Edge(edge_pat) = &pattern.elements[1] {
let (actual_src, actual_tgt) = match edge_pat.direction {
CreateEdgeDirection::Outgoing => (source_idx, target_idx),
CreateEdgeDirection::Incoming => (target_idx, source_idx),
};
let interned_ct = InternedKey::from_str(&edge_pat.connection_type);
let matching_edge = graph
.graph
.edges_directed(actual_src, petgraph::Direction::Outgoing)
.find(|e| {
e.target() == actual_tgt && e.weight().connection_type == interned_ct
});
if let Some(edge_ref) = matching_edge {
let mut result_row = ResultRow::new();
if let Some(ref var) = edge_pat.variable {
result_row.edge_bindings.insert(
var.clone(),
EdgeBinding {
source: actual_src,
target: actual_tgt,
edge_index: edge_ref.id(),
},
);
}
Ok(Some(result_row))
} else {
Ok(None)
}
} else {
Err("Expected edge in MERGE path pattern".to_string())
}
}
_ => Err("MERGE supports single-node or single-edge patterns only".to_string()),
}
}
#[cfg(test)]
#[path = "write_remove_columnar_tests.rs"]
mod remove_columnar_tests;
#[cfg(test)]
#[path = "write_rel_constraint_tests.rs"]
mod rel_constraint_tests;
#[cfg(test)]
mod is_mutation_query_tests {
use super::super::super::ast::*;
use super::is_mutation_query;
fn query(clauses: Vec<Clause>) -> CypherQuery {
CypherQuery {
clauses,
explain: false,
profile: false,
output_format: OutputFormat::Default,
optimizer_tags: Vec::new(),
}
}
fn create_clause() -> Clause {
Clause::Create(CreateClause {
patterns: Vec::new(),
})
}
fn return_clause() -> Clause {
Clause::Return(ReturnClause {
items: Vec::new(),
distinct: false,
having: None,
lazy_eligible: false,
group_limit_hint: None,
})
}
#[test]
fn plain_read_is_not_a_mutation() {
assert!(!is_mutation_query(&query(vec![return_clause()])));
}
#[test]
fn top_level_write_is_a_mutation() {
assert!(is_mutation_query(&query(vec![create_clause()])));
}
#[test]
fn write_inside_call_subquery_body_is_a_mutation() {
let body = Box::new(query(vec![create_clause(), return_clause()]));
let call = Clause::CallSubquery {
import: Vec::new(),
body,
};
assert!(is_mutation_query(&query(vec![call, return_clause()])));
}
#[test]
fn nested_write_inside_call_subquery_body_is_a_mutation() {
let inner = Box::new(query(vec![create_clause(), return_clause()]));
let inner_call = Clause::CallSubquery {
import: Vec::new(),
body: inner,
};
let outer = Box::new(query(vec![inner_call, return_clause()]));
let outer_call = Clause::CallSubquery {
import: Vec::new(),
body: outer,
};
assert!(is_mutation_query(&query(vec![outer_call])));
}
#[test]
fn read_only_call_subquery_body_is_not_a_mutation() {
let body = Box::new(query(vec![return_clause()]));
let call = Clause::CallSubquery {
import: Vec::new(),
body,
};
assert!(!is_mutation_query(&query(vec![call, return_clause()])));
}
#[test]
fn write_inside_union_arm_is_a_mutation() {
let arm = Box::new(query(vec![create_clause(), return_clause()]));
let union = Clause::Union(UnionClause {
all: false,
query: arm,
kind: SetOpKind::Union,
});
assert!(is_mutation_query(&query(vec![return_clause(), union])));
}
}