use super::super::super::ast::*;
use super::type_mismatch::TypeMismatch;
use super::{for_each_query_pattern, PatternSite, SchemaError, SchemaErrorKind, BUILTIN_FIELDS};
use crate::datatypes::values::Value;
use crate::graph::core::pattern_matching::{EdgeDirection, NodePattern, Pattern, PatternElement};
use crate::graph::mutation::validation::did_you_mean;
use crate::graph::schema::{DirGraph, InternedKey};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU8, Ordering};
fn match_var_labels<'q>(query: &'q CypherQuery, graph: &DirGraph) -> HashMap<&'q str, &'q str> {
let mut var_label: HashMap<&str, &str> = HashMap::new();
if graph.node_type_metadata.is_empty() {
return var_label;
}
for clause in &query.clauses {
if let Clause::Match(m) | Clause::OptionalMatch(m) = clause {
for pattern in &m.patterns {
for el in &pattern.elements {
if let PatternElement::Node(np) = el {
if let (Some(var), Some(label)) =
(np.variable.as_deref(), np.node_type.as_deref())
{
if np.extra_labels.is_empty()
&& graph.node_type_metadata.contains_key(label)
{
var_label.insert(var, label);
} else {
var_label.remove(var);
}
}
}
}
}
}
}
var_label
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AbsentSite {
Where,
Return,
With,
OrderBy,
}
impl AbsentSite {
fn clause(self) -> &'static str {
match self {
AbsentSite::Where => "WHERE",
AbsentSite::Return => "RETURN",
AbsentSite::With => "WITH",
AbsentSite::OrderBy => "ORDER BY",
}
}
fn message(self, property: &str, label: &str, hint: &str) -> String {
match self {
AbsentSite::Where => format!(
"WHERE references property '{property}' which no {label} node has — the \
comparison is null (always false), so this filters out every row.{hint}"
),
AbsentSite::Return => format!(
"RETURN projects property '{property}' which no {label} node has — every \
value will be null.{hint}"
),
AbsentSite::With => format!(
"WITH projects property '{property}' which no {label} node has — every \
value will be null.{hint}"
),
AbsentSite::OrderBy => format!(
"ORDER BY sorts on property '{property}' which no {label} node has — every \
key is null, so the sort does nothing.{hint}"
),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct AbsentProperty {
site: AbsentSite,
property: String,
label: String,
hint: String,
}
impl AbsentProperty {
fn warning(&self) -> String {
self.site.message(&self.property, &self.label, &self.hint)
}
}
fn absent_property_findings<'q>(
query: &'q CypherQuery,
graph: &DirGraph,
var_label: &HashMap<&'q str, &'q str>,
) -> Vec<AbsentProperty> {
if var_label.is_empty() {
return Vec::new();
}
let mut scan = AbsentPropertyScan {
graph,
var_label,
written: HashSet::new(),
written_any: HashSet::new(),
seen: HashSet::new(),
out: Vec::new(),
};
collect_written_properties(&query.clauses, &mut scan.written, &mut scan.written_any);
for clause in &query.clauses {
match clause {
Clause::Where(w) => scan.predicate(&w.predicate, AbsentSite::Where),
Clause::Match(m) | Clause::OptionalMatch(m) => {
if let Some(wc) = &m.where_clause {
scan.predicate(&wc.predicate, AbsentSite::Where);
}
}
Clause::With(w) => {
for item in &w.items {
scan.expression(&item.expression, AbsentSite::With);
}
if let Some(wc) = &w.where_clause {
scan.predicate(&wc.predicate, AbsentSite::Where);
}
}
Clause::Return(r) => {
for item in &r.items {
scan.expression(&item.expression, AbsentSite::Return);
}
}
Clause::OrderBy(o) => {
for item in &o.items {
scan.expression(&item.expression, AbsentSite::OrderBy);
}
}
_ => {}
}
}
scan.out
}
pub(super) fn property_absent(graph: &DirGraph, node_type: &str, prop: &str) -> bool {
if BUILTIN_FIELDS.contains(&prop) {
return false;
}
match graph.node_type_metadata.get(node_type) {
Some(tp) => !tp.is_empty() && !tp.contains_key(prop),
None => false,
}
}
fn collect_written_properties<'q>(
clauses: &'q [Clause],
named: &mut HashSet<(&'q str, &'q str)>,
any: &mut HashSet<&'q str>,
) {
for clause in clauses {
match clause {
Clause::Set(s) => note_written_set_items(&s.items, named, any),
Clause::Merge(m) => {
for items in [m.on_create.as_ref(), m.on_match.as_ref()]
.into_iter()
.flatten()
{
note_written_set_items(items, named, any);
}
}
Clause::Foreach { body, .. } => collect_written_properties(body, named, any),
Clause::CallSubquery { body, .. } => {
collect_written_properties(&body.clauses, named, any)
}
Clause::Union(u) => collect_written_properties(&u.query.clauses, named, any),
_ => {}
}
}
}
fn note_written_set_items<'q>(
items: &'q [SetItem],
named: &mut HashSet<(&'q str, &'q str)>,
any: &mut HashSet<&'q str>,
) {
for item in items {
match item {
SetItem::Property {
variable, property, ..
} => {
named.insert((variable.as_str(), property.as_str()));
}
SetItem::Map {
variable,
expression,
..
} => match expression {
Expression::MapLiteral(entries) => {
for (key, _) in entries {
named.insert((variable.as_str(), key.as_str()));
}
}
_ => {
any.insert(variable.as_str());
}
},
SetItem::Label { .. } => {}
}
}
}
struct AbsentPropertyScan<'a, 'q> {
graph: &'a DirGraph,
var_label: &'a HashMap<&'q str, &'q str>,
written: HashSet<(&'q str, &'q str)>,
written_any: HashSet<&'q str>,
seen: HashSet<(&'q str, &'q str)>,
out: Vec<AbsentProperty>,
}
impl<'q> AbsentPropertyScan<'_, 'q> {
fn report(&mut self, variable: &'q str, property: &'q str, site: AbsentSite) {
let Some(&label) = self.var_label.get(variable) else {
return;
};
if !property_absent(self.graph, label, property)
|| self.written_any.contains(variable)
|| self.written.contains(&(variable, property))
|| !self.seen.insert((variable, property))
{
return;
}
let candidates: Vec<&str> = self
.graph
.node_type_metadata
.get(label)
.map(|m| m.keys().map(|s| s.as_str()).collect())
.unwrap_or_default();
self.out.push(AbsentProperty {
site,
property: property.to_string(),
label: label.to_string(),
hint: did_you_mean(property, &candidates),
});
}
fn predicate(&mut self, pred: &'q Predicate, site: AbsentSite) {
match pred {
Predicate::And(a, b) | Predicate::Or(a, b) | Predicate::Xor(a, b) => {
self.predicate(a, site);
self.predicate(b, site);
}
Predicate::Not(p) => self.predicate(p, site),
Predicate::Comparison { left, right, .. } => {
self.expression(left, site);
self.expression(right, site);
}
Predicate::In { expr, .. }
| Predicate::InLiteralSet { expr, .. }
| Predicate::InExpression { expr, .. }
| Predicate::StartsWith { expr, .. }
| Predicate::EndsWith { expr, .. }
| Predicate::Contains { expr, .. }
| Predicate::IsNull(expr)
| Predicate::IsNotNull(expr) => self.expression(expr, site),
_ => {}
}
}
fn expression(&mut self, expr: &'q Expression, site: AbsentSite) {
match expr {
Expression::PropertyAccess { variable, property } => {
self.report(variable.as_str(), property.as_str(), site)
}
Expression::Add(a, b)
| Expression::Subtract(a, b)
| Expression::Multiply(a, b)
| Expression::Divide(a, b)
| Expression::Modulo(a, b)
| Expression::Concat(a, b) => {
self.expression(a, site);
self.expression(b, site);
}
Expression::Negate(e) => self.expression(e, site),
Expression::FunctionCall { args, .. } => {
for a in args {
self.expression(a, site);
}
}
Expression::ListLiteral(items) => {
for it in items {
self.expression(it, site);
}
}
_ => {}
}
}
}
fn reversed_direction_warnings(
pattern: &Pattern,
var_label: &HashMap<&str, &str>,
graph: &DirGraph,
seen: &mut HashSet<String>,
out: &mut Vec<String>,
) {
for window in pattern.elements.windows(3) {
let [PatternElement::Node(left), PatternElement::Edge(edge), PatternElement::Node(right)] =
window
else {
continue;
};
if edge.var_length.is_some() {
continue;
}
let (from, to) = match edge.direction {
EdgeDirection::Outgoing => (left, right),
EdgeDirection::Incoming => (right, left),
EdgeDirection::Both => continue,
};
let (Some(from_label), Some(to_label)) = (
endpoint_label(from, var_label, graph),
endpoint_label(to, var_label, graph),
) else {
continue;
};
let mut rels: Vec<&str> = Vec::new();
for rel in edge
.connection_type
.iter()
.chain(edge.connection_types.iter().flatten())
{
if !rels.contains(&rel.as_str()) {
rels.push(rel.as_str());
}
}
if rels.is_empty()
|| !rels
.iter()
.all(|rel| is_reversed(graph, rel, from_label, to_label))
{
continue;
}
let named = rels
.iter()
.map(|r| format!("'{r}'"))
.collect::<Vec<_>>()
.join(" or ");
if !seen.insert(format!("D:{named}:{from_label}:{to_label}")) {
continue;
}
let subject = if rels.len() == 1 {
format!("every {named} relationship")
} else {
"every one of them".to_string()
};
out.push(format!(
"MATCH traverses {named} as {from_label} → {to_label}, but {subject} runs \
{to_label} → {from_label} — this pattern matches no edges. Reverse the arrow?"
));
}
}
fn is_reversed(graph: &DirGraph, rel: &str, from_label: &str, to_label: &str) -> bool {
let Some(info) = graph.connection_type_metadata.get(rel) else {
return false;
};
let supports =
|src: &str, tgt: &str| info.source_types.contains(src) && info.target_types.contains(tgt);
!supports(from_label, to_label) && supports(to_label, from_label)
}
fn endpoint_label<'s>(
node: &'s NodePattern,
var_label: &HashMap<&'s str, &'s str>,
graph: &DirGraph,
) -> Option<&'s str> {
if !node.extra_labels.is_empty() {
return None;
}
if let Some(label) = node.node_type.as_deref() {
let known =
graph.node_type_metadata.contains_key(label) || graph.type_indices.contains_key(label);
return known.then_some(label);
}
node.variable
.as_deref()
.and_then(|var| var_label.get(var).copied())
}
pub(crate) struct QueryWarnings {
pub(crate) absent_property: Vec<AbsentProperty>,
pub(crate) other: Vec<String>,
pub(crate) type_mismatch: Vec<TypeMismatch>,
}
impl QueryWarnings {
pub(crate) fn into_messages(self) -> Vec<String> {
let mut out: Vec<String> = self
.absent_property
.iter()
.map(AbsentProperty::warning)
.collect();
out.extend(self.other);
out.extend(
self.type_mismatch
.into_iter()
.map(TypeMismatch::into_message),
);
out
}
}
pub fn collect_unknown_pattern_warnings(query: &CypherQuery, graph: &DirGraph) -> Vec<String> {
collect_query_warnings(query, graph, &HashMap::new()).into_messages()
}
pub(crate) fn collect_query_warnings(
query: &CypherQuery,
graph: &DirGraph,
params: &HashMap<String, Value>,
) -> QueryWarnings {
let have_node_schema =
!graph.node_type_metadata.is_empty() || graph.type_indices.keys().next().is_some();
let have_edge_schema = !graph.connection_type_metadata.is_empty();
if !have_node_schema && !have_edge_schema {
return QueryWarnings {
absent_property: Vec::new(),
other: Vec::new(),
type_mismatch: Vec::new(),
};
}
let mut seen: HashSet<String> = HashSet::new();
let mut unknown_labels: Vec<String> = Vec::new();
let var_label = match_var_labels(query, graph);
let mut reversed: Vec<String> = Vec::new();
let mut unknown_rels: Vec<(String, Vec<String>)> = Vec::new();
for_each_query_pattern(query, &mut |site| {
let PatternSite::Read(pattern) = site else {
return;
};
for element in &pattern.elements {
match element {
PatternElement::Node(np) if have_node_schema => {
for label in np.node_type.iter().chain(np.extra_labels.iter()) {
let known = graph.node_type_metadata.contains_key(label)
|| graph.type_indices.contains_key(label)
|| graph
.secondary_label_index
.contains_key(&InternedKey::from_str(label));
if !known && seen.insert(format!("L:{label}")) {
unknown_labels.push(label.clone());
}
}
}
PatternElement::Edge(ep) if have_edge_schema => {
let branches = || {
ep.connection_type
.iter()
.chain(ep.connection_types.iter().flatten())
};
let known = |rel: &String| graph.connection_type_metadata.contains_key(rel);
if branches().all(known) {
continue;
}
let surviving: Vec<String> = branches().filter(|r| known(r)).cloned().collect();
for rel in branches().filter(|r| !known(r)) {
if seen.insert(format!("R:{rel}")) {
unknown_rels.push((rel.clone(), surviving.clone()));
}
}
}
_ => {}
}
}
if have_edge_schema {
reversed_direction_warnings(pattern, &var_label, graph, &mut seen, &mut reversed);
}
});
let absent_property = absent_property_findings(query, graph, &var_label);
let type_mismatch =
super::type_mismatch::type_mismatch_findings(query, graph, &var_label, params);
let mut out: Vec<String> = Vec::new();
if unknown_labels.is_empty() && unknown_rels.is_empty() {
out.append(&mut reversed);
return QueryWarnings {
absent_property,
other: out,
type_mismatch,
};
}
out.reserve(unknown_labels.len() + unknown_rels.len());
if !unknown_labels.is_empty() {
let candidates: Vec<&str> = graph
.node_type_metadata
.keys()
.map(|s| s.as_str())
.chain(graph.type_indices.keys())
.collect();
for label in &unknown_labels {
out.push(format!(
"MATCH references unknown node label '{label}' — the graph has no such type, \
so this pattern returns no rows.{}",
did_you_mean(label, &candidates)
));
}
}
if !unknown_rels.is_empty() {
let candidates: Vec<&str> = graph
.connection_type_metadata
.keys()
.map(|s| s.as_str())
.collect();
for (rel, surviving) in &unknown_rels {
let hint = did_you_mean(rel, &candidates);
out.push(if surviving.is_empty() {
format!(
"MATCH references unknown relationship type '{rel}' — the graph has no such \
edge type, so this pattern returns no rows.{hint}"
)
} else {
let named = surviving
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
format!(
"MATCH references unknown relationship type '{rel}' — the graph has no such \
edge type, so that branch matches no edges; the pattern can still return \
rows via {named}.{hint}"
)
});
}
}
out.append(&mut reversed);
QueryWarnings {
absent_property,
other: out,
type_mismatch,
}
}
pub(crate) fn strict_read_error(
findings: &[AbsentProperty],
graph: &DirGraph,
) -> Option<SchemaError> {
let found = findings
.iter()
.find(|f| !super::property_is_declared(&f.label, &f.property, graph))?;
let mut valid: Vec<&str> = graph
.node_type_metadata
.get(&found.label)
.map(|m| m.keys().map(|s| s.as_str()).collect())
.unwrap_or_default();
valid.sort_unstable();
Some(SchemaError {
kind: SchemaErrorKind::UnknownProperty,
message: format!(
"Unknown property '{}' on {}, referenced in {}.{}\n Valid properties: {}\n \
(the schema is locked — call unlock_schema() to make this a warning instead)",
found.property,
found.label,
found.site.clause(),
found.hint,
valid.join(", ")
),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QueryWarningSink {
#[default]
Stderr,
Silent,
}
const SINK_STDERR: u8 = 0;
const SINK_SILENT: u8 = 1;
static SINK: AtomicU8 = AtomicU8::new(SINK_STDERR);
pub fn set_query_warning_sink(sink: QueryWarningSink) {
SINK.store(
match sink {
QueryWarningSink::Stderr => SINK_STDERR,
QueryWarningSink::Silent => SINK_SILENT,
},
Ordering::Relaxed,
);
}
pub fn query_warning_sink() -> QueryWarningSink {
match SINK.load(Ordering::Relaxed) {
SINK_SILENT => QueryWarningSink::Silent,
_ => QueryWarningSink::Stderr,
}
}
pub(crate) fn emit_query_warnings(warnings: &[String]) {
if query_warning_sink() == QueryWarningSink::Silent {
return;
}
for msg in warnings {
#[cfg(test)]
echo_recorder::record(msg);
eprintln!("warning: {msg}");
}
}
#[cfg(test)]
mod echo_recorder {
use std::sync::Mutex;
static RECORDED: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub(super) fn record(msg: &str) {
RECORDED
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(msg.to_string());
}
pub(super) fn matching(needle: &str) -> Vec<String> {
RECORDED
.lock()
.unwrap_or_else(|p| p.into_inner())
.iter()
.filter(|m| m.contains(needle))
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::super::tests::graph_with_schema;
use super::*;
use crate::graph::languages::cypher::parser::parse_cypher;
#[test]
fn warns_on_where_property_absent_from_label() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) WHERE p.is_external = false RETURN p").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].contains("is_external") && w[0].contains("Person"),
"{}",
w[0]
);
let q2 = parse_cypher("MATCH (p:Person) WHERE p.agee = 1 RETURN p").unwrap();
let w2 = collect_unknown_pattern_warnings(&q2, &g);
assert!(
w2.iter().any(|m| m.contains("Did you mean 'age'")),
"{w2:?}"
);
}
#[test]
fn no_warning_on_present_or_builtin_property() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) WHERE p.age = 30 RETURN p").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
let q2 = parse_cypher("MATCH (p:Person) WHERE p.id = 1 RETURN p").unwrap();
assert!(collect_unknown_pattern_warnings(&q2, &g).is_empty());
}
#[test]
fn no_warning_on_untyped_var() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (n) WHERE n.whatever = 1 RETURN n").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
}
#[test]
fn warns_on_return_projection_of_absent_property() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) RETURN p.name, p.imo").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].starts_with("RETURN projects property 'imo'")
&& w[0].contains("no Person node has")
&& w[0].contains("null"),
"{}",
w[0]
);
}
#[test]
fn warns_on_with_projection_of_absent_property() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) WITH p.imo AS x RETURN x").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].starts_with("WITH projects property 'imo'"), "{}", w[0]);
}
#[test]
fn warns_on_order_by_absent_property() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) RETURN p ORDER BY p.imo").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].starts_with("ORDER BY sorts on property 'imo'"),
"{}",
w[0]
);
}
#[test]
fn projection_warning_suggests_a_near_miss() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) RETURN p.agee").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert!(w.iter().any(|m| m.contains("Did you mean 'age'")), "{w:?}");
}
#[test]
fn no_projection_warning_for_present_or_builtin_property() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) RETURN p.age, p.email, p.id, p.title").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
}
#[test]
fn no_projection_warning_through_a_with_alias() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (n:Person) WITH n AS m RETURN m.badprop").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
let q2 = parse_cypher("MATCH (n:Person) WITH n AS m WHERE m.badprop = 1 RETURN m").unwrap();
assert!(collect_unknown_pattern_warnings(&q2, &g).is_empty());
}
#[test]
fn no_warning_for_a_property_the_same_query_writes() {
let g = graph_with_schema();
for query in [
"MATCH (p:Person) SET p.badprop = 1 RETURN p.badprop",
"MATCH (p:Person) SET p += {badprop: 1} RETURN p.badprop",
"MATCH (p:Person) SET p.badprop = 1 WITH p WHERE p.badprop = 1 RETURN p",
"MATCH (p:Person) FOREACH (x IN [1] | SET p.badprop = x) RETURN p.badprop",
] {
let q = parse_cypher(query).unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert!(w.is_empty(), "{query} -> {w:?}");
}
}
#[test]
fn projection_and_where_reference_warn_once() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) WHERE p.imo = 1 RETURN p.imo").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].starts_with("WHERE references property 'imo'"),
"{}",
w[0]
);
}
#[test]
fn strict_read_error_reports_the_first_finding_in_error_voice() {
let g = graph_with_schema();
let q = parse_cypher("MATCH (p:Person) WHERE p.agee = 1 RETURN p.imo").unwrap();
let found = collect_query_warnings(&q, &g, &HashMap::new()).absent_property;
assert_eq!(found.len(), 2, "{found:?}");
let err = strict_read_error(&found, &g).expect("both findings are typos");
assert!(
err.message
.starts_with("Unknown property 'agee' on Person, referenced in WHERE."),
"{}",
err.message
);
assert!(
err.message.contains("Did you mean 'age'?"),
"{}",
err.message
);
assert!(
err.message.contains("Valid properties: age, email"),
"{}",
err.message
);
assert!(err.message.contains("unlock_schema()"), "{}", err.message);
assert!(matches!(err.kind, SchemaErrorKind::UnknownProperty));
}
#[test]
fn a_declared_but_unwritten_property_warns_but_is_never_promoted() {
use crate::graph::schema::{NodeSchemaDefinition, SchemaDefinition, SchemaInstall};
let mut g = graph_with_schema();
let mut declared = SchemaDefinition::default();
declared.node_schemas.insert(
"Person".to_string(),
NodeSchemaDefinition {
optional_fields: vec!["nickname".to_string()],
..Default::default()
},
);
g.set_schema(declared, SchemaInstall::Replace)
.expect("schema installs on an empty graph");
let q = parse_cypher("MATCH (p:Person) RETURN p.nickname").unwrap();
let collected = collect_query_warnings(&q, &g, &HashMap::new());
assert_eq!(collected.absent_property.len(), 1);
assert!(
strict_read_error(&collected.absent_property, &g).is_none(),
"a declared field must not be promoted to an error"
);
let messages = collected.into_messages();
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(messages[0].contains("'nickname'"), "{}", messages[0]);
let q2 = parse_cypher("MATCH (p:Person) RETURN p.nicknmae").unwrap();
let found2 = collect_query_warnings(&q2, &g, &HashMap::new()).absent_property;
assert!(strict_read_error(&found2, &g).is_some());
}
fn graph_with_directed_schema() -> DirGraph {
let mut g = graph_with_schema();
g.upsert_connection_type_metadata("REVIEWED", "Person", "Paper", HashMap::new());
g.upsert_connection_type_metadata("LINKS", "Person", "Paper", HashMap::new());
g.upsert_connection_type_metadata("LINKS", "Paper", "Person", HashMap::new());
g
}
#[test]
fn warns_on_reversed_relationship_direction() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (a:Paper)-[:AUTHORED]->(p:Person) RETURN p").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].contains("'AUTHORED'")
&& w[0].contains("Paper → Person")
&& w[0].contains("Person → Paper")
&& w[0].contains("matches no edges"),
"{}",
w[0]
);
}
#[test]
fn warns_on_reversed_left_pointing_relationship() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (p:Person)<-[:AUTHORED]-(a:Paper) RETURN p").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains("Paper → Person"), "{}", w[0]);
}
#[test]
fn no_direction_warning_for_correct_orientation() {
let g = graph_with_directed_schema();
for query in [
"MATCH (p:Person)-[:AUTHORED]->(a:Paper) RETURN p",
"MATCH (a:Paper)<-[:AUTHORED]-(p:Person) RETURN p",
"MATCH (p:Person)-[:KNOWS]->(q:Person) RETURN p",
] {
let q = parse_cypher(query).unwrap();
assert!(
collect_unknown_pattern_warnings(&q, &g).is_empty(),
"{query}"
);
}
}
#[test]
fn no_direction_warning_for_undirected_pattern() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (a:Paper)-[:AUTHORED]-(p:Person) RETURN p").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
}
#[test]
fn no_direction_warning_when_an_endpoint_label_is_unknown() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (a:Paper)-[:AUTHORED]->(x) RETURN x").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
}
#[test]
fn direction_warning_uses_a_match_bound_label_for_a_bare_endpoint() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (x:Person) MATCH (a:Paper)-[:AUTHORED]->(x) RETURN x").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains("'AUTHORED'"), "{}", w[0]);
}
#[test]
fn no_direction_warning_when_both_orientations_are_recorded() {
let g = graph_with_directed_schema();
for query in [
"MATCH (p:Person)-[:LINKS]->(a:Paper) RETURN p",
"MATCH (a:Paper)-[:LINKS]->(p:Person) RETURN p",
] {
let q = parse_cypher(query).unwrap();
assert!(
collect_unknown_pattern_warnings(&q, &g).is_empty(),
"{query}"
);
}
}
#[test]
fn alternation_warns_only_when_every_branch_is_reversed() {
let g = graph_with_directed_schema();
let mixed = parse_cypher("MATCH (a:Paper)-[:AUTHORED|KNOWS]->(p:Person) RETURN p").unwrap();
assert!(collect_unknown_pattern_warnings(&mixed, &g).is_empty());
let all =
parse_cypher("MATCH (a:Paper)-[:AUTHORED|REVIEWED]->(p:Person) RETURN p").unwrap();
let w = collect_unknown_pattern_warnings(&all, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].contains("'AUTHORED'") && w[0].contains("'REVIEWED'"),
"{}",
w[0]
);
}
#[test]
fn no_direction_warning_for_a_variable_length_edge() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (a:Paper)-[:AUTHORED*1..3]->(p:Person) RETURN p").unwrap();
assert!(collect_unknown_pattern_warnings(&q, &g).is_empty());
}
#[test]
fn direction_warning_reaches_nested_patterns() {
let g = graph_with_directed_schema();
let q = parse_cypher(
"MATCH (p:Person) WHERE EXISTS { MATCH (a:Paper)-[:AUTHORED]->(p) } RETURN p",
)
.unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains("'AUTHORED'"), "{}", w[0]);
}
#[test]
fn no_direction_warning_for_an_unknown_relationship_type() {
let g = graph_with_directed_schema();
let q = parse_cypher("MATCH (a:Paper)-[:AUTHORD]->(p:Person) RETURN p").unwrap();
let w = collect_unknown_pattern_warnings(&q, &g);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains("unknown relationship type"), "{}", w[0]);
}
static SINK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn stderr_sink_echoes_and_silent_sink_does_not() {
let _guard = SINK_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let echoed = "sink-probe-echoed-a7f3".to_string();
let muted = "sink-probe-muted-a7f3".to_string();
assert_eq!(query_warning_sink(), QueryWarningSink::Stderr, "default");
emit_query_warnings(std::slice::from_ref(&echoed));
assert_eq!(echo_recorder::matching(&echoed).len(), 1);
set_query_warning_sink(QueryWarningSink::Silent);
assert_eq!(query_warning_sink(), QueryWarningSink::Silent);
emit_query_warnings(std::slice::from_ref(&muted));
assert!(
echo_recorder::matching(&muted).is_empty(),
"silent sink still echoed"
);
set_query_warning_sink(QueryWarningSink::Stderr);
emit_query_warnings(std::slice::from_ref(&muted));
assert_eq!(
echo_recorder::matching(&muted).len(),
1,
"sink did not restore"
);
}
}