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
255impl std::fmt::Display for QualifiedName {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        if let Some(schema) = &self.schema {
258            write!(f, "{}.{}", schema, self.name)
259        } else {
260            write!(f, "{}", self.name)
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_ident_new_normalizes() {
271        let id = Ident::new("MyTable");
272        assert_eq!(id.normalized, "mytable");
273        assert_eq!(id.raw, "MyTable");
274    }
275
276    #[test]
277    fn test_ident_quoted_preserves() {
278        let id = Ident::quoted("MyTable");
279        assert_eq!(id.normalized, "MyTable");
280    }
281
282    #[test]
283    fn test_needs_quotes_reserved() {
284        assert!(Ident::new("select").needs_quotes());
285        assert!(Ident::new("table").needs_quotes());
286    }
287
288    #[test]
289    fn test_needs_quotes_special_chars() {
290        assert!(Ident::quoted("My-Column").needs_quotes());
291        assert!(Ident::quoted("Has Space").needs_quotes());
292    }
293
294    #[test]
295    fn test_no_quotes_simple() {
296        assert!(!Ident::new("users").needs_quotes());
297        assert!(!Ident::new("user_id").needs_quotes());
298    }
299
300    #[test]
301    fn test_to_sql() {
302        assert_eq!(Ident::new("users").to_sql(), "users");
303        assert_eq!(Ident::new("select").to_sql(), "\"select\"");
304        assert_eq!(Ident::quoted("MyTable").to_sql(), "\"MyTable\"");
305    }
306
307    #[test]
308    fn test_starts_with_digit() {
309        assert!(Ident::new("1col").needs_quotes());
310    }
311}