Skip to content

refactor(db): composite PK on M2M association tables (sc-105349)#39859

Draft
mikebridge wants to merge 8 commits intoapache:masterfrom
mikebridge:sc-105349-composite-association-pks
Draft

refactor(db): composite PK on M2M association tables (sc-105349)#39859
mikebridge wants to merge 8 commits intoapache:masterfrom
mikebridge:sc-105349-composite-association-pks

Conversation

@mikebridge
Copy link
Copy Markdown
Contributor

@mikebridge mikebridge commented May 4, 2026

SUMMARY

*** This is a PR to aid in discussion about whether we should replace id fields in the m2m join tables ***

Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2) on the eight pure-junction tables. The redundant UNIQUE(fk1, fk2) on the two tables that previously carried one is dropped (subsumed by the new PK). All eight tables are M:N association tables — there is no semantic load on the surrogate id.

Affected tables and their composite PK pair:

Table Composite PK
dashboard_roles (dashboard_id, role_id)
dashboard_slices (dashboard_id, slice_id)
dashboard_user (user_id, dashboard_id)
report_schedule_user (user_id, report_schedule_id)
rls_filter_roles (role_id, rls_filter_id)
rls_filter_tables (table_id, rls_filter_id)
slice_user (user_id, slice_id)
sqlatable_user (user_id, table_id)

Why now: 6 of the 8 tables had no UNIQUE constraint at all, so duplicate (fk1, fk2) rows could (and did) accumulate in production data. The migration deduplicates by MIN(id) before the PK promotion. This change also lifts a precondition for the entity-versioning epic (sc-103156) — SQLAlchemy-Continuum's M:N restore replays the version table; surrogate-PK junctions interact poorly with the bulk reassign-and-replace pattern (Continuum issue #129). Continuum verification against the new shape is not part of this PR — it is the responsibility of the versioning epic.

BEFORE/AFTER

dashboard_slices is the canonical example (the other seven follow the same pattern, with dashboard_user, dashboard_roles, slice_user, sqlatable_user, rls_filter_* having no pre-existing UNIQUE):

Before:

CREATE TABLE dashboard_slices (
    id           SERIAL  PRIMARY KEY,
    dashboard_id INTEGER REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER REFERENCES slices(id) ON DELETE CASCADE,
    UNIQUE (dashboard_id, slice_id)
);

After:

CREATE TABLE dashboard_slices (
    dashboard_id INTEGER NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER NOT NULL REFERENCES slices(id) ON DELETE CASCADE,
    PRIMARY KEY (dashboard_id, slice_id)
);

The redundant UNIQUE (dashboard_id, slice_id) is dropped (subsumed by the PK).

TESTING INSTRUCTIONS

Reproduces locally per quickstart.md steps 4–8:

  1. Apply the migrationsuperset db upgrade. Two tables with pre-existing UNIQUE (dashboard_slices, report_schedule_user) are batch-recreated via copy_from; the other six lose id and gain a composite PK directly. Migration logs the duplicate-row count and NULL-FK row count for each affected table.
  2. Confirm composite-PK enforcement (smoke) — for any of the eight tables: INSERT a (fk1, fk2) pair, then INSERT the same pair a second time. The second insert must raise IntegrityError (PK violation). Same pair under different IDs is no longer possible.
  3. Run the migration unit testspytest tests/unit_tests/migrations/composite_pk_association_tables_test.py (16 parametrized assertions: 8 duplicate-rejection, 8 distinct-pair).
  4. Run the migration integration testspytest tests/integration_tests/migrations/composite_pk_association_tables__tests.py tests/integration_tests/migrations/composite_pk_round_trip__tests.py (28 schema-shape assertions plus a round-trip + idempotency test against in-memory SQLite via MigrationContext).
  5. Verify downgradesuperset db downgrade <prior-revision>. The id column is restored (backfilled via sa.Identity(always=False) on Postgres/MySQL), and the original UNIQUE(fk1, fk2) is re-added on the two tables that originally had it. Intentional asymmetry: FK columns remain NOT NULL after downgrade — under SQLAlchemy secondary= semantics, NULL-FK junction rows are meaningless, so we don't restore the original nullable state.
  6. Verify upgrade idempotency — re-running superset db upgrade after downgrade returns the post-upgrade shape.

Cross-DB matrix (SC-005)

