From 2bed72b1f5ab7937faf5e0c345dfdfb0079511ee Mon Sep 17 00:00:00 2001
From: Phillip Assmann
Date: Sun, 25 Oct 2026 18:17:09 +0100
Subject: [PATCH] fix(migrations): keep explicit table names in generated
migration models
The migration generator cleared all attributes of a model when writing it
into a migration file, including an explicit `table_name`. The migration
model then derived its table name from its own struct name, so the next
`cot migration make` compared it against a different table and generated
a bogus remove/create pair for every model with a custom table name.
---
cot-cli/src/migration_generator.rs | 19 +++++-
cot-cli/tests/migration_generator.rs | 68 +++++++++++++++++++
.../custom_table_name/step_1.rs | 9 +++
.../custom_table_name/step_2.rs | 10 +++
4 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 cot-cli/tests/migration_generator/custom_table_name/step_1.rs
create mode 100644 cot-cli/tests/migration_generator/custom_table_name/step_2.rs
diff --git a/cot-cli/src/migration_generator.rs b/cot-cli/src/migration_generator.rs
index be4e08c73..91085379b 100644
--- a/cot-cli/src/migration_generator.rs
+++ b/cot-cli/src/migration_generator.rs
@@ -601,6 +601,16 @@ impl MigrationGenerator {
#[must_use]
fn model_to_migration_model(model: &ModelInSource) -> TokenStream {
+ // keep the table name if it has been set explicitly, as it would
+ // otherwise be derived from the (prefixed) name of the migration model
+ let table_name = model
+ .model_item
+ .attrs
+ .iter()
+ .find(|attr| is_model_attr(attr))
+ .and_then(|attr| ModelArgs::from_meta(&attr.meta).ok())
+ .and_then(|args| args.table_name);
+
let mut model_source = model.model_item.clone();
model_source.vis = syn::Visibility::Inherited;
model_source.ident = format_ident!("_{}", model_source.ident);
@@ -608,9 +618,12 @@ impl MigrationGenerator {
model_source
.attrs
.push(syn::parse_quote! {#[derive(::core::fmt::Debug)]});
- model_source
- .attrs
- .push(syn::parse_quote! {#[::cot::db::model(model_type = "migration")]});
+ model_source.attrs.push(match table_name {
+ Some(table_name) => syn::parse_quote! {
+ #[::cot::db::model(model_type = "migration", table_name = #table_name)]
+ },
+ None => syn::parse_quote! {#[::cot::db::model(model_type = "migration")]},
+ });
quote! {
#model_source
}
diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs
index 1b17160d0..b3681518b 100644
--- a/cot-cli/tests/migration_generator.rs
+++ b/cot-cli/tests/migration_generator.rs
@@ -60,6 +60,74 @@ fn create_model_state_test() {
assert!(field.foreign_key.clone().is_some());
}
+/// Test that the migration generator will generate the correct create model
+/// migration if the model has a custom table name, and will also correctly
+/// generate the add field migration
+#[test]
+fn custom_table_name_test() {
+ let generator = test_generator();
+ let src = include_str!("migration_generator/custom_table_name/step_1.rs");
+ let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()];
+
+ let migration = generator
+ .generate_migrations_as_generated_from_files(source_files.clone())
+ .unwrap()
+ .unwrap();
+
+ let migration_file = generator
+ .generate_migrations_as_source_from_files(source_files)
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(migration.migration_name, "m_0001_initial");
+ assert_eq!(migration.dependencies, []);
+
+ let (table_name, fields) = unwrap_create_model(&migration.operations[0]);
+ assert_eq!(table_name, "cot__myamazing_table_name");
+ assert_eq!(fields.len(), 2);
+
+ let field = &fields[0];
+ assert_eq!(field.column_name, "id");
+ assert!(field.primary_key);
+ assert!(field.auto_value);
+ assert!(field.foreign_key.clone().is_none());
+
+ let field = &fields[1];
+ assert_eq!(field.column_name, "field_1");
+ assert!(!field.primary_key);
+ assert!(!field.auto_value);
+ assert!(field.foreign_key.clone().is_none());
+
+ let src = include_str!("migration_generator/custom_table_name/step_2.rs");
+ let source_files = vec![
+ SourceFile::parse(PathBuf::from("main.rs"), src).unwrap(),
+ SourceFile::parse(PathBuf::from(&migration_file.name), &migration_file.content).unwrap(),
+ ];
+
+ let migration = generator
+ .generate_migrations_as_generated_from_files(source_files)
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(migration.dependencies.len(), 2);
+ assert!(migration.dependencies.contains(&DynDependency::Migration {
+ app: "cot".to_string(),
+ migration: "m_0001_initial".to_string()
+ }));
+ assert!(migration.dependencies.contains(&DynDependency::Model {
+ model_type: parse_quote!(crate::CustomModel),
+ }));
+
+ assert_eq!(migration.operations.len(), 1);
+
+ let (table_name, field) = unwrap_add_field(&migration.operations[0]);
+ assert_eq!(table_name, "cot__myamazing_table_name");
+ assert_eq!(field.column_name, "field_2");
+ assert!(!field.primary_key);
+ assert!(!field.auto_value);
+ assert!(field.foreign_key.clone().is_none());
+}
+
#[test]
fn create_models_foreign_key() {
let generator = test_generator();
diff --git a/cot-cli/tests/migration_generator/custom_table_name/step_1.rs b/cot-cli/tests/migration_generator/custom_table_name/step_1.rs
new file mode 100644
index 000000000..aa6d19fec
--- /dev/null
+++ b/cot-cli/tests/migration_generator/custom_table_name/step_1.rs
@@ -0,0 +1,9 @@
+use cot::db::{Auto, LimitedString, model};
+
+#[derive(Debug)]
+#[model(table_name = "myamazing_table_name")]
+struct CustomModel {
+ #[model(primary_key)]
+ id: Auto,
+ field_1: String,
+}
diff --git a/cot-cli/tests/migration_generator/custom_table_name/step_2.rs b/cot-cli/tests/migration_generator/custom_table_name/step_2.rs
new file mode 100644
index 000000000..a4cb7ee8f
--- /dev/null
+++ b/cot-cli/tests/migration_generator/custom_table_name/step_2.rs
@@ -0,0 +1,10 @@
+use cot::db::{Auto, LimitedString, model};
+
+#[derive(Debug)]
+#[model(table_name = "myamazing_table_name")]
+struct CustomModel {
+ #[model(primary_key)]
+ id: Auto,
+ field_1: String,
+ field_2: i32,
+}