Skip to content

Commit bc6edf1

Browse files
committed
Harden incremental results rendering for mixed types
1 parent fddf898 commit bc6edf1

File tree

2 files changed

+104
-18
lines changed

2 files changed

+104
-18
lines changed

sqlit/domains/query/ui/mixins/query_results.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def _schedule_results_render(
151151
rows: list[tuple],
152152
*,
153153
escape: bool,
154+
coerce_to_str_columns: set[int] | None = None,
154155
start_index: int,
155156
row_limit: int,
156157
render_token: int,
@@ -171,6 +172,17 @@ def add_batch() -> None:
171172
if end <= index:
172173
return
173174
batch = rows[index:end]
175+
if coerce_to_str_columns:
176+
coerced: list[tuple] = []
177+
for row in batch:
178+
new_row = []
179+
for col_idx, value in enumerate(row):
180+
if col_idx in coerce_to_str_columns and value is not None:
181+
new_row.append(str(value))
182+
else:
183+
new_row.append(value)
184+
coerced.append(tuple(new_row))
185+
batch = coerced
174186
try:
175187
table.add_rows(batch)
176188
except Exception as exc:
@@ -218,26 +230,46 @@ def _render_results_table_incremental(
218230
has_decimal_in_initial = any(
219231
isinstance(value, Decimal) for row in initial_rows for value in row
220232
)
221-
if has_decimal_in_initial:
222-
decimal_types = self._get_decimal_column_types(rows)
223-
if decimal_types:
224-
from textual_fastdatatable.backend import ArrowBackend
225-
import pyarrow as pa
226-
227-
arrays = []
228-
for idx, _name in enumerate(columns):
229-
values = [row[idx] for row in initial_rows] if initial_rows else []
230-
col_type = decimal_types.get(idx)
231-
if col_type is not None:
232-
arrays.append(pa.array(values, type=col_type))
233-
else:
234-
arrays.append(pa.array(values))
235-
table_backend = ArrowBackend(pa.Table.from_arrays(arrays, names=columns))
236-
table = self._build_results_table(columns, initial_rows, escape=escape, backend=table_backend)
233+
coerce_to_str_columns: set[int] | None = None
234+
try:
235+
if has_decimal_in_initial:
236+
decimal_types = self._get_decimal_column_types(rows)
237+
if decimal_types:
238+
from textual_fastdatatable.backend import ArrowBackend
239+
import pyarrow as pa
240+
241+
arrays = []
242+
coerce_to_str_columns = set()
243+
for idx, _name in enumerate(columns):
244+
values = [row[idx] for row in initial_rows] if initial_rows else []
245+
col_type = decimal_types.get(idx)
246+
try:
247+
if col_type is not None:
248+
arrays.append(pa.array(values, type=col_type))
249+
else:
250+
arrays.append(pa.array(values))
251+
except (TypeError, ValueError, pa.ArrowInvalid, pa.ArrowTypeError):
252+
coerce_to_str_columns.add(idx)
253+
arrays.append(
254+
pa.array(
255+
[str(value) if value is not None else None for value in values],
256+
type=pa.string(),
257+
)
258+
)
259+
table_backend = ArrowBackend(pa.Table.from_arrays(arrays, names=columns))
260+
table = self._build_results_table(columns, initial_rows, escape=escape, backend=table_backend)
261+
else:
262+
table = self._build_results_table(columns, initial_rows, escape=escape)
237263
else:
238264
table = self._build_results_table(columns, initial_rows, escape=escape)
239-
else:
240-
table = self._build_results_table(columns, initial_rows, escape=escape)
265+
except Exception as exc:
266+
try:
267+
self.log.error(f"Results table build failed; falling back to full render: {exc}")
268+
except Exception:
269+
pass
270+
if render_token == getattr(self, "_results_render_token", 0):
271+
self._replace_results_table_with_data(columns, rows, escape=escape)
272+
return
241273
if render_token != getattr(self, "_results_render_token", 0):
242274
return
243275
self._replace_results_table_with_table(table)
@@ -246,6 +278,7 @@ def _render_results_table_incremental(
246278
columns,
247279
rows,
248280
escape=escape,
281+
coerce_to_str_columns=coerce_to_str_columns,
249282
start_index=initial_count,
250283
row_limit=row_limit,
251284
render_token=render_token,

tests/ui/test_results_incremental_rendering.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,56 @@ def _wrapped_replace(self, columns, rows, *, escape):
152152
assert fallback_called["value"] is True
153153

154154
monkeypatch.setattr(SqlitDataTable, "add_rows", original_add_rows)
155+
156+
157+
class _WeirdType:
158+
def __init__(self, value: str) -> None:
159+
self.value = value
160+
161+
def __str__(self) -> str:
162+
return f"Weird({self.value})"
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_incremental_rendering_coerces_unsupported_initial_types():
167+
"""Unsupported types in initial rows should be coerced without forcing fallback."""
168+
connections = [create_test_connection("test-db", "sqlite")]
169+
mock_connections = MockConnectionStore(connections)
170+
mock_settings = MockSettingsStore({"theme": "tokyo-night"})
171+
172+
services = build_test_services(
173+
connection_store=mock_connections,
174+
settings_store=mock_settings,
175+
)
176+
app = SSMSTUI(services=services)
177+
178+
columns = ["id", "amount", "range"]
179+
rows = []
180+
for i in range(201):
181+
rows.append((i + 1, Decimal(f"{i + 1}.25"), _WeirdType(str(i))))
182+
183+
async with app.run_test(size=(120, 40)) as pilot:
184+
await pilot.pause()
185+
186+
fallback_called = {"value": False}
187+
original_replace = app._replace_results_table_with_data
188+
189+
def _wrapped_replace(self, columns, rows, *, escape):
190+
fallback_called["value"] = True
191+
return original_replace(columns, rows, escape=escape)
192+
193+
app._replace_results_table_with_data = MethodType(_wrapped_replace, app)
194+
195+
await app._display_query_results(
196+
columns=columns,
197+
rows=rows,
198+
row_count=len(rows),
199+
truncated=False,
200+
elapsed_ms=0,
201+
)
202+
203+
for _ in range(3):
204+
await pilot.pause(0.05)
205+
206+
assert app.results_table.row_count == len(rows)
207+
assert fallback_called["value"] is False

0 commit comments

Comments
 (0)