use std::collections::HashMap;
use super::execute::{execute_mut, execute_read, ExecuteOptions};
use crate::datatypes::Value;
use crate::error::KgError;
use crate::graph::dir_graph::DirGraph;
use crate::graph::schema::{NodeSchemaDefinition, SchemaDefinition, SchemaInstall};
fn empty_params() -> HashMap<String, Value> {
HashMap::new()
}
fn seeded() -> DirGraph {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = DirGraph::new();
for statement in [
"CREATE (:Person {id: 1, age: 30, email: 'a@b.c'})-[:AUTHORED]->(:Paper {id: 2, year: 2020})",
"CREATE (:Person {id: 3, age: 40})",
"CREATE (:Tag {id: 4})",
] {
execute_mut(&mut graph, statement, &opts).expect("seed write");
}
graph
}
fn locked() -> DirGraph {
let mut graph = seeded();
graph.schema_locked = true;
graph
}
fn run(graph: &DirGraph, query: &str) -> Result<Vec<String>, String> {
run_with(graph, query, &empty_params())
}
fn run_with(
graph: &DirGraph,
query: &str,
params: &HashMap<String, Value>,
) -> Result<Vec<String>, String> {
let opts = ExecuteOptions::eager(params);
match execute_read(graph, query, &opts) {
Ok(outcome) => Ok(outcome
.result
.diagnostics
.expect("every execution carries diagnostics")
.warnings),
Err(KgError::Schema { message, .. }) => Err(message),
Err(other) => panic!("{query}: unexpected error {other:?}"),
}
}
fn strict_error(graph: &DirGraph, query: &str) -> String {
match run(graph, query) {
Err(message) => message,
Ok(warnings) => panic!("{query}: expected a schema error, got warnings {warnings:?}"),
}
}
fn accepted(graph: &DirGraph, query: &str) -> Vec<String> {
match run(graph, query) {
Ok(warnings) => warnings,
Err(message) => panic!("{query}: locked schema wrongly rejected this — {message}"),
}
}
#[test]
fn locked_schema_rejects_an_absent_property_in_where() {
let message = strict_error(&locked(), "MATCH (p:Person) WHERE p.agee = 1 RETURN p");
assert!(
message.contains("Unknown property 'agee' on Person, referenced in WHERE"),
"{message}"
);
assert!(message.contains("Did you mean 'age'?"), "{message}");
assert!(
message.contains("Valid properties: age, email"),
"{message}"
);
assert!(message.contains("unlock_schema()"), "{message}");
}
#[test]
fn locked_schema_rejects_an_absent_property_in_return() {
let message = strict_error(&locked(), "MATCH (p:Person) RETURN p.name, p.agee");
assert!(
message.contains("Unknown property 'agee' on Person, referenced in RETURN"),
"{message}"
);
}
#[test]
fn locked_schema_rejects_absent_properties_in_with_and_order_by() {
let graph = locked();
assert!(
strict_error(&graph, "MATCH (p:Person) WITH p.agee AS a RETURN a")
.contains("referenced in WITH"),
);
assert!(
strict_error(&graph, "MATCH (p:Person) RETURN p ORDER BY p.agee")
.contains("referenced in ORDER BY"),
);
}
#[test]
fn locked_schema_rejects_an_absent_property_read_inside_a_mutation() {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = locked();
let err = match execute_mut(
&mut graph,
"MATCH (p:Person) WHERE p.agee = 1 SET p.age = 99",
&opts,
) {
Err(err) => err,
Ok(_) => panic!("a locked schema must reject the typo'd selector"),
};
assert!(
matches!(&err, KgError::Schema { message, .. } if message.contains("'agee'")),
"{err:?}"
);
let rows = execute_read(&graph, "MATCH (p:Person) WHERE p.age = 99 RETURN p", &opts)
.expect("read")
.result
.rows;
assert!(rows.is_empty(), "{rows:?}");
}
#[test]
fn explain_of_a_strict_failing_query_errors_like_an_unknown_label_does() {
let graph = locked();
assert!(strict_error(&graph, "EXPLAIN MATCH (p:Person) RETURN p.agee").contains("'agee'"),);
assert!(
strict_error(&graph, "EXPLAIN MATCH (n:Persn) RETURN n").contains("Persn"),
"unknown label under a lock must reject EXPLAIN too"
);
assert!(
strict_error(
&declared_locked(),
"EXPLAIN MATCH (p:Person) WHERE p.age > 'forty' RETURN p"
)
.contains("declared INTEGER"),
"a declared-type mismatch under a lock must reject EXPLAIN too"
);
}
#[test]
fn an_open_schema_still_only_warns() {
let graph = seeded();
for query in [
"MATCH (p:Person) WHERE p.agee = 1 RETURN p",
"MATCH (p:Person) RETURN p.agee",
] {
let warnings = accepted(&graph, query);
assert!(
warnings.iter().any(|w| w.contains("Did you mean 'age'?")),
"{query} -> {warnings:?}"
);
}
}
#[test]
fn unlocking_restores_the_warning() {
let mut graph = locked();
strict_error(&graph, "MATCH (p:Person) RETURN p.agee");
graph.schema_locked = false;
let warnings = accepted(&graph, "MATCH (p:Person) RETURN p.agee");
assert!(
warnings.iter().any(|w| w.contains("Did you mean 'age'?")),
"{warnings:?}"
);
}
#[test]
fn a_plan_primed_before_the_lock_is_still_rejected_after_it() {
let mut graph = seeded();
let query = "MATCH (p:Person) RETURN p.agee";
for _ in 0..2 {
accepted(&graph, query);
}
let version_before = graph.version();
graph.schema_locked = true;
assert_eq!(
graph.version(),
version_before,
"this test is about a lock that did NOT invalidate the cache"
);
assert!(strict_error(&graph, query).contains("'agee'"));
}
#[test]
fn a_sparse_property_is_not_a_typo() {
let graph = locked();
assert!(accepted(&graph, "MATCH (p:Person) RETURN p.email, p.age").is_empty());
}
#[test]
fn a_type_with_no_property_metadata_is_never_rejected() {
let graph = locked();
assert!(accepted(&graph, "MATCH (t:Tag) RETURN t.anything").is_empty());
}
#[test]
fn builtins_and_untyped_vars_are_never_rejected() {
let graph = locked();
accepted(
&graph,
"MATCH (p:Person) RETURN p.id, p.title, p.name, p.type",
);
accepted(&graph, "MATCH (n) WHERE n.whatever = 1 RETURN n");
}
#[test]
fn a_multi_label_pattern_is_never_rejected() {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = seeded();
execute_mut(&mut graph, "MATCH (p:Person) SET p:Admin", &opts).expect("secondary label");
graph.schema_locked = true;
accepted(&graph, "MATCH (p:Person:Admin) RETURN p.agee");
}
#[test]
fn a_with_rebound_var_is_never_rejected() {
let graph = locked();
accepted(&graph, "MATCH (n:Person) WITH n AS m RETURN m.agee");
accepted(
&graph,
"MATCH (n:Person) WITH n AS m WHERE m.agee = 1 RETURN m",
);
}
#[test]
fn a_property_the_same_statement_writes_is_never_rejected_at_prepare() {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = locked();
for query in [
"EXPLAIN MATCH (p:Person) SET p.badprop = 1 RETURN p.badprop",
"EXPLAIN MATCH (p:Person) SET p += {badprop: 1} RETURN p.badprop",
] {
if let Err(err) = execute_mut(&mut graph, query, &opts) {
panic!("{query}: rejected at prepare — {err:?}");
}
}
}
#[test]
fn optional_match_is_treated_as_match() {
let graph = locked();
accepted(&graph, "OPTIONAL MATCH (p:Person) RETURN p.age");
assert!(strict_error(
&graph,
"OPTIONAL MATCH (p:Person) WHERE p.agee = 1 RETURN p"
)
.contains("'agee'"),);
}
#[test]
fn a_reversed_arrow_stays_a_warning_under_the_lock() {
let graph = locked();
let warnings = accepted(&graph, "MATCH (a:Paper)-[:AUTHORED]->(p:Person) RETURN p");
assert!(
warnings.iter().any(|w| w.contains("Reverse the arrow?")),
"{warnings:?}"
);
}
#[test]
fn an_unknown_relationship_type_stays_a_warning_under_the_lock() {
let graph = locked();
let warnings = accepted(&graph, "MATCH (p:Person)-[:AUTHRED]->(a:Paper) RETURN p");
assert!(
warnings
.iter()
.any(|w| w.contains("unknown relationship type 'AUTHRED'")),
"{warnings:?}"
);
}
#[test]
fn an_unknown_label_is_reported_once_as_a_label_error() {
let message = strict_error(&locked(), "MATCH (p:Persn) WHERE p.agee = 1 RETURN p");
assert!(message.contains("Unknown node type 'Persn'"), "{message}");
assert!(!message.contains("agee"), "{message}");
}
fn declared() -> DirGraph {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = seeded();
for statement in [
"CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS :: INTEGER",
"CREATE CONSTRAINT FOR (p:Person) REQUIRE p.email IS :: STRING",
] {
execute_mut(&mut graph, statement, &opts).expect("declare the property type");
}
graph
}
fn declared_locked() -> DirGraph {
let mut graph = declared();
graph.schema_locked = true;
graph
}
fn schema_defined() -> DirGraph {
let mut graph = seeded();
let mut node = NodeSchemaDefinition::default();
for (property, declared) in [("age", "integer"), ("email", "string")] {
node.field_types
.insert(property.to_string(), declared.to_string());
}
let mut schema = SchemaDefinition::default();
schema.node_schemas.insert("Person".to_string(), node);
graph
.set_schema(schema, SchemaInstall::Replace)
.expect("the seeded rows honour the declaration");
graph
}
fn schema_defined_locked() -> DirGraph {
let mut graph = schema_defined();
graph.schema_locked = true;
graph
}
fn mixed_locked() -> DirGraph {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let mut graph = schema_defined();
execute_mut(
&mut graph,
"CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS :: INTEGER",
&opts,
)
.expect("declare the property type");
graph.schema_locked = true;
graph
}
const CROSS_TYPE: &str = "MATCH (p:Person) WHERE p.age > 'forty' RETURN p";
#[test]
fn locked_schema_rejects_a_declared_type_mismatch() {
let message = strict_error(&declared_locked(), CROSS_TYPE);
assert!(
message.contains("WHERE compares Person.age (declared INTEGER)"),
"{message}"
);
assert!(
message.contains("with a STRING literal 'forty'"),
"{message}"
);
assert!(
message.contains("a cross-type ordering comparison is null in openCypher"),
"{message}"
);
assert!(
message.contains(
"(the schema is locked — call unlock_schema() to make this a warning instead)"
),
"{message}"
);
}
#[test]
fn a_schema_defined_type_mismatch_stays_a_warning_under_the_lock() {
let graph = schema_defined_locked();
let warnings = accepted(&graph, CROSS_TYPE);
assert!(
warnings
.iter()
.any(|w| w.contains("Person.age (schema-defined integer)")),
"{warnings:?}"
);
let rows = execute_read(
&graph,
"MATCH (p:Person) WHERE p.age <> 'forty' RETURN p.age",
&ExecuteOptions::eager(&empty_params()),
)
.expect("an unpromotable finding never stops the query")
.result
.rows;
assert_eq!(rows.len(), 2, "{rows:?}");
}
#[test]
fn an_open_schema_only_warns_about_either_source() {
for graph in [declared(), schema_defined()] {
let warnings = accepted(&graph, CROSS_TYPE);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].contains("filters out every row"),
"{warnings:?}"
);
}
}
#[test]
fn unlocking_restores_the_declared_type_mismatch_warning() {
let mut graph = declared_locked();
strict_error(&graph, CROSS_TYPE);
graph.schema_locked = false;
let warnings = accepted(&graph, CROSS_TYPE);
assert!(
warnings
.iter()
.any(|w| w.contains("Person.age (declared INTEGER)")),
"{warnings:?}"
);
}
#[test]
fn a_declared_type_plan_primed_before_the_lock_is_still_rejected_after_it() {
let mut graph = declared();
for _ in 0..2 {
accepted(&graph, CROSS_TYPE);
}
let version_before = graph.version();
graph.schema_locked = true;
assert_eq!(
graph.version(),
version_before,
"this test is about a lock that did NOT invalidate the cache"
);
assert!(strict_error(&graph, CROSS_TYPE).contains("declared INTEGER"));
}
#[test]
fn a_property_pair_promotes_only_when_both_sides_are_declared() {
const PAIR: &str = "MATCH (p:Person) WHERE p.age > p.email RETURN p";
let message = strict_error(&declared_locked(), PAIR);
assert!(
message.contains("Person.age (declared INTEGER)"),
"{message}"
);
assert!(
message.contains("Person.email (declared STRING)"),
"{message}"
);
let warnings = accepted(&mixed_locked(), PAIR);
assert!(
warnings
.iter()
.any(|w| w.contains("Person.email (schema-defined string)")),
"one unenforced side must leave the finding a warning: {warnings:?}"
);
}
#[test]
fn a_bound_parameter_promotes_against_a_declared_type() {
let graph = declared_locked();
let query = "MATCH (p:Person) WHERE p.age > $cutoff RETURN p";
let mut bad = HashMap::new();
bad.insert("cutoff".to_string(), Value::String("forty".to_string()));
let message = match run_with(&graph, query, &bad) {
Err(message) => message,
Ok(warnings) => panic!("expected a schema error, got warnings {warnings:?}"),
};
assert!(
message.contains("a STRING parameter $cutoff ('forty')"),
"{message}"
);
assert!(message.contains("unlock_schema()"), "{message}");
let mut good = HashMap::new();
good.insert("cutoff".to_string(), Value::Int64(35));
let warnings = run_with(&graph, query, &good).expect("a well-typed binding is not a mistake");
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn every_finding_site_promotes_a_declared_source_and_only_that() {
for query in [
"MATCH (p:Person) WHERE p.age IN ['a', 'b'] RETURN p",
"MATCH (p:Person) WHERE p.age STARTS WITH 'x' RETURN p",
"MATCH (p:Person) WHERE p.age CONTAINS 'x' RETURN p",
"MATCH (p:Person) WHERE p.age =~ 'x.*' RETURN p",
] {
assert!(
strict_error(&declared_locked(), query).contains("unlock_schema()"),
"{query}"
);
let warnings = accepted(&schema_defined_locked(), query);
assert_eq!(warnings.len(), 1, "{query} -> {warnings:?}");
}
}
#[test]
fn well_typed_and_undeclared_comparisons_are_never_rejected() {
let graph = declared_locked();
for query in [
"MATCH (p:Person) WHERE p.age > 30 RETURN p",
"MATCH (p:Person) WHERE p.age > 30.5 RETURN p",
"MATCH (p:Person) WHERE p.email STARTS WITH 'a' RETURN p",
"MATCH (a:Paper) WHERE a.year > 'x' RETURN a",
"MATCH (p:Person) WHERE p.name > 5 RETURN p",
"MATCH (n) WHERE n.age > 'forty' RETURN n",
] {
assert!(accepted(&graph, query).is_empty(), "{query}");
}
}
#[test]
fn unresolvable_variables_are_never_rejected_by_the_type_family() {
let params = empty_params();
let opts = ExecuteOptions::eager(¶ms);
let graph = declared_locked();
accepted(
&graph,
"MATCH (p:Person) WITH p AS q WHERE q.age > 'forty' RETURN q",
);
let mut graph = declared();
execute_mut(&mut graph, "MATCH (p:Person) SET p:Admin", &opts).expect("secondary label");
graph.schema_locked = true;
accepted(
&graph,
"MATCH (p:Person:Admin) WHERE p.age > 'forty' RETURN p",
);
}