diff --git a/sqlit/core/input_context.py b/sqlit/core/input_context.py index ab726946..1532ccbf 100644 --- a/sqlit/core/input_context.py +++ b/sqlit/core/input_context.py @@ -40,3 +40,5 @@ class InputContext: # True when the column under the cursor is referenced by some other table's FK # (i.e. pressing the navigate-referrers key would open the picker). cursor_column_is_foreign_key_target: bool = False + # True when the active results table is showing the transposed (columns-as-rows) view. + results_transposed: bool = False diff --git a/sqlit/core/keymap.py b/sqlit/core/keymap.py index 9d055a29..24525b89 100644 --- a/sqlit/core/keymap.py +++ b/sqlit/core/keymap.py @@ -509,6 +509,7 @@ def _build_action_keys(self) -> list[ActionKeyDef]: ActionKeyDef("tab", "next_result_section", "results"), ActionKeyDef("shift+tab", "prev_result_section", "results"), ActionKeyDef("z", "toggle_result_section", "results"), + ActionKeyDef("T", "toggle_transpose", "results"), ActionKeyDef("escape", "results_filter_close", "results_filter"), ActionKeyDef("enter", "results_filter_accept", "results_filter"), # Value view diff --git a/sqlit/domains/query/ui/mixins/query_results.py b/sqlit/domains/query/ui/mixins/query_results.py index afe9f87e..a5571dda 100644 --- a/sqlit/domains/query/ui/mixins/query_results.py +++ b/sqlit/domains/query/ui/mixins/query_results.py @@ -35,6 +35,7 @@ def _replace_results_table_with_data( ) -> None: """Replace the results table with new data.""" self._cancel_results_render() + self._results_transposed = False container = self.results_area old_table = self.results_table was_focused = old_table.has_focus @@ -321,6 +322,7 @@ async def _display_query_results( self._last_result_columns = columns self._last_result_rows = rows self._last_result_row_count = row_count + self._results_transposed = False table_info = getattr(self, "_pending_result_table_info", None) # Switch to single result mode (in case we were showing stacked results) diff --git a/sqlit/domains/results/state/results_focused.py b/sqlit/domains/results/state/results_focused.py index c61c3f78..b61e6eb7 100644 --- a/sqlit/domains/results/state/results_focused.py +++ b/sqlit/domains/results/state/results_focused.py @@ -16,15 +16,36 @@ def _setup_actions(self) -> None: def has_results(app: InputContext) -> bool: return app.has_results + def not_transposed(app: InputContext) -> bool: + return app.has_results and not app.results_transposed + + def can_toggle_transpose(app: InputContext) -> bool: + return app.has_results and not app.results_filter_active + self.allows("view_cell", has_results, key="v", label="View cell", help="Preview cell (tooltip)") self.allows("view_cell_full", has_results, key="V", label="View full", help="View full cell value") - self.allows("edit_cell", has_results, key="u", label="Update cell", help="Update cell (generate UPDATE)") - self.allows("delete_row", has_results, key="d", label="Delete row", help="Delete row (generate DELETE)") - self.allows("navigate_fk", has_results, key="o", label="FK jump", help="Open row referenced by foreign key") - self.allows("navigate_referrers", has_results, key="O", label="Refs", help="Show tables referencing this row") + self.allows("edit_cell", not_transposed, key="u", label="Update cell", help="Update cell (generate UPDATE)") + self.allows("delete_row", not_transposed, key="d", label="Delete row", help="Delete row (generate DELETE)") + self.allows( + "navigate_fk", not_transposed, key="o", label="FK jump", help="Open row referenced by foreign key" + ) + self.allows( + "navigate_referrers", + not_transposed, + key="O", + label="Refs", + help="Show tables referencing this row", + ) self.allows("results_yank_leader_key", has_results, key="y", label="Copy", help="Copy menu (cell/row/all)") self.allows("clear_results", has_results, key="x", label="Clear", help="Clear results") - self.allows("results_filter", has_results, key="slash", label="Filter", help="Filter rows") + self.allows("results_filter", not_transposed, key="slash", label="Filter", help="Filter rows") + self.allows( + "toggle_transpose", + can_toggle_transpose, + key="T", + label="Transpose", + help="Transpose columns/rows", + ) self.allows("results_cursor_left", has_results) # vim h self.allows("results_cursor_down", has_results) # vim j self.allows("results_cursor_up", has_results) # vim k @@ -83,21 +104,22 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], action="view_cell_full", ) ) - left.append( - DisplayBinding( - key=resolve_display_key("edit_cell") or "u", - label="Update", - action="edit_cell", + if not app.results_transposed: + left.append( + DisplayBinding( + key=resolve_display_key("edit_cell") or "u", + label="Update", + action="edit_cell", + ) ) - ) - left.append( - DisplayBinding( - key=resolve_display_key("delete_row") or "d", - label="Delete", - action="delete_row", + left.append( + DisplayBinding( + key=resolve_display_key("delete_row") or "d", + label="Delete", + action="delete_row", + ) ) - ) - if app.cursor_column_is_foreign_key: + if app.cursor_column_is_foreign_key and not app.results_transposed: left.append( DisplayBinding( key=resolve_display_key("navigate_fk") or "o", @@ -105,7 +127,7 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], action="navigate_fk", ) ) - if app.cursor_column_is_foreign_key_target: + if app.cursor_column_is_foreign_key_target and not app.results_transposed: left.append( DisplayBinding( key=resolve_display_key("navigate_referrers") or "O", @@ -129,11 +151,19 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], ) left.append( DisplayBinding( - key=resolve_display_key("results_filter") or "/", - label="Filter", - action="results_filter", + key=resolve_display_key("toggle_transpose") or "T", + label="Untranspose" if app.results_transposed else "Transpose", + action="toggle_transpose", ) ) + if not app.results_transposed: + left.append( + DisplayBinding( + key=resolve_display_key("results_filter") or "/", + label="Filter", + action="results_filter", + ) + ) if app.stacked_result_count > 1: left.append( DisplayBinding( @@ -154,11 +184,13 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], [ "view_cell", "view_cell_full", + "edit_cell", "delete_row", "navigate_fk", "navigate_referrers", "results_yank_leader_key", "clear_results", + "toggle_transpose", "results_filter", "next_result_section", "prev_result_section", diff --git a/sqlit/domains/results/ui/mixins/results.py b/sqlit/domains/results/ui/mixins/results.py index 487d8a47..7472c632 100644 --- a/sqlit/domains/results/ui/mixins/results.py +++ b/sqlit/domains/results/ui/mixins/results.py @@ -15,6 +15,11 @@ FK_NAVIGATION_DEFAULT_LIMIT = 100 +# Each original row becomes a column in the transposed view, and SqlitDataTable +# (Arrow-backed) isn't built for hundreds of columns, so cap how many rows get +# transposed at once. +MAX_TRANSPOSE_ROWS = 200 + def build_fk_navigation_query( *, @@ -60,6 +65,25 @@ def _strip_table_markup(table: Any, value: Any) -> Any: return value +def _transpose_result_data( + columns: list[str], rows: list[tuple[Any, ...]] +) -> tuple[list[str], list[tuple[Any, ...]]]: + """Swap axes: column names become the "Column" column, each row a "Row N" column. + + Each transposed "Row N" column mixes values from every original column, which + may have different types (e.g. an int id next to a str name) - the Arrow-backed + table requires one type per column, so values are formatted to strings up front + rather than left raw (which Arrow would otherwise silently coerce inconsistently). + """ + + def fmt(value: Any) -> str: + return "NULL" if value is None else str(value) + + header = ["Column"] + [f"Row {i + 1}" for i in range(len(rows))] + transposed = [(col_name, *(fmt(row[col_idx]) for row in rows)) for col_idx, col_name in enumerate(columns)] + return header, transposed + + class ResultsMixin: """Mixin providing results handling functionality.""" @@ -67,6 +91,7 @@ class ResultsMixin: _last_result_rows: list[tuple[Any, ...]] = [] _export_column_indices: list[int] | None = None _last_result_row_count: int = 0 + _results_transposed: bool = False _tooltip_cell_coord: tuple[int, int] | None = None _tooltip_showing: bool = False _tooltip_timer: Any | None = None @@ -339,6 +364,41 @@ def _find_results_section(self: ResultsMixinHost, widget: Any) -> Any | None: current = getattr(current, "parent", None) return None + def _replace_results_section_table_typed( + self: ResultsMixinHost, + section: Any, + old_table: SqlitDataTable, + columns: list[str], + rows: list[tuple[Any, ...]], + ) -> None: + """Replace a stacked result section's table, without results-filter markup escaping. + + Not `_build_results_section_table` (results_filter.py), which stringifies every + cell to highlight filter matches. Used both to build the transposed view (where + `columns`/`rows` already come pre-formatted as strings from `_transpose_result_data`) + and to restore the original table on untranspose (where `rows` are the untouched, + per-cell-typed source data, so numeric/date formatting still applies there). + """ + table_height = min(2 + len(rows), 16) + was_focused = old_table.has_focus + # `old_table.remove()` below doesn't free its widget id synchronously, so a + # fresh id (mirroring `_build_results_table`'s counter) avoids a DuplicateIds + # error when mounting `new_table` while `old_table` is still registered. + self._results_table_counter += 1 + new_table = SqlitDataTable( + id=f"result-table-{section.index}-{self._results_table_counter}", + zebra_stripes=True, + data=rows, + column_labels=columns, + render_markup=False, + null_rep="NULL", + ) + new_table.styles.height = table_height + section.mount(new_table, after=old_table) + old_table.remove() + if was_focused: + new_table.focus() + def _flash_table_yank(self: ResultsMixinHost, table: SqlitDataTable, scope: str) -> None: """Briefly flash the yanked cell(s) to confirm a copy action.""" from sqlit.shared.ui.widgets import flash_widget @@ -410,7 +470,7 @@ def action_view_cell_full(self: ResultsMixinHost) -> None: """View the full value of the selected cell inline.""" from sqlit.shared.ui.widgets import InlineValueView - table, _columns, _rows, _stacked = self._get_active_results_context() + table, _columns, _rows, stacked = self._get_active_results_context() if not table or table.row_count <= 0: self.notify("No results", severity="warning") return @@ -422,9 +482,17 @@ def action_view_cell_full(self: ResultsMixinHost) -> None: self._hide_cell_tooltip(table) - # Get column name if available + # Get column name if available. While transposed, cursor_col runs over the + # displayed Column/Row-N headers, not `_last_result_columns` (the original, + # untransposed source) - read the label straight off the live table instead. column_name = "" - if self._last_result_columns and cursor_col < len(self._last_result_columns): + if self._is_active_results_transposed(table, stacked): + try: + if 0 <= cursor_col < len(table.ordered_columns): + column_name = table.ordered_columns[cursor_col].label.plain + except Exception: + column_name = "" + elif self._last_result_columns and cursor_col < len(self._last_result_columns): column_name = self._last_result_columns[cursor_col] # Show inline value view @@ -524,6 +592,51 @@ def action_vy_all(self: ResultsMixinHost) -> None: except Exception: pass + def _is_active_results_transposed(self: ResultsMixinHost, table: Any, stacked: bool) -> bool: + """Whether the given active results table is currently showing the transposed view.""" + if stacked: + section = self._find_results_section(table) if table else None + return bool(getattr(section, "result_transposed", False)) + return bool(getattr(self, "_results_transposed", False)) + + def action_toggle_transpose(self: ResultsMixinHost) -> None: + """Toggle the transposed (columns-as-rows) view of the active results table.""" + table, columns, rows, stacked = self._get_active_results_context() + if not table or not columns or not rows: + self.notify("No results", severity="warning") + return + + section = self._find_results_section(table) if stacked else None + currently_transposed = self._is_active_results_transposed(table, stacked) + + if currently_transposed: + if stacked and section is not None: + self._replace_results_section_table_typed(section, table, columns, rows) + section.result_transposed = False + else: + self._replace_results_table(columns, rows) + self._results_transposed = False + self._update_footer_bindings() + return + + display_rows = rows + truncated = len(rows) > MAX_TRANSPOSE_ROWS + if truncated: + display_rows = rows[:MAX_TRANSPOSE_ROWS] + + t_columns, t_rows = _transpose_result_data(columns, display_rows) + + if stacked and section is not None: + self._replace_results_section_table_typed(section, table, t_columns, t_rows) + section.result_transposed = True + else: + self._replace_results_table(t_columns, t_rows) + self._results_transposed = True + + if truncated: + self.notify(f"Transposed first {MAX_TRANSPOSE_ROWS} of {len(rows)} rows", severity="warning") + self._update_footer_bindings() + def action_toggle_value_view_mode(self: ResultsMixinHost) -> None: """Toggle between tree and syntax view in the inline value view.""" from sqlit.shared.ui.widgets import InlineValueView @@ -911,10 +1024,16 @@ def _copy_scope_as_format( from sqlit.domains.results.formatters import FORMATS self._clear_leader_pending() - table, columns, rows, _stacked = self._get_active_results_context() + table, columns, rows, stacked = self._get_active_results_context() if not table or table.row_count <= 0: self.notify("No results", severity="warning") return + # "cell" labels the value with `columns[col_idx]` and "row" pairs live row + # values with `columns` - both index/zip against the original untransposed + # source, which is the wrong shape once cursor_col/row run over Column/Row-N. + if scope != "all" and self._is_active_results_transposed(table, stacked): + self.notify("Not available in transposed view", severity="warning") + return fmt = FORMATS[fmt_key] try: @@ -983,6 +1102,12 @@ def _copy_column_values(self: ResultsMixinHost) -> None: if not table or table.row_count <= 0 or not rows: self.notify("No results", severity="warning") return + # The cursor's column index runs over the transposed Column/Row-N axis, + # not `columns` (the original untransposed source), so this would read + # values from the wrong original column. + if self._is_active_results_transposed(table, _stacked): + self.notify("Not available in transposed view", severity="warning") + return try: _row_idx, col_idx = table.cursor_coordinate except Exception: diff --git a/sqlit/domains/shell/app/main.py b/sqlit/domains/shell/app/main.py index 3bc0aded..77b4313a 100644 --- a/sqlit/domains/shell/app/main.py +++ b/sqlit/domains/shell/app/main.py @@ -149,6 +149,7 @@ def __init__( self._autocomplete_just_applied: bool = False self._suppress_autocomplete_once: bool = False self._value_view_active: bool = False + self._results_transposed: bool = False self._last_result_columns: list[str] = [] self._last_result_rows: list[tuple[Any, ...]] = [] self._last_result_row_count: int = 0 @@ -301,10 +302,15 @@ def _get_input_context(self) -> InputContext: # before the widget is mounted, so guard it. active_table_info: dict[str, Any] | None = None cursor_column_name: str | None = None + results_transposed = False try: rt, columns, _rows, stacked = self._get_active_results_context() active_table_info = self._get_active_results_table_info(rt, stacked) if rt else None - if rt and rt.row_count > 0: + results_transposed = self._is_active_results_transposed(rt, stacked) if rt else False + # When transposed, the cursor's column index runs over the "Row N" + # columns, not the original DB columns, so cursor_column_name (and the + # FK lookups below) would be nonsense - skip it. + if rt and rt.row_count > 0 and not results_transposed: col_index = rt.cursor_coordinate.column if 0 <= col_index < len(columns): cursor_column_name = columns[col_index] @@ -354,6 +360,7 @@ def _get_input_context(self) -> InputContext: count_buffer=self._count_buffer, cursor_column_is_foreign_key=cursor_column_is_foreign_key, cursor_column_is_foreign_key_target=cursor_column_is_foreign_key_target, + results_transposed=results_transposed, ) def _debug_screen_label(self, screen: Any | None) -> str: diff --git a/sqlit/shared/ui/protocols/results.py b/sqlit/shared/ui/protocols/results.py index 66c71985..aa5d943f 100644 --- a/sqlit/shared/ui/protocols/results.py +++ b/sqlit/shared/ui/protocols/results.py @@ -35,6 +35,7 @@ class ResultsStateProtocol(Protocol): _tooltip_showing: bool _tooltip_timer: Any | None _value_view_active: bool + _results_transposed: bool MAX_FILTER_MATCHES: int @@ -151,6 +152,17 @@ def _get_active_results_context( def _find_results_section(self, widget: Any) -> Any | None: ... + def _is_active_results_transposed(self, table: Any, stacked: bool) -> bool: + ... + + def action_toggle_transpose(self) -> None: + ... + + def _replace_results_section_table_typed( + self, section: Any, old_table: SqlitDataTable, columns: list[str], rows: list[tuple[Any, ...]] + ) -> None: + ... + def _start_leader_pending(self, prefix: str) -> None: ... diff --git a/sqlit/shared/ui/widgets_stacked_results.py b/sqlit/shared/ui/widgets_stacked_results.py index 36904e24..e41a2108 100644 --- a/sqlit/shared/ui/widgets_stacked_results.py +++ b/sqlit/shared/ui/widgets_stacked_results.py @@ -106,6 +106,7 @@ def __init__( self.result_columns: list[str] = [] self.result_rows: list[tuple] = [] self.result_table_info: dict[str, Any] | None = None + self.result_transposed: bool = False self._content = content if is_error: self.add_class("error") diff --git a/tests/ui/keybindings/test_state_machine.py b/tests/ui/keybindings/test_state_machine.py index 8f29bad6..6b7f9157 100644 --- a/tests/ui/keybindings/test_state_machine.py +++ b/tests/ui/keybindings/test_state_machine.py @@ -245,3 +245,84 @@ def test_allowed_when_results_focused(self): leader_menu="leader", ) assert sm.check_action(ctx, "leader_edit_query_in_editor") is True + + +class TestResultsTransposeState: + """toggle_transpose and the actions that are unsafe while transposed.""" + + def test_toggle_transpose_blocked_without_results(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=False) + assert sm.check_action(ctx, "toggle_transpose") is False + + def test_toggle_transpose_allowed_with_results(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True) + assert sm.check_action(ctx, "toggle_transpose") is True + + def test_toggle_transpose_blocked_when_filter_active(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_filter_active=True) + assert sm.check_action(ctx, "toggle_transpose") is False + + def test_results_filter_blocked_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + assert sm.check_action(ctx, "results_filter") is False + + def test_edit_cell_blocked_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + assert sm.check_action(ctx, "edit_cell") is False + + def test_delete_row_blocked_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + assert sm.check_action(ctx, "delete_row") is False + + def test_navigate_fk_blocked_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + assert sm.check_action(ctx, "navigate_fk") is False + + def test_navigate_referrers_blocked_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + assert sm.check_action(ctx, "navigate_referrers") is False + + def test_edit_cell_allowed_when_not_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=False) + assert sm.check_action(ctx, "edit_cell") is True + + def test_footer_shows_transpose_label_when_not_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=False) + left, _ = sm.get_display_bindings(ctx) + binding = next(b for b in left if b.action == "toggle_transpose") + assert binding.label == "Transpose" + + def test_footer_shows_untranspose_label_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + left, _ = sm.get_display_bindings(ctx) + binding = next(b for b in left if b.action == "toggle_transpose") + assert binding.label == "Untranspose" + + def test_footer_hides_edit_delete_filter_when_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=True) + left, _ = sm.get_display_bindings(ctx) + actions = {b.action for b in left} + assert "edit_cell" not in actions + assert "delete_row" not in actions + assert "results_filter" not in actions + + def test_footer_shows_edit_delete_filter_when_not_transposed(self): + sm = UIStateMachine() + ctx = make_context(focus="results", has_results=True, results_transposed=False) + left, _ = sm.get_display_bindings(ctx) + actions = {b.action for b in left} + assert "edit_cell" in actions + assert "delete_row" in actions + assert "results_filter" in actions diff --git a/tests/ui/test_results_transpose.py b/tests/ui/test_results_transpose.py new file mode 100644 index 00000000..dacf0657 --- /dev/null +++ b/tests/ui/test_results_transpose.py @@ -0,0 +1,216 @@ +"""End-to-end pilot tests for the results-grid transpose toggle.""" + +from __future__ import annotations + +import pytest + +from sqlit.domains.shell.app.main import SSMSTUI + +from .mocks import MockConnectionStore, MockSettingsStore, build_test_services, create_test_connection + + +def _make_app() -> SSMSTUI: + connections = [create_test_connection("test-db", "sqlite")] + services = build_test_services( + connection_store=MockConnectionStore(connections), + settings_store=MockSettingsStore({"theme": "tokyo-night"}), + ) + return SSMSTUI(services=services) + + +@pytest.mark.asyncio +async def test_toggle_transpose_swaps_and_restores_grid(): + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await app._display_query_results( + columns=["id", "name"], + rows=[(1, "Ana"), (2, "Luis")], + row_count=2, + truncated=False, + elapsed_ms=0, + ) + await pilot.pause() + + assert app.results_table.row_count == 2 + assert [c.label.plain for c in app.results_table.ordered_columns] == ["id", "name"] + + app.action_toggle_transpose() + await pilot.pause() + + assert app._results_transposed is True + assert [c.label.plain for c in app.results_table.ordered_columns] == ["Column", "Row 1", "Row 2"] + assert app.results_table.row_count == 2 + assert list(app.results_table.get_row_at(0)) == ["id", "1", "2"] + assert list(app.results_table.get_row_at(1)) == ["name", "Ana", "Luis"] + + app.action_toggle_transpose() + await pilot.pause() + + assert app._results_transposed is False + assert [c.label.plain for c in app.results_table.ordered_columns] == ["id", "name"] + assert list(app.results_table.get_row_at(0)) == [1, "Ana"] + assert list(app.results_table.get_row_at(1)) == [2, "Luis"] + + +@pytest.mark.asyncio +async def test_new_query_results_reset_transpose_state(): + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await app._display_query_results( + columns=["id"], rows=[(1,), (2,)], row_count=2, truncated=False, elapsed_ms=0 + ) + await pilot.pause() + + app.action_toggle_transpose() + await pilot.pause() + assert app._results_transposed is True + + await app._display_query_results( + columns=["id"], rows=[(3,), (4,)], row_count=2, truncated=False, elapsed_ms=0 + ) + await pilot.pause() + + assert app._results_transposed is False + assert [c.label.plain for c in app.results_table.ordered_columns] == ["id"] + + +@pytest.mark.asyncio +async def test_toggle_transpose_stacked_mode_per_section(): + from sqlit.domains.query.app.multi_statement import MultiStatementResult, StatementResult + from sqlit.domains.query.app.query_service import QueryResult + from sqlit.shared.ui.widgets_stacked_results import ResultSection, StackedResultsContainer + + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + multi_result = MultiStatementResult( + results=[ + StatementResult( + statement="SELECT 1", + result=QueryResult(columns=["id"], rows=[(1,), (2,)], row_count=2, truncated=False), + success=True, + ), + StatementResult( + statement="SELECT 2", + result=QueryResult(columns=["x"], rows=[(9,)], row_count=1, truncated=False), + success=True, + ), + ] + ) + app._display_multi_statement_results(multi_result, elapsed_ms=0) + await pilot.pause() + + container = app.query_one("#stacked-results", StackedResultsContainer) + sections = list(container.query(ResultSection)) + assert len(sections) == 2 + first_section, second_section = sections + first_section.query_one(app.results_table.__class__).focus() + await pilot.pause() + + app.action_toggle_transpose() + await pilot.pause() + + assert first_section.result_transposed is True + assert second_section.result_transposed is False + + first_table = first_section.query_one(app.results_table.__class__) + assert [c.label.plain for c in first_table.ordered_columns] == ["Column", "Row 1", "Row 2"] + assert list(first_table.get_row_at(0)) == ["id", "1", "2"] + + second_table = second_section.query_one(app.results_table.__class__) + assert [c.label.plain for c in second_table.ordered_columns] == ["x"] + + +@pytest.mark.asyncio +async def test_toggle_transpose_stacked_mode_preserves_focus_across_sections(): + """Each toggle must keep focus on the table just rebuilt, or the next `T` press + silently targets whatever `_get_active_results_context()` falls back to instead + of the section the user is actually looking at.""" + from sqlit.domains.query.app.multi_statement import MultiStatementResult, StatementResult + from sqlit.domains.query.app.query_service import QueryResult + from sqlit.shared.ui.widgets_stacked_results import ResultSection, StackedResultsContainer + + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + multi_result = MultiStatementResult( + results=[ + StatementResult( + statement="SELECT 1", + result=QueryResult(columns=["id"], rows=[(1,), (2,)], row_count=2, truncated=False), + success=True, + ), + StatementResult( + statement="SELECT 2", + result=QueryResult(columns=["x"], rows=[(9,)], row_count=1, truncated=False), + success=True, + ), + ] + ) + app._display_multi_statement_results(multi_result, elapsed_ms=0) + await pilot.pause() + + container = app.query_one("#stacked-results", StackedResultsContainer) + first_section, second_section = list(container.query(ResultSection)) + + second_section.query_one(app.results_table.__class__).focus() + await pilot.pause() + + app.action_toggle_transpose() + await pilot.pause() + app.action_toggle_transpose() + await pilot.pause() + + assert second_section.result_transposed is False + assert first_section.result_transposed is False + + +@pytest.mark.asyncio +async def test_view_cell_full_uses_transposed_column_label(): + """`action_view_cell_full` must label the value with the column actually under + the cursor. While transposed, `_last_result_columns[cursor_col]` (the original, + untransposed column list) is the wrong list to index - the cursor runs over + Column/Row-N instead.""" + from sqlit.shared.ui.widgets import InlineValueView + + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + await app._display_query_results( + columns=["id", "name"], rows=[(1, "Ana"), (2, "Luis")], row_count=2, truncated=False, elapsed_ms=0 + ) + await pilot.pause() + + app.action_toggle_transpose() + await pilot.pause() + + app.action_view_cell_full() + await pilot.pause() + + value_view = app.query_one("#value-view", InlineValueView) + assert value_view._column_name == "Column" + + +@pytest.mark.asyncio +async def test_toggle_transpose_with_no_results_is_noop(): + app = _make_app() + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + + app.action_toggle_transpose() + await pilot.pause() + + assert app._results_transposed is False diff --git a/tests/unit/test_results_transpose.py b/tests/unit/test_results_transpose.py new file mode 100644 index 00000000..398ec307 --- /dev/null +++ b/tests/unit/test_results_transpose.py @@ -0,0 +1,218 @@ +"""Tests for the results-grid transpose (columns-as-rows) toggle.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from sqlit.domains.results.ui.mixins.results import MAX_TRANSPOSE_ROWS, ResultsMixin, _transpose_result_data + + +class _FakeTable: + def __init__(self, row_count: int, cursor_coordinate: tuple[int, int] = (0, 0)) -> None: + self.row_count = row_count + self.cursor_coordinate = cursor_coordinate + + @property + def cursor_row(self) -> int: + return self.cursor_coordinate[0] + + def get_cell_at(self, _coord: Any) -> Any: + return "cell-value" + + def get_row_at(self, _row: int) -> list[Any]: + return ["row-value"] + + +class _FakeApp(ResultsMixin): + """Just enough harness to exercise action_toggle_transpose without Textual.""" + + def __init__( + self, + columns: list[str], + rows: list[tuple[Any, ...]], + *, + stacked: bool = False, + section: Any = None, + table_row_count: int | None = None, + ) -> None: + self._columns = columns + self._rows = rows + self._stacked = stacked + self._section = section + self._table = _FakeTable(table_row_count if table_row_count is not None else len(rows)) + self._results_transposed = False + self.notifications: list[tuple[str, str]] = [] + self.replace_calls: list[tuple[list[str], list[tuple[Any, ...]]]] = [] + self.typed_replace_calls: list[tuple[list[str], list[tuple[Any, ...]]]] = [] + self.footer_updates = 0 + self.clipboard_text: str | None = None + + def _get_active_results_context(self) -> tuple[Any, list[str], list[tuple[Any, ...]], bool]: + return self._table, list(self._columns), list(self._rows), self._stacked + + def _find_results_section(self, _widget: Any) -> Any | None: + return self._section + + def _replace_results_table(self, columns: list[str], rows: list[tuple[Any, ...]]) -> None: + self.replace_calls.append((columns, rows)) + + def _replace_results_section_table_typed( + self, section: Any, _old_table: Any, columns: list[str], rows: list[tuple[Any, ...]] + ) -> None: + assert section is self._section + self.typed_replace_calls.append((columns, rows)) + + def notify(self, message: str, severity: str = "information", **_kwargs: Any) -> None: + self.notifications.append((message, severity)) + + def _update_footer_bindings(self) -> None: + self.footer_updates += 1 + + def _copy_text(self, text: str) -> bool: + self.clipboard_text = text + return True + + def _flash_table_yank(self, *_args: Any, **_kwargs: Any) -> None: + pass + + def _clear_leader_pending(self) -> None: + pass + + +class TestTransposeResultData: + def test_swaps_columns_and_rows(self) -> None: + header, transposed = _transpose_result_data(["id", "name"], [(1, "Ana"), (2, "Luis")]) + assert header == ["Column", "Row 1", "Row 2"] + assert transposed == [("id", "1", "2"), ("name", "Ana", "Luis")] + + def test_formats_none_as_null_string(self) -> None: + # A "Row N" column mixes values from every original column (which may have + # different types), and the Arrow-backed table requires one type per column, + # so cells are formatted to strings up front - including NULLs. + header, transposed = _transpose_result_data(["id", "note"], [(1, None)]) + assert header == ["Column", "Row 1"] + assert transposed == [("id", "1"), ("note", "NULL")] + + +class TestActionToggleTranspose: + def test_toggle_on_rebuilds_table_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + + app.action_toggle_transpose() + + assert app.replace_calls == [ + (["Column", "Row 1", "Row 2"], [("id", "1", "2"), ("name", "Ana", "Luis")]) + ] + assert app._results_transposed is True + assert app.footer_updates == 1 + + def test_toggle_off_restores_original_table(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + + app.action_toggle_transpose() + app.action_toggle_transpose() + + assert app.replace_calls[-1] == (["id", "name"], [(1, "Ana"), (2, "Luis")]) + assert app._results_transposed is False + assert app.footer_updates == 2 + + def test_no_results_notifies_and_does_not_rebuild(self) -> None: + app = _FakeApp([], []) + + app.action_toggle_transpose() + + assert app.replace_calls == [] + assert app.notifications == [("No results", "warning")] + assert app.footer_updates == 0 + + def test_truncates_when_too_many_rows(self) -> None: + columns = ["id"] + rows = [(i,) for i in range(MAX_TRANSPOSE_ROWS + 50)] + app = _FakeApp(columns, rows, table_row_count=len(rows)) + + app.action_toggle_transpose() + + built_columns, built_rows = app.replace_calls[0] + assert built_columns == ["Column"] + [f"Row {i + 1}" for i in range(MAX_TRANSPOSE_ROWS)] + assert built_rows == [("id", *(str(i) for i in range(MAX_TRANSPOSE_ROWS)))] + assert app.notifications == [ + (f"Transposed first {MAX_TRANSPOSE_ROWS} of {len(rows)} rows", "warning") + ] + + def test_stacked_mode_uses_section_flag_and_typed_builder(self) -> None: + section = SimpleNamespace(result_transposed=False) + app = _FakeApp(["id"], [(1,), (2,)], stacked=True, section=section) + + app.action_toggle_transpose() + + assert section.result_transposed is True + assert app.typed_replace_calls == [(["Column", "Row 1", "Row 2"], [("id", "1", "2")])] + assert app.replace_calls == [] + + app.action_toggle_transpose() + + assert section.result_transposed is False + assert app.typed_replace_calls[-1] == (["id"], [(1,), (2,)]) + + +class TestCopyColumnValuesBlockedWhenTransposed: + """`_copy_column_values` (bound to `ryf v`) reads the cursor's column index + against the *original* untransposed columns/rows - while transposed, the + cursor's column index runs over Column/Row-N instead, so the copied values + would come from the wrong original column. It must be blocked, like the + other column-identity actions (edit_cell, delete_row, ...).""" + + def test_blocked_when_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + app._table.cursor_coordinate = (0, 1) + app._results_transposed = True + + app._copy_column_values() + + assert app.clipboard_text is None + assert app.notifications == [("Not available in transposed view", "warning")] + + def test_allowed_when_not_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + app._table.cursor_coordinate = (0, 1) + + app._copy_column_values() + + assert app.clipboard_text is not None + assert "Ana" in app.clipboard_text and "Luis" in app.clipboard_text + + +class TestCopyScopeAsFormatBlockedWhenTransposed: + """`_copy_scope_as_format` (ryf* cell/row) mixes the live cursor position with + the original untransposed `columns` list for cell labels, and pairs a + transposed row's live values with the original column headers for rows - + both mismatched while transposed. `all` is unaffected (no cursor involved).""" + + def test_cell_scope_blocked_when_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + app._table.cursor_coordinate = (0, 1) + app._results_transposed = True + + app._copy_scope_as_format("json", "cell") + + assert app.clipboard_text is None + assert app.notifications == [("Not available in transposed view", "warning")] + + def test_row_scope_blocked_when_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + app._table.cursor_coordinate = (0, 0) + app._results_transposed = True + + app._copy_scope_as_format("json", "row") + + assert app.clipboard_text is None + assert app.notifications == [("Not available in transposed view", "warning")] + + def test_cell_scope_allowed_when_not_transposed(self) -> None: + app = _FakeApp(["id", "name"], [(1, "Ana"), (2, "Luis")]) + app._table.cursor_coordinate = (0, 1) + + app._copy_scope_as_format("json", "cell") + + assert app.clipboard_text is not None