You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/08/18 22:37:47 UTC

(superset) 01/02: test(snowflake): reproduce case-sensitive column quoting

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

sadpandajoe pushed a commit to branch fix-snowflake-case-sensitive-column-quoting
in repository https://gitbox.apache.org/repos/asf/superset.git

commit d5f5c40817c71e346b20098ec62dcac720e9114f
Author: sadpandajoe <jc...@gmail.com>
AuthorDate: Tue Aug 18 22:14:42 2026 +0000

    test(snowflake): reproduce case-sensitive column quoting
---
 RCA.md                                             | 64 ++++++++++++++++++++++
 tests/unit_tests/connectors/sqla/models_test.py    | 30 ++++++++++
 tests/unit_tests/db_engine_specs/test_base.py      |  7 +++
 tests/unit_tests/db_engine_specs/test_snowflake.py | 27 +++++++++
 tests/unit_tests/models/helpers_test.py            | 34 ++++++++++++
 5 files changed, 162 insertions(+)

diff --git a/RCA.md b/RCA.md
new file mode 100644
index 00000000000..974f014b110
--- /dev/null
+++ b/RCA.md
@@ -0,0 +1,64 @@
+# Snowflake Case-Sensitive Column Identifier Quoting
+
+## Symptom
+
+A physical Snowflake table can contain explicitly quoted, case-sensitive lowercase
+columns, for example:
+
+```sql
+CREATE TABLE bug_test ("id" INT, "name" VARCHAR);
+```
+
+Charts backed by such a table can generate references such as `SELECT id, name`.
+Snowflake folds those unquoted identifiers to `ID` and `NAME`, which do not match the
+physical lowercase identifiers, so the query fails with a column-not-found error.
+
+## Root Cause
+
+The Snowflake SQLAlchemy dialect preserves the exact case discovered during
+reflection by returning `quoted_name` values from its name-normalization logic.
+Superset persists reflected table and column names in ordinary string ORM fields,
+however, so the `quoted_name` subtype and its explicit-quoting flag are lost after a
+save and reload.
+
+During query generation, Superset consequently passes a plain string to
+`sqlalchemy.column()`. SQLAlchemy's generic identifier heuristic leaves all-lowercase
+names unquoted and quotes names containing uppercase or special characters. That
+heuristic fits engines that fold unquoted identifiers to lowercase, but it is the
+opposite of Snowflake's unquoted-identifier behavior. Superset can no longer infer
+whether a lowercase string was explicitly quoted when its table was created.
+
+For datasets with `normalize_columns` disabled, that history is not needed: the
+stored value is already the exact physical identifier produced during metadata
+synchronization. Explicitly quoting that exact value is safe and idempotent.
+
+## Fix
+
+The engine-spec interface gains an identifier-preparation hook whose default
+implementation returns the supplied name unchanged, preserving every other engine's
+existing SQLAlchemy auto-quoting behavior.
+
+Snowflake overrides the hook to return an explicitly quoted SQLAlchemy identifier
+when `normalize_columns` is disabled. Physical column construction in the modern and
+legacy dataset query paths uses the hook before creating SQLAlchemy column elements.
+Calculated and custom expressions remain on their existing `literal_column()` path.
+
+When `normalize_columns` is enabled, the Snowflake hook leaves the stored name
+unchanged because normalization is intentionally lossy; force-quoting that display
+form could refer to a different physical identifier.
+
+## Out of Scope
+
+A related symptom affects dataset creation when the table name itself is an
+explicitly quoted lowercase Snowflake identifier. Table, schema, and catalog quoting
+in `FROM` clauses and table-existence checks are separate code paths and are not
+changed by this fix. They require a follow-up focused on physical table identifiers.
+
+## Testing
+
+Offline unit tests compile SQLAlchemy expressions with an ANSI double-quote dialect,
+so no Snowflake connection or Snowflake SQLAlchemy package is required. The tests
+cover the physical-column query paths, Snowflake's quoted and normalized identifier
+behavior, and the no-op default engine-spec behavior. The affected connector,
+query-helper, Snowflake engine-spec, and base engine-spec test modules provide
+regression coverage.
diff --git a/tests/unit_tests/connectors/sqla/models_test.py b/tests/unit_tests/connectors/sqla/models_test.py
index e95ff6c3d2e..4240ea40c45 100644
--- a/tests/unit_tests/connectors/sqla/models_test.py
+++ b/tests/unit_tests/connectors/sqla/models_test.py
@@ -21,6 +21,7 @@ import pandas as pd
 import pytest
 from pytest_mock import MockerFixture
 from sqlalchemy import create_engine
+from sqlalchemy.dialects import sqlite
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm.session import Session
 
@@ -49,6 +50,35 @@ from superset.superset_typing import QueryObjectDict
 from superset.utils import json
 
 
