1use 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#[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#[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#[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#[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#[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#[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#[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 ®istry.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(©.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(©.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 ©.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 ©.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 ©.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(¤t_column.table, ¤t_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(¤t_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, ¤t);
532 let error = apply_rebuild_data_plan(
533 PREDECESSOR,
534 &previous,
535 ¤t,
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, ¤t);
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 ¤t,
556 &mut migration.statements,
557 Some(®istry),
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, ¤t);
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 ¤t,
581 &mut migration.statements,
582 Some(®istry),
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, ¤t, &mut [], Some(®istry))
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, ¤t, &mut [], Some(®istry))
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}