Skip to main content

uqa_core/
relation_identity.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Canonical SQL relation identities and legacy name decoding.
8
9use serde::{Deserialize, Serialize};
10
11/// Durable identity of a SQL relation.
12///
13/// The schema and local name are stored separately so `foo` and
14/// `public.foo` can never become two physical catalog identities for the
15/// same SQL object.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct RelationIdentity {
18    pub schema: String,
19    pub name: String,
20}
21
22impl RelationIdentity {
23    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
24        Self {
25            schema: schema.into(),
26            name: name.into(),
27        }
28    }
29
30    pub fn qualified_name(&self) -> String {
31        format!(
32            "{}.{}",
33            render_relation_component(&self.schema),
34            render_relation_component(&self.name)
35        )
36    }
37
38    /// Physical owner keys that can refer to this relation. New writes use
39    /// only the canonical qualified name. Catalog cleanup also accepts the
40    /// former unqualified key for `public` relations so data written before
41    /// relation identities became schema-aware cannot survive its owner.
42    pub fn canonical_and_legacy_public_names(&self) -> Vec<String> {
43        let canonical = self.qualified_name();
44        if self.schema != "public" {
45            return vec![canonical];
46        }
47        let mut names = vec![canonical];
48        let rendered_alias = render_relation_component(&self.name);
49        if !names.contains(&rendered_alias) {
50            names.push(rendered_alias);
51        }
52        // The direct Rust API historically accepted a decoded local name as
53        // well as SQL-rendered text. Include that spelling only when parsing
54        // it maps back to this exact relation; for example, raw `a.b` must not
55        // be removed while dropping the distinct public relation `"a.b"`.
56        if RelationIdentity::from_legacy_name(&self.name).is_ok_and(|raw| raw == *self)
57            && !names.contains(&self.name)
58        {
59            names.push(self.name.clone());
60        }
61        names
62    }
63
64    /// Decode a SQL relation reference or a former flat catalog key.
65    /// Unqualified objects belong to `public`. Quoted components preserve
66    /// embedded dots and escaped quotes, so `public.\"a.b\"` is distinct from
67    /// `\"public.a\".b` all the way down to physical storage keys.
68    pub fn from_legacy_name(value: &str) -> Result<Self, String> {
69        let (schema, name) = Self::parse_reference(value)?;
70        Ok(Self::new(
71            schema.unwrap_or_else(|| "public".to_string()),
72            name,
73        ))
74    }
75
76    /// Recover an index identity from the former flat index catalog. The stored value is a decoded local identifier rather than a relation reference, so dots and quotes remain part of the local name and the owning table supplies the schema.
77    pub fn from_legacy_index_name(value: &str, table: &Self) -> Self {
78        Self::new(&table.schema, value)
79    }
80
81    /// Parse a possibly-unqualified SQL relation reference without choosing a
82    /// search-path schema. Components use `PostgreSQL` double-quote escaping.
83    pub fn parse_reference(value: &str) -> Result<(Option<String>, String), String> {
84        let components = parse_relation_components(value)?;
85        match components.as_slice() {
86            [name] => Ok((None, name.clone())),
87            [schema, name] => Ok((Some(schema.clone()), name.clone())),
88            _ => Err(format!("invalid persisted relation name `{value}`")),
89        }
90    }
91}
92
93fn render_relation_component(component: &str) -> String {
94    let can_render_bare = component
95        .bytes()
96        .enumerate()
97        .all(|(index, byte)| match byte {
98            b'a'..=b'z' | b'_' => true,
99            b'0'..=b'9' | b'$' => index != 0,
100            _ => false,
101        });
102    if can_render_bare && !component.is_empty() {
103        component.to_string()
104    } else {
105        format!("\"{}\"", component.replace('"', "\"\""))
106    }
107}
108
109fn parse_relation_components(value: &str) -> Result<Vec<String>, String> {
110    if value.is_empty() {
111        return Err("persisted relation name is empty".to_string());
112    }
113    let mut components = Vec::with_capacity(2);
114    let mut chars = value.char_indices().peekable();
115    while chars.peek().is_some() {
116        let mut component = String::new();
117        if chars.peek().is_some_and(|(_, ch)| *ch == '"') {
118            chars.next();
119            let mut terminated = false;
120            while let Some((_, ch)) = chars.next() {
121                if ch != '"' {
122                    component.push(ch);
123                    continue;
124                }
125                if chars.peek().is_some_and(|(_, next)| *next == '"') {
126                    chars.next();
127                    component.push('"');
128                } else {
129                    terminated = true;
130                    break;
131                }
132            }
133            if !terminated {
134                return Err(format!("unterminated quoted relation name `{value}`"));
135            }
136            if chars.peek().is_some_and(|(_, ch)| *ch != '.') {
137                return Err(format!("invalid persisted relation name `{value}`"));
138            }
139        } else {
140            while let Some((_, ch)) = chars.peek() {
141                if *ch == '.' {
142                    break;
143                }
144                if *ch == '"' {
145                    return Err(format!("invalid persisted relation name `{value}`"));
146                }
147                component.push(*ch);
148                chars.next();
149            }
150        }
151        if component.is_empty() {
152            return Err(format!("invalid persisted relation name `{value}`"));
153        }
154        components.push(component);
155        if components.len() > 2 {
156            return Err(format!("invalid persisted relation name `{value}`"));
157        }
158        match chars.next() {
159            Some((_, '.')) if chars.peek().is_some() => {}
160            Some(_) => return Err(format!("invalid persisted relation name `{value}`")),
161            None => break,
162        }
163    }
164    Ok(components)
165}