vespertide_planner/
validate.rs

1use std::collections::HashSet;
2
3use vespertide_core::{
4    ColumnDef, ColumnType, ComplexColumnType, EnumValues, MigrationAction, MigrationPlan,
5    TableConstraint, TableDef,
6};
7
8use crate::error::{InvalidEnumDefaultError, PlannerError};
9
10/// Validate a schema for data integrity issues.
11/// Checks for:
12/// - Duplicate table names
13/// - Foreign keys referencing non-existent tables
14/// - Foreign keys referencing non-existent columns
15/// - Indexes referencing non-existent columns
16/// - Constraints referencing non-existent columns
17/// - Empty constraint column lists
18pub fn validate_schema(schema: &[TableDef]) -> Result<(), PlannerError> {
19    // Check for duplicate table names
20    let mut table_names = HashSet::new();
21    for table in schema {
22        if !table_names.insert(&table.name) {
23            return Err(PlannerError::DuplicateTableName(table.name.clone()));
24        }
25    }
26
27    // Build a map of table names to their column names for quick lookup
28    let table_map: std::collections::HashMap<_, _> = schema
29        .iter()
30        .map(|t| {
31            let columns: HashSet<_> = t.columns.iter().map(|c| c.name.as_str()).collect();
32            (t.name.as_str(), columns)
33        })
34        .collect();
35
36    // Validate each table
37    for table in schema {
38        validate_table(table, &table_map)?;
39    }
40
41    Ok(())
42}
43
44fn validate_table(
45    table: &TableDef,
46    table_map: &std::collections::HashMap<&str, HashSet<&str>>,
47) -> Result<(), PlannerError> {
48    let table_columns: HashSet<_> = table.columns.iter().map(|c| c.name.as_str()).collect();
49
50    // Check that the table has a primary key
51    // Primary key can be defined either:
52    // 1. As a table-level constraint (TableConstraint::PrimaryKey)
53    // 2. As an inline column definition (column.primary_key = Some(...))
54    let has_table_pk = table
55        .constraints
56        .iter()
57        .any(|c| matches!(c, TableConstraint::PrimaryKey { .. }));
58    let has_inline_pk = table.columns.iter().any(|c| c.primary_key.is_some());
59
60    if !has_table_pk && !has_inline_pk {
61        return Err(PlannerError::MissingPrimaryKey(table.name.clone()));
62    }
63
64    // Validate columns (enum types)
65    for column in &table.columns {
66        validate_column(column, &table.name)?;
67    }
68
69    // Validate constraints (including indexes)
70    for constraint in &table.constraints {
71        validate_constraint(constraint, &table.name, &table_columns, table_map)?;
72    }
73
74    Ok(())
75}
76
77/// Extract the unquoted value from a potentially quoted string.
78/// Returns None if the value is a SQL expression (contains parentheses or is a keyword).
79fn extract_enum_value(value: &str) -> Option<&str> {
80    let trimmed = value.trim();
81    if trimmed.is_empty() {
82        return None;
83    }
84    // Check for SQL expressions/keywords that shouldn't be validated
85    if trimmed.contains('(')
86        || trimmed.contains(')')
87        || trimmed.eq_ignore_ascii_case("null")
88        || trimmed.eq_ignore_ascii_case("current_timestamp")
89        || trimmed.eq_ignore_ascii_case("now")
90    {
91        return None;
92    }
93    // Strip quotes if present
94    if ((trimmed.starts_with('\'') && trimmed.ends_with('\''))
95        || (trimmed.starts_with('"') && trimmed.ends_with('"')))
96        && trimmed.len() >= 2
97    {
98        return Some(&trimmed[1..trimmed.len() - 1]);
99    }
100    // Unquoted value
101    Some(trimmed)
102}
103
104/// Validate that an enum default/fill_with value is in the allowed enum values.
105fn validate_enum_value(
106    value: &str,
107    enum_name: &str,
108    enum_values: &EnumValues,
109    table_name: &str,
110    column_name: &str,
111    value_type: &str, // "default" or "fill_with"
112) -> Result<(), PlannerError> {
113    let extracted = match extract_enum_value(value) {
114        Some(v) => v,
115        None => return Ok(()), // Skip validation for SQL expressions
116    };
117
118    let is_valid = match enum_values {
119        EnumValues::String(variants) => variants.iter().any(|v| v == extracted),
120        EnumValues::Integer(variants) => variants.iter().any(|v| v.name == extracted),
121    };
122
123    if !is_valid {
124        let allowed = enum_values.variant_names().join(", ");
125        return Err(Box::new(InvalidEnumDefaultError {
126            enum_name: enum_name.to_string(),
127            table_name: table_name.to_string(),
128            column_name: column_name.to_string(),
129            value_type: value_type.to_string(),
130            value: extracted.to_string(),
131            allowed,
132        })
133        .into());
134    }
135
136    Ok(())
137}
138
139fn validate_column(column: &ColumnDef, table_name: &str) -> Result<(), PlannerError> {
140    // Validate enum types for duplicate names/values
141    if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &column.r#type {
142        match values {
143            EnumValues::String(variants) => {
144                let mut seen = HashSet::new();
145                for variant in variants {
146                    if !seen.insert(variant.as_str()) {
147                        return Err(PlannerError::DuplicateEnumVariantName(
148                            name.clone(),
149                            table_name.to_string(),
150                            column.name.clone(),
151                            variant.clone(),
152                        ));
153                    }
154                }
155            }
156            EnumValues::Integer(variants) => {
157                // Check duplicate names
158                let mut seen_names = HashSet::new();
159                for variant in variants {
160                    if !seen_names.insert(variant.name.as_str()) {
161                        return Err(PlannerError::DuplicateEnumVariantName(
162                            name.clone(),
163                            table_name.to_string(),
164                            column.name.clone(),
165                            variant.name.clone(),
166                        ));
167                    }
168                }
169                // Check duplicate values
170                let mut seen_values = HashSet::new();
171                for variant in variants {
172                    if !seen_values.insert(variant.value) {
173                        return Err(PlannerError::DuplicateEnumValue(
174                            name.clone(),
175                            table_name.to_string(),
176                            column.name.clone(),
177                            variant.value,
178                        ));
179                    }
180                }
181            }
182        }
183
184        // Validate default value is in enum values
185        if let Some(default) = &column.default {
186            let default_str = default.to_sql();
187            validate_enum_value(
188                &default_str,
189                name,
190                values,
191                table_name,
192                &column.name,
193                "default",
194            )?;
195        }
196    }
197    Ok(())
198}
199
200fn validate_constraint(
201    constraint: &TableConstraint,
202    table_name: &str,
203    table_columns: &HashSet<&str>,
204    table_map: &std::collections::HashMap<&str, HashSet<&str>>,
205) -> Result<(), PlannerError> {
206    match constraint {
207        TableConstraint::PrimaryKey { columns, .. } => {
208            if columns.is_empty() {
209                return Err(PlannerError::EmptyConstraintColumns(
210                    table_name.to_string(),
211                    "PrimaryKey".to_string(),
212                ));
213            }
214            for col in columns {
215                if !table_columns.contains(col.as_str()) {
216                    return Err(PlannerError::ConstraintColumnNotFound(
217                        table_name.to_string(),
218                        "PrimaryKey".to_string(),
219                        col.clone(),
220                    ));
221                }
222            }
223        }
224        TableConstraint::Unique { columns, .. } => {
225            if columns.is_empty() {
226                return Err(PlannerError::EmptyConstraintColumns(
227                    table_name.to_string(),
228                    "Unique".to_string(),
229                ));
230            }
231            for col in columns {
232                if !table_columns.contains(col.as_str()) {
233                    return Err(PlannerError::ConstraintColumnNotFound(
234                        table_name.to_string(),
235                        "Unique".to_string(),
236                        col.clone(),
237                    ));
238                }
239            }
240        }
241        TableConstraint::ForeignKey {
242            columns,
243            ref_table,
244            ref_columns,
245            ..
246        } => {
247            if columns.is_empty() {
248                return Err(PlannerError::EmptyConstraintColumns(
249                    table_name.to_string(),
250                    "ForeignKey".to_string(),
251                ));
252            }
253            if ref_columns.is_empty() {
254                return Err(PlannerError::EmptyConstraintColumns(
255                    ref_table.clone(),
256                    "ForeignKey (ref_columns)".to_string(),
257                ));
258            }
259
260            // Check that referenced table exists
261            let ref_table_columns = table_map.get(ref_table.as_str()).ok_or_else(|| {
262                PlannerError::ForeignKeyTableNotFound(
263                    table_name.to_string(),
264                    columns.join(", "),
265                    ref_table.clone(),
266                )
267            })?;
268
269            // Check that all columns in this table exist
270            for col in columns {
271                if !table_columns.contains(col.as_str()) {
272                    return Err(PlannerError::ConstraintColumnNotFound(
273                        table_name.to_string(),
274                        "ForeignKey".to_string(),
275                        col.clone(),
276                    ));
277                }
278            }
279
280            // Check that all referenced columns exist in the referenced table
281            for ref_col in ref_columns {
282                if !ref_table_columns.contains(ref_col.as_str()) {
283                    return Err(PlannerError::ForeignKeyColumnNotFound(
284                        table_name.to_string(),
285                        columns.join(", "),
286                        ref_table.clone(),
287                        ref_col.clone(),
288                    ));
289                }
290            }
291
292            // Check that column counts match
293            if columns.len() != ref_columns.len() {
294                return Err(PlannerError::ForeignKeyColumnNotFound(
295                    table_name.to_string(),
296                    format!(
297                        "column count mismatch: {} != {}",
298                        columns.len(),
299                        ref_columns.len()
300                    ),
301                    ref_table.clone(),
302                    "".to_string(),
303                ));
304            }
305        }
306        TableConstraint::Check { .. } => {
307            // Check constraints are just expressions, no validation needed
308        }
309        TableConstraint::Index { name, columns } => {
310            if columns.is_empty() {
311                let index_name = name.clone().unwrap_or_else(|| "(unnamed)".to_string());
312                return Err(PlannerError::EmptyConstraintColumns(
313                    table_name.to_string(),
314                    format!("Index({})", index_name),
315                ));
316            }
317
318            for col in columns {
319                if !table_columns.contains(col.as_str()) {
320                    let index_name = name.clone().unwrap_or_else(|| "(unnamed)".to_string());
321                    return Err(PlannerError::IndexColumnNotFound(
322                        table_name.to_string(),
323                        index_name,
324                        col.clone(),
325                    ));
326                }
327            }
328        }
329    }
330
331    Ok(())
332}
333
334/// Validate a migration plan for correctness.
335/// Checks for:
336/// - AddColumn actions with NOT NULL columns without default must have fill_with
337/// - ModifyColumnNullable actions changing from nullable to non-nullable must have fill_with
338/// - Enum columns with default/fill_with values must have valid enum values
339pub fn validate_migration_plan(plan: &MigrationPlan) -> Result<(), PlannerError> {
340    for action in &plan.actions {
341        match action {
342            MigrationAction::AddColumn {
343                table,
344                column,
345                fill_with,
346            } => {
347                // If column is NOT NULL and has no default, fill_with is required
348                if !column.nullable && column.default.is_none() && fill_with.is_none() {
349                    return Err(PlannerError::MissingFillWith(
350                        table.clone(),
351                        column.name.clone(),
352                    ));
353                }
354
355                // Validate enum default/fill_with values
356                if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) =
357                    &column.r#type
358                {
359                    if let Some(fill) = fill_with {
360                        validate_enum_value(fill, name, values, table, &column.name, "fill_with")?;
361                    }
362                    if let Some(default) = &column.default {
363                        let default_str = default.to_sql();
364                        validate_enum_value(
365                            &default_str,
366                            name,
367                            values,
368                            table,
369                            &column.name,
370                            "default",
371                        )?;
372                    }
373                }
374            }
375            MigrationAction::ModifyColumnNullable {
376                table,
377                column,
378                nullable,
379                fill_with,
380            } => {
381                // If changing from nullable to non-nullable, fill_with is required
382                if !nullable && fill_with.is_none() {
383                    return Err(PlannerError::MissingFillWith(table.clone(), column.clone()));
384                }
385            }
386            _ => {}
387        }
388    }
389    Ok(())
390}
391
392/// Information about an action that requires a fill_with value.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct FillWithRequired {
395    /// Index of the action in the migration plan.
396    pub action_index: usize,
397    /// Table name.
398    pub table: String,
399    /// Column name.
400    pub column: String,
401    /// Type of action: "AddColumn" or "ModifyColumnNullable".
402    pub action_type: &'static str,
403    /// Column type (for display purposes).
404    pub column_type: Option<String>,
405    /// Default fill value hint for this column type.
406    pub default_value: Option<String>,
407}
408
409/// Find all actions in a migration plan that require fill_with values.
410/// Returns a list of actions that need fill_with but don't have one.
411pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec<FillWithRequired> {
412    let mut missing = Vec::new();
413
414    for (idx, action) in plan.actions.iter().enumerate() {
415        match action {
416            MigrationAction::AddColumn {
417                table,
418                column,
419                fill_with,
420            } => {
421                // If column is NOT NULL and has no default, fill_with is required
422                if !column.nullable && column.default.is_none() && fill_with.is_none() {
423                    missing.push(FillWithRequired {
424                        action_index: idx,
425                        table: table.clone(),
426                        column: column.name.clone(),
427                        action_type: "AddColumn",
428                        column_type: Some(column.r#type.to_display_string()),
429                        default_value: column.r#type.default_fill_value().map(String::from),
430                    });
431                }
432            }
433            MigrationAction::ModifyColumnNullable {
434                table,
435                column,
436                nullable,
437                fill_with,
438            } => {
439                // If changing from nullable to non-nullable, fill_with is required
440                if !nullable && fill_with.is_none() {
441                    missing.push(FillWithRequired {
442                        action_index: idx,
443                        table: table.clone(),
444                        column: column.clone(),
445                        action_type: "ModifyColumnNullable",
446                        column_type: None,
447                        default_value: None,
448                    });
449                }
450            }
451            _ => {}
452        }
453    }
454
455    missing
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use rstest::rstest;
462    use vespertide_core::{
463        ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType,
464        TableConstraint,
465    };
466
467    fn col(name: &str, ty: ColumnType) -> ColumnDef {
468        ColumnDef {
469            name: name.to_string(),
470            r#type: ty,
471            nullable: true,
472            default: None,
473            comment: None,
474            primary_key: None,
475            unique: None,
476            index: None,
477            foreign_key: None,
478        }
479    }
480
481    fn table(name: &str, columns: Vec<ColumnDef>, constraints: Vec<TableConstraint>) -> TableDef {
482        TableDef {
483            name: name.to_string(),
484            description: None,
485            columns,
486            constraints,
487        }
488    }
489
490    fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
491        TableConstraint::Index {
492            name: Some(name.to_string()),
493            columns: columns.into_iter().map(|s| s.to_string()).collect(),
494        }
495    }
496
497    fn is_duplicate(err: &PlannerError) -> bool {
498        matches!(err, PlannerError::DuplicateTableName(_))
499    }
500
501    fn is_fk_table(err: &PlannerError) -> bool {
502        matches!(err, PlannerError::ForeignKeyTableNotFound(_, _, _))
503    }
504
505    fn is_fk_column(err: &PlannerError) -> bool {
506        matches!(err, PlannerError::ForeignKeyColumnNotFound(_, _, _, _))
507    }
508
509    fn is_index_column(err: &PlannerError) -> bool {
510        matches!(err, PlannerError::IndexColumnNotFound(_, _, _))
511    }
512
513    fn is_constraint_column(err: &PlannerError) -> bool {
514        matches!(err, PlannerError::ConstraintColumnNotFound(_, _, _))
515    }
516
517    fn is_empty_columns(err: &PlannerError) -> bool {
518        matches!(err, PlannerError::EmptyConstraintColumns(_, _))
519    }
520
521    fn is_missing_pk(err: &PlannerError) -> bool {
522        matches!(err, PlannerError::MissingPrimaryKey(_))
523    }
524
525    fn pk(columns: Vec<&str>) -> TableConstraint {
526        TableConstraint::PrimaryKey {
527            auto_increment: false,
528            columns: columns.into_iter().map(|s| s.to_string()).collect(),
529        }
530    }
531
532    #[rstest]
533    #[case::valid_schema(
534        vec![table(
535            "users",
536            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
537            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
538        )],
539        None
540    )]
541    #[case::duplicate_table(
542        vec![
543            table("users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![]),
544            table("users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![]),
545        ],
546        Some(is_duplicate as fn(&PlannerError) -> bool)
547    )]
548    #[case::fk_missing_table(
549        vec![table(
550            "users",
551            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
552            vec![pk(vec!["id"]), TableConstraint::ForeignKey {
553                name: None,
554                columns: vec!["id".into()],
555                ref_table: "nonexistent".into(),
556                ref_columns: vec!["id".into()],
557                on_delete: None,
558                on_update: None,
559            }],
560        )],
561        Some(is_fk_table as fn(&PlannerError) -> bool)
562    )]
563    #[case::fk_missing_column(
564        vec![
565            table("posts", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![pk(vec!["id"])]),
566            table(
567                "users",
568                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
569                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
570                    name: None,
571                    columns: vec!["id".into()],
572                    ref_table: "posts".into(),
573                    ref_columns: vec!["nonexistent".into()],
574                    on_delete: None,
575                    on_update: None,
576                }],
577            ),
578        ],
579        Some(is_fk_column as fn(&PlannerError) -> bool)
580    )]
581    #[case::fk_local_missing_column(
582        vec![
583            table("posts", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![pk(vec!["id"])]),
584            table(
585                "users",
586                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
587                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
588                    name: None,
589                    columns: vec!["missing".into()],
590                    ref_table: "posts".into(),
591                    ref_columns: vec!["id".into()],
592                    on_delete: None,
593                    on_update: None,
594                }],
595            ),
596        ],
597        Some(is_constraint_column as fn(&PlannerError) -> bool)
598    )]
599    #[case::fk_valid(
600        vec![
601            table(
602                "posts",
603                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
604                vec![pk(vec!["id"])],
605            ),
606            table(
607                "users",
608                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("post_id", ColumnType::Simple(SimpleColumnType::Integer))],
609                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
610                    name: None,
611                    columns: vec!["post_id".into()],
612                    ref_table: "posts".into(),
613                    ref_columns: vec!["id".into()],
614                    on_delete: None,
615                    on_update: None,
616                }],
617            ),
618        ],
619        None
620    )]
621    #[case::index_missing_column(
622        vec![table(
623            "users",
624            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
625            vec![pk(vec!["id"]), idx("idx_name", vec!["nonexistent"])],
626        )],
627        Some(is_index_column as fn(&PlannerError) -> bool)
628    )]
629    #[case::constraint_missing_column(
630        vec![table(
631            "users",
632            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
633            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["nonexistent".into()] }],
634        )],
635        Some(is_constraint_column as fn(&PlannerError) -> bool)
636    )]
637    #[case::unique_empty_columns(
638        vec![table(
639            "users",
640            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
641            vec![pk(vec!["id"]), TableConstraint::Unique {
642                name: Some("u".into()),
643                columns: vec![],
644            }],
645        )],
646        Some(is_empty_columns as fn(&PlannerError) -> bool)
647    )]
648    #[case::unique_missing_column(
649        vec![table(
650            "users",
651            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
652            vec![pk(vec!["id"]), TableConstraint::Unique {
653                name: None,
654                columns: vec!["missing".into()],
655            }],
656        )],
657        Some(is_constraint_column as fn(&PlannerError) -> bool)
658    )]
659    #[case::empty_primary_key(
660        vec![table(
661            "users",
662            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
663            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec![] }],
664        )],
665        Some(is_empty_columns as fn(&PlannerError) -> bool)
666    )]
667    #[case::fk_column_count_mismatch(
668        vec![
669            table(
670                "posts",
671                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
672                vec![pk(vec!["id"])],
673            ),
674            table(
675                "users",
676                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("post_id", ColumnType::Simple(SimpleColumnType::Integer))],
677                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
678                    name: None,
679                    columns: vec!["id".into(), "post_id".into()],
680                    ref_table: "posts".into(),
681                    ref_columns: vec!["id".into()],
682                    on_delete: None,
683                    on_update: None,
684                }],
685            ),
686        ],
687        Some(is_fk_column as fn(&PlannerError) -> bool)
688    )]
689    #[case::fk_empty_columns(
690        vec![table(
691            "users",
692            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
693            vec![pk(vec!["id"]), TableConstraint::ForeignKey {
694                name: None,
695                columns: vec![],
696                ref_table: "posts".into(),
697                ref_columns: vec!["id".into()],
698                on_delete: None,
699                on_update: None,
700            }],
701        )],
702        Some(is_empty_columns as fn(&PlannerError) -> bool)
703    )]
704    #[case::fk_empty_ref_columns(
705        vec![
706            table(
707                "posts",
708                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
709                vec![pk(vec!["id"])],
710            ),
711            table(
712                "users",
713                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
714                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
715                    name: None,
716                    columns: vec!["id".into()],
717                    ref_table: "posts".into(),
718                    ref_columns: vec![],
719                    on_delete: None,
720                    on_update: None,
721                }],
722            ),
723        ],
724        Some(is_empty_columns as fn(&PlannerError) -> bool)
725    )]
726    #[case::index_empty_columns(
727        vec![table(
728            "users",
729            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
730            vec![pk(vec!["id"]), TableConstraint::Index {
731                name: Some("idx".into()),
732                columns: vec![],
733            }],
734        )],
735        Some(is_empty_columns as fn(&PlannerError) -> bool)
736    )]
737    #[case::index_valid(
738        vec![table(
739            "users",
740            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
741            vec![pk(vec!["id"]), idx("idx_name", vec!["name"])],
742        )],
743        None
744    )]
745    #[case::check_constraint_ok(
746        vec![table(
747            "users",
748            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
749            vec![pk(vec!["id"]), TableConstraint::Check {
750                name: "ck".into(),
751                expr: "id > 0".into(),
752            }],
753        )],
754        None
755    )]
756    #[case::missing_primary_key(
757        vec![table(
758            "users",
759            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
760            vec![],
761        )],
762        Some(is_missing_pk as fn(&PlannerError) -> bool)
763    )]
764    fn validate_schema_cases(
765        #[case] schema: Vec<TableDef>,
766        #[case] expected_err: Option<fn(&PlannerError) -> bool>,
767    ) {
768        let result = validate_schema(&schema);
769        match expected_err {
770            None => assert!(result.is_ok()),
771            Some(pred) => {
772                let err = result.unwrap_err();
773                assert!(pred(&err), "unexpected error: {:?}", err);
774            }
775        }
776    }
777
778    #[test]
779    fn validate_migration_plan_missing_fill_with() {
780        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
781
782        let plan = MigrationPlan {
783            comment: None,
784            created_at: None,
785            version: 1,
786            actions: vec![MigrationAction::AddColumn {
787                table: "users".into(),
788                column: Box::new(ColumnDef {
789                    name: "email".into(),
790                    r#type: ColumnType::Simple(SimpleColumnType::Text),
791                    nullable: false,
792                    default: None,
793                    comment: None,
794                    primary_key: None,
795                    unique: None,
796                    index: None,
797                    foreign_key: None,
798                }),
799                fill_with: None,
800            }],
801        };
802
803        let result = validate_migration_plan(&plan);
804        assert!(result.is_err());
805        match result.unwrap_err() {
806            PlannerError::MissingFillWith(table, column) => {
807                assert_eq!(table, "users");
808                assert_eq!(column, "email");
809            }
810            _ => panic!("expected MissingFillWith error"),
811        }
812    }
813
814    #[test]
815    fn validate_migration_plan_with_fill_with() {
816        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
817
818        let plan = MigrationPlan {
819            comment: None,
820            created_at: None,
821            version: 1,
822            actions: vec![MigrationAction::AddColumn {
823                table: "users".into(),
824                column: Box::new(ColumnDef {
825                    name: "email".into(),
826                    r#type: ColumnType::Simple(SimpleColumnType::Text),
827                    nullable: false,
828                    default: None,
829                    comment: None,
830                    primary_key: None,
831                    unique: None,
832                    index: None,
833                    foreign_key: None,
834                }),
835                fill_with: Some("default@example.com".into()),
836            }],
837        };
838
839        let result = validate_migration_plan(&plan);
840        assert!(result.is_ok());
841    }
842
843    #[test]
844    fn validate_migration_plan_nullable_column() {
845        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
846
847        let plan = MigrationPlan {
848            comment: None,
849            created_at: None,
850            version: 1,
851            actions: vec![MigrationAction::AddColumn {
852                table: "users".into(),
853                column: Box::new(ColumnDef {
854                    name: "email".into(),
855                    r#type: ColumnType::Simple(SimpleColumnType::Text),
856                    nullable: true,
857                    default: None,
858                    comment: None,
859                    primary_key: None,
860                    unique: None,
861                    index: None,
862                    foreign_key: None,
863                }),
864                fill_with: None,
865            }],
866        };
867
868        let result = validate_migration_plan(&plan);
869        assert!(result.is_ok());
870    }
871
872    #[test]
873    fn validate_migration_plan_with_default() {
874        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
875
876        let plan = MigrationPlan {
877            comment: None,
878            created_at: None,
879            version: 1,
880            actions: vec![MigrationAction::AddColumn {
881                table: "users".into(),
882                column: Box::new(ColumnDef {
883                    name: "email".into(),
884                    r#type: ColumnType::Simple(SimpleColumnType::Text),
885                    nullable: false,
886                    default: Some("default@example.com".into()),
887                    comment: None,
888                    primary_key: None,
889                    unique: None,
890                    index: None,
891                    foreign_key: None,
892                }),
893                fill_with: None,
894            }],
895        };
896
897        let result = validate_migration_plan(&plan);
898        assert!(result.is_ok());
899    }
900
901    #[test]
902    fn validate_string_enum_duplicate_variant_name() {
903        let schema = vec![table(
904            "users",
905            vec![
906                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
907                col(
908                    "status",
909                    ColumnType::Complex(ComplexColumnType::Enum {
910                        name: "user_status".into(),
911                        values: EnumValues::String(vec![
912                            "active".into(),
913                            "inactive".into(),
914                            "active".into(), // duplicate
915                        ]),
916                    }),
917                ),
918            ],
919            vec![TableConstraint::PrimaryKey {
920                auto_increment: false,
921                columns: vec!["id".into()],
922            }],
923        )];
924
925        let result = validate_schema(&schema);
926        assert!(result.is_err());
927        match result.unwrap_err() {
928            PlannerError::DuplicateEnumVariantName(enum_name, table, column, variant) => {
929                assert_eq!(enum_name, "user_status");
930                assert_eq!(table, "users");
931                assert_eq!(column, "status");
932                assert_eq!(variant, "active");
933            }
934            err => panic!("expected DuplicateEnumVariantName, got {:?}", err),
935        }
936    }
937
938    #[test]
939    fn validate_integer_enum_duplicate_variant_name() {
940        let schema = vec![table(
941            "tasks",
942            vec![
943                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
944                col(
945                    "priority",
946                    ColumnType::Complex(ComplexColumnType::Enum {
947                        name: "priority_level".into(),
948                        values: EnumValues::Integer(vec![
949                            NumValue {
950                                name: "Low".into(),
951                                value: 0,
952                            },
953                            NumValue {
954                                name: "High".into(),
955                                value: 1,
956                            },
957                            NumValue {
958                                name: "Low".into(), // duplicate name
959                                value: 2,
960                            },
961                        ]),
962                    }),
963                ),
964            ],
965            vec![TableConstraint::PrimaryKey {
966                auto_increment: false,
967                columns: vec!["id".into()],
968            }],
969        )];
970
971        let result = validate_schema(&schema);
972        assert!(result.is_err());
973        match result.unwrap_err() {
974            PlannerError::DuplicateEnumVariantName(enum_name, table, column, variant) => {
975                assert_eq!(enum_name, "priority_level");
976                assert_eq!(table, "tasks");
977                assert_eq!(column, "priority");
978                assert_eq!(variant, "Low");
979            }
980            err => panic!("expected DuplicateEnumVariantName, got {:?}", err),
981        }
982    }
983
984    #[test]
985    fn validate_integer_enum_duplicate_value() {
986        let schema = vec![table(
987            "tasks",
988            vec![
989                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
990                col(
991                    "priority",
992                    ColumnType::Complex(ComplexColumnType::Enum {
993                        name: "priority_level".into(),
994                        values: EnumValues::Integer(vec![
995                            NumValue {
996                                name: "Low".into(),
997                                value: 0,
998                            },
999                            NumValue {
1000                                name: "Medium".into(),
1001                                value: 1,
1002                            },
1003                            NumValue {
1004                                name: "High".into(),
1005                                value: 0, // duplicate value
1006                            },
1007                        ]),
1008                    }),
1009                ),
1010            ],
1011            vec![TableConstraint::PrimaryKey {
1012                auto_increment: false,
1013                columns: vec!["id".into()],
1014            }],
1015        )];
1016
1017        let result = validate_schema(&schema);
1018        assert!(result.is_err());
1019        match result.unwrap_err() {
1020            PlannerError::DuplicateEnumValue(enum_name, table, column, value) => {
1021                assert_eq!(enum_name, "priority_level");
1022                assert_eq!(table, "tasks");
1023                assert_eq!(column, "priority");
1024                assert_eq!(value, 0);
1025            }
1026            err => panic!("expected DuplicateEnumValue, got {:?}", err),
1027        }
1028    }
1029
1030    #[test]
1031    fn validate_enum_valid() {
1032        let schema = vec![table(
1033            "tasks",
1034            vec![
1035                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1036                col(
1037                    "status",
1038                    ColumnType::Complex(ComplexColumnType::Enum {
1039                        name: "task_status".into(),
1040                        values: EnumValues::String(vec![
1041                            "pending".into(),
1042                            "in_progress".into(),
1043                            "completed".into(),
1044                        ]),
1045                    }),
1046                ),
1047                col(
1048                    "priority",
1049                    ColumnType::Complex(ComplexColumnType::Enum {
1050                        name: "priority_level".into(),
1051                        values: EnumValues::Integer(vec![
1052                            NumValue {
1053                                name: "Low".into(),
1054                                value: 0,
1055                            },
1056                            NumValue {
1057                                name: "Medium".into(),
1058                                value: 50,
1059                            },
1060                            NumValue {
1061                                name: "High".into(),
1062                                value: 100,
1063                            },
1064                        ]),
1065                    }),
1066                ),
1067            ],
1068            vec![TableConstraint::PrimaryKey {
1069                auto_increment: false,
1070                columns: vec!["id".into()],
1071            }],
1072        )];
1073
1074        let result = validate_schema(&schema);
1075        assert!(result.is_ok());
1076    }
1077
1078    #[test]
1079    fn validate_migration_plan_modify_nullable_to_non_nullable_missing_fill_with() {
1080        let plan = MigrationPlan {
1081            comment: None,
1082            created_at: None,
1083            version: 1,
1084            actions: vec![MigrationAction::ModifyColumnNullable {
1085                table: "users".into(),
1086                column: "email".into(),
1087                nullable: false,
1088                fill_with: None,
1089            }],
1090        };
1091
1092        let result = validate_migration_plan(&plan);
1093        assert!(result.is_err());
1094        match result.unwrap_err() {
1095            PlannerError::MissingFillWith(table, column) => {
1096                assert_eq!(table, "users");
1097                assert_eq!(column, "email");
1098            }
1099            _ => panic!("expected MissingFillWith error"),
1100        }
1101    }
1102
1103    #[test]
1104    fn validate_migration_plan_modify_nullable_to_non_nullable_with_fill_with() {
1105        let plan = MigrationPlan {
1106            comment: None,
1107            created_at: None,
1108            version: 1,
1109            actions: vec![MigrationAction::ModifyColumnNullable {
1110                table: "users".into(),
1111                column: "email".into(),
1112                nullable: false,
1113                fill_with: Some("'unknown'".into()),
1114            }],
1115        };
1116
1117        let result = validate_migration_plan(&plan);
1118        assert!(result.is_ok());
1119    }
1120
1121    #[test]
1122    fn validate_migration_plan_modify_non_nullable_to_nullable() {
1123        // Changing from non-nullable to nullable does NOT require fill_with
1124        let plan = MigrationPlan {
1125            comment: None,
1126            created_at: None,
1127            version: 1,
1128            actions: vec![MigrationAction::ModifyColumnNullable {
1129                table: "users".into(),
1130                column: "email".into(),
1131                nullable: true,
1132                fill_with: None,
1133            }],
1134        };
1135
1136        let result = validate_migration_plan(&plan);
1137        assert!(result.is_ok());
1138    }
1139
1140    #[test]
1141    fn validate_enum_add_column_invalid_default() {
1142        let plan = MigrationPlan {
1143            comment: None,
1144            created_at: None,
1145            version: 1,
1146            actions: vec![MigrationAction::AddColumn {
1147                table: "users".into(),
1148                column: Box::new(ColumnDef {
1149                    name: "status".into(),
1150                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1151                        name: "user_status".into(),
1152                        values: EnumValues::String(vec![
1153                            "active".into(),
1154                            "inactive".into(),
1155                            "pending".into(),
1156                        ]),
1157                    }),
1158                    nullable: false,
1159                    default: Some("invalid_value".into()),
1160                    comment: None,
1161                    primary_key: None,
1162                    unique: None,
1163                    index: None,
1164                    foreign_key: None,
1165                }),
1166                fill_with: None,
1167            }],
1168        };
1169
1170        let result = validate_migration_plan(&plan);
1171        assert!(result.is_err());
1172        match result.unwrap_err() {
1173            PlannerError::InvalidEnumDefault(err) => {
1174                assert_eq!(err.enum_name, "user_status");
1175                assert_eq!(err.table_name, "users");
1176                assert_eq!(err.column_name, "status");
1177                assert_eq!(err.value_type, "default");
1178                assert_eq!(err.value, "invalid_value");
1179            }
1180            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1181        }
1182    }
1183
1184    #[test]
1185    fn validate_enum_add_column_invalid_fill_with() {
1186        let plan = MigrationPlan {
1187            comment: None,
1188            created_at: None,
1189            version: 1,
1190            actions: vec![MigrationAction::AddColumn {
1191                table: "users".into(),
1192                column: Box::new(ColumnDef {
1193                    name: "status".into(),
1194                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1195                        name: "user_status".into(),
1196                        values: EnumValues::String(vec![
1197                            "active".into(),
1198                            "inactive".into(),
1199                            "pending".into(),
1200                        ]),
1201                    }),
1202                    nullable: false,
1203                    default: None,
1204                    comment: None,
1205                    primary_key: None,
1206                    unique: None,
1207                    index: None,
1208                    foreign_key: None,
1209                }),
1210                fill_with: Some("unknown_status".into()),
1211            }],
1212        };
1213
1214        let result = validate_migration_plan(&plan);
1215        assert!(result.is_err());
1216        match result.unwrap_err() {
1217            PlannerError::InvalidEnumDefault(err) => {
1218                assert_eq!(err.enum_name, "user_status");
1219                assert_eq!(err.table_name, "users");
1220                assert_eq!(err.column_name, "status");
1221                assert_eq!(err.value_type, "fill_with");
1222                assert_eq!(err.value, "unknown_status");
1223            }
1224            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1225        }
1226    }
1227
1228    #[test]
1229    fn validate_enum_add_column_valid_default_quoted() {
1230        let plan = MigrationPlan {
1231            comment: None,
1232            created_at: None,
1233            version: 1,
1234            actions: vec![MigrationAction::AddColumn {
1235                table: "users".into(),
1236                column: Box::new(ColumnDef {
1237                    name: "status".into(),
1238                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1239                        name: "user_status".into(),
1240                        values: EnumValues::String(vec![
1241                            "active".into(),
1242                            "inactive".into(),
1243                            "pending".into(),
1244                        ]),
1245                    }),
1246                    nullable: false,
1247                    default: Some("'active'".into()),
1248                    comment: None,
1249                    primary_key: None,
1250                    unique: None,
1251                    index: None,
1252                    foreign_key: None,
1253                }),
1254                fill_with: None,
1255            }],
1256        };
1257
1258        let result = validate_migration_plan(&plan);
1259        assert!(result.is_ok());
1260    }
1261
1262    #[test]
1263    fn validate_enum_add_column_valid_default_unquoted() {
1264        let plan = MigrationPlan {
1265            comment: None,
1266            created_at: None,
1267            version: 1,
1268            actions: vec![MigrationAction::AddColumn {
1269                table: "users".into(),
1270                column: Box::new(ColumnDef {
1271                    name: "status".into(),
1272                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1273                        name: "user_status".into(),
1274                        values: EnumValues::String(vec![
1275                            "active".into(),
1276                            "inactive".into(),
1277                            "pending".into(),
1278                        ]),
1279                    }),
1280                    nullable: false,
1281                    default: Some("active".into()),
1282                    comment: None,
1283                    primary_key: None,
1284                    unique: None,
1285                    index: None,
1286                    foreign_key: None,
1287                }),
1288                fill_with: None,
1289            }],
1290        };
1291
1292        let result = validate_migration_plan(&plan);
1293        assert!(result.is_ok());
1294    }
1295
1296    #[test]
1297    fn validate_enum_add_column_valid_fill_with() {
1298        let plan = MigrationPlan {
1299            comment: None,
1300            created_at: None,
1301            version: 1,
1302            actions: vec![MigrationAction::AddColumn {
1303                table: "users".into(),
1304                column: Box::new(ColumnDef {
1305                    name: "status".into(),
1306                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1307                        name: "user_status".into(),
1308                        values: EnumValues::String(vec![
1309                            "active".into(),
1310                            "inactive".into(),
1311                            "pending".into(),
1312                        ]),
1313                    }),
1314                    nullable: false,
1315                    default: None,
1316                    comment: None,
1317                    primary_key: None,
1318                    unique: None,
1319                    index: None,
1320                    foreign_key: None,
1321                }),
1322                fill_with: Some("'pending'".into()),
1323            }],
1324        };
1325
1326        let result = validate_migration_plan(&plan);
1327        assert!(result.is_ok());
1328    }
1329
1330    #[test]
1331    fn validate_enum_schema_invalid_default() {
1332        // Test that schema validation also catches invalid enum defaults
1333        let schema = vec![table(
1334            "users",
1335            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
1336                let mut c = col(
1337                    "status",
1338                    ColumnType::Complex(ComplexColumnType::Enum {
1339                        name: "user_status".into(),
1340                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1341                    }),
1342                );
1343                c.default = Some("invalid".into());
1344                c
1345            }],
1346            vec![pk(vec!["id"])],
1347        )];
1348
1349        let result = validate_schema(&schema);
1350        assert!(result.is_err());
1351        match result.unwrap_err() {
1352            PlannerError::InvalidEnumDefault(err) => {
1353                assert_eq!(err.enum_name, "user_status");
1354                assert_eq!(err.table_name, "users");
1355                assert_eq!(err.column_name, "status");
1356                assert_eq!(err.value_type, "default");
1357                assert_eq!(err.value, "invalid");
1358            }
1359            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1360        }
1361    }
1362
1363    #[test]
1364    fn validate_enum_schema_valid_default() {
1365        let schema = vec![table(
1366            "users",
1367            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
1368                let mut c = col(
1369                    "status",
1370                    ColumnType::Complex(ComplexColumnType::Enum {
1371                        name: "user_status".into(),
1372                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1373                    }),
1374                );
1375                c.default = Some("'active'".into());
1376                c
1377            }],
1378            vec![pk(vec!["id"])],
1379        )];
1380
1381        let result = validate_schema(&schema);
1382        assert!(result.is_ok());
1383    }
1384
1385    #[test]
1386    fn validate_enum_integer_add_column_valid() {
1387        let plan = MigrationPlan {
1388            comment: None,
1389            created_at: None,
1390            version: 1,
1391            actions: vec![MigrationAction::AddColumn {
1392                table: "tasks".into(),
1393                column: Box::new(ColumnDef {
1394                    name: "priority".into(),
1395                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1396                        name: "priority_level".into(),
1397                        values: EnumValues::Integer(vec![
1398                            NumValue {
1399                                name: "Low".into(),
1400                                value: 0,
1401                            },
1402                            NumValue {
1403                                name: "Medium".into(),
1404                                value: 50,
1405                            },
1406                            NumValue {
1407                                name: "High".into(),
1408                                value: 100,
1409                            },
1410                        ]),
1411                    }),
1412                    nullable: false,
1413                    default: None,
1414                    comment: None,
1415                    primary_key: None,
1416                    unique: None,
1417                    index: None,
1418                    foreign_key: None,
1419                }),
1420                fill_with: Some("Low".into()),
1421            }],
1422        };
1423
1424        let result = validate_migration_plan(&plan);
1425        assert!(result.is_ok());
1426    }
1427
1428    #[test]
1429    fn validate_enum_integer_add_column_invalid() {
1430        let plan = MigrationPlan {
1431            comment: None,
1432            created_at: None,
1433            version: 1,
1434            actions: vec![MigrationAction::AddColumn {
1435                table: "tasks".into(),
1436                column: Box::new(ColumnDef {
1437                    name: "priority".into(),
1438                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1439                        name: "priority_level".into(),
1440                        values: EnumValues::Integer(vec![
1441                            NumValue {
1442                                name: "Low".into(),
1443                                value: 0,
1444                            },
1445                            NumValue {
1446                                name: "Medium".into(),
1447                                value: 50,
1448                            },
1449                            NumValue {
1450                                name: "High".into(),
1451                                value: 100,
1452                            },
1453                        ]),
1454                    }),
1455                    nullable: false,
1456                    default: None,
1457                    comment: None,
1458                    primary_key: None,
1459                    unique: None,
1460                    index: None,
1461                    foreign_key: None,
1462                }),
1463                fill_with: Some("Critical".into()), // Not a valid enum name
1464            }],
1465        };
1466
1467        let result = validate_migration_plan(&plan);
1468        assert!(result.is_err());
1469        match result.unwrap_err() {
1470            PlannerError::InvalidEnumDefault(err) => {
1471                assert_eq!(err.enum_name, "priority_level");
1472                assert_eq!(err.table_name, "tasks");
1473                assert_eq!(err.column_name, "priority");
1474                assert_eq!(err.value_type, "fill_with");
1475                assert_eq!(err.value, "Critical");
1476            }
1477            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1478        }
1479    }
1480
1481    #[test]
1482    fn validate_enum_null_value_skipped() {
1483        // NULL values should be allowed and skipped during validation
1484        let plan = MigrationPlan {
1485            comment: None,
1486            created_at: None,
1487            version: 1,
1488            actions: vec![MigrationAction::AddColumn {
1489                table: "users".into(),
1490                column: Box::new(ColumnDef {
1491                    name: "status".into(),
1492                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1493                        name: "user_status".into(),
1494                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1495                    }),
1496                    nullable: true,
1497                    default: Some("NULL".into()),
1498                    comment: None,
1499                    primary_key: None,
1500                    unique: None,
1501                    index: None,
1502                    foreign_key: None,
1503                }),
1504                fill_with: None,
1505            }],
1506        };
1507
1508        let result = validate_migration_plan(&plan);
1509        assert!(result.is_ok());
1510    }
1511
1512    #[test]
1513    fn validate_enum_sql_expression_skipped() {
1514        // SQL expressions like function calls should be skipped
1515        let plan = MigrationPlan {
1516            comment: None,
1517            created_at: None,
1518            version: 1,
1519            actions: vec![MigrationAction::AddColumn {
1520                table: "users".into(),
1521                column: Box::new(ColumnDef {
1522                    name: "status".into(),
1523                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1524                        name: "user_status".into(),
1525                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1526                    }),
1527                    nullable: true,
1528                    default: None,
1529                    comment: None,
1530                    primary_key: None,
1531                    unique: None,
1532                    index: None,
1533                    foreign_key: None,
1534                }),
1535                fill_with: Some("COALESCE(old_status, 'active')".into()),
1536            }],
1537        };
1538
1539        let result = validate_migration_plan(&plan);
1540        assert!(result.is_ok());
1541    }
1542
1543    #[test]
1544    fn validate_enum_empty_string_fill_with_skipped() {
1545        // Empty string fill_with should be skipped during enum validation
1546        // (converted to '' by to_sql, which is empty after trimming)
1547        let plan = MigrationPlan {
1548            comment: None,
1549            created_at: None,
1550            version: 1,
1551            actions: vec![MigrationAction::AddColumn {
1552                table: "users".into(),
1553                column: Box::new(ColumnDef {
1554                    name: "status".into(),
1555                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1556                        name: "user_status".into(),
1557                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1558                    }),
1559                    nullable: true,
1560                    default: None,
1561                    comment: None,
1562                    primary_key: None,
1563                    unique: None,
1564                    index: None,
1565                    foreign_key: None,
1566                }),
1567                // Empty string - extract_enum_value returns None for empty trimmed values
1568                fill_with: Some("   ".into()),
1569            }],
1570        };
1571
1572        let result = validate_migration_plan(&plan);
1573        assert!(result.is_ok());
1574    }
1575
1576    // Tests for find_missing_fill_with function
1577    #[test]
1578    fn find_missing_fill_with_add_column_not_null_no_default() {
1579        let plan = MigrationPlan {
1580            comment: None,
1581            created_at: None,
1582            version: 1,
1583            actions: vec![MigrationAction::AddColumn {
1584                table: "users".into(),
1585                column: Box::new(ColumnDef {
1586                    name: "email".into(),
1587                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1588                    nullable: false,
1589                    default: None,
1590                    comment: None,
1591                    primary_key: None,
1592                    unique: None,
1593                    index: None,
1594                    foreign_key: None,
1595                }),
1596                fill_with: None,
1597            }],
1598        };
1599
1600        let missing = find_missing_fill_with(&plan);
1601        assert_eq!(missing.len(), 1);
1602        assert_eq!(missing[0].table, "users");
1603        assert_eq!(missing[0].column, "email");
1604        assert_eq!(missing[0].action_type, "AddColumn");
1605        assert!(missing[0].column_type.is_some());
1606    }
1607
1608    #[test]
1609    fn find_missing_fill_with_add_column_with_default() {
1610        let plan = MigrationPlan {
1611            comment: None,
1612            created_at: None,
1613            version: 1,
1614            actions: vec![MigrationAction::AddColumn {
1615                table: "users".into(),
1616                column: Box::new(ColumnDef {
1617                    name: "email".into(),
1618                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1619                    nullable: false,
1620                    default: Some("'default@example.com'".into()),
1621                    comment: None,
1622                    primary_key: None,
1623                    unique: None,
1624                    index: None,
1625                    foreign_key: None,
1626                }),
1627                fill_with: None,
1628            }],
1629        };
1630
1631        let missing = find_missing_fill_with(&plan);
1632        assert!(missing.is_empty());
1633    }
1634
1635    #[test]
1636    fn find_missing_fill_with_add_column_nullable() {
1637        let plan = MigrationPlan {
1638            comment: None,
1639            created_at: None,
1640            version: 1,
1641            actions: vec![MigrationAction::AddColumn {
1642                table: "users".into(),
1643                column: Box::new(ColumnDef {
1644                    name: "email".into(),
1645                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1646                    nullable: true,
1647                    default: None,
1648                    comment: None,
1649                    primary_key: None,
1650                    unique: None,
1651                    index: None,
1652                    foreign_key: None,
1653                }),
1654                fill_with: None,
1655            }],
1656        };
1657
1658        let missing = find_missing_fill_with(&plan);
1659        assert!(missing.is_empty());
1660    }
1661
1662    #[test]
1663    fn find_missing_fill_with_add_column_with_fill_with() {
1664        let plan = MigrationPlan {
1665            comment: None,
1666            created_at: None,
1667            version: 1,
1668            actions: vec![MigrationAction::AddColumn {
1669                table: "users".into(),
1670                column: Box::new(ColumnDef {
1671                    name: "email".into(),
1672                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1673                    nullable: false,
1674                    default: None,
1675                    comment: None,
1676                    primary_key: None,
1677                    unique: None,
1678                    index: None,
1679                    foreign_key: None,
1680                }),
1681                fill_with: Some("'default@example.com'".into()),
1682            }],
1683        };
1684
1685        let missing = find_missing_fill_with(&plan);
1686        assert!(missing.is_empty());
1687    }
1688
1689    #[test]
1690    fn find_missing_fill_with_modify_nullable_to_not_null() {
1691        let plan = MigrationPlan {
1692            comment: None,
1693            created_at: None,
1694            version: 1,
1695            actions: vec![MigrationAction::ModifyColumnNullable {
1696                table: "users".into(),
1697                column: "email".into(),
1698                nullable: false,
1699                fill_with: None,
1700            }],
1701        };
1702
1703        let missing = find_missing_fill_with(&plan);
1704        assert_eq!(missing.len(), 1);
1705        assert_eq!(missing[0].table, "users");
1706        assert_eq!(missing[0].column, "email");
1707        assert_eq!(missing[0].action_type, "ModifyColumnNullable");
1708        assert!(missing[0].column_type.is_none());
1709    }
1710
1711    #[test]
1712    fn find_missing_fill_with_modify_to_nullable() {
1713        let plan = MigrationPlan {
1714            comment: None,
1715            created_at: None,
1716            version: 1,
1717            actions: vec![MigrationAction::ModifyColumnNullable {
1718                table: "users".into(),
1719                column: "email".into(),
1720                nullable: true,
1721                fill_with: None,
1722            }],
1723        };
1724
1725        let missing = find_missing_fill_with(&plan);
1726        assert!(missing.is_empty());
1727    }
1728
1729    #[test]
1730    fn find_missing_fill_with_modify_not_null_with_fill_with() {
1731        let plan = MigrationPlan {
1732            comment: None,
1733            created_at: None,
1734            version: 1,
1735            actions: vec![MigrationAction::ModifyColumnNullable {
1736                table: "users".into(),
1737                column: "email".into(),
1738                nullable: false,
1739                fill_with: Some("'default'".into()),
1740            }],
1741        };
1742
1743        let missing = find_missing_fill_with(&plan);
1744        assert!(missing.is_empty());
1745    }
1746
1747    #[test]
1748    fn find_missing_fill_with_multiple_actions() {
1749        let plan = MigrationPlan {
1750            comment: None,
1751            created_at: None,
1752            version: 1,
1753            actions: vec![
1754                MigrationAction::AddColumn {
1755                    table: "users".into(),
1756                    column: Box::new(ColumnDef {
1757                        name: "email".into(),
1758                        r#type: ColumnType::Simple(SimpleColumnType::Text),
1759                        nullable: false,
1760                        default: None,
1761                        comment: None,
1762                        primary_key: None,
1763                        unique: None,
1764                        index: None,
1765                        foreign_key: None,
1766                    }),
1767                    fill_with: None,
1768                },
1769                MigrationAction::ModifyColumnNullable {
1770                    table: "orders".into(),
1771                    column: "status".into(),
1772                    nullable: false,
1773                    fill_with: None,
1774                },
1775                MigrationAction::AddColumn {
1776                    table: "users".into(),
1777                    column: Box::new(ColumnDef {
1778                        name: "name".into(),
1779                        r#type: ColumnType::Simple(SimpleColumnType::Text),
1780                        nullable: true, // nullable, so not missing
1781                        default: None,
1782                        comment: None,
1783                        primary_key: None,
1784                        unique: None,
1785                        index: None,
1786                        foreign_key: None,
1787                    }),
1788                    fill_with: None,
1789                },
1790            ],
1791        };
1792
1793        let missing = find_missing_fill_with(&plan);
1794        assert_eq!(missing.len(), 2);
1795        assert_eq!(missing[0].action_index, 0);
1796        assert_eq!(missing[0].table, "users");
1797        assert_eq!(missing[0].column, "email");
1798        assert_eq!(missing[1].action_index, 1);
1799        assert_eq!(missing[1].table, "orders");
1800        assert_eq!(missing[1].column, "status");
1801    }
1802
1803    #[test]
1804    fn find_missing_fill_with_other_actions_ignored() {
1805        let plan = MigrationPlan {
1806            comment: None,
1807            created_at: None,
1808            version: 1,
1809            actions: vec![
1810                MigrationAction::CreateTable {
1811                    table: "users".into(),
1812                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1813                    constraints: vec![pk(vec!["id"])],
1814                },
1815                MigrationAction::DeleteColumn {
1816                    table: "orders".into(),
1817                    column: "old_column".into(),
1818                },
1819            ],
1820        };
1821
1822        let missing = find_missing_fill_with(&plan);
1823        assert!(missing.is_empty());
1824    }
1825}