1use std::collections::HashMap;
19use std::fmt::Display;
20use std::str::FromStr;
21
22use crate::compression::CompressionCodec;
23use crate::error::{Error, ErrorKind, Result};
24use crate::util::location::strip_trailing_slash;
25
26fn parse_property<T: FromStr>(
27 properties: &HashMap<String, String>,
28 key: &str,
29 default: T,
30) -> Result<T>
31where
32 <T as FromStr>::Err: Display,
33{
34 properties.get(key).map_or(Ok(default), |value| {
35 value.parse::<T>().map_err(|e| {
36 Error::new(
37 ErrorKind::DataInvalid,
38 format!("Invalid value for {key}: {e}"),
39 )
40 })
41 })
42}
43
44fn parse_optional_property<T: FromStr>(
47 properties: &HashMap<String, String>,
48 key: &str,
49) -> Result<Option<T>>
50where
51 <T as FromStr>::Err: Display,
52{
53 properties
54 .get(key)
55 .map(|value| {
56 value.parse::<T>().map_err(|e| {
57 Error::new(
58 ErrorKind::DataInvalid,
59 format!("Invalid value for {key}: {e}"),
60 )
61 })
62 })
63 .transpose()
64}
65
66fn parse_location_property(
67 properties: &HashMap<String, String>,
68 key: &str,
69) -> Result<Option<String>> {
70 properties
71 .get(key)
72 .map(|path| {
73 if path.is_empty() {
74 return Err(Error::new(
75 ErrorKind::DataInvalid,
76 format!("Invalid value for {key}: path must not be empty"),
77 ));
78 }
79
80 Ok(strip_trailing_slash(path).to_string())
81 })
82 .transpose()
83}
84
85pub(crate) fn parse_metadata_file_compression(
98 properties: &HashMap<String, String>,
99) -> Result<CompressionCodec> {
100 let value = properties
101 .get(TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC)
102 .map(|s| s.as_str())
103 .unwrap_or(TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC_DEFAULT);
104
105 if value.is_empty() {
107 return Ok(CompressionCodec::None);
108 }
109
110 let lowercase_value = value.to_lowercase();
112
113 let codec: CompressionCodec = serde_json::from_value(serde_json::Value::String(
115 lowercase_value,
116 ))
117 .map_err(|_| {
118 Error::new(
119 ErrorKind::DataInvalid,
120 format!(
121 "Invalid metadata compression codec: {value}. Only '{}' and '{}' are supported.",
122 CompressionCodec::None.name(),
123 CompressionCodec::gzip_default().name()
124 ),
125 )
126 })?;
127
128 match codec {
130 CompressionCodec::None | CompressionCodec::Gzip(_) => Ok(codec),
131 _ => Err(Error::new(
132 ErrorKind::DataInvalid,
133 format!(
134 "Invalid metadata compression codec: {value}. Only '{}' and '{}' are supported for metadata files.",
135 CompressionCodec::None.name(),
136 CompressionCodec::gzip_default().name()
137 ),
138 )),
139 }
140}
141
142fn parse_parquet_compression(properties: &HashMap<String, String>) -> Result<CompressionCodec> {
146 let value = properties
147 .get(TableProperties::PROPERTY_PARQUET_COMPRESSION_CODEC)
148 .map(|s| s.as_str())
149 .unwrap_or(TableProperties::PROPERTY_PARQUET_COMPRESSION_CODEC_DEFAULT);
150
151 let codec: CompressionCodec =
152 serde_json::from_value(serde_json::Value::String(value.to_lowercase())).map_err(|_| {
153 Error::new(
154 ErrorKind::DataInvalid,
155 format!(
156 "Invalid Parquet compression codec: {value}. Supported codecs: \
157 uncompressed, snappy, gzip, lzo, brotli, lz4, lz4_raw, zstd"
158 ),
159 )
160 })?;
161
162 let level: Option<u8> = parse_optional_property(
163 properties,
164 TableProperties::PROPERTY_PARQUET_COMPRESSION_LEVEL,
165 )?;
166
167 Ok(match (codec, level) {
168 (CompressionCodec::Zstd(_), Some(level)) => CompressionCodec::Zstd(level),
169 (CompressionCodec::Gzip(_), Some(level)) => CompressionCodec::Gzip(level),
170 (CompressionCodec::Brotli(_), Some(level)) => CompressionCodec::Brotli(level),
171 (codec, _) => codec,
172 })
173}
174
175fn parse_property_bool(
179 properties: &HashMap<String, String>,
180 key: &str,
181 default: bool,
182) -> Result<bool> {
183 properties.get(key).map_or(Ok(default), |value| {
184 value.to_lowercase().parse::<bool>().map_err(|e| {
185 Error::new(
186 ErrorKind::DataInvalid,
187 format!("Invalid value for {key}: {e}"),
188 )
189 })
190 })
191}
192
193#[derive(Debug)]
195pub struct TableProperties {
196 pub commit_num_retries: usize,
198 pub commit_min_retry_wait_ms: u64,
200 pub commit_max_retry_wait_ms: u64,
202 pub commit_total_retry_timeout_ms: u64,
204 pub write_format_default: String,
206 pub write_target_file_size_bytes: usize,
208 pub write_metadata_path: Option<String>,
211 pub metadata_compression_codec: CompressionCodec,
213 pub write_datafusion_fanout_enabled: bool,
215 pub gc_enabled: bool,
218 pub max_snapshot_age_ms: i64,
220 pub min_snapshots_to_keep: usize,
222 pub max_ref_age_ms: i64,
224 pub cdc_enabled: bool,
227 pub cdc_min_chunk_size: usize,
229 pub cdc_max_chunk_size: usize,
231 pub cdc_norm_level: i32,
233 pub parquet_compression_codec: CompressionCodec,
237 pub parquet_row_group_size_bytes: usize,
239 pub parquet_page_size_bytes: usize,
241 pub parquet_page_row_limit: usize,
243 pub parquet_dict_size_bytes: usize,
245 pub encryption_key_id: Option<String>,
248 pub encryption_data_key_length: usize,
250 pub write_data_location: Option<String>,
252 pub write_folder_storage_location: Option<String>,
257 pub write_object_storage_location: Option<String>,
262 pub write_object_storage_partitioned_paths: bool,
264}
265
266impl TableProperties {
267 pub const PROPERTY_FORMAT_VERSION: &str = "format-version";
277 pub const PROPERTY_UUID: &str = "uuid";
279 pub const PROPERTY_SNAPSHOT_COUNT: &str = "snapshot-count";
281 pub const PROPERTY_CURRENT_SNAPSHOT_SUMMARY: &str = "current-snapshot-summary";
283 pub const PROPERTY_CURRENT_SNAPSHOT_ID: &str = "current-snapshot-id";
285 pub const PROPERTY_CURRENT_SNAPSHOT_TIMESTAMP: &str = "current-snapshot-timestamp-ms";
287 pub const PROPERTY_CURRENT_SCHEMA: &str = "current-schema";
289 pub const PROPERTY_DEFAULT_PARTITION_SPEC: &str = "default-partition-spec";
291 pub const PROPERTY_DEFAULT_SORT_ORDER: &str = "default-sort-order";
293
294 pub const PROPERTY_METADATA_PREVIOUS_VERSIONS_MAX: &str =
296 "write.metadata.previous-versions-max";
297 pub const PROPERTY_METADATA_PREVIOUS_VERSIONS_MAX_DEFAULT: usize = 100;
299
300 pub const PROPERTY_WRITE_PARTITION_SUMMARY_LIMIT: &str = "write.summary.partition-limit";
302 pub const PROPERTY_WRITE_PARTITION_SUMMARY_LIMIT_DEFAULT: u64 = 0;
304
305 pub const RESERVED_PROPERTIES: [&str; 9] = [
310 Self::PROPERTY_FORMAT_VERSION,
311 Self::PROPERTY_UUID,
312 Self::PROPERTY_SNAPSHOT_COUNT,
313 Self::PROPERTY_CURRENT_SNAPSHOT_ID,
314 Self::PROPERTY_CURRENT_SNAPSHOT_SUMMARY,
315 Self::PROPERTY_CURRENT_SNAPSHOT_TIMESTAMP,
316 Self::PROPERTY_CURRENT_SCHEMA,
317 Self::PROPERTY_DEFAULT_PARTITION_SPEC,
318 Self::PROPERTY_DEFAULT_SORT_ORDER,
319 ];
320
321 pub const PROPERTY_COMMIT_NUM_RETRIES: &str = "commit.retry.num-retries";
323 pub const PROPERTY_COMMIT_NUM_RETRIES_DEFAULT: usize = 4;
325
326 pub const PROPERTY_COMMIT_MIN_RETRY_WAIT_MS: &str = "commit.retry.min-wait-ms";
328 pub const PROPERTY_COMMIT_MIN_RETRY_WAIT_MS_DEFAULT: u64 = 100;
330
331 pub const PROPERTY_COMMIT_MAX_RETRY_WAIT_MS: &str = "commit.retry.max-wait-ms";
333 pub const PROPERTY_COMMIT_MAX_RETRY_WAIT_MS_DEFAULT: u64 = 60 * 1000; pub const PROPERTY_COMMIT_TOTAL_RETRY_TIME_MS: &str = "commit.retry.total-timeout-ms";
338 pub const PROPERTY_COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT: u64 = 30 * 60 * 1000; pub const PROPERTY_DEFAULT_FILE_FORMAT: &str = "write.format.default";
343 pub const PROPERTY_DELETE_DEFAULT_FILE_FORMAT: &str = "write.delete.format.default";
345 pub const PROPERTY_DEFAULT_FILE_FORMAT_DEFAULT: &str = "parquet";
347
348 pub const PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES: &str = "write.target-file-size-bytes";
350 pub const PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT: usize = 512 * 1024 * 1024; pub const PROPERTY_WRITE_METADATA_PATH: &str = "write.metadata.path";
357
358 pub const PROPERTY_METADATA_COMPRESSION_CODEC: &str = "write.metadata.compression-codec";
360 pub const PROPERTY_METADATA_COMPRESSION_CODEC_DEFAULT: &str = "none";
362 pub const PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED: &str = "write.datafusion.fanout.enabled";
365 pub const PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED_DEFAULT: bool = true;
367
368 pub const PROPERTY_GC_ENABLED: &str = "gc.enabled";
372 pub const PROPERTY_GC_ENABLED_DEFAULT: bool = true;
374
375 pub const PROPERTY_MAX_SNAPSHOT_AGE_MS: &str = "history.expire.max-snapshot-age-ms";
377 pub const PROPERTY_MAX_SNAPSHOT_AGE_MS_DEFAULT: i64 = 5 * 24 * 60 * 60 * 1000;
379 pub const PROPERTY_MIN_SNAPSHOTS_TO_KEEP: &str = "history.expire.min-snapshots-to-keep";
381 pub const PROPERTY_MIN_SNAPSHOTS_TO_KEEP_DEFAULT: usize = 1;
383 pub const PROPERTY_MAX_REF_AGE_MS: &str = "history.expire.max-ref-age-ms";
385 pub const PROPERTY_MAX_REF_AGE_MS_DEFAULT: i64 = i64::MAX;
387
388 pub const PROPERTY_PARQUET_CDC_ENABLED: &str = "write.parquet.content-defined-chunking.enabled";
390 pub const PROPERTY_PARQUET_CDC_ENABLED_DEFAULT: bool = false;
392 pub const PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE: &str =
394 "write.parquet.content-defined-chunking.min-chunk-size";
395 pub const PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT: usize = 256 * 1024;
397 pub const PROPERTY_PARQUET_CDC_MAX_CHUNK_SIZE: &str =
399 "write.parquet.content-defined-chunking.max-chunk-size";
400 pub const PROPERTY_PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT: usize = 1024 * 1024;
402 pub const PROPERTY_PARQUET_CDC_NORM_LEVEL: &str =
404 "write.parquet.content-defined-chunking.norm-level";
405 pub const PROPERTY_PARQUET_CDC_NORM_LEVEL_DEFAULT: i32 = 0;
407
408 pub const PROPERTY_PARQUET_COMPRESSION_CODEC: &str = "write.parquet.compression-codec";
413 pub const PROPERTY_PARQUET_COMPRESSION_CODEC_DEFAULT: &str = "zstd";
415 pub const PROPERTY_PARQUET_COMPRESSION_LEVEL: &str = "write.parquet.compression-level";
418
419 pub const PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES: &str = "write.parquet.row-group-size-bytes";
421 pub const PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT: usize = 128 * 1024 * 1024;
423
424 pub const PROPERTY_PARQUET_PAGE_SIZE_BYTES: &str = "write.parquet.page-size-bytes";
426 pub const PROPERTY_PARQUET_PAGE_SIZE_BYTES_DEFAULT: usize = 1024 * 1024;
428
429 pub const PROPERTY_PARQUET_PAGE_ROW_LIMIT: &str = "write.parquet.page-row-limit";
431 pub const PROPERTY_PARQUET_PAGE_ROW_LIMIT_DEFAULT: usize = 20000;
433
434 pub const PROPERTY_PARQUET_DICT_SIZE_BYTES: &str = "write.parquet.dict-size-bytes";
436 pub const PROPERTY_PARQUET_DICT_SIZE_BYTES_DEFAULT: usize = 2 * 1024 * 1024;
438
439 pub const PROPERTY_ENCRYPTION_KEY_ID: &str = "encryption.key-id";
442
443 pub const PROPERTY_ENCRYPTION_DATA_KEY_LENGTH: &str = "encryption.data-key-length";
445 pub const PROPERTY_ENCRYPTION_DATA_KEY_LENGTH_DEFAULT: usize = 16;
447 pub const PROPERTY_WRITE_DATA_LOCATION: &str = "write.data.path";
449 pub const PROPERTY_WRITE_FOLDER_STORAGE_LOCATION: &str = "write.folder-storage.path";
451 pub const PROPERTY_WRITE_OBJECT_STORAGE_LOCATION: &str = "write.object-storage.path";
453 pub const PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS: &str =
455 "write.object-storage.partitioned-paths";
456 pub const PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT: bool = true;
458}
459
460impl TryFrom<&HashMap<String, String>> for TableProperties {
461 type Error = Error;
463
464 fn try_from(props: &HashMap<String, String>) -> Result<Self> {
465 Ok(TableProperties {
466 commit_num_retries: parse_property(
467 props,
468 TableProperties::PROPERTY_COMMIT_NUM_RETRIES,
469 TableProperties::PROPERTY_COMMIT_NUM_RETRIES_DEFAULT,
470 )?,
471 commit_min_retry_wait_ms: parse_property(
472 props,
473 TableProperties::PROPERTY_COMMIT_MIN_RETRY_WAIT_MS,
474 TableProperties::PROPERTY_COMMIT_MIN_RETRY_WAIT_MS_DEFAULT,
475 )?,
476 commit_max_retry_wait_ms: parse_property(
477 props,
478 TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS,
479 TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS_DEFAULT,
480 )?,
481 commit_total_retry_timeout_ms: parse_property(
482 props,
483 TableProperties::PROPERTY_COMMIT_TOTAL_RETRY_TIME_MS,
484 TableProperties::PROPERTY_COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT,
485 )?,
486 write_format_default: parse_property(
487 props,
488 TableProperties::PROPERTY_DEFAULT_FILE_FORMAT,
489 TableProperties::PROPERTY_DEFAULT_FILE_FORMAT_DEFAULT.to_string(),
490 )?,
491 write_target_file_size_bytes: parse_property(
492 props,
493 TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES,
494 TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT,
495 )?,
496 write_metadata_path: parse_location_property(
497 props,
498 TableProperties::PROPERTY_WRITE_METADATA_PATH,
499 )?,
500 metadata_compression_codec: parse_metadata_file_compression(props)?,
501 write_datafusion_fanout_enabled: parse_property_bool(
502 props,
503 TableProperties::PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED,
504 TableProperties::PROPERTY_DATAFUSION_WRITE_FANOUT_ENABLED_DEFAULT,
505 )?,
506 gc_enabled: parse_property_bool(
507 props,
508 TableProperties::PROPERTY_GC_ENABLED,
509 TableProperties::PROPERTY_GC_ENABLED_DEFAULT,
510 )?,
511 max_snapshot_age_ms: parse_property(
512 props,
513 TableProperties::PROPERTY_MAX_SNAPSHOT_AGE_MS,
514 TableProperties::PROPERTY_MAX_SNAPSHOT_AGE_MS_DEFAULT,
515 )?,
516 min_snapshots_to_keep: parse_property(
517 props,
518 TableProperties::PROPERTY_MIN_SNAPSHOTS_TO_KEEP,
519 TableProperties::PROPERTY_MIN_SNAPSHOTS_TO_KEEP_DEFAULT,
520 )?,
521 max_ref_age_ms: parse_property(
522 props,
523 TableProperties::PROPERTY_MAX_REF_AGE_MS,
524 TableProperties::PROPERTY_MAX_REF_AGE_MS_DEFAULT,
525 )?,
526 cdc_enabled: parse_property_bool(
527 props,
528 TableProperties::PROPERTY_PARQUET_CDC_ENABLED,
529 TableProperties::PROPERTY_PARQUET_CDC_ENABLED_DEFAULT,
530 )?,
531 cdc_min_chunk_size: parse_property(
532 props,
533 TableProperties::PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE,
534 TableProperties::PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
535 )?,
536 cdc_max_chunk_size: parse_property(
537 props,
538 TableProperties::PROPERTY_PARQUET_CDC_MAX_CHUNK_SIZE,
539 TableProperties::PROPERTY_PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
540 )?,
541 cdc_norm_level: parse_property(
542 props,
543 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL,
544 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL_DEFAULT,
545 )?,
546 parquet_compression_codec: parse_parquet_compression(props)?,
547 parquet_row_group_size_bytes: parse_property(
548 props,
549 TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES,
550 TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT,
551 )?,
552 parquet_page_size_bytes: parse_property(
553 props,
554 TableProperties::PROPERTY_PARQUET_PAGE_SIZE_BYTES,
555 TableProperties::PROPERTY_PARQUET_PAGE_SIZE_BYTES_DEFAULT,
556 )?,
557 parquet_page_row_limit: parse_property(
558 props,
559 TableProperties::PROPERTY_PARQUET_PAGE_ROW_LIMIT,
560 TableProperties::PROPERTY_PARQUET_PAGE_ROW_LIMIT_DEFAULT,
561 )?,
562 parquet_dict_size_bytes: parse_property(
563 props,
564 TableProperties::PROPERTY_PARQUET_DICT_SIZE_BYTES,
565 TableProperties::PROPERTY_PARQUET_DICT_SIZE_BYTES_DEFAULT,
566 )?,
567 encryption_key_id: props
568 .get(TableProperties::PROPERTY_ENCRYPTION_KEY_ID)
569 .cloned(),
570 encryption_data_key_length: parse_property(
571 props,
572 TableProperties::PROPERTY_ENCRYPTION_DATA_KEY_LENGTH,
573 TableProperties::PROPERTY_ENCRYPTION_DATA_KEY_LENGTH_DEFAULT,
574 )?,
575 write_data_location: props
576 .get(TableProperties::PROPERTY_WRITE_DATA_LOCATION)
577 .cloned(),
578 write_folder_storage_location: props
579 .get(TableProperties::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION)
580 .cloned(),
581 write_object_storage_location: props
582 .get(TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_LOCATION)
583 .cloned(),
584 write_object_storage_partitioned_paths: parse_property_bool(
585 props,
586 TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS,
587 TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT,
588 )?,
589 })
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::compression::CompressionCodec;
597
598 #[test]
599 fn test_table_properties_default() {
600 let props = HashMap::new();
601 let table_properties = TableProperties::try_from(&props).unwrap();
602 assert_eq!(
603 table_properties.commit_num_retries,
604 TableProperties::PROPERTY_COMMIT_NUM_RETRIES_DEFAULT
605 );
606 assert_eq!(
607 table_properties.commit_min_retry_wait_ms,
608 TableProperties::PROPERTY_COMMIT_MIN_RETRY_WAIT_MS_DEFAULT
609 );
610 assert_eq!(
611 table_properties.commit_max_retry_wait_ms,
612 TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS_DEFAULT
613 );
614 assert_eq!(
615 table_properties.write_format_default,
616 TableProperties::PROPERTY_DEFAULT_FILE_FORMAT_DEFAULT.to_string()
617 );
618 assert_eq!(
619 table_properties.write_target_file_size_bytes,
620 TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT
621 );
622 assert_eq!(
624 table_properties.metadata_compression_codec,
625 CompressionCodec::None
626 );
627 assert_eq!(
628 table_properties.gc_enabled,
629 TableProperties::PROPERTY_GC_ENABLED_DEFAULT
630 );
631 assert_eq!(
632 table_properties.max_snapshot_age_ms,
633 TableProperties::PROPERTY_MAX_SNAPSHOT_AGE_MS_DEFAULT
634 );
635 assert_eq!(
636 table_properties.min_snapshots_to_keep,
637 TableProperties::PROPERTY_MIN_SNAPSHOTS_TO_KEEP_DEFAULT
638 );
639 assert_eq!(
640 table_properties.max_ref_age_ms,
641 TableProperties::PROPERTY_MAX_REF_AGE_MS_DEFAULT
642 );
643 }
644
645 #[test]
646 fn test_table_properties_history_expire_overrides() {
647 let props = HashMap::from([
648 (
649 TableProperties::PROPERTY_MAX_SNAPSHOT_AGE_MS.to_string(),
650 "1234".to_string(),
651 ),
652 (
653 TableProperties::PROPERTY_MIN_SNAPSHOTS_TO_KEEP.to_string(),
654 "7".to_string(),
655 ),
656 (
657 TableProperties::PROPERTY_MAX_REF_AGE_MS.to_string(),
658 "5678".to_string(),
659 ),
660 ]);
661 let table_properties = TableProperties::try_from(&props).unwrap();
662 assert_eq!(table_properties.max_snapshot_age_ms, 1234);
663 assert_eq!(table_properties.min_snapshots_to_keep, 7);
664 assert_eq!(table_properties.max_ref_age_ms, 5678);
665 }
666
667 #[test]
668 fn test_table_properties_write_metadata_path() {
669 let table_properties = TableProperties::try_from(&HashMap::new()).unwrap();
671 assert_eq!(table_properties.write_metadata_path, None);
672
673 let props = HashMap::from([(
675 TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
676 String::new(),
677 )]);
678 let error = TableProperties::try_from(&props).unwrap_err();
679 assert_eq!(error.kind(), ErrorKind::DataInvalid);
680 assert!(
681 error
682 .message()
683 .contains(TableProperties::PROPERTY_WRITE_METADATA_PATH)
684 );
685
686 let props = HashMap::from([(
687 TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
688 "s3://other-bucket/custom-meta/".to_string(),
689 )]);
690 let table_properties = TableProperties::try_from(&props).unwrap();
691 assert_eq!(
692 table_properties.write_metadata_path.as_deref(),
693 Some("s3://other-bucket/custom-meta")
694 );
695 }
696
697 #[test]
698 fn test_table_properties_compression() {
699 let props = HashMap::from([(
700 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
701 "gzip".to_string(),
702 )]);
703 let table_properties = TableProperties::try_from(&props).unwrap();
704 assert_eq!(
705 table_properties.metadata_compression_codec,
706 CompressionCodec::gzip_default()
707 );
708 }
709
710 #[test]
711 fn test_table_properties_compression_none() {
712 let props = HashMap::from([(
713 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
714 "none".to_string(),
715 )]);
716 let table_properties = TableProperties::try_from(&props).unwrap();
717 assert_eq!(
718 table_properties.metadata_compression_codec,
719 CompressionCodec::None
720 );
721 }
722
723 #[test]
724 fn test_table_properties_compression_case_insensitive() {
725 let props_upper = HashMap::from([(
727 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
728 "GZIP".to_string(),
729 )]);
730 let table_properties = TableProperties::try_from(&props_upper).unwrap();
731 assert_eq!(
732 table_properties.metadata_compression_codec,
733 CompressionCodec::gzip_default()
734 );
735
736 let props_mixed = HashMap::from([(
738 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
739 "GzIp".to_string(),
740 )]);
741 let table_properties = TableProperties::try_from(&props_mixed).unwrap();
742 assert_eq!(
743 table_properties.metadata_compression_codec,
744 CompressionCodec::gzip_default()
745 );
746
747 let props_none_upper = HashMap::from([(
749 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
750 "NONE".to_string(),
751 )]);
752 let table_properties = TableProperties::try_from(&props_none_upper).unwrap();
753 assert_eq!(
754 table_properties.metadata_compression_codec,
755 CompressionCodec::None
756 );
757 }
758
759 #[test]
760 fn test_table_properties_valid() {
761 let props = HashMap::from([
762 (
763 TableProperties::PROPERTY_COMMIT_NUM_RETRIES.to_string(),
764 "10".to_string(),
765 ),
766 (
767 TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS.to_string(),
768 "20".to_string(),
769 ),
770 (
771 TableProperties::PROPERTY_DEFAULT_FILE_FORMAT.to_string(),
772 "avro".to_string(),
773 ),
774 (
775 TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
776 "512".to_string(),
777 ),
778 (
779 TableProperties::PROPERTY_GC_ENABLED.to_string(),
780 "false".to_string(),
781 ),
782 ]);
783 let table_properties = TableProperties::try_from(&props).unwrap();
784 assert_eq!(table_properties.commit_num_retries, 10);
785 assert_eq!(table_properties.commit_max_retry_wait_ms, 20);
786 assert_eq!(table_properties.write_format_default, "avro".to_string());
787 assert_eq!(table_properties.write_target_file_size_bytes, 512);
788 assert!(!table_properties.gc_enabled);
789 }
790
791 #[test]
792 fn test_table_properties_invalid() {
793 let invalid_retries = HashMap::from([(
794 TableProperties::PROPERTY_COMMIT_NUM_RETRIES.to_string(),
795 "abc".to_string(),
796 )]);
797
798 let table_properties = TableProperties::try_from(&invalid_retries).unwrap_err();
799 assert!(
800 table_properties.to_string().contains(
801 "Invalid value for commit.retry.num-retries: invalid digit found in string"
802 )
803 );
804
805 let invalid_min_wait = HashMap::from([(
806 TableProperties::PROPERTY_COMMIT_MIN_RETRY_WAIT_MS.to_string(),
807 "abc".to_string(),
808 )]);
809 let table_properties = TableProperties::try_from(&invalid_min_wait).unwrap_err();
810 assert!(
811 table_properties.to_string().contains(
812 "Invalid value for commit.retry.min-wait-ms: invalid digit found in string"
813 )
814 );
815
816 let invalid_max_wait = HashMap::from([(
817 TableProperties::PROPERTY_COMMIT_MAX_RETRY_WAIT_MS.to_string(),
818 "abc".to_string(),
819 )]);
820 let table_properties = TableProperties::try_from(&invalid_max_wait).unwrap_err();
821 assert!(
822 table_properties.to_string().contains(
823 "Invalid value for commit.retry.max-wait-ms: invalid digit found in string"
824 )
825 );
826
827 let invalid_target_size = HashMap::from([(
828 TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
829 "abc".to_string(),
830 )]);
831 let table_properties = TableProperties::try_from(&invalid_target_size).unwrap_err();
832 assert!(table_properties.to_string().contains(
833 "Invalid value for write.target-file-size-bytes: invalid digit found in string"
834 ));
835
836 let invalid_gc_enabled = HashMap::from([(
837 TableProperties::PROPERTY_GC_ENABLED.to_string(),
838 "notabool".to_string(),
839 )]);
840 let table_properties = TableProperties::try_from(&invalid_gc_enabled).unwrap_err();
841 assert!(
842 table_properties
843 .to_string()
844 .contains("Invalid value for gc.enabled")
845 );
846 }
847
848 #[test]
849 fn test_table_properties_compression_invalid_rejected() {
850 let invalid_codecs = ["lz4", "zstd", "snappy"];
851
852 for codec in invalid_codecs {
853 let props = HashMap::from([(
854 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
855 codec.to_string(),
856 )]);
857 let err = TableProperties::try_from(&props).unwrap_err();
858 let err_msg = err.to_string();
859 assert!(
860 err_msg.contains(&format!("Invalid metadata compression codec: {codec}")),
861 "Expected error message to contain codec '{codec}', got: {err_msg}"
862 );
863 assert!(
864 err_msg.contains("Only 'none' and 'gzip' are supported"),
865 "Expected error message to contain supported codecs, got: {err_msg}"
866 );
867 }
868 }
869
870 #[test]
871 fn test_parse_metadata_file_compression_valid() {
872 let props = HashMap::from([(
874 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
875 "none".to_string(),
876 )]);
877 assert_eq!(
878 parse_metadata_file_compression(&props).unwrap(),
879 CompressionCodec::None
880 );
881
882 let props = HashMap::from([(
884 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
885 "".to_string(),
886 )]);
887 assert_eq!(
888 parse_metadata_file_compression(&props).unwrap(),
889 CompressionCodec::None
890 );
891
892 let props = HashMap::from([(
894 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
895 "gzip".to_string(),
896 )]);
897 assert_eq!(
898 parse_metadata_file_compression(&props).unwrap(),
899 CompressionCodec::gzip_default()
900 );
901
902 let props = HashMap::from([(
904 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
905 "NONE".to_string(),
906 )]);
907 assert_eq!(
908 parse_metadata_file_compression(&props).unwrap(),
909 CompressionCodec::None
910 );
911
912 let props = HashMap::from([(
914 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
915 "GZIP".to_string(),
916 )]);
917 assert_eq!(
918 parse_metadata_file_compression(&props).unwrap(),
919 CompressionCodec::gzip_default()
920 );
921
922 let props = HashMap::from([(
924 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
925 "GzIp".to_string(),
926 )]);
927 assert_eq!(
928 parse_metadata_file_compression(&props).unwrap(),
929 CompressionCodec::gzip_default()
930 );
931
932 let props = HashMap::new();
934 assert_eq!(
935 parse_metadata_file_compression(&props).unwrap(),
936 CompressionCodec::None
937 );
938 }
939
940 #[test]
941 fn test_parse_metadata_file_compression_invalid() {
942 let invalid_codecs = ["lz4", "zstd", "snappy"];
943
944 for codec in invalid_codecs {
945 let props = HashMap::from([(
946 TableProperties::PROPERTY_METADATA_COMPRESSION_CODEC.to_string(),
947 codec.to_string(),
948 )]);
949 let err = parse_metadata_file_compression(&props).unwrap_err();
950 let err_msg = err.to_string();
951 assert!(
952 err_msg.contains("Invalid metadata compression codec"),
953 "Expected error message to contain 'Invalid metadata compression codec', got: {err_msg}"
954 );
955 assert!(
956 err_msg.contains("Only 'none' and 'gzip' are supported"),
957 "Expected error message to contain supported codecs, got: {err_msg}"
958 );
959 }
960 }
961
962 #[test]
963 fn test_cdc_disabled_by_default() {
964 let props = HashMap::new();
965 let tp = TableProperties::try_from(&props).unwrap();
966 assert!(!tp.cdc_enabled);
967 }
968
969 #[test]
970 fn test_cdc_enabled_via_flag() {
971 let props = HashMap::from([(
972 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
973 "true".to_string(),
974 )]);
975 let tp = TableProperties::try_from(&props).unwrap();
976 assert!(tp.cdc_enabled);
977 assert_eq!(tp.cdc_min_chunk_size, 256 * 1024);
978 assert_eq!(tp.cdc_max_chunk_size, 1024 * 1024);
979 assert_eq!(tp.cdc_norm_level, 0);
980 }
981
982 #[test]
983 fn test_cdc_size_props_alone_do_not_enable() {
984 let props = HashMap::from([(
985 TableProperties::PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE.to_string(),
986 "262144".to_string(),
987 )]);
988 let tp = TableProperties::try_from(&props).unwrap();
989 assert!(!tp.cdc_enabled);
990 }
991
992 #[test]
993 fn test_cdc_custom_values() {
994 let props = HashMap::from([
995 (
996 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
997 "true".to_string(),
998 ),
999 (
1000 TableProperties::PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE.to_string(),
1001 "200000".to_string(),
1002 ),
1003 (
1004 TableProperties::PROPERTY_PARQUET_CDC_MAX_CHUNK_SIZE.to_string(),
1005 "900000".to_string(),
1006 ),
1007 (
1008 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL.to_string(),
1009 "1".to_string(),
1010 ),
1011 ]);
1012 let tp = TableProperties::try_from(&props).unwrap();
1013 assert!(tp.cdc_enabled);
1014 assert_eq!(tp.cdc_min_chunk_size, 200000);
1015 assert_eq!(tp.cdc_max_chunk_size, 900000);
1016 assert_eq!(tp.cdc_norm_level, 1);
1017 }
1018
1019 #[test]
1020 fn test_cdc_partial_override() {
1021 let props = HashMap::from([
1022 (
1023 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
1024 "true".to_string(),
1025 ),
1026 (
1027 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL.to_string(),
1028 "2".to_string(),
1029 ),
1030 ]);
1031 let tp = TableProperties::try_from(&props).unwrap();
1032 assert!(tp.cdc_enabled);
1033 assert_eq!(tp.cdc_min_chunk_size, 256 * 1024);
1034 assert_eq!(tp.cdc_max_chunk_size, 1024 * 1024);
1035 assert_eq!(tp.cdc_norm_level, 2);
1036 }
1037
1038 #[test]
1039 fn test_cdc_negative_norm_level() {
1040 let props = HashMap::from([
1041 (
1042 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
1043 "true".to_string(),
1044 ),
1045 (
1046 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL.to_string(),
1047 "-2".to_string(),
1048 ),
1049 ]);
1050 let tp = TableProperties::try_from(&props).unwrap();
1051 assert_eq!(tp.cdc_norm_level, -2);
1052 }
1053
1054 #[test]
1055 fn test_cdc_invalid_min_chunk_size() {
1056 let props = HashMap::from([
1057 (
1058 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
1059 "true".to_string(),
1060 ),
1061 (
1062 TableProperties::PROPERTY_PARQUET_CDC_MIN_CHUNK_SIZE.to_string(),
1063 "not_a_number".to_string(),
1064 ),
1065 ]);
1066 let err = TableProperties::try_from(&props).unwrap_err();
1067 assert!(
1068 err.to_string().contains(
1069 "Invalid value for write.parquet.content-defined-chunking.min-chunk-size"
1070 )
1071 );
1072 }
1073
1074 #[test]
1075 fn test_cdc_invalid_norm_level() {
1076 let props = HashMap::from([
1077 (
1078 TableProperties::PROPERTY_PARQUET_CDC_ENABLED.to_string(),
1079 "true".to_string(),
1080 ),
1081 (
1082 TableProperties::PROPERTY_PARQUET_CDC_NORM_LEVEL.to_string(),
1083 "not_a_number".to_string(),
1084 ),
1085 ]);
1086 let err = TableProperties::try_from(&props).unwrap_err();
1087 assert!(
1088 err.to_string()
1089 .contains("Invalid value for write.parquet.content-defined-chunking.norm-level")
1090 );
1091 }
1092
1093 #[test]
1094 fn test_cdc_no_properties() {
1095 let props = HashMap::from([("some.other.property".to_string(), "value".to_string())]);
1096 let tp = TableProperties::try_from(&props).unwrap();
1097 assert!(!tp.cdc_enabled);
1098 }
1099
1100 #[test]
1101 fn test_parquet_sizing_defaults() {
1102 let tp = TableProperties::try_from(&HashMap::new()).unwrap();
1103 assert_eq!(
1105 tp.parquet_compression_codec,
1106 CompressionCodec::zstd_default()
1107 );
1108 assert_eq!(
1109 tp.parquet_row_group_size_bytes,
1110 TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT
1111 );
1112 assert_eq!(
1113 tp.parquet_page_size_bytes,
1114 TableProperties::PROPERTY_PARQUET_PAGE_SIZE_BYTES_DEFAULT
1115 );
1116 assert_eq!(
1117 tp.parquet_page_row_limit,
1118 TableProperties::PROPERTY_PARQUET_PAGE_ROW_LIMIT_DEFAULT
1119 );
1120 assert_eq!(
1121 tp.parquet_dict_size_bytes,
1122 TableProperties::PROPERTY_PARQUET_DICT_SIZE_BYTES_DEFAULT
1123 );
1124 }
1125
1126 #[test]
1127 fn test_parquet_sizing_overrides() {
1128 let props = HashMap::from([
1129 (
1130 TableProperties::PROPERTY_PARQUET_COMPRESSION_CODEC.to_string(),
1131 "gzip".to_string(),
1132 ),
1133 (
1134 TableProperties::PROPERTY_PARQUET_COMPRESSION_LEVEL.to_string(),
1135 "4".to_string(),
1136 ),
1137 (
1138 TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES.to_string(),
1139 "1048576".to_string(),
1140 ),
1141 (
1142 TableProperties::PROPERTY_PARQUET_PAGE_SIZE_BYTES.to_string(),
1143 "65536".to_string(),
1144 ),
1145 (
1146 TableProperties::PROPERTY_PARQUET_PAGE_ROW_LIMIT.to_string(),
1147 "5000".to_string(),
1148 ),
1149 (
1150 TableProperties::PROPERTY_PARQUET_DICT_SIZE_BYTES.to_string(),
1151 "131072".to_string(),
1152 ),
1153 ]);
1154 let tp = TableProperties::try_from(&props).unwrap();
1155 assert_eq!(tp.parquet_compression_codec, CompressionCodec::Gzip(4));
1157 assert_eq!(tp.parquet_row_group_size_bytes, 1048576);
1158 assert_eq!(tp.parquet_page_size_bytes, 65536);
1159 assert_eq!(tp.parquet_page_row_limit, 5000);
1160 assert_eq!(tp.parquet_dict_size_bytes, 131072);
1161 }
1162
1163 #[test]
1164 fn test_parquet_invalid_sizing_rejected() {
1165 let props = HashMap::from([(
1166 TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES.to_string(),
1167 "not_a_number".to_string(),
1168 )]);
1169 let err = TableProperties::try_from(&props).unwrap_err();
1170 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1171 assert!(
1172 err.to_string()
1173 .contains(TableProperties::PROPERTY_PARQUET_ROW_GROUP_SIZE_BYTES)
1174 );
1175 }
1176
1177 #[test]
1178 fn test_parquet_all_codecs_parse() {
1179 for (name, expected) in [
1182 ("uncompressed", CompressionCodec::None),
1183 ("snappy", CompressionCodec::Snappy),
1184 ("gzip", CompressionCodec::gzip_default()),
1185 ("lzo", CompressionCodec::Lzo),
1186 ("brotli", CompressionCodec::brotli_default()),
1187 ("lz4", CompressionCodec::Lz4),
1188 ("lz4_raw", CompressionCodec::Lz4Raw),
1189 ("zstd", CompressionCodec::zstd_default()),
1190 ] {
1191 let props = HashMap::from([(
1192 TableProperties::PROPERTY_PARQUET_COMPRESSION_CODEC.to_string(),
1193 name.to_string(),
1194 )]);
1195 let tp = TableProperties::try_from(&props).unwrap();
1196 assert_eq!(tp.parquet_compression_codec, expected, "codec {name}");
1197 }
1198 }
1199
1200 #[test]
1201 fn test_parquet_compression_level_ignored_for_levelless_codec() {
1202 let props = HashMap::from([
1205 (
1206 TableProperties::PROPERTY_PARQUET_COMPRESSION_CODEC.to_string(),
1207 "snappy".to_string(),
1208 ),
1209 (
1210 TableProperties::PROPERTY_PARQUET_COMPRESSION_LEVEL.to_string(),
1211 "5".to_string(),
1212 ),
1213 ]);
1214 let tp = TableProperties::try_from(&props).unwrap();
1215 assert_eq!(tp.parquet_compression_codec, CompressionCodec::Snappy);
1216 }
1217
1218 #[test]
1219 fn test_parse_boolean_property_case_insensitive() {
1220 let false_variants = ["False", "FALSE"];
1221 let true_variants = ["True", "TRUE"];
1222
1223 for f in false_variants {
1224 let props = HashMap::from([(
1225 TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
1226 f.to_string(),
1227 )]);
1228 let tp = TableProperties::try_from(&props).unwrap();
1229 assert!(!tp.write_object_storage_partitioned_paths);
1230 }
1231
1232 for t in true_variants {
1233 let props = HashMap::from([(
1234 TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS.to_string(),
1235 t.to_string(),
1236 )]);
1237 let tp = TableProperties::try_from(&props).unwrap();
1238 assert!(tp.write_object_storage_partitioned_paths);
1239 }
1240 }
1241}