+def test_get_sqla_col_quotes_snowflake_case_sensitive_identifier(
+    mocker: MockerFixture,
+) -> None:
+    """Snowflake physical columns retain their exact reflected case in generated SQL."""
+    from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
+
+    database = Database(database_name="db", sqlalchemy_uri="sqlite://")
+    mocker.patch.object(
+        Database,
+        "get_db_engine_spec",
+        return_value=SnowflakeEngineSpec,
+    )
+    table = SqlaTable(
+        table_name="bug_test",
+        database=database,
+        normalize_columns=False,
+    )
+    tbl_column = TableColumn(column_name="id", type="INTEGER", table=table)
+
+    rendered = str(
+        tbl_column.get_sqla_col().compile(
+            dialect=sqlite.dialect(),
+            compile_kwargs={"literal_binds": True},
+        )
+    )
+
+    assert rendered == '"id"'
+
+
 def test_query_bubbles_errors(mocker: MockerFixture) -> None:
     """
     Test that the `query` method bubbles exceptions correctly.
diff --git a/tests/unit_tests/db_engine_specs/test_base.py b/tests/unit_tests/db_engine_specs/test_base.py
index 01f9d46242f..4578ef58d1c 100644
--- a/tests/unit_tests/db_engine_specs/test_base.py
+++ b/tests/unit_tests/db_engine_specs/test_base.py
@@ -291,6 +291,13 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
     assert BaseEngineSpec.get_default_catalog(database) is None
 
 
+def test_prepare_identifier_returns_name_unchanged() -> None:
+    name = "physical_column"
+
+    assert BaseEngineSpec.prepare_identifier(name, normalize_columns=False) is name
+    assert BaseEngineSpec.prepare_identifier(name, normalize_columns=True) is name
+
+
 def test_quote_table() -> None:
     """
     Test the `quote_table` function.
diff --git a/tests/unit_tests/db_engine_specs/test_snowflake.py b/tests/unit_tests/db_engine_specs/test_snowflake.py
index 3f166b22650..c1d7354e244 100644
--- a/tests/unit_tests/db_engine_specs/test_snowflake.py
+++ b/tests/unit_tests/db_engine_specs/test_snowflake.py
@@ -24,6 +24,7 @@ from unittest import mock
 import pytest
 from pytest_mock import MockerFixture
 from sqlalchemy.engine.url import make_url
+from sqlalchemy.sql import quoted_name
 
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.utils import json
@@ -31,6 +32,32 @@ from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
 from tests.unit_tests.fixtures.common import dttm  # noqa: F401
 
 
+@pytest.mark.parametrize("name", ["lowercase", "UPPERCASE"])
+def test_prepare_identifier_quotes_exact_case_names(name: str) -> None:
+    from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
+
+    identifier = SnowflakeEngineSpec.prepare_identifier(
+        name,
+        normalize_columns=False,
+    )
+
+    assert isinstance(identifier, quoted_name)
+    assert str(identifier) == name
+    assert identifier.quote is True
+
+
+def test_prepare_identifier_preserves_normalized_name() -> None:
+    from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
+
+    name = "lowercase"
+    identifier = SnowflakeEngineSpec.prepare_identifier(
+        name,
+        normalize_columns=True,
+    )
+
+    assert identifier is name
+
+
 @pytest.mark.parametrize(
     "target_type,expected_result",
     [
diff --git a/tests/unit_tests/models/helpers_test.py b/tests/unit_tests/models/helpers_test.py
index a7156b795b9..f100c74287d 100644
--- a/tests/unit_tests/models/helpers_test.py
+++ b/tests/unit_tests/models/helpers_test.py
@@ -4552,6 +4552,40 @@ def test_simple_metric_quotes_column_requiring_quoting(database: Database) -> No
     )
 
 
+def test_convert_tbl_column_quotes_snowflake_case_sensitive_identifier(
+    database: Database,
+    mocker: MockerFixture,
+) -> None:
+    """The chart query-object path quotes exact-case Snowflake physical columns."""
+    from superset.connectors.sqla.models import SqlaTable, TableColumn
+    from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
+    from superset.models.core import Database
+
+    mocker.patch.object(
+        Database,
+        "get_db_engine_spec",
+        return_value=SnowflakeEngineSpec,
+    )
+    table = SqlaTable(
+        database=database,
+        table_name="bug_test",
+        normalize_columns=False,
+    )
+    tbl_column = TableColumn(column_name="name", type="VARCHAR", table=table)
+
+    with database.get_sqla_engine() as engine:
+        dialect = engine.dialect
+
+    rendered = str(
+        table.convert_tbl_column_to_sqla_col(tbl_column).compile(
+            dialect=dialect,
+            compile_kwargs={"literal_binds": True},
+        )
+    )
+
+    assert rendered == '"name"'
+
+
 @pytest.mark.parametrize(
     "native_type",
     [