use std::collections::HashMap;
use crate::cypher::procedure::{ProcParam, ProcedureDef, ProcedureRegistry};
use crate::{Database, Value};
use cucumber::{gherkin::Step, given, then, when};
use super::compare;
use super::errors;
use super::graphs;
use super::world::{GraphCounts, World};
#[given("any graph")]
fn given_any_graph(world: &mut World) {
world.db = Some(Database::open_memory().expect("open_memory"));
}
#[given("an empty graph")]
fn given_empty_graph(world: &mut World) {
world.db = Some(Database::open_memory().expect("open_memory"));
}
#[given(expr = "the {word} graph")]
fn given_named_graph(world: &mut World, name: String) {
world.db = Some(graphs::load_named_graph(&name).expect("load named graph"));
}
#[given("having executed:")]
fn having_executed(world: &mut World, step: &Step) {
let query = step
.docstring
.as_ref()
.expect("having executed requires a docstring")
.trim();
let db = world.db.as_mut().expect("database not initialized");
let tx = db.write_tx().expect("begin_write");
for stmt in split_setup_statements(query) {
let stmt = stmt.trim();
if stmt.is_empty() {
continue;
}
tx.query(stmt).unwrap_or_else(|e| {
panic!("setup query failed: {e}\nstatement: {stmt}\nfull script:\n{query}");
});
}
tx.commit().expect("commit");
}
fn split_setup_statements(script: &str) -> Vec<String> {
let keywords = [
"CREATE", "MATCH", "MERGE", "DELETE", "SET", "UNWIND", "RETURN",
];
let mut stmts = Vec::new();
let mut current = String::new();
for line in script.lines() {
let trimmed = line.trim_start();
let is_keyword_start = keywords.iter().any(|kw| {
trimmed.starts_with(kw)
&& trimmed[kw.len()..].starts_with(|c: char| c.is_whitespace() || c == '(')
});
let current_trimmed = current.trim_start();
let current_starts_with_create = current_trimmed.starts_with("CREATE");
let current_starts_with_unwind = current_trimmed.starts_with("UNWIND");
let current_starts_with_match = current_trimmed.starts_with("MATCH");
let line_starts_with_create = trimmed.starts_with("CREATE");
let line_starts_with_unwind = trimmed.starts_with("UNWIND");
let line_starts_with_delete = trimmed.starts_with("DELETE");
let line_starts_with_set = trimmed.starts_with("SET");
let keep_together = (line_starts_with_create
&& (current_starts_with_create
|| current_starts_with_unwind
|| current_starts_with_match))
|| (line_starts_with_unwind && current_starts_with_unwind)
|| ((line_starts_with_delete || line_starts_with_set)
&& current_starts_with_match);
let has_with = current.to_ascii_uppercase().contains("\nWITH ")
|| current.to_ascii_uppercase().starts_with("WITH ");
let should_split = is_keyword_start && !current.is_empty() && !keep_together && !has_with;
if should_split {
stmts.push(current.clone());
current.clear();
}
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
if !current.is_empty() {
stmts.push(current);
}
stmts
}
#[given("parameters are:")]
fn given_parameters(world: &mut World, step: &Step) {
if let Some(table) = &step.table {
for row in &table.rows {
if row.len() >= 2 {
let key = row[0].trim().to_string();
let val = compare::parse_expected(row[1].trim())
.unwrap_or_else(|e| panic!("bad param value {}: {e}", row[1]));
world.params.insert(key, val);
}
}
}
}
#[given(regex = r"^there exists a procedure (.+):$")]
fn given_procedure(world: &mut World, step: &Step, sig: String) {
let (name, inputs, outputs) = parse_procedure_signature(&sig);
let mut rows = Vec::new();
if let Some(table) = &step.table {
if table.rows.len() > 1 {
let headers: Vec<String> = table.rows[0].iter().map(|c| c.trim().to_string()).collect();
for data_row in &table.rows[1..] {
let mut row = std::collections::HashMap::new();
for (i, cell) in data_row.iter().enumerate() {
if i < headers.len() {
let value = compare::parse_expected(cell.trim())
.unwrap_or_else(|e| panic!("bad procedure data {cell}: {e}"));
row.insert(headers[i].clone(), value);
}
}
rows.push(row);
}
}
}
world.procedures.register(ProcedureDef {
name,
inputs,
outputs,
rows,
});
}
fn run_query(
db: &mut Database,
query: &str,
params: Option<&HashMap<String, Value>>,
procedures: &ProcedureRegistry,
is_write: bool,
) -> Result<Vec<crate::cypher::record::NamedRecord>, crate::GraphError> {
if is_write {
let tx = db.write_tx().expect("begin_write");
let outcome = tx.query_with_procedures(query, params, procedures);
match &outcome {
Ok(_) => tx.commit().expect("commit"),
Err(_) => tx.rollback().expect("rollback"),
}
outcome
} else {
let tx = db.read_tx().expect("begin_read");
let outcome = tx.query_with_procedures(query, params, procedures);
tx.commit().expect("commit read tx");
outcome
}
}
fn is_write_query(query: &str) -> bool {
let upper = query.to_uppercase();
upper.contains("CREATE")
|| upper.contains("DELETE")
|| upper.contains("SET ")
|| upper.contains("MERGE")
|| upper.contains("REMOVE")
}
#[when("executing query:")]
fn when_executing_query(world: &mut World, step: &Step) {
let query = step
.docstring
.as_ref()
.expect("executing query requires a docstring")
.trim();
let db = world.db.as_mut().expect("database not initialized");
world.pre_counts = GraphCounts::snapshot(db.connection());
let params: Option<HashMap<String, Value>> = if world.params.is_empty() {
None
} else {
Some(world.params.drain().collect())
};
let is_write = is_write_query(query);
let primary = run_query(db, query, params.as_ref(), &world.procedures, is_write);
match primary {
Ok(records) => {
crate::tck_support::headers::log_headers(
&world.current_feature,
&world.current_scenario,
&records,
);
world.last_result = Some(records);
world.last_error = None;
}
Err(e) => {
world.last_result = None;
world.last_error = Some(e);
}
}
}
#[when("executing control query:")]
fn when_executing_control_query(world: &mut World, step: &Step) {
let query = step
.docstring
.as_ref()
.expect("executing control query requires a docstring")
.trim();
let db = world.db.as_mut().expect("database not initialized");
let primary = run_query(db, query, None, &world.procedures, false);
match primary {
Ok(records) => {
world.last_result = Some(records);
world.last_error = None;
}
Err(e) => {
world.last_result = None;
world.last_error = Some(e);
}
}
}
#[then("the result should be, in any order:")]
fn then_result_any_order(world: &mut World, step: &Step) {
let results = world
.last_result
.as_ref()
.expect("expected results but query returned an error");
let table = step.table.as_ref().expect("expected a result table");
let (columns, expected_rows) = parse_result_table(table);
compare::compare_result(results, &columns, &expected_rows, false)
.expect("result comparison failed");
}
#[then("the result should be, in order:")]
fn then_result_in_order(world: &mut World, step: &Step) {
let results = world
.last_result
.as_ref()
.expect("expected results but query returned an error");
let table = step.table.as_ref().expect("expected a result table");
let (columns, expected_rows) = parse_result_table(table);
compare::compare_result(results, &columns, &expected_rows, true)
.expect("result comparison failed");
}
#[then("the result should be (ignoring element order for lists):")]
fn then_result_any_order_ignore_list_order(world: &mut World, step: &Step) {
let results = world
.last_result
.as_ref()
.expect("expected results but query returned an error");
let table = step.table.as_ref().expect("expected a result table");
let (columns, expected_rows) = parse_result_table(table);
compare::compare_result_ignore_list_order(results, &columns, &expected_rows, false)
.expect("result comparison failed");
}
#[then("the result should be, in order (ignoring element order for lists):")]
fn then_result_in_order_ignore_list_order(world: &mut World, step: &Step) {
let results = world
.last_result
.as_ref()
.expect("expected results but query returned an error");
let table = step.table.as_ref().expect("expected a result table");
let (columns, expected_rows) = parse_result_table(table);
compare::compare_result_ignore_list_order(results, &columns, &expected_rows, true)
.expect("result comparison failed");
}
#[then("the result should be empty")]
fn then_result_empty(world: &mut World) {
let results = world
.last_result
.as_ref()
.expect("expected results but query returned an error");
assert!(
results.is_empty(),
"expected empty result set, got {} rows",
results.len()
);
}
#[then("no side effects")]
fn then_no_side_effects(world: &mut World) {
let db = world.db.as_mut().expect("database not initialized");
let post = GraphCounts::snapshot(db.connection());
let delta = world.pre_counts.delta(&post);
for (name, count) in &delta {
assert_eq!(*count, 0, "expected no side effects but {name} = {count}");
}
}
#[then("the side effects should be:")]
fn then_side_effects(world: &mut World, step: &Step) {
let db = world.db.as_mut().expect("database not initialized");
let post = GraphCounts::snapshot(db.connection());
let delta = world.pre_counts.delta(&post);
if let Some(table) = &step.table {
for row in &table.rows {
if row.len() >= 2 {
let name = row[0].trim();
let expected: i64 = row[1].trim().parse().unwrap_or(0);
let actual = *delta.get(name).unwrap_or(&0);
assert_eq!(
actual, expected,
"side effect mismatch for {name}: expected {expected}, got {actual}"
);
}
}
}
}
#[then(regex = r"^a (\w+) should be raised")]
fn then_error_raised(world: &mut World, kind: String) {
let error = world
.last_error
.as_ref()
.unwrap_or_else(|| panic!("expected a {kind} to be raised, but query succeeded"));
assert!(
errors::matches_tck_error(error, &kind, None),
"expected {kind} error, got: {error:?}"
);
}
fn parse_result_table(table: &cucumber::gherkin::Table) -> (Vec<String>, Vec<Vec<Value>>) {
if table.rows.is_empty() {
return (Vec::new(), Vec::new());
}
let columns: Vec<String> = table.rows[0].iter().map(|c| c.trim().to_string()).collect();
let expected_rows: Vec<Vec<Value>> = table.rows[1..]
.iter()
.map(|row| {
row.iter()
.map(|cell| {
compare::parse_expected(cell.trim())
.unwrap_or_else(|e| panic!("bad expected cell {cell:?}: {e}"))
})
.collect()
})
.collect();
(columns, expected_rows)
}
fn parse_procedure_signature(sig: &str) -> (String, Vec<ProcParam>, Vec<ProcParam>) {
let (left, right) = if let Some(pos) = sig.find(") :: (") {
(&sig[..pos + 1], sig[pos + 4..].trim()) } else {
(sig.trim_end(), "()")
};
let left = left.trim();
let (name, inputs) = if let Some(paren_pos) = left.find('(') {
let name = left[..paren_pos].trim().to_string();
let close = left.rfind(')').unwrap_or(left.len());
let params_str = &left[paren_pos + 1..close];
(name, parse_params(params_str))
} else {
(left.to_string(), Vec::new())
};
let outputs = if let Some(start) = right.find('(') {
let end = right.rfind(')').unwrap_or(right.len());
parse_params(&right[start + 1..end])
} else {
Vec::new()
};
(name, inputs, outputs)
}
fn parse_params(s: &str) -> Vec<ProcParam> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Vec::new();
}
trimmed
.split(',')
.map(|p| {
let parts: Vec<&str> = p.trim().splitn(2, "::").collect();
ProcParam {
name: parts[0].trim().to_string(),
type_name: if parts.len() > 1 {
parts[1].trim().to_string()
} else {
"ANY?".to_string()
},
}
})
.collect()
}