Skip to content

Commit c5f7907

Browse files
authored
Merge pull request JanKaul#368 from splitgraph/fix-value-from-bytes
fix: decimal and uuid value parsing from parquet types
2 parents 3a3b704 + d1ac76c commit c5f7907

7 files changed

Lines changed: 451 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ murmur3 = { version = "0.5.2" }
4242
parquet = { version = "58", features = ["async", "object_store"] }
4343
pin-project-lite = "0.2"
4444
regex = "1.11.1"
45+
rust_decimal = "1.42.0"
4546
serde = "^1.0"
4647
serde_derive = "^1.0"
4748
serde_json = "^1.0"

datafusion_iceberg/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ uuid = { workspace = true }
3333
[dev-dependencies]
3434
# The version should match the Arrow version used by duckdb
3535
arrow-ipc = "58"
36+
bytes = { workspace = true }
3637
duckdb = { version = "1.10503", features = ["bundled"] }
3738
iceberg-rest-catalog = { path = "../catalogs/iceberg-rest-catalog" }
3839
iceberg-rust = { path = "../iceberg-rust", version = "0.10.0", features = ["test-utils"] }
3940
iceberg-sql-catalog = { path = "../catalogs/iceberg-sql-catalog" }
41+
parquet = { workspace = true }
4042
pyo3 = { version = "0.28", features = ["auto-initialize"] }
4143
reqwest = "0.12"
4244
rstest = "0.26"
45+
rust_decimal = { workspace = true }
4346
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "any", "sqlite", "postgres", "mysql"], default-features = false }
4447
tempfile = "3.27.0"
4548
testcontainers = "0.26"
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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+
}

iceberg-rust-spec/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ getrandom = { workspace = true }
1818
itertools = { workspace = true }
1919
murmur3 = { workspace = true }
2020
ordered-float = { version = "5.3.0", features = ["serde"] }
21-
rust_decimal = "1.42.0"
21+
rust_decimal = { workspace = true }
2222
serde = { workspace = true }
2323
serde_bytes = "0.11.15"
2424
serde_derive = { workspace = true }

0 commit comments

Comments
 (0)