iceberg/catalog/
metadata_location.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::fmt::Display;
19use std::str::FromStr;
20
21use uuid::Uuid;
22
23use crate::{Error, ErrorKind, Result};
24
25/// Helper for parsing a location of the format: `<location>/metadata/<version>-<uuid>.metadata.json`
26#[derive(Clone, Debug, PartialEq)]
27pub struct MetadataLocation {
28    table_location: String,
29    version: i32,
30    id: Uuid,
31}
32
33impl MetadataLocation {
34    /// Creates a completely new metadata location starting at version 0.
35    /// Only used for creating a new table. For updates, see `with_next_version`.
36    pub fn new_with_table_location(table_location: impl ToString) -> Self {
37        Self {
38            table_location: table_location.to_string(),
39            version: 0,
40            id: Uuid::new_v4(),
41        }
42    }
43
44    /// Creates a new metadata location for an updated metadata file.
45    pub fn with_next_version(&self) -> Self {
46        Self {
47            table_location: self.table_location.clone(),
48            version: self.version + 1,
49            id: Uuid::new_v4(),
50        }
51    }
52
53    fn parse_metadata_path_prefix(path: &str) -> Result<String> {
54        let prefix = path.strip_suffix("/metadata").ok_or(Error::new(
55            ErrorKind::Unexpected,
56            format!("Metadata location not under \"/metadata\" subdirectory: {path}"),
57        ))?;
58
59        Ok(prefix.to_string())
60    }
61
62    /// Parses a file name of the format `<version>-<uuid>.metadata.json`.
63    fn parse_file_name(file_name: &str) -> Result<(i32, Uuid)> {
64        let (version, id) = file_name
65            .strip_suffix(".metadata.json")
66            .ok_or(Error::new(
67                ErrorKind::Unexpected,
68                format!("Invalid metadata file ending: {file_name}"),
69            ))?
70            .split_once('-')
71            .ok_or(Error::new(
72                ErrorKind::Unexpected,
73                format!("Invalid metadata file name format: {file_name}"),
74            ))?;
75
76        Ok((version.parse::<i32>()?, Uuid::parse_str(id)?))
77    }
78}
79
80impl Display for MetadataLocation {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(
83            f,
84            "{}/metadata/{:0>5}-{}.metadata.json",
85            self.table_location, self.version, self.id
86        )
87    }
88}
89
90impl FromStr for MetadataLocation {
91    type Err = Error;
92
93    fn from_str(s: &str) -> Result<Self> {
94        let (path, file_name) = s.rsplit_once('/').ok_or(Error::new(
95            ErrorKind::Unexpected,
96            format!("Invalid metadata location: {s}"),
97        ))?;
98
99        let prefix = Self::parse_metadata_path_prefix(path)?;
100        let (version, id) = Self::parse_file_name(file_name)?;
101
102        Ok(MetadataLocation {
103            table_location: prefix,
104            version,
105            id,
106        })
107    }
108}
109
110#[cfg(test)]
111mod test {
112    use std::str::FromStr;
113
114    use uuid::Uuid;
115
116    use crate::MetadataLocation;
117
118    #[test]
119    fn test_metadata_location_from_string() {
120        let test_cases = vec![
121            // No prefix
122            (
123                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
124                Ok(MetadataLocation {
125                    table_location: "".to_string(),
126                    version: 1234567,
127                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
128                }),
129            ),
130            // Some prefix
131            (
132                "/abc/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
133                Ok(MetadataLocation {
134                    table_location: "/abc".to_string(),
135                    version: 1234567,
136                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
137                }),
138            ),
139            // Longer prefix
140            (
141                "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
142                Ok(MetadataLocation {
143                    table_location: "/abc/def".to_string(),
144                    version: 1234567,
145                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
146                }),
147            ),
148            // Prefix with special characters
149            (
150                "https://127.0.0.1/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
151                Ok(MetadataLocation {
152                    table_location: "https://127.0.0.1".to_string(),
153                    version: 1234567,
154                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
155                }),
156            ),
157            // Another id
158            (
159                "/abc/metadata/1234567-81056704-ce5b-41c4-bb83-eb6408081af6.metadata.json",
160                Ok(MetadataLocation {
161                    table_location: "/abc".to_string(),
162                    version: 1234567,
163                    id: Uuid::from_str("81056704-ce5b-41c4-bb83-eb6408081af6").unwrap(),
164                }),
165            ),
166            // Version 0
167            (
168                "/abc/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
169                Ok(MetadataLocation {
170                    table_location: "/abc".to_string(),
171                    version: 0,
172                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
173                }),
174            ),
175            // Negative version
176            (
177                "/metadata/-123-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
178                Err("".to_string()),
179            ),
180            // Invalid uuid
181            (
182                "/metadata/1234567-no-valid-id.metadata.json",
183                Err("".to_string()),
184            ),
185            // Non-numeric version
186            (
187                "/metadata/noversion-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
188                Err("".to_string()),
189            ),
190            // No /metadata subdirectory
191            (
192                "/wrongsubdir/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
193                Err("".to_string()),
194            ),
195            // No .metadata.json suffix
196            (
197                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata",
198                Err("".to_string()),
199            ),
200            (
201                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.wrong.file",
202                Err("".to_string()),
203            ),
204        ];
205
206        for (input, expected) in test_cases {
207            match MetadataLocation::from_str(input) {
208                Ok(metadata_location) => {
209                    assert!(expected.is_ok());
210                    assert_eq!(metadata_location, expected.unwrap());
211                }
212                Err(_) => assert!(expected.is_err()),
213            }
214        }
215    }
216
217    #[test]
218    fn test_metadata_location_with_next_version() {
219        let test_cases = vec![
220            MetadataLocation::new_with_table_location("/abc"),
221            MetadataLocation::from_str(
222                "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
223            )
224            .unwrap(),
225        ];
226
227        for input in test_cases {
228            let next = MetadataLocation::from_str(&input.to_string())
229                .unwrap()
230                .with_next_version();
231            assert_eq!(next.table_location, input.table_location);
232            assert_eq!(next.version, input.version + 1);
233            assert_ne!(next.id, input.id);
234        }
235    }
236}