From 29bece0547ab898c6af6971dde7cad90da80cd52 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 15:34:38 -0600 Subject: [PATCH] fix(py): quote every component of a table name for SQLAlchemy engines EngineBackend.quote() used the preparer's quote() and quote_schema(), which leave an all-lowercase name bare because SQLAlchemy treats it as case-insensitive. Snowflake folds a bare name to upper case, so a table created as "survey" was looked up as SURVEY and every query against it failed. quote_identifier() quotes each component as written, as the R package's dbQuoteIdentifier() does. A lowercase name typed for an upper-case table on Snowflake or Oracle now has to be typed as stored, which is already the case in R. Closes #438 --- pkg-py/src/commons/_backends.py | 17 ++++++----------- pkg-py/tests/test_data_source_engine.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pkg-py/src/commons/_backends.py b/pkg-py/src/commons/_backends.py index 14f1e4266..67a88c2ea 100644 --- a/pkg-py/src/commons/_backends.py +++ b/pkg-py/src/commons/_backends.py @@ -107,18 +107,13 @@ def list_tables(self) -> list[str]: return list(sqlalchemy.inspect(self._engine).get_table_names()) def quote(self, table_id: TableId) -> str: + # quote_identifier() always quotes. quote() leaves an all-lowercase + # name bare, because SQLAlchemy reads it as case-insensitive, and a + # warehouse that folds to upper case then looks up a different name. + # Each component is quoted on its own: one call on "ANALYTICS.PUBLIC" + # names a schema with a dot in it. preparer = self._engine.dialect.identifier_preparer - quoted = preparer.quote(table_id.table) - if table_id.schema is None: - return quoted - # quote_schema() is given one component at a time: handed - # "ANALYTICS.PUBLIC" it produces one identifier containing a dot. - outer = ".".join( - preparer.quote_schema(part) - for part in (table_id.catalog, table_id.schema) - if part is not None - ) - return f"{outer}.{quoted}" + return ".".join(preparer.quote_identifier(part) for part in table_id.parts) def columns(self, table_id: TableId) -> list[dict[str, Any]]: inspector = sqlalchemy.inspect(self._engine) diff --git a/pkg-py/tests/test_data_source_engine.py b/pkg-py/tests/test_data_source_engine.py index 0ee82055c..88dafc49d 100644 --- a/pkg-py/tests/test_data_source_engine.py +++ b/pkg-py/tests/test_data_source_engine.py @@ -207,13 +207,23 @@ def test_the_engine_backend_quotes_each_component_separately() -> None: assert quoted == '"ANALYTICS"."PUBLIC"."ORDERS"' -def test_the_engine_backend_still_quotes_a_two_part_name() -> None: +def test_the_engine_backend_quotes_a_lowercase_name_too() -> None: backend = EngineBackend(sqlalchemy.create_engine("sqlite://")) assert backend.quote(TableId(table="sales", schema="analytics")) == ( - "analytics.sales" + '"analytics"."sales"' ) +def test_a_lowercase_snowflake_table_keeps_its_case() -> None: + # SQLAlchemy treats an all-lowercase name as case-insensitive and leaves + # it bare, which Snowflake folds to upper case: a table created as + # "survey" would be looked up as SURVEY. + pytest.importorskip("snowflake.sqlalchemy") + backend = EngineBackend(sqlalchemy.create_engine("snowflake://u@account/db")) + quoted = backend.quote(TableId(table="survey", schema="LIVE", catalog="COMMONS_CI")) + assert quoted == '"COMMONS_CI"."LIVE"."survey"' + + def test_an_engine_backend_reports_columns_from_its_inspector( engine: sqlalchemy.Engine, ) -> None: