feat(poll): enforce active respondent uniqueness
This commit is contained in:
@@ -99,6 +99,18 @@ class PollResponse(Base, TimestampMixin):
|
||||
__table_args__ = (
|
||||
Index("ix_poll_responses_poll_submitted", "poll_id", "submitted_at"),
|
||||
Index("ix_poll_responses_poll_respondent", "poll_id", "respondent_id"),
|
||||
Index(
|
||||
"uq_poll_responses_active_respondent",
|
||||
"poll_id",
|
||||
"respondent_id",
|
||||
unique=True,
|
||||
sqlite_where=text(
|
||||
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||
),
|
||||
postgresql_where=text(
|
||||
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||
),
|
||||
),
|
||||
Index("ix_poll_responses_tenant_poll", "tenant_id", "poll_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""v0.1.10 active identified Poll response uniqueness
|
||||
|
||||
Revision ID: 6e7f8a9b0c1d
|
||||
Revises: 5d6e7f8a9b0c
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
This migration deduplicates active identified responses before creating the
|
||||
partial unique index. Run it while Poll response writes are quiesced or while
|
||||
the platform maintenance lock is held. PostgreSQL additionally takes a table
|
||||
lock so an unexpected writer cannot enter between cleanup and index creation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "6e7f8a9b0c1d"
|
||||
down_revision = "5d6e7f8a9b0c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_INDEX_NAME = "uq_poll_responses_active_respondent"
|
||||
_ACTIVE_IDENTIFIED_PREDICATE = (
|
||||
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def _tombstone_duplicate_active_responses() -> None:
|
||||
responses = sa.table(
|
||||
"poll_responses",
|
||||
sa.column("id", sa.String(length=36)),
|
||||
sa.column("poll_id", sa.String(length=36)),
|
||||
sa.column("respondent_id", sa.String(length=255)),
|
||||
sa.column("submitted_at", sa.DateTime(timezone=True)),
|
||||
sa.column("deleted_at", sa.DateTime(timezone=True)),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
ranked = (
|
||||
sa.select(
|
||||
responses.c.id.label("id"),
|
||||
sa.func.row_number()
|
||||
.over(
|
||||
partition_by=(
|
||||
responses.c.poll_id,
|
||||
responses.c.respondent_id,
|
||||
),
|
||||
order_by=(
|
||||
responses.c.submitted_at.desc(),
|
||||
responses.c.id.desc(),
|
||||
),
|
||||
)
|
||||
.label("response_rank"),
|
||||
)
|
||||
.where(
|
||||
responses.c.deleted_at.is_(None),
|
||||
responses.c.respondent_id.is_not(None),
|
||||
)
|
||||
.subquery("ranked_active_poll_responses")
|
||||
)
|
||||
duplicate_ids = sa.select(ranked.c.id).where(ranked.c.response_rank > 1)
|
||||
migration_timestamp = sa.func.current_timestamp()
|
||||
op.get_bind().execute(
|
||||
responses.update()
|
||||
.where(
|
||||
responses.c.id.in_(duplicate_ids),
|
||||
responses.c.deleted_at.is_(None),
|
||||
)
|
||||
.values(
|
||||
deleted_at=migration_timestamp,
|
||||
updated_at=migration_timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name == "postgresql":
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"LOCK TABLE poll_responses IN SHARE ROW EXCLUSIVE MODE"
|
||||
)
|
||||
)
|
||||
_tombstone_duplicate_active_responses()
|
||||
op.create_index(
|
||||
_INDEX_NAME,
|
||||
"poll_responses",
|
||||
["poll_id", "respondent_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
|
||||
postgresql_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Deduplicated rows remain tombstoned: a downgrade must not silently revive
|
||||
# responses that were no longer part of the active result set.
|
||||
op.drop_index(_INDEX_NAME, table_name="poll_responses")
|
||||
Reference in New Issue
Block a user