Skip to main content

lance_datafusion/
projection.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use arrow_array::RecordBatch;
5use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
6use datafusion::{logical_expr::Expr, physical_plan::projection::ProjectionExec};
7use datafusion_common::{Column, DFSchema};
8use datafusion_physical_expr::PhysicalExpr;
9use futures::TryStreamExt;
10use std::{
11    collections::{HashMap, HashSet},
12    sync::Arc,
13};
14use tracing::instrument;
15
16use lance_core::{
17    Error, ROW_ADDR, ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION, ROW_OFFSET,
18    Result, WILDCARD,
19    datatypes::{OnMissing, OnTypeMismatch, Projectable, Projection, Schema},
20};
21
22use crate::{
23    exec::{LanceExecutionOptions, OneShotExec, execute_plan},
24    planner::Planner,
25};
26
27const SCORING_COLUMNS: [&str; 2] = ["_distance", "_score"];
28
29fn canonical_scoring_column(name: &str) -> Option<&'static str> {
30    SCORING_COLUMNS
31        .into_iter()
32        .find(|scoring_column| name.eq_ignore_ascii_case(scoring_column))
33}
34
35struct ProjectionBuilder {
36    base: Arc<dyn Projectable>,
37    planner: Planner,
38    output: HashMap<String, Expr>,
39    output_cols: Vec<OutputColumn>,
40    scoring_exprs: HashMap<String, String>,
41    physical_cols_set: HashSet<String>,
42    physical_cols: Vec<String>,
43    needs_row_id: bool,
44    needs_row_addr: bool,
45    needs_row_last_updated_at: bool,
46    needs_row_created_at: bool,
47    must_add_row_offset: bool,
48    has_wildcard: bool,
49}
50
51impl ProjectionBuilder {
52    fn new(base: Arc<dyn Projectable>) -> Self {
53        let full_schema = Arc::new(Projection::full(base.clone()).to_arrow_schema());
54        let full_schema = Arc::new(ProjectionPlan::add_system_columns(&full_schema));
55        let planner = Planner::new(full_schema);
56
57        Self {
58            base,
59            planner,
60            output: HashMap::default(),
61            output_cols: Vec::default(),
62            scoring_exprs: HashMap::default(),
63            physical_cols_set: HashSet::default(),
64            physical_cols: Vec::default(),
65            needs_row_id: false,
66            needs_row_addr: false,
67            needs_row_created_at: false,
68            needs_row_last_updated_at: false,
69            must_add_row_offset: false,
70            has_wildcard: false,
71        }
72    }
73
74    fn check_duplicate_column(&self, name: &str) -> Result<()> {
75        if self.output.contains_key(name) {
76            return Err(Error::invalid_input(format!(
77                "Duplicate column name: {}",
78                name
79            )));
80        }
81        Ok(())
82    }
83
84    fn add_column(&mut self, output_name: &str, raw_expr: &str) -> Result<()> {
85        self.check_duplicate_column(output_name)?;
86
87        let expr = self.planner.parse_expr(raw_expr)?;
88        let expr = if Self::references_scoring_column(&expr) {
89            // A scoring name can refer to either a stored column or a search-generated
90            // Float32 column. Reparse and coerce once the physical input schema disambiguates it.
91            self.scoring_exprs
92                .insert(output_name.to_string(), raw_expr.to_string());
93            expr
94        } else {
95            // Run simplification + coercion so that expressions like `coalesce(...)`
96            // (which DataFusion's physical evaluator expects to have been rewritten
97            // into a `CASE` expression by the simplifier) work correctly.
98            self.planner.optimize_expr(expr)?
99        };
100
101        // If the expression is a bare column reference to a system column, mark that we need it
102        if let Expr::Column(Column {
103            name,
104            relation: None,
105            ..
106        }) = &expr
107        {
108            if name == ROW_ID {
109                self.needs_row_id = true;
110            } else if name == ROW_ADDR {
111                self.needs_row_addr = true;
112            } else if name == ROW_OFFSET {
113                self.must_add_row_offset = true;
114            } else if name == ROW_LAST_UPDATED_AT_VERSION {
115                self.needs_row_last_updated_at = true;
116            } else if name == ROW_CREATED_AT_VERSION {
117                self.needs_row_created_at = true;
118            }
119        }
120
121        for col in Planner::column_names_in_expr(&expr) {
122            // Discovery can bind an exact provisional scoring field beside a mixed-case stored
123            // field. Load the stored field too so final-schema replanning can select the stored
124            // or search-generated field from the physical input.
125            let physical_col = if canonical_scoring_column(&col).is_some() {
126                self.base
127                    .schema()
128                    .field_case_insensitive(&col)
129                    .map(|field| field.name.clone())
130                    .unwrap_or(col)
131            } else {
132                col
133            };
134            if self.physical_cols_set.contains(&physical_col) {
135                continue;
136            }
137            self.physical_cols.push(physical_col.clone());
138            self.physical_cols_set.insert(physical_col);
139        }
140        self.output.insert(output_name.to_string(), expr.clone());
141
142        self.output_cols.push(OutputColumn {
143            expr,
144            name: output_name.to_string(),
145        });
146
147        Ok(())
148    }
149
150    fn references_scoring_column(expr: &Expr) -> bool {
151        Planner::column_names_in_expr(expr)
152            .iter()
153            .any(|name| canonical_scoring_column(name).is_some())
154    }
155
156    fn add_columns(&mut self, columns: &[(impl AsRef<str>, impl AsRef<str>)]) -> Result<()> {
157        for (output_name, raw_expr) in columns {
158            if raw_expr.as_ref() == WILDCARD {
159                self.has_wildcard = true;
160                for col in self.base.schema().fields.iter().map(|f| f.name.as_str()) {
161                    self.check_duplicate_column(col)?;
162                    self.output_cols.push(OutputColumn {
163                        expr: Expr::Column(Column::from_name(col)),
164                        name: col.to_string(),
165                    });
166                    // Throw placeholder expr in self.output, this will trigger error on duplicates
167                    self.output.insert(col.to_string(), Expr::default());
168                }
169            } else {
170                self.add_column(output_name.as_ref(), raw_expr.as_ref())?;
171            }
172        }
173        Ok(())
174    }
175
176    fn build(self) -> Result<ProjectionPlan> {
177        // Now, calculate the physical projection from the columns referenced by the expressions
178        //
179        // If a column is missing it might be a system column (_rowid, _distance, etc.) and so
180        // we ignore it.  We don't need to load that column from disk at least, which is all we are
181        // trying to calculate here.
182        let mut physical_projection = if self.has_wildcard {
183            Projection::full(self.base.clone())
184        } else {
185            Projection::empty(self.base.clone())
186                .union_columns(&self.physical_cols, OnMissing::Ignore)?
187        };
188
189        physical_projection.with_row_id = self.needs_row_id;
190        physical_projection.with_row_addr = self.needs_row_addr || self.must_add_row_offset;
191        physical_projection.with_row_last_updated_at_version = self.needs_row_last_updated_at;
192        physical_projection.with_row_created_at_version = self.needs_row_created_at;
193
194        Ok(ProjectionPlan {
195            physical_projection,
196            must_add_row_offset: self.must_add_row_offset,
197            requested_output_expr: self.output_cols,
198            scoring_exprs: self.scoring_exprs,
199        })
200    }
201}
202
203#[derive(Clone, Debug)]
204pub struct OutputColumn {
205    /// The expression that represents the output column
206    pub expr: Expr,
207    /// The name of the output column
208    pub name: String,
209}
210
211#[derive(Clone, Debug)]
212pub struct ProjectionPlan {
213    /// The physical schema that must be loaded from the dataset
214    pub physical_projection: Projection,
215
216    /// Needs the row address converted into a row offset
217    pub must_add_row_offset: bool,
218
219    /// The desired output columns
220    pub requested_output_expr: Vec<OutputColumn>,
221
222    /// Original SQL for scoring expressions that must be replanned against the physical schema.
223    scoring_exprs: HashMap<String, String>,
224}
225
226impl ProjectionPlan {
227    fn add_system_columns(schema: &ArrowSchema) -> ArrowSchema {
228        let mut fields = Vec::from_iter(schema.fields.iter().cloned());
229        fields.push(Arc::new(ArrowField::new(ROW_ID, DataType::UInt64, true)));
230        fields.push(Arc::new(ArrowField::new(ROW_ADDR, DataType::UInt64, true)));
231        fields.push(Arc::new(ArrowField::new(
232            ROW_OFFSET,
233            DataType::UInt64,
234            true,
235        )));
236        fields.push(Arc::new(
237            (*lance_core::ROW_LAST_UPDATED_AT_VERSION_FIELD).clone(),
238        ));
239        fields.push(Arc::new(
240            (*lance_core::ROW_CREATED_AT_VERSION_FIELD).clone(),
241        ));
242        // Exact scoring fields are needed for initial parsing of schema-dependent functions, even
243        // beside a mixed-case stored field. The stored field is carried into the physical
244        // projection separately, and scoring expressions are replanned against the final schema.
245        for name in SCORING_COLUMNS {
246            if schema.field_with_name(name).is_err() {
247                fields.push(Arc::new(ArrowField::new(name, DataType::Float32, true)));
248            }
249        }
250        ArrowSchema::new(fields)
251    }
252
253    /// Set the projection from SQL expressions
254    pub fn from_expressions(
255        base: Arc<dyn Projectable>,
256        columns: &[(impl AsRef<str>, impl AsRef<str>)],
257    ) -> Result<Self> {
258        let mut builder = ProjectionBuilder::new(base);
259        builder.add_columns(columns)?;
260        builder.build()
261    }
262
263    /// Set the projection from a schema
264    ///
265    /// This plan will have no complex expressions, the schema must be a subset of the dataset schema.
266    ///
267    /// With this approach it is possible to refer to portions of nested fields.
268    ///
269    /// For example, if the schema is:
270    ///
271    /// ```ignore
272    /// {
273    ///   "metadata": {
274    ///     "location": {
275    ///       "x": f32,
276    ///       "y": f32,
277    ///     },
278    ///     "age": i32,
279    ///   }
280    /// }
281    /// ```
282    ///
283    /// It is possible to project a partial schema that drops `y` like:
284    ///
285    /// ```ignore
286    /// {
287    ///   "metadata": {
288    ///     "location": {
289    ///       "x": f32,
290    ///     },
291    ///     "age": i32,
292    ///   }
293    /// }
294    /// ```
295    ///
296    /// This is something that cannot be done easily using expressions.
297    pub fn from_schema(base: Arc<dyn Projectable>, projection: &Schema) -> Result<Self> {
298        // Separate data columns from system columns
299        // System columns (_rowid, _rowaddr, etc.) are handled via flags in Projection,
300        // not as fields in the Schema
301        let mut data_fields = Vec::new();
302        let mut with_row_id = false;
303        let mut with_row_addr = false;
304        let mut must_add_row_offset = false;
305        let mut with_row_last_updated_at_version = false;
306        let mut with_row_created_at_version = false;
307
308        for field in projection.fields.iter() {
309            if lance_core::is_system_column(&field.name) {
310                // Handle known system columns that can be included in projections
311                if field.name == ROW_ID {
312                    // Only `_rowoffset` needs `AddRowOffsetExec`. Requesting that node for
313                    // `_rowid` leaves it without the `_rowaddr` column it reads, so the scan
314                    // fails to plan with "Input plan does not have a _rowaddr column".
315                    with_row_id = true;
316                } else if field.name == ROW_ADDR {
317                    with_row_addr = true;
318                } else if field.name == ROW_OFFSET {
319                    with_row_addr = true;
320                    must_add_row_offset = true;
321                } else if field.name == ROW_LAST_UPDATED_AT_VERSION {
322                    with_row_last_updated_at_version = true;
323                } else if field.name == ROW_CREATED_AT_VERSION {
324                    with_row_created_at_version = true;
325                }
326            } else {
327                // Regular data column - validate it exists in base schema
328                if base.schema().field(&field.name).is_none() {
329                    return Err(Error::invalid_input(format!(
330                        "Column '{}' not found in schema",
331                        field.name
332                    )));
333                }
334                data_fields.push(field.clone());
335            }
336        }
337
338        // Create a schema with only data columns for the physical projection
339        let data_schema = Schema {
340            fields: data_fields,
341            metadata: projection.metadata.clone(),
342        };
343
344        // Resolve the requested fields against the base schema first.
345        // `union_schema` records any field carrying a non-negative id without
346        // checking that the base knows that id, so a schema numbered
347        // independently of the dataset -- `Schema::try_from(&ArrowSchema)` gives
348        // every field a fresh id -- yields a projection that silently resolves
349        // to fewer columns than were asked for, or to the wrong ones. Matching
350        // by name and type here reports that instead.
351        let data_schema = base.schema().project_by_schema(
352            &data_schema,
353            OnMissing::Error,
354            OnTypeMismatch::Error,
355        )?;
356
357        // Calculate the physical projection from data columns only
358        let mut physical_projection = Projection::empty(base).union_schema(&data_schema);
359        physical_projection.with_row_id = with_row_id;
360        physical_projection.with_row_addr = with_row_addr;
361        physical_projection.with_row_last_updated_at_version = with_row_last_updated_at_version;
362        physical_projection.with_row_created_at_version = with_row_created_at_version;
363
364        // Build output expressions preserving the original order (including system columns)
365        let exprs = projection
366            .fields
367            .iter()
368            .map(|f| OutputColumn {
369                expr: Expr::Column(Column::from_name(&f.name)),
370                name: f.name.clone(),
371            })
372            .collect::<Vec<_>>();
373
374        Ok(Self {
375            physical_projection,
376            requested_output_expr: exprs,
377            must_add_row_offset,
378            scoring_exprs: HashMap::default(),
379        })
380    }
381
382    pub fn full(base: Arc<dyn Projectable>) -> Result<Self> {
383        let physical_cols: Vec<&str> = base
384            .schema()
385            .fields
386            .iter()
387            .map(|f| f.name.as_ref())
388            .collect::<Vec<_>>();
389
390        let physical_projection =
391            Projection::empty(base.clone()).union_columns(&physical_cols, OnMissing::Ignore)?;
392
393        let requested_output_expr = physical_cols
394            .into_iter()
395            .map(|col_name| OutputColumn {
396                expr: Expr::Column(Column::from_name(col_name)),
397                name: col_name.to_string(),
398            })
399            .collect();
400
401        Ok(Self {
402            physical_projection,
403            must_add_row_offset: false,
404            requested_output_expr,
405            scoring_exprs: HashMap::default(),
406        })
407    }
408
409    /// Convert the projection to a list of physical expressions
410    ///
411    /// This is used to apply the final projection (including dynamic expressions) to the data.
412    pub fn to_physical_exprs(
413        &self,
414        current_schema: &ArrowSchema,
415    ) -> Result<Vec<(Arc<dyn PhysicalExpr>, String)>> {
416        let physical_df_schema = Arc::new(DFSchema::try_from(current_schema.clone())?);
417        self.requested_output_expr
418            .iter()
419            .map(|output_column| {
420                let expr = if let Some(raw_expr) = self.scoring_exprs.get(&output_column.name) {
421                    let planner = Planner::new(Arc::new(current_schema.clone()));
422                    let expr = planner.parse_expr(raw_expr)?;
423                    planner.optimize_expr(expr)?
424                } else {
425                    output_column.expr.clone()
426                };
427                Ok((
428                    datafusion::physical_expr::create_physical_expr(
429                        &expr,
430                        physical_df_schema.as_ref(),
431                        &Default::default(),
432                    )?,
433                    output_column.name.clone(),
434                ))
435            })
436            .collect::<Result<Vec<_>>>()
437    }
438
439    /// Include the row id in the output
440    pub fn include_row_id(&mut self) {
441        self.physical_projection.with_row_id = true;
442        if !self
443            .requested_output_expr
444            .iter()
445            .any(|OutputColumn { name, .. }| name == ROW_ID)
446        {
447            self.requested_output_expr.push(OutputColumn {
448                expr: Expr::Column(Column::from_name(ROW_ID)),
449                name: ROW_ID.to_string(),
450            });
451        }
452    }
453
454    /// Include the row address in the output
455    pub fn include_row_addr(&mut self) {
456        self.physical_projection.with_row_addr = true;
457        if !self
458            .requested_output_expr
459            .iter()
460            .any(|OutputColumn { name, .. }| name == ROW_ADDR)
461        {
462            self.requested_output_expr.push(OutputColumn {
463                expr: Expr::Column(Column::from_name(ROW_ADDR)),
464                name: ROW_ADDR.to_string(),
465            });
466        }
467    }
468
469    /// Check if the projection has any output columns
470    ///
471    /// This doesn't mean there is a physical projection.  For example, we may someday support
472    /// something like `SELECT 1 AS foo` which would have an output column (foo) but no physical projection
473    pub fn has_output_cols(&self) -> bool {
474        !self.requested_output_expr.is_empty()
475    }
476
477    pub fn output_schema(&self) -> Result<ArrowSchema> {
478        let physical_schema = self.physical_projection.to_arrow_schema();
479        let exprs = self.to_physical_exprs(&physical_schema)?;
480        let fields = exprs
481            .iter()
482            .map(|(expr, name)| {
483                let metadata = expr.return_field(&physical_schema)?.metadata().clone();
484                Ok(ArrowField::new(
485                    name,
486                    expr.data_type(&physical_schema)?,
487                    expr.nullable(&physical_schema)?,
488                )
489                .with_metadata(metadata))
490            })
491            .collect::<Result<Vec<_>>>()?;
492        Ok(ArrowSchema::new_with_metadata(
493            fields,
494            physical_schema.metadata().clone(),
495        ))
496    }
497
498    #[instrument(skip_all, level = "debug")]
499    pub async fn project_batch(&self, batch: RecordBatch) -> Result<RecordBatch> {
500        let src = Arc::new(OneShotExec::from_batch(batch));
501
502        // Need to add ROW_OFFSET to get filterable schema
503        let extra_columns = vec![
504            ArrowField::new(ROW_ADDR, DataType::UInt64, true),
505            ArrowField::new(ROW_OFFSET, DataType::UInt64, true),
506        ];
507        let mut filterable_schema = self.physical_projection.to_schema();
508        filterable_schema = filterable_schema.merge(&ArrowSchema::new(extra_columns))?;
509
510        let physical_exprs = self.to_physical_exprs(&(&filterable_schema).into())?;
511        let projection = Arc::new(ProjectionExec::try_new(physical_exprs, src)?);
512
513        // Run dummy plan to execute projection, do not log the plan run
514        let stream = execute_plan(
515            projection,
516            LanceExecutionOptions {
517                skip_logging: true,
518                ..Default::default()
519            },
520        )?;
521        let batches = stream.try_collect::<Vec<_>>().await?;
522        if batches.len() != 1 {
523            Err(Error::internal("Expected exactly one batch".to_string()))
524        } else {
525            Ok(batches.into_iter().next().unwrap())
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    use arrow_array::{ArrayRef, Float32Array, Int64Array};
535    use arrow_schema::Fields;
536    use lance_arrow::json::{is_json_field, json_field};
537
538    #[test]
539    fn test_scoring_column_expression() {
540        for scoring_column in ["_distance", "_score"] {
541            for has_stored_column in [false, true] {
542                let base = if has_stored_column {
543                    Arc::new(
544                        Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
545                            scoring_column,
546                            DataType::Float64,
547                            true,
548                        )]))
549                        .unwrap(),
550                    )
551                } else {
552                    Arc::new(Schema::default())
553                };
554                let expression = format!("1 - {scoring_column}");
555                let plan =
556                    ProjectionPlan::from_expressions(base, &[("inverted", expression.as_str())])
557                        .unwrap();
558
559                if has_stored_column {
560                    let stored_output = plan.output_schema().unwrap();
561                    assert_eq!(stored_output.field(0).data_type(), &DataType::Float64);
562                }
563
564                let batch = RecordBatch::try_from_iter([(
565                    scoring_column,
566                    Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef,
567                )])
568                .unwrap();
569
570                let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
571                let values = physical_exprs[0]
572                    .0
573                    .evaluate(&batch)
574                    .unwrap()
575                    .into_array(batch.num_rows())
576                    .unwrap();
577
578                assert_eq!(
579                    values.as_ref(),
580                    &Float32Array::from(vec![0.75, 0.25]),
581                    "unexpected result for {scoring_column}",
582                );
583            }
584        }
585    }
586
587    #[test]
588    fn test_stored_scoring_column_does_not_break_other_expressions() {
589        for scoring_column in ["_distance", "_score"] {
590            let base = Arc::new(
591                Schema::try_from(&ArrowSchema::new(vec![
592                    ArrowField::new("id", DataType::Int64, false),
593                    ArrowField::new(scoring_column, DataType::Float64, true),
594                ]))
595                .unwrap(),
596            );
597
598            ProjectionPlan::from_expressions(base, &[("incremented", "id + 1")]).unwrap();
599        }
600    }
601
602    #[test]
603    fn test_stored_scoring_column_is_case_insensitive() {
604        for (stored_name, requested_name) in [("_Distance", "_distance"), ("_Score", "_score")] {
605            let base = Arc::new(
606                Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
607                    stored_name,
608                    DataType::Float64,
609                    true,
610                )]))
611                .unwrap(),
612            );
613            let plan =
614                ProjectionPlan::from_expressions(base, &[("stored", requested_name)]).unwrap();
615
616            assert_eq!(
617                plan.output_schema().unwrap().field(0).data_type(),
618                &DataType::Float64,
619            );
620
621            let batch = RecordBatch::try_from_iter([(
622                requested_name,
623                Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef,
624            )])
625            .unwrap();
626            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
627            assert_eq!(
628                physical_exprs[0]
629                    .0
630                    .data_type(batch.schema().as_ref())
631                    .unwrap(),
632                DataType::Float32,
633            );
634        }
635    }
636
637    #[test]
638    fn test_generated_scoring_function_with_mixed_case_stored_column() {
639        for (stored_name, generated_name) in [("_Distance", "_distance"), ("_Score", "_score")] {
640            let base = Arc::new(
641                Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
642                    stored_name,
643                    DataType::Float64,
644                    true,
645                )]))
646                .unwrap(),
647            );
648            let expression = format!("coalesce(1 - {generated_name}, 0)");
649            let plan =
650                ProjectionPlan::from_expressions(base, &[("normalized", expression.as_str())])
651                    .unwrap();
652            let batch = RecordBatch::try_from_iter([(
653                generated_name,
654                Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef,
655            )])
656            .unwrap();
657
658            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
659            let values = physical_exprs[0]
660                .0
661                .evaluate(&batch)
662                .unwrap()
663                .into_array(batch.num_rows())
664                .unwrap();
665            assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0]));
666        }
667    }
668
669    #[test]
670    fn test_scoring_column_function_expression() {
671        for scoring_column in ["_distance", "_score"] {
672            let expression = format!("coalesce(1 - {scoring_column}, 0)");
673            let plan = ProjectionPlan::from_expressions(
674                Arc::new(Schema::default()),
675                &[("normalized", expression.as_str())],
676            )
677            .unwrap();
678            let batch = RecordBatch::try_from_iter([(
679                scoring_column,
680                Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef,
681            )])
682            .unwrap();
683
684            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
685            let values = physical_exprs[0]
686                .0
687                .evaluate(&batch)
688                .unwrap()
689                .into_array(batch.num_rows())
690                .unwrap();
691            assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0]));
692        }
693    }
694
695    #[tokio::test]
696    async fn test_coalesce_in_column_map() {
697        // Regression test: `coalesce` in a column-map expression used to fail with
698        // "coalesce should have been simplified to case" because the parsed expression
699        // was passed straight to `create_physical_expr` without running the simplifier.
700        let arrow_schema = Arc::new(ArrowSchema::new(vec![
701            ArrowField::new("col_a", DataType::Int64, true),
702            ArrowField::new("col_b", DataType::Int64, true),
703        ]));
704        let base_schema = Schema::try_from(arrow_schema.as_ref()).unwrap();
705        let base = Arc::new(base_schema);
706
707        let plan =
708            ProjectionPlan::from_expressions(base, &[("foo", "coalesce(col_a, col_b)")]).unwrap();
709
710        let batch = RecordBatch::try_new(
711            arrow_schema,
712            vec![
713                Arc::new(Int64Array::from(vec![Some(1), None, Some(3), None])),
714                Arc::new(Int64Array::from(vec![Some(10), Some(20), None, None])),
715            ],
716        )
717        .unwrap();
718
719        let projected = plan.project_batch(batch).await.unwrap();
720        let foo = projected
721            .column(0)
722            .as_any()
723            .downcast_ref::<Int64Array>()
724            .unwrap();
725        assert_eq!(
726            foo.iter().collect::<Vec<_>>(),
727            vec![Some(1), Some(20), Some(3), None],
728        );
729    }
730
731    #[test]
732    fn test_output_schema_preserves_json_extension_metadata() {
733        let arrow_schema = ArrowSchema::new(vec![
734            ArrowField::new("id", DataType::Int32, false),
735            json_field("meta", true),
736        ]);
737        let base_schema = Schema::try_from(&arrow_schema).unwrap();
738        let base = Arc::new(base_schema.clone());
739
740        let plan = ProjectionPlan::from_schema(base, &base_schema).unwrap();
741
742        let physical = plan.physical_projection.to_arrow_schema();
743        assert!(is_json_field(physical.field_with_name("meta").unwrap()));
744
745        let output = plan.output_schema().unwrap();
746        let output_field = output.field_with_name("meta").unwrap();
747        assert!(is_json_field(output_field));
748    }
749
750    fn nested_base_schema() -> Schema {
751        Schema::try_from(&ArrowSchema::new(vec![
752            ArrowField::new("id", DataType::Int32, false),
753            ArrowField::new(
754                "meta",
755                DataType::Struct(Fields::from(vec![
756                    ArrowField::new("a", DataType::Int32, true),
757                    ArrowField::new("b", DataType::Int32, true),
758                ])),
759                true,
760            ),
761        ]))
762        .unwrap()
763    }
764
765    /// A nested child the base does not have must be reported, not dropped.
766    ///
767    /// Only top-level names are checked before the projection is built, so
768    /// `meta` passes and the walk then records every id beneath it. `c` carries
769    /// a non-negative id the base never issued, which resolves to nothing: the
770    /// caller asked for two children, silently got one.
771    #[test]
772    fn test_from_schema_rejects_a_nested_child_the_base_does_not_have() {
773        let requested = Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
774            "meta",
775            DataType::Struct(Fields::from(vec![
776                ArrowField::new("a", DataType::Int32, true),
777                ArrowField::new("c", DataType::Int32, true),
778            ])),
779            true,
780        )]))
781        .unwrap();
782
783        let error = ProjectionPlan::from_schema(Arc::new(nested_base_schema()), &requested)
784            .expect_err("a nested child outside the base schema must be rejected");
785        assert!(
786            error.to_string().contains('c'),
787            "the error should name the offending child: {error}"
788        );
789    }
790
791    /// Ids must be resolved against the base, not trusted.
792    ///
793    /// A schema built on its own numbers from scratch, so `meta.b` here carries
794    /// the id the base issued to `meta.a`. Taken at face value the projection
795    /// reads the wrong column and reports no error at all.
796    #[test]
797    fn test_from_schema_resolves_independently_numbered_ids() {
798        let requested = Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
799            "meta",
800            DataType::Struct(Fields::from(vec![ArrowField::new(
801                "b",
802                DataType::Int32,
803                true,
804            )])),
805            true,
806        )]))
807        .unwrap();
808        let base_schema = nested_base_schema();
809        // Numbered alone, `b` takes an id the base issued to a different field.
810        let requested_b = requested.fields[0].children[0].id;
811        let base_b = base_schema.fields[1].children[1].id;
812        assert_ne!(
813            requested_b, base_b,
814            "the requested id must differ from the base's for this to bite"
815        );
816
817        let plan = ProjectionPlan::from_schema(Arc::new(base_schema), &requested).unwrap();
818        let projected = plan.physical_projection.to_bare_schema();
819        assert_eq!(
820            projected.fields[0]
821                .children
822                .iter()
823                .map(|child| child.name.as_str())
824                .collect::<Vec<_>>(),
825            vec!["b"],
826            "the projection must follow the requested name, not the colliding id"
827        );
828    }
829}