diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 59095ab16..4f709a4d8 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. (#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. * 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