Skip to main content

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    /// Enum values if the column is an enum type (for selection UI).
408    pub enum_values: Option<Vec<String>>,
409}
410
411/// Find all actions in a migration plan that require fill_with values.
412/// Returns a list of actions that need fill_with but don't have one.
413pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec<FillWithRequired> {
414    let mut missing = Vec::new();
415
416    for (idx, action) in plan.actions.iter().enumerate() {
417        match action {
418            MigrationAction::AddColumn {
419                table,
420                column,
421                fill_with,
422            } => {
423                // If column is NOT NULL and has no default, fill_with is required
424                if !column.nullable && column.default.is_none() && fill_with.is_none() {
425                    missing.push(FillWithRequired {
426                        action_index: idx,
427                        table: table.clone(),
428                        column: column.name.clone(),
429                        action_type: "AddColumn",
430                        column_type: Some(column.r#type.to_display_string()),
431                        default_value: column.r#type.default_fill_value().map(String::from),
432                        enum_values: column.r#type.enum_variant_names(),
433                    });
434                }
435            }
436            MigrationAction::ModifyColumnNullable {
437                table,
438                column,
439                nullable,
440                fill_with,
441            } => {
442                // If changing from nullable to non-nullable, fill_with is required
443                if !nullable && fill_with.is_none() {
444                    missing.push(FillWithRequired {
445                        action_index: idx,
446                        table: table.clone(),
447                        column: column.clone(),
448                        action_type: "ModifyColumnNullable",
449                        column_type: None,
450                        default_value: None,
451                        enum_values: None, // We don't have column type info here
452                    });
453                }
454            }
455            _ => {}
456        }
457    }
458
459    missing
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use rstest::rstest;
466    use vespertide_core::{
467        ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType,
468        TableConstraint,
469    };
470
471    fn col(name: &str, ty: ColumnType) -> ColumnDef {
472        ColumnDef {
473            name: name.to_string(),
474            r#type: ty,
475            nullable: true,
476            default: None,
477            comment: None,
478            primary_key: None,
479            unique: None,
480            index: None,
481            foreign_key: None,
482        }
483    }
484
485    fn table(name: &str, columns: Vec<ColumnDef>, constraints: Vec<TableConstraint>) -> TableDef {
486        TableDef {
487            name: name.to_string(),
488            description: None,
489            columns,
490            constraints,
491        }
492    }
493
494    fn idx(name: &str, columns: Vec<&str>) -> TableConstraint {
495        TableConstraint::Index {
496            name: Some(name.to_string()),
497            columns: columns.into_iter().map(|s| s.to_string()).collect(),
498        }
499    }
500
501    fn is_duplicate(err: &PlannerError) -> bool {
502        matches!(err, PlannerError::DuplicateTableName(_))
503    }
504
505    fn is_fk_table(err: &PlannerError) -> bool {
506        matches!(err, PlannerError::ForeignKeyTableNotFound(_, _, _))
507    }
508
509    fn is_fk_column(err: &PlannerError) -> bool {
510        matches!(err, PlannerError::ForeignKeyColumnNotFound(_, _, _, _))
511    }
512
513    fn is_index_column(err: &PlannerError) -> bool {
514        matches!(err, PlannerError::IndexColumnNotFound(_, _, _))
515    }
516
517    fn is_constraint_column(err: &PlannerError) -> bool {
518        matches!(err, PlannerError::ConstraintColumnNotFound(_, _, _))
519    }
520
521    fn is_empty_columns(err: &PlannerError) -> bool {
522        matches!(err, PlannerError::EmptyConstraintColumns(_, _))
523    }
524
525    fn is_missing_pk(err: &PlannerError) -> bool {
526        matches!(err, PlannerError::MissingPrimaryKey(_))
527    }
528
529    fn pk(columns: Vec<&str>) -> TableConstraint {
530        TableConstraint::PrimaryKey {
531            auto_increment: false,
532            columns: columns.into_iter().map(|s| s.to_string()).collect(),
533        }
534    }
535
536    #[rstest]
537    #[case::valid_schema(
538        vec![table(
539            "users",
540            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
541            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
542        )],
543        None
544    )]
545    #[case::duplicate_table(
546        vec![
547            table("users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![]),
548            table("users", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![]),
549        ],
550        Some(is_duplicate as fn(&PlannerError) -> bool)
551    )]
552    #[case::fk_missing_table(
553        vec![table(
554            "users",
555            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
556            vec![pk(vec!["id"]), TableConstraint::ForeignKey {
557                name: None,
558                columns: vec!["id".into()],
559                ref_table: "nonexistent".into(),
560                ref_columns: vec!["id".into()],
561                on_delete: None,
562                on_update: None,
563            }],
564        )],
565        Some(is_fk_table as fn(&PlannerError) -> bool)
566    )]
567    #[case::fk_missing_column(
568        vec![
569            table("posts", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![pk(vec!["id"])]),
570            table(
571                "users",
572                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
573                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
574                    name: None,
575                    columns: vec!["id".into()],
576                    ref_table: "posts".into(),
577                    ref_columns: vec!["nonexistent".into()],
578                    on_delete: None,
579                    on_update: None,
580                }],
581            ),
582        ],
583        Some(is_fk_column as fn(&PlannerError) -> bool)
584    )]
585    #[case::fk_local_missing_column(
586        vec![
587            table("posts", vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], vec![pk(vec!["id"])]),
588            table(
589                "users",
590                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
591                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
592                    name: None,
593                    columns: vec!["missing".into()],
594                    ref_table: "posts".into(),
595                    ref_columns: vec!["id".into()],
596                    on_delete: None,
597                    on_update: None,
598                }],
599            ),
600        ],
601        Some(is_constraint_column as fn(&PlannerError) -> bool)
602    )]
603    #[case::fk_valid(
604        vec![
605            table(
606                "posts",
607                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
608                vec![pk(vec!["id"])],
609            ),
610            table(
611                "users",
612                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("post_id", ColumnType::Simple(SimpleColumnType::Integer))],
613                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
614                    name: None,
615                    columns: vec!["post_id".into()],
616                    ref_table: "posts".into(),
617                    ref_columns: vec!["id".into()],
618                    on_delete: None,
619                    on_update: None,
620                }],
621            ),
622        ],
623        None
624    )]
625    #[case::index_missing_column(
626        vec![table(
627            "users",
628            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
629            vec![pk(vec!["id"]), idx("idx_name", vec!["nonexistent"])],
630        )],
631        Some(is_index_column as fn(&PlannerError) -> bool)
632    )]
633    #[case::constraint_missing_column(
634        vec![table(
635            "users",
636            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
637            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["nonexistent".into()] }],
638        )],
639        Some(is_constraint_column as fn(&PlannerError) -> bool)
640    )]
641    #[case::unique_empty_columns(
642        vec![table(
643            "users",
644            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
645            vec![pk(vec!["id"]), TableConstraint::Unique {
646                name: Some("u".into()),
647                columns: vec![],
648            }],
649        )],
650        Some(is_empty_columns as fn(&PlannerError) -> bool)
651    )]
652    #[case::unique_missing_column(
653        vec![table(
654            "users",
655            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
656            vec![pk(vec!["id"]), TableConstraint::Unique {
657                name: None,
658                columns: vec!["missing".into()],
659            }],
660        )],
661        Some(is_constraint_column as fn(&PlannerError) -> bool)
662    )]
663    #[case::empty_primary_key(
664        vec![table(
665            "users",
666            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
667            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec![] }],
668        )],
669        Some(is_empty_columns as fn(&PlannerError) -> bool)
670    )]
671    #[case::fk_column_count_mismatch(
672        vec![
673            table(
674                "posts",
675                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
676                vec![pk(vec!["id"])],
677            ),
678            table(
679                "users",
680                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("post_id", ColumnType::Simple(SimpleColumnType::Integer))],
681                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
682                    name: None,
683                    columns: vec!["id".into(), "post_id".into()],
684                    ref_table: "posts".into(),
685                    ref_columns: vec!["id".into()],
686                    on_delete: None,
687                    on_update: None,
688                }],
689            ),
690        ],
691        Some(is_fk_column as fn(&PlannerError) -> bool)
692    )]
693    #[case::fk_empty_columns(
694        vec![table(
695            "users",
696            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
697            vec![pk(vec!["id"]), TableConstraint::ForeignKey {
698                name: None,
699                columns: vec![],
700                ref_table: "posts".into(),
701                ref_columns: vec!["id".into()],
702                on_delete: None,
703                on_update: None,
704            }],
705        )],
706        Some(is_empty_columns as fn(&PlannerError) -> bool)
707    )]
708    #[case::fk_empty_ref_columns(
709        vec![
710            table(
711                "posts",
712                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
713                vec![pk(vec!["id"])],
714            ),
715            table(
716                "users",
717                vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
718                vec![pk(vec!["id"]), TableConstraint::ForeignKey {
719                    name: None,
720                    columns: vec!["id".into()],
721                    ref_table: "posts".into(),
722                    ref_columns: vec![],
723                    on_delete: None,
724                    on_update: None,
725                }],
726            ),
727        ],
728        Some(is_empty_columns as fn(&PlannerError) -> bool)
729    )]
730    #[case::index_empty_columns(
731        vec![table(
732            "users",
733            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
734            vec![pk(vec!["id"]), TableConstraint::Index {
735                name: Some("idx".into()),
736                columns: vec![],
737            }],
738        )],
739        Some(is_empty_columns as fn(&PlannerError) -> bool)
740    )]
741    #[case::index_valid(
742        vec![table(
743            "users",
744            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
745            vec![pk(vec!["id"]), idx("idx_name", vec!["name"])],
746        )],
747        None
748    )]
749    #[case::check_constraint_ok(
750        vec![table(
751            "users",
752            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
753            vec![pk(vec!["id"]), TableConstraint::Check {
754                name: "ck".into(),
755                expr: "id > 0".into(),
756            }],
757        )],
758        None
759    )]
760    #[case::missing_primary_key(
761        vec![table(
762            "users",
763            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
764            vec![],
765        )],
766        Some(is_missing_pk as fn(&PlannerError) -> bool)
767    )]
768    fn validate_schema_cases(
769        #[case] schema: Vec<TableDef>,
770        #[case] expected_err: Option<fn(&PlannerError) -> bool>,
771    ) {
772        let result = validate_schema(&schema);
773        match expected_err {
774            None => assert!(result.is_ok()),
775            Some(pred) => {
776                let err = result.unwrap_err();
777                assert!(pred(&err), "unexpected error: {:?}", err);
778            }
779        }
780    }
781
782    #[test]
783    fn validate_migration_plan_missing_fill_with() {
784        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
785
786        let plan = MigrationPlan {
787            comment: None,
788            created_at: None,
789            version: 1,
790            actions: vec![MigrationAction::AddColumn {
791                table: "users".into(),
792                column: Box::new(ColumnDef {
793                    name: "email".into(),
794                    r#type: ColumnType::Simple(SimpleColumnType::Text),
795                    nullable: false,
796                    default: None,
797                    comment: None,
798                    primary_key: None,
799                    unique: None,
800                    index: None,
801                    foreign_key: None,
802                }),
803                fill_with: None,
804            }],
805        };
806
807        let result = validate_migration_plan(&plan);
808        assert!(result.is_err());
809        match result.unwrap_err() {
810            PlannerError::MissingFillWith(table, column) => {
811                assert_eq!(table, "users");
812                assert_eq!(column, "email");
813            }
814            _ => panic!("expected MissingFillWith error"),
815        }
816    }
817
818    #[test]
819    fn validate_migration_plan_with_fill_with() {
820        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
821
822        let plan = MigrationPlan {
823            comment: None,
824            created_at: None,
825            version: 1,
826            actions: vec![MigrationAction::AddColumn {
827                table: "users".into(),
828                column: Box::new(ColumnDef {
829                    name: "email".into(),
830                    r#type: ColumnType::Simple(SimpleColumnType::Text),
831                    nullable: false,
832                    default: None,
833                    comment: None,
834                    primary_key: None,
835                    unique: None,
836                    index: None,
837                    foreign_key: None,
838                }),
839                fill_with: Some("default@example.com".into()),
840            }],
841        };
842
843        let result = validate_migration_plan(&plan);
844        assert!(result.is_ok());
845    }
846
847    #[test]
848    fn validate_migration_plan_nullable_column() {
849        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
850
851        let plan = MigrationPlan {
852            comment: None,
853            created_at: None,
854            version: 1,
855            actions: vec![MigrationAction::AddColumn {
856                table: "users".into(),
857                column: Box::new(ColumnDef {
858                    name: "email".into(),
859                    r#type: ColumnType::Simple(SimpleColumnType::Text),
860                    nullable: true,
861                    default: None,
862                    comment: None,
863                    primary_key: None,
864                    unique: None,
865                    index: None,
866                    foreign_key: None,
867                }),
868                fill_with: None,
869            }],
870        };
871
872        let result = validate_migration_plan(&plan);
873        assert!(result.is_ok());
874    }
875
876    #[test]
877    fn validate_migration_plan_with_default() {
878        use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan};
879
880        let plan = MigrationPlan {
881            comment: None,
882            created_at: None,
883            version: 1,
884            actions: vec![MigrationAction::AddColumn {
885                table: "users".into(),
886                column: Box::new(ColumnDef {
887                    name: "email".into(),
888                    r#type: ColumnType::Simple(SimpleColumnType::Text),
889                    nullable: false,
890                    default: Some("default@example.com".into()),
891                    comment: None,
892                    primary_key: None,
893                    unique: None,
894                    index: None,
895                    foreign_key: None,
896                }),
897                fill_with: None,
898            }],
899        };
900
901        let result = validate_migration_plan(&plan);
902        assert!(result.is_ok());
903    }
904
905    #[test]
906    fn validate_string_enum_duplicate_variant_name() {
907        let schema = vec![table(
908            "users",
909            vec![
910                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
911                col(
912                    "status",
913                    ColumnType::Complex(ComplexColumnType::Enum {
914                        name: "user_status".into(),
915                        values: EnumValues::String(vec![
916                            "active".into(),
917                            "inactive".into(),
918                            "active".into(), // duplicate
919                        ]),
920                    }),
921                ),
922            ],
923            vec![TableConstraint::PrimaryKey {
924                auto_increment: false,
925                columns: vec!["id".into()],
926            }],
927        )];
928
929        let result = validate_schema(&schema);
930        assert!(result.is_err());
931        match result.unwrap_err() {
932            PlannerError::DuplicateEnumVariantName(enum_name, table, column, variant) => {
933                assert_eq!(enum_name, "user_status");
934                assert_eq!(table, "users");
935                assert_eq!(column, "status");
936                assert_eq!(variant, "active");
937            }
938            err => panic!("expected DuplicateEnumVariantName, got {:?}", err),
939        }
940    }
941
942    #[test]
943    fn validate_integer_enum_duplicate_variant_name() {
944        let schema = vec![table(
945            "tasks",
946            vec![
947                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
948                col(
949                    "priority",
950                    ColumnType::Complex(ComplexColumnType::Enum {
951                        name: "priority_level".into(),
952                        values: EnumValues::Integer(vec![
953                            NumValue {
954                                name: "Low".into(),
955                                value: 0,
956                            },
957                            NumValue {
958                                name: "High".into(),
959                                value: 1,
960                            },
961                            NumValue {
962                                name: "Low".into(), // duplicate name
963                                value: 2,
964                            },
965                        ]),
966                    }),
967                ),
968            ],
969            vec![TableConstraint::PrimaryKey {
970                auto_increment: false,
971                columns: vec!["id".into()],
972            }],
973        )];
974
975        let result = validate_schema(&schema);
976        assert!(result.is_err());
977        match result.unwrap_err() {
978            PlannerError::DuplicateEnumVariantName(enum_name, table, column, variant) => {
979                assert_eq!(enum_name, "priority_level");
980                assert_eq!(table, "tasks");
981                assert_eq!(column, "priority");
982                assert_eq!(variant, "Low");
983            }
984            err => panic!("expected DuplicateEnumVariantName, got {:?}", err),
985        }
986    }
987
988    #[test]
989    fn validate_integer_enum_duplicate_value() {
990        let schema = vec![table(
991            "tasks",
992            vec![
993                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
994                col(
995                    "priority",
996                    ColumnType::Complex(ComplexColumnType::Enum {
997                        name: "priority_level".into(),
998                        values: EnumValues::Integer(vec![
999                            NumValue {
1000                                name: "Low".into(),
1001                                value: 0,
1002                            },
1003                            NumValue {
1004                                name: "Medium".into(),
1005                                value: 1,
1006                            },
1007                            NumValue {
1008                                name: "High".into(),
1009                                value: 0, // duplicate value
1010                            },
1011                        ]),
1012                    }),
1013                ),
1014            ],
1015            vec![TableConstraint::PrimaryKey {
1016                auto_increment: false,
1017                columns: vec!["id".into()],
1018            }],
1019        )];
1020
1021        let result = validate_schema(&schema);
1022        assert!(result.is_err());
1023        match result.unwrap_err() {
1024            PlannerError::DuplicateEnumValue(enum_name, table, column, value) => {
1025                assert_eq!(enum_name, "priority_level");
1026                assert_eq!(table, "tasks");
1027                assert_eq!(column, "priority");
1028                assert_eq!(value, 0);
1029            }
1030            err => panic!("expected DuplicateEnumValue, got {:?}", err),
1031        }
1032    }
1033
1034    #[test]
1035    fn validate_enum_valid() {
1036        let schema = vec![table(
1037            "tasks",
1038            vec![
1039                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1040                col(
1041                    "status",
1042                    ColumnType::Complex(ComplexColumnType::Enum {
1043                        name: "task_status".into(),
1044                        values: EnumValues::String(vec![
1045                            "pending".into(),
1046                            "in_progress".into(),
1047                            "completed".into(),
1048                        ]),
1049                    }),
1050                ),
1051                col(
1052                    "priority",
1053                    ColumnType::Complex(ComplexColumnType::Enum {
1054                        name: "priority_level".into(),
1055                        values: EnumValues::Integer(vec![
1056                            NumValue {
1057                                name: "Low".into(),
1058                                value: 0,
1059                            },
1060                            NumValue {
1061                                name: "Medium".into(),
1062                                value: 50,
1063                            },
1064                            NumValue {
1065                                name: "High".into(),
1066                                value: 100,
1067                            },
1068                        ]),
1069                    }),
1070                ),
1071            ],
1072            vec![TableConstraint::PrimaryKey {
1073                auto_increment: false,
1074                columns: vec!["id".into()],
1075            }],
1076        )];
1077
1078        let result = validate_schema(&schema);
1079        assert!(result.is_ok());
1080    }
1081
1082    #[test]
1083    fn validate_migration_plan_modify_nullable_to_non_nullable_missing_fill_with() {
1084        let plan = MigrationPlan {
1085            comment: None,
1086            created_at: None,
1087            version: 1,
1088            actions: vec![MigrationAction::ModifyColumnNullable {
1089                table: "users".into(),
1090                column: "email".into(),
1091                nullable: false,
1092                fill_with: None,
1093            }],
1094        };
1095
1096        let result = validate_migration_plan(&plan);
1097        assert!(result.is_err());
1098        match result.unwrap_err() {
1099            PlannerError::MissingFillWith(table, column) => {
1100                assert_eq!(table, "users");
1101                assert_eq!(column, "email");
1102            }
1103            _ => panic!("expected MissingFillWith error"),
1104        }
1105    }
1106
1107    #[test]
1108    fn validate_migration_plan_modify_nullable_to_non_nullable_with_fill_with() {
1109        let plan = MigrationPlan {
1110            comment: None,
1111            created_at: None,
1112            version: 1,
1113            actions: vec![MigrationAction::ModifyColumnNullable {
1114                table: "users".into(),
1115                column: "email".into(),
1116                nullable: false,
1117                fill_with: Some("'unknown'".into()),
1118            }],
1119        };
1120
1121        let result = validate_migration_plan(&plan);
1122        assert!(result.is_ok());
1123    }
1124
1125    #[test]
1126    fn validate_migration_plan_modify_non_nullable_to_nullable() {
1127        // Changing from non-nullable to nullable does NOT require fill_with
1128        let plan = MigrationPlan {
1129            comment: None,
1130            created_at: None,
1131            version: 1,
1132            actions: vec![MigrationAction::ModifyColumnNullable {
1133                table: "users".into(),
1134                column: "email".into(),
1135                nullable: true,
1136                fill_with: None,
1137            }],
1138        };
1139
1140        let result = validate_migration_plan(&plan);
1141        assert!(result.is_ok());
1142    }
1143
1144    #[test]
1145    fn validate_enum_add_column_invalid_default() {
1146        let plan = MigrationPlan {
1147            comment: None,
1148            created_at: None,
1149            version: 1,
1150            actions: vec![MigrationAction::AddColumn {
1151                table: "users".into(),
1152                column: Box::new(ColumnDef {
1153                    name: "status".into(),
1154                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1155                        name: "user_status".into(),
1156                        values: EnumValues::String(vec![
1157                            "active".into(),
1158                            "inactive".into(),
1159                            "pending".into(),
1160                        ]),
1161                    }),
1162                    nullable: false,
1163                    default: Some("invalid_value".into()),
1164                    comment: None,
1165                    primary_key: None,
1166                    unique: None,
1167                    index: None,
1168                    foreign_key: None,
1169                }),
1170                fill_with: None,
1171            }],
1172        };
1173
1174        let result = validate_migration_plan(&plan);
1175        assert!(result.is_err());
1176        match result.unwrap_err() {
1177            PlannerError::InvalidEnumDefault(err) => {
1178                assert_eq!(err.enum_name, "user_status");
1179                assert_eq!(err.table_name, "users");
1180                assert_eq!(err.column_name, "status");
1181                assert_eq!(err.value_type, "default");
1182                assert_eq!(err.value, "invalid_value");
1183            }
1184            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1185        }
1186    }
1187
1188    #[test]
1189    fn validate_enum_add_column_invalid_fill_with() {
1190        let plan = MigrationPlan {
1191            comment: None,
1192            created_at: None,
1193            version: 1,
1194            actions: vec![MigrationAction::AddColumn {
1195                table: "users".into(),
1196                column: Box::new(ColumnDef {
1197                    name: "status".into(),
1198                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1199                        name: "user_status".into(),
1200                        values: EnumValues::String(vec![
1201                            "active".into(),
1202                            "inactive".into(),
1203                            "pending".into(),
1204                        ]),
1205                    }),
1206                    nullable: false,
1207                    default: None,
1208                    comment: None,
1209                    primary_key: None,
1210                    unique: None,
1211                    index: None,
1212                    foreign_key: None,
1213                }),
1214                fill_with: Some("unknown_status".into()),
1215            }],
1216        };
1217
1218        let result = validate_migration_plan(&plan);
1219        assert!(result.is_err());
1220        match result.unwrap_err() {
1221            PlannerError::InvalidEnumDefault(err) => {
1222                assert_eq!(err.enum_name, "user_status");
1223                assert_eq!(err.table_name, "users");
1224                assert_eq!(err.column_name, "status");
1225                assert_eq!(err.value_type, "fill_with");
1226                assert_eq!(err.value, "unknown_status");
1227            }
1228            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1229        }
1230    }
1231
1232    #[test]
1233    fn validate_enum_add_column_valid_default_quoted() {
1234        let plan = MigrationPlan {
1235            comment: None,
1236            created_at: None,
1237            version: 1,
1238            actions: vec![MigrationAction::AddColumn {
1239                table: "users".into(),
1240                column: Box::new(ColumnDef {
1241                    name: "status".into(),
1242                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1243                        name: "user_status".into(),
1244                        values: EnumValues::String(vec![
1245                            "active".into(),
1246                            "inactive".into(),
1247                            "pending".into(),
1248                        ]),
1249                    }),
1250                    nullable: false,
1251                    default: Some("'active'".into()),
1252                    comment: None,
1253                    primary_key: None,
1254                    unique: None,
1255                    index: None,
1256                    foreign_key: None,
1257                }),
1258                fill_with: None,
1259            }],
1260        };
1261
1262        let result = validate_migration_plan(&plan);
1263        assert!(result.is_ok());
1264    }
1265
1266    #[test]
1267    fn validate_enum_add_column_valid_default_unquoted() {
1268        let plan = MigrationPlan {
1269            comment: None,
1270            created_at: None,
1271            version: 1,
1272            actions: vec![MigrationAction::AddColumn {
1273                table: "users".into(),
1274                column: Box::new(ColumnDef {
1275                    name: "status".into(),
1276                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1277                        name: "user_status".into(),
1278                        values: EnumValues::String(vec![
1279                            "active".into(),
1280                            "inactive".into(),
1281                            "pending".into(),
1282                        ]),
1283                    }),
1284                    nullable: false,
1285                    default: Some("active".into()),
1286                    comment: None,
1287                    primary_key: None,
1288                    unique: None,
1289                    index: None,
1290                    foreign_key: None,
1291                }),
1292                fill_with: None,
1293            }],
1294        };
1295
1296        let result = validate_migration_plan(&plan);
1297        assert!(result.is_ok());
1298    }
1299
1300    #[test]
1301    fn validate_enum_add_column_valid_fill_with() {
1302        let plan = MigrationPlan {
1303            comment: None,
1304            created_at: None,
1305            version: 1,
1306            actions: vec![MigrationAction::AddColumn {
1307                table: "users".into(),
1308                column: Box::new(ColumnDef {
1309                    name: "status".into(),
1310                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1311                        name: "user_status".into(),
1312                        values: EnumValues::String(vec![
1313                            "active".into(),
1314                            "inactive".into(),
1315                            "pending".into(),
1316                        ]),
1317                    }),
1318                    nullable: false,
1319                    default: None,
1320                    comment: None,
1321                    primary_key: None,
1322                    unique: None,
1323                    index: None,
1324                    foreign_key: None,
1325                }),
1326                fill_with: Some("'pending'".into()),
1327            }],
1328        };
1329
1330        let result = validate_migration_plan(&plan);
1331        assert!(result.is_ok());
1332    }
1333
1334    #[test]
1335    fn validate_enum_schema_invalid_default() {
1336        // Test that schema validation also catches invalid enum defaults
1337        let schema = vec![table(
1338            "users",
1339            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
1340                let mut c = col(
1341                    "status",
1342                    ColumnType::Complex(ComplexColumnType::Enum {
1343                        name: "user_status".into(),
1344                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1345                    }),
1346                );
1347                c.default = Some("invalid".into());
1348                c
1349            }],
1350            vec![pk(vec!["id"])],
1351        )];
1352
1353        let result = validate_schema(&schema);
1354        assert!(result.is_err());
1355        match result.unwrap_err() {
1356            PlannerError::InvalidEnumDefault(err) => {
1357                assert_eq!(err.enum_name, "user_status");
1358                assert_eq!(err.table_name, "users");
1359                assert_eq!(err.column_name, "status");
1360                assert_eq!(err.value_type, "default");
1361                assert_eq!(err.value, "invalid");
1362            }
1363            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1364        }
1365    }
1366
1367    #[test]
1368    fn validate_enum_schema_valid_default() {
1369        let schema = vec![table(
1370            "users",
1371            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), {
1372                let mut c = col(
1373                    "status",
1374                    ColumnType::Complex(ComplexColumnType::Enum {
1375                        name: "user_status".into(),
1376                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1377                    }),
1378                );
1379                c.default = Some("'active'".into());
1380                c
1381            }],
1382            vec![pk(vec!["id"])],
1383        )];
1384
1385        let result = validate_schema(&schema);
1386        assert!(result.is_ok());
1387    }
1388
1389    #[test]
1390    fn validate_enum_integer_add_column_valid() {
1391        let plan = MigrationPlan {
1392            comment: None,
1393            created_at: None,
1394            version: 1,
1395            actions: vec![MigrationAction::AddColumn {
1396                table: "tasks".into(),
1397                column: Box::new(ColumnDef {
1398                    name: "priority".into(),
1399                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1400                        name: "priority_level".into(),
1401                        values: EnumValues::Integer(vec![
1402                            NumValue {
1403                                name: "Low".into(),
1404                                value: 0,
1405                            },
1406                            NumValue {
1407                                name: "Medium".into(),
1408                                value: 50,
1409                            },
1410                            NumValue {
1411                                name: "High".into(),
1412                                value: 100,
1413                            },
1414                        ]),
1415                    }),
1416                    nullable: false,
1417                    default: None,
1418                    comment: None,
1419                    primary_key: None,
1420                    unique: None,
1421                    index: None,
1422                    foreign_key: None,
1423                }),
1424                fill_with: Some("Low".into()),
1425            }],
1426        };
1427
1428        let result = validate_migration_plan(&plan);
1429        assert!(result.is_ok());
1430    }
1431
1432    #[test]
1433    fn validate_enum_integer_add_column_invalid() {
1434        let plan = MigrationPlan {
1435            comment: None,
1436            created_at: None,
1437            version: 1,
1438            actions: vec![MigrationAction::AddColumn {
1439                table: "tasks".into(),
1440                column: Box::new(ColumnDef {
1441                    name: "priority".into(),
1442                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1443                        name: "priority_level".into(),
1444                        values: EnumValues::Integer(vec![
1445                            NumValue {
1446                                name: "Low".into(),
1447                                value: 0,
1448                            },
1449                            NumValue {
1450                                name: "Medium".into(),
1451                                value: 50,
1452                            },
1453                            NumValue {
1454                                name: "High".into(),
1455                                value: 100,
1456                            },
1457                        ]),
1458                    }),
1459                    nullable: false,
1460                    default: None,
1461                    comment: None,
1462                    primary_key: None,
1463                    unique: None,
1464                    index: None,
1465                    foreign_key: None,
1466                }),
1467                fill_with: Some("Critical".into()), // Not a valid enum name
1468            }],
1469        };
1470
1471        let result = validate_migration_plan(&plan);
1472        assert!(result.is_err());
1473        match result.unwrap_err() {
1474            PlannerError::InvalidEnumDefault(err) => {
1475                assert_eq!(err.enum_name, "priority_level");
1476                assert_eq!(err.table_name, "tasks");
1477                assert_eq!(err.column_name, "priority");
1478                assert_eq!(err.value_type, "fill_with");
1479                assert_eq!(err.value, "Critical");
1480            }
1481            err => panic!("expected InvalidEnumDefault error, got {:?}", err),
1482        }
1483    }
1484
1485    #[test]
1486    fn validate_enum_null_value_skipped() {
1487        // NULL values should be allowed and skipped during validation
1488        let plan = MigrationPlan {
1489            comment: None,
1490            created_at: None,
1491            version: 1,
1492            actions: vec![MigrationAction::AddColumn {
1493                table: "users".into(),
1494                column: Box::new(ColumnDef {
1495                    name: "status".into(),
1496                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1497                        name: "user_status".into(),
1498                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1499                    }),
1500                    nullable: true,
1501                    default: Some("NULL".into()),
1502                    comment: None,
1503                    primary_key: None,
1504                    unique: None,
1505                    index: None,
1506                    foreign_key: None,
1507                }),
1508                fill_with: None,
1509            }],
1510        };
1511
1512        let result = validate_migration_plan(&plan);
1513        assert!(result.is_ok());
1514    }
1515
1516    #[test]
1517    fn validate_enum_sql_expression_skipped() {
1518        // SQL expressions like function calls should be skipped
1519        let plan = MigrationPlan {
1520            comment: None,
1521            created_at: None,
1522            version: 1,
1523            actions: vec![MigrationAction::AddColumn {
1524                table: "users".into(),
1525                column: Box::new(ColumnDef {
1526                    name: "status".into(),
1527                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1528                        name: "user_status".into(),
1529                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1530                    }),
1531                    nullable: true,
1532                    default: None,
1533                    comment: None,
1534                    primary_key: None,
1535                    unique: None,
1536                    index: None,
1537                    foreign_key: None,
1538                }),
1539                fill_with: Some("COALESCE(old_status, 'active')".into()),
1540            }],
1541        };
1542
1543        let result = validate_migration_plan(&plan);
1544        assert!(result.is_ok());
1545    }
1546
1547    #[test]
1548    fn validate_enum_empty_string_fill_with_skipped() {
1549        // Empty string fill_with should be skipped during enum validation
1550        // (converted to '' by to_sql, which is empty after trimming)
1551        let plan = MigrationPlan {
1552            comment: None,
1553            created_at: None,
1554            version: 1,
1555            actions: vec![MigrationAction::AddColumn {
1556                table: "users".into(),
1557                column: Box::new(ColumnDef {
1558                    name: "status".into(),
1559                    r#type: ColumnType::Complex(ComplexColumnType::Enum {
1560                        name: "user_status".into(),
1561                        values: EnumValues::String(vec!["active".into(), "inactive".into()]),
1562                    }),
1563                    nullable: true,
1564                    default: None,
1565                    comment: None,
1566                    primary_key: None,
1567                    unique: None,
1568                    index: None,
1569                    foreign_key: None,
1570                }),
1571                // Empty string - extract_enum_value returns None for empty trimmed values
1572                fill_with: Some("   ".into()),
1573            }],
1574        };
1575
1576        let result = validate_migration_plan(&plan);
1577        assert!(result.is_ok());
1578    }
1579
1580    // Tests for find_missing_fill_with function
1581    #[test]
1582    fn find_missing_fill_with_add_column_not_null_no_default() {
1583        let plan = MigrationPlan {
1584            comment: None,
1585            created_at: None,
1586            version: 1,
1587            actions: vec![MigrationAction::AddColumn {
1588                table: "users".into(),
1589                column: Box::new(ColumnDef {
1590                    name: "email".into(),
1591                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1592                    nullable: false,
1593                    default: None,
1594                    comment: None,
1595                    primary_key: None,
1596                    unique: None,
1597                    index: None,
1598                    foreign_key: None,
1599                }),
1600                fill_with: None,
1601            }],
1602        };
1603
1604        let missing = find_missing_fill_with(&plan);
1605        assert_eq!(missing.len(), 1);
1606        assert_eq!(missing[0].table, "users");
1607        assert_eq!(missing[0].column, "email");
1608        assert_eq!(missing[0].action_type, "AddColumn");
1609        assert!(missing[0].column_type.is_some());
1610    }
1611
1612    #[test]
1613    fn find_missing_fill_with_add_column_with_default() {
1614        let plan = MigrationPlan {
1615            comment: None,
1616            created_at: None,
1617            version: 1,
1618            actions: vec![MigrationAction::AddColumn {
1619                table: "users".into(),
1620                column: Box::new(ColumnDef {
1621                    name: "email".into(),
1622                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1623                    nullable: false,
1624                    default: Some("'default@example.com'".into()),
1625                    comment: None,
1626                    primary_key: None,
1627                    unique: None,
1628                    index: None,
1629                    foreign_key: None,
1630                }),
1631                fill_with: None,
1632            }],
1633        };
1634
1635        let missing = find_missing_fill_with(&plan);
1636        assert!(missing.is_empty());
1637    }
1638
1639    #[test]
1640    fn find_missing_fill_with_add_column_nullable() {
1641        let plan = MigrationPlan {
1642            comment: None,
1643            created_at: None,
1644            version: 1,
1645            actions: vec![MigrationAction::AddColumn {
1646                table: "users".into(),
1647                column: Box::new(ColumnDef {
1648                    name: "email".into(),
1649                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1650                    nullable: true,
1651                    default: None,
1652                    comment: None,
1653                    primary_key: None,
1654                    unique: None,
1655                    index: None,
1656                    foreign_key: None,
1657                }),
1658                fill_with: None,
1659            }],
1660        };
1661
1662        let missing = find_missing_fill_with(&plan);
1663        assert!(missing.is_empty());
1664    }
1665
1666    #[test]
1667    fn find_missing_fill_with_add_column_with_fill_with() {
1668        let plan = MigrationPlan {
1669            comment: None,
1670            created_at: None,
1671            version: 1,
1672            actions: vec![MigrationAction::AddColumn {
1673                table: "users".into(),
1674                column: Box::new(ColumnDef {
1675                    name: "email".into(),
1676                    r#type: ColumnType::Simple(SimpleColumnType::Text),
1677                    nullable: false,
1678                    default: None,
1679                    comment: None,
1680                    primary_key: None,
1681                    unique: None,
1682                    index: None,
1683                    foreign_key: None,
1684                }),
1685                fill_with: Some("'default@example.com'".into()),
1686            }],
1687        };
1688
1689        let missing = find_missing_fill_with(&plan);
1690        assert!(missing.is_empty());
1691    }
1692
1693    #[test]
1694    fn find_missing_fill_with_modify_nullable_to_not_null() {
1695        let plan = MigrationPlan {
1696            comment: None,
1697            created_at: None,
1698            version: 1,
1699            actions: vec![MigrationAction::ModifyColumnNullable {
1700                table: "users".into(),
1701                column: "email".into(),
1702                nullable: false,
1703                fill_with: None,
1704            }],
1705        };
1706
1707        let missing = find_missing_fill_with(&plan);
1708        assert_eq!(missing.len(), 1);
1709        assert_eq!(missing[0].table, "users");
1710        assert_eq!(missing[0].column, "email");
1711        assert_eq!(missing[0].action_type, "ModifyColumnNullable");
1712        assert!(missing[0].column_type.is_none());
1713    }
1714
1715    #[test]
1716    fn find_missing_fill_with_modify_to_nullable() {
1717        let plan = MigrationPlan {
1718            comment: None,
1719            created_at: None,
1720            version: 1,
1721            actions: vec![MigrationAction::ModifyColumnNullable {
1722                table: "users".into(),
1723                column: "email".into(),
1724                nullable: true,
1725                fill_with: None,
1726            }],
1727        };
1728
1729        let missing = find_missing_fill_with(&plan);
1730        assert!(missing.is_empty());
1731    }
1732
1733    #[test]
1734    fn find_missing_fill_with_modify_not_null_with_fill_with() {
1735        let plan = MigrationPlan {
1736            comment: None,
1737            created_at: None,
1738            version: 1,
1739            actions: vec![MigrationAction::ModifyColumnNullable {
1740                table: "users".into(),
1741                column: "email".into(),
1742                nullable: false,
1743                fill_with: Some("'default'".into()),
1744            }],
1745        };
1746
1747        let missing = find_missing_fill_with(&plan);
1748        assert!(missing.is_empty());
1749    }
1750
1751    #[test]
1752    fn find_missing_fill_with_multiple_actions() {
1753        let plan = MigrationPlan {
1754            comment: None,
1755            created_at: None,
1756            version: 1,
1757            actions: vec![
1758                MigrationAction::AddColumn {
1759                    table: "users".into(),
1760                    column: Box::new(ColumnDef {
1761                        name: "email".into(),
1762                        r#type: ColumnType::Simple(SimpleColumnType::Text),
1763                        nullable: false,
1764                        default: None,
1765                        comment: None,
1766                        primary_key: None,
1767                        unique: None,
1768                        index: None,
1769                        foreign_key: None,
1770                    }),
1771                    fill_with: None,
1772                },
1773                MigrationAction::ModifyColumnNullable {
1774                    table: "orders".into(),
1775                    column: "status".into(),
1776                    nullable: false,
1777                    fill_with: None,
1778                },
1779                MigrationAction::AddColumn {
1780                    table: "users".into(),
1781                    column: Box::new(ColumnDef {
1782                        name: "name".into(),
1783                        r#type: ColumnType::Simple(SimpleColumnType::Text),
1784                        nullable: true, // nullable, so not missing
1785                        default: None,
1786                        comment: None,
1787                        primary_key: None,
1788                        unique: None,
1789                        index: None,
1790                        foreign_key: None,
1791                    }),
1792                    fill_with: None,
1793                },
1794            ],
1795        };
1796
1797        let missing = find_missing_fill_with(&plan);
1798        assert_eq!(missing.len(), 2);
1799        assert_eq!(missing[0].action_index, 0);
1800        assert_eq!(missing[0].table, "users");
1801        assert_eq!(missing[0].column, "email");
1802        assert_eq!(missing[1].action_index, 1);
1803        assert_eq!(missing[1].table, "orders");
1804        assert_eq!(missing[1].column, "status");
1805    }
1806
1807    #[test]
1808    fn find_missing_fill_with_other_actions_ignored() {
1809        let plan = MigrationPlan {
1810            comment: None,
1811            created_at: None,
1812            version: 1,
1813            actions: vec![
1814                MigrationAction::CreateTable {
1815                    table: "users".into(),
1816                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
1817                    constraints: vec![pk(vec!["id"])],
1818                },
1819                MigrationAction::DeleteColumn {
1820                    table: "orders".into(),
1821                    column: "old_column".into(),
1822                },
1823            ],
1824        };
1825
1826        let missing = find_missing_fill_with(&plan);
1827        assert!(missing.is_empty());
1828    }
1829}