This is an automated email from the ASF dual-hosted git repository.

rusackas 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 5d771e9f467 fix(dataset): apply the import overwrite permission check 
to identity-matched datasets (#43058)
5d771e9f467 is described below

commit 5d771e9f467d73c03796e20749cde9ccb79392e2
Author: Shaitan <[email protected]>
AuthorDate: Wed Sep 9 05:37:14 2026 +0100

    fix(dataset): apply the import overwrite permission check to 
identity-matched datasets (#43058)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 superset/commands/chart/importers/v1/__init__.py   |   6 +-
 .../commands/dashboard/importers/v1/__init__.py    |   6 +-
 superset/commands/dataset/importers/v1/utils.py    |  46 +++---
 superset/commands/importers/v1/assets.py           |   6 +-
 superset/commands/importers/v1/utils.py            |  54 +++++++
 superset/datasets/schemas.py                       |   8 +-
 .../importers/v1/test_find_existing_for_import.py  | 134 ++++++++++++++++-
 .../datasets/commands/importers/v1/import_test.py  | 165 +++++++++++++++++++++
 tests/unit_tests/datasets/schema_tests.py          |  20 +++
 9 files changed, 421 insertions(+), 24 deletions(-)

diff --git a/superset/commands/chart/importers/v1/__init__.py 
b/superset/commands/chart/importers/v1/__init__.py
index 3f0ea68608a..6340c713f4e 100644
--- a/superset/commands/chart/importers/v1/__init__.py
+++ b/superset/commands/chart/importers/v1/__init__.py
@@ -87,7 +87,11 @@ class ImportChartsCommand(ImportModelsCommand):
             ):
                 config["database_id"] = database_ids[config["database_uuid"]]
                 dataset = import_dataset(config, overwrite=False)
-                datasets[str(dataset.uuid)] = dataset
+                # Key on the bundle's own uuid, which is what the bundle's
+                # charts reference. An import that resolves onto an existing
+                # dataset by physical identity returns a row whose uuid
+                # differs, and keying on that would strand those charts.
+                datasets[str(config["uuid"])] = dataset
 
         # Resolve the creator's default viewers once for the whole bundle
         # rather than once per chart (a membership query each).
diff --git a/superset/commands/dashboard/importers/v1/__init__.py 
b/superset/commands/dashboard/importers/v1/__init__.py
index 787ea74a8e0..22084581f73 100644
--- a/superset/commands/dashboard/importers/v1/__init__.py
+++ b/superset/commands/dashboard/importers/v1/__init__.py
@@ -158,7 +158,11 @@ class ImportDashboardsCommand(ImportModelsCommand):
             ):
                 config["database_id"] = database_ids[config["database_uuid"]]
                 dataset = import_dataset(config, overwrite=overwrite_assets)
