use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
use crate::datatypes::Value;
use crate::error::KgError;
use crate::graph::dir_graph::DirGraph;
use crate::graph::embedder::Embedder;
use crate::graph::languages::cypher;
use crate::graph::languages::cypher::ast::{
Clause, CreateElement, CreatePattern, CypherQuery, OutputFormat, RemoveItem, SetItem,
};
use crate::graph::languages::cypher::result::CypherResult;
use crate::graph::languages::cypher::value_codec::ValueCodec;
pub struct ExecuteOptions<'a> {
pub params: &'a HashMap<String, Value>,
pub deadline: Option<Instant>,
pub max_rows: Option<usize>,
pub lazy_eligible: bool,
pub disabled_passes: Option<&'a HashSet<String>>,
pub embedder: Option<Arc<dyn Embedder>>,
pub value_codecs: Option<&'a [ValueCodec]>,
pub cancel: Option<&'static AtomicBool>,
pub write_scope: Option<&'a HashSet<String>>,
pub git_sha: Option<&'a str>,
pub modified_by: Option<&'a str>,
}
impl<'a> ExecuteOptions<'a> {
pub fn new(params: &'a HashMap<String, Value>) -> Self {
Self::eager(params)
}
pub fn eager(params: &'a HashMap<String, Value>) -> Self {
Self {
params,
deadline: None,
max_rows: None,
lazy_eligible: false,
disabled_passes: None,
embedder: None,
value_codecs: None,
cancel: None,
write_scope: None,
git_sha: None,
modified_by: None,
}
}
}
#[inline]
fn exec_err(opts: &ExecuteOptions<'_>, message: String) -> KgError {
if opts
.cancel
.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
{
KgError::Cancelled
} else {
KgError::CypherExecution {
message,
position: None,
}
}
}
pub struct ExecuteOutcome {
pub result: CypherResult,
pub is_mutation: bool,
pub output_format: OutputFormat,
pub explain: bool,
}
pub fn execute_read(
graph: &DirGraph,
query: &str,
opts: &ExecuteOptions<'_>,
) -> Result<ExecuteOutcome, KgError> {
let (parsed, params, encode_plan) = prepare(graph, query, opts)?;
let is_mutation = cypher::is_mutation_query(&parsed);
if parsed.explain {
let result = cypher::generate_explain_result(&parsed, graph);
return Ok(ExecuteOutcome {
result,
is_mutation,
output_format: parsed.output_format,
explain: true,
});
}
if is_mutation {
return Err(KgError::Argument(
"execute_read called with a mutation query (CREATE/SET/DELETE/REMOVE/MERGE) \
— use execute_mut against a mutable graph view"
.to_string(),
));
}
let mut result = cypher::CypherExecutor::with_params(graph, ¶ms, opts.deadline)
.with_max_rows(opts.max_rows)
.with_streaming(opts.lazy_eligible)
.with_cancel(opts.cancel)
.execute(&parsed)
.map_err(|message| exec_err(opts, message))?;
cypher::value_codec::apply_encode(&mut result, &encode_plan);
Ok(ExecuteOutcome {
result,
is_mutation: false,
output_format: parsed.output_format,
explain: false,
})
}
fn can_skip_rollback_checkpoint(
graph: &DirGraph,
query: &CypherQuery,
opts: &ExecuteOptions<'_>,
) -> bool {
if !graph.graph.supports_checkpoint_free_mutation() || query.profile || opts.max_rows.is_some()
{
return false;
}
match query.clauses.as_slice() {
[Clause::Create(create)] => matches!(
create.patterns.as_slice(),
[pattern] if matches!(pattern.elements.as_slice(), [CreateElement::Node(_)])
),
clauses => {
let Some((Clause::Delete(delete), prefix)) = clauses.split_last() else {
return false;
};
delete
.expressions
.iter()
.all(|expr| matches!(expr, cypher::ast::Expression::Variable(_)))
&& prefix
.iter()
.all(|clause| !cypher::executor::write::clause_is_mutation(clause))
}
}
}
pub fn execute_mut(
graph: &mut DirGraph,
query: &str,
opts: &ExecuteOptions<'_>,
) -> Result<ExecuteOutcome, KgError> {
let (parsed, params, encode_plan) = prepare(graph, query, opts)?;
let is_mutation = cypher::is_mutation_query(&parsed);
if parsed.explain {
let result = cypher::generate_explain_result(&parsed, graph);
return Ok(ExecuteOutcome {
result,
is_mutation,
output_format: parsed.output_format,
explain: true,
});
}
if is_mutation {
let mut names = Vec::new();
collect_mutation_names(&parsed, &mut names);
graph
.interner
.validate_names(names)
.map_err(KgError::from)?;
}
let rollback_checkpoint = (is_mutation && !can_skip_rollback_checkpoint(graph, &parsed, opts))
.then(|| graph.fork_transaction());
if is_mutation {
if let Err(error) = graph.prepare_disk_mutation() {
if let Some(checkpoint) = rollback_checkpoint {
*graph = checkpoint;
}
return Err(KgError::FileIo(error));
}
}
let mut result = if is_mutation {
let interrupt = crate::graph::algorithms::Interrupt {
deadline: opts.deadline,
cancel: opts.cancel,
};
graph.active_write_scope = opts.write_scope.cloned();
graph.active_git_sha = opts.git_sha.map(str::to_string);
graph.active_modified_by = opts.modified_by.map(str::to_string);
let r = if opts.max_rows.is_some() {
cypher::executor::write::execute_mutable_bounded(
graph,
&parsed,
params,
interrupt,
opts.max_rows,
)
} else {
cypher::execute_mutable(graph, &parsed, params, interrupt)
};
graph.active_write_scope = None;
graph.active_git_sha = None;
graph.active_modified_by = None;
let r = match r {
Ok(result) => result,
Err(message) => {
if let Some(checkpoint) = rollback_checkpoint {
*graph = checkpoint;
}
return Err(exec_err(opts, message));
}
};
graph.bump_version();
r
} else {
cypher::CypherExecutor::with_params(graph, ¶ms, opts.deadline)
.with_max_rows(opts.max_rows)
.with_streaming(opts.lazy_eligible)
.with_cancel(opts.cancel)
.execute(&parsed)
.map_err(|message| exec_err(opts, message))?
};
cypher::value_codec::apply_encode(&mut result, &encode_plan);
Ok(ExecuteOutcome {
result,
is_mutation,
output_format: parsed.output_format,
explain: false,
})
}
fn collect_pattern_names<'a>(pattern: &'a CreatePattern, out: &mut Vec<&'a str>) {
for element in &pattern.elements {
match element {
CreateElement::Node(node) => {
out.extend(node.label.as_deref());
out.extend(node.extra_labels.iter().map(String::as_str));
out.extend(node.properties.iter().map(|(name, _)| name.as_str()));
}
CreateElement::Edge(edge) => {
out.push(edge.connection_type.as_str());
out.extend(edge.properties.iter().map(|(name, _)| name.as_str()));
}
}
}
}
fn collect_set_names<'a>(items: &'a [SetItem], out: &mut Vec<&'a str>) {
for item in items {
match item {
SetItem::Property { property, .. } => out.push(property),
SetItem::Label { label, .. } => out.push(label),
SetItem::Map { .. } => {}
}
}
}
fn collect_mutation_names<'a>(query: &'a CypherQuery, out: &mut Vec<&'a str>) {
collect_clause_names(&query.clauses, out);
}
fn collect_clause_names<'a>(clauses: &'a [Clause], out: &mut Vec<&'a str>) {
for clause in clauses {
match clause {
Clause::Create(create) => {
for pattern in &create.patterns {
collect_pattern_names(pattern, out);
}
}
Clause::Set(set) => collect_set_names(&set.items, out),
Clause::Remove(remove) => {
for item in &remove.items {
match item {
RemoveItem::Property { property, .. } => out.push(property),
RemoveItem::Label { label, .. } => out.push(label),
}
}
}
Clause::Merge(merge) => {
collect_pattern_names(&merge.pattern, out);
if let Some(items) = &merge.on_create {
collect_set_names(items, out);
}
if let Some(items) = &merge.on_match {
collect_set_names(items, out);
}
}
Clause::Foreach { body, .. } => collect_clause_names(body, out),
Clause::CallSubquery { body, .. } => collect_mutation_names(body, out),
Clause::Union(union) => collect_mutation_names(&union.query, out),
_ => {}
}
}
}
type PreparedQuery = (
Arc<CypherQuery>,
HashMap<String, Value>,
Vec<Option<ValueCodec>>,
);
fn prepare(
graph: &DirGraph,
query: &str,
opts: &ExecuteOptions<'_>,
) -> Result<PreparedQuery, KgError> {
let cacheable = opts.params.is_empty()
&& opts.disabled_passes.is_none_or(|s| s.is_empty())
&& opts.value_codecs.is_none_or(|c| c.is_empty());
if cacheable {
if let Some(plan) =
cypher::plan_cache::get(graph.graph_id(), graph.version(), opts.lazy_eligible, query)
{
return Ok((plan, HashMap::new(), Vec::new()));
}
}
let mut parsed = cypher::parse_cypher(query)?;
let codecs = opts.value_codecs.unwrap_or(&[]);
cypher::value_codec::apply_decode(&mut parsed, codecs);
let encode_plan = cypher::value_codec::build_encode_plan(&parsed, codecs);
cypher::validate_schema(&parsed, graph).map_err(KgError::from)?;
cypher::warn_unknown_pattern_refs(&parsed, graph);
let rewrite = cypher::rewrite_text_score(&mut parsed, opts.params).map_err(|message| {
KgError::CypherExecution {
message,
position: None,
}
})?;
let params: Cow<'_, HashMap<String, Value>> =
if !rewrite.texts_to_embed.is_empty() && !parsed.explain {
Cow::Owned(embed_into_params(opts, &rewrite)?)
} else {
Cow::Borrowed(opts.params)
};
let disabled_default = cypher::planner::empty_disabled_set();
let disabled_ref = opts.disabled_passes.unwrap_or(disabled_default);
cypher::planner::optimize_with_disabled(&mut parsed, graph, ¶ms, disabled_ref);
if opts.lazy_eligible {
cypher::mark_lazy_eligibility(&mut parsed);
}
let plan = Arc::new(parsed);
if cacheable && params.is_empty() {
cypher::plan_cache::insert(
graph.graph_id(),
graph.version(),
opts.lazy_eligible,
query,
plan.clone(),
);
}
Ok((plan, params.into_owned(), encode_plan))
}
fn embed_into_params(
opts: &ExecuteOptions<'_>,
rewrite: &cypher::planner::simplification::TextScoreRewrite,
) -> Result<HashMap<String, Value>, KgError> {
let model = opts
.embedder
.as_ref()
.ok_or_else(|| KgError::CypherExecution {
message: "text_score() requires a registered embedding model. \
Call g.set_embedder(model) first (Python) or pass an embedder \
via ExecuteOptions::embedder (downstream Rust consumers)."
.to_string(),
position: None,
})?;
model.load().map_err(|message| KgError::CypherExecution {
message,
position: None,
})?;
let texts: Vec<String> = rewrite
.texts_to_embed
.iter()
.map(|(_, t)| t.clone())
.collect();
let embed_result = model.embed(&texts);
model.unload();
let embeddings: Vec<Vec<f32>> = embed_result.map_err(|message| KgError::CypherExecution {
message,
position: None,
})?;
if embeddings.len() != texts.len() {
return Err(KgError::CypherExecution {
message: format!(
"text_score: model.embed() returned {} vectors for {} texts",
embeddings.len(),
texts.len()
),
position: None,
});
}
let mut params = opts.params.clone();
for (i, (param_name, _)) in rewrite.texts_to_embed.iter().enumerate() {
let json = format!(
"[{}]",
embeddings[i]
.iter()
.map(|f| f.to_string())
.collect::<Vec<_>>()
.join(", ")
);
params.insert(param_name.clone(), Value::String(json));
}
Ok(params)
}
#[cfg(test)]
mod version_soundness_tests {
use super::*;
use crate::graph::dir_graph::DirGraph;
use crate::graph::storage::GraphRead;
#[test]
fn execute_mut_write_bumps_version() {
let mut g = DirGraph::new();
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
let before = g.version();
execute_mut(&mut g, "CREATE (:Item {id: 1})", &opts).expect("create");
assert!(
g.version() > before,
"a Cypher write must bump version (was {before}, now {})",
g.version()
);
}
#[test]
fn execute_read_does_not_bump_version() {
let mut g = DirGraph::new();
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
execute_mut(&mut g, "CREATE (:Item {id: 1})", &opts).expect("create");
let after_write = g.version();
let _ = execute_read(&g, "MATCH (n:Item) RETURN n.id", &opts).expect("read");
assert_eq!(g.version(), after_write, "a read must not bump version");
}
#[test]
fn cypher_collision_is_typed_and_atomic() {
let mut g = DirGraph::new();
let incoming = "CollisionType";
g.interner
.try_register(
crate::graph::schema::InternedKey::from_str(incoming),
"conflicting-existing",
)
.unwrap();
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
let error = match execute_mut(&mut g, "CREATE (:CollisionType {id: 1})", &opts) {
Err(error) => error,
Ok(_) => panic!("colliding Cypher name must be rejected"),
};
assert!(matches!(error, KgError::InternerCollision(_)));
assert_eq!(g.graph.node_count(), 0);
assert_eq!(g.version(), 0);
}
#[test]
fn checkpointed_multi_create_rolls_back_late_expression_error() {
let mut g = DirGraph::new();
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
let error = match execute_mut(
&mut g,
"CREATE (:Item {id: 1}), (:Item {id: 2, broken: duration({months: 2147483648})})",
&opts,
) {
Err(error) => error,
Ok(_) => panic!("the second CREATE expression must fail"),
};
assert!(
error.to_string().contains("duration()"),
"unexpected error: {error}"
);
assert_eq!(g.graph.node_count(), 0, "the first CREATE must roll back");
assert_eq!(g.version(), 0);
}
#[test]
fn checkpoint_free_mutations_cancel_before_their_first_write() {
static CANCEL: AtomicBool = AtomicBool::new(false);
let mut g = DirGraph::new();
let params = HashMap::new();
let base_opts = ExecuteOptions::eager(¶ms);
execute_mut(&mut g, "CREATE (:Item {id: 1})", &base_opts).unwrap();
CANCEL.store(true, std::sync::atomic::Ordering::Relaxed);
let mut cancelled_opts = ExecuteOptions::eager(¶ms);
cancelled_opts.cancel = Some(&CANCEL);
assert!(matches!(
execute_mut(&mut g, "MATCH (n:Item) DELETE n", &cancelled_opts),
Err(KgError::Cancelled)
));
assert_eq!(g.graph.node_count(), 1);
CANCEL.store(false, std::sync::atomic::Ordering::Relaxed);
}
}