Skip to main content

sqlx_gen/codegen/
naming.rs

1//! Convert SQL identifiers to Rust-idiomatic forms.
2//!
3//! The primary entry point is [`singularize`], which turns a (potentially
4//! snake_case) plural noun into its singular form so that table names like
5//! `users` produce `User` instead of `Users`. Rust ORMs (Diesel, SeaORM) and
6//! the broader ActiveRecord-style world expect singular row structs.
7
8/// English nouns whose plural and singular forms are identical, or that
9/// happen to end in `s` but are not plural. Kept short on purpose — anything
10/// off this list falls back to the regular rules.
11const UNCOUNTABLES: &[&str] = &[
12    "data",
13    "news",
14    "equipment",
15    "information",
16    "software",
17    "statistics",
18    "analytics",
19    "series",
20    "species",
21    "means",
22    "audio",
23    "video",
24    "metadata",
25];
26
27fn is_uncountable(word: &str) -> bool {
28    let lower = word.to_ascii_lowercase();
29    UNCOUNTABLES.contains(&lower.as_str())
30}
31
32/// Singularize a single English word. Conservative: when in doubt the word is
33/// returned unchanged so we never butcher a perfectly good singular noun.
34pub fn singularize_word(word: &str) -> String {
35    if word.is_empty() || is_uncountable(word) {
36        return word.to_string();
37    }
38    let lower = word.to_ascii_lowercase();
39
40    // categories → category
41    if lower.ends_with("ies") && word.len() > 3 {
42        return format!("{}y", &word[..word.len() - 3]);
43    }
44
45    // boxes → box, churches → church, dishes → dish, classes → class
46    if lower.ends_with("xes")
47        || lower.ends_with("ches")
48        || lower.ends_with("shes")
49        || lower.ends_with("sses")
50        || lower.ends_with("zes")
51    {
52        return word[..word.len() - 2].to_string();
53    }
54
55    // Latin imports: leave alone (analysis, basis, radius, status, virus).
56    if lower.ends_with("is") || lower.ends_with("us") {
57        return word.to_string();
58    }
59
60    // users → user, houses → house. Skip "address", "process" etc. (ss).
61    if lower.ends_with('s') && !lower.ends_with("ss") {
62        return word[..word.len() - 1].to_string();
63    }
64
65    word.to_string()
66}
67
68/// Singularize a snake_case identifier by transforming only the trailing
69/// word: `user_accounts` → `user_account`, `news_posts` → `news_post`.
70pub fn singularize(name: &str) -> String {
71    if let Some(idx) = name.rfind('_') {
72        let (prefix, last) = name.split_at(idx + 1);
73        return format!("{}{}", prefix, singularize_word(last));
74    }
75    singularize_word(name)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn strips_trailing_s() {
84        assert_eq!(singularize_word("users"), "user");
85        assert_eq!(singularize_word("posts"), "post");
86        assert_eq!(singularize_word("voices"), "voice");
87    }
88
89    #[test]
90    fn handles_ies_to_y() {
91        assert_eq!(singularize_word("categories"), "category");
92        assert_eq!(singularize_word("queries"), "query");
93        assert_eq!(singularize_word("cities"), "city");
94    }
95
96    #[test]
97    fn handles_xes_ches_shes_sses() {
98        assert_eq!(singularize_word("boxes"), "box");
99        assert_eq!(singularize_word("churches"), "church");
100        assert_eq!(singularize_word("dishes"), "dish");
101        assert_eq!(singularize_word("classes"), "class");
102        assert_eq!(singularize_word("addresses"), "address");
103    }
104
105    #[test]
106    fn preserves_uncountables() {
107        assert_eq!(singularize_word("data"), "data");
108        assert_eq!(singularize_word("news"), "news");
109        assert_eq!(singularize_word("series"), "series");
110        assert_eq!(singularize_word("metadata"), "metadata");
111    }
112
113    #[test]
114    fn preserves_latin_endings() {
115        assert_eq!(singularize_word("analysis"), "analysis");
116        assert_eq!(singularize_word("basis"), "basis");
117        assert_eq!(singularize_word("status"), "status");
118        assert_eq!(singularize_word("virus"), "virus");
119    }
120
121    #[test]
122    fn preserves_double_s_words() {
123        assert_eq!(singularize_word("address"), "address");
124        assert_eq!(singularize_word("process"), "process");
125        assert_eq!(singularize_word("class"), "class");
126    }
127
128    #[test]
129    fn preserves_already_singular() {
130        assert_eq!(singularize_word("user"), "user");
131        assert_eq!(singularize_word("category"), "category");
132        assert_eq!(singularize_word("box"), "box");
133    }
134
135    #[test]
136    fn preserves_empty() {
137        assert_eq!(singularize_word(""), "");
138    }
139
140    #[test]
141    fn singularize_snake_case_targets_last_word() {
142        assert_eq!(singularize("user_accounts"), "user_account");
143        assert_eq!(singularize("agent_connector"), "agent_connector");
144        assert_eq!(singularize("audit_logs"), "audit_log");
145        assert_eq!(singularize("category_translations"), "category_translation");
146    }
147
148    #[test]
149    fn singularize_preserves_double_underscore() {
150        // The user's real-world junction table form.
151        assert_eq!(singularize("agent__connector"), "agent__connector");
152        assert_eq!(singularize("agent__connectors"), "agent__connector");
153    }
154
155    #[test]
156    fn singularize_uppercase_unaffected_by_lower_check() {
157        // We normalize to ASCII lowercase internally; original case preserved.
158        assert_eq!(singularize_word("USERS"), "USER");
159    }
160}