diff --git a/Cargo.lock b/Cargo.lock index fb75d8d9438..e10c07951e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5949,6 +5949,7 @@ dependencies = [ "enum-map", "hashbrown 0.15.3", "indexmap 2.9.0", + "insta", "itertools 0.12.1", "lazy_static", "petgraph", @@ -5962,6 +5963,7 @@ dependencies = [ "spacetimedb-sats", "spacetimedb-sql-parser", "spacetimedb-testing", + "termcolor", "thiserror 1.0.69", "unicode-ident", "unicode-normalization", diff --git a/crates/schema/Cargo.toml b/crates/schema/Cargo.toml index 00ba794ec2f..3cb672706b7 100644 --- a/crates/schema/Cargo.toml +++ b/crates/schema/Cargo.toml @@ -30,6 +30,8 @@ smallvec.workspace = true hashbrown.workspace = true enum-as-inner.workspace = true enum-map.workspace = true +insta.workspace = true +termcolor.workspace = true [dev-dependencies] spacetimedb-lib = { path = "../lib", features = ["test"] } diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index ec30d6fe582..c1d7e71ae4d 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -1,6 +1,7 @@ use core::{cmp::Ordering, ops::BitOr}; use crate::{def::*, error::PrettyAlgebraicType, identifier::Identifier}; +use formatter::format_plan; use spacetimedb_data_structures::{ error_stream::{CollectAllErrors, CombineErrors, ErrorStream}, map::HashSet, @@ -13,6 +14,9 @@ use spacetimedb_sats::{ layout::{HasLayout, SumTypeLayout}, WithTypespace, }; +use termcolor_formatter::{ColorScheme, TermColorFormatter}; +mod formatter; +mod termcolor_formatter; pub type Result = std::result::Result>; @@ -23,6 +27,12 @@ pub enum MigratePlan<'def> { Auto(AutoMigratePlan<'def>), } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum PrettyPrintStyle { + AnsiColor, + NoColor, +} + impl<'def> MigratePlan<'def> { /// Get the old `ModuleDef` for this migration plan. pub fn old_def(&self) -> &'def ModuleDef { @@ -39,6 +49,28 @@ impl<'def> MigratePlan<'def> { MigratePlan::Auto(plan) => plan.new, } } + + pub fn pretty_print(&self, style: PrettyPrintStyle) -> anyhow::Result { + use PrettyPrintStyle::*; + + match self { + MigratePlan::Manual(_) => { + anyhow::bail!("Manual migration plans are not yet supported for pretty printing.") + } + + MigratePlan::Auto(plan) => match style { + NoColor => { + let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::Never); + format_plan(&mut fmt, plan).map(|_| fmt.to_string()) + } + AnsiColor => { + let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::AlwaysAnsi); + format_plan(&mut fmt, plan).map(|_| fmt.to_string()) + } + } + .map_err(|e| anyhow::anyhow!("Failed to format migration plan: {e}")), + } + } } /// A plan for a manual migration. @@ -766,20 +798,19 @@ mod tests { use v9::{RawModuleDefV9Builder, TableAccess}; use validate::tests::expect_identifier; - #[test] - fn successful_auto_migration() { - let mut old_builder = RawModuleDefV9Builder::new(); - let old_schedule_at = old_builder.add_type::(); - let old_sum_ty = AlgebraicType::sum([("v1", AlgebraicType::U64)]); - let old_sum_refty = old_builder.add_algebraic_type([], "sum", old_sum_ty, true); - old_builder + fn initial_module_def() -> ModuleDef { + let mut builder = RawModuleDefV9Builder::new(); + let schedule_at = builder.add_type::(); + let sum_ty = AlgebraicType::sum([("v1", AlgebraicType::U64)]); + let sum_refty = builder.add_algebraic_type([], "sum", sum_ty, true); + builder .build_table_with_new_type( "Apples", ProductType::from([ ("id", AlgebraicType::U64), ("name", AlgebraicType::String), ("count", AlgebraicType::U16), - ("sum", old_sum_refty.into()), + ("sum", sum_refty.into()), ]), true, ) @@ -789,7 +820,7 @@ mod tests { .with_index(btree([0, 1]), "id_name_index") .finish(); - old_builder + builder .build_table_with_new_type( "Bananas", ProductType::from([ @@ -802,13 +833,13 @@ mod tests { .with_access(TableAccess::Public) .finish(); - let old_deliveries_type = old_builder + let deliveries_type = builder .build_table_with_new_type( "Deliveries", ProductType::from([ ("scheduled_id", AlgebraicType::U64), - ("scheduled_at", old_schedule_at.clone()), - ("sum", AlgebraicType::array(old_sum_refty.into())), + ("scheduled_at", schedule_at.clone()), + ("sum", AlgebraicType::array(sum_refty.into())), ]), true, ) @@ -816,18 +847,18 @@ mod tests { .with_index_no_accessor_name(btree(0)) .with_schedule("check_deliveries", 1) .finish(); - old_builder.add_reducer( + builder.add_reducer( "check_deliveries", - ProductType::from([("a", AlgebraicType::Ref(old_deliveries_type))]), + ProductType::from([("a", AlgebraicType::Ref(deliveries_type))]), None, ); - old_builder + builder .build_table_with_new_type( "Inspections", ProductType::from([ ("scheduled_id", AlgebraicType::U64), - ("scheduled_at", old_schedule_at.clone()), + ("scheduled_at", schedule_at.clone()), ]), true, ) @@ -835,26 +866,28 @@ mod tests { .with_index_no_accessor_name(btree(0)) .finish(); - old_builder.add_row_level_security("SELECT * FROM Apples"); + builder.add_row_level_security("SELECT * FROM Apples"); - let old_def: ModuleDef = old_builder + builder .finish() .try_into() - .expect("old_def should be a valid database definition"); + .expect("old_def should be a valid database definition") + } - let mut new_builder = RawModuleDefV9Builder::new(); - let _ = new_builder.add_type::(); // reposition ScheduleAt in the typespace, should have no effect. - let new_schedule_at = new_builder.add_type::(); - let new_sum_ty = AlgebraicType::sum([("v1", AlgebraicType::U64), ("v2", AlgebraicType::Bool)]); - let new_sum_refty = new_builder.add_algebraic_type([], "sum", new_sum_ty, true); - new_builder + fn updated_module_def() -> ModuleDef { + let mut builder = RawModuleDefV9Builder::new(); + let _ = builder.add_type::(); // reposition ScheduleAt in the typespace, should have no effect. + let schedule_at = builder.add_type::(); + let sum_ty = AlgebraicType::sum([("v1", AlgebraicType::U64), ("v2", AlgebraicType::Bool)]); + let sum_refty = builder.add_algebraic_type([], "sum", sum_ty, true); + builder .build_table_with_new_type( "Apples", ProductType::from([ ("id", AlgebraicType::U64), ("name", AlgebraicType::String), ("count", AlgebraicType::U16), - ("sum", new_sum_refty.into()), + ("sum", sum_refty.into()), ]), true, ) @@ -866,7 +899,7 @@ mod tests { .with_index(btree([0, 2]), "id_count_index") .finish(); - new_builder + builder .build_table_with_new_type( "Bananas", ProductType::from([ @@ -884,13 +917,13 @@ mod tests { .with_access(TableAccess::Private) .finish(); - let new_deliveries_type = new_builder + let deliveries_type = builder .build_table_with_new_type( "Deliveries", ProductType::from([ ("scheduled_id", AlgebraicType::U64), - ("scheduled_at", new_schedule_at.clone()), - ("sum", AlgebraicType::array(new_sum_refty.into())), + ("scheduled_at", schedule_at.clone()), + ("sum", AlgebraicType::array(sum_refty.into())), ]), true, ) @@ -899,18 +932,18 @@ mod tests { // remove schedule def .finish(); - new_builder.add_reducer( + builder.add_reducer( "check_deliveries", - ProductType::from([("a", AlgebraicType::Ref(new_deliveries_type))]), + ProductType::from([("a", AlgebraicType::Ref(deliveries_type))]), None, ); - let new_inspections_type = new_builder + let new_inspections_type = builder .build_table_with_new_type( "Inspections", ProductType::from([ ("scheduled_id", AlgebraicType::U64), - ("scheduled_at", new_schedule_at.clone()), + ("scheduled_at", schedule_at.clone()), ]), true, ) @@ -921,14 +954,14 @@ mod tests { .finish(); // add reducer. - new_builder.add_reducer( + builder.add_reducer( "perform_inspection", ProductType::from([("a", AlgebraicType::Ref(new_inspections_type))]), None, ); // Add new table - new_builder + builder .build_table_with_new_type("Oranges", ProductType::from([("id", AlgebraicType::U32)]), true) .with_index(btree(0), "id_index") .with_column_sequence(0) @@ -936,13 +969,18 @@ mod tests { .with_primary_key(0) .finish(); - new_builder.add_row_level_security("SELECT * FROM Bananas"); + builder.add_row_level_security("SELECT * FROM Bananas"); - let new_def: ModuleDef = new_builder + builder .finish() .try_into() - .expect("new_def should be a valid database definition"); + .expect("new_def should be a valid database definition") + } + #[test] + fn successful_auto_migration() { + let old_def = initial_module_def(); + let new_def = updated_module_def(); let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed"); let apples = expect_identifier("Apples"); @@ -1392,4 +1430,48 @@ mod tests { // but different columns from an old one. // We've left the check in, just in case this changes in the future. } + #[test] + fn print_empty_to_populated_schema_migration() { + // Start with completely empty schema + let old_builder = RawModuleDefV9Builder::new(); + let old_def: ModuleDef = old_builder + .finish() + .try_into() + .expect("old_def should be a valid database definition"); + + let new_def = initial_module_def(); + let plan = ponder_migrate(&old_def, &new_def).expect("auto migration should succeed"); + + insta::assert_snapshot!( + "empty_to_populated_migration", + plan.pretty_print(PrettyPrintStyle::AnsiColor) + .expect("should pretty print") + ); + } + + #[test] + fn print_supervised_migration() { + let old_def = initial_module_def(); + let new_def = updated_module_def(); + let plan = ponder_migrate(&old_def, &new_def).expect("auto migration should succeed"); + + insta::assert_snapshot!( + "updated pretty print", + plan.pretty_print(PrettyPrintStyle::AnsiColor) + .expect("should pretty print") + ); + } + + #[test] + fn no_color_print_supervised_migration() { + let old_def = initial_module_def(); + let new_def = updated_module_def(); + let plan = ponder_migrate(&old_def, &new_def).expect("auto migration should succeed"); + + insta::assert_snapshot!( + "updated pretty print no color", + plan.pretty_print(PrettyPrintStyle::NoColor) + .expect("should pretty print") + ); + } } diff --git a/crates/schema/src/auto_migrate/formatter.rs b/crates/schema/src/auto_migrate/formatter.rs new file mode 100644 index 00000000000..bd38f52c177 --- /dev/null +++ b/crates/schema/src/auto_migrate/formatter.rs @@ -0,0 +1,528 @@ +//! This module provides enhanced functionality for rendering automatic migration plans to strings. + +use std::io; + +use super::{AutoMigratePlan, IndexAlgorithm, ModuleDefLookup, TableDef}; +use crate::{ + auto_migrate::AutoMigrateStep, + def::{ConstraintData, ModuleDef, ScheduleDef}, + identifier::Identifier, +}; +use spacetimedb_lib::{ + db::raw_def::v9::{RawRowLevelSecurityDefV9, TableAccess, TableType}, + AlgebraicType, AlgebraicValue, +}; +use spacetimedb_sats::WithTypespace; +use thiserror::Error; + +pub fn format_plan(f: &mut F, plan: &AutoMigratePlan) -> Result<(), FormattingErrors> { + f.format_header()?; + + for step in &plan.steps { + format_step(f, step, plan)?; + } + + Ok(()) +} + +fn format_step( + f: &mut F, + step: &AutoMigrateStep, + plan: &super::AutoMigratePlan, +) -> Result<(), FormattingErrors> { + match step { + AutoMigrateStep::AddTable(t) => { + let table_info = extract_table_info(*t, plan)?; + f.format_add_table(&table_info) + } + AutoMigrateStep::AddIndex(index) => { + let index_info = extract_index_info(*index, plan.new)?; + f.format_index(&index_info, Action::Created) + } + AutoMigrateStep::RemoveIndex(index) => { + let index_info = extract_index_info(*index, plan.old)?; + f.format_index(&index_info, Action::Removed) + } + AutoMigrateStep::RemoveConstraint(constraint) => { + let constraint_info = extract_constraint_info(*constraint, plan.old)?; + f.format_constraint(&constraint_info, Action::Removed) + } + AutoMigrateStep::AddSequence(sequence) => { + let sequence_info = extract_sequence_info(*sequence, plan.new)?; + f.format_sequence(&sequence_info, Action::Created) + } + AutoMigrateStep::RemoveSequence(sequence) => { + let sequence_info = extract_sequence_info(*sequence, plan.old)?; + f.format_sequence(&sequence_info, Action::Removed) + } + AutoMigrateStep::ChangeAccess(table) => { + let access_info = extract_access_change_info(*table, plan)?; + f.format_change_access(&access_info) + } + AutoMigrateStep::AddSchedule(schedule) => { + let schedule_info = extract_schedule_info(*schedule, plan.new)?; + f.format_schedule(&schedule_info, Action::Created) + } + AutoMigrateStep::RemoveSchedule(schedule) => { + let schedule_info = extract_schedule_info(*schedule, plan.old)?; + f.format_schedule(&schedule_info, Action::Removed) + } + AutoMigrateStep::AddRowLevelSecurity(rls) => { + if let Some(rls_info) = extract_rls_info(*rls, plan)? { + f.format_rls(&rls_info, Action::Created)?; + } + Ok(()) + } + AutoMigrateStep::RemoveRowLevelSecurity(rls) => { + if let Some(rls_info) = extract_rls_info(*rls, plan)? { + f.format_rls(&rls_info, Action::Removed)?; + } + Ok(()) + } + AutoMigrateStep::ChangeColumns(table) => { + let column_changes = extract_column_changes(*table, plan)?; + f.format_change_columns(&column_changes) + } + AutoMigrateStep::AddColumns(table) => { + let new_columns = extract_new_columns(*table, plan)?; + f.format_add_columns(&new_columns) + } + AutoMigrateStep::DisconnectAllUsers => f.format_disconnect_warning(), + }?; + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum FormattingErrors { + #[error("Table not found: {table}")] + TableNotFound { table: Box }, + #[error("Index not found")] + IndexNotFound, + #[error("Constraint not found")] + ConstraintNotFound, + #[error("Sequence not found")] + SequenceNotFound, + #[error("Schedule not found")] + ScheduleNotFound, + #[error("Type resolution failed")] + TypeResolution, + #[error("Column not found")] + ColumnNotFound, + #[error("IO error: {0}")] + IO(#[from] std::io::Error), +} + +/// Action types for database operations +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + Created, + Removed, + Changed, +} + +/// Trait for formatting migration steps +/// This trait defines methods for formatting various components of a migration plan. +/// It allows for different implementations, such as ANSI formatting or plain text formatting. +pub trait MigrationFormatter { + fn format_header(&mut self) -> io::Result<()>; + fn format_add_table(&mut self, table_info: &TableInfo) -> io::Result<()>; + fn format_index(&mut self, index_info: &IndexInfo, action: Action) -> io::Result<()>; + fn format_constraint(&mut self, constraint_info: &ConstraintInfo, action: Action) -> io::Result<()>; + fn format_sequence(&mut self, sequence_info: &SequenceInfo, action: Action) -> io::Result<()>; + fn format_change_access(&mut self, access_info: &AccessChangeInfo) -> io::Result<()>; + fn format_schedule(&mut self, schedule_info: &ScheduleInfo, action: Action) -> io::Result<()>; + fn format_rls(&mut self, rls_info: &RlsInfo, action: Action) -> io::Result<()>; + fn format_change_columns(&mut self, column_changes: &ColumnChanges) -> io::Result<()>; + fn format_add_columns(&mut self, new_columns: &NewColumns) -> io::Result<()>; + fn format_disconnect_warning(&mut self) -> io::Result<()>; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TableInfo { + pub name: String, + pub is_system: bool, + pub access: TableAccess, + pub columns: Vec, + pub constraints: Vec, + pub indexes: Vec, + pub sequences: Vec, + pub schedule: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnInfo { + pub name: Identifier, + pub type_name: AlgebraicType, + pub default_value: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstraintInfo { + pub name: String, + pub columns: Vec, + pub table_name: Identifier, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IndexInfo { + pub name: String, + pub columns: Vec, + pub table_name: Identifier, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SequenceInfo { + pub name: String, + pub column_name: Identifier, + pub table_name: Identifier, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AccessChangeInfo { + pub table_name: Identifier, + pub new_access: TableAccess, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ScheduleInfo { + pub table_name: String, + pub reducer_name: Identifier, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RlsInfo { + pub policy: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnChanges { + pub table_name: Identifier, + pub changes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ColumnChange { + Renamed { + old_name: Identifier, + new_name: Identifier, + }, + TypeChanged { + name: Identifier, + old_type: AlgebraicType, + new_type: AlgebraicType, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NewColumns { + pub table_name: Identifier, + pub columns: Vec, +} + +// Data extraction functions (these replace the original print functions' data gathering logic) +fn extract_table_info( + table: ::Key<'_>, + plan: &super::AutoMigratePlan, +) -> Result { + let table_def = plan.new.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + + let columns = table_def + .columns + .iter() + .map(|column| { + let type_name = WithTypespace::new(plan.new.typespace(), &column.ty) + .resolve_refs() + .map_err(|_| FormattingErrors::TypeResolution)?; + Ok(ColumnInfo { + name: column.name.clone(), + type_name, + default_value: column.default_value.clone(), + }) + }) + .collect::, FormattingErrors>>()?; + + let constraints = table_def + .constraints + .values() + .map(|constraint| { + let ConstraintData::Unique(unique) = &constraint.data; + Ok(ConstraintInfo { + name: constraint.name.to_string(), + columns: unique + .columns + .iter() + .map(|col_id| { + let column = table_def.get_column(col_id).ok_or(FormattingErrors::ColumnNotFound)?; + Ok(column.name.clone()) + }) + .collect::, FormattingErrors>>()?, + table_name: table_def.name.clone(), + }) + }) + .collect::, FormattingErrors>>()?; + + let indexes = table_def + .indexes + .values() + .map(|index| { + let columns = match &index.algorithm { + IndexAlgorithm::BTree(btree) => btree + .columns + .iter() + .map(|col_id| { + let column = table_def.get_column(col_id).ok_or(FormattingErrors::ColumnNotFound)?; + Ok(column.name.clone()) + }) + .collect::, FormattingErrors>>()?, + IndexAlgorithm::Direct(direct) => { + let column = table_def + .get_column(direct.column) + .ok_or(FormattingErrors::ColumnNotFound)?; + vec![column.name.clone()] + } + }; + + Ok(IndexInfo { + name: index.name.to_string(), + columns, + table_name: table_def.name.clone(), + }) + }) + .collect::, FormattingErrors>>()?; + + let sequences = table_def + .sequences + .values() + .map(|sequence| { + let column = table_def + .get_column(sequence.column) + .ok_or(FormattingErrors::ColumnNotFound)?; + Ok(SequenceInfo { + name: sequence.name.to_string(), + column_name: column.name.clone(), + table_name: table_def.name.clone(), + }) + }) + .collect::, FormattingErrors>>()?; + + let schedule = table_def.schedule.as_ref().map(|schedule| ScheduleInfo { + table_name: table_def.name.to_string().clone(), + reducer_name: schedule.reducer_name.clone(), + }); + + Ok(TableInfo { + name: table_def.name.to_string(), + is_system: table_def.table_type == TableType::System, + access: table_def.table_access, + columns, + constraints, + indexes, + sequences, + schedule, + }) +} + +fn extract_index_info( + index: ::Key<'_>, + module_def: &ModuleDef, +) -> Result { + let table_def = module_def + .stored_in_table_def(index) + .ok_or(FormattingErrors::IndexNotFound)?; + let index_def = table_def.indexes.get(index).ok_or(FormattingErrors::IndexNotFound)?; + + let columns = match &index_def.algorithm { + IndexAlgorithm::BTree(btree) => btree + .columns + .iter() + .map(|col_id| { + let column = table_def.get_column(col_id).ok_or(FormattingErrors::ColumnNotFound)?; + Ok(column.name.clone()) + }) + .collect::, FormattingErrors>>()?, + IndexAlgorithm::Direct(direct) => { + let column = table_def + .get_column(direct.column) + .ok_or(FormattingErrors::ColumnNotFound)?; + vec![column.name.clone()] + } + }; + + Ok(IndexInfo { + name: index_def.name.to_string(), + columns, + table_name: table_def.name.clone(), + }) +} + +fn extract_constraint_info( + constraint: ::Key<'_>, + module_def: &ModuleDef, +) -> Result { + let table_def = module_def + .stored_in_table_def(constraint) + .ok_or(FormattingErrors::ConstraintNotFound)?; + let constraint_def = table_def + .constraints + .get(constraint) + .ok_or(FormattingErrors::ConstraintNotFound)?; + + let ConstraintData::Unique(unique_constraint_data) = &constraint_def.data; + let columns = unique_constraint_data + .columns + .iter() + .map(|col_id| { + let column = table_def.get_column(col_id).ok_or(FormattingErrors::ColumnNotFound)?; + Ok(column.name.clone()) + }) + .collect::, FormattingErrors>>()?; + + Ok(ConstraintInfo { + name: constraint_def.name.to_string(), + columns, + table_name: table_def.name.clone(), + }) +} + +fn extract_sequence_info( + sequence: ::Key<'_>, + module_def: &ModuleDef, +) -> Result { + let table_def = module_def + .stored_in_table_def(sequence) + .ok_or(FormattingErrors::SequenceNotFound)?; + let sequence_def = table_def + .sequences + .get(sequence) + .ok_or(FormattingErrors::SequenceNotFound)?; + + let column = table_def + .get_column(sequence_def.column) + .ok_or(FormattingErrors::ColumnNotFound)?; + + Ok(SequenceInfo { + name: sequence_def.name.to_string(), + column_name: column.name.clone(), + table_name: table_def.name.clone(), + }) +} + +fn extract_access_change_info( + table: ::Key<'_>, + plan: &super::AutoMigratePlan, +) -> Result { + let table_def = plan.new.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + + Ok(AccessChangeInfo { + table_name: table_def.name.clone(), + new_access: table_def.table_access, + }) +} + +fn extract_schedule_info( + schedule_table: ::Key<'_>, + module_def: &ModuleDef, +) -> Result { + let schedule_def: &ScheduleDef = module_def + .lookup(schedule_table) + .ok_or(FormattingErrors::ScheduleNotFound)?; + + Ok(ScheduleInfo { + table_name: schedule_def.name.to_string().clone(), + reducer_name: schedule_def.reducer_name.clone(), + }) +} + +fn extract_rls_info( + rls: ::Key<'_>, + plan: &super::AutoMigratePlan, +) -> Result, FormattingErrors> { + // Skip if policy unchanged (implementation detail workaround) + if plan.old.lookup::(rls) == plan.new.lookup::(rls) { + return Ok(None); + } + + Ok(Some(RlsInfo { + policy: rls.to_string(), + })) +} + +fn extract_column_changes( + table: ::Key<'_>, + plan: &super::AutoMigratePlan, +) -> Result { + let old_table = plan.old.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + let new_table = plan.new.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + + let mut changes = Vec::new(); + + // Find modified columns + for new_col in &new_table.columns { + if let Some(old_col) = old_table.columns.iter().find(|c| c.col_id == new_col.col_id) { + if old_col.name != new_col.name { + changes.push(ColumnChange::Renamed { + old_name: old_col.name.clone(), + new_name: new_col.name.clone(), + }); + } + if old_col.ty != new_col.ty { + let old_type = WithTypespace::new(plan.old.typespace(), &old_col.ty) + .resolve_refs() + .map_err(|_| FormattingErrors::TypeResolution)?; + let new_type = WithTypespace::new(plan.new.typespace(), &new_col.ty) + .resolve_refs() + .map_err(|_| FormattingErrors::TypeResolution)?; + changes.push(ColumnChange::TypeChanged { + name: new_col.name.clone(), + old_type, + new_type, + }); + } + } + } + + Ok(ColumnChanges { + table_name: new_table.name.clone(), + changes, + }) +} + +fn extract_new_columns( + table: ::Key<'_>, + plan: &super::AutoMigratePlan, +) -> Result { + let table_def = plan.new.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + let old_table_def = plan.old.table(table).ok_or_else(|| FormattingErrors::TableNotFound { + table: table.to_string().into(), + })?; + + let mut new_columns = Vec::new(); + for column in &table_def.columns { + if !old_table_def.columns.iter().any(|c| c.col_id == column.col_id) { + let type_name = WithTypespace::new(plan.new.typespace(), &column.ty) + .resolve_refs() + .map_err(|_| FormattingErrors::TypeResolution)?; + new_columns.push(ColumnInfo { + name: column.name.clone(), + type_name, + default_value: column.default_value.clone(), + }); + } + } + + Ok(NewColumns { + table_name: table_def.name.clone(), + columns: new_columns, + }) +} diff --git a/crates/schema/src/auto_migrate/termcolor_formatter.rs b/crates/schema/src/auto_migrate/termcolor_formatter.rs new file mode 100644 index 00000000000..85648e3c244 --- /dev/null +++ b/crates/schema/src/auto_migrate/termcolor_formatter.rs @@ -0,0 +1,377 @@ +use std::io::Write; +use std::{fmt, io}; + +use spacetimedb_lib::{db::raw_def::v9::TableAccess, AlgebraicType}; +use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; +use termcolor::{Buffer, Color, ColorChoice, ColorSpec, WriteColor}; + +use super::formatter::{ + AccessChangeInfo, Action, ColumnChange, ColumnChanges, ConstraintInfo, IndexInfo, MigrationFormatter, NewColumns, + RlsInfo, ScheduleInfo, SequenceInfo, TableInfo, +}; + +/// Color scheme for consistent formatting +#[derive(Debug, Clone)] +pub struct ColorScheme { + pub created: Color, + pub removed: Color, + pub changed: Color, + pub header: Color, + pub table_name: Color, + pub column_type: Color, + pub section_header: Color, + pub access: Color, + pub warning: Color, +} + +impl Default for ColorScheme { + fn default() -> Self { + Self { + created: Color::Green, + removed: Color::Red, + changed: Color::Yellow, + header: Color::Blue, + table_name: Color::Cyan, + column_type: Color::Magenta, + section_header: Color::Blue, + access: Color::Green, + warning: Color::Red, + } + } +} + +#[derive(Debug)] +pub struct TermColorFormatter { + buffer: Buffer, + colors: ColorScheme, + indent_level: usize, +} + +impl fmt::Display for TermColorFormatter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let buffer_content = std::str::from_utf8(self.buffer.as_slice()).map_err(|_| fmt::Error)?; + write!(f, "{buffer_content}") + } +} + +impl TermColorFormatter { + pub fn new(colors: ColorScheme, choice: ColorChoice) -> Self { + Self { + buffer: if choice == ColorChoice::Never { + Buffer::no_color() + } else { + Buffer::ansi() + }, + colors, + indent_level: 0, + } + } + + fn write_indent(&mut self) -> io::Result<()> { + let indent = " ".repeat(self.indent_level); + self.buffer.write_all(indent.as_bytes()) + } + + fn write_line(&mut self, text: impl AsRef) -> io::Result<()> { + self.write_indent()?; + self.buffer.write_all(text.as_ref().as_bytes())?; + self.buffer.write_all(b"\n") + } + + fn write_colored(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + if let Some(c) = color { + spec.set_fg(Some(c)); + } + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + fn write_colored_line(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + self.write_indent()?; + self.write_colored(text, color, bold)?; + self.buffer.write_all(b"\n") + } + + fn write_with_background(&mut self, text: &str, bg: Color, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + spec.set_bg(Some(bg)); + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + fn write_bullet(&mut self, text: &str) -> io::Result<()> { + self.write_line(format!("• {text}")) + } + + fn write_action_prefix(&mut self, action: &Action) -> io::Result<()> { + self.write_indent()?; + self.buffer.write_all("▸ ".to_string().as_bytes())?; + action.write_with_color(&mut self.buffer, &self.colors) + } + + fn indent(&mut self) { + self.indent_level += 1; + } + + fn dedent(&mut self) { + if self.indent_level > 0 { + self.indent_level -= 1; + } + } + + fn format_type_name(&self, ty: &AlgebraicType) -> String { + fmt_algebraic_type(ty).to_string() + } + + fn write_type_name(&mut self, ty: &AlgebraicType) -> io::Result<()> { + let s = self.format_type_name(ty); + self.write_colored(&s, Some(self.colors.column_type), false) + } + + fn format_access(&self, access: TableAccess) -> &'static str { + match access { + TableAccess::Private => "private", + TableAccess::Public => "public", + } + } + + fn write_access(&mut self, access: TableAccess) -> io::Result<()> { + let s = self.format_access(access); + self.write_colored(s, Some(self.colors.access), false) + } +} + +impl MigrationFormatter for TermColorFormatter { + fn format_header(&mut self) -> io::Result<()> { + let line = "━".repeat(60); + self.write_line(&line)?; + self.write_colored_line("Database Migration Plan", Some(self.colors.header), true)?; + self.write_line(&line)?; + self.write_line("") + } + + fn format_add_table(&mut self, table: &TableInfo) -> io::Result<()> { + // Table header + self.write_indent()?; + self.buffer.write_all("▸ ".to_string().as_bytes())?; + Action::Created.write_with_color(&mut self.buffer, &self.colors)?; + let kind = if table.is_system { "system" } else { "user" }; + self.buffer.write_all(format!(" {kind} table: ").as_bytes())?; + self.write_colored(&table.name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b" (")?; + self.write_access(table.access)?; + self.buffer.write_all(b")\n")?; + + self.indent(); + + if !table.columns.is_empty() { + self.write_colored_line("Columns:", Some(self.colors.section_header), true)?; + self.indent(); + for col in &table.columns { + self.write_indent()?; + self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.write_type_name(&col.type_name)?; + self.buffer.write_all(b"\n")?; + } + self.dedent(); + } + + if !table.constraints.is_empty() { + self.write_colored_line("Unique constraints:", Some(self.colors.section_header), true)?; + self.indent(); + for c in &table.constraints { + let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); + self.write_bullet(&format!("{} on [{}]", c.name, cols))?; + } + self.dedent(); + } + + if !table.indexes.is_empty() { + self.write_colored_line("Indexes:", Some(self.colors.section_header), true)?; + self.indent(); + for i in &table.indexes { + let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); + self.write_bullet(&format!("{} on [{}]", i.name, cols))?; + } + self.dedent(); + } + + if !table.sequences.is_empty() { + self.write_colored_line("Auto-increment constraints:", Some(self.colors.section_header), true)?; + self.indent(); + for s in &table.sequences { + self.write_bullet(&format!("{} on {}", s.name, s.column_name))?; + } + self.dedent(); + } + + if let Some(s) = &table.schedule { + self.write_colored_line("Schedule:", Some(self.colors.section_header), true)?; + self.indent(); + self.write_bullet(&format!("Calls reducer: {}", s.reducer_name))?; + self.dedent(); + } + + self.dedent(); + self.write_line("") + } + + fn format_constraint(&mut self, c: &ConstraintInfo, action: Action) -> io::Result<()> { + self.write_action_prefix(&action)?; + let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); + self.buffer + .write_all(format!(" unique constraint {} on [{}] of table ", c.name, cols).as_bytes())?; + self.write_colored(&c.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n") + } + + fn format_index(&mut self, i: &IndexInfo, action: Action) -> io::Result<()> { + self.write_action_prefix(&action)?; + let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); + self.buffer + .write_all(format!(" index {} on [{}] of table ", i.name, cols).as_bytes())?; + self.write_colored(&i.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n") + } + + fn format_sequence(&mut self, s: &SequenceInfo, action: Action) -> io::Result<()> { + self.write_action_prefix(&action)?; + self.buffer.write_all( + format!( + " auto-increment constraint {} on column {} of table ", + s.name, s.column_name + ) + .as_bytes(), + )?; + self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n") + } + + fn format_change_access(&mut self, a: &AccessChangeInfo) -> io::Result<()> { + let direction = match a.new_access { + TableAccess::Private => "public → private", + TableAccess::Public => "private → public", + }; + self.write_action_prefix(&Action::Changed)?; + self.buffer.write_all(b" access for table ")?; + self.write_colored(&a.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b" (")?; + self.write_colored(direction, Some(self.colors.access), false)?; + self.buffer.write_all(b")\n") + } + + fn format_schedule(&mut self, s: &ScheduleInfo, action: Action) -> io::Result<()> { + self.write_action_prefix(&action)?; + self.buffer.write_all(b" schedule for table ")?; + self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; + self.buffer + .write_all(format!(" calling reducer {}\n", s.reducer_name).as_bytes()) + } + + fn format_rls(&mut self, r: &RlsInfo, action: Action) -> io::Result<()> { + self.write_action_prefix(&action)?; + self.buffer.write_all(b" row level security policy:\n")?; + self.indent(); + self.write_indent()?; + self.buffer.write_all(b"`")?; + self.write_colored(&r.policy, Some(self.colors.section_header), false)?; + self.buffer.write_all(b"`\n")?; + self.dedent(); + Ok(()) + } + + fn format_change_columns(&mut self, cs: &ColumnChanges) -> io::Result<()> { + self.write_action_prefix(&Action::Changed)?; + self.buffer.write_all(b" columns for table ")?; + self.write_colored(&cs.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n")?; + + self.indent(); + for ch in &cs.changes { + self.write_indent()?; + match ch { + ColumnChange::Renamed { old_name, new_name } => { + self.buffer + .write_all(format!("~ Renamed: {old_name} → {new_name}\n").as_bytes())?; + } + ColumnChange::TypeChanged { + name, + old_type, + new_type, + } => { + self.buffer.write_all(format!("~ Modified: {name} (").as_bytes())?; + self.write_type_name(old_type)?; + self.buffer.write_all(" → ".to_string().as_bytes())?; + self.write_type_name(new_type)?; + self.buffer.write_all(b")\n")?; + } + } + } + self.dedent(); + Ok(()) + } + + fn format_add_columns(&mut self, nc: &NewColumns) -> io::Result<()> { + let plural = if nc.columns.len() > 1 { "s" } else { "" }; + self.write_action_prefix(&Action::Created)?; + self.buffer.write_all(format!(" column{plural} in table ").as_bytes())?; + self.write_colored(&nc.table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n")?; + + self.indent(); + for col in &nc.columns { + let default = col + .default_value + .as_ref() + .map(|v| format!(" (default: {v:#?})")) + .unwrap_or_default(); + self.write_indent()?; + self.buffer.write_all(format!("+ {}: ", col.name).as_bytes())?; + self.write_type_name(&col.type_name)?; + self.buffer.write_all(format!("{default}\n").as_bytes())?; + } + self.dedent(); + Ok(()) + } + + fn format_disconnect_warning(&mut self) -> io::Result<()> { + self.write_indent()?; + self.write_with_background( + "!!! Warning: All clients will be disconnected due to breaking schema changes", + self.colors.warning, + true, + )?; + self.buffer.write_all(b"\n") + } +} + +trait ActionColorExt { + fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()>; +} + +impl ActionColorExt for Action { + fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()> { + let (text, color) = match self { + Action::Created => ("Created", colors.created), + Action::Removed => ("Removed", colors.removed), + Action::Changed => ("Changed", colors.changed), + }; + let mut spec = ColorSpec::new(); + spec.set_fg(Some(color)).set_bold(true); + buffer.set_color(&spec)?; + buffer.write_all(text.as_bytes())?; + buffer.reset()?; + Ok(()) + } +} diff --git a/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__empty_to_populated_migration.snap b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__empty_to_populated_migration.snap new file mode 100644 index 00000000000..564c1bfb0e2 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__empty_to_populated_migration.snap @@ -0,0 +1,55 @@ +--- +source: crates/schema/src/auto_migrate.rs +expression: "plan.pretty_print(PrettyPrintStyle::AnsiColor).expect(\"should pretty print\")" +--- +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Database Migration Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +▸ Created user table: Apples (public) + Columns: + • id: U64 + • name: String + • count: U16 + • sum: (v1: U64) + Unique constraints: + • Apples_id_key on [id] + Indexes: + • Apples_id_idx_btree on [id] + • Apples_id_name_idx_btree on [id, name] + Auto-increment constraints: + • Apples_id_seq on id + +▸ Created user table: Bananas (public) + Columns: + • id: U64 + • name: String + • count: U16 + +▸ Created user table: Deliveries (public) + Columns: + • scheduled_id: U64 + • scheduled_at: (Interval: (__time_duration_micros__: I64) | Time: (__timestamp_micros_since_unix_epoch__: I64)) + • sum: Array<(v1: U64)> + Unique constraints: + • Deliveries_scheduled_id_key on [scheduled_id] + Indexes: + • Deliveries_scheduled_id_idx_btree on [scheduled_id] + Auto-increment constraints: + • Deliveries_scheduled_id_seq on scheduled_id + Schedule: + • Calls reducer: check_deliveries + +▸ Created user table: Inspections (public) + Columns: + • scheduled_id: U64 + • scheduled_at: (Interval: (__time_duration_micros__: I64) | Time: (__timestamp_micros_since_unix_epoch__: I64)) + Unique constraints: + • Inspections_scheduled_id_key on [scheduled_id] + Indexes: + • Inspections_scheduled_id_idx_btree on [scheduled_id] + Auto-increment constraints: + • Inspections_scheduled_id_seq on scheduled_id + +▸ Created row level security policy: + `SELECT * FROM Apples` diff --git a/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print no color.snap b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print no color.snap new file mode 100644 index 00000000000..c317d1216fc --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print no color.snap @@ -0,0 +1,37 @@ +--- +source: crates/schema/src/auto_migrate.rs +expression: "plan.pretty_print(true).expect(\"should pretty print\")" +--- +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Database Migration Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +▸ Removed index Apples_id_name_idx_btree on [id, name] of table Apples +▸ Removed unique constraint Apples_id_key on [id] of table Apples +▸ Removed auto-increment constraint Apples_id_seq on column id of table Apples +▸ Removed schedule for table Deliveries_sched calling reducer check_deliveries +▸ Removed row level security policy: + `SELECT * FROM Apples` +▸ Changed columns for table Apples +▸ Changed columns for table Deliveries +▸ Created column in table Bananas + + freshness: U32 (default: U32( + 5, +)) +▸ Created user table: Oranges (public) + Columns: + • id: U32 + Unique constraints: + • Oranges_id_key on [id] + Indexes: + • Oranges_id_idx_btree on [id] + Auto-increment constraints: + • Oranges_id_seq on id + +▸ Created index Apples_id_count_idx_btree on [id, count] of table Apples +▸ Created auto-increment constraint Bananas_id_seq on column id of table Bananas +▸ Created schedule for table Inspections_sched calling reducer perform_inspection +▸ Created row level security policy: + `SELECT * FROM Bananas` +▸ Changed access for table Bananas (public → private) +!!! Warning: All clients will be disconnected due to breaking schema changes diff --git a/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print.snap b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print.snap new file mode 100644 index 00000000000..b6281e07036 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__updated pretty print.snap @@ -0,0 +1,37 @@ +--- +source: crates/schema/src/auto_migrate.rs +expression: "plan.pretty_print(PrettyPrintStyle::AnsiColor).expect(\"should pretty print\")" +--- +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Database Migration Plan +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +▸ Removed index Apples_id_name_idx_btree on [id, name] of table Apples +▸ Removed unique constraint Apples_id_key on [id] of table Apples +▸ Removed auto-increment constraint Apples_id_seq on column id of table Apples +▸ Removed schedule for table Deliveries_sched calling reducer check_deliveries +▸ Removed row level security policy: + `SELECT * FROM Apples` +▸ Changed columns for table Apples +▸ Changed columns for table Deliveries +▸ Created column in table Bananas + + freshness: U32 (default: U32( + 5, +)) +▸ Created user table: Oranges (public) + Columns: + • id: U32 + Unique constraints: + • Oranges_id_key on [id] + Indexes: + • Oranges_id_idx_btree on [id] + Auto-increment constraints: + • Oranges_id_seq on id + +▸ Created index Apples_id_count_idx_btree on [id, count] of table Apples +▸ Created auto-increment constraint Bananas_id_seq on column id of table Bananas +▸ Created schedule for table Inspections_sched calling reducer perform_inspection +▸ Created row level security policy: + `SELECT * FROM Bananas` +▸ Changed access for table Bananas (public → private) +!!! Warning: All clients will be disconnected due to breaking schema changes