Verified end-to-end on all three backends locally — fresh full-history install (superset db upgrade from scratch) plus round-trip (upgrade → downgrade → upgrade) — and CI re-runs the migration during integration-test setup.

Backend Fresh install Round-trip Source
PostgreSQL 17 local docker container; CI test-postgres (current/next/previous), test-postgres-hive, test-postgres-presto
MySQL 8 local docker container with INSERT-without-id sanity check; CI test-mysql
SQLite local file-based DB with INSERT (NULL, 5)-rejection sanity check; CI test-sqlite; in-memory unit/integration tests (44 passed, 1 skip)

Bugs found and fixed during local cross-backend verification

The dedicated unit and integration tests run against in-memory SQLite, and CI's per-backend integration shards exercise the migration only as part of the suite's setup phase (which catches crashes but not subtle behavioural disparities). Doing fresh-install + round-trip locally on each real backend surfaced four bugs that were masked by both layers:

  • MySQL ERROR 1826 (Duplicate foreign key constraint name) during recreate="always" — InnoDB scopes FK constraint names per-database, not per-table. The temp table created by the recreate path collides with the original. CI's setup phase did catch this (the test-mysql shard turned red on first push); fix: drop FKs by name before the recreate on MySQL.
  • MySQL ERROR 1553 (Cannot drop index 'PRIMARY': needed in a foreign key constraint) on downgrade — InnoDB uses the composite PK index to back the FK on the leftmost column. Dropping the PK without first dropping the FKs orphans the index. Only manifests on a real round-trip — CI's setup-only check would never see it; fix: drop FKs before the PK swap on MySQL, re-add them after.
  • Missing AUTO_INCREMENT on the restored id column on MySQL — sa.Identity(always=False) only emits AUTO_INCREMENT when the column has primary_key=True at create time, but our portable path adds the column then creates the PK separately. Existing rows would all collide on id=0; subsequent INSERTs would fail with Field 'id' doesn't have a default value. Found by an explicit INSERT-without-id test against the post-downgrade DB; fix: combined DROP PRIMARY KEY, ADD COLUMN AUTO_INCREMENT, ADD PRIMARY KEY ALTER on MySQL.
  • SQLite composite PK doesn't enforce NOT NULL on constituent columns (long-standing SQLite quirk — only INTEGER PRIMARY KEY does). PostgreSQL and MySQL implicitly promote PK columns to NOT NULL; SQLite leaves them nullable for compound keys. A fresh SQLite install would have accepted INSERT (NULL, 5) despite both columns being part of the PK. The integration tests masked this because the test fixture seeds columns with nullable=False. Fix: explicit batch_op.alter_column(fk, nullable=False) for both FK columns — no-op on Postgres/MySQL where the PK already implies it.

The MySQL-specific fixes use raw triple-quoted SQL (no SQLA-core equivalent for the dialect-specific combined ALTER); the constitution allows raw SQL for dialect-specific DDL with no programmatic equivalent.

Operator runbook

Operators should run the pre-flight inventory queries from UPDATING.md ("Composite primary keys on many-to-many association tables") before applying — they show how many duplicate (fk1, fk2) rows and how many NULL-FK rows exist in their database. The migration deletes both classes (keeping MIN(id) per group for duplicates). The full operator-facing runbook is in UPDATING.md and includes:

  • The eight affected tables and their composite-PK columns
  • Impact on external readers (BI tools, backup scripts) that reference the surrogate id
  • Pre-flight inventory queries for assessing impact
  • Notice that duplicate and NULL-FK rows are not preserved
  • Workaround for restoring an old pg_dump against the new schema
  • Documentation of the FK-NOT-NULL downgrade asymmetry

ADDITIONAL INFORMATION

  • Has associated issue: sc-105349
  • Required feature flags: none
  • Changes UI: no
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible (modulo the documented FK-NOT-NULL downgrade asymmetry, which is intentional and operator-irrelevant)
    • Confirm DB migration upgrade and downgrade tested (round-trip + idempotency, 44 tests passing on SQLite locally; CI matrix covers Postgres + MySQL)
    • Runtime estimates and downtime expectations: the affected tables are small (M:N junctions for dashboards/datasets/charts/users/roles/RLS/reports — typically thousands to low-millions of rows even on the largest Superset deployments). The copy_from recreate path is O(n) table-rewrite for dashboard_slices and report_schedule_user; the other six are direct ALTER. Expected to complete in seconds-to-tens-of-seconds.
  • Introduces new feature or API: no
  • Removes existing feature or API: no

