Skip to content

Commit

Permalink
feat: cancel queries; impl for duckdb and sqlite (#629)
Browse files Browse the repository at this point in the history
* feat: cancel queries; impl for duckdb and sqlite

* fix: update keys app snapshots
  • Loading branch information
tconbeer authored Aug 19, 2024
1 parent 4cfb0ef commit b22c453
Show file tree
Hide file tree
Showing 9 changed files with 892 additions and 786 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ All notable changes to this project will be documented in this file.

### Features

- For adapters that support canceling queries, Harlequin will now display a "Cancel Query" button while queries are running.
- Two new Actions are available for key bindings: `run_query` (with a global app scope, in addition to the existing `code_editor.run_query` action) and `cancel_query`.
- Adapters may now implement a `connection_id` property to improve Harlequin's ability to persist the data catalog and query history across Harlequin invocations ([#410](https://github.com/tconbeer/harlequin/issues/410)).
- Adapters may now implement a `HarlequinConnection.cancel()` method to cancel all in-flight queries ([#333](https://github.com/tconbeer/harlequin/issues/333)).
- Queries can now be canceled when using the DuckDB or SQLite adapters.

### Bug Fixes

Expand Down
4 changes: 4 additions & 0 deletions src/harlequin/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class Action:
"refresh_catalog": Action(
target=None, action="refresh_catalog", description="Refresh Data Catalog"
),
"run_query": Action(target=None, action="run_query", description="Run Query"),
"cancel_query": Action(
target=None, action="cancel_query", description="Cancel Query"
),
#######################################################
# CodeEditor ACTIONS
#######################################################
Expand Down
10 changes: 10 additions & 0 deletions src/harlequin/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ def execute(self, query: str) -> HarlequinCursor | None:
"""
pass

def cancel(self) -> None:
"""
Cancels/interrupts all in-flight queries previously executed with `execute`.
After implementing this method, set the adapter class variable
IMPLEMENTS_CANCEL to True to show the cancel button in the Harlequin UI.
"""
return None

@abstractmethod
def get_catalog(self) -> Catalog:
"""
Expand Down Expand Up @@ -203,6 +212,7 @@ class variable.
ADAPTER_OPTIONS: list[HarlequinAdapterOption] | None = None
COPY_FORMATS: list[HarlequinCopyFormat] | None = None
"""DEPRECATED. Adapter Copy formats are now ignored by Harlequin."""
IMPLEMENTS_CANCEL = False

@abstractmethod
def __init__(self, conn_str: Sequence[str], **options: Any) -> None:
Expand Down
42 changes: 40 additions & 2 deletions src/harlequin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ def __init__(
self.ddl_queries = ddl_queries


class QueriesCanceled(Message):
pass


class ResultsFetched(Message):
def __init__(
self,
Expand Down Expand Up @@ -229,7 +233,9 @@ def compose(self) -> ComposeResult:
type_color=self.app_colors.gray,
)
self.run_query_bar = RunQueryBar(
max_results=self.max_results, classes="non-responsive"
max_results=self.max_results,
classes="non-responsive",
show_cancel_button=self.adapter.IMPLEMENTS_CANCEL,
)
self.footer = Footer()

Expand Down Expand Up @@ -294,6 +300,11 @@ def submit_query_from_run_query_bar(self, message: Button.Pressed) -> None:
)
)

@on(Button.Pressed, "#cancel_query")
def cancel_query(self, message: Button.Pressed) -> None:
message.stop()
self.action_cancel_query()

@on(Button.Pressed, "#transaction_button")
def handle_transaction_button_press(self, message: Button.Pressed) -> None:
message.stop()
Expand Down Expand Up @@ -534,6 +545,12 @@ def fetch_data_or_reset_table(self, message: QueriesExecuted) -> None:
)
self.update_schema_data()

@on(QueriesCanceled)
def reset_after_cancel(self) -> None:
self.run_query_bar.set_responsive()
self.results_viewer.show_table(did_run=False)
self.notify("Queries canceled.", severity="error")

@on(ResultsFetched)
async def load_tables(self, message: ResultsFetched) -> None:
for id_, (cols, data, query_text) in message.data.items():
Expand Down Expand Up @@ -725,6 +742,14 @@ def action_bind_keymaps(self, *keymap_names: str) -> None:
pretty_print_error(e)
self.exit(return_code=2)

async def action_run_query(self) -> None:
if self.editor is None:
return
await self.editor.action_submit()

def action_cancel_query(self) -> None:
self._cancel_query()

def action_export(self) -> None:
show_export_error = partial(
self._push_error_modal,
Expand Down Expand Up @@ -896,6 +921,19 @@ def _execute_query(self, message: QuerySubmitted) -> None:
)
)

