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, 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                    with_row_id = true;
313                    must_add_row_offset = true;
314                } else if field.name == ROW_ADDR {
315                    with_row_addr = true;
316                } else if field.name == ROW_OFFSET {
317                    with_row_addr = true;
318                    must_add_row_offset = true;
319                } else if field.name == ROW_LAST_UPDATED_AT_VERSION {
320                    with_row_last_updated_at_version = true;
321                } else if field.name == ROW_CREATED_AT_VERSION {
322                    with_row_created_at_version = true;
323                }
324            } else {
325                // Regular data column - validate it exists in base schema
326                if base.schema().field(&field.name).is_none() {
327                    return Err(Error::invalid_input(format!(
328                        "Column '{}' not found in schema",
329                        field.name
330                    )));
331                }
332                data_fields.push(field.clone());
333            }
334        }
335
336        // Create a schema with only data columns for the physical projection
337        let data_schema = Schema {
338            fields: data_fields,
339            metadata: projection.metadata.clone(),
340        };
341
342        // Calculate the physical projection from data columns only
343        let mut physical_projection = Projection::empty(base).union_schema(&data_schema);
344        physical_projection.with_row_id = with_row_id;
345        physical_projection.with_row_addr = with_row_addr;
346        physical_projection.with_row_last_updated_at_version = with_row_last_updated_at_version;
347        physical_projection.with_row_created_at_version = with_row_created_at_version;
348
349        // Build output expressions preserving the original order (including system columns)
350        let exprs = projection
351            .fields
352            .iter()
353            .map(|f| OutputColumn {
354                expr: Expr::Column(Column::from_name(&f.name)),
355                name: f.name.clone(),
356            })
357            .collect::<Vec<_>>();
358
359        Ok(Self {
360            physical_projection,
361            requested_output_expr: exprs,
362            must_add_row_offset,
363            scoring_exprs: HashMap::default(),
364        })
365    }
366
367    pub fn full(base: Arc<dyn Projectable>) -> Result<Self> {
368        let physical_cols: Vec<&str> = base
369            .schema()
370            .fields
371            .iter()
372            .map(|f| f.name.as_ref())
373            .collect::<Vec<_>>();
374
375        let physical_projection =
376            Projection::empty(base.clone()).union_columns(&physical_cols, OnMissing::Ignore)?;
377
378        let requested_output_expr = physical_cols
379            .into_iter()
380            .map(|col_name| OutputColumn {
381                expr: Expr::Column(Column::from_name(col_name)),
382                name: col_name.to_string(),
383            })
384            .collect();
385
386        Ok(Self {
387            physical_projection,
388            must_add_row_offset: false,
389            requested_output_expr,
390            scoring_exprs: HashMap::default(),
391        })
392    }
393
394    /// Convert the projection to a list of physical expressions
395    ///
396    /// This is used to apply the final projection (including dynamic expressions) to the data.
397    pub fn to_physical_exprs(
398        &self,
399        current_schema: &ArrowSchema,
400    ) -> Result<Vec<(Arc<dyn PhysicalExpr>, String)>> {
401        let physical_df_schema = Arc::new(DFSchema::try_from(current_schema.clone())?);
402        self.requested_output_expr
403            .iter()
404            .map(|output_column| {
405                let expr = if let Some(raw_expr) = self.scoring_exprs.get(&output_column.name) {
406                    let planner = Planner::new(Arc::new(current_schema.clone()));
407                    let expr = planner.parse_expr(raw_expr)?;
408                    planner.optimize_expr(expr)?
409                } else {
410                    output_column.expr.clone()
411                };
412                Ok((
413                    datafusion::physical_expr::create_physical_expr(
414                        &expr,
415                        physical_df_schema.as_ref(),
416                        &Default::default(),
417                    )?,
418                    output_column.name.clone(),
419                ))
420            })
421            .collect::<Result<Vec<_>>>()
422    }
423
424    /// Include the row id in the output
425    pub fn include_row_id(&mut self) {
426        self.physical_projection.with_row_id = true;
427        if !self
428            .requested_output_expr
429            .iter()
430            .any(|OutputColumn { name, .. }| name == ROW_ID)
431        {
432            self.requested_output_expr.push(OutputColumn {
433                expr: Expr::Column(Column::from_name(ROW_ID)),
434                name: ROW_ID.to_string(),
435            });
436        }
437    }
438
439    /// Include the row address in the output
440    pub fn include_row_addr(&mut self) {
441        self.physical_projection.with_row_addr = true;
442        if !self
443            .requested_output_expr
444            .iter()
445            .any(|OutputColumn { name, .. }| name == ROW_ADDR)
446        {
447            self.requested_output_expr.push(OutputColumn {
448                expr: Expr::Column(Column::from_name(ROW_ADDR)),
449                name: ROW_ADDR.to_string(),
450            });
451        }
452    }
453
454    /// Check if the projection has any output columns
455    ///
456    /// This doesn't mean there is a physical projection.  For example, we may someday support
457    /// something like `SELECT 1 AS foo` which would have an output column (foo) but no physical projection
458    pub fn has_output_cols(&self) -> bool {
459        !self.requested_output_expr.is_empty()
460    }
461
462    pub fn output_schema(&self) -> Result<ArrowSchema> {
463        let physical_schema = self.physical_projection.to_arrow_schema();
464        let exprs = self.to_physical_exprs(&physical_schema)?;
465        let fields = exprs
466            .iter()
467            .map(|(expr, name)| {
468                let metadata = expr.return_field(&physical_schema)?.metadata().clone();
469                Ok(ArrowField::new(
470                    name,
471                    expr.data_type(&physical_schema)?,
472                    expr.nullable(&physical_schema)?,
473                )
474                .with_metadata(metadata))
475            })
476            .collect::<Result<Vec<_>>>()?;
477        Ok(ArrowSchema::new_with_metadata(
478            fields,
479            physical_schema.metadata().clone(),
480        ))
481    }
482
483    #[instrument(skip_all, level = "debug")]
484    pub async fn project_batch(&self, batch: RecordBatch) -> Result<RecordBatch> {
485        let src = Arc::new(OneShotExec::from_batch(batch));
486
487        // Need to add ROW_OFFSET to get filterable schema
488        let extra_columns = vec![
489            ArrowField::new(ROW_ADDR, DataType::UInt64, true),
490            ArrowField::new(ROW_OFFSET, DataType::UInt64, true),
491        ];
492        let mut filterable_schema = self.physical_projection.to_schema();
493        filterable_schema = filterable_schema.merge(&ArrowSchema::new(extra_columns))?;
494
495        let physical_exprs = self.to_physical_exprs(&(&filterable_schema).into())?;
496        let projection = Arc::new(ProjectionExec::try_new(physical_exprs, src)?);
497
498        // Run dummy plan to execute projection, do not log the plan run
499        let stream = execute_plan(
500            projection,
501            LanceExecutionOptions {
502                skip_logging: true,
503                ..Default::default()
504            },
505        )?;
506        let batches = stream.try_collect::<Vec<_>>().await?;
507        if batches.len() != 1 {
508            Err(Error::internal("Expected exactly one batch".to_string()))
509        } else {
510            Ok(batches.into_iter().next().unwrap())
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    use arrow_array::{ArrayRef, Float32Array, Int64Array};
520    use lance_arrow::json::{is_json_field, json_field};
521
522    #[test]
523    fn test_scoring_column_expression() {
524        for scoring_column in ["_distance", "_score"] {
525            for has_stored_column in [false, true] {
526                let base = if has_stored_column {
527                    Arc::new(
528                        Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
529                            scoring_column,
530                            DataType::Float64,
531                            true,
532                        )]))
533                        .unwrap(),
534                    )
535                } else {
536                    Arc::new(Schema::default())
537                };
538                let expression = format!("1 - {scoring_column}");
539                let plan =
540                    ProjectionPlan::from_expressions(base, &[("inverted", expression.as_str())])
541                        .unwrap();
542
543                if has_stored_column {
544                    let stored_output = plan.output_schema().unwrap();
545                    assert_eq!(stored_output.field(0).data_type(), &DataType::Float64);
546                }
547
548                let batch = RecordBatch::try_from_iter([(
549                    scoring_column,
550                    Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef,
551                )])
552                .unwrap();
553
554                let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
555                let values = physical_exprs[0]
556                    .0
557                    .evaluate(&batch)
558                    .unwrap()
559                    .into_array(batch.num_rows())
560                    .unwrap();
561
562                assert_eq!(
563                    values.as_ref(),
564                    &Float32Array::from(vec![0.75, 0.25]),
565                    "unexpected result for {scoring_column}",
566                );
567            }
568        }
569    }
570
571    #[test]
572    fn test_stored_scoring_column_does_not_break_other_expressions() {
573        for scoring_column in ["_distance", "_score"] {
574            let base = Arc::new(
575                Schema::try_from(&ArrowSchema::new(vec![
576                    ArrowField::new("id", DataType::Int64, false),
577                    ArrowField::new(scoring_column, DataType::Float64, true),
578                ]))
579                .unwrap(),
580            );
581
582            ProjectionPlan::from_expressions(base, &[("incremented", "id + 1")]).unwrap();
583        }
584    }
585
586    #[test]
587    fn test_stored_scoring_column_is_case_insensitive() {
588        for (stored_name, requested_name) in [("_Distance", "_distance"), ("_Score", "_score")] {
589            let base = Arc::new(
590                Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
591                    stored_name,
592                    DataType::Float64,
593                    true,
594                )]))
595                .unwrap(),
596            );
597            let plan =
598                ProjectionPlan::from_expressions(base, &[("stored", requested_name)]).unwrap();
599
600            assert_eq!(
601                plan.output_schema().unwrap().field(0).data_type(),
602                &DataType::Float64,
603            );
604
605            let batch = RecordBatch::try_from_iter([(
606                requested_name,
607                Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef,
608            )])
609            .unwrap();
610            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
611            assert_eq!(
612                physical_exprs[0]
613                    .0
614                    .data_type(batch.schema().as_ref())
615                    .unwrap(),
616                DataType::Float32,
617            );
618        }
619    }
620
621    #[test]
622    fn test_generated_scoring_function_with_mixed_case_stored_column() {
623        for (stored_name, generated_name) in [("_Distance", "_distance"), ("_Score", "_score")] {
624            let base = Arc::new(
625                Schema::try_from(&ArrowSchema::new(vec![ArrowField::new(
626                    stored_name,
627                    DataType::Float64,
628                    true,
629                )]))
630                .unwrap(),
631            );
632            let expression = format!("coalesce(1 - {generated_name}, 0)");
633            let plan =
634                ProjectionPlan::from_expressions(base, &[("normalized", expression.as_str())])
635                    .unwrap();
636            let batch = RecordBatch::try_from_iter([(
637                generated_name,
638                Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef,
639            )])
640            .unwrap();
641
642            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
643            let values = physical_exprs[0]
644                .0
645                .evaluate(&batch)
646                .unwrap()
647                .into_array(batch.num_rows())
648                .unwrap();
649            assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0]));
650        }
651    }
652
653    #[test]
654    fn test_scoring_column_function_expression() {
655        for scoring_column in ["_distance", "_score"] {
656            let expression = format!("coalesce(1 - {scoring_column}, 0)");
657            let plan = ProjectionPlan::from_expressions(
658                Arc::new(Schema::default()),
659                &[("normalized", expression.as_str())],
660            )
661            .unwrap();
662            let batch = RecordBatch::try_from_iter([(
663                scoring_column,
664                Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef,
665            )])
666            .unwrap();
667
668            let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap();
669            let values = physical_exprs[0]
670                .0
671                .evaluate(&batch)
672                .unwrap()
673                .into_array(batch.num_rows())
674                .unwrap();
675            assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0]));
676        }
677    }
678
679    #[tokio::test]
680    async fn test_coalesce_in_column_map() {
681        // Regression test: `coalesce` in a column-map expression used to fail with
682        // "coalesce should have been simplified to case" because the parsed expression
683        // was passed straight to `create_physical_expr` without running the simplifier.
684        let arrow_schema = Arc::new(ArrowSchema::new(vec![
685            ArrowField::new("col_a", DataType::Int64, true),
686            ArrowField::new("col_b", DataType::Int64, true),
687        ]));
688        let base_schema = Schema::try_from(arrow_schema.as_ref()).unwrap();
689        let base = Arc::new(base_schema);
690
691        let plan =
692            ProjectionPlan::from_expressions(base, &[("foo", "coalesce(col_a, col_b)")]).unwrap();
693
694        let batch = RecordBatch::try_new(
695            arrow_schema,
696            vec![
697                Arc::new(Int64Array::from(vec![Some(1), None, Some(3), None])),
698                Arc::new(Int64Array::from(vec![Some(10), Some(20), None, None])),
699            ],
700        )
701        .unwrap();
702
703        let projected = plan.project_batch(batch).await.unwrap();
704        let foo = projected
705            .column(0)
706            .as_any()
707            .downcast_ref::<Int64Array>()
708            .unwrap();
709        assert_eq!(
710            foo.iter().collect::<Vec<_>>(),
711            vec![Some(1), Some(20), Some(3), None],
712        );
713    }
714
715    #[test]
716    fn test_output_schema_preserves_json_extension_metadata() {
717        let arrow_schema = ArrowSchema::new(vec![
718            ArrowField::new("id", DataType::Int32, false),
719            json_field("meta", true),
720        ]);
721        let base_schema = Schema::try_from(&arrow_schema).unwrap();
722        let base = Arc::new(base_schema.clone());
723
724        let plan = ProjectionPlan::from_schema(base, &base_schema).unwrap();
725
726        let physical = plan.physical_projection.to_arrow_schema();
727        assert!(is_json_field(physical.field_with_name("meta").unwrap()));
728
729        let output = plan.output_schema().unwrap();
730        let output_field = output.field_with_name("meta").unwrap();
731        assert!(is_json_field(output_field));
732    }
733}