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
8pub use deadline::with_execution_deadline;
9
10use crate::ast::CypherQuery as CypherAST;
11use crate::ast::ReadingClause;
12use crate::config::GraphConfig;
13use crate::error::{GraphError, Result};
14use crate::logical_plan::LogicalPlanner;
15use crate::parser::parse_cypher_query;
16use crate::spark_dialect::build_spark_dialect;
17use arrow_array::RecordBatch;
18use arrow_schema::{Field, Schema, SchemaRef};
19use datafusion_sql::unparser::dialect::{
20    CustomDialect, DefaultDialect, MySqlDialect, PostgreSqlDialect, SqliteDialect,
21};
22use datafusion_sql::unparser::Unparser;
23use lance_csr_catalog::DirNamespace;
24use lance_namespace::models::DescribeTableRequest;
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27
28/// SQL dialect to use when generating SQL from Cypher queries.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum SqlDialect {
31    /// Generic SQL (DataFusion default dialect)
32    #[default]
33    Default,
34    /// Spark SQL dialect (backtick quoting, STRING type, EXTRACT, etc.)
35    Spark,
36    /// PostgreSQL dialect
37    PostgreSql,
38    /// MySQL dialect
39    MySql,
40    /// SQLite dialect
41    Sqlite,
42}
43
44/// Wrapper to hold the concrete dialect type and provide an `Unparser` reference.
45pub enum DialectUnparser {
46    Default(DefaultDialect),
47    Spark(Box<CustomDialect>),
48    PostgreSql(PostgreSqlDialect),
49    MySql(MySqlDialect),
50    Sqlite(SqliteDialect),
51}
52
53impl DialectUnparser {
54    pub fn as_unparser(&self) -> Unparser<'_> {
55        match self {
56            DialectUnparser::Default(d) => Unparser::new(d),
57            DialectUnparser::Spark(d) => Unparser::new(d.as_ref()),
58            DialectUnparser::PostgreSql(d) => Unparser::new(d),
59            DialectUnparser::MySql(d) => Unparser::new(d),
60            DialectUnparser::Sqlite(d) => Unparser::new(d),
61        }
62    }
63}
64
65impl SqlDialect {
66    /// Create a `DialectUnparser` configured for this dialect.
67    pub fn unparser(&self) -> DialectUnparser {
68        match self {
69            SqlDialect::Default => DialectUnparser::Default(DefaultDialect {}),
70            SqlDialect::Spark => DialectUnparser::Spark(Box::new(build_spark_dialect())),
71            SqlDialect::PostgreSql => DialectUnparser::PostgreSql(PostgreSqlDialect {}),
72            SqlDialect::MySql => DialectUnparser::MySql(MySqlDialect {}),
73            SqlDialect::Sqlite => DialectUnparser::Sqlite(SqliteDialect {}),
74        }
75    }
76}
77
78/// Normalize an Arrow schema to have lowercase field names.
79///
80/// This ensures that column names in the dataset match the normalized
81/// qualified column names used internally (e.g., "fullName" becomes "fullname").
82pub(crate) fn normalize_schema(schema: SchemaRef) -> Result<SchemaRef> {
83    let fields: Vec<_> = schema
84        .fields()
85        .iter()
86        .map(|f| {
87            Arc::new(Field::new(
88                f.name().to_lowercase(),
89                f.data_type().clone(),
90                f.is_nullable(),
91            ))
92        })
93        .collect();
94    Ok(Arc::new(Schema::new(fields)))
95}
96
97/// Normalize a RecordBatch to have lowercase column names.
98///
99/// This creates a new RecordBatch with a normalized schema while
100/// preserving all the data arrays.
101pub(crate) fn normalize_record_batch(batch: &RecordBatch) -> Result<RecordBatch> {
102    let normalized_schema = normalize_schema(batch.schema())?;
103    RecordBatch::try_new(normalized_schema, batch.columns().to_vec()).map_err(|e| {
104        GraphError::PlanError {
105            message: format!("Failed to normalize record batch schema: {}", e),
106            location: snafu::Location::new(file!(), line!(), column!()),
107        }
108    })
109}
110
111/// Build a query-local frame without unsafe distinct-count statistics shortcuts.
112fn query_dataframe(
113    ctx: &datafusion::execution::context::SessionContext,
114    plan: datafusion::logical_expr::LogicalPlan,
115) -> datafusion::dataframe::DataFrame {
116    use datafusion::execution::session_state::SessionStateBuilder;
117    use datafusion::logical_expr::{Expr, LogicalPlan};
118
119    fn has_distinct_count(plan: &LogicalPlan) -> bool {
120        if let LogicalPlan::Aggregate(aggregate) = plan {
121            if aggregate.aggr_expr.iter().any(|expr| {
122                matches!(expr.clone().unalias(), Expr::AggregateFunction(f)
123                    if f.func.name() == "count" && f.params.distinct)
124            }) {
125                return true;
126            }
127        }
128        plan.inputs().into_iter().any(has_distinct_count)
129    }
130
131    let mut state = ctx.state();
132    if has_distinct_count(&plan) {
133        // shortcut: DataFusion 54 treats filtered distinct estimates as exact; remove
134        // when upstream COUNT DISTINCT statistics preserve empty-input semantics.
135        let rules = state
136            .physical_optimizers()
137            .iter()
138            .filter(|rule| rule.name() != "aggregate_statistics")
139            .cloned()
140            .collect();
141        state = SessionStateBuilder::from(state)
142            .with_physical_optimizer_rules(rules)
143            .build();
144    }
145    datafusion::dataframe::DataFrame::new(state, plan)
146}
147
148/// Execution strategy for Cypher queries
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum ExecutionStrategy {
151    /// Use DataFusion query planner (default, full feature support)
152    #[default]
153    DataFusion,
154    /// Use Lance native executor (not yet implemented)
155    LanceNative,
156}
157
158/// A Cypher query that can be executed against Lance datasets
159#[derive(Debug, Clone)]
160pub struct CypherQuery {
161    /// The original Cypher query string
162    query_text: String,
163    /// Parsed AST representation
164    ast: CypherAST,
165    /// Graph configuration for mapping
166    config: Option<GraphConfig>,
167    /// Query parameters
168    parameters: HashMap<String, serde_json::Value>,
169    /// Optional CSR adjacencies; when set, supported single-hop expansions use them
170    adjacency: Option<crate::csr_expand::AdjacencyCatalog>,
171    /// Optional mean fan-out per relationship type and direction; see [`FanOut`]
172    fan_out: Option<crate::logical_plan::FanOut>,
173    /// An unoptimized template belonging to the caller's catalog snapshot.
174    cached_plan: Option<Arc<CachedPlan>>,
175}
176struct CachedPlan {
177    graph: crate::logical_plan::LogicalOperator,
178    datafusion: datafusion::logical_expr::LogicalPlan,
179    catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
180    parameter_types: HashMap<String, arrow_schema::DataType>,
181}
182
183impl std::fmt::Debug for CachedPlan {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.debug_struct("CachedPlan")
186            .field("parameter_types", &self.parameter_types)
187            .finish_non_exhaustive()
188    }
189}
190
191impl CypherQuery {
192    /// Create a new Cypher query from a query string
193    pub fn new(query: &str) -> Result<Self> {
194        let ast = parse_cypher_query(query)?;
195
196        Ok(Self {
197            query_text: query.to_string(),
198            ast,
199            config: None,
200            parameters: HashMap::new(),
201            adjacency: None,
202            fan_out: None,
203            cached_plan: None,
204        })
205    }
206
207    /// Set the graph configuration for this query
208    pub fn with_config(mut self, config: GraphConfig) -> Self {
209        self.cached_plan = None;
210        self.config = Some(config);
211        self
212    }
213
214    /// Plan supported single-hop expansions with CSR lookups instead of joins.
215    ///
216    /// The `SessionContext` used for execution must install
217    /// [`crate::csr_expand::CsrQueryPlanner`]. Each adjacency must be built from the
218    /// same relationship data the context serves, or results will be wrong.
219    pub fn with_adjacency(mut self, adjacency: crate::csr_expand::AdjacencyCatalog) -> Self {
220        self.cached_plan = None;
221        self.adjacency = Some(adjacency);
222        self
223    }
224
225    /// Mean fan-out per relationship type and direction, so a pattern that continues
226    /// earlier rows can start from its cheaper bound end (see [`crate::logical_plan::FanOut`]).
227    pub fn with_fan_out(mut self, fan_out: crate::logical_plan::FanOut) -> Self {
228        self.cached_plan = None;
229        self.fan_out = Some(fan_out);
230        self
231    }
232
233    /// Add a parameter to the query
234    pub fn with_parameter<K, V>(mut self, key: K, value: V) -> Self
235    where
236        K: Into<String>,
237        V: Into<serde_json::Value>,
238    {
239        self.parameters
240            .insert(key.into().to_lowercase(), value.into());
241        self
242    }
243
244    /// Add multiple parameters to the query
245    pub fn with_parameters(mut self, params: HashMap<String, serde_json::Value>) -> Self {
246        for (k, v) in params {
247            self.parameters.insert(k.to_lowercase(), v);
248        }
249        self
250    }
251
252    /// Get the original query text
253    pub fn query_text(&self) -> &str {
254        &self.query_text
255    }
256
257    /// Get the parsed AST
258    pub fn ast(&self) -> &CypherAST {
259        &self.ast
260    }
261
262    /// Get the graph configuration
263    pub fn config(&self) -> Option<&GraphConfig> {
264        self.config.as_ref()
265    }
266
267    /// Get query parameters
268    pub fn parameters(&self) -> &HashMap<String, serde_json::Value> {
269        &self.parameters
270    }
271
272    /// Build a snapshot-local scalar-parameter template for the Python engine's cache.
273    /// Binding must precede optimization so pushed CSR filters never retain old values.
274    #[doc(hidden)]
275    pub fn prepare_cached_plan(
276        &mut self,
277        catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
278    ) -> Result<bool> {
279        // Rich path properties live in an extension-owned spec, and its inventory is
280        // query-local. Keep ordinary substitution and a fresh inventory for each query.
281        let mut shape_sensitive = false;
282        self.ast.for_each_query(&mut |query| {
283            shape_sensitive |= query
284                .limit
285                .iter()
286                .chain(query.skip.iter())
287                .chain(
288                    query
289                        .with_clause
290                        .iter()
291                        .filter_map(|clause| clause.limit.as_ref()),
292                )
293                .chain(
294                    query
295                        .further_with_stages
296                        .iter()
297                        .filter_map(|stage| stage.with_clause.limit.as_ref()),
298                )
299                .any(|count| matches!(count, crate::ast::RowCount::Parameter(_)))
300                || query
301                    .all_reading_clauses()
302                    .any(|clause| matches!(clause, ReadingClause::Call(_)));
303        });
304        if shape_sensitive
305            || self.scalar_parameters().is_none()
306            || crate::path_value::needs_values(&self.ast, self.require_config()?)
307        {
308            // CALL binds its own child templates; row counts alter plan shape.
309            return Ok(false);
310        }
311        let (graph, datafusion) = self.build_logical_plans(catalog.clone(), true)?;
312        self.cached_plan = Some(Arc::new(CachedPlan {
313            graph,
314            datafusion,
315            catalog,
316            parameter_types: self
317                .scalar_parameters()
318                .unwrap()
319                .into_iter()
320                .map(|(name, value)| (name, value.data_type()))
321                .collect(),
322        }));
323        Ok(true)
324    }
325
326    fn scalar_parameters(&self) -> Option<HashMap<String, datafusion::common::ScalarValue>> {
327        use datafusion::common::ScalarValue;
328        self.parameters
329            .iter()
330            .map(|(name, value)| {
331                let value = match value {
332                    serde_json::Value::Null => ScalarValue::Null,
333                    serde_json::Value::Bool(value) => ScalarValue::Boolean(Some(*value)),
334                    serde_json::Value::String(value) => ScalarValue::Utf8(Some(value.clone())),
335                    serde_json::Value::Number(value) if value.is_i64() => {
336                        ScalarValue::Int64(value.as_i64())
337                    }
338                    serde_json::Value::Number(value) if value.is_f64() => {
339                        ScalarValue::Float64(value.as_f64())
340                    }
341                    _ => return None,
342                };
343                Some((name.clone(), value))
344            })
345            .collect()
346    }
347
348    /// Get the required config, returning an error if not set
349    fn require_config(&self) -> Result<&GraphConfig> {
350        self.config.as_ref().ok_or_else(|| GraphError::ConfigError {
351            message: "Graph configuration is required for query execution".to_string(),
352            location: snafu::Location::new(file!(), line!(), column!()),
353        })
354    }
355
356    /// Execute the query against provided in-memory datasets
357    ///
358    /// This method uses the DataFusion planner by default for comprehensive query support
359    /// including joins, aggregations, and complex patterns. You can optionally specify
360    /// a different execution strategy.
361    ///
362    /// # Arguments
363    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
364    /// * `strategy` - Optional execution strategy (defaults to DataFusion)
365    ///
366    /// # Returns
367    /// A single RecordBatch containing the query results
368    ///
369    /// # Errors
370    /// Returns error if query parsing, planning, or execution fails
371    ///
372    /// # Example
373    /// ```ignore
374    /// use std::collections::HashMap;
375    /// use arrow::record_batch::RecordBatch;
376    /// use lance_csr::query::CypherQuery;
377    ///
378    /// // Create in-memory datasets
379    /// let mut datasets = HashMap::new();
380    /// datasets.insert("Person".to_string(), person_batch);
381    /// datasets.insert("KNOWS".to_string(), knows_batch);
382    ///
383    /// // Parse and execute query
384    /// let query = CypherQuery::parse("MATCH (p:Person)-[:KNOWS]->(f) RETURN p.name, f.name")?
385    ///     .with_config(config);
386    /// // Use the default DataFusion strategy
387    /// let result = query.execute(datasets, None).await?;
388    /// ```
389    pub async fn execute(
390        &self,
391        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
392        strategy: Option<ExecutionStrategy>,
393    ) -> Result<arrow::record_batch::RecordBatch> {
394        let strategy = strategy.unwrap_or_default();
395        match strategy {
396            ExecutionStrategy::DataFusion => self.execute_datafusion(datasets).await,
397            ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
398                feature: "Lance native execution strategy is not yet implemented".to_string(),
399                location: snafu::Location::new(file!(), line!(), column!()),
400            }),
401        }
402    }
403
404    /// Execute the query using a namespace-backed table resolver.
405    ///
406    /// The namespace is provided by value and will be shared internally as needed.
407    pub async fn execute_with_namespace(
408        &self,
409        namespace: DirNamespace,
410        strategy: Option<ExecutionStrategy>,
411    ) -> Result<arrow::record_batch::RecordBatch> {
412        self.execute_with_namespace_arc(std::sync::Arc::new(namespace), strategy)
413            .await
414    }
415
416    /// Execute the query using a shared namespace instance.
417    pub async fn execute_with_namespace_arc(
418        &self,
419        namespace: std::sync::Arc<DirNamespace>,
420        strategy: Option<ExecutionStrategy>,
421    ) -> Result<arrow::record_batch::RecordBatch> {
422        let namespace_trait: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync> =
423            namespace;
424        self.execute_with_namespace_internal(namespace_trait, strategy)
425            .await
426    }
427
428    async fn execute_with_namespace_internal(
429        &self,
430        namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
431        strategy: Option<ExecutionStrategy>,
432    ) -> Result<arrow::record_batch::RecordBatch> {
433        let strategy = strategy.unwrap_or_default();
434        match strategy {
435            ExecutionStrategy::DataFusion => {
436                let (catalog, ctx) = self
437                    .build_catalog_and_context_from_namespace(namespace)
438                    .await?;
439                self.execute_with_catalog_and_context(std::sync::Arc::new(catalog), ctx)
440                    .await
441            }
442            ExecutionStrategy::LanceNative => Err(GraphError::UnsupportedFeature {
443                feature: "Lance native execution strategy is not yet implemented".to_string(),
444                location: snafu::Location::new(file!(), line!(), column!()),
445            }),
446        }
447    }
448
449    /// Explain the query execution plan using in-memory datasets
450    ///
451    /// Returns a formatted string showing the query execution plan at different stages:
452    /// - Graph Logical Plan (graph-specific operators)
453    /// - DataFusion Logical Plan (optimized relational plan)
454    /// - DataFusion Physical Plan (execution plan with optimizations)
455    ///
456    /// This is useful for understanding query performance, debugging, and optimization.
457    ///
458    /// # Arguments
459    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
460    ///
461    /// # Returns
462    /// A formatted string containing the execution plan at multiple levels
463    ///
464    /// # Errors
465    /// Returns error if planning fails
466    ///
467    /// # Example
468    /// ```ignore
469    /// use std::collections::HashMap;
470    /// use arrow::record_batch::RecordBatch;
471    /// use lance_csr::query::CypherQuery;
472    ///
473    /// // Create in-memory datasets
474    /// let mut datasets = HashMap::new();
475    /// datasets.insert("Person".to_string(), person_batch);
476    /// datasets.insert("KNOWS".to_string(), knows_batch);
477    ///
478    /// let query = CypherQuery::parse("MATCH (p:Person) WHERE p.age > 30 RETURN p.name")?
479    ///     .with_config(config);
480    ///
481    /// let plan = query.explain(datasets).await?;
482    /// println!("{}", plan);
483    /// ```
484    pub async fn explain(
485        &self,
486        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
487    ) -> Result<String> {
488        use std::sync::Arc;
489
490        // Build catalog and context from datasets
491        let (catalog, ctx) = self
492            .build_catalog_and_context_from_datasets(datasets)
493            .await?;
494
495        // Delegate to the internal explain method
496        self.explain_internal(Arc::new(catalog), ctx).await
497    }
498
499    /// Convert the Cypher query to a SQL string in the specified dialect.
500    ///
501    /// This method generates a SQL string that corresponds to the DataFusion logical plan
502    /// derived from the Cypher query, using the specified SQL dialect for unparsing.
503    ///
504    /// **WARNING**: This method is experimental and the generated SQL dialect may change.
505    ///
506    /// **Note**: All table names and column names in the generated SQL are lowercased
507    /// (e.g., `Person` becomes `person`, `fullName` becomes `fullname`), consistent with
508    /// the system's case-insensitive identifier behavior.
509    ///
510    /// # Arguments
511    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
512    /// * `dialect` - The SQL dialect to use for generating the output SQL.
513    ///   Defaults to `SqlDialect::Default` (generic DataFusion SQL).
514    ///   Use `SqlDialect::Spark` for Spark SQL, `SqlDialect::PostgreSql`, etc.
515    ///
516    /// # Returns
517    /// A SQL string representing the query in the specified dialect
518    ///
519    /// # Errors
520    /// Returns `GraphError::UnsupportedFeature` for runtime-only Cypher functions
521    /// without a faithful SQL translation. Constant expressions may be folded first.
522    pub async fn to_sql(
523        &self,
524        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
525        dialect: Option<SqlDialect>,
526    ) -> Result<String> {
527        use datafusion::common::tree_node::{Transformed, TreeNode};
528        use datafusion::common::{not_impl_err, DFSchema, ScalarValue};
529        use datafusion::logical_expr::expr_rewriter::NamePreserver;
530        use datafusion::logical_expr::registry::FunctionRegistry;
531        use datafusion::logical_expr::{lit, utils::merge_schema, Expr, LogicalPlan};
532        use std::sync::Arc;
533
534        let dialect = dialect.unwrap_or_default();
535        let _config = self.require_config()?;
536
537        // Build catalog and context from datasets using the helper
538        let (catalog, ctx) = self
539            .build_catalog_and_context_from_datasets(datasets)
540            .await?;
541
542        // Generate Logical Plan
543        let (_, df_plan) = self.create_logical_plans(Arc::new(catalog))?;
544
545        // Optimize the plan using DataFusion's default optimizer rules
546        // This helps simplify the plan (e.g., merging projections) to produce cleaner SQL
547        let optimized_plan = ctx
548            .state()
549            .optimize(&df_plan)
550            .map_err(|e| GraphError::PlanError {
551                message: format!("Failed to optimize plan: {}", e),
552                location: snafu::Location::new(file!(), line!(), column!()),
553            })?;
554
555        // An SQL function with the same name need not implement Cypher semantics.
556        let optimized_plan = optimized_plan
557            .transform_up_with_subqueries(|plan| {
558                let schema = if let LogicalPlan::TableScan(scan) = &plan {
559                    DFSchema::try_from_qualified_schema(scan.table_name.clone(), &scan.source.schema())?
560                } else {
561                    merge_schema(&plan.inputs())
562                };
563                let names = NamePreserver::new(&plan);
564                plan.map_expressions(|expr| {
565                    let name = names.save(&expr);
566                    let rewritten = expr.transform_up(|expr| {
567                        if let Expr::ScalarFunction(function) = &expr {
568                            if let Some(native) = crate::datafusion_planner::sql_expr(function, &schema)? {
569                                return Ok(Transformed::yes(native));
570                            }
571                            if !ctx.udf(function.name()).is_ok_and(|registered| registered == function.func) {
572                                return not_impl_err!(
573                                    "SQL export of Cypher function '{}' is unsupported; execute the Cypher query directly",
574                                    function.name()
575                                );
576                            }
577                        }
578                        if let Expr::Literal(value, _) = &expr {
579                            let data_type = value.data_type();
580                            let type_name = data_type.to_string();
581                            // The unparser emits bare NULL for typed nulls, losing their Arrow type.
582                            // Only the Default dialect has arrow_cast; elsewhere bare NULL is the
583                            // closest SQL and yields the same null value, as exports always did.
584                            if value.is_null()
585                                && !data_type.is_null()
586                                && dialect == SqlDialect::Default
587                                && type_name.parse::<arrow_schema::DataType>().is_ok_and(|parsed| parsed == data_type)
588                            {
589                                return Ok(Transformed::yes(datafusion::functions::core::arrow_cast().call(vec![
590                                    lit(ScalarValue::Null), lit(type_name),
591                                ])));
592                            }
593                        }
594                        Ok(Transformed::no(expr))
595                    })?;
596                    Ok(rewritten.update_data(|expr| name.restore(expr)))
597                })
598            })
599            .map_err(|error| GraphError::UnsupportedFeature {
600                feature: error.to_string(),
601                location: snafu::Location::new(file!(), line!(), column!()),
602            })?
603            .data;
604
605        // Unparse to SQL using the specified dialect
606        let dialect_unparser = dialect.unparser();
607        let unparser = dialect_unparser.as_unparser();
608        let sql_ast = unparser
609            .plan_to_sql(&optimized_plan)
610            .map_err(|e| GraphError::PlanError {
611                message: format!("Failed to unparse plan to SQL: {}", e),
612                location: snafu::Location::new(file!(), line!(), column!()),
613            })?;
614
615        Ok(sql_ast.to_string())
616    }
617
618    /// Execute query with a DataFusion SessionContext, automatically building the catalog
619    ///
620    /// This is a convenience method that builds the graph catalog by querying the
621    /// SessionContext for table schemas. The GraphConfig determines which tables to
622    /// look up (node labels and relationship types).
623    ///
624    /// This method is ideal for integrating with DataFusion's rich data source ecosystem
625    /// (CSV, Parquet, Delta Lake, Iceberg, etc.) without manually building a catalog.
626    ///
627    /// # Arguments
628    /// * `ctx` - DataFusion SessionContext with pre-registered tables
629    ///
630    /// # Returns
631    /// Query results as an Arrow RecordBatch
632    ///
633    /// # Errors
634    /// Returns error if:
635    /// - GraphConfig is not set (use `.with_config()` first)
636    /// - Required tables are not registered in the SessionContext
637    /// - Query execution fails
638    ///
639    /// # Example
640    /// ```ignore
641    /// use datafusion::execution::context::SessionContext;
642    /// use datafusion::prelude::CsvReadOptions;
643    /// use lance_csr::{CypherQuery, GraphConfig};
644    ///
645    /// // Step 1: Create GraphConfig
646    /// let config = GraphConfig::builder()
647    ///     .node_table("Person", "person_id")
648    ///     .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
649    ///     .build()?;
650    ///
651    /// // Step 2: Register data sources in DataFusion
652    /// let ctx = SessionContext::new();
653    /// ctx.register_csv("Person", "data/persons.csv", CsvReadOptions::default()).await?;
654    /// ctx.register_parquet("KNOWS", "s3://bucket/knows.parquet", Default::default()).await?;
655    ///
656    /// // Step 3: Execute query (catalog is built automatically)
657    /// let query = CypherQuery::parse("MATCH (p:Person)-[:KNOWS]->(f) RETURN p.name")?
658    ///     .with_config(config);
659    /// let result = query.execute_with_context(ctx).await?;
660    /// ```
661    ///
662    /// # Note
663    /// The catalog is built by querying the SessionContext for schemas of tables
664    /// mentioned in the GraphConfig. Table names must match between GraphConfig
665    /// (node labels/relationship types) and SessionContext (registered table names).
666    pub async fn execute_with_context(
667        &self,
668        ctx: datafusion::execution::context::SessionContext,
669    ) -> Result<arrow::record_batch::RecordBatch> {
670        use datafusion::datasource::DefaultTableSource;
671        use lance_csr_catalog::InMemoryCatalog;
672        use std::sync::Arc;
673
674        let config = self.require_config()?;
675
676        // Build catalog by querying SessionContext for table providers
677        let mut catalog = InMemoryCatalog::new();
678
679        // Register node sources
680        for (label, _) in config.iter_node_mappings() {
681            let table_provider =
682                ctx.table_provider(label)
683                    .await
684                    .map_err(|e| GraphError::ConfigError {
685                        message: format!(
686                            "Node label '{}' not found in SessionContext: {}",
687                            label, e
688                        ),
689                        location: snafu::Location::new(file!(), line!(), column!()),
690                    })?;
691
692            let table_source = Arc::new(DefaultTableSource::new(table_provider));
693            catalog = catalog.with_node_source(label, table_source);
694        }
695
696        // Register relationship sources
697        for (rel_type, _) in config.iter_relationship_mappings() {
698            let table_provider =
699                ctx.table_provider(rel_type)
700                    .await
701                    .map_err(|e| GraphError::ConfigError {
702                        message: format!(
703                            "Relationship type '{}' not found in SessionContext: {}",
704                            rel_type, e
705                        ),
706                        location: snafu::Location::new(file!(), line!(), column!()),
707                    })?;
708
709            let table_source = Arc::new(DefaultTableSource::new(table_provider));
710            catalog = catalog.with_relationship_source(rel_type, table_source);
711        }
712
713        // Execute using the built catalog
714        self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
715            .await
716    }
717
718    /// Execute query with an explicit catalog and session context
719    ///
720    /// This is the most flexible API for advanced users who want to provide their own
721    /// catalog implementation or have fine-grained control over both the catalog and
722    /// session context.
723    ///
724    /// # Arguments
725    /// * `catalog` - Graph catalog containing node and relationship schemas for planning
726    /// * `ctx` - DataFusion SessionContext with registered data sources for execution
727    ///
728    /// # Returns
729    /// Query results as an Arrow RecordBatch
730    ///
731    /// # Errors
732    /// Returns error if query parsing, planning, or execution fails
733    ///
734    /// # Example
735    /// ```ignore
736    /// use std::sync::Arc;
737    /// use datafusion::execution::context::SessionContext;
738    /// use lance_csr::InMemoryCatalog;
739    /// use lance_csr::query::CypherQuery;
740    ///
741    /// // Create custom catalog
742    /// let catalog = InMemoryCatalog::new()
743    ///     .with_node_source("Person", custom_table_source);
744    ///
745    /// // Create SessionContext
746    /// let ctx = SessionContext::new();
747    /// ctx.register_table("Person", custom_table).unwrap();
748    ///
749    /// // Execute with explicit catalog and context
750    /// let query = CypherQuery::parse("MATCH (p:Person) RETURN p.name")?
751    ///     .with_config(config);
752    /// let result = query.execute_with_catalog_and_context(Arc::new(catalog), ctx).await?;
753    /// ```
754    pub async fn execute_with_catalog_and_context(
755        &self,
756        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
757        ctx: datafusion::execution::context::SessionContext,
758    ) -> Result<arrow::record_batch::RecordBatch> {
759        use arrow::compute::concat_batches;
760
761        let df = self
762            .dataframe_with_catalog_and_context(catalog, ctx)
763            .await?;
764
765        // Capture the final schema after type coercion, exactly as DataFrame::collect
766        // creates its physical plan. UNION batches can differ in nullability, while
767        // the unoptimized logical schema can still have a narrower data type.
768        let task_context = Arc::new(df.task_ctx());
769        let physical = df
770            .create_physical_plan()
771            .await
772            .map_err(|e| GraphError::ExecutionError {
773                message: format!("Failed to plan query results: {e}"),
774                location: snafu::Location::new(file!(), line!(), column!()),
775            })?;
776        let result_schema = physical.schema();
777        let batches = datafusion::physical_plan::collect(physical, task_context)
778            .await
779            .map_err(|e| GraphError::ExecutionError {
780                message: format!("Failed to collect query results: {e}"),
781                location: snafu::Location::new(file!(), line!(), column!()),
782            })?;
783
784        if batches.is_empty() {
785            // Return empty batch with the schema from the DataFrame
786            // This preserves column structure even when there are no rows
787            return Ok(arrow::record_batch::RecordBatch::new_empty(result_schema));
788        }
789
790        // UNION branches may emit batches with narrower nullability. The complete
791        // result schema must govern concatenation regardless of which branch finishes first.
792        concat_batches(&result_schema, &batches).map_err(|e| GraphError::ExecutionError {
793            message: format!("Failed to concatenate result batches: {}", e),
794            location: snafu::Location::new(file!(), line!(), column!()),
795        })
796    }
797
798    /// Execute query with an explicit catalog and session context, returning a stream.
799    ///
800    /// Unlike [`Self::execute_with_catalog_and_context`], results are not collected:
801    /// batches are produced as the consumer polls, so memory is bounded by the plan's
802    /// operators (scans and filters stream; joins, sorts and aggregates may buffer).
803    pub async fn execute_stream_with_catalog_and_context(
804        &self,
805        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
806        ctx: datafusion::execution::context::SessionContext,
807    ) -> Result<datafusion::physical_plan::SendableRecordBatchStream> {
808        let df = self
809            .dataframe_with_catalog_and_context(catalog, ctx)
810            .await?;
811        df.execute_stream()
812            .await
813            .map_err(|e| GraphError::ExecutionError {
814                message: format!("Failed to start query stream: {}", e),
815                location: snafu::Location::new(file!(), line!(), column!()),
816            })
817    }
818
819    /// Explain the plan with an explicit catalog and session context.
820    pub async fn explain_with_catalog_and_context(
821        &self,
822        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
823        ctx: datafusion::execution::context::SessionContext,
824    ) -> Result<String> {
825        self.explain_internal(catalog, ctx).await
826    }
827
828    /// Plan the query (phases 1-3) and hand it to DataFusion (phase 4) without executing.
829    async fn dataframe_with_catalog_and_context(
830        &self,
831        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
832        ctx: datafusion::execution::context::SessionContext,
833    ) -> Result<datafusion::dataframe::DataFrame> {
834        let (_logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
835        let ctx = deadline::context(ctx);
836        Ok(query_dataframe(&ctx, df_logical_plan))
837    }
838
839    /// Execute using the DataFusion planner with in-memory datasets
840    ///
841    /// # Overview
842    /// This convenience method creates both a catalog and session context from the provided
843    /// in-memory RecordBatches. It's ideal for testing and small datasets that fit in memory.
844    ///
845    /// For production use with external data sources (CSV, Parquet, databases), use
846    /// `execute_with_context` instead, which automatically builds the catalog
847    /// from the SessionContext.
848    ///
849    /// # Arguments
850    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
851    ///
852    /// # Returns
853    /// A single RecordBatch containing the query results
854    async fn execute_datafusion(
855        &self,
856        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
857    ) -> Result<arrow::record_batch::RecordBatch> {
858        use std::sync::Arc;
859
860        // Build catalog and context from datasets
861        let (catalog, ctx) = self
862            .build_catalog_and_context_from_datasets(datasets)
863            .await?;
864
865        // Delegate to common execution logic
866        self.execute_with_catalog_and_context(Arc::new(catalog), ctx)
867            .await
868    }
869
870    /// Helper to build catalog and context from in-memory datasets
871    async fn build_catalog_and_context_from_datasets(
872        &self,
873        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
874    ) -> Result<(
875        lance_csr_catalog::InMemoryCatalog,
876        datafusion::execution::context::SessionContext,
877    )> {
878        use datafusion::datasource::{DefaultTableSource, MemTable};
879        use datafusion::execution::context::SessionContext;
880        use lance_csr_catalog::InMemoryCatalog;
881        use std::sync::Arc;
882
883        if datasets.is_empty() {
884            return Err(GraphError::ConfigError {
885                message: "No input datasets provided".to_string(),
886                location: snafu::Location::new(file!(), line!(), column!()),
887            });
888        }
889
890        // Create session context and catalog
891        let ctx = SessionContext::new_with_state(
892            datafusion::execution::session_state::SessionStateBuilder::new()
893                .with_default_features()
894                .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
895                .build(),
896        );
897        let mut catalog = InMemoryCatalog::new();
898
899        // Register all datasets as tables
900        for (name, batch) in &datasets {
901            // Normalize the schema to lowercase field names
902            let normalized_batch = normalize_record_batch(batch)?;
903
904            let mem_table = Arc::new(
905                MemTable::try_new(
906                    normalized_batch.schema(),
907                    vec![vec![normalized_batch.clone()]],
908                )
909                .map_err(|e| GraphError::PlanError {
910                    message: format!("Failed to create MemTable for {}: {}", name, e),
911                    location: snafu::Location::new(file!(), line!(), column!()),
912                })?,
913            );
914
915            // Normalize table name to lowercase
916            let normalized_name = name.to_lowercase();
917
918            // Register in session context for execution
919            ctx.register_table(&normalized_name, mem_table.clone())
920                .map_err(|e| GraphError::PlanError {
921                    message: format!("Failed to register table {}: {}", name, e),
922                    location: snafu::Location::new(file!(), line!(), column!()),
923                })?;
924
925            let table_source = Arc::new(DefaultTableSource::new(mem_table));
926
927            // Register as both node and relationship source with original name
928            // The config lookup is already case-insensitive, so we can use original name
929            catalog = catalog
930                .with_node_source(name, table_source.clone())
931                .with_relationship_source(name, table_source);
932        }
933
934        Ok((catalog, ctx))
935    }
936
937    /// Helper to build catalog and context using a namespace resolver
938    async fn build_catalog_and_context_from_namespace(
939        &self,
940        namespace: std::sync::Arc<dyn lance_namespace::LanceNamespace + Send + Sync>,
941    ) -> Result<(
942        lance_csr_catalog::InMemoryCatalog,
943        datafusion::execution::context::SessionContext,
944    )> {
945        use datafusion::datasource::{DefaultTableSource, TableProvider};
946        use datafusion::execution::context::SessionContext;
947        use lance::datafusion::LanceTableProvider;
948        use lance_csr_catalog::InMemoryCatalog;
949        use std::sync::Arc;
950
951        let config = self.require_config()?;
952
953        let mut required_tables: HashSet<String> = HashSet::new();
954        // Use original label/type names (not lowercase keys) for namespace resolution
955        // The namespace needs the original casing to find files on disk
956        required_tables.extend(
957            config
958                .iter_node_mappings()
959                .map(|(_, mapping)| mapping.label.clone()),
960        );
961        required_tables.extend(
962            config
963                .iter_relationship_mappings()
964                .map(|(_, mapping)| mapping.relationship_type.clone()),
965        );
966
967        if required_tables.is_empty() {
968            return Err(GraphError::ConfigError {
969                message:
970                    "Graph configuration does not reference any node labels or relationship types"
971                        .to_string(),
972                location: snafu::Location::new(file!(), line!(), column!()),
973            });
974        }
975
976        let ctx = SessionContext::new_with_state(
977            datafusion::execution::session_state::SessionStateBuilder::new()
978                .with_default_features()
979                .with_query_planner(Arc::new(crate::csr_expand::CsrQueryPlanner))
980                .build(),
981        );
982        let mut catalog = InMemoryCatalog::new();
983        let mut providers: HashMap<String, Arc<dyn TableProvider>> = HashMap::new();
984
985        for table_name in required_tables {
986            let mut request = DescribeTableRequest::new();
987            request.id = Some(vec![table_name.clone()]);
988
989            let response =
990                namespace
991                    .describe_table(request)
992                    .await
993                    .map_err(|e| GraphError::ConfigError {
994                        message: format!(
995                            "Namespace failed to resolve table '{}': {}",
996                            table_name, e
997                        ),
998                        location: snafu::Location::new(file!(), line!(), column!()),
999                    })?;
1000
1001            let location = response.location.ok_or_else(|| GraphError::ConfigError {
1002                message: format!(
1003                    "Namespace did not provide a location for table '{}'",
1004                    table_name
1005                ),
1006                location: snafu::Location::new(file!(), line!(), column!()),
1007            })?;
1008
1009            let dataset = lance::dataset::Dataset::open(&location)
1010                .await
1011                .map_err(|e| GraphError::ConfigError {
1012                    message: format!("Failed to open dataset for table '{}': {}", table_name, e),
1013                    location: snafu::Location::new(file!(), line!(), column!()),
1014                })?;
1015
1016            let dataset = Arc::new(dataset);
1017            let provider: Arc<dyn TableProvider> =
1018                Arc::new(LanceTableProvider::new(dataset.clone(), true, true));
1019
1020            // Register with lowercase table name for case-insensitive behavior
1021            let normalized_table_name = table_name.to_lowercase();
1022            ctx.register_table(&normalized_table_name, provider.clone())
1023                .map_err(|e| GraphError::PlanError {
1024                    message: format!(
1025                        "Failed to register table '{}' in SessionContext: {}",
1026                        table_name, e
1027                    ),
1028                    location: snafu::Location::new(file!(), line!(), column!()),
1029                })?;
1030
1031            // Store provider with normalized (lowercase) key for consistent lookup
1032            providers.insert(normalized_table_name.clone(), provider);
1033        }
1034
1035        for (label, _) in config.iter_node_mappings() {
1036            let provider = providers
1037                .get(label)
1038                .ok_or_else(|| GraphError::ConfigError {
1039                    message: format!(
1040                        "Namespace did not resolve dataset for node label '{}'",
1041                        label
1042                    ),
1043                    location: snafu::Location::new(file!(), line!(), column!()),
1044                })?;
1045
1046            let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1047            catalog = catalog.with_node_source(label, table_source);
1048        }
1049
1050        for (rel_type, _) in config.iter_relationship_mappings() {
1051            let provider = providers
1052                .get(rel_type)
1053                .ok_or_else(|| GraphError::ConfigError {
1054                    message: format!(
1055                        "Namespace did not resolve dataset for relationship type '{}'",
1056                        rel_type
1057                    ),
1058                    location: snafu::Location::new(file!(), line!(), column!()),
1059                })?;
1060
1061            let table_source = Arc::new(DefaultTableSource::new(provider.clone()));
1062            catalog = catalog.with_relationship_source(rel_type, table_source);
1063        }
1064
1065        Ok((catalog, ctx))
1066    }
1067
1068    /// Internal helper to explain the query execution plan with explicit catalog and session context
1069    async fn explain_internal(
1070        &self,
1071        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1072        ctx: datafusion::execution::context::SessionContext,
1073    ) -> Result<String> {
1074        // Create all plans (phases 1-4)
1075        let (logical_plan, df_logical_plan, physical_plan) =
1076            self.create_plans(catalog, &ctx).await?;
1077
1078        // Format the explain output
1079        self.format_explain_output(&logical_plan, &df_logical_plan, physical_plan.as_ref())
1080    }
1081
1082    /// Helper to create logical plans (graph logical, DataFusion logical)
1083    ///
1084    /// This performs phases 1-3 of query execution (semantic analysis, graph logical planning,
1085    /// DataFusion logical planning) without creating the physical plan.
1086    fn create_logical_plans(
1087        &self,
1088        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1089    ) -> Result<(
1090        crate::logical_plan::LogicalOperator,
1091        datafusion::logical_expr::LogicalPlan,
1092    )> {
1093        if let Some(template) = &self.cached_plan {
1094            if let Some(parameters) = self.scalar_parameters() {
1095                let same_types = parameters.len() == template.parameter_types.len()
1096                    && parameters.iter().all(|(name, value)| {
1097                        template.parameter_types.get(name) == Some(&value.data_type())
1098                    });
1099                if !Arc::ptr_eq(&catalog, &template.catalog) || !same_types {
1100                    return self.build_logical_plans(catalog, false);
1101                }
1102                let plan = template
1103                    .datafusion
1104                    .clone()
1105                    .with_param_values(parameters)
1106                    .map_err(|error| GraphError::PlanError {
1107                        message: format!("Failed to bind query parameters: {error}"),
1108                        location: snafu::Location::new(file!(), line!(), column!()),
1109                    })?;
1110                return Ok((template.graph.clone(), plan));
1111            }
1112        }
1113        self.build_logical_plans(catalog, false)
1114    }
1115
1116    fn build_logical_plans(
1117        &self,
1118        catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1119        parameterized: bool,
1120    ) -> Result<(
1121        crate::logical_plan::LogicalOperator,
1122        datafusion::logical_expr::LogicalPlan,
1123    )> {
1124        use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1125        use crate::semantic::SemanticAnalyzer;
1126
1127        let config = self.require_config()?;
1128
1129        // Phase 1: Semantic Analysis
1130        let mut analyzer = SemanticAnalyzer::new(config.clone()).with_catalog(catalog.clone());
1131        let semantic = analyzer.analyze(&self.ast, &self.parameters)?;
1132        if !semantic.errors.is_empty() {
1133            return Err(GraphError::PlanError {
1134                message: format!("Semantic analysis failed:\n{}", semantic.errors.join("\n")),
1135                location: snafu::Location::new(file!(), line!(), column!()),
1136            });
1137        }
1138
1139        // Phase 2: Graph Logical Plan
1140        let mut logical_planner = LogicalPlanner::new(config)
1141            .with_fan_out(self.fan_out.as_ref())
1142            .with_adjacency(self.adjacency.as_ref());
1143        let ast = if parameterized {
1144            &self.ast
1145        } else {
1146            &semantic.ast
1147        };
1148        let logical_plan = logical_planner.plan(ast)?;
1149
1150        // Phase 3: DataFusion Logical Plan
1151        let mut df_planner = DataFusionPlanner::with_catalog(config.clone(), catalog);
1152        df_planner.adjacency = self.adjacency.clone();
1153        if parameterized {
1154            df_planner.parameter_types = self
1155                .scalar_parameters()
1156                .unwrap_or_default()
1157                .into_iter()
1158                .map(|(name, value)| (name, value.data_type()))
1159                .collect();
1160        }
1161        let df_logical_plan = df_planner.plan(&logical_plan)?;
1162
1163        Ok((logical_plan, df_logical_plan))
1164    }
1165
1166    /// Helper to create all plans (graph logical, DataFusion logical, physical)
1167    async fn create_plans(
1168        &self,
1169        catalog: std::sync::Arc<dyn lance_csr_catalog::GraphSourceCatalog>,
1170        ctx: &datafusion::execution::context::SessionContext,
1171    ) -> Result<(
1172        crate::logical_plan::LogicalOperator,
1173        datafusion::logical_expr::LogicalPlan,
1174        std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>,
1175    )> {
1176        // Phases 1-3: Create logical plans
1177        let (logical_plan, df_logical_plan) = self.create_logical_plans(catalog)?;
1178
1179        // Phase 4: DataFusion Physical Plan
1180        let ctx = deadline::context(ctx.clone());
1181        let df = query_dataframe(&ctx, df_logical_plan.clone());
1182
1183        let physical_plan =
1184            df.create_physical_plan()
1185                .await
1186                .map_err(|e| GraphError::ExecutionError {
1187                    message: format!("Failed to create physical plan: {}", e),
1188                    location: snafu::Location::new(file!(), line!(), column!()),
1189                })?;
1190
1191        Ok((logical_plan, df_logical_plan, physical_plan))
1192    }
1193
1194    /// Format explain output as a table
1195    fn format_explain_output(
1196        &self,
1197        logical_plan: &crate::logical_plan::LogicalOperator,
1198        df_logical_plan: &datafusion::logical_expr::LogicalPlan,
1199        physical_plan: &dyn datafusion::physical_plan::ExecutionPlan,
1200    ) -> Result<String> {
1201        // Format output with query first, then table
1202        let mut output = String::new();
1203
1204        // Show Cypher query before the table
1205        output.push_str("Cypher Query:\n");
1206        output.push_str(&format!("  {}\n\n", self.query_text));
1207
1208        // Build table rows (without the query)
1209        let mut rows = vec![];
1210
1211        // Row 1: Graph Logical Plan
1212        let graph_plan_str = format!("{:#?}", logical_plan);
1213        rows.push(("graph_logical_plan", graph_plan_str));
1214
1215        // Row 2: DataFusion Logical Plan
1216        let df_logical_str = format!("{}", df_logical_plan.display_indent());
1217        rows.push(("logical_plan", df_logical_str));
1218
1219        // Row 3: DataFusion Physical Plan
1220        let df_physical_str = format!(
1221            "{}",
1222            datafusion::physical_plan::displayable(physical_plan).indent(true)
1223        );
1224        rows.push(("physical_plan", df_physical_str));
1225
1226        // Calculate column widths
1227        let plan_type_width = rows.iter().map(|(t, _)| t.len()).max().unwrap_or(10);
1228        let plan_width = rows
1229            .iter()
1230            .map(|(_, p)| p.lines().map(|l| l.len()).max().unwrap_or(0))
1231            .max()
1232            .unwrap_or(50);
1233
1234        // Build table
1235        let separator = format!(
1236            "+{}+{}+",
1237            "-".repeat(plan_type_width + 2),
1238            "-".repeat(plan_width + 2)
1239        );
1240
1241        output.push_str(&separator);
1242        output.push('\n');
1243
1244        // Header
1245        output.push_str(&format!(
1246            "| {:<width$} | {:<plan_width$} |\n",
1247            "plan_type",
1248            "plan",
1249            width = plan_type_width,
1250            plan_width = plan_width
1251        ));
1252        output.push_str(&separator);
1253        output.push('\n');
1254
1255        // Data rows
1256        for (plan_type, plan_content) in rows {
1257            let lines: Vec<&str> = plan_content.lines().collect();
1258            if lines.is_empty() {
1259                output.push_str(&format!(
1260                    "| {:<width$} | {:<plan_width$} |\n",
1261                    plan_type,
1262                    "",
1263                    width = plan_type_width,
1264                    plan_width = plan_width
1265                ));
1266            } else {
1267                // First line with plan_type
1268                output.push_str(&format!(
1269                    "| {:<width$} | {:<plan_width$} |\n",
1270                    plan_type,
1271                    lines[0],
1272                    width = plan_type_width,
1273                    plan_width = plan_width
1274                ));
1275
1276                // Remaining lines with empty plan_type
1277                for line in &lines[1..] {
1278                    output.push_str(&format!(
1279                        "| {:<width$} | {:<plan_width$} |\n",
1280                        "",
1281                        line,
1282                        width = plan_type_width,
1283                        plan_width = plan_width
1284                    ));
1285                }
1286            }
1287        }
1288
1289        output.push_str(&separator);
1290        output.push('\n');
1291
1292        Ok(output)
1293    }
1294
1295    /// Get all node labels referenced in this query
1296    pub fn referenced_node_labels(&self) -> Vec<String> {
1297        let mut labels = Vec::new();
1298
1299        self.ast.for_each_query(&mut |ast| {
1300            for clause in ast.all_reading_clauses() {
1301                if let ReadingClause::Match(match_clause) = clause {
1302                    for pattern in &match_clause.patterns {
1303                        self.collect_node_labels_from_pattern(pattern, &mut labels);
1304                    }
1305                }
1306            }
1307        });
1308
1309        labels.sort();
1310        labels.dedup();
1311        labels
1312    }
1313
1314    /// Get all relationship types referenced in this query
1315    pub fn referenced_relationship_types(&self) -> Vec<String> {
1316        let mut types = Vec::new();
1317
1318        self.ast.for_each_query(&mut |ast| {
1319            for clause in ast.all_reading_clauses() {
1320                if let ReadingClause::Match(match_clause) = clause {
1321                    for pattern in &match_clause.patterns {
1322                        self.collect_relationship_types_from_pattern(pattern, &mut types);
1323                    }
1324                }
1325            }
1326        });
1327
1328        types.sort();
1329        types.dedup();
1330        types
1331    }
1332
1333    /// Get all variables used in this query
1334    pub fn variables(&self) -> Vec<String> {
1335        let mut variables = Vec::new();
1336
1337        self.ast.for_each_query(&mut |ast| {
1338            for clause in ast.all_reading_clauses() {
1339                match clause {
1340                    ReadingClause::Call(inner) => {
1341                        variables.extend(inner.call_imports().unwrap_or_default());
1342                        variables.extend(inner.return_clause.items.iter().filter_map(|item| {
1343                            item.alias.clone().or_else(|| match &item.expression {
1344                                crate::ast::ValueExpression::Variable(name) => Some(name.clone()),
1345                                _ => None,
1346                            })
1347                        }));
1348                    }
1349                    ReadingClause::Match(match_clause) => {
1350                        for pattern in &match_clause.patterns {
1351                            self.collect_variables_from_pattern(pattern, &mut variables);
1352                        }
1353                    }
1354                    ReadingClause::Unwind(unwind_clause) => {
1355                        variables.push(unwind_clause.alias.clone());
1356                    }
1357                }
1358            }
1359        });
1360
1361        variables.sort();
1362        variables.dedup();
1363        variables
1364    }
1365
1366    // Collection helper methods
1367
1368    fn collect_node_labels_from_pattern(
1369        &self,
1370        pattern: &crate::ast::GraphPattern,
1371        labels: &mut Vec<String>,
1372    ) {
1373        match pattern {
1374            crate::ast::GraphPattern::Node(node) => {
1375                labels.extend(node.labels.clone());
1376            }
1377            crate::ast::GraphPattern::Path(path) => {
1378                labels.extend(path.start_node.labels.clone());
1379                for segment in &path.segments {
1380                    labels.extend(segment.end_node.labels.clone());
1381                }
1382            }
1383        }
1384    }
1385
1386    fn collect_relationship_types_from_pattern(
1387        &self,
1388        pattern: &crate::ast::GraphPattern,
1389        types: &mut Vec<String>,
1390    ) {
1391        if let crate::ast::GraphPattern::Path(path) = pattern {
1392            for segment in &path.segments {
1393                types.extend(segment.relationship.types.clone());
1394            }
1395        }
1396    }
1397
1398    fn collect_variables_from_pattern(
1399        &self,
1400        pattern: &crate::ast::GraphPattern,
1401        variables: &mut Vec<String>,
1402    ) {
1403        match pattern {
1404            crate::ast::GraphPattern::Node(node) => {
1405                if let Some(var) = &node.variable {
1406                    variables.push(var.clone());
1407                }
1408            }
1409            crate::ast::GraphPattern::Path(path) => {
1410                if let Some(var) = &path.start_node.variable {
1411                    variables.push(var.clone());
1412                }
1413                for segment in &path.segments {
1414                    if let Some(var) = &segment.relationship.variable {
1415                        variables.push(var.clone());
1416                    }
1417                    if let Some(var) = &segment.end_node.variable {
1418                        variables.push(var.clone());
1419                    }
1420                }
1421            }
1422        }
1423    }
1424}
1425
1426impl CypherQuery {
1427    /// Execute Cypher query, then apply vector search reranking on results
1428    ///
1429    /// This is a convenience method for the common GraphRAG pattern:
1430    /// 1. Run Cypher query to get candidate entities via graph traversal
1431    /// 2. Rerank candidates by vector similarity
1432    ///
1433    /// # Arguments
1434    /// * `datasets` - HashMap of table name to RecordBatch (nodes and relationships)
1435    /// * `vector_search` - VectorSearch configuration for reranking
1436    ///
1437    /// # Returns
1438    /// A RecordBatch with the top-k results sorted by vector similarity
1439    ///
1440    /// # Example
1441    /// ```ignore
1442    /// use lance_csr::{CypherQuery, VectorSearch};
1443    /// use lance_csr::ast::DistanceMetric;
1444    ///
1445    /// let results = query
1446    ///     .execute_with_vector_rerank(
1447    ///         datasets,
1448    ///         VectorSearch::new("embedding")
1449    ///             .query_vector(query_vec)
1450    ///             .metric(DistanceMetric::Cosine)
1451    ///             .top_k(10)
1452    ///     )
1453    ///     .await?;
1454    /// ```
1455    pub async fn execute_with_vector_rerank(
1456        &self,
1457        datasets: HashMap<String, arrow::record_batch::RecordBatch>,
1458        vector_search: crate::lance_vector_search::VectorSearch,
1459    ) -> Result<arrow::record_batch::RecordBatch> {
1460        // Step 1: Execute Cypher query to get candidates
1461        let candidates = self.execute(datasets, None).await?;
1462
1463        // Step 2: Apply vector search reranking
1464        vector_search.search(&candidates).await
1465    }
1466}
1467
1468/// Builder for constructing Cypher queries programmatically
1469#[derive(Debug, Default)]
1470pub struct CypherQueryBuilder {
1471    match_clauses: Vec<crate::ast::MatchClause>,
1472    where_expression: Option<crate::ast::BooleanExpression>,
1473    return_items: Vec<crate::ast::ReturnItem>,
1474    order_by_items: Vec<crate::ast::OrderByItem>,
1475    limit: Option<u64>,
1476    distinct: bool,
1477    skip: Option<u64>,
1478    config: Option<GraphConfig>,
1479    parameters: HashMap<String, serde_json::Value>,
1480}
1481
1482impl CypherQueryBuilder {
1483    /// Create a new query builder
1484    pub fn new() -> Self {
1485        Self::default()
1486    }
1487
1488    /// Add a MATCH clause for a node pattern
1489    pub fn match_node(mut self, variable: &str, label: &str) -> Self {
1490        let node = crate::ast::NodePattern {
1491            variable: Some(variable.to_string()),
1492            labels: vec![label.to_string()],
1493            properties: HashMap::new(),
1494        };
1495
1496        let match_clause = crate::ast::MatchClause {
1497            patterns: vec![crate::ast::GraphPattern::Node(node)],
1498            optional: false,
1499            where_clause: None,
1500        };
1501
1502        self.match_clauses.push(match_clause);
1503        self
1504    }
1505
1506    /// Set the graph configuration
1507    pub fn with_config(mut self, config: GraphConfig) -> Self {
1508        self.config = Some(config);
1509        self
1510    }
1511
1512    /// Add a RETURN item
1513    pub fn return_property(mut self, variable: &str, property: &str) -> Self {
1514        let prop_ref = crate::ast::PropertyRef::new(variable, property);
1515        let return_item = crate::ast::ReturnItem {
1516            expression: crate::ast::ValueExpression::Property(prop_ref),
1517            alias: None,
1518        };
1519
1520        self.return_items.push(return_item);
1521        self
1522    }
1523
1524    /// Set DISTINCT flag
1525    pub fn distinct(mut self, distinct: bool) -> Self {
1526        self.distinct = distinct;
1527        self
1528    }
1529
1530    /// Add a LIMIT clause
1531    pub fn limit(mut self, limit: u64) -> Self {
1532        self.limit = Some(limit);
1533        self
1534    }
1535
1536    /// Add a SKIP clause
1537    pub fn skip(mut self, skip: u64) -> Self {
1538        self.skip = Some(skip);
1539        self
1540    }
1541
1542    /// Build the final CypherQuery
1543    pub fn build(self) -> Result<CypherQuery> {
1544        if self.match_clauses.is_empty() {
1545            return Err(GraphError::PlanError {
1546                message: "Query must have at least one MATCH clause".to_string(),
1547                location: snafu::Location::new(file!(), line!(), column!()),
1548            });
1549        }
1550
1551        if self.return_items.is_empty() {
1552            return Err(GraphError::PlanError {
1553                message: "Query must have at least one RETURN item".to_string(),
1554                location: snafu::Location::new(file!(), line!(), column!()),
1555            });
1556        }
1557
1558        let ast = crate::ast::CypherQuery {
1559            reading_clauses: self
1560                .match_clauses
1561                .into_iter()
1562                .map(crate::ast::ReadingClause::Match)
1563                .collect(),
1564            where_clause: self
1565                .where_expression
1566                .map(|expr| crate::ast::WhereClause { expression: expr }),
1567            with_clause: None, // WITH not supported via builder yet
1568            post_with_reading_clauses: vec![],
1569            post_with_where_clause: None,
1570            further_with_stages: vec![],
1571            union_branches: Vec::new(),
1572            return_clause: crate::ast::ReturnClause {
1573                distinct: self.distinct,
1574                items: self.return_items,
1575            },
1576            order_by: if self.order_by_items.is_empty() {
1577                None
1578            } else {
1579                Some(crate::ast::OrderByClause {
1580                    items: self.order_by_items,
1581                })
1582            },
1583            limit: self.limit.map(crate::ast::RowCount::Literal),
1584            skip: self.skip.map(crate::ast::RowCount::Literal),
1585        };
1586
1587        // Generate query text from AST (simplified)
1588        let query_text = "MATCH ... RETURN ...".to_string(); // TODO: Implement AST->text conversion
1589
1590        let query = CypherQuery {
1591            query_text,
1592            ast,
1593            config: self.config,
1594            parameters: self.parameters,
1595            adjacency: None,
1596            fan_out: None,
1597            cached_plan: None,
1598        };
1599
1600        Ok(query)
1601    }
1602}
1603
1604#[cfg(test)]
1605mod tests {
1606    use super::*;
1607    use crate::config::GraphConfig;
1608
1609    #[tokio::test]
1610    async fn union_distinct_all_and_schema_validation_execute() {
1611        use arrow_array::{Int64Array, RecordBatch};
1612        use arrow_schema::{DataType, Field, Schema};
1613        use std::sync::Arc;
1614        let batch = RecordBatch::try_new(
1615            Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])),
1616            vec![Arc::new(Int64Array::from(vec![1, 2, 2]))],
1617        )
1618        .unwrap();
1619        let config = GraphConfig::builder()
1620            .node_table("Person", "id")
1621            .build()
1622            .unwrap();
1623        for (query, rows) in [
1624            ("MATCH (p:Person) RETURN p.id AS n UNION RETURN 2 AS n", 2),
1625            (
1626                "MATCH (p:Person) RETURN p.id AS n UNION ALL RETURN 2 AS n",
1627                4,
1628            ),
1629            ("RETURN NULL AS n UNION RETURN NULL AS n", 1),
1630        ] {
1631            let result = CypherQuery::new(query)
1632                .unwrap()
1633                .with_config(config.clone())
1634                .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1635                .await
1636                .unwrap();
1637            assert_eq!(result.num_rows(), rows, "{query}");
1638        }
1639        for query in [
1640            "RETURN 1 AS x UNION RETURN 2 AS y",
1641            "RETURN 1 AS x UNION RETURN 'a' AS x",
1642            "RETURN 1 AS n UNION ALL RETURN 1 AS n UNION RETURN 2 AS n",
1643        ] {
1644            assert!(CypherQuery::new(query)
1645                .unwrap()
1646                .with_config(config.clone())
1647                .execute(HashMap::from([("Person".to_string(), batch.clone())]), None)
1648                .await
1649                .is_err());
1650        }
1651    }
1652
1653    #[tokio::test]
1654    async fn cartesian_stream_yields_and_drops_before_full_collection() {
1655        use arrow_array::{ArrayRef, Int64Array};
1656        use datafusion::datasource::{DefaultTableSource, MemTable};
1657        use datafusion::execution::context::{SessionConfig, SessionContext};
1658        use futures::TryStreamExt;
1659        use lance_csr_catalog::InMemoryCatalog;
1660        use std::sync::atomic::{AtomicUsize, Ordering};
1661
1662        let batch = RecordBatch::try_from_iter([(
1663            "id",
1664            Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1665        )])
1666        .unwrap();
1667        let table = Arc::new(MemTable::try_new(batch.schema(), vec![vec![batch]]).unwrap());
1668        let catalog = Arc::new(
1669            InMemoryCatalog::new()
1670                .with_node_source("Person", Arc::new(DefaultTableSource::new(table))),
1671        );
1672        let ctx = SessionContext::new_with_config(
1673            SessionConfig::new()
1674                .with_target_partitions(1)
1675                .with_round_robin_repartition(false),
1676        );
1677        let config = GraphConfig::builder()
1678            .node_table("Person", "id")
1679            .build()
1680            .unwrap();
1681        let query = CypherQuery::new(
1682            "MATCH (a:Person), (b:Person) RETURN a.id AS left_id, b.id AS right_id",
1683        )
1684        .unwrap()
1685        .with_config(config.clone());
1686        let stream = with_execution_deadline(
1687            Some(tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1688            query.execute_stream_with_catalog_and_context(catalog.clone(), ctx.clone()),
1689        )
1690        .await
1691        .unwrap();
1692        // A fresh Tokio task supplies its own budget; no inherited source yield can pass this test.
1693        tokio::spawn(async move {
1694            let visited = Arc::new(AtomicUsize::new(0));
1695            let progress = visited.clone();
1696            let mut read = Box::pin(stream.try_fold(0_usize, move |count, batch| {
1697                progress.fetch_add(batch.num_rows(), Ordering::SeqCst);
1698                futures::future::ready(Ok(count + batch.num_rows()))
1699            }));
1700            assert!(futures::poll!(read.as_mut()).is_pending());
1701            let stopped = visited.load(Ordering::SeqCst);
1702            assert!(stopped > 0 && stopped < 1_000_000);
1703            drop(read);
1704            tokio::task::yield_now().await;
1705            assert_eq!(visited.load(Ordering::SeqCst), stopped);
1706        })
1707        .await
1708        .unwrap();
1709        let result = query
1710            .execute_with_catalog_and_context(catalog.clone(), ctx.clone())
1711            .await
1712            .unwrap();
1713        assert_eq!(result.num_rows(), 1_000_000);
1714        assert_eq!(result.num_columns(), 2);
1715
1716        let filtered = CypherQuery::new(
1717            "MATCH (a:Person), (b:Person) WHERE a.id + b.id >= 999 RETURN count(*) AS n",
1718        )
1719        .unwrap()
1720        .with_config(config);
1721        let expired = with_execution_deadline(
1722            Some(tokio::time::Instant::now() - std::time::Duration::from_secs(1)),
1723            filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1724        )
1725        .await
1726        .unwrap_err();
1727        assert!(expired.to_string().contains("Query timeout exceeded"));
1728        for timed in [false, true] {
1729            let result = with_execution_deadline(
1730                timed.then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(60)),
1731                filtered.execute_with_catalog_and_context(catalog.clone(), ctx.clone()),
1732            )
1733            .await
1734            .unwrap();
1735            assert_eq!(result.num_rows(), 1);
1736            assert_eq!(
1737                result
1738                    .column(0)
1739                    .as_any()
1740                    .downcast_ref::<Int64Array>()
1741                    .unwrap()
1742                    .value(0),
1743                500_500
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn test_parse_simple_cypher_query() {
1750        let query = CypherQuery::new("MATCH (n:Person) RETURN n.name").unwrap();
1751        assert_eq!(query.query_text(), "MATCH (n:Person) RETURN n.name");
1752        assert_eq!(query.referenced_node_labels(), vec!["Person"]);
1753        assert_eq!(query.variables(), vec!["n"]);
1754    }
1755
1756    #[test]
1757    fn test_query_with_parameters() {
1758        let mut params = HashMap::new();
1759        params.insert("minAge".to_string(), serde_json::Value::Number(30.into()));
1760        params.insert("maxAge".to_string(), serde_json::Value::Number(50.into()));
1761
1762        let query = CypherQuery::new(
1763            "MATCH (n:Person) WHERE n.age > $minAge AND n.age < $maxAge RETURN n.name",
1764        )
1765        .unwrap()
1766        .with_parameters(params);
1767
1768        assert!(query.parameters().contains_key("minage"));
1769        assert!(query.parameters().contains_key("maxage"));
1770    }
1771
1772    #[tokio::test]
1773    async fn cached_templates_rebind_scalars_and_preserve_schema() {
1774        use arrow_array::{ArrayRef, Int64Array, StringArray};
1775        let config = GraphConfig::builder()
1776            .node_table("Person", "id")
1777            .build()
1778            .unwrap();
1779        let datasets = HashMap::from([(
1780            "Person".into(),
1781            RecordBatch::try_from_iter([
1782                ("id", Arc::new(Int64Array::from(vec![1, 2, 3])) as ArrayRef),
1783                (
1784                    "name",
1785                    Arc::new(StringArray::from(vec!["Ada", "Bob", "Cam"])) as ArrayRef,
1786                ),
1787            ])
1788            .unwrap(),
1789        )]);
1790        let cases = [
1791            (
1792                "MATCH (p:Person) WHERE p.id >= $value RETURN p.id ORDER BY p.id",
1793                vec![serde_json::json!(1), serde_json::json!(3)],
1794            ),
1795            (
1796                "MATCH (p:Person) RETURN p.id + $value AS adjusted ORDER BY adjusted",
1797                vec![serde_json::json!(1.5), serde_json::json!(8.5)],
1798            ),
1799            (
1800                "MATCH (p:Person) WHERE p.name = $value RETURN p.id",
1801                vec![serde_json::json!("Ada"), serde_json::json!("Cam")],
1802            ),
1803            (
1804                "MATCH (p:Person) RETURN $value AS value",
1805                vec![serde_json::json!(true), serde_json::json!(false)],
1806            ),
1807            (
1808                "MATCH (p:Person) RETURN $value AS value",
1809                vec![serde_json::Value::Null],
1810            ),
1811            (
1812                "MATCH (p:Person) RETURN $value",
1813                vec![serde_json::json!(1), serde_json::json!(2)],
1814            ),
1815            (
1816                "MATCH (p:Person) RETURN p.id + $value",
1817                vec![serde_json::json!(1), serde_json::json!(2)],
1818            ),
1819            (
1820                "MATCH (p:Person) RETURN p.id AS id, [x IN range(1, $value) | x + p.id] AS values ORDER BY id",
1821                vec![serde_json::json!(1), serde_json::json!(3)],
1822            ),
1823        ];
1824        for (text, values) in cases {
1825            let mut template = CypherQuery::new(text)
1826                .unwrap()
1827                .with_config(config.clone())
1828                .with_parameter("value", values[0].clone());
1829            let (catalog, context) = template
1830                .build_catalog_and_context_from_datasets(datasets.clone())
1831                .await
1832                .unwrap();
1833            let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1834            assert!(
1835                template.prepare_cached_plan(catalog.clone()).unwrap(),
1836                "{text}"
1837            );
1838            for value in values {
1839                let cached = template.clone().with_parameter("value", value.clone());
1840                let fresh = CypherQuery::new(text)
1841                    .unwrap()
1842                    .with_config(config.clone())
1843                    .with_parameter("value", value);
1844                let actual = cached
1845                    .execute_with_catalog_and_context(catalog.clone(), context.clone())
1846                    .await
1847                    .unwrap();
1848                let expected = fresh
1849                    .execute_with_catalog_and_context(catalog.clone(), context.clone())
1850                    .await
1851                    .unwrap();
1852                assert_eq!(actual, expected, "{text}");
1853            }
1854        }
1855    }
1856
1857    #[tokio::test]
1858    async fn cached_templates_respect_catalog_and_parameter_types() {
1859        use arrow_array::{ArrayRef, Int64Array};
1860        let config = GraphConfig::builder()
1861            .node_table("Person", "id")
1862            .build()
1863            .unwrap();
1864        let data = |id| {
1865            HashMap::from([(
1866                "Person".into(),
1867                RecordBatch::try_from_iter([(
1868                    "id",
1869                    Arc::new(Int64Array::from(vec![id])) as ArrayRef,
1870                )])
1871                .unwrap(),
1872            )])
1873        };
1874        let mut query = CypherQuery::new("MATCH (p:Person) WHERE p.id = $value RETURN p.id")
1875            .unwrap()
1876            .with_config(config.clone())
1877            .with_parameter("value", 1);
1878        let (catalog, _) = query
1879            .build_catalog_and_context_from_datasets(data(1))
1880            .await
1881            .unwrap();
1882        assert!(query.prepare_cached_plan(Arc::new(catalog)).unwrap());
1883        // A different catalog must never serve rows from the prepared snapshot.
1884        assert_eq!(query.execute(data(2), None).await.unwrap().num_rows(), 0);
1885        assert!(query
1886            .clone()
1887            .with_parameter("value", true)
1888            .execute(data(1), None)
1889            .await
1890            .unwrap_err()
1891            .to_string()
1892            .contains("Type mismatch"));
1893        assert!(query.with_config(config).cached_plan.is_none());
1894        let mut containers = CypherQuery::new("MATCH (p:Person) WHERE p.id IN $ids RETURN p.id")
1895            .unwrap()
1896            .with_parameter("ids", serde_json::json!([1, 2]));
1897        assert!(!containers
1898            .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1899            .unwrap());
1900    }
1901
1902    #[test]
1903    fn parameterized_row_counts_bypass_templates() {
1904        for text in [
1905            "MATCH (p:Person) RETURN p.id LIMIT $count",
1906            "MATCH (p:Person) RETURN p.id SKIP $count",
1907            "MATCH (p:Person) WITH p LIMIT $count RETURN p.id",
1908            "MATCH (p:Person) WITH p WITH p LIMIT $count RETURN p.id",
1909        ] {
1910            let mut query = CypherQuery::new(text).unwrap().with_parameter("count", 1);
1911            assert!(
1912                !query
1913                    .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1914                    .unwrap(),
1915                "{text}"
1916            );
1917            assert!(query.cached_plan.is_none());
1918        }
1919    }
1920
1921    #[test]
1922    fn cross_label_shortest_queries_bypass_cached_inventories() {
1923        let config = GraphConfig::builder()
1924            .node_table("Person", "id")
1925            .node_table("Place", "id")
1926            .rel_table("LIVES", "Person", "Place", "src", "dst")
1927            .build()
1928            .unwrap();
1929        for text in [
1930            "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p)",
1931            "MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]-(b:Person)) WITH p AS q RETURN nodes(q)",
1932            "RETURN 0 AS n UNION MATCH p = shortestPath((a:Person {id: $id})-[:LIVES*0..]->(b:Place)) RETURN length(p) AS n",
1933        ] {
1934            let mut query = CypherQuery::new(text)
1935                .unwrap()
1936                .with_config(config.clone())
1937                .with_parameter("id", 1);
1938            assert!(!query
1939                .prepare_cached_plan(Arc::new(crate::InMemoryCatalog::new()))
1940                .unwrap());
1941            assert!(query.cached_plan.is_none(), "{text}");
1942        }
1943    }
1944
1945    // Opt-in diagnostic: reports stage means for a fixed local scan, with no timing
1946    // assertions. Physical planning calls the query planner directly, avoiding the
1947    // extra optimize() performed by SessionState::create_physical_plan().
1948    #[tokio::test]
1949    #[ignore = "local stage timing; run with --ignored --nocapture"]
1950    async fn profile_query_planning_stages() {
1951        use crate::datafusion_planner::{DataFusionPlanner, GraphPhysicalPlanner};
1952        use crate::semantic::SemanticAnalyzer;
1953        use arrow_array::{ArrayRef, Int64Array};
1954        use std::time::Instant;
1955        let text = "MATCH (p:Person) WHERE p.id >= $minimum RETURN p.id AS id ORDER BY id";
1956        let config = GraphConfig::builder()
1957            .node_table("Person", "id")
1958            .build()
1959            .unwrap();
1960        let parameters = HashMap::from([("minimum".into(), serde_json::json!(10))]);
1961        let mut template = CypherQuery::new(text)
1962            .unwrap()
1963            .with_config(config.clone())
1964            .with_parameters(parameters.clone());
1965        let data = HashMap::from([(
1966            "Person".into(),
1967            RecordBatch::try_from_iter([(
1968                "id",
1969                Arc::new(Int64Array::from_iter_values(0..1000)) as ArrayRef,
1970            )])
1971            .unwrap(),
1972        )]);
1973        let (catalog, context) = template
1974            .build_catalog_and_context_from_datasets(data)
1975            .await
1976            .unwrap();
1977        let catalog: Arc<dyn lance_csr_catalog::GraphSourceCatalog> = Arc::new(catalog);
1978        template.prepare_cached_plan(catalog.clone()).unwrap();
1979        let state = context.state();
1980        let mut totals = [std::time::Duration::ZERO; 8];
1981        for _ in 0..100 {
1982            let start = Instant::now();
1983            let ast = parse_cypher_query(text).unwrap();
1984            totals[0] += start.elapsed();
1985            let start = Instant::now();
1986            let semantic = SemanticAnalyzer::new(config.clone())
1987                .with_catalog(catalog.clone())
1988                .analyze(&ast, &parameters)
1989                .unwrap();
1990            assert!(semantic.errors.is_empty());
1991            totals[1] += start.elapsed();
1992            let start = Instant::now();
1993            let graph = LogicalPlanner::new(&config).plan(&semantic.ast).unwrap();
1994            totals[2] += start.elapsed();
1995            let start = Instant::now();
1996            let logical = DataFusionPlanner::with_catalog(config.clone(), catalog.clone())
1997                .plan(&graph)
1998                .unwrap();
1999            totals[3] += start.elapsed();
2000            let start = Instant::now();
2001            let optimized = state.optimize(&logical).unwrap();
2002            totals[4] += start.elapsed();
2003            let start = Instant::now();
2004            let physical = state
2005                .query_planner()
2006                .create_physical_plan(&optimized, &state)
2007                .await
2008                .unwrap();
2009            totals[5] += start.elapsed();
2010            let start = Instant::now();
2011            let batches = datafusion::physical_plan::collect(physical, context.task_ctx())
2012                .await
2013                .unwrap();
2014            totals[6] += start.elapsed();
2015            assert_eq!(
2016                batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
2017                990
2018            );
2019            let checksum: i64 = batches
2020                .iter()
2021                .flat_map(|batch| {
2022                    batch
2023                        .column(0)
2024                        .as_any()
2025                        .downcast_ref::<Int64Array>()
2026                        .unwrap()
2027                        .values()
2028                        .iter()
2029                })
2030                .sum();
2031            assert_eq!(checksum, (10 + 999) * 990 / 2);
2032            let start = Instant::now();
2033            let _ = template.create_logical_plans(catalog.clone()).unwrap();
2034            totals[7] += start.elapsed();
2035        }
2036        for (name, duration) in [
2037            "parse",
2038            "semantic",
2039            "graph_logical",
2040            "datafusion_logical",
2041            "optimize",
2042            "physical",
2043            "execute",
2044            "cached_bind",
2045        ]
2046        .into_iter()
2047        .zip(totals)
2048        {
2049            println!(
2050                "{name}: {:.3} us mean (100 iterations, 1000 input rows, 990 result rows)",
2051                duration.as_secs_f64() * 1_000_000.0 / 100.0
2052            );
2053        }
2054    }
2055
2056    #[test]
2057    fn test_query_builder() {
2058        let config = GraphConfig::builder()
2059            .node_table("Person", "person_id")
2060            .build()
2061            .unwrap();
2062
2063        let query = CypherQueryBuilder::new()
2064            .with_config(config)
2065            .match_node("n", "Person")
2066            .return_property("n", "name")
2067            .limit(10)
2068            .build()
2069            .unwrap();
2070
2071        assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2072        assert_eq!(query.variables(), vec!["n"]);
2073    }
2074
2075    #[test]
2076    fn test_relationship_query_parsing() {
2077        let query =
2078            CypherQuery::new("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a.name, b.name")
2079                .unwrap();
2080        assert_eq!(query.referenced_node_labels(), vec!["Person"]);
2081        assert_eq!(query.referenced_relationship_types(), vec!["KNOWS"]);
2082        assert_eq!(query.variables(), vec!["a", "b", "r"]);
2083    }
2084
2085    #[tokio::test]
2086    async fn test_execute_basic_projection_and_filter() {
2087        use arrow_array::{Int64Array, RecordBatch, StringArray};
2088        use arrow_schema::{DataType, Field, Schema};
2089        use std::sync::Arc;
2090
2091        // Build a simple batch: name (Utf8), age (Int64)
2092        let schema = Arc::new(Schema::new(vec![
2093            Field::new("name", DataType::Utf8, true),
2094            Field::new("age", DataType::Int64, true),
2095        ]));
2096        let batch = RecordBatch::try_new(
2097            schema,
2098            vec![
2099                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2100                Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2101            ],
2102        )
2103        .unwrap();
2104
2105        let cfg = GraphConfig::builder()
2106            .node_table("Person", "id")
2107            .build()
2108            .unwrap();
2109
2110        let q = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2111            .unwrap()
2112            .with_config(cfg);
2113
2114        let mut data = HashMap::new();
2115        data.insert("Person".to_string(), batch);
2116
2117        let out = q.execute(data, None).await.unwrap();
2118        assert_eq!(out.num_rows(), 2);
2119        let names = out
2120            .column(0)
2121            .as_any()
2122            .downcast_ref::<StringArray>()
2123            .unwrap();
2124        let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2125        // Expect Bob (34) and David (42)
2126        let result: Vec<(String, i64)> = (0..out.num_rows())
2127            .map(|i| (names.value(i).to_string(), ages.value(i)))
2128            .collect();
2129        assert!(result.contains(&("Bob".to_string(), 34)));
2130        assert!(result.contains(&("David".to_string(), 42)));
2131    }
2132
2133    #[tokio::test]
2134    async fn test_execute_single_hop_path_join_projection() {
2135        use arrow_array::{Int64Array, RecordBatch, StringArray};
2136        use arrow_schema::{DataType, Field, Schema};
2137        use std::sync::Arc;
2138
2139        // People table: id, name, age
2140        let person_schema = Arc::new(Schema::new(vec![
2141            Field::new("id", DataType::Int64, false),
2142            Field::new("name", DataType::Utf8, true),
2143            Field::new("age", DataType::Int64, true),
2144        ]));
2145        let people = RecordBatch::try_new(
2146            person_schema,
2147            vec![
2148                Arc::new(Int64Array::from(vec![1, 2, 3])),
2149                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2150                Arc::new(Int64Array::from(vec![28, 34, 29])),
2151            ],
2152        )
2153        .unwrap();
2154
2155        // KNOWS relationship: src_person_id -> dst_person_id
2156        let rel_schema = Arc::new(Schema::new(vec![
2157            Field::new("src_person_id", DataType::Int64, false),
2158            Field::new("dst_person_id", DataType::Int64, false),
2159        ]));
2160        let knows = RecordBatch::try_new(
2161            rel_schema,
2162            vec![
2163                Arc::new(Int64Array::from(vec![1, 2])), // Alice -> Bob, Bob -> Carol
2164                Arc::new(Int64Array::from(vec![2, 3])),
2165            ],
2166        )
2167        .unwrap();
2168
2169        // Config: Person(id) and KNOWS(src_person_id -> dst_person_id)
2170        let cfg = GraphConfig::builder()
2171            .node_table("Person", "id")
2172            .rel_table(
2173                "KNOWS",
2174                "Person",
2175                "Person",
2176                "src_person_id",
2177                "dst_person_id",
2178            )
2179            .build()
2180            .unwrap();
2181
2182        // Query: MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name
2183        let q = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN b.name")
2184            .unwrap()
2185            .with_config(cfg);
2186
2187        let mut data = HashMap::new();
2188        // Register tables using labels / rel types as names
2189        data.insert("Person".to_string(), people);
2190        data.insert("KNOWS".to_string(), knows);
2191
2192        let out = q.execute(data, None).await.unwrap();
2193        // Expect two rows: Bob, Carol (the targets)
2194        let names = out
2195            .column(0)
2196            .as_any()
2197            .downcast_ref::<StringArray>()
2198            .unwrap();
2199        let got: Vec<String> = (0..out.num_rows())
2200            .map(|i| names.value(i).to_string())
2201            .collect();
2202        assert_eq!(got.len(), 2);
2203        assert!(got.contains(&"Bob".to_string()));
2204        assert!(got.contains(&"Carol".to_string()));
2205    }
2206
2207    #[tokio::test]
2208    async fn test_execute_order_by_asc() {
2209        use arrow_array::{Int64Array, RecordBatch, StringArray};
2210        use arrow_schema::{DataType, Field, Schema};
2211        use std::sync::Arc;
2212
2213        // name, age (int)
2214        let schema = Arc::new(Schema::new(vec![
2215            Field::new("name", DataType::Utf8, true),
2216            Field::new("age", DataType::Int64, true),
2217        ]));
2218        let batch = RecordBatch::try_new(
2219            schema,
2220            vec![
2221                Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2222                Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2223            ],
2224        )
2225        .unwrap();
2226
2227        let cfg = GraphConfig::builder()
2228            .node_table("Person", "id")
2229            .build()
2230            .unwrap();
2231
2232        // Order ascending by age
2233        let q = CypherQuery::new("MATCH (p:Person) RETURN p.name, p.age ORDER BY p.age ASC")
2234            .unwrap()
2235            .with_config(cfg);
2236
2237        let mut data = HashMap::new();
2238        data.insert("Person".to_string(), batch);
2239
2240        let out = q.execute(data, None).await.unwrap();
2241        let ages = out.column(1).as_any().downcast_ref::<Int64Array>().unwrap();
2242        let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2243        assert_eq!(collected, vec![28, 29, 34, 42]);
2244    }
2245
2246    #[tokio::test]
2247    async fn test_execute_order_by_desc_with_skip_limit() {
2248        use arrow_array::{Int64Array, RecordBatch, StringArray};
2249        use arrow_schema::{DataType, Field, Schema};
2250        use std::sync::Arc;
2251
2252        let schema = Arc::new(Schema::new(vec![
2253            Field::new("name", DataType::Utf8, true),
2254            Field::new("age", DataType::Int64, true),
2255        ]));
2256        let batch = RecordBatch::try_new(
2257            schema,
2258            vec![
2259                Arc::new(StringArray::from(vec!["Bob", "Alice", "David", "Carol"])),
2260                Arc::new(Int64Array::from(vec![34, 28, 42, 29])),
2261            ],
2262        )
2263        .unwrap();
2264
2265        let cfg = GraphConfig::builder()
2266            .node_table("Person", "id")
2267            .build()
2268            .unwrap();
2269
2270        // Desc by age, skip 1 (drop 42), take 2 -> [34, 29]
2271        let q =
2272            CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age DESC SKIP 1 LIMIT 2")
2273                .unwrap()
2274                .with_config(cfg);
2275
2276        let mut data = HashMap::new();
2277        data.insert("Person".to_string(), batch);
2278
2279        let out = q.execute(data, None).await.unwrap();
2280        assert_eq!(out.num_rows(), 2);
2281        let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2282        let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2283        assert_eq!(collected, vec![34, 29]);
2284    }
2285
2286    #[tokio::test]
2287    async fn test_execute_skip_without_limit() {
2288        use arrow_array::{Int64Array, RecordBatch};
2289        use arrow_schema::{DataType, Field, Schema};
2290        use std::sync::Arc;
2291
2292        let schema = Arc::new(Schema::new(vec![Field::new("age", DataType::Int64, true)]));
2293        let batch = RecordBatch::try_new(
2294            schema,
2295            vec![Arc::new(Int64Array::from(vec![10, 20, 30, 40]))],
2296        )
2297        .unwrap();
2298
2299        let cfg = GraphConfig::builder()
2300            .node_table("Person", "id")
2301            .build()
2302            .unwrap();
2303
2304        let q = CypherQuery::new("MATCH (p:Person) RETURN p.age ORDER BY p.age ASC SKIP 2")
2305            .unwrap()
2306            .with_config(cfg);
2307
2308        let mut data = HashMap::new();
2309        data.insert("Person".to_string(), batch);
2310
2311        let out = q.execute(data, None).await.unwrap();
2312        assert_eq!(out.num_rows(), 2);
2313        let ages = out.column(0).as_any().downcast_ref::<Int64Array>().unwrap();
2314        let collected: Vec<i64> = (0..out.num_rows()).map(|i| ages.value(i)).collect();
2315        assert_eq!(collected, vec![30, 40]);
2316    }
2317
2318    #[tokio::test]
2319    async fn test_execute_datafusion_simple_scan() {
2320        use arrow_array::{Int64Array, RecordBatch, StringArray};
2321        use arrow_schema::{DataType, Field, Schema};
2322        use std::sync::Arc;
2323
2324        // Create test data
2325        let schema = Arc::new(Schema::new(vec![
2326            Field::new("id", DataType::Int64, false),
2327            Field::new("name", DataType::Utf8, false),
2328        ]));
2329
2330        let batch = RecordBatch::try_new(
2331            schema,
2332            vec![
2333                Arc::new(Int64Array::from(vec![1, 2])),
2334                Arc::new(StringArray::from(vec!["Alice", "Bob"])),
2335            ],
2336        )
2337        .unwrap();
2338
2339        let cfg = GraphConfig::builder()
2340            .node_table("Person", "id")
2341            .build()
2342            .unwrap();
2343
2344        // Test simple scan without filters
2345        let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2346            .unwrap()
2347            .with_config(cfg);
2348
2349        let mut datasets = HashMap::new();
2350        datasets.insert("Person".to_string(), batch);
2351
2352        // Execute using DataFusion pipeline
2353        let result = query.execute_datafusion(datasets).await.unwrap();
2354
2355        // Should return all rows
2356        assert_eq!(
2357            result.num_rows(),
2358            2,
2359            "Should return all 2 rows without filtering"
2360        );
2361        assert_eq!(result.num_columns(), 1, "Should return 1 column (name)");
2362
2363        // Verify data
2364        let names = result
2365            .column(0)
2366            .as_any()
2367            .downcast_ref::<StringArray>()
2368            .unwrap();
2369        let name_set: std::collections::HashSet<String> = (0..result.num_rows())
2370            .map(|i| names.value(i).to_string())
2371            .collect();
2372        let expected: std::collections::HashSet<String> =
2373            ["Alice", "Bob"].iter().map(|s| s.to_string()).collect();
2374        assert_eq!(name_set, expected, "Should return Alice and Bob");
2375    }
2376
2377    #[tokio::test]
2378    async fn test_aggregate_predicates_on_empty_input() {
2379        use arrow_array::{BooleanArray, Int64Array};
2380        use datafusion::execution::context::SessionContext;
2381
2382        let ctx = SessionContext::new();
2383        ctx.register_batch(
2384            "Person",
2385            RecordBatch::try_from_iter(vec![("id", Arc::new(Int64Array::from(vec![1, 2])) as _)])
2386                .unwrap(),
2387        )
2388        .unwrap();
2389        let rules_before: Vec<_> = ctx
2390            .state()
2391            .physical_optimizers()
2392            .iter()
2393            .map(|rule| rule.name().to_owned())
2394            .collect();
2395        let config = GraphConfig::builder()
2396            .node_table("Person", "id")
2397            .build()
2398            .unwrap();
2399        for (predicate, expected) in [("p.id = 99", false), ("p.id = 1", true)] {
2400            let query = CypherQuery::new(&format!(
2401                "MATCH (p:Person) WHERE {predicate} RETURN count(DISTINCT p.id) AS n, \
2402                 count(DISTINCT p.id) > 0 AS present, \
2403                 CASE WHEN count(*) > 0 THEN 1 ELSE 0 END AS flag"
2404            ))
2405            .unwrap()
2406            .with_config(config.clone());
2407            let result = query.execute_with_context(ctx.clone()).await.unwrap();
2408            assert_eq!(result.num_rows(), 1);
2409            assert_eq!(
2410                result
2411                    .column(0)
2412                    .as_any()
2413                    .downcast_ref::<Int64Array>()
2414                    .unwrap()
2415                    .value(0),
2416                i64::from(expected)
2417            );
2418            assert_eq!(
2419                result
2420                    .column(1)
2421                    .as_any()
2422                    .downcast_ref::<BooleanArray>()
2423                    .unwrap()
2424                    .value(0),
2425                expected
2426            );
2427            assert_eq!(
2428                result
2429                    .column(2)
2430                    .as_any()
2431                    .downcast_ref::<Int64Array>()
2432                    .unwrap()
2433                    .value(0),
2434                i64::from(expected)
2435            );
2436        }
2437        let rules_after: Vec<_> = ctx
2438            .state()
2439            .physical_optimizers()
2440            .iter()
2441            .map(|rule| rule.name().to_owned())
2442            .collect();
2443        assert_eq!(
2444            rules_before, rules_after,
2445            "caller session must remain unchanged"
2446        );
2447        assert!(rules_after
2448            .iter()
2449            .any(|name| name == "aggregate_statistics"));
2450    }
2451
2452    #[tokio::test]
2453    async fn test_execute_with_context_simple_scan() {
2454        use arrow_array::{Int64Array, RecordBatch, StringArray};
2455        use arrow_schema::{DataType, Field, Schema};
2456        use datafusion::datasource::MemTable;
2457        use datafusion::execution::context::SessionContext;
2458        use std::sync::Arc;
2459
2460        // Create test data
2461        let schema = Arc::new(Schema::new(vec![
2462            Field::new("id", DataType::Int64, false),
2463            Field::new("name", DataType::Utf8, false),
2464            Field::new("age", DataType::Int64, false),
2465        ]));
2466        let batch = RecordBatch::try_new(
2467            schema.clone(),
2468            vec![
2469                Arc::new(Int64Array::from(vec![1, 2, 3])),
2470                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2471                Arc::new(Int64Array::from(vec![28, 34, 29])),
2472            ],
2473        )
2474        .unwrap();
2475
2476        // Create SessionContext and register data source
2477        let mem_table =
2478            Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2479        let ctx = SessionContext::new();
2480        ctx.register_table("Person", mem_table).unwrap();
2481
2482        // Create query
2483        let cfg = GraphConfig::builder()
2484            .node_table("Person", "id")
2485            .build()
2486            .unwrap();
2487
2488        let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2489            .unwrap()
2490            .with_config(cfg);
2491
2492        // Execute with context (catalog built automatically)
2493        let result = query.execute_with_context(ctx).await.unwrap();
2494
2495        // Verify results
2496        assert_eq!(result.num_rows(), 3);
2497        assert_eq!(result.num_columns(), 1);
2498
2499        let names = result
2500            .column(0)
2501            .as_any()
2502            .downcast_ref::<StringArray>()
2503            .unwrap();
2504        assert_eq!(names.value(0), "Alice");
2505        assert_eq!(names.value(1), "Bob");
2506        assert_eq!(names.value(2), "Carol");
2507    }
2508
2509    #[tokio::test]
2510    async fn test_execute_with_context_with_filter() {
2511        use arrow_array::{Int64Array, RecordBatch, StringArray};
2512        use arrow_schema::{DataType, Field, Schema};
2513        use datafusion::datasource::MemTable;
2514        use datafusion::execution::context::SessionContext;
2515        use std::sync::Arc;
2516
2517        // Create test data
2518        let schema = Arc::new(Schema::new(vec![
2519            Field::new("id", DataType::Int64, false),
2520            Field::new("name", DataType::Utf8, false),
2521            Field::new("age", DataType::Int64, false),
2522        ]));
2523        let batch = RecordBatch::try_new(
2524            schema.clone(),
2525            vec![
2526                Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2527                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2528                Arc::new(Int64Array::from(vec![28, 34, 29, 42])),
2529            ],
2530        )
2531        .unwrap();
2532
2533        // Create SessionContext
2534        let mem_table =
2535            Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2536        let ctx = SessionContext::new();
2537        ctx.register_table("Person", mem_table).unwrap();
2538
2539        // Create query with filter
2540        let cfg = GraphConfig::builder()
2541            .node_table("Person", "id")
2542            .build()
2543            .unwrap();
2544
2545        let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name, p.age")
2546            .unwrap()
2547            .with_config(cfg);
2548
2549        // Execute with context
2550        let result = query.execute_with_context(ctx).await.unwrap();
2551
2552        // Verify: should return Bob (34) and David (42)
2553        assert_eq!(result.num_rows(), 2);
2554        assert_eq!(result.num_columns(), 2);
2555
2556        let names = result
2557            .column(0)
2558            .as_any()
2559            .downcast_ref::<StringArray>()
2560            .unwrap();
2561        let ages = result
2562            .column(1)
2563            .as_any()
2564            .downcast_ref::<Int64Array>()
2565            .unwrap();
2566
2567        let results: Vec<(String, i64)> = (0..result.num_rows())
2568            .map(|i| (names.value(i).to_string(), ages.value(i)))
2569            .collect();
2570
2571        assert!(results.contains(&("Bob".to_string(), 34)));
2572        assert!(results.contains(&("David".to_string(), 42)));
2573    }
2574
2575    #[tokio::test]
2576    async fn test_execute_with_context_relationship_traversal() {
2577        use arrow_array::{Int64Array, RecordBatch, StringArray};
2578        use arrow_schema::{DataType, Field, Schema};
2579        use datafusion::datasource::MemTable;
2580        use datafusion::execution::context::SessionContext;
2581        use std::sync::Arc;
2582
2583        // Create Person nodes
2584        let person_schema = Arc::new(Schema::new(vec![
2585            Field::new("id", DataType::Int64, false),
2586            Field::new("name", DataType::Utf8, false),
2587        ]));
2588        let person_batch = RecordBatch::try_new(
2589            person_schema.clone(),
2590            vec![
2591                Arc::new(Int64Array::from(vec![1, 2, 3])),
2592                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol"])),
2593            ],
2594        )
2595        .unwrap();
2596
2597        // Create KNOWS relationships
2598        let knows_schema = Arc::new(Schema::new(vec![
2599            Field::new("src_id", DataType::Int64, false),
2600            Field::new("dst_id", DataType::Int64, false),
2601            Field::new("since", DataType::Int64, false),
2602        ]));
2603        let knows_batch = RecordBatch::try_new(
2604            knows_schema.clone(),
2605            vec![
2606                Arc::new(Int64Array::from(vec![1, 2])),
2607                Arc::new(Int64Array::from(vec![2, 3])),
2608                Arc::new(Int64Array::from(vec![2020, 2021])),
2609            ],
2610        )
2611        .unwrap();
2612
2613        // Create SessionContext and register tables
2614        let person_table = Arc::new(
2615            MemTable::try_new(person_schema.clone(), vec![vec![person_batch.clone()]]).unwrap(),
2616        );
2617        let knows_table = Arc::new(
2618            MemTable::try_new(knows_schema.clone(), vec![vec![knows_batch.clone()]]).unwrap(),
2619        );
2620
2621        let ctx = SessionContext::new();
2622        ctx.register_table("Person", person_table).unwrap();
2623        ctx.register_table("KNOWS", knows_table).unwrap();
2624
2625        // Create query
2626        let cfg = GraphConfig::builder()
2627            .node_table("Person", "id")
2628            .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id")
2629            .build()
2630            .unwrap();
2631
2632        let query = CypherQuery::new("MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name")
2633            .unwrap()
2634            .with_config(cfg);
2635
2636        // Execute with context
2637        let result = query.execute_with_context(ctx).await.unwrap();
2638
2639        // Verify: should return 2 relationships (Alice->Bob, Bob->Carol)
2640        assert_eq!(result.num_rows(), 2);
2641        assert_eq!(result.num_columns(), 2);
2642
2643        let src_names = result
2644            .column(0)
2645            .as_any()
2646            .downcast_ref::<StringArray>()
2647            .unwrap();
2648        let dst_names = result
2649            .column(1)
2650            .as_any()
2651            .downcast_ref::<StringArray>()
2652            .unwrap();
2653
2654        let relationships: Vec<(String, String)> = (0..result.num_rows())
2655            .map(|i| {
2656                (
2657                    src_names.value(i).to_string(),
2658                    dst_names.value(i).to_string(),
2659                )
2660            })
2661            .collect();
2662
2663        assert!(relationships.contains(&("Alice".to_string(), "Bob".to_string())));
2664        assert!(relationships.contains(&("Bob".to_string(), "Carol".to_string())));
2665    }
2666
2667    #[tokio::test]
2668    async fn test_execute_with_context_order_by_limit() {
2669        use arrow_array::{Int64Array, RecordBatch, StringArray};
2670        use arrow_schema::{DataType, Field, Schema};
2671        use datafusion::datasource::MemTable;
2672        use datafusion::execution::context::SessionContext;
2673        use std::sync::Arc;
2674
2675        // Create test data
2676        let schema = Arc::new(Schema::new(vec![
2677            Field::new("id", DataType::Int64, false),
2678            Field::new("name", DataType::Utf8, false),
2679            Field::new("score", DataType::Int64, false),
2680        ]));
2681        let batch = RecordBatch::try_new(
2682            schema.clone(),
2683            vec![
2684                Arc::new(Int64Array::from(vec![1, 2, 3, 4])),
2685                Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])),
2686                Arc::new(Int64Array::from(vec![85, 92, 78, 95])),
2687            ],
2688        )
2689        .unwrap();
2690
2691        // Create SessionContext
2692        let mem_table =
2693            Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap());
2694        let ctx = SessionContext::new();
2695        ctx.register_table("Student", mem_table).unwrap();
2696
2697        // Create query with ORDER BY and LIMIT
2698        let cfg = GraphConfig::builder()
2699            .node_table("Student", "id")
2700            .build()
2701            .unwrap();
2702
2703        let query = CypherQuery::new(
2704            "MATCH (s:Student) RETURN s.name, s.score ORDER BY s.score DESC LIMIT 2",
2705        )
2706        .unwrap()
2707        .with_config(cfg);
2708
2709        // Execute with context
2710        let result = query.execute_with_context(ctx).await.unwrap();
2711
2712        // Verify: should return top 2 scores (David: 95, Bob: 92)
2713        assert_eq!(result.num_rows(), 2);
2714        assert_eq!(result.num_columns(), 2);
2715
2716        let names = result
2717            .column(0)
2718            .as_any()
2719            .downcast_ref::<StringArray>()
2720            .unwrap();
2721        let scores = result
2722            .column(1)
2723            .as_any()
2724            .downcast_ref::<Int64Array>()
2725            .unwrap();
2726
2727        // First row should be David (95)
2728        assert_eq!(names.value(0), "David");
2729        assert_eq!(scores.value(0), 95);
2730
2731        // Second row should be Bob (92)
2732        assert_eq!(names.value(1), "Bob");
2733        assert_eq!(scores.value(1), 92);
2734    }
2735
2736    #[tokio::test]
2737    async fn test_to_sql() {
2738        use arrow_array::RecordBatch;
2739        use arrow_schema::{DataType, Field, Schema};
2740        use std::collections::HashMap;
2741        use std::sync::Arc;
2742
2743        let schema = Arc::new(Schema::new(vec![
2744            Field::new("id", DataType::Int64, false),
2745            Field::new("name", DataType::Utf8, false),
2746        ]));
2747        let batch = RecordBatch::new_empty(schema.clone());
2748
2749        let mut datasets = HashMap::new();
2750        datasets.insert("Person".to_string(), batch);
2751
2752        let cfg = GraphConfig::builder()
2753            .node_table("Person", "id")
2754            .build()
2755            .unwrap();
2756
2757        let query = CypherQuery::new("MATCH (p:Person) RETURN p.name")
2758            .unwrap()
2759            .with_config(cfg);
2760
2761        let sql = query.to_sql(datasets, None).await.unwrap();
2762        println!("Generated SQL: {}", sql);
2763
2764        assert!(sql.contains("SELECT"));
2765        assert!(sql.to_lowercase().contains("from person"));
2766        // Note: DataFusion unparser might quote identifiers or use aliases
2767        // We check for "p.name" which is the expected output alias
2768        assert!(sql.contains("p.name"));
2769    }
2770
2771    #[tokio::test]
2772    async fn test_to_sql_rejects_runtime_only_cypher_function() {
2773        let batch = RecordBatch::try_from_iter(vec![
2774            (
2775                "id",
2776                Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2777            ),
2778            (
2779                "number",
2780                Arc::new(arrow_array::Float64Array::from(vec![-1.5])) as arrow_array::ArrayRef,
2781            ),
2782        ])
2783        .unwrap();
2784        let query = CypherQuery::new("MATCH (p:Person) RETURN round(p.number) AS value")
2785            .unwrap()
2786            .with_config(
2787                GraphConfig::builder()
2788                    .node_table("Person", "id")
2789                    .build()
2790                    .unwrap(),
2791            );
2792        let error = query
2793            .to_sql(HashMap::from([("Person".to_string(), batch)]), None)
2794            .await
2795            .unwrap_err();
2796        assert!(matches!(error, GraphError::UnsupportedFeature { .. }));
2797        assert!(error
2798            .to_string()
2799            .contains("SQL export of Cypher function 'round'"));
2800    }
2801
2802    #[tokio::test]
2803    async fn test_to_sql_preserves_constant_null_types() {
2804        let batch = RecordBatch::try_from_iter(vec![(
2805            "id",
2806            Arc::new(arrow_array::Int64Array::from(vec![1])) as arrow_array::ArrayRef,
2807        )])
2808        .unwrap();
2809        let datasets = HashMap::from([("Person".to_string(), batch)]);
2810        let config = GraphConfig::builder()
2811            .node_table("Person", "id")
2812            .build()
2813            .unwrap();
2814        for expression in [
2815            "round(null)",
2816            "abs(null)",
2817            "sqrt(null)",
2818            "floor(null)",
2819            "ceil(null)",
2820            "toUpper(null)",
2821            "null",
2822        ] {
2823            let query = CypherQuery::new(&format!("MATCH (p:Person) RETURN {expression} AS value"))
2824                .unwrap()
2825                .with_config(config.clone());
2826            let cypher = query.execute(datasets.clone(), None).await.unwrap();
2827            let sql_text = query.to_sql(datasets.clone(), None).await.unwrap();
2828            let sql = crate::sql_query::SqlQuery::new(&sql_text)
2829                .execute(datasets.clone())
2830                .await
2831                .unwrap();
2832            assert_eq!(
2833                sql.schema().field(0).data_type(),
2834                cypher.schema().field(0).data_type(),
2835                "{expression}: {sql_text}"
2836            );
2837            assert_eq!(sql.column(0).logical_null_count(), 1, "{expression}");
2838        }
2839    }
2840
2841    async fn write_lance_dataset(path: &std::path::Path, batch: arrow_array::RecordBatch) {
2842        use arrow_array::{RecordBatch, RecordBatchIterator};
2843        use lance::dataset::{Dataset, WriteParams};
2844
2845        let schema = batch.schema();
2846        let batches: Vec<std::result::Result<RecordBatch, arrow::error::ArrowError>> =
2847            vec![std::result::Result::Ok(batch)];
2848        let reader = RecordBatchIterator::new(batches.into_iter(), schema);
2849
2850        Dataset::write(reader, path.to_str().unwrap(), None::<WriteParams>)
2851            .await
2852            .expect("write lance dataset");
2853    }
2854
2855    fn build_people_batch() -> arrow_array::RecordBatch {
2856        use arrow_array::{ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
2857        use arrow_schema::{DataType, Field, Schema};
2858        use std::sync::Arc;
2859
2860        let schema = Arc::new(Schema::new(vec![
2861            Field::new("person_id", DataType::Int64, false),
2862            Field::new("name", DataType::Utf8, false),
2863            Field::new("age", DataType::Int32, false),
2864        ]));
2865
2866        let columns: Vec<ArrayRef> = vec![
2867            Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef,
2868            Arc::new(StringArray::from(vec!["Alice", "Bob", "Carol", "David"])) as ArrayRef,
2869            Arc::new(Int32Array::from(vec![28, 34, 29, 42])) as ArrayRef,
2870        ];
2871
2872        RecordBatch::try_new(schema, columns).expect("valid person batch")
2873    }
2874
2875    fn build_friendship_batch() -> arrow_array::RecordBatch {
2876        use arrow_array::{ArrayRef, Int64Array, RecordBatch};
2877        use arrow_schema::{DataType, Field, Schema};
2878        use std::sync::Arc;
2879
2880        let schema = Arc::new(Schema::new(vec![
2881            Field::new("person1_id", DataType::Int64, false),
2882            Field::new("person2_id", DataType::Int64, false),
2883        ]));
2884
2885        let columns: Vec<ArrayRef> = vec![
2886            Arc::new(Int64Array::from(vec![1, 1, 2, 3])) as ArrayRef,
2887            Arc::new(Int64Array::from(vec![2, 3, 4, 4])) as ArrayRef,
2888        ];
2889
2890        RecordBatch::try_new(schema, columns).expect("valid friendship batch")
2891    }
2892
2893    #[tokio::test]
2894    async fn executes_against_directory_namespace() {
2895        use arrow_array::StringArray;
2896        use tempfile::tempdir;
2897
2898        let tmp_dir = tempdir().unwrap();
2899        write_lance_dataset(&tmp_dir.path().join("Person.lance"), build_people_batch()).await;
2900        write_lance_dataset(
2901            &tmp_dir.path().join("FRIEND_OF.lance"),
2902            build_friendship_batch(),
2903        )
2904        .await;
2905
2906        let config = GraphConfig::builder()
2907            .node_table("Person", "person_id")
2908            .rel_table("FRIEND_OF", "Person", "Person", "person1_id", "person2_id")
2909            .build()
2910            .expect("valid graph config");
2911
2912        let query = CypherQuery::new("MATCH (p:Person) WHERE p.age > 30 RETURN p.name")
2913            .expect("query parses")
2914            .with_config(config);
2915
2916        let namespace = DirNamespace::new(tmp_dir.path().to_string_lossy().into_owned());
2917
2918        let result = query
2919            .execute_with_namespace(namespace.clone(), None)
2920            .await
2921            .expect("namespace execution succeeds");
2922
2923        use arrow_array::Array;
2924        let names = result
2925            .column(0)
2926            .as_any()
2927            .downcast_ref::<StringArray>()
2928            .expect("string column");
2929
2930        let mut values: Vec<String> = (0..names.len())
2931            .map(|i| names.value(i).to_string())
2932            .collect();
2933        values.sort();
2934        assert_eq!(values, vec!["Bob".to_string(), "David".to_string()]);
2935    }
2936
2937    #[tokio::test]
2938    async fn test_execute_fails_on_semantic_error() {
2939        use arrow_array::RecordBatch;
2940        use arrow_schema::{DataType, Field, Schema};
2941        use std::collections::HashMap;
2942        use std::sync::Arc;
2943
2944        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
2945        let batch = RecordBatch::new_empty(schema);
2946        let mut datasets = HashMap::new();
2947        datasets.insert("Person".to_string(), batch);
2948
2949        let cfg = GraphConfig::builder()
2950            .node_table("Person", "id")
2951            .build()
2952            .unwrap();
2953
2954        // Query referencing undefined variable 'x'
2955        let query = CypherQuery::new("MATCH (n:Person) RETURN x.name")
2956            .unwrap()
2957            .with_config(cfg);
2958
2959        let result = query.execute(datasets, None).await;
2960
2961        assert!(result.is_err());
2962        match result {
2963            Err(GraphError::PlanError { message, .. }) => {
2964                assert!(message.contains("Semantic analysis failed"));
2965                assert!(message.contains("Undefined variable: 'x'"));
2966            }
2967            _ => panic!(
2968                "Expected PlanError with semantic failure message, got {:?}",
2969                result
2970            ),
2971        }
2972    }
2973}