laminar_connectors/lakehouse/iceberg_config/
table_definition.rs1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::str::FromStr;
4
5use serde::Deserialize;
6
7use crate::config::ConnectorConfig;
8use crate::error::ConnectorError;
9
10use super::validate_property_map_bounds;
11
12pub(crate) const TARGET_FILE_SIZE_PROPERTY: &str = "write.target-file-size-bytes";
13pub(crate) const PARQUET_ROW_GROUP_SIZE_PROPERTY: &str = "write.parquet.row-group-size-bytes";
14pub(crate) const PARQUET_COMPRESSION_PROPERTY: &str = "write.parquet.compression-codec";
15const MAX_TABLE_DEFINITION_FIELDS: usize = 128;
16const MAX_TABLE_DEFINITION_BYTES: usize = 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum IcebergTransform {
21 Identity,
23 Bucket(u32),
25 Truncate(u32),
27 Year,
29 Month,
31 Day,
33 Hour,
35 Void,
37}
38
39impl FromStr for IcebergTransform {
40 type Err = String;
41
42 fn from_str(value: &str) -> Result<Self, Self::Err> {
43 let value = value.trim().to_ascii_lowercase();
44 match value.as_str() {
45 "identity" => Ok(Self::Identity),
46 "year" => Ok(Self::Year),
47 "month" => Ok(Self::Month),
48 "day" => Ok(Self::Day),
49 "hour" => Ok(Self::Hour),
50 "void" => Ok(Self::Void),
51 _ => parameterized_transform(&value, "bucket", Self::Bucket)
52 .or_else(|| parameterized_transform(&value, "truncate", Self::Truncate))
53 .ok_or_else(|| format!("unsupported Iceberg transform '{value}'")),
54 }
55 }
56}
57
58impl<'de> Deserialize<'de> for IcebergTransform {
59 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
60 where
61 D: serde::Deserializer<'de>,
62 {
63 String::deserialize(deserializer)?
64 .parse()
65 .map_err(serde::de::Error::custom)
66 }
67}
68
69impl fmt::Display for IcebergTransform {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::Identity => formatter.write_str("identity"),
73 Self::Bucket(count) => write!(formatter, "bucket[{count}]"),
74 Self::Truncate(width) => write!(formatter, "truncate[{width}]"),
75 Self::Year => formatter.write_str("year"),
76 Self::Month => formatter.write_str("month"),
77 Self::Day => formatter.write_str("day"),
78 Self::Hour => formatter.write_str("hour"),
79 Self::Void => formatter.write_str("void"),
80 }
81 }
82}
83
84fn parameterized_transform(
85 value: &str,
86 name: &str,
87 constructor: impl FnOnce(u32) -> IcebergTransform,
88) -> Option<IcebergTransform> {
89 let parameter = value
90 .strip_prefix(name)?
91 .strip_prefix('[')?
92 .strip_suffix(']')?;
93 let parameter = parameter.parse::<u32>().ok().filter(|value| *value > 0)?;
94 Some(constructor(parameter))
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct IcebergPartitionField {
101 pub source: String,
103 pub name: String,
105 pub transform: IcebergTransform,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum IcebergSortDirection {
113 Asc,
115 Desc,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
121#[serde(rename_all = "kebab-case")]
122pub enum IcebergNullOrder {
123 NullsFirst,
125 NullsLast,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct IcebergSortField {
133 pub source: String,
135 pub transform: IcebergTransform,
137 pub direction: IcebergSortDirection,
139 pub null_order: IcebergNullOrder,
141}
142
143pub(crate) fn parse_parquet_compression(value: &str) -> Result<String, ConnectorError> {
144 match value.trim().to_ascii_lowercase().as_str() {
145 "zstd" => Ok("zstd".into()),
146 "snappy" => Ok("snappy".into()),
147 "lz4" => Ok("lz4".into()),
148 "none" | "uncompressed" => Ok("uncompressed".into()),
149 other => Err(ConnectorError::ConfigurationError(format!(
150 "unsupported parquet.compression '{other}'; expected zstd, snappy, lz4, or uncompressed"
151 ))),
152 }
153}
154
155pub(crate) fn parse_table_fields<T>(
156 config: &ConnectorConfig,
157 key: &str,
158) -> Result<Vec<T>, ConnectorError>
159where
160 T: serde::de::DeserializeOwned,
161{
162 let Some(value) = config.get(key) else {
163 return Ok(Vec::new());
164 };
165 if value.trim().is_empty() {
166 return Ok(Vec::new());
167 }
168 if value.len() > MAX_TABLE_DEFINITION_BYTES {
169 return Err(ConnectorError::ConfigurationError(format!(
170 "{key} is {} bytes; the limit is {MAX_TABLE_DEFINITION_BYTES}",
171 value.len()
172 )));
173 }
174 let fields = serde_json::from_str::<Vec<T>>(value).map_err(|error| {
175 ConnectorError::ConfigurationError(format!("invalid {key} JSON: {error}"))
176 })?;
177 if fields.len() > MAX_TABLE_DEFINITION_FIELDS {
178 return Err(ConnectorError::ConfigurationError(format!(
179 "{key} contains {} fields; the limit is {MAX_TABLE_DEFINITION_FIELDS}",
180 fields.len()
181 )));
182 }
183 Ok(fields)
184}
185
186pub(crate) fn validate_distinct_names(values: &[String], key: &str) -> Result<(), ConnectorError> {
187 if values.len() > MAX_TABLE_DEFINITION_FIELDS {
188 return Err(ConnectorError::ConfigurationError(format!(
189 "{key} contains {} fields; the limit is {MAX_TABLE_DEFINITION_FIELDS}",
190 values.len()
191 )));
192 }
193 let mut names = HashSet::with_capacity(values.len());
194 for value in values {
195 if value.is_empty() || value.trim() != value || !names.insert(value) {
196 return Err(ConnectorError::ConfigurationError(format!(
197 "{key} contains an empty, whitespace-padded, or duplicate field name"
198 )));
199 }
200 }
201 Ok(())
202}
203
204pub(crate) fn validate_table_definition(
205 partition_spec: &[IcebergPartitionField],
206 sort_order: &[IcebergSortField],
207) -> Result<(), ConnectorError> {
208 let mut partition_names = HashSet::with_capacity(partition_spec.len());
209 let mut partition_sources = HashSet::with_capacity(partition_spec.len());
210 for field in partition_spec {
211 if field.source.trim() != field.source
212 || field.name.trim() != field.name
213 || field.source.is_empty()
214 || field.name.is_empty()
215 || !partition_names.insert(field.name.as_str())
216 || !partition_sources.insert((field.source.as_str(), field.transform))
217 {
218 return Err(ConnectorError::ConfigurationError(
219 "partition.spec contains an empty, whitespace-padded, or duplicate field".into(),
220 ));
221 }
222 }
223 let mut sort_sources = HashSet::with_capacity(sort_order.len());
224 for field in sort_order {
225 if field.source.trim() != field.source
226 || field.source.is_empty()
227 || !sort_sources.insert((field.source.as_str(), field.transform))
228 {
229 return Err(ConnectorError::ConfigurationError(
230 "sort.order contains an empty, whitespace-padded, or duplicate field".into(),
231 ));
232 }
233 }
234 Ok(())
235}
236
237pub(crate) fn validate_persisted_properties(
238 properties: &HashMap<String, String>,
239) -> Result<(), ConnectorError> {
240 validate_property_map_bounds("table.property.*", properties)?;
241 if let Some(key) = properties.keys().find(|key| {
242 matches!(
243 key.as_str(),
244 TARGET_FILE_SIZE_PROPERTY
245 | PARQUET_ROW_GROUP_SIZE_PROPERTY
246 | PARQUET_COMPRESSION_PROPERTY
247 )
248 }) {
249 return Err(ConnectorError::ConfigurationError(format!(
250 "table.property.{key} duplicates a typed writer option"
251 )));
252 }
253 if let Some(key) = properties.keys().find(|key| {
254 let normalized = key.to_ascii_lowercase().replace(['.', '-'], "_");
255 crate::security::is_secret_option_key(key)
256 || normalized.contains("access_key")
257 || normalized.contains("service_account")
258 }) {
259 return Err(ConnectorError::ConfigurationError(format!(
260 "table.property.{key} cannot persist credential material in Iceberg metadata"
261 )));
262 }
263 if let Some((key, _)) = properties
264 .iter()
265 .find(|(_, value)| crate::security::value_contains_uri_secret(value, false))
266 {
267 return Err(ConnectorError::ConfigurationError(format!(
268 "table.property.{key} contains credentials in a URI and cannot be persisted"
269 )));
270 }
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn parameterized_transforms_require_positive_values() {
280 assert_eq!("bucket[32]".parse(), Ok(IcebergTransform::Bucket(32)));
281 assert_eq!("truncate[8]".parse(), Ok(IcebergTransform::Truncate(8)));
282 assert!("bucket[0]".parse::<IcebergTransform>().is_err());
283 assert!("bucket(16)".parse::<IcebergTransform>().is_err());
284 }
285
286 #[test]
287 fn metadata_identifiers_are_not_confused_with_credential_material() {
288 let properties = HashMap::from([
289 ("tenant_id".to_string(), "tenant-reference".to_string()),
290 ("client_id".to_string(), "client-reference".to_string()),
291 ("profile".to_string(), "analytics".to_string()),
292 ]);
293 validate_persisted_properties(&properties).unwrap();
294 }
295
296 #[test]
297 fn access_keys_remain_forbidden_in_table_metadata() {
298 for key in ["access-key-id", "aws_access_key_id", "secret_access_key"] {
299 let properties = HashMap::from([(key.to_string(), "credential".to_string())]);
300 assert!(validate_persisted_properties(&properties).is_err(), "{key}");
301 }
302 }
303
304 #[test]
305 fn service_account_material_remains_forbidden_in_table_metadata() {
306 for key in [
307 "service_account",
308 "service-account-json",
309 "gcs.service_account_path",
310 ] {
311 let properties =
312 HashMap::from([(key.to_string(), "private-credential-value".to_string())]);
313 let error = validate_persisted_properties(&properties).unwrap_err();
314 assert!(error.to_string().contains(key), "{key}: {error}");
315 assert!(!error.to_string().contains("private-credential-value"));
316 }
317 }
318}