Skip to main content

olai_store/
name.rs

1//! Hierarchical resource names and their dotted, backtick-escaped string form.
2//!
3//! A [`ResourceName`] is a path of field-name segments (`["catalog", "schema", "table"]`)
4//! identifying a resource within a taxonomy's hierarchy. It round-trips to and from a dotted
5//! string via [`Display`] and [`FromStr`](std::str::FromStr): segments join with `.`, and a segment containing a
6//! dot, whitespace, other non-alphanumeric character, or a leading digit is wrapped in backticks
7//! (`` ` ``) with any literal backtick doubled — the same quoting rule SQL uses for identifiers.
8//! [`parse_column_name_list`](ResourceName::parse_column_name_list) parses a comma-separated
9//! list of such names.
10
11use crate::{Error, Result};
12
13use std::borrow::Borrow;
14use std::fmt::{Display, Formatter};
15use std::hash::{Hash, Hasher};
16use std::iter::Peekable;
17use std::ops::Deref;
18use std::sync::LazyLock;
19
20use serde::{Deserialize, Serialize};
21
22/// A shared, empty [`ResourceName`] (a zero-segment path).
23///
24/// Useful as a default or as the root of a hierarchy without allocating a fresh empty name.
25pub static EMPTY_RESOURCE_NAME: LazyLock<ResourceName> =
26    LazyLock::new(|| ResourceName::new(&[] as &[String]));
27
28/// A (possibly nested) resource name: a path of field-name segments.
29///
30/// Segments are ordered outermost-first (`["catalog", "schema", "table"]`). The name derefs to
31/// `&[String]`, and [`Display`]/[`FromStr`](std::str::FromStr) convert to and from the dotted, backtick-escaped
32/// string form described in the [module docs](self).
33///
34/// # Examples
35///
36/// ```
37/// use olai_store::ResourceName;
38///
39/// let name: ResourceName = "catalog.schema.table".parse().unwrap();
40/// assert_eq!(name.path(), ["catalog", "schema", "table"]);
41/// assert_eq!(name.to_string(), "catalog.schema.table");
42///
43/// // A segment needing quoting round-trips through backticks.
44/// let quoted = ResourceName::new(["my catalog", "t.1"]);
45/// assert_eq!(quoted.to_string(), "`my catalog`.`t.1`");
46/// assert_eq!(quoted.to_string().parse::<ResourceName>().unwrap(), quoted);
47/// ```
48#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize)]
49#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
50#[cfg_attr(feature = "sqlx", sqlx(transparent, no_pg_array))]
51pub struct ResourceName(Vec<String>);
52
53impl ResourceName {
54    /// Creates a new resource name from an iterator of field names.
55    pub fn new<A>(iter: impl IntoIterator<Item = A>) -> Self
56    where
57        Self: FromIterator<A>,
58    {
59        iter.into_iter().collect()
60    }
61
62    /// Naively splits a string at dots to create a resource name.
63    ///
64    /// This method is _NOT_ recommended for production use, as it does not attempt to interpret
65    /// special characters in field names.
66    pub fn from_naive_str_split(name: impl AsRef<str>) -> Self {
67        Self::new(name.as_ref().split(FIELD_SEPARATOR))
68    }
69
70    /// Parses a comma-separated list of resource names, properly accounting for escapes and special
71    /// characters.
72    ///
73    /// Surrounding whitespace around each name is ignored; an empty input yields an empty list.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`Error::Generic`] if any name is malformed — for example an
78    /// unterminated backtick quote or an invalid character following a segment.
79    ///
80    /// [`Error::Generic`]: crate::Error::Generic
81    pub fn parse_column_name_list(names: impl AsRef<str>) -> Result<Vec<ResourceName>> {
82        let names = names.as_ref();
83        let chars = &mut names.chars().peekable();
84
85        drop_leading_whitespace(chars);
86        let mut ending = match chars.peek() {
87            Some(_) => FieldEnding::NextColumn,
88            None => FieldEnding::InputExhausted,
89        };
90
91        let mut cols = vec![];
92        while ending == FieldEnding::NextColumn {
93            let (col, new_ending) = parse_resource_name(chars)?;
94            cols.push(col);
95            ending = new_ending;
96        }
97        Ok(cols)
98    }
99
100    /// Joins this name with another, concatenating their fields into a single nested path.
101    pub fn join(&self, right: &ResourceName) -> ResourceName {
102        [self.clone(), right.clone()].into_iter().collect()
103    }
104
105    /// The path of field names for this resource name.
106    pub fn path(&self) -> &[String] {
107        &self.0
108    }
109
110    /// Consumes this resource name and returns the path of field names.
111    pub fn into_inner(self) -> Vec<String> {
112        self.0
113    }
114
115    /// Returns `true` if this name starts with the given prefix.
116    ///
117    /// The comparison is segment-wise, not by string prefix: a name matches `prefix` when its
118    /// first `prefix.len()` segments equal `prefix`'s segments. An empty prefix matches every name.
119    /// Used to scope listings to a namespace subtree.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use olai_store::ResourceName;
125    ///
126    /// let name = ResourceName::new(["catalog", "schema", "table"]);
127    /// assert!(name.prefix_matches(&ResourceName::new(["catalog", "schema"])));
128    /// assert!(!name.prefix_matches(&ResourceName::new(["catalog", "other"])));
129    /// ```
130    ///
131    /// Segments are compared case-insensitively for ASCII, matching the
132    /// `COLLATE NOCASE` folding the SQLite backend applies to the `name` column
133    /// (so namespace prefix listing agrees with `get_by_name` equality). Case
134    /// folding is ASCII-only; non-ASCII segments compare byte-for-byte.
135    ///
136    /// ```
137    /// use olai_store::ResourceName;
138    ///
139    /// let name = ResourceName::new(["Catalog", "Schema", "table"]);
140    /// assert!(name.prefix_matches(&ResourceName::new(["catalog", "schema"])));
141    /// ```
142    pub fn prefix_matches(&self, prefix: &ResourceName) -> bool {
143        if self.len() < prefix.len() {
144            return false;
145        }
146        prefix
147            .iter()
148            .zip(self.iter())
149            .all(|(a, b)| a.eq_ignore_ascii_case(b))
150    }
151
152    /// Compare two resource names for equality, folding ASCII case per segment.
153    ///
154    /// This mirrors the `COLLATE NOCASE` folding the SQLite backend applies to
155    /// the `name` column, so in-memory name lookups agree with the persistent
156    /// backend: `Catalog` and `catalog` are the same name. Case folding is
157    /// ASCII-only; non-ASCII segments compare byte-for-byte.
158    ///
159    /// # Examples
160    ///
161    /// ```
162    /// use olai_store::ResourceName;
163    ///
164    /// assert!(ResourceName::new(["Catalog"]).eq_ignore_ascii_case(&ResourceName::new(["catalog"])));
165    /// assert!(!ResourceName::new(["a", "b"]).eq_ignore_ascii_case(&ResourceName::new(["a"])));
166    /// ```
167    pub fn eq_ignore_ascii_case(&self, other: &ResourceName) -> bool {
168        self.len() == other.len()
169            && self
170                .iter()
171                .zip(other.iter())
172                .all(|(a, b)| a.eq_ignore_ascii_case(b))
173    }
174}
175
176impl<A: Into<String>> FromIterator<A> for ResourceName {
177    fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self {
178        let path = iter.into_iter().map(|s| s.into()).collect();
179        Self(path)
180    }
181}
182
183impl From<Vec<String>> for ResourceName {
184    fn from(path: Vec<String>) -> Self {
185        Self(path)
186    }
187}
188
189impl FromIterator<ResourceName> for ResourceName {
190    fn from_iter<T: IntoIterator<Item = ResourceName>>(iter: T) -> Self {
191        let path = iter.into_iter().flat_map(|c| c.into_iter()).collect();
192        Self(path)
193    }
194}
195
196impl IntoIterator for ResourceName {
197    type Item = String;
198    type IntoIter = std::vec::IntoIter<Self::Item>;
199
200    fn into_iter(self) -> Self::IntoIter {
201        self.0.into_iter()
202    }
203}
204
205impl Deref for ResourceName {
206    type Target = [String];
207
208    fn deref(&self) -> &[String] {
209        &self.0
210    }
211}
212
213impl Borrow<[String]> for ResourceName {
214    fn borrow(&self) -> &[String] {
215        self
216    }
217}
218
219impl Borrow<[String]> for &ResourceName {
220    fn borrow(&self) -> &[String] {
221        self
222    }
223}
224
225impl Hash for ResourceName {
226    fn hash<H: Hasher>(&self, hasher: &mut H) {
227        (**self).hash(hasher)
228    }
229}
230
231impl Display for ResourceName {
232    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
233        for (i, s) in self.iter().enumerate() {
234            use std::fmt::Write as _;
235
236            if i > 0 {
237                f.write_char(FIELD_SEPARATOR)?;
238            }
239
240            let digit_char = |c: char| c.is_ascii_digit();
241            if s.is_empty() || s.starts_with(digit_char) || s.contains(|c| !is_simple_char(c)) {
242                f.write_char(FIELD_ESCAPE_CHAR)?;
243                for c in s.chars() {
244                    f.write_char(c)?;
245                    if c == FIELD_ESCAPE_CHAR {
246                        f.write_char(c)?;
247                    }
248                }
249                f.write_char(FIELD_ESCAPE_CHAR)?;
250            } else {
251                f.write_str(s)?;
252            }
253        }
254        Ok(())
255    }
256}
257
258fn is_simple_char(c: char) -> bool {
259    c.is_ascii_alphanumeric() || c == '_'
260}
261
262fn drop_leading_whitespace(iter: &mut Peekable<impl Iterator<Item = char>>) {
263    while iter.next_if(|c| c.is_whitespace()).is_some() {}
264}
265
266impl std::str::FromStr for ResourceName {
267    type Err = Error;
268
269    /// Parses the dotted, backtick-escaped string form (see the [module docs](self)).
270    ///
271    /// # Errors
272    ///
273    /// Returns [`Error::Generic`] if the string is malformed — an
274    /// unterminated backtick quote, an invalid character after a segment, or a trailing comma
275    /// (parse a comma-separated list with
276    /// [`parse_column_name_list`](ResourceName::parse_column_name_list) instead).
277    ///
278    /// [`Error::Generic`]: crate::Error::Generic
279    fn from_str(s: &str) -> Result<Self> {
280        match parse_resource_name(&mut s.chars().peekable())? {
281            (_, FieldEnding::NextColumn) => Err(Error::generic("Trailing comma in column name")),
282            (col, _) => Ok(col),
283        }
284    }
285}
286
287type Chars<'a> = Peekable<std::str::Chars<'a>>;
288
289#[derive(PartialEq)]
290enum FieldEnding {
291    InputExhausted,
292    NextField,
293    NextColumn,
294}
295
296const FIELD_ESCAPE_CHAR: char = '`';
297const FIELD_SEPARATOR: char = '.';
298const COLUMN_SEPARATOR: char = ',';
299
300fn parse_resource_name(chars: &mut Chars<'_>) -> Result<(ResourceName, FieldEnding)> {
301    drop_leading_whitespace(chars);
302    let mut ending = if chars.peek().is_none() {
303        FieldEnding::InputExhausted
304    } else if chars.next_if_eq(&COLUMN_SEPARATOR).is_some() {
305        FieldEnding::NextColumn
306    } else {
307        FieldEnding::NextField
308    };
309
310    let mut path = vec![];
311    while ending == FieldEnding::NextField {
312        drop_leading_whitespace(chars);
313        let field_name = match chars.next_if_eq(&FIELD_ESCAPE_CHAR) {
314            Some(_) => parse_escaped_field_name(chars)?,
315            None => parse_simple_field_name(chars)?,
316        };
317
318        ending = match chars.find(|c| !c.is_whitespace()) {
319            None => FieldEnding::InputExhausted,
320            Some(FIELD_SEPARATOR) => FieldEnding::NextField,
321            Some(COLUMN_SEPARATOR) => FieldEnding::NextColumn,
322            Some(other) => {
323                return Err(Error::generic(format!(
324                    "Invalid character {other:?} after field {field_name:?}",
325                )));
326            }
327        };
328        path.push(field_name);
329    }
330    Ok((ResourceName::new(path), ending))
331}
332
333fn parse_simple_field_name(chars: &mut Chars<'_>) -> Result<String> {
334    let mut name = String::new();
335    let mut first = true;
336    while let Some(c) = chars.next_if(|c| is_simple_char(*c)) {
337        if first && c.is_ascii_digit() {
338            return Err(Error::generic(format!(
339                "Unescaped field name cannot start with a digit {c:?}"
340            )));
341        }
342        name.push(c);
343        first = false;
344    }
345    Ok(name)
346}
347
348fn parse_escaped_field_name(chars: &mut Chars<'_>) -> Result<String> {
349    let mut name = String::new();
350    loop {
351        match chars.next() {
352            Some(FIELD_ESCAPE_CHAR) if chars.next_if_eq(&FIELD_ESCAPE_CHAR).is_none() => break,
353            Some(c) => name.push(c),
354            None => {
355                return Err(Error::generic(format!(
356                    "No closing {FIELD_ESCAPE_CHAR:?} after field {name:?}"
357                )));
358            }
359        }
360    }
361    Ok(name)
362}
363
364#[cfg(test)]
365mod test {
366    use super::*;
367
368    impl ResourceName {
369        fn empty() -> Self {
370            Self::new(&[] as &[String])
371        }
372    }
373
374    #[test]
375    fn test_column_name_methods() {
376        let simple: ResourceName = "x".parse().unwrap();
377        let nested: ResourceName = "x.y".parse().unwrap();
378
379        assert_eq!(simple.path(), ["x"]);
380        assert_eq!(nested.path(), ["x", "y"]);
381
382        assert_eq!(simple.clone().into_inner(), ["x"]);
383        assert_eq!(nested.clone().into_inner(), ["x", "y"]);
384
385        let name: &[String] = &nested;
386        assert_eq!(name, &["x", "y"]);
387
388        let name: ResourceName = ["x", "y"].into_iter().collect();
389        assert_eq!(name, nested);
390
391        let name: ResourceName = [&nested, &simple].into_iter().cloned().collect();
392        assert_eq!(name, ResourceName::new(["x", "y", "x"]));
393    }
394
395    #[test]
396    fn test_column_name_from_str() {
397        let cases = [
398            ("", Some(ResourceName::empty())),
399            (".", Some(ResourceName::new(["", ""]))),
400            ("  .  ", Some(ResourceName::new(["", ""]))),
401            (" ", Some(ResourceName::empty())),
402            ("0", None),
403            (".a", Some(ResourceName::new(["", "a"]))),
404            ("a.", Some(ResourceName::new(["a", ""]))),
405            ("  a  .  ", Some(ResourceName::new(["a", ""]))),
406            ("a..b", Some(ResourceName::new(["a", "", "b"]))),
407            ("`a", None),
408            ("a`", None),
409            ("a`b`", None),
410            ("`a`b", None),
411            ("`a``b`", Some(ResourceName::new(["a`b"]))),
412            ("  `a``b`  ", Some(ResourceName::new(["a`b"]))),
413            ("`a`` b`", Some(ResourceName::new(["a` b"]))),
414            ("a", Some(ResourceName::new(["a"]))),
415            ("a0", Some(ResourceName::new(["a0"]))),
416            ("`a`", Some(ResourceName::new(["a"]))),
417            ("  `a`  ", Some(ResourceName::new(["a"]))),
418            ("` `", Some(ResourceName::new([" "]))),
419            ("  ` `  ", Some(ResourceName::new([" "]))),
420            ("`0`", Some(ResourceName::new(["0"]))),
421            ("`.`", Some(ResourceName::new(["."]))),
422            ("`.`.`.`", Some(ResourceName::new([".", "."]))),
423            ("` `.` `", Some(ResourceName::new([" ", " "]))),
424            ("a.b", Some(ResourceName::new(["a", "b"]))),
425            ("a b", None),
426            ("a.`b`", Some(ResourceName::new(["a", "b"]))),
427            ("`a`.b", Some(ResourceName::new(["a", "b"]))),
428            ("`a`.`b`", Some(ResourceName::new(["a", "b"]))),
429            ("`a`.`b`.`c`", Some(ResourceName::new(["a", "b", "c"]))),
430            ("`a``.`b```", None),
431            ("`a```.`b``", None),
432            ("`a```.`b```", Some(ResourceName::new(["a`", "b`"]))),
433            ("`a.`b``.c`", None),
434            ("`a.``b`.c`", None),
435            ("`a.``b``.c`", Some(ResourceName::new(["a.`b`.c"]))),
436            ("a`.b``", None),
437        ];
438        for (input, expected_output) in cases {
439            let output: Result<ResourceName> = input.parse();
440            match (&output, &expected_output) {
441                (Ok(output), Some(expected_output)) => {
442                    assert_eq!(output, expected_output, "from {input}")
443                }
444                (Err(_), None) => {}
445                _ => panic!("Expected {input} to parse as {expected_output:?}, got {output:?}"),
446            }
447        }
448    }
449
450    #[test]
451    fn test_column_name_to_string() {
452        let cases = [
453            ("", ResourceName::empty()),
454            ("``.``", ResourceName::new(["", ""])),
455            ("``.a", ResourceName::new(["", "a"])),
456            ("a.``", ResourceName::new(["a", ""])),
457            ("a.``.b", ResourceName::new(["a", "", "b"])),
458            ("a", ResourceName::new(["a"])),
459            ("a0", ResourceName::new(["a0"])),
460            ("`a `", ResourceName::new(["a "])),
461            ("` `", ResourceName::new([" "])),
462            ("`0`", ResourceName::new(["0"])),
463            ("`.`", ResourceName::new(["."])),
464            ("`.`.`.`", ResourceName::new([".", "."])),
465            ("` `.` `", ResourceName::new([" ", " "])),
466            ("a.b", ResourceName::new(["a", "b"])),
467            ("a.b.c", ResourceName::new(["a", "b", "c"])),
468            ("a.`b.c`.d", ResourceName::new(["a", "b.c", "d"])),
469            ("`a```.`b```", ResourceName::new(["a`", "b`"])),
470        ];
471        for (expected_output, input) in cases {
472            let output = input.to_string();
473            assert_eq!(output, expected_output);
474
475            let parsed: ResourceName = output.parse().expect(&output);
476            assert_eq!(parsed, input);
477        }
478
479        let cases = [
480            ("  `a`  ", "a", ResourceName::new(["a"])),
481            ("  `a0`  ", "a0", ResourceName::new(["a0"])),
482            ("  `a`  .  `b`  ", "a.b", ResourceName::new(["a", "b"])),
483        ];
484        for (input, expected_output, expected_parsed) in cases {
485            let parsed: ResourceName = input.parse().unwrap();
486            assert_eq!(parsed, expected_parsed);
487            assert_eq!(parsed.to_string(), expected_output);
488        }
489    }
490
491    #[test]
492    fn test_parse_column_name_list() {
493        let cases = [
494            ("", Some(vec![])),
495            (
496                "  ,  ",
497                Some(vec![ResourceName::empty(), ResourceName::empty()]),
498            ),
499            ("  a  ", Some(vec![ResourceName::new(["a"])])),
500            (
501                "  ,  a  ",
502                Some(vec![ResourceName::empty(), ResourceName::new(["a"])]),
503            ),
504            (
505                "  a  ,  ",
506                Some(vec![ResourceName::new(["a"]), ResourceName::empty()]),
507            ),
508            (
509                "a  ,  b",
510                Some(vec![ResourceName::new(["a"]), ResourceName::new(["b"])]),
511            ),
512            ("`a, b`", Some(vec![ResourceName::new(["a, b"])])),
513            (
514                "a.b, c",
515                Some(vec![
516                    ResourceName::new(["a", "b"]),
517                    ResourceName::new(["c"]),
518                ]),
519            ),
520        ];
521        for (input, expected_output) in cases {
522            let output = ResourceName::parse_column_name_list(input);
523            match (&output, &expected_output) {
524                (Ok(output), Some(expected_output)) => {
525                    assert_eq!(output, expected_output, "from \"{input}\"")
526                }
527                (Err(_), None) => {}
528                _ => panic!("Expected {input} to parse as {expected_output:?}, got {output:?}"),
529            }
530        }
531    }
532}