Skip to content

Commit 872bbd9

Browse files
committed
Infer a Parquet column as nullable when some files lack it
ListingTable schema inference merges per-file schemas with Schema::try_merge, which only widens nullability for fields present in more than one file. A column that exists, as required, in some files and not at all in others therefore came out NOT NULL, and reading the files without it failed with "declared as non-nullable but contains null values". Track which files carry each field and mark fields that are missing from any file nullable after the merge.
1 parent 7b1e55d commit 872bbd9

3 files changed

Lines changed: 176 additions & 2 deletions

File tree

‎datafusion/core/tests/parquet/schema.rs‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
//! Tests for parquet schema handling
1919
use std::{collections::HashMap, fs, path::Path};
2020

21+
use arrow::array::ArrayRef;
22+
2123
use tempfile::TempDir;
2224

2325
use super::*;
@@ -185,6 +187,71 @@ async fn schema_merge_can_preserve_metadata() {
185187
assert_metadata(&actual, &expected_metadata);
186188
}
187189

190+
/// A column that only some files carry must be inferred as nullable, because
191+
/// reading the files without it yields nulls, even when every file that has
192+
/// the column declares it required.
193+
#[tokio::test]
194+
async fn schema_merge_marks_partially_present_columns_nullable() {
195+
let tmp_dir = TempDir::new().unwrap();
196+
let table_dir = tmp_dir.path().join("parquet_test");
197+
198+
let id = Field::new("id", DataType::Int32, false);
199+
let name = Field::new("name", DataType::Utf8, false);
200+
let extra = Field::new("extra", DataType::Int32, false);
201+
let opt = Field::new("opt", DataType::Int32, true);
202+
203+
let schemas = vec![
204+
// required `extra` and nullable `opt` in the first file only
205+
Schema::new(vec![id.clone(), name.clone(), extra.clone(), opt]),
206+
// neither column
207+
Schema::new(vec![id.clone(), name.clone()]),
208+
// required `extra` again, in a different position
209+
Schema::new(vec![extra, id, name]),
210+
];
211+
write_files_with_columns(table_dir.as_path(), schemas);
212+
let table_path = table_dir.to_str().unwrap().to_string();
213+
214+
for skip_metadata in [true, false] {
215+
let options = ParquetReadOptions::default().skip_metadata(skip_metadata);
216+
let ctx = SessionContext::new();
217+
let df = ctx.read_parquet(&table_path, options).await.unwrap();
218+
219+
let nullability: Vec<(&str, bool)> = df
220+
.schema()
221+
.fields()
222+
.iter()
223+
.map(|f| (f.name().as_str(), f.is_nullable()))
224+
.collect();
225+
assert_eq!(
226+
nullability,
227+
vec![
228+
("id", false),
229+
("name", false),
230+
("extra", true),
231+
("opt", true)
232+
],
233+
"skip_metadata={skip_metadata}"
234+
);
235+
236+
let actual = df.collect().await.unwrap();
237+
let expected = [
238+
"+----+------+-------+-----+",
239+
"| id | name | extra | opt |",
240+
"+----+------+-------+-----+",
241+
"| 0 | test | 0 | |",
242+
"| 1 | test | | |",
243+
"| 2 | test | 2 | |",
244+
"+----+------+-------+-----+",
245+
]
246+
.join("\n");
247+
assert_eq!(
248+
batches_to_sort_string(&actual).trim(),
249+
expected,
250+
"skip_metadata={skip_metadata}"
251+
);
252+
}
253+
}
254+
188255
fn make_meta(k: impl Into<String>, v: impl Into<String>) -> HashMap<String, String> {
189256
let mut meta = HashMap::new();
190257
meta.insert(k.into(), v.into());
@@ -214,6 +281,33 @@ fn write_files(table_path: &Path, schemas: Vec<Schema>) {
214281
}
215282
}
216283

