use super::*;
fn query_error(graph: &DirGraph, query: &str) -> String {
let parsed = match parser::parse_cypher(query) {
Ok(parsed) => parsed,
Err(e) => return e.to_string(),
};
let no_params = HashMap::new();
match CypherExecutor::with_params(graph, &no_params, None).execute(&parsed) {
Ok(result) => panic!(
"query unexpectedly succeeded: {query}\n rows: {:?}",
result.rows
),
Err(e) => e,
}
}
fn one_cell(graph: &DirGraph, query: &str) -> Value {
let parsed = parser::parse_cypher(query)
.unwrap_or_else(|e| panic!("query failed to parse: {query}\n error: {e}"));
let no_params = HashMap::new();
let result = CypherExecutor::with_params(graph, &no_params, None)
.execute(&parsed)
.unwrap_or_else(|e| panic!("query failed: {query}\n error: {e}"));
assert_eq!(result.rows.len(), 1, "expected one row from: {query}");
assert_eq!(result.rows[0].len(), 1, "expected one column from: {query}");
result.rows[0][0].clone()
}
#[test]
fn duplicate_return_aliases_are_rejected() {
let graph = DirGraph::new();
for query in [
"RETURN 1 AS x, 2 AS x",
"RETURN 1 AS x, 2 AS y, 3 AS x",
"MATCH (n:Person) RETURN n.a AS x, n.b AS x",
"MATCH (n:Person) RETURN DISTINCT n.a AS x, n.b AS x",
"MATCH (n:Person) RETURN count(n) AS c, count(n) AS c",
"MATCH (n:Person) WITH n.a AS x, n.b AS x RETURN x",
"MATCH (n:Person) RETURN n.a, n.a",
"RETURN 1 AS `x`, 2 AS x",
] {
let error = query_error(&graph, query);
assert!(
error.contains("Multiple result columns with the same name are not supported"),
"expected a duplicate-column rejection for `{query}`, got: {error}"
);
}
}
#[test]
fn distinct_column_names_still_project() {
let graph = DirGraph::new();
let parsed = parser::parse_cypher("RETURN 1 AS x, 2 AS X, 3 AS y").unwrap();
let no_params = HashMap::new();
let result = CypherExecutor::with_params(&graph, &no_params, None)
.execute(&parsed)
.unwrap();
assert_eq!(result.columns, vec!["x", "X", "y"]);
assert_eq!(
result.rows[0],
vec![Value::Int64(1), Value::Int64(2), Value::Int64(3)]
);
}
fn timestamp(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> Value {
Value::Timestamp(
chrono::NaiveDate::from_ymd_opt(y, m, d)
.unwrap()
.and_hms_opt(hh, mm, ss)
.unwrap(),
)
}
#[test]
fn datetime_preserves_time_and_normalises_the_zone_to_utc() {
let graph = DirGraph::new();
let cases: &[(&str, Value)] = &[
(
"RETURN datetime('2024-01-15T10:30:00') AS d",
timestamp(2024, 1, 15, 10, 30, 0),
),
(
"RETURN datetime('2024-01-15T10:30:00Z') AS d",
timestamp(2024, 1, 15, 10, 30, 0),
),
(
"RETURN datetime('2024-01-15T10:30:00+02:00') AS d",
timestamp(2024, 1, 15, 8, 30, 0),
),
(
"RETURN datetime('2024-01-15T01:30:00-05:00') AS d",
timestamp(2024, 1, 15, 6, 30, 0),
),
(
"RETURN datetime('2024-01-15T10:30:00.500Z') AS d",
timestamp(2024, 1, 15, 10, 30, 0),
),
(
"RETURN datetime('2024-01-15T10:30') AS d",
timestamp(2024, 1, 15, 10, 30, 0),
),
(
"RETURN datetime('2024-01-15') AS d",
timestamp(2024, 1, 15, 0, 0, 0),
),
("RETURN datetime('not-a-date') AS d", Value::Null),
("RETURN datetime('2024-01-15T25:99:99') AS d", Value::Null),
(
"RETURN datetime('10000-01-01T00:00:00') AS d",
timestamp(10000, 1, 1, 0, 0, 0),
),
];
for (query, expected) in cases {
assert_eq!(one_cell(&graph, query), *expected, "for: {query}");
}
}
#[test]
fn localdatetime_keeps_the_wall_clock_reading() {
let graph = DirGraph::new();
assert_eq!(
one_cell(
&graph,
"RETURN localdatetime('2024-01-15T10:30:00+02:00') AS d"
),
timestamp(2024, 1, 15, 10, 30, 0)
);
assert_eq!(
one_cell(&graph, "RETURN localdatetime('2024-01-15T10:30:00Z') AS d"),
timestamp(2024, 1, 15, 10, 30, 0)
);
assert_eq!(
one_cell(&graph, "RETURN localdatetime('2024-01-15') AS d"),
timestamp(2024, 1, 15, 0, 0, 0)
);
}
#[test]
fn integer_overflow_and_division_by_zero_are_query_errors() {
let graph = DirGraph::new();
let cases: &[(&str, &str)] = &[
(
"RETURN 9223372036854775807 + 1 AS n",
"Integer overflow in addition",
),
(
"RETURN (-9223372036854775807 - 1) - 1 AS n",
"Integer overflow in subtraction",
),
(
"RETURN 9223372036854775807 * 2 AS n",
"Integer overflow in multiplication",
),
(
"RETURN (-9223372036854775807 - 1) / -1 AS n",
"Integer overflow in division",
),
(
"RETURN (-9223372036854775807 - 1) % -1 AS n",
"Integer overflow in modulo",
),
("RETURN 1 / 0 AS n", "Integer division by zero"),
("RETURN 1 % 0 AS n", "Integer modulo by zero"),
];
for (query, expected) in cases {
let error = query_error(&graph, query);
assert!(
error.contains(expected),
"expected `{expected}` for `{query}`, got: {error}"
);
}
}
#[test]
fn ordinary_integer_arithmetic_is_unchanged() {
let graph = DirGraph::new();
for (query, expected) in [
(
"RETURN 9223372036854775806 + 1 AS n",
Value::Int64(i64::MAX),
),
("RETURN -7 / 2 AS n", Value::Int64(-3)),
("RETURN 1967 / 10 * 10 AS n", Value::Int64(1960)),
("RETURN 7 % 3 AS n", Value::Int64(1)),
("RETURN 1.0 / 0 AS n", Value::Null),
] {
assert_eq!(one_cell(&graph, query), expected, "for: {query}");
}
}
fn build_mixed_property_graph(values: Vec<Value>) -> DirGraph {
let mut graph = DirGraph::new();
for (i, value) in values.into_iter().enumerate() {
let node = NodeData::new(
Value::UniqueId(i as u32 + 1),
Value::String(format!("s{i}")),
"S".to_string(),
HashMap::from([("v".to_string(), value)]),
&mut graph.interner,
);
let idx = graph.graph.add_node(node);
graph
.type_indices
.entry_or_default("S".to_string())
.push(idx);
}
graph
}
fn optimized_and_unoptimized(graph: &DirGraph, query: &str) -> (Vec<Value>, Vec<Value>) {
let params = HashMap::new();
let mut optimized = parser::parse_cypher(query).unwrap();
crate::graph::languages::cypher::planner::optimize(&mut optimized, graph, ¶ms);
assert!(
optimized
.clauses
.iter()
.any(|c| matches!(c, Clause::FusedNodeScanAggregate { .. })),
"non-vacuity: `{query}` did not fuse, so it would not exercise the \
inline accumulator at all"
);
let unoptimized = parser::parse_cypher(query).unwrap();
let run = |q: &CypherQuery| -> Vec<Value> {
let result = CypherExecutor::with_params(graph, ¶ms, None)
.execute(q)
.unwrap_or_else(|e| panic!("query failed: {query}\n error: {e}"));
assert_eq!(result.rows.len(), 1, "expected one row from: {query}");
(0..result.rows[0].len())
.map(|i| result.rows[0][i].clone())
.collect()
};
(run(&optimized), run(&unoptimized))
}
#[test]
fn fused_avg_divides_by_the_numeric_count_not_the_non_null_count() {
let graph = build_mixed_property_graph(vec![
Value::Int64(10),
Value::Int64(20),
Value::String("hello".to_string()),
]);
let (fused, materialized) = optimized_and_unoptimized(
&graph,
"MATCH (n:S) RETURN avg(n.v) AS a, sum(n.v) AS s, count(n.v) AS c",
);
assert_eq!(fused[0], Value::Float64(15.0), "avg over [10, 20, 'hello']");
assert_eq!(fused[1], Value::Int64(30), "sum over [10, 20, 'hello']");
assert_eq!(fused[2], Value::Int64(3), "count over [10, 20, 'hello']");
assert_eq!(fused, materialized, "fused vs materialized aggregation");
}
#[test]
fn fused_avg_and_sum_over_zero_numeric_values_match_the_unfused_path() {
let graph = build_mixed_property_graph(vec![
Value::String("a".to_string()),
Value::String("b".to_string()),
]);
let (fused, materialized) =
optimized_and_unoptimized(&graph, "MATCH (n:S) RETURN avg(n.v) AS a, sum(n.v) AS s");
assert_eq!(fused[0], Value::Null, "avg over ['a', 'b']");
assert_eq!(fused[1], Value::Int64(0), "sum over ['a', 'b']");
assert_eq!(fused, materialized, "fused vs materialized aggregation");
}
#[test]
fn fused_sum_keeps_the_unfused_paths_numeric_type_on_mixed_columns() {
for values in [
vec![
Value::Int64(10),
Value::Int64(20),
Value::String("x".into()),
],
vec![Value::String("x".into()), Value::Int64(10)],
vec![Value::Null, Value::Int64(1), Value::Int64(2)],
vec![Value::Int64(1), Value::Float64(2.5)],
vec![Value::Float64(1.5), Value::Int64(2)],
vec![Value::Int64(1), Value::Int64(2)],
] {
let graph = build_mixed_property_graph(values.clone());
let (fused, materialized) = optimized_and_unoptimized(
&graph,
"MATCH (n:S) RETURN sum(n.v) AS s, avg(n.v) AS a, count(n.v) AS c",
);
assert_eq!(fused, materialized, "fused vs materialized over {values:?}");
}
}
#[test]
fn fused_scan_raises_on_a_pattern_that_does_not_compile() {
let graph = build_mixed_property_graph(vec![
Value::String("Alice".into()),
Value::String("Bob".into()),
]);
let params = HashMap::new();
for pattern in ["[", "A(?=l)ice", r"(a)\\1"] {
let query = format!("MATCH (n:S) WHERE n.v =~ '{pattern}' RETURN count(*) AS c");
let mut optimized = parser::parse_cypher(&query).unwrap();
crate::graph::languages::cypher::planner::optimize(&mut optimized, &graph, ¶ms);
assert!(
optimized
.clauses
.iter()
.any(|c| matches!(c, Clause::FusedNodeScanAggregate { .. })),
"non-vacuity: `{query}` did not fuse, so it would not exercise the \
fused scan's predicate at all"
);
let err = CypherExecutor::with_params(&graph, ¶ms, None)
.execute(&optimized)
.expect_err(&format!("`{query}` must not answer with rows"));
assert!(
err.contains("Invalid regular expression"),
"fused scan error for `{pattern}`: {err}"
);
let unoptimized = parser::parse_cypher(&query).unwrap();
let unfused_err = CypherExecutor::with_params(&graph, ¶ms, None)
.execute(&unoptimized)
.expect_err(&format!("`{query}` must not answer with rows unfused"));
assert_eq!(err, unfused_err, "fused vs unfused error for `{pattern}`");
}
}
#[test]
fn fused_scan_raises_on_an_unbound_parameter() {
let graph = build_mixed_property_graph(vec![
Value::String("Alice".into()),
Value::String("Bob".into()),
]);
let params = HashMap::new();
let query = "MATCH (n:S) WHERE n.v = $missing RETURN count(*) AS c";
let mut optimized = parser::parse_cypher(query).unwrap();
crate::graph::languages::cypher::planner::optimize(&mut optimized, &graph, ¶ms);
assert!(
optimized
.clauses
.iter()
.any(|c| matches!(c, Clause::FusedNodeScanAggregate { .. })),
"non-vacuity: `{query}` did not fuse, so it would not exercise the \
fused scan's predicate at all"
);
let err = CypherExecutor::with_params(&graph, ¶ms, None)
.execute(&optimized)
.expect_err("an unbound parameter must not answer with a count");
assert!(err.contains("Missing parameter: $missing"), "fused: {err}");
let unoptimized = parser::parse_cypher(query).unwrap();
let unfused_err = CypherExecutor::with_params(&graph, ¶ms, None)
.execute(&unoptimized)
.expect_err("an unbound parameter must not answer with a count unfused");
assert_eq!(err, unfused_err, "fused vs unfused error for `{query}`");
}
#[test]
fn fused_scan_still_counts_with_the_parameter_bound() {
let graph = build_mixed_property_graph(vec![
Value::String("Alice".into()),
Value::String("Bob".into()),
Value::String("Alice".into()),
]);
let params = HashMap::from([("bound".to_string(), Value::String("Alice".into()))]);
let query = "MATCH (n:S) WHERE n.v = $bound RETURN count(*) AS c";
let mut optimized = parser::parse_cypher(query).unwrap();
crate::graph::languages::cypher::planner::optimize(&mut optimized, &graph, ¶ms);
let result = CypherExecutor::with_params(&graph, ¶ms, None)
.execute(&optimized)
.expect("a bound parameter still answers");
assert_eq!(result.rows[0][0], Value::Int64(2));
}
#[test]
fn fused_scan_still_drops_rows_whose_predicate_cannot_be_evaluated() {
let graph = build_mixed_property_graph(vec![
Value::String("Alice".into()),
Value::Int64(7),
Value::Null,
]);
let (fused, materialized) = optimized_and_unoptimized(
&graph,
"MATCH (n:S) WHERE n.v =~ '^A.*' RETURN count(*) AS c",
);
assert_eq!(fused[0], Value::Int64(1), "only the string cell matches");
assert_eq!(fused, materialized, "fused vs materialized filter");
}
fn materialized_aggregation_row(graph: &DirGraph, query: &str) -> Vec<Value> {
let params = HashMap::new();
let parsed = parser::parse_cypher(query).unwrap();
let result = CypherExecutor::with_params(graph, ¶ms, None)
.execute(&parsed)
.unwrap_or_else(|e| panic!("query failed: {query}\n error: {e}"));
assert_eq!(result.rows.len(), 1, "expected one row from: {query}");
(0..result.rows[0].len())
.map(|i| result.rows[0][i].clone())
.collect()
}
#[test]
fn materialized_sum_keeps_the_streaming_paths_numeric_type() {
for (values, expected) in [
(
vec![Value::String("x".into()), Value::Int64(10)],
Value::Int64(10),
),
(
vec![Value::Null, Value::Int64(1), Value::Int64(2)],
Value::Int64(3),
),
(
vec![Value::Float64(1.5), Value::Int64(2)],
Value::Float64(3.5),
),
(
vec![Value::Int64(2), Value::Float64(1.5)],
Value::Float64(3.5),
),
(vec![Value::Int64(1), Value::Int64(2)], Value::Int64(3)),
(
vec![Value::String("x".into()), Value::String("y".into())],
Value::Int64(0),
),
] {
let graph = build_mixed_property_graph(values.clone());
let unkeyed = "MATCH (n:S) RETURN sum(n.v) AS s, median(n.v) AS m";
assert!(
crate::graph::languages::cypher::executor::stream::aggregate::try_compile_specs(
match &parser::parse_cypher(unkeyed).unwrap().clauses[1] {
Clause::Return(rc) => rc,
other => panic!("expected a RETURN clause, got {other:?}"),
}
)
.is_err(),
"non-vacuity: the streaming path accepted `{unkeyed}`"
);
assert_eq!(
materialized_aggregation_row(&graph, unkeyed)[0],
expected,
"unkeyed sum over {values:?}"
);
let keyed = "MATCH (n:S) RETURN 1 AS k, sum(n.v) AS s";
assert_eq!(
materialized_aggregation_row(&graph, keyed)[1],
expected,
"grouped sum over {values:?}"
);
let distinct = "MATCH (n:S) RETURN 1 AS k, sum(DISTINCT n.v) AS s";
assert_eq!(
materialized_aggregation_row(&graph, distinct)[1],
expected,
"grouped sum(DISTINCT) over {values:?}"
);
}
}
#[test]
fn materialized_distinct_aggregates_dedup_on_the_value() {
for (values, query, expected) in [
(
vec![Value::Int64(1), Value::Float64(1.0), Value::Int64(2)],
"MATCH (n:S) RETURN sum(DISTINCT n.v) AS s, median(n.v) AS m",
Value::Float64(4.0),
),
(
vec![Value::Int64(1), Value::Int64(1), Value::Int64(2)],
"MATCH (n:S) RETURN sum(DISTINCT n.v) AS s, median(n.v) AS m",
Value::Int64(3),
),
(
vec![
Value::Float64(0.0),
Value::Float64(-0.0),
Value::Float64(1.5),
],
"MATCH (n:S) RETURN avg(DISTINCT n.v) AS a, median(n.v) AS m",
Value::Float64(0.75),
),
(
vec![Value::Int64(1), Value::String("1".into())],
"MATCH (n:S) RETURN collect(DISTINCT n.v) AS l",
Value::List(vec![Value::Int64(1), Value::String("1".into())]),
),
] {
let graph = build_mixed_property_graph(values.clone());
assert!(
crate::graph::languages::cypher::executor::stream::aggregate::try_compile_specs(
match &parser::parse_cypher(query).unwrap().clauses[1] {
Clause::Return(rc) => rc,
other => panic!("expected a RETURN clause, got {other:?}"),
}
)
.is_err(),
"non-vacuity: the streaming path accepted `{query}`"
);
assert_eq!(
materialized_aggregation_row(&graph, query)[0],
expected,
"distinct aggregate over {values:?}"
);
}
}