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}