Skip to main content

pg2sqlite_core/ir/
ident.rs

1//! Identifier types for PostgreSQL and SQLite DDL objects.
2
3/// SQLite reserved keywords that require quoting.
4const SQLITE_RESERVED: &[&str] = &[
5    "abort",
6    "action",
7    "add",
8    "after",
9    "all",
10    "alter",
11    "always",
12    "analyze",
13    "and",
14    "as",
15    "asc",
16    "attach",
17    "autoincrement",
18    "before",
19    "begin",
20    "between",
21    "by",
22    "cascade",
23    "case",
24    "cast",
25    "check",
26    "collate",
27    "column",
28    "commit",
29    "conflict",
30    "constraint",
31    "create",
32    "cross",
33    "current",
34    "current_date",
35    "current_time",
36    "current_timestamp",
37    "database",
38    "default",
39    "deferrable",
40    "deferred",
41    "delete",
42    "desc",
43    "detach",
44    "distinct",
45    "do",
46    "drop",
47    "each",
48    "else",
49    "end",
50    "escape",
51    "except",
52    "exclude",
53    "exclusive",
54    "exists",
55    "explain",
56    "fail",
57    "filter",
58    "first",
59    "following",
60    "for",
61    "foreign",
62    "from",
63    "full",
64    "generated",
65    "glob",
66    "group",
67    "groups",
68    "having",
69    "if",
70    "ignore",
71    "immediate",
72    "in",
73    "index",
74    "indexed",
75    "initially",
76    "inner",
77    "insert",
78    "instead",
79    "intersect",
80    "into",
81    "is",
82    "isnull",
83    "join",
84    "key",
85    "last",
86    "left",
87    "like",
88    "limit",
89    "match",
90    "materialized",
91    "natural",
92    "no",
93    "not",
94    "nothing",
95    "notnull",
96    "null",
97    "nulls",
98    "of",
99    "offset",
100    "on",
101    "or",
102    "order",
103    "others",
104    "outer",
105    "over",
106    "partition",
107    "plan",
108    "pragma",
109    "preceding",
110    "primary",
111    "query",
112    "raise",
113    "range",
114    "recursive",
115    "references",
116    "regexp",
117    "reindex",
118    "release",
119    "rename",
120    "replace",
121    "restrict",
122    "returning",
123    "right",
124    "rollback",
125    "row",
126    "rows",
127    "savepoint",
128    "select",
129    "set",
130    "table",
131    "temp",
132    "temporary",
133    "then",
134    "ties",
135    "to",
136    "transaction",
137    "trigger",
138    "unbounded",
139    "union",
140    "unique",
141    "update",
142    "using",
143    "vacuum",
144    "values",
145    "view",
146    "virtual",
147    "when",
148    "where",
149    "window",
150    "with",
151    "without",
152];
153
154/// An identifier with both raw (original) and normalized (lowercase) forms.
155#[derive(Debug, Clone, PartialEq, Eq, Hash)]
156pub struct Ident {
157    /// Original form as written in source DDL.
158    pub raw: String,
159    /// Normalized form (lowercased for unquoted identifiers).
160    pub normalized: String,
161}
162
163impl Ident {
164    /// Create an identifier from an unquoted name (normalizes to lowercase).
165    pub fn new(name: &str) -> Self {
166        Self {
167            raw: name.to_string(),
168            normalized: name.to_lowercase(),
169        }
170    }
171
172    /// Create an identifier from a quoted name (preserves case).
173    pub fn quoted(name: &str) -> Self {
174        Self {
175            raw: name.to_string(),
176            normalized: name.to_string(),
177        }
178    }
179
180    /// Check if this identifier needs quoting in SQLite output.
181    pub fn needs_quotes(&self) -> bool {
182        let n = &self.normalized;
183
184        // Empty identifiers need quotes
185        if n.is_empty() {
186            return true;
187        }
188
189        // Starts with digit
190        if n.starts_with(|c: char| c.is_ascii_digit()) {
191            return true;
192        }
193
194        // Contains uppercase, spaces, hyphens, or other special chars
195        if n.chars()
196            .any(|c| c.is_ascii_uppercase() || c == ' ' || c == '-')
197        {
198            return true;
199        }
200
201        // Contains non-alphanumeric/underscore chars
202        if n.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_') {
203            return true;
204        }
205
206        // Is a SQLite reserved keyword
207        if SQLITE_RESERVED.contains(&n.as_str()) {
208            return true;
209        }
210
211        false
212    }
213
214    /// Render the identifier for SQLite output, quoting if necessary.
215    pub fn to_sql(&self) -> String {
216        if self.needs_quotes() {
217            format!("\"{}\"", self.normalized.replace('"', "\"\""))
218        } else {
219            self.normalized.clone()
220        }
221    }
222}
223
224impl std::fmt::Display for Ident {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "{}", self.normalized)
227    }
228}
229
230/// A schema-qualified name (e.g., `public.users`).
231#[derive(Debug, Clone, PartialEq, Eq, Hash)]
232pub struct QualifiedName {
233    pub schema: Option<Ident>,
234    pub name: Ident,
235}
236
237impl QualifiedName {
238    pub fn new(name: Ident) -> Self {
239        Self { schema: None, name }
240    }
241
242    pub fn with_schema(schema: Ident, name: Ident) -> Self {
243        Self {
244            schema: Some(schema),
245            name,
246        }
247    }
248
249    /// Get the table name for SQLite output (no schema prefix).
250    pub fn to_sql(&self) -> String {
251        self.name.to_sql()
252    }
253
254    /// Compare by name only, treating `None` schema as matching any schema.
255    /// Use this when matching ALTER TABLE targets against CREATE TABLE names,
256    /// since normalize may leave schemas inconsistent (e.g., unqualified CREATE
257    /// vs schema-qualified ALTER).
258    pub fn name_eq(&self, other: &QualifiedName) -> bool {
259        self.name == other.name
260    }
261}
262
263impl std::fmt::Display for QualifiedName {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        if let Some(schema) = &self.schema {
266            write!(f, "{}.{}", schema, self.name)
267        } else {
268            write!(f, "{}", self.name)
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_ident_new_normalizes() {
279        let id = Ident::new("MyTable");
280        assert_eq!(id.normalized, "mytable");
281        assert_eq!(id.raw, "MyTable");
282    }
283
284    #[test]
285    fn test_ident_quoted_preserves() {
286        let id = Ident::quoted("MyTable");
287        assert_eq!(id.normalized, "MyTable");
288    }
289
290    #[test]
291    fn test_needs_quotes_reserved() {
292        assert!(Ident::new("select").needs_quotes());
293        assert!(Ident::new("table").needs_quotes());
294    }
295
296    #[test]
297    fn test_needs_quotes_special_chars() {
298        assert!(Ident::quoted("My-Column").needs_quotes());
299        assert!(Ident::quoted("Has Space").needs_quotes());
300    }
301
302    #[test]
303    fn test_no_quotes_simple() {
304        assert!(!Ident::new("users").needs_quotes());
305        assert!(!Ident::new("user_id").needs_quotes());
306    }
307
308    #[test]
309    fn test_to_sql() {
310        assert_eq!(Ident::new("users").to_sql(), "users");
311        assert_eq!(Ident::new("select").to_sql(), "\"select\"");
312        assert_eq!(Ident::quoted("MyTable").to_sql(), "\"MyTable\"");
313    }
314
315    #[test]
316    fn test_starts_with_digit() {
317        assert!(Ident::new("1col").needs_quotes());
318    }
319}