Skip to main content

datafusion_datasource/
table_schema.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
18//! Helper struct to manage table schemas with partition columns
19
20use arrow::datatypes::{FieldRef, Fields, SchemaBuilder, SchemaRef};
21use std::sync::Arc;
22
23/// The overall schema for potentially partitioned data sources.
24///
25/// When reading partitioned data (such as Hive-style partitioning), a [`TableSchema`]
26/// consists of up to three parts:
27/// 1. **File schema**: The schema of the actual data files on disk
28/// 2. **Partition columns**: Columns whose values are encoded in the directory structure,
29///    but not stored in the files themselves
30/// 3. **Virtual columns**: Columns produced by the file reader (e.g. Parquet
31///    `row_number`) that are not stored in the files
32///
33/// The full table schema is composed in that order: file columns, then
34/// partition columns, then virtual columns. Consumers that need a different
35/// output ordering should use a projection on top of
36/// [`TableSchema::table_schema`].
37///
38/// # Example: Partitioned Table
39///
40/// Consider a table with the following directory structure:
41/// ```text
42/// /data/date=2025-10-10/region=us-west/data.parquet
43/// /data/date=2025-10-11/region=us-east/data.parquet
44/// ```
45///
46/// In this case:
47/// - **File schema**: The schema of `data.parquet` files (e.g., `[user_id, amount]`)
48/// - **Partition columns**: `[date, region]` extracted from the directory path
49/// - **Table schema**: The full schema combining both (e.g., `[user_id, amount, date, region]`)
50///
51/// # When to Use
52///
53/// Use `TableSchema` when:
54/// - Reading partitioned data sources (Parquet, CSV, etc. with Hive-style partitioning)
55/// - You need to efficiently access different schema representations without reconstructing them
56/// - You want to avoid repeatedly concatenating file and partition schemas
57///
58/// For non-partitioned data or when working with a single schema representation,
59/// working directly with Arrow's `Schema` or `SchemaRef` is simpler.
60///
61/// # Performance
62///
63/// This struct pre-computes and caches the full table schema, allowing cheap references
64/// to any representation without repeated allocations or reconstructions.
65#[derive(Debug, Clone)]
66pub struct TableSchema {
67    /// The schema of the data files themselves, without partition columns.
68    ///
69    /// For example, if your Parquet files contain `[user_id, amount]`,
70    /// this field holds that schema.
71    file_schema: SchemaRef,
72
73    /// Columns that are derived from the directory structure (partitioning scheme).
74    ///
75    /// For Hive-style partitioning like `/date=2025-10-10/region=us-west/`,
76    /// this contains the `date` and `region` fields.
77    ///
78    /// These columns are NOT present in the data files but are appended to each
79    /// row during query execution based on the file's location.
80    ///
81    /// Stored as [`Fields`] (an immutable `Arc<[FieldRef]>`) so that cloning a
82    /// `TableSchema` is cheap and the partition columns can be shared zero-copy
83    /// with an existing schema.
84    table_partition_cols: Fields,
85
86    /// Virtual columns that are generated by the reader rather than read from
87    /// the data files or the directory structure.
88    ///
89    /// For example, a Parquet reader may inject a `row_number` column whose
90    /// values are produced per file by the reader. Virtual column fields must
91    /// carry an arrow extension type (e.g. `RowNumber`, `RowGroupIndex`) so the
92    /// file reader can recognize them.
93    ///
94    /// Virtual columns are appended at the end of the table schema, after the
95    /// file columns and any partition columns (layout: `[file, partition,
96    /// virtual]`).
97    virtual_columns: Fields,
98
99    /// The complete table schema: file_schema columns, followed by partition
100    /// columns, followed by virtual columns.
101    ///
102    /// This is pre-computed during construction by concatenating the three
103    /// parts, so it can be returned as a cheap reference.
104    table_schema: SchemaRef,
105
106    /// Schema of file + partition columns, excluding virtual columns.
107    ///
108    /// Pre-computed during construction so [`Self::schema_without_virtual_columns`]
109    /// can return a cheap reference. When there are no virtual columns this
110    /// shares the same `Arc` as `table_schema`.
111    schema_without_virtual_columns: SchemaRef,
112}
113
114impl TableSchema {
115    /// Start building a [`TableSchema`] from its (required) file schema.
116    ///
117    /// Partition columns are optional and added with
118    /// [`TableSchemaBuilder::with_table_partition_cols`]; the full table schema
119    /// is computed once by [`TableSchemaBuilder::build`]. This is the preferred
120    /// way to construct a `TableSchema`.
121    ///
122    /// # Example
123    ///
124    /// ```
125    /// # use std::sync::Arc;
126    /// # use arrow::datatypes::{Schema, Field, DataType};
127    /// # use datafusion_datasource::TableSchema;
128    /// let file_schema = Arc::new(Schema::new(vec![
129    ///     Field::new("user_id", DataType::Int64, false),
130    ///     Field::new("amount", DataType::Float64, false),
131    /// ]));
132    ///
133    /// let table_schema = TableSchema::builder(file_schema)
134    ///     .with_table_partition_cols(vec![
135    ///         Arc::new(Field::new("date", DataType::Utf8, false)),
136    ///         Arc::new(Field::new("region", DataType::Utf8, false)),
137    ///     ])
138    ///     .build();
139    ///
140    /// // Table schema will have 4 columns: user_id, amount, date, region
141    /// assert_eq!(table_schema.table_schema().fields().len(), 4);
142    /// ```
143    pub fn builder(file_schema: SchemaRef) -> TableSchemaBuilder {
144        TableSchemaBuilder::new(file_schema)
145    }
146
147    /// Create a new TableSchema from a file schema and partition columns.
148    ///
149    /// This is a convenience for
150    /// `TableSchema::builder(file_schema).with_table_partition_cols(cols).build()`.
151    #[deprecated(
152        since = "55.0.0",
153        note = "use TableSchema::builder(file_schema).with_table_partition_cols(cols).build() (or TableSchema::from(file_schema) for no partition columns)"
154    )]
155    pub fn new(file_schema: SchemaRef, table_partition_cols: Vec<FieldRef>) -> Self {
156        TableSchemaBuilder::new(file_schema)
157            .with_table_partition_cols(table_partition_cols)
158            .build()
159    }
160
161    /// Create a new TableSchema with no partition columns.
162    #[deprecated(
163        since = "55.0.0",
164        note = "use TableSchema::from(file_schema) / file_schema.into()"
165    )]
166    pub fn from_file_schema(file_schema: SchemaRef) -> Self {
167        TableSchemaBuilder::new(file_schema).build()
168    }
169
170    /// Return a new `TableSchema` with `partition_cols` as its partition columns,
171    /// replacing any existing ones. Existing virtual columns are preserved.
172    #[deprecated(
173        since = "55.0.0",
174        note = "use TableSchema::builder(file_schema).with_table_partition_cols(cols).build()"
175    )]
176    pub fn with_table_partition_cols(self, partition_cols: Vec<FieldRef>) -> Self {
177        TableSchemaBuilder::new(self.file_schema)
178            .with_table_partition_cols(partition_cols)
179            .with_virtual_columns(self.virtual_columns)
180            .build()
181    }
182
183    /// Get the file schema (without partition columns).
184    ///
185    /// This is the schema of the actual data files on disk.
186    pub fn file_schema(&self) -> &SchemaRef {
187        &self.file_schema
188    }
189
190    /// Get the table partition columns.
191    ///
192    /// These are the columns derived from the directory structure that
193    /// will be appended to each row during query execution.
194    pub fn table_partition_cols(&self) -> &Fields {
195        &self.table_partition_cols
196    }
197
198    /// Get the virtual columns.
199    ///
200    /// Virtual columns are produced by the file reader (e.g. Parquet
201    /// `row_number`) and are not stored in the data files or derived from
202    /// partition paths.
203    pub fn virtual_columns(&self) -> &Fields {
204        &self.virtual_columns
205    }
206
207    /// Get the full table schema (file schema + partition columns + virtual columns).
208    ///
209    /// This is the complete schema that will be seen by queries. Fields appear
210    /// in the order: file columns, partition columns, virtual columns.
211    pub fn table_schema(&self) -> &SchemaRef {
212        &self.table_schema
213    }
214
215    /// Schema of columns that can be referenced by predicates pushed into the
216    /// file reader: file columns plus partition columns, excluding virtual
217    /// columns.
218    ///
219    /// Virtual columns are produced by the reader itself (e.g. Parquet
220    /// `row_number`) and cannot be referenced inside the reader's row filter,
221    /// so predicates that reference them must stay above the scan. Callers
222    /// deciding which filters to push down should check against this schema
223    /// rather than [`Self::table_schema`].
224    ///
225    /// When there are no virtual columns this returns the same schema as
226    /// [`Self::table_schema`].
227    pub fn schema_without_virtual_columns(&self) -> &SchemaRef {
228        &self.schema_without_virtual_columns
229    }
230}
231
232impl From<SchemaRef> for TableSchema {
233    fn from(schema: SchemaRef) -> Self {
234        TableSchemaBuilder::new(schema).build()
235    }
236}
237
238impl From<&SchemaRef> for TableSchema {
239    fn from(schema: &SchemaRef) -> Self {
240        TableSchemaBuilder::new(Arc::clone(schema)).build()
241    }
242}
243
244/// Builder for [`TableSchema`].
245///
246/// The file schema is the only required input; partition columns and virtual
247/// columns are optional. Unlike calling [`TableSchema`]'s setters repeatedly,
248/// the builder computes the concatenated table schema exactly once, in
249/// [`TableSchemaBuilder::build`].
250///
251/// ```
252/// # use std::sync::Arc;
253/// # use arrow::datatypes::{Schema, Field, DataType};
254/// # use datafusion_datasource::TableSchemaBuilder;
255/// # let file_schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
256/// let table_schema = TableSchemaBuilder::new(file_schema)
257///     .with_table_partition_cols(vec![Arc::new(Field::new("date", DataType::Utf8, false))])
258///     .build();
259/// assert_eq!(table_schema.table_partition_cols().len(), 1);
260/// ```
261#[derive(Debug, Clone)]
262pub struct TableSchemaBuilder {
263    file_schema: SchemaRef,
264    table_partition_cols: Fields,
265    virtual_columns: Fields,
266}
267
268impl TableSchemaBuilder {
269    /// Create a builder for a `TableSchema` over the given file schema, with no
270    /// partition or virtual columns yet.
271    pub fn new(file_schema: SchemaRef) -> Self {
272        Self {
273            file_schema,
274            table_partition_cols: Fields::empty(),
275            virtual_columns: Fields::empty(),
276        }
277    }
278
279    /// Set the partition columns, replacing any previously set.
280    ///
281    /// Accepts anything convertible into [`Fields`] (e.g. `Vec<FieldRef>` or an
282    /// existing schema's `Fields`, which is shared zero-copy).
283    pub fn with_table_partition_cols(
284        mut self,
285        table_partition_cols: impl Into<Fields>,
286    ) -> Self {
287        self.table_partition_cols = table_partition_cols.into();
288        self
289    }
290
291    /// Set the virtual columns, replacing any previously set.
292    ///
293    /// Virtual columns are produced by the file reader (e.g. Parquet
294    /// `row_number`) and appended at the end of the table schema. Each field
295    /// must carry an arrow virtual extension type so the reader can recognize
296    /// it.
297    ///
298    /// Accepts anything convertible into [`Fields`] (e.g. `Vec<FieldRef>`).
299    pub fn with_virtual_columns(mut self, virtual_columns: impl Into<Fields>) -> Self {
300        self.virtual_columns = virtual_columns.into();
301        self
302    }
303
304    /// Build the [`TableSchema`], computing the full
305    /// `file + partition + virtual` schema once.
306    pub fn build(self) -> TableSchema {
307        debug_assert!(
308            self.virtual_columns.iter().enumerate().all(|(i, v)| {
309                let name = v.name();
310                !self.file_schema.fields().iter().any(|f| f.name() == name)
311                    && !self.table_partition_cols.iter().any(|p| p.name() == name)
312                    && !self.virtual_columns[..i].iter().any(|w| w.name() == name)
313            }),
314            "virtual column name collides with an existing file, partition, or virtual column"
315        );
316
317        let mut builder = SchemaBuilder::from(self.file_schema.as_ref());
318        builder.extend(self.table_partition_cols.iter().cloned());
319        let (table_schema, schema_without_virtual_columns) =
320            if self.virtual_columns.is_empty() {
321                let schema = Arc::new(builder.finish());
322                (Arc::clone(&schema), schema)
323            } else {
324                let without_virtual = Arc::new(builder.finish());
325                let mut builder = SchemaBuilder::from(without_virtual.as_ref());
326                builder.extend(self.virtual_columns.iter().cloned());
327                (Arc::new(builder.finish()), without_virtual)
328            };
329        TableSchema {
330            file_schema: self.file_schema,
331            table_partition_cols: self.table_partition_cols,
332            virtual_columns: self.virtual_columns,
333            table_schema,
334            schema_without_virtual_columns,
335        }
336    }
337}
338
339impl From<SchemaRef> for TableSchemaBuilder {
340    fn from(schema: SchemaRef) -> Self {
341        TableSchemaBuilder::new(schema)
342    }
343}
344
345impl From<&SchemaRef> for TableSchemaBuilder {
346    fn from(schema: &SchemaRef) -> Self {
347        TableSchemaBuilder::new(Arc::clone(schema))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::{TableSchema, TableSchemaBuilder};
354    use arrow::datatypes::{DataType, Field, Schema};
355    use std::sync::Arc;
356
357    #[test]
358    fn test_table_schema_creation() {
359        let file_schema = Arc::new(Schema::new(vec![
360            Field::new("user_id", DataType::Int64, false),
361            Field::new("amount", DataType::Float64, false),
362        ]));
363
364        let partition_cols = vec![
365            Arc::new(Field::new("date", DataType::Utf8, false)),
366            Arc::new(Field::new("region", DataType::Utf8, false)),
367        ];
368
369        let table_schema = TableSchema::builder(file_schema.clone())
370            .with_table_partition_cols(partition_cols.clone())
371            .build();
372
373        // Verify file schema
374        assert_eq!(table_schema.file_schema().as_ref(), file_schema.as_ref());
375
376        // Verify partition columns
377        assert_eq!(table_schema.table_partition_cols().len(), 2);
378        assert_eq!(table_schema.table_partition_cols()[0], partition_cols[0]);
379        assert_eq!(table_schema.table_partition_cols()[1], partition_cols[1]);
380
381        // Verify full table schema
382        let expected_fields = vec![
383            Field::new("user_id", DataType::Int64, false),
384            Field::new("amount", DataType::Float64, false),
385            Field::new("date", DataType::Utf8, false),
386            Field::new("region", DataType::Utf8, false),
387        ];
388        let expected_schema = Schema::new(expected_fields);
389        assert_eq!(table_schema.table_schema().as_ref(), &expected_schema);
390    }
391
392    #[test]
393    fn test_builder_with_partition_cols() {
394        let file_schema =
395            Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
396
397        let table_schema = TableSchemaBuilder::new(Arc::clone(&file_schema))
398            .with_table_partition_cols(vec![
399                Arc::new(Field::new("country", DataType::Utf8, false)),
400                Arc::new(Field::new("year", DataType::Int32, false)),
401            ])
402            .build();
403
404        // File schema is preserved and the partition columns are appended.
405        assert_eq!(table_schema.file_schema().as_ref(), file_schema.as_ref());
406        assert_eq!(table_schema.table_partition_cols().len(), 2);
407        assert_eq!(table_schema.table_partition_cols()[0].name(), "country");
408        assert_eq!(table_schema.table_partition_cols()[1].name(), "year");
409
410        let expected_schema = Schema::new(vec![
411            Field::new("id", DataType::Int32, false),
412            Field::new("country", DataType::Utf8, false),
413            Field::new("year", DataType::Int32, false),
414        ]);
415        assert_eq!(table_schema.table_schema().as_ref(), &expected_schema);
416    }
417
418    #[test]
419    fn test_builder_with_table_partition_cols_replaces() {
420        // Calling the setter more than once replaces rather than appends.
421        let file_schema =
422            Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
423
424        let table_schema = TableSchemaBuilder::new(file_schema)
425            .with_table_partition_cols(vec![Arc::new(Field::new(
426                "country",
427                DataType::Utf8,
428                false,
429            ))])
430            .with_table_partition_cols(vec![Arc::new(Field::new(
431                "city",
432                DataType::Utf8,
433                false,
434            ))])
435            .build();
436
437        assert_eq!(table_schema.table_partition_cols().len(), 1);
438        assert_eq!(table_schema.table_partition_cols()[0].name(), "city");
439    }
440
441    #[test]
442    fn test_builder_accepts_fields_zero_copy() {
443        // `with_table_partition_cols` accepts an existing schema's `Fields`
444        // directly (shared via `Arc`, no `Vec` round-trip).
445        let file_schema =
446            Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
447        let partition_schema =
448            Schema::new(vec![Field::new("date", DataType::Utf8, false)]);
449
450        let table_schema = TableSchemaBuilder::new(file_schema)
451            .with_table_partition_cols(partition_schema.fields().clone())
452            .build();
453
454        assert_eq!(table_schema.table_partition_cols().len(), 1);
455        assert_eq!(table_schema.table_partition_cols()[0].name(), "date");
456    }
457
458    #[test]
459    #[expect(deprecated)]
460    fn test_deprecated_with_table_partition_cols_replaces() {
461        // The deprecated setter still works and replaces the partition columns.
462        // It is safe on a shared clone because partition columns are immutable.
463        let file_schema =
464            Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
465        let original = TableSchema::builder(file_schema)
466            .with_table_partition_cols(vec![Arc::new(Field::new(
467                "country",
468                DataType::Utf8,
469                false,
470            ))])
471            .build();
472
473        let replaced =
474            original
475                .clone()
476                .with_table_partition_cols(vec![Arc::new(Field::new(
477                    "city",
478                    DataType::Utf8,
479                    false,
480                ))]);
481
482        assert_eq!(replaced.table_partition_cols().len(), 1);
483        assert_eq!(replaced.table_partition_cols()[0].name(), "city");
484
485        // The original is untouched.
486        assert_eq!(original.table_partition_cols().len(), 1);
487        assert_eq!(original.table_partition_cols()[0].name(), "country");
488    }
489
490    #[test]
491    fn test_builder_with_virtual_columns_layout() {
492        let file_schema = Arc::new(Schema::new(vec![
493            Field::new("user_id", DataType::Int64, false),
494            Field::new("amount", DataType::Float64, false),
495        ]));
496
497        let virtual_cols =
498            vec![Arc::new(Field::new("row_number", DataType::Int64, true))];
499
500        let partition_cols = vec![Arc::new(Field::new("date", DataType::Utf8, false))];
501
502        // Apply virtual columns and partition columns in either order on the
503        // builder; the resulting table schema should always be
504        // [file, partition, virtual].
505        let built_virtual_first = TableSchemaBuilder::new(Arc::clone(&file_schema))
506            .with_virtual_columns(virtual_cols.clone())
507            .with_table_partition_cols(partition_cols.clone())
508            .build();
509
510        let built_partition_first = TableSchemaBuilder::new(Arc::clone(&file_schema))
511            .with_table_partition_cols(partition_cols.clone())
512            .with_virtual_columns(virtual_cols.clone())
513            .build();
514
515        let expected = Schema::new(vec![
516            Field::new("user_id", DataType::Int64, false),
517            Field::new("amount", DataType::Float64, false),
518            Field::new("date", DataType::Utf8, false),
519            Field::new("row_number", DataType::Int64, true),
520        ]);
521
522        for ts in [built_virtual_first, built_partition_first] {
523            assert_eq!(ts.table_schema().as_ref(), &expected);
524            assert_eq!(ts.virtual_columns().len(), 1);
525            assert_eq!(ts.virtual_columns()[0].name(), "row_number");
526            assert_eq!(ts.table_partition_cols().len(), 1);
527            assert_eq!(ts.file_schema().fields().len(), 2);
528        }
529    }
530
531    #[test]
532    #[should_panic(expected = "virtual column name collides")]
533    #[cfg(debug_assertions)]
534    fn test_virtual_column_collides_with_file_schema_panics_in_debug() {
535        let file_schema = Arc::new(Schema::new(vec![Field::new(
536            "row_number",
537            DataType::Int64,
538            false,
539        )]));
540        let _ = TableSchemaBuilder::new(file_schema)
541            .with_virtual_columns(vec![Arc::new(Field::new(
542                "row_number",
543                DataType::Int64,
544                true,
545            ))])
546            .build();
547    }
548
549    #[test]
550    #[should_panic(expected = "virtual column name collides")]
551    #[cfg(debug_assertions)]
552    fn test_virtual_column_collides_with_partition_panics_in_debug() {
553        let file_schema = Arc::new(Schema::new(vec![Field::new(
554            "user_id",
555            DataType::Int64,
556            false,
557        )]));
558        let partition_cols =
559            vec![Arc::new(Field::new("row_number", DataType::Utf8, false))];
560        let _ = TableSchemaBuilder::new(file_schema)
561            .with_table_partition_cols(partition_cols)
562            .with_virtual_columns(vec![Arc::new(Field::new(
563                "row_number",
564                DataType::Int64,
565                true,
566            ))])
567            .build();
568    }
569
570    #[test]
571    #[should_panic(expected = "virtual column name collides")]
572    #[cfg(debug_assertions)]
573    fn test_duplicate_virtual_columns_panic_in_debug() {
574        let file_schema = Arc::new(Schema::new(vec![Field::new(
575            "user_id",
576            DataType::Int64,
577            false,
578        )]));
579        let _ = TableSchemaBuilder::new(file_schema)
580            .with_virtual_columns(vec![
581                Arc::new(Field::new("vc", DataType::Int64, true)),
582                Arc::new(Field::new("vc", DataType::Int64, true)),
583            ])
584            .build();
585    }
586
587    #[test]
588    #[should_panic(expected = "virtual column name collides")]
589    #[cfg(debug_assertions)]
590    fn test_partition_column_added_after_colliding_virtual_panics_in_debug() {
591        // Builder order is irrelevant: collision check runs in build().
592        let file_schema = Arc::new(Schema::new(vec![Field::new(
593            "user_id",
594            DataType::Int64,
595            false,
596        )]));
597        let _ = TableSchemaBuilder::new(file_schema)
598            .with_virtual_columns(vec![Arc::new(Field::new(
599                "row_number",
600                DataType::Int64,
601                true,
602            ))])
603            .with_table_partition_cols(vec![Arc::new(Field::new(
604                "row_number",
605                DataType::Utf8,
606                false,
607            ))])
608            .build();
609    }
610}