1mod deadline;
7
8pub use deadline::with_execution_deadline;
9
10use crate::ast::CypherQuery as CypherAST;
11use crate::ast::ReadingClause;
12use crate::config::GraphConfig;
13use crate::error::{GraphError, Result};
14use crate::logical_plan::LogicalPlanner;
15use crate::parser::parse_cypher_query;
16use crate::spark_dialect::build_spark_dialect;
17use arrow_array::RecordBatch;
18use arrow_schema::{Field, Schema, SchemaRef};
19use datafusion_sql::unparser::dialect::{
20 CustomDialect, DefaultDialect, MySqlDialect, PostgreSqlDialect, SqliteDialect,
21};
22use datafusion_sql::unparser::Unparser;
23use lance_csr_catalog::DirNamespace;
24use lance_namespace::models::DescribeTableRequest;
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum SqlDialect {
31 #[default]
33 Default,
34 Spark,
36 PostgreSql,
38 MySql,
40 Sqlite,
42}
43
44pub enum DialectUnparser {
46 Default(DefaultDialect),
47 Spark(Box<CustomDialect>),
48 PostgreSql(PostgreSqlDialect),
49 MySql(MySqlDialect),
50 Sqlite(SqliteDialect),
51}
52
53impl DialectUnparser {
54 pub fn as_unparser(&self) -> Unparser<'_> {
55 match self {
56 DialectUnparser::Default(d) => Unparser::new(d),
57 DialectUnparser::Spark(d) => Unparser::new(d.as_ref()),
58 DialectUnparser::PostgreSql(d) => Unparser::new(d),
59 DialectUnparser::MySql(d) => Unparser::new(d),
60 DialectUnparser::Sqlite(d) => Unparser::new(d),
61 }
62 }
63}
64
65impl SqlDialect {
66 pub fn unparser(&self) -> DialectUnparser {
68 match self {
69 SqlDialect::Default => DialectUnparser::Default(DefaultDialect {}),
70 SqlDialect::Spark => DialectUnparser::Spark(Box::new(build_spark_dialect())),
71 SqlDialect::PostgreSql => DialectUnparser::PostgreSql(PostgreSqlDialect {}),
72 SqlDialect::MySql => DialectUnparser::MySql(MySqlDialect {}),
73 SqlDialect::Sqlite => DialectUnparser::Sqlite(SqliteDialect {}),
74 }
75 }
76}
77
78pub(crate) fn normalize_schema(schema: SchemaRef) -> Result<SchemaRef> {
83 let fields: Vec<_> = schema
84 .fields()
85 .iter()
86 .map(|f| {
87 Arc::new(Field::new(
88 f.name().to_lowercase(),
89 f.data_type().clone(),
90 f.is_nullable(),
91 ))
92 })
93 .collect();
94 Ok(Arc::new(Schema::new(fields)))
95}
96
97pub(crate) fn normalize_record_batch(batch: &RecordBatch) -> Result<RecordBatch> {
102 let normalized_schema = normalize_schema(batch.schema())?;
103 RecordBatch::try_new(normalized_schema, batch.columns().to_vec()).map_err(|e| {
104 GraphError::PlanError {
105 message: format!("Failed to normalize record batch schema: {}", e),
106 location: snafu::Location::new(file!(), line!(), column!()),
107 }
108 })
109}
110
111fn query_dataframe(
113 ctx: &datafusion::execution::context::SessionContext,
114 plan: datafusion::logical_expr::LogicalPlan,
115) -> datafusion::dataframe::DataFrame {
116 use datafusion::execution::session_state::SessionStateBuilder;
117 use datafusion::logical_expr::{Expr, LogicalPlan};
118
119 fn has_distinct_count(plan: &LogicalPlan) -> bool {
120 if let LogicalPlan::Aggregate(aggregate) = plan {
121 if aggregate.aggr_expr.iter().any(|expr| {
122 matches!(expr.clone().unalias(), Expr::AggregateFunction(f)
123 if f.func.name() == "count" && f.params.distinct)
124 }) {
125 return true;
126 }
127 }
128 plan.inputs().into_iter().any(has_distinct_count)
129 }
130
131 let mut state = ctx.state();
132 if has_distinct_count(&plan) {
133 let rules = state
136 .physical_optimizers()
137 .iter()
138 .filter(|rule| rule.name() != "aggregate_statistics")
139 .cloned()
140 .collect();
141 state = SessionStateBuilder::from(state)
142 .with_physical_optimizer_rules(rules)
143 .build();
144 }
145 datafusion::dataframe::DataFrame::new(state, plan)
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ExecutionStrategy {
151 #[default]
153 DataFusion,
154 LanceNative,
156}
157
158#[derive(Debug, Clone)]
160pub struct CypherQuery {
161 query_text: String,
163 ast: CypherAST,
165 config: Option<GraphConfig>,
167 parameters: HashMap<String, serde_json::Value>,
169 adjacency: Option<crate::csr_expand::AdjacencyCatalog>,
171 fan_out: Option<crate::logical_plan::FanOut>,
173 cached_plan: Option<Arc<CachedPlan>>,
175}
176struct CachedPlan {
177 graph: crate::logical_plan::LogicalOperator,
178 datafusion: datafusion::logical_expr::LogicalPlan,
179 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
180 parameter_types: HashMap<String, arrow_schema::DataType>,
181}
182
183impl std::fmt::Debug for CachedPlan {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.debug_struct("CachedPlan")
186 .field("parameter_types", &self.parameter_types)
187 .finish_non_exhaustive()
188 }
189}
190
191impl CypherQuery {
192 pub fn new(query: &str) -> Result<Self> {
194 let ast = parse_cypher_query(query)?;
195
196 Ok(Self {
197 query_text: query.to_string(),
198 ast,
199 config: None,
200 parameters: HashMap::new(),
201 adjacency: None,
202 fan_out: None,
203 cached_plan: None,
204 })
205 }
206
207 pub fn with_config(mut self, config: GraphConfig) -> Self {
209 self.cached_plan = None;
210 self.config = Some(config);
211 self
212 }
213
214 pub fn with_adjacency(mut self, adjacency: crate::csr_expand::AdjacencyCatalog) -> Self {
220 self.cached_plan = None;
221 self.adjacency = Some(adjacency);
222 self
223 }
224
225 pub fn with_fan_out(mut self, fan_out: crate::logical_plan::FanOut) -> Self {
228 self.cached_plan = None;
229 self.fan_out = Some(fan_out);
230 self
231 }
232
233 pub fn with_parameter<K, V>(mut self, key: K, value: V) -> Self
235 where
236 K: Into<String>,
237 V: Into<serde_json::Value>,
238 {
239 self.parameters
240 .insert(key.into().to_lowercase(), value.into());
241 self
242 }
243
244 pub fn with_parameters(mut self, params: HashMap<String, serde_json::Value>) -> Self {
246 for (k, v) in params {
247 self.parameters.insert(k.to_lowercase(), v);
248 }
249 self
250 }
251
252 pub fn query_text(&self) -> &str {
254 &self.query_text
255 }
256
257 pub fn ast(&self) -> &CypherAST {
259 &self.ast
260 }
261
262 pub fn config(&self) -> Option<&GraphConfig> {
264 self.config.as_ref()
265 }
266
267 pub fn parameters(&self) -> &HashMap<String, serde_json::Value> {
269 &self.parameters
270 }
271
272 #[doc(hidden)]
275 pub fn prepare_cached_plan(
276 &mut self,
277 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
278 ) -> Result<bool> {
279 let mut shape_sensitive = false;
282 self.ast.for_each_query(&mut |query| {
283 shape_sensitive |= query
284 .limit
285 .iter()
286 .chain(query.skip.iter())
287 .chain(
288 query
289 .with_clause
290 .iter()
291 .filter_map(|clause| clause.limit.as_ref()),
292 )
293 .chain(
294 query
295 .further_with_stages
296 .iter()
297 .filter_map(|stage| stage.with_clause.limit.as_ref()),
298 )
299 .any(|count| matches!(count, crate::ast::RowCount::Parameter(_)))
300 || query
301 .all_reading_clauses()
302 .any(|clause| matches!(clause, ReadingClause::Call(_)));
303 });
304 if shape_sensitive
305 || self.scalar_parameters().is_none()
306 || crate::path_value::needs_values(&self.ast, self.require_config()?)
307 {
308 return Ok(false);
310 }
311 let (graph, datafusion) = self.build_logical_plans(catalog.clone(), true)?;
312 self.cached_plan = Some(Arc::new(CachedPlan {
313 graph,
314 datafusion,
315 catalog,
316 parameter_types: self
317 .scalar_parameters()
318 .unwrap()
319 .into_iter()
320 .map(|(name, value)| (name, value.data_type()))
321 .collect(),
322 }));
323 Ok(true)
324 }
325
326 fn scalar_parameters(&self) -> Option<HashMap<String, datafusion::common::ScalarValue>> {
327 use datafusion::common::ScalarValue;
328 self.parameters
329 .iter()
330 .map(|(name, value)| {
331 let value = match value {
332 serde_json::Value::Null => ScalarValue::Null,
333 serde_json::Value::Bool(value) => ScalarValue::Boolean(Some(*value)),
334 serde_json::Value::String(value) => ScalarValue::Utf8(Some(value.clone())),
335 serde_json::Value::Number(value) if value.is_i64() => {
336 ScalarValue::Int64(value.as_i64())
337 }
338 serde_json::Value::Number(value) if value.is_f64() => {
339 ScalarValue::Float64(value.as_f64())
340 }
341 _ => return None,
342 };
343 Some((name.clone(), value))
344 })
345 .collect()
346 }
347
348 fn require_config(&self) -> Result<&GraphConfig> {
350 self.config.as_ref().ok_or_else(|| GraphError::ConfigError {
351 message: "Graph configuration is required for query execution".to_string(),
352 location: snafu::Location::new(file!(), line!(), column!()),
353 })
354 }
355
356 pub async fn execute(
390 &self,
391 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
392 strategy: Option<ExecutionStrategy>,
393 ) -> Result<arrow::record_batch::RecordBatch> {
394 let strategy = strategy.unwrap_or_default();
395 match strategy {
396 ExecutionStrategy::DataFusion => self.execute_datafusion(datasets).await,
397 ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
398 feature: "Lance native execution strategy is not yet implemented".to_string(),
399 location: snafu::Location::new(file!(), line!(), column!()),
400 }),
401 }
402 }
403
404 pub async fn execute_with_namespace(
408 &self,
409 namespace: DirNamespace,
410 strategy: Option<ExecutionStrategy>,
411 ) -> Result<arrow::record_batch::RecordBatch> {
412 self.execute_with_namespace_arc(std::sync::Arc::new(namespace), strategy)
413 .await
414 }
415
416 pub async fn execute_with_namespace_arc(
418 &self,
419 namespace: std::sync::Arc<DirNamespace>,
420 strategy: Option<ExecutionStrategy>,
421 ) -> Result<arrow::record_batch::RecordBatch> {
422 let namespace_trait: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync> =
423 namespace;
424 self.execute_with_namespace_internal(namespace_trait, strategy)
425 .await
426 }
427
428 async fn execute_with_namespace_internal(
429 &self,
430 namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
431 strategy: Option<ExecutionStrategy>,
432 ) -> Result<arrow::record_batch::RecordBatch> {
433 let strategy = strategy.unwrap_or_default();
434 match strategy {
435 ExecutionStrategy::DataFusion => {
436 let (catalog, ctx) = self
437 .build_catalog_and_context_from_namespace(namespace)
438 .await?;
439 self.execute_with_catalog_and_context(std::sync::Arc::new(catalog), ctx)
440 .await
441 }
442 ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
443 feature: "Lance native execution strategy is not yet implemented".to_string(),
444 location: snafu::Location::new(file!(), line!(), column!()),
445 }),
446 }
447 }
448
449 pub async fn explain(
485 &self,
486 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
487 ) -> Result<String> {
488 use std::sync::Arc;
489
490 let (catalog, ctx) = self
492 .build_catalog_and_context_from_datasets(datasets)
493 .await?;
494
495 self.explain_internal(Arc::new(catalog), ctx).await
497 }
498
499 pub async fn to_sql(
523 &self,
524 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
525 dialect: Option<SqlDialect>,
526 ) -> Result<String> {
527 use datafusion::common::tree_node::{Transformed, TreeNode};
528 use datafusion::common::{not_impl_err, DFSchema, ScalarValue};
529 use datafusion::logical_expr::expr_rewriter::NamePreserver;
530 use datafusion::logical_expr::registry::FunctionRegistry;
531 use datafusion::logical_expr::{lit, utils::merge_schema, Expr, LogicalPlan};
532 use std::sync::Arc;
533
534 let dialect = dialect.unwrap_or_default();
535 let _config = self.require_config()?;
536
537 let (catalog, ctx) = self
539 .build_catalog_and_context_from_datasets(datasets)
540 .await?;
541
542 let (_, df_plan) = self.create_logical_plans(Arc::new(catalog))?;
544
545 let optimized_plan = ctx
548 .state()
549 .optimize(&df_plan)
550 .map_err(|e| GraphError::PlanError {
551 message: format!("Failed to optimize plan: {}", e),
552 location: snafu::Location::new(file!(), line!(), column!()),
553 })?;
554
555 let optimized_plan = optimized_plan
557 .transform_up_with_subqueries(|plan| {
558 let schema = if let LogicalPlan::TableScan(scan) = &plan {
559 DFSchema::try_from_qualified_schema(scan.table_name.clone(), &scan.source.schema())?
560 } else {
561 merge_schema(&plan.inputs())
562 };
563 let names = NamePreserver::new(&plan);
564 plan.map_expressions(|expr| {
565 let name = names.save(&expr);
566 let rewritten = expr.transform_up(|expr| {
567 if let Expr::ScalarFunction(function) = &expr {
568 if let Some(native) = crate::datafusion_planner::sql_expr(function, &schema)? {
569 return Ok(Transformed::yes(native));
570 }
571 if !ctx.udf(function.name()).is_ok_and(|registered| registered == function.func) {
572 return not_impl_err!(
573 "SQL export of Cypher function '{}' is unsupported; execute the Cypher query directly",
574 function.name()
575 );
576 }
577 }
578 if let Expr::Literal(value, _) = &expr {
579 let data_type = value.data_type();
580 let type_name = data_type.to_string();
581 if value.is_null()
585 && !data_type.is_null()
586 && dialect == SqlDialect::Default
587 && type_name.parse::<arrow_schema::DataType>().is_ok_and(|parsed| parsed == data_type)
588 {
589 return Ok(Transformed::yes(datafusion::functions::core::arrow_cast().call(vec![
590 lit(ScalarValue::Null), lit(type_name),
591 ])));
592 }
593 }
594 Ok(Transformed::no(expr))
595 })?;
596 Ok(rewritten.update_data(|expr| name.restore(expr)))
597 })
598 })
599 .map_err(|error| GraphError::UnsupportedFeature {
600 feature: error.to_string(),
601 location: snafu::Location::new(file!(), line!(), column!()),
602 })?
603 .data;
604
605 let dialect_unparser = dialect.unparser();
607 let unparser = dialect_unparser.as_unparser();
608 let sql_ast = unparser
609 .plan_to_sql(&optimized_plan)
610 .map_err(|e| GraphError::PlanError {
611 message: format!("Failed to unparse plan to SQL: {}", e),
612 location: snafu::Location::new(file!(), line!(), column!()),
613 })?;
614
615 Ok(sql_ast.to_string())
616 }
617
618 pub async fn execute_with_context(
667 &self,
668 ctx: datafusion::execution::context::SessionContext,
669 ) -> Result<arrow::record_batch::RecordBatch> {
670 use datafusion::datasource::DefaultTableSource;
671 use lance_csr_catalog::InMemoryCatalog;
672 use std::sync::Arc;
673
674 let config = self.require_config()?;
675
676 let mut catalog = InMemoryCatalog::new();
678
679 for (label, _) in config.iter_node_mappings() {
681 let table_provider =
682 ctx.table_provider(label)
683 .await
684 .map_err(|e| GraphError::ConfigError {
685 message: format!(
686 "Node label '{}' not found in SessionContext: {}",
687 label, e
688 ),
689 location: snafu::Location::new(file!(), line!(), column!()),
690 })?;
691
692 let table_source = Arc::new(DefaultTableSource::new(table_provider));
693 catalog = catalog.with_node_source(label, table_source);
694 }
695
696 for (rel_type, _) in config.iter_relationship_mappings() {
698 let table_provider =
699 ctx.table_provider(rel_type)
700 .await
701 .map_err(|e| GraphError::ConfigError {
702 message: format!(
703 "Relationship type '{}' not found in SessionContext: {}",
704 rel_type, e
705 ),
706 location: snafu::Location::new(file!(), line!(), column!()),
707 })?;
708
709 let table_source = Arc::new(DefaultTableSource::new(table_provider));
710 catalog = catalog.with_relationship_source(rel_type, table_source);
711 }
712
713 self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
715 .await
716 }
717
718 pub async fn execute_with_catalog_and_context(
755 &self,
756 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
757 ctx: datafusion::execution::context::SessionContext,
758 ) -> Result<arrow::record_batch::RecordBatch> {
759 use arrow::compute::concat_batches;
760
761 let df = self
762 .dataframe_with_catalog_and_context(catalog, ctx)
763 .await?;
764
765 let task_context = Arc::new(df.task_ctx());
769 let physical = df
770 .create_physical_plan()
771 .await
772 .map_err(|e| GraphError::ExecutionError {
773 message: format!("Failed to plan query results: {e}"),
774 location: snafu::Location::new(file!(), line!(), column!()),
775 })?;
776 let result_schema = physical.schema();
777 let batches = datafusion::physical_plan::collect(physical, task_context)
778 .await
779 .map_err(|e| GraphError::ExecutionError {
780 message: format!("Failed to collect query results: {e}"),
781 location: snafu::Location::new(file!(), line!(), column!()),
782 })?;
783
784 if batches.is_empty() {
785 return Ok(arrow::record_batch::RecordBatch::new_empty(result_schema));
788 }
789
790 concat_batches(&result_schema, &batches).map_err(|e| GraphError::ExecutionError {
793 message: format!("Failed to concatenate result batches: {}", e),
794 location: snafu::Location::new(file!(), line!(), column!()),
795 })
796 }
797
798 pub async fn execute_stream_with_catalog_and_context(
804 &self,
805 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
806 ctx: datafusion::execution::context::SessionContext,
807 ) -> Result<datafusion::physical_plan::SendableRecordBatchStream> {
808 let df = self
809 .dataframe_with_catalog_and_context(catalog, ctx)
810 .await?;
811 df.execute_stream()
812 .await
813 .map_err(|e| GraphError::ExecutionError {
814 message: format!("Failed to start query stream: {}", e),
815 location: snafu::Location::new(file!(), line!(), column!()),
816 })
817 }
818
819 pub async fn explain_with_catalog_and_context(
821 &self,
822 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
823 ctx: datafusion::execution::context::SessionContext,
824 ) -> Result<String> {
825 self.explain_internal(catalog, ctx).await
826 }
827
828 async fn dataframe_with_catalog_and_context(
830 &self,
831 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
832 ctx: datafusion::execution::context::SessionContext,
833 ) -> Result<datafusion::dataframe::DataFrame> {
834 let (_logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
835 let ctx = deadline::context(ctx);
836 Ok(query_dataframe(&ctx, df_logical_plan))
837 }
838
839 async fn execute_datafusion(
855 &self,
856 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
857 ) -> Result<arrow::record_batch::RecordBatch> {
858 use std::sync::Arc;
859
860 let (catalog, ctx) = self
862 .build_catalog_and_context_from_datasets(datasets)
863 .await?;
864
865 self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
867 .await
868 }
869
870 async fn build_catalog_and_context_from_datasets(
872 &self,
873 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
874 ) -> Result<(
875 lance_csr_catalog::InMemoryCatalog,
876 datafusion::execution::context::SessionContext,
877 )> {
878 use datafusion::datasource::{DefaultTableSource, MemTable};
879 use datafusion::execution::context::SessionContext;
880 use lance_csr_catalog::InMemoryCatalog;
881 use std::sync::Arc;
882
883 if datasets.is_empty() {
884 return Err(GraphError::ConfigError {
885 message: "No input datasets provided".to_string(),
886 location: snafu::Location::new(file!(), line!(), column!()),
887 });
888 }
889
890 let ctx = SessionContext::new_with_state(
892 datafusion::execution::session_state::SessionStateBuilder::new()
893 .with_default_features()
894 .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
895 .build(),
896 );
897 let mut catalog = InMemoryCatalog::new();
898
899 for (name, batch) in &datasets {
901 let normalized_batch = normalize_record_batch(batch)?;
903
904 let mem_table = Arc::new(
905 MemTable::try_new(
906 normalized_batch.schema(),
907 vec![vec![normalized_batch.clone()]],
908 )
909 .map_err(|e| GraphError::PlanError {
910 message: format!("Failed to create MemTable for {}: {}", name, e),
911 location: snafu::Location::new(file!(), line!(), column!()),
912 })?,
913 );
914
915 let normalized_name = name.to_lowercase();
917
918 ctx.register_table(&normalized_name, mem_table.clone())
920 .map_err(|e| GraphError::PlanError {
921 message: format!("Failed to register table {}: {}", name, e),
922 location: snafu::Location::new(file!(), line!(), column!()),
923 })?;
924
925 let table_source = Arc::new(DefaultTableSource::new(mem_table));
926
927 catalog = catalog
930 .with_node_source(name, table_source.clone())
931 .with_relationship_source(name, table_source);
932 }
933
934 Ok((catalog, ctx))
935 }
936
937 async fn build_catalog_and_context_from_namespace(
939 &self,
940 namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
941 ) -> Result<(
942 lance_csr_catalog::InMemoryCatalog,
943 datafusion::execution::context::SessionContext,
944 )> {
945 use datafusion::datasource::{DefaultTableSource, TableProvider};
946 use datafusion::execution::context::SessionContext;
947 use lance::datafusion::LanceTableProvider;
948 use lance_csr_catalog::InMemoryCatalog;
949 use std::sync::Arc;
950
951 let config = self.require_config()?;
952
953 let mut required_tables: HashSet<String> = HashSet::new();
954 required_tables.extend(
957 config
958 .iter_node_mappings()
959 .map(|(_, mapping)| mapping.label.clone()),
960 );
961 required_tables.extend(
962 config
963 .iter_relationship_mappings()
964 .map(|(_, mapping)| mapping.relationship_type.clone()),
965 );
966
967 if required_tables.is_empty() {
968 return Err(GraphError::ConfigError {
969 message:
970 "Graph configuration does not reference any node labels or relationship types"
971 .to_string(),
972 location: snafu::Location::new(file!(), line!(), column!()),
973 });
974 }
975
976 let ctx = SessionContext::new_with_state(
977 datafusion::execution::session_state::SessionStateBuilder::new()
978 .with_default_features()
979 .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
980 .build(),
981 );
982 let mut catalog = InMemoryCatalog::new();
983 let mut providers: HashMap<String, Arc<dyn TableProvider>> = HashMap::new();
984
985 for table_name in required_tables {
986 let mut request = DescribeTableRequest::new();
987 request.id = Some(vec![table_name.clone()]);
988
989 let response =
990 namespace
991 .describe_table(request)
992 .await
993 .map_err(|e| GraphError::ConfigError {
994 message: format!(
995 "Namespace failed to resolve table '{}': {}",
996 table_name, e
997 ),
998 location: snafu::Location::new(file!(), line!(), column!()),
999 })?;
1000
1001 let location = response.location.ok_or_else(|| GraphError::ConfigError {
1002 message: format!(
1003 "Namespace did not provide a location for table '{}'",
1004 table_name
1005 ),
1006 location: snafu::Location::new(file!(), line!(), column!()),
1007 })?;
1008
1009 let dataset = lance::dataset::Dataset::open(&location)
1010 .await
1011 .map_err(|e| GraphError::ConfigError {
1012 message: format!("Failed to open dataset for table '{}': {}", table_name, e),
1013 location: snafu::Location::new(file!(), line!(), column!()),
1014 })?;
1015
1016 let dataset = Arc::new(dataset);
1017 let provider: Arc<dyn TableProvider> =
1018 Arc::new(LanceTableProvider::new(dataset.clone(), true, true));
1019
1020 let normalized_table_name = table_name.to_lowercase();
1022 ctx.register_table(&normalized_table_name, provider.clone())
1023 .map_err(|e| GraphError::PlanError {
1024 message: format!(
1025 "Failed to register table '{}' in SessionContext: {}",
1026 table_name, e
1027 ),
1028 location: snafu::Location::new(file!(), line!(), column!()),
1029 })?;
1030
1031 providers.insert(normalized_table_name.clone(), provider);
1033 }
1034
1035 for (label, _) in config.iter_node_mappings() {
1036 let provider = providers
1037 .get(label)
1038 .ok_or_else(|| GraphError::ConfigError {
1039 message: format!(
1040 "Namespace did not resolve dataset for node label '{}'",
1041 label
1042 ),
1043 location: snafu::Location::new(file!(), line!(), column!()),
1044 })?;
1045
1046 let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1047 catalog = catalog.with_node_source(label, table_source);
1048 }
1049
1050 for (rel_type, _) in config.iter_relationship_mappings() {
1051 let provider = providers
1052 .get(rel_type)
1053 .ok_or_else(|| GraphError::ConfigError {
1054 message: format!(
1055 "Namespace did not resolve dataset for relationship type '{}'",
1056 rel_type
1057 ),
1058 location: snafu::Location::new(file!(), line!(), column!()),
1059 })?;
1060
1061 let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1062 catalog = catalog.with_relationship_source(rel_type, table_source);
1063 }
1064
1065 Ok((catalog, ctx))
1066 }
1067
1068 async fn explain_internal(
1070 &self,
1071 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1072 ctx: datafusion::execution::context::SessionContext,
1073 ) -> Result<String> {
1074 let (logical_plan, df_logical_plan, physical_plan) =
1076 self.create_plans(catalog, &ctx).await?;
1077
1078 self.format_explain_output(&logical_plan, &df_logical_plan, physical_plan.as_ref())
1080 }
1081
1082 fn create_logical_plans(
1087 &self,
1088 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1089 ) -> Result<(
1090 crate::logical_plan::LogicalOperator,
1091 datafusion::logical_expr::LogicalPlan,
1092 )> {
1093 if let Some(template) = &self.cached_plan {
1094 if let Some(parameters) = self.scalar_parameters() {
1095 let same_types = parameters.len() == template.parameter_types.len()
1096 && parameters.iter().all(|(name, value)| {
1097 template.parameter_types.get(name) == Some(&value.data_type())
1098 });
1099 if !Arc::ptr_eq(&catalog, &template.catalog) || !same_types {
1100 return self.build_logical_plans(catalog, false);
1101 }
1102 let plan = template
1103 .datafusion
1104 .clone()
1105 .with_param_values(parameters)
1106 .map_err(|error| GraphError::PlanError {
1107 message: format!("Failed to bind query parameters: {error}"),
1108 location: snafu::Location::new(file!(), line!(), column!()),
1109 })?;
1110 return Ok((template.graph.clone(), plan));
1111 }
1112 }
1113 self.build_logical_plans(catalog, false)
1114 }
1115
1116 fn build_logical_plans(
1117 &self,
1118 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1119 parameterized: bool,
1120 ) -> Result<(
1121 crate::logical_plan::LogicalOperator,
1122 datafusion::logical_expr::LogicalPlan,
1123 )> {
1124 use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1125 use crate::semantic::SemanticAnalyzer;
1126
1127 let config = self.require_config()?;
1128
1129 let mut analyzer = SemanticAnalyzer::new(config.clone()).with_catalog(catalog.clone());
1131 let semantic = analyzer.analyze(&self.ast, &self.parameters)?;
1132 if !semantic.errors.is_empty() {
1133 return Err(GraphError::PlanError {
1134 message: format!("Semantic analysis failed:\n{}", semantic.errors.join("\n")),
1135 location: snafu::Location::new(file!(), line!(), column!()),
1136 });
1137 }
1138
1139 let mut logical_planner = LogicalPlanner::new(config)
1141 .with_fan_out(self.fan_out.as_ref())
1142 .with_adjacency(self.adjacency.as_ref());
1143 let ast = if parameterized {
1144 &self.ast
1145 } else {
1146 &semantic.ast
1147 };
1148 let logical_plan = logical_planner.plan(ast)?;
1149
1150 let mut df_planner = DataFusionPlanner::with_catalog(config.clone(), catalog);
1152 df_planner.adjacency = self.adjacency.clone();
1153 if parameterized {
1154 df_planner.parameter_types = self
1155 .scalar_parameters()
1156 .unwrap_or_default()
1157 .into_iter()
1158 .map(|(name, value)| (name, value.data_type()))
1159 .collect();
1160 }
1161 let df_logical_plan = df_planner.plan(&logical_plan)?;
1162
1163 Ok((logical_plan, df_logical_plan))
1164 }
1165
1166 async fn create_plans(
1168 &self,
1169 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1170 ctx: &datafusion::execution::context::SessionContext,
1171 ) -> Result<(
1172 crate::logical_plan::LogicalOperator,
1173 datafusion::logical_expr::LogicalPlan,
1174 std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>,
1175 )> {
1176 let (logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
1178
1179 let ctx = deadline::context(ctx.clone());
1181 let df = query_dataframe(&ctx, df_logical_plan.clone());
1182
1183 let physical_plan =
1184 df.create_physical_plan()
1185 .await
1186 .map_err(|e| GraphError::ExecutionError {
1187 message: format!("Failed to create physical plan: {}", e),
1188 location: snafu::Location::new(file!(), line!(), column!()),
1189 })?;
1190
1191 Ok((logical_plan, df_logical_plan, physical_plan))
1192 }
1193
1194 fn format_explain_output(
1196 &self,
1197 logical_plan: &crate::logical_plan::LogicalOperator,
1198 df_logical_plan: &datafusion::logical_expr::LogicalPlan,
1199 physical_plan: &dyn datafusion::physical_plan::ExecutionPlan,
1200 ) -> Result<String> {
1201 let mut output = String::new();
1203
1204 output.push_str("Cypher Query:\n");
1206 output.push_str(&format!(" {}\n\n", self.query_text));
1207
1208 let mut rows = vec![];
1210
1211 let graph_plan_str = format!("{:#?}", logical_plan);
1213 rows.push(("graph_logical_plan", graph_plan_str));
1214
1215 let df_logical_str = format!("{}", df_logical_plan.display_indent());
1217 rows.push(("logical_plan", df_logical_str));
1218
1219 let df_physical_str = format!(
1221 "{}",
1222 datafusion::physical_plan::displayable(physical_plan).indent(true)
1223 );
1224 rows.push(("physical_plan", df_physical_str));
1225
1226 let plan_type_width = rows.iter().map(|(t, _)| t.len()).max().unwrap_or(10);
1228 let plan_width = rows
1229 .iter()
1230 .map(|(_, p)| p.lines().map(|l| l.len()).max().unwrap_or(0))
1231 .max()
1232 .unwrap_or(50);
1233
1234 let separator = format!(
1236 "+{}+{}+",
1237 "-".repeat(plan_type_width + 2),
1238 "-".repeat(plan_width + 2)
1239 );
1240
1241 output.push_str(&separator);
1242 output.push('\n');
1243
1244 output.push_str(&format!(
1246 "| {:<width$} | {:<plan_width$} |\n",
1247 "plan_type",
1248 "plan",
1249 width = plan_type_width,
1250 plan_width = plan_width
1251 ));
1252 output.push_str(&separator);
1253 output.push('\n');
1254
1255 for (plan_type, plan_content) in rows {
1257 let lines: Vec<&str> = plan_content.lines().collect();
1258 if lines.is_empty() {
1259 output.push_str(&format!(
1260 "| {:<width$} | {:<plan_width$} |\n",
1261 plan_type,
1262 "",
1263 width = plan_type_width,
1264 plan_width = plan_width
1265 ));
1266 } else {
1267 output.push_str(&format!(
1269 "| {:<width$} | {:<plan_width$} |\n",
1270 plan_type,
1271 lines[0],
1272 width = plan_type_width,
1273 plan_width = plan_width
1274 ));
1275
1276 for line in &lines[1..] {
1278 output.push_str(&format!(
1279 "| {:<width$} | {:<plan_width$} |\n",
1280 "",
1281 line,
1282 width = plan_type_width,
1283 plan_width = plan_width
1284 ));
1285 }
1286 }
1287 }
1288
1289 output.push_str(&separator);
1290 output.push('\n');
1291
1292 Ok(output)
1293 }
1294
1295 pub fn referenced_node_labels(&self) -> Vec<String> {
1297 let mut labels = Vec::new();
1298
1299 self.ast.for_each_query(&mut |ast| {
1300 for clause in ast.all_reading_clauses() {
1301 if let ReadingClause::Match(match_clause) = clause {
1302 for pattern in &match_clause.patterns {
1303 self.collect_node_labels_from_pattern(pattern, &mut labels);
1304 }
1305 }
1306 }
1307 });
1308
1309 labels.sort();
1310 labels.dedup();
1311 labels
1312 }
1313
1314 pub fn referenced_relationship_types(&self) -> Vec<String> {
1316 let mut types = Vec::new();
1317
1318 self.ast.for_each_query(&mut |ast| {
1319 for clause in ast.all_reading_clauses() {
1320 if let ReadingClause::Match(match_clause) = clause {
1321 for pattern in &match_clause.patterns {
1322 self.collect_relationship_types_from_pattern(pattern, &mut types);
1323 }
1324 }
1325 }
1326 });
1327
1328 types.sort();
1329 types.dedup();
1330 types
1331 }
1332
1333 pub fn variables(&self) -> Vec<String> {
1335 let mut variables = Vec::new();
1336
1337 self.ast.for_each_query(&mut |ast| {
1338 for clause in ast.all_reading_clauses() {
1339 match clause {
1340 ReadingClause::Call(inner) => {
1341 variables.extend(inner.call_imports().unwrap_or_default());
1342 variables.extend(inner.return_clause.items.iter().filter_map(|item| {
1343 item.alias.clone().or_else(|| match &item.expression {
1344 crate::ast::ValueExpression::Variable(name) => Some(name.clone()),
1345 _ => None,
1346 })
1347 }));
1348 }
1349 ReadingClause::Match(match_clause) => {
1350 for pattern in &match_clause.patterns {
1351 self.collect_variables_from_pattern(pattern, &mut variables);
1352 }
1353 }
1354 ReadingClause::Unwind(unwind_clause) => {
1355 variables.push(unwind_clause.alias.clone());
1356 }
1357 }
1358 }
1359 });
1360
1361 variables.sort();
1362 variables.dedup();
1363 variables
1364 }
1365
1366 fn collect_node_labels_from_pattern(
1369 &self,
1370 pattern: &crate::ast::GraphPattern,
1371 labels: &mut Vec<String>,
1372 ) {
1373 match pattern {
1374 crate::ast::GraphPattern::Node(node) => {
1375 labels.extend(node.labels.clone());
1376 }
1377 crate::ast::GraphPattern::Path(path) => {
1378 labels.extend(path.start_node.labels.clone());
1379 for segment in &path.segments {
1380 labels.extend(segment.end_node.labels.clone());
1381 }
1382 }
1383 }
1384 }
1385
1386 fn collect_relationship_types_from_pattern(
1387 &self,
1388 pattern: &crate::ast::GraphPattern,
1389 types: &mut Vec<String>,
1390 ) {
1391 if let crate::ast::GraphPattern::Path(path) = pattern {
1392 for segment in &path.segments {
1393 types.extend(segment.relationship.types.clone());
1394 }
1395 }
1396 }
1397
1398 fn collect_variables_from_pattern(
1399 &self,
1400 pattern: &crate::ast::GraphPattern,
1401 variables: &mut Vec<String>,
1402 ) {
1403 match pattern {
1404 crate::ast::GraphPattern::Node(node) => {
1405 if let Some(var) = &node.variable {
1406 variables.push(var.clone());
1407 }
1408 }
1409 crate::ast::GraphPattern::Path(path) => {
1410 if let Some(var) = &path.start_node.variable {
1411 variables.push(var.clone());
1412 }
1413 for segment in &path.segments {
1414 if let Some(var) = &segment.relationship.variable {
1415 variables.push(var.clone());
1416 }
1417 if let Some(var) = &segment.end_node.variable {
1418 variables.push(var.clone());
1419 }
1420 }
1421 }
1422 }
1423 }
1424}
1425
1426impl CypherQuery {
1427 pub async fn execute_with_vector_rerank(
1456 &self,
1457 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
1458 vector_search: crate::lance_vector_search::VectorSearch,
1459 ) -> Result<arrow::record_batch::RecordBatch> {
1460 let candidates = self.execute(datasets, None).await?;
1462
1463 vector_search.search(&candidates).await
1465 }
1466}
1467
1468#[derive(Debug, Default)]
1470pub struct CypherQueryBuilder {
1471 match_clauses: Vec<crate::ast::MatchClause>,
1472 where_expression: Option<crate::ast::BooleanExpression>,
1473 return_items: Vec<crate::ast::ReturnItem>,
1474 order_by_items: Vec<crate::ast::OrderByItem>,
1475 limit: Option<u64>,
1476 distinct: bool,
1477 skip: Option<u64>,
1478 config: Option<GraphConfig>,
1479 parameters: HashMap<String, serde_json::Value>,
1480}
1481
1482impl CypherQueryBuilder {
1483 pub fn new() -> Self {
1485 Self::default()
1486 }
1487
1488 pub fn match_node(mut self, variable: &str, label: &str) -> Self {
1490 let node = crate::ast::NodePattern {
1491 variable: Some(variable.to_string()),
1492 labels: vec![label.to_string()],
1493 properties: HashMap::new(),
1494 };
1495
1496 let match_clause = crate::ast::MatchClause {
1497 patterns: vec![crate::ast::GraphPattern::Node(node)],
1498 optional: false,
1499 where_clause: None,
1500 };
1501
1502 self.match_clauses.push(match_clause);
1503 self
1504 }
1505
1506 pub fn with_config(mut self, config: GraphConfig) -> Self {
1508 self.config = Some(config);
1509 self
1510 }
1511
1512 pub fn return_property(mut self, variable: &str, property: &str) -> Self {
1514 let prop_ref = crate::ast::PropertyRef::new(variable, property);
1515 let return_item = crate::ast::ReturnItem {
1516 expression: crate::ast::ValueExpression::Property(prop_ref),
1517 alias: None,
1518 };
1519
1520 self.return_items.push(return_item);
1521 self
1522 }
1523
1524 pub fn distinct(mut self, distinct: bool) -> Self {
1526 self.distinct = distinct;
1527 self
1528 }
1529
1530 pub fn limit(mut self, limit: u64) -> Self {
1532 self.limit = Some(limit);
1533 self
1534 }
1535
1536 pub fn skip(mut self, skip: u64) -> Self {
1538 self.skip = Some(skip);
1539 self
1540 }
1541
1542 pub fn build(self) -> Result<CypherQuery> {
1544 if self.match_clauses.is_empty() {
1545 return Err(GraphError::PlanError {
1546 message: "Query must have at least one MATCH clause".to_string(),
1547 location: snafu::Location::new(file!(), line!(), column!()),
1548 });
1549 }
1550
1551 if self.return_items.is_empty() {
1552 return Err(GraphError::PlanError {
1553 message: "Query must have at least one RETURN item".to_string(),
1554 location: snafu::Location::new(file!(), line!(), column!()),
1555 });
1556 }
1557
1558 let ast = crate::ast::CypherQuery {
1559 reading_clauses: self
1560 .match_clauses
1561 .into_iter()
1562 .map(crate::ast::ReadingClause::Match)
1563 .collect(),
1564 where_clause: self
1565 .where_expression
1566 .map(|expr| crate::ast::WhereClause { expression: expr }),
1567 with_clause: None, post_with_reading_clauses: vec![],
1569 post_with_where_clause: None,
1570 further_with_stages: vec![],
1571 union_branches: Vec::new(),
1572 return_clause: crate::ast::ReturnClause {
1573 distinct: self.distinct,
1574 items: self.return_items,
1575 },
1576 order_by: if self.order_by_items.is_empty() {
1577 None
1578 } else {
1579 Some(crate::ast::OrderByClause {
1580 items: self.order_by_items,
1581 })
1582 },
1583 limit: self.limit.map(crate::ast::RowCount::Literal),
1584 skip: self.skip.map(crate::ast::RowCount::Literal),
1585 };
1586
1587 let query_text = "MATCH ... RETURN ...".to_string(); let query = CypherQuery {
1591 query_text,
1592 ast,
1593 config: self.config,
1594 parameters: self.parameters,
1595 adjacency: None,
1596 fan_out: None,
1597 cached_plan: None,
1598 };
1599
1600 Ok(query)
1601 }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606 use super::*;
1607 use crate::config::GraphConfig;
1608
1609 #[tokio::test]
1610 async fn union_distinct_all_and_schema_validation_execute() {
1611 use arrow_array::{Int64Array, RecordBatch};
1612 use arrow_schema::{DataType, Field, Schema};
1613 use std::sync::Arc;
1614 let batch = RecordBatch::try_new(
1615 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1616 vec![Arc::new(Int64Array::from(vec![1, 2, 2]))],
1617 )
1618 .unwrap();
1619 let config = GraphConfig::builder()
1620 .node_table("Person", "id")
1621 .build()
1622 .unwrap();
1623 for (query, rows) in [
1624 ("MATCH (p:Person) RETURN p.id AS n UNION RETURN 2 AS n", 2),
1625 (
1626 "MATCH (p:Person) RETURN p.id AS n UNION ALL RETURN 2 AS n",
1627 4,
1628 ),
1629 ("RETURN NULL AS n UNION RETURN NULL AS n", 1),
1630 ] {
1631 let result = CypherQuery::new(query)
1632 .unwrap()
1633 .with_config(config.clone())
1634 .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1635 .await
1636 .unwrap();
1637 assert_eq!(result.num_rows(), rows, "{query}");
1638 }
1639 for query in [
1640 "RETURN 1 AS x UNION RETURN 2 AS y",
1641 "RETURN 1 AS x UNION RETURN 'a' AS x",
1642 "RETURN 1 AS n UNION ALL RETURN 1 AS n UNION RETURN 2 AS n",
1643 ] {
1644 assert!(CypherQuery::new(query)
1645 .unwrap()
1646 .with_config(config.clone())
1647 .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1648 .await
1649 .is_err());
1650 }
1651 }
1652
1653 #[tokio::test]
1654 async fn cartesian_stream_yields_and_drops_before_full_collection() {
1655 use arrow_array::{ArrayRef, Int64Array};
1656 use datafusion::datasource::{DefaultTableSource, MemTable};
1657 use datafusion::execution::context::{SessionConfig, SessionContext};
1658 use futures::TryStreamExt;
1659 use lance_csr_catalog::InMemoryCatalog;
1660 use std::sync::atomic::{AtomicUsize, Ordering};
1661
1662 let batch = RecordBatch::try_from_iter([(
1663 "id",
1664 Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1665 )])
1666 .unwrap();
1667 let table = Arc::new(MemTable::try_new(batch.schema(), vec![vec![batch]]).unwrap());
1668 let catalog = Arc::new(
1669 InMemoryCatalog::new()
1670 .with_node_source("Person", Arc::new(DefaultTableSource::new(table))),
1671 );
1672 let ctx = SessionContext::new_with_config(
1673 SessionConfig::new()
1674 .with_target_partitions(1)
1675 .with_round_robin_repartition(false),
1676 );
1677 let config = GraphConfig::builder()
1678 .node_table("Person", "id")
1679 .build()
1680 .unwrap();
1681 let query = CypherQuery::new(
1682 "MATCH (a:Person), (b:Person) RETURN a.id AS left_id, b.id AS right_id",
1683 )
1684 .unwrap()
1685 .with_config(config.clone());
1686 let stream = with_execution_deadline(
1687 Some(tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1688 query.execute_stream_with_catalog_and_context(catalog.clone(), ctx.clone()),
1689 )
1690 .await
1691 .unwrap();
1692 tokio::spawn(async move {
1694 let visited = Arc::new(AtomicUsize::new(0));
1695 let progress = visited.clone();
1696 let mut read = Box::pin(stream.try_fold(0_usize, move |count, batch| {
1697 progress.fetch_add(batch.num_rows(), Ordering::SeqCst);
1698 futures::future::ready(Ok(count + batch.num_rows()))
1699 }));
1700 assert!(futures::poll!(read.as_mut()).is_pending());
1701 let stopped = visited.load(Ordering::SeqCst);
1702 assert!(stopped > 0 && stopped < 1_000_000);
1703 drop(read);
1704 tokio::task::yield_now().await;
1705 assert_eq!(visited.load(Ordering::SeqCst), stopped);
1706 })
1707 .await
1708 .unwrap();
1709 let result = query
1710 .execute_with_catalog_and_context(catalog.clone(), ctx.clone())
1711 .await
1712 .unwrap();
1713 assert_eq!(result.num_rows(), 1_000_000);
1714 assert_eq!(result.num_columns(), 2);
1715
1716 let filtered = CypherQuery::new(
1717 "MATCH (a:Person), (b:Person) WHERE a.id + b.id >= 999 RETURN count(*) AS n",
1718 )
1719 .unwrap()
1720 .with_config(config);
1721 let expired = with_execution_deadline(
1722 Some(tokio::time::Instant::now() - std::time::Duration::from_secs(1)),
1723 filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1724 )
1725 .await
1726 .unwrap_err();
1727 assert!(expired.to_string().contains("Query timeout exceeded"));
1728 for timed in [false, true] {
1729 let result = with_execution_deadline(
1730 timed.then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1731 filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1732 )
1733 .await
1734 .unwrap();
1735 assert_eq!(result.num_rows(), 1);
1736 assert_eq!(
1737 result
1738 .column(0)
1739 .as_any()
1740 .downcast_ref::<Int64Array>()
1741 .unwrap()
1742 .value(0),
1743 500_500
1744 );
1745 }
1746 }
1747
1748 #[test]
1749 fn test_parse_simple_cypher_query() {
1750 let query = CypherQuery::new("MATCH (n:Person) RETURN n.name").unwrap();
1751 assert_eq!(query.query_text(), "MATCH (n:Person) RETURN n.name");
1752 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
1753 assert_eq!(query.variables(), vec!["n"]);
1754 }
1755
1756 #[test]
1757 fn test_query_with_parameters() {
1758 let mut params = HashMap::new();
1759 params.insert("minAge".to_string(), serde_json::Value::Number(30.into()));
1760 params.insert("maxAge".to_string(), serde_json::Value::Number(50.into()));
1761
1762 let query = CypherQuery::new(
1763 "MATCH (n:Person) WHERE n.age > $minAge AND n.age < $maxAge RETURN n.name",
1764 )
1765 .unwrap()
1766 .with_parameters(params);
1767
1768 assert!(query.parameters().contains_key("minage"));
1769 assert!(query.parameters().contains_key("maxage"));
1770 }
1771
1772 #[tokio::test]
1773 async fn cached_templates_rebind_scalars_and_preserve_schema() {
1774 use arrow_array::{ArrayRef, Int64Array, StringArray};
1775 let config = GraphConfig::builder()
1776 .node_table("Person", "id")
1777 .build()
1778 .unwrap();
1779 let datasets = HashMap::from([(
1780 "Person".into(),
1781 RecordBatch::try_from_iter([
1782 ("id", Arc::new(Int64Array::from(vec![1, 2, 3])) as ArrayRef),
1783 (
1784 "name",
1785 Arc::new(StringArray::from(vec!["Ada", "Bob", "Cam"])) as ArrayRef,
1786 ),
1787 ])
1788 .unwrap(),
1789 )]);
1790 let cases = [
1791 (
1792 "MATCH (p:Person) WHERE p.id >= $value RETURN p.id ORDER BY p.id",
1793 vec![serde_json::json!(1), serde_json::json!(3)],
1794 ),
1795 (
1796 "MATCH (p:Person) RETURN p.id + $value AS adjusted ORDER BY adjusted",
1797 vec![serde_json::json!(1.5), serde_json::json!(8.5)],
1798 ),
1799 (
1800 "MATCH (p:Person) WHERE p.name = $value RETURN p.id",
1801 vec![serde_json::json!("Ada"), serde_json::json!("Cam")],
1802 ),
1803 (
1804 "MATCH (p:Person) RETURN $value AS value",
1805 vec![serde_json::json!(true), serde_json::json!(false)],
1806 ),
1807 (
1808 "MATCH (p:Person) RETURN $value AS value",
1809 vec![serde_json::Value::Null],
1810 ),
1811 (
1812 "MATCH (p:Person) RETURN $value",
1813 vec![serde_json::json!(1), serde_json::json!(2)],
1814 ),
1815 (
1816 "MATCH (p:Person) RETURN p.id + $value",
1817 vec![serde_json::json!(1), serde_json::json!(2)],
1818 ),
1819 (
1820 "MATCH (p:Person) RETURN p.id AS id, [x IN range(1, $value) | x + p.id] AS values ORDER BY id",
1821 vec![serde_json::json!(1), serde_json::json!(3)],
1822 ),
1823 ];
1824 for (text, values) in cases {
1825 let mut template = CypherQuery::new(text)
1826 .unwrap()
1827 .with_config(config.clone())
1828 .with_parameter("value", values[0].clone());
1829 let (catalog, context) = template
1830 .build_catalog_and_context_from_datasets(datasets.clone())
1831 .await
1832 .unwrap();
1833 let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1834 assert!(
1835 template.prepare_cached_plan(catalog.clone()).unwrap(),
1836 "{text}"
1837 );
1838 for value in values {
1839 let cached = template.clone().with_parameter("value", value.clone());
1840 let fresh = CypherQuery::new(text)
1841 .unwrap()
1842 .with_config(config.clone())
1843 .with_parameter("value", value);
1844 let actual = cached
1845 .execute_with_catalog_and_context(catalog.clone(), context.clone())
1846 .await
1847 .unwrap();
1848 let expected = fresh
1849 .execute_with_catalog_and_context(catalog.clone(), context.clone())
1850 .await
1851 .unwrap();
1852 assert_eq!(actual, expected, "{text}");
1853 }
1854 }
1855 }
1856
1857 #[tokio::test]
1858 async fn cached_templates_respect_catalog_and_parameter_types() {
1859 use arrow_array::{ArrayRef, Int64Array};
1860 let config = GraphConfig::builder()
1861 .node_table("Person", "id")
1862 .build()
1863 .unwrap();
1864 let data = |id| {
1865 HashMap::from([(
1866 "Person".into(),
1867 RecordBatch::try_from_iter([(
1868 "id",
1869 Arc::new(Int64Array::from(vec![id])) as ArrayRef,
1870 )])
1871 .unwrap(),
1872 )])
1873 };
1874 let mut query = CypherQuery::new("MATCH (p:Person) WHERE p.id = $value RETURN p.id")
1875 .unwrap()
1876 .with_config(config.clone())
1877 .with_parameter("value", 1);
1878 let (catalog, _) = query
1879 .build_catalog_and_context_from_datasets(data(1))
1880 .await
1881 .unwrap();
1882 assert!(query.prepare_cached_plan(Arc::new(catalog)).unwrap());
1883 assert_eq!(query.execute(data(2), None).await.unwrap().num_rows(), 0);
1885 assert!(query
1886 .clone()
1887 .with_parameter("value", true)
1888 .execute(data(1), None)
1889 .await
1890 .unwrap_err()
1891 .to_string()
1892 .contains("Type mismatch"));
1893 assert!(query.with_config(config).cached_plan.is_none());
1894 let mut containers = CypherQuery::new("MATCH (p:Person) WHERE p.id IN $ids RETURN p.id")
1895 .unwrap()
1896 .with_parameter("ids", serde_json::json!([1, 2]));
1897 assert!(!containers
1898 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1899 .unwrap());
1900 }
1901
1902 #[test]
1903 fn parameterized_row_counts_bypass_templates() {
1904 for text in [
1905 "MATCH (p:Person) RETURN p.id LIMIT $count",
1906 "MATCH (p:Person) RETURN p.id SKIP $count",
1907 "MATCH (p:Person) WITH p LIMIT $count RETURN p.id",
1908 "MATCH (p:Person) WITH p WITH p LIMIT $count RETURN p.id",
1909 ] {
1910 let mut query = CypherQuery::new(text).unwrap().with_parameter("count", 1);
1911 assert!(
1912 !query
1913 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1914 .unwrap(),
1915 "{text}"
1916 );
1917 assert!(query.cached_plan.is_none());
1918 }
1919 }
1920
1921 #[test]
1922 fn cross_label_shortest_queries_bypass_cached_inventories() {
1923 let config = GraphConfig::builder()
1924 .node_table("Person", "id")
1925 .node_table("Place", "id")
1926 .rel_table("LIVES", "Person", "Place", "src", "dst")
1927 .build()
1928 .unwrap();
1929 for text in [
1930 "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p)",
1931 "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]-(b:Person)) WITH p AS q RETURN nodes(q)",
1932 "RETURN 0 AS n UNION MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p) AS n",
1933 ] {
1934 let mut query = CypherQuery::new(text)
1935 .unwrap()
1936 .with_config(config.clone())
1937 .with_parameter("id", 1);
1938 assert!(!query
1939 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1940 .unwrap());
1941 assert!(query.cached_plan.is_none(), "{text}");
1942 }
1943 }
1944
1945 #[tokio::test]
1949 #[ignore = "local stage timing; run with --ignored --nocapture"]
1950 async fn profile_query_planning_stages() {
1951 use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1952 use crate::semantic::SemanticAnalyzer;
1953 use arrow_array::{ArrayRef, Int64Array};
1954 use std::time::Instant;
1955 let text = "MATCH (p:Person) WHERE p.id >= $minimum RETURN p.id AS id ORDER BY id";
1956 let config = GraphConfig::builder()
1957 .node_table("Person", "id")
1958 .build()
1959 .unwrap();
1960 let parameters = HashMap::from([("minimum".into(), serde_json::json!(10))]);
1961 let mut template = CypherQuery::new(text)
1962 .unwrap()
1963 .with_config(config.clone())
1964 .with_parameters(parameters.clone());
1965 let data = HashMap::from([(
1966 "Person".into(),
1967 RecordBatch::try_from_iter([(
1968 "id",
1969 Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1970 )])
1971 .unwrap(),
1972 )]);
1973 let (catalog, context) = template
1974 .build_catalog_and_context_from_datasets(data)
1975 .await
1976 .unwrap();
1977 let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1978 template.prepare_cached_plan(catalog.clone()).unwrap();
1979 let state = context.state();
1980 let mut totals = [std::time::Duration::ZERO; 8];
1981 for _ in 0..100 {
1982 let start = Instant::now();
1983 let ast = parse_cypher_query(text).unwrap();
1984 totals[0] += start.elapsed();
1985 let start = Instant::now();
1986 let semantic = SemanticAnalyzer::new(config.clone())
1987 .with_catalog(catalog.clone())
1988 .analyze(&ast, ¶meters)
1989 .unwrap();
1990 assert!(semantic.errors.is_empty());
1991 totals[1] += start.elapsed();
1992 let start = Instant::now();
1993 let graph = LogicalPlanner::new(&config).plan(&semantic.ast).unwrap();
1994 totals[2] += start.elapsed();
1995 let start = Instant::now();
1996 let logical = DataFusionPlanner::with_catalog(config.clone(), catalog.clone())
1997 .plan(&graph)
1998 .unwrap();
1999 totals[3] += start.elapsed();
2000 let start = Instant::now();
2001 let optimized = state.optimize(&logical).unwrap();
2002 totals[4] += start.elapsed();
2003 let start = Instant::now();
2004 let physical = state
2005 .query_planner()
2006 .create_physical_plan(&optimized, &state)
2007 .await
2008 .unwrap();
2009 totals[5] += start.elapsed();
2010 let start = Instant::now();
2011 let batches = datafusion::physical_plan::collect(physical, context.task_ctx())
2012 .await
2013 .unwrap();
2014 totals[6] += start.elapsed();
2015 assert_eq!(
2016 batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
2017 990
2018 );
2019 let checksum: i64 = batches
2020 .iter()
2021 .flat_map(|batch| {
2022 batch
2023 .column(0)
2024 .as_any()
2025 .downcast_ref::<Int64Array>()
2026 .unwrap()
2027 .values()
2028 .iter()
2029 })
2030 .sum();
2031 assert_eq!(checksum, (10 + 999) * 990 / 2);
2032 let start = Instant::now();
2033 let _ = template.create_logical_plans(catalog.clone()).unwrap();
2034 totals[7] += start.elapsed();
2035 }
2036 for (name, duration) in [
2037 "parse",
2038 "semantic",
2039 "graph_logical",
2040 "datafusion_logical",
2041 "optimize",
2042 "physical",
2043 "execute",
2044 "cached_bind",
2045 ]
2046 .into_iter()
2047 .zip(totals)
2048 {
2049 println!(
2050 "{name}: {:.3} us mean (100 iterations, 1000 input rows, 990 result rows)",
2051 duration.as_secs_f64() * 1_000_000.0 / 100.0
2052 );
2053 }
2054 }
2055
2056 #[test]
2057 fn test_query_builder() {
2058 let config = GraphConfig::builder()
2059 .node_table("Person", "person_id")
2060 .build()
2061 .unwrap();
2062
2063 let query = CypherQueryBuilder::new()
2064 .with_config(config)
2065 .match_node("n", "Person")
2066 .return_property("n", "name")
2067 .limit(10)
2068 .build()
2069 .unwrap();
2070
2071 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2072 assert_eq!(query.variables(), vec!["n"]);
2073 }
2074
2075 #[test]
2076 fn test_relationship_query_parsing() {
2077 let query =
2078 CypherQuery::new("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a.name, b.name")
2079 .unwrap();
2080 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2081 assert_eq!(query.referenced_relationship_types(), vec!["KNOWS"]);
2082 assert_eq!(query.variables(), vec!["a", "b", "r"]);
2083 }
2084
2085 #[tokio::test]
2086 async fn test_execute_basic_projection_and_filter() {
2087 use arrow_array::{Int64Array, RecordBatch, StringArray};
2088 use arrow_schema::{DataType, Field, Schema};
2089 use std::sync::Arc;
2090
2091 let schema = Arc::new(Schema::new(vec![
2093 Field::new("name", DataType::Utf8, true),
2094 Field::new("age", DataType::Int64, true),
2095 ]));
2096 let batch = RecordBatch::try_new(
2097 schema,
2098 vec![
2099 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2100 Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2101 ],
2102 )
2103 .unwrap();
2104
2105 let cfg = GraphConfig::builder()
2106 .node_table("Person", "id")
2107 .build()
2108 .unwrap();
2109
2110 let q = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2111 .unwrap()
2112 .with_config(cfg);
2113
2114 let mut data = HashMap::new();
2115 data.insert("Person".to_string(), batch);
2116
2117 let out = q.execute(data, None).await.unwrap();
2118 assert_eq!(out.num_rows(), 2);
2119 let names = out
2120 .column(0)
2121 .as_any()
2122 .downcast_ref::<StringArray>()
2123 .unwrap();
2124 let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2125 let result: Vec<(String, i64)> = (0..out.num_rows())
2127 .map(|i| (names.value(i).to_string(), ages.value(i)))
2128 .collect();
2129 assert!(result.contains(&("Bob".to_string(), 34)));
2130 assert!(result.contains(&("David".to_string(), 42)));
2131 }
2132
2133 #[tokio::test]
2134 async fn test_execute_single_hop_path_join_projection() {
2135 use arrow_array::{Int64Array, RecordBatch, StringArray};
2136 use arrow_schema::{DataType, Field, Schema};
2137 use std::sync::Arc;
2138
2139 let person_schema = Arc::new(Schema::new(vec![
2141 Field::new("id", DataType::Int64, false),
2142 Field::new("name", DataType::Utf8, true),
2143 Field::new("age", DataType::Int64, true),
2144 ]));
2145 let people = RecordBatch::try_new(
2146 person_schema,
2147 vec![
2148 Arc::new(Int64Array::from(vec![1, 2, 3])),
2149 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2150 Arc::new(Int64Array::from(vec![28, 34, 29])),
2151 ],
2152 )
2153 .unwrap();
2154
2155 let rel_schema = Arc::new(Schema::new(vec![
2157 Field::new("src_person_id", DataType::Int64, false),
2158 Field::new("dst_person_id", DataType::Int64, false),
2159 ]));
2160 let knows = RecordBatch::try_new(
2161 rel_schema,
2162 vec![
2163 Arc::new(Int64Array::from(vec![1, 2])), Arc::new(Int64Array::from(vec![2, 3])),
2165 ],
2166 )
2167 .unwrap();
2168
2169 let cfg = GraphConfig::builder()
2171 .node_table("Person", "id")
2172 .rel_table(
2173 "KNOWS",
2174 "Person",
2175 "Person",
2176 "src_person_id",
2177 "dst_person_id",
2178 )
2179 .build()
2180 .unwrap();
2181
2182 let q = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name")
2184 .unwrap()
2185 .with_config(cfg);
2186
2187 let mut data = HashMap::new();
2188 data.insert("Person".to_string(), people);
2190 data.insert("KNOWS".to_string(), knows);
2191
2192 let out = q.execute(data, None).await.unwrap();
2193 let names = out
2195 .column(0)
2196 .as_any()
2197 .downcast_ref::<StringArray>()
2198 .unwrap();
2199 let got: Vec<String> = (0..out.num_rows())
2200 .map(|i| names.value(i).to_string())
2201 .collect();
2202 assert_eq!(got.len(), 2);
2203 assert!(got.contains(&"Bob".to_string()));
2204 assert!(got.contains(&"Carol".to_string()));
2205 }
2206
2207 #[tokio::test]
2208 async fn test_execute_order_by_asc() {
2209 use arrow_array::{Int64Array, RecordBatch, StringArray};
2210 use arrow_schema::{DataType, Field, Schema};
2211 use std::sync::Arc;
2212
2213 let schema = Arc::new(Schema::new(vec![
2215 Field::new("name", DataType::Utf8, true),
2216 Field::new("age", DataType::Int64, true),
2217 ]));
2218 let batch = RecordBatch::try_new(
2219 schema,
2220 vec![
2221 Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2222 Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2223 ],
2224 )
2225 .unwrap();
2226
2227 let cfg = GraphConfig::builder()
2228 .node_table("Person", "id")
2229 .build()
2230 .unwrap();
2231
2232 let q = CypherQuery::new("MATCH (p:Person) RETURN p.name, p.age ORDER BY p.age ASC")
2234 .unwrap()
2235 .with_config(cfg);
2236
2237 let mut data = HashMap::new();
2238 data.insert("Person".to_string(), batch);
2239
2240 let out = q.execute(data, None).await.unwrap();
2241 let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2242 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2243 assert_eq!(collected, vec![28, 29, 34, 42]);
2244 }
2245
2246 #[tokio::test]
2247 async fn test_execute_order_by_desc_with_skip_limit() {
2248 use arrow_array::{Int64Array, RecordBatch, StringArray};
2249 use arrow_schema::{DataType, Field, Schema};
2250 use std::sync::Arc;
2251
2252 let schema = Arc::new(Schema::new(vec![
2253 Field::new("name", DataType::Utf8, true),
2254 Field::new("age", DataType::Int64, true),
2255 ]));
2256 let batch = RecordBatch::try_new(
2257 schema,
2258 vec![
2259 Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2260 Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2261 ],
2262 )
2263 .unwrap();
2264
2265 let cfg = GraphConfig::builder()
2266 .node_table("Person", "id")
2267 .build()
2268 .unwrap();
2269
2270 let q =
2272 CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age DESC SKIP 1 LIMIT 2")
2273 .unwrap()
2274 .with_config(cfg);
2275
2276 let mut data = HashMap::new();
2277 data.insert("Person".to_string(), batch);
2278
2279 let out = q.execute(data, None).await.unwrap();
2280 assert_eq!(out.num_rows(), 2);
2281 let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2282 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2283 assert_eq!(collected, vec![34, 29]);
2284 }
2285
2286 #[tokio::test]
2287 async fn test_execute_skip_without_limit() {
2288 use arrow_array::{Int64Array, RecordBatch};
2289 use arrow_schema::{DataType, Field, Schema};
2290 use std::sync::Arc;
2291
2292 let schema = Arc::new(Schema::new(vec![Field::new("age", DataType::Int64, true)]));
2293 let batch = RecordBatch::try_new(
2294 schema,
2295 vec![Arc::new(Int64Array::from(vec![10, 20, 30, 40]))],
2296 )
2297 .unwrap();
2298
2299 let cfg = GraphConfig::builder()
2300 .node_table("Person", "id")
2301 .build()
2302 .unwrap();
2303
2304 let q = CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age ASC SKIP 2")
2305 .unwrap()
2306 .with_config(cfg);
2307
2308 let mut data = HashMap::new();
2309 data.insert("Person".to_string(), batch);
2310
2311 let out = q.execute(data, None).await.unwrap();
2312 assert_eq!(out.num_rows(), 2);
2313 let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2314 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2315 assert_eq!(collected, vec![30, 40]);
2316 }
2317
2318 #[tokio::test]
2319 async fn test_execute_datafusion_simple_scan() {
2320 use arrow_array::{Int64Array, RecordBatch, StringArray};
2321 use arrow_schema::{DataType, Field, Schema};
2322 use std::sync::Arc;
2323
2324 let schema = Arc::new(Schema::new(vec![
2326 Field::new("id", DataType::Int64, false),
2327 Field::new("name", DataType::Utf8, false),
2328 ]));
2329
2330 let batch = RecordBatch::try_new(
2331 schema,
2332 vec![
2333 Arc::new(Int64Array::from(vec![1, 2])),
2334 Arc::new(StringArray::from(vec!["Alice", "Bob"])),
2335 ],
2336 )
2337 .unwrap();
2338
2339 let cfg = GraphConfig::builder()
2340 .node_table("Person", "id")
2341 .build()
2342 .unwrap();
2343
2344 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2346 .unwrap()
2347 .with_config(cfg);
2348
2349 let mut datasets = HashMap::new();
2350 datasets.insert("Person".to_string(), batch);
2351
2352 let result = query.execute_datafusion(datasets).await.unwrap();
2354
2355 assert_eq!(
2357 result.num_rows(),
2358 2,
2359 "Should return all 2 rows without filtering"
2360 );
2361 assert_eq!(result.num_columns(), 1, "Should return 1 column (name)");
2362
2363 let names = result
2365 .column(0)
2366 .as_any()
2367 .downcast_ref::<StringArray>()
2368 .unwrap();
2369 let name_set: std::collections::HashSet<String> = (0..result.num_rows())
2370 .map(|i| names.value(i).to_string())
2371 .collect();
2372 let expected: std::collections::HashSet<String> =
2373 ["Alice", "Bob"].iter().map(|s| s.to_string()).collect();
2374 assert_eq!(name_set, expected, "Should return Alice and Bob");
2375 }
2376
2377 #[tokio::test]
2378 async fn test_aggregate_predicates_on_empty_input() {
2379 use arrow_array::{BooleanArray, Int64Array};
2380 use datafusion::execution::context::SessionContext;
2381
2382 let ctx = SessionContext::new();
2383 ctx.register_batch(
2384 "Person",
2385 RecordBatch::try_from_iter(vec![("id", Arc::new(Int64Array::from(vec![1, 2])) as _)])
2386 .unwrap(),
2387 )
2388 .unwrap();
2389 let rules_before: Vec<_> = ctx
2390 .state()
2391 .physical_optimizers()
2392 .iter()
2393 .map(|rule| rule.name().to_owned())
2394 .collect();
2395 let config = GraphConfig::builder()
2396 .node_table("Person", "id")
2397 .build()
2398 .unwrap();
2399 for (predicate, expected) in [("p.id = 99", false), ("p.id = 1", true)] {
2400 let query = CypherQuery::new(&format!(
2401 "MATCH (p:Person) WHERE {predicate} RETURN count(DISTINCT p.id) AS n, \
2402 count(DISTINCT p.id) > 0 AS present, \
2403 CASE WHEN count(*) > 0 THEN 1 ELSE 0 END AS flag"
2404 ))
2405 .unwrap()
2406 .with_config(config.clone());
2407 let result = query.execute_with_context(ctx.clone()).await.unwrap();
2408 assert_eq!(result.num_rows(), 1);
2409 assert_eq!(
2410 result
2411 .column(0)
2412 .as_any()
2413 .downcast_ref::<Int64Array>()
2414 .unwrap()
2415 .value(0),
2416 i64::from(expected)
2417 );
2418 assert_eq!(
2419 result
2420 .column(1)
2421 .as_any()
2422 .downcast_ref::<BooleanArray>()
2423 .unwrap()
2424 .value(0),
2425 expected
2426 );
2427 assert_eq!(
2428 result
2429 .column(2)
2430 .as_any()
2431 .downcast_ref::<Int64Array>()
2432 .unwrap()
2433 .value(0),
2434 i64::from(expected)
2435 );
2436 }
2437 let rules_after: Vec<_> = ctx
2438 .state()
2439 .physical_optimizers()
2440 .iter()
2441 .map(|rule| rule.name().to_owned())
2442 .collect();
2443 assert_eq!(
2444 rules_before, rules_after,
2445 "caller session must remain unchanged"
2446 );
2447 assert!(rules_after
2448 .iter()
2449 .any(|name| name == "aggregate_statistics"));
2450 }
2451
2452 #[tokio::test]
2453 async fn test_execute_with_context_simple_scan() {
2454 use arrow_array::{Int64Array, RecordBatch, StringArray};
2455 use arrow_schema::{DataType, Field, Schema};
2456 use datafusion::datasource::MemTable;
2457 use datafusion::execution::context::SessionContext;
2458 use std::sync::Arc;
2459
2460 let schema = Arc::new(Schema::new(vec![
2462 Field::new("id", DataType::Int64, false),
2463 Field::new("name", DataType::Utf8, false),
2464 Field::new("age", DataType::Int64, false),
2465 ]));
2466 let batch = RecordBatch::try_new(
2467 schema.clone(),
2468 vec![
2469 Arc::new(Int64Array::from(vec![1, 2, 3])),
2470 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2471 Arc::new(Int64Array::from(vec![28, 34, 29])),
2472 ],
2473 )
2474 .unwrap();
2475
2476 let mem_table =
2478 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2479 let ctx = SessionContext::new();
2480 ctx.register_table("Person", mem_table).unwrap();
2481
2482 let cfg = GraphConfig::builder()
2484 .node_table("Person", "id")
2485 .build()
2486 .unwrap();
2487
2488 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2489 .unwrap()
2490 .with_config(cfg);
2491
2492 let result = query.execute_with_context(ctx).await.unwrap();
2494
2495 assert_eq!(result.num_rows(), 3);
2497 assert_eq!(result.num_columns(), 1);
2498
2499 let names = result
2500 .column(0)
2501 .as_any()
2502 .downcast_ref::<StringArray>()
2503 .unwrap();
2504 assert_eq!(names.value(0), "Alice");
2505 assert_eq!(names.value(1), "Bob");
2506 assert_eq!(names.value(2), "Carol");
2507 }
2508
2509 #[tokio::test]
2510 async fn test_execute_with_context_with_filter() {
2511 use arrow_array::{Int64Array, RecordBatch, StringArray};
2512 use arrow_schema::{DataType, Field, Schema};
2513 use datafusion::datasource::MemTable;
2514 use datafusion::execution::context::SessionContext;
2515 use std::sync::Arc;
2516
2517 let schema = Arc::new(Schema::new(vec![
2519 Field::new("id", DataType::Int64, false),
2520 Field::new("name", DataType::Utf8, false),
2521 Field::new("age", DataType::Int64, false),
2522 ]));
2523 let batch = RecordBatch::try_new(
2524 schema.clone(),
2525 vec![
2526 Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2527 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2528 Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2529 ],
2530 )
2531 .unwrap();
2532
2533 let mem_table =
2535 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2536 let ctx = SessionContext::new();
2537 ctx.register_table("Person", mem_table).unwrap();
2538
2539 let cfg = GraphConfig::builder()
2541 .node_table("Person", "id")
2542 .build()
2543 .unwrap();
2544
2545 let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2546 .unwrap()
2547 .with_config(cfg);
2548
2549 let result = query.execute_with_context(ctx).await.unwrap();
2551
2552 assert_eq!(result.num_rows(), 2);
2554 assert_eq!(result.num_columns(), 2);
2555
2556 let names = result
2557 .column(0)
2558 .as_any()
2559 .downcast_ref::<StringArray>()
2560 .unwrap();
2561 let ages = result
2562 .column(1)
2563 .as_any()
2564 .downcast_ref::<Int64Array>()
2565 .unwrap();
2566
2567 let results: Vec<(String, i64)> = (0..result.num_rows())
2568 .map(|i| (names.value(i).to_string(), ages.value(i)))
2569 .collect();
2570
2571 assert!(results.contains(&("Bob".to_string(), 34)));
2572 assert!(results.contains(&("David".to_string(), 42)));
2573 }
2574
2575 #[tokio::test]
2576 async fn test_execute_with_context_relationship_traversal() {
2577 use arrow_array::{Int64Array, RecordBatch, StringArray};
2578 use arrow_schema::{DataType, Field, Schema};
2579 use datafusion::datasource::MemTable;
2580 use datafusion::execution::context::SessionContext;
2581 use std::sync::Arc;
2582
2583 let person_schema = Arc::new(Schema::new(vec![
2585 Field::new("id", DataType::Int64, false),
2586 Field::new("name", DataType::Utf8, false),
2587 ]));
2588 let person_batch = RecordBatch::try_new(
2589 person_schema.clone(),
2590 vec![
2591 Arc::new(Int64Array::from(vec![1, 2, 3])),
2592 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2593 ],
2594 )
2595 .unwrap();
2596
2597 let knows_schema = Arc::new(Schema::new(vec![
2599 Field::new("src_id", DataType::Int64, false),
2600 Field::new("dst_id", DataType::Int64, false),
2601 Field::new("since", DataType::Int64, false),
2602 ]));
2603 let knows_batch = RecordBatch::try_new(
2604 knows_schema.clone(),
2605 vec![
2606 Arc::new(Int64Array::from(vec![1, 2])),
2607 Arc::new(Int64Array::from(vec![2, 3])),
2608 Arc::new(Int64Array::from(vec![2020, 2021])),
2609 ],
2610 )
2611 .unwrap();
2612
2613 let person_table = Arc::new(
2615 MemTable::try_new(person_schema.clone(), vec![vec![person_batch.clone()]]).unwrap(),
2616 );
2617 let knows_table = Arc::new(
2618 MemTable::try_new(knows_schema.clone(), vec![vec![knows_batch.clone()]]).unwrap(),
2619 );
2620
2621 let ctx = SessionContext::new();
2622 ctx.register_table("Person", person_table).unwrap();
2623 ctx.register_table("KNOWS", knows_table).unwrap();
2624
2625 let cfg = GraphConfig::builder()
2627 .node_table("Person", "id")
2628 .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
2629 .build()
2630 .unwrap();
2631
2632 let query = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name")
2633 .unwrap()
2634 .with_config(cfg);
2635
2636 let result = query.execute_with_context(ctx).await.unwrap();
2638
2639 assert_eq!(result.num_rows(), 2);
2641 assert_eq!(result.num_columns(), 2);
2642
2643 let src_names = result
2644 .column(0)
2645 .as_any()
2646 .downcast_ref::<StringArray>()
2647 .unwrap();
2648 let dst_names = result
2649 .column(1)
2650 .as_any()
2651 .downcast_ref::<StringArray>()
2652 .unwrap();
2653
2654 let relationships: Vec<(String, String)> = (0..result.num_rows())
2655 .map(|i| {
2656 (
2657 src_names.value(i).to_string(),
2658 dst_names.value(i).to_string(),
2659 )
2660 })
2661 .collect();
2662
2663 assert!(relationships.contains(&("Alice".to_string(), "Bob".to_string())));
2664 assert!(relationships.contains(&("Bob".to_string(), "Carol".to_string())));
2665 }
2666
2667 #[tokio::test]
2668 async fn test_execute_with_context_order_by_limit() {
2669 use arrow_array::{Int64Array, RecordBatch, StringArray};
2670 use arrow_schema::{DataType, Field, Schema};
2671 use datafusion::datasource::MemTable;
2672 use datafusion::execution::context::SessionContext;
2673 use std::sync::Arc;
2674
2675 let schema = Arc::new(Schema::new(vec![
2677 Field::new("id", DataType::Int64, false),
2678 Field::new("name", DataType::Utf8, false),
2679 Field::new("score", DataType::Int64, false),
2680 ]));
2681 let batch = RecordBatch::try_new(
2682 schema.clone(),
2683 vec![
2684 Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2685 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2686 Arc::new(Int64Array::from(vec![85, 92, 78, 95])),
2687 ],
2688 )
2689 .unwrap();
2690
2691 let mem_table =
2693 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2694 let ctx = SessionContext::new();
2695 ctx.register_table("Student", mem_table).unwrap();
2696
2697 let cfg = GraphConfig::builder()
2699 .node_table("Student", "id")
2700 .build()
2701 .unwrap();
2702
2703 let query = CypherQuery::new(
2704 "MATCH (s:Student) RETURN s.name, s.score ORDER BY s.score DESC LIMIT 2",
2705 )
2706 .unwrap()
2707 .with_config(cfg);
2708
2709 let result = query.execute_with_context(ctx).await.unwrap();
2711
2712 assert_eq!(result.num_rows(), 2);
2714 assert_eq!(result.num_columns(), 2);
2715
2716 let names = result
2717 .column(0)
2718 .as_any()
2719 .downcast_ref::<StringArray>()
2720 .unwrap();
2721 let scores = result
2722 .column(1)
2723 .as_any()
2724 .downcast_ref::<Int64Array>()
2725 .unwrap();
2726
2727 assert_eq!(names.value(0), "David");
2729 assert_eq!(scores.value(0), 95);
2730
2731 assert_eq!(names.value(1), "Bob");
2733 assert_eq!(scores.value(1), 92);
2734 }
2735
2736 #[tokio::test]
2737 async fn test_to_sql() {
2738 use arrow_array::RecordBatch;
2739 use arrow_schema::{DataType, Field, Schema};
2740 use std::collections::HashMap;
2741 use std::sync::Arc;
2742
2743 let schema = Arc::new(Schema::new(vec![
2744 Field::new("id", DataType::Int64, false),
2745 Field::new("name", DataType::Utf8, false),
2746 ]));
2747 let batch = RecordBatch::new_empty(schema.clone());
2748
2749 let mut datasets = HashMap::new();
2750 datasets.insert("Person".to_string(), batch);
2751
2752 let cfg = GraphConfig::builder()
2753 .node_table("Person", "id")
2754 .build()
2755 .unwrap();
2756
2757 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2758 .unwrap()
2759 .with_config(cfg);
2760
2761 let sql = query.to_sql(datasets, None).await.unwrap();
2762 println!("Generated SQL: {}", sql);
2763
2764 assert!(sql.contains("SELECT"));
2765 assert!(sql.to_lowercase().contains("from person"));
2766 assert!(sql.contains("p.name"));
2769 }
2770
2771 #[tokio::test]
2772 async fn test_to_sql_rejects_runtime_only_cypher_function() {
2773 let batch = RecordBatch::try_from_iter(vec![
2774 (
2775 "id",
2776 Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2777 ),
2778 (
2779 "number",
2780 Arc::new(arrow_array::Float64Array::from(vec![-1.5])) as arrow_array::ArrayRef,
2781 ),
2782 ])
2783 .unwrap();
2784 let query = CypherQuery::new("MATCH (p:Person) RETURN round(p.number) AS value")
2785 .unwrap()
2786 .with_config(
2787 GraphConfig::builder()
2788 .node_table("Person", "id")
2789 .build()
2790 .unwrap(),
2791 );
2792 let error = query
2793 .to_sql(HashMap::from([("Person".to_string(), batch)]), None)
2794 .await
2795 .unwrap_err();
2796 assert!(matches!(error, GraphError::UnsupportedFeature { .. }));
2797 assert!(error
2798 .to_string()
2799 .contains("SQL export of Cypher function 'round'"));
2800 }
2801
2802 #[tokio::test]
2803 async fn test_to_sql_preserves_constant_null_types() {
2804 let batch = RecordBatch::try_from_iter(vec![(
2805 "id",
2806 Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2807 )])
2808 .unwrap();
2809 let datasets = HashMap::from([("Person".to_string(), batch)]);
2810 let config = GraphConfig::builder()
2811 .node_table("Person", "id")
2812 .build()
2813 .unwrap();
2814 for expression in [
2815 "round(null)",
2816 "abs(null)",
2817 "sqrt(null)",
2818 "floor(null)",
2819 "ceil(null)",
2820 "toUpper(null)",
2821 "null",
2822 ] {
2823 let query = CypherQuery::new(&format!("MATCH (p:Person) RETURN {expression} AS value"))
2824 .unwrap()
2825 .with_config(config.clone());
2826 let cypher = query.execute(datasets.clone(), None).await.unwrap();
2827 let sql_text = query.to_sql(datasets.clone(), None).await.unwrap();
2828 let sql = crate::sql_query::SqlQuery::new(&sql_text)
2829 .execute(datasets.clone())
2830 .await
2831 .unwrap();
2832 assert_eq!(
2833 sql.schema().field(0).data_type(),
2834 cypher.schema().field(0).data_type(),
2835 "{expression}: {sql_text}"
2836 );
2837 assert_eq!(sql.column(0).logical_null_count(), 1, "{expression}");
2838 }
2839 }
2840
2841 async fn write_lance_dataset(path: &std::path::Path, batch: arrow_array::RecordBatch) {
2842 use arrow_array::{RecordBatch, RecordBatchIterator};
2843 use lance::dataset::{Dataset, WriteParams};
2844
2845 let schema = batch.schema();
2846 let batches: Vec<std::result::Result<RecordBatch, arrow::error::ArrowError>> =
2847 vec![std::result::Result::Ok(batch)];
2848 let reader = RecordBatchIterator::new(batches.into_iter(), schema);
2849
2850 Dataset::write(reader, path.to_str().unwrap(), None::<WriteParams>)
2851 .await
2852 .expect("write lance dataset");
2853 }
2854
2855 fn build_people_batch() -> arrow_array::RecordBatch {
2856 use arrow_array::{ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
2857 use arrow_schema::{DataType, Field, Schema};
2858 use std::sync::Arc;
2859
2860 let schema = Arc::new(Schema::new(vec![
2861 Field::new("person_id", DataType::Int64, false),
2862 Field::new("name", DataType::Utf8, false),
2863 Field::new("age", DataType::Int32, false),
2864 ]));
2865
2866 let columns: Vec<ArrayRef> = vec![
2867 Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef,
2868 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])) as ArrayRef,
2869 Arc::new(Int32Array::from(vec![28, 34, 29, 42])) as ArrayRef,
2870 ];
2871
2872 RecordBatch::try_new(schema, columns).expect("valid person batch")
2873 }
2874
2875 fn build_friendship_batch() -> arrow_array::RecordBatch {
2876 use arrow_array::{ArrayRef, Int64Array, RecordBatch};
2877 use arrow_schema::{DataType, Field, Schema};
2878 use std::sync::Arc;
2879
2880 let schema = Arc::new(Schema::new(vec![
2881 Field::new("person1_id", DataType::Int64, false),
2882 Field::new("person2_id", DataType::Int64, false),
2883 ]));
2884
2885 let columns: Vec<ArrayRef> = vec![
2886 Arc::new(Int64Array::from(vec![1, 1, 2, 3])) as ArrayRef,
2887 Arc::new(Int64Array::from(vec![2, 3, 4, 4])) as ArrayRef,
2888 ];
2889
2890 RecordBatch::try_new(schema, columns).expect("valid friendship batch")
2891 }
2892
2893 #[tokio::test]
2894 async fn executes_against_directory_namespace() {
2895 use arrow_array::StringArray;
2896 use tempfile::tempdir;
2897
2898 let tmp_dir = tempdir().unwrap();
2899 write_lance_dataset(&tmp_dir.path().join("Person.lance"), build_people_batch()).await;
2900 write_lance_dataset(
2901 &tmp_dir.path().join("FRIEND_OF.lance"),
2902 build_friendship_batch(),
2903 )
2904 .await;
2905
2906 let config = GraphConfig::builder()
2907 .node_table("Person", "person_id")
2908 .rel_table("FRIEND_OF", "Person", "Person", "person1_id", "person2_id")
2909 .build()
2910 .expect("valid graph config");
2911
2912 let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name")
2913 .expect("query parses")
2914 .with_config(config);
2915
2916 let namespace = DirNamespace::new(tmp_dir.path().to_string_lossy().into_owned());
2917
2918 let result = query
2919 .execute_with_namespace(namespace.clone(), None)
2920 .await
2921 .expect("namespace execution succeeds");
2922
2923 use arrow_array::Array;
2924 let names = result
2925 .column(0)
2926 .as_any()
2927 .downcast_ref::<StringArray>()
2928 .expect("string column");
2929
2930 let mut values: Vec<String> = (0..names.len())
2931 .map(|i| names.value(i).to_string())
2932 .collect();
2933 values.sort();
2934 assert_eq!(values, vec!["Bob".to_string(), "David".to_string()]);
2935 }
2936
2937 #[tokio::test]
2938 async fn test_execute_fails_on_semantic_error() {
2939 use arrow_array::RecordBatch;
2940 use arrow_schema::{DataType, Field, Schema};
2941 use std::collections::HashMap;
2942 use std::sync::Arc;
2943
2944 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
2945 let batch = RecordBatch::new_empty(schema);
2946 let mut datasets = HashMap::new();
2947 datasets.insert("Person".to_string(), batch);
2948
2949 let cfg = GraphConfig::builder()
2950 .node_table("Person", "id")
2951 .build()
2952 .unwrap();
2953
2954 let query = CypherQuery::new("MATCH (n:Person) RETURN x.name")
2956 .unwrap()
2957 .with_config(cfg);
2958
2959 let result = query.execute(datasets, None).await;
2960
2961 assert!(result.is_err());
2962 match result {
2963 Err(GraphError::PlanError { message, .. }) => {
2964 assert!(message.contains("Semantic analysis failed"));
2965 assert!(message.contains("Undefined variable: 'x'"));
2966 }
2967 _ => panic!(
2968 "Expected PlanError with semantic failure message, got {:?}",
2969 result
2970 ),
2971 }
2972 }
2973}