Source code for nexusLIMS.tui.apps.db_browser
"""Read-only NexusLIMS database browser TUI.
Provides :class:`NexusLIMSDBApp`, a Textual app that wraps Squall's
``SQLiteClientApp`` to give a stripped-down, read-only view of the database:
* The "Open Database" button and "Execute SQL" tab are removed.
* The Table Viewer tab gains a live filter input and click-to-sort column
headers, both implemented via parameterised SQL queries so they compose
correctly and never expose the database to modification.
Usage
-----
.. code-block:: python
from argparse import Namespace
from nexusLIMS.tui.apps.db_browser import NexusLIMSDBApp
app = NexusLIMSDBApp(Namespace(filepath="/path/to/nexuslims.db"))
app.run()
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import TYPE_CHECKING
from squall import db_utility
from squall.database_structure_tree import DatabaseStructurePane
from squall.squall import SQLiteClientApp
from textual import on
from textual.widgets import DataTable, Input, Select, TabbedContent, TabPane
from textual.widgets._select import NULL as SELECT_BLANK
if TYPE_CHECKING:
from textual.app import ComposeResult
# ---------------------------------------------------------------------------
# Resolve squall's stylesheet at import time so the subclass can reference it
# as a class variable regardless of where *this* module lives on disk.
# ---------------------------------------------------------------------------
import squall as _squall_pkg
_SQUALL_TCSS = str(Path(_squall_pkg.__file__).parent / "squall.tcss")
[docs]
class NexusLIMSTableViewerPane(TabPane):
"""Table viewer with live full-text filter and sortable column headers.
All filtering and sorting is performed via parameterised SQL queries
so they compose naturally and the database is never modified.
"""
DEFAULT_CSS = """
NexusLIMSTableViewerPane {
Select {
margin: 1;
border: round gold;
}
#filter_input {
margin: 1;
border: round $accent;
width: 100%;
}
DataTable {
margin: 1;
border: round gold;
height: 1fr;
}
}
"""
def __init__(self, db_path: Path, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.db_path = db_path
self.tables: list[str] = sorted(db_utility.get_table_names(db_path))
self._filter_text: str = ""
self._sort_col: str | None = None
self._sort_asc: bool = True
self._col_names: list[str] = []
[docs]
def compose(self) -> ComposeResult:
"""Build the table viewer layout."""
yield Select.from_values(
self.tables, id="table_names_select", value=self.tables[0]
)
yield Input(
placeholder="Filter rows… (searches all columns)",
id="filter_input",
)
yield DataTable(id="sqlite_table_data")
[docs]
def on_mount(self) -> None:
"""Load the initial table on mount."""
# Inline style wins over squall's CSS_PATH rule (Input { width: 80% })
self.query_one("#filter_input", Input).styles.width = "100%"
self._load_table()
# ------------------------------------------------------------------
# Event handlers
# ------------------------------------------------------------------
@on(Select.Changed, "#table_names_select")
def _on_table_changed(self, event: Select.Changed) -> None:
"""Reset filter and sort state when the selected table changes."""
if event.value is SELECT_BLANK:
return
self._filter_text = ""
self._sort_col = None
self._sort_asc = True
self.query_one("#filter_input", Input).value = ""
self._load_table()
@on(Input.Changed, "#filter_input")
def _on_filter_changed(self, event: Input.Changed) -> None:
"""Re-query the database whenever the filter text changes."""
self._filter_text = event.value
self._load_table()
@on(DataTable.HeaderSelected, "#sqlite_table_data")
def _on_header_selected(self, event: DataTable.HeaderSelected) -> None:
"""Cycle sort direction on repeated clicks; change column otherwise."""
clicked_col = event.column_key.value # str key we supplied to add_column
if self._sort_col == clicked_col:
if self._sort_asc:
self._sort_asc = False
else:
# Third click on the same column clears sorting
self._sort_col = None
self._sort_asc = True
else:
self._sort_col = clicked_col
self._sort_asc = True
self._load_table()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _load_table(self) -> None:
"""Fetch rows from SQLite and populate the DataTable.
Builds a single parameterised ``SELECT`` statement that combines the
current filter text (``WHERE … LIKE ?``) and sort column
(``ORDER BY … ASC/DESC``) so both features compose correctly.
"""
table_name = str(self.app.query_one("#table_names_select", Select).value)
filter_text = self._filter_text.strip()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Discover column names without fetching any data
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0") # noqa: S608
col_names = [d[0] for d in cursor.description]
self._col_names = col_names
# Build WHERE clause — cast every column to TEXT so LIKE works on
# integers, datetimes, etc.
params: list[str] = []
where = ""
if filter_text:
conditions = [f"CAST({col} AS TEXT) LIKE ?" for col in col_names]
where = "WHERE " + " OR ".join(conditions)
params = [f"%{filter_text}%" for _ in col_names]
# Build ORDER BY clause
order = ""
if self._sort_col and self._sort_col in col_names:
direction = "ASC" if self._sort_asc else "DESC"
order = f"ORDER BY {self._sort_col} {direction}"
sql = f"SELECT * FROM {table_name} {where} {order} LIMIT 1000" # noqa: S608
cursor.execute(sql, params)
rows = cursor.fetchall()
conn.close()
# Build display labels: add ↑/↓ to the sorted column header
display_labels = [self._sort_label(name) for name in col_names]
dt = self.query_one("#sqlite_table_data", DataTable)
dt.clear(columns=True)
for name, label in zip(col_names, display_labels):
dt.add_column(label, key=name)
if rows:
dt.add_rows(rows)
else:
dt.add_rows([tuple("" for _ in col_names)])
dt.cursor_type = "row"
def _sort_label(self, col_name: str) -> str:
"""Return the display label for a column header, with sort indicator."""
if col_name == self._sort_col:
indicator = " ↑" if self._sort_asc else " ↓"
return f"{col_name}{indicator}"
return col_name
[docs]
class NexusLIMSDBApp(SQLiteClientApp):
"""Read-only NexusLIMS database browser.
Extends Squall's ``SQLiteClientApp`` with:
* The "Open Database" button hidden (path always comes from ``NX_DB_PATH``)
* The "Execute SQL" tab removed to prevent accidental modifications
* The Table Viewer replaced with :class:`NexusLIMSTableViewerPane`, which
adds a live filter input and sortable column headers
"""
CSS_PATH = _SQUALL_TCSS
DEFAULT_CSS = "#center { display: none; }"
[docs]
async def update_ui(self, db_file_path: Path) -> None:
"""Load the database into the read-only browser tabs."""
if not Path(db_file_path).exists():
self.notify("Database not found")
return
tabbed_content = self.query_one("#tabbed_ui", TabbedContent)
await tabbed_content.clear_panes()
await tabbed_content.add_pane(
NexusLIMSTableViewerPane(db_file_path, title="Table Viewer")
)
await tabbed_content.add_pane(
DatabaseStructurePane(
db_file_path,
title="Database Structure",
id="db_structure",
)
)
self.title = f"NexusLIMS DB — {db_file_path}"