1use std::collections::HashMap;
19use std::fmt::Display;
20use std::str::FromStr;
21
22use uuid::Uuid;
23
24use crate::compression::CompressionCodec;
25use crate::spec::{TableMetadata, parse_metadata_file_compression};
26use crate::{Error, ErrorKind, Result};
27
28pub(crate) const METADATA_FOLDER_NAME: &str = "metadata";
31
32#[derive(Clone, Debug, PartialEq)]
38pub struct MetadataLocation {
39 location: String,
40 version: i32,
41 id: Uuid,
42 compression_codec: CompressionCodec,
43}
44
45impl MetadataLocation {
46 fn compression_from_properties(properties: &HashMap<String, String>) -> CompressionCodec {
49 parse_metadata_file_compression(properties).unwrap_or(CompressionCodec::None)
50 }
51
52 pub fn try_new_with_metadata(metadata: &TableMetadata) -> Result<Self> {
57 Ok(Self {
58 location: metadata.metadata_location()?,
59 version: 0,
60 id: Uuid::new_v4(),
61 compression_codec: Self::compression_from_properties(metadata.properties()),
62 })
63 }
64
65 pub fn with_next_version(&self) -> Self {
68 Self {
69 location: self.location.clone(),
70 version: self.version + 1,
71 id: Uuid::new_v4(),
72 compression_codec: self.compression_codec,
73 }
74 }
75
76 pub fn try_with_new_metadata(&self, new_metadata: &TableMetadata) -> Result<Self> {
79 Ok(Self {
80 location: new_metadata.metadata_location()?,
81 version: self.version,
82 id: self.id,
83 compression_codec: Self::compression_from_properties(new_metadata.properties()),
84 })
85 }
86
87 pub fn compression_codec(&self) -> CompressionCodec {
89 self.compression_codec
90 }
91
92 fn parse_file_name(file_name: &str) -> Result<(i32, Uuid, CompressionCodec)> {
96 let stripped = file_name.strip_suffix(".metadata.json").ok_or(Error::new(
97 ErrorKind::Unexpected,
98 format!("Invalid metadata file ending: {file_name}"),
99 ))?;
100
101 let gzip_suffix = CompressionCodec::gzip_default().suffix()?;
103 let (stripped, compression_codec) = if let Some(s) = stripped.strip_suffix(gzip_suffix) {
104 (s, CompressionCodec::gzip_default())
105 } else {
106 (stripped, CompressionCodec::None)
107 };
108
109 let (version, id) = stripped.split_once('-').ok_or(Error::new(
110 ErrorKind::Unexpected,
111 format!("Invalid metadata file name format: {file_name}"),
112 ))?;
113
114 Ok((
115 version.parse::<i32>()?,
116 Uuid::parse_str(id)?,
117 compression_codec,
118 ))
119 }
120}
121
122impl Display for MetadataLocation {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 let suffix = self.compression_codec.suffix().unwrap_or("");
125 write!(
126 f,
127 "{}/{:0>5}-{}{}.metadata.json",
128 self.location, self.version, self.id, suffix
129 )
130 }
131}
132
133impl FromStr for MetadataLocation {
134 type Err = Error;
135
136 fn from_str(s: &str) -> Result<Self> {
137 let (location, file_name) = s.rsplit_once('/').ok_or(Error::new(
138 ErrorKind::Unexpected,
139 format!("Invalid metadata location: {s}"),
140 ))?;
141
142 let (version, id, compression_codec) = Self::parse_file_name(file_name)?;
143
144 Ok(MetadataLocation {
145 location: location.to_string(),
146 version,
147 id,
148 compression_codec,
149 })
150 }
151}
152
153#[cfg(test)]
154mod test {
155 use std::collections::HashMap;
156 use std::str::FromStr;
157
158 use uuid::Uuid;
159
160 use crate::compression::CompressionCodec;
161 use crate::spec::{Schema, TableMetadata, TableMetadataBuilder, TableProperties};
162 use crate::{MetadataLocation, TableCreation};
163
164 fn create_test_metadata(properties: HashMap<String, String>) -> TableMetadata {
165 let table_creation = TableCreation::builder()
166 .name("test_table".to_string())
167 .location("/test/table".to_string())
168 .schema(Schema::builder().build().unwrap())
169 .properties(properties)
170 .build();
171 TableMetadataBuilder::from_table_creation(table_creation)
172 .unwrap()
173 .build()
174 .unwrap()
175 .metadata
176 }
177
178 #[test]
179 fn test_metadata_location_from_string() {
180 let test_cases = vec![
181 (
183 "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
184 Ok(MetadataLocation {
185 location: "/metadata".to_string(),
186 version: 1234567,
187 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
188 compression_codec: CompressionCodec::None,
189 }),
190 ),
191 (
193 "/abc/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
194 Ok(MetadataLocation {
195 location: "/abc/metadata".to_string(),
196 version: 1234567,
197 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
198 compression_codec: CompressionCodec::None,
199 }),
200 ),
201 (
203 "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
204 Ok(MetadataLocation {
205 location: "/abc/def/metadata".to_string(),
206 version: 1234567,
207 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
208 compression_codec: CompressionCodec::None,
209 }),
210 ),
211 (
213 "https://127.0.0.1/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
214 Ok(MetadataLocation {
215 location: "https://127.0.0.1/metadata".to_string(),
216 version: 1234567,
217 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
218 compression_codec: CompressionCodec::None,
219 }),
220 ),
221 (
223 "/abc/metadata/1234567-81056704-ce5b-41c4-bb83-eb6408081af6.metadata.json",
224 Ok(MetadataLocation {
225 location: "/abc/metadata".to_string(),
226 version: 1234567,
227 id: Uuid::from_str("81056704-ce5b-41c4-bb83-eb6408081af6").unwrap(),
228 compression_codec: CompressionCodec::None,
229 }),
230 ),
231 (
233 "/abc/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
234 Ok(MetadataLocation {
235 location: "/abc/metadata".to_string(),
236 version: 0,
237 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
238 compression_codec: CompressionCodec::None,
239 }),
240 ),
241 (
243 "/abc/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.gz.metadata.json",
244 Ok(MetadataLocation {
245 location: "/abc/metadata".to_string(),
246 version: 1234567,
247 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
248 compression_codec: CompressionCodec::gzip_default(),
249 }),
250 ),
251 (
253 "/metadata/-123-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
254 Err("".to_string()),
255 ),
256 (
258 "/metadata/1234567-no-valid-id.metadata.json",
259 Err("".to_string()),
260 ),
261 (
263 "/metadata/noversion-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
264 Err("".to_string()),
265 ),
266 (
268 "/wrongsubdir/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
269 Ok(MetadataLocation {
270 location: "/wrongsubdir".to_string(),
271 version: 1234567,
272 id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
273 compression_codec: CompressionCodec::None,
274 }),
275 ),
276 (
278 "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata",
279 Err("".to_string()),
280 ),
281 (
282 "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.wrong.file",
283 Err("".to_string()),
284 ),
285 ];
286
287 for (input, expected) in test_cases {
288 match MetadataLocation::from_str(input) {
289 Ok(metadata_location) => {
290 assert!(expected.is_ok());
291 assert_eq!(metadata_location, expected.unwrap());
292 }
293 Err(_) => assert!(expected.is_err()),
294 }
295 }
296 }
297
298 #[test]
299 fn test_metadata_location_with_next_version() {
300 let metadata = create_test_metadata(HashMap::new());
301 let test_cases = vec![
302 MetadataLocation::try_new_with_metadata(&metadata).unwrap(),
303 MetadataLocation::from_str(
304 "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
305 )
306 .unwrap(),
307 ];
308
309 for input in test_cases {
310 let next = MetadataLocation::from_str(&input.to_string())
311 .unwrap()
312 .with_next_version();
313 assert_eq!(next.location, input.location);
314 assert_eq!(next.version, input.version + 1);
315 assert_ne!(next.id, input.id);
316 }
317 }
318
319 #[test]
320 fn test_with_next_version_preserves_compression() {
321 let location_none = MetadataLocation::from_str(
323 "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
324 )
325 .unwrap();
326 assert_eq!(location_none.compression_codec, CompressionCodec::None);
327
328 let next_none = location_none.with_next_version();
329 assert_eq!(next_none.compression_codec, CompressionCodec::None);
330 assert_eq!(next_none.version, 1);
331
332 let location_gzip = MetadataLocation::from_str(
334 "/test/table/metadata/00005-81056704-ce5b-41c4-bb83-eb6408081af6.gz.metadata.json",
335 )
336 .unwrap();
337 assert_eq!(
338 location_gzip.compression_codec,
339 CompressionCodec::gzip_default()
340 );
341
342 let next_gzip = location_gzip.with_next_version();
343 assert_eq!(
344 next_gzip.compression_codec,
345 CompressionCodec::gzip_default()
346 );
347 assert_eq!(next_gzip.version, 6);
348 }
349
350 #[test]
351 fn test_with_new_metadata_updates_compression() {
352 let location = MetadataLocation::from_str(
354 "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
355 )
356 .unwrap();
357 assert_eq!(location.compression_codec, CompressionCodec::None);
358
359 let mut props_gzip = HashMap::new();
361 props_gzip.insert(
362 "write.metadata.compression-codec".to_string(),
363 "gzip".to_string(),
364 );
365 let metadata_gzip = create_test_metadata(props_gzip);
366 let updated_gzip = location.try_with_new_metadata(&metadata_gzip).unwrap();
367 assert_eq!(
368 updated_gzip.compression_codec,
369 CompressionCodec::gzip_default()
370 );
371 assert_eq!(updated_gzip.version, 0);
372 assert_eq!(
373 updated_gzip.to_string(),
374 "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.gz.metadata.json"
375 );
376
377 let props_none = HashMap::new();
379 let metadata_none = create_test_metadata(props_none);
380 let updated_none = updated_gzip.try_with_new_metadata(&metadata_none).unwrap();
381 assert_eq!(updated_none.compression_codec, CompressionCodec::None);
382 assert_eq!(updated_none.version, 0);
383 assert_eq!(
384 updated_none.to_string(),
385 "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json"
386 );
387
388 let mut props_explicit_none = HashMap::new();
390 props_explicit_none.insert(
391 "write.metadata.compression-codec".to_string(),
392 "none".to_string(),
393 );
394 let metadata_explicit_none = create_test_metadata(props_explicit_none);
395 let updated_explicit = updated_gzip
396 .try_with_new_metadata(&metadata_explicit_none)
397 .unwrap();
398 assert_eq!(updated_explicit.compression_codec, CompressionCodec::None);
399 assert_eq!(
400 updated_explicit.to_string(),
401 "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json"
402 );
403 }
404
405 #[test]
406 fn test_with_new_metadata_re_derives_location() {
407 let location = MetadataLocation::from_str(
409 "/old/table/metadata/00003-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
410 )
411 .unwrap();
412
413 let relocated = create_test_metadata(HashMap::new());
416 let updated = location.try_with_new_metadata(&relocated).unwrap();
417 assert!(
418 updated.to_string().starts_with("/test/table/metadata/"),
419 "unexpected location: {updated}"
420 );
421 assert_eq!(updated.version, location.version);
422 assert_eq!(updated.id, location.id);
423
424 let props = HashMap::from([(
426 TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
427 "s3://bucket/custom-meta".to_string(),
428 )]);
429 let with_meta_path = create_test_metadata(props);
430 let updated = location.try_with_new_metadata(&with_meta_path).unwrap();
431 assert!(
432 updated.to_string().starts_with("s3://bucket/custom-meta/"),
433 "unexpected location: {updated}"
434 );
435 }
436
437 #[test]
438 fn test_new_with_metadata_honors_write_metadata_path() {
439 let default_meta = create_test_metadata(HashMap::new());
441 let default_loc = MetadataLocation::try_new_with_metadata(&default_meta).unwrap();
442 assert!(
443 default_loc
444 .to_string()
445 .starts_with("/test/table/metadata/00000-"),
446 "unexpected location: {default_loc}"
447 );
448
449 let props = HashMap::from([(
451 TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
452 "s3://bucket/custom-meta".to_string(),
453 )]);
454 let custom_meta = create_test_metadata(props);
455 let custom_loc = MetadataLocation::try_new_with_metadata(&custom_meta).unwrap();
456 assert!(
457 custom_loc
458 .to_string()
459 .starts_with("s3://bucket/custom-meta/00000-"),
460 "unexpected location: {custom_loc}"
461 );
462 assert!(custom_loc.to_string().ends_with(".metadata.json"));
463 }
464}