Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 16 additions & 9 deletions pkg-py/src/querychat/_datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
20 changes: 20 additions & 0 deletions pkg-py/tests/test_datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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