Skip to content

Commit 4440267

Browse files
wiedldappletreeisyellow
authored andcommitted
Allow adding user defined metadata to ParquetSink (apache#10224)
* chore: make explicit what ParquetWriterOptions are created from a subset of TableParquetOptions * refactor: restore the ability to add kv metadata into the generated file sink * test: demomnstrate API contract for metadata TableParquetOptions * chore: update code docs * fix: parse on proper delimiter, and improve tests * fix: enable any character in the metadata string value, by having any key parsing be a part of the format.metadata::key
1 parent 65ecfda commit 4440267

6 files changed

Lines changed: 238 additions & 37 deletions

File tree

datafusion/common/src/config.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,12 +1364,31 @@ impl TableOptions {
13641364

13651365
/// Options that control how Parquet files are read, including global options
13661366
/// that apply to all columns and optional column-specific overrides
1367+
///
1368+
/// Closely tied to [`ParquetWriterOptions`](crate::file_options::parquet_writer::ParquetWriterOptions).
1369+
/// Properties not included in [`TableParquetOptions`] may not be configurable at the external API
1370+
/// (e.g. sorting_columns).
13671371
#[derive(Clone, Default, Debug, PartialEq)]
13681372
pub struct TableParquetOptions {
13691373
/// Global Parquet options that propagates to all columns.
13701374
pub global: ParquetOptions,
13711375
/// Column specific options. Default usage is parquet.XX::column.
13721376
pub column_specific_options: HashMap<String, ColumnOptions>,
1377+
/// Additional file-level metadata to include. Inserted into the key_value_metadata
1378+
/// for the written [`FileMetaData`](https://docs.rs/parquet/latest/parquet/file/metadata/struct.FileMetaData.html).
1379+
///
1380+
/// Multiple entries are permitted
1381+
/// ```sql
1382+
/// OPTIONS (
1383+
/// 'format.metadata::key1' '',
1384+
/// 'format.metadata::key2' 'value',
1385+
/// 'format.metadata::key3' 'value has spaces',
1386+
/// 'format.metadata::key4' 'value has special chars :: :',
1387+
/// 'format.metadata::key_dupe' 'original will be overwritten',
1388+
/// 'format.metadata::key_dupe' 'final'
1389+
/// )
1390+
/// ```
1391+
pub key_value_metadata: HashMap<String, Option<String>>,
13731392
}
13741393

13751394
impl ConfigField for TableParquetOptions {
@@ -1380,8 +1399,24 @@ impl ConfigField for TableParquetOptions {
13801399
}
13811400

13821401
fn set(&mut self, key: &str, value: &str) -> Result<()> {
1383-
// Determine the key if it's a global or column-specific setting
1384-
if key.contains("::") {
1402+
// Determine if the key is a global, metadata, or column-specific setting
1403+
if key.starts_with("metadata::") {
1404+
let k =
1405+
match key.split("::").collect::<Vec<_>>()[..] {
1406+
[_meta] | [_meta, ""] => return Err(DataFusionError::Configuration(
1407+
"Invalid metadata key provided, missing key in metadata::<key>"
1408+
.to_string(),
1409+
)),
1410+
[_meta, k] => k.into(),
1411+
_ => {
1412+
return Err(DataFusionError::Configuration(format!(
1413+
"Invalid metadata key provided, found too many '::' in \"{key}\""
1414+
)))
1415+
}
1416+
};
1417+
self.key_value_metadata.insert(k, Some(value.into()));
1418+
Ok(())
1419+
} else if key.contains("::") {
13851420
self.column_specific_options.set(key, value)
13861421
} else {
13871422
self.global.set(key, value)
@@ -1773,4 +1808,38 @@ mod tests {
17731808
.iter()
17741809
.any(|item| item.key == "format.bloom_filter_enabled::col1"))
17751810
}
1811+
1812+
#[cfg(feature = "parquet")]
1813+
#[test]
1814+
fn parquet_table_options_config_metadata_entry() {
1815+
let mut table_config = TableOptions::new();
1816+
table_config.set_file_format(FileType::PARQUET);
1817+
table_config.set("format.metadata::key1", "").unwrap();
1818+
table_config.set("format.metadata::key2", "value2").unwrap();
1819+
table_config
1820+
.set("format.metadata::key3", "value with spaces ")
1821+
.unwrap();
1822+
table_config
1823+
.set("format.metadata::key4", "value with special chars :: :")
1824+
.unwrap();
1825+
1826+
let parsed_metadata = table_config.parquet.key_value_metadata.clone();
1827+
assert_eq!(parsed_metadata.get("should not exist1"), None);
1828+
assert_eq!(parsed_metadata.get("key1"), Some(&Some("".into())));
1829+
assert_eq!(parsed_metadata.get("key2"), Some(&Some("value2".into())));
1830+
assert_eq!(
1831+
parsed_metadata.get("key3"),
1832+
Some(&Some("value with spaces ".into()))
1833+
);
1834+
assert_eq!(
1835+
parsed_metadata.get("key4"),
1836+
Some(&Some("value with special chars :: :".into()))
1837+
);
1838+
1839+
// duplicate keys are overwritten
1840+
table_config.set("format.metadata::key_dupe", "A").unwrap();
1841+
table_config.set("format.metadata::key_dupe", "B").unwrap();
1842+
let parsed_metadata = table_config.parquet.key_value_metadata;
1843+
assert_eq!(parsed_metadata.get("key_dupe"), Some(&Some("B".into())));
1844+
}
17761845
}

datafusion/common/src/file_options/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ mod tests {
124124
123
125125
);
126126

127+
// properties which remain as default on WriterProperties
128+
assert_eq!(properties.key_value_metadata(), None);
129+
assert_eq!(properties.sorting_columns(), None);
130+
127131
Ok(())
128132
}
129133

datafusion/common/src/file_options/parquet_writer.rs

Lines changed: 73 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@
1717

1818
//! Options related to how parquet files should be written
1919
20-
use crate::{config::TableParquetOptions, DataFusionError, Result};
20+
use crate::{
21+
config::{ParquetOptions, TableParquetOptions},
22+
DataFusionError, Result,
23+
};
2124

2225
use parquet::{
2326
basic::{BrotliLevel, GzipLevel, ZstdLevel},
24-
file::properties::{EnabledStatistics, WriterProperties, WriterVersion},
27+
file::{
28+
metadata::KeyValue,
29+
properties::{EnabledStatistics, WriterProperties, WriterVersion},
30+
},
2531
schema::types::ColumnPath,
2632
};
2733

@@ -47,53 +53,87 @@ impl TryFrom<&TableParquetOptions> for ParquetWriterOptions {
4753
type Error = DataFusionError;
4854

4955
fn try_from(parquet_options: &TableParquetOptions) -> Result<Self> {
50-
let parquet_session_options = &parquet_options.global;
51-
let mut builder = WriterProperties::builder()
52-
.set_data_page_size_limit(parquet_session_options.data_pagesize_limit)
53-
.set_write_batch_size(parquet_session_options.write_batch_size)
54-
.set_writer_version(parse_version_string(
55-
&parquet_session_options.writer_version,
56-
)?)
57-
.set_dictionary_page_size_limit(
58-
parquet_session_options.dictionary_page_size_limit,
59-
)
60-
.set_max_row_group_size(parquet_session_options.max_row_group_size)
61-
.set_created_by(parquet_session_options.created_by.clone())
62-
.set_column_index_truncate_length(
63-
parquet_session_options.column_index_truncate_length,
56+
let ParquetOptions {
57+
data_pagesize_limit,
58+
write_batch_size,
59+
writer_version,
60+
dictionary_page_size_limit,
61+
max_row_group_size,
62+
created_by,
63+
column_index_truncate_length,
64+
data_page_row_count_limit,
65+
bloom_filter_enabled,
66+
encoding,
67+
dictionary_enabled,
68+
compression,
69+
statistics_enabled,
70+
max_statistics_size,
71+
bloom_filter_fpp,
72+
bloom_filter_ndv,
73+
// below is not part of ParquetWriterOptions
74+
enable_page_index: _,
75+
pruning: _,
76+
skip_metadata: _,
77+
metadata_size_hint: _,
78+
pushdown_filters: _,
79+
reorder_filters: _,
80+
allow_single_file_parallelism: _,
81+
maximum_parallel_row_group_writers: _,
82+
maximum_buffered_record_batches_per_stream: _,
83+
} = &parquet_options.global;
84+
85+
let key_value_metadata = if !parquet_options.key_value_metadata.is_empty() {
86+
Some(
87+
parquet_options
88+
.key_value_metadata
89+
.clone()
90+
.drain()
91+
.map(|(key, value)| KeyValue { key, value })
92+
.collect::<Vec<_>>(),
6493
)
65-
.set_data_page_row_count_limit(
66-
parquet_session_options.data_page_row_count_limit,
67-
)
68-
.set_bloom_filter_enabled(parquet_session_options.bloom_filter_enabled);
94+
} else {
95+
None
96+
};
6997

70-
if let Some(encoding) = &parquet_session_options.encoding {
98+
let mut builder = WriterProperties::builder()
99+
.set_data_page_size_limit(*data_pagesize_limit)
100+
.set_write_batch_size(*write_batch_size)
101+
.set_writer_version(parse_version_string(writer_version.as_str())?)
102+
.set_dictionary_page_size_limit(*dictionary_page_size_limit)
103+
.set_max_row_group_size(*max_row_group_size)
104+
.set_created_by(created_by.clone())
105+
.set_column_index_truncate_length(*column_index_truncate_length)
106+
.set_data_page_row_count_limit(*data_page_row_count_limit)
107+
.set_bloom_filter_enabled(*bloom_filter_enabled)
108+
.set_key_value_metadata(key_value_metadata);
109+
110+
if let Some(encoding) = &encoding {
71111
builder = builder.set_encoding(parse_encoding_string(encoding)?);
72112
}
73113

74-
if let Some(enabled) = parquet_session_options.dictionary_enabled {
75-
builder = builder.set_dictionary_enabled(enabled);
114+
if let Some(enabled) = dictionary_enabled {
115+
builder = builder.set_dictionary_enabled(*enabled);
76116
}
77117

78-
if let Some(compression) = &parquet_session_options.compression {
118+
if let Some(compression) = &compression {
79119
builder = builder.set_compression(parse_compression_string(compression)?);
80120
}
81121

82-
if let Some(statistics) = &parquet_session_options.statistics_enabled {
122+
if let Some(statistics) = &statistics_enabled {
83123
builder =
84124
builder.set_statistics_enabled(parse_statistics_string(statistics)?);
85125
}
86126

87-
if let Some(size) = parquet_session_options.max_statistics_size {
88-
builder = builder.set_max_statistics_size(size);
127+
if let Some(size) = max_statistics_size {
128+
builder = builder.set_max_statistics_size(*size);
89129
}
90130

91-
if let Some(fpp) = parquet_session_options.bloom_filter_fpp {
92-
builder = builder.set_bloom_filter_fpp(fpp);
131+
if let Some(fpp) = bloom_filter_fpp {
132+
builder = builder.set_bloom_filter_fpp(*fpp);
93133
}
94134

95-
if let Some(ndv) = parquet_session_options.bloom_filter_ndv {
96-
builder = builder.set_bloom_filter_ndv(ndv);
135+
if let Some(ndv) = bloom_filter_ndv {
136+
builder = builder.set_bloom_filter_ndv(*ndv);
97137
}
98138

99139
for (column, options) in &parquet_options.column_specific_options {
@@ -141,6 +181,8 @@ impl TryFrom<&TableParquetOptions> for ParquetWriterOptions {
141181
builder.set_column_max_statistics_size(path, max_statistics_size);
142182
}
143183
}
184+
185+
// ParquetWriterOptions will have defaults for the remaining fields (e.g. sorting_columns)
144186
Ok(ParquetWriterOptions {
145187
writer_options: builder.build(),
146188
})

datafusion/core/src/datasource/file_format/parquet.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,7 @@ mod tests {
11361136
};
11371137
use parquet::arrow::arrow_reader::ArrowReaderOptions;
11381138
use parquet::arrow::ParquetRecordBatchStreamBuilder;
1139-
use parquet::file::metadata::{ParquetColumnIndex, ParquetOffsetIndex};
1139+
use parquet::file::metadata::{KeyValue, ParquetColumnIndex, ParquetOffsetIndex};
11401140
use parquet::file::page_index::index::Index;
11411141
use tokio::fs::File;
11421142
use tokio::io::AsyncWrite;
@@ -1865,7 +1865,13 @@ mod tests {
18651865
};
18661866
let parquet_sink = Arc::new(ParquetSink::new(
18671867
file_sink_config,
1868-
TableParquetOptions::default(),
1868+
TableParquetOptions {
1869+
key_value_metadata: std::collections::HashMap::from([
1870+
("my-data".to_string(), Some("stuff".to_string())),
1871+
("my-data-bool-key".to_string(), None),
1872+
]),
1873+
..Default::default()
1874+
},
18691875
));
18701876

18711877
// create data
@@ -1899,7 +1905,10 @@ mod tests {
18991905
let (
19001906
path,
19011907
FileMetaData {
1902-
num_rows, schema, ..
1908+
num_rows,
1909+
schema,
1910+
key_value_metadata,
1911+
..
19031912
},
19041913
) = written.take(1).next().unwrap();
19051914
let path_parts = path.parts().collect::<Vec<_>>();
@@ -1915,6 +1924,20 @@ mod tests {
19151924
"output file metadata should contain col b"
19161925
);
19171926

1927+
let mut key_value_metadata = key_value_metadata.unwrap();
1928+
key_value_metadata.sort_by(|a, b| a.key.cmp(&b.key));
1929+
let expected_metadata = vec![
1930+
KeyValue {
1931+
key: "my-data".to_string(),
1932+
value: Some("stuff".to_string()),
1933+
},
1934+
KeyValue {
1935+
key: "my-data-bool-key".to_string(),
1936+
value: None,
1937+
},
1938+
];
1939+
assert_eq!(key_value_metadata, expected_metadata);
1940+
19181941
Ok(())
19191942
}
19201943

datafusion/proto/src/physical_plan/from_proto.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@ impl TryFrom<&protobuf::TableParquetOptions> for TableParquetOptions {
970970
.unwrap()
971971
.unwrap(),
972972
column_specific_options,
973+
key_value_metadata: Default::default(),
973974
})
974975
}
975976
}

datafusion/sqllogictest/test_files/copy.slt

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,73 @@ OPTIONS (
283283
'format.statistics_enabled::col2' none,
284284
'format.max_statistics_size' 123,
285285
'format.bloom_filter_fpp' 0.001,
286-
'format.bloom_filter_ndv' 100
286+
'format.bloom_filter_ndv' 100,
287+
'format.metadata::key' 'value'
287288
)
288289
----
289290
2
290291

292+
# valid vs invalid metadata
293+
294+
# accepts map with a single entry
295+
statement ok
296+
COPY source_table
297+
TO 'test_files/scratch/copy/table_with_metadata/'
298+
STORED AS PARQUET
299+
OPTIONS (
300+
'format.metadata::key' 'value'
301+
)
302+
303+
# accepts multiple entries (on different keys)
304+
statement ok
305+
COPY source_table
306+
TO 'test_files/scratch/copy/table_with_metadata/'
307+
STORED AS PARQUET
308+
OPTIONS (
309+
'format.metadata::key1' '',
310+
'format.metadata::key2' 'value',
311+
'format.metadata::key3' 'value with spaces',
312+
'format.metadata::key4' 'value with special chars :: :'
313+
)
314+
315+
# accepts multiple entries with the same key (will overwrite)
316+
statement ok
317+
COPY source_table
318+
TO 'test_files/scratch/copy/table_with_metadata/'
319+
STORED AS PARQUET
320+
OPTIONS (
321+
'format.metadata::key1' 'value',
322+
'format.metadata::key1' 'value'
323+
)
324+
325+
# errors if key is missing
326+
statement error DataFusion error: Invalid or Unsupported Configuration: Invalid metadata key provided, missing key in metadata::<key>
327+
COPY source_table
328+
TO 'test_files/scratch/copy/table_with_metadata/'
329+
STORED AS PARQUET
330+
OPTIONS (
331+
'format.metadata::' 'value'
332+
)
333+
334+
# errors if key contains internal '::'
335+
statement error DataFusion error: Invalid or Unsupported Configuration: Invalid metadata key provided, found too many '::' in "metadata::key::extra"
336+
COPY source_table
337+
TO 'test_files/scratch/copy/table_with_metadata/'
338+
STORED AS PARQUET
339+
OPTIONS (
340+
'format.metadata::key::extra' 'value'
341+
)
342+
343+
# errors for invalid property (not stating `format.metadata`)
344+
statement error DataFusion error: Invalid or Unsupported Configuration: Config value "wrong-metadata" not found on ColumnOptions
345+
COPY source_table
346+
TO 'test_files/scratch/copy/table_with_metadata/'
347+
STORED AS PARQUET
348+
OPTIONS (
349+
'format.wrong-metadata::key' 'value'
350+
)
351+
352+
291353
# validate multiple parquet file output with all options set
292354
statement ok
293355
CREATE EXTERNAL TABLE validate_parquet_with_options STORED AS PARQUET LOCATION 'test_files/scratch/copy/table_with_options/';

0 commit comments

Comments
 (0)