Skip to main content

dinoco_engine/
planner.rs

1use std::collections::{HashMap, HashSet};
2
3use dinoco_compiler::{
4    ParsedField, ParsedFieldDefault, ParsedFieldType, ParsedRelation, ParsedSchema, ParsedTable, ReferentialAction,
5};
6
7use crate::{MigrationPlan, MigrationStep, SafetyLevel, is_destructive_cast};
8
9fn primary_key_type(table: &ParsedTable, field_name: &str) -> Option<ParsedFieldType> {
10    table.fields.iter().find(|field| field.name == field_name).map(|field| field.field_type.clone())
11}
12
13fn primary_key_constraint_name(table_name: &str) -> Option<String> {
14    Some(format!("pk_{}", table_name))
15}
16
17fn push_safety_alert(alerts: &mut Vec<SafetyLevel>, alert: SafetyLevel) {
18    let exists = alerts.iter().any(|item| match (item, &alert) {
19        (SafetyLevel::Warning(left), SafetyLevel::Warning(right)) => left == right,
20        (SafetyLevel::Destructive(left), SafetyLevel::Destructive(right)) => left == right,
21        _ => false,
22    });
23
24    if !exists {
25        alerts.push(alert);
26    }
27}
28
29fn diff_primary_key(
30    old_table: &ParsedTable,
31    new_table: &ParsedTable,
32    alerts: &mut Vec<SafetyLevel>,
33) -> Vec<MigrationStep> {
34    if old_table.primary_key_fields == new_table.primary_key_fields {
35        return Vec::new();
36    }
37
38    let old_primary_key = if old_table.primary_key_fields.is_empty() {
39        "(none)".to_string()
40    } else {
41        old_table.primary_key_fields.join(", ")
42    };
43    let new_primary_key = if new_table.primary_key_fields.is_empty() {
44        "(none)".to_string()
45    } else {
46        new_table.primary_key_fields.join(", ")
47    };
48
49    push_safety_alert(
50        alerts,
51        SafetyLevel::Destructive(format!(
52            "Primary key changed on table '{}': [{}] -> [{}]. This can fail if existing data violates the new primary key or dependent relations still use the old key.",
53            new_table.database_name, old_primary_key, new_primary_key
54        )),
55    );
56
57    let mut steps = Vec::new();
58
59    if !old_table.primary_key_fields.is_empty() {
60        steps.push(MigrationStep::DropPrimaryKey {
61            table_name: old_table.database_name.clone(),
62            constraint_name: primary_key_constraint_name(&old_table.database_name),
63        });
64    }
65
66    if !new_table.primary_key_fields.is_empty() {
67        steps.push(MigrationStep::AddPrimaryKey {
68            table_name: new_table.database_name.clone(),
69            columns: new_table.primary_key_fields.clone(),
70            constraint_name: primary_key_constraint_name(&new_table.database_name),
71        });
72    }
73
74    steps
75}
76
77fn explicit_index_name(table_name: &str, columns: &[String], is_unique: bool) -> String {
78    let suffix = columns.join("_");
79
80    if is_unique { format!("uq_{}_{}", table_name, suffix) } else { format!("idx_{}_{}", table_name, suffix) }
81}
82
83fn diff_table_indexes(old_table: &ParsedTable, new_table: &ParsedTable) -> Vec<MigrationStep> {
84    let old_unique = old_table.unique_field_sets.iter().map(|columns| (true, columns.clone())).collect::<Vec<_>>();
85    let old_indexes = old_table.index_field_sets.iter().map(|columns| (false, columns.clone())).collect::<Vec<_>>();
86    let new_unique = new_table.unique_field_sets.iter().map(|columns| (true, columns.clone())).collect::<Vec<_>>();
87    let new_indexes = new_table.index_field_sets.iter().map(|columns| (false, columns.clone())).collect::<Vec<_>>();
88
89    let old_all = old_unique.into_iter().chain(old_indexes).collect::<Vec<_>>();
90    let new_all = new_unique.into_iter().chain(new_indexes).collect::<Vec<_>>();
91    let old_signatures = old_all
92        .iter()
93        .map(|(is_unique, columns)| ((*is_unique, columns.join("|")), columns.clone()))
94        .collect::<HashMap<_, _>>();
95    let new_signatures = new_all
96        .iter()
97        .map(|(is_unique, columns)| ((*is_unique, columns.join("|")), columns.clone()))
98        .collect::<HashMap<_, _>>();
99    let mut steps = Vec::new();
100
101    for ((is_unique, _), columns) in &old_signatures {
102        if !new_signatures.contains_key(&(*is_unique, columns.join("|"))) {
103            steps.push(MigrationStep::DropIndex {
104                table_name: old_table.database_name.clone(),
105                index_name: explicit_index_name(&old_table.database_name, columns, *is_unique),
106            });
107        }
108    }
109
110    for ((is_unique, _), columns) in &new_signatures {
111        if !old_signatures.contains_key(&(*is_unique, columns.join("|"))) {
112            steps.push(MigrationStep::CreateIndex {
113                table_name: new_table.database_name.clone(),
114                columns: columns.clone(),
115                index_name: explicit_index_name(&new_table.database_name, columns, *is_unique),
116                is_unique: *is_unique,
117            });
118        }
119    }
120
121    steps
122}
123
124pub fn calculate_diff(old_schema: &Option<ParsedSchema>, new_schema: &ParsedSchema) -> MigrationPlan {
125    let fallback_old_schema = ParsedSchema { config: new_schema.config.clone(), enums: Vec::new(), tables: Vec::new() };
126    let old_schema = old_schema.as_ref().unwrap_or(&fallback_old_schema);
127
128    let mut safety_alerts = Vec::new();
129
130    let mut create_enum_steps = Vec::new();
131    let mut alter_enum_steps = Vec::new();
132    let mut drop_enum_steps = Vec::new();
133    let mut drop_fk_steps = Vec::new();
134    let mut drop_index_steps = Vec::new();
135    let mut drop_table_steps = Vec::new();
136    let mut create_table_steps = Vec::new();
137    let mut add_column_steps = Vec::new();
138    let mut drop_column_steps = Vec::new();
139    let mut alter_column_steps = Vec::new();
140    let mut primary_key_steps = Vec::new();
141    let mut create_index_steps = Vec::new();
142    let mut add_fk_steps = Vec::new();
143    let mut created_table_names = HashSet::new();
144
145    let old_enums_map: HashMap<&String, _> = old_schema.enums.iter().map(|e| (&e.name, e)).collect();
146    let new_enums_map: HashMap<&String, _> = new_schema.enums.iter().map(|e| (&e.name, e)).collect();
147
148    for (name, new_enum) in &new_enums_map {
149        if let Some(old_enum) = old_enums_map.get(name) {
150            if old_enum.values != new_enum.values {
151                alter_enum_steps.push(MigrationStep::AlterEnum {
152                    name: (*name).clone(),
153                    old_variants: old_enum.values.clone(),
154                    new_variants: new_enum.values.clone(),
155                });
156            }
157        } else {
158            create_enum_steps
159                .push(MigrationStep::CreateEnum { name: (*name).clone(), variants: new_enum.values.clone() });
160        }
161    }
162
163    for name in old_enums_map.keys() {
164        if !new_enums_map.contains_key(name) {
165            drop_enum_steps.push(MigrationStep::DropEnum((*name).clone()));
166        }
167    }
168
169    let old_map: HashMap<&String, &ParsedTable> = old_schema.tables.iter().map(|t| (&t.name, t)).collect();
170    let new_map: HashMap<&String, &ParsedTable> = new_schema.tables.iter().map(|t| (&t.name, t)).collect();
171
172    let mut old_join_tables = HashMap::new();
173    for table in &old_schema.tables {
174        let (_, jts) = extract_relations(None, table, &old_schema.tables);
175        for jt in jts {
176            old_join_tables.insert(jt.name.clone(), jt);
177        }
178    }
179
180    let mut new_join_tables_map = HashMap::new();
181
182    for (name, new_table) in &new_map {
183        if let Some(old_table) = old_map.get(name) {
184            if old_table.database_name != new_table.database_name {
185                push_safety_alert(
186                    &mut safety_alerts,
187                    SafetyLevel::Warning(format!(
188                        "Table '{}' will be renamed to '{}'. Existing queries or raw SQL using the old physical table name may need updates.",
189                        old_table.database_name, new_table.database_name
190                    )),
191                );
192                created_table_names.insert(new_table.database_name.clone());
193                create_table_steps.push(MigrationStep::RenameTable {
194                    old_name: old_table.database_name.clone(),
195                    new_name: new_table.database_name.clone(),
196                });
197            }
198
199            primary_key_steps.extend(diff_primary_key(old_table, new_table, &mut safety_alerts));
200            for step in diff_table_indexes(old_table, new_table) {
201                match step {
202                    MigrationStep::DropIndex { .. } => drop_index_steps.push(step),
203                    MigrationStep::CreateIndex { .. } => create_index_steps.push(step),
204                    _ => {}
205                }
206            }
207
208            for step in diff_columns(old_table, new_table, &mut safety_alerts) {
209                match step {
210                    MigrationStep::AddColumn { .. } => add_column_steps.push(step),
211                    MigrationStep::DropColumn { .. } => drop_column_steps.push(step),
212                    MigrationStep::DropForeignKey { .. } => drop_fk_steps.push(step),
213                    MigrationStep::AlterColumn { .. } | MigrationStep::RenameColumn { .. } => {
214                        alter_column_steps.push(step)
215                    }
216                    _ => {}
217                }
218            }
219        } else {
220            create_table_steps.push(MigrationStep::CreateTable((*new_table).clone()));
221            created_table_names.insert(new_table.database_name.clone());
222        }
223
224        let (relations_steps, join_tables) =
225            extract_relations(old_map.get(name).copied(), new_table, &new_schema.tables);
226
227        for step in relations_steps {
228            match step {
229                MigrationStep::AddForeignKey { ref table_name, .. } => {
230                    if !created_table_names.contains(table_name) {
231                        add_fk_steps.push(step);
232                    }
233                }
234                MigrationStep::DropForeignKey { .. } => drop_fk_steps.push(step),
235                MigrationStep::CreateIndex { .. } => create_index_steps.push(step),
236                _ => {}
237            }
238        }
239
240        for join_table in join_tables {
241            new_join_tables_map.insert(join_table.name.clone(), join_table.clone());
242
243            if !old_map.contains_key(&join_table.name) && !old_join_tables.contains_key(&join_table.name) {
244                created_table_names.insert(join_table.database_name.clone());
245                create_table_steps.push(MigrationStep::CreateTable(join_table));
246            }
247        }
248    }
249
250    for (name, old_table) in &old_map {
251        if !new_map.contains_key(*name) {
252            for field in &old_table.fields {
253                if let ParsedRelation::ManyToOne(_, local_cols, _, _, _)
254                | ParsedRelation::OneToOneOwner(_, local_cols, _, _, _) = &field.relation
255                {
256                    if !local_cols.is_empty() {
257                        drop_fk_steps.push(MigrationStep::DropForeignKey {
258                            table_name: old_table.database_name.clone(),
259                            constraint_name: format!("fk_{}_{}", old_table.database_name, local_cols.join("_")),
260                        });
261                    }
262                }
263            }
264        }
265    }
266
267    for name in old_join_tables.keys() {
268        if !new_join_tables_map.contains_key(name) {
269            drop_table_steps.push(MigrationStep::DropTable(name.clone()));
270        }
271    }
272
273    for (name, old_table) in &old_map {
274        if !new_map.contains_key(name) {
275            safety_alerts.push(SafetyLevel::Destructive(format!(
276                "Dropping table '{}'. All records will be permanently deleted.",
277                old_table.database_name
278            )));
279
280            drop_table_steps.push(MigrationStep::DropTable(old_table.database_name.clone()));
281        }
282    }
283
284    let mut final_steps = Vec::new();
285
286    final_steps.extend(create_enum_steps);
287    final_steps.extend(drop_fk_steps);
288    final_steps.extend(drop_index_steps);
289    final_steps.extend(drop_table_steps);
290    final_steps.extend(alter_enum_steps);
291    final_steps.extend(create_table_steps);
292    final_steps.extend(drop_column_steps);
293    final_steps.extend(add_column_steps);
294    final_steps.extend(alter_column_steps);
295    final_steps.extend(primary_key_steps);
296    final_steps.extend(create_index_steps);
297    final_steps.extend(add_fk_steps);
298    final_steps.extend(drop_enum_steps);
299
300    MigrationPlan { steps: final_steps, safety_alerts }
301}
302fn diff_columns(old_table: &ParsedTable, new_table: &ParsedTable, alerts: &mut Vec<SafetyLevel>) -> Vec<MigrationStep> {
303    let mut steps = Vec::new();
304
305    let old_fields: HashMap<&String, &ParsedField> =
306        old_table.fields.iter().filter(|f| !f.is_virtual).map(|f| (&f.name, f)).collect();
307    let new_fields: HashMap<&String, &ParsedField> =
308        new_table.fields.iter().filter(|f| !f.is_virtual).map(|f| (&f.name, f)).collect();
309
310    let mut added_fields = Vec::new();
311    let mut dropped_fields = Vec::new();
312
313    for (name, new_field) in &new_fields {
314        if matches!(new_field.field_type, ParsedFieldType::Relation(..)) {
315            continue;
316        }
317
318        if let Some(old_field) = old_fields.get(name) {
319            let type_changed = old_field.field_type != new_field.field_type;
320            let became_required = old_field.is_optional && !new_field.is_optional;
321            let default_value = old_field.default_value != new_field.default_value;
322            let unique = old_field.is_unique != new_field.is_unique;
323
324            if type_changed || became_required || default_value || unique {
325                if type_changed && is_destructive_cast(&old_field.field_type, &new_field.field_type) {
326                    push_safety_alert(
327                        alerts,
328                        SafetyLevel::Destructive(format!(
329                            "Incompatible type change in '{}.{}': {:?} -> {:?}. Existing values may not be convertible.",
330                            new_table.database_name, name, old_field.field_type, new_field.field_type
331                        )),
332                    );
333                }
334
335                if became_required && new_field.default_value == ParsedFieldDefault::NotDefined {
336                    push_safety_alert(
337                        alerts,
338                        SafetyLevel::Warning(format!(
339                            "Column '{}.{}' was changed from optional to required without a default value. This migration can fail if existing rows contain NULL.",
340                            new_table.database_name, name
341                        )),
342                    );
343                }
344
345                steps.push(MigrationStep::AlterColumn {
346                    table_name: new_table.database_name.clone(),
347                    old_field: (*old_field).clone(),
348                    new_field: (*new_field).clone(),
349                });
350            }
351        } else {
352            added_fields.push(*new_field);
353        }
354    }
355
356    for (name, old_field) in &old_fields {
357        if matches!(old_field.field_type, ParsedFieldType::Relation(..)) {
358            continue;
359        }
360
361        if !new_fields.contains_key(name) {
362            dropped_fields.push(*old_field);
363        }
364    }
365
366    let mut resolved_adds = HashSet::new();
367    let mut resolved_drops = HashSet::new();
368
369    for add_f in added_fields.iter() {
370        if let Some(drop_f) = dropped_fields.iter().find(|d| {
371            !resolved_drops.contains(&d.name)
372                && d.field_type == add_f.field_type
373                && d.is_optional == add_f.is_optional
374                && d.default_value == add_f.default_value
375                && d.relation == add_f.relation
376        }) {
377            steps.push(MigrationStep::RenameColumn {
378                table_name: new_table.database_name.clone(),
379                old_name: drop_f.name.clone(),
380                new_name: add_f.name.clone(),
381            });
382
383            if let ParsedRelation::ManyToOne(_, local_cols, _, _, _)
384            | ParsedRelation::OneToOneOwner(_, local_cols, _, _, _) = &drop_f.relation
385            {
386                if !local_cols.is_empty() {
387                    steps.push(MigrationStep::DropForeignKey {
388                        table_name: old_table.database_name.clone(),
389                        constraint_name: format!("fk_{}_{}", old_table.database_name, local_cols.join("_")),
390                    });
391                }
392            }
393
394            resolved_adds.insert(add_f.name.clone());
395            resolved_drops.insert(drop_f.name.clone());
396        }
397    }
398
399    for add_f in added_fields {
400        if !resolved_adds.contains(&add_f.name) {
401            if !add_f.is_optional
402                && add_f.default_value == ParsedFieldDefault::NotDefined
403                && old_table
404                    .fields
405                    .iter()
406                    .any(|field| !field.is_virtual && !matches!(field.field_type, ParsedFieldType::Relation(..)))
407            {
408                push_safety_alert(
409                    alerts,
410                    SafetyLevel::Warning(format!(
411                        "Adding required column '{}.{}' without a default value can fail on tables that already contain rows.",
412                        new_table.database_name, add_f.name
413                    )),
414                );
415            }
416
417            steps.push(MigrationStep::AddColumn { table_name: new_table.database_name.clone(), field: add_f.clone() });
418        }
419    }
420
421    for drop_f in dropped_fields {
422        if !resolved_drops.contains(&drop_f.name) {
423            if let ParsedRelation::ManyToOne(_, local_cols, _, _, _)
424            | ParsedRelation::OneToOneOwner(_, local_cols, _, _, _) = &drop_f.relation
425            {
426                if !local_cols.is_empty() {
427                    steps.push(MigrationStep::DropForeignKey {
428                        table_name: old_table.database_name.clone(),
429                        constraint_name: format!("fk_{}_{}", old_table.database_name, local_cols.join("_")),
430                    });
431                }
432            }
433
434            push_safety_alert(
435                alerts,
436                SafetyLevel::Destructive(format!(
437                    "Dropping column '{}.{}'. All data stored in this column will be permanently lost.",
438                    old_table.database_name, drop_f.name
439                )),
440            );
441
442            steps
443                .push(MigrationStep::DropColumn { table_name: old_table.database_name.clone(), field: drop_f.clone() });
444        }
445    }
446
447    steps
448}
449
450pub fn extract_relations(
451    old_table: Option<&ParsedTable>,
452    new_table: &ParsedTable,
453    all_tables: &[ParsedTable],
454) -> (Vec<MigrationStep>, Vec<ParsedTable>) {
455    let mut fk_steps = Vec::new();
456    let mut join_tables = Vec::new();
457    let tables_by_name: HashMap<&str, &ParsedTable> =
458        all_tables.iter().map(|table| (table.name.as_str(), table)).collect();
459
460    let mut processed_m2m = HashSet::new();
461
462    for field in &new_table.fields {
463        let is_unchanged = if let Some(old_t) = old_table {
464            if let Some(old_f) = old_t.fields.iter().find(|f| f.name == field.name) {
465                old_f.relation == field.relation && old_f.field_type == field.field_type
466            } else {
467                false
468            }
469        } else {
470            false
471        };
472
473        match &field.relation {
474            ParsedRelation::ManyToOne(_, local_cols, ref_cols, on_delete, on_update)
475            | ParsedRelation::OneToOneOwner(_, local_cols, ref_cols, on_delete, on_update) => {
476                if is_unchanged {
477                    continue;
478                }
479
480                if !local_cols.is_empty() && local_cols.len() == ref_cols.len() {
481                    let ref_table = match &field.field_type {
482                        ParsedFieldType::Relation(name) => name.clone(),
483                        _ => field.field_type.to_string(),
484                    };
485
486                    fk_steps.push(MigrationStep::AddForeignKey {
487                        table_name: new_table.database_name.clone(),
488                        columns: local_cols.clone(),
489                        referenced_table: tables_by_name
490                            .get(ref_table.as_str())
491                            .map(|table| table.database_name.clone())
492                            .unwrap_or(ref_table),
493                        referenced_columns: ref_cols.clone(),
494                        on_delete: on_delete.clone(),
495                        on_update: on_update.clone(),
496                        constraint_name: format!("fk_{}_{}", new_table.database_name, local_cols.join("_")),
497                    });
498                }
499            }
500            ParsedRelation::ManyToMany(relation_name) => {
501                let target_table_name = match &field.field_type {
502                    ParsedFieldType::Relation(name) => name.clone(),
503                    _ => continue,
504                };
505
506                if new_table.name <= target_table_name {
507                    let Some(safe_rel_name) = build_many_to_many_join_table_name(
508                        new_table,
509                        field,
510                        &target_table_name,
511                        relation_name.as_deref(),
512                        all_tables,
513                    ) else {
514                        continue;
515                    };
516
517                    if !processed_m2m.insert(safe_rel_name.clone()) {
518                        continue;
519                    }
520
521                    let t1_clean = new_table.database_name.replace("\"", "").to_lowercase();
522                    let t2_clean = tables_by_name
523                        .get(target_table_name.as_str())
524                        .map(|table| table.database_name.replace("\"", "").to_lowercase())
525                        .unwrap_or_else(|| target_table_name.replace("\"", "").to_lowercase());
526
527                    let target_table = tables_by_name.get(target_table_name.as_str()).copied();
528                    let current_primary_keys = new_table.primary_key_fields.clone();
529                    let target_primary_keys =
530                        target_table.map(|table| table.primary_key_fields.clone()).unwrap_or_default();
531
532                    let self_relation = new_table.name == target_table_name;
533                    let current_join_columns = current_primary_keys
534                        .iter()
535                        .map(|field_name| {
536                            if self_relation {
537                                format!("{}_A_{}", t1_clean, field_name)
538                            } else {
539                                format!("{}_{}", t1_clean, field_name)
540                            }
541                        })
542                        .collect::<Vec<_>>();
543                    let target_join_columns = target_primary_keys
544                        .iter()
545                        .map(|field_name| {
546                            if self_relation {
547                                format!("{}_B_{}", t1_clean, field_name)
548                            } else {
549                                format!("{}_{}", t2_clean, field_name)
550                            }
551                        })
552                        .collect::<Vec<_>>();
553
554                    let mut join_fields = Vec::new();
555                    for (column_name, field_name) in current_join_columns.iter().zip(current_primary_keys.iter()) {
556                        join_fields.push(ParsedField {
557                            name: column_name.clone(),
558                            field_type: primary_key_type(new_table, field_name).unwrap_or(ParsedFieldType::Integer),
559                            is_primary_key: false,
560                            is_optional: false,
561                            is_unique: false,
562                            is_virtual: false,
563                            is_list: false,
564                            relation: ParsedRelation::NotDefined,
565                            default_value: ParsedFieldDefault::NotDefined,
566                        });
567                    }
568
569                    if let Some(target_table) = target_table {
570                        for (column_name, field_name) in target_join_columns.iter().zip(target_primary_keys.iter()) {
571                            join_fields.push(ParsedField {
572                                name: column_name.clone(),
573                                field_type: primary_key_type(target_table, field_name)
574                                    .unwrap_or(ParsedFieldType::Integer),
575                                is_primary_key: false,
576                                is_optional: false,
577                                is_unique: false,
578                                is_virtual: false,
579                                is_list: false,
580                                relation: ParsedRelation::NotDefined,
581                                default_value: ParsedFieldDefault::NotDefined,
582                            });
583                        }
584                    }
585
586                    let join_table = ParsedTable {
587                        name: safe_rel_name.clone(),
588                        database_name: safe_rel_name.clone(),
589                        primary_key_fields: current_join_columns
590                            .iter()
591                            .chain(target_join_columns.iter())
592                            .cloned()
593                            .collect(),
594                        unique_field_sets: Vec::new(),
595                        index_field_sets: Vec::new(),
596                        fields: join_fields,
597                    };
598
599                    join_tables.push(join_table.clone());
600
601                    if is_unchanged {
602                        continue;
603                    }
604
605                    fk_steps.push(MigrationStep::AddForeignKey {
606                        table_name: safe_rel_name.clone(),
607                        columns: current_join_columns.clone(),
608                        referenced_table: new_table.database_name.clone(),
609                        referenced_columns: new_table.primary_key_fields.clone(),
610                        on_delete: Some(ReferentialAction::Cascade),
611                        on_update: Some(ReferentialAction::Cascade),
612                        constraint_name: format!("fk_{}_{}", safe_rel_name, current_join_columns.join("_")),
613                    });
614
615                    fk_steps.push(MigrationStep::AddForeignKey {
616                        table_name: safe_rel_name.clone(),
617                        columns: target_join_columns.clone(),
618                        referenced_table: tables_by_name
619                            .get(target_table_name.as_str())
620                            .map(|table| table.database_name.clone())
621                            .unwrap_or(target_table_name.clone()),
622                        referenced_columns: tables_by_name
623                            .get(target_table_name.as_str())
624                            .map(|table| table.primary_key_fields.clone())
625                            .unwrap_or_else(|| vec!["id".to_string()]),
626                        on_delete: Some(ReferentialAction::Cascade),
627                        on_update: Some(ReferentialAction::Cascade),
628                        constraint_name: format!("fk_{}_{}", safe_rel_name, target_join_columns.join("_")),
629                    });
630
631                    let index_name =
632                        format!("{}_{}_idx", safe_rel_name.replace("\"", ""), target_join_columns.join("_"));
633
634                    fk_steps.push(MigrationStep::CreateIndex {
635                        table_name: safe_rel_name.clone(),
636                        columns: target_join_columns,
637                        index_name,
638                        is_unique: false,
639                    });
640                }
641            }
642            _ => {}
643        }
644    }
645
646    (fk_steps, join_tables)
647}
648
649fn build_many_to_many_join_table_name(
650    current_table: &ParsedTable,
651    current_field: &ParsedField,
652    target_table_name: &str,
653    relation_name: Option<&str>,
654    all_tables: &[ParsedTable],
655) -> Option<String> {
656    if let Some(relation_name) = relation_name {
657        return Some(format!("_{}", relation_name.replace('"', "")));
658    }
659
660    if current_table.name == target_table_name {
661        let anchor_field =
662            find_many_to_many_counterpart(current_table, current_field, target_table_name, relation_name, all_tables)
663                .map(|field| if current_field.name <= field.name { current_field } else { field })
664                .unwrap_or(current_field);
665
666        return Some(format!("_{}{}", current_table.name, pascal_case(&anchor_field.name)));
667    }
668
669    if current_table.name.as_str() < target_table_name {
670        return Some(format!("_{}{}", current_table.name, pascal_case(&current_field.name)));
671    }
672
673    let counterpart =
674        find_many_to_many_counterpart(current_table, current_field, target_table_name, relation_name, all_tables)?;
675
676    Some(format!("_{}{}", target_table_name, pascal_case(&counterpart.name)))
677}
678
679fn find_many_to_many_counterpart<'a>(
680    current_table: &ParsedTable,
681    current_field: &ParsedField,
682    target_table_name: &str,
683    relation_name: Option<&str>,
684    all_tables: &'a [ParsedTable],
685) -> Option<&'a ParsedField> {
686    let target_table = all_tables.iter().find(|table| table.name == target_table_name)?;
687
688    target_table.fields.iter().find(|candidate| {
689        if !matches!(candidate.field_type, ParsedFieldType::Relation(ref target) if target == &current_table.name) {
690            return false;
691        }
692
693        if current_table.name == target_table_name && candidate.name == current_field.name {
694            return false;
695        }
696
697        matches!(
698            &candidate.relation,
699            ParsedRelation::ManyToMany(candidate_relation_name)
700                if candidate_relation_name.as_deref() == relation_name
701        )
702    })
703}
704
705fn pascal_case(value: &str) -> String {
706    let mut output = String::new();
707    let mut uppercase_next = true;
708
709    for ch in value.chars() {
710        if ch == '_' || ch == '-' || ch == ' ' {
711            uppercase_next = true;
712            continue;
713        }
714
715        if uppercase_next {
716            output.extend(ch.to_uppercase());
717            uppercase_next = false;
718        } else {
719            output.push(ch);
720        }
721    }
722
723    output
724}