Continuum verification deferral

Verification of SQLAlchemy-Continuum's M:N restore behaviour against the new schema is the responsibility of the versioning epic (sc-103156) and is not part of this PR. This PR delivers the schema precondition; the versioning work picks up sqlalchemy-continuum as a dependency and verifies the restore behaviour there.

@github-actions github-actions Bot added the risk:db-migration PRs that require a DB migration label May 4, 2026
@dosubot dosubot Bot added change:backend Requires changing the backend risk:breaking-change Issues or PRs that will introduce breaking changes labels May 4, 2026
@mikebridge mikebridge marked this pull request as draft May 4, 2026 16:02
@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.87%. Comparing base (cb53745) to head (7b7b81c).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #39859   +/-   ##
=======================================
  Coverage   63.87%   63.87%           
=======================================
  Files        2582     2582           
  Lines      136413   136413           
  Branches    31453    31453           
=======================================
  Hits        87136    87136           
  Misses      47764    47764           
  Partials     1513     1513           
Flag Coverage Δ
hive 39.29% <ø> (ø)
mysql 59.01% <ø> (ø)
postgres 59.09% <ø> (ø)
presto 41.00% <ø> (ø)
python 60.53% <ø> (ø)
sqlite 58.72% <ø> (ø)
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py Outdated
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py
Comment on lines +123 to +124
f"Drop or migrate the referencing FK before applying this "
f"migration."
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will likely cause lint errors (f-string without any interpolation).

"""
# Identifiers come from the AFFECTED_TABLES whitelist, not user input.
sql = sa.text(
f"DELETE FROM {t.name} WHERE {t.fk1} IS NULL OR {t.fk2} IS NULL" # noqa: S608
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The combination of sa.text() and f" makes me nervous. Let's build a proper query here using SQLAlchemy core/ORM instead of building the SQL with string interpolation.

Comment on lines +165 to +181
sample_sql = sa.text(
f"SELECT {t.fk1}, {t.fk2}, id FROM {t.name} WHERE id NOT IN (" # noqa: S608
f" SELECT keep_id FROM ("
f" SELECT MIN(id) AS keep_id FROM {t.name} "
f"GROUP BY {t.fk1}, {t.fk2}"
f" ) AS s"
f") LIMIT 10"
)
sample = list(conn.execute(sample_sql))
sql = sa.text(
f"DELETE FROM {t.name} WHERE id NOT IN (" # noqa: S608
f" SELECT keep_id FROM ("
f" SELECT MIN(id) AS keep_id FROM {t.name} "
f"GROUP BY {t.fk1}, {t.fk2}"
f" ) AS s"
f")"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here, let's use a programmatic query instead of building the string.

Comment on lines +206 to +208
f"SELECT COUNT(*) FROM (" # noqa: S608
f" SELECT 1 FROM {t.name} GROUP BY {t.fk1}, {t.fk2} HAVING COUNT(*) > 1"
f") AS s"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here. Also, not that it's usually much more legible to simply use triple quotes if we wanted to build the query as a string:

sql = f"""
    SELECT COUNT(*) FROM (
      SELECT 1 FROM {t.name}
      GROUP BY {t.fk1}, {t.fk2}
      HAVING COUNT(*) > 1
    ) AS s
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are updated---I am also running the down migrations locally against sqlite, postgres and mysql to make sure they do something sensible.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-105349-composite-association-pks branch from dd4d48d to 0e5380d Compare May 4, 2026 23:26
@netlify
Copy link
Copy Markdown

netlify Bot commented May 5, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 827aefd
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fa1da6a40abe0007935615
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 5, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 7ae35b4
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fa210674b4cd0007478c8f
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Mike Bridge and others added 7 commits May 5, 2026 10:54
Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2)
on the eight pure-junction tables: dashboard_roles, dashboard_slices,
dashboard_user, report_schedule_user, rls_filter_roles, rls_filter_tables,
slice_user, sqlatable_user. The redundant UNIQUE(fk1, fk2) on dashboard_slices
and report_schedule_user is dropped (subsumed by the new PK).

