hyperdb_api_core/protocol/escape.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SQL escaping utilities.
5//!
6//! This module provides zero-cost wrapper types for safe SQL escaping.
7//! Using the newtype pattern with [`std::fmt::Display`] ensures identifiers
8//! and literals are properly escaped at format-time without extra allocations.
9//!
10//! # Why Newtype + Display?
11//!
12//! The alternative -- a function like `fn escape_identifier(s: &str) -> String`
13//! -- allocates immediately even when the result is only used inside a larger
14//! `format!()` call. The newtype pattern defers escaping to `Display::fmt`,
15//! so the escaped output is written directly into the destination buffer.
16//! This is the same approach used by `std::path::Path::display()`.
17//!
18//! The convenience functions [`escape_identifier`] and [`escape_literal`] are
19//! provided for cases where a `String` is needed directly.
20
21use std::fmt;
22
23/// A wrapper that ensures a SQL identifier is properly escaped when formatted.
24///
25/// This is a zero-cost abstraction that performs escaping lazily during formatting.
26/// Identifiers are conditionally quoted:
27/// - Simple lowercase identifiers (`users`, `my_table`) are not quoted
28/// - Identifiers with uppercase letters are quoted to preserve case
29/// - Identifiers with special characters are quoted
30///
31/// # Example
32///
33/// ```
34/// use hyperdb_api_core::protocol::escape::SqlIdentifier;
35///
36/// // Simple identifiers are not quoted
37/// assert_eq!(format!("{}", SqlIdentifier("users")), "users");
38/// assert_eq!(format!("{}", SqlIdentifier("my_table")), "my_table");
39///
40/// // Uppercase letters are quoted to preserve case
41/// assert_eq!(format!("{}", SqlIdentifier("Segment")), "\"Segment\"");
42///
43/// // Special characters require quoting
44/// assert_eq!(format!("{}", SqlIdentifier("my-table")), "\"my-table\"");
45/// assert_eq!(format!("{}", SqlIdentifier("my table")), "\"my table\"");
46///
47/// // Internal quotes are escaped
48/// assert_eq!(format!("{}", SqlIdentifier("my\"table")), "\"my\"\"table\"");
49/// ```
50#[derive(Debug, Clone, Copy)]
51pub struct SqlIdentifier<'a>(pub &'a str);
52
53impl fmt::Display for SqlIdentifier<'_> {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 // Check if identifier needs quoting:
56 // 1. Not a valid unquoted identifier (has spaces, hyphens, etc.)
57 // 2. Contains uppercase letters (to preserve case - PostgreSQL case-folds unquoted identifiers)
58 let needs_quoting =
59 !is_valid_unquoted_identifier(self.0) || self.0.chars().any(char::is_uppercase);
60
61 if needs_quoting {
62 f.write_str("\"")?;
63 for c in self.0.chars() {
64 if c == '"' {
65 f.write_str("\"\"")?;
66 } else {
67 write!(f, "{c}")?;
68 }
69 }
70 f.write_str("\"")
71 } else {
72 f.write_str(self.0)
73 }
74 }
75}
76
77/// A wrapper that ensures a SQL string literal is properly escaped when formatted.
78///
79/// This wraps the string in single quotes and escapes any internal single quotes.
80///
81/// # Example
82///
83/// ```no_run
84/// // Marked `no_run` to dodge a Windows Defender heuristic that intermittently
85/// // refuses to launch this specific compiled doctest binary with
86/// // `ERROR_ACCESS_DENIED`. The same assertions are exercised by
87/// // `tests::test_sql_literal_display` so coverage is preserved.
88/// use hyperdb_api_core::protocol::escape::SqlLiteral;
89///
90/// assert_eq!(format!("{}", SqlLiteral("hello")), "'hello'");
91/// assert_eq!(format!("{}", SqlLiteral("it's")), "'it''s'");
92/// ```
93#[derive(Debug, Clone, Copy)]
94pub struct SqlLiteral<'a>(pub &'a str);
95
96impl fmt::Display for SqlLiteral<'_> {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str("'")?;
99 for c in self.0.chars() {
100 if c == '\'' {
101 f.write_str("''")?;
102 } else {
103 write!(f, "{c}")?;
104 }
105 }
106 f.write_str("'")
107 }
108}
109
110/// Checks if a string is a valid unquoted identifier.
111///
112/// Valid unquoted identifiers:
113/// - Start with a letter (a-z, A-Z) or underscore
114/// - Contain only letters, digits (0-9), underscores, and dollar signs
115/// - Are not SQL reserved words (this function doesn't check for reserved words)
116#[must_use]
117pub fn is_valid_unquoted_identifier(s: &str) -> bool {
118 if s.is_empty() {
119 return false;
120 }
121
122 let mut chars = s.chars();
123
124 // First character must be letter or underscore
125 match chars.next() {
126 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
127 _ => return false,
128 }
129
130 // Rest can be letters, digits, underscores, or dollar signs
131 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
132}
133
134/// Formats a qualified table name with proper escaping.
135///
136/// # Arguments
137///
138/// * `database` - Optional database name
139/// * `schema` - Optional schema name
140/// * `table` - Table name
141///
142/// # Example
143///
144/// ```
145/// use hyperdb_api_core::protocol::escape::format_table_name;
146///
147/// assert_eq!(format_table_name(None, None, "users"), "users");
148/// assert_eq!(format_table_name(None, Some("public"), "users"), "public.users");
149/// assert_eq!(format_table_name(Some("mydb"), Some("public"), "users"), "mydb.public.users");
150/// assert_eq!(format_table_name(None, None, "my-table"), "\"my-table\"");
151/// ```
152#[must_use]
153pub fn format_table_name(database: Option<&str>, schema: Option<&str>, table: &str) -> String {
154 match (database, schema) {
155 (Some(db), Some(s)) => format!(
156 "{}.{}.{}",
157 SqlIdentifier(db),
158 SqlIdentifier(s),
159 SqlIdentifier(table)
160 ),
161 (None, Some(s)) => format!("{}.{}", SqlIdentifier(s), SqlIdentifier(table)),
162 (Some(db), None) => format!("{}.{}", SqlIdentifier(db), SqlIdentifier(table)),
163 (None, None) => format!("{}", SqlIdentifier(table)),
164 }
165}
166
167// Backward compatibility functions - can be removed if not needed externally
168
169/// Escapes a SQL identifier (table name, column name, etc.).
170///
171/// This is a convenience function that returns the escaped identifier as a String.
172/// For more efficient formatting, use `SqlIdentifier` directly in format strings.
173///
174/// # Example
175///
176/// ```
177/// use hyperdb_api_core::protocol::escape::escape_identifier;
178///
179/// assert_eq!(escape_identifier("table"), "table");
180/// assert_eq!(escape_identifier("Segment"), "\"Segment\"");
181/// ```
182#[must_use]
183pub fn escape_identifier(identifier: &str) -> String {
184 format!("{}", SqlIdentifier(identifier))
185}
186
187/// A SQL identifier that is **always** quoted, whatever it contains.
188///
189/// [`SqlIdentifier`] omits the quotes when a name is already a legal bare
190/// identifier, which is fine for display but unsafe for generated DDL:
191/// [`is_valid_unquoted_identifier`] deliberately does not know the reserved
192/// word list, so an all-lowercase keyword such as `select` or `order` passes
193/// the check and is emitted bare, producing a syntax error. Quoting
194/// unconditionally sidesteps the whole question — `"users"` and `users` name
195/// the same relation, so the extra quotes never change meaning.
196///
197/// Use this for any identifier written into SQL that the engine must parse.
198///
199/// # Example
200///
201/// ```
202/// use hyperdb_api_core::protocol::escape::QuotedIdentifier;
203///
204/// // Reserved words survive, where SqlIdentifier would emit them bare
205/// assert_eq!(format!("{}", QuotedIdentifier("select")), "\"select\"");
206/// assert_eq!(format!("{}", QuotedIdentifier("users")), "\"users\"");
207/// // Internal quotes are doubled
208/// assert_eq!(format!("{}", QuotedIdentifier("a\"b")), "\"a\"\"b\"");
209/// ```
210#[derive(Debug, Clone, Copy)]
211pub struct QuotedIdentifier<'a>(pub &'a str);
212
213impl fmt::Display for QuotedIdentifier<'_> {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 f.write_str("\"")?;
216 for c in self.0.chars() {
217 if c == '"' {
218 f.write_str("\"\"")?;
219 } else {
220 write!(f, "{c}")?;
221 }
222 }
223 f.write_str("\"")
224 }
225}
226
227/// Escapes a SQL string literal.
228///
229/// This is a convenience function that returns the escaped literal as a String.
230/// For more efficient formatting, use `SqlLiteral` directly in format strings.
231///
232/// # Example
233///
234/// ```
235/// use hyperdb_api_core::protocol::escape::escape_literal;
236///
237/// assert_eq!(escape_literal("hello"), "'hello'");
238/// assert_eq!(escape_literal("it's"), "'it''s'");
239/// ```
240#[must_use]
241pub fn escape_literal(literal: &str) -> String {
242 format!("{}", SqlLiteral(literal))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_sql_identifier_display() {
251 // Valid unquoted identifiers with only lowercase should not be quoted
252 assert_eq!(format!("{}", SqlIdentifier("table")), "table");
253 assert_eq!(format!("{}", SqlIdentifier("my_table")), "my_table");
254 assert_eq!(format!("{}", SqlIdentifier("table1")), "table1");
255 assert_eq!(format!("{}", SqlIdentifier("_private")), "_private");
256 assert_eq!(format!("{}", SqlIdentifier("my$var")), "my$var");
257
258 // Identifiers with uppercase letters should be quoted to preserve case
259 assert_eq!(format!("{}", SqlIdentifier("Segment")), "\"Segment\"");
260 assert_eq!(format!("{}", SqlIdentifier("CustomerID")), "\"CustomerID\"");
261 assert_eq!(format!("{}", SqlIdentifier("Table")), "\"Table\"");
262
263 // Invalid unquoted identifiers should be quoted
264 assert_eq!(format!("{}", SqlIdentifier("my-table")), "\"my-table\"");
265 assert_eq!(format!("{}", SqlIdentifier("my table")), "\"my table\"");
266 assert_eq!(format!("{}", SqlIdentifier("1table")), "\"1table\"");
267 assert_eq!(format!("{}", SqlIdentifier("my\"table")), "\"my\"\"table\"");
268 assert_eq!(format!("{}", SqlIdentifier("")), "\"\"");
269 }
270
271 #[test]
272 fn test_sql_literal_display() {
273 assert_eq!(format!("{}", SqlLiteral("hello")), "'hello'");
274 assert_eq!(format!("{}", SqlLiteral("it's")), "'it''s'");
275 assert_eq!(format!("{}", SqlLiteral("")), "''");
276 }
277
278 #[test]
279 fn test_is_valid_unquoted_identifier() {
280 assert!(is_valid_unquoted_identifier("table"));
281 assert!(is_valid_unquoted_identifier("_private"));
282 assert!(is_valid_unquoted_identifier("table1"));
283 assert!(is_valid_unquoted_identifier("my$var"));
284
285 assert!(!is_valid_unquoted_identifier(""));
286 assert!(!is_valid_unquoted_identifier("1table"));
287 assert!(!is_valid_unquoted_identifier("my-table"));
288 assert!(!is_valid_unquoted_identifier("my table"));
289 }
290
291 #[test]
292 fn test_format_table_name() {
293 assert_eq!(format_table_name(None, None, "users"), "users");
294 assert_eq!(
295 format_table_name(None, Some("public"), "users"),
296 "public.users"
297 );
298 assert_eq!(
299 format_table_name(Some("mydb"), Some("public"), "users"),
300 "mydb.public.users"
301 );
302 // Test with names that need quoting
303 assert_eq!(format_table_name(None, None, "my-table"), "\"my-table\"");
304 assert_eq!(
305 format_table_name(None, Some("my schema"), "users"),
306 "\"my schema\".users"
307 );
308 }
309
310 #[test]
311 fn test_sql_identifier_in_format() {
312 // Demonstrate zero-allocation composability
313 let table = "users";
314 let column = "Customer ID";
315 let sql = format!(
316 "SELECT {} FROM {}",
317 SqlIdentifier(column),
318 SqlIdentifier(table)
319 );
320 assert_eq!(sql, "SELECT \"Customer ID\" FROM users");
321 }
322
323 // Backward compat function tests
324 #[test]
325 fn test_escape_identifier() {
326 assert_eq!(escape_identifier("table"), "table");
327 assert_eq!(escape_identifier("Segment"), "\"Segment\"");
328 }
329
330 #[test]
331 fn test_escape_literal() {
332 assert_eq!(escape_literal("hello"), "'hello'");
333 assert_eq!(escape_literal("it's"), "'it''s'");
334 }
335}