diff --git a/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py new file mode 100644 index 0000000000..495efc02f3 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0155_fix_language_foreign_key_length.py @@ -0,0 +1,83 @@ +# Generated manually to fix Language foreign key column lengths +# See https://github.com/learningequality/studio/issues/5618 +# +# When Language.id was changed from max_length=7 to max_length=14 in migration +# 0081, Django 1.9 did not cascade the primary key column size change to +# foreign key and many-to-many junction table columns. This migration fixes +# those columns for databases that were created before the migration squash. +# +# This migration is idempotent - it only alters columns that are still varchar(7). +from django.db import migrations + + +# SQL to fix each column, checking if it needs to be altered first +FORWARD_SQL = """ +DO $$ +BEGIN + -- Fix contentcuration_channel.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_channel' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_channel + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_channel_included_languages.language_id (M2M junction table) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_channel_included_languages' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_channel_included_languages + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_contentnode.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_contentnode' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_contentnode + ALTER COLUMN language_id TYPE varchar(14); + END IF; + + -- Fix contentcuration_file.language_id + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'contentcuration_file' + AND column_name = 'language_id' + AND character_maximum_length = 7 + ) THEN + ALTER TABLE contentcuration_file + ALTER COLUMN language_id TYPE varchar(14); + END IF; +END $$; +""" + +# Reverse SQL is a no-op since we don't want to shrink the columns back +# (that could cause data loss if longer language codes have been inserted) +REVERSE_SQL = """ +-- No-op: Cannot safely reverse this migration as it may cause data loss +SELECT 1; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0154_alter_assessmentitem_type"), + ] + + operations = [ + migrations.RunSQL(FORWARD_SQL, REVERSE_SQL), + ] diff --git a/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py new file mode 100644 index 0000000000..e31831d97d --- /dev/null +++ b/contentcuration/contentcuration/tests/test_language_fk_column_length_migration.py @@ -0,0 +1,79 @@ +""" +Test for migration 0161_fix_language_foreign_key_length. + +This test verifies that the migration correctly fixes Language foreign key +columns that are varchar(7) instead of varchar(14). +""" +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +# The columns that should be fixed by the migration +COLUMNS_TO_CHECK = [ + ("contentcuration_channel", "language_id"), + ("contentcuration_channel_included_languages", "language_id"), + ("contentcuration_contentnode", "language_id"), + ("contentcuration_file", "language_id"), +] + + +def get_column_max_length(table_name, column_name): + """Get the character_maximum_length for a varchar column.""" + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = %s + AND column_name = %s + """, + [table_name, column_name], + ) + row = cursor.fetchone() + return row[0] if row else None + + +def set_column_to_varchar7(table_name, column_name): + """Shrink a varchar column to varchar(7) to simulate bad production state.""" + with connection.cursor() as cursor: + cursor.execute( + f"ALTER TABLE {table_name} ALTER COLUMN {column_name} TYPE varchar(7)" + ) + + +class TestLanguageForeignKeyLengthMigration(TransactionTestCase): + """ + Test that migration 0155 fixes varchar(7) Language FK columns to varchar(14). + + This simulates the production database state where Language.id was changed + from max_length=7 to max_length=14, but Django 1.9 didn't cascade the change + to foreign key columns. + """ + + def test_migration_fixes_varchar7_columns(self): + # First, shrink all columns back to varchar(7) to simulate bad state + for table_name, column_name in COLUMNS_TO_CHECK: + set_column_to_varchar7(table_name, column_name) + # Verify the column is now varchar(7) + self.assertEqual( + get_column_max_length(table_name, column_name), + 7, + f"{table_name}.{column_name} should be varchar(7) before migration", + ) + + # Run migration 0161 from 0160 + executor = MigrationExecutor(connection) + executor.migrate([("contentcuration", "0154_alter_assessmentitem_type")]) + executor = MigrationExecutor(connection) + executor.loader.build_graph() + executor.migrate([("contentcuration", "0155_fix_language_foreign_key_length")]) + + # Verify all columns are now varchar(14) + for table_name, column_name in COLUMNS_TO_CHECK: + self.assertEqual( + get_column_max_length(table_name, column_name), + 14, + f"{table_name}.{column_name} should be varchar(14) after migration", + )