feat: add data catalog support for oracledb
This commit is contained in:
parent
6de3bce738
commit
73afd429ac
3 changed files with 298 additions and 16 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date, datetime, time
|
||||||
from typing import TYPE_CHECKING, Any, Sequence
|
from typing import TYPE_CHECKING, Any, Sequence
|
||||||
|
|
||||||
import pyodbc
|
import pyodbc
|
||||||
|
|
@ -29,29 +31,92 @@ if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OdbcTypeLabels:
|
||||||
|
"""Shared Harlequin type-label mapper for result sets and catalog metadata."""
|
||||||
|
|
||||||
|
UNKNOWN = "?"
|
||||||
|
|
||||||
|
PYTHON_TYPE_NAMES: dict[str, str] = {
|
||||||
|
"bool": "t/f",
|
||||||
|
"int": "##",
|
||||||
|
"float": "#.#",
|
||||||
|
"Decimal": "#.#",
|
||||||
|
"str": "s",
|
||||||
|
"bytes": "0b",
|
||||||
|
"date": "d",
|
||||||
|
"time": "t",
|
||||||
|
"datetime": "dt",
|
||||||
|
"UUID": "uid",
|
||||||
|
}
|
||||||
|
|
||||||
|
PYTHON_TYPES: dict[type[Any], str] = {
|
||||||
|
bool: "t/f",
|
||||||
|
int: "##",
|
||||||
|
float: "#.#",
|
||||||
|
Decimal: "#.#",
|
||||||
|
str: "s",
|
||||||
|
bytes: "0b",
|
||||||
|
date: "d",
|
||||||
|
time: "t",
|
||||||
|
datetime: "dt",
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_python_type(cls, col_type: type[Any] | None) -> str:
|
||||||
|
if col_type is None:
|
||||||
|
return cls.UNKNOWN
|
||||||
|
return cls.PYTHON_TYPES.get(
|
||||||
|
col_type, cls.PYTHON_TYPE_NAMES.get(col_type.__name__, cls.UNKNOWN)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_oracle_metadata(
|
||||||
|
cls,
|
||||||
|
data_type: str | None,
|
||||||
|
data_precision: int | None = None,
|
||||||
|
data_scale: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Map Oracle dictionary metadata to the same labels used for query results."""
|
||||||
|
if data_type is None:
|
||||||
|
return cls.UNKNOWN
|
||||||
|
|
||||||
|
normalized_type = data_type.upper()
|
||||||
|
|
||||||
|
if normalized_type in {"CHAR", "NCHAR", "VARCHAR2", "NVARCHAR2", "CLOB", "NCLOB", "LONG"}:
|
||||||
|
return cls.PYTHON_TYPE_NAMES["str"]
|
||||||
|
|
||||||
|
if normalized_type == "NUMBER":
|
||||||
|
if data_precision == 1 and (data_scale is None or data_scale == 0):
|
||||||
|
return cls.PYTHON_TYPE_NAMES["bool"]
|
||||||
|
if data_scale is None or data_scale == 0:
|
||||||
|
return cls.PYTHON_TYPE_NAMES["int"]
|
||||||
|
return cls.PYTHON_TYPE_NAMES["Decimal"]
|
||||||
|
|
||||||
|
if normalized_type in {"BINARY_FLOAT", "BINARY_DOUBLE", "FLOAT"}:
|
||||||
|
return cls.PYTHON_TYPE_NAMES["float"]
|
||||||
|
|
||||||
|
if normalized_type == "DATE" or normalized_type.startswith("TIMESTAMP"):
|
||||||
|
return cls.PYTHON_TYPE_NAMES["datetime"]
|
||||||
|
|
||||||
|
if normalized_type.startswith("INTERVAL"):
|
||||||
|
return "td"
|
||||||
|
|
||||||
|
if normalized_type in {"RAW", "LONG RAW", "BLOB", "BFILE"}:
|
||||||
|
return cls.PYTHON_TYPE_NAMES["bytes"]
|
||||||
|
|
||||||
|
return cls.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
class HarlequinOdbcCursor(HarlequinCursor):
|
class HarlequinOdbcCursor(HarlequinCursor):
|
||||||
def __init__(self, cur: pyodbc.Cursor) -> None:
|
def __init__(self, cur: pyodbc.Cursor) -> None:
|
||||||
self.cur = cur
|
self.cur = cur
|
||||||
self._limit: int | None = None
|
self._limit: int | None = None
|
||||||
|
|
||||||
def columns(self) -> list[tuple[str, str]]:
|
def columns(self) -> list[tuple[str, str]]:
|
||||||
# todo: use getTypeInfo
|
|
||||||
type_mapping = {
|
|
||||||
"bool": "t/f",
|
|
||||||
"int": "##",
|
|
||||||
"float": "#.#",
|
|
||||||
"Decimal": "#.#",
|
|
||||||
"str": "s",
|
|
||||||
"bytes": "0b",
|
|
||||||
"date": "d",
|
|
||||||
"time": "t",
|
|
||||||
"datetime": "dt",
|
|
||||||
"UUID": "uid",
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
(
|
(
|
||||||
col_name if col_name else "(No column name)",
|
col_name if col_name else "(No column name)",
|
||||||
type_mapping.get(col_type.__name__, "?"),
|
OdbcTypeLabels.from_python_type(col_type),
|
||||||
)
|
)
|
||||||
for col_name, col_type, *_ in self.cur.description
|
for col_name, col_type, *_ in self.cur.description
|
||||||
]
|
]
|
||||||
|
|
@ -80,7 +145,9 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
init_message: str = "",
|
init_message: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
assert len(conn_str) == 1
|
assert len(conn_str) == 1
|
||||||
|
self.conn_str = conn_str
|
||||||
self.init_message = init_message
|
self.init_message = init_message
|
||||||
|
self._is_oracle_db: bool | None = None
|
||||||
try:
|
try:
|
||||||
self.conn = pyodbc.connect(conn_str[0], autocommit=True)
|
self.conn = pyodbc.connect(conn_str[0], autocommit=True)
|
||||||
self.aux_conn = pyodbc.connect(conn_str[0], autocommit=True)
|
self.aux_conn = pyodbc.connect(conn_str[0], autocommit=True)
|
||||||
|
|
@ -106,6 +173,7 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
|
|
||||||
def get_catalog(self) -> Catalog:
|
def get_catalog(self) -> Catalog:
|
||||||
raw_catalog = self._list_tables()
|
raw_catalog = self._list_tables()
|
||||||
|
include_db_in_identifier = not self._is_oracle()
|
||||||
db_items: list[CatalogItem] = []
|
db_items: list[CatalogItem] = []
|
||||||
for db, schemas in raw_catalog.items():
|
for db, schemas in raw_catalog.items():
|
||||||
schema_items: list[CatalogItem] = []
|
schema_items: list[CatalogItem] = []
|
||||||
|
|
@ -119,6 +187,7 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
db_label=db,
|
db_label=db,
|
||||||
rel_type=rel_type,
|
rel_type=rel_type,
|
||||||
connection=self,
|
connection=self,
|
||||||
|
include_db_in_identifier=include_db_in_identifier,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
schema_items.append(
|
schema_items.append(
|
||||||
|
|
@ -127,6 +196,7 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
db_label=db,
|
db_label=db,
|
||||||
connection=self,
|
connection=self,
|
||||||
children=rel_items,
|
children=rel_items,
|
||||||
|
include_db_in_identifier=include_db_in_identifier,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db_items.append(
|
db_items.append(
|
||||||
|
|
@ -145,6 +215,9 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
self.aux_conn.close()
|
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]]]]:
|
||||||
|
if self._is_oracle():
|
||||||
|
return self._list_oracle_tables()
|
||||||
|
|
||||||
cur = self.aux_conn.cursor()
|
cur = self.aux_conn.cursor()
|
||||||
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(catalog="%"):
|
for db_name, schema_name, rel_name, rel_type, *_ in cur.tables(catalog="%"):
|
||||||
|
|
@ -166,10 +239,107 @@ class HarlequinOdbcConnection(HarlequinConnection):
|
||||||
def _list_columns_in_relation(
|
def _list_columns_in_relation(
|
||||||
self, catalog_name: str, schema_name: str, rel_name: str
|
self, catalog_name: str, schema_name: str, rel_name: str
|
||||||
) -> list[tuple[str, str]]:
|
) -> list[tuple[str, str]]:
|
||||||
|
if self._is_oracle():
|
||||||
|
return self._list_oracle_columns_in_relation(schema_name, rel_name)
|
||||||
|
|
||||||
cur = self.aux_conn.cursor()
|
cur = self.aux_conn.cursor()
|
||||||
raw_cols = cur.columns(table=rel_name, catalog=catalog_name, schema=schema_name)
|
raw_cols = cur.columns(table=rel_name, catalog=catalog_name, schema=schema_name)
|
||||||
return [(col[3], col[5]) for col in raw_cols]
|
return [(col[3], col[5]) for col in raw_cols]
|
||||||
|
|
||||||
|
def _is_oracle(self) -> bool:
|
||||||
|
if self._is_oracle_db is None:
|
||||||
|
dbms_name = ""
|
||||||
|
with suppress(Exception):
|
||||||
|
dbms_name_info = self.conn.getinfo(pyodbc.SQL_DBMS_NAME)
|
||||||
|
if dbms_name_info:
|
||||||
|
dbms_name = str(dbms_name_info)
|
||||||
|
if not dbms_name:
|
||||||
|
dbms_name = self.conn_str[0]
|
||||||
|
self._is_oracle_db = "oracle" in dbms_name.lower()
|
||||||
|
return self._is_oracle_db is True
|
||||||
|
|
||||||
|
def _list_oracle_tables(self) -> dict[str, dict[str, list[tuple[str, str]]]]:
|
||||||
|
cur = self.aux_conn.cursor()
|
||||||
|
database_name = self._get_oracle_database_name(cur)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
select owner, table_name, rel_type
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
owner,
|
||||||
|
table_name,
|
||||||
|
case temporary
|
||||||
|
when 'Y' then 'GLOBAL TEMPORARY'
|
||||||
|
else 'TABLE'
|
||||||
|
end as rel_type
|
||||||
|
from all_tables
|
||||||
|
where owner not in (
|
||||||
|
'SYS', 'SYSTEM', 'XDB', 'CTXSYS', 'MDSYS', 'ORDSYS', 'WMSYS'
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select owner, view_name as table_name, 'VIEW' as rel_type
|
||||||
|
from all_views
|
||||||
|
where owner not in (
|
||||||
|
'SYS', 'SYSTEM', 'XDB', 'CTXSYS', 'MDSYS', 'ORDSYS', 'WMSYS'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order by owner, table_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog: dict[str, dict[str, list[tuple[str, str]]]] = {database_name: {}}
|
||||||
|
for schema_name, rel_name, rel_type in cur:
|
||||||
|
if schema_name not in catalog[database_name]:
|
||||||
|
catalog[database_name][schema_name] = []
|
||||||
|
catalog[database_name][schema_name].append((rel_name, rel_type))
|
||||||
|
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
def _get_oracle_database_name(self, cur: pyodbc.Cursor) -> str:
|
||||||
|
with suppress(Exception):
|
||||||
|
cur.execute("select sys_context('USERENV', 'DB_NAME') from dual")
|
||||||
|
database_name = cur.fetchval()
|
||||||
|
if database_name:
|
||||||
|
return str(database_name)
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
database_name = self.conn.getinfo(pyodbc.SQL_DATABASE_NAME)
|
||||||
|
if database_name:
|
||||||
|
return str(database_name)
|
||||||
|
|
||||||
|
return "Oracle"
|
||||||
|
|
||||||
|
def _list_oracle_columns_in_relation(
|
||||||
|
self, schema_name: str, rel_name: str
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
cur = self.aux_conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
select
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
data_precision,
|
||||||
|
data_scale
|
||||||
|
from all_tab_columns
|
||||||
|
where owner = ?
|
||||||
|
and table_name = ?
|
||||||
|
order by column_id
|
||||||
|
""",
|
||||||
|
schema_name,
|
||||||
|
rel_name,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
column_name,
|
||||||
|
OdbcTypeLabels.from_oracle_metadata(
|
||||||
|
data_type=data_type,
|
||||||
|
data_precision=data_precision,
|
||||||
|
data_scale=data_scale,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column_name, data_type, data_precision, data_scale in cur
|
||||||
|
]
|
||||||
|
|
||||||
def get_completions(self) -> list[HarlequinCompletion]:
|
def get_completions(self) -> list[HarlequinCompletion]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -191,3 +361,4 @@ class HarlequinOdbcAdapter(HarlequinAdapter):
|
||||||
def connect(self) -> HarlequinOdbcConnection:
|
def connect(self) -> HarlequinOdbcConnection:
|
||||||
conn = HarlequinOdbcConnection(self.conn_str)
|
conn = HarlequinOdbcConnection(self.conn_str)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ class RelationCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
db_label: str,
|
db_label: str,
|
||||||
rel_type: str,
|
rel_type: str,
|
||||||
connection: "HarlequinOdbcConnection",
|
connection: "HarlequinOdbcConnection",
|
||||||
|
include_db_in_identifier: bool = True,
|
||||||
) -> "RelationCatalogItem":
|
) -> "RelationCatalogItem":
|
||||||
rel_type_map: dict[str, type[RelationCatalogItem]] = {
|
rel_type_map: dict[str, type[RelationCatalogItem]] = {
|
||||||
"TABLE": TableCatalogItem,
|
"TABLE": TableCatalogItem,
|
||||||
|
|
@ -70,8 +71,13 @@ class RelationCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
}
|
}
|
||||||
|
|
||||||
item_class = rel_type_map.get(rel_type, TableCatalogItem)
|
item_class = rel_type_map.get(rel_type, TableCatalogItem)
|
||||||
|
if include_db_in_identifier:
|
||||||
|
qualified_identifier = f'"{db_label}"."{schema_label}"."{label}"'
|
||||||
|
else:
|
||||||
|
qualified_identifier = f'"{schema_label}"."{label}"'
|
||||||
|
|
||||||
return item_class(
|
return item_class(
|
||||||
qualified_identifier=f'"{db_label}"."{schema_label}"."{label}"',
|
qualified_identifier=qualified_identifier,
|
||||||
query_name=f'"{schema_label}"."{label}"',
|
query_name=f'"{schema_label}"."{label}"',
|
||||||
label=label,
|
label=label,
|
||||||
schema_label=schema_label,
|
schema_label=schema_label,
|
||||||
|
|
@ -137,12 +143,17 @@ class SchemaCatalogItem(InteractiveCatalogItem["HarlequinOdbcConnection"]):
|
||||||
db_label: str,
|
db_label: str,
|
||||||
connection: "HarlequinOdbcConnection",
|
connection: "HarlequinOdbcConnection",
|
||||||
children: list[CatalogItem] | None = None,
|
children: list[CatalogItem] | None = None,
|
||||||
|
include_db_in_identifier: bool = True,
|
||||||
) -> "SchemaCatalogItem":
|
) -> "SchemaCatalogItem":
|
||||||
schema_identifier = f'"{label}"'
|
schema_identifier = f'"{label}"'
|
||||||
if children is None:
|
if children is None:
|
||||||
children = []
|
children = []
|
||||||
|
qualified_identifier = schema_identifier
|
||||||
|
if include_db_in_identifier:
|
||||||
|
qualified_identifier = f'"{db_label}".{schema_identifier}'
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
qualified_identifier=f'"{db_label}".{schema_identifier}',
|
qualified_identifier=qualified_identifier,
|
||||||
query_name=schema_identifier,
|
query_name=schema_identifier,
|
||||||
label=label,
|
label=label,
|
||||||
db_label=db_label,
|
db_label=db_label,
|
||||||
|
|
|
||||||
100
tests/test_oracle_catalog.py
Normal file
100
tests/test_oracle_catalog.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pyodbc
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from harlequin_odbc.adapter import HarlequinOdbcConnection
|
||||||
|
from harlequin_odbc.catalog import (
|
||||||
|
DatabaseCatalogItem,
|
||||||
|
SchemaCatalogItem,
|
||||||
|
TableCatalogItem,
|
||||||
|
ViewCatalogItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.executed: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
self.rows: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def execute(self, sql: str, *params: object) -> FakeCursor:
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
normalized_sql = sql.lower()
|
||||||
|
if "sys_context" in normalized_sql:
|
||||||
|
self.rows = [("ORCL",)]
|
||||||
|
elif "all_tab_columns" in normalized_sql:
|
||||||
|
self.rows = [("EMPLOYEE_ID", "NUMBER(6,0)"), ("FIRST_NAME", "VARCHAR2(20)")]
|
||||||
|
elif "all_tables" in normalized_sql and "all_views" in normalized_sql:
|
||||||
|
self.rows = [("HR", "EMPLOYEES", "TABLE"), ("HR", "EMPLOYEE_VIEW", "VIEW")]
|
||||||
|
else:
|
||||||
|
self.rows = []
|
||||||
|
return self
|
||||||
|
|
||||||
|
def fetchval(self) -> str | None:
|
||||||
|
if not self.rows:
|
||||||
|
return None
|
||||||
|
return self.rows[0][0]
|
||||||
|
|
||||||
|
def tables(self, *_args: object, **_kwargs: object) -> object:
|
||||||
|
raise AssertionError("Oracle catalog should not call SQLTables")
|
||||||
|
|
||||||
|
def columns(self, *_args: object, **_kwargs: object) -> object:
|
||||||
|
raise AssertionError("Oracle catalog should not call SQLColumns")
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[tuple[str, ...]]:
|
||||||
|
return iter(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cursor_instance = FakeCursor()
|
||||||
|
|
||||||
|
def cursor(self) -> FakeCursor:
|
||||||
|
return self.cursor_instance
|
||||||
|
|
||||||
|
def getinfo(self, info_type: int) -> str:
|
||||||
|
if info_type == pyodbc.SQL_DBMS_NAME:
|
||||||
|
return "Oracle"
|
||||||
|
if info_type == pyodbc.SQL_DATABASE_NAME:
|
||||||
|
return "ORCL"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_oracle_catalog_uses_data_dictionary(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
fake_connection = FakeConnection()
|
||||||
|
|
||||||
|
def connect(*_args: object, **_kwargs: object) -> FakeConnection:
|
||||||
|
return fake_connection
|
||||||
|
|
||||||
|
monkeypatch.setattr(pyodbc, "connect", connect)
|
||||||
|
connection = HarlequinOdbcConnection(("Driver={Oracle};",))
|
||||||
|
|
||||||
|
catalog = connection.get_catalog()
|
||||||
|
|
||||||
|
[database_item] = catalog.items
|
||||||
|
assert isinstance(database_item, DatabaseCatalogItem)
|
||||||
|
assert database_item.label == "ORCL"
|
||||||
|
[schema_item] = database_item.children
|
||||||
|
assert isinstance(schema_item, SchemaCatalogItem)
|
||||||
|
assert schema_item.label == "HR"
|
||||||
|
|
||||||
|
[table_item, view_item] = schema_item.children
|
||||||
|
assert isinstance(table_item, TableCatalogItem)
|
||||||
|
assert table_item.label == "EMPLOYEES"
|
||||||
|
assert table_item.query_name == '"HR"."EMPLOYEES"'
|
||||||
|
assert isinstance(view_item, ViewCatalogItem)
|
||||||
|
assert view_item.label == "EMPLOYEE_VIEW"
|
||||||
|
|
||||||
|
column_items = table_item.fetch_children()
|
||||||
|
assert [(item.label, item.type_label) for item in column_items] == [
|
||||||
|
("EMPLOYEE_ID", "NUMBER(6,0)"),
|
||||||
|
("FIRST_NAME", "VARCHAR2(20)"),
|
||||||
|
]
|
||||||
|
assert fake_connection.cursor_instance.executed[-1][1] == ("HR", "EMPLOYEES")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue