sqlrooms 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlrooms/cli.py +576 -0
- sqlrooms/web/__init__.py +0 -0
- sqlrooms/web/db_bridge/__init__.py +28 -0
- sqlrooms/web/db_bridge/connectors/__init__.py +9 -0
- sqlrooms/web/db_bridge/connectors/base.py +59 -0
- sqlrooms/web/db_bridge/connectors/postgres.py +118 -0
- sqlrooms/web/db_bridge/connectors/snowflake.py +161 -0
- sqlrooms/web/db_bridge/factory.py +91 -0
- sqlrooms/web/db_bridge/registry.py +113 -0
- sqlrooms/web/db_bridge/types.py +29 -0
- sqlrooms/web/db_bridge/utils.py +45 -0
- sqlrooms/web/launcher.py +1215 -0
- sqlrooms/web/static/assets/AiSlice-x2gVCmwI.js +137 -0
- sqlrooms/web/static/assets/CommandSlice-DPSuuiIV.js +23 -0
- sqlrooms/web/static/assets/DockLayout-DhgcIQET.js +1 -0
- sqlrooms/web/static/assets/GridLayout-CBVgs-6H.css +1 -0
- sqlrooms/web/static/assets/GridLayout-fXJZYHbE.js +253 -0
- sqlrooms/web/static/assets/LayoutRendererContext-BKO2wB-W.js +1 -0
- sqlrooms/web/static/assets/LeafLayout-DPFHUP6B.js +1 -0
- sqlrooms/web/static/assets/LeafLayout-ekhNDEEg.js +1 -0
- sqlrooms/web/static/assets/RenderNodeContext-BdrX8FaE.js +1 -0
- sqlrooms/web/static/assets/RendererSwitcher-DnVbhqg4.js +1 -0
- sqlrooms/web/static/assets/SplitLayout-fPLAPJN-.js +1 -0
- sqlrooms/web/static/assets/TabsLayout-C0N-7wmx.js +1 -0
- sqlrooms/web/static/assets/TabsLayout-T3iApyr5.js +41 -0
- sqlrooms/web/static/assets/chunk-jRWAZmH_.js +1 -0
- sqlrooms/web/static/assets/codicon-ngg6Pgfi.ttf +0 -0
- sqlrooms/web/static/assets/core.esm-DdCldPzV.js +5 -0
- sqlrooms/web/static/assets/css.worker-Wv5dxAWO.js +89 -0
- sqlrooms/web/static/assets/devtools-BNUn8Jb2.js +2 -0
- sqlrooms/web/static/assets/dist-dwKeDPoe.js +1 -0
- sqlrooms/web/static/assets/html.worker-CQP8QQsS.js +502 -0
- sqlrooms/web/static/assets/index-D9UP9D4f.js +316286 -0
- sqlrooms/web/static/assets/index-DioDnqnf.css +1 -0
- sqlrooms/web/static/assets/json.worker-DzV-CpCQ.js +58 -0
- sqlrooms/web/static/assets/loro_wasm_bg-DP4dC0x3.wasm +0 -0
- sqlrooms/web/static/assets/loro_wasm_bg-VQ4j4Qa9.js +9 -0
- sqlrooms/web/static/assets/loro_wasm_bg-oL0xMWtE.js +3630 -0
- sqlrooms/web/static/assets/maplibre-gl-C-a91wbz.js +748 -0
- sqlrooms/web/static/assets/node-sql-parser-ChfKIXD7.js +68 -0
- sqlrooms/web/static/assets/prop-types-DybOnnvg.js +1 -0
- sqlrooms/web/static/assets/react-dom-liMHu8hH.js +1 -0
- sqlrooms/web/static/assets/resizable-DYr7VLR3.js +1 -0
- sqlrooms/web/static/assets/scroll-area-ZmzNHGEm.js +1 -0
- sqlrooms/web/static/assets/tooltip-mgpsA9tW.js +1 -0
- sqlrooms/web/static/assets/ts.worker-Dth06zuC.js +67734 -0
- sqlrooms/web/static/assets/utils-yJ4l7ARz.js +1 -0
- sqlrooms/web/static/assets/webgl-device-CgQl7NRd.js +1 -0
- sqlrooms/web/static/assets/webgl-device-CtgDFnYR.js +13 -0
- sqlrooms/web/static/index.html +32 -0
- sqlrooms/web/static/logo.png +0 -0
- sqlrooms/web/ui.py +37 -0
- sqlrooms-0.1.0.dist-info/METADATA +274 -0
- sqlrooms-0.1.0.dist-info/RECORD +57 -0
- sqlrooms-0.1.0.dist-info/WHEEL +4 -0
- sqlrooms-0.1.0.dist-info/entry_points.txt +2 -0
- sqlrooms-0.1.0.dist-info/licenses/LICENSE +9 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from .base import BaseSqlBridgeConnector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PostgresConnectorSettings:
|
|
13
|
+
connection_id: str = "postgres-default"
|
|
14
|
+
title: str = "Postgres"
|
|
15
|
+
host: str = "localhost"
|
|
16
|
+
port: str = "5432"
|
|
17
|
+
database: str = ""
|
|
18
|
+
user: str = ""
|
|
19
|
+
password: str | None = None
|
|
20
|
+
|
|
21
|
+
def resolve_dsn(self) -> str:
|
|
22
|
+
pw = f":{quote(self.password, safe='')}" if self.password else ""
|
|
23
|
+
return f"postgresql://{quote(self.user, safe='')}{pw}@{self.host}:{self.port}/{self.database}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PostgresBridgeConnector(BaseSqlBridgeConnector):
|
|
28
|
+
settings: PostgresConnectorSettings
|
|
29
|
+
engine_id: str = "postgres"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def connection_id(self) -> str:
|
|
33
|
+
return self.settings.connection_id
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def title(self) -> str:
|
|
37
|
+
return self.settings.title
|
|
38
|
+
|
|
39
|
+
def config_dict(self) -> dict[str, Any]:
|
|
40
|
+
s = self.settings
|
|
41
|
+
d: dict[str, Any] = {}
|
|
42
|
+
for k in ("host", "port", "database", "user", "password"):
|
|
43
|
+
v = getattr(s, k)
|
|
44
|
+
if v:
|
|
45
|
+
d[k] = v
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
def _connect(self):
|
|
49
|
+
try:
|
|
50
|
+
import psycopg # type: ignore
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"Postgres bridge requires `psycopg`. Install it to enable Postgres."
|
|
54
|
+
) from exc
|
|
55
|
+
return psycopg.connect(self.settings.resolve_dsn())
|
|
56
|
+
|
|
57
|
+
def dependency_diagnostics(self) -> dict[str, Any]:
|
|
58
|
+
available = importlib.util.find_spec("psycopg") is not None
|
|
59
|
+
if available:
|
|
60
|
+
return {"available": True}
|
|
61
|
+
return {
|
|
62
|
+
"available": False,
|
|
63
|
+
"error": "Missing Python dependency: psycopg",
|
|
64
|
+
"requiredPackages": ["psycopg[binary]>=3.2.0"],
|
|
65
|
+
"installCommands": {
|
|
66
|
+
"uvProject": "uv sync --extra postgres",
|
|
67
|
+
"uvxRelaunch": (
|
|
68
|
+
'uvx --from "sqlrooms[postgres]" sqlrooms --db-path :memory:'
|
|
69
|
+
),
|
|
70
|
+
"uvxWith": (
|
|
71
|
+
'uvx --from sqlrooms --with "psycopg[binary]>=3.2.0" '
|
|
72
|
+
"sqlrooms --db-path :memory:"
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def list_catalog(self) -> dict[str, list[dict[str, Any]]]:
|
|
78
|
+
with self._connect() as conn:
|
|
79
|
+
with conn.cursor() as cur:
|
|
80
|
+
cur.execute("SELECT current_database()")
|
|
81
|
+
result = cur.fetchone()
|
|
82
|
+
if result is None:
|
|
83
|
+
raise RuntimeError(
|
|
84
|
+
"Postgres bridge could not resolve current database."
|
|
85
|
+
)
|
|
86
|
+
current_db = result[0]
|
|
87
|
+
cur.execute(
|
|
88
|
+
"""
|
|
89
|
+
SELECT schema_name
|
|
90
|
+
FROM information_schema.schemata
|
|
91
|
+
ORDER BY schema_name
|
|
92
|
+
"""
|
|
93
|
+
)
|
|
94
|
+
schemas = [
|
|
95
|
+
{"database": current_db, "schema": row[0]} for row in cur.fetchall()
|
|
96
|
+
]
|
|
97
|
+
cur.execute(
|
|
98
|
+
"""
|
|
99
|
+
SELECT table_schema, table_name, table_type
|
|
100
|
+
FROM information_schema.tables
|
|
101
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
102
|
+
ORDER BY table_schema, table_name
|
|
103
|
+
"""
|
|
104
|
+
)
|
|
105
|
+
tables = [
|
|
106
|
+
{
|
|
107
|
+
"database": current_db,
|
|
108
|
+
"schema": row[0],
|
|
109
|
+
"table": row[1],
|
|
110
|
+
"isView": str(row[2]).upper().endswith("VIEW"),
|
|
111
|
+
}
|
|
112
|
+
for row in cur.fetchall()
|
|
113
|
+
]
|
|
114
|
+
return {
|
|
115
|
+
"databases": [{"database": current_db}],
|
|
116
|
+
"schemas": schemas,
|
|
117
|
+
"tables": tables,
|
|
118
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..utils import cursor_columns, quoted_ident, rows_to_json_rows
|
|
8
|
+
from .base import BaseSqlBridgeConnector
|
|
9
|
+
|
|
10
|
+
SNOWFLAKE_INSTALL_COMMANDS = {
|
|
11
|
+
"uvProject": "uv sync --extra snowflake",
|
|
12
|
+
"uvxRelaunch": 'uvx --from "sqlrooms[snowflake]" sqlrooms --db-path :memory:',
|
|
13
|
+
"uvxWith": (
|
|
14
|
+
'uvx --from sqlrooms --with "snowflake-connector-python>=4.3.0" '
|
|
15
|
+
"sqlrooms --db-path :memory:"
|
|
16
|
+
),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SnowflakeConnectorSettings:
|
|
22
|
+
account: str | None = None
|
|
23
|
+
user: str | None = None
|
|
24
|
+
password: str | None = None
|
|
25
|
+
warehouse: str | None = None
|
|
26
|
+
database: str | None = None
|
|
27
|
+
schema: str | None = None
|
|
28
|
+
role: str | None = None
|
|
29
|
+
authenticator: str | None = None
|
|
30
|
+
connection_id: str = "snowflake-default"
|
|
31
|
+
title: str = "Snowflake"
|
|
32
|
+
|
|
33
|
+
def is_enabled(self) -> bool:
|
|
34
|
+
return bool(self.account and self.user)
|
|
35
|
+
|
|
36
|
+
def to_connect_kwargs(self) -> dict[str, Any]:
|
|
37
|
+
kwargs: dict[str, Any] = {}
|
|
38
|
+
for key in (
|
|
39
|
+
"account",
|
|
40
|
+
"user",
|
|
41
|
+
"password",
|
|
42
|
+
"warehouse",
|
|
43
|
+
"database",
|
|
44
|
+
"schema",
|
|
45
|
+
"role",
|
|
46
|
+
"authenticator",
|
|
47
|
+
):
|
|
48
|
+
value = getattr(self, key)
|
|
49
|
+
if value:
|
|
50
|
+
kwargs[key] = value
|
|
51
|
+
return kwargs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class SnowflakeBridgeConnector(BaseSqlBridgeConnector):
|
|
56
|
+
settings: SnowflakeConnectorSettings
|
|
57
|
+
engine_id: str = "snowflake"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def connection_id(self) -> str:
|
|
61
|
+
return self.settings.connection_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def title(self) -> str:
|
|
65
|
+
return self.settings.title
|
|
66
|
+
|
|
67
|
+
def config_dict(self) -> dict[str, Any]:
|
|
68
|
+
return self.settings.to_connect_kwargs()
|
|
69
|
+
|
|
70
|
+
def _connect(self):
|
|
71
|
+
try:
|
|
72
|
+
import snowflake.connector # type: ignore
|
|
73
|
+
except ImportError as exc:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"Snowflake bridge requires `snowflake-connector-python`."
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
kwargs = self.settings.to_connect_kwargs()
|
|
79
|
+
if not kwargs.get("account") or not kwargs.get("user"):
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"Snowflake bridge requires both account and user configuration."
|
|
82
|
+
)
|
|
83
|
+
return snowflake.connector.connect(**kwargs)
|
|
84
|
+
|
|
85
|
+
def dependency_diagnostics(self) -> dict[str, Any]:
|
|
86
|
+
try:
|
|
87
|
+
available = importlib.util.find_spec("snowflake.connector") is not None
|
|
88
|
+
except ModuleNotFoundError as exc:
|
|
89
|
+
return {
|
|
90
|
+
"available": False,
|
|
91
|
+
"reason": (
|
|
92
|
+
f"Failed to inspect dependency via "
|
|
93
|
+
f"find_spec('snowflake.connector'): {exc}"
|
|
94
|
+
),
|
|
95
|
+
"error": "Missing Python dependency: snowflake-connector-python",
|
|
96
|
+
"requiredPackages": ["snowflake-connector-python>=4.3.0"],
|
|
97
|
+
"installCommands": SNOWFLAKE_INSTALL_COMMANDS,
|
|
98
|
+
}
|
|
99
|
+
if available:
|
|
100
|
+
return {"available": True}
|
|
101
|
+
return {
|
|
102
|
+
"available": False,
|
|
103
|
+
"error": "Missing Python dependency: snowflake-connector-python",
|
|
104
|
+
"requiredPackages": ["snowflake-connector-python>=4.3.0"],
|
|
105
|
+
"installCommands": SNOWFLAKE_INSTALL_COMMANDS,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def list_catalog(self) -> dict[str, list[dict[str, Any]]]:
|
|
109
|
+
with self._connect() as conn:
|
|
110
|
+
with conn.cursor() as cur:
|
|
111
|
+
cur.execute("SHOW DATABASES")
|
|
112
|
+
db_rows = cur.fetchall()
|
|
113
|
+
db_columns = cursor_columns(cur)
|
|
114
|
+
databases: list[dict[str, Any]] = []
|
|
115
|
+
for row in rows_to_json_rows(db_rows, db_columns):
|
|
116
|
+
normalized = {str(k).lower(): v for k, v in row.items()}
|
|
117
|
+
name = normalized.get("name") or normalized.get("database_name")
|
|
118
|
+
if name:
|
|
119
|
+
databases.append({"database": str(name)})
|
|
120
|
+
|
|
121
|
+
current_db = self.settings.database
|
|
122
|
+
if not current_db:
|
|
123
|
+
cur.execute("SELECT CURRENT_DATABASE()")
|
|
124
|
+
maybe_db = cur.fetchone()
|
|
125
|
+
if maybe_db and maybe_db[0]:
|
|
126
|
+
current_db = str(maybe_db[0])
|
|
127
|
+
|
|
128
|
+
schemas: list[dict[str, Any]] = []
|
|
129
|
+
tables: list[dict[str, Any]] = []
|
|
130
|
+
|
|
131
|
+
if current_db:
|
|
132
|
+
db_ref = quoted_ident(current_db)
|
|
133
|
+
cur.execute(
|
|
134
|
+
f"""
|
|
135
|
+
SELECT SCHEMA_NAME
|
|
136
|
+
FROM {db_ref}.INFORMATION_SCHEMA.SCHEMATA
|
|
137
|
+
ORDER BY SCHEMA_NAME
|
|
138
|
+
"""
|
|
139
|
+
)
|
|
140
|
+
schemas = [
|
|
141
|
+
{"database": current_db, "schema": row[0]}
|
|
142
|
+
for row in cur.fetchall()
|
|
143
|
+
]
|
|
144
|
+
cur.execute(
|
|
145
|
+
f"""
|
|
146
|
+
SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE
|
|
147
|
+
FROM {db_ref}.INFORMATION_SCHEMA.TABLES
|
|
148
|
+
ORDER BY TABLE_SCHEMA, TABLE_NAME
|
|
149
|
+
"""
|
|
150
|
+
)
|
|
151
|
+
tables = [
|
|
152
|
+
{
|
|
153
|
+
"database": current_db,
|
|
154
|
+
"schema": row[0],
|
|
155
|
+
"table": row[1],
|
|
156
|
+
"isView": str(row[2]).upper().endswith("VIEW"),
|
|
157
|
+
}
|
|
158
|
+
for row in cur.fetchall()
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
return {"databases": databases, "schemas": schemas, "tables": tables}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .connectors import (
|
|
6
|
+
PostgresBridgeConnector,
|
|
7
|
+
PostgresConnectorSettings,
|
|
8
|
+
SnowflakeBridgeConnector,
|
|
9
|
+
SnowflakeConnectorSettings,
|
|
10
|
+
)
|
|
11
|
+
from .connectors.base import BaseSqlBridgeConnector
|
|
12
|
+
from .registry import DbBridgeRegistry
|
|
13
|
+
|
|
14
|
+
SUPPORTED_ENGINES: list[str] = ["postgres", "snowflake"]
|
|
15
|
+
|
|
16
|
+
ENGINE_CONFIG_FIELDS: dict[str, list[dict[str, Any]]] = {
|
|
17
|
+
"postgres": [
|
|
18
|
+
{"key": "host", "label": "Host", "placeholder": "localhost", "required": True},
|
|
19
|
+
{"key": "port", "label": "Port", "placeholder": "5432"},
|
|
20
|
+
{
|
|
21
|
+
"key": "database",
|
|
22
|
+
"label": "Database",
|
|
23
|
+
"placeholder": "my_db",
|
|
24
|
+
"required": True,
|
|
25
|
+
},
|
|
26
|
+
{"key": "user", "label": "User", "placeholder": "postgres", "required": True},
|
|
27
|
+
{"key": "password", "label": "Password", "placeholder": "", "secret": True},
|
|
28
|
+
],
|
|
29
|
+
"snowflake": [
|
|
30
|
+
{
|
|
31
|
+
"key": "account",
|
|
32
|
+
"label": "Account",
|
|
33
|
+
"placeholder": "xy12345.us-east-1",
|
|
34
|
+
"required": True,
|
|
35
|
+
},
|
|
36
|
+
{"key": "user", "label": "User", "placeholder": "my_user", "required": True},
|
|
37
|
+
{"key": "password", "label": "Password", "placeholder": "", "secret": True},
|
|
38
|
+
{"key": "warehouse", "label": "Warehouse", "placeholder": "COMPUTE_WH"},
|
|
39
|
+
{"key": "database", "label": "Database", "placeholder": "MY_DB"},
|
|
40
|
+
{"key": "schema", "label": "Schema", "placeholder": "PUBLIC"},
|
|
41
|
+
{"key": "role", "label": "Role", "placeholder": "ACCOUNTADMIN"},
|
|
42
|
+
{"key": "authenticator", "label": "Authenticator", "placeholder": "snowflake"},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_cli_db_bridge_registry(
|
|
48
|
+
*,
|
|
49
|
+
bridge_id: str,
|
|
50
|
+
connector_settings: list[PostgresConnectorSettings | SnowflakeConnectorSettings]
|
|
51
|
+
| None = None,
|
|
52
|
+
) -> DbBridgeRegistry:
|
|
53
|
+
registry = DbBridgeRegistry(bridge_id=bridge_id)
|
|
54
|
+
for settings in connector_settings or []:
|
|
55
|
+
if isinstance(settings, PostgresConnectorSettings):
|
|
56
|
+
registry.register(PostgresBridgeConnector(settings=settings))
|
|
57
|
+
continue
|
|
58
|
+
if isinstance(settings, SnowflakeConnectorSettings) and settings.is_enabled():
|
|
59
|
+
registry.register(SnowflakeBridgeConnector(settings=settings))
|
|
60
|
+
return registry
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_ephemeral_connector(
|
|
64
|
+
engine: str,
|
|
65
|
+
config: dict[str, str],
|
|
66
|
+
) -> BaseSqlBridgeConnector:
|
|
67
|
+
"""Create a throwaway connector from raw config for ad-hoc connection tests."""
|
|
68
|
+
if engine == "postgres":
|
|
69
|
+
return PostgresBridgeConnector(
|
|
70
|
+
settings=PostgresConnectorSettings(
|
|
71
|
+
host=config.get("host", "localhost"),
|
|
72
|
+
port=config.get("port", "5432"),
|
|
73
|
+
database=config.get("database", ""),
|
|
74
|
+
user=config.get("user", ""),
|
|
75
|
+
password=config.get("password"),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
if engine == "snowflake":
|
|
79
|
+
return SnowflakeBridgeConnector(
|
|
80
|
+
settings=SnowflakeConnectorSettings(
|
|
81
|
+
account=config.get("account"),
|
|
82
|
+
user=config.get("user"),
|
|
83
|
+
password=config.get("password"),
|
|
84
|
+
warehouse=config.get("warehouse"),
|
|
85
|
+
database=config.get("database"),
|
|
86
|
+
schema=config.get("schema"),
|
|
87
|
+
role=config.get("role"),
|
|
88
|
+
authenticator=config.get("authenticator"),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
raise ValueError(f"Unsupported engine: {engine!r}")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .types import DbBridgeConnector
|
|
6
|
+
|
|
7
|
+
_SECRET_KEYS: frozenset[str] | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_secret_keys() -> frozenset[str]:
|
|
11
|
+
global _SECRET_KEYS
|
|
12
|
+
if _SECRET_KEYS is None:
|
|
13
|
+
from .factory import ENGINE_CONFIG_FIELDS
|
|
14
|
+
|
|
15
|
+
keys: set[str] = set()
|
|
16
|
+
for fields in ENGINE_CONFIG_FIELDS.values():
|
|
17
|
+
for f in fields:
|
|
18
|
+
if f.get("secret"):
|
|
19
|
+
keys.add(f["key"])
|
|
20
|
+
_SECRET_KEYS = frozenset(keys)
|
|
21
|
+
return _SECRET_KEYS
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnknownBridgeConnectionError(KeyError):
|
|
25
|
+
"""Raised when a bridge request references an unknown connection id."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DbBridgeRegistry:
|
|
29
|
+
def __init__(self, *, bridge_id: str):
|
|
30
|
+
self.bridge_id = bridge_id
|
|
31
|
+
self._connectors: dict[str, DbBridgeConnector] = {}
|
|
32
|
+
|
|
33
|
+
def register(self, connector: DbBridgeConnector) -> None:
|
|
34
|
+
if connector.connection_id in self._connectors:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Duplicate DB bridge connection id: {connector.connection_id}"
|
|
37
|
+
)
|
|
38
|
+
self._connectors[connector.connection_id] = connector
|
|
39
|
+
|
|
40
|
+
def has_connections(self) -> bool:
|
|
41
|
+
return bool(self._connectors)
|
|
42
|
+
|
|
43
|
+
def has_engine(self, engine_id: str) -> bool:
|
|
44
|
+
return any(conn.engine_id == engine_id for conn in self._connectors.values())
|
|
45
|
+
|
|
46
|
+
def runtime_connections(self) -> list[dict[str, Any]]:
|
|
47
|
+
result = []
|
|
48
|
+
for conn in self._connectors.values():
|
|
49
|
+
entry: dict[str, Any] = {
|
|
50
|
+
"id": conn.connection_id,
|
|
51
|
+
"engineId": conn.engine_id,
|
|
52
|
+
"title": conn.title,
|
|
53
|
+
"runtimeSupport": "server",
|
|
54
|
+
"requiresBridge": True,
|
|
55
|
+
"bridgeId": self.bridge_id,
|
|
56
|
+
"isCore": False,
|
|
57
|
+
}
|
|
58
|
+
cfg = conn.config_dict()
|
|
59
|
+
if cfg:
|
|
60
|
+
secret_keys = _get_secret_keys()
|
|
61
|
+
entry["config"] = {k: v for k, v in cfg.items() if k not in secret_keys}
|
|
62
|
+
result.append(entry)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def runtime_diagnostics(self) -> list[dict[str, Any]]:
|
|
66
|
+
diagnostics: list[dict[str, Any]] = []
|
|
67
|
+
for conn in self._connectors.values():
|
|
68
|
+
payload = conn.dependency_diagnostics()
|
|
69
|
+
payload["id"] = conn.connection_id
|
|
70
|
+
payload["engineId"] = conn.engine_id
|
|
71
|
+
payload["title"] = conn.title
|
|
72
|
+
diagnostics.append(payload)
|
|
73
|
+
return diagnostics
|
|
74
|
+
|
|
75
|
+
def test_connection(self, connection_id: str) -> bool:
|
|
76
|
+
return self._get_connector(connection_id).test_connection()
|
|
77
|
+
|
|
78
|
+
def list_catalog(self, connection_id: str) -> dict[str, list[dict[str, Any]]]:
|
|
79
|
+
return self._get_connector(connection_id).list_catalog()
|
|
80
|
+
|
|
81
|
+
def execute_query(
|
|
82
|
+
self,
|
|
83
|
+
connection_id: str,
|
|
84
|
+
sql: str,
|
|
85
|
+
query_type: str,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
return self._get_connector(connection_id).execute_query(sql, query_type)
|
|
88
|
+
|
|
89
|
+
def fetch_arrow_bytes(self, connection_id: str, sql: str) -> bytes:
|
|
90
|
+
return self._get_connector(connection_id).fetch_arrow_bytes(sql)
|
|
91
|
+
|
|
92
|
+
def stream_arrow_batches(
|
|
93
|
+
self,
|
|
94
|
+
connection_id: str,
|
|
95
|
+
sql: str,
|
|
96
|
+
*,
|
|
97
|
+
chunk_rows: int = 5000,
|
|
98
|
+
query_id: str | None = None,
|
|
99
|
+
):
|
|
100
|
+
return self._get_connector(connection_id).stream_arrow_batches(
|
|
101
|
+
sql, chunk_rows=chunk_rows, query_id=query_id
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def cancel_query(self, connection_id: str, query_id: str) -> bool:
|
|
105
|
+
return self._get_connector(connection_id).cancel_query(query_id)
|
|
106
|
+
|
|
107
|
+
def _get_connector(self, connection_id: str) -> DbBridgeConnector:
|
|
108
|
+
connector = self._connectors.get(connection_id)
|
|
109
|
+
if connector is None:
|
|
110
|
+
raise UnknownBridgeConnectionError(
|
|
111
|
+
f"Unknown DB bridge connection: {connection_id}"
|
|
112
|
+
)
|
|
113
|
+
return connector
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DbBridgeConnector(Protocol):
|
|
7
|
+
connection_id: str
|
|
8
|
+
engine_id: str
|
|
9
|
+
title: str
|
|
10
|
+
|
|
11
|
+
def config_dict(self) -> dict[str, Any]:
|
|
12
|
+
"""Return engine-specific configuration as a flat dict."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def test_connection(self) -> bool: ...
|
|
16
|
+
|
|
17
|
+
def list_catalog(self) -> dict[str, list[dict[str, Any]]]: ...
|
|
18
|
+
|
|
19
|
+
def execute_query(self, sql: str, query_type: str) -> dict[str, Any]: ...
|
|
20
|
+
|
|
21
|
+
def fetch_arrow_bytes(self, sql: str) -> bytes: ...
|
|
22
|
+
|
|
23
|
+
def stream_arrow_batches(
|
|
24
|
+
self, sql: str, chunk_rows: int = 5000, query_id: str | None = None
|
|
25
|
+
) -> Iterable[bytes]: ...
|
|
26
|
+
|
|
27
|
+
def cancel_query(self, query_id: str) -> bool: ...
|
|
28
|
+
|
|
29
|
+
def dependency_diagnostics(self) -> dict[str, Any]: ...
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cursor_columns(cursor: Any) -> list[str]:
|
|
9
|
+
description = getattr(cursor, "description", None) or []
|
|
10
|
+
columns: list[str] = []
|
|
11
|
+
for item in description:
|
|
12
|
+
if hasattr(item, "name"):
|
|
13
|
+
columns.append(str(item.name))
|
|
14
|
+
elif isinstance(item, (list, tuple)) and item:
|
|
15
|
+
columns.append(str(item[0]))
|
|
16
|
+
return columns
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def rows_to_json_rows(
|
|
20
|
+
rows: Iterable[Iterable[Any]],
|
|
21
|
+
columns: list[str],
|
|
22
|
+
) -> list[dict[str, Any]]:
|
|
23
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rows_to_arrow_bytes(
|
|
27
|
+
rows: Iterable[Iterable[Any]],
|
|
28
|
+
columns: list[str],
|
|
29
|
+
) -> bytes:
|
|
30
|
+
json_rows = rows_to_json_rows(rows, columns)
|
|
31
|
+
if json_rows:
|
|
32
|
+
table = pa.Table.from_pylist(json_rows)
|
|
33
|
+
elif columns:
|
|
34
|
+
table = pa.table({column: pa.array([], type=pa.null()) for column in columns})
|
|
35
|
+
else:
|
|
36
|
+
table = pa.table({})
|
|
37
|
+
|
|
38
|
+
sink = pa.BufferOutputStream()
|
|
39
|
+
with pa.ipc.new_stream(sink, table.schema) as writer:
|
|
40
|
+
writer.write_table(table)
|
|
41
|
+
return sink.getvalue().to_pybytes()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def quoted_ident(ident: str) -> str:
|
|
45
|
+
return '"' + ident.replace('"', '""') + '"'
|