-                dataset_info[str(dataset.uuid)] = {
+                # Key on the bundle's own uuid, which is what the bundle's
+                # charts reference. An import that resolves onto an existing
+                # dataset by physical identity returns a row whose uuid
+                # differs, and keying on that would strand those charts.
+                dataset_info[str(config["uuid"])] = {
                     "datasource_id": dataset.id,
                     "datasource_type": dataset.datasource_type,
                     "datasource_name": dataset.table_name,
diff --git a/superset/commands/dataset/importers/v1/utils.py 
b/superset/commands/dataset/importers/v1/utils.py
index 39dd81b476c..cd41d3ec812 100644
--- a/superset/commands/dataset/importers/v1/utils.py
+++ b/superset/commands/dataset/importers/v1/utils.py
@@ -41,7 +41,10 @@ from superset.commands.dataset.exceptions import (
     MultiCatalogDisabledValidationError,
 )
 from superset.commands.exceptions import ImportFailedError
-from superset.commands.importers.v1.utils import find_existing_for_import
+from superset.commands.importers.v1.utils import (
+    find_existing_by_import_identity,
+    find_existing_for_import,
+)
 from superset.connectors.sqla.models import SqlaTable
 from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
 from superset.daos.dataset import DatasetDAO
@@ -293,7 +296,15 @@ def import_dataset(  # noqa: C901
     # implicit-restore re-import is a clean replacement, not a merge.
     is_soft_deleted_match = False
 
-    if existing := find_existing_for_import(SqlaTable, config["uuid"]):
+    existing = find_existing_for_import(SqlaTable, config["uuid"])
+    if not existing and can_write:
+        # A fresh UUID over the (database, catalog, schema, table) identity of
+        # an existing dataset is still matched-and-updated by
+        # ``import_from_dict``, so resolving only the UUID would leave that
+        # update ungated. Resolve the identity the same way the import will.
+        # (Soft-deleted twins are handled in the create branch further down.)
+        existing = find_existing_by_import_identity(SqlaTable, config)
+    if existing:
         if existing.deleted_at is not None:
             # RESTORE path — re-importing a soft-deleted UUID is an implicit
             # restore-with-update, a distinct operation from overwriting an
@@ -520,10 +531,8 @@ def import_dataset(  # noqa: C901
         # raise so the operator can resolve the legacy-NULL-schema
         # ambiguity before re-uploading.
         if is_soft_deleted_match:
-            # ``is_soft_deleted_match`` is only ever set inside the
-            # ``if existing := ...`` walrus block, so ``existing`` is
-            # guaranteed non-None here. The assert pins the invariant
-            # for mypy.
+            # Set only inside the ``if existing:`` block above; the assert
+            # pins that invariant for mypy.
             assert existing is not None
             existing.deleted_at = original_deleted_at
             db.session.flush()
@@ -534,17 +543,20 @@ def import_dataset(  # noqa: C901
                 "manually before retrying."
             ) from ex
         # On the non-soft-deleted overwrite path the legacy contract
-        # holds: return the existing row unmodified. Bypasses the
-        # visibility filter so a soft-deleted duplicate can be located
-        # too — without the bypass the listener would hide the row and
-        # the ``.one()`` would raise NoResultFound, masking the
-        # original MultipleResultsFound.
-        dataset = (
-            db.session.query(SqlaTable)
-            .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {SqlaTable}})
-            .filter_by(uuid=config["uuid"])
-            .one()
-        )
+        # holds: return the existing row unmodified. Prefer the row already
+        # resolved above — on an identity match the incoming uuid belongs to
+        # no row at all, so looking it up again would raise NoResultFound and
+        # mask the original MultipleResultsFound. Falling back to the uuid
+        # lookup bypasses the visibility filter so a soft-deleted duplicate
+        # can still be located, for the same reason.
+        dataset = existing
+        if dataset is None:
+            dataset = (
+                db.session.query(SqlaTable)
+                .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: 
{SqlaTable}})
+                .filter_by(uuid=config["uuid"])
+                .one()
+            )
 
     if dataset.id is None:
         db.session.flush()
diff --git a/superset/commands/importers/v1/assets.py 
b/superset/commands/importers/v1/assets.py
index 25cd33c7d25..c0e92a85d2f 100644
--- a/superset/commands/importers/v1/assets.py
+++ b/superset/commands/importers/v1/assets.py
@@ -135,7 +135,11 @@ class ImportAssetsCommand(BaseCommand):
             if file_name.startswith("datasets/"):
                 config["database_id"] = database_ids[config["database_uuid"]]
                 dataset = import_dataset(config, overwrite=overwrite)
