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
This commit is contained in:
Klaas van Schelven
2025-10-09 21:39:38 +02:00
parent 43b1d63030
commit 1b5cf8dea4

View File

@@ -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)")