Skip to content

fix(csv-import): add primary key when MySQL requires one - #44411

Open
sadpandajoe wants to merge 2 commits into
masterfrom
ultraset-fix-37399
Open

sadpandajoe wants to merge 2 commits into
masterfrom
ultraset-fix-37399

Conversation

@sadpandajoe

Copy link
Copy Markdown
Member

SUMMARY

Uploading a CSV, Excel, or columnar file creates the target table via
pandas.DataFrame.to_sql, which never declares a primary key. A MySQL server
configured with sql_require_primary_key = ON rejects that CREATE TABLE
outright with error 3750, so the upload fails with no way for the user to
work around it — the key has to be part of the initial CREATE TABLE, since
MySQL rejects the bare statement itself and there is no table left to
ALTER afterwards.

MySQLEngineSpec now overrides df_to_sql. When a table is being created and
the server requires a primary key, it builds the table through pandas'
SQLTable helper with an explicit key:

  • if the "Dataframe index" option is on, the pandas index column that is
    already being written is promoted to the primary key (this is the literal
    case in Unable to import CSV into hosted database that enforces primary key ID #37399 — the reporter's CREATE TABLE had that column but it was
    never marked PRIMARY KEY);
  • otherwise a synthesized auto-numbered column is added, named to avoid
    colliding with an existing column.

sql_require_primary_key only exists in MySQL 8.0.13+, and several
MySQL-compatible specs subclass this one (MariaDB, Doris, StarRocks,
OceanBase, Aurora). Those servers error on the probe query and do not enforce
the requirement, so an unreadable variable is treated as "not required" and
the upload falls through to the unchanged base implementation. Rows are
inserted with the same multi-row INSERT the base implementation requests on
dialects that support it, so the new path carries no throughput penalty.

Appends to an existing table are untouched, and no behaviour changes on a
server with sql_require_primary_key = OFF (the default).

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend-only, no UI change.

TESTING INSTRUCTIONS

Unit tests: pytest tests/unit_tests/db_engine_specs/test_mysql.py

Three tests cover the behaviour. No live MySQL is required: an in-memory
SQLite engine stands in, with a SQLAlchemy event listener reproducing MySQL's
documented enforcement by rejecting any CREATE TABLE that lacks a primary
key. The tests verify that the table is created and populated, that the
pandas index column becomes the primary key rather than a redundant extra
column being added, and that a server which does not expose
sql_require_primary_key still uploads successfully.

Manual verification against a real server:

  1. Start MySQL 8.0.13+ with sql_require_primary_key = ON.
  2. Add it as a database in Superset with "Allow file uploads to database"
    enabled.
  3. Upload any CSV via Data → Upload a CSV, once with "Dataframe index"
    enabled and once without.
  4. Both uploads should succeed. Previously they failed with
    (3750, "Unable to create or change a table without a primary key, when the system variable 'sql_require_primary_key' is set.").
  5. SHOW CREATE TABLE <your_table> — the table should have a PRIMARY KEY
    on the index column (index enabled) or on the synthesized column
    (index disabled).
  6. Repeat step 3 against a MariaDB instance to confirm uploads there are
    unaffected.

ADDITIONAL INFORMATION

  • Has associated issue: Fixes Unable to import CSV into hosted database that enforces primary key ID #37399
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

sadpandajoe and others added 2 commits September 18, 2026 00:41
Uploading a CSV/Excel/columnar file creates the target table via
pandas.DataFrame.to_sql, which never declares a primary key. A MySQL
server configured with sql_require_primary_key = ON rejects that
CREATE TABLE with error 3750, so the upload fails outright.

Build the table with an explicit primary key when the server requires
one: the pandas index column if it is being written, otherwise a
synthesized auto-numbered column. The key has to be part of the initial
CREATE TABLE since MySQL rejects the bare statement itself.

The sql_require_primary_key variable only exists in MySQL 8.0.13+, so
probing it is best-effort -- older servers and the MySQL-compatible
engines that subclass this spec (MariaDB, Doris, StarRocks, OceanBase)
error on the query and fall back to the unchanged base implementation.

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

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.48%. Comparing base (560828d) to head (0b7e0fb).

Files with missing lines Patch % Lines
superset/db_engine_specs/mysql.py 93.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #44411      +/-   ##
==========================================
+ Coverage   76.67%   80.48%   +3.80%     
==========================================
  Files        2935     2933       -2     
  Lines      174904   174796     -108     
  Branches    40609    40576      -33     
