Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: support multiple databases #97

Merged
merged 3 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Features

- The schema viewer (now called Data Catalog) now supports multiple databases.
([#89](https://github.com/tconbeer/harlequin/issues/89) - thank you
[@ywelsch](https://github.com/ywelsch)!)
- Harlequin can be opened with multiple databases by passing them as CLI args:
`harlequin f1.db iris.db`. Databases can also be attached or detached using
SQL executed in Harlequin.
### Bug Fixes

- Reimplements ``ctrl+` `` to format files (regression from 0.0.13)
- Updates textual_textarea, which fixes two bugs when opening files.
- Updates textual_textarea, which fixes two bugs when opening files
and another bug related to scrolling the TextArea.

## [0.0.13] - 2023-06-15

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ pipx install harlequin

## Using Harlequin

From any shell, to open a DuckDB database file:
From any shell, to open one or more DuckDB database files:

```bash
harlequin "path/to/duck.db"
harlequin "path/to/duck.db" "another_duck.db"
```

To open an in-memory DuckDB session, run Harlequin with no arguments:
Expand Down
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.dependencies]
python = "^3.8"
textual = ">=0.22.3,<1.0.0"
textual-textarea = "==0.2.1"
textual-textarea = "==0.2.2"
click = "^8.1.3"
duckdb = "==0.8.0"
shandy-sqlfmt = ">=0.19.0"
Expand Down
8 changes: 5 additions & 3 deletions src/harlequin/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
from typing import List

import click

Expand Down Expand Up @@ -27,10 +28,11 @@
)
@click.argument(
"db_path",
default=":memory:",
nargs=1,
nargs=-1,
type=click.Path(path_type=Path),
)
def harlequin(db_path: Path, read_only: bool, theme: str) -> None:
def harlequin(db_path: List[Path], read_only: bool, theme: str) -> None:
if not db_path:
db_path = [Path(":memory:")]
tui = Harlequin(db_path=db_path, read_only=read_only, theme=theme)
tui.run()
65 changes: 43 additions & 22 deletions src/harlequin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from textual_textarea import TextArea

from harlequin.tui.components import (
DATABASES,
SCHEMAS,
TABLES,
CodeEditor,
Expand Down Expand Up @@ -39,18 +40,26 @@ class Harlequin(App, inherit_bindings=False):

def __init__(
self,
db_path: Path,
db_path: List[Path],
read_only: bool = False,
theme: str = "monokai",
driver_class: Union[Type[Driver], None] = None,
css_path: Union[CSSPathType, None] = None,
watch_css: bool = False,
):
super().__init__(driver_class, css_path, watch_css)
self.db_name = db_path.stem
self.theme = theme
if not db_path:
db_path = [Path(":memory:")]
primary_db, *other_dbs = db_path
try:
self.connection = duckdb.connect(database=str(db_path), read_only=read_only)
self.connection = duckdb.connect(
database=str(primary_db), read_only=read_only
)
for db in other_dbs:
self.connection.execute(
f"attach '{db}'{ '(READ ONLY)' if read_only else ''}"
)
except (duckdb.CatalogException, duckdb.IOException) as e:
from rich import print
from rich.panel import Panel
Expand All @@ -71,7 +80,7 @@ def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
with Container(id="sql_client"):
yield Header()
yield SchemaViewer(self.db_name, connection=self.connection)
yield SchemaViewer("Data Catalog", connection=self.connection)
yield CodeEditor(language="sql", theme=self.theme)
yield ResultsViewer()
yield Footer()
Expand Down Expand Up @@ -214,38 +223,50 @@ def add_data_to_table(self, table: ResultsTable, data: List[Tuple]) -> Worker:
@work(exclusive=True)
def update_schema_data(self) -> None:
log("update_schema_data")
data: SCHEMAS = []
schemas = self.connection.execute(
"select distinct table_schema "
"from information_schema.tables "
"order by 1"
).fetchall()
for (schema,) in schemas:
tables = self.connection.execute(
"select table_name, table_type "
"from information_schema.tables "
"where table_schema = ?"
data: DATABASES = []
databases = self.connection.execute("pragma show_databases").fetchall()
for (database,) in databases:
schemas = self.connection.execute(
"select schema_name "
"from information_schema.schemata "
"where "
" catalog_name = ? "
" and schema_name not in ('pg_catalog', 'information_schema') "
"order by 1",
[schema],
[database],
).fetchall()
tables_data: TABLES = []
if tables:
schemas_data: SCHEMAS = []
for (schema,) in schemas:
tables = self.connection.execute(
"select table_name, table_type "
"from information_schema.tables "
"where "
" table_catalog = ? "
" and table_schema = ? "
"order by 1",
[database, schema],
).fetchall()
tables_data: TABLES = []
for table, type in tables:
columns = self.connection.execute(
"select column_name, data_type "
"from information_schema.columns "
"where table_schema = ? and table_name = ? "
"where "
" table_catalog = ? "
" and table_schema = ? "
" and table_name = ? "
"order by 1",
[schema, table],
[database, schema, table],
).fetchall()
tables_data.append((table, type, columns))
data.append((schema, tables_data))
schemas_data.append((schema, tables_data))
data.append((database, schemas_data))
schema_viewer = self.query_one(SchemaViewer)
worker = get_current_worker()
if not worker.is_cancelled:
self.call_from_thread(schema_viewer.update_tree, data)


if __name__ == "__main__":
app = Harlequin(Path("f1.db"))
app = Harlequin([Path("f1.db")])
app.run()
3 changes: 3 additions & 0 deletions src/harlequin/tui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
from harlequin.tui.components.results_viewer import (
ResultsViewer as ResultsViewer,
)
from harlequin.tui.components.schema_viewer import (
DATABASES as DATABASES,
)
from harlequin.tui.components.schema_viewer import (
SCHEMAS as SCHEMAS,
)
Expand Down
3 changes: 3 additions & 0 deletions src/harlequin/tui/components/code_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def __init__(self, text: str) -> None:
super().__init__()
self.text = text

def on_mount(self) -> None:
self.border_title = "Query Editor"

async def action_submit(self) -> None:
self.post_message(self.Submitted(self.text))

Expand Down
44 changes: 27 additions & 17 deletions src/harlequin/tui/components/schema_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
COLS = List[Tuple[str, str]]
TABLES = List[Tuple[str, str, COLS]]
SCHEMAS = List[Tuple[str, TABLES]]
DATABASES = List[Tuple[str, SCHEMAS]]


class SchemaViewer(Tree[Union[str, None]]):
Expand All @@ -30,15 +31,18 @@ def __init__(
disabled: bool = False,
) -> None:
self.connection = connection
self.label = label
super().__init__(
label, data, name=name, id=id, classes=classes, disabled=disabled
)

def on_mount(self) -> None:
self.border_title = "Schema"
self.border_title = self.label
self.show_root = False
self.guide_depth = 3
self.root.expand()

def update_tree(self, data: SCHEMAS) -> None:
def update_tree(self, data: DATABASES) -> None:
tree_state = self.get_node_states(self.root)
expanded_nodes: Set[str] = set(tree_state[0])
# todo: tree's select_node() not working
Expand All @@ -47,24 +51,30 @@ def update_tree(self, data: SCHEMAS) -> None:
# selected_node = tree_state[1]
self.clear()
if data:
for schema in data:
schema_node = self.root.add(
schema[0], data=schema[0], expand=schema[0] in expanded_nodes
for database in data:
database_node = self.root.add(
database[0],
data=database[0],
expand=database[0] in expanded_nodes,
)
for table in schema[1]:
short_table_type = self.table_type_mapping.get(table[1], "?")
table_identifier = f"{schema[0]}.{table[0]}"
table_node = schema_node.add(
f"{table[0]} [#888888]{short_table_type}[/]",
data=table_identifier,
expand=(table_identifier in expanded_nodes),
for schema in database[1]:
schema_node = database_node.add(
schema[0], data=schema[0], expand=schema[0] in expanded_nodes
)
for col in table[2]:
col_identifier = f"{table_identifier}.{col[0]}"
table_node.add_leaf(
f"{col[0]} [#888888]{short_type(col[1])}[/]",
data=col_identifier,
for table in schema[1]:
short_table_type = self.table_type_mapping.get(table[1], "?")
table_identifier = f"{schema[0]}.{table[0]}"
table_node = schema_node.add(
f"{table[0]} [#888888]{short_table_type}[/]",
data=table_identifier,
expand=(table_identifier in expanded_nodes),
)
for col in table[2]:
col_identifier = f"{table_identifier}.{col[0]}"
table_node.add_leaf(
f"{col[0]} [#888888]{short_type(col[1])}[/]",
data=col_identifier,
)

@classmethod
def get_node_states(
Expand Down
2 changes: 1 addition & 1 deletion tests/functional_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

@pytest.fixture
def app() -> Harlequin:
return Harlequin(Path(":memory:"))
return Harlequin([Path(":memory:")])