Skip to main content

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}