1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum PgIdentifierStyle {
10 #[default]
12 Unquoted,
13 Quoted,
15}
16
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct PgIdentifier {
20 text: String,
21 style: PgIdentifierStyle,
22}
23
24impl PgIdentifier {
25 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 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 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 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 #[must_use]
98 pub fn as_str(&self) -> &str {
99 &self.text
100 }
101
102 #[must_use]
104 pub const fn style(&self) -> PgIdentifierStyle {
105 self.style
106 }
107
108 #[must_use]
110 pub const fn is_quoted(&self) -> bool {
111 matches!(self.style, PgIdentifierStyle::Quoted)
112 }
113
114 #[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("e_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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub struct PgQualifiedName {
155 parts: Vec<PgIdentifier>,
156}
157
158impl PgQualifiedName {
159 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 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 #[must_use]
192 pub fn schema_object(schema: PgIdentifier, object: PgIdentifier) -> Self {
193 Self {
194 parts: vec![schema, object],
195 }
196 }
197
198 #[must_use]
200 pub fn parts(&self) -> &[PgIdentifier] {
201 &self.parts
202 }
203
204 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum PgIdentifierError {
243 Empty,
245 ContainsDot,
247 EmptyQualifiedName,
249 InvalidStart {
251 character: char,
253 },
254 InvalidCharacter {
256 index: usize,
258 character: char,
260 },
261 ControlCharacter {
263 index: usize,
265 character: char,
267 },
268 ReservedKeyword,
270 UnterminatedQuotedIdentifier,
272 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#[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#[must_use]
323pub fn needs_quoting(input: &str) -> bool {
324 !is_valid_unquoted_identifier(input)
325}
326
327#[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#[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}