|
| 1 | +//! Verifies `parquet_to_datafile`/`Value::try_from_bytes` decode Parquet |
| 2 | +//! column statistics correctly across primitive types and `PhysicalTypeHint`s: |
| 3 | +//! INT32/INT64-physical DECIMAL stats (native little-endian on disk; Iceberg |
| 4 | +//! decimals are big-endian), Uuid (written as Arrow `Utf8`, so BYTE_ARRAY |
| 5 | +//! stats hold its string form), and the remaining little-endian primitives. |
| 6 | +
|
| 7 | +use std::collections::HashMap; |
| 8 | +use std::sync::Arc; |
| 9 | + |
| 10 | +use datafusion::arrow::array::{ |
| 11 | + ArrayRef, Date32Array, Decimal128Array, Float32Array, Float64Array, Int32Array, Int64Array, |
| 12 | + RecordBatch, StringArray, Time64MicrosecondArray, TimestampMicrosecondArray, |
| 13 | +}; |
| 14 | +use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema, TimeUnit}; |
| 15 | +use datafusion::dataframe::DataFrameWriteOptions; |
| 16 | +use datafusion::prelude::SessionContext; |
| 17 | +use datafusion_iceberg::catalog::catalog::IcebergCatalog; |
| 18 | +use iceberg_rust::catalog::identifier::Identifier; |
| 19 | +use iceberg_rust::catalog::Catalog; |
| 20 | +use iceberg_rust::error::Error; |
| 21 | +use iceberg_rust::file_format::parquet::parquet_to_datafile; |
| 22 | +use iceberg_rust::object_store::ObjectStoreBuilder; |
| 23 | +use iceberg_rust::spec::manifest::DataFile; |
| 24 | +use iceberg_rust::spec::namespace::Namespace; |
| 25 | +use iceberg_rust::spec::partition::{BoundPartitionField, PartitionField, Transform}; |
| 26 | +use iceberg_rust::spec::schema::Schema; |
| 27 | +use iceberg_rust::spec::types::{PrimitiveType, StructField, Type}; |
| 28 | +use iceberg_rust::spec::values::Value; |
| 29 | +use iceberg_rust::table::Table; |
| 30 | +use iceberg_sql_catalog::SqlCatalog; |
| 31 | +use parquet::arrow::ArrowWriter; |
| 32 | +use parquet::file::reader::{FileReader, SerializedFileReader}; |
| 33 | +use rust_decimal::Decimal; |
| 34 | +use uuid::Uuid; |
| 35 | + |
| 36 | +/// Build an in-memory catalog with a single `public.t(id INT, amount DECIMAL(18,2))` |
| 37 | +/// table and write `unscaled_amounts` as one data file. |
| 38 | +async fn setup(unscaled_amounts: Vec<i128>) -> SessionContext { |
| 39 | + let object_store = ObjectStoreBuilder::memory(); |
| 40 | + let catalog: Arc<dyn Catalog> = Arc::new( |
| 41 | + SqlCatalog::new("sqlite://", "test", object_store) |
| 42 | + .await |
| 43 | + .unwrap(), |
| 44 | + ); |
| 45 | + catalog |
| 46 | + .create_namespace(&Namespace::try_new(&["public".to_string()]).unwrap(), None) |
| 47 | + .await |
| 48 | + .unwrap(); |
| 49 | + let identifier = Identifier::new(&["public".to_string()], "t"); |
| 50 | + |
| 51 | + Table::builder() |
| 52 | + .with_name("t") |
| 53 | + .with_location("/t") |
| 54 | + .with_schema( |
| 55 | + Schema::builder() |
| 56 | + .with_struct_field(StructField { |
| 57 | + id: 0, |
| 58 | + name: "id".to_owned(), |
| 59 | + required: true, |
| 60 | + field_type: Type::Primitive(PrimitiveType::Int), |
| 61 | + doc: None, |
| 62 | + initial_default: None, |
| 63 | + write_default: None, |
| 64 | + }) |
| 65 | + .with_struct_field(StructField { |
| 66 | + id: 1, |
| 67 | + name: "amount".to_owned(), |
| 68 | + required: false, |
| 69 | + field_type: Type::Primitive(PrimitiveType::Decimal { |
| 70 | + precision: 18, |
| 71 | + scale: 2, |
| 72 | + }), |
| 73 | + doc: None, |
| 74 | + initial_default: None, |
| 75 | + write_default: None, |
| 76 | + }) |
| 77 | + .build() |
| 78 | + .unwrap(), |
| 79 | + ) |
| 80 | + .build(identifier.namespace(), catalog.clone()) |
| 81 | + .await |
| 82 | + .unwrap(); |
| 83 | + |
| 84 | + let ctx = SessionContext::new(); |
| 85 | + ctx.register_catalog( |
| 86 | + "warehouse", |
| 87 | + Arc::new(IcebergCatalog::new(catalog, None).await.unwrap()), |
| 88 | + ); |
| 89 | + |
| 90 | + let n = unscaled_amounts.len(); |
| 91 | + let ids = Int32Array::from((0..n as i32).collect::<Vec<_>>()); |
| 92 | + let amount = Decimal128Array::from(unscaled_amounts) |
| 93 | + .with_precision_and_scale(18, 2) |
| 94 | + .unwrap(); |
| 95 | + let data = RecordBatch::try_from_iter(vec![ |
| 96 | + ("id", Arc::new(ids) as ArrayRef), |
| 97 | + ("amount", Arc::new(amount) as ArrayRef), |
| 98 | + ]) |
| 99 | + .unwrap(); |
| 100 | + |
| 101 | + ctx.read_batch(data) |
| 102 | + .unwrap() |
| 103 | + .write_table("warehouse.public.t", DataFrameWriteOptions::default()) |
| 104 | + .await |
| 105 | + .unwrap(); |
| 106 | + |
| 107 | + ctx |
| 108 | +} |
| 109 | + |
| 110 | +async fn scalar_i128(ctx: &SessionContext, sql: &str) -> i128 { |
| 111 | + let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap(); |
| 112 | + let b = batches.into_iter().find(|b| b.num_rows() > 0).unwrap(); |
| 113 | + b.column(0) |
| 114 | + .as_any() |
| 115 | + .downcast_ref::<Decimal128Array>() |
| 116 | + .unwrap() |
| 117 | + .value(0) |
| 118 | +} |
| 119 | + |
| 120 | +async fn count(ctx: &SessionContext, sql: &str) -> i64 { |
| 121 | + use datafusion::arrow::array::Int64Array; |
| 122 | + let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap(); |
| 123 | + let b = batches.into_iter().find(|b| b.num_rows() > 0).unwrap(); |
| 124 | + b.column(0) |
| 125 | + .as_any() |
| 126 | + .downcast_ref::<Int64Array>() |
| 127 | + .unwrap() |
| 128 | + .value(0) |
| 129 | +} |
| 130 | + |
| 131 | +/// Writes a single-row Parquet file for the given Arrow schema/batch and |
| 132 | +/// returns the `DataFile` metadata `parquet_to_datafile` builds for it. |
| 133 | +fn write_and_extract( |
| 134 | + arrow_schema: Arc<ArrowSchema>, |
| 135 | + batch: RecordBatch, |
| 136 | + schema: &Schema, |
| 137 | + partition_fields: &[BoundPartitionField<'_>], |
| 138 | +) -> Result<DataFile, Error> { |
| 139 | + let mut buf = Vec::new(); |
| 140 | + let mut writer = ArrowWriter::try_new(&mut buf, arrow_schema, None).unwrap(); |
| 141 | + writer.write(&batch).unwrap(); |
| 142 | + writer.close().unwrap(); |
| 143 | + let file_size = buf.len() as u64; |
| 144 | + |
| 145 | + let reader = SerializedFileReader::new(bytes::Bytes::from(buf)).unwrap(); |
| 146 | + let parquet_metadata = reader.metadata().clone(); |
| 147 | + |
| 148 | + parquet_to_datafile( |
| 149 | + "/t/data/1.parquet", |
| 150 | + file_size, |
| 151 | + &parquet_metadata, |
| 152 | + schema, |
| 153 | + partition_fields, |
| 154 | + None, |
| 155 | + &HashMap::new(), |
| 156 | + ) |
| 157 | +} |
| 158 | + |
| 159 | +/// A single-row file has exact min==max stats, so DataFusion may materialize |
| 160 | +/// the column value from the statistic instead of the page. |
| 161 | +#[tokio::test] |
| 162 | +async fn single_row_decimal_value_is_not_byteswapped() { |
| 163 | + // 100000.00 |
| 164 | + let ctx = setup(vec![10_000_000]).await; |
| 165 | + let got = scalar_i128(&ctx, "SELECT amount FROM warehouse.public.t").await; |
| 166 | + assert_eq!(got, 10_000_000, "single-row decimal value was byteswapped"); |
| 167 | +} |
| 168 | + |
| 169 | +/// A multi-row file's data is read from the page, but a predicate still |
| 170 | +/// depends on the manifest's min/max bounds for pruning. |
| 171 | +#[tokio::test] |
| 172 | +async fn multi_row_decimal_pruning_uses_correct_bounds() { |
| 173 | + // 100.00, 200.00, 300.00 |
| 174 | + let ctx = setup(vec![10_000, 20_000, 30_000]).await; |
| 175 | + |
| 176 | + let total = count(&ctx, "SELECT count(*) FROM warehouse.public.t").await; |
| 177 | + assert_eq!(total, 3); |
| 178 | + |
| 179 | + let matched = count( |
| 180 | + &ctx, |
| 181 | + "SELECT count(*) FROM warehouse.public.t WHERE amount = CAST(200.00 AS DECIMAL(18,2))", |
| 182 | + ) |
| 183 | + .await; |
| 184 | + assert_eq!( |
| 185 | + matched, 1, |
| 186 | + "predicate matching a real row was wrongly pruned (byteswapped bound)" |
| 187 | + ); |
| 188 | +} |
| 189 | + |
| 190 | +#[test] |
| 191 | +fn parquet_stats_and_partition_value_decode_correctly() { |
| 192 | + let arrow_schema = Arc::new(ArrowSchema::new(vec![ |
| 193 | + Field::new("amount", DataType::Decimal128(18, 2), false), |
| 194 | + Field::new("int_col", DataType::Int32, false), |
| 195 | + Field::new("long_col", DataType::Int64, false), |
| 196 | + Field::new("float_col", DataType::Float32, false), |
| 197 | + Field::new("double_col", DataType::Float64, false), |
| 198 | + Field::new("date_col", DataType::Date32, false), |
| 199 | + Field::new("time_col", DataType::Time64(TimeUnit::Microsecond), false), |
| 200 | + Field::new( |
| 201 | + "ts_col", |
| 202 | + DataType::Timestamp(TimeUnit::Microsecond, None), |
| 203 | + false, |
| 204 | + ), |
| 205 | + Field::new( |
| 206 | + "tstz_col", |
| 207 | + DataType::Timestamp(TimeUnit::Microsecond, Some(Arc::from("UTC"))), |
| 208 | + false, |
| 209 | + ), |
| 210 | + Field::new("uuid_col", DataType::Utf8, false), |
| 211 | + ])); |
| 212 | + |
| 213 | + let amount_val: i128 = 10_000_000; // 100000.00 |
| 214 | + let int_val: i32 = 0x0102_0304; |
| 215 | + let long_val: i64 = 0x0102_0304_0506_0708; |
| 216 | + let float_val: f32 = 1.1; |
| 217 | + let double_val: f64 = 2.2; |
| 218 | + let date_val: i32 = 19_723; // 2023-12-04 |
| 219 | + let time_val: i64 = 45_296_123_456; // 12:34:56.123456 |
| 220 | + let ts_val: i64 = 1_700_000_000_123_456; |
| 221 | + let tstz_val: i64 = 1_700_000_012_345_678; |
| 222 | + let uuid_str = "550e8400-e29b-41d4-a716-446655440000"; |
| 223 | + |
| 224 | + let batch = RecordBatch::try_new( |
| 225 | + arrow_schema.clone(), |
| 226 | + vec![ |
| 227 | + Arc::new( |
| 228 | + Decimal128Array::from(vec![amount_val]) |
| 229 | + .with_precision_and_scale(18, 2) |
| 230 | + .unwrap(), |
| 231 | + ), |
| 232 | + Arc::new(Int32Array::from(vec![int_val])), |
| 233 | + Arc::new(Int64Array::from(vec![long_val])), |
| 234 | + Arc::new(Float32Array::from(vec![float_val])), |
| 235 | + Arc::new(Float64Array::from(vec![double_val])), |
| 236 | + Arc::new(Date32Array::from(vec![date_val])), |
| 237 | + Arc::new(Time64MicrosecondArray::from(vec![time_val])), |
| 238 | + Arc::new(TimestampMicrosecondArray::from(vec![ts_val])), |
| 239 | + Arc::new(TimestampMicrosecondArray::from(vec![tstz_val]).with_timezone("UTC")), |
| 240 | + Arc::new(StringArray::from(vec![uuid_str])), |
| 241 | + ], |
| 242 | + ) |
| 243 | + .unwrap(); |
| 244 | + |
| 245 | + let mut schema_builder = Schema::builder(); |
| 246 | + for (id, (name, field_type)) in [ |
| 247 | + ( |
| 248 | + "amount", |
| 249 | + Type::Primitive(PrimitiveType::Decimal { |
| 250 | + precision: 18, |
| 251 | + scale: 2, |
| 252 | + }), |
| 253 | + ), |
| 254 | + ("int_col", Type::Primitive(PrimitiveType::Int)), |
| 255 | + ("long_col", Type::Primitive(PrimitiveType::Long)), |
| 256 | + ("float_col", Type::Primitive(PrimitiveType::Float)), |
| 257 | + ("double_col", Type::Primitive(PrimitiveType::Double)), |
| 258 | + ("date_col", Type::Primitive(PrimitiveType::Date)), |
| 259 | + ("time_col", Type::Primitive(PrimitiveType::Time)), |
| 260 | + ("ts_col", Type::Primitive(PrimitiveType::Timestamp)), |
| 261 | + ("tstz_col", Type::Primitive(PrimitiveType::Timestamptz)), |
| 262 | + ("uuid_col", Type::Primitive(PrimitiveType::Uuid)), |
| 263 | + ] |
| 264 | + .into_iter() |
| 265 | + .enumerate() |
| 266 | + { |
| 267 | + schema_builder.with_struct_field(StructField { |
| 268 | + id: id as i32, |
| 269 | + name: name.to_owned(), |
| 270 | + required: true, |
| 271 | + field_type, |
| 272 | + doc: None, |
| 273 | + initial_default: None, |
| 274 | + write_default: None, |
| 275 | + }); |
| 276 | + } |
| 277 | + let schema = schema_builder.build().unwrap(); |
| 278 | + |
| 279 | + let partition_field = PartitionField::new(0, 1000, "amount", Transform::Identity); |
| 280 | + let struct_field = schema.fields().get(0).unwrap().clone(); |
| 281 | + let bound_field = BoundPartitionField::new(&partition_field, &struct_field); |
| 282 | + |
| 283 | + let data_file = write_and_extract(arrow_schema, batch, &schema, &[bound_field]) |
| 284 | + .expect("stats decode and partition value inference should succeed"); |
| 285 | + |
| 286 | + let partition_value = data_file |
| 287 | + .partition() |
| 288 | + .get("amount") |
| 289 | + .cloned() |
| 290 | + .flatten() |
| 291 | + .expect("partition value should have been inferred from stats"); |
| 292 | + |
| 293 | + let amount = Value::Decimal(Decimal::from_i128_with_scale(amount_val, 2)); |
| 294 | + assert_eq!(partition_value, amount); |
| 295 | + |
| 296 | + let uuid_val = Value::UUID(Uuid::parse_str(uuid_str).unwrap()); |
| 297 | + let lower = data_file.lower_bounds().as_ref().unwrap(); |
| 298 | + let upper = data_file.upper_bounds().as_ref().unwrap(); |
| 299 | + for bounds in [lower, upper] { |
| 300 | + assert_eq!(bounds[&0], amount); |
| 301 | + assert_eq!(bounds[&1], Value::Int(int_val)); |
| 302 | + assert_eq!(bounds[&2], Value::LongInt(long_val)); |
| 303 | + assert_eq!(bounds[&3], Value::Float(float_val.into())); |
| 304 | + assert_eq!(bounds[&4], Value::Double(double_val.into())); |
| 305 | + assert_eq!(bounds[&5], Value::Date(date_val)); |
| 306 | + assert_eq!(bounds[&6], Value::Time(time_val)); |
| 307 | + assert_eq!(bounds[&7], Value::Timestamp(ts_val)); |
| 308 | + assert_eq!(bounds[&8], Value::TimestampTZ(tstz_val)); |
| 309 | + assert_eq!(bounds[&9], uuid_val); |
| 310 | + } |
| 311 | +} |
0 commit comments