sqlbuddy-nwi 0.3.1__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.
- _version.py +10 -0
- cli.py +1164 -0
- connections/__init__.py +31 -0
- connections/config_util.py +159 -0
- connections/manager.py +205 -0
- connections/readonly.py +45 -0
- connections/runtime.py +51 -0
- connections/wrappers.py +105 -0
- db_query.py +735 -0
- db_security.py +146 -0
- init.py +528 -0
- server.py +275 -0
- sqlbuddy_nwi-0.3.1.dist-info/METADATA +705 -0
- sqlbuddy_nwi-0.3.1.dist-info/RECORD +20 -0
- sqlbuddy_nwi-0.3.1.dist-info/WHEEL +5 -0
- sqlbuddy_nwi-0.3.1.dist-info/entry_points.txt +3 -0
- sqlbuddy_nwi-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlbuddy_nwi-0.3.1.dist-info/top_level.txt +9 -0
- test_vendor.py +50 -0
- tunnel.py +345 -0
connections/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Connection management for sql-buddy.
|
|
2
|
+
|
|
3
|
+
Public surface::
|
|
4
|
+
|
|
5
|
+
from connections import DBManager, LockedDBManager, connection_type
|
|
6
|
+
|
|
7
|
+
db = LockedDBManager() # read-only guarded clients (the default)
|
|
8
|
+
ck = db.ck # a Locked ClickHouse client
|
|
9
|
+
mysql = db.mysql # a Locked MySQL client
|
|
10
|
+
|
|
11
|
+
`LockedDBManager` is what the MCP server uses: every client it hands out is
|
|
12
|
+
wrapped by the read-only guard, so write keywords can never reach the network.
|
|
13
|
+
`DBManager` returns raw clients (used by tooling that supplies its own guard).
|
|
14
|
+
|
|
15
|
+
Implementation lives in focused submodules:
|
|
16
|
+
readonly - the SQL read-only filter
|
|
17
|
+
runtime - WSL / container / Windows-host-IP detection
|
|
18
|
+
wrappers - raw + Locked client wrappers
|
|
19
|
+
manager - DBManager / LockedDBManager + connection_type
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .manager import DBManager, LockedDBManager, connection_type
|
|
23
|
+
from .runtime import is_container, is_wsl
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'DBManager',
|
|
27
|
+
'LockedDBManager',
|
|
28
|
+
'connection_type',
|
|
29
|
+
'is_wsl',
|
|
30
|
+
'is_container',
|
|
31
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Shared config helpers for reading db.ini with cross-platform encoding support.
|
|
2
|
+
|
|
3
|
+
All modules that touch db.ini resolve its location and parse it through the
|
|
4
|
+
helpers here, never duplicating the path arithmetic.
|
|
5
|
+
|
|
6
|
+
Config-location resolution
|
|
7
|
+
(see docs/adr/0002-config-discovery-and-invocation-mode-on-publish.md):
|
|
8
|
+
config_path() - first existing candidate, for READ
|
|
9
|
+
config_write_path() - the detected home, for WRITE (the init wizard)
|
|
10
|
+
precedence: $SQL_BUDDY_CONFIG > platformdirs user dir > checkout-relative
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import configparser
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
APP_NAME = "sql-buddy"
|
|
18
|
+
_CONFIG_FILENAME = "db.ini"
|
|
19
|
+
_ENV_OVERRIDE = "SQL_BUDDY_CONFIG"
|
|
20
|
+
|
|
21
|
+
# config_util.py ships inside the `connections` package, so the checkout-relative
|
|
22
|
+
# config lives at <this dir>/config/db.ini.
|
|
23
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
24
|
+
_CHECKOUT_CONFIG_PATH = os.path.join(_HERE, "config", _CONFIG_FILENAME)
|
|
25
|
+
# A checkout is signalled by a `.git` entry at the repo root (a directory for a
|
|
26
|
+
# plain clone, a file for a linked worktree), or by a writable config dir
|
|
27
|
+
# holding the example. Wheels/sdists include neither, so installed copies never
|
|
28
|
+
# match here and correctly fall through to the per-user config dir.
|
|
29
|
+
_REPO_ROOT = os.path.dirname(_HERE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _env_config_path():
|
|
33
|
+
"""$SQL_BUDDY_CONFIG may name the file directly or a directory holding it."""
|
|
34
|
+
raw = os.environ.get(_ENV_OVERRIDE)
|
|
35
|
+
if not raw:
|
|
36
|
+
return None
|
|
37
|
+
p = os.path.expanduser(raw)
|
|
38
|
+
if os.path.isdir(p):
|
|
39
|
+
return os.path.join(p, _CONFIG_FILENAME)
|
|
40
|
+
return p
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _user_config_path():
|
|
44
|
+
"""Per-user config dir, or None if platformdirs isn't importable.
|
|
45
|
+
|
|
46
|
+
Returning None lets config_path()/config_write_path() degrade to the
|
|
47
|
+
checkout-relative path when the dependency is somehow absent, so a
|
|
48
|
+
platformdirs install failure can never break an existing checkout user.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
import platformdirs
|
|
52
|
+
except ImportError:
|
|
53
|
+
return None
|
|
54
|
+
return os.path.join(platformdirs.user_config_dir(APP_NAME), _CONFIG_FILENAME)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_checkout() -> bool:
|
|
58
|
+
"""Whether sql-buddy is running from a source checkout vs an install.
|
|
59
|
+
|
|
60
|
+
Public so the CLI's invocation-mode detection can reuse the same signal
|
|
61
|
+
(see cli._mcp_entry). See _is_checkout docstring location for the rationale.
|
|
62
|
+
"""
|
|
63
|
+
if os.path.exists(os.path.join(_REPO_ROOT, ".git")):
|
|
64
|
+
return True
|
|
65
|
+
cfg_dir = os.path.join(_HERE, "config")
|
|
66
|
+
example = os.path.join(cfg_dir, "db.ini.example")
|
|
67
|
+
return os.path.isfile(example) and os.access(cfg_dir, os.W_OK)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def config_write_path() -> str:
|
|
71
|
+
"""Where the init wizard should write db.ini.
|
|
72
|
+
|
|
73
|
+
$SQL_BUDDY_CONFIG wins; otherwise a checkout writes checkout-relative (dev
|
|
74
|
+
back-compat) and an install writes to the per-user config dir.
|
|
75
|
+
"""
|
|
76
|
+
env = _env_config_path()
|
|
77
|
+
if env is not None:
|
|
78
|
+
return env
|
|
79
|
+
if is_checkout():
|
|
80
|
+
return _CHECKOUT_CONFIG_PATH
|
|
81
|
+
return _user_config_path() or _CHECKOUT_CONFIG_PATH
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def config_path() -> str:
|
|
85
|
+
"""Where to READ db.ini from.
|
|
86
|
+
|
|
87
|
+
$SQL_BUDDY_CONFIG is strict (an explicit override). Otherwise the first
|
|
88
|
+
existing candidate among the per-user dir and the checkout-relative path. If
|
|
89
|
+
none exist yet, returns config_write_path() so callers can report the place
|
|
90
|
+
the user should create.
|
|
91
|
+
"""
|
|
92
|
+
env = _env_config_path()
|
|
93
|
+
if env is not None:
|
|
94
|
+
return env
|
|
95
|
+
for candidate in (_user_config_path(), _CHECKOUT_CONFIG_PATH):
|
|
96
|
+
if candidate is not None and os.path.isfile(candidate):
|
|
97
|
+
return candidate
|
|
98
|
+
return config_write_path()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Historical env override kept for back-compat: db_query used it as the root for
|
|
102
|
+
# ALL its caches (query_history + db_info_cache + db_schema_cache + table_list).
|
|
103
|
+
_CACHE_ENV_OVERRIDE = "DB_QUERY_HISTORY_DIR"
|
|
104
|
+
_CACHE_DIRNAME = "database"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _user_cache_root():
|
|
108
|
+
"""Per-user cache dir, or None if platformdirs isn't importable."""
|
|
109
|
+
try:
|
|
110
|
+
import platformdirs
|
|
111
|
+
except ImportError:
|
|
112
|
+
return None
|
|
113
|
+
return platformdirs.user_cache_dir(APP_NAME)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cache_dir_path() -> str:
|
|
117
|
+
"""Where sql-buddy writes runtime caches (query history, schema/info caches).
|
|
118
|
+
|
|
119
|
+
$DB_QUERY_HISTORY_DIR (historical override) > per-user cache dir (when not a
|
|
120
|
+
checkout) > the checkout-relative `database/` dir. Installed copies must
|
|
121
|
+
never write into the read-only package cache, so a non-checkout resolves to
|
|
122
|
+
the per-user cache dir; if platformdirs is somehow absent it falls back to
|
|
123
|
+
the checkout-relative path so a missing dep never crashes the server.
|
|
124
|
+
"""
|
|
125
|
+
env = os.environ.get(_CACHE_ENV_OVERRIDE)
|
|
126
|
+
if env:
|
|
127
|
+
return os.path.expanduser(env)
|
|
128
|
+
if is_checkout():
|
|
129
|
+
return os.path.join(_REPO_ROOT, _CACHE_DIRNAME)
|
|
130
|
+
return _user_cache_root() or os.path.join(_REPO_ROOT, _CACHE_DIRNAME)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_env(value: str) -> str:
|
|
134
|
+
if isinstance(value, str) and value.startswith('$env:'):
|
|
135
|
+
var_name = value[5:]
|
|
136
|
+
resolved = os.environ.get(var_name)
|
|
137
|
+
if resolved is None:
|
|
138
|
+
raise EnvironmentError(
|
|
139
|
+
f'[配置缺失] $env:{var_name} 未在环境中设置。请确认 .env 文件存在(参考 .env.example)或手动设置该环境变量。'
|
|
140
|
+
)
|
|
141
|
+
return resolved
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_config(config_file: str) -> configparser.RawConfigParser:
|
|
146
|
+
cfg = configparser.RawConfigParser()
|
|
147
|
+
with open(config_file, "rb") as f:
|
|
148
|
+
raw = f.read()
|
|
149
|
+
encodings = ["utf-8-sig", "utf-8", "gbk"]
|
|
150
|
+
if sys.platform == "win32":
|
|
151
|
+
encodings.append("mbcs")
|
|
152
|
+
for enc in encodings:
|
|
153
|
+
try:
|
|
154
|
+
cfg.read_string(raw.decode(enc))
|
|
155
|
+
return cfg
|
|
156
|
+
except UnicodeDecodeError:
|
|
157
|
+
continue
|
|
158
|
+
cfg.read_string(raw.decode("utf-8", errors="replace"))
|
|
159
|
+
return cfg
|
connections/manager.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""DBManager: builds and caches DB clients from db.ini sections.
|
|
2
|
+
|
|
3
|
+
`DBManager` returns raw driver clients; `LockedDBManager` returns clients
|
|
4
|
+
wrapped by the read-only guard (the "Locked" layer). Both honor the
|
|
5
|
+
self-managed SSH tunnel (`tunnel.py`): a tunneled connection connects to
|
|
6
|
+
`localhost:local_port`, and the cached client is rebuilt whenever the tunnel's
|
|
7
|
+
generation bumps (the previous TCP socket is dead after a restart).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import configparser
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import Any, Dict, Tuple
|
|
16
|
+
|
|
17
|
+
import pymysql
|
|
18
|
+
from clickhouse_driver import Client
|
|
19
|
+
|
|
20
|
+
from .config_util import read_config, resolve_env, config_path
|
|
21
|
+
from .runtime import get_windows_host_ip, is_wsl
|
|
22
|
+
from .wrappers import (
|
|
23
|
+
LockedClickHouseClient,
|
|
24
|
+
LockedMySQLConnectionWrapper,
|
|
25
|
+
MySQLConnectionWrapper,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# tunnel.py lives at the repo root; make sure it's importable regardless of cwd.
|
|
29
|
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
+
if _REPO_ROOT not in sys.path:
|
|
31
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
32
|
+
from tunnel import get_manager as _get_tunnel_manager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_REMOTE_DB_CREDS: Dict[str, Tuple[str, str]] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connection_type(cfg, section: str) -> str:
|
|
39
|
+
"""Resolve a connection's DBMS type.
|
|
40
|
+
|
|
41
|
+
Reads an explicit `type = clickhouse|mysql` from the section; falls back to
|
|
42
|
+
inferring from the section-name prefix so older configs keep working.
|
|
43
|
+
"""
|
|
44
|
+
explicit = cfg.get(section, 'type', fallback=None)
|
|
45
|
+
if explicit:
|
|
46
|
+
return explicit.strip().lower()
|
|
47
|
+
if section.startswith("ck"):
|
|
48
|
+
return 'clickhouse'
|
|
49
|
+
if section.startswith("db") or section.startswith("mysql"):
|
|
50
|
+
return 'mysql'
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Unknown connection type for [{section}]; set `type = clickhouse|mysql` in db.ini"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DBManager:
|
|
57
|
+
"""Unlocked DBManager - returns raw connections."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config_file: str = None, remote_db_creds: Dict[str, Tuple[str, str]] = None, wsl_host_override: bool = True):
|
|
60
|
+
if config_file is None:
|
|
61
|
+
config_file = config_path()
|
|
62
|
+
if not os.path.exists(config_file):
|
|
63
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
64
|
+
|
|
65
|
+
cfg = read_config(config_file)
|
|
66
|
+
self.config_file = config_file
|
|
67
|
+
self._cfg = cfg
|
|
68
|
+
self._cache = {}
|
|
69
|
+
self._remote_db_creds = remote_db_creds or DEFAULT_REMOTE_DB_CREDS
|
|
70
|
+
self._wsl_host_override = wsl_host_override
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get(cls, name: str, config_file: str = None, **kwargs):
|
|
74
|
+
return cls(config_file, **kwargs).getattr(name)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def load_config(config_file: str) -> configparser.RawConfigParser:
|
|
78
|
+
if config_file is None:
|
|
79
|
+
config_file = config_path()
|
|
80
|
+
if not os.path.exists(config_file):
|
|
81
|
+
raise FileNotFoundError(f"Config file not found: {config_file}")
|
|
82
|
+
return read_config(config_file)
|
|
83
|
+
|
|
84
|
+
def __getattr__(self, name: str):
|
|
85
|
+
section = name.replace("_", "-")
|
|
86
|
+
if section not in self._cfg:
|
|
87
|
+
raise AttributeError(f"No section [{section}] in config")
|
|
88
|
+
|
|
89
|
+
tm = _get_tunnel_manager()
|
|
90
|
+
block = tm.block_for(section)
|
|
91
|
+
if block is not None:
|
|
92
|
+
# Bring up (or confirm) the self-managed SSH tunnel before connecting.
|
|
93
|
+
tm.ensure_for(section)
|
|
94
|
+
# Generation changes when the tunnel was (re)started -> the cached client's
|
|
95
|
+
# TCP socket is dead and must be rebuilt. 0 for non-tunneled connections.
|
|
96
|
+
gen = tm.generation(block) if block is not None else 0
|
|
97
|
+
|
|
98
|
+
cached = self._cache.get(name)
|
|
99
|
+
if cached is not None and cached['gen'] == gen:
|
|
100
|
+
return cached['client']
|
|
101
|
+
|
|
102
|
+
ctype = connection_type(self._cfg, section)
|
|
103
|
+
if ctype == 'clickhouse':
|
|
104
|
+
client = self._create_clickhouse_client(section)
|
|
105
|
+
elif ctype in ('mysql', 'mariadb'):
|
|
106
|
+
client = self._create_mysql_client(section)
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(f"Unsupported connection type '{ctype}' for [{section}]")
|
|
109
|
+
|
|
110
|
+
self._cache[name] = {'client': client, 'gen': gen}
|
|
111
|
+
return client
|
|
112
|
+
|
|
113
|
+
def _resolve_conn_config(self, section: str, default_port: int) -> dict:
|
|
114
|
+
"""Build driver kwargs for a section: strip meta keys, resolve host/port.
|
|
115
|
+
|
|
116
|
+
Self-tunneled connections connect to localhost:local_port (the SSH forward).
|
|
117
|
+
Otherwise the legacy WSL host-override applies when running inside WSL.
|
|
118
|
+
"""
|
|
119
|
+
conn_config = dict(self._cfg[section])
|
|
120
|
+
# Meta keys consumed elsewhere, not by the DB driver.
|
|
121
|
+
for meta in ('type', 'tunnel', 'remote_host', 'remote_port', 'local_port'):
|
|
122
|
+
conn_config.pop(meta, None)
|
|
123
|
+
for k, v in list(conn_config.items()):
|
|
124
|
+
conn_config[k] = resolve_env(str(v))
|
|
125
|
+
|
|
126
|
+
if _get_tunnel_manager().block_for(section) is not None:
|
|
127
|
+
conn_config['host'] = 'localhost'
|
|
128
|
+
conn_config['port'] = int(_get_tunnel_manager().local_port_for(section))
|
|
129
|
+
else:
|
|
130
|
+
if self._wsl_host_override and is_wsl():
|
|
131
|
+
windows_ip = get_windows_host_ip()
|
|
132
|
+
if windows_ip:
|
|
133
|
+
conn_config['host'] = windows_ip
|
|
134
|
+
conn_config['port'] = int(conn_config.get('port', default_port))
|
|
135
|
+
return conn_config
|
|
136
|
+
|
|
137
|
+
def _create_clickhouse_client(self, section: str) -> Client:
|
|
138
|
+
return Client(**self._resolve_conn_config(section, 9000))
|
|
139
|
+
|
|
140
|
+
def _create_mysql_client(self, section: str) -> MySQLConnectionWrapper:
|
|
141
|
+
return MySQLConnectionWrapper(pymysql.connect(**self._resolve_conn_config(section, 3306)))
|
|
142
|
+
|
|
143
|
+
def all_clients(self) -> Dict[str, Any]:
|
|
144
|
+
return {name: entry['client'] for name, entry in self._cache.items()}
|
|
145
|
+
|
|
146
|
+
def close_all(self):
|
|
147
|
+
for entry in self._cache.values():
|
|
148
|
+
client = entry['client']
|
|
149
|
+
if hasattr(client, "disconnect"):
|
|
150
|
+
client.disconnect()
|
|
151
|
+
elif hasattr(client, "close"):
|
|
152
|
+
client.close()
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def session_scope(self, client_name: str = 'ck'):
|
|
156
|
+
client = getattr(self, client_name)
|
|
157
|
+
session_id = str(uuid.uuid4())
|
|
158
|
+
original_execute = client.execute
|
|
159
|
+
original_query_dataframe = getattr(client, 'query_dataframe', None)
|
|
160
|
+
|
|
161
|
+
def scoped_execute(query, params=None, settings=None, **kwargs):
|
|
162
|
+
if settings is None:
|
|
163
|
+
settings = {}
|
|
164
|
+
settings['session_id'] = session_id
|
|
165
|
+
return original_execute(query, params=params, settings=settings, **kwargs)
|
|
166
|
+
|
|
167
|
+
def scoped_query_dataframe(query, params=None, settings=None, **kwargs):
|
|
168
|
+
if settings is None:
|
|
169
|
+
settings = {}
|
|
170
|
+
settings['session_id'] = session_id
|
|
171
|
+
return original_query_dataframe(query, params=params, settings=settings, **kwargs)
|
|
172
|
+
|
|
173
|
+
class SessionClient:
|
|
174
|
+
def execute(self, *args, **kwargs):
|
|
175
|
+
return scoped_execute(*args, **kwargs)
|
|
176
|
+
def query_dataframe(self, *args, **kwargs):
|
|
177
|
+
if original_query_dataframe:
|
|
178
|
+
return scoped_query_dataframe(*args, **kwargs)
|
|
179
|
+
raise AttributeError("query_dataframe not available on this client")
|
|
180
|
+
|
|
181
|
+
yield SessionClient()
|
|
182
|
+
|
|
183
|
+
@contextmanager
|
|
184
|
+
def transaction_scope(self, connection: str):
|
|
185
|
+
conn = getattr(self, connection)
|
|
186
|
+
cursor = conn.cursor()
|
|
187
|
+
cursor.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")
|
|
188
|
+
cursor.execute("SET SESSION innodb_lock_wait_timeout = 600")
|
|
189
|
+
cursor.close()
|
|
190
|
+
try:
|
|
191
|
+
yield conn
|
|
192
|
+
conn.commit()
|
|
193
|
+
except Exception:
|
|
194
|
+
conn.rollback()
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class LockedDBManager(DBManager):
|
|
199
|
+
"""Returns read-only wrapped connections."""
|
|
200
|
+
|
|
201
|
+
def _create_clickhouse_client(self, section: str) -> LockedClickHouseClient:
|
|
202
|
+
return LockedClickHouseClient(super()._create_clickhouse_client(section))
|
|
203
|
+
|
|
204
|
+
def _create_mysql_client(self, section: str) -> LockedMySQLConnectionWrapper:
|
|
205
|
+
return LockedMySQLConnectionWrapper(super()._create_mysql_client(section))
|
connections/readonly.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Read-only SQL guard.
|
|
2
|
+
|
|
3
|
+
The enforcement layer that makes every connection read-only by construction:
|
|
4
|
+
write/mutating keywords are rejected here, before any SQL reaches the network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
# SQL verbs that mutate state. Matched as whole words (case-insensitive).
|
|
10
|
+
WRITE_KEYWORDS = [
|
|
11
|
+
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
|
|
12
|
+
'TRUNCATE', 'REPLACE', 'MERGE', 'CALL', 'EXEC', 'EXECUTE',
|
|
13
|
+
'GRANT', 'REVOKE', 'LOCK', 'UNLOCK', 'SET GLOBAL',
|
|
14
|
+
'KILL', 'LOAD DATA', 'START TRANSACTION', 'BEGIN'
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Verbs a read-only query may begin with. Anything else is treated as a write.
|
|
18
|
+
READ_ONLY_KEYWORDS = [
|
|
19
|
+
'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN',
|
|
20
|
+
'USE', 'WITH', 'EXISTS', 'CHECK', 'HELP'
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_write_operation(sql: str) -> bool:
|
|
25
|
+
sql_upper = sql.strip().upper()
|
|
26
|
+
for keyword in READ_ONLY_KEYWORDS:
|
|
27
|
+
if sql_upper.startswith(keyword):
|
|
28
|
+
return False
|
|
29
|
+
for keyword in WRITE_KEYWORDS:
|
|
30
|
+
pattern = r'\b' + keyword + r'\b'
|
|
31
|
+
if re.search(pattern, sql_upper):
|
|
32
|
+
if keyword == 'CREATE' and 'SHOW' in sql_upper:
|
|
33
|
+
continue
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _filter_sql(sql: str) -> str:
|
|
39
|
+
"""Pass through `sql` unchanged, or raise PermissionError if it mutates."""
|
|
40
|
+
if _is_write_operation(sql):
|
|
41
|
+
raise PermissionError(
|
|
42
|
+
"Write operation blocked by sql-buddy's read-only guard. "
|
|
43
|
+
f"Only READ queries are allowed. Blocked SQL: {sql[:100]}..."
|
|
44
|
+
)
|
|
45
|
+
return sql
|
connections/runtime.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Runtime environment detection.
|
|
2
|
+
|
|
3
|
+
Decides whether a connection's `host` should be rewritten to the Windows host
|
|
4
|
+
IP: when sql-buddy runs inside WSL but the user's tunnel is on the Windows side,
|
|
5
|
+
the driver must connect to the Windows host rather than `localhost`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_wsl() -> bool:
|
|
13
|
+
if is_container():
|
|
14
|
+
return False
|
|
15
|
+
try:
|
|
16
|
+
with open('/proc/version', 'r') as f:
|
|
17
|
+
version = f.read().lower()
|
|
18
|
+
return 'microsoft' in version or 'wsl' in version
|
|
19
|
+
except (FileNotFoundError, IOError):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_container() -> bool:
|
|
24
|
+
if os.path.exists('/.dockerenv'):
|
|
25
|
+
return True
|
|
26
|
+
try:
|
|
27
|
+
with open('/proc/1/cgroup', 'r') as f:
|
|
28
|
+
cgroup = f.read()
|
|
29
|
+
if any(sig in cgroup for sig in ['docker', 'containerd', 'kubepods', 'lxc']):
|
|
30
|
+
return True
|
|
31
|
+
except (FileNotFoundError, IOError):
|
|
32
|
+
pass
|
|
33
|
+
for var in ['DOCKER_CONTAINER', 'DOCKER_ID', 'CONTAINER', 'KUBERNETES_SERVICE_HOST']:
|
|
34
|
+
if os.environ.get(var):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_windows_host_ip() -> Optional[str]:
|
|
40
|
+
if not is_wsl():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open('/etc/resolv.conf', 'r') as f:
|
|
44
|
+
for line in f:
|
|
45
|
+
if line.strip().startswith('nameserver'):
|
|
46
|
+
ip = line.split()[1]
|
|
47
|
+
if ip and ip != '127.0.0.1':
|
|
48
|
+
return ip
|
|
49
|
+
except (FileNotFoundError, IOError):
|
|
50
|
+
pass
|
|
51
|
+
return None
|
connections/wrappers.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Connection wrappers: raw and read-only-guarded DB clients.
|
|
2
|
+
|
|
3
|
+
`MySQLConnectionWrapper` adapts a raw pymysql connection to the dataframe/
|
|
4
|
+
execute interface the rest of sql-buddy expects. The `Locked*` variants wrap a
|
|
5
|
+
client and route every statement through the read-only guard (see `readonly`)
|
|
6
|
+
so write keywords can never reach the network.
|
|
7
|
+
|
|
8
|
+
See UBIQUITOUS_LANGUAGE.md: "Locked" == the wrapper layer enforcing Read-only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pymysql
|
|
12
|
+
from clickhouse_driver import Client
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .readonly import _filter_sql
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MySQLConnectionWrapper:
|
|
19
|
+
def __init__(self, conn):
|
|
20
|
+
self._conn = conn
|
|
21
|
+
|
|
22
|
+
def __getattr__(self, name):
|
|
23
|
+
return getattr(self._conn, name)
|
|
24
|
+
|
|
25
|
+
def db_execute(self, sql: str, params: tuple = None) -> int:
|
|
26
|
+
cursor = self._conn.cursor()
|
|
27
|
+
try:
|
|
28
|
+
cursor.execute(sql, params)
|
|
29
|
+
self._conn.commit()
|
|
30
|
+
return cursor.rowcount
|
|
31
|
+
finally:
|
|
32
|
+
cursor.close()
|
|
33
|
+
|
|
34
|
+
def query_dataframe(self, sql: str) -> pd.DataFrame:
|
|
35
|
+
cursor = self._conn.cursor(pymysql.cursors.DictCursor)
|
|
36
|
+
try:
|
|
37
|
+
cursor.execute(sql)
|
|
38
|
+
rows = cursor.fetchall()
|
|
39
|
+
return pd.DataFrame(rows)
|
|
40
|
+
finally:
|
|
41
|
+
cursor.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LockedMySQLConnectionWrapper:
|
|
45
|
+
def __init__(self, wrapped: MySQLConnectionWrapper):
|
|
46
|
+
self._wrapped = wrapped
|
|
47
|
+
|
|
48
|
+
def __getattr__(self, name):
|
|
49
|
+
return getattr(self._wrapped, name)
|
|
50
|
+
|
|
51
|
+
def cursor(self, *args, **kwargs):
|
|
52
|
+
return self._wrapped.cursor(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
def commit(self):
|
|
55
|
+
return self._wrapped.commit()
|
|
56
|
+
|
|
57
|
+
def rollback(self):
|
|
58
|
+
return self._wrapped.rollback()
|
|
59
|
+
|
|
60
|
+
def close(self):
|
|
61
|
+
return self._wrapped.close()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def open(self):
|
|
65
|
+
return self._wrapped.open
|
|
66
|
+
|
|
67
|
+
def db_execute(self, sql: str, params: tuple = None) -> int:
|
|
68
|
+
_filter_sql(sql)
|
|
69
|
+
return self._wrapped.db_execute(sql, params)
|
|
70
|
+
|
|
71
|
+
def query_dataframe(self, sql: str) -> pd.DataFrame:
|
|
72
|
+
_filter_sql(sql)
|
|
73
|
+
return self._wrapped.query_dataframe(sql)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LockedClickHouseClient:
|
|
77
|
+
def __init__(self, wrapped: Client):
|
|
78
|
+
self._wrapped = wrapped
|
|
79
|
+
|
|
80
|
+
def __getattr__(self, name):
|
|
81
|
+
return getattr(self._wrapped, name)
|
|
82
|
+
|
|
83
|
+
def execute(self, query, params=None, with_column_types=False, settings=None, **kwargs):
|
|
84
|
+
_filter_sql(query)
|
|
85
|
+
return self._wrapped.execute(
|
|
86
|
+
query, params=params,
|
|
87
|
+
with_column_types=with_column_types,
|
|
88
|
+
settings=settings, **kwargs
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def query_dataframe(self, query, params=None, settings=None, **kwargs):
|
|
92
|
+
_filter_sql(query)
|
|
93
|
+
result = self._wrapped.execute(query, params=params, settings=settings, with_column_types=True, **kwargs)
|
|
94
|
+
if result:
|
|
95
|
+
data, columns = result
|
|
96
|
+
if data:
|
|
97
|
+
return pd.DataFrame(data, columns=[c[0] for c in columns])
|
|
98
|
+
return pd.DataFrame()
|
|
99
|
+
|
|
100
|
+
def disconnect(self):
|
|
101
|
+
return self._wrapped.disconnect()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def connected(self):
|
|
105
|
+
return self._wrapped.connected
|