Skip to content

Add the unique keys the models have been promising - #744

Merged
mircealungu merged 1 commit into
masterfrom
wt/promised-indexes
Sep 15, 2026
Merged

mircealungu merged 1 commit into
masterfrom
wt/promised-indexes

Conversation

@mircealungu

Copy link
Copy Markdown
Member

Follow-up to #741, and the last of the backwards sweep. Five constraints the models declare that production has never had — so each has been enforced in tests and unenforced in production, the exact reverse of #741. A race the test suite refuses can still duplicate rows in prod.

Production data was checked before writing any DDL: all five have zero duplicate groups today, so the ALTERs apply with no dedupe step.

level_adapted_article_summary_context — the clearest case

Its sibling level_adapted_article_title_context has uq_latc_bookmark_title in production. This one doesn't, and was almost certainly lost when article_level_summary was renamed and the column became level_adapted_article_text_id. Its find_or_create already inserts inside a SAVEPOINT expecting the constraint to be there — so today that recovery path is dead code in production.

Migration only; no code change needed.

The other four needed code first

search_filter, search_subscription, topic_filter, topic_subscription each had a find_or_create catching only NoResultFound:

except sqlalchemy.orm.exc.NoResultFound:
    new = cls(user, topic)
    session.add(new)

Adding the unique key to that would have converted a silent duplicate into a 500 on a user-facing path (subscribing to a topic, saving a search). So they now use the same SAVEPOINT-and-requery pattern as UserLanguage.find_or_create from #733 — rolling back just the losing insert rather than the caller's whole transaction.

The bare floating UniqueConstraint(user_id, topic_id) in each class body — which does attach, but unnamed — moves into __table_args__ under the name the migration uses, so model and production agree.

Apply the migration together with this code, not ahead of it.

Two I deliberately did not fix

Both were in scope and both turned out to need a decision rather than an ALTER:

phrase (content, language_id) — 55502 duplicate groups. The model has declared this unique since the index was dropped in 25-05-21--rename_user_word_to_phrase.sql. Enforcing it means a data cleanup that rewrites bookmark and meaning references, not a migration. I left the model declaration alone rather than dropping it: it encodes the correct intent, and removing it would quietly bless the duplicates.

article_topic_user_feedback (article_id, user_id, topic_id) — zero duplicates, but a semantic conflict. Its find_or_create keys on (article, user, topic, feedback) — narrower than the constraint. Adding the key would turn "user changes their mind about a topic" into an IntegrityError. Whether a changed feedback is a new row or an update to the existing one is a product decision, not one to guess at.

Testing

DEV_SKIP_TRANSLATION=1 python -m pytest zeeguu/core/test zeeguu/api/test -q

498 passed, 12 subtests — unchanged from this branch point.

🤖 Generated with Claude Code

Five constraints the models declare and production has never had. Because the
test database is built by db.create_all() from the models, each has been
enforced in tests and unenforced in production -- the reverse of #741, and the
reason a race the tests refuse can still duplicate rows in prod.

Production data was checked first: all five have zero duplicate groups today,
so the ALTERs apply with no dedupe step.

level_adapted_article_summary_context is the clearest case. Its sibling
level_adapted_article_title_context has uq_latc_bookmark_title in production,
and this one was almost certainly lost when article_level_summary was renamed
and the column became level_adapted_article_text_id. Its find_or_create
already inserts inside a SAVEPOINT expecting the constraint to be there.

The other four needed code first. Each find_or_create caught only
NoResultFound:

    except sqlalchemy.orm.exc.NoResultFound:
        new = cls(user, topic)
        session.add(new)

so adding the key would have converted a silent duplicate into a 500 on a
user-facing path. They now use the same SAVEPOINT-and-requery pattern as
UserLanguage.find_or_create (#733), which undoes just the losing insert rather
than the caller's whole transaction. The bare floating UniqueConstraint in each
class body -- which does attach, but unnamed -- moves into __table_args__ under
the name the migration uses, so model and production agree.

Two more from the same sweep are deliberately NOT here, because neither is a
mechanical add:

  phrase (content, language_id) -- 55502 duplicate groups in production. The
  model has declared this unique since the index was dropped in
  25-05-21--rename_user_word_to_phrase.sql. Enforcing it needs a data cleanup
  that rewrites bookmark and meaning references, not an ALTER.

  article_topic_user_feedback (article_id, user_id, topic_id) -- zero
  duplicates, but find_or_create keys on (article, user, topic, FEEDBACK),
  narrower than the constraint. Adding it would turn "user changes their mind
  about a topic" into an IntegrityError. Needs a decision about whether a
  changed feedback is a new row or an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ArchLens - No architecturally relevant changes to the existing views

@mircealungu
mircealungu merged commit 5932e7e into master Sep 15, 2026
3 checks passed
@mircealungu
mircealungu deleted the wt/promised-indexes branch September 15, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant