From 1b5cf8dea4c016cd0eb55b312f4c0c37d0192bd0 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 9 Oct 2025 21:39:38 +0200 Subject: [PATCH] Add convert_mariadb_uuids command Bugsink 2.0 upgraded to Django 5.2 (from 4.2) under the hood. However, Django 5.0 made it so that on MariaDB >= 10.7, UUIDFields are stored as native UUID fields, and special conversion steps are required. The Django 5.0 release notes suggest basically downgrading your existing UUIDs to CharField values and sticking with that forever, but that's not a workable solution for us for many reasons: * Bugsink is multi-DB-backend, and this change would affect them all. I don't want to force postgres-users to downgrade just for this. * Bugsink 2.0 is already out in the wild, and people who've deployed it on MariaDB >= 10.7 would already "be in the correct future" (have UUID columns). i.e. the "solution" would just create the inverse problem. * What with new models with new UUID fields? We'd run the rist of a "mix" with all the future complexity that comes with _that_. So I've decided to just bite the bullet and force an upgrade. The provided command allows users to upgrade their data. Just run: ``` bugsink-manage convert_mariadb_uuids ``` I have not (yet) provided this as a migration (it's always an option for the future)... mostly for lack of time but also because we "need this as a command anyway"... because people that would be on MariaDB < 10.7 still need this tool whenever they migrate to >= 10.7. Fix #226 --- .../commands/convert_mariadb_uuids.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 bsmain/management/commands/convert_mariadb_uuids.py diff --git a/bsmain/management/commands/convert_mariadb_uuids.py b/bsmain/management/commands/convert_mariadb_uuids.py new file mode 100644 index 0000000..f395bed --- /dev/null +++ b/bsmain/management/commands/convert_mariadb_uuids.py @@ -0,0 +1,117 @@ +from django.apps import apps +from django.core.management.base import BaseCommand +from django.db import connection, models +import time + + +class Command(BaseCommand): + help = "On MariaDB 10.7+, convert legacy UUID columns stored as CHAR(32) to the native UUID type. See #226." + + def add_arguments(self, parser): + parser.add_argument("--dry-run", action="store_true", help="Only show what would change.") + parser.add_argument("--yes", action="store_true", help="Do not prompt before altering tables.") + + def _require_mariadb_10_7_plus(self): + if connection.vendor != "mysql": + self.stdout.write("Skipping: backend is not MySQL/MariaDB.") + return False + + if not connection.mysql_is_mariadb: + self.stdout.write("Skipping: backend is MySQL, not MariaDB.") + return False + + version = getattr(connection, "mysql_version", None) + if not version or version < (10, 7): + self.stdout.write(f"Skipping: MariaDB version {version} detected; requires >= (10, 7).") + return False + self.stdout.write(f"MariaDB detected: {version}. Scanning all managed models for UUIDField columns...\n\n") + return True + + # LIKE is safe here because Django column names are alphanumeric with underscores only. + def _column_type(self, table, column): + qn_table = connection.ops.quote_name(table) + sql = f"SHOW FULL COLUMNS FROM {qn_table} LIKE %s" + + with connection.cursor() as c: + c.execute(sql, [column]) + row = c.fetchone() + + if not row: + self.stdout.write(self.style.ERROR(f" -> ERROR: column {column!r} not found in table {table!r}.")) + exit(1) + + return row[1].lower() # .lower() for consistent comparisons and display (AFAICT it's lowercase anyway) + + def handle(self, *args, **opts): + dry_run = opts["dry_run"] + if not self._require_mariadb_10_7_plus(): + return + + if not dry_run and not opts["yes"]: + self.stdout.write(self.style.WARNING("This may take a long time on large tables. Continue? [y/N]")) + if input("> ").strip().lower() != "y": + self.stdout.write("Cancelled.") + return + + with connection.schema_editor() as schema_editor: + for model in apps.get_models(): + if not model._meta.managed: + continue + + table = model._meta.db_table + for field in model._meta.get_fields(): + if not isinstance(field, models.UUIDField): + continue + + # NOTE: we have no special handling for inherited fields because Bugsink has simple models only + + column = field.column + db_type = self._column_type(table, column).lower() + + self.stdout.write( + f"{model._meta.label}.{field.name} is of type {db_type}." + ) + + if db_type == "uuid": + self.stdout.write(" -> SKIPPING: already native UUID") + continue + + if db_type != "char(32)": + self.stdout.write( + " -> WARNING: unexpected type for a UUIDField (neither char(32) nor UUID). No action." + ) + continue + + # we let Django's schema editor handle the actual conversion; we just specify how it was in the 4.2 + # era (under the hood); "how it is now" is already in the field definition + old_field = models.CharField(max_length=32, null=field.null, primary_key=field.primary_key) + old_field.set_attributes_from_name(field.name) # sets field name, column etc. + + old_field = models.CharField( + max_length=32, + null=field.null, + primary_key=field.primary_key, + unique=field.unique, + db_index=field.db_index, + db_column=field.db_column, + ) + + # Bind identity so Django knows which column on which model we're altering + old_field.name = field.name + old_field.attname = field.attname + old_field.model = model + old_field.concrete = True + old_field.column = field.column + + if dry_run: + self.stdout.write(" -> DRY RUN: real run would ALTER COLUMN to native UUID.") + continue + + self.stdout.write(" -> Altering ", ending="") + + t0 = time.time() + schema_editor.alter_field(model, old_field, field) + dt = time.time() - t0 + + new_db_type = self._column_type(table, column) + self.stdout.write(f" to {new_db_type!r} (took {dt:.3f}s)")