use std::collections::BTreeMap;
use hawdb_core::{RelationshipDirection, Value};
use hawdb_plan_cypher::{LogicalPlan, ProjectionExpression};
use hawdb_sql_syntax::parse_postgres_select;
use super::*;
use crate::pgq::{
bind_postgres_graph_tables, BoundPgqGraphTable, PgqBindingContext, PgqCatalog, PgqDataType,
PropertyGraphCatalog, PropertyGraphElementSchema, PropertyGraphSchema,
};
fn catalog() -> PropertyGraphCatalog {
let mut graph = PropertyGraphSchema::new(["knowledge"]);
graph.add_vertex_label(
"document",
PropertyGraphElementSchema::default()
.with_property("id", PgqDataType::Int64)
.with_property("visible", PgqDataType::Boolean),
);
graph.add_vertex_label(
"entity",
PropertyGraphElementSchema::default()
.with_property("id", PgqDataType::Int64)
.with_property("name", PgqDataType::String),
);
graph.add_edge_label(
"mentions",
PropertyGraphElementSchema::default().with_property("confidence", PgqDataType::Float64),
);
let mut catalog = PropertyGraphCatalog::default();
catalog.insert(graph);
catalog
}
fn bind(sql: &str, catalog: &dyn PgqCatalog) -> BoundPgqGraphTable {
let select = parse_postgres_select(sql).expect("owned SQL/PGQ syntax");
bind_postgres_graph_tables(sql, &select, catalog, &PgqBindingContext::default())
.expect("bound GRAPH_TABLE")
.remove(0)
}
#[test]
fn lowers_single_hop_into_shared_graph_operators() {
let sql = "SELECT matched.name FROM GRAPH_TABLE (
knowledge
MATCH (document IS document WHERE document.visible)
-[mention IS mentions WHERE mention.confidence >= $1]->
(entity IS entity)
WHERE entity.id IN (7, 9)
COLUMNS (lower(entity.name) AS name, mention.confidence)
) matched";
let bound = bind(sql, &catalog());
let plan = lower_bound_pgq_graph_table(&bound, &BTreeMap::from([(1, Value::Float(0.75))]))
.expect("qualified SQL/PGQ lowering");
let LogicalPlan::Project { items, input } = plan else {
panic!("expected shared projection");
};
assert_eq!(items.len(), 2);
assert!(matches!(
items[0].expression,
ProjectionExpression::Lower(_)
));
let LogicalPlan::Filter { input, .. } = *input else {
panic!("expected graph-level filter");
};
let LogicalPlan::Filter { input, .. } = *input else {
panic!("expected edge predicate filter");
};
let LogicalPlan::Expand {
source_variable,
source_label,
rel_type,
target_variable,
target_label,
direction,
input,
..
} = *input
else {
panic!("expected shared adjacency expansion");
};
assert_eq!(source_variable, "document");
assert_eq!(source_label, "document");
assert_eq!(rel_type, "mentions");
assert_eq!(target_variable, "entity");
assert_eq!(target_label, "entity");
assert_eq!(direction, RelationshipDirection::Outgoing);
assert!(matches!(*input, LogicalPlan::Filter { .. }));
}
#[test]
fn synthesizes_deterministic_names_for_anonymous_elements() {
let bound = bind(
"SELECT * FROM GRAPH_TABLE (
knowledge MATCH (IS document)-[IS mentions]->(IS entity)
COLUMNS (1 AS one)
) graph_rows",
&catalog(),
);
let plan =
lower_bound_pgq_graph_table(&bound, &BTreeMap::new()).expect("anonymous slot lowering");
let LogicalPlan::Project { input, .. } = plan else {
panic!("expected projection");
};
let LogicalPlan::Expand {
source_variable,
rel_variable,
target_variable,
..
} = *input
else {
panic!("expected expansion");
};
assert_eq!(source_variable, "__hawdb_pgq_slot_0");
assert_eq!(rel_variable.as_deref(), Some("__hawdb_pgq_slot_1"));
assert_eq!(target_variable, "__hawdb_pgq_slot_2");
}
#[test]
fn rejects_unbound_parameters_before_planning() {
let bound = bind(
"SELECT * FROM GRAPH_TABLE (
knowledge MATCH (entity IS entity WHERE entity.id = $1)
COLUMNS (entity.id)
) graph_rows",
&catalog(),
);
let error = lower_bound_pgq_graph_table(&bound, &BTreeMap::new())
.expect_err("unbound parameter must fail closed");
assert_eq!(error.code, PgqLoweringErrorCode::MissingParameter);
assert!(!error.span.is_empty());
}
#[test]
fn rejects_quantified_paths_until_walk_semantics_are_qualified() {
let bound = bind(
"SELECT * FROM GRAPH_TABLE (
knowledge MATCH (document IS document)-[IS mentions]->{1,2}(entity IS entity)
COLUMNS (entity.id)
) graph_rows",
&catalog(),
);
let error = lower_bound_pgq_graph_table(&bound, &BTreeMap::new())
.expect_err("unqualified path semantics must fail closed");
assert_eq!(error.code, PgqLoweringErrorCode::UnsupportedPath);
assert!(!error.span.is_empty());
}
#[test]
fn rejects_multi_label_elements_until_shared_scan_supports_them() {
let mut graph = PropertyGraphSchema::new(["knowledge"]);
let element = PropertyGraphElementSchema::default().with_property("id", PgqDataType::Int64);
graph.add_vertex_label("entity", element.clone());
graph.add_vertex_label("document", element);
let mut catalog = PropertyGraphCatalog::default();
catalog.insert(graph);
let bound = bind(
"SELECT * FROM GRAPH_TABLE (
knowledge MATCH (item IS entity | document) COLUMNS (item.id)
) graph_rows",
&catalog,
);
let error = lower_bound_pgq_graph_table(&bound, &BTreeMap::new())
.expect_err("multi-label scan must fail closed");
assert_eq!(error.code, PgqLoweringErrorCode::UnsupportedLabels);
}
#[test]
fn rejects_reused_path_variables_until_identity_constraints_are_explicit() {
let bound = bind(
"SELECT * FROM GRAPH_TABLE (
knowledge MATCH (entity IS entity)-[IS mentions]->(entity)
COLUMNS (entity.id)
) graph_rows",
&catalog(),
);
let error = lower_bound_pgq_graph_table(&bound, &BTreeMap::new())
.expect_err("reused variables must not silently become an overwrite");
assert_eq!(error.code, PgqLoweringErrorCode::UnsupportedPath);
}
#[test]
fn rejects_correlated_columns_until_relational_and_graph_plans_are_joined() {
let sql = "SELECT * FROM source, GRAPH_TABLE (
knowledge MATCH (entity IS entity WHERE entity.id = source.id)
COLUMNS (entity.id)
) graph_rows";
let select = parse_postgres_select(sql).expect("owned SQL/PGQ syntax");
let context =
PgqBindingContext::default().with_outer_column(["source", "id"], PgqDataType::Int64);
let bound = bind_postgres_graph_tables(sql, &select, &catalog(), &context)
.expect("correlated column binding")
.remove(0);
let error = lower_bound_pgq_graph_table(&bound, &BTreeMap::new())
.expect_err("host-side correlated evaluation must not be introduced");
assert_eq!(error.code, PgqLoweringErrorCode::UnsupportedExpression);
assert!(!error.span.is_empty());
}