@work(
thread=True,
exclusive=True,
exit_on_error=True,
group="query_cancellers",
description="Cancelling queries.",
)
def _cancel_query(self) -> None:
if self.connection is None or not self.adapter.IMPLEMENTS_CANCEL:
return
self.connection.cancel()
self.post_message(QueriesCanceled())

def _get_query_text(self) -> str:
if self.editor is None:
return ""
Expand Down Expand Up @@ -935,7 +973,7 @@ def _fetch_data(
for id_, (cur, q) in cursors.items():
try:
cur_data = cur.fetchall()
except HarlequinQueryError as e:
except BaseException as e:
errors.append((e, q))
else:
data[id_] = (cur.columns(), cur_data, q)
Expand Down
36 changes: 24 additions & 12 deletions src/harlequin/app.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,30 @@ RunQueryBar Button {
RunQueryBar Button#run_query {
background: $primary;
margin: 0 0 0 4;
}

RunQueryBar Button#run_query:hover {
background: $secondary;
}

RunQueryBar Button#run_query:focus {
text-style: reverse;
&:hover {
background: $secondary;
}
&:focus {
text-style: reverse;
}
&.hidden {
display: none;
}
}

RunQueryBar Button#cancel_query {
background: $error;
margin: 0 0 0 4;
text-style: bold;
&:hover {
background: $secondary;
}
&:focus {
text-style: reverse;
}
&.hidden {
display: none;
}
}

RunQueryBar Button#transaction_button,
Expand Down Expand Up @@ -204,10 +220,6 @@ RunQueryBar Button#rollback_button:hover, {
color: $secondary;
}

RunQueryBar Button#run_query:focus {
text-style: reverse;
}

RunQueryBar Checkbox {
border: none;
padding: 0;
Expand Down
13 changes: 13 additions & 0 deletions src/harlequin/components/run_query_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ def __init__(
classes: Union[str, None] = None,
disabled: bool = False,
max_results: int = 10_000,
show_cancel_button: bool = False,
) -> None:
self.max_results = max_results
self.show_cancel_button = show_cancel_button
super().__init__(
*children, name=name, id=id, classes=classes, disabled=disabled
)
Expand Down Expand Up @@ -49,6 +51,8 @@ def compose(self) -> ComposeResult:
),
)
self.run_button = Button("Run Query", id="run_query")
self.cancel_button = Button("Cancel Query", id="cancel_query")
self.cancel_button.add_class("hidden")
with Horizontal(id="transaction_buttons"):
yield self.transaction_button
yield self.commit_button
Expand All @@ -57,6 +61,7 @@ def compose(self) -> ComposeResult:
yield self.limit_checkbox
yield self.limit_input
yield self.run_button
yield self.cancel_button

def on_mount(self) -> None:
if self.app.is_headless:
Expand Down Expand Up @@ -87,6 +92,14 @@ def limit_value(self) -> int | None:

def set_not_responsive(self) -> None:
self.add_class("non-responsive")
if self.show_cancel_button:
with self.app.batch_update():
self.run_button.add_class("hidden")
self.cancel_button.remove_class("hidden")