-                dataset_info[str(dataset.uuid)] = {
+                # Key on the bundle's own uuid, which is what the bundle's
+                # charts reference. An import that resolves onto an existing
+                # dataset by physical identity returns a row whose uuid
+                # differs, and keying on that would strand those charts.
+                dataset_info[str(config["uuid"])] = {
                     "datasource_id": dataset.id,
                     "datasource_type": dataset.datasource_type,
                     "datasource_name": dataset.table_name,
diff --git a/superset/commands/importers/v1/utils.py 
b/superset/commands/importers/v1/utils.py
index b8272dfafde..b88f1e9f4a0 100644
--- a/superset/commands/importers/v1/utils.py
+++ b/superset/commands/importers/v1/utils.py
@@ -22,6 +22,7 @@ import yaml
 from flask import current_app
 from marshmallow import fields, Schema, validate
 from marshmallow.exceptions import ValidationError
+from sqlalchemy import and_, or_
 from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.orm import Session
 
@@ -589,6 +590,59 @@ def find_existing_for_import(model_cls: type[Any], uuid: 
str) -> Any | None:
     )
 
 
+def find_existing_by_import_identity(
+    model_cls: type[Any], config: dict[str, Any]
+) -> Any | None:
+    """Look up an active row by the identity an import would bind it to.
+
+    ``ImportExportMixin.import_from_dict`` does not match on ``uuid`` alone: it
+    matches on a disjunction of *every* unique constraint the model declares,
+    dropping the keys the incoming config leaves null. A config carrying a
+    fresh ``uuid`` can therefore still be matched-and-updated onto an existing
+    row through one of those other constraints.
+
+    A caller that gates an overwrite on a ``uuid`` hit alone (see
+    :func:`find_existing_for_import`) would miss that row and let the import
+    update it ungated, so this resolves the same identity the import will,
+    using the model's own constraint metadata rather than a copy of it that can
+    drift. Returns ``None`` for models whose only unique key is ``uuid``.
+
+    Soft-deleted rows are excluded: they are invisible to
+    ``import_from_dict`` too, so they cannot be reached this way. Callers that
+    need them use :meth:`DatasetDAO.find_soft_deleted_logical_duplicate` and
+    friends, which apply the *semantic* identity rules (default-catalog
+    normalization) rather than mirroring the import lookup.
+
+    Results are ordered by primary key: not every logical unique constraint is
+    enforced physically (see ``SqlaTable.__table_args__``), so duplicates can
+    exist and the gate must pick the same row every time.
+    """
+    # pylint: disable=protected-access
+    predicates = []
+    for columns in model_cls._unique_constraints():
+        if "uuid" in columns:
+            continue
+        # Mirror ``import_from_dict``: a key the config leaves null drops out 
of
+        # the predicate instead of being matched as ``IS NULL``.
+        terms = [
+            getattr(model_cls, column) == config[column]
+            for column in sorted(columns)
+            if config.get(column) is not None
+        ]
+        if terms:
+            predicates.append(and_(*terms))
+
+    if not predicates:
+        return None
+
+    return (
+        db.session.query(model_cls)
+        .filter(or_(*predicates))
+        .order_by(model_cls.id)
+        .first()
+    )
+
+
 def clear_soft_deleted_for_import(existing: Any) -> None:
     """Hard-delete a soft-deleted row to free its UUID for re-import.
 
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index a6f30532855..c7beb350c16 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -333,8 +333,12 @@ class ImportV1ColumnSchema(Schema):
     filterable = fields.Boolean()
     expression = fields.String(allow_none=True)
     description = fields.String(allow_none=True)
-    python_date_format = fields.String(allow_none=True)
-    datetime_format = fields.String(allow_none=True)
+    python_date_format = fields.String(
+        allow_none=True, validate=[Length(1, 255), validate_python_date_format]
+    )
+    datetime_format = fields.String(
+        allow_none=True, validate=[Length(1, 100), validate_python_date_format]
+    )
     uuid = fields.UUID(allow_none=True)
 
 
diff --git 
a/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py 
b/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
index e15e5f85af3..f75040ccc51 100644
--- a/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
+++ b/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
@@ -26,18 +26,20 @@ as-is so the importer can decide overwrite/permission before
 from __future__ import annotations
 
 from collections.abc import Generator
+from typing import Any
 
 import pytest
-from sqlalchemy import Column, Integer, String
+from sqlalchemy import Column, Integer, String, UniqueConstraint
 from sqlalchemy.orm import declarative_base
 from sqlalchemy.orm.session import Session
 from sqlalchemy_utils import UUIDType
 
 from superset.commands.importers.v1.utils import (
     clear_soft_deleted_for_import,
+    find_existing_by_import_identity,
     find_existing_for_import,
 )
-from superset.models.helpers import SoftDeleteMixin
+from superset.models.helpers import ImportExportMixin, SoftDeleteMixin
 
 _TestBase = declarative_base()
 
@@ -49,6 +51,40 @@ class _ImportableSoftDeletable(SoftDeleteMixin, _TestBase):  
# type: ignore[misc
     uuid = Column(UUIDType(binary=False), unique=True)
 
 
+class _ImportableWithIdentity(ImportExportMixin, _TestBase):  # type: 
ignore[misc, valid-type]
+    """Stands in for a model with a unique key other than ``uuid``."""
+
+    __tablename__ = "_importable_with_identity_test"
+    __table_args__ = (UniqueConstraint("parent_id", "namespace", "name"),)
+    id = Column(Integer, primary_key=True)
+    parent_id = Column(Integer, nullable=False)
+    namespace = Column(String, nullable=True)
+    name = Column(String, nullable=False)
+    uuid = Column(UUIDType(binary=False), unique=True)
+
+
+class _ImportableUUIDOnly(ImportExportMixin, _TestBase):  # type: ignore[misc, 
valid-type]
+    """Stands in for a model whose only unique key is ``uuid``."""
+
+    __tablename__ = "_importable_uuid_only_test"
+    # Every model reaching import_from_dict declares __table_args__;
+    # _unique_constraints reads it directly.
+    __table_args__: tuple[Any, ...] = ()
+    id = Column(Integer, primary_key=True)
+    name = Column(String, nullable=False)
+    uuid = Column(UUIDType(binary=False), unique=True)
+
+
[email protected]
+def _identity_tables(session: Session) -> Generator[None, None, None]:
+    bind = session.get_bind()
+    _ImportableWithIdentity.__table__.create(bind)
+    _ImportableUUIDOnly.__table__.create(bind)
+    yield
+    _ImportableWithIdentity.__table__.drop(bind)
+    _ImportableUUIDOnly.__table__.drop(bind)
+
+
 @pytest.fixture
 def _synthetic_table(session: Session) -> Generator[None, None, None]:
     _ImportableSoftDeletable.metadata.create_all(session.get_bind())
@@ -178,3 +214,97 @@ def test_find_then_clear_is_the_intended_caller_sequence(
     session.flush()
     assert fresh.id is not None
     assert fresh.deleted_at is None
+
+
[email protected]("_identity_tables")
+def test_find_by_identity_matches_non_uuid_unique_key(
+    app_context: None, session: Session
+) -> None:
+    """A fresh uuid still resolves to the row owning the config's identity."""
+    import uuid as uuid_lib
+
+    row = _ImportableWithIdentity(
+        parent_id=1, namespace="finance", name="salaries", 
uuid=uuid_lib.uuid4()
+    )
+    session.add(row)
+    session.flush()
+
+    config = {
+        "parent_id": 1,
+        "namespace": "finance",
+        "name": "salaries",
+        "uuid": str(uuid_lib.uuid4()),
+    }
+    assert find_existing_by_import_identity(_ImportableWithIdentity, config) 
is row
+
+
[email protected]("_identity_tables")
+def test_find_by_identity_drops_null_config_keys(
+    app_context: None, session: Session
+) -> None:
+    """``import_from_dict`` drops null keys from its predicate, so a config
+    that omits ``namespace`` still reaches a row that stores one. Matching it
+    with an exact ``IS NULL`` instead would miss that row."""
+    import uuid as uuid_lib
+
+    row = _ImportableWithIdentity(
+        parent_id=1, namespace="finance", name="salaries", 
uuid=uuid_lib.uuid4()
+    )
+    session.add(row)
+    session.flush()
+
+    config = {"parent_id": 1, "name": "salaries", "uuid": 
str(uuid_lib.uuid4())}
+    assert find_existing_by_import_identity(_ImportableWithIdentity, config) 
is row
+
+
[email protected]("_identity_tables")
+def test_find_by_identity_returns_none_without_match(
+    app_context: None, session: Session
+) -> None:
+    import uuid as uuid_lib
+
+    session.add(
+        _ImportableWithIdentity(
+            parent_id=1, namespace="finance", name="salaries", 
uuid=uuid_lib.uuid4()
+        )
+    )
+    session.flush()
+
+    config = {"parent_id": 2, "name": "salaries", "uuid": 
str(uuid_lib.uuid4())}
+    assert find_existing_by_import_identity(_ImportableWithIdentity, config) 
is None
+
+
[email protected]("_identity_tables")
+def test_find_by_identity_is_deterministic_across_duplicates(
+    app_context: None, session: Session
+) -> None:
+    """Not every logical unique constraint is enforced physically, so the gate
+    must pick the same row on every run."""
+    import uuid as uuid_lib
+
+    first, second = (
+        _ImportableWithIdentity(
+            parent_id=1, namespace=namespace, name="salaries", 
uuid=uuid_lib.uuid4()
+        )
+        for namespace in ("finance", "hr")
+    )
+    session.add_all([first, second])
+    session.flush()
+
+    config = {"parent_id": 1, "name": "salaries", "uuid": 
str(uuid_lib.uuid4())}
+    assert find_existing_by_import_identity(_ImportableWithIdentity, config) 
is first
+
+
[email protected]("_identity_tables")
+def test_find_by_identity_returns_none_for_uuid_only_model(
+    app_context: None, session: Session
+) -> None:
+    """With no unique key besides ``uuid`` there is no alternate identity to
+    resolve, so the helper must not fall back to matching everything."""
+    import uuid as uuid_lib
+
+    session.add(_ImportableUUIDOnly(name="whatever", uuid=uuid_lib.uuid4()))
+    session.flush()
+
+    config = {"name": "whatever", "uuid": str(uuid_lib.uuid4())}
+    assert find_existing_by_import_identity(_ImportableUUIDOnly, config) is 
None
diff --git a/tests/unit_tests/datasets/commands/importers/v1/import_test.py 
b/tests/unit_tests/datasets/commands/importers/v1/import_test.py
index bebc5687527..d4943ea4df6 100644
--- a/tests/unit_tests/datasets/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/datasets/commands/importers/v1/import_test.py
@@ -2383,6 +2383,171 @@ def 
test_import_restore_blocked_by_active_twin_at_incoming_identity(
     assert existing.deleted_at is not None
 
 
[email protected]("config_catalog", ["public", None])
+def test_import_dataset_identity_collision_requires_overwrite_permission(
+    mocker: MockerFixture, session: Session, config_catalog: str | None
+) -> None:
+    """
+    A config with a fresh UUID but the physical identity of an existing ACTIVE
+    dataset must go through the same overwrite permission gate as a UUID match.
+
+    The ``None`` case matters on its own: ``import_from_dict`` drops null keys
+    from its uniqueness predicate, so a catalog-less config still reaches a
+    dataset stored under a catalog.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "is_editor", return_value=False)
+    mocker.patch.object(security_manager, "is_admin", return_value=False)
+
+    engine = db.session.get_bind()
+    SqlaTable.metadata.create_all(engine)  # pylint: disable=no-member
+
+    database = Database(database_name="my_database", 
sqlalchemy_uri="sqlite://")
+    db.session.add(database)
+    db.session.flush()
+
+    victim = SqlaTable(
+        table_name="salaries",
+        schema="finance",
+        catalog="public",
+        database_id=database.id,
+        uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+        sql="SELECT * FROM finance.salaries",
+    )
+    db.session.add(victim)
+    db.session.flush()
+
+    importer_user = User(
+        username="importer",
+        first_name="at",
+        last_name="tacker",
+        email="[email protected]",
+    )
+
+    # Fresh UUID, but the same physical identity as ``victim``.
+    config = {
+        "table_name": "salaries",
+        "schema": "finance",
+        "catalog": config_catalog,
+        "sql": "SELECT * FROM finance.salaries -- clobbered",
+        "uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+        "metrics": [],
+        "columns": [],
+        "database_uuid": database.uuid,
+        "database_id": database.id,
+    }
+
+    with override_user(importer_user):
+        with pytest.raises(ImportFailedError) as excinfo:
+            import_dataset(copy.deepcopy(config), overwrite=True)
+    assert "overwrite" in str(excinfo.value).lower()
+
+    # The victim dataset must not have been clobbered.
+    assert victim.sql == "SELECT * FROM finance.salaries"
+
+
+def test_import_dataset_identity_collision_overwrites_in_place_for_editor(
+    mocker: MockerFixture, session: Session
+) -> None:
+    """
+    Once the gate passes, an identity collision updates the existing dataset in
+    place rather than creating a twin, and the caller's config is left alone so
+    a bundle importer that re-reads or retries it still sees its own UUID.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "is_editor", return_value=True)
+
+    engine = db.session.get_bind()
+    SqlaTable.metadata.create_all(engine)  # pylint: disable=no-member
+
+    database = Database(database_name="my_database", 
sqlalchemy_uri="sqlite://")
+    db.session.add(database)
+    db.session.flush()
+
+    existing = SqlaTable(
+        table_name="salaries",
+        schema="finance",
+        catalog="public",
+        database_id=database.id,
+        uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+        sql="SELECT * FROM finance.salaries",
+    )
+    db.session.add(existing)
+    db.session.flush()
+
+    config = {
+        "table_name": "salaries",
+        "schema": "finance",
+        "catalog": "public",
+        "sql": "SELECT * FROM finance.salaries -- updated",
+        "uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+        "metrics": [],
+        "columns": [],
+        "database_uuid": database.uuid,
+        "database_id": database.id,
+    }
+
+    imported = import_dataset(config, overwrite=True)
+
+    assert imported.id == existing.id
+    assert imported.sql == "SELECT * FROM finance.salaries -- updated"
+    assert db.session.query(SqlaTable).count() == 1
+    assert config["uuid"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
+
+
+def 
test_import_dataset_identity_collision_with_duplicate_rows_returns_existing(
+    mocker: MockerFixture, session: Session
+) -> None:
+    """
+    A config that omits the catalog matches every row sharing its (database,
+    schema, table), so it can be ambiguous. ``import_from_dict`` then raises
+    ``MultipleResultsFound`` and the legacy fallback returns the existing row
+    unmodified. It must not look the incoming UUID up again: on an identity
+    match that UUID belongs to no row, and the lookup would raise.
+    """
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "is_editor", return_value=True)
+
+    engine = db.session.get_bind()
+    SqlaTable.metadata.create_all(engine)  # pylint: disable=no-member
+
+    database = Database(database_name="my_database", 
sqlalchemy_uri="sqlite://")
+    db.session.add(database)
+    db.session.flush()
+
+    for dataset_uuid, catalog in (
+        ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "public"),
+        ("cccccccc-cccc-cccc-cccc-cccccccccccc", "private"),
+    ):
+        db.session.add(
+            SqlaTable(
+                table_name="salaries",
+                schema="finance",
+                catalog=catalog,
+                database_id=database.id,
+                uuid=dataset_uuid,
+                sql="SELECT * FROM finance.salaries",
+            )
+        )
+    db.session.flush()
+
+    config = {
+        "table_name": "salaries",
+        "schema": "finance",
+        "sql": "SELECT * FROM finance.salaries -- updated",
+        "uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
+        "metrics": [],
+        "columns": [],
+        "database_uuid": database.uuid,
+        "database_id": database.id,
+    }
+
+    dataset = import_dataset(copy.deepcopy(config), overwrite=True)
+
+    assert str(dataset.uuid) == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+    assert dataset.sql == "SELECT * FROM finance.salaries"
+
+
 def test_peer_validating_connection_blocks_rebound_peer() -> None:
     """
     The import fetch validates the connected peer address, so a hostname that
diff --git a/tests/unit_tests/datasets/schema_tests.py 
b/tests/unit_tests/datasets/schema_tests.py
index 23c493de64f..713c09ea80c 100644
--- a/tests/unit_tests/datasets/schema_tests.py
+++ b/tests/unit_tests/datasets/schema_tests.py
@@ -296,3 +296,23 @@ def test_import_v1_metric_schema_parses_currency_string() 
-> None:
     }
     result = schema.load(data)
     assert result["currency"] == {"symbol": "CAD", "symbolPosition": "suffix"}
+
+
+def test_import_v1_column_schema_validates_date_formats() -> None:
+    """ImportV1ColumnSchema validates python_date_format/datetime_format the
+    same way the edit (PUT) schema does, so the import path does not accept a
+    format string the edit path would reject."""
+    from superset.datasets.schemas import ImportV1ColumnSchema
+
+    schema = ImportV1ColumnSchema()
+
+    ok = schema.load(
+        {"column_name": "ds", "python_date_format": "%Y-%m-%d", "is_dttm": 
True}
+    )
+    assert ok["python_date_format"] == "%Y-%m-%d"
+
+    with pytest.raises(ValidationError):
+        schema.load({"column_name": "ds", "python_date_format": "%Y-%m-%d'"})
+
+    with pytest.raises(ValidationError):
+        schema.load({"column_name": "ds", "datetime_format": "not a format'"})

Reply via email to