284+
/// Writes one file per schema, filling each column from its name: `id` and
285+
/// `extra` get the file index, `name` gets "test", anything else is null.
286+
fn write_files_with_columns(table_path: &Path, schemas: Vec<Schema>) {
287+
fs::create_dir(table_path).expect("Error creating temp dir");
288+
289+
for (i, schema) in schemas.into_iter().enumerate() {
290+
let schema = Arc::new(schema);
291+
let path = table_path.join(format!("part-{i}.parquet"));
292+
let file = fs::File::create(path).unwrap();
293+
let mut writer = ArrowWriter::try_new(file, schema.clone(), None).unwrap();
294+
295+
let columns: Vec<ArrayRef> = schema
296+
.fields()
297+
.iter()
298+
.map(|field| match field.name().as_str() {
299+
"id" | "extra" => Arc::new(Int32Array::from(vec![i as i32])) as ArrayRef,
300+
"name" => Arc::new(StringArray::from(vec!["test"])),
301+
_ => Arc::new(Int32Array::from(vec![None::<i32>])),
302+
})
303+
.collect();
304+
let rec_batch = RecordBatch::try_new(schema, columns).unwrap();
305+
306+
writer.write(&rec_batch).unwrap();
307+
writer.close().unwrap();
308+
}
309+
}
310+
217311
fn assert_no_metadata(batches: &[RecordBatch]) {
218312
// all batches should have no metadata
219313
for batch in batches {

‎datafusion/datasource-parquet/src/file_format.rs‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
//! [`ParquetFormat`]: Parquet [`FileFormat`] abstractions
1919
20-
use std::collections::HashSet;
20+
use std::collections::{HashMap, HashSet};
2121
use std::fmt;
2222
use std::fmt::Debug;
2323
use std::ops::Range;
@@ -99,7 +99,7 @@ impl FileFormatFactory for ParquetFormatFactory {
9999
fn create(
100100
&self,
101101
state: &dyn Session,
102-
format_options: &std::collections::HashMap<String, String>,
102+
format_options: &HashMap<String, String>,
103103
) -> Result<Arc<dyn FileFormat>> {
104104
let parquet_options = match &self.options {
105105
None => {
@@ -272,6 +272,34 @@ fn clear_metadata(
272272
})
273273
}
274274

275+
/// Marks every field that is missing from at least one of the `file_count`
276+
/// files as nullable: reading such a file yields nulls for that column.
277+
fn mark_partially_present_fields_nullable(
278+
schema: Schema,
279+
presence: &HashMap<String, usize>,
280+
file_count: usize,
281+
) -> Schema {
282+
if presence.values().all(|&count| count == file_count) {
283+
return schema;
284+
}
285+
let metadata = schema.metadata().clone();
286+
let fields = schema
287+
.fields()
288+
.iter()
289+
.map(|field| {
290+
let present_everywhere = presence
291+
.get(field.name())
292+
.is_some_and(|&count| count == file_count);
293+
if present_everywhere || field.is_nullable() {
294+
Arc::clone(field)
295+
} else {
296+
Arc::new(field.as_ref().clone().with_nullable(true))
297+
}
298+
})
299+
.collect::<Vec<_>>();
300+
Schema::new_with_metadata(fields, metadata)
301+
}
302+
275303
#[cfg(feature = "parquet_encryption")]
276304
async fn get_file_decryption_properties(
277305
state: &dyn Session,
@@ -400,6 +428,19 @@ impl FileFormat for ParquetFormat {
400428
}
401429
drop(seen);
402430

431+
// A column that some files do not have is read as all-null from those
432+
// files, so the merged table schema must declare it nullable even
433+
// when every file that has it declares it required. `Schema::try_merge`
434+
// only widens nullability for fields that appear in more than one
435+
// schema, so track presence separately.
436+
let file_count = schemas.len();
437+
let mut presence: HashMap<String, usize> = HashMap::new();
438+
for (_, schema) in &schemas {
439+
for field in schema.fields() {
440+
*presence.entry(field.name().clone()).or_insert(0) += 1;
441+
}
442+
}
443+
403444
// Normalize dict-promoted schemas before merging so mixed dict/plain files merge cleanly.
404445
let mut schemas: Vec<Schema> =
405446
schemas.into_iter().map(|(_, schema)| schema).collect();
@@ -413,6 +454,9 @@ impl FileFormat for ParquetFormat {
413454
Schema::try_merge(schemas)
414455
}?;
415456

457+
let schema =
458+
mark_partially_present_fields_nullable(schema, &presence, file_count);
459+
416460
let schema = if self.binary_as_string() {
417461
transform_binary_to_string(&schema)
418462
} else {

‎datafusion/sqllogictest/test_files/schema_evolution.slt‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,39 @@ query TIR rowsort
282282
select * from parquet_table where b = 100 AND c = 10.5;
283283
----
284284
foo 100 10.5
285+
286+
##########
287+
# Inferred schema: a column that some files lack must be nullable, because
288+
# reading those files yields nulls for it, even when every file that has the
289+
# column declares it required.
290+
##########
291+
292+
statement ok
293+
COPY (SELECT 1 AS id, 10 AS c)
294+
TO 'test_files/scratch/schema_evolution/inferred_partial/a.parquet'
295+
STORED AS PARQUET;
296+
297+
statement ok
298+
COPY (SELECT 2 AS id)
299+
TO 'test_files/scratch/schema_evolution/inferred_partial/b.parquet'
300+
STORED AS PARQUET;
301+
302+
statement ok
303+
CREATE EXTERNAL TABLE inferred_partial STORED AS PARQUET
304+
LOCATION 'test_files/scratch/schema_evolution/inferred_partial/';
305+
306+
# `id` is in every file and stays required; `c` is missing from b.parquet.
307+
query TTT
308+
DESCRIBE inferred_partial;
309+
----
310+
id Int64 NO
311+
c Int64 YES
312+
313+
query II rowsort
314+
SELECT id, c FROM inferred_partial;
315+
----
316+
1 10
317+
2 NULL
318+
319+
statement ok
320+
DROP TABLE inferred_partial;

0 commit comments

Comments
 (0)