Merging upstream version 0.3.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
99298ed60e
commit
3d50637468
11 changed files with 2155 additions and 459 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,6 @@
|
||||||
|
# test data
|
||||||
|
sqlserver_data/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-02-25
|
||||||
|
|
||||||
|
- The Data Catalog now displays all databases on the connected server, not just the currently-connected database ([tconbeer/harlequin#415](https://github.com/tconbeer/harlequin/discussions/415)).
|
||||||
|
- Columns in the Data Catalog are now fetched lazily ([#12](https://github.com/tconbeer/harlequin-odbc/issues/12), [#13](https://github.com/tconbeer/harlequin-odbc/issues/13)).
|
||||||
|
- Data Catalog items now support basic interactions ([#14](https://github.com/tconbeer/harlequin-odbc/issues/14)).
|
||||||
|
|
||||||
## [0.2.0] - 2025-01-08
|
## [0.2.0] - 2025-01-08
|
||||||
|
|
||||||
- Drops support for Python 3.8
|
- Drops support for Python 3.8
|
||||||
|
@ -22,7 +28,9 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- Adds a basic ODBC adapter.
|
- Adds a basic ODBC adapter.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/tconbeer/harlequin-odbc/compare/0.2.0...HEAD
|
[Unreleased]: https://github.com/tconbeer/harlequin-odbc/compare/0.3.0...HEAD
|
||||||
|
|
||||||
|
[0.3.0]: https://github.com/tconbeer/harlequin-odbc/compare/0.2.0...0.3.0
|
||||||
|
|
||||||
[0.2.0]: https://github.com/tconbeer/harlequin-odbc/compare/0.1.1...0.2.0
|
[0.2.0]: https://github.com/tconbeer/harlequin-odbc/compare/0.1.1...0.2.0
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,8 @@ services:
|
||||||
ACCEPT_EULA: Y
|
ACCEPT_EULA: Y
|
||||||
MSSQL_SA_PASSWORD: for-testing
|
MSSQL_SA_PASSWORD: for-testing
|
||||||
volumes:
|
volumes:
|
||||||
- sqlserver_data:/var/opt/mssql
|
- ./sqlserver_data/data:/var/opt/mssql/data
|
||||||
|
- ./sqlserver_data/log:/var/opt/mssql/log
|
||||||
|
- ./sqlserver_data/secrets:/var/opt/mssql/secrets
|
||||||
ports:
|
ports:
|
||||||
- 1433:1433
|
- 1433:1433
|
||||||
|
|
||||||
volumes:
|
|
||||||
sqlserver_data:
|
|
||||||
|
|
1064
poetry.lock
generated
1064
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "harlequin-odbc"
|
name = "harlequin-odbc"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "A Harlequin adapter for ODBC drivers."
|
description = "A Harlequin adapter for ODBC drivers."
|
||||||
authors = ["Ted Conbeer <tconbeer@users.noreply.github.com>"]
|
authors = ["Ted Conbeer <tconbeer@users.noreply.github.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -14,7 +14,7 @@ odbc = "harlequin_odbc:HarlequinOdbcAdapter"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.9,<3.14"
|
python = ">=3.9,<3.14"
|
||||||
harlequin = ">=1.9.1,<3"
|
harlequin = ">=1.25,<3"
|
||||||
pyodbc = "^5.0"
|
pyodbc = "^5.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
@ -30,13 +30,13 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py38"
|
target-version = "py39"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["A", "B", "E", "F", "I"]
|
select = ["A", "B", "E", "F", "I"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.8"
|
python_version = "3.9"
|
||||||
files = [
|
files = [
|
||||||
"src/**/*.py",
|
"src/**/*.py",
|
||||||
"tests/**/*.py",
|
"tests/**/*.py",
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Sequence
|
from contextlib import suppress
|
||||||
|
from typing import TYPE_CHECKING, Any, Sequence
|
||||||
|
|
||||||
import pyodbc # type: ignore
|
import pyodbc
|
||||||
from harlequin import (
|
from harlequin import (
|
||||||
HarlequinAdapter,
|
HarlequinAdapter,
|
||||||
HarlequinConnection,
|
HarlequinConnection,
|
||||||
|
@ -17,8 +18,16 @@ from harlequin.exception import (
|
||||||
)
|
)
|
||||||
from textual_fastdatatable.backend import AutoBackendType
|
from textual_fastdatatable.backend import AutoBackendType
|
||||||
|
|
||||||
|
from harlequin_odbc.catalog import (
|
||||||
|
DatabaseCatalogItem,
|
||||||
|
RelationCatalogItem,
|
||||||
|
SchemaCatalogItem,
|
||||||
|
)
|
||||||
from harlequin_odbc.cli_options import ODBC_OPTIONS
|
from harlequin_odbc.cli_options import ODBC_OPTIONS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HarlequinOdbcCursor(HarlequinCursor):
|
class HarlequinOdbcCursor(HarlequinCursor):
|
||||||
def __init__(self, cur: pyodbc.Cursor) -> None:
|
def __init__(self, cur: pyodbc.Cursor) -> None:
|
||||||
|
@ -86,7 +95,7 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
cur.execute(query)
|
cur.execute(query)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HarlequinQueryError(
|
raise HarlequinQueryError(
|
||||||
msg=str(e),
|
msg=f"{e.__class__.__name__}: {e}",
|
||||||
title="Harlequin encountered an error while executing your query.",
|
title="Harlequin encountered an error while executing your query.",
|
||||||
) from e
|
) from e
|
||||||
else:
|
else:
|
||||||
|
@ -103,72 +112,48 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
for schema, relations in schemas.items():
|
for schema, relations in schemas.items():
|
||||||
rel_items: list[CatalogItem] = []
|
rel_items: list[CatalogItem] = []
|
||||||
for rel, rel_type in relations:
|
for rel, rel_type in relations:
|
||||||
cols = self._list_columns_in_relation(
|
|
||||||
catalog_name=db, schema_name=schema, rel_name=rel
|
|
||||||
)
|
|
||||||
col_items = [
|
|
||||||
CatalogItem(
|
|
||||||
qualified_identifier=f'"{db}"."{schema}"."{rel}"."{col}"',
|
|
||||||
query_name=f'"{col}"',
|
|
||||||
label=col,
|
|
||||||
type_label=col_type,
|
|
||||||
)
|
|
||||||
for col, col_type in cols
|
|
||||||
]
|
|
||||||
rel_items.append(
|
rel_items.append(
|
||||||
CatalogItem(
|
RelationCatalogItem.from_label(
|
||||||
qualified_identifier=f'"{db}"."{schema}"."{rel}"',
|
|
||||||
query_name=f'"{db}"."{schema}"."{rel}"',
|
|
||||||
label=rel,
|
label=rel,
|
||||||
type_label=rel_type,
|
schema_label=schema,
|
||||||
children=col_items,
|
db_label=db,
|
||||||
|
rel_type=rel_type,
|
||||||
|
connection=self,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
schema_items.append(
|
schema_items.append(
|
||||||
CatalogItem(
|
SchemaCatalogItem.from_label(
|
||||||
qualified_identifier=f'"{db}"."{schema}"',
|
|
||||||
query_name=f'"{db}"."{schema}"',
|
|
||||||
label=schema,
|
label=schema,
|
||||||
type_label="s",
|
db_label=db,
|
||||||
|
connection=self,
|
||||||
children=rel_items,
|
children=rel_items,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db_items.append(
|
db_items.append(
|
||||||
CatalogItem(
|
DatabaseCatalogItem.from_label(
|
||||||
qualified_identifier=f'"{db}"',
|
|
||||||
query_name=f'"{db}"',
|
|
||||||
label=db,
|
label=db,
|
||||||
type_label="db",
|
connection=self,
|
||||||
children=schema_items,
|
children=schema_items,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Catalog(items=db_items)
|
return Catalog(items=db_items)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with suppress(Exception):
|
||||||
|
self.conn.close()
|
||||||
|
with suppress(Exception):
|
||||||
|
self.aux_conn.close()
|
||||||
|
|
||||||
def _list_tables(self) -> dict[str, dict[str, list[tuple[str, str]]]]:
|
def _list_tables(self) -> dict[str, dict[str, list[tuple[str, str]]]]:
|
||||||
cur = self.aux_conn.cursor()
|
cur = self.aux_conn.cursor()
|
||||||
rel_type_map = {
|
|
||||||
"TABLE": "t",
|
|
||||||
"VIEW": "v",
|
|
||||||
"SYSTEM TABLE": "st",
|
|
||||||
"GLOBAL TEMPORARY": "tmp",
|
|
||||||
"LOCAL TEMPORARY": "tmp",
|
|
||||||
}
|
|
||||||
catalog: dict[str, dict[str, list[tuple[str, str]]]] = {}
|
catalog: dict[str, dict[str, list[tuple[str, str]]]] = {}
|
||||||
for db_name, schema_name, rel_name, rel_type, *_ in cur.tables():
|
for db_name, schema_name, rel_name, rel_type, *_ in cur.tables(catalog="%"):
|
||||||
if db_name not in catalog:
|
if db_name not in catalog:
|
||||||
catalog[db_name] = {
|
catalog[db_name] = {schema_name: [(rel_name, rel_type)]}
|
||||||
schema_name: [
|
|
||||||
(rel_name, rel_type_map.get(rel_type, str(rel_type).lower()))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
elif schema_name not in catalog[db_name]:
|
elif schema_name not in catalog[db_name]:
|
||||||
catalog[db_name][schema_name] = [
|
catalog[db_name][schema_name] = [(rel_name, rel_type)]
|
||||||
(rel_name, rel_type_map.get(rel_type, rel_type))
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
catalog[db_name][schema_name].append(
|
catalog[db_name][schema_name].append((rel_name, rel_type))
|
||||||
(rel_name, rel_type_map.get(rel_type, rel_type))
|
|
||||||
)
|
|
||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
def _list_columns_in_relation(
|
def _list_columns_in_relation(
|
||||||
|
|
180
src/harlequin_odbc/catalog.py
Normal file
180
src/harlequin_odbc/catalog.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
from harlequin.catalog import CatalogItem, InteractiveCatalogItem
|
||||||
|
|
||||||
|
from harlequin_odbc.interactions import (
|
||||||
|
execute_drop_database_statement,
|
||||||
|
execute_drop_table_statement,
|
||||||
|
execute_drop_view_statement,
|
||||||
|
execute_use_statement,
|
||||||
|
insert_columns_at_cursor,
|
||||||
|
show_select_star,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from harlequin_odbc.adapter import HarlequinOdbcConnection
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ColumnCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
|
parent: "RelationCatalogItem" | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_parent(
|
||||||
|
cls,
|
||||||
|
parent: "RelationCatalogItem",
|
||||||
|
label: str,
|
||||||
|
type_label: str,
|
||||||
|
) -> "ColumnCatalogItem":
|
||||||
|
column_qualified_identifier = f'{parent.qualified_identifier}."{label}"'
|
||||||
|
column_query_name = f'"{label}"'
|
||||||
|
return cls(
|
||||||
|
qualified_identifier=column_qualified_identifier,
|
||||||
|
query_name=column_query_name,
|
||||||
|
label=label,
|
||||||
|
type_label=type_label,
|
||||||
|
connection=parent.connection,
|
||||||
|
parent=parent,
|
||||||
|
loaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RelationCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
|
INTERACTIONS = [
|
||||||
|
("Insert Columns at Cursor", insert_columns_at_cursor),
|
||||||
|
("Preview Data", show_select_star),
|
||||||
|
]
|
||||||
|
TYPE_LABEL: ClassVar[str] = ""
|
||||||
|
schema_label: str = ""
|
||||||
|
db_label: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_label(
|
||||||
|
cls,
|
||||||
|
label: str,
|
||||||
|
schema_label: str,
|
||||||
|
db_label: str,
|
||||||
|
rel_type: str,
|
||||||
|
connection: "HarlequinOdbcConnection",
|
||||||
|
) -> "RelationCatalogItem":
|
||||||
|
rel_type_map: dict[str, type[RelationCatalogItem]] = {
|
||||||
|
"TABLE": TableCatalogItem,
|
||||||
|
"VIEW": ViewCatalogItem,
|
||||||
|
"SYSTEM TABLE": SystemTableCatalogItem,
|
||||||
|
"GLOBAL TEMPORARY": GlobalTempTableCatalogItem,
|
||||||
|
"LOCAL TEMPORARY": LocalTempTableCatalogItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
item_class = rel_type_map.get(rel_type, TableCatalogItem)
|
||||||
|
return item_class(
|
||||||
|
qualified_identifier=f'"{db_label}"."{schema_label}"."{label}"',
|
||||||
|
query_name=f'"{schema_label}"."{label}"',
|
||||||
|
label=label,
|
||||||
|
schema_label=schema_label,
|
||||||
|
db_label=db_label,
|
||||||
|
type_label=item_class.TYPE_LABEL,
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_children(self) -> list[ColumnCatalogItem]:
|
||||||
|
if self.connection is None:
|
||||||
|
return []
|
||||||
|
cols = self.connection._list_columns_in_relation(
|
||||||
|
catalog_name=self.db_label,
|
||||||
|
schema_name=self.schema_label,
|
||||||
|
rel_name=self.label,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ColumnCatalogItem.from_parent(
|
||||||
|
parent=self, label=col_label, type_label=col_type_label
|
||||||
|
)
|
||||||
|
for col_label, col_type_label in cols
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ViewCatalogItem(RelationCatalogItem):
|
||||||
|
INTERACTIONS = RelationCatalogItem.INTERACTIONS + [
|
||||||
|
("Drop View", execute_drop_view_statement),
|
||||||
|
]
|
||||||
|
TYPE_LABEL: ClassVar[str] = "v"
|
||||||
|
|
||||||
|
|
||||||
|
class TableCatalogItem(RelationCatalogItem):
|
||||||
|
INTERACTIONS = RelationCatalogItem.INTERACTIONS + [
|
||||||
|
("Drop Table", execute_drop_table_statement),
|
||||||
|
]
|
||||||
|
TYPE_LABEL: ClassVar[str] = "t"
|
||||||
|
|
||||||
|
|
||||||
|
class SystemTableCatalogItem(RelationCatalogItem):
|
||||||
|
TYPE_LABEL: ClassVar[str] = "st"
|
||||||
|
|
||||||
|
|
||||||
|
class TempTableCatalogItem(TableCatalogItem):
|
||||||
|
TYPE_LABEL: ClassVar[str] = "tmp"
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalTempTableCatalogItem(TempTableCatalogItem):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LocalTempTableCatalogItem(TempTableCatalogItem):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SchemaCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
|
db_label: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_label(
|
||||||
|
cls,
|
||||||
|
label: str,
|
||||||
|
db_label: str,
|
||||||
|
connection: "HarlequinOdbcConnection",
|
||||||
|
children: list[CatalogItem] | None = None,
|
||||||
|
) -> "SchemaCatalogItem":
|
||||||
|
schema_identifier = f'"{label}"'
|
||||||
|
if children is None:
|
||||||
|
children = []
|
||||||
|
return cls(
|
||||||
|
qualified_identifier=f'"{db_label}".{schema_identifier}',
|
||||||
|
query_name=schema_identifier,
|
||||||
|
label=label,
|
||||||
|
db_label=db_label,
|
||||||
|
type_label="sch",
|
||||||
|
connection=connection,
|
||||||
|
children=children,
|
||||||
|
loaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
|
INTERACTIONS = [
|
||||||
|
("Use Database", execute_use_statement),
|
||||||
|
("Drop Database", execute_drop_database_statement),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_label(
|
||||||
|
cls,
|
||||||
|
label: str,
|
||||||
|
connection: "HarlequinOdbcConnection",
|
||||||
|
children: list[CatalogItem] | None = None,
|
||||||
|
) -> "DatabaseCatalogItem":
|
||||||
|
database_identifier = f'"{label}"'
|
||||||
|
if children is None:
|
||||||
|
children = []
|
||||||
|
return cls(
|
||||||
|
qualified_identifier=database_identifier,
|
||||||
|
query_name=database_identifier,
|
||||||
|
label=label,
|
||||||
|
type_label="db",
|
||||||
|
connection=connection,
|
||||||
|
children=children,
|
||||||
|
loaded=True,
|
||||||
|
)
|
120
src/harlequin_odbc/interactions.py
Normal file
120
src/harlequin_odbc/interactions.py
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
from typing import TYPE_CHECKING, Literal, Sequence
|
||||||
|
|
||||||
|
from harlequin.catalog import CatalogItem
|
||||||
|
from harlequin.exception import HarlequinQueryError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from harlequin.driver import HarlequinDriver
|
||||||
|
|
||||||
|
from harlequin_odbc.catalog import (
|
||||||
|
ColumnCatalogItem,
|
||||||
|
DatabaseCatalogItem,
|
||||||
|
RelationCatalogItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_use_statement(
|
||||||
|
item: "DatabaseCatalogItem",
|
||||||
|
driver: "HarlequinDriver",
|
||||||
|
) -> None:
|
||||||
|
if item.connection is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item.connection.execute(f"use {item.query_name}")
|
||||||
|
except HarlequinQueryError:
|
||||||
|
driver.notify("Could not switch context", severity="error")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
driver.notify(f"Editor context switched to {item.label}")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_drop_database_statement(
|
||||||
|
item: "DatabaseCatalogItem",
|
||||||
|
driver: "HarlequinDriver",
|
||||||
|
) -> None:
|
||||||
|
def _drop_database() -> None:
|
||||||
|
if item.connection is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item.connection.execute(f"drop database {item.query_name}")
|
||||||
|
except HarlequinQueryError:
|
||||||
|
driver.notify(f"Could not drop database {item.label}", severity="error")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
driver.notify(f"Dropped database {item.label}")
|
||||||
|
driver.refresh_catalog()
|
||||||
|
|
||||||
|
if item.children or item.fetch_children():
|
||||||
|
driver.confirm_and_execute(callback=_drop_database)
|
||||||
|
else:
|
||||||
|
_drop_database()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_drop_relation_statement(
|
||||||
|
item: "RelationCatalogItem",
|
||||||
|
driver: "HarlequinDriver",
|
||||||
|
relation_type: Literal["view", "table", "foreign table"],
|
||||||
|
) -> None:
|
||||||
|
def _drop_relation() -> None:
|
||||||
|
if item.connection is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item.connection.execute(f"drop {relation_type} {item.query_name}")
|
||||||
|
except HarlequinQueryError:
|
||||||
|
driver.notify(
|
||||||
|
f"Could not drop {relation_type} {item.label}", severity="error"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
driver.notify(f"Dropped {relation_type} {item.label}")
|
||||||
|
driver.refresh_catalog()
|
||||||
|
|
||||||
|
driver.confirm_and_execute(callback=_drop_relation)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_drop_table_statement(
|
||||||
|
item: "RelationCatalogItem", driver: "HarlequinDriver"
|
||||||
|
) -> None:
|
||||||
|
execute_drop_relation_statement(item=item, driver=driver, relation_type="table")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_drop_foreign_table_statement(
|
||||||
|
item: "RelationCatalogItem", driver: "HarlequinDriver"
|
||||||
|
) -> None:
|
||||||
|
execute_drop_relation_statement(
|
||||||
|
item=item, driver=driver, relation_type="foreign table"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_drop_view_statement(
|
||||||
|
item: "RelationCatalogItem", driver: "HarlequinDriver"
|
||||||
|
) -> None:
|
||||||
|
execute_drop_relation_statement(item=item, driver=driver, relation_type="view")
|
||||||
|
|
||||||
|
|
||||||
|
def show_select_star(
|
||||||
|
item: "RelationCatalogItem",
|
||||||
|
driver: "HarlequinDriver",
|
||||||
|
) -> None:
|
||||||
|
driver.insert_text_in_new_buffer(
|
||||||
|
dedent(
|
||||||
|
f"""
|
||||||
|
select *
|
||||||
|
from {item.query_name}
|
||||||
|
""".strip("\n")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_columns_at_cursor(
|
||||||
|
item: "RelationCatalogItem",
|
||||||
|
driver: "HarlequinDriver",
|
||||||
|
) -> None:
|
||||||
|
if item.loaded:
|
||||||
|
cols: Sequence["CatalogItem" | "ColumnCatalogItem"] = item.children
|
||||||
|
else:
|
||||||
|
cols = item.fetch_children()
|
||||||
|
driver.insert_text_at_selection(text=",\n".join(c.query_name for c in cols))
|
1017
stubs/pyodbc.pyi
Normal file
1017
stubs/pyodbc.pyi
Normal file
File diff suppressed because it is too large
Load diff
27
tests/conftest.py
Normal file
27
tests/conftest.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
import pyodbc
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from harlequin_odbc.adapter import (
|
||||||
|
HarlequinOdbcAdapter,
|
||||||
|
HarlequinOdbcConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
MASTER_DB_CONN = "Driver={ODBC Driver 18 for SQL Server};Server=tcp:localhost,1433;Database=master;Uid=sa;Pwd={for-testing};Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=5;" # noqa: E501
|
||||||
|
TEST_DB_CONN = "Driver={ODBC Driver 18 for SQL Server};Server=tcp:localhost,1433;Database=test;Uid=sa;Pwd={for-testing};Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=5;" # noqa: E501
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def connection() -> Generator[HarlequinOdbcConnection, None, None]:
|
||||||
|
master_conn = pyodbc.connect(MASTER_DB_CONN, autocommit=True)
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
cur.execute("drop database if exists test;")
|
||||||
|
cur.execute("create database test;")
|
||||||
|
cur.close()
|
||||||
|
master_conn.close()
|
||||||
|
conn = HarlequinOdbcAdapter(conn_str=(TEST_DB_CONN,)).connect()
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
99
tests/test_catalog.py
Normal file
99
tests/test_catalog.py
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from harlequin.catalog import InteractiveCatalogItem
|
||||||
|
|
||||||
|
from harlequin_odbc.adapter import HarlequinOdbcConnection
|
||||||
|
from harlequin_odbc.catalog import (
|
||||||
|
ColumnCatalogItem,
|
||||||
|
DatabaseCatalogItem,
|
||||||
|
RelationCatalogItem,
|
||||||
|
SchemaCatalogItem,
|
||||||
|
TableCatalogItem,
|
||||||
|
ViewCatalogItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def connection_with_objects(
|
||||||
|
connection: HarlequinOdbcConnection,
|
||||||
|
) -> Generator[HarlequinOdbcConnection, None, None]:
|
||||||
|
connection.execute("create schema one")
|
||||||
|
connection.execute("select 1 as a, '2' as b into one.foo")
|
||||||
|
connection.execute("select 1 as a, '2' as b into one.bar")
|
||||||
|
connection.execute("select 1 as a, '2' as b into one.baz")
|
||||||
|
connection.execute("create schema two")
|
||||||
|
connection.execute("create view two.qux as select * from one.foo")
|
||||||
|
connection.execute("create schema three")
|
||||||
|
|
||||||
|
yield connection
|
||||||
|
|
||||||
|
connection.execute("drop table one.foo")
|
||||||
|
connection.execute("drop table one.bar")
|
||||||
|
connection.execute("drop table one.baz")
|
||||||
|
connection.execute("drop schema one")
|
||||||
|
connection.execute("drop view two.qux")
|
||||||
|
connection.execute("drop schema two")
|
||||||
|
connection.execute("drop schema three")
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog(connection_with_objects: HarlequinOdbcConnection) -> None:
|
||||||
|
conn = connection_with_objects
|
||||||
|
|
||||||
|
catalog = conn.get_catalog()
|
||||||
|
|
||||||
|
# at least two databases, postgres and test
|
||||||
|
assert len(catalog.items) >= 2
|
||||||
|
|
||||||
|
[test_db_item] = filter(lambda item: item.label == "test", catalog.items)
|
||||||
|
assert isinstance(test_db_item, InteractiveCatalogItem)
|
||||||
|
assert isinstance(test_db_item, DatabaseCatalogItem)
|
||||||
|
assert test_db_item.children
|
||||||
|
assert test_db_item.loaded
|
||||||
|
|
||||||
|
schema_items = test_db_item.children
|
||||||
|
assert all(isinstance(item, SchemaCatalogItem) for item in schema_items)
|
||||||
|
|
||||||
|
[schema_one_item] = filter(lambda item: item.label == "one", schema_items)
|
||||||
|
assert isinstance(schema_one_item, SchemaCatalogItem)
|
||||||
|
assert schema_one_item.children
|
||||||
|
assert schema_one_item.loaded
|
||||||
|
|
||||||
|
table_items = schema_one_item.children
|
||||||
|
assert all(isinstance(item, RelationCatalogItem) for item in table_items)
|
||||||
|
|
||||||
|
[foo_item] = filter(lambda item: item.label == "foo", table_items)
|
||||||
|
assert isinstance(foo_item, TableCatalogItem)
|
||||||
|
assert not foo_item.children
|
||||||
|
assert not foo_item.loaded
|
||||||
|
|
||||||
|
foo_column_items = foo_item.fetch_children()
|
||||||
|
assert all(isinstance(item, ColumnCatalogItem) for item in foo_column_items)
|
||||||
|
|
||||||
|
[schema_two_item] = filter(lambda item: item.label == "two", schema_items)
|
||||||
|
assert isinstance(schema_two_item, SchemaCatalogItem)
|
||||||
|
assert schema_two_item.children
|
||||||
|
assert schema_two_item.loaded
|
||||||
|
|
||||||
|
view_items = schema_two_item.children
|
||||||
|
assert all(isinstance(item, ViewCatalogItem) for item in view_items)
|
||||||
|
|
||||||
|
[qux_item] = filter(lambda item: item.label == "qux", view_items)
|
||||||
|
assert isinstance(qux_item, ViewCatalogItem)
|
||||||
|
assert not qux_item.children
|
||||||
|
assert not qux_item.loaded
|
||||||
|
|
||||||
|
qux_column_items = qux_item.fetch_children()
|
||||||
|
assert all(isinstance(item, ColumnCatalogItem) for item in qux_column_items)
|
||||||
|
|
||||||
|
assert [item.label for item in foo_column_items] == [
|
||||||
|
item.label for item in qux_column_items
|
||||||
|
]
|
||||||
|
|
||||||
|
# ensure calling fetch_children on cols doesn't raise
|
||||||
|
children_items = foo_column_items[0].fetch_children()
|
||||||
|
assert not children_items
|
||||||
|
|
||||||
|
# empty schemas don't appear in the catalog
|
||||||
|
schema_three_items = list(filter(lambda item: item.label == "three", schema_items))
|
||||||
|
assert not schema_three_items
|
Loading…
Add table
Reference in a new issue