Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,6 @@ services:
- --skip-name-resolve
# Disable performance schema for faster startup
- --performance-schema=OFF
# Allow nonstandard FKs (needed for translations)
- --skip-restrict-fk-on-non-standard-key
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#24894 proves just removing --skip-restrict-fk-on-non-standard-key in MySQL 8.4 without the changes from this PR loudly fails in CI.

healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "--silent"]
start_interval: 1s
Expand Down
10 changes: 8 additions & 2 deletions docs/topics/development/localization_and_internationalization.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,13 @@ models.signals.pre_save.connect(save_signal,

### How It Works Behind the Scenes

A `TranslatedField` is actually a `ForeignKey` to the `translations` table. To support multiple languages, we use a special feature of MySQL allowing a `ForeignKey` to point to multiple rows.
Rows in the translations table have an `autoid` primary key and an `id` key that is
shared between different translations of the same string in multiple languages.

That `id` is generated from a dedicated sequence table `translations_seq` every time
a new string to be translated is saved.

A `TranslatedField` is actually a `ForeignKey` to the `translations` table. To support multiple languages, these have `db_constraint` set to `False`, and the FK points to the `id` key.

#### When Querying

Expand All @@ -94,7 +100,7 @@ Our base manager has a `_with_translations()` method that is automatically calle
- Adds an extra `lang=lang` in the query to prevent query caching from returning objects in the wrong language.
- Calls `olympia.translations.transformers.get_trans()` which builds a custom SQL query to fetch translations in the current language and fallback language.

This custom query ensures that only the specified languages are considered and uses a double join with `IF`/`ELSE` for each field. The results are fetched using a slave database connection to improve performance.
This custom query ensures that only the specified languages are considered and uses a double join with `IF`/`ELSE` for each field. The results are fetched using a replica database connection to improve performance.

#### When Setting

Expand Down
7 changes: 5 additions & 2 deletions src/olympia/translations/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,15 @@ class TranslatedField(models.ForeignKey):
forward_related_accessor_class = TranslationDescriptor

def __init__(self, **kwargs):
# to_field: The field on the related object that the relation is to.
# Django wants to default to translations.autoid, but we need id.
kwargs.update(
{
'null': True,
# Field on the related object that the relation is to. Django
# wants to default to translations.autoid, but we need id.
'to_field': 'id',
# No db constraint, it's not a "true" FK, there can be multiple
# rows for a given translated string, one for each locale.
'db_constraint': False,
'unique': True,
'blank': True,
'on_delete': models.SET_NULL,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.2.13 on 2026-04-28 21:26

from django.db import migrations, router


def remove_legacy_translations_constraints(apps, schema_editor):
"""
Remove Foreign Key constraints to Translations.id - TranslatedField should
now be using db_constraint: False as there can be multiple translation rows
sharing the same id, one per locale per translated string.
"""
Translation = apps.get_model('translations', 'Translation')
models_related_to_translations = {
f.related_model
for f in Translation._meta.get_fields(include_hidden=True)
if f.auto_created and not f.concrete and (f.one_to_one or f.one_to_many)
}
connection = schema_editor.connection
with connection.cursor() as cursor:
for model in models_related_to_translations:
constraints = connection.introspection.get_constraints(
cursor, model._meta.db_table
)
for name, info in constraints.items():
if info['foreign_key'] == (Translation._meta.db_table, 'id'):
schema_editor.execute(
schema_editor._delete_fk_sql(model, name), params=None
)


class Migration(migrations.Migration):
# We're going to execute ALTER TABLE statements that are not supported
# inside a transaction, so disable transaction management to avoid
# "Executing DDL statements while in a transaction on databases that can't
# perform a rollback is prohibited" error.
atomic = False

dependencies = [
('translations', '0003_puretranslation_purifiedmarkdowntranslation'),
# Depend on these apps as they use TranslatedField
('addons', '0060_backfill_last_content_review_status'),
('versions', '0052_delete_enable_source_builder_waffle_switch'),
('bandwagon', '0010_fix_default_locale_spanish'),
]

operations = [
migrations.RunPython(remove_legacy_translations_constraints, lambda *args: None)
]
7 changes: 1 addition & 6 deletions src/olympia/translations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,7 @@ def delete(self, using=None):
assert self._get_pk_val() is not None
collector = Collector(using=using)
collector.collect([self], collect_related=False)
# In addition, because we have FK pointing to a non-unique column,
# we need to force MySQL to ignore constraints because it's dumb
# and would otherwise complain even if there are remaining rows
# that matches the FK.
with connections[using].constraint_checks_disabled():
collector.delete()
collector.delete()
else:
# If no other Translations with that id exist, then we should let
# django behave normally. It should find the related model and set
Expand Down
1 change: 1 addition & 0 deletions src/olympia/translations/tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ def test_user_foreign_key_field_deconstruct():
assert kwargs['require_locale'] == new_field_instance.require_locale
assert kwargs['to'] == new_field_instance.to
assert kwargs['short'] == new_field_instance.short
assert kwargs['db_constraint'] is False
19 changes: 19 additions & 0 deletions src/olympia/translations/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1057,3 +1057,22 @@ def get_model():
# Check that de finds the right translation.
fresh_german = get_model()
assert fresh_german.name == '😀'


@pytest.mark.django_db
def test_no_db_constraints():
models_related_to_translations = {
f.related_model
for f in Translation._meta.get_fields(include_hidden=True)
if f.auto_created and not f.concrete and (f.one_to_one or f.one_to_many)
}
assert models_related_to_translations
connection = connections['default']
with connection.cursor() as cursor:
for model in models_related_to_translations:
constraints = connection.introspection.get_constraints(
cursor, model._meta.db_table
)
for name, info in constraints.items():
assert not name.endswith(f'{Translation._meta.db_table}_id'), name
assert info['foreign_key'] != (Translation._meta.db_table, 'id'), name
Loading