From 6dfdab2212e103c0f6bb18328fbd09b129903801 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Fri, 11 Sep 2026 16:56:48 +0100 Subject: [PATCH] Declare the unique indexes production already has The test database is built by db.create_all() from the models, so a UNIQUE index that exists only in production is absent from the SQLite schema tests run against. Tests then happily create rows MySQL would refuse, and a test written to exercise a duplicate-key race passes against entirely unfixed code. That has already produced two bugs in a week (#733 for user_language, #738 for basic_sr_schedule). Migrations are not the source of truth here -- user_language's index appears in no migration at all -- so this compares the live production index list against what SQLAlchemy actually puts on db.metadata, rather than against tools/migrations/*.sql or a reading of the model files. Ten indexes were missing. Each is declared under production's exact name, so nobody later generates a second one under a tidier name: context_type unique_context_type level_adapted_article_text uq_article_level new_text HASH_INDEX source_text HASH_INDEX starred_article url_id teacher_cohort_map user_id_2 text content_hash user_avatar user_id user_onboarding_message ux_user_onboarding_message_user_message user_word unique_user_word starred_article needed a second fix. Its constraint was already declared as a bare UniqueConstraint in the class body -- a form that does attach to the table, contrary to the comment in 26-05-26-a--dedupe-and-unique-user-video.sql -- but the module was missing from zeeguu.core.model's imports, so the table never reached db.create_all() and the test database had no starred_article at all. Moved into __table_args__ under production's name and imported. No DDL and no migration: production already has every one of these. Co-Authored-By: Claude Opus 5 --- zeeguu/core/model/__init__.py | 1 + zeeguu/core/model/context_type.py | 6 +++++- zeeguu/core/model/level_adapted_article_text.py | 8 +++++++- zeeguu/core/model/new_text.py | 7 ++++++- zeeguu/core/model/source_text.py | 7 ++++++- zeeguu/core/model/starred_article.py | 13 +++++++++---- zeeguu/core/model/teacher_cohort_map.py | 7 ++++++- zeeguu/core/model/text.py | 6 +++++- zeeguu/core/model/user_avatar.py | 5 +++++ zeeguu/core/model/user_onboarding_message.py | 10 +++++++++- zeeguu/core/model/user_word.py | 10 +++++++++- 11 files changed, 68 insertions(+), 12 deletions(-) diff --git a/zeeguu/core/model/__init__.py b/zeeguu/core/model/__init__.py index e3868ba84..f23ef6979 100644 --- a/zeeguu/core/model/__init__.py +++ b/zeeguu/core/model/__init__.py @@ -44,6 +44,7 @@ from .user_language import UserLanguage from .user_article import UserArticle +from .starred_article import StarredArticle from .article_difficulty_feedback import ArticleDifficultyFeedback from .feed import Feed diff --git a/zeeguu/core/model/context_type.py b/zeeguu/core/model/context_type.py index 3519fafe9..530f6967f 100644 --- a/zeeguu/core/model/context_type.py +++ b/zeeguu/core/model/context_type.py @@ -37,7 +37,11 @@ class ContextType(db.Model): EXAMPLE_SENTENCE, ] - __table_args__ = {"mysql_collate": "utf8_bin"} + # Named for the index production already has, so nobody generates a second one. + __table_args__ = ( + db.UniqueConstraint("type", name="unique_context_type"), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) type = db.Column(db.String(45)) diff --git a/zeeguu/core/model/level_adapted_article_text.py b/zeeguu/core/model/level_adapted_article_text.py index 386856a62..fefa4fac1 100644 --- a/zeeguu/core/model/level_adapted_article_text.py +++ b/zeeguu/core/model/level_adapted_article_text.py @@ -38,7 +38,13 @@ class LevelAdaptedArticleText(db.Model): added — renaming it would have churned the two context joins and every FK.) """ - __table_args__ = {"mysql_collate": "utf8_bin"} + # Named for the index production already has (added by + # tools/migrations/26-08-12--add_article_level_summary.sql), so nobody + # generates a second one. + __table_args__ = ( + db.UniqueConstraint("article_id", "cefr_level", name="uq_article_level"), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) diff --git a/zeeguu/core/model/new_text.py b/zeeguu/core/model/new_text.py index 307562f29..9db8c4b5e 100644 --- a/zeeguu/core/model/new_text.py +++ b/zeeguu/core/model/new_text.py @@ -8,7 +8,12 @@ class NewText(db.Model): - __table_args__ = {"mysql_collate": "utf8_bin"} + # HASH_INDEX is production's own name for this index; keep it so nobody + # generates a second one. + __table_args__ = ( + db.UniqueConstraint("content_hash", name="HASH_INDEX"), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) diff --git a/zeeguu/core/model/source_text.py b/zeeguu/core/model/source_text.py index ab64c8fe7..39753ce90 100644 --- a/zeeguu/core/model/source_text.py +++ b/zeeguu/core/model/source_text.py @@ -11,7 +11,12 @@ class SourceText(db.Model): - __table_args__ = {"mysql_collate": "utf8_bin"} + # HASH_INDEX is production's own name for this index; keep it so nobody + # generates a second one. It is what find_or_create's hash lookup relies on. + __table_args__ = ( + db.UniqueConstraint("content_hash", name="HASH_INDEX"), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) # this mapes to TEXT in the mysql which can hold about 15K words diff --git a/zeeguu/core/model/starred_article.py b/zeeguu/core/model/starred_article.py index 62706a7ec..9ad5c7ad8 100644 --- a/zeeguu/core/model/starred_article.py +++ b/zeeguu/core/model/starred_article.py @@ -25,7 +25,15 @@ class StarredArticle(db.Model): """ - __table_args__ = {"mysql_collate": "utf8_bin"} + # url_id is production's own name for this index; keep it so nobody + # generates a second one. This was a bare UniqueConstraint further down the + # class body -- which does attach to the table -- but the module was missing + # from zeeguu.core.model's imports, so the table never reached + # db.create_all() and the test database had no starred_article at all. + __table_args__ = ( + UniqueConstraint("url_id", "user_id", name="url_id"), + {"mysql_collate": "utf8_bin"}, + ) id = Column(Integer, primary_key=True) @@ -43,9 +51,6 @@ class StarredArticle(db.Model): # Useful for ordering past read articles starred_date = Column(DateTime) - # Together an url_id and user_id identify an article - UniqueConstraint(url_id, user_id) - def __init__(self, user, url, _title: str, language): self.user = user self.url = url diff --git a/zeeguu/core/model/teacher_cohort_map.py b/zeeguu/core/model/teacher_cohort_map.py index b0b5fa594..22d6c5b40 100644 --- a/zeeguu/core/model/teacher_cohort_map.py +++ b/zeeguu/core/model/teacher_cohort_map.py @@ -7,7 +7,12 @@ class TeacherCohortMap(db.Model): - __table_args__ = {"mysql_collate": "utf8_bin"} + # user_id_2 is production's own (auto-generated) name for this index; keep it + # so nobody generates a second one under a tidier name. + __table_args__ = ( + db.UniqueConstraint("user_id", "cohort_id", name="user_id_2"), + {"mysql_collate": "utf8_bin"}, + ) id = Column(Integer, primary_key=True) diff --git a/zeeguu/core/model/text.py b/zeeguu/core/model/text.py index bb41e5df8..6bafc0b6c 100644 --- a/zeeguu/core/model/text.py +++ b/zeeguu/core/model/text.py @@ -13,7 +13,11 @@ class Text(db.Model): - __table_args__ = {"mysql_collate": "utf8_bin"} + # Named for the index production already has, so nobody generates a second one. + __table_args__ = ( + db.UniqueConstraint("content_hash", name="content_hash"), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) diff --git a/zeeguu/core/model/user_avatar.py b/zeeguu/core/model/user_avatar.py index 7b7414156..357c31c7f 100644 --- a/zeeguu/core/model/user_avatar.py +++ b/zeeguu/core/model/user_avatar.py @@ -7,6 +7,11 @@ class UserAvatar(db.Model): __tablename__ = "user_avatar" + # One avatar per user. Named for the index production already has + # (tools/migrations/26-03-13--add_user_avatar.sql), so nobody generates a + # second one -- and so update_or_create's find-then-insert cannot quietly + # produce two avatars for a user in the test database. + __table_args__ = (db.UniqueConstraint("user_id", name="user_id"),) id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) diff --git a/zeeguu/core/model/user_onboarding_message.py b/zeeguu/core/model/user_onboarding_message.py index 4e0b6f37b..0734696e3 100644 --- a/zeeguu/core/model/user_onboarding_message.py +++ b/zeeguu/core/model/user_onboarding_message.py @@ -13,7 +13,15 @@ class UserOnboardingMessage(db.Model): when that dismissal was performed. If not, this field will be null """ - __table_args__ = {"mysql_collate": "utf8_bin"} + # Named for the index production already has, so nobody generates a second one. + __table_args__ = ( + db.UniqueConstraint( + "user_id", + "onboarding_message_id", + name="ux_user_onboarding_message_user_message", + ), + {"mysql_collate": "utf8_bin"}, + ) id = db.Column(db.Integer, primary_key=True) diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index 622988037..636366697 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -14,7 +14,15 @@ class UserWord(db.Model): - __table_args__ = {"mysql_collate": "utf8_bin"} + # Named for the index production already has (added by + # tools/migrations/25-05-24--adding_the_user_word_table.sql), so nobody + # generates a second one under a different name. Declared here so the + # SQLite test database refuses the duplicate pairs MySQL refuses -- without + # it, a test exercising the find_or_create race passes against unfixed code. + __table_args__ = ( + db.UniqueConstraint("user_id", "meaning_id", name="unique_user_word"), + {"mysql_collate": "utf8_bin"}, + ) __tablename__ = "user_word" # Explicitly set table name for migration id = db.Column(db.Integer, primary_key=True)