Skip to main content

drizzle_migrations/sqlite/
rebuild_data.rs

1//! Typed data movement for SQLite table rebuild migrations.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use super::collection::SQLiteDDL;
8use super::ddl::Column;
9use super::statements::{JsonStatement, RebuildTableData};
10
11pub const SQLITE_REBUILD_DATA_PLAN_VERSION: u32 = 1;
12
13/// Versioned data movement attached to one exact predecessor snapshot.
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct SqliteRebuildDataPlanRegistry {
17    pub version: u32,
18    pub plans: Vec<SqliteRebuildDataPlan>,
19}
20
21/// Data movement selected only when its predecessor is the loaded snapshot.
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct SqliteRebuildDataPlan {
25    pub predecessor_snapshot_id: uuid::Uuid,
26    pub tables: Vec<SqliteTableRebuildPlan>,
27}
28
29impl SqliteRebuildDataPlanRegistry {
30    #[must_use]
31    pub fn single(plan: SqliteRebuildDataPlan) -> Self {
32        Self {
33            version: SQLITE_REBUILD_DATA_PLAN_VERSION,
34            plans: vec![plan],
35        }
36    }
37}
38
39/// Data movement and validation for one table rebuilt by the schema diff.
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct SqliteTableRebuildPlan {
43    pub table: String,
44    #[serde(default)]
45    pub columns: Vec<SqliteColumnCopy>,
46    #[serde(default)]
47    pub validations: Vec<SqliteDataValidation>,
48}
49
50/// One target column whose copied value differs from an identity projection.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct SqliteColumnCopy {
54    pub target: String,
55    pub expression: SqliteCopyExpression,
56}
57
58/// Closed, generator-owned expressions admitted in a rebuild copy projection.
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
61pub enum SqliteCopyExpression {
62    HexTextToBlob {
63        source: String,
64        bytes: usize,
65    },
66    IntegerMap {
67        source: String,
68        cases: Vec<SqliteIntegerMapping>,
69    },
70}
71
72/// One exact integer remap in a [`SqliteCopyExpression::IntegerMap`].
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct SqliteIntegerMapping {
76    pub from: i64,
77    pub to: i64,
78}
79
80/// Closed, generator-owned source-data invariants checked before rebuilding.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
83pub enum SqliteDataValidation {
84    JsonValid { column: String },
85    IntegerSet { column: String, allowed: Vec<i64> },
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89enum SqliteAffinity {
90    Integer,
91    Text,
92    Blob,
93    Real,
94    Numeric,
95}
96
97pub(crate) fn apply_rebuild_data_plan(
98    predecessor_snapshot_id: &str,
99    previous: &SQLiteDDL,
100    current: &SQLiteDDL,
101    statements: &mut [JsonStatement],
102    registry: Option<&SqliteRebuildDataPlanRegistry>,
103) -> Result<(), String> {
104    let affinity_changes = changed_affinities(previous, current);
105    let plan = select_plan(predecessor_snapshot_id, registry)?;
106    if plan.is_none() && affinity_changes.is_empty() {
107        return Ok(());
108    }
109    let plan = plan.ok_or_else(|| {
110        format!(
111            "SQLite columns change storage affinity without a rebuild-data plan: {}",
112            format_columns(&affinity_changes)
113        )
114    })?;
115    let mut plans = BTreeMap::new();
116    for table in &plan.tables {
117        if table.table.trim().is_empty() {
118            return Err("SQLite rebuild-data plan contains an empty table name".to_string());
119        }
120        if plans.insert(table.table.clone(), table).is_some() {
121            return Err(format!(
122                "SQLite rebuild-data plan contains duplicate table `{}`",
123                table.table
124            ));
125        }
126    }
127
128    let mut consumed = BTreeSet::new();
129    let mut handled_affinity_changes = BTreeSet::new();
130    for statement in statements {
131        let JsonStatement::RecreateTable(recreate) = statement else {
132            continue;
133        };
134        let table_name = recreate.to.name.as_str();
135        let table_plan = plans.get(table_name);
136        let required = affinity_changes
137            .iter()
138            .filter(|(table, _)| table == table_name)
139            .map(|(_, column)| column.as_str())
140            .collect::<BTreeSet<_>>();
141        if table_plan.is_none() && required.is_empty() {
142            continue;
143        }
144        let table_plan = table_plan.ok_or_else(|| {
145            format!(
146                "SQLite table `{table_name}` changes storage affinity for {} without a table rebuild-data plan",
147                required.iter().copied().collect::<Vec<_>>().join(", ")
148            )
149        })?;
150        recreate.data = Some(validate_table_plan(
151            table_plan,
152            &recreate.from.columns,
153            &recreate.to.columns,
154            &required,
155        )?);
156        handled_affinity_changes.extend(
157            required
158                .iter()
159                .map(|column| (table_name.to_string(), (*column).to_string())),
160        );
161        consumed.insert(table_name.to_string());
162    }
163
164    let unhandled = affinity_changes
165        .difference(&handled_affinity_changes)
166        .cloned()
167        .collect::<BTreeSet<_>>();
168    if !unhandled.is_empty() {
169        return Err(format!(
170            "SQLite affinity changes were not attached to a table rebuild: {}",
171            format_columns(&unhandled)
172        ));
173    }
174
175    let unused = plans
176        .keys()
177        .filter(|table| !consumed.contains(*table))
178        .cloned()
179        .collect::<Vec<_>>();
180    if !unused.is_empty() {
181        return Err(format!(
182            "SQLite rebuild-data plan contains unused tables: {}",
183            unused.join(", ")
184        ));
185    }
186    Ok(())
187}
188
189fn select_plan<'a>(
190    predecessor_snapshot_id: &str,
191    registry: Option<&'a SqliteRebuildDataPlanRegistry>,
192) -> Result<Option<&'a SqliteRebuildDataPlan>, String> {
193    let Some(registry) = registry else {
194        return Ok(None);
195    };
196    if registry.version != SQLITE_REBUILD_DATA_PLAN_VERSION {
197        return Err(format!(
198            "unsupported SQLite rebuild-data plan registry version {}; expected {}",
199            registry.version, SQLITE_REBUILD_DATA_PLAN_VERSION
200        ));
201    }
202    let predecessor_snapshot_id = uuid::Uuid::parse_str(predecessor_snapshot_id).map_err(|error| {
203        format!(
204            "loaded SQLite predecessor snapshot ID `{predecessor_snapshot_id}` is not a UUID: {error}"
205        )
206    })?;
207    let mut plans = BTreeMap::new();
208    for plan in &registry.plans {
209        if plans.insert(plan.predecessor_snapshot_id, plan).is_some() {
210            return Err(format!(
211                "SQLite rebuild-data plan registry repeats predecessor `{}`",
212                plan.predecessor_snapshot_id
213            ));
214        }
215    }
216    Ok(plans.get(&predecessor_snapshot_id).copied())
217}
218
219fn validate_table_plan(
220    plan: &SqliteTableRebuildPlan,
221    from: &[Column],
222    to: &[Column],
223    required: &BTreeSet<&str>,
224) -> Result<RebuildTableData, String> {
225    let from_columns = from
226        .iter()
227        .map(|column| (column.name.to_string(), column))
228        .collect::<BTreeMap<_, _>>();
229    let to_columns = to
230        .iter()
231        .map(|column| (column.name.to_string(), column))
232        .collect::<BTreeMap<_, _>>();
233    let mut copies = BTreeMap::new();
234    let mut derived_validations = Vec::new();
235
236    for copy in &plan.columns {
237        let target = to_columns.get(&copy.target).ok_or_else(|| {
238            format!(
239                "SQLite rebuild-data plan for `{}` targets missing current column `{}`",
240                plan.table, copy.target
241            )
242        })?;
243        if target.generated.is_some() {
244            return Err(format!(
245                "SQLite rebuild-data plan for `{}` cannot map generated target column `{}`",
246                plan.table, copy.target
247            ));
248        }
249        if copies.contains_key(&copy.target) {
250            return Err(format!(
251                "SQLite rebuild-data plan for `{}` maps target column `{}` more than once",
252                plan.table, copy.target
253            ));
254        }
255        let expression = match &copy.expression {
256            SqliteCopyExpression::HexTextToBlob { source, bytes } => {
257                let source_column = source_column(&plan.table, source, &from_columns)?;
258                if *bytes == 0 {
259                    return Err(format!(
260                        "SQLite HexTextToBlob mapping for `{}.{}` requires a positive byte length",
261                        plan.table, copy.target
262                    ));
263                }
264                require_affinities(
265                    &plan.table,
266                    &copy.target,
267                    affinity(&source_column.sql_type),
268                    SqliteAffinity::Text,
269                    affinity(&target.sql_type),
270                    SqliteAffinity::Blob,
271                )?;
272                derived_validations.push(super::statements::RebuildDataValidation::HexText {
273                    column: source.clone(),
274                    bytes: *bytes,
275                });
276                super::statements::RebuildCopyExpression::HexTextToBlob {
277                    source: source.clone(),
278                }
279            }
280            SqliteCopyExpression::IntegerMap { source, cases } => {
281                let source_column = source_column(&plan.table, source, &from_columns)?;
282                require_affinities(
283                    &plan.table,
284                    &copy.target,
285                    affinity(&source_column.sql_type),
286                    SqliteAffinity::Integer,
287                    affinity(&target.sql_type),
288                    SqliteAffinity::Integer,
289                )?;
290                if cases.is_empty() {
291                    return Err(format!(
292                        "SQLite IntegerMap for `{}.{}` has no cases",
293                        plan.table, copy.target
294                    ));
295                }
296                let mut normalized = BTreeMap::new();
297                for case in cases {
298                    if normalized.insert(case.from, case.to).is_some() {
299                        return Err(format!(
300                            "SQLite IntegerMap for `{}.{}` repeats source value {}",
301                            plan.table, copy.target, case.from
302                        ));
303                    }
304                }
305                derived_validations.push(super::statements::RebuildDataValidation::IntegerSet {
306                    column: source.clone(),
307                    allowed: normalized.keys().copied().collect(),
308                });
309                super::statements::RebuildCopyExpression::IntegerMap {
310                    source: source.clone(),
311                    cases: normalized.into_iter().collect(),
312                }
313            }
314        };
315        copies.insert(copy.target.clone(), expression);
316    }
317
318    let missing = required
319        .iter()
320        .filter(|column| !copies.contains_key(**column))
321        .copied()
322        .collect::<Vec<_>>();
323    if !missing.is_empty() {
324        return Err(format!(
325            "SQLite rebuild-data plan for `{}` omits affinity-changing columns: {}",
326            plan.table,
327            missing.join(", ")
328        ));
329    }
330
331    let mut validations = BTreeSet::new();
332    for validation in derived_validations {
333        validations.insert(validation);
334    }
335    for validation in &plan.validations {
336        let normalized = match validation {
337            SqliteDataValidation::JsonValid { column } => {
338                let source = source_column(&plan.table, column, &from_columns)?;
339                if affinity(&source.sql_type) != SqliteAffinity::Text {
340                    return Err(format!(
341                        "SQLite JsonValid validation requires TEXT source `{}.{column}`",
342                        plan.table
343                    ));
344                }
345                validation_target(
346                    &plan.table,
347                    column,
348                    &to_columns,
349                    SqliteAffinity::Text,
350                    "JsonValid",
351                )?;
352                super::statements::RebuildDataValidation::JsonValid {
353                    column: column.clone(),
354                }
355            }
356            SqliteDataValidation::IntegerSet { column, allowed } => {
357                let source = source_column(&plan.table, column, &from_columns)?;
358                if affinity(&source.sql_type) != SqliteAffinity::Integer {
359                    return Err(format!(
360                        "SQLite IntegerSet validation requires INTEGER source `{}.{column}`",
361                        plan.table
362                    ));
363                }
364                validation_target(
365                    &plan.table,
366                    column,
367                    &to_columns,
368                    SqliteAffinity::Integer,
369                    "IntegerSet",
370                )?;
371                let allowed = allowed.iter().copied().collect::<BTreeSet<_>>();
372                if allowed.is_empty() {
373                    return Err(format!(
374                        "SQLite IntegerSet validation for `{}.{column}` has no allowed values",
375                        plan.table
376                    ));
377                }
378                super::statements::RebuildDataValidation::IntegerSet {
379                    column: column.clone(),
380                    allowed: allowed.into_iter().collect(),
381                }
382            }
383        };
384        validations.insert(normalized);
385    }
386
387    Ok(RebuildTableData {
388        copies,
389        validations: validations.into_iter().collect(),
390    })
391}
392
393fn validation_target(
394    table: &str,
395    name: &str,
396    columns: &BTreeMap<String, &Column>,
397    expected_affinity: SqliteAffinity,
398    validation: &str,
399) -> Result<(), String> {
400    let column = columns.get(name).copied().ok_or_else(|| {
401        format!("SQLite {validation} validation protects removed column `{table}.{name}`")
402    })?;
403    if column.generated.is_some() {
404        return Err(format!(
405            "SQLite {validation} validation cannot target generated current column `{table}.{name}`"
406        ));
407    }
408    if affinity(&column.sql_type) != expected_affinity {
409        return Err(format!(
410            "SQLite {validation} validation target `{table}.{name}` has incompatible {} affinity",
411            column.sql_type
412        ));
413    }
414    Ok(())
415}
416
417fn source_column<'a>(
418    table: &str,
419    name: &str,
420    columns: &'a BTreeMap<String, &'a Column>,
421) -> Result<&'a Column, String> {
422    let column = columns.get(name).copied().ok_or_else(|| {
423        format!("SQLite rebuild-data plan references missing predecessor column `{table}.{name}`")
424    })?;
425    if column.generated.is_some() {
426        return Err(format!(
427            "SQLite rebuild-data plan cannot read generated predecessor column `{table}.{name}`"
428        ));
429    }
430    Ok(column)
431}
432
433fn require_affinities(
434    table: &str,
435    target: &str,
436    actual_source: SqliteAffinity,
437    expected_source: SqliteAffinity,
438    actual_target: SqliteAffinity,
439    expected_target: SqliteAffinity,
440) -> Result<(), String> {
441    if actual_source != expected_source || actual_target != expected_target {
442        return Err(format!(
443            "SQLite rebuild-data mapping for `{table}.{target}` requires {expected_source:?}->{expected_target:?}, found {actual_source:?}->{actual_target:?}"
444        ));
445    }
446    Ok(())
447}
448
449fn changed_affinities(previous: &SQLiteDDL, current: &SQLiteDDL) -> BTreeSet<(String, String)> {
450    let mut changed = BTreeSet::new();
451    for current_column in current.columns.list() {
452        let Some(previous_column) = previous
453            .columns
454            .one(&current_column.table, &current_column.name)
455        else {
456            continue;
457        };
458        if previous_column.generated.is_none()
459            && current_column.generated.is_none()
460            && affinity(&previous_column.sql_type) != affinity(&current_column.sql_type)
461        {
462            changed.insert((
463                current_column.table.to_string(),
464                current_column.name.to_string(),
465            ));
466        }
467    }
468    changed
469}
470
471fn affinity(sql_type: &str) -> SqliteAffinity {
472    let sql_type = sql_type.to_ascii_uppercase();
473    if sql_type.contains("INT") {
474        SqliteAffinity::Integer
475    } else if sql_type.contains("CHAR") || sql_type.contains("CLOB") || sql_type.contains("TEXT") {
476        SqliteAffinity::Text
477    } else if sql_type.contains("BLOB") || sql_type.trim().is_empty() {
478        SqliteAffinity::Blob
479    } else if sql_type.contains("REAL") || sql_type.contains("FLOA") || sql_type.contains("DOUB") {
480        SqliteAffinity::Real
481    } else {
482        SqliteAffinity::Numeric
483    }
484}
485
486fn format_columns(columns: &BTreeSet<(String, String)>) -> String {
487    columns
488        .iter()
489        .map(|(table, column)| format!("{table}.{column}"))
490        .collect::<Vec<_>>()
491        .join(", ")
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::sqlite::ddl::{ColumnDef, TableDef};
498    use crate::sqlite::diff::compute_migration;
499
500    const PREDECESSOR: &str = "11111111-1111-4111-8111-111111111111";
501    const OTHER: &str = "22222222-2222-4222-8222-222222222222";
502
503    fn uuid(value: &str) -> uuid::Uuid {
504        uuid::Uuid::parse_str(value).expect("valid fixture UUID")
505    }
506
507    fn schemas() -> (SQLiteDDL, SQLiteDDL) {
508        let mut previous = SQLiteDDL::new();
509        previous.tables.push(TableDef::new("assets").into_table());
510        previous
511            .columns
512            .push(ColumnDef::new("assets", "digest", "text").into_column());
513
514        let mut current = SQLiteDDL::new();
515        current
516            .tables
517            .push(TableDef::new("assets").strict().into_table());
518        current
519            .columns
520            .push(ColumnDef::new("assets", "digest", "blob").into_column());
521        (previous, current)
522    }
523
524    fn registry(plan: SqliteRebuildDataPlan) -> SqliteRebuildDataPlanRegistry {
525        SqliteRebuildDataPlanRegistry::single(plan)
526    }
527
528    #[test]
529    fn affinity_change_requires_an_explicit_mapping() {
530        let (previous, current) = schemas();
531        let mut migration = compute_migration(&previous, &current);
532        let error = apply_rebuild_data_plan(
533            PREDECESSOR,
534            &previous,
535            &current,
536            &mut migration.statements,
537            None,
538        )
539        .expect_err("affinity change must fail without a plan");
540        assert!(error.contains("assets.digest"), "{error}");
541    }
542
543    #[test]
544    fn plan_is_bound_to_the_exact_predecessor_snapshot() {
545        let (previous, current) = schemas();
546        let mut migration = compute_migration(&previous, &current);
547        let plan = SqliteRebuildDataPlan {
548            predecessor_snapshot_id: uuid(OTHER),
549            tables: Vec::new(),
550        };
551        let registry = registry(plan);
552        let error = apply_rebuild_data_plan(
553            PREDECESSOR,
554            &previous,
555            &current,
556            &mut migration.statements,
557            Some(&registry),
558        )
559        .expect_err("wrong predecessor must fail");
560        assert!(error.contains("assets.digest"), "{error}");
561    }
562
563    #[test]
564    fn unused_table_plan_is_rejected() {
565        let (previous, _) = schemas();
566        let current = previous.clone();
567        let mut migration = compute_migration(&previous, &current);
568        let plan = SqliteRebuildDataPlan {
569            predecessor_snapshot_id: uuid(PREDECESSOR),
570            tables: vec![SqliteTableRebuildPlan {
571                table: "missing".to_string(),
572                columns: Vec::new(),
573                validations: Vec::new(),
574            }],
575        };
576        let registry = registry(plan);
577        let error = apply_rebuild_data_plan(
578            PREDECESSOR,
579            &previous,
580            &current,
581            &mut migration.statements,
582            Some(&registry),
583        )
584        .expect_err("unused table must fail");
585        assert!(error.contains("unused tables: missing"), "{error}");
586    }
587
588    #[test]
589    fn every_affinity_change_must_attach_to_a_rebuild_statement() {
590        let (previous, current) = schemas();
591        let plan = SqliteRebuildDataPlan {
592            predecessor_snapshot_id: uuid(PREDECESSOR),
593            tables: vec![SqliteTableRebuildPlan {
594                table: "assets".to_string(),
595                columns: vec![SqliteColumnCopy {
596                    target: "digest".to_string(),
597                    expression: SqliteCopyExpression::HexTextToBlob {
598                        source: "digest".to_string(),
599                        bytes: 32,
600                    },
601                }],
602                validations: Vec::new(),
603            }],
604        };
605        let registry = registry(plan);
606        let error =
607            apply_rebuild_data_plan(PREDECESSOR, &previous, &current, &mut [], Some(&registry))
608                .expect_err("unattached affinity change must fail");
609        assert!(error.contains("not attached"), "{error}");
610    }
611
612    #[test]
613    fn historical_registry_entry_is_inert_when_the_schema_is_unchanged() {
614        let (previous, _) = schemas();
615        let current = previous.clone();
616        let registry = registry(SqliteRebuildDataPlan {
617            predecessor_snapshot_id: uuid(OTHER),
618            tables: vec![SqliteTableRebuildPlan {
619                table: "assets".to_string(),
620                columns: Vec::new(),
621                validations: Vec::new(),
622            }],
623        });
624
625        apply_rebuild_data_plan(PREDECESSOR, &previous, &current, &mut [], Some(&registry))
626            .expect("historical plans must not poison later no-change generation");
627    }
628
629    #[test]
630    fn generated_copy_targets_and_sources_are_rejected() {
631        let from = vec![
632            ColumnDef::new("assets", "source", "text")
633                .generated_stored("'00'")
634                .into(),
635        ];
636        let to = vec![
637            ColumnDef::new("assets", "digest", "blob")
638                .generated_stored("x'00'")
639                .into(),
640        ];
641        let plan = SqliteTableRebuildPlan {
642            table: "assets".to_string(),
643            columns: vec![SqliteColumnCopy {
644                target: "digest".to_string(),
645                expression: SqliteCopyExpression::HexTextToBlob {
646                    source: "source".to_string(),
647                    bytes: 1,
648                },
649            }],
650            validations: Vec::new(),
651        };
652        let error = validate_table_plan(&plan, &from, &to, &BTreeSet::from(["digest"]))
653            .expect_err("generated target must fail");
654        assert!(error.contains("generated target"), "{error}");
655
656        let plain_target = vec![ColumnDef::new("assets", "digest", "blob").into_column()];
657        let error = validate_table_plan(&plan, &from, &plain_target, &BTreeSet::from(["digest"]))
658            .expect_err("generated source must fail");
659        assert!(error.contains("generated predecessor"), "{error}");
660    }
661
662    #[test]
663    fn json_validation_requires_text_storage() {
664        let from = vec![ColumnDef::new("assets", "metadata", "integer").into_column()];
665        let to = from.clone();
666        let plan = SqliteTableRebuildPlan {
667            table: "assets".to_string(),
668            columns: Vec::new(),
669            validations: vec![SqliteDataValidation::JsonValid {
670                column: "metadata".to_string(),
671            }],
672        };
673        let error = validate_table_plan(&plan, &from, &to, &BTreeSet::new())
674            .expect_err("non-text JSON validation must fail");
675        assert!(error.contains("requires TEXT"), "{error}");
676    }
677
678    #[test]
679    fn validations_must_protect_a_copied_current_column() {
680        let from = vec![ColumnDef::new("assets", "metadata", "text").into_column()];
681        let validation = SqliteTableRebuildPlan {
682            table: "assets".to_string(),
683            columns: Vec::new(),
684            validations: vec![SqliteDataValidation::JsonValid {
685                column: "metadata".to_string(),
686            }],
687        };
688
689        let error = validate_table_plan(&validation, &from, &[], &BTreeSet::new())
690            .expect_err("removed validation target must fail");
691        assert!(error.contains("protects removed column"), "{error}");
692
693        let generated = vec![
694            ColumnDef::new("assets", "metadata", "text")
695                .generated_stored("'[]'")
696                .into_column(),
697        ];
698        let error = validate_table_plan(&validation, &from, &generated, &BTreeSet::new())
699            .expect_err("generated validation target must fail");
700        assert!(error.contains("generated current column"), "{error}");
701
702        let incompatible = vec![ColumnDef::new("assets", "metadata", "integer").into_column()];
703        let error = validate_table_plan(&validation, &from, &incompatible, &BTreeSet::new())
704            .expect_err("incompatible validation target must fail");
705        assert!(error.contains("incompatible integer affinity"), "{error}");
706    }
707
708    #[test]
709    fn integer_validation_requires_a_copied_current_integer_column() {
710        let from = vec![ColumnDef::new("assets", "status", "integer").into_column()];
711        let validation = SqliteTableRebuildPlan {
712            table: "assets".to_string(),
713            columns: Vec::new(),
714            validations: vec![SqliteDataValidation::IntegerSet {
715                column: "status".to_string(),
716                allowed: vec![0, 1],
717            }],
718        };
719
720        let error = validate_table_plan(&validation, &from, &[], &BTreeSet::new())
721            .expect_err("removed integer validation target must fail");
722        assert!(error.contains("protects removed column"), "{error}");
723
724        let generated = vec![
725            ColumnDef::new("assets", "status", "integer")
726                .generated_stored("0")
727                .into_column(),
728        ];
729        let error = validate_table_plan(&validation, &from, &generated, &BTreeSet::new())
730            .expect_err("generated integer validation target must fail");
731        assert!(error.contains("generated current column"), "{error}");
732
733        let incompatible = vec![ColumnDef::new("assets", "status", "text").into_column()];
734        let error = validate_table_plan(&validation, &from, &incompatible, &BTreeSet::new())
735            .expect_err("incompatible integer validation target must fail");
736        assert!(error.contains("incompatible text affinity"), "{error}");
737    }
738}