Skip to main content

vespertide_naming/
lib.rs

1//! Naming conventions and helpers for vespertide database schema management.
2//!
3//! This crate provides consistent naming functions for database objects like
4//! indexes, constraints, and foreign keys. It has no dependencies and can be
5//! used by any other vespertide crate.
6
7// ============================================================================
8// Relation Naming (for ORM exporters)
9// ============================================================================
10
11/// Extract semantic prefix from FK column for reverse relation naming.
12///
13/// Given an FK column name, the current (target) table name, and the referenced
14/// column name (e.g., "id", "idx"), extracts the semantic role portion.
15///
16/// # Arguments
17/// * `fk_column` - The FK column name (e.g., "user_id", "answered_by_user_id", "author_id")
18/// * `current_table` - The table being referenced (e.g., "user")
19/// * `ref_column` - The referenced column name (e.g., "id", "idx", "pk")
20///
21/// # Returns
22/// The semantic prefix (empty string for default FK, or the role/prefix for others)
23///
24/// # Examples
25/// ```
26/// use vespertide_naming::extract_relation_prefix;
27///
28/// // Default FK: column matches table name + ref_column suffix
29/// assert_eq!(extract_relation_prefix("user_id", "user", "id"), "");
30/// assert_eq!(extract_relation_prefix("user_idx", "user", "idx"), "");
31///
32/// // Prefixed FK: has semantic prefix before table name
33/// assert_eq!(extract_relation_prefix("answered_by_user_id", "user", "id"), "answered_by");
34/// assert_eq!(extract_relation_prefix("target_user_id", "user", "id"), "target");
35///
36/// // Role FK: column doesn't end with table name
37/// assert_eq!(extract_relation_prefix("author_id", "user", "id"), "author");
38/// assert_eq!(extract_relation_prefix("owner_id", "user", "id"), "owner");
39/// ```
40pub fn extract_relation_prefix(fk_column: &str, current_table: &str, ref_column: &str) -> String {
41    // Build the suffix to strip: _{ref_column} (e.g., "_id", "_idx")
42    let ref_suffix = format!("_{}", ref_column);
43
44    // Remove the ref_column suffix if present
45    let without_ref = if fk_column.ends_with(&ref_suffix) {
46        &fk_column[..fk_column.len() - ref_suffix.len()]
47    } else {
48        fk_column
49    };
50
51    let current_lower = current_table.to_lowercase();
52    let without_ref_lower = without_ref.to_lowercase();
53
54    // Case 1: FK column exactly matches current table (e.g., "user_id" for table "user")
55    // This is the "default" FK - return empty prefix
56    if without_ref_lower == current_lower {
57        return String::new();
58    }
59
60    // Case 2: FK column ends with _{current_table} (e.g., "answered_by_user_id" for table "user")
61    // Strip the _{table} suffix to get the semantic prefix
62    let table_suffix = format!("_{}", current_lower);
63    if without_ref_lower.ends_with(&table_suffix) {
64        let prefix_len = without_ref.len() - table_suffix.len();
65        return without_ref[..prefix_len].to_string();
66    }
67
68    // Case 3: FK column is a different role (e.g., "author_id" for table "user")
69    // Use the column name as the prefix
70    without_ref.to_string()
71}
72
73/// Generate reverse relation field name for has_many/has_one relations.
74///
75/// # Arguments
76/// * `fk_columns` - The FK column names
77/// * `current_table` - The table being referenced (e.g., "user")
78/// * `source_table` - The table that has the FK (e.g., "inquiry")
79/// * `ref_column` - The referenced column name (e.g., "id")
80/// * `has_multiple_fks` - Whether source_table has multiple FKs to current_table
81/// * `is_one_to_one` - Whether this is a has_one relation
82///
83/// # Returns
84/// The field name (e.g., "inquiries", "answered_by_inquiries")
85pub fn build_reverse_relation_field_name(
86    fk_columns: &[String],
87    current_table: &str,
88    source_table: &str,
89    ref_column: &str,
90    has_multiple_fks: bool,
91    is_one_to_one: bool,
92) -> String {
93    let base_name = if is_one_to_one {
94        source_table.to_string()
95    } else {
96        pluralize(source_table)
97    };
98
99    if !has_multiple_fks || fk_columns.is_empty() {
100        return base_name;
101    }
102
103    let prefix = extract_relation_prefix(&fk_columns[0], current_table, ref_column);
104
105    if prefix.is_empty() {
106        base_name
107    } else {
108        format!("{}_{}", prefix, base_name)
109    }
110}
111
112/// Generate relation enum name for FK relations.
113///
114/// Uses the same logic as field naming but converts to PascalCase.
115/// This ensures relation_enum aligns with field names for consistency.
116///
117/// # Examples
118/// ```
119/// use vespertide_naming::build_relation_enum_name;
120///
121/// assert_eq!(build_relation_enum_name(&["user_id".into()], "user", "id"), "");
122/// assert_eq!(build_relation_enum_name(&["answered_by_user_id".into()], "user", "id"), "AnsweredBy");
123/// assert_eq!(build_relation_enum_name(&["author_id".into()], "user", "id"), "Author");
124/// ```
125pub fn build_relation_enum_name(
126    fk_columns: &[String],
127    current_table: &str,
128    ref_column: &str,
129) -> String {
130    if fk_columns.is_empty() {
131        return String::new();
132    }
133
134    let prefix = extract_relation_prefix(&fk_columns[0], current_table, ref_column);
135
136    if prefix.is_empty() {
137        String::new()
138    } else {
139        to_pascal_case(&prefix)
140    }
141}
142
143/// Convert snake_case to PascalCase.
144///
145/// # Examples
146/// ```
147/// use vespertide_naming::to_pascal_case;
148///
149/// assert_eq!(to_pascal_case("hello_world"), "HelloWorld");
150/// assert_eq!(to_pascal_case("answered_by"), "AnsweredBy");
151/// assert_eq!(to_pascal_case("user"), "User");
152/// ```
153pub fn to_pascal_case(s: &str) -> String {
154    let mut result = String::new();
155    let mut capitalize = true;
156    for c in s.chars() {
157        let is_separator = c == '_' || c == '-';
158        if is_separator {
159            capitalize = true;
160            continue;
161        }
162        let ch = if capitalize {
163            c.to_ascii_uppercase()
164        } else {
165            c
166        };
167        capitalize = false;
168        result.push(ch);
169    }
170    result
171}
172
173/// Simple pluralization for relation field names.
174///
175/// # Examples
176/// ```
177/// use vespertide_naming::pluralize;
178///
179/// assert_eq!(pluralize("inquiry"), "inquiries");
180/// assert_eq!(pluralize("comment"), "comments");
181/// assert_eq!(pluralize("status"), "status");
182/// ```
183pub fn pluralize(name: &str) -> String {
184    if name.ends_with('s') || name.ends_with("es") {
185        name.to_string()
186    } else if name.ends_with('y')
187        && !name.ends_with("ay")
188        && !name.ends_with("ey")
189        && !name.ends_with("oy")
190        && !name.ends_with("uy")
191    {
192        // e.g., category -> categories, inquiry -> inquiries
193        format!("{}ies", &name[..name.len() - 1])
194    } else {
195        format!("{}s", name)
196    }
197}
198
199// ============================================================================
200// Constraint Naming (for SQL generation)
201// ============================================================================
202
203/// Generate index name from table name, columns, and optional user-provided key.
204/// Always includes table name to avoid conflicts across tables.
205/// Uses double underscore to separate table name from the rest.
206/// Format: ix_{table}__{key} or ix_{table}__{col1}_{col2}...
207pub fn build_index_name(table: &str, columns: &[String], key: Option<&str>) -> String {
208    match key {
209        Some(k) => format!("ix_{}__{}", table, k),
210        None => format!("ix_{}__{}", table, columns.join("_")),
211    }
212}
213
214/// Generate unique constraint name from table name, columns, and optional user-provided key.
215/// Always includes table name to avoid conflicts across tables.
216/// Uses double underscore to separate table name from the rest.
217/// Format: uq_{table}__{key} or uq_{table}__{col1}_{col2}...
218pub fn build_unique_constraint_name(table: &str, columns: &[String], key: Option<&str>) -> String {
219    match key {
220        Some(k) => format!("uq_{}__{}", table, k),
221        None => format!("uq_{}__{}", table, columns.join("_")),
222    }
223}
224
225/// Generate foreign key constraint name from table name, columns, and optional user-provided key.
226/// Always includes table name to avoid conflicts across tables.
227/// Uses double underscore to separate table name from the rest.
228/// Format: fk_{table}__{key} or fk_{table}__{col1}_{col2}...
229pub fn build_foreign_key_name(table: &str, columns: &[String], key: Option<&str>) -> String {
230    match key {
231        Some(k) => format!("fk_{}__{}", table, k),
232        None => format!("fk_{}__{}", table, columns.join("_")),
233    }
234}
235
236/// Generate CHECK constraint name for SQLite enum column.
237/// Uses double underscore to separate table name from the rest.
238/// Format: chk_{table}__{column}
239pub fn build_check_constraint_name(table: &str, column: &str) -> String {
240    format!("chk_{}__{}", table, column)
241}
242
243/// Generate enum type name with table prefix to avoid conflicts.
244/// Always includes table name to ensure uniqueness across tables.
245/// Format: {table}_{enum_name}
246///
247/// This prevents conflicts when multiple tables use the same enum name
248/// (e.g., "status" or "gender") with potentially different values.
249pub fn build_enum_type_name(table: &str, enum_name: &str) -> String {
250    format!("{}_{}", table, enum_name)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // ========================================================================
258    // Relation Naming Tests
259    // ========================================================================
260
261    #[test]
262    fn test_extract_relation_prefix_default_fk() {
263        // Default FK: column matches table name + ref_column suffix
264        assert_eq!(extract_relation_prefix("user_id", "user", "id"), "");
265        assert_eq!(extract_relation_prefix("org_id", "org", "id"), "");
266        assert_eq!(extract_relation_prefix("post_id", "post", "id"), "");
267    }
268
269    #[test]
270    fn test_extract_relation_prefix_different_ref_column() {
271        // Handle different ref_column suffixes (not just _id)
272        assert_eq!(extract_relation_prefix("user_idx", "user", "idx"), "");
273        assert_eq!(extract_relation_prefix("user_pk", "user", "pk"), "");
274        assert_eq!(extract_relation_prefix("user_key", "user", "key"), "");
275    }
276
277    #[test]
278    fn test_extract_relation_prefix_semantic_prefix() {
279        // Prefixed FK: has semantic prefix before table name
280        assert_eq!(
281            extract_relation_prefix("answered_by_user_id", "user", "id"),
282            "answered_by"
283        );
284        assert_eq!(
285            extract_relation_prefix("created_by_user_id", "user", "id"),
286            "created_by"
287        );
288        assert_eq!(
289            extract_relation_prefix("target_user_id", "user", "id"),
290            "target"
291        );
292        assert_eq!(
293            extract_relation_prefix("parent_org_id", "org", "id"),
294            "parent"
295        );
296    }
297
298    #[test]
299    fn test_extract_relation_prefix_role_fk() {
300        // Role FK: column doesn't end with table name
301        assert_eq!(extract_relation_prefix("author_id", "user", "id"), "author");
302        assert_eq!(extract_relation_prefix("owner_id", "user", "id"), "owner");
303        assert_eq!(
304            extract_relation_prefix("creator_id", "user", "id"),
305            "creator"
306        );
307    }
308
309    #[test]
310    fn test_extract_relation_prefix_no_suffix() {
311        // Edge case: no ref_column suffix
312        assert_eq!(extract_relation_prefix("user", "user", "id"), "");
313        assert_eq!(extract_relation_prefix("admin_user", "user", "id"), "admin");
314    }
315
316    #[test]
317    fn test_build_reverse_relation_field_name_single_fk() {
318        // Single FK - just use source table name
319        assert_eq!(
320            build_reverse_relation_field_name(
321                &["user_id".into()],
322                "user",
323                "inquiry",
324                "id",
325                false,
326                false
327            ),
328            "inquiries"
329        );
330        assert_eq!(
331            build_reverse_relation_field_name(
332                &["author_id".into()],
333                "user",
334                "comment",
335                "id",
336                false,
337                false
338            ),
339            "comments"
340        );
341    }
342
343    #[test]
344    fn test_build_reverse_relation_field_name_multiple_fks() {
345        // Multiple FKs - need disambiguation
346        assert_eq!(
347            build_reverse_relation_field_name(
348                &["user_id".into()],
349                "user",
350                "inquiry",
351                "id",
352                true,
353                false
354            ),
355            "inquiries"
356        );
357        assert_eq!(
358            build_reverse_relation_field_name(
359                &["answered_by_user_id".into()],
360                "user",
361                "inquiry",
362                "id",
363                true,
364                false
365            ),
366            "answered_by_inquiries"
367        );
368    }
369
370    #[test]
371    fn test_build_reverse_relation_field_name_one_to_one() {
372        assert_eq!(
373            build_reverse_relation_field_name(
374                &["user_id".into()],
375                "user",
376                "profile",
377                "id",
378                false,
379                true
380            ),
381            "profile"
382        );
383        assert_eq!(
384            build_reverse_relation_field_name(
385                &["backup_user_id".into()],
386                "user",
387                "settings",
388                "id",
389                true,
390                true
391            ),
392            "backup_settings"
393        );
394    }
395
396    #[test]
397    fn test_build_relation_enum_name() {
398        // Empty fk_columns - early return
399        assert_eq!(build_relation_enum_name(&[], "user", "id"), "");
400
401        // Default FK - empty enum name (not needed or use table name)
402        assert_eq!(
403            build_relation_enum_name(&["user_id".into()], "user", "id"),
404            ""
405        );
406
407        // Semantic prefix - PascalCase
408        assert_eq!(
409            build_relation_enum_name(&["answered_by_user_id".into()], "user", "id"),
410            "AnsweredBy"
411        );
412        assert_eq!(
413            build_relation_enum_name(&["target_user_id".into()], "user", "id"),
414            "Target"
415        );
416
417        // Role FK - PascalCase of role
418        assert_eq!(
419            build_relation_enum_name(&["author_id".into()], "user", "id"),
420            "Author"
421        );
422    }
423
424    #[test]
425    fn test_to_pascal_case() {
426        assert_eq!(to_pascal_case("hello_world"), "HelloWorld");
427        assert_eq!(to_pascal_case("answered_by"), "AnsweredBy");
428        assert_eq!(to_pascal_case("user"), "User");
429        assert_eq!(to_pascal_case("hello-world"), "HelloWorld");
430        assert_eq!(to_pascal_case(""), "");
431    }
432
433    #[test]
434    fn test_pluralize() {
435        assert_eq!(pluralize("inquiry"), "inquiries");
436        assert_eq!(pluralize("category"), "categories");
437        assert_eq!(pluralize("comment"), "comments");
438        assert_eq!(pluralize("user"), "users");
439        assert_eq!(pluralize("status"), "status");
440        assert_eq!(pluralize("address"), "address");
441    }
442
443    // ========================================================================
444    // Constraint Naming Tests
445    // ========================================================================
446
447    #[test]
448    fn test_build_index_name_with_key() {
449        assert_eq!(
450            build_index_name("users", &["email".into()], Some("email_idx")),
451            "ix_users__email_idx"
452        );
453    }
454
455    #[test]
456    fn test_build_index_name_without_key() {
457        assert_eq!(
458            build_index_name("users", &["email".into()], None),
459            "ix_users__email"
460        );
461    }
462
463    #[test]
464    fn test_build_index_name_multiple_columns() {
465        assert_eq!(
466            build_index_name("users", &["first_name".into(), "last_name".into()], None),
467            "ix_users__first_name_last_name"
468        );
469    }
470
471    #[test]
472    fn test_build_unique_constraint_name_with_key() {
473        assert_eq!(
474            build_unique_constraint_name("users", &["email".into()], Some("email_unique")),
475            "uq_users__email_unique"
476        );
477    }
478
479    #[test]
480    fn test_build_unique_constraint_name_without_key() {
481        assert_eq!(
482            build_unique_constraint_name("users", &["email".into()], None),
483            "uq_users__email"
484        );
485    }
486
487    #[test]
488    fn test_build_foreign_key_name_with_key() {
489        assert_eq!(
490            build_foreign_key_name("posts", &["user_id".into()], Some("fk_user")),
491            "fk_posts__fk_user"
492        );
493    }
494
495    #[test]
496    fn test_build_foreign_key_name_without_key() {
497        assert_eq!(
498            build_foreign_key_name("posts", &["user_id".into()], None),
499            "fk_posts__user_id"
500        );
501    }
502
503    #[test]
504    fn test_build_check_constraint_name() {
505        assert_eq!(
506            build_check_constraint_name("users", "status"),
507            "chk_users__status"
508        );
509    }
510
511    #[test]
512    fn test_build_enum_type_name() {
513        assert_eq!(build_enum_type_name("users", "status"), "users_status");
514    }
515
516    #[test]
517    fn test_build_enum_type_name_with_existing_prefix() {
518        // Even if enum_name already has table prefix, we add it
519        // User should provide clean enum name (e.g., "status" not "users_status")
520        assert_eq!(
521            build_enum_type_name("users", "user_status"),
522            "users_user_status"
523        );
524    }
525
526    #[test]
527    fn test_build_enum_type_name_prevents_conflicts() {
528        // Different tables can have same enum name without conflict
529        assert_eq!(build_enum_type_name("users", "gender"), "users_gender");
530        assert_eq!(
531            build_enum_type_name("employees", "gender"),
532            "employees_gender"
533        );
534
535        assert_eq!(build_enum_type_name("orders", "status"), "orders_status");
536        assert_eq!(
537            build_enum_type_name("shipments", "status"),
538            "shipments_status"
539        );
540    }
541}