#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PgIdentifierStyle {
#[default]
Unquoted,
Quoted,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgIdentifier {
text: String,
style: PgIdentifierStyle,
}
impl PgIdentifier {
pub fn new(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
let input = input.as_ref();
let trimmed = input.trim();
if trimmed.starts_with('"') || trimmed.ends_with('"') {
return Self::from_quoted_token(trimmed);
}
Self::unquoted(trimmed)
}
pub fn unquoted(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
let trimmed = validate_identifier_segment(input.as_ref(), false)?;
validate_unquoted_identifier(trimmed)?;
Ok(Self {
text: trimmed.to_ascii_lowercase(),
style: PgIdentifierStyle::Unquoted,
})
}
pub fn quoted(input: impl AsRef<str>) -> Result<Self, PgIdentifierError> {
let text = validate_identifier_segment(input.as_ref(), true)?;
Ok(Self {
text: text.to_owned(),
style: PgIdentifierStyle::Quoted,
})
}
pub fn from_quoted_token(input: &str) -> Result<Self, PgIdentifierError> {
if !(input.starts_with('"') && input.ends_with('"') && input.len() >= 2) {
return Err(PgIdentifierError::UnterminatedQuotedIdentifier);
}
let inner = &input[1..input.len() - 1];
let mut text = String::new();
let mut characters = inner.chars().peekable();
while let Some(character) = characters.next() {
if character == '"' {
if matches!(characters.peek(), Some('"')) {
let _ = characters.next();
text.push('"');
} else {
return Err(PgIdentifierError::UnescapedQuote);
}
} else {
text.push(character);
}
}
Self::quoted(text)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub const fn style(&self) -> PgIdentifierStyle {
self.style
}
#[must_use]
pub const fn is_quoted(&self) -> bool {
matches!(self.style, PgIdentifierStyle::Quoted)
}
#[must_use]
pub fn into_string(self) -> String {
self.text
}
}
impl AsRef<str> for PgIdentifier {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for PgIdentifier {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.style {
PgIdentifierStyle::Unquoted => formatter.write_str(self.as_str()),
PgIdentifierStyle::Quoted => formatter.write_str("e_identifier(self.as_str())),
}
}
}
impl FromStr for PgIdentifier {
type Err = PgIdentifierError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for PgIdentifier {
type Error = PgIdentifierError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PgQualifiedName {
parts: Vec<PgIdentifier>,
}
impl PgQualifiedName {
pub fn new(parts: Vec<PgIdentifier>) -> Result<Self, PgIdentifierError> {
if parts.is_empty() {
return Err(PgIdentifierError::EmptyQualifiedName);
}
Ok(Self { parts })
}
pub fn parse(input: &str) -> Result<Self, PgIdentifierError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(PgIdentifierError::EmptyQualifiedName);
}
let parts = trimmed
.split('.')
.map(PgIdentifier::new)
.collect::<Result<Vec<_>, _>>()?;
Self::new(parts)
}
#[must_use]
pub fn schema_object(schema: PgIdentifier, object: PgIdentifier) -> Self {
Self {
parts: vec![schema, object],
}
}
#[must_use]
pub fn parts(&self) -> &[PgIdentifier] {
&self.parts
}
#[must_use]
pub fn leaf(&self) -> &PgIdentifier {
&self.parts[self.parts.len() - 1]
}
}
impl fmt::Display for PgQualifiedName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = self.parts.iter();
if let Some(first) = parts.next() {
write!(formatter, "{first}")?;
}
for part in parts {
write!(formatter, ".{part}")?;
}
Ok(())
}
}
impl FromStr for PgQualifiedName {
type Err = PgIdentifierError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::parse(input)
}
}
impl TryFrom<&str> for PgQualifiedName {
type Error = PgIdentifierError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PgIdentifierError {
Empty,
ContainsDot,
EmptyQualifiedName,
InvalidStart {
character: char,
},
InvalidCharacter {
index: usize,
character: char,
},
ControlCharacter {
index: usize,
character: char,
},
ReservedKeyword,
UnterminatedQuotedIdentifier,
UnescapedQuote,
}
impl fmt::Display for PgIdentifierError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("PostgreSQL identifier cannot be empty"),
Self::ContainsDot => {
formatter.write_str("PostgreSQL unquoted identifier segment cannot contain a dot")
},
Self::EmptyQualifiedName => {
formatter.write_str("PostgreSQL qualified name cannot be empty")
},
Self::InvalidStart { character } => write!(
formatter,
"PostgreSQL unquoted identifier cannot start with {character:?}"
),
Self::InvalidCharacter { index, character } => write!(
formatter,
"PostgreSQL unquoted identifier contains invalid character {character:?} at byte index {index}"
),
Self::ControlCharacter { index, character } => write!(
formatter,
"PostgreSQL identifier contains control character {character:?} at byte index {index}"
),
Self::ReservedKeyword => formatter.write_str(
"PostgreSQL reserved keyword-like labels should be represented as quoted identifiers",
),
Self::UnterminatedQuotedIdentifier => {
formatter.write_str("PostgreSQL quoted identifier is not terminated")
},
Self::UnescapedQuote => formatter.write_str(
"PostgreSQL quoted identifier contains an embedded quote that is not doubled",
),
}
}
}
impl Error for PgIdentifierError {}
#[must_use]
pub fn is_valid_unquoted_identifier(input: &str) -> bool {
validate_identifier_segment(input, false)
.and_then(validate_unquoted_identifier)
.is_ok()
}
#[must_use]
pub fn needs_quoting(input: &str) -> bool {
!is_valid_unquoted_identifier(input)
}
#[must_use]
pub fn quote_identifier(input: &str) -> String {
let mut quoted = String::with_capacity(input.len() + 2);
quoted.push('"');
for character in input.chars() {
if character == '"' {
quoted.push('"');
}
quoted.push(character);
}
quoted.push('"');
quoted
}
#[must_use]
pub fn normalize_identifier(input: &str) -> String {
let trimmed = input.trim();
if is_valid_unquoted_identifier(trimmed) {
trimmed.to_ascii_lowercase()
} else {
quote_identifier(trimmed)
}
}
fn validate_identifier_segment(input: &str, allow_dot: bool) -> Result<&str, PgIdentifierError> {
if input.is_empty() {
return Err(PgIdentifierError::Empty);
}
if !allow_dot && input.contains('.') {
return Err(PgIdentifierError::ContainsDot);
}
if let Some((index, character)) = input
.char_indices()
.find(|(_, character)| character.is_control())
{
return Err(PgIdentifierError::ControlCharacter { index, character });
}
Ok(input)
}
fn validate_unquoted_identifier(input: &str) -> Result<(), PgIdentifierError> {
let mut characters = input.char_indices();
let Some((_, first)) = characters.next() else {
return Err(PgIdentifierError::Empty);
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return Err(PgIdentifierError::InvalidStart { character: first });
}
for (index, character) in characters {
if !(character == '_' || character.is_ascii_alphanumeric()) {
return Err(PgIdentifierError::InvalidCharacter { index, character });
}
}
if is_reserved_keyword_like(input) {
return Err(PgIdentifierError::ReservedKeyword);
}
Ok(())
}
fn is_reserved_keyword_like(input: &str) -> bool {
matches!(
input.to_ascii_uppercase().as_str(),
"ALL"
| "ALTER"
| "AND"
| "AS"
| "CHECK"
| "CREATE"
| "DELETE"
| "DROP"
| "FALSE"
| "FOREIGN"
| "FROM"
| "GROUP"
| "INDEX"
| "INSERT"
| "KEY"
| "LIMIT"
| "NOT"
| "NULL"
| "OR"
| "ORDER"
| "PRIMARY"
| "RETURNING"
| "SELECT"
| "TABLE"
| "TRUE"
| "UNIQUE"
| "UPDATE"
| "USER"
| "WHERE"
)
}
#[cfg(test)]
mod tests {
use super::{
PgIdentifier, PgIdentifierError, PgIdentifierStyle, PgQualifiedName,
is_valid_unquoted_identifier, needs_quoting, normalize_identifier, quote_identifier,
};
#[test]
fn validates_unquoted_identifiers() -> Result<(), PgIdentifierError> {
let identifier = PgIdentifier::new(" Users_1 ")?;
assert_eq!(identifier.as_str(), "users_1");
assert_eq!(identifier.style(), PgIdentifierStyle::Unquoted);
assert!(is_valid_unquoted_identifier("users_1"));
assert!(!is_valid_unquoted_identifier("1users"));
assert!(matches!(
PgIdentifier::new("public.users"),
Err(PgIdentifierError::ContainsDot)
));
Ok(())
}
#[test]
fn supports_quoted_identifiers() -> Result<(), PgIdentifierError> {
let identifier = PgIdentifier::quoted("User Name")?;
assert!(identifier.is_quoted());
assert_eq!(identifier.to_string(), "\"User Name\"");
let parsed = PgIdentifier::new("\"user\"\"name\"")?;
assert_eq!(parsed.as_str(), "user\"name");
assert_eq!(parsed.to_string(), "\"user\"\"name\"");
Ok(())
}
#[test]
fn quotes_reserved_or_complex_labels() {
assert!(needs_quoting("select"));
assert_eq!(quote_identifier("user\"name"), "\"user\"\"name\"");
assert_eq!(normalize_identifier("Users"), "users");
assert_eq!(normalize_identifier("order items"), "\"order items\"");
}
#[test]
fn parses_qualified_names() -> Result<(), PgIdentifierError> {
let qualified = PgQualifiedName::parse("public.users")?;
assert_eq!(qualified.parts().len(), 2);
assert_eq!(qualified.leaf().as_str(), "users");
assert_eq!(qualified.to_string(), "public.users");
assert!(PgQualifiedName::parse("public.").is_err());
Ok(())
}
}