==========================================
+ Hits       134110   140677    +6567     
+ Misses      38029    31450    -6579     
+ Partials     2765     2669      -96     
Flag Coverage Δ
hive 37.28% <20.00%> (-0.01%) ⬇️
mysql 56.56% <40.00%> (?)
postgres 56.56% <20.00%> (?)
presto 39.17% <20.00%> (-0.01%) ⬇️
python 84.81% <93.33%> (+7.50%) ⬆️
sqlite 56.28% <20.00%> (?)
unit 76.45% <93.33%> (+0.01%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 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.

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review Agent Run #543d2f

Actionable Suggestions - 2
  • tests/unit_tests/db_engine_specs/test_mysql.py - 2
Additional Suggestions - 2
  • tests/unit_tests/db_engine_specs/test_mysql.py - 2
    • Missing Any import NameError · Line 540-540
      `_enforce_sql_require_primary_key` annotates `conn: Any` and `parameters: Any` (lines 540, 543), but `Any` is not imported in `tests/unit_tests/db_engine_specs/test_mysql.py` — the module header imports only `datetime`, `Decimal`, `SimpleNamespace`, `Optional`, `Mock`, `patch`, etc. The annotation is evaluated at function-definition time, so collecting this file raises `NameError: name 'Any' is not defined` and every test in the module errors. Add `Any` to the typing imports.
    • Line exceeds 88-char lint limit · Line 578-578
      Line 578 is 97 characters, over Superset's 88-char limit configured in `pyproject.toml` (`flake8`/`RUF` section), so the repo's lint CI will fail on this changed line. The sibling query at lines 622-624 already wraps `conn.execute(sa.text(...))` across lines — apply the same wrapping here.
Review Details
  • Files reviewed - 2 · Commit Range: b7d5740..0b7e0fb
    • superset/db_engine_specs/mysql.py
    • tests/unit_tests/db_engine_specs/test_mysql.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment on lines +528 to +534
import pandas as pd
import sqlalchemy as sa
from sqlalchemy import create_engine, event
from sqlalchemy.exc import OperationalError

from superset.db_engine_specs.mysql import MySQLEngineSpec
from superset.sql.parse import Table

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Inline imports violate BITO 12745

The three new tests each repeat the same inline imports (pandas, sqlalchemy, create_engine, MySQLEngineSpec/MariaDBEngineSpec, Table) inside the function bodies (lines 528-534, 597-602, 642-647). None of these modules is circular with this test file — sibling tests in this repo import pandas/sqlalchemy at module level — so per BITO rule 12745 these belong in the module header. This also removes the triplicated import blocks.

Code Review Run #543d2f


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

mock_get_engine.return_value.__exit__.return_value = False

MySQLEngineSpec.df_to_sql(
database=Mock(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Untyped Mock locals BITO 13153

The database=Mock() arguments at lines 571, 615, and 657 bind unannotated local-free literals; per BITO rule 13153, mock variables/dataset values in test files must carry explicit type annotations (e.g. database: Mock = Mock()). The sibling mock_get_engine bindings are also unannotated. This matches the rule's stated enforcement during code review.

Code Review Run #543d2f


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved correctness and compatibility issues remain in primary-key detection and key-generation behavior.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes MySQL file uploads when sql_require_primary_key is enabled by creating upload tables with a primary key.

Changes:

  • Adds MySQL primary-key requirement detection and custom table creation.
  • Uses the DataFrame index or a synthesized key.
  • Adds unit tests for required and unsupported server settings.
File summaries
File Summary
tests/unit_tests/db_engine_specs/test_mysql.py Tests primary-key creation and fallback behavior.
superset/db_engine_specs/mysql.py Implements MySQL-specific primary-key handling for uploads.
Review details

Suppressed comments (4)

superset/db_engine_specs/mysql.py:606

  • A newly created table with the default RangeIndex gets primary-key values 0..n-1 here. A later upload with already_exists=append and dataframe_index=True writes a fresh 0..n-1 index, so the unchanged append path will hit duplicate-primary-key errors. The generated key strategy needs to remain append-safe (for example, a dedicated auto-increment key or an explicit uniqueness/offset policy) and should be covered by an append regression test.
                    primary_key = index_label or df.index.name or "index"

superset/db_engine_specs/mysql.py:602

  • The engine context is entered even for if_exists='append', then this method delegates to super().df_to_sql, which opens get_engine again. That means every append creates and tears down an unnecessary second engine context (including SSH/OAuth/prequery setup), contrary to the unchanged append path described in the PR. Delegate append before opening this context.
        with cls.get_engine(
            database,
            catalog=table.catalog,
            schema=table.schema,
        ) as engine:
            creating_table = to_sql_kwargs.get("if_exists", "fail") != "append"
            if creating_table and cls._requires_primary_key(engine):

superset/db_engine_specs/mysql.py:606

  • On MySQL, a single integer primary-key column is implicitly AUTO_INCREMENT. The default upload index starts at 0, so inserting [0, 1, 2] into this key (with NO_AUTO_VALUE_ON_ZERO off) can turn 0 into the next generated value and then collide with the explicit 1 in the same multi-row insert. The SQLite test cannot exercise this dialect behavior; avoid auto-increment semantics for a user-provided index key or use a compatible key strategy, and add MySQL-specific coverage.
                    primary_key = index_label or df.index.name or "index"

superset/db_engine_specs/mysql.py:623

  • When keys is supplied, pandas SQLTable names the PrimaryKeyConstraint as <table_name>_pk. The upload schema allows long names, and a 64-character table name is valid in MySQL, but the resulting 67-character constraint name exceeds MySQL's 64-character identifier limit, so this path rejects otherwise valid uploads. Use a bounded constraint name or avoid pandas' hard-coded constraint name.
                        keys=[primary_key],
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

with engine.connect() as conn:
return bool(
conn.exec_driver_sql(
"SELECT @@session.sql_require_primary_key"
index = to_sql_kwargs.get("index", True)
index_label = to_sql_kwargs.get("index_label")
if index:
primary_key = index_label or df.index.name or "index"
Comment on lines +609 to +611
primary_key = "id"
while primary_key in df.columns:
primary_key = f"_{primary_key}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to import CSV into hosted database that enforces primary key ID

2 participants