1mod deadline;
7
8#[doc(hidden)]
9pub use deadline::with_execution_deadline;
10
11use crate::ast::CypherQuery as CypherAST;
12use crate::ast::ReadingClause;
13use crate::config::GraphConfig;
14use crate::error::{GraphError, Result};
15use crate::logical_plan::LogicalPlanner;
16use crate::parser::parse_cypher_query;
17use crate::spark_dialect::build_spark_dialect;
18use arrow_array::RecordBatch;
19use arrow_schema::{Field, Schema, SchemaRef};
20use datafusion_sql::unparser::dialect::{
21 CustomDialect, DefaultDialect, MySqlDialect, PostgreSqlDialect, SqliteDialect,
22};
23use datafusion_sql::unparser::Unparser;
24use lance_csr_catalog::DirNamespace;
25use lance_namespace::models::DescribeTableRequest;
26use std::collections::{HashMap, HashSet};
27use std::sync::Arc;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SqlDialect {
32 #[default]
34 Default,
35 Spark,
37 PostgreSql,
39 MySql,
41 Sqlite,
43}
44
45pub enum DialectUnparser {
47 Default(DefaultDialect),
48 Spark(Box<CustomDialect>),
49 PostgreSql(PostgreSqlDialect),
50 MySql(MySqlDialect),
51 Sqlite(SqliteDialect),
52}
53
54impl DialectUnparser {
55 pub fn as_unparser(&self) -> Unparser<'_> {
56 match self {
57 DialectUnparser::Default(d) => Unparser::new(d),
58 DialectUnparser::Spark(d) => Unparser::new(d.as_ref()),
59 DialectUnparser::PostgreSql(d) => Unparser::new(d),
60 DialectUnparser::MySql(d) => Unparser::new(d),
61 DialectUnparser::Sqlite(d) => Unparser::new(d),
62 }
63 }
64}
65
66impl SqlDialect {
67 pub fn unparser(&self) -> DialectUnparser {
69 match self {
70 SqlDialect::Default => DialectUnparser::Default(DefaultDialect {}),
71 SqlDialect::Spark => DialectUnparser::Spark(Box::new(build_spark_dialect())),
72 SqlDialect::PostgreSql => DialectUnparser::PostgreSql(PostgreSqlDialect {}),
73 SqlDialect::MySql => DialectUnparser::MySql(MySqlDialect {}),
74 SqlDialect::Sqlite => DialectUnparser::Sqlite(SqliteDialect {}),
75 }
76 }
77}
78
79pub(crate) fn normalize_schema(schema: SchemaRef) -> Result<SchemaRef> {
84 let fields: Vec<_> = schema
85 .fields()
86 .iter()
87 .map(|f| {
88 Arc::new(Field::new(
89 f.name().to_lowercase(),
90 f.data_type().clone(),
91 f.is_nullable(),
92 ))
93 })
94 .collect();
95 Ok(Arc::new(Schema::new(fields)))
96}
97
98pub(crate) fn normalize_record_batch(batch: &RecordBatch) -> Result<RecordBatch> {
103 let normalized_schema = normalize_schema(batch.schema())?;
104 RecordBatch::try_new(normalized_schema, batch.columns().to_vec()).map_err(|e| {
105 GraphError::PlanError {
106 message: format!("Failed to normalize record batch schema: {}", e),
107 location: snafu::Location::new(file!(), line!(), column!()),
108 }
109 })
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum ExecutionStrategy {
115 #[default]
117 DataFusion,
118 LanceNative,
120}
121
122#[derive(Debug, Clone)]
124pub struct CypherQuery {
125 query_text: String,
127 ast: CypherAST,
129 config: Option<GraphConfig>,
131 parameters: HashMap<String, serde_json::Value>,
133 adjacency: Option<crate::csr_expand::AdjacencyCatalog>,
135 fan_out: Option<crate::logical_plan::FanOut>,
137 cached_plan: Option<Arc<CachedPlan>>,
139}
140struct CachedPlan {
141 graph: crate::logical_plan::LogicalOperator,
142 datafusion: datafusion::logical_expr::LogicalPlan,
143 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
144 parameter_types: HashMap<String, arrow_schema::DataType>,
145}
146
147impl std::fmt::Debug for CachedPlan {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("CachedPlan")
150 .field("parameter_types", &self.parameter_types)
151 .finish_non_exhaustive()
152 }
153}
154
155impl CypherQuery {
156 pub fn new(query: &str) -> Result<Self> {
158 let ast = parse_cypher_query(query)?;
159
160 Ok(Self {
161 query_text: query.to_string(),
162 ast,
163 config: None,
164 parameters: HashMap::new(),
165 adjacency: None,
166 fan_out: None,
167 cached_plan: None,
168 })
169 }
170
171 pub fn with_config(mut self, config: GraphConfig) -> Self {
173 self.cached_plan = None;
174 self.config = Some(config);
175 self
176 }
177
178 pub fn with_adjacency(mut self, adjacency: crate::csr_expand::AdjacencyCatalog) -> Self {
184 self.cached_plan = None;
185 self.adjacency = Some(adjacency);
186 self
187 }
188
189 pub fn with_fan_out(mut self, fan_out: crate::logical_plan::FanOut) -> Self {
192 self.cached_plan = None;
193 self.fan_out = Some(fan_out);
194 self
195 }
196
197 pub fn with_parameter<K, V>(mut self, key: K, value: V) -> Self
199 where
200 K: Into<String>,
201 V: Into<serde_json::Value>,
202 {
203 self.parameters
204 .insert(key.into().to_lowercase(), value.into());
205 self
206 }
207
208 pub fn with_parameters(mut self, params: HashMap<String, serde_json::Value>) -> Self {
210 for (k, v) in params {
211 self.parameters.insert(k.to_lowercase(), v);
212 }
213 self
214 }
215
216 pub fn query_text(&self) -> &str {
218 &self.query_text
219 }
220
221 pub fn ast(&self) -> &CypherAST {
223 &self.ast
224 }
225
226 pub fn config(&self) -> Option<&GraphConfig> {
228 self.config.as_ref()
229 }
230
231 pub fn parameters(&self) -> &HashMap<String, serde_json::Value> {
233 &self.parameters
234 }
235
236 #[doc(hidden)]
239 pub fn prepare_cached_plan(
240 &mut self,
241 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
242 ) -> Result<bool> {
243 let mut shape_sensitive = false;
246 self.ast.for_each_query(&mut |query| {
247 shape_sensitive |= query
248 .limit
249 .iter()
250 .chain(query.skip.iter())
251 .chain(
252 query
253 .with_clause
254 .iter()
255 .filter_map(|clause| clause.limit.as_ref()),
256 )
257 .chain(
258 query
259 .further_with_stages
260 .iter()
261 .filter_map(|stage| stage.with_clause.limit.as_ref()),
262 )
263 .any(|count| matches!(count, crate::ast::RowCount::Parameter(_)))
264 || query
265 .all_reading_clauses()
266 .any(|clause| matches!(clause, ReadingClause::Call(_)));
267 });
268 if shape_sensitive
269 || self.scalar_parameters().is_none()
270 || crate::path_value::needs_values(&self.ast, self.require_config()?)
271 {
272 return Ok(false);
274 }
275 let (graph, datafusion) = self.build_logical_plans(catalog.clone(), true)?;
276 self.cached_plan = Some(Arc::new(CachedPlan {
277 graph,
278 datafusion,
279 catalog,
280 parameter_types: self
281 .scalar_parameters()
282 .unwrap()
283 .into_iter()
284 .map(|(name, value)| (name, value.data_type()))
285 .collect(),
286 }));
287 Ok(true)
288 }
289
290 fn scalar_parameters(&self) -> Option<HashMap<String, datafusion::common::ScalarValue>> {
291 use datafusion::common::ScalarValue;
292 self.parameters
293 .iter()
294 .map(|(name, value)| {
295 let value = match value {
296 serde_json::Value::Null => ScalarValue::Null,
297 serde_json::Value::Bool(value) => ScalarValue::Boolean(Some(*value)),
298 serde_json::Value::String(value) => ScalarValue::Utf8(Some(value.clone())),
299 serde_json::Value::Number(value) if value.is_i64() => {
300 ScalarValue::Int64(value.as_i64())
301 }
302 serde_json::Value::Number(value) if value.is_f64() => {
303 ScalarValue::Float64(value.as_f64())
304 }
305 _ => return None,
306 };
307 Some((name.clone(), value))
308 })
309 .collect()
310 }
311
312 fn require_config(&self) -> Result<&GraphConfig> {
314 self.config.as_ref().ok_or_else(|| GraphError::ConfigError {
315 message: "Graph configuration is required for query execution".to_string(),
316 location: snafu::Location::new(file!(), line!(), column!()),
317 })
318 }
319
320 pub async fn execute(
354 &self,
355 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
356 strategy: Option<ExecutionStrategy>,
357 ) -> Result<arrow::record_batch::RecordBatch> {
358 let strategy = strategy.unwrap_or_default();
359 match strategy {
360 ExecutionStrategy::DataFusion => self.execute_datafusion(datasets).await,
361 ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
362 feature: "Lance native execution strategy is not yet implemented".to_string(),
363 location: snafu::Location::new(file!(), line!(), column!()),
364 }),
365 }
366 }
367
368 pub async fn execute_with_namespace(
372 &self,
373 namespace: DirNamespace,
374 strategy: Option<ExecutionStrategy>,
375 ) -> Result<arrow::record_batch::RecordBatch> {
376 self.execute_with_namespace_arc(std::sync::Arc::new(namespace), strategy)
377 .await
378 }
379
380 pub async fn execute_with_namespace_arc(
382 &self,
383 namespace: std::sync::Arc<DirNamespace>,
384 strategy: Option<ExecutionStrategy>,
385 ) -> Result<arrow::record_batch::RecordBatch> {
386 let namespace_trait: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync> =
387 namespace;
388 self.execute_with_namespace_internal(namespace_trait, strategy)
389 .await
390 }
391
392 async fn execute_with_namespace_internal(
393 &self,
394 namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
395 strategy: Option<ExecutionStrategy>,
396 ) -> Result<arrow::record_batch::RecordBatch> {
397 let strategy = strategy.unwrap_or_default();
398 match strategy {
399 ExecutionStrategy::DataFusion => {
400 let (catalog, ctx) = self
401 .build_catalog_and_context_from_namespace(namespace)
402 .await?;
403 self.execute_with_catalog_and_context(std::sync::Arc::new(catalog), ctx)
404 .await
405 }
406 ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
407 feature: "Lance native execution strategy is not yet implemented".to_string(),
408 location: snafu::Location::new(file!(), line!(), column!()),
409 }),
410 }
411 }
412
413 pub async fn explain(
449 &self,
450 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
451 ) -> Result<String> {
452 use std::sync::Arc;
453
454 let (catalog, ctx) = self
456 .build_catalog_and_context_from_datasets(datasets)
457 .await?;
458
459 self.explain_internal(Arc::new(catalog), ctx).await
461 }
462
463 pub async fn to_sql(
487 &self,
488 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
489 dialect: Option<SqlDialect>,
490 ) -> Result<String> {
491 use datafusion::common::tree_node::{Transformed, TreeNode};
492 use datafusion::common::{not_impl_err, DFSchema, ScalarValue};
493 use datafusion::logical_expr::expr_rewriter::NamePreserver;
494 use datafusion::logical_expr::registry::FunctionRegistry;
495 use datafusion::logical_expr::{lit, utils::merge_schema, Expr, LogicalPlan};
496 use std::sync::Arc;
497
498 let dialect = dialect.unwrap_or_default();
499 let _config = self.require_config()?;
500
501 let (catalog, ctx) = self
503 .build_catalog_and_context_from_datasets(datasets)
504 .await?;
505
506 let (_, df_plan) = self.create_logical_plans(Arc::new(catalog))?;
508
509 let optimized_plan = ctx
512 .state()
513 .optimize(&df_plan)
514 .map_err(|e| GraphError::PlanError {
515 message: format!("Failed to optimize plan: {}", e),
516 location: snafu::Location::new(file!(), line!(), column!()),
517 })?;
518
519 let optimized_plan = optimized_plan
521 .transform_up_with_subqueries(|plan| {
522 let schema = if let LogicalPlan::TableScan(scan) = &plan {
523 DFSchema::try_from_qualified_schema(scan.table_name.clone(), &scan.source.schema())?
524 } else {
525 merge_schema(&plan.inputs())
526 };
527 let names = NamePreserver::new(&plan);
528 plan.map_expressions(|expr| {
529 let name = names.save(&expr);
530 let rewritten = expr.transform_up(|expr| {
531 if let Expr::ScalarFunction(function) = &expr {
532 if let Some(native) = crate::datafusion_planner::sql_expr(function, &schema)? {
533 return Ok(Transformed::yes(native));
534 }
535 if !ctx.udf(function.name()).is_ok_and(|registered| registered == function.func) {
536 return not_impl_err!(
537 "SQL export of Cypher function '{}' is unsupported; execute the Cypher query directly",
538 function.name()
539 );
540 }
541 }
542 if let Expr::Literal(value, _) = &expr {
543 let data_type = value.data_type();
544 let type_name = data_type.to_string();
545 if value.is_null()
549 && !data_type.is_null()
550 && dialect == SqlDialect::Default
551 && type_name.parse::<arrow_schema::DataType>().is_ok_and(|parsed| parsed == data_type)
552 {
553 return Ok(Transformed::yes(datafusion::functions::core::arrow_cast().call(vec![
554 lit(ScalarValue::Null), lit(type_name),
555 ])));
556 }
557 }
558 Ok(Transformed::no(expr))
559 })?;
560 Ok(rewritten.update_data(|expr| name.restore(expr)))
561 })
562 })
563 .map_err(|error| GraphError::UnsupportedFeature {
564 feature: error.to_string(),
565 location: snafu::Location::new(file!(), line!(), column!()),
566 })?
567 .data;
568
569 let dialect_unparser = dialect.unparser();
571 let unparser = dialect_unparser.as_unparser();
572 let sql_ast = unparser
573 .plan_to_sql(&optimized_plan)
574 .map_err(|e| GraphError::PlanError {
575 message: format!("Failed to unparse plan to SQL: {}", e),
576 location: snafu::Location::new(file!(), line!(), column!()),
577 })?;
578
579 Ok(sql_ast.to_string())
580 }
581
582 pub async fn execute_with_context(
631 &self,
632 ctx: datafusion::execution::context::SessionContext,
633 ) -> Result<arrow::record_batch::RecordBatch> {
634 use datafusion::datasource::DefaultTableSource;
635 use lance_csr_catalog::InMemoryCatalog;
636 use std::sync::Arc;
637
638 let config = self.require_config()?;
639
640 let mut catalog = InMemoryCatalog::new();
642
643 for (label, _) in config.iter_node_mappings() {
645 let table_provider =
646 ctx.table_provider(label)
647 .await
648 .map_err(|e| GraphError::ConfigError {
649 message: format!(
650 "Node label '{}' not found in SessionContext: {}",
651 label, e
652 ),
653 location: snafu::Location::new(file!(), line!(), column!()),
654 })?;
655
656 let table_source = Arc::new(DefaultTableSource::new(table_provider));
657 catalog = catalog.with_node_source(label, table_source);
658 }
659
660 for (rel_type, _) in config.iter_relationship_mappings() {
662 let table_provider =
663 ctx.table_provider(rel_type)
664 .await
665 .map_err(|e| GraphError::ConfigError {
666 message: format!(
667 "Relationship type '{}' not found in SessionContext: {}",
668 rel_type, e
669 ),
670 location: snafu::Location::new(file!(), line!(), column!()),
671 })?;
672
673 let table_source = Arc::new(DefaultTableSource::new(table_provider));
674 catalog = catalog.with_relationship_source(rel_type, table_source);
675 }
676
677 self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
679 .await
680 }
681
682 pub async fn execute_with_catalog_and_context(
719 &self,
720 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
721 ctx: datafusion::execution::context::SessionContext,
722 ) -> Result<arrow::record_batch::RecordBatch> {
723 use arrow::compute::concat_batches;
724
725 let df = self
726 .dataframe_with_catalog_and_context(catalog, ctx)
727 .await?;
728
729 let task_context = Arc::new(df.task_ctx());
733 let physical = df
734 .create_physical_plan()
735 .await
736 .map_err(|e| GraphError::ExecutionError {
737 message: format!("Failed to plan query results: {e}"),
738 location: snafu::Location::new(file!(), line!(), column!()),
739 })?;
740 let result_schema = physical.schema();
741 let batches = datafusion::physical_plan::collect(physical, task_context)
742 .await
743 .map_err(|e| GraphError::ExecutionError {
744 message: format!("Failed to collect query results: {e}"),
745 location: snafu::Location::new(file!(), line!(), column!()),
746 })?;
747
748 if batches.is_empty() {
749 return Ok(arrow::record_batch::RecordBatch::new_empty(result_schema));
752 }
753
754 concat_batches(&result_schema, &batches).map_err(|e| GraphError::ExecutionError {
757 message: format!("Failed to concatenate result batches: {}", e),
758 location: snafu::Location::new(file!(), line!(), column!()),
759 })
760 }
761
762 pub async fn execute_stream_with_catalog_and_context(
768 &self,
769 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
770 ctx: datafusion::execution::context::SessionContext,
771 ) -> Result<datafusion::physical_plan::SendableRecordBatchStream> {
772 let df = self
773 .dataframe_with_catalog_and_context(catalog, ctx)
774 .await?;
775 df.execute_stream()
776 .await
777 .map_err(|e| GraphError::ExecutionError {
778 message: format!("Failed to start query stream: {}", e),
779 location: snafu::Location::new(file!(), line!(), column!()),
780 })
781 }
782
783 pub async fn explain_with_catalog_and_context(
785 &self,
786 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
787 ctx: datafusion::execution::context::SessionContext,
788 ) -> Result<String> {
789 self.explain_internal(catalog, ctx).await
790 }
791
792 async fn dataframe_with_catalog_and_context(
794 &self,
795 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
796 ctx: datafusion::execution::context::SessionContext,
797 ) -> Result<datafusion::dataframe::DataFrame> {
798 let (_logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
799 let ctx = deadline::context(ctx);
800 ctx.execute_logical_plan(df_logical_plan)
801 .await
802 .map_err(|e| GraphError::ExecutionError {
803 message: format!("Failed to execute DataFusion plan: {}", e),
804 location: snafu::Location::new(file!(), line!(), column!()),
805 })
806 }
807
808 async fn execute_datafusion(
824 &self,
825 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
826 ) -> Result<arrow::record_batch::RecordBatch> {
827 use std::sync::Arc;
828
829 let (catalog, ctx) = self
831 .build_catalog_and_context_from_datasets(datasets)
832 .await?;
833
834 self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
836 .await
837 }
838
839 async fn build_catalog_and_context_from_datasets(
841 &self,
842 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
843 ) -> Result<(
844 lance_csr_catalog::InMemoryCatalog,
845 datafusion::execution::context::SessionContext,
846 )> {
847 use datafusion::datasource::{DefaultTableSource, MemTable};
848 use datafusion::execution::context::SessionContext;
849 use lance_csr_catalog::InMemoryCatalog;
850 use std::sync::Arc;
851
852 if datasets.is_empty() {
853 return Err(GraphError::ConfigError {
854 message: "No input datasets provided".to_string(),
855 location: snafu::Location::new(file!(), line!(), column!()),
856 });
857 }
858
859 let ctx = SessionContext::new_with_state(
861 datafusion::execution::session_state::SessionStateBuilder::new()
862 .with_default_features()
863 .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
864 .build(),
865 );
866 let mut catalog = InMemoryCatalog::new();
867
868 for (name, batch) in &datasets {
870 let normalized_batch = normalize_record_batch(batch)?;
872
873 let mem_table = Arc::new(
874 MemTable::try_new(
875 normalized_batch.schema(),
876 vec![vec![normalized_batch.clone()]],
877 )
878 .map_err(|e| GraphError::PlanError {
879 message: format!("Failed to create MemTable for {}: {}", name, e),
880 location: snafu::Location::new(file!(), line!(), column!()),
881 })?,
882 );
883
884 let normalized_name = name.to_lowercase();
886
887 ctx.register_table(&normalized_name, mem_table.clone())
889 .map_err(|e| GraphError::PlanError {
890 message: format!("Failed to register table {}: {}", name, e),
891 location: snafu::Location::new(file!(), line!(), column!()),
892 })?;
893
894 let table_source = Arc::new(DefaultTableSource::new(mem_table));
895
896 catalog = catalog
899 .with_node_source(name, table_source.clone())
900 .with_relationship_source(name, table_source);
901 }
902
903 Ok((catalog, ctx))
904 }
905
906 async fn build_catalog_and_context_from_namespace(
908 &self,
909 namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
910 ) -> Result<(
911 lance_csr_catalog::InMemoryCatalog,
912 datafusion::execution::context::SessionContext,
913 )> {
914 use datafusion::datasource::{DefaultTableSource, TableProvider};
915 use datafusion::execution::context::SessionContext;
916 use lance::datafusion::LanceTableProvider;
917 use lance_csr_catalog::InMemoryCatalog;
918 use std::sync::Arc;
919
920 let config = self.require_config()?;
921
922 let mut required_tables: HashSet<String> = HashSet::new();
923 required_tables.extend(
926 config
927 .iter_node_mappings()
928 .map(|(_, mapping)| mapping.label.clone()),
929 );
930 required_tables.extend(
931 config
932 .iter_relationship_mappings()
933 .map(|(_, mapping)| mapping.relationship_type.clone()),
934 );
935
936 if required_tables.is_empty() {
937 return Err(GraphError::ConfigError {
938 message:
939 "Graph configuration does not reference any node labels or relationship types"
940 .to_string(),
941 location: snafu::Location::new(file!(), line!(), column!()),
942 });
943 }
944
945 let ctx = SessionContext::new_with_state(
946 datafusion::execution::session_state::SessionStateBuilder::new()
947 .with_default_features()
948 .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
949 .build(),
950 );
951 let mut catalog = InMemoryCatalog::new();
952 let mut providers: HashMap<String, Arc<dyn TableProvider>> = HashMap::new();
953
954 for table_name in required_tables {
955 let mut request = DescribeTableRequest::new();
956 request.id = Some(vec![table_name.clone()]);
957
958 let response =
959 namespace
960 .describe_table(request)
961 .await
962 .map_err(|e| GraphError::ConfigError {
963 message: format!(
964 "Namespace failed to resolve table '{}': {}",
965 table_name, e
966 ),
967 location: snafu::Location::new(file!(), line!(), column!()),
968 })?;
969
970 let location = response.location.ok_or_else(|| GraphError::ConfigError {
971 message: format!(
972 "Namespace did not provide a location for table '{}'",
973 table_name
974 ),
975 location: snafu::Location::new(file!(), line!(), column!()),
976 })?;
977
978 let dataset = lance::dataset::Dataset::open(&location)
979 .await
980 .map_err(|e| GraphError::ConfigError {
981 message: format!("Failed to open dataset for table '{}': {}", table_name, e),
982 location: snafu::Location::new(file!(), line!(), column!()),
983 })?;
984
985 let dataset = Arc::new(dataset);
986 let provider: Arc<dyn TableProvider> =
987 Arc::new(LanceTableProvider::new(dataset.clone(), true, true));
988
989 let normalized_table_name = table_name.to_lowercase();
991 ctx.register_table(&normalized_table_name, provider.clone())
992 .map_err(|e| GraphError::PlanError {
993 message: format!(
994 "Failed to register table '{}' in SessionContext: {}",
995 table_name, e
996 ),
997 location: snafu::Location::new(file!(), line!(), column!()),
998 })?;
999
1000 providers.insert(normalized_table_name.clone(), provider);
1002 }
1003
1004 for (label, _) in config.iter_node_mappings() {
1005 let provider = providers
1006 .get(label)
1007 .ok_or_else(|| GraphError::ConfigError {
1008 message: format!(
1009 "Namespace did not resolve dataset for node label '{}'",
1010 label
1011 ),
1012 location: snafu::Location::new(file!(), line!(), column!()),
1013 })?;
1014
1015 let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1016 catalog = catalog.with_node_source(label, table_source);
1017 }
1018
1019 for (rel_type, _) in config.iter_relationship_mappings() {
1020 let provider = providers
1021 .get(rel_type)
1022 .ok_or_else(|| GraphError::ConfigError {
1023 message: format!(
1024 "Namespace did not resolve dataset for relationship type '{}'",
1025 rel_type
1026 ),
1027 location: snafu::Location::new(file!(), line!(), column!()),
1028 })?;
1029
1030 let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1031 catalog = catalog.with_relationship_source(rel_type, table_source);
1032 }
1033
1034 Ok((catalog, ctx))
1035 }
1036
1037 async fn explain_internal(
1039 &self,
1040 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1041 ctx: datafusion::execution::context::SessionContext,
1042 ) -> Result<String> {
1043 let (logical_plan, df_logical_plan, physical_plan) =
1045 self.create_plans(catalog, &ctx).await?;
1046
1047 self.format_explain_output(&logical_plan, &df_logical_plan, physical_plan.as_ref())
1049 }
1050
1051 fn create_logical_plans(
1056 &self,
1057 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1058 ) -> Result<(
1059 crate::logical_plan::LogicalOperator,
1060 datafusion::logical_expr::LogicalPlan,
1061 )> {
1062 if let Some(template) = &self.cached_plan {
1063 if let Some(parameters) = self.scalar_parameters() {
1064 let same_types = parameters.len() == template.parameter_types.len()
1065 && parameters.iter().all(|(name, value)| {
1066 template.parameter_types.get(name) == Some(&value.data_type())
1067 });
1068 if !Arc::ptr_eq(&catalog, &template.catalog) || !same_types {
1069 return self.build_logical_plans(catalog, false);
1070 }
1071 let plan = template
1072 .datafusion
1073 .clone()
1074 .with_param_values(parameters)
1075 .map_err(|error| GraphError::PlanError {
1076 message: format!("Failed to bind query parameters: {error}"),
1077 location: snafu::Location::new(file!(), line!(), column!()),
1078 })?;
1079 return Ok((template.graph.clone(), plan));
1080 }
1081 }
1082 self.build_logical_plans(catalog, false)
1083 }
1084
1085 fn build_logical_plans(
1086 &self,
1087 catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1088 parameterized: bool,
1089 ) -> Result<(
1090 crate::logical_plan::LogicalOperator,
1091 datafusion::logical_expr::LogicalPlan,
1092 )> {
1093 use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1094 use crate::semantic::SemanticAnalyzer;
1095
1096 let config = self.require_config()?;
1097
1098 let mut analyzer = SemanticAnalyzer::new(config.clone()).with_catalog(catalog.clone());
1100 let semantic = analyzer.analyze(&self.ast, &self.parameters)?;
1101 if !semantic.errors.is_empty() {
1102 return Err(GraphError::PlanError {
1103 message: format!("Semantic analysis failed:\n{}", semantic.errors.join("\n")),
1104 location: snafu::Location::new(file!(), line!(), column!()),
1105 });
1106 }
1107
1108 let mut logical_planner = LogicalPlanner::new(config)
1110 .with_fan_out(self.fan_out.as_ref())
1111 .with_adjacency(self.adjacency.as_ref());
1112 let ast = if parameterized {
1113 &self.ast
1114 } else {
1115 &semantic.ast
1116 };
1117 let logical_plan = logical_planner.plan(ast)?;
1118
1119 let mut df_planner = DataFusionPlanner::with_catalog(config.clone(), catalog);
1121 df_planner.adjacency = self.adjacency.clone();
1122 if parameterized {
1123 df_planner.parameter_types = self
1124 .scalar_parameters()
1125 .unwrap_or_default()
1126 .into_iter()
1127 .map(|(name, value)| (name, value.data_type()))
1128 .collect();
1129 }
1130 let df_logical_plan = df_planner.plan(&logical_plan)?;
1131
1132 Ok((logical_plan, df_logical_plan))
1133 }
1134
1135 async fn create_plans(
1137 &self,
1138 catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1139 ctx: &datafusion::execution::context::SessionContext,
1140 ) -> Result<(
1141 crate::logical_plan::LogicalOperator,
1142 datafusion::logical_expr::LogicalPlan,
1143 std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>,
1144 )> {
1145 let (logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
1147
1148 let ctx = deadline::context(ctx.clone());
1150 let df = ctx
1151 .execute_logical_plan(df_logical_plan.clone())
1152 .await
1153 .map_err(|e| GraphError::ExecutionError {
1154 message: format!("Failed to execute DataFusion plan: {}", e),
1155 location: snafu::Location::new(file!(), line!(), column!()),
1156 })?;
1157
1158 let physical_plan =
1159 df.create_physical_plan()
1160 .await
1161 .map_err(|e| GraphError::ExecutionError {
1162 message: format!("Failed to create physical plan: {}", e),
1163 location: snafu::Location::new(file!(), line!(), column!()),
1164 })?;
1165
1166 Ok((logical_plan, df_logical_plan, physical_plan))
1167 }
1168
1169 fn format_explain_output(
1171 &self,
1172 logical_plan: &crate::logical_plan::LogicalOperator,
1173 df_logical_plan: &datafusion::logical_expr::LogicalPlan,
1174 physical_plan: &dyn datafusion::physical_plan::ExecutionPlan,
1175 ) -> Result<String> {
1176 let mut output = String::new();
1178
1179 output.push_str("Cypher Query:\n");
1181 output.push_str(&format!(" {}\n\n", self.query_text));
1182
1183 let mut rows = vec![];
1185
1186 let graph_plan_str = format!("{:#?}", logical_plan);
1188 rows.push(("graph_logical_plan", graph_plan_str));
1189
1190 let df_logical_str = format!("{}", df_logical_plan.display_indent());
1192 rows.push(("logical_plan", df_logical_str));
1193
1194 let df_physical_str = format!(
1196 "{}",
1197 datafusion::physical_plan::displayable(physical_plan).indent(true)
1198 );
1199 rows.push(("physical_plan", df_physical_str));
1200
1201 let plan_type_width = rows.iter().map(|(t, _)| t.len()).max().unwrap_or(10);
1203 let plan_width = rows
1204 .iter()
1205 .map(|(_, p)| p.lines().map(|l| l.len()).max().unwrap_or(0))
1206 .max()
1207 .unwrap_or(50);
1208
1209 let separator = format!(
1211 "+{}+{}+",
1212 "-".repeat(plan_type_width + 2),
1213 "-".repeat(plan_width + 2)
1214 );
1215
1216 output.push_str(&separator);
1217 output.push('\n');
1218
1219 output.push_str(&format!(
1221 "| {:<width$} | {:<plan_width$} |\n",
1222 "plan_type",
1223 "plan",
1224 width = plan_type_width,
1225 plan_width = plan_width
1226 ));
1227 output.push_str(&separator);
1228 output.push('\n');
1229
1230 for (plan_type, plan_content) in rows {
1232 let lines: Vec<&str> = plan_content.lines().collect();
1233 if lines.is_empty() {
1234 output.push_str(&format!(
1235 "| {:<width$} | {:<plan_width$} |\n",
1236 plan_type,
1237 "",
1238 width = plan_type_width,
1239 plan_width = plan_width
1240 ));
1241 } else {
1242 output.push_str(&format!(
1244 "| {:<width$} | {:<plan_width$} |\n",
1245 plan_type,
1246 lines[0],
1247 width = plan_type_width,
1248 plan_width = plan_width
1249 ));
1250
1251 for line in &lines[1..] {
1253 output.push_str(&format!(
1254 "| {:<width$} | {:<plan_width$} |\n",
1255 "",
1256 line,
1257 width = plan_type_width,
1258 plan_width = plan_width
1259 ));
1260 }
1261 }
1262 }
1263
1264 output.push_str(&separator);
1265 output.push('\n');
1266
1267 Ok(output)
1268 }
1269
1270 pub fn referenced_node_labels(&self) -> Vec<String> {
1272 let mut labels = Vec::new();
1273
1274 self.ast.for_each_query(&mut |ast| {
1275 for clause in ast.all_reading_clauses() {
1276 if let ReadingClause::Match(match_clause) = clause {
1277 for pattern in &match_clause.patterns {
1278 self.collect_node_labels_from_pattern(pattern, &mut labels);
1279 }
1280 }
1281 }
1282 });
1283
1284 labels.sort();
1285 labels.dedup();
1286 labels
1287 }
1288
1289 pub fn referenced_relationship_types(&self) -> Vec<String> {
1291 let mut types = Vec::new();
1292
1293 self.ast.for_each_query(&mut |ast| {
1294 for clause in ast.all_reading_clauses() {
1295 if let ReadingClause::Match(match_clause) = clause {
1296 for pattern in &match_clause.patterns {
1297 self.collect_relationship_types_from_pattern(pattern, &mut types);
1298 }
1299 }
1300 }
1301 });
1302
1303 types.sort();
1304 types.dedup();
1305 types
1306 }
1307
1308 pub fn variables(&self) -> Vec<String> {
1310 let mut variables = Vec::new();
1311
1312 self.ast.for_each_query(&mut |ast| {
1313 for clause in ast.all_reading_clauses() {
1314 match clause {
1315 ReadingClause::Call(inner) => {
1316 variables.extend(inner.call_imports().unwrap_or_default());
1317 variables.extend(inner.return_clause.items.iter().filter_map(|item| {
1318 item.alias.clone().or_else(|| match &item.expression {
1319 crate::ast::ValueExpression::Variable(name) => Some(name.clone()),
1320 _ => None,
1321 })
1322 }));
1323 }
1324 ReadingClause::Match(match_clause) => {
1325 for pattern in &match_clause.patterns {
1326 self.collect_variables_from_pattern(pattern, &mut variables);
1327 }
1328 }
1329 ReadingClause::Unwind(unwind_clause) => {
1330 variables.push(unwind_clause.alias.clone());
1331 }
1332 }
1333 }
1334 });
1335
1336 variables.sort();
1337 variables.dedup();
1338 variables
1339 }
1340
1341 fn collect_node_labels_from_pattern(
1344 &self,
1345 pattern: &crate::ast::GraphPattern,
1346 labels: &mut Vec<String>,
1347 ) {
1348 match pattern {
1349 crate::ast::GraphPattern::Node(node) => {
1350 labels.extend(node.labels.clone());
1351 }
1352 crate::ast::GraphPattern::Path(path) => {
1353 labels.extend(path.start_node.labels.clone());
1354 for segment in &path.segments {
1355 labels.extend(segment.end_node.labels.clone());
1356 }
1357 }
1358 }
1359 }
1360
1361 fn collect_relationship_types_from_pattern(
1362 &self,
1363 pattern: &crate::ast::GraphPattern,
1364 types: &mut Vec<String>,
1365 ) {
1366 if let crate::ast::GraphPattern::Path(path) = pattern {
1367 for segment in &path.segments {
1368 types.extend(segment.relationship.types.clone());
1369 }
1370 }
1371 }
1372
1373 fn collect_variables_from_pattern(
1374 &self,
1375 pattern: &crate::ast::GraphPattern,
1376 variables: &mut Vec<String>,
1377 ) {
1378 match pattern {
1379 crate::ast::GraphPattern::Node(node) => {
1380 if let Some(var) = &node.variable {
1381 variables.push(var.clone());
1382 }
1383 }
1384 crate::ast::GraphPattern::Path(path) => {
1385 if let Some(var) = &path.start_node.variable {
1386 variables.push(var.clone());
1387 }
1388 for segment in &path.segments {
1389 if let Some(var) = &segment.relationship.variable {
1390 variables.push(var.clone());
1391 }
1392 if let Some(var) = &segment.end_node.variable {
1393 variables.push(var.clone());
1394 }
1395 }
1396 }
1397 }
1398 }
1399}
1400
1401impl CypherQuery {
1402 pub async fn execute_with_vector_rerank(
1431 &self,
1432 datasets: HashMap<String, arrow::record_batch::RecordBatch>,
1433 vector_search: crate::lance_vector_search::VectorSearch,
1434 ) -> Result<arrow::record_batch::RecordBatch> {
1435 let candidates = self.execute(datasets, None).await?;
1437
1438 vector_search.search(&candidates).await
1440 }
1441}
1442
1443#[derive(Debug, Default)]
1445pub struct CypherQueryBuilder {
1446 match_clauses: Vec<crate::ast::MatchClause>,
1447 where_expression: Option<crate::ast::BooleanExpression>,
1448 return_items: Vec<crate::ast::ReturnItem>,
1449 order_by_items: Vec<crate::ast::OrderByItem>,
1450 limit: Option<u64>,
1451 distinct: bool,
1452 skip: Option<u64>,
1453 config: Option<GraphConfig>,
1454 parameters: HashMap<String, serde_json::Value>,
1455}
1456
1457impl CypherQueryBuilder {
1458 pub fn new() -> Self {
1460 Self::default()
1461 }
1462
1463 pub fn match_node(mut self, variable: &str, label: &str) -> Self {
1465 let node = crate::ast::NodePattern {
1466 variable: Some(variable.to_string()),
1467 labels: vec![label.to_string()],
1468 properties: HashMap::new(),
1469 };
1470
1471 let match_clause = crate::ast::MatchClause {
1472 patterns: vec![crate::ast::GraphPattern::Node(node)],
1473 optional: false,
1474 where_clause: None,
1475 };
1476
1477 self.match_clauses.push(match_clause);
1478 self
1479 }
1480
1481 pub fn with_config(mut self, config: GraphConfig) -> Self {
1483 self.config = Some(config);
1484 self
1485 }
1486
1487 pub fn return_property(mut self, variable: &str, property: &str) -> Self {
1489 let prop_ref = crate::ast::PropertyRef::new(variable, property);
1490 let return_item = crate::ast::ReturnItem {
1491 expression: crate::ast::ValueExpression::Property(prop_ref),
1492 alias: None,
1493 };
1494
1495 self.return_items.push(return_item);
1496 self
1497 }
1498
1499 pub fn distinct(mut self, distinct: bool) -> Self {
1501 self.distinct = distinct;
1502 self
1503 }
1504
1505 pub fn limit(mut self, limit: u64) -> Self {
1507 self.limit = Some(limit);
1508 self
1509 }
1510
1511 pub fn skip(mut self, skip: u64) -> Self {
1513 self.skip = Some(skip);
1514 self
1515 }
1516
1517 pub fn build(self) -> Result<CypherQuery> {
1519 if self.match_clauses.is_empty() {
1520 return Err(GraphError::PlanError {
1521 message: "Query must have at least one MATCH clause".to_string(),
1522 location: snafu::Location::new(file!(), line!(), column!()),
1523 });
1524 }
1525
1526 if self.return_items.is_empty() {
1527 return Err(GraphError::PlanError {
1528 message: "Query must have at least one RETURN item".to_string(),
1529 location: snafu::Location::new(file!(), line!(), column!()),
1530 });
1531 }
1532
1533 let ast = crate::ast::CypherQuery {
1534 reading_clauses: self
1535 .match_clauses
1536 .into_iter()
1537 .map(crate::ast::ReadingClause::Match)
1538 .collect(),
1539 where_clause: self
1540 .where_expression
1541 .map(|expr| crate::ast::WhereClause { expression: expr }),
1542 with_clause: None, post_with_reading_clauses: vec![],
1544 post_with_where_clause: None,
1545 further_with_stages: vec![],
1546 union_branches: Vec::new(),
1547 return_clause: crate::ast::ReturnClause {
1548 distinct: self.distinct,
1549 items: self.return_items,
1550 },
1551 order_by: if self.order_by_items.is_empty() {
1552 None
1553 } else {
1554 Some(crate::ast::OrderByClause {
1555 items: self.order_by_items,
1556 })
1557 },
1558 limit: self.limit.map(crate::ast::RowCount::Literal),
1559 skip: self.skip.map(crate::ast::RowCount::Literal),
1560 };
1561
1562 let query_text = "MATCH ... RETURN ...".to_string(); let query = CypherQuery {
1566 query_text,
1567 ast,
1568 config: self.config,
1569 parameters: self.parameters,
1570 adjacency: None,
1571 fan_out: None,
1572 cached_plan: None,
1573 };
1574
1575 Ok(query)
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582 use crate::config::GraphConfig;
1583
1584 #[tokio::test]
1585 async fn union_distinct_all_and_schema_validation_execute() {
1586 use arrow_array::{Int64Array, RecordBatch};
1587 use arrow_schema::{DataType, Field, Schema};
1588 use std::sync::Arc;
1589 let batch = RecordBatch::try_new(
1590 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1591 vec![Arc::new(Int64Array::from(vec![1, 2, 2]))],
1592 )
1593 .unwrap();
1594 let config = GraphConfig::builder()
1595 .node_table("Person", "id")
1596 .build()
1597 .unwrap();
1598 for (query, rows) in [
1599 ("MATCH (p:Person) RETURN p.id AS n UNION RETURN 2 AS n", 2),
1600 (
1601 "MATCH (p:Person) RETURN p.id AS n UNION ALL RETURN 2 AS n",
1602 4,
1603 ),
1604 ("RETURN NULL AS n UNION RETURN NULL AS n", 1),
1605 ] {
1606 let result = CypherQuery::new(query)
1607 .unwrap()
1608 .with_config(config.clone())
1609 .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1610 .await
1611 .unwrap();
1612 assert_eq!(result.num_rows(), rows, "{query}");
1613 }
1614 for query in [
1615 "RETURN 1 AS x UNION RETURN 2 AS y",
1616 "RETURN 1 AS x UNION RETURN 'a' AS x",
1617 "RETURN 1 AS n UNION ALL RETURN 1 AS n UNION RETURN 2 AS n",
1618 ] {
1619 assert!(CypherQuery::new(query)
1620 .unwrap()
1621 .with_config(config.clone())
1622 .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1623 .await
1624 .is_err());
1625 }
1626 }
1627
1628 #[tokio::test]
1629 async fn cartesian_stream_yields_and_drops_before_full_collection() {
1630 use arrow_array::{ArrayRef, Int64Array};
1631 use datafusion::datasource::{DefaultTableSource, MemTable};
1632 use datafusion::execution::context::{SessionConfig, SessionContext};
1633 use futures::TryStreamExt;
1634 use lance_csr_catalog::InMemoryCatalog;
1635 use std::sync::atomic::{AtomicUsize, Ordering};
1636
1637 let batch = RecordBatch::try_from_iter([(
1638 "id",
1639 Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1640 )])
1641 .unwrap();
1642 let table = Arc::new(MemTable::try_new(batch.schema(), vec![vec![batch]]).unwrap());
1643 let catalog = Arc::new(
1644 InMemoryCatalog::new()
1645 .with_node_source("Person", Arc::new(DefaultTableSource::new(table))),
1646 );
1647 let ctx = SessionContext::new_with_config(
1648 SessionConfig::new()
1649 .with_target_partitions(1)
1650 .with_round_robin_repartition(false),
1651 );
1652 let config = GraphConfig::builder()
1653 .node_table("Person", "id")
1654 .build()
1655 .unwrap();
1656 let query = CypherQuery::new(
1657 "MATCH (a:Person), (b:Person) RETURN a.id AS left_id, b.id AS right_id",
1658 )
1659 .unwrap()
1660 .with_config(config.clone());
1661 let stream = with_execution_deadline(
1662 Some(tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1663 query.execute_stream_with_catalog_and_context(catalog.clone(), ctx.clone()),
1664 )
1665 .await
1666 .unwrap();
1667 tokio::spawn(async move {
1669 let visited = Arc::new(AtomicUsize::new(0));
1670 let progress = visited.clone();
1671 let mut read = Box::pin(stream.try_fold(0_usize, move |count, batch| {
1672 progress.fetch_add(batch.num_rows(), Ordering::SeqCst);
1673 futures::future::ready(Ok(count + batch.num_rows()))
1674 }));
1675 assert!(futures::poll!(read.as_mut()).is_pending());
1676 let stopped = visited.load(Ordering::SeqCst);
1677 assert!(stopped > 0 && stopped < 1_000_000);
1678 drop(read);
1679 tokio::task::yield_now().await;
1680 assert_eq!(visited.load(Ordering::SeqCst), stopped);
1681 })
1682 .await
1683 .unwrap();
1684 let result = query
1685 .execute_with_catalog_and_context(catalog.clone(), ctx.clone())
1686 .await
1687 .unwrap();
1688 assert_eq!(result.num_rows(), 1_000_000);
1689 assert_eq!(result.num_columns(), 2);
1690
1691 let filtered = CypherQuery::new(
1692 "MATCH (a:Person), (b:Person) WHERE a.id + b.id >= 999 RETURN count(*) AS n",
1693 )
1694 .unwrap()
1695 .with_config(config);
1696 let expired = with_execution_deadline(
1697 Some(tokio::time::Instant::now() - std::time::Duration::from_secs(1)),
1698 filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1699 )
1700 .await
1701 .unwrap_err();
1702 assert!(expired.to_string().contains("Query timeout exceeded"));
1703 for timed in [false, true] {
1704 let result = with_execution_deadline(
1705 timed.then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1706 filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1707 )
1708 .await
1709 .unwrap();
1710 assert_eq!(result.num_rows(), 1);
1711 assert_eq!(
1712 result
1713 .column(0)
1714 .as_any()
1715 .downcast_ref::<Int64Array>()
1716 .unwrap()
1717 .value(0),
1718 500_500
1719 );
1720 }
1721 }
1722
1723 #[test]
1724 fn test_parse_simple_cypher_query() {
1725 let query = CypherQuery::new("MATCH (n:Person) RETURN n.name").unwrap();
1726 assert_eq!(query.query_text(), "MATCH (n:Person) RETURN n.name");
1727 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
1728 assert_eq!(query.variables(), vec!["n"]);
1729 }
1730
1731 #[test]
1732 fn test_query_with_parameters() {
1733 let mut params = HashMap::new();
1734 params.insert("minAge".to_string(), serde_json::Value::Number(30.into()));
1735 params.insert("maxAge".to_string(), serde_json::Value::Number(50.into()));
1736
1737 let query = CypherQuery::new(
1738 "MATCH (n:Person) WHERE n.age > $minAge AND n.age < $maxAge RETURN n.name",
1739 )
1740 .unwrap()
1741 .with_parameters(params);
1742
1743 assert!(query.parameters().contains_key("minage"));
1744 assert!(query.parameters().contains_key("maxage"));
1745 }
1746
1747 #[tokio::test]
1748 async fn cached_templates_rebind_scalars_and_preserve_schema() {
1749 use arrow_array::{ArrayRef, Int64Array, StringArray};
1750 let config = GraphConfig::builder()
1751 .node_table("Person", "id")
1752 .build()
1753 .unwrap();
1754 let datasets = HashMap::from([(
1755 "Person".into(),
1756 RecordBatch::try_from_iter([
1757 ("id", Arc::new(Int64Array::from(vec![1, 2, 3])) as ArrayRef),
1758 (
1759 "name",
1760 Arc::new(StringArray::from(vec!["Ada", "Bob", "Cam"])) as ArrayRef,
1761 ),
1762 ])
1763 .unwrap(),
1764 )]);
1765 let cases = [
1766 (
1767 "MATCH (p:Person) WHERE p.id >= $value RETURN p.id ORDER BY p.id",
1768 vec![serde_json::json!(1), serde_json::json!(3)],
1769 ),
1770 (
1771 "MATCH (p:Person) RETURN p.id + $value AS adjusted ORDER BY adjusted",
1772 vec![serde_json::json!(1.5), serde_json::json!(8.5)],
1773 ),
1774 (
1775 "MATCH (p:Person) WHERE p.name = $value RETURN p.id",
1776 vec![serde_json::json!("Ada"), serde_json::json!("Cam")],
1777 ),
1778 (
1779 "MATCH (p:Person) RETURN $value AS value",
1780 vec![serde_json::json!(true), serde_json::json!(false)],
1781 ),
1782 (
1783 "MATCH (p:Person) RETURN $value AS value",
1784 vec![serde_json::Value::Null],
1785 ),
1786 (
1787 "MATCH (p:Person) RETURN $value",
1788 vec![serde_json::json!(1), serde_json::json!(2)],
1789 ),
1790 (
1791 "MATCH (p:Person) RETURN p.id + $value",
1792 vec![serde_json::json!(1), serde_json::json!(2)],
1793 ),
1794 (
1795 "MATCH (p:Person) RETURN p.id AS id, [x IN range(1, $value) | x + p.id] AS values ORDER BY id",
1796 vec![serde_json::json!(1), serde_json::json!(3)],
1797 ),
1798 ];
1799 for (text, values) in cases {
1800 let mut template = CypherQuery::new(text)
1801 .unwrap()
1802 .with_config(config.clone())
1803 .with_parameter("value", values[0].clone());
1804 let (catalog, context) = template
1805 .build_catalog_and_context_from_datasets(datasets.clone())
1806 .await
1807 .unwrap();
1808 let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1809 assert!(
1810 template.prepare_cached_plan(catalog.clone()).unwrap(),
1811 "{text}"
1812 );
1813 for value in values {
1814 let cached = template.clone().with_parameter("value", value.clone());
1815 let fresh = CypherQuery::new(text)
1816 .unwrap()
1817 .with_config(config.clone())
1818 .with_parameter("value", value);
1819 let actual = cached
1820 .execute_with_catalog_and_context(catalog.clone(), context.clone())
1821 .await
1822 .unwrap();
1823 let expected = fresh
1824 .execute_with_catalog_and_context(catalog.clone(), context.clone())
1825 .await
1826 .unwrap();
1827 assert_eq!(actual, expected, "{text}");
1828 }
1829 }
1830 }
1831
1832 #[tokio::test]
1833 async fn cached_templates_respect_catalog_and_parameter_types() {
1834 use arrow_array::{ArrayRef, Int64Array};
1835 let config = GraphConfig::builder()
1836 .node_table("Person", "id")
1837 .build()
1838 .unwrap();
1839 let data = |id| {
1840 HashMap::from([(
1841 "Person".into(),
1842 RecordBatch::try_from_iter([(
1843 "id",
1844 Arc::new(Int64Array::from(vec![id])) as ArrayRef,
1845 )])
1846 .unwrap(),
1847 )])
1848 };
1849 let mut query = CypherQuery::new("MATCH (p:Person) WHERE p.id = $value RETURN p.id")
1850 .unwrap()
1851 .with_config(config.clone())
1852 .with_parameter("value", 1);
1853 let (catalog, _) = query
1854 .build_catalog_and_context_from_datasets(data(1))
1855 .await
1856 .unwrap();
1857 assert!(query.prepare_cached_plan(Arc::new(catalog)).unwrap());
1858 assert_eq!(query.execute(data(2), None).await.unwrap().num_rows(), 0);
1860 assert!(query
1861 .clone()
1862 .with_parameter("value", true)
1863 .execute(data(1), None)
1864 .await
1865 .unwrap_err()
1866 .to_string()
1867 .contains("Type mismatch"));
1868 assert!(query.with_config(config).cached_plan.is_none());
1869 let mut containers = CypherQuery::new("MATCH (p:Person) WHERE p.id IN $ids RETURN p.id")
1870 .unwrap()
1871 .with_parameter("ids", serde_json::json!([1, 2]));
1872 assert!(!containers
1873 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1874 .unwrap());
1875 }
1876
1877 #[test]
1878 fn parameterized_row_counts_bypass_templates() {
1879 for text in [
1880 "MATCH (p:Person) RETURN p.id LIMIT $count",
1881 "MATCH (p:Person) RETURN p.id SKIP $count",
1882 "MATCH (p:Person) WITH p LIMIT $count RETURN p.id",
1883 "MATCH (p:Person) WITH p WITH p LIMIT $count RETURN p.id",
1884 ] {
1885 let mut query = CypherQuery::new(text).unwrap().with_parameter("count", 1);
1886 assert!(
1887 !query
1888 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1889 .unwrap(),
1890 "{text}"
1891 );
1892 assert!(query.cached_plan.is_none());
1893 }
1894 }
1895
1896 #[test]
1897 fn cross_label_shortest_queries_bypass_cached_inventories() {
1898 let config = GraphConfig::builder()
1899 .node_table("Person", "id")
1900 .node_table("Place", "id")
1901 .rel_table("LIVES", "Person", "Place", "src", "dst")
1902 .build()
1903 .unwrap();
1904 for text in [
1905 "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p)",
1906 "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]-(b:Person)) WITH p AS q RETURN nodes(q)",
1907 "RETURN 0 AS n UNION MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p) AS n",
1908 ] {
1909 let mut query = CypherQuery::new(text)
1910 .unwrap()
1911 .with_config(config.clone())
1912 .with_parameter("id", 1);
1913 assert!(!query
1914 .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1915 .unwrap());
1916 assert!(query.cached_plan.is_none(), "{text}");
1917 }
1918 }
1919
1920 #[tokio::test]
1924 #[ignore = "local stage timing; run with --ignored --nocapture"]
1925 async fn profile_query_planning_stages() {
1926 use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1927 use crate::semantic::SemanticAnalyzer;
1928 use arrow_array::{ArrayRef, Int64Array};
1929 use std::time::Instant;
1930 let text = "MATCH (p:Person) WHERE p.id >= $minimum RETURN p.id AS id ORDER BY id";
1931 let config = GraphConfig::builder()
1932 .node_table("Person", "id")
1933 .build()
1934 .unwrap();
1935 let parameters = HashMap::from([("minimum".into(), serde_json::json!(10))]);
1936 let mut template = CypherQuery::new(text)
1937 .unwrap()
1938 .with_config(config.clone())
1939 .with_parameters(parameters.clone());
1940 let data = HashMap::from([(
1941 "Person".into(),
1942 RecordBatch::try_from_iter([(
1943 "id",
1944 Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1945 )])
1946 .unwrap(),
1947 )]);
1948 let (catalog, context) = template
1949 .build_catalog_and_context_from_datasets(data)
1950 .await
1951 .unwrap();
1952 let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1953 template.prepare_cached_plan(catalog.clone()).unwrap();
1954 let state = context.state();
1955 let mut totals = [std::time::Duration::ZERO; 8];
1956 for _ in 0..100 {
1957 let start = Instant::now();
1958 let ast = parse_cypher_query(text).unwrap();
1959 totals[0] += start.elapsed();
1960 let start = Instant::now();
1961 let semantic = SemanticAnalyzer::new(config.clone())
1962 .with_catalog(catalog.clone())
1963 .analyze(&ast, ¶meters)
1964 .unwrap();
1965 assert!(semantic.errors.is_empty());
1966 totals[1] += start.elapsed();
1967 let start = Instant::now();
1968 let graph = LogicalPlanner::new(&config).plan(&semantic.ast).unwrap();
1969 totals[2] += start.elapsed();
1970 let start = Instant::now();
1971 let logical = DataFusionPlanner::with_catalog(config.clone(), catalog.clone())
1972 .plan(&graph)
1973 .unwrap();
1974 totals[3] += start.elapsed();
1975 let start = Instant::now();
1976 let optimized = state.optimize(&logical).unwrap();
1977 totals[4] += start.elapsed();
1978 let start = Instant::now();
1979 let physical = state
1980 .query_planner()
1981 .create_physical_plan(&optimized, &state)
1982 .await
1983 .unwrap();
1984 totals[5] += start.elapsed();
1985 let start = Instant::now();
1986 let batches = datafusion::physical_plan::collect(physical, context.task_ctx())
1987 .await
1988 .unwrap();
1989 totals[6] += start.elapsed();
1990 assert_eq!(
1991 batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
1992 990
1993 );
1994 let checksum: i64 = batches
1995 .iter()
1996 .flat_map(|batch| {
1997 batch
1998 .column(0)
1999 .as_any()
2000 .downcast_ref::<Int64Array>()
2001 .unwrap()
2002 .values()
2003 .iter()
2004 })
2005 .sum();
2006 assert_eq!(checksum, (10 + 999) * 990 / 2);
2007 let start = Instant::now();
2008 let _ = template.create_logical_plans(catalog.clone()).unwrap();
2009 totals[7] += start.elapsed();
2010 }
2011 for (name, duration) in [
2012 "parse",
2013 "semantic",
2014 "graph_logical",
2015 "datafusion_logical",
2016 "optimize",
2017 "physical",
2018 "execute",
2019 "cached_bind",
2020 ]
2021 .into_iter()
2022 .zip(totals)
2023 {
2024 println!(
2025 "{name}: {:.3} us mean (100 iterations, 1000 input rows, 990 result rows)",
2026 duration.as_secs_f64() * 1_000_000.0 / 100.0
2027 );
2028 }
2029 }
2030
2031 #[test]
2032 fn test_query_builder() {
2033 let config = GraphConfig::builder()
2034 .node_table("Person", "person_id")
2035 .build()
2036 .unwrap();
2037
2038 let query = CypherQueryBuilder::new()
2039 .with_config(config)
2040 .match_node("n", "Person")
2041 .return_property("n", "name")
2042 .limit(10)
2043 .build()
2044 .unwrap();
2045
2046 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2047 assert_eq!(query.variables(), vec!["n"]);
2048 }
2049
2050 #[test]
2051 fn test_relationship_query_parsing() {
2052 let query =
2053 CypherQuery::new("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a.name, b.name")
2054 .unwrap();
2055 assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2056 assert_eq!(query.referenced_relationship_types(), vec!["KNOWS"]);
2057 assert_eq!(query.variables(), vec!["a", "b", "r"]);
2058 }
2059
2060 #[tokio::test]
2061 async fn test_execute_basic_projection_and_filter() {
2062 use arrow_array::{Int64Array, RecordBatch, StringArray};
2063 use arrow_schema::{DataType, Field, Schema};
2064 use std::sync::Arc;
2065
2066 let schema = Arc::new(Schema::new(vec![
2068 Field::new("name", DataType::Utf8, true),
2069 Field::new("age", DataType::Int64, true),
2070 ]));
2071 let batch = RecordBatch::try_new(
2072 schema,
2073 vec![
2074 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2075 Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2076 ],
2077 )
2078 .unwrap();
2079
2080 let cfg = GraphConfig::builder()
2081 .node_table("Person", "id")
2082 .build()
2083 .unwrap();
2084
2085 let q = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2086 .unwrap()
2087 .with_config(cfg);
2088
2089 let mut data = HashMap::new();
2090 data.insert("Person".to_string(), batch);
2091
2092 let out = q.execute(data, None).await.unwrap();
2093 assert_eq!(out.num_rows(), 2);
2094 let names = out
2095 .column(0)
2096 .as_any()
2097 .downcast_ref::<StringArray>()
2098 .unwrap();
2099 let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2100 let result: Vec<(String, i64)> = (0..out.num_rows())
2102 .map(|i| (names.value(i).to_string(), ages.value(i)))
2103 .collect();
2104 assert!(result.contains(&("Bob".to_string(), 34)));
2105 assert!(result.contains(&("David".to_string(), 42)));
2106 }
2107
2108 #[tokio::test]
2109 async fn test_execute_single_hop_path_join_projection() {
2110 use arrow_array::{Int64Array, RecordBatch, StringArray};
2111 use arrow_schema::{DataType, Field, Schema};
2112 use std::sync::Arc;
2113
2114 let person_schema = Arc::new(Schema::new(vec![
2116 Field::new("id", DataType::Int64, false),
2117 Field::new("name", DataType::Utf8, true),
2118 Field::new("age", DataType::Int64, true),
2119 ]));
2120 let people = RecordBatch::try_new(
2121 person_schema,
2122 vec![
2123 Arc::new(Int64Array::from(vec![1, 2, 3])),
2124 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2125 Arc::new(Int64Array::from(vec![28, 34, 29])),
2126 ],
2127 )
2128 .unwrap();
2129
2130 let rel_schema = Arc::new(Schema::new(vec![
2132 Field::new("src_person_id", DataType::Int64, false),
2133 Field::new("dst_person_id", DataType::Int64, false),
2134 ]));
2135 let knows = RecordBatch::try_new(
2136 rel_schema,
2137 vec![
2138 Arc::new(Int64Array::from(vec![1, 2])), Arc::new(Int64Array::from(vec![2, 3])),
2140 ],
2141 )
2142 .unwrap();
2143
2144 let cfg = GraphConfig::builder()
2146 .node_table("Person", "id")
2147 .rel_table(
2148 "KNOWS",
2149 "Person",
2150 "Person",
2151 "src_person_id",
2152 "dst_person_id",
2153 )
2154 .build()
2155 .unwrap();
2156
2157 let q = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name")
2159 .unwrap()
2160 .with_config(cfg);
2161
2162 let mut data = HashMap::new();
2163 data.insert("Person".to_string(), people);
2165 data.insert("KNOWS".to_string(), knows);
2166
2167 let out = q.execute(data, None).await.unwrap();
2168 let names = out
2170 .column(0)
2171 .as_any()
2172 .downcast_ref::<StringArray>()
2173 .unwrap();
2174 let got: Vec<String> = (0..out.num_rows())
2175 .map(|i| names.value(i).to_string())
2176 .collect();
2177 assert_eq!(got.len(), 2);
2178 assert!(got.contains(&"Bob".to_string()));
2179 assert!(got.contains(&"Carol".to_string()));
2180 }
2181
2182 #[tokio::test]
2183 async fn test_execute_order_by_asc() {
2184 use arrow_array::{Int64Array, RecordBatch, StringArray};
2185 use arrow_schema::{DataType, Field, Schema};
2186 use std::sync::Arc;
2187
2188 let schema = Arc::new(Schema::new(vec![
2190 Field::new("name", DataType::Utf8, true),
2191 Field::new("age", DataType::Int64, true),
2192 ]));
2193 let batch = RecordBatch::try_new(
2194 schema,
2195 vec![
2196 Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2197 Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2198 ],
2199 )
2200 .unwrap();
2201
2202 let cfg = GraphConfig::builder()
2203 .node_table("Person", "id")
2204 .build()
2205 .unwrap();
2206
2207 let q = CypherQuery::new("MATCH (p:Person) RETURN p.name, p.age ORDER BY p.age ASC")
2209 .unwrap()
2210 .with_config(cfg);
2211
2212 let mut data = HashMap::new();
2213 data.insert("Person".to_string(), batch);
2214
2215 let out = q.execute(data, None).await.unwrap();
2216 let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2217 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2218 assert_eq!(collected, vec![28, 29, 34, 42]);
2219 }
2220
2221 #[tokio::test]
2222 async fn test_execute_order_by_desc_with_skip_limit() {
2223 use arrow_array::{Int64Array, RecordBatch, StringArray};
2224 use arrow_schema::{DataType, Field, Schema};
2225 use std::sync::Arc;
2226
2227 let schema = Arc::new(Schema::new(vec![
2228 Field::new("name", DataType::Utf8, true),
2229 Field::new("age", DataType::Int64, true),
2230 ]));
2231 let batch = RecordBatch::try_new(
2232 schema,
2233 vec![
2234 Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2235 Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2236 ],
2237 )
2238 .unwrap();
2239
2240 let cfg = GraphConfig::builder()
2241 .node_table("Person", "id")
2242 .build()
2243 .unwrap();
2244
2245 let q =
2247 CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age DESC SKIP 1 LIMIT 2")
2248 .unwrap()
2249 .with_config(cfg);
2250
2251 let mut data = HashMap::new();
2252 data.insert("Person".to_string(), batch);
2253
2254 let out = q.execute(data, None).await.unwrap();
2255 assert_eq!(out.num_rows(), 2);
2256 let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2257 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2258 assert_eq!(collected, vec![34, 29]);
2259 }
2260
2261 #[tokio::test]
2262 async fn test_execute_skip_without_limit() {
2263 use arrow_array::{Int64Array, RecordBatch};
2264 use arrow_schema::{DataType, Field, Schema};
2265 use std::sync::Arc;
2266
2267 let schema = Arc::new(Schema::new(vec![Field::new("age", DataType::Int64, true)]));
2268 let batch = RecordBatch::try_new(
2269 schema,
2270 vec![Arc::new(Int64Array::from(vec![10, 20, 30, 40]))],
2271 )
2272 .unwrap();
2273
2274 let cfg = GraphConfig::builder()
2275 .node_table("Person", "id")
2276 .build()
2277 .unwrap();
2278
2279 let q = CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age ASC SKIP 2")
2280 .unwrap()
2281 .with_config(cfg);
2282
2283 let mut data = HashMap::new();
2284 data.insert("Person".to_string(), batch);
2285
2286 let out = q.execute(data, None).await.unwrap();
2287 assert_eq!(out.num_rows(), 2);
2288 let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2289 let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2290 assert_eq!(collected, vec![30, 40]);
2291 }
2292
2293 #[tokio::test]
2294 async fn test_execute_datafusion_simple_scan() {
2295 use arrow_array::{Int64Array, RecordBatch, StringArray};
2296 use arrow_schema::{DataType, Field, Schema};
2297 use std::sync::Arc;
2298
2299 let schema = Arc::new(Schema::new(vec![
2301 Field::new("id", DataType::Int64, false),
2302 Field::new("name", DataType::Utf8, false),
2303 ]));
2304
2305 let batch = RecordBatch::try_new(
2306 schema,
2307 vec![
2308 Arc::new(Int64Array::from(vec![1, 2])),
2309 Arc::new(StringArray::from(vec!["Alice", "Bob"])),
2310 ],
2311 )
2312 .unwrap();
2313
2314 let cfg = GraphConfig::builder()
2315 .node_table("Person", "id")
2316 .build()
2317 .unwrap();
2318
2319 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2321 .unwrap()
2322 .with_config(cfg);
2323
2324 let mut datasets = HashMap::new();
2325 datasets.insert("Person".to_string(), batch);
2326
2327 let result = query.execute_datafusion(datasets).await.unwrap();
2329
2330 assert_eq!(
2332 result.num_rows(),
2333 2,
2334 "Should return all 2 rows without filtering"
2335 );
2336 assert_eq!(result.num_columns(), 1, "Should return 1 column (name)");
2337
2338 let names = result
2340 .column(0)
2341 .as_any()
2342 .downcast_ref::<StringArray>()
2343 .unwrap();
2344 let name_set: std::collections::HashSet<String> = (0..result.num_rows())
2345 .map(|i| names.value(i).to_string())
2346 .collect();
2347 let expected: std::collections::HashSet<String> =
2348 ["Alice", "Bob"].iter().map(|s| s.to_string()).collect();
2349 assert_eq!(name_set, expected, "Should return Alice and Bob");
2350 }
2351
2352 #[tokio::test]
2353 async fn test_execute_with_context_simple_scan() {
2354 use arrow_array::{Int64Array, RecordBatch, StringArray};
2355 use arrow_schema::{DataType, Field, Schema};
2356 use datafusion::datasource::MemTable;
2357 use datafusion::execution::context::SessionContext;
2358 use std::sync::Arc;
2359
2360 let schema = Arc::new(Schema::new(vec![
2362 Field::new("id", DataType::Int64, false),
2363 Field::new("name", DataType::Utf8, false),
2364 Field::new("age", DataType::Int64, false),
2365 ]));
2366 let batch = RecordBatch::try_new(
2367 schema.clone(),
2368 vec![
2369 Arc::new(Int64Array::from(vec![1, 2, 3])),
2370 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2371 Arc::new(Int64Array::from(vec![28, 34, 29])),
2372 ],
2373 )
2374 .unwrap();
2375
2376 let mem_table =
2378 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2379 let ctx = SessionContext::new();
2380 ctx.register_table("Person", mem_table).unwrap();
2381
2382 let cfg = GraphConfig::builder()
2384 .node_table("Person", "id")
2385 .build()
2386 .unwrap();
2387
2388 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2389 .unwrap()
2390 .with_config(cfg);
2391
2392 let result = query.execute_with_context(ctx).await.unwrap();
2394
2395 assert_eq!(result.num_rows(), 3);
2397 assert_eq!(result.num_columns(), 1);
2398
2399 let names = result
2400 .column(0)
2401 .as_any()
2402 .downcast_ref::<StringArray>()
2403 .unwrap();
2404 assert_eq!(names.value(0), "Alice");
2405 assert_eq!(names.value(1), "Bob");
2406 assert_eq!(names.value(2), "Carol");
2407 }
2408
2409 #[tokio::test]
2410 async fn test_execute_with_context_with_filter() {
2411 use arrow_array::{Int64Array, RecordBatch, StringArray};
2412 use arrow_schema::{DataType, Field, Schema};
2413 use datafusion::datasource::MemTable;
2414 use datafusion::execution::context::SessionContext;
2415 use std::sync::Arc;
2416
2417 let schema = Arc::new(Schema::new(vec![
2419 Field::new("id", DataType::Int64, false),
2420 Field::new("name", DataType::Utf8, false),
2421 Field::new("age", DataType::Int64, false),
2422 ]));
2423 let batch = RecordBatch::try_new(
2424 schema.clone(),
2425 vec![
2426 Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2427 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2428 Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2429 ],
2430 )
2431 .unwrap();
2432
2433 let mem_table =
2435 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2436 let ctx = SessionContext::new();
2437 ctx.register_table("Person", mem_table).unwrap();
2438
2439 let cfg = GraphConfig::builder()
2441 .node_table("Person", "id")
2442 .build()
2443 .unwrap();
2444
2445 let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2446 .unwrap()
2447 .with_config(cfg);
2448
2449 let result = query.execute_with_context(ctx).await.unwrap();
2451
2452 assert_eq!(result.num_rows(), 2);
2454 assert_eq!(result.num_columns(), 2);
2455
2456 let names = result
2457 .column(0)
2458 .as_any()
2459 .downcast_ref::<StringArray>()
2460 .unwrap();
2461 let ages = result
2462 .column(1)
2463 .as_any()
2464 .downcast_ref::<Int64Array>()
2465 .unwrap();
2466
2467 let results: Vec<(String, i64)> = (0..result.num_rows())
2468 .map(|i| (names.value(i).to_string(), ages.value(i)))
2469 .collect();
2470
2471 assert!(results.contains(&("Bob".to_string(), 34)));
2472 assert!(results.contains(&("David".to_string(), 42)));
2473 }
2474
2475 #[tokio::test]
2476 async fn test_execute_with_context_relationship_traversal() {
2477 use arrow_array::{Int64Array, RecordBatch, StringArray};
2478 use arrow_schema::{DataType, Field, Schema};
2479 use datafusion::datasource::MemTable;
2480 use datafusion::execution::context::SessionContext;
2481 use std::sync::Arc;
2482
2483 let person_schema = Arc::new(Schema::new(vec![
2485 Field::new("id", DataType::Int64, false),
2486 Field::new("name", DataType::Utf8, false),
2487 ]));
2488 let person_batch = RecordBatch::try_new(
2489 person_schema.clone(),
2490 vec![
2491 Arc::new(Int64Array::from(vec![1, 2, 3])),
2492 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2493 ],
2494 )
2495 .unwrap();
2496
2497 let knows_schema = Arc::new(Schema::new(vec![
2499 Field::new("src_id", DataType::Int64, false),
2500 Field::new("dst_id", DataType::Int64, false),
2501 Field::new("since", DataType::Int64, false),
2502 ]));
2503 let knows_batch = RecordBatch::try_new(
2504 knows_schema.clone(),
2505 vec![
2506 Arc::new(Int64Array::from(vec![1, 2])),
2507 Arc::new(Int64Array::from(vec![2, 3])),
2508 Arc::new(Int64Array::from(vec![2020, 2021])),
2509 ],
2510 )
2511 .unwrap();
2512
2513 let person_table = Arc::new(
2515 MemTable::try_new(person_schema.clone(), vec![vec![person_batch.clone()]]).unwrap(),
2516 );
2517 let knows_table = Arc::new(
2518 MemTable::try_new(knows_schema.clone(), vec![vec![knows_batch.clone()]]).unwrap(),
2519 );
2520
2521 let ctx = SessionContext::new();
2522 ctx.register_table("Person", person_table).unwrap();
2523 ctx.register_table("KNOWS", knows_table).unwrap();
2524
2525 let cfg = GraphConfig::builder()
2527 .node_table("Person", "id")
2528 .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
2529 .build()
2530 .unwrap();
2531
2532 let query = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name")
2533 .unwrap()
2534 .with_config(cfg);
2535
2536 let result = query.execute_with_context(ctx).await.unwrap();
2538
2539 assert_eq!(result.num_rows(), 2);
2541 assert_eq!(result.num_columns(), 2);
2542
2543 let src_names = result
2544 .column(0)
2545 .as_any()
2546 .downcast_ref::<StringArray>()
2547 .unwrap();
2548 let dst_names = result
2549 .column(1)
2550 .as_any()
2551 .downcast_ref::<StringArray>()
2552 .unwrap();
2553
2554 let relationships: Vec<(String, String)> = (0..result.num_rows())
2555 .map(|i| {
2556 (
2557 src_names.value(i).to_string(),
2558 dst_names.value(i).to_string(),
2559 )
2560 })
2561 .collect();
2562
2563 assert!(relationships.contains(&("Alice".to_string(), "Bob".to_string())));
2564 assert!(relationships.contains(&("Bob".to_string(), "Carol".to_string())));
2565 }
2566
2567 #[tokio::test]
2568 async fn test_execute_with_context_order_by_limit() {
2569 use arrow_array::{Int64Array, RecordBatch, StringArray};
2570 use arrow_schema::{DataType, Field, Schema};
2571 use datafusion::datasource::MemTable;
2572 use datafusion::execution::context::SessionContext;
2573 use std::sync::Arc;
2574
2575 let schema = Arc::new(Schema::new(vec![
2577 Field::new("id", DataType::Int64, false),
2578 Field::new("name", DataType::Utf8, false),
2579 Field::new("score", DataType::Int64, false),
2580 ]));
2581 let batch = RecordBatch::try_new(
2582 schema.clone(),
2583 vec![
2584 Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2585 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2586 Arc::new(Int64Array::from(vec![85, 92, 78, 95])),
2587 ],
2588 )
2589 .unwrap();
2590
2591 let mem_table =
2593 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2594 let ctx = SessionContext::new();
2595 ctx.register_table("Student", mem_table).unwrap();
2596
2597 let cfg = GraphConfig::builder()
2599 .node_table("Student", "id")
2600 .build()
2601 .unwrap();
2602
2603 let query = CypherQuery::new(
2604 "MATCH (s:Student) RETURN s.name, s.score ORDER BY s.score DESC LIMIT 2",
2605 )
2606 .unwrap()
2607 .with_config(cfg);
2608
2609 let result = query.execute_with_context(ctx).await.unwrap();
2611
2612 assert_eq!(result.num_rows(), 2);
2614 assert_eq!(result.num_columns(), 2);
2615
2616 let names = result
2617 .column(0)
2618 .as_any()
2619 .downcast_ref::<StringArray>()
2620 .unwrap();
2621 let scores = result
2622 .column(1)
2623 .as_any()
2624 .downcast_ref::<Int64Array>()
2625 .unwrap();
2626
2627 assert_eq!(names.value(0), "David");
2629 assert_eq!(scores.value(0), 95);
2630
2631 assert_eq!(names.value(1), "Bob");
2633 assert_eq!(scores.value(1), 92);
2634 }
2635
2636 #[tokio::test]
2637 async fn test_to_sql() {
2638 use arrow_array::RecordBatch;
2639 use arrow_schema::{DataType, Field, Schema};
2640 use std::collections::HashMap;
2641 use std::sync::Arc;
2642
2643 let schema = Arc::new(Schema::new(vec![
2644 Field::new("id", DataType::Int64, false),
2645 Field::new("name", DataType::Utf8, false),
2646 ]));
2647 let batch = RecordBatch::new_empty(schema.clone());
2648
2649 let mut datasets = HashMap::new();
2650 datasets.insert("Person".to_string(), batch);
2651
2652 let cfg = GraphConfig::builder()
2653 .node_table("Person", "id")
2654 .build()
2655 .unwrap();
2656
2657 let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2658 .unwrap()
2659 .with_config(cfg);
2660
2661 let sql = query.to_sql(datasets, None).await.unwrap();
2662 println!("Generated SQL: {}", sql);
2663
2664 assert!(sql.contains("SELECT"));
2665 assert!(sql.to_lowercase().contains("from person"));
2666 assert!(sql.contains("p.name"));
2669 }
2670
2671 #[tokio::test]
2672 async fn test_to_sql_rejects_runtime_only_cypher_function() {
2673 let batch = RecordBatch::try_from_iter(vec![
2674 (
2675 "id",
2676 Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2677 ),
2678 (
2679 "number",
2680 Arc::new(arrow_array::Float64Array::from(vec![-1.5])) as arrow_array::ArrayRef,
2681 ),
2682 ])
2683 .unwrap();
2684 let query = CypherQuery::new("MATCH (p:Person) RETURN round(p.number) AS value")
2685 .unwrap()
2686 .with_config(
2687 GraphConfig::builder()
2688 .node_table("Person", "id")
2689 .build()
2690 .unwrap(),
2691 );
2692 let error = query
2693 .to_sql(HashMap::from([("Person".to_string(), batch)]), None)
2694 .await
2695 .unwrap_err();
2696 assert!(matches!(error, GraphError::UnsupportedFeature { .. }));
2697 assert!(error
2698 .to_string()
2699 .contains("SQL export of Cypher function 'round'"));
2700 }
2701
2702 #[tokio::test]
2703 async fn test_to_sql_preserves_constant_null_types() {
2704 let batch = RecordBatch::try_from_iter(vec![(
2705 "id",
2706 Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2707 )])
2708 .unwrap();
2709 let datasets = HashMap::from([("Person".to_string(), batch)]);
2710 let config = GraphConfig::builder()
2711 .node_table("Person", "id")
2712 .build()
2713 .unwrap();
2714 for expression in [
2715 "round(null)",
2716 "abs(null)",
2717 "sqrt(null)",
2718 "floor(null)",
2719 "ceil(null)",
2720 "toUpper(null)",
2721 "null",
2722 ] {
2723 let query = CypherQuery::new(&format!("MATCH (p:Person) RETURN {expression} AS value"))
2724 .unwrap()
2725 .with_config(config.clone());
2726 let cypher = query.execute(datasets.clone(), None).await.unwrap();
2727 let sql_text = query.to_sql(datasets.clone(), None).await.unwrap();
2728 let sql = crate::sql_query::SqlQuery::new(&sql_text)
2729 .execute(datasets.clone())
2730 .await
2731 .unwrap();
2732 assert_eq!(
2733 sql.schema().field(0).data_type(),
2734 cypher.schema().field(0).data_type(),
2735 "{expression}: {sql_text}"
2736 );
2737 assert_eq!(sql.column(0).logical_null_count(), 1, "{expression}");
2738 }
2739 }
2740
2741 async fn write_lance_dataset(path: &std::path::Path, batch: arrow_array::RecordBatch) {
2742 use arrow_array::{RecordBatch, RecordBatchIterator};
2743 use lance::dataset::{Dataset, WriteParams};
2744
2745 let schema = batch.schema();
2746 let batches: Vec<std::result::Result<RecordBatch, arrow::error::ArrowError>> =
2747 vec![std::result::Result::Ok(batch)];
2748 let reader = RecordBatchIterator::new(batches.into_iter(), schema);
2749
2750 Dataset::write(reader, path.to_str().unwrap(), None::<WriteParams>)
2751 .await
2752 .expect("write lance dataset");
2753 }
2754
2755 fn build_people_batch() -> arrow_array::RecordBatch {
2756 use arrow_array::{ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
2757 use arrow_schema::{DataType, Field, Schema};
2758 use std::sync::Arc;
2759
2760 let schema = Arc::new(Schema::new(vec![
2761 Field::new("person_id", DataType::Int64, false),
2762 Field::new("name", DataType::Utf8, false),
2763 Field::new("age", DataType::Int32, false),
2764 ]));
2765
2766 let columns: Vec<ArrayRef> = vec![
2767 Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef,
2768 Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])) as ArrayRef,
2769 Arc::new(Int32Array::from(vec![28, 34, 29, 42])) as ArrayRef,
2770 ];
2771
2772 RecordBatch::try_new(schema, columns).expect("valid person batch")
2773 }
2774
2775 fn build_friendship_batch() -> arrow_array::RecordBatch {
2776 use arrow_array::{ArrayRef, Int64Array, RecordBatch};
2777 use arrow_schema::{DataType, Field, Schema};
2778 use std::sync::Arc;
2779
2780 let schema = Arc::new(Schema::new(vec![
2781 Field::new("person1_id", DataType::Int64, false),
2782 Field::new("person2_id", DataType::Int64, false),
2783 ]));
2784
2785 let columns: Vec<ArrayRef> = vec![
2786 Arc::new(Int64Array::from(vec![1, 1, 2, 3])) as ArrayRef,
2787 Arc::new(Int64Array::from(vec![2, 3, 4, 4])) as ArrayRef,
2788 ];
2789
2790 RecordBatch::try_new(schema, columns).expect("valid friendship batch")
2791 }
2792
2793 #[tokio::test]
2794 async fn executes_against_directory_namespace() {
2795 use arrow_array::StringArray;
2796 use tempfile::tempdir;
2797
2798 let tmp_dir = tempdir().unwrap();
2799 write_lance_dataset(&tmp_dir.path().join("Person.lance"), build_people_batch()).await;
2800 write_lance_dataset(
2801 &tmp_dir.path().join("FRIEND_OF.lance"),
2802 build_friendship_batch(),
2803 )
2804 .await;
2805
2806 let config = GraphConfig::builder()
2807 .node_table("Person", "person_id")
2808 .rel_table("FRIEND_OF", "Person", "Person", "person1_id", "person2_id")
2809 .build()
2810 .expect("valid graph config");
2811
2812 let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name")
2813 .expect("query parses")
2814 .with_config(config);
2815
2816 let namespace = DirNamespace::new(tmp_dir.path().to_string_lossy().into_owned());
2817
2818 let result = query
2819 .execute_with_namespace(namespace.clone(), None)
2820 .await
2821 .expect("namespace execution succeeds");
2822
2823 use arrow_array::Array;
2824 let names = result
2825 .column(0)
2826 .as_any()
2827 .downcast_ref::<StringArray>()
2828 .expect("string column");
2829
2830 let mut values: Vec<String> = (0..names.len())
2831 .map(|i| names.value(i).to_string())
2832 .collect();
2833 values.sort();
2834 assert_eq!(values, vec!["Bob".to_string(), "David".to_string()]);
2835 }
2836
2837 #[tokio::test]
2838 async fn test_execute_fails_on_semantic_error() {
2839 use arrow_array::RecordBatch;
2840 use arrow_schema::{DataType, Field, Schema};
2841 use std::collections::HashMap;
2842 use std::sync::Arc;
2843
2844 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
2845 let batch = RecordBatch::new_empty(schema);
2846 let mut datasets = HashMap::new();
2847 datasets.insert("Person".to_string(), batch);
2848
2849 let cfg = GraphConfig::builder()
2850 .node_table("Person", "id")
2851 .build()
2852 .unwrap();
2853
2854 let query = CypherQuery::new("MATCH (n:Person) RETURN x.name")
2856 .unwrap()
2857 .with_config(cfg);
2858
2859 let result = query.execute(datasets, None).await;
2860
2861 assert!(result.is_err());
2862 match result {
2863 Err(GraphError::PlanError { message, .. }) => {
2864 assert!(message.contains("Semantic analysis failed"));
2865 assert!(message.contains("Undefined variable: 'x'"));
2866 }
2867 _ => panic!(
2868 "Expected PlanError with semantic failure message, got {:?}",
2869 result
2870 ),
2871 }
2872 }
2873}