From 5162eea08f6c2268e489b221af3351f5ebf40145 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 21 Oct 2026 02:57:50 -0400 Subject: [PATCH 1/2] fix(py): quote column names in SQLAlchemySource statistics queries The min/max/nunique and categorical-value queries interpolated column names unquoted, so a name with a space, capitals, a reserved word or an apostrophe made the query fail; the broad except then dropped the statistics for the whole table. Quote with the engine's identifier preparer, as the DuckDB path does with quote_identifier. Co-Authored-By: Claude Sonnet 5.5 --- pkg-py/CHANGELOG.md | 2 ++ pkg-py/src/querychat/_datasource.py | 25 ++++++++++++++++--------- pkg-py/tests/test_datasource.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 59095ab16..90ffe2c4e 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes +* `QueryChat()` with a SQLAlchemy table no longer loses column ranges and categorical values for columns whose names need quoting (spaces, capital letters, reserved words, apostrophes). The statistics queries now quote column names, as the DuckDB-backed sources already did. + * JSON pins now fall back to `pin_read()` when DuckDB's JSON extension cannot be installed or loaded, including on builds that disable extension loading. Native DuckDB JSON reading is retained when the extension is available. * Fixed SQL code blocks (the visualization footer's "Show Query" editor and the data drawer's SQL editor) rendering illegibly in dark mode — light syntax-highlighted text on a forced light background. The code panel background now adapts to the app's color mode. (#329) diff --git a/pkg-py/src/querychat/_datasource.py b/pkg-py/src/querychat/_datasource.py index 5dee42c6a..b678f3ffa 100644 --- a/pkg-py/src/querychat/_datasource.py +++ b/pkg-py/src/querychat/_datasource.py @@ -665,15 +665,18 @@ def _add_column_stats( categorical_threshold: int, ) -> None: """Add min/max/categories to column metadata using SQL queries.""" + quote = self._engine.dialect.identifier_preparer.quote + # Build aggregate expressions for stats query select_parts = [] for col in columns: + quoted = quote(col.name) if col.kind in ("numeric", "date"): - select_parts.append(f"MIN({col.name}) as {col.name}__min") - select_parts.append(f"MAX({col.name}) as {col.name}__max") + select_parts.append(f"MIN({quoted}) as {quote(col.name + '__min')}") + select_parts.append(f"MAX({quoted}) as {quote(col.name + '__max')}") elif col.kind == "text": select_parts.append( - f"COUNT(DISTINCT {col.name}) as {col.name}__nunique" + f"COUNT(DISTINCT {quoted}) as {quote(col.name + '__nunique')}" ) if not select_parts: @@ -715,12 +718,16 @@ def _add_column_stats( def _fetch_categorical_values(self, columns: list[ColumnMeta]) -> None: """Fetch unique values for categorical columns.""" - union_parts = [ - f"SELECT '{col.name}' as col_name, {col.name} as value " - f"FROM {self.table_name} WHERE {col.name} IS NOT NULL " - f"GROUP BY {col.name}" - for col in columns - ] + quote = self._engine.dialect.identifier_preparer.quote + union_parts = [] + for col in columns: + quoted = quote(col.name) + literal = col.name.replace("'", "''") + union_parts.append( + f"SELECT '{literal}' as col_name, {quoted} as value " + f"FROM {self.table_name} WHERE {quoted} IS NOT NULL " + f"GROUP BY {quoted}" + ) try: query = text(" UNION ALL ".join(union_parts)) diff --git a/pkg-py/tests/test_datasource.py b/pkg-py/tests/test_datasource.py index 1f17389fb..1b9d4e989 100644 --- a/pkg-py/tests/test_datasource.py +++ b/pkg-py/tests/test_datasource.py @@ -584,3 +584,23 @@ def test_sqlalchemy_cleanup_does_not_dispose_caller_owned_engine( dispose.assert_not_called() assert len(source.get_data()) > 0 + + +def test_sqlalchemy_stats_for_columns_that_need_quoting(): + """Column names with spaces, capitals or quotes still get stats.""" + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.execute( + text('CREATE TABLE t ("unit price" REAL, "Group" TEXT, "it\'s" TEXT)') + ) + conn.execute( + text( + "INSERT INTO t VALUES (1.5, 'a', 'x'), (2.0, 'b', 'y'), (9.0, 'a', 'x')" + ) + ) + + schema = SQLAlchemySource(engine, "t").get_schema(categorical_threshold=5) + + assert "- unit price (FLOAT)\n Range: 1.5 to 9.0" in schema + assert "Categorical values: 'a', 'b'" in schema + assert "Categorical values: 'x', 'y'" in schema From 2e9a5b0f98f932e2f7f3fb6d66adeb368164529c Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 21 Oct 2026 04:22:13 -0400 Subject: [PATCH 2/2] docs: add PR number to the changelog entry --- pkg-py/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 90ffe2c4e..4f709a4d8 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes -* `QueryChat()` with a SQLAlchemy table no longer loses column ranges and categorical values for columns whose names need quoting (spaces, capital letters, reserved words, apostrophes). The statistics queries now quote column names, as the DuckDB-backed sources already did. +* `QueryChat()` with a SQLAlchemy table no longer loses column ranges and categorical values for columns whose names need quoting (spaces, capital letters, reserved words, apostrophes). The statistics queries now quote column names, as the DuckDB-backed sources already did. (#332) * JSON pins now fall back to `pin_read()` when DuckDB's JSON extension cannot be installed or loaded, including on builds that disable extension loading. Native DuckDB JSON reading is retained when the extension is available.