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