def set_responsive(self) -> None:
self.remove_class("non-responsive")
if self.show_cancel_button:
with self.app.batch_update():
self.run_button.remove_class("hidden")
self.cancel_button.add_class("hidden")
8 changes: 8 additions & 0 deletions src/harlequin_duckdb/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def set_limit(self, limit: int) -> HarlequinCursor:
def fetchall(self) -> AutoBackendType | None:
try:
result = self.relation.fetch_arrow_table()
except duckdb.InterruptException:
return None
except duckdb.Error as e:
raise HarlequinQueryError(
msg=str(e), title="DuckDB raised an error when running your query:"
Expand Down Expand Up @@ -105,6 +107,8 @@ def __init__(self, conn: duckdb.DuckDBPyConnection, init_message: str = "") -> N
def execute(self, query: str) -> DuckDbCursor | None:
try:
rel = self.conn.sql(query)
except duckdb.InterruptException:
return None
except duckdb.Error as e:
raise HarlequinQueryError(
msg=str(e),
Expand All @@ -116,6 +120,9 @@ def execute(self, query: str) -> DuckDbCursor | None:
else:
return None

def cancel(self) -> None:
self.conn.interrupt()

def get_catalog(self) -> Catalog:
catalog_items: list[CatalogItem] = []
databases = self._get_databases()
Expand Down Expand Up @@ -267,6 +274,7 @@ def _short_column_type(cls, native_type: DuckDBPyType | str) -> str:
class DuckDbAdapter(HarlequinAdapter):
ADAPTER_OPTIONS = DUCKDB_OPTIONS
COPY_FORMATS = None
IMPLEMENTS_CANCEL = True

def __init__(
self,
Expand Down
29 changes: 23 additions & 6 deletions src/harlequin_sqlite/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ def __init__(self, conn: HarlequinSqliteConnection, cur: sqlite3.Cursor) -> None
self.conn = conn
self.cur = cur
self._limit: int | None = None
_first_row = cur.fetchone()
try:
_first_row = cur.fetchone()
except sqlite3.Error: # maybe canceled query here
_first_row = None
self.has_records = _first_row is not None
self._first_row: tuple[Any, ...] = _first_row or tuple(
[None] * len(cur.description)
Expand All @@ -50,11 +53,21 @@ def set_limit(self, limit: int) -> "HarlequinSqliteCursor":

def fetchall(self) -> AutoBackendType | None:
if self.has_records:
remaining_rows = (
self.cur.fetchall()
if self._limit is None
else self.cur.fetchmany(self._limit - 1)
)
try:
remaining_rows = (
self.cur.fetchall()
if self._limit is None
else self.cur.fetchmany(self._limit - 1)
)
except sqlite3.OperationalError: # maybe canceled here
return None
except sqlite3.Error as e:
raise HarlequinQueryError(
msg=str(e),
title=(
"SQLite raised an error when fetching results for your query:"
),
) from e
return [self._first_row, *remaining_rows]
else:
return None
Expand Down Expand Up @@ -109,6 +122,9 @@ def execute(self, query: str) -> HarlequinSqliteCursor | None:
else:
return None

def cancel(self) -> None:
self.conn.interrupt()

def get_catalog(self) -> Catalog:
catalog_items: list[CatalogItem] = []
databases = self._get_databases()
Expand Down Expand Up @@ -251,6 +267,7 @@ def validate_sql(self, text: str) -> str:
class HarlequinSqliteAdapter(HarlequinAdapter):
ADAPTER_OPTIONS: list[HarlequinAdapterOption] | None = SQLITE_OPTIONS
COPY_FORMATS: list[HarlequinCopyFormat] | None = None
IMPLEMENTS_CANCEL = True

def __init__(
self,
Expand Down
Loading

0 comments on commit b22c453

Please sign in to comment.