Migration handles dialect quirks: copy_from for tables with pre-existing
UNIQUE (so SQLite's anonymous-constraint reflection doesn't matter), wrapped-
subquery dedupe for MySQL (ERROR 1093), sa.Identity(always=False) on downgrade
to backfill the restored id column without NOT NULL violations, and distinct
PK names per direction (pk_<table> on upgrade, <table>_pkey on downgrade) to
avoid round-trip index-name collisions on Postgres.

ORM Table() definitions updated to match. UPDATING.md entry added with
operator runbook (BI-tool impact, pre-flight inventory queries, dedupe-row-
loss notice, pg_dump workaround, FK-NOT-NULL downgrade asymmetry note).

Tests: 8 schema-shape assertions (post-upgrade), 8 duplicate-rejection unit
tests, 8 distinct-pair sanity tests, 1 round-trip + idempotency test
(in-memory SQLite via Alembic MigrationContext).

Continuum-restore verification against the new shape is out of scope for this
PR; it is the responsibility of the versioning epic (sc-103156).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups from PR review:

1. ``dashboard_roles.dashboard_id`` was created nullable in revision
   e11ccdd12658 but was missing from ``TABLES_WITH_NULLABLE_FKS``. A
   production database with a stray NULL ``dashboard_id`` row would have
   failed the PK-add with a cryptic constraint violation. Fix by running
   the NULL-FK cleanup on every affected table — it is a no-op DELETE on
   tables whose FK columns are already NOT NULL, and it eliminates the
   risk of further drift in the hardcoded set. ``dashboard_roles`` is
   added to the documentation set; the runtime now does not consult it.

2. The unit-test parent-table name for ``rls_filter_roles`` and
   ``rls_filter_tables`` was ``rls_filter`` (does not exist) instead of
   the real parent ``row_level_security_filters``. Test passes either
   way (the in-memory FK is self-consistent), but the parameter is now
   accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four operator-experience improvements from the second review pass:

1. ``TABLES_WITH_NULLABLE_FKS`` is now explicitly documented as an
   informational set that is not consulted at runtime; the comment
   explains the previous ``dashboard_roles`` omission was the bug
   that motivated the always-run cleanup.
