Skip to main content

use_pg_identifier/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Rendering style for a PostgreSQL identifier segment.
8#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum PgIdentifierStyle {
10    /// The identifier can render without double quotes.
11    #[default]
12    Unquoted,
13    /// The identifier must render as a double-quoted identifier.
14    Quoted,
15}
16
17/// A validated PostgreSQL identifier segment.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct PgIdentifier {
20    text: String,
21    style: PgIdentifierStyle,
22}
23
24impl PgIdentifier {
25    /// Creates an identifier from either unquoted text or a double-quoted identifier token.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`PgIdentifierError`] when the value is empty, malformed, or not valid for its style.
30    pub fn new(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
31        let input = input.as_ref();
32        let trimmed = input.trim();
33        if trimmed.starts_with('"') || trimmed.ends_with('"') {
34            return Self::from_quoted_token(trimmed);
35        }
36        Self::unquoted(trimmed)
37    }
38
39    /// Creates an unquoted identifier segment using PostgreSQL-style conservative validation.
40    ///
41    /// Unquoted identifiers are stored in lowercase because PostgreSQL folds unquoted names.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`PgIdentifierError`] when the value cannot be rendered safely without quotes.
46    pub fn unquoted(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
47        let trimmed = validate_identifier_segment(input.as_ref(), false)?;
48        validate_unquoted_identifier(trimmed)?;
49        Ok(Self {
50            text: trimmed.to_ascii_lowercase(),
51            style: PgIdentifierStyle::Unquoted,
52        })
53    }
54
55    /// Creates a quoted identifier segment from raw identifier text.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`PgIdentifierError`] when the value is empty or contains a control character.
60    pub fn quoted(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
61        let text = validate_identifier_segment(input.as_ref(), true)?;
62        Ok(Self {
63            text: text.to_owned(),
64            style: PgIdentifierStyle::Quoted,
65        })
66    }
67
68    /// Parses a double-quoted SQL identifier token, including doubled embedded quotes.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`PgIdentifierError`] when the token is not a complete quoted identifier.
73    pub fn from_quoted_token(input: &str) -> Result<Self, PgIdentifierError> {
74        if !(input.starts_with('"') && input.ends_with('"') && input.len() >= 2) {
75            return Err(PgIdentifierError::UnterminatedQuotedIdentifier);
76        }
77
78        let inner = &input[1..input.len() - 1];
79        let mut text = String::new();
80        let mut characters = inner.chars().peekable();
81        while let Some(character) = characters.next() {
82            if character == '"' {
83                if matches!(characters.peek(), Some('"')) {
84                    let _ = characters.next();
85                    text.push('"');
86                } else {
87                    return Err(PgIdentifierError::UnescapedQuote);
88                }
89            } else {
90                text.push(character);
91            }
92        }
93        Self::quoted(text)
94    }
95
96    /// Returns the raw identifier text without SQL quotes.
97    #[must_use]
98    pub fn as_str(&self) -> &str {
99        &self.text
100    }
101
102    /// Returns the rendering style.
103    #[must_use]
104    pub const fn style(&self) -> PgIdentifierStyle {
105        self.style
106    }
107
108    /// Returns `true` when this identifier renders with double quotes.
109    #[must_use]
110    pub const fn is_quoted(&self) -> bool {
111        matches!(self.style, PgIdentifierStyle::Quoted)
112    }
113
114    /// Consumes the identifier and returns its raw text.
115    #[must_use]
116    pub fn into_string(self) -> String {
117        self.text
118    }
119}
120
121impl AsRef<str> for PgIdentifier {
122    fn as_ref(&self) -> &str {
123        self.as_str()
124    }
125}
126
127impl fmt::Display for PgIdentifier {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self.style {
130            PgIdentifierStyle::Unquoted => formatter.write_str(self.as_str()),
131            PgIdentifierStyle::Quoted => formatter.write_str(&quote_identifier(self.as_str())),
132        }
133    }
134}
135
136impl FromStr for PgIdentifier {
137    type Err = PgIdentifierError;
138
139    fn from_str(input: &str) -> Result<Self, Self::Err> {
140        Self::new(input)
141    }
142}
143
144impl TryFrom<&str> for PgIdentifier {
145    type Error = PgIdentifierError;
146
147    fn try_from(value: &str) -> Result<Self, Self::Error> {
148        Self::new(value)
149    }
150}
151
152/// A dot-qualified PostgreSQL name.
153#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub struct PgQualifiedName {
155    parts: Vec<PgIdentifier>,
156}
157
158impl PgQualifiedName {
159    /// Creates a qualified name from one or more identifier parts.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`PgIdentifierError::EmptyQualifiedName`] when `parts` is empty.
164    pub fn new(parts: Vec<PgIdentifier>) -> Result<Self, PgIdentifierError> {
165        if parts.is_empty() {
166            return Err(PgIdentifierError::EmptyQualifiedName);
167        }
168        Ok(Self { parts })
169    }
170
171    /// Parses a conservative dot-qualified name.
172    ///
173    /// This is not a full SQL parser. It splits on dots and parses each segment as a PostgreSQL identifier.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`PgIdentifierError`] when any segment is invalid.
178    pub fn parse(input: &str) -> Result<Self, PgIdentifierError> {
179        let trimmed = input.trim();
180        if trimmed.is_empty() {
181            return Err(PgIdentifierError::EmptyQualifiedName);
182        }
183        let parts = trimmed
184            .split('.')
185            .map(PgIdentifier::new)
186            .collect::<Result<Vec<_>, _>>()?;
187        Self::new(parts)
188    }
189
190    /// Creates a two-part schema-qualified object name.
191    #[must_use]
192    pub fn schema_object(schema: PgIdentifier, object: PgIdentifier) -> Self {
193        Self {
194            parts: vec![schema, object],
195        }
196    }
197
198    /// Returns the identifier parts.
199    #[must_use]
200    pub fn parts(&self) -> &[PgIdentifier] {
201        &self.parts
202    }
203
204    /// Returns the last identifier part.
205    #[must_use]
206    pub fn leaf(&self) -> &PgIdentifier {
207        &self.parts[self.parts.len() - 1]
208    }
209}
210
211impl fmt::Display for PgQualifiedName {
212    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213        let mut parts = self.parts.iter();
214        if let Some(first) = parts.next() {
215            write!(formatter, "{first}")?;
216        }
217        for part in parts {
218            write!(formatter, ".{part}")?;
219        }
220        Ok(())
221    }
222}
223
224impl FromStr for PgQualifiedName {
225    type Err = PgIdentifierError;
226
227    fn from_str(input: &str) -> Result<Self, Self::Err> {
228        Self::parse(input)
229    }
230}
231
232impl TryFrom<&str> for PgQualifiedName {
233    type Error = PgIdentifierError;
234
235    fn try_from(value: &str) -> Result<Self, Self::Error> {
236        Self::parse(value)
237    }
238}
239
240/// Error returned when PostgreSQL identifier text is rejected.
241#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum PgIdentifierError {
243    /// The supplied value was empty.
244    Empty,
245    /// A conservative unquoted identifier segment cannot contain `.`.
246    ContainsDot,
247    /// A qualified name requires at least one segment.
248    EmptyQualifiedName,
249    /// The supplied value started with an invalid character.
250    InvalidStart {
251        /// The rejected character.
252        character: char,
253    },
254    /// The supplied value contained an invalid unquoted character.
255    InvalidCharacter {
256        /// Byte index of the rejected character.
257        index: usize,
258        /// The rejected character.
259        character: char,
260    },
261    /// The supplied value contained a control character.
262    ControlCharacter {
263        /// Byte index of the rejected character.
264        index: usize,
265        /// The rejected character.
266        character: char,
267    },
268    /// The supplied value was a reserved PostgreSQL keyword-like label.
269    ReservedKeyword,
270    /// A quoted identifier token was missing its closing quote.
271    UnterminatedQuotedIdentifier,
272    /// A quoted identifier token contained a single embedded quote instead of a doubled quote.
273    UnescapedQuote,
274}
275
276impl fmt::Display for PgIdentifierError {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self {
279            Self::Empty => formatter.write_str("PostgreSQL identifier cannot be empty"),
280            Self::ContainsDot => {
281                formatter.write_str("PostgreSQL unquoted identifier segment cannot contain a dot")
282            },
283            Self::EmptyQualifiedName => {
284                formatter.write_str("PostgreSQL qualified name cannot be empty")
285            },
286            Self::InvalidStart { character } => write!(
287                formatter,
288                "PostgreSQL unquoted identifier cannot start with {character:?}"
289            ),
290            Self::InvalidCharacter { index, character } => write!(
291                formatter,
292                "PostgreSQL unquoted identifier contains invalid character {character:?} at byte index {index}"
293            ),
294            Self::ControlCharacter { index, character } => write!(
295                formatter,
296                "PostgreSQL identifier contains control character {character:?} at byte index {index}"
297            ),
298            Self::ReservedKeyword => formatter.write_str(
299                "PostgreSQL reserved keyword-like labels should be represented as quoted identifiers",
300            ),
301            Self::UnterminatedQuotedIdentifier => {
302                formatter.write_str("PostgreSQL quoted identifier is not terminated")
303            },
304            Self::UnescapedQuote => formatter.write_str(
305                "PostgreSQL quoted identifier contains an embedded quote that is not doubled",
306            ),
307        }
308    }
309}
310
311impl Error for PgIdentifierError {}
312
313/// Returns `true` when `input` is conservatively valid as an unquoted PostgreSQL identifier.
314#[must_use]
315pub fn is_valid_unquoted_identifier(input: &str) -> bool {
316    validate_identifier_segment(input, false)
317        .and_then(validate_unquoted_identifier)
318        .is_ok()
319}
320
321/// Returns `true` when an identifier should be double-quoted for conservative PostgreSQL rendering.
322#[must_use]
323pub fn needs_quoting(input: &str) -> bool {
324    !is_valid_unquoted_identifier(input)
325}
326
327/// Quotes an identifier with PostgreSQL double quotes, doubling embedded double quotes.
328#[must_use]
329pub fn quote_identifier(input: &str) -> String {
330    let mut quoted = String::with_capacity(input.len() + 2);
331    quoted.push('"');
332    for character in input.chars() {
333        if character == '"' {
334            quoted.push('"');
335        }
336        quoted.push(character);
337    }
338    quoted.push('"');
339    quoted
340}
341
342/// Normalizes an identifier label for simple display-oriented comparisons.
343#[must_use]
344pub fn normalize_identifier(input: &str) -> String {
345    let trimmed = input.trim();
346    if is_valid_unquoted_identifier(trimmed) {
347        trimmed.to_ascii_lowercase()
348    } else {
349        quote_identifier(trimmed)
350    }
351}
352
353fn validate_identifier_segment(input: &str, allow_dot: bool) -> Result<&str, PgIdentifierError> {
354    if input.is_empty() {
355        return Err(PgIdentifierError::Empty);
356    }
357    if !allow_dot && input.contains('.') {
358        return Err(PgIdentifierError::ContainsDot);
359    }
360    if let Some((index, character)) = input
361        .char_indices()
362        .find(|(_, character)| character.is_control())
363    {
364        return Err(PgIdentifierError::ControlCharacter { index, character });
365    }
366    Ok(input)
367}
368
369fn validate_unquoted_identifier(input: &str) -> Result<(), PgIdentifierError> {
370    let mut characters = input.char_indices();
371    let Some((_, first)) = characters.next() else {
372        return Err(PgIdentifierError::Empty);
373    };
374    if !(first == '_' || first.is_ascii_alphabetic()) {
375        return Err(PgIdentifierError::InvalidStart { character: first });
376    }
377    for (index, character) in characters {
378        if !(character == '_' || character.is_ascii_alphanumeric()) {
379            return Err(PgIdentifierError::InvalidCharacter { index, character });
380        }
381    }
382    if is_reserved_keyword_like(input) {
383        return Err(PgIdentifierError::ReservedKeyword);
384    }
385    Ok(())
386}
387
388fn is_reserved_keyword_like(input: &str) -> bool {
389    matches!(
390        input.to_ascii_uppercase().as_str(),
391        "ALL"
392            | "ALTER"
393            | "AND"
394            | "AS"
395            | "CHECK"
396            | "CREATE"
397            | "DELETE"
398            | "DROP"
399            | "FALSE"
400            | "FOREIGN"
401            | "FROM"
402            | "GROUP"
403            | "INDEX"
404            | "INSERT"
405            | "KEY"
406            | "LIMIT"
407            | "NOT"
408            | "NULL"
409            | "OR"
410            | "ORDER"
411            | "PRIMARY"
412            | "RETURNING"
413            | "SELECT"
414            | "TABLE"
415            | "TRUE"
416            | "UNIQUE"
417            | "UPDATE"
418            | "USER"
419            | "WHERE"
420    )
421}
422
423#[cfg(test)]
424mod tests {
425    use super::{
426        PgIdentifier, PgIdentifierError, PgIdentifierStyle, PgQualifiedName,
427        is_valid_unquoted_identifier, needs_quoting, normalize_identifier, quote_identifier,
428    };
429
430    #[test]
431    fn validates_unquoted_identifiers() -> Result<(), PgIdentifierError> {
432        let identifier = PgIdentifier::new(" Users_1 ")?;
433        assert_eq!(identifier.as_str(), "users_1");
434        assert_eq!(identifier.style(), PgIdentifierStyle::Unquoted);
435        assert!(is_valid_unquoted_identifier("users_1"));
436        assert!(!is_valid_unquoted_identifier("1users"));
437        assert!(matches!(
438            PgIdentifier::new("public.users"),
439            Err(PgIdentifierError::ContainsDot)
440        ));
441        Ok(())
442    }
443
444    #[test]
445    fn supports_quoted_identifiers() -> Result<(), PgIdentifierError> {
446        let identifier = PgIdentifier::quoted("User Name")?;
447        assert!(identifier.is_quoted());
448        assert_eq!(identifier.to_string(), "\"User Name\"");
449
450        let parsed = PgIdentifier::new("\"user\"\"name\"")?;
451        assert_eq!(parsed.as_str(), "user\"name");
452        assert_eq!(parsed.to_string(), "\"user\"\"name\"");
453        Ok(())
454    }
455
456    #[test]
457    fn quotes_reserved_or_complex_labels() {
458        assert!(needs_quoting("select"));
459        assert_eq!(quote_identifier("user\"name"), "\"user\"\"name\"");
460        assert_eq!(normalize_identifier("Users"), "users");
461        assert_eq!(normalize_identifier("order items"), "\"order items\"");
462    }
463
464    #[test]
465    fn parses_qualified_names() -> Result<(), PgIdentifierError> {
466        let qualified = PgQualifiedName::parse("public.users")?;
467        assert_eq!(qualified.parts().len(), 2);
468        assert_eq!(qualified.leaf().as_str(), "users");
469        assert_eq!(qualified.to_string(), "public.users");
470        assert!(PgQualifiedName::parse("public.").is_err());
471        Ok(())
472    }
473}