This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 3b06cadca4c fix(csv-import): add primary key when MySQL requires one
(#44411)
3b06cadca4c is described below
commit 3b06cadca4cffee2f954bf53e1530ecf22a2b0ad
Author: Joe Li <[email protected]>
AuthorDate: Wed Sep 30 12:51:51 2026 -0700
fix(csv-import): add primary key when MySQL requires one (#44411)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Evan Rusackas <[email protected]>
Co-authored-by: Evan Rusackas <[email protected]>
---
superset/db_engine_specs/mysql.py | 148 +++++++++++
tests/unit_tests/db_engine_specs/test_mysql.py | 355 +++++++++++++++++++++++++
2 files changed, 503 insertions(+)
diff --git a/superset/db_engine_specs/mysql.py
b/superset/db_engine_specs/mysql.py
index fdd055d84e3..07db928285c 100644
--- a/superset/db_engine_specs/mysql.py
+++ b/superset/db_engine_specs/mysql.py
@@ -26,6 +26,7 @@ from re import Pattern
from typing import Any, Callable, Optional, TYPE_CHECKING
from urllib import parse
+import pandas as pd
import sqlalchemy as sa
from flask_babel import gettext as __
from sqlalchemy import types
@@ -41,6 +42,7 @@ from sqlalchemy.dialects.mysql import (
TINYINT,
TINYTEXT,
)
+from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import URL
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.util import asbool
@@ -59,6 +61,7 @@ from superset.utils.core import GenericDataType
if TYPE_CHECKING:
from superset.models.core import Database
+ from superset.sql.parse import Table
logger = logging.getLogger(__name__)
@@ -737,3 +740,148 @@ class MySQLEngineSpec(BasicParametersMixin,
BaseEngineSpec):
return False
return True
+
+ @classmethod
+ def _requires_primary_key(cls, engine: Engine) -> bool:
+ """
+ Check whether the connected MySQL server rejects ``CREATE TABLE``
+ statements that do not declare a primary key
+ (``sql_require_primary_key = ON``, MySQL error 3750).
+
+ The variable was introduced in MySQL 8.0.13 and is absent from older
+ MySQL releases and from the MySQL-compatible engines that subclass
+ this spec (MariaDB, Doris, StarRocks, OceanBase), where querying it
+ errors out. Those servers do not enforce the requirement, so treat an
+ unreadable variable as "not required" rather than failing the upload.
+ """
+ try:
+ with engine.connect() as conn:
+ return bool(
+ conn.exec_driver_sql(
+ "SELECT @@session.sql_require_primary_key"
+ ).scalar()
+ )
+ except Exception: # pylint: disable=broad-except
+ logger.debug(
+ "Unable to read @@session.sql_require_primary_key; "
+ "assuming a primary key is not required",
+ exc_info=True,
+ )
+ return False
+
+ @classmethod
+ def df_to_sql(
+ cls,
+ database: Database,
+ table: Table,
+ df: pd.DataFrame,
+ to_sql_kwargs: dict[str, Any],
+ ) -> None:
+ """
+ Upload a DataFrame to MySQL.
+
+ When the target table is being created and the server requires a
+ primary key (``sql_require_primary_key = ON``), the plain
+ ``pandas.DataFrame.to_sql`` call used by the base implementation
+ generates a ``CREATE TABLE`` without one, which MySQL rejects with
+ error 3750. Since MySQL rejects the bare ``CREATE TABLE`` itself,
+ the primary key has to be declared as part of that first statement;
+ it cannot be added afterwards with an ``ALTER TABLE``.
+
+ :param database: The database to upload the data to
+ :param table: The table to upload the data to
+ :param df: The dataframe with data to be uploaded
+ :param to_sql_kwargs: The kwargs to be passed to
+ pandas.DataFrame.to_sql
+ """
+ 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):
+ index = to_sql_kwargs.get("index", True)
+ index_label = to_sql_kwargs.get("index_label")
+ # Column names are compared case-insensitively in MySQL, so
+ # an existing "ID" column collides with a lowercase "id" one
+ # even though Python sees them as different strings.
+ existing_columns = {col.lower() for col in df.columns}
+ index_name = index_label or df.index.name or "index"
+ # A column promoted to PRIMARY KEY must reject duplicate and
+ # NULL values and must not collide with an existing column;
+ # the DataFrame index has none of those guaranteed (a
+ # CSV/Excel upload can point the "Dataframe index" option at
+ # a column that repeats, is missing values, or shares a name
+ # with a real column), so only promote it when it actually
+ # qualifies. Otherwise fall back to the synthesized key
+ # below, and make sure the real index is not also written
+ # out as an extra column.
+ promote_index = (
+ bool(index)
+ and df.index.is_unique
+ and not df.index.hasnans
+ and index_name.lower() not in existing_columns
+ )
+ if promote_index:
+ primary_key = index_name
+ else:
+ if index:
+ logger.warning(
+ "Dropping the DataFrame index instead of "
+ "promoting it to PRIMARY KEY: %s",
+ f"its name {index_name!r} collides with an
existing column"
+ if index_name.lower() in existing_columns
+ else "it is not unique or contains missing values",
+ )
+ df = df.copy()
+ primary_key = "id"
+ while primary_key.lower() in existing_columns:
+ primary_key = f"_{primary_key}"
+ df.insert(0, primary_key, range(1, len(df) + 1))
+
+ with pd.io.sql.SQLDatabase(engine, need_transaction=True) as
pandas_db:
+ pandas_table = pd.io.sql.SQLTable(
+ table.table,
+ pandas_db,
+ frame=df,
+ index=promote_index,
+ if_exists=to_sql_kwargs.get("if_exists", "fail"),
+ index_label=index_label,
+ schema=table.schema,
+ keys=[primary_key],
+ )
+ # pandas declares the key via a table-level
+ # PrimaryKeyConstraint (never Column(primary_key=True)),
+ # but SQLAlchemy's MySQL DDL compiler still treats a lone
+ # integer primary-key column as AUTO_INCREMENT by
+ # default. The values here are explicit (the promoted
+ # index, or the 1..n range synthesized above), not
+ # DB-generated, and pandas' default RangeIndex starts at
+ # 0 -- inserting 0 into an AUTO_INCREMENT column asks
+ # MySQL to generate a value instead of storing 0
+ # literally, colliding with the row whose key is 1.
+ pk_columns = list(pandas_table.table.primary_key.columns)
+ if len(pk_columns) == 1:
+ pk_columns[0].autoincrement = False
+ # MySQL caps identifiers at 64 chars; pandas names the
+ # constraint f"{table_name}_pk", which overflows for a
+ # long table name. MySQL renames PRIMARY KEY constraints
+ # to "PRIMARY" internally regardless of the name given in
+ # DDL, so any short fixed name is safe here.
+ pandas_table.table.primary_key.name = "pk"
+ pandas_table.create()
+ pandas_table.insert(
+ chunksize=to_sql_kwargs.get("chunksize"),
+ # matches the multi-row INSERT the base implementation
+ # asks pandas for on dialects that support it
+ method="multi"
+ if (
+ engine.dialect.supports_multivalues_insert
+ or cls.supports_multivalues_insert
+ )
+ else None,
+ )
+ return
+
+ super().df_to_sql(database, table, df, to_sql_kwargs)
diff --git a/tests/unit_tests/db_engine_specs/test_mysql.py
b/tests/unit_tests/db_engine_specs/test_mysql.py
index 287ca8b7d48..909fabb6615 100644
--- a/tests/unit_tests/db_engine_specs/test_mysql.py
+++ b/tests/unit_tests/db_engine_specs/test_mysql.py
@@ -22,6 +22,7 @@ from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import Mock, patch
+import pandas as pd
import pytest
from sqlalchemy import column, types
from sqlalchemy.dialects.mysql import (
@@ -36,6 +37,7 @@ from sqlalchemy.dialects.mysql import (
TINYINT,
TINYTEXT,
)
+from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url, URL # noqa: F401
from superset.constants import TimeGrain
@@ -512,6 +514,359 @@ def test_extended_aggregation_func_median_unsupported()
-> None:
assert spec.get_extended_aggregation_func("MEDIAN") is None
+def _upload_requiring_primary_key(
+ engine: Engine,
+ df: pd.DataFrame,
+ *,
+ table_name: str = "my_table",
+ index: bool = False,
+) -> None:
+ """
+ Run ``MySQLEngineSpec.df_to_sql`` against ``engine`` with the server
+ reporting ``sql_require_primary_key = ON`` -- the setup every
+ primary-key test below shares.
+ """
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+ from superset.sql.parse import Table
+
+ with (
+ patch.object(MySQLEngineSpec, "get_engine") as mock_get_engine,
+ patch.object(MySQLEngineSpec, "_requires_primary_key",
return_value=True),
+ ):
+ mock_get_engine.return_value.__enter__.return_value = engine
+ mock_get_engine.return_value.__exit__.return_value = False
+
+ MySQLEngineSpec.df_to_sql(
+ database=Mock(),
+ table=Table(table=table_name),
+ df=df,
+ to_sql_kwargs={"if_exists": "fail", "index": index},
+ )
+
+
+def test_df_to_sql_adds_primary_key_when_mysql_requires_one() -> None:
+ """
+ apache/superset#37399: uploading a CSV/Excel/columnar file creates the
+ table via ``pandas.DataFrame.to_sql``, which never declares a primary
+ key. A MySQL server configured with ``sql_require_primary_key = ON``
+ rejects such a ``CREATE TABLE`` with error 3750.
+
+ No live MySQL server is available in this environment, so an in-memory
+ SQLite engine stands in for the target database, with a SQLAlchemy event
+ listener reproducing MySQL's documented enforcement: any executed
+ ``CREATE TABLE`` lacking a primary key raises the same error 3750 seen
+ in the issue.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine, event
+ from sqlalchemy.exc import OperationalError
+
+ engine = create_engine("sqlite://")
+
+ @event.listens_for(engine, "before_cursor_execute")
+ def _enforce_sql_require_primary_key( # pylint: disable=unused-argument
+ conn: Any,
+ cursor: Any,
+ statement: str,
+ parameters: Any,
+ context: Any,
+ executemany: bool,
+ ) -> None:
+ if (
+ statement.strip().upper().startswith("CREATE TABLE")
+ and "PRIMARY KEY" not in statement.upper()
+ ):
+ raise OperationalError(
+ statement,
+ parameters,
+ Exception(
+ '(3750, "Unable to create or change a table without a '
+ "primary key, when the system variable "
+ "'sql_require_primary_key' is set.\")"
+ ),
+ )
+
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
+
+ _upload_requiring_primary_key(engine, df)
+
+ with engine.connect() as conn:
+ rows = conn.execute(sa.text("SELECT a, b FROM my_table ORDER BY
a")).fetchall()
+ assert [tuple(row) for row in rows] == [(1, "x"), (2, "y"), (3, "z")]
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"], (
+ "expected the created table to have a primary key so MySQL's "
+ "sql_require_primary_key would accept the CREATE TABLE"
+ )
+
+
+def test_df_to_sql_promotes_pandas_index_to_primary_key() -> None:
+ """
+ The literal scenario reported in apache/superset#37399: the "Dataframe
+ index" upload option is enabled, so pandas writes an extra ``index``
+ column -- the reporter's CREATE TABLE showed this column present but
+ never marked PRIMARY KEY. When MySQL requires one, that pandas index
+ column should be promoted to the primary key instead of adding a
+ redundant second column.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
+
+ _upload_requiring_primary_key(engine, df, index=True)
+
+ with engine.connect() as conn:
+ rows = conn.execute(
+ sa.text('SELECT "index", a, b FROM my_table ORDER BY "index"')
+ ).fetchall()
+ assert [tuple(row) for row in rows] == [(0, 1, "x"), (1, 2, "y"), (2, 3,
"z")]
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"] == ["index"], (
+ "expected the pandas index column to become the primary key, not "
+ "an extra synthesized column"
+ )
+
+
+def test_df_to_sql_disables_autoincrement_on_synthesized_primary_key() -> None:
+ """
+ pandas declares the primary key via a table-level PrimaryKeyConstraint
+ (never Column(primary_key=True)), but SQLAlchemy's MySQL DDL compiler
+ still infers AUTO_INCREMENT for a lone integer primary-key column by
+ default. The synthesized key values here are explicit (the promoted
+ index, or the 1..n range for the synthesized "id" column), not
+ DB-generated -- and pandas' default RangeIndex starts at 0, so inserting
+ 0 into an AUTO_INCREMENT column asks MySQL to generate a value instead
+ of storing 0 literally, colliding with the row whose key is 1.
+
+ No live MySQL server is available in this environment; compile the
+ exact table ``SQLTable.create()`` would hand to MySQL against
+ SQLAlchemy's MySQL dialect directly and assert AUTO_INCREMENT never
+ appears.
+ """
+ from sqlalchemy import create_engine
+ from sqlalchemy.dialects import mysql
+ from sqlalchemy.schema import CreateTable
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
+ captured_tables: list[Any] = []
+
+ def _capture_instead_of_create(self: Any) -> None:
+ captured_tables.append(self.table)
+
+ with (
+ patch.object(pd.io.sql.SQLTable, "create", _capture_instead_of_create),
+ patch.object(pd.io.sql.SQLTable, "insert"),
+ ):
+ _upload_requiring_primary_key(engine, df)
+
+ assert len(captured_tables) == 1
+ ddl = str(CreateTable(captured_tables[0]).compile(dialect=mysql.dialect()))
+ assert "AUTO_INCREMENT" not in ddl.upper()
+
+
+def test_df_to_sql_falls_back_to_synthesized_key_for_non_unique_index() ->
None:
+ """
+ apache/superset#37399: the "Dataframe index" upload option promotes the
+ DataFrame's index straight to PRIMARY KEY, but a CSV/Excel upload can
+ point that option at a column that repeats values or contains missing
+ ones -- neither of which a primary key can hold. When the index isn't
+ unique (or has NaNs), fall back to the synthesized key used for the
+ ``index=False`` path instead of letting MySQL reject the INSERT with a
+ duplicate-key or NOT-NULL error.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}, index=[0, 0, 1])
+
+ _upload_requiring_primary_key(engine, df, index=True)
+
+ with engine.connect() as conn:
+ rows = conn.execute(
+ sa.text("SELECT id, a, b FROM my_table ORDER BY id")
+ ).fetchall()
+ assert [tuple(row) for row in rows] == [(1, 1, "x"), (2, 2, "y"), (3, 3,
"z")]
+
+ columns = {col["name"] for col in
sa.inspect(engine).get_columns("my_table")}
+ assert "index" not in columns, (
+ "the non-unique pandas index should not also be written as a redundant
column"
+ )
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"] == ["id"], (
+ "expected the synthesized key to be used since the pandas index is not
unique"
+ )
+
+
+def
test_df_to_sql_falls_back_to_synthesized_key_for_index_with_missing_values() ->
(
+ None
+):
+ """
+ The other half of the same guard: an index whose values are all
+ distinct still cannot become a PRIMARY KEY if any of them is missing,
+ because MySQL rejects NULL in a key column (error 1048). Pointing the
+ "Dataframe index" upload option at a column with a blank cell produces
+ exactly that -- ``is_unique`` stays True while ``hasnans`` is True --
+ so only the NaN half of the guard keeps it off the primary key.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}, index=[1.0, 2.0,
None])
+ df.index.name = "key"
+ assert df.index.is_unique, "the fixture's index values must all be
distinct"
+ assert df.index.hasnans, (
+ "the fixture must contain a missing index value to exercise the "
+ "hasnans half of the guard"
+ )
+
+ _upload_requiring_primary_key(engine, df, index=True)
+
+ with engine.connect() as conn:
+ rows = conn.execute(
+ sa.text("SELECT id, a, b FROM my_table ORDER BY id")
+ ).fetchall()
+ assert [tuple(row) for row in rows] == [(1, 1, "x"), (2, 2, "y"), (3, 3,
"z")]
+
+ columns = {col["name"] for col in
sa.inspect(engine).get_columns("my_table")}
+ assert "key" not in columns, (
+ "the NaN-containing pandas index should not also be written as a "
+ "redundant column"
+ )
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"] == ["id"], (
+ "expected the synthesized key to be used since the pandas index "
+ "contains a missing value"
+ )
+
+
+def test_df_to_sql_synthesized_key_avoids_case_insensitive_collision() -> None:
+ """
+ MySQL compares column identifiers case-insensitively, so an existing
+ "ID" column would collide with a lowercase synthesized "id" primary key
+ at the MySQL level even though Python sees them as different strings.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"ID": [10, 20, 30], "b": ["x", "y", "z"]})
+
+ _upload_requiring_primary_key(engine, df)
+
+ columns = [col["name"] for col in
sa.inspect(engine).get_columns("my_table")]
+ assert "_id" in columns, (
+ "the synthesized key should be renamed to avoid the case-"
+ "insensitive collision with the existing 'ID' column"
+ )
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"] == ["_id"]
+
+
+def
test_df_to_sql_promoted_index_name_collision_falls_back_to_synthesized_key() ->
(
+ None
+):
+ """
+ The pandas index is unique and NaN-free, so it would normally be
+ promoted straight to PRIMARY KEY. But its name ("ID") collides
+ case-insensitively with an existing "id" column, and pandas'
+ ``SQLTable`` only rejects exact (case-sensitive) name clashes when
+ resetting the index -- so promoting it would produce a CREATE TABLE
+ with two columns MySQL sees as the same identifier (error 1060). Fall
+ back to the synthesized key instead, the same as a non-unique index.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"id": [1, 2, 3], "b": ["x", "y", "z"]})
+ df.index.name = "ID"
+
+ _upload_requiring_primary_key(engine, df, index=True)
+
+ columns = [col["name"] for col in
sa.inspect(engine).get_columns("my_table")]
+ assert "ID" not in columns, (
+ "the colliding index name should not be promoted to a column"
+ )
+ assert "_id" in columns, (
+ "the synthesized key should be renamed to avoid the case-"
+ "insensitive collision with the existing 'id' column"
+ )
+
+ pk = sa.inspect(engine).get_pk_constraint("my_table")
+ assert pk["constrained_columns"] == ["_id"]
+
+
+def test_df_to_sql_constraint_name_within_mysql_identifier_limit() -> None:
+ """
+ MySQL caps identifiers at 64 characters; pandas names the primary key
+ constraint ``f"{table_name}_pk"``, which overflows for a long (but
+ otherwise valid) MySQL table name and would make the CREATE TABLE fail.
+ MySQL renames PRIMARY KEY constraints to "PRIMARY" internally regardless
+ of the name given in DDL, so a short fixed name is safe to force.
+ """
+ from sqlalchemy import create_engine
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
+ long_table_name = "t" * 64 # valid MySQL length; +"_pk" would overflow
+ captured_tables: list[Any] = []
+
+ def _capture_instead_of_create(self: Any) -> None:
+ captured_tables.append(self.table)
+
+ with (
+ patch.object(pd.io.sql.SQLTable, "create", _capture_instead_of_create),
+ patch.object(pd.io.sql.SQLTable, "insert"),
+ ):
+ _upload_requiring_primary_key(engine, df, table_name=long_table_name)
+
+ assert len(captured_tables) == 1
+ assert len(captured_tables[0].primary_key.name) <= 64
+
+
+def test_df_to_sql_when_server_does_not_expose_sql_require_primary_key() ->
None:
+ """
+ ``sql_require_primary_key`` only exists in MySQL 8.0.13+. Older servers and
+ the MySQL-compatible engines that subclass this spec (MariaDB, Doris,
+ StarRocks, OceanBase) error out on the probe query, and they do not enforce
+ the requirement -- the upload must still go through the plain
+ ``pandas.DataFrame.to_sql`` path rather than propagating that error.
+ """
+ import sqlalchemy as sa
+ from sqlalchemy import create_engine
+
+ from superset.db_engine_specs.mariadb import MariaDBEngineSpec
+ from superset.sql.parse import Table
+
+ engine = create_engine("sqlite://")
+ df = pd.DataFrame({"a": [1, 2, 3]})
+
+ with patch.object(MariaDBEngineSpec, "get_engine") as mock_get_engine:
+ mock_get_engine.return_value.__enter__.return_value = engine
+ mock_get_engine.return_value.__exit__.return_value = False
+
+ MariaDBEngineSpec.df_to_sql(
+ database=Mock(),
+ table=Table(table="my_table"),
+ df=df,
+ to_sql_kwargs={"if_exists": "fail", "index": False},
+ )
+
+ with engine.connect() as conn:
+ rows = conn.execute(sa.text("SELECT a FROM my_table ORDER BY
a")).fetchall()
+ assert [row[0] for row in rows] == [1, 2, 3]
+
+
@pytest.mark.parametrize(
"client_info,expected_mode",
[