Skip to main content

gize_core/
validation.rs

1//! Portable names for generated Rust identifiers and unquoted SQL identifiers.
2use anyhow::{Result, ensure};
3
4const RUST_KEYWORDS: &[&str] = &[
5    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
6    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
7    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
8    "unsafe", "use", "where", "while", "abstract", "become", "box", "do", "final", "gen", "macro",
9    "override", "priv", "try", "typeof", "unsized", "virtual", "yield", "union",
10];
11
12// Words that break the unquoted SQL Gize generates (CREATE TABLE, ADD COLUMN, INSERT,
13// UPDATE ... SET, SELECT/DELETE ... WHERE, REFERENCES <table>(id)) when used as a column or
14// table name. Measured against PostgreSQL 14, MySQL 5.7 and SQLite 3.53 with
15// `scripts/probe_reserved_words.py`, plus words reserved only by newer releases:
16// PostgreSQL 16 (`system_user`) and MySQL 8.0/8.4 (refman "Keywords and Reserved Words").
17// Non-reserved keywords (`key` on PostgreSQL, `user` on SQLite, ...) remain valid names.
18const POSTGRES_RESERVED: &[&str] = &[
19    "all",
20    "analyse",
21    "analyze",
22    "and",
23    "any",
24    "array",
25    "as",
26    "asc",
27    "asymmetric",
28    "authorization",
29    "binary",
30    "both",
31    "case",
32    "cast",
33    "check",
34    "collate",
35    "collation",
36    "column",
37    "concurrently",
38    "constraint",
39    "create",
40    "cross",
41    "current_catalog",
42    "current_date",
43    "current_role",
44    "current_schema",
45    "current_time",
46    "current_timestamp",
47    "current_user",
48    "default",
49    "deferrable",
50    "desc",
51    "distinct",
52    "do",
53    "else",
54    "end",
55    "except",
56    "false",
57    "fetch",
58    "for",
59    "foreign",
60    "freeze",
61    "from",
62    "full",
63    "grant",
64    "group",
65    "having",
66    "ilike",
67    "in",
68    "initially",
69    "inner",
70    "intersect",
71    "into",
72    "is",
73    "isnull",
74    "join",
75    "lateral",
76    "leading",
77    "left",
78    "like",
79    "limit",
80    "localtime",
81    "localtimestamp",
82    "natural",
83    "not",
84    "notnull",
85    "null",
86    "offset",
87    "on",
88    "only",
89    "or",
90    "order",
91    "outer",
92    "overlaps",
93    "placing",
94    "primary",
95    "references",
96    "returning",
97    "right",
98    "select",
99    "session_user",
100    "similar",
101    "some",
102    "symmetric",
103    "system_user",
104    "table",
105    "tablesample",
106    "then",
107    "to",
108    "trailing",
109    "true",
110    "union",
111    "unique",
112    "user",
113    "using",
114    "variadic",
115    "verbose",
116    "when",
117    "where",
118    "window",
119    "with",
120];
121const SQLITE_RESERVED: &[&str] = &[
122    "add",
123    "all",
124    "alter",
125    "and",
126    "as",
127    "autoincrement",
128    "between",
129    "case",
130    "check",
131    "collate",
132    "commit",
133    "constraint",
134    "create",
135    "default",
136    "deferrable",
137    "delete",
138    "distinct",
139    "drop",
140    "else",
141    "escape",
142    "except",
143    "exists",
144    "foreign",
145    "from",
146    "group",
147    "having",
148    "if",
149    "in",
150    "index",
151    "insert",
152    "intersect",
153    "into",
154    "is",
155    "isnull",
156    "join",
157    "limit",
158    "not",
159    "nothing",
160    "notnull",
161    "null",
162    "on",
163    "or",
164    "order",
165    "primary",
166    "references",
167    "returning",
168    "select",
169    "set",
170    "table",
171    "then",
172    "to",
173    "transaction",
174    "union",
175    "unique",
176    "update",
177    "using",
178    "values",
179    "when",
180    "where",
181];
182const MYSQL_RESERVED: &[&str] = &[
183    "accessible",
184    "add",
185    "all",
186    "alter",
187    "analyze",
188    "and",
189    "as",
190    "asc",
191    "asensitive",
192    "before",
193    "between",
194    "bigint",
195    "binary",
196    "blob",
197    "both",
198    "by",
199    "call",
200    "cascade",
201    "case",
202    "cast",
203    "change",
204    "char",
205    "character",
206    "check",
207    "collate",
208    "column",
209    "condition",
210    "constraint",
211    "continue",
212    "convert",
213    "create",
214    "cross",
215    "cube",
216    "cume_dist",
217    "current_date",
218    "current_time",
219    "current_timestamp",
220    "current_user",
221    "cursor",
222    "database",
223    "databases",
224    "day_hour",
225    "day_microsecond",
226    "day_minute",
227    "day_second",
228    "dec",
229    "decimal",
230    "declare",
231    "default",
232    "delayed",
233    "delete",
234    "dense_rank",
235    "desc",
236    "describe",
237    "deterministic",
238    "distinct",
239    "distinctrow",
240    "div",
241    "double",
242    "drop",
243    "dual",
244    "each",
245    "else",
246    "elseif",
247    "empty",
248    "enclosed",
249    "escaped",
250    "except",
251    "exists",
252    "exit",
253    "explain",
254    "extract",
255    "false",
256    "fetch",
257    "first_value",
258    "float",
259    "float4",
260    "float8",
261    "for",
262    "force",
263    "foreign",
264    "from",
265    "fulltext",
266    "function",
267    "generated",
268    "get",
269    "grant",
270    "group",
271    "grouping",
272    "groups",
273    "having",
274    "high_priority",
275    "hour_microsecond",
276    "hour_minute",
277    "hour_second",
278    "if",
279    "ignore",
280    "in",
281    "index",
282    "infile",
283    "inner",
284    "inout",
285    "insensitive",
286    "insert",
287    "int",
288    "int1",
289    "int2",
290    "int3",
291    "int4",
292    "int8",
293    "integer",
294    "intersect",
295    "interval",
296    "into",
297    "io_after_gtids",
298    "io_before_gtids",
299    "is",
300    "iterate",
301    "join",
302    "json_table",
303    "key",
304    "keys",
305    "kill",
306    "lag",
307    "last_value",
308    "lateral",
309    "lead",
310    "leading",
311    "leave",
312    "left",
313    "like",
314    "limit",
315    "linear",
316    "lines",
317    "load",
318    "localtime",
319    "localtimestamp",
320    "lock",
321    "long",
322    "longblob",
323    "longtext",
324    "loop",
325    "low_priority",
326    "manual",
327    "master_bind",
328    "master_ssl_verify_server_cert",
329    "match",
330    "maxvalue",
331    "mediumblob",
332    "mediumint",
333    "mediumtext",
334    "middleint",
335    "minute_microsecond",
336    "minute_second",
337    "mod",
338    "modifies",
339    "natural",
340    "no_write_to_binlog",
341    "not",
342    "nth_value",
343    "ntile",
344    "null",
345    "numeric",
346    "of",
347    "on",
348    "optimize",
349    "optimizer_costs",
350    "option",
351    "optionally",
352    "or",
353    "order",
354    "out",
355    "outer",
356    "outfile",
357    "over",
358    "parallel",
359    "partition",
360    "percent_rank",
361    "position",
362    "precision",
363    "primary",
364    "procedure",
365    "purge",
366    "qualify",
367    "range",
368    "rank",
369    "read",
370    "read_write",
371    "reads",
372    "real",
373    "recursive",
374    "references",
375    "regexp",
376    "release",
377    "rename",
378    "repeat",
379    "replace",
380    "require",
381    "resignal",
382    "restrict",
383    "return",
384    "revoke",
385    "right",
386    "rlike",
387    "row",
388    "row_number",
389    "rows",
390    "schema",
391    "schemas",
392    "second_microsecond",
393    "select",
394    "sensitive",
395    "separator",
396    "set",
397    "show",
398    "signal",
399    "smallint",
400    "spatial",
401    "specific",
402    "sql",
403    "sql_big_result",
404    "sql_calc_found_rows",
405    "sql_small_result",
406    "sqlexception",
407    "sqlstate",
408    "sqlwarning",
409    "ssl",
410    "starting",
411    "stored",
412    "straight_join",
413    "substring",
414    "system",
415    "table",
416    "tablesample",
417    "terminated",
418    "then",
419    "tinyblob",
420    "tinyint",
421    "tinytext",
422    "to",
423    "trailing",
424    "trigger",
425    "trim",
426    "true",
427    "undo",
428    "union",
429    "unique",
430    "unlock",
431    "unsigned",
432    "update",
433    "usage",
434    "use",
435    "using",
436    "utc_date",
437    "utc_time",
438    "utc_timestamp",
439    "values",
440    "varbinary",
441    "varchar",
442    "varcharacter",
443    "varying",
444    "virtual",
445    "when",
446    "where",
447    "while",
448    "window",
449    "with",
450    "write",
451    "xor",
452    "year_month",
453    "zerofill",
454];
455
456pub fn validate_identifier(name: &str, kind: &str) -> Result<()> {
457    ensure!(
458        name != "_"
459            && !name.is_empty()
460            && name
461                .bytes()
462                .next()
463                .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
464            && name
465                .bytes()
466                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'),
467        "invalid {kind} `{name}`: use a lowercase ASCII snake_case identifier, starting with a letter or underscore"
468    );
469    ensure!(
470        name.len() <= 63,
471        "invalid {kind} `{name}`: maximum identifier length is 63 bytes"
472    );
473    ensure!(
474        !RUST_KEYWORDS.contains(&name),
475        "invalid {kind} `{name}`: reserved Rust keyword"
476    );
477    Ok(())
478}
479
480/// Check unquoted SQL names only when the target database is known.
481pub fn validate_sql_identifier_for_dialect(
482    name: &str,
483    kind: &str,
484    dialect: crate::Dialect,
485) -> Result<()> {
486    validate_identifier(name, kind)?;
487    let reserved = match dialect {
488        crate::Dialect::Postgres => POSTGRES_RESERVED,
489        crate::Dialect::Sqlite => SQLITE_RESERVED,
490        crate::Dialect::MySql => MYSQL_RESERVED,
491    };
492    ensure!(
493        !reserved.contains(&name),
494        "invalid {kind} `{name}`: reserved SQL keyword for {dialect}; choose another name"
495    );
496    Ok(())
497}
498
499pub fn validate_column(name: &str) -> Result<()> {
500    validate_identifier(name, "field")?;
501    ensure!(
502        !["id", "created_at", "updated_at"].contains(&name),
503        "invalid field `{name}`: automatically generated by Gize"
504    );
505    Ok(())
506}
507
508pub fn validate_model_name(name: &str) -> Result<()> {
509    ensure!(
510        !RUST_KEYWORDS.contains(&name),
511        "invalid model `{name}`: reserved Rust keyword"
512    );
513    ensure!(
514        name.bytes().next().is_some_and(|b| b.is_ascii_uppercase())
515            && name.bytes().all(|b| b.is_ascii_alphanumeric()),
516        "invalid model `{name}`: use an ASCII PascalCase name, such as BlogPost"
517    );
518    ensure!(
519        ![
520            "String",
521            "Vec",
522            "Result",
523            "Error",
524            "State",
525            "AppState",
526            "StatusCode",
527            "Json",
528            "Serialize",
529            "Deserialize"
530        ]
531        .contains(&name),
532        "invalid model `{name}`: conflicts with a type used by generated code"
533    );
534    validate_identifier(&crate::naming::table_name(name), "generated table")
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::Dialect;
541
542    #[test]
543    fn reserved_words_follow_the_selected_database() {
544        // Each rejected word was confirmed to break generated SQL on a real server.
545        for (dialect, rejected, accepted) in [
546            (
547                Dialect::Postgres,
548                &[
549                    "array", "only", "cast", "analyze", "binary", "user", "order",
550                ][..],
551                &["key", "index", "database", "values", "rows", "range"][..],
552            ),
553            (
554                Dialect::MySql,
555                &[
556                    "range", "interval", "schema", "read", "lock", "show", "rank", "rows",
557                ][..],
558                &["user", "returning", "offset", "array", "only"][..],
559            ),
560            (
561                Dialect::Sqlite,
562                &["index", "values", "order", "transaction", "commit"][..],
563                &["key", "database", "user", "rows", "range", "offset"][..],
564            ),
565        ] {
566            for name in rejected {
567                let error = validate_sql_identifier_for_dialect(name, "field", dialect)
568                    .unwrap_err()
569                    .to_string();
570                assert!(
571                    error.contains("reserved SQL keyword"),
572                    "{dialect:?}: {error}"
573                );
574            }
575            for name in accepted {
576                validate_sql_identifier_for_dialect(name, "field", dialect)
577                    .unwrap_or_else(|e| panic!("{dialect:?}: {e}"));
578            }
579        }
580    }
581
582    #[test]
583    fn reserved_lists_are_sorted_lowercase_identifiers() {
584        for list in [POSTGRES_RESERVED, SQLITE_RESERVED, MYSQL_RESERVED] {
585            assert!(list.windows(2).all(|w| w[0] < w[1]));
586            assert!(list.iter().all(|w| validate_identifier(w, "keyword").is_ok()
587                || RUST_KEYWORDS.contains(w)));
588        }
589    }
590}