Skip to content

Commit

Permalink
feat/history (#426)
Browse files Browse the repository at this point in the history
* feat: add history viewer

* fix: exit on error for _execute_query worker

* fix: force color system in history test
  • Loading branch information
tconbeer authored Jan 26, 2024
1 parent a7618de commit f679f34
Show file tree
Hide file tree
Showing 20 changed files with 8,953 additions and 8,144 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Features

- Adds a Query History Viewer: press <kbd>F8</kbd> to view a list of up to 500 previously-executed queries ([#259](https://github.com/tconbeer/harlequin/issues/259)).

### Bug Fixes

- The new `--show-files` and `--show-s3` options are now correctly grouped under "Harlequin Options" in `harlequin --help`; installed adapters are now alphabetically sorted.
Expand Down
202 changes: 144 additions & 58 deletions src/harlequin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ErrorModal,
ExportScreen,
HelpScreen,
HistoryScreen,
ResultsViewer,
RunQueryBar,
export_callback,
Expand All @@ -56,6 +57,7 @@
pretty_error_message,
pretty_print_error,
)
from harlequin.history import History


class CatalogCacheLoaded(Message):
Expand All @@ -70,6 +72,13 @@ def __init__(self, connection: HarlequinConnection) -> None:
self.connection = connection


class QueryError(Message):
def __init__(self, query_text: str, error: BaseException) -> None:
super().__init__()
self.query_text = query_text
self.error = error


class QuerySubmitted(Message):
def __init__(self, query_text: str, limit: int | None) -> None:
super().__init__()
Expand All @@ -80,20 +89,25 @@ def __init__(self, query_text: str, limit: int | None) -> None:

class QueriesExecuted(Message):
def __init__(
self, query_count: int, cursors: Dict[str, HarlequinCursor], submitted_at: float
self,
query_count: int,
cursors: Dict[str, tuple[HarlequinCursor, str]],
submitted_at: float,
ddl_queries: list[str],
) -> None:
super().__init__()
self.query_count = query_count
self.cursors = cursors
self.submitted_at = submitted_at
self.ddl_queries = ddl_queries


class ResultsFetched(Message):
def __init__(
self,
cursors: Dict[str, HarlequinCursor],
data: Dict[str, tuple[list[tuple[str, str]], AutoBackendType | None]],
errors: List[BaseException],
cursors: Dict[str, tuple[HarlequinCursor, str]],
data: Dict[str, tuple[list[tuple[str, str]], AutoBackendType | None, str]],
errors: list[tuple[BaseException, str]],
elapsed: float,
) -> None:
super().__init__()
Expand All @@ -116,6 +130,7 @@ class Harlequin(App, inherit_bindings=False):
Binding("f2", "focus_query_editor", "Focus Query Editor", show=False),
Binding("f5", "focus_results_viewer", "Focus Results Viewer", show=False),
Binding("f6", "focus_data_catalog", "Focus Data Catalog", show=False),
Binding("f8", "show_query_history", "History", show=True),
Binding("ctrl+b", "toggle_sidebar", "Toggle Sidebar", show=False),
Binding("f9", "toggle_sidebar", "Toggle Sidebar", show=False),
Binding("f10", "toggle_full_screen", "Toggle Full Screen Mode", show=False),
Expand Down Expand Up @@ -143,6 +158,7 @@ def __init__(
self.adapter = adapter
self.connection_hash = connection_hash
self.catalog: Catalog | None = None
self.history: History | None = None
self.theme = theme
self.show_files = show_files
self.show_s3 = show_s3 or None
Expand Down Expand Up @@ -170,22 +186,29 @@ def __init__(

def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
self.data_catalog = DataCatalog(
type_color=self.app_colors.gray,
show_files=self.show_files,
show_s3=self.show_s3,
)
self.editor_collection = EditorCollection(language="sql", theme=self.theme)
self.results_viewer = ResultsViewer(
max_results=self.max_results,
type_color=self.app_colors.gray,
)
self.run_query_bar = RunQueryBar(
max_results=self.max_results, classes="non-responsive"
)
self.footer = Footer()

# lay out the widgets
with Horizontal():
yield DataCatalog(
type_color=self.app_colors.gray,
show_files=self.show_files,
show_s3=self.show_s3,
)
yield self.data_catalog
with Vertical(id="main_panel"):
yield EditorCollection(language="sql", theme=self.theme)
yield RunQueryBar(
max_results=self.max_results, classes="non-responsive"
)
yield ResultsViewer(
max_results=self.max_results,
type_color=self.app_colors.gray,
)
yield Footer()
yield self.editor_collection
yield self.run_query_bar
yield self.results_viewer
yield self.footer

def push_screen( # type: ignore
self,
Expand All @@ -207,14 +230,17 @@ def pop_screen(self) -> Screen[object]:
self.editor.text_input._restart_blink()
return new_screen

def append_to_history(
self, query_text: str, result_row_count: int, elapsed: float
) -> None:
if self.history is None:
self.history = History.blank()
self.history.append(
query_text=query_text, result_row_count=result_row_count, elapsed=elapsed
)

async def on_mount(self) -> None:
self.data_catalog = self.query_one(DataCatalog)
self.editor_collection = self.query_one(EditorCollection)
self.editor = self.editor_collection.current_editor
self.results_viewer = self.query_one(ResultsViewer)
self.run_query_bar = self.query_one(RunQueryBar)
self.footer = self.query_one(Footer)

self.editor.focus()
self.run_query_bar.checkbox.value = False

Expand All @@ -239,6 +265,9 @@ def build_trees(self, message: CatalogCacheLoaded) -> None:
self.post_message(NewCatalog(catalog=cached_db))
if self.show_s3 is not None:
self.data_catalog.load_s3_tree_from_cache(message.cache)
if self.connection_hash is not None:
history = message.cache.get_history(self.connection_hash)
self.history = history if history is not None else History.blank()

@on(CodeEditor.Submitted)
def submit_query_from_editor(self, message: CodeEditor.Submitted) -> None:
Expand Down Expand Up @@ -353,19 +382,6 @@ async def _handle_worker_error(self, message: Worker.StateChanged) -> None:
error=message.worker.error,
)
self.data_catalog.database_tree.loading = False
elif (
message.worker.name == "_execute_query" and message.worker.error is not None
):
self.run_query_bar.set_responsive()
self.results_viewer.show_table()
header = getattr(
message.worker.error, "title", message.worker.error.__class__.__name__
)
self._push_error_modal(
title="Query Error",
header=header,
error=message.worker.error,
)
elif message.worker.name == "_connect" and message.worker.error is not None:
title = getattr(
message.worker.error,
Expand All @@ -389,6 +405,20 @@ def handle_catalog_error(self, message: DataCatalog.CatalogError) -> None:
error=message.error,
)

@on(QueryError)
def handle_query_error(self, message: QueryError) -> None:
self.append_to_history(
query_text=message.query_text, result_row_count=-1, elapsed=0.0
)
self.run_query_bar.set_responsive()
self.results_viewer.show_table()
header = getattr(message.error, "title", message.error.__class__.__name__)
self._push_error_modal(
title="Query Error",
header=header,
error=message.error,
)

@on(NewCatalog)
def update_tree_and_completers(self, message: NewCatalog) -> None:
self.catalog = message.catalog
Expand All @@ -402,9 +432,14 @@ def fetch_data_or_reset_table(self, message: QueriesExecuted) -> None:
else:
self.run_query_bar.set_responsive()
self.results_viewer.show_table(did_run=message.query_count > 0)
if (n := message.query_count - len(message.cursors)) > 0:
if message.ddl_queries:
n = len(message.ddl_queries)
# at least one DDL statement
elapsed = time.monotonic() - message.submitted_at
for query_text in message.ddl_queries:
self.append_to_history(
query_text=query_text, result_row_count=0, elapsed=elapsed
)
self.notify(
f"{n} DDL/DML {'query' if n == 1 else 'queries'} "
f"executed successfully in {elapsed:.2f} seconds."
Expand All @@ -413,22 +448,31 @@ def fetch_data_or_reset_table(self, message: QueriesExecuted) -> None:

@on(ResultsFetched)
async def load_tables(self, message: ResultsFetched) -> None:
for id_, (cols, data) in message.data.items():
await self.results_viewer.push_table(
for id_, (cols, data, query_text) in message.data.items():
table = await self.results_viewer.push_table(
table_id=id_,
column_labels=cols,
data=data, # type: ignore
)
self.append_to_history(
query_text=query_text,
result_row_count=table.source_row_count,
elapsed=message.elapsed,
)
if message.errors:
for _, query_text in message.errors:
self.append_to_history(
query_text=query_text, result_row_count=-1, elapsed=0.0
)
header = getattr(
message.errors[0],
message.errors[0][0],
"title",
"The database raised an error when running your query:",
)
self._push_error_modal(
title="Query Error",
header=header,
error=message.errors[0],
error=message.errors[0][0],
)
else:
self.notify(
Expand Down Expand Up @@ -521,6 +565,34 @@ def action_export(self) -> None:
ExportScreen(adapter=self.adapter, id="export_screen"), callback
)

def action_show_query_history(self) -> None:
async def history_callback(screen_data: str) -> None:
"""
Insert the selected query into a new buffer.
"""
await self.editor_collection.insert_buffer_with_text(query_text=screen_data)

if self.history is None:
# This should only happen immediately after start-up, before the cache is
# loaded from disk.
self._push_error_modal(
title="History Not Yet Loaded",
header="Harlequin could not load the Query History.",
error=ValueError(
"Your Query History has not yet been loaded. "
"Please wait a moment and try again."
),
)
else:
self.push_screen(
HistoryScreen(
history=self.history,
theme=self.theme,
id="history_screen",
),
history_callback,
)

def action_focus_data_catalog(self) -> None:
if self.sidebar_hidden or self.data_catalog.disabled:
self.action_toggle_sidebar()
Expand All @@ -542,7 +614,10 @@ async def action_quit(self) -> None:
)
write_editor_cache(Cache(focus_index=focus_index, buffers=buffers))
update_catalog_cache(
self.connection_hash, self.catalog, self.data_catalog.s3_tree
connection_hash=self.connection_hash,
catalog=self.catalog,
s3_tree=self.data_catalog.s3_tree,
history=self.history,
)
await super().action_quit()

Expand Down Expand Up @@ -596,27 +671,36 @@ def _load_catalog_cache(self) -> None:
@work(
thread=True,
exclusive=True,
exit_on_error=False,
exit_on_error=True,
group="query_runners",
description="Executing queries.",
)
def _execute_query(self, message: QuerySubmitted) -> None:
if self.connection is None:
return
cursors: Dict[str, HarlequinCursor] = {}
cursors: Dict[str, tuple[HarlequinCursor, str]] = {}
queries = self._split_query_text(message.query_text)
ddl_queries: list[str] = []
for q in queries:
cur = self.connection.execute(q)
if cur is not None:
if message.limit is not None:
cur = cur.set_limit(message.limit)
table_id = f"t{hash(cur)}"
cursors[table_id] = cur
try:
cur = self.connection.execute(q)
except HarlequinQueryError as e:
self.post_message(QueryError(query_text=q, error=e))
break
else:
if cur is not None:
if message.limit is not None:
cur = cur.set_limit(message.limit)
table_id = f"t{hash(cur)}"
cursors[table_id] = (cur, q)
else:
ddl_queries.append(q)
self.post_message(
QueriesExecuted(
query_count=len(queries),
query_count=len(cursors) + len(ddl_queries),
cursors=cursors,
submitted_at=message.submitted_at,
ddl_queries=ddl_queries,
)
)

Expand Down Expand Up @@ -648,17 +732,19 @@ def _push_error_modal(self, title: str, header: str, error: BaseException) -> No
description="fetching data from adapter.",
)
def _fetch_data(
self, cursors: Dict[str, HarlequinCursor], submitted_at: float
self,
cursors: Dict[str, tuple[HarlequinCursor, str]],
submitted_at: float,
) -> None:
errors: List[BaseException] = []
data: Dict[str, tuple[list[tuple[str, str]], AutoBackendType | None]] = {}
for id_, cur in cursors.items():
errors: list[tuple[BaseException, str]] = []
data: Dict[str, tuple[list[tuple[str, str]], AutoBackendType | None, str]] = {}
for id_, (cur, q) in cursors.items():
try:
cur_data = cur.fetchall()
except HarlequinQueryError as e:
errors.append(e)
errors.append((e, q))
else:
data[id_] = (cur.columns(), cur_data)
data[id_] = (cur.columns(), cur_data, q)
elapsed = time.monotonic() - submitted_at
self.post_message(
ResultsFetched(cursors=cursors, data=data, errors=errors, elapsed=elapsed)
Expand Down
Loading

0 comments on commit f679f34

Please sign in to comment.