Skip to main content

pgevolve_core/
identifier.rs

1//! Identifier and qualified-name types.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// A single Postgres identifier (e.g., a table name).
10///
11/// Postgres identifier rules:
12/// - Length: 1..=63 bytes (NAMEDATALEN).
13/// - Unquoted: starts with `[A-Za-z_]` followed by `[A-Za-z0-9_$]*`.
14/// - Quoted: any UTF-8 except `"` (we accept any non-empty UTF-8 here; `pg_query`
15///   will reject anything postgres can't actually accept at parse time).
16///
17/// We store identifiers in their *case-folded canonical form* for unquoted
18/// inputs (Postgres lowercases unquoted identifiers) and in their original
19/// form for quoted inputs. The constructor distinguishes the two cases via
20/// [`Identifier::from_unquoted`] vs [`Identifier::from_quoted`].
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct Identifier(String);
24
25/// Errors raised when constructing an [`Identifier`].
26#[derive(Debug, Error, PartialEq, Eq)]
27pub enum IdentifierError {
28    /// The identifier was empty.
29    #[error("identifier is empty")]
30    Empty,
31    /// The identifier exceeded Postgres's 63-byte limit.
32    #[error("identifier exceeds 63 bytes: got {0}")]
33    TooLong(usize),
34    /// The unquoted identifier contained invalid characters.
35    #[error("unquoted identifier contains invalid characters: {0:?}")]
36    InvalidUnquotedChars(String),
37}
38
39impl Identifier {
40    /// Construct from an unquoted identifier source — lowercases per Postgres rules.
41    pub fn from_unquoted(s: &str) -> Result<Self, IdentifierError> {
42        if s.is_empty() {
43            return Err(IdentifierError::Empty);
44        }
45        if s.len() > 63 {
46            return Err(IdentifierError::TooLong(s.len()));
47        }
48        let mut chars = s.chars();
49        // SAFETY: s is non-empty — the empty check above ensures chars.next() yields Some.
50        let Some(first) = chars.next() else {
51            unreachable!("non-empty string has at least one char")
52        };
53        if !(first.is_ascii_alphabetic() || first == '_') {
54            return Err(IdentifierError::InvalidUnquotedChars(s.to_string()));
55        }
56        for c in chars {
57            if !(c.is_ascii_alphanumeric() || c == '_' || c == '$') {
58                return Err(IdentifierError::InvalidUnquotedChars(s.to_string()));
59            }
60        }
61        Ok(Self(s.to_ascii_lowercase()))
62    }
63
64    /// Construct from a quoted identifier — preserves case.
65    pub fn from_quoted(s: &str) -> Result<Self, IdentifierError> {
66        if s.is_empty() {
67            return Err(IdentifierError::Empty);
68        }
69        if s.len() > 63 {
70            return Err(IdentifierError::TooLong(s.len()));
71        }
72        Ok(Self(s.to_string()))
73    }
74
75    /// Returns the inner canonical string.
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79
80    /// Renders this identifier as it would appear in SQL — quoted iff necessary.
81    pub fn render_sql(&self) -> String {
82        if needs_quoting(&self.0) {
83            format!("\"{}\"", self.0.replace('"', "\"\""))
84        } else {
85            self.0.clone()
86        }
87    }
88}
89
90fn needs_quoting(s: &str) -> bool {
91    if s.is_empty() {
92        return true;
93    }
94    if RESERVED_KEYWORDS.binary_search(&s).is_ok() {
95        return true;
96    }
97    let mut chars = s.chars();
98    // SAFETY: s is non-empty — the `is_empty()` guard above returns early.
99    let Some(first) = chars.next() else {
100        unreachable!("non-empty string has at least one char")
101    };
102    if !(first.is_ascii_lowercase() || first == '_') {
103        return true;
104    }
105    for c in chars {
106        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
107            return true;
108        }
109    }
110    false
111}
112
113// Sorted; binary searched. Source: Postgres docs (reserved + reserved-non-function-or-type).
114// This list is intentionally conservative — it errs on the side of quoting.
115static RESERVED_KEYWORDS: &[&str] = &[
116    "all",
117    "analyse",
118    "analyze",
119    "and",
120    "any",
121    "array",
122    "as",
123    "asc",
124    "asymmetric",
125    "both",
126    "case",
127    "cast",
128    "check",
129    "collate",
130    "column",
131    "constraint",
132    "create",
133    "current_catalog",
134    "current_date",
135    "current_role",
136    "current_time",
137    "current_timestamp",
138    "current_user",
139    "default",
140    "deferrable",
141    "desc",
142    "distinct",
143    "do",
144    "else",
145    "end",
146    "except",
147    "false",
148    "fetch",
149    "for",
150    "foreign",
151    "from",
152    "grant",
153    "group",
154    "having",
155    "in",
156    "initially",
157    "intersect",
158    "into",
159    "lateral",
160    "leading",
161    "limit",
162    "localtime",
163    "localtimestamp",
164    "not",
165    "null",
166    "offset",
167    "on",
168    "only",
169    "or",
170    "order",
171    "placing",
172    "primary",
173    "references",
174    "returning",
175    "select",
176    "session_user",
177    "some",
178    "symmetric",
179    "table",
180    "then",
181    "to",
182    "trailing",
183    "true",
184    "union",
185    "unique",
186    "user",
187    "using",
188    "variadic",
189    "when",
190    "where",
191    "window",
192    "with",
193];
194
195impl fmt::Display for Identifier {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.write_str(&self.0)
198    }
199}
200
201impl FromStr for Identifier {
202    type Err = IdentifierError;
203    /// Parses as an unquoted identifier.
204    fn from_str(s: &str) -> Result<Self, Self::Err> {
205        Self::from_unquoted(s)
206    }
207}
208
209/// A schema-qualified identifier — e.g., `app.users` or `"AppSchema".users`.
210#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
211pub struct QualifiedName {
212    /// The schema component.
213    pub schema: Identifier,
214    /// The object name.
215    pub name: Identifier,
216}
217
218impl QualifiedName {
219    /// Construct from two identifiers.
220    pub const fn new(schema: Identifier, name: Identifier) -> Self {
221        Self { schema, name }
222    }
223
224    /// Renders as it would appear in SQL.
225    pub fn render_sql(&self) -> String {
226        format!("{}.{}", self.schema.render_sql(), self.name.render_sql())
227    }
228}
229
230impl fmt::Display for QualifiedName {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        write!(f, "{}.{}", self.schema, self.name)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use pretty_assertions::assert_eq;
240
241    #[test]
242    fn unquoted_lowercases() {
243        let id = Identifier::from_unquoted("Users").unwrap();
244        assert_eq!(id.as_str(), "users");
245    }
246
247    #[test]
248    fn quoted_preserves_case() {
249        let id = Identifier::from_quoted("Users").unwrap();
250        assert_eq!(id.as_str(), "Users");
251    }
252
253    #[test]
254    fn rejects_empty() {
255        assert_eq!(Identifier::from_unquoted(""), Err(IdentifierError::Empty));
256        assert_eq!(Identifier::from_quoted(""), Err(IdentifierError::Empty));
257    }
258
259    #[test]
260    fn rejects_overlong() {
261        let long = "a".repeat(64);
262        assert!(matches!(
263            Identifier::from_unquoted(&long),
264            Err(IdentifierError::TooLong(64))
265        ));
266    }
267
268    #[test]
269    fn rejects_unquoted_starting_with_digit() {
270        assert!(matches!(
271            Identifier::from_unquoted("1foo"),
272            Err(IdentifierError::InvalidUnquotedChars(_))
273        ));
274    }
275
276    #[test]
277    fn quoted_allows_special_chars() {
278        let id = Identifier::from_quoted("foo bar").unwrap();
279        assert_eq!(id.as_str(), "foo bar");
280    }
281
282    #[test]
283    fn render_sql_quotes_when_necessary() {
284        assert_eq!(
285            Identifier::from_unquoted("users").unwrap().render_sql(),
286            "users"
287        );
288        assert_eq!(
289            Identifier::from_quoted("Users").unwrap().render_sql(),
290            "\"Users\""
291        );
292        assert_eq!(
293            Identifier::from_quoted("select").unwrap().render_sql(),
294            "\"select\""
295        );
296    }
297
298    #[test]
299    fn render_sql_escapes_embedded_quotes() {
300        let id = Identifier::from_quoted("a\"b").unwrap();
301        assert_eq!(id.render_sql(), "\"a\"\"b\"");
302    }
303
304    #[test]
305    fn qualified_name_renders() {
306        let qn = QualifiedName::new(
307            Identifier::from_unquoted("app").unwrap(),
308            Identifier::from_unquoted("users").unwrap(),
309        );
310        assert_eq!(qn.render_sql(), "app.users");
311        assert_eq!(qn.to_string(), "app.users");
312    }
313
314    #[test]
315    fn from_str_uses_unquoted_rules() {
316        let id: Identifier = "Foo".parse().unwrap();
317        assert_eq!(id.as_str(), "foo");
318    }
319}