blob: 4c7b37113d58de21f4acf640c48bd1d808a62f63 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! [`SchemaAdapter`] and [`SchemaAdapterFactory`] to adapt file-level record batches to a table schema.
//!
//! Adapter provides a method of translating the RecordBatches that come out of the
//! physical format into how they should be used by DataFusion. For instance, a schema
//! can be stored external to a parquet file that maps parquet logical types to arrow types.
use arrow::{
array::{new_null_array, ArrayRef, RecordBatch, RecordBatchOptions},
compute::can_cast_types,
datatypes::{DataType, Field, Schema, SchemaRef},
};
use datafusion_common::{
format::DEFAULT_CAST_OPTIONS,
nested_struct::{cast_column, validate_struct_compatibility},
plan_err, ColumnStatistics,
};
use std::{fmt::Debug, sync::Arc};
/// Function used by [`SchemaMapping`] to adapt a column from the file schema to
/// the table schema.
pub type CastColumnFn = dyn Fn(
&ArrayRef,
&Field,
&arrow::compute::CastOptions,
) -> datafusion_common::Result<ArrayRef>
+ Send
+ Sync;
/// Factory for creating [`SchemaAdapter`]
///
/// This interface provides a way to implement custom schema adaptation logic
/// for DataSourceExec (for example, to fill missing columns with default value
/// other than null).
///
/// Most users should use [`DefaultSchemaAdapterFactory`]. See that struct for
/// more details and examples.
pub trait SchemaAdapterFactory: Debug + Send + Sync + 'static {
/// Create a [`SchemaAdapter`]
///
/// Arguments:
///
/// * `projected_table_schema`: The schema for the table, projected to
/// include only the fields being output (projected) by the this mapping.
///
/// * `table_schema`: The entire table schema for the table
fn create(
&self,
projected_table_schema: SchemaRef,
table_schema: SchemaRef,
) -> Box<dyn SchemaAdapter>;
/// Create a [`SchemaAdapter`] using only the projected table schema.
///
/// This is a convenience method for cases where the table schema and the
/// projected table schema are the same.
fn create_with_projected_schema(
&self,
projected_table_schema: SchemaRef,
) -> Box<dyn SchemaAdapter> {
self.create(Arc::clone(&projected_table_schema), projected_table_schema)
}
}
/// Creates [`SchemaMapper`]s to map file-level [`RecordBatch`]es to a table
/// schema, which may have a schema obtained from merging multiple file-level
/// schemas.
///
/// This is useful for implementing schema evolution in partitioned datasets.
///
/// See [`DefaultSchemaAdapterFactory`] for more details and examples.
pub trait SchemaAdapter: Send + Sync {
/// Map a column index in the table schema to a column index in a particular
/// file schema
///
/// This is used while reading a file to push down projections by mapping
/// projected column indexes from the table schema to the file schema
///
/// Panics if index is not in range for the table schema
fn map_column_index(&self, index: usize, file_schema: &Schema) -> Option<usize>;
/// Creates a mapping for casting columns from the file schema to the table
/// schema.
///
/// This is used after reading a record batch. The returned [`SchemaMapper`]:
///
/// 1. Maps columns to the expected columns indexes
/// 2. Handles missing values (e.g. fills nulls or a default value) for
/// columns in the in the table schema not in the file schema
/// 2. Handles different types: if the column in the file schema has a
/// different type than `table_schema`, the mapper will resolve this
/// difference (e.g. by casting to the appropriate type)
///
/// Returns:
/// * a [`SchemaMapper`]
/// * an ordered list of columns to project from the file
fn map_schema(
&self,
file_schema: &Schema,
) -> datafusion_common::Result<(Arc<dyn SchemaMapper>, Vec<usize>)>;
}
/// Maps, columns from a specific file schema to the table schema.
///
/// See [`DefaultSchemaAdapterFactory`] for more details and examples.
pub trait SchemaMapper: Debug + Send + Sync {
/// Adapts a `RecordBatch` to match the `table_schema`
fn map_batch(&self, batch: RecordBatch) -> datafusion_common::Result<RecordBatch>;
/// Adapts file-level column `Statistics` to match the `table_schema`
fn map_column_statistics(
&self,
file_col_statistics: &[ColumnStatistics],
) -> datafusion_common::Result<Vec<ColumnStatistics>>;
}
/// Default [`SchemaAdapterFactory`] for mapping schemas.
///
/// This can be used to adapt file-level record batches to a table schema and
/// implement schema evolution.
///
/// Given an input file schema and a table schema, this factory returns
/// [`SchemaAdapter`] that return [`SchemaMapper`]s that:
///
/// 1. Reorder columns
/// 2. Cast columns to the correct type
/// 3. Fill missing columns with nulls
///
/// # Errors:
///
/// * If a column in the table schema is non-nullable but is not present in the
/// file schema (i.e. it is missing), the returned mapper tries to fill it with
/// nulls resulting in a schema error.
///
/// # Illustration of Schema Mapping
///
/// ```text
/// ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
/// ┌───────┐ ┌───────┐ │ ┌───────┐ ┌───────┐ ┌───────┐ │
/// ││ 1.0 │ │ "foo" │ ││ NULL │ │ "foo" │ │ "1.0" │
/// ├───────┤ ├───────┤ │ Schema mapping ├───────┤ ├───────┤ ├───────┤ │
/// ││ 2.0 │ │ "bar" │ ││ NULL │ │ "bar" │ │ "2.0" │
/// └───────┘ └───────┘ │────────────────▶ └───────┘ └───────┘ └───────┘ │
/// │ │
/// column "c" column "b"│ column "a" column "b" column "c"│
/// │ Float64 Utf8 │ Int32 Utf8 Utf8
/// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
/// Input Record Batch Output Record Batch
///
/// Schema { Schema {
/// "c": Float64, "a": Int32,
/// "b": Utf8, "b": Utf8,
/// } "c": Utf8,
/// }
/// ```
///
/// # Example of using the `DefaultSchemaAdapterFactory` to map [`RecordBatch`]s
///
/// Note `SchemaMapping` also supports mapping partial batches, which is used as
/// part of predicate pushdown.
///
/// ```
/// # use std::sync::Arc;
/// # use arrow::datatypes::{DataType, Field, Schema};
/// # use datafusion_datasource::schema_adapter::{DefaultSchemaAdapterFactory, SchemaAdapterFactory};
/// # use datafusion_common::record_batch;
/// // Table has fields "a", "b" and "c"
/// let table_schema = Schema::new(vec![
/// Field::new("a", DataType::Int32, true),
/// Field::new("b", DataType::Utf8, true),
/// Field::new("c", DataType::Utf8, true),
/// ]);
///
/// // create an adapter to map the table schema to the file schema
/// let adapter = DefaultSchemaAdapterFactory::from_schema(Arc::new(table_schema));
///
/// // The file schema has fields "c" and "b" but "b" is stored as an 'Float64'
/// // instead of 'Utf8'
/// let file_schema = Schema::new(vec![
/// Field::new("c", DataType::Utf8, true),
/// Field::new("b", DataType::Float64, true),
/// ]);
///
/// // Get a mapping from the file schema to the table schema
/// let (mapper, _indices) = adapter.map_schema(&file_schema).unwrap();
///
/// let file_batch = record_batch!(
/// ("c", Utf8, vec!["foo", "bar"]),
/// ("b", Float64, vec![1.0, 2.0])
/// ).unwrap();
///
/// let mapped_batch = mapper.map_batch(file_batch).unwrap();
///
/// // the mapped batch has the correct schema and the "b" column has been cast to Utf8
/// let expected_batch = record_batch!(
/// ("a", Int32, vec![None, None]), // missing column filled with nulls
/// ("b", Utf8, vec!["1.0", "2.0"]), // b was cast to string and order was changed
/// ("c", Utf8, vec!["foo", "bar"])
/// ).unwrap();
/// assert_eq!(mapped_batch, expected_batch);
/// ```
#[derive(Clone, Debug, Default)]
pub struct DefaultSchemaAdapterFactory;
impl DefaultSchemaAdapterFactory {
/// Create a new factory for mapping batches from a file schema to a table
/// schema.
///
/// This is a convenience for [`DefaultSchemaAdapterFactory::create`] with
/// the same schema for both the projected table schema and the table
/// schema.
pub fn from_schema(table_schema: SchemaRef) -> Box<dyn SchemaAdapter> {
Self.create(Arc::clone(&table_schema), table_schema)
}
}
impl SchemaAdapterFactory for DefaultSchemaAdapterFactory {
fn create(
&self,
projected_table_schema: SchemaRef,
_table_schema: SchemaRef,
) -> Box<dyn SchemaAdapter> {
Box::new(DefaultSchemaAdapter {
projected_table_schema,
})
}
}
/// This SchemaAdapter requires both the table schema and the projected table
/// schema. See [`SchemaMapping`] for more details
#[derive(Clone, Debug)]
pub(crate) struct DefaultSchemaAdapter {
/// The schema for the table, projected to include only the fields being output (projected) by the
/// associated ParquetSource
projected_table_schema: SchemaRef,
}
/// Checks if a file field can be cast to a table field
///
/// Returns Ok(true) if casting is possible, or an error explaining why casting is not possible
pub(crate) fn can_cast_field(
file_field: &Field,
table_field: &Field,
) -> datafusion_common::Result<bool> {
match (file_field.data_type(), table_field.data_type()) {
(DataType::Struct(source_fields), DataType::Struct(target_fields)) => {
// validate_struct_compatibility returns Result<()>; on success we can cast structs
validate_struct_compatibility(source_fields, target_fields)?;
Ok(true)
}
_ => {
if can_cast_types(file_field.data_type(), table_field.data_type()) {
Ok(true)
} else {
plan_err!(
"Cannot cast file schema field {} of type {} to table schema field of type {}",
file_field.name(),
file_field.data_type(),
table_field.data_type()
)
}
}
}
}
impl SchemaAdapter for DefaultSchemaAdapter {
/// Map a column index in the table schema to a column index in a particular
/// file schema
///
/// Panics if index is not in range for the table schema
fn map_column_index(&self, index: usize, file_schema: &Schema) -> Option<usize> {
let field = self.projected_table_schema.field(index);
Some(file_schema.fields.find(field.name())?.0)
}
/// Creates a `SchemaMapping` for casting or mapping the columns from the
/// file schema to the table schema.
///
/// If the provided `file_schema` contains columns of a different type to
/// the expected `table_schema`, the method will attempt to cast the array
/// data from the file schema to the table schema where possible.
///
/// Returns a [`SchemaMapping`] that can be applied to the output batch
/// along with an ordered list of columns to project from the file
fn map_schema(
&self,
file_schema: &Schema,
) -> datafusion_common::Result<(Arc<dyn SchemaMapper>, Vec<usize>)> {
let (field_mappings, projection) = create_field_mapping(
file_schema,
&self.projected_table_schema,
can_cast_field,
)?;
Ok((
Arc::new(SchemaMapping::new(
Arc::clone(&self.projected_table_schema),
field_mappings,
Arc::new(
|array: &ArrayRef,
field: &Field,
opts: &arrow::compute::CastOptions| {
cast_column(array, field, opts)
},
),
)),
projection,
))
}
}
/// Helper function that creates field mappings between file schema and table schema
///
/// Maps columns from the file schema to their corresponding positions in the table schema,
/// applying type compatibility checking via the provided predicate function.
///
/// Returns field mappings (for column reordering) and a projection (for field selection).
pub(crate) fn create_field_mapping<F>(
file_schema: &Schema,
projected_table_schema: &SchemaRef,
can_map_field: F,
) -> datafusion_common::Result<(Vec<Option<usize>>, Vec<usize>)>
where
F: Fn(&Field, &Field) -> datafusion_common::Result<bool>,
{
let mut projection = Vec::with_capacity(file_schema.fields().len());
let mut field_mappings = vec![None; projected_table_schema.fields().len()];
for (file_idx, file_field) in file_schema.fields.iter().enumerate() {
if let Some((table_idx, table_field)) =
projected_table_schema.fields().find(file_field.name())
{
if can_map_field(file_field, table_field)? {
field_mappings[table_idx] = Some(projection.len());
projection.push(file_idx);
}
}
}
Ok((field_mappings, projection))
}
/// The SchemaMapping struct holds a mapping from the file schema to the table
/// schema and any necessary type conversions.
///
/// [`map_batch`] is used by the ParquetOpener to produce a RecordBatch which
/// has the projected schema, since that's the schema which is supposed to come
/// out of the execution of this query. Thus `map_batch` uses
/// `projected_table_schema` as it can only operate on the projected fields.
///
/// [`map_batch`]: Self::map_batch
pub struct SchemaMapping {
/// The schema of the table. This is the expected schema after conversion
/// and it should match the schema of the query result.
projected_table_schema: SchemaRef,
/// Mapping from field index in `projected_table_schema` to index in
/// projected file_schema.
///
/// They are Options instead of just plain `usize`s because the table could
/// have fields that don't exist in the file.
field_mappings: Vec<Option<usize>>,
/// Function used to adapt a column from the file schema to the table schema
/// when it exists in both schemas
cast_column: Arc<CastColumnFn>,
}
impl Debug for SchemaMapping {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SchemaMapping")
.field("projected_table_schema", &self.projected_table_schema)
.field("field_mappings", &self.field_mappings)
.field("cast_column", &"<fn>")
.finish()
}
}
impl SchemaMapping {
/// Creates a new SchemaMapping instance
///
/// Initializes the field mappings needed to transform file data to the projected table schema
pub fn new(
projected_table_schema: SchemaRef,
field_mappings: Vec<Option<usize>>,
cast_column: Arc<CastColumnFn>,
) -> Self {
Self {
projected_table_schema,
field_mappings,
cast_column,
}
}
}
impl SchemaMapper for SchemaMapping {
/// Adapts a `RecordBatch` to match the `projected_table_schema` using the stored mapping and
/// conversions.
/// The produced RecordBatch has a schema that contains only the projected columns.
fn map_batch(&self, batch: RecordBatch) -> datafusion_common::Result<RecordBatch> {
let (_old_schema, batch_cols, batch_rows) = batch.into_parts();
let cols = self
.projected_table_schema
// go through each field in the projected schema
.fields()
.iter()
// and zip it with the index that maps fields from the projected table schema to the
// projected file schema in `batch`
.zip(&self.field_mappings)
// and for each one...
.map(|(field, file_idx)| {
file_idx.map_or_else(
// If this field only exists in the table, and not in the file, then we know
// that it's null, so just return that.
|| Ok(new_null_array(field.data_type(), batch_rows)),
// However, if it does exist in both, use the cast_column function
// to perform any necessary conversions
|batch_idx| {
(self.cast_column)(
&batch_cols[batch_idx],
field,
&DEFAULT_CAST_OPTIONS,
)
},
)
})
.collect::<datafusion_common::Result<Vec<_>, _>>()?;
// Necessary to handle empty batches
let options = RecordBatchOptions::new().with_row_count(Some(batch_rows));
let schema = Arc::clone(&self.projected_table_schema);
let record_batch = RecordBatch::try_new_with_options(schema, cols, &options)?;
Ok(record_batch)
}
/// Adapts file-level column `Statistics` to match the `table_schema`
fn map_column_statistics(
&self,
file_col_statistics: &[ColumnStatistics],
) -> datafusion_common::Result<Vec<ColumnStatistics>> {
let mut table_col_statistics = vec![];
// Map the statistics for each field in the file schema to the corresponding field in the
// table schema, if a field is not present in the file schema, we need to fill it with `ColumnStatistics::new_unknown`
for (_, file_col_idx) in self
.projected_table_schema
.fields()
.iter()
.zip(&self.field_mappings)
{
if let Some(file_col_idx) = file_col_idx {
table_col_statistics.push(
file_col_statistics
.get(*file_col_idx)
.cloned()
.unwrap_or_default(),
);
} else {
table_col_statistics.push(ColumnStatistics::new_unknown());
}
}
Ok(table_col_statistics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::{
array::{Array, ArrayRef, StringBuilder, StructArray, TimestampMillisecondArray},
compute::cast,
datatypes::{DataType, Field, TimeUnit},
record_batch::RecordBatch,
};
use datafusion_common::{stats::Precision, Result, ScalarValue, Statistics};
#[test]
fn test_schema_mapping_map_statistics_basic() {
// Create table schema (a, b, c)
let table_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
Field::new("c", DataType::Float64, true),
]));
// Create file schema (b, a) - different order, missing c
let file_schema = Schema::new(vec![
Field::new("b", DataType::Utf8, true),
Field::new("a", DataType::Int32, true),
]);
// Create SchemaAdapter
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
// Get mapper and projection
let (mapper, projection) = adapter.map_schema(&file_schema).unwrap();
// Should project columns 0,1 from file
assert_eq!(projection, vec![0, 1]);
// Create file statistics
let mut file_stats = Statistics::default();
// Statistics for column b (index 0 in file)
let b_stats = ColumnStatistics {
null_count: Precision::Exact(5),
..Default::default()
};
// Statistics for column a (index 1 in file)
let a_stats = ColumnStatistics {
null_count: Precision::Exact(10),
..Default::default()
};
file_stats.column_statistics = vec![b_stats, a_stats];
// Map statistics
let table_col_stats = mapper
.map_column_statistics(&file_stats.column_statistics)
.unwrap();
// Verify stats
assert_eq!(table_col_stats.len(), 3);
assert_eq!(table_col_stats[0].null_count, Precision::Exact(10)); // a from file idx 1
assert_eq!(table_col_stats[1].null_count, Precision::Exact(5)); // b from file idx 0
assert_eq!(table_col_stats[2].null_count, Precision::Absent); // c (unknown)
}
#[test]
fn test_schema_mapping_map_statistics_empty() {
// Create schemas
let table_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
]));
let file_schema = Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
]);
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
let (mapper, _) = adapter.map_schema(&file_schema).unwrap();
// Empty file statistics
let file_stats = Statistics::default();
let table_col_stats = mapper
.map_column_statistics(&file_stats.column_statistics)
.unwrap();
// All stats should be unknown
assert_eq!(table_col_stats.len(), 2);
assert_eq!(table_col_stats[0], ColumnStatistics::new_unknown(),);
assert_eq!(table_col_stats[1], ColumnStatistics::new_unknown(),);
}
#[test]
fn test_can_cast_field() {
// Same type should work
let from_field = Field::new("col", DataType::Int32, true);
let to_field = Field::new("col", DataType::Int32, true);
assert!(can_cast_field(&from_field, &to_field).unwrap());
// Casting Int32 to Float64 is allowed
let from_field = Field::new("col", DataType::Int32, true);
let to_field = Field::new("col", DataType::Float64, true);
assert!(can_cast_field(&from_field, &to_field).unwrap());
// Casting Float64 to Utf8 should work (converts to string)
let from_field = Field::new("col", DataType::Float64, true);
let to_field = Field::new("col", DataType::Utf8, true);
assert!(can_cast_field(&from_field, &to_field).unwrap());
// Binary to Utf8 is not supported - this is an example of a cast that should fail
// Note: We use Binary instead of Utf8->Int32 because Arrow actually supports that cast
let from_field = Field::new("col", DataType::Binary, true);
let to_field = Field::new("col", DataType::Decimal128(10, 2), true);
let result = can_cast_field(&from_field, &to_field);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Cannot cast file schema field col"));
}
#[test]
fn test_create_field_mapping() {
// Define the table schema
let table_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
Field::new("c", DataType::Float64, true),
]));
// Define file schema: different order, missing column c, and b has different type
let file_schema = Schema::new(vec![
Field::new("b", DataType::Float64, true), // Different type but castable to Utf8
Field::new("a", DataType::Int32, true), // Same type
Field::new("d", DataType::Boolean, true), // Not in table schema
]);
// Custom can_map_field function that allows all mappings for testing
let allow_all = |_: &Field, _: &Field| Ok(true);
// Test field mapping
let (field_mappings, projection) =
create_field_mapping(&file_schema, &table_schema, allow_all).unwrap();
// Expected:
// - field_mappings[0] (a) maps to projection[1]
// - field_mappings[1] (b) maps to projection[0]
// - field_mappings[2] (c) is None (not in file)
assert_eq!(field_mappings, vec![Some(1), Some(0), None]);
assert_eq!(projection, vec![0, 1]); // Projecting file columns b, a
// Test with a failing mapper
let fails_all = |_: &Field, _: &Field| Ok(false);
let (field_mappings, projection) =
create_field_mapping(&file_schema, &table_schema, fails_all).unwrap();
// Should have no mappings or projections if all cast checks fail
assert_eq!(field_mappings, vec![None, None, None]);
assert_eq!(projection, Vec::<usize>::new());
// Test with error-producing mapper
let error_mapper = |_: &Field, _: &Field| plan_err!("Test error");
let result = create_field_mapping(&file_schema, &table_schema, error_mapper);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Test error"));
}
#[test]
fn test_schema_mapping_new() {
// Define the projected table schema
let projected_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
]));
// Define field mappings from table to file
let field_mappings = vec![Some(1), Some(0)];
// Create SchemaMapping manually
let mapping = SchemaMapping::new(
Arc::clone(&projected_schema),
field_mappings.clone(),
Arc::new(
|array: &ArrayRef, field: &Field, opts: &arrow::compute::CastOptions| {
cast_column(array, field, opts)
},
),
);
// Check that fields were set correctly
assert_eq!(*mapping.projected_table_schema, *projected_schema);
assert_eq!(mapping.field_mappings, field_mappings);
// Test with a batch to ensure it works properly
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("b_file", DataType::Utf8, true),
Field::new("a_file", DataType::Int32, true),
])),
vec![
Arc::new(arrow::array::StringArray::from(vec!["hello", "world"])),
Arc::new(arrow::array::Int32Array::from(vec![1, 2])),
],
)
.unwrap();
// Test that map_batch works with our manually created mapping
let mapped_batch = mapping.map_batch(batch).unwrap();
// Verify the mapped batch has the correct schema and data
assert_eq!(*mapped_batch.schema(), *projected_schema);
assert_eq!(mapped_batch.num_columns(), 2);
assert_eq!(mapped_batch.column(0).len(), 2); // a column
assert_eq!(mapped_batch.column(1).len(), 2); // b column
}
#[test]
fn test_map_schema_error_path() {
// Define the table schema
let table_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
Field::new("c", DataType::Decimal128(10, 2), true), // Use Decimal which has stricter cast rules
]));
// Define file schema with incompatible type for column c
let file_schema = Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Float64, true), // Different but castable
Field::new("c", DataType::Binary, true), // Not castable to Decimal128
]);
// Create DefaultSchemaAdapter
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
// map_schema should error due to incompatible types
let result = adapter.map_schema(&file_schema);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Cannot cast file schema field c"));
}
#[test]
fn test_map_schema_happy_path() {
// Define the table schema
let table_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Utf8, true),
Field::new("c", DataType::Decimal128(10, 2), true),
]));
// Create DefaultSchemaAdapter
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
// Define compatible file schema (missing column c)
let compatible_file_schema = Schema::new(vec![
Field::new("a", DataType::Int64, true), // Can be cast to Int32
Field::new("b", DataType::Float64, true), // Can be cast to Utf8
]);
// Test successful schema mapping
let (mapper, projection) = adapter.map_schema(&compatible_file_schema).unwrap();
// Verify field_mappings and projection created correctly
assert_eq!(projection, vec![0, 1]); // Projecting a and b
// Verify the SchemaMapping works with actual data
let file_batch = RecordBatch::try_new(
Arc::new(compatible_file_schema.clone()),
vec![
Arc::new(arrow::array::Int64Array::from(vec![100, 200])),
Arc::new(arrow::array::Float64Array::from(vec![1.5, 2.5])),
],
)
.unwrap();
let mapped_batch = mapper.map_batch(file_batch).unwrap();
// Verify correct schema mapping
assert_eq!(*mapped_batch.schema(), *table_schema);
assert_eq!(mapped_batch.num_columns(), 3); // a, b, c
// Column c should be null since it wasn't in the file schema
let c_array = mapped_batch.column(2);
assert_eq!(c_array.len(), 2);
assert_eq!(c_array.null_count(), 2);
}
#[test]
fn test_adapt_struct_with_added_nested_fields() -> Result<()> {
let (file_schema, table_schema) = create_test_schemas_with_nested_fields();
let batch = create_test_batch_with_struct_data(&file_schema)?;
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
let (mapper, _) = adapter.map_schema(file_schema.as_ref())?;
let mapped_batch = mapper.map_batch(batch)?;
verify_adapted_batch_with_nested_fields(&mapped_batch, &table_schema)?;
Ok(())
}
#[test]
fn test_map_column_statistics_struct() -> Result<()> {
let (file_schema, table_schema) = create_test_schemas_with_nested_fields();
let adapter = DefaultSchemaAdapter {
projected_table_schema: Arc::clone(&table_schema),
};
let (mapper, _) = adapter.map_schema(file_schema.as_ref())?;
let file_stats = vec![
create_test_column_statistics(
0,
100,
Some(ScalarValue::Int32(Some(1))),
Some(ScalarValue::Int32(Some(100))),
Some(ScalarValue::Int32(Some(5100))),
),
create_test_column_statistics(10, 50, None, None, None),
];
let table_stats = mapper.map_column_statistics(&file_stats)?;
assert_eq!(table_stats.len(), 1);
verify_column_statistics(
&table_stats[0],
Some(0),
Some(100),
Some(ScalarValue::Int32(Some(1))),
Some(ScalarValue::Int32(Some(100))),
Some(ScalarValue::Int32(Some(5100))),
);
let missing_stats = mapper.map_column_statistics(&[])?;
assert_eq!(missing_stats.len(), 1);
assert_eq!(missing_stats[0], ColumnStatistics::new_unknown());
Ok(())
}
fn create_test_schemas_with_nested_fields() -> (SchemaRef, SchemaRef) {
let file_schema = Arc::new(Schema::new(vec![Field::new(
"info",
DataType::Struct(
vec![
Field::new("location", DataType::Utf8, true),
Field::new(
"timestamp_utc",
DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())),
true,
),
]
.into(),
),
true,
)]));
let table_schema = Arc::new(Schema::new(vec![Field::new(
"info",
DataType::Struct(
vec![
Field::new("location", DataType::Utf8, true),
Field::new(
"timestamp_utc",
DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())),
true,
),
Field::new(
"reason",
DataType::Struct(
vec![
Field::new("_level", DataType::Float64, true),
Field::new(
"details",
DataType::Struct(
vec![
Field::new("rurl", DataType::Utf8, true),
Field::new("s", DataType::Float64, true),
Field::new("t", DataType::Utf8, true),
]
.into(),
),
true,
),
]
.into(),
),
true,
),
]
.into(),
),
true,
)]));
(file_schema, table_schema)
}
fn create_test_batch_with_struct_data(
file_schema: &SchemaRef,
) -> Result<RecordBatch> {
let mut location_builder = StringBuilder::new();
location_builder.append_value("San Francisco");
location_builder.append_value("New York");
let timestamp_array = TimestampMillisecondArray::from(vec![
Some(1640995200000),
Some(1641081600000),
]);
let timestamp_type =
DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into()));
let timestamp_array = cast(&timestamp_array, &timestamp_type)?;
let info_struct = StructArray::from(vec![
(
Arc::new(Field::new("location", DataType::Utf8, true)),
Arc::new(location_builder.finish()) as ArrayRef,
),
(
Arc::new(Field::new("timestamp_utc", timestamp_type, true)),
timestamp_array,
),
]);
Ok(RecordBatch::try_new(
Arc::clone(file_schema),
vec![Arc::new(info_struct)],
)?)
}
fn verify_adapted_batch_with_nested_fields(
mapped_batch: &RecordBatch,
table_schema: &SchemaRef,
) -> Result<()> {
assert_eq!(mapped_batch.schema(), *table_schema);
assert_eq!(mapped_batch.num_rows(), 2);
let info_col = mapped_batch.column(0);
let info_array = info_col
.as_any()
.downcast_ref::<StructArray>()
.expect("Expected info column to be a StructArray");
verify_preserved_fields(info_array)?;
verify_reason_field_structure(info_array)?;
Ok(())
}
fn verify_preserved_fields(info_array: &StructArray) -> Result<()> {
let location_col = info_array
.column_by_name("location")
.expect("Expected location field in struct");
let location_array = location_col
.as_any()
.downcast_ref::<arrow::array::StringArray>()
.expect("Expected location to be a StringArray");
assert_eq!(location_array.value(0), "San Francisco");
assert_eq!(location_array.value(1), "New York");
let timestamp_col = info_array
.column_by_name("timestamp_utc")
.expect("Expected timestamp_utc field in struct");
let timestamp_array = timestamp_col
.as_any()
.downcast_ref::<TimestampMillisecondArray>()
.expect("Expected timestamp_utc to be a TimestampMillisecondArray");
assert_eq!(timestamp_array.value(0), 1640995200000);
assert_eq!(timestamp_array.value(1), 1641081600000);
Ok(())
}
fn verify_reason_field_structure(info_array: &StructArray) -> Result<()> {
let reason_col = info_array
.column_by_name("reason")
.expect("Expected reason field in struct");
let reason_array = reason_col
.as_any()
.downcast_ref::<StructArray>()
.expect("Expected reason to be a StructArray");
assert_eq!(reason_array.fields().len(), 2);
assert!(reason_array.column_by_name("_level").is_some());
assert!(reason_array.column_by_name("details").is_some());
let details_col = reason_array
.column_by_name("details")
.expect("Expected details field in reason struct");
let details_array = details_col
.as_any()
.downcast_ref::<StructArray>()
.expect("Expected details to be a StructArray");
assert_eq!(details_array.fields().len(), 3);
assert!(details_array.column_by_name("rurl").is_some());
assert!(details_array.column_by_name("s").is_some());
assert!(details_array.column_by_name("t").is_some());
for i in 0..2 {
assert!(reason_array.is_null(i), "reason field should be null");
}
Ok(())
}
fn verify_column_statistics(
stats: &ColumnStatistics,
expected_null_count: Option<usize>,
expected_distinct_count: Option<usize>,
expected_min: Option<ScalarValue>,
expected_max: Option<ScalarValue>,
expected_sum: Option<ScalarValue>,
) {
if let Some(count) = expected_null_count {
assert_eq!(
stats.null_count,
Precision::Exact(count),
"Null count should match expected value"
);
}
if let Some(count) = expected_distinct_count {
assert_eq!(
stats.distinct_count,
Precision::Exact(count),
"Distinct count should match expected value"
);
}
if let Some(min) = expected_min {
assert_eq!(
stats.min_value,
Precision::Exact(min),
"Min value should match expected value"
);
}
if let Some(max) = expected_max {
assert_eq!(
stats.max_value,
Precision::Exact(max),
"Max value should match expected value"
);
}
if let Some(sum) = expected_sum {
assert_eq!(
stats.sum_value,
Precision::Exact(sum),
"Sum value should match expected value"
);
}
}
fn create_test_column_statistics(
null_count: usize,
distinct_count: usize,
min_value: Option<ScalarValue>,
max_value: Option<ScalarValue>,
sum_value: Option<ScalarValue>,
) -> ColumnStatistics {
ColumnStatistics {
null_count: Precision::Exact(null_count),
distinct_count: Precision::Exact(distinct_count),
min_value: min_value.map_or_else(|| Precision::Absent, Precision::Exact),
max_value: max_value.map_or_else(|| Precision::Absent, Precision::Exact),
sum_value: sum_value.map_or_else(|| Precision::Absent, Precision::Exact),
}
}
}