Skip to main content

lance_csr/
query.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! High-level Cypher query interface for Lance datasets
5
6mod 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/// SQL dialect to use when generating SQL from Cypher queries.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SqlDialect {
32    /// Generic SQL (DataFusion default dialect)
33    #[default]
34    Default,
35    /// Spark SQL dialect (backtick quoting, STRING type, EXTRACT, etc.)
36    Spark,
37    /// PostgreSQL dialect
38    PostgreSql,
39    /// MySQL dialect
40    MySql,
41    /// SQLite dialect
42    Sqlite,
43}
44
45/// Wrapper to hold the concrete dialect type and provide an `Unparser` reference.
46pub 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    /// Create a `DialectUnparser` configured for this dialect.
68    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
79/// Normalize an Arrow schema to have lowercase field names.
80///
81/// This ensures that column names in the dataset match the normalized
82/// qualified column names used internally (e.g., "fullName" becomes "fullname").
83pub(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
98/// Normalize a RecordBatch to have lowercase column names.
99///
100/// This creates a new RecordBatch with a normalized schema while
101/// preserving all the data arrays.
102pub(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/// Execution strategy for Cypher queries
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum ExecutionStrategy {
115    /// Use DataFusion query planner (default, full feature support)
116    #[default]
117    DataFusion,
118    /// Use Lance native executor (not yet implemented)
119    LanceNative,
120}
121
122/// A Cypher query that can be executed against Lance datasets
123#[derive(Debug, Clone)]
124pub struct CypherQuery {
125    /// The original Cypher query string
126    query_text: String,
127    /// Parsed AST representation
128    ast: CypherAST,
129    /// Graph configuration for mapping
130    config: Option<GraphConfig>,
131    /// Query parameters
132    parameters: HashMap<String, serde_json::Value>,
133    /// Optional CSR adjacencies; when set, supported single-hop expansions use them
134    adjacency: Option<crate::csr_expand::AdjacencyCatalog>,
135    /// Optional mean fan-out per relationship type and direction; see [`FanOut`]
136    fan_out: Option<crate::logical_plan::FanOut>,
137    /// An unoptimized template belonging to the caller's catalog snapshot.
138    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    /// Create a new Cypher query from a query string
157    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    /// Set the graph configuration for this query
172    pub fn with_config(mut self, config: GraphConfig) -> Self {
173        self.cached_plan = None;
174        self.config = Some(config);
175        self
176    }
177
178    /// Plan supported single-hop expansions with CSR lookups instead of joins.
179    ///
180    /// The `SessionContext` used for execution must install
181    /// [`crate::csr_expand::CsrQueryPlanner`]. Each adjacency must be built from the
182    /// same relationship data the context serves, or results will be wrong.
183    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    /// Mean fan-out per relationship type and direction, so a pattern that continues
190    /// earlier rows can start from its cheaper bound end (see [`crate::logical_plan::FanOut`]).
191    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    /// Add a parameter to the query
198    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    /// Add multiple parameters to the query
209    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    /// Get the original query text
217    pub fn query_text(&self) -> &str {
218        &self.query_text
219    }
220
221    /// Get the parsed AST
222    pub fn ast(&self) -> &CypherAST {
223        &self.ast
224    }
225
226    /// Get the graph configuration
227    pub fn config(&self) -> Option<&GraphConfig> {
228        self.config.as_ref()
229    }
230
231    /// Get query parameters
232    pub fn parameters(&self) -> &HashMap<String, serde_json::Value> {
233        &self.parameters
234    }
235
236    /// Build a snapshot-local scalar-parameter template for the Python engine's cache.
237    /// Binding must precede optimization so pushed CSR filters never retain old values.
238    #[doc(hidden)]
239    pub fn prepare_cached_plan(
240        &mut self,
241        catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
242    ) -> Result<bool> {
243        // Rich path properties live in an extension-owned spec, and its inventory is
244        // query-local. Keep ordinary substitution and a fresh inventory for each query.
245        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            // CALL binds its own child templates; row counts alter plan shape.
273            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    /// Get the required config, returning an error if not set
313    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    /// Execute the query against provided in-memory datasets
321    ///
322    /// This method uses the DataFusion planner by default for comprehensive query support
323    /// including joins, aggregations, and complex patterns. You can optionally specify
324    /// a different execution strategy.
325    ///
326    /// # Arguments
327    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
328    /// * `strategy` - Optional execution strategy (defaults to DataFusion)
329    ///
330    /// # Returns
331    /// A single RecordBatch containing the query results
332    ///
333    /// # Errors
334    /// Returns error if query parsing, planning, or execution fails
335    ///
336    /// # Example
337    /// ```ignore
338    /// use std::collections::HashMap;
339    /// use arrow::record_batch::RecordBatch;
340    /// use lance_csr::query::CypherQuery;
341    ///
342    /// // Create in-memory datasets
343    /// let mut datasets = HashMap::new();
344    /// datasets.insert("Person".to_string(), person_batch);
345    /// datasets.insert("KNOWS".to_string(), knows_batch);
346    ///
347    /// // Parse and execute query
348    /// let query = CypherQuery::parse("MATCH (p:Person)-[:KNOWS]->(f) RETURN p.name, f.name")?
349    ///     .with_config(config);
350    /// // Use the default DataFusion strategy
351    /// let result = query.execute(datasets, None).await?;
352    /// ```
353    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    /// Execute the query using a namespace-backed table resolver.
369    ///
370    /// The namespace is provided by value and will be shared internally as needed.
371    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    /// Execute the query using a shared namespace instance.
381    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    /// Explain the query execution plan using in-memory datasets
414    ///
415    /// Returns a formatted string showing the query execution plan at different stages:
416    /// - Graph Logical Plan (graph-specific operators)
417    /// - DataFusion Logical Plan (optimized relational plan)
418    /// - DataFusion Physical Plan (execution plan with optimizations)
419    ///
420    /// This is useful for understanding query performance, debugging, and optimization.
421    ///
422    /// # Arguments
423    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
424    ///
425    /// # Returns
426    /// A formatted string containing the execution plan at multiple levels
427    ///
428    /// # Errors
429    /// Returns error if planning fails
430    ///
431    /// # Example
432    /// ```ignore
433    /// use std::collections::HashMap;
434    /// use arrow::record_batch::RecordBatch;
435    /// use lance_csr::query::CypherQuery;
436    ///
437    /// // Create in-memory datasets
438    /// let mut datasets = HashMap::new();
439    /// datasets.insert("Person".to_string(), person_batch);
440    /// datasets.insert("KNOWS".to_string(), knows_batch);
441    ///
442    /// let query = CypherQuery::parse("MATCH (p:Person) WHERE p.age > 30 RETURN p.name")?
443    ///     .with_config(config);
444    ///
445    /// let plan = query.explain(datasets).await?;
446    /// println!("{}", plan);
447    /// ```
448    pub async fn explain(
449        &self,
450        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
451    ) -> Result<String> {
452        use std::sync::Arc;
453
454        // Build catalog and context from datasets
455        let (catalog, ctx) = self
456            .build_catalog_and_context_from_datasets(datasets)
457            .await?;
458
459        // Delegate to the internal explain method
460        self.explain_internal(Arc::new(catalog), ctx).await
461    }
462
463    /// Convert the Cypher query to a SQL string in the specified dialect.
464    ///
465    /// This method generates a SQL string that corresponds to the DataFusion logical plan
466    /// derived from the Cypher query, using the specified SQL dialect for unparsing.
467    ///
468    /// **WARNING**: This method is experimental and the generated SQL dialect may change.
469    ///
470    /// **Note**: All table names and column names in the generated SQL are lowercased
471    /// (e.g., `Person` becomes `person`, `fullName` becomes `fullname`), consistent with
472    /// the system's case-insensitive identifier behavior.
473    ///
474    /// # Arguments
475    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
476    /// * `dialect` - The SQL dialect to use for generating the output SQL.
477    ///   Defaults to `SqlDialect::Default` (generic DataFusion SQL).
478    ///   Use `SqlDialect::Spark` for Spark SQL, `SqlDialect::PostgreSql`, etc.
479    ///
480    /// # Returns
481    /// A SQL string representing the query in the specified dialect
482    ///
483    /// # Errors
484    /// Returns `GraphError::UnsupportedFeature` for runtime-only Cypher functions
485    /// without a faithful SQL translation. Constant expressions may be folded first.
486    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        // Build catalog and context from datasets using the helper
502        let (catalog, ctx) = self
503            .build_catalog_and_context_from_datasets(datasets)
504            .await?;
505
506        // Generate Logical Plan
507        let (_, df_plan) = self.create_logical_plans(Arc::new(catalog))?;
508
509        // Optimize the plan using DataFusion's default optimizer rules
510        // This helps simplify the plan (e.g., merging projections) to produce cleaner SQL
511        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        // An SQL function with the same name need not implement Cypher semantics.
520        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                            // The unparser emits bare NULL for typed nulls, losing their Arrow type.
546                            // Only the Default dialect has arrow_cast; elsewhere bare NULL is the
547                            // closest SQL and yields the same null value, as exports always did.
548                            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        // Unparse to SQL using the specified dialect
570        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    /// Execute query with a DataFusion SessionContext, automatically building the catalog
583    ///
584    /// This is a convenience method that builds the graph catalog by querying the
585    /// SessionContext for table schemas. The GraphConfig determines which tables to
586    /// look up (node labels and relationship types).
587    ///
588    /// This method is ideal for integrating with DataFusion's rich data source ecosystem
589    /// (CSV, Parquet, Delta Lake, Iceberg, etc.) without manually building a catalog.
590    ///
591    /// # Arguments
592    /// * `ctx` - DataFusion SessionContext with pre-registered tables
593    ///
594    /// # Returns
595    /// Query results as an Arrow RecordBatch
596    ///
597    /// # Errors
598    /// Returns error if:
599    /// - GraphConfig is not set (use `.with_config()` first)
600    /// - Required tables are not registered in the SessionContext
601    /// - Query execution fails
602    ///
603    /// # Example
604    /// ```ignore
605    /// use datafusion::execution::context::SessionContext;
606    /// use datafusion::prelude::CsvReadOptions;
607    /// use lance_csr::{CypherQuery, GraphConfig};
608    ///
609    /// // Step 1: Create GraphConfig
610    /// let config = GraphConfig::builder()
611    ///     .node_table("Person", "person_id")
612    ///     .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
613    ///     .build()?;
614    ///
615    /// // Step 2: Register data sources in DataFusion
616    /// let ctx = SessionContext::new();
617    /// ctx.register_csv("Person", "data/persons.csv", CsvReadOptions::default()).await?;
618    /// ctx.register_parquet("KNOWS", "s3://bucket/knows.parquet", Default::default()).await?;
619    ///
620    /// // Step 3: Execute query (catalog is built automatically)
621    /// let query = CypherQuery::parse("MATCH (p:Person)-[:KNOWS]->(f) RETURN p.name")?
622    ///     .with_config(config);
623    /// let result = query.execute_with_context(ctx).await?;
624    /// ```
625    ///
626    /// # Note
627    /// The catalog is built by querying the SessionContext for schemas of tables
628    /// mentioned in the GraphConfig. Table names must match between GraphConfig
629    /// (node labels/relationship types) and SessionContext (registered table names).
630    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        // Build catalog by querying SessionContext for table providers
641        let mut catalog = InMemoryCatalog::new();
642
643        // Register node sources
644        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        // Register relationship sources
661        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        // Execute using the built catalog
678        self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
679            .await
680    }
681
682    /// Execute query with an explicit catalog and session context
683    ///
684    /// This is the most flexible API for advanced users who want to provide their own
685    /// catalog implementation or have fine-grained control over both the catalog and
686    /// session context.
687    ///
688    /// # Arguments
689    /// * `catalog` - Graph catalog containing node and relationship schemas for planning
690    /// * `ctx` - DataFusion SessionContext with registered data sources for execution
691    ///
692    /// # Returns
693    /// Query results as an Arrow RecordBatch
694    ///
695    /// # Errors
696    /// Returns error if query parsing, planning, or execution fails
697    ///
698    /// # Example
699    /// ```ignore
700    /// use std::sync::Arc;
701    /// use datafusion::execution::context::SessionContext;
702    /// use lance_csr::InMemoryCatalog;
703    /// use lance_csr::query::CypherQuery;
704    ///
705    /// // Create custom catalog
706    /// let catalog = InMemoryCatalog::new()
707    ///     .with_node_source("Person", custom_table_source);
708    ///
709    /// // Create SessionContext
710    /// let ctx = SessionContext::new();
711    /// ctx.register_table("Person", custom_table).unwrap();
712    ///
713    /// // Execute with explicit catalog and context
714    /// let query = CypherQuery::parse("MATCH (p:Person) RETURN p.name")?
715    ///     .with_config(config);
716    /// let result = query.execute_with_catalog_and_context(Arc::new(catalog), ctx).await?;
717    /// ```
718    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        // Capture the final schema after type coercion, exactly as DataFrame::collect
730        // creates its physical plan. UNION batches can differ in nullability, while
731        // the unoptimized logical schema can still have a narrower data type.
732        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 empty batch with the schema from the DataFrame
750            // This preserves column structure even when there are no rows
751            return Ok(arrow::record_batch::RecordBatch::new_empty(result_schema));
752        }
753
754        // UNION branches may emit batches with narrower nullability. The complete
755        // result schema must govern concatenation regardless of which branch finishes first.
756        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    /// Execute query with an explicit catalog and session context, returning a stream.
763    ///
764    /// Unlike [`Self::execute_with_catalog_and_context`], results are not collected:
765    /// batches are produced as the consumer polls, so memory is bounded by the plan's
766    /// operators (scans and filters stream; joins, sorts and aggregates may buffer).
767    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    /// Explain the plan with an explicit catalog and session context.
784    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    /// Plan the query (phases 1-3) and hand it to DataFusion (phase 4) without executing.
793    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    /// Execute using the DataFusion planner with in-memory datasets
809    ///
810    /// # Overview
811    /// This convenience method creates both a catalog and session context from the provided
812    /// in-memory RecordBatches. It's ideal for testing and small datasets that fit in memory.
813    ///
814    /// For production use with external data sources (CSV, Parquet, databases), use
815    /// `execute_with_context` instead, which automatically builds the catalog
816    /// from the SessionContext.
817    ///
818    /// # Arguments
819    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
820    ///
821    /// # Returns
822    /// A single RecordBatch containing the query results
823    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        // Build catalog and context from datasets
830        let (catalog, ctx) = self
831            .build_catalog_and_context_from_datasets(datasets)
832            .await?;
833
834        // Delegate to common execution logic
835        self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
836            .await
837    }
838
839    /// Helper to build catalog and context from in-memory datasets
840    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        // Create session context and catalog
860        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        // Register all datasets as tables
869        for (name, batch) in &datasets {
870            // Normalize the schema to lowercase field names
871            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            // Normalize table name to lowercase
885            let normalized_name = name.to_lowercase();
886
887            // Register in session context for execution
888            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            // Register as both node and relationship source with original name
897            // The config lookup is already case-insensitive, so we can use original name
898            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    /// Helper to build catalog and context using a namespace resolver
907    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        // Use original label/type names (not lowercase keys) for namespace resolution
924        // The namespace needs the original casing to find files on disk
925        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            // Register with lowercase table name for case-insensitive behavior
990            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            // Store provider with normalized (lowercase) key for consistent lookup
1001            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    /// Internal helper to explain the query execution plan with explicit catalog and session context
1038    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        // Create all plans (phases 1-4)
1044        let (logical_plan, df_logical_plan, physical_plan) =
1045            self.create_plans(catalog, &ctx).await?;
1046
1047        // Format the explain output
1048        self.format_explain_output(&logical_plan, &df_logical_plan, physical_plan.as_ref())
1049    }
1050
1051    /// Helper to create logical plans (graph logical, DataFusion logical)
1052    ///
1053    /// This performs phases 1-3 of query execution (semantic analysis, graph logical planning,
1054    /// DataFusion logical planning) without creating the physical plan.
1055    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        // Phase 1: Semantic Analysis
1099        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        // Phase 2: Graph Logical Plan
1109        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        // Phase 3: DataFusion Logical Plan
1120        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    /// Helper to create all plans (graph logical, DataFusion logical, physical)
1136    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        // Phases 1-3: Create logical plans
1146        let (logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
1147
1148        // Phase 4: DataFusion Physical Plan
1149        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    /// Format explain output as a table
1170    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        // Format output with query first, then table
1177        let mut output = String::new();
1178
1179        // Show Cypher query before the table
1180        output.push_str("Cypher Query:\n");
1181        output.push_str(&format!("  {}\n\n", self.query_text));
1182
1183        // Build table rows (without the query)
1184        let mut rows = vec![];
1185
1186        // Row 1: Graph Logical Plan
1187        let graph_plan_str = format!("{:#?}", logical_plan);
1188        rows.push(("graph_logical_plan", graph_plan_str));
1189
1190        // Row 2: DataFusion Logical Plan
1191        let df_logical_str = format!("{}", df_logical_plan.display_indent());
1192        rows.push(("logical_plan", df_logical_str));
1193
1194        // Row 3: DataFusion Physical Plan
1195        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        // Calculate column widths
1202        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        // Build table
1210        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        // Header
1220        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        // Data rows
1231        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                // First line with plan_type
1243                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                // Remaining lines with empty plan_type
1252                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    /// Get all node labels referenced in this query
1271    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    /// Get all relationship types referenced in this query
1290    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    /// Get all variables used in this query
1309    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    // Collection helper methods
1342
1343    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    /// Execute Cypher query, then apply vector search reranking on results
1403    ///
1404    /// This is a convenience method for the common GraphRAG pattern:
1405    /// 1. Run Cypher query to get candidate entities via graph traversal
1406    /// 2. Rerank candidates by vector similarity
1407    ///
1408    /// # Arguments
1409    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
1410    /// * `vector_search` - VectorSearch configuration for reranking
1411    ///
1412    /// # Returns
1413    /// A RecordBatch with the top-k results sorted by vector similarity
1414    ///
1415    /// # Example
1416    /// ```ignore
1417    /// use lance_csr::{CypherQuery, VectorSearch};
1418    /// use lance_csr::ast::DistanceMetric;
1419    ///
1420    /// let results = query
1421    ///     .execute_with_vector_rerank(
1422    ///         datasets,
1423    ///         VectorSearch::new("embedding")
1424    ///             .query_vector(query_vec)
1425    ///             .metric(DistanceMetric::Cosine)
1426    ///             .top_k(10)
1427    ///     )
1428    ///     .await?;
1429    /// ```
1430    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        // Step 1: Execute Cypher query to get candidates
1436        let candidates = self.execute(datasets, None).await?;
1437
1438        // Step 2: Apply vector search reranking
1439        vector_search.search(&candidates).await
1440    }
1441}
1442
1443/// Builder for constructing Cypher queries programmatically
1444#[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    /// Create a new query builder
1459    pub fn new() -> Self {
1460        Self::default()
1461    }
1462
1463    /// Add a MATCH clause for a node pattern
1464    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    /// Set the graph configuration
1482    pub fn with_config(mut self, config: GraphConfig) -> Self {
1483        self.config = Some(config);
1484        self
1485    }
1486
1487    /// Add a RETURN item
1488    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    /// Set DISTINCT flag
1500    pub fn distinct(mut self, distinct: bool) -> Self {
1501        self.distinct = distinct;
1502        self
1503    }
1504
1505    /// Add a LIMIT clause
1506    pub fn limit(mut self, limit: u64) -> Self {
1507        self.limit = Some(limit);
1508        self
1509    }
1510
1511    /// Add a SKIP clause
1512    pub fn skip(mut self, skip: u64) -> Self {
1513        self.skip = Some(skip);
1514        self
1515    }
1516
1517    /// Build the final CypherQuery
1518    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, // WITH not supported via builder yet
1543            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        // Generate query text from AST (simplified)
1563        let query_text = "MATCH ... RETURN ...".to_string(); // TODO: Implement AST->text conversion
1564
1565        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        // A fresh Tokio task supplies its own budget; no inherited source yield can pass this test.
1668        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        // A different catalog must never serve rows from the prepared snapshot.
1859        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    // Opt-in diagnostic: reports stage means for a fixed local scan, with no timing
1921    // assertions. Physical planning calls the query planner directly, avoiding the
1922    // extra optimize() performed by SessionState::create_physical_plan().
1923    #[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, &parameters)
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        // Build a simple batch: name (Utf8), age (Int64)
2067        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        // Expect Bob (34) and David (42)
2101        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        // People table: id, name, age
2115        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        // KNOWS relationship: src_person_id -> dst_person_id
2131        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])), // Alice -> Bob, Bob -> Carol
2139                Arc::new(Int64Array::from(vec![2, 3])),
2140            ],
2141        )
2142        .unwrap();
2143
2144        // Config: Person(id) and KNOWS(src_person_id -> dst_person_id)
2145        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        // Query: MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name
2158        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        // Register tables using labels / rel types as names
2164        data.insert("Person".to_string(), people);
2165        data.insert("KNOWS".to_string(), knows);
2166
2167        let out = q.execute(data, None).await.unwrap();
2168        // Expect two rows: Bob, Carol (the targets)
2169        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        // name, age (int)
2189        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        // Order ascending by age
2208        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        // Desc by age, skip 1 (drop 42), take 2 -> [34, 29]
2246        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        // Create test data
2300        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        // Test simple scan without filters
2320        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        // Execute using DataFusion pipeline
2328        let result = query.execute_datafusion(datasets).await.unwrap();
2329
2330        // Should return all rows
2331        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        // Verify data
2339        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        // Create test data
2361        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        // Create SessionContext and register data source
2377        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        // Create query
2383        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        // Execute with context (catalog built automatically)
2393        let result = query.execute_with_context(ctx).await.unwrap();
2394
2395        // Verify results
2396        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        // Create test data
2418        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        // Create SessionContext
2434        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        // Create query with filter
2440        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        // Execute with context
2450        let result = query.execute_with_context(ctx).await.unwrap();
2451
2452        // Verify: should return Bob (34) and David (42)
2453        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        // Create Person nodes
2484        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        // Create KNOWS relationships
2498        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        // Create SessionContext and register tables
2514        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        // Create query
2526        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        // Execute with context
2537        let result = query.execute_with_context(ctx).await.unwrap();
2538
2539        // Verify: should return 2 relationships (Alice->Bob, Bob->Carol)
2540        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        // Create test data
2576        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        // Create SessionContext
2592        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        // Create query with ORDER BY and LIMIT
2598        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        // Execute with context
2610        let result = query.execute_with_context(ctx).await.unwrap();
2611
2612        // Verify: should return top 2 scores (David: 95, Bob: 92)
2613        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        // First row should be David (95)
2628        assert_eq!(names.value(0), "David");
2629        assert_eq!(scores.value(0), 95);
2630
2631        // Second row should be Bob (92)
2632        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        // Note: DataFusion unparser might quote identifiers or use aliases
2667        // We check for "p.name" which is the expected output alias
2668        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        // Query referencing undefined variable 'x'
2855        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}