Skip to main content

datafusion_datasource/
projection.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::sync::Arc;
19
20use arrow::datatypes::{Schema, SchemaRef};
21use datafusion_common::{
22    Result, ScalarValue,
23    tree_node::{Transformed, TransformedResult, TreeNode},
24};
25use datafusion_physical_expr::{
26    expressions::{Column, Literal},
27    projection::{ProjectionExpr, ProjectionExprs},
28};
29use datafusion_physical_expr_adapter::rewrite::rewrite_input_file_name_in_projection;
30use futures::{FutureExt, StreamExt};
31use itertools::Itertools;
32
33use crate::{
34    PartitionedFile, TableSchema,
35    file_stream::{FileOpenFuture, FileOpener},
36};
37
38/// A file opener that handles applying a projection on top of an inner opener.
39///
40/// This includes handling partition columns.
41///
42/// Any projection pushed down will be split up into:
43/// - Simple column indices / column selection
44/// - A remainder projection that this opener applies on top of it
45///
46/// This is meant to simplify projection pushdown for sources like CSV
47/// that can only handle "simple" column selection.
48pub struct ProjectionOpener {
49    inner: Arc<dyn FileOpener>,
50    projection: ProjectionExprs,
51    input_schema: SchemaRef,
52    partition_columns: Vec<PartitionColumnIndex>,
53}
54
55impl ProjectionOpener {
56    pub fn try_new(
57        projection: SplitProjection,
58        inner: Arc<dyn FileOpener>,
59        file_schema: &Schema,
60    ) -> Result<Arc<dyn FileOpener>> {
61        Ok(Arc::new(ProjectionOpener {
62            inner,
63            projection: projection.remapped_projection,
64            input_schema: Arc::new(file_schema.project(&projection.file_indices)?),
65            partition_columns: projection.partition_columns,
66        }))
67    }
68}
69
70impl FileOpener for ProjectionOpener {
71    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
72        let partition_values = partitioned_file.partition_values.clone();
73
74        // Modify any references to partition columns in the projection expressions
75        // and substitute them with literal values from PartitionedFile.partition_values
76        let projection = if self.partition_columns.is_empty() {
77            self.projection.clone()
78        } else {
79            inject_partition_columns_into_projection(
80                &self.projection,
81                &self.partition_columns,
82                partition_values,
83            )
84        };
85        // Replace `input_file_name()` with a per-file literal if present.
86        let projection = rewrite_input_file_name_in_projection(
87            projection,
88            partitioned_file.object_meta.location.as_ref(),
89        )?;
90        let projector = projection.make_projector(&self.input_schema)?;
91
92        let inner = self.inner.open(partitioned_file)?;
93
94        Ok(async move {
95            let stream = inner.await?;
96            let stream = stream.map(move |batch| {
97                let batch = batch?;
98                let batch = projector.project_batch(&batch)?;
99                Ok(batch)
100            });
101            Ok(stream.boxed())
102        }
103        .boxed())
104    }
105}
106
107#[derive(Debug, Clone, Copy)]
108pub struct PartitionColumnIndex {
109    /// The index of this partition column in the remainder projection (>= num_file_columns)
110    pub in_remainder_projection: usize,
111    /// The index of this partition column in the partition_values array
112    pub in_partition_values: usize,
113}
114
115fn inject_partition_columns_into_projection(
116    projection: &ProjectionExprs,
117    partition_columns: &[PartitionColumnIndex],
118    partition_values: Vec<ScalarValue>,
119) -> ProjectionExprs {
120    // Pre-create all literals for partition columns to avoid cloning ScalarValues multiple times.
121    let partition_literals: Vec<Arc<Literal>> = partition_values
122        .into_iter()
123        .map(|value| Arc::new(Literal::new(value)))
124        .collect();
125
126    let projections = projection
127        .iter()
128        .map(|projection| {
129            let expr = Arc::clone(&projection.expr)
130                .transform(|expr| {
131                    let original_expr = Arc::clone(&expr);
132                    if let Some(column) = expr.downcast_ref::<Column>() {
133                        // Check if this column index corresponds to a partition column
134                        if let Some(pci) = partition_columns
135                            .iter()
136                            .find(|pci| pci.in_remainder_projection == column.index())
137                        {
138                            let literal =
139                                Arc::clone(&partition_literals[pci.in_partition_values]);
140                            return Ok(Transformed::yes(literal));
141                        }
142                    }
143                    Ok(Transformed::no(original_expr))
144                })
145                .data()
146                .expect("infallible transform");
147            ProjectionExpr::new(expr, projection.alias.clone())
148        })
149        .collect_vec();
150    ProjectionExprs::new(projections)
151}
152
153/// At a high level the goal of SplitProjection is to take a ProjectionExprs meant to be applied to the table schema
154/// and split that into:
155/// - The projection indices into the file schema (file_indices)
156/// - The projection indices into the partition values (partition_value_indices), which pre-compute both the index into the table schema
157///   and the index into the partition values array
158/// - A remapped projection that can be applied after the file projection is applied
159///   This remapped projection has the following properties:
160///     - Column indices referring to file columns are remapped to [0..file_indices.len())
161///     - Column indices referring to partition columns are remapped to [file_indices.len()..)
162///
163///   This allows the ProjectionOpener to easily identify which columns in the remapped projection
164///   refer to partition columns and substitute them with literals from the partition values.
165#[derive(Debug, Clone)]
166pub struct SplitProjection {
167    /// The original projection this [`SplitProjection`] was derived from
168    pub source: ProjectionExprs,
169    /// Column indices to read from file (public for file sources)
170    pub file_indices: Vec<usize>,
171    /// Pre-computed partition column mappings (internal, used by ProjectionOpener)
172    pub(crate) partition_columns: Vec<PartitionColumnIndex>,
173    /// The remapped projection (internal, used by ProjectionOpener)
174    pub(crate) remapped_projection: ProjectionExprs,
175}
176
177impl SplitProjection {
178    pub fn unprojected(table_schema: &TableSchema) -> Self {
179        let projection = ProjectionExprs::from_indices(
180            &(0..table_schema.table_schema().fields().len()).collect_vec(),
181            table_schema.table_schema(),
182        );
183        Self::new(table_schema.file_schema(), &projection)
184    }
185
186    /// Creates a new [`SplitProjection`] by splitting a projection into
187    /// simple file column indices and a remainder projection that is applied after reading the file.
188    ///
189    /// In other words: we get a `Vec<usize>` projection that is meant to be applied on top of `file_schema`
190    /// and a remainder projection that is applied to the result of that first projection.
191    ///
192    /// Here `file_schema` is expected to be the *logical* schema of the file, that is the
193    /// table schema minus any partition columns.
194    /// Partition columns are always expected to be at the end of the table schema.
195    /// Note that `file_schema` is *not* the physical schema of the file.
196    pub fn new(logical_file_schema: &Schema, projection: &ProjectionExprs) -> Self {
197        let num_file_schema_columns = logical_file_schema.fields().len();
198
199        // Collect all unique columns and classify as file or partition
200        let mut file_columns = Vec::new();
201        let mut partition_columns = Vec::new();
202        let mut all_columns = std::collections::HashMap::new();
203
204        // Extract all unique column references (index -> name)
205        for proj_expr in projection {
206            proj_expr
207                .expr
208                .apply(|expr| {
209                    if let Some(column) = expr.downcast_ref::<Column>() {
210                        all_columns
211                            .entry(column.index())
212                            .or_insert_with(|| column.name().to_string());
213                    }
214                    Ok(datafusion_common::tree_node::TreeNodeRecursion::Continue)
215                })
216                .expect("infallible apply");
217        }
218
219        // Sort by index and classify into file vs partition columns
220        let mut sorted_columns: Vec<_> = all_columns
221            .into_iter()
222            .map(|(idx, name)| (name, idx))
223            .collect();
224        sorted_columns.sort_by_key(|(_, idx)| *idx);
225
226        // Separate file and partition columns, assigning final indices
227        // Pre-create all remapped columns to avoid duplicate Arc'd expressions
228        let mut column_mapping = std::collections::HashMap::new();
229        let mut file_idx = 0;
230        let mut partition_idx = 0;
231
232        for (name, original_index) in sorted_columns {
233            let new_index = if original_index < num_file_schema_columns {
234                // File column: gets index [0..num_file_columns)
235                file_columns.push(original_index);
236                let idx = file_idx;
237                file_idx += 1;
238                idx
239            } else {
240                // Partition column: gets index [num_file_columns..)
241                partition_columns.push(original_index);
242                let idx = file_idx + partition_idx;
243                partition_idx += 1;
244                idx
245            };
246
247            // Pre-create the remapped column so all references can share the same Arc
248            let new_column: Arc<dyn datafusion_physical_plan::PhysicalExpr> =
249                Arc::new(Column::new(&name, new_index));
250            column_mapping.insert(original_index, new_column);
251        }
252
253        // Single tree transformation: remap all column references using pre-created columns
254        let remapped_projection = projection
255            .iter()
256            .map(|proj_expr| {
257                let expr = Arc::clone(&proj_expr.expr)
258                    .transform(|expr| {
259                        let original_expr = Arc::clone(&expr);
260                        if let Some(column) = expr.downcast_ref::<Column>()
261                            && let Some(new_column) = column_mapping.get(&column.index())
262                        {
263                            return Ok(Transformed::yes(Arc::clone(new_column)));
264                        }
265                        Ok(Transformed::no(original_expr))
266                    })
267                    .data()
268                    .expect("infallible transform");
269                ProjectionExpr::new(expr, proj_expr.alias.clone())
270            })
271            .collect_vec();
272
273        // Pre-compute partition column mappings for ProjectionOpener
274        let num_file_columns = file_columns.len();
275        let partition_column_mappings = partition_columns
276            .iter()
277            .enumerate()
278            .map(|(partition_idx, &table_index)| PartitionColumnIndex {
279                in_remainder_projection: num_file_columns + partition_idx,
280                in_partition_values: table_index - num_file_schema_columns,
281            })
282            .collect_vec();
283
284        Self {
285            source: projection.clone(),
286            file_indices: file_columns,
287            partition_columns: partition_column_mappings,
288            remapped_projection: ProjectionExprs::from(remapped_projection),
289        }
290    }
291}
292
293#[cfg(test)]
294mod test {
295    use std::sync::Arc;
296
297    use arrow::array::{AsArray, RecordBatch, record_batch};
298    use arrow::datatypes as arrow_schema;
299    use arrow::datatypes::{DataType, Field, SchemaRef};
300    use datafusion_common::{DFSchema, ScalarValue, config::ConfigOptions};
301    use datafusion_expr::{
302        Expr, ScalarUDF, col, execution_props::ExecutionProps,
303        physical_planning_context::PhysicalPlanningContext,
304    };
305    use datafusion_functions::core::input_file_name::InputFileNameFunc;
306    use datafusion_physical_expr::{
307        ScalarFunctionExpr, create_physical_exprs, projection::ProjectionExpr,
308    };
309    use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
310    use futures::{FutureExt, StreamExt};
311    use itertools::Itertools;
312
313    use super::*;
314
315    struct StaticBatchOpener {
316        batch: RecordBatch,
317    }
318
319    impl FileOpener for StaticBatchOpener {
320        fn open(&self, _partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
321            let batch = self.batch.clone();
322            Ok(async move { Ok(futures::stream::iter([Ok(batch)]).boxed()) }.boxed())
323        }
324    }
325
326    fn create_projection_exprs<'a>(
327        exprs: impl IntoIterator<Item = &'a Expr>,
328        schema: &SchemaRef,
329    ) -> ProjectionExprs {
330        let df_schema = DFSchema::try_from(Arc::clone(schema)).unwrap();
331        let physical_exprs = create_physical_exprs(
332            exprs,
333            &df_schema,
334            &ExecutionProps::default(),
335            &PhysicalPlanningContext::default(),
336        )
337        .unwrap();
338        let projection_exprs = physical_exprs
339            .into_iter()
340            .enumerate()
341            .map(|(i, e)| ProjectionExpr::new(Arc::clone(&e), format!("col{i}")))
342            .collect_vec();
343        ProjectionExprs::from(projection_exprs)
344    }
345
346    fn input_file_name_expr() -> Arc<dyn PhysicalExpr> {
347        Arc::new(ScalarFunctionExpr::new(
348            "input_file_name",
349            Arc::new(ScalarUDF::from(InputFileNameFunc::new())),
350            vec![],
351            Arc::new(Field::new("input_file_name", DataType::Utf8, true)),
352            Arc::new(ConfigOptions::default()),
353        ))
354    }
355
356    #[tokio::test]
357    async fn test_projection_opener_rewrites_input_file_name_with_partitions() {
358        let file_schema = Schema::new(vec![Field::new("value", DataType::Int32, false)]);
359        let projection = ProjectionExprs::new([
360            ProjectionExpr::new(Arc::new(Column::new("value", 0)), "value"),
361            ProjectionExpr::new(Arc::new(Column::new("part", 1)), "part"),
362            ProjectionExpr::new(input_file_name_expr(), "file_name"),
363        ]);
364        let split = SplitProjection::new(&file_schema, &projection);
365        let input_batch =
366            record_batch!(("value", Int32, vec![10, 20])).expect("input batch");
367
368        let opener = ProjectionOpener::try_new(
369            split,
370            Arc::new(StaticBatchOpener { batch: input_batch }),
371            &file_schema,
372        )
373        .expect("projection opener");
374
375        let mut file = PartitionedFile::new("part=west/data.csv", 100);
376        file.partition_values = vec![ScalarValue::from("west")];
377        let mut stream = opener
378            .open(file)
379            .expect("open projection")
380            .await
381            .expect("inner stream");
382        let batch = stream
383            .next()
384            .await
385            .expect("one projected batch")
386            .expect("projected batch");
387        assert!(stream.next().await.is_none());
388
389        assert_eq!(batch.schema().field(0).name(), "value");
390        assert_eq!(batch.schema().field(1).name(), "part");
391        assert_eq!(batch.schema().field(2).name(), "file_name");
392
393        let values = batch
394            .column(0)
395            .as_primitive::<arrow::datatypes::Int32Type>();
396        assert_eq!(values.value(0), 10);
397        assert_eq!(values.value(1), 20);
398
399        let parts = batch.column(1).as_string::<i32>();
400        assert_eq!(parts.value(0), "west");
401        assert_eq!(parts.value(1), "west");
402
403        let file_names = batch.column(2).as_string::<i32>();
404        assert_eq!(file_names.value(0), "part=west/data.csv");
405        assert_eq!(file_names.value(1), "part=west/data.csv");
406    }
407
408    #[test]
409    fn test_split_projection_with_partition_columns() {
410        use arrow::array::AsArray;
411        use arrow::datatypes::Field;
412        // Simulate the avro_exec_with_partition test scenario:
413        // file_schema has 3 fields
414        let file_schema = Arc::new(Schema::new(vec![
415            Field::new("id", DataType::Int32, false),
416            Field::new("bool_col", DataType::Boolean, false),
417            Field::new("tinyint_col", DataType::Int8, false),
418        ]));
419
420        // table_schema has 4 fields (3 file + 1 partition)
421        let table_schema = Arc::new(Schema::new(vec![
422            Field::new("id", DataType::Int32, false),
423            Field::new("bool_col", DataType::Boolean, false),
424            Field::new("tinyint_col", DataType::Int8, false),
425            Field::new("date", DataType::Utf8, false), // partition column at index 3
426        ]));
427
428        // projection indices: [0, 1, 3, 2]
429        // This should select: id (0), bool_col (1), date (3-partition), tinyint_col (2)
430        let projection_indices = vec![0, 1, 3, 2];
431
432        // Create projection expressions from indices using the table schema
433        let projection =
434            ProjectionExprs::from_indices(&projection_indices, &table_schema);
435
436        // Call SplitProjection to separate file and partition columns
437        let split = SplitProjection::new(&file_schema, &projection);
438
439        // The file_indices should be [0, 1, 2] (all file columns needed)
440        assert_eq!(split.file_indices, vec![0, 1, 2]);
441
442        // Should have 1 partition column at in_partition_values index 0
443        assert_eq!(split.partition_columns.len(), 1);
444        assert_eq!(split.partition_columns[0].in_partition_values, 0);
445
446        // Now create a batch with only the file columns
447        let file_batch = record_batch!(
448            ("id", Int32, vec![4]),
449            ("bool_col", Boolean, vec![true]),
450            ("tinyint_col", Int8, vec![0])
451        )
452        .unwrap();
453
454        // After the fix, the remainder projection should have remapped indices:
455        // - File columns: [0, 1, 2] (unchanged since they're already in order)
456        // - Partition column: [3] (stays at index 3, which is >= num_file_columns)
457        // So the remainder expects input columns [0, 1, 2] and references column [3] for partition
458
459        // Verify that we can inject partition columns and apply the projection
460        let partition_values = vec![ScalarValue::from("2021-10-26")];
461
462        // Create partition column mapping
463        let partition_columns = vec![PartitionColumnIndex {
464            in_remainder_projection: 3, // partition column is at index 3 in remainder
465            in_partition_values: 0,     // first partition value
466        }];
467
468        // Inject partition columns (replaces Column(3) with Literal)
469        let injected_projection = inject_partition_columns_into_projection(
470            &split.remapped_projection,
471            &partition_columns,
472            partition_values,
473        );
474
475        // Now the projection should work on the file batch
476        let projector = injected_projection
477            .make_projector(&file_batch.schema())
478            .unwrap();
479        let result = projector.project_batch(&file_batch).unwrap();
480
481        // Verify the output has the correct column order: id, bool_col, date, tinyint_col
482        assert_eq!(result.num_columns(), 4);
483        assert_eq!(
484            result
485                .column(0)
486                .as_primitive::<arrow::datatypes::Int32Type>()
487                .value(0),
488            4
489        );
490        assert!(result.column(1).as_boolean().value(0));
491        assert_eq!(result.column(2).as_string::<i32>().value(0), "2021-10-26");
492        assert_eq!(
493            result
494                .column(3)
495                .as_primitive::<arrow::datatypes::Int8Type>()
496                .value(0),
497            0
498        );
499    }
500
501    // ========================================================================
502    // Comprehensive Test Suite for SplitProjection
503    // ========================================================================
504
505    // Helper to create test schemas with file and partition columns
506    fn create_test_schemas(
507        file_cols: usize,
508        partition_cols: usize,
509    ) -> (SchemaRef, SchemaRef) {
510        use arrow::datatypes::Field;
511
512        let file_fields: Vec<_> = (0..file_cols)
513            .map(|i| Field::new(format!("col_{i}"), DataType::Int32, false))
514            .collect();
515
516        let mut table_fields = file_fields.clone();
517        table_fields.extend(
518            (0..partition_cols)
519                .map(|i| Field::new(format!("part_{i}"), DataType::Utf8, false)),
520        );
521
522        (
523            Arc::new(Schema::new(file_fields)),
524            Arc::new(Schema::new(table_fields)),
525        )
526    }
527
528    // ========================================================================
529    // Partition Column Handling Tests
530    // ========================================================================
531
532    #[test]
533    fn test_split_projection_only_file_columns() {
534        let (file_schema, table_schema) = create_test_schemas(3, 2);
535        // Select only file columns [0, 1, 2]
536        let projection = ProjectionExprs::from_indices(&[0, 1, 2], &table_schema);
537
538        let split = SplitProjection::new(&file_schema, &projection);
539
540        assert_eq!(split.file_indices, vec![0, 1, 2]);
541        assert_eq!(split.partition_columns.len(), 0);
542    }
543
544    #[test]
545    fn test_split_projection_only_partition_columns() {
546        let (file_schema, table_schema) = create_test_schemas(3, 2);
547        // Select only partition columns [3, 4]
548        let projection = ProjectionExprs::from_indices(&[3, 4], &table_schema);
549
550        let split = SplitProjection::new(&file_schema, &projection);
551
552        assert_eq!(split.file_indices, Vec::<usize>::new());
553        assert_eq!(split.partition_columns.len(), 2);
554        assert_eq!(split.partition_columns[0].in_partition_values, 0);
555        assert_eq!(split.partition_columns[1].in_partition_values, 1);
556    }
557
558    #[test]
559    fn test_split_projection_multiple_partition_columns() {
560        let (file_schema, table_schema) = create_test_schemas(2, 3);
561        // File cols: 0, 1; Partition cols: 2, 3, 4
562        // Select: [0, 2, 4, 1, 3] (mixed file and partition)
563        let projection = ProjectionExprs::from_indices(&[0, 2, 4, 1, 3], &table_schema);
564
565        let split = SplitProjection::new(&file_schema, &projection);
566
567        assert_eq!(split.file_indices, vec![0, 1]);
568        assert_eq!(split.partition_columns.len(), 3);
569        assert_eq!(split.partition_columns[0].in_partition_values, 0);
570        assert_eq!(split.partition_columns[1].in_partition_values, 1);
571        assert_eq!(split.partition_columns[2].in_partition_values, 2);
572
573        // Verify remapped projection has correct indices
574        // File columns should be at [0, 1], partition columns at [2, 3, 4]
575        assert_eq!(split.remapped_projection.iter().count(), 5);
576    }
577
578    #[test]
579    fn test_split_projection_partition_columns_reverse_order() {
580        let (file_schema, table_schema) = create_test_schemas(2, 2);
581        // File cols: 0, 1; Partition cols: 2, 3
582        // Select: [3, 2] (partitions in reverse)
583        let projection = ProjectionExprs::from_indices(&[3, 2], &table_schema);
584
585        let split = SplitProjection::new(&file_schema, &projection);
586
587        assert_eq!(split.file_indices, Vec::<usize>::new());
588        assert_eq!(split.partition_columns.len(), 2);
589        assert_eq!(split.partition_columns[0].in_partition_values, 0);
590        assert_eq!(split.partition_columns[1].in_partition_values, 1);
591    }
592
593    #[test]
594    fn test_split_projection_interleaved_file_and_partition() {
595        let (file_schema, table_schema) = create_test_schemas(3, 3);
596        // File cols: 0, 1, 2; Partition cols: 3, 4, 5
597        // Select: [0, 3, 1, 4, 2, 5] (alternating)
598        let projection =
599            ProjectionExprs::from_indices(&[0, 3, 1, 4, 2, 5], &table_schema);
600
601        let split = SplitProjection::new(&file_schema, &projection);
602
603        assert_eq!(split.file_indices, vec![0, 1, 2]);
604        assert_eq!(split.partition_columns.len(), 3);
605        assert_eq!(split.partition_columns[0].in_partition_values, 0);
606        assert_eq!(split.partition_columns[1].in_partition_values, 1);
607        assert_eq!(split.partition_columns[2].in_partition_values, 2);
608    }
609
610    #[test]
611    fn test_split_projection_expression_with_file_and_partition_columns() {
612        use arrow::datatypes::Field;
613
614        // Create schemas: 2 file columns, 1 partition column
615        let file_schema = Arc::new(Schema::new(vec![
616            Field::new("file_a", DataType::Int32, false),
617            Field::new("file_b", DataType::Int32, false),
618        ]));
619        let table_schema = Arc::new(Schema::new(vec![
620            Field::new("file_a", DataType::Int32, false),
621            Field::new("file_b", DataType::Int32, false),
622            Field::new("part_c", DataType::Int32, false),
623        ]));
624
625        // Create expression: file_a + part_c
626        let exprs = [col("file_a") + col("part_c")];
627        let projection = create_projection_exprs(exprs.iter(), &table_schema);
628
629        let split = SplitProjection::new(&file_schema, &projection);
630
631        // Should extract both columns
632        assert_eq!(split.file_indices, vec![0]);
633        assert_eq!(split.partition_columns.len(), 1);
634        assert_eq!(split.partition_columns[0].in_partition_values, 0);
635    }
636
637    // ========================================================================
638    // Category 4: Boundary Conditions
639    // ========================================================================
640
641    #[test]
642    fn test_split_projection_boundary_last_file_column() {
643        let (file_schema, table_schema) = create_test_schemas(3, 2);
644        // Last file column is index 2
645        let projection = ProjectionExprs::from_indices(&[2], &table_schema);
646
647        let split = SplitProjection::new(&file_schema, &projection);
648
649        assert_eq!(split.file_indices, vec![2]);
650        assert_eq!(split.partition_columns.len(), 0);
651    }
652
653    #[test]
654    fn test_split_projection_boundary_first_partition_column() {
655        let (file_schema, table_schema) = create_test_schemas(3, 2);
656        // First partition column is index 3
657        let projection = ProjectionExprs::from_indices(&[3], &table_schema);
658
659        let split = SplitProjection::new(&file_schema, &projection);
660
661        assert_eq!(split.file_indices, Vec::<usize>::new());
662        assert_eq!(split.partition_columns.len(), 1);
663        assert_eq!(split.partition_columns[0].in_partition_values, 0);
664    }
665
666    // ========================================================================
667    // Category 6: Integration Tests
668    // ========================================================================
669
670    #[test]
671    fn test_inject_partition_columns_multiple_partitions() {
672        let data =
673            record_batch!(("col_0", Int32, vec![1]), ("col_1", Int32, vec![2])).unwrap();
674
675        // Create projection that references file columns and partition columns
676        let (file_schema, table_schema) = create_test_schemas(2, 2);
677        // Projection: [0, 2, 1, 3] = [file_0, part_0, file_1, part_1]
678        let projection = ProjectionExprs::from_indices(&[0, 2, 1, 3], &table_schema);
679        let split = SplitProjection::new(&file_schema, &projection);
680
681        // Create partition column mappings
682        let partition_columns = vec![
683            PartitionColumnIndex {
684                in_remainder_projection: 2, // First partition column at index 2
685                in_partition_values: 0,
686            },
687            PartitionColumnIndex {
688                in_remainder_projection: 3, // Second partition column at index 3
689                in_partition_values: 1,
690            },
691        ];
692
693        let partition_values =
694            vec![ScalarValue::from("part_a"), ScalarValue::from("part_b")];
695
696        let injected = inject_partition_columns_into_projection(
697            &split.remapped_projection,
698            &partition_columns,
699            partition_values,
700        );
701
702        // Apply projection
703        let projector = injected.make_projector(&data.schema()).unwrap();
704        let result = projector.project_batch(&data).unwrap();
705
706        assert_eq!(result.num_columns(), 4);
707        assert_eq!(
708            result
709                .column(0)
710                .as_primitive::<arrow::datatypes::Int32Type>()
711                .value(0),
712            1
713        );
714        assert_eq!(result.column(1).as_string::<i32>().value(0), "part_a");
715        assert_eq!(
716            result
717                .column(2)
718                .as_primitive::<arrow::datatypes::Int32Type>()
719                .value(0),
720            2
721        );
722        assert_eq!(result.column(3).as_string::<i32>().value(0), "part_b");
723    }
724}