2. ``_delete_null_fk_rows`` docstring updated to match the
   "always run" semantics (was still claiming "called only on tables
   in TABLES_WITH_NULLABLE_FKS").
3. ``_check_no_external_fks_to_id`` now documents its scope
   limitation: ``Inspector.get_table_names()`` returns the default
   schema only, so cross-schema FKs in non-standard multi-schema
   PostgreSQL deployments would not be caught. The single-schema
   case (Superset's documented deployment) is fully covered.
4. ``_dedupe_by_min_id`` now logs a sample of up to 10 discarded
   ``(fk1, fk2, id)`` tuples at WARN before deletion, so operators
   can audit which rows the ``MIN(id)`` policy drops. The keep-
   original policy is correct in practice but discards later
   re-grants on ownership tables; the sample makes that visible.
5. ``UPDATING.md`` documents the upgrade/downgrade primary-key
   name divergence (``pk_<table>`` vs ``<table>_pkey``) so
   operators using schema-comparison tools don't mistake it for
   migration drift.

No schema or runtime-behaviour changes. All 44 migration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI test-mysql failed with:

  MySQLdb.OperationalError: (1826, "Duplicate foreign key constraint
  name 'fk_dashboard_slices_slice_id_slices'")

Root cause: MySQL scopes foreign-key constraint names per-database,
not per-table (PostgreSQL and SQLite scope per-table). The
``batch_alter_table(... recreate="always", copy_from=...)`` path
used for ``dashboard_slices`` and ``report_schedule_user`` builds
``_alembic_tmp_<table>`` carrying the original FK names from
``copy_from`` while the original table still holds those names — MySQL
rejects the temp-table creation with ERROR 1826.

Fix: on MySQL only, drop the original FK constraints by name before
the ``batch_alter_table`` runs. The ``copy_from`` re-creates them on
the rebuilt table with their original names, so the post-migration
shape is unchanged. On PostgreSQL and SQLite the original code path
still runs unchanged.

Local SQLite tests (44 passed, 1 skipped) still pass; CI will validate
on MySQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two MySQL-only failures in the downgrade path, found by running the
full migration history against a fresh MySQL 8 container:

1. ``MySQLdb.OperationalError: (1553, "Cannot drop index 'PRIMARY':
   needed in a foreign key constraint")``. InnoDB uses the composite
   PK index to back the FK on the leftmost column. The downgrade
   tried to drop the composite PK before dropping the FKs, orphaning
   the FK's backing index. PostgreSQL and SQLite create separate
   indexes for FK columns and don't trip on this.

2. ``Field 'id' doesn't have a default value`` on subsequent INSERT.
   ``sa.Identity(always=False)`` only emits ``AUTO_INCREMENT`` on
   MySQL when the column is created with ``primary_key=True`` — our
   portable path adds the column first then creates the PK separately,
   so MySQL leaves the column without auto-generation. Existing rows
   would all collide on id=0; future inserts fail because no default.
   Postgres' ``GENERATED BY DEFAULT AS IDENTITY`` and SQLite's
   ``INTEGER PRIMARY KEY`` rowid alias don't have this gap.

Fix: extract ``_downgrade_mysql_table()`` that emits the canonical
MySQL idiom — drop FKs, then a single ALTER combining
``DROP PRIMARY KEY, ADD COLUMN id INT NOT NULL AUTO_INCREMENT,
ADD PRIMARY KEY (id)`` (which backfills existing rows with sequential
ids and preserves AUTO_INCREMENT), restore the redundant UNIQUE on
the 2 tables that originally had it, and re-add the FKs with their
original names. Postgres and SQLite keep the existing portable
``batch_alter_table`` path.

Raw SQL is unavoidable for the combined-ALTER form; per the
constitution it's allowed for dialect-specific DDL with no SQLA
equivalent, with triple-quoted strings for legibility.

Verified end-to-end: upgrade → downgrade → upgrade against a fresh
MySQL 8 container with INSERT-without-id sanity check showing the
restored ``id`` column auto-increments correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found by running fresh-install + round-trip against a real SQLite DB:
6 of the 8 affected tables had FK columns that were originally
declared nullable. PostgreSQL and MySQL implicitly promote the
constituent columns of an ``ALTER TABLE ... ADD PRIMARY KEY`` to
``NOT NULL``; SQLite does not (it's a long-standing SQLite quirk —
only ``INTEGER PRIMARY KEY`` enforces NOT NULL on a composite-PK
column). Result: a fresh SQLite install would accept
``INSERT INTO dashboard_slices (NULL, 5)`` despite both columns
being part of the composite PK.

Our integration tests previously masked this: the test fixture seeds
columns with ``nullable=False``, so the post-upgrade NOT NULL
assertion passed regardless of whether the migration enforced it.

Fix: add explicit ``batch_op.alter_column(fk, nullable=False)`` for
both FK columns inside the per-table batch_alter_table block. On
PostgreSQL and MySQL this is a no-op (PK already implies NOT NULL);
on SQLite it adds the missing NOT NULL declaration so a fresh
install matches the data-model.md "After" contract.

Verified end-to-end:
- Postgres + MySQL: column shape unchanged (still NOT NULL)
- SQLite fresh install + round-trip: all 8 tables now have NOT NULL
  on FK columns, ``INSERT (NULL, 5)`` correctly rejected with
  IntegrityError on dashboard_slices, dashboard_user, sqlatable_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-105349-composite-association-pks branch from 3870647 to 7ae35b4 Compare May 5, 2026 16:55
CI cypress + playwright shards were red with:

  ERROR [flask_migrate] Error: Multiple head revisions are present
  for given argument 'head'

The recent rebase onto master pulled in
``33d7e0e21daa_add_semantic_layers_and_views.py`` (from PR apache#37815,
"semantic layer extension"), which had been authored against
``ce6bd21901ab`` as its parent — the same parent our migration
referenced. After the rebase both migrations point at
``ce6bd21901ab``, producing two heads and breaking ``flask db
upgrade head`` for any downstream consumer (CI's Cypress / Playwright
shards spin up a real Superset instance via ``superset db upgrade``,
which is why those shards failed first; the integration shards run
against a precomputed schema and didn't surface this).

Fix: chain our migration after the semantic-layer migration by
pointing ``down_revision`` at ``33d7e0e21daa``. The chain is now
linear:

    ... → ce6bd21901ab → 33d7e0e21daa (semantic layers)
                          → 2bee73611e32 (composite PK, this PR)

Verified with ``superset db heads`` (returns single head
``2bee73611e32``) and the local migration test suite (44 passed,
1 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend risk:breaking-change Issues or PRs that will introduce breaking changes risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants