sqlide 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.
- sqlide/__init__.py +3 -0
- sqlide/__main__.py +5 -0
- sqlide/app.py +46 -0
- sqlide/cli.py +64 -0
- sqlide/clipboard.py +65 -0
- sqlide/config/__init__.py +1 -0
- sqlide/config/_toml.py +41 -0
- sqlide/config/connections.py +76 -0
- sqlide/config/keymap.py +25 -0
- sqlide/config/paths.py +48 -0
- sqlide/config/secrets.py +82 -0
- sqlide/config/settings.py +32 -0
- sqlide/consoles.py +100 -0
- sqlide/db/__init__.py +1 -0
- sqlide/db/completion.py +127 -0
- sqlide/db/factory.py +37 -0
- sqlide/db/metadata.py +174 -0
- sqlide/db/result.py +52 -0
- sqlide/db/session.py +339 -0
- sqlide/db/types.py +84 -0
- sqlide/doctor.py +60 -0
- sqlide/drivers/__init__.py +1 -0
- sqlide/drivers/catalog.toml +103 -0
- sqlide/drivers/cli.py +74 -0
- sqlide/drivers/custom.py +69 -0
- sqlide/drivers/loader.py +75 -0
- sqlide/drivers/maven.py +108 -0
- sqlide/drivers/registry.py +117 -0
- sqlide/export/__init__.py +12 -0
- sqlide/export/base.py +86 -0
- sqlide/export/csv_.py +40 -0
- sqlide/export/html.py +44 -0
- sqlide/export/json_.py +47 -0
- sqlide/export/markdown.py +27 -0
- sqlide/export/service.py +34 -0
- sqlide/export/sql_insert.py +37 -0
- sqlide/export/xlsx.py +109 -0
- sqlide/grid/__init__.py +1 -0
- sqlide/grid/copyfmt.py +97 -0
- sqlide/grid/formatting.py +43 -0
- sqlide/grid/model.py +109 -0
- sqlide/history/__init__.py +5 -0
- sqlide/history/store.py +117 -0
- sqlide/jvm/__init__.py +1 -0
- sqlide/jvm/locate.py +98 -0
- sqlide/jvm/runtime.py +44 -0
- sqlide/sql/__init__.py +1 -0
- sqlide/sql/context.py +195 -0
- sqlide/sql/dialects.py +93 -0
- sqlide/sql/format.py +50 -0
- sqlide/sql/keywords.py +143 -0
- sqlide/sql/lexer.py +148 -0
- sqlide/sql/snippets.py +44 -0
- sqlide/sql/splitter.py +325 -0
- sqlide/ui/__init__.py +1 -0
- sqlide/ui/app.tcss +67 -0
- sqlide/ui/commands.py +59 -0
- sqlide/ui/keymap.py +32 -0
- sqlide/ui/screens/__init__.py +0 -0
- sqlide/ui/screens/connection_editor.py +113 -0
- sqlide/ui/screens/dialogs.py +110 -0
- sqlide/ui/screens/driver_manager.py +195 -0
- sqlide/ui/screens/export_dialog.py +158 -0
- sqlide/ui/screens/grid_dialogs.py +92 -0
- sqlide/ui/screens/history.py +130 -0
- sqlide/ui/screens/main.py +383 -0
- sqlide/ui/screens/settings.py +77 -0
- sqlide/ui/widgets/__init__.py +0 -0
- sqlide/ui/widgets/completion_popup.py +81 -0
- sqlide/ui/widgets/connections_list.py +54 -0
- sqlide/ui/widgets/console_export.py +124 -0
- sqlide/ui/widgets/console_tab.py +335 -0
- sqlide/ui/widgets/console_tabs.py +117 -0
- sqlide/ui/widgets/result_grid.py +435 -0
- sqlide/ui/widgets/result_panel.py +73 -0
- sqlide/ui/widgets/result_view.py +50 -0
- sqlide/ui/widgets/schema_tree.py +145 -0
- sqlide/ui/widgets/sql_editor.py +310 -0
- sqlide/ui/widgets/status_bar.py +32 -0
- sqlide/workspace.py +97 -0
- sqlide-0.1.0.dist-info/METADATA +151 -0
- sqlide-0.1.0.dist-info/RECORD +85 -0
- sqlide-0.1.0.dist-info/WHEEL +4 -0
- sqlide-0.1.0.dist-info/entry_points.txt +2 -0
- sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlide/db/session.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""One JDBC connection = one DbSession.
|
|
2
|
+
|
|
3
|
+
JDBC connections are not thread-safe, so every JDBC call of a session runs on that
|
|
4
|
+
session's own single worker thread. The UI only awaits; cancel() may be called from
|
|
5
|
+
any thread (Statement.cancel is the one call JDBC allows concurrently).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from typing import Any, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
import jpype
|
|
19
|
+
|
|
20
|
+
from sqlide.db.result import Column, DbError, Execution, ResultItem
|
|
21
|
+
from sqlide.db.types import make_converter
|
|
22
|
+
from sqlide.drivers.loader import LoadedDriver
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
DEFAULT_PAGE = 500
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _db_error(e: Exception, cancelled: bool = False) -> DbError:
|
|
30
|
+
if cancelled:
|
|
31
|
+
return DbError("Cancelled by user")
|
|
32
|
+
if isinstance(e, jpype.JException):
|
|
33
|
+
state = getattr(e, "getSQLState", lambda: "")() or ""
|
|
34
|
+
code = getattr(e, "getErrorCode", lambda: 0)()
|
|
35
|
+
return DbError(str(e.getMessage() or e), str(state), int(code))
|
|
36
|
+
return DbError(str(e))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Cursor:
|
|
40
|
+
"""Open JDBC ResultSet; satisfies result.RowSource."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session: DbSession, rs: Any, stmt: Any, convs: list, pending: bool) -> None:
|
|
43
|
+
self._s, self._rs, self._stmt, self._convs = session, rs, stmt, convs
|
|
44
|
+
self._pending = pending # rs is already positioned on an unread row
|
|
45
|
+
self.closed = False
|
|
46
|
+
self.owns_stmt = False # True once handed to the caller; before that execute() owns it
|
|
47
|
+
|
|
48
|
+
def _read(self, n: int) -> tuple[list[tuple], bool]:
|
|
49
|
+
if self.closed:
|
|
50
|
+
raise DbError("Cursor is closed (another query ran). Re-run to load more rows.")
|
|
51
|
+
rows: list[tuple] = []
|
|
52
|
+
try:
|
|
53
|
+
while len(rows) < n:
|
|
54
|
+
if self._pending:
|
|
55
|
+
self._pending = False
|
|
56
|
+
elif not self._rs.next():
|
|
57
|
+
self._close_sync()
|
|
58
|
+
return rows, True
|
|
59
|
+
rows.append(tuple(c(self._rs) for c in self._convs))
|
|
60
|
+
if self._rs.next():
|
|
61
|
+
self._pending = True
|
|
62
|
+
return rows, False
|
|
63
|
+
self._close_sync()
|
|
64
|
+
return rows, True
|
|
65
|
+
except jpype.JException as e:
|
|
66
|
+
self._close_sync()
|
|
67
|
+
raise _db_error(e, self._s._cancelled) from e
|
|
68
|
+
|
|
69
|
+
def _close_sync(self) -> None:
|
|
70
|
+
if self.closed:
|
|
71
|
+
return
|
|
72
|
+
self.closed = True
|
|
73
|
+
for o in (self._rs, self._stmt) if self.owns_stmt else (self._rs,):
|
|
74
|
+
with contextlib.suppress(jpype.JException):
|
|
75
|
+
o.close()
|
|
76
|
+
self._s._cursors.discard(self)
|
|
77
|
+
if self.owns_stmt:
|
|
78
|
+
self._s._leave_paging_tx()
|
|
79
|
+
|
|
80
|
+
async def fetch_more(self, n: int) -> tuple[list[tuple], bool]:
|
|
81
|
+
return await self._s.call(lambda _c: self._read(n))
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
await self._s.call(lambda _c: self._close_sync())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DbSession:
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
loaded: LoadedDriver,
|
|
91
|
+
url: str,
|
|
92
|
+
user: str = "",
|
|
93
|
+
password: str | None = None,
|
|
94
|
+
properties: dict[str, str] | None = None,
|
|
95
|
+
dialect: str = "generic",
|
|
96
|
+
autocommit: bool = True,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._loaded, self._url, self._dialect = loaded, url, dialect
|
|
99
|
+
self._props = dict(properties or {})
|
|
100
|
+
if user:
|
|
101
|
+
self._props["user"] = user
|
|
102
|
+
if password is not None:
|
|
103
|
+
self._props["password"] = password
|
|
104
|
+
self._want_autocommit = autocommit
|
|
105
|
+
self._conn: Any = None
|
|
106
|
+
self._pool = ThreadPoolExecutor(1, "sqlide-jdbc", initializer=self._init_thread)
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self._stmt: Any = None
|
|
109
|
+
self._cancelled = False
|
|
110
|
+
self._cursors: set[_Cursor] = set()
|
|
111
|
+
self._paging_tx = False # we turned autocommit off only to get server-side cursors
|
|
112
|
+
self.pending_tx = False # manual mode: statements since last commit/rollback
|
|
113
|
+
self.product = ""
|
|
114
|
+
|
|
115
|
+
# --- plumbing ---
|
|
116
|
+
def _init_thread(self) -> None:
|
|
117
|
+
# drivers using ServiceLoader (ClickHouse) look classes up via the context loader
|
|
118
|
+
jpype.JClass("java.lang.Thread").currentThread().setContextClassLoader(self._loaded.loader)
|
|
119
|
+
|
|
120
|
+
async def call(self, fn: Callable[[Any], T]) -> T:
|
|
121
|
+
"""Run fn(java_connection) on the session thread. Use for metadata and misc JDBC."""
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
return await loop.run_in_executor(self._pool, lambda: fn(self._conn))
|
|
124
|
+
|
|
125
|
+
# --- lifecycle ---
|
|
126
|
+
async def open(self) -> None:
|
|
127
|
+
def connect(_: Any) -> None:
|
|
128
|
+
props = jpype.JClass("java.util.Properties")()
|
|
129
|
+
for k, v in self._props.items():
|
|
130
|
+
props.setProperty(k, v)
|
|
131
|
+
try:
|
|
132
|
+
conn = self._loaded.driver.connect(self._url, props)
|
|
133
|
+
if conn is None:
|
|
134
|
+
raise DbError(f"Driver does not accept URL: {self._url}")
|
|
135
|
+
conn.setAutoCommit(self._want_autocommit)
|
|
136
|
+
md = conn.getMetaData()
|
|
137
|
+
self.product = f"{md.getDatabaseProductName()} {md.getDatabaseProductVersion()}"
|
|
138
|
+
except jpype.JException as e:
|
|
139
|
+
raise _db_error(e) from e
|
|
140
|
+
self._conn = conn
|
|
141
|
+
|
|
142
|
+
await self.call(connect)
|
|
143
|
+
|
|
144
|
+
async def close(self) -> None:
|
|
145
|
+
if self._conn is None:
|
|
146
|
+
self._pool.shutdown(wait=False)
|
|
147
|
+
return
|
|
148
|
+
self.cancel()
|
|
149
|
+
|
|
150
|
+
def shut(conn: Any) -> None:
|
|
151
|
+
for c in list(self._cursors):
|
|
152
|
+
c._close_sync()
|
|
153
|
+
if self.pending_tx: # some drivers (Oracle) would commit on close
|
|
154
|
+
with contextlib.suppress(jpype.JException):
|
|
155
|
+
conn.rollback()
|
|
156
|
+
with contextlib.suppress(jpype.JException):
|
|
157
|
+
conn.close()
|
|
158
|
+
|
|
159
|
+
await self.call(shut)
|
|
160
|
+
self._conn = None
|
|
161
|
+
self._pool.shutdown(wait=False)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def connected(self) -> bool:
|
|
165
|
+
return self._conn is not None
|
|
166
|
+
|
|
167
|
+
# --- execution ---
|
|
168
|
+
async def execute(self, sql: str, page_size: int = DEFAULT_PAGE) -> Execution:
|
|
169
|
+
return await self.call(lambda _c: self._execute_sync(sql, page_size))
|
|
170
|
+
|
|
171
|
+
async def stream(
|
|
172
|
+
self,
|
|
173
|
+
sql: str,
|
|
174
|
+
consumer: Callable[[list[Column], Iterator[tuple]], T],
|
|
175
|
+
page_size: int = 2000,
|
|
176
|
+
) -> T:
|
|
177
|
+
"""Run `sql`, hand its first result set to `consumer` as a lazy row iterator.
|
|
178
|
+
|
|
179
|
+
`consumer` runs on the session thread, so it may block (write a file). Pages are
|
|
180
|
+
fetched on demand: memory stays flat for any result size.
|
|
181
|
+
"""
|
|
182
|
+
return await self.call(lambda _c: self._stream_sync(sql, page_size, consumer))
|
|
183
|
+
|
|
184
|
+
def _stream_sync(
|
|
185
|
+
self, sql: str, page_size: int, consumer: Callable[[list[Column], Iterator[tuple]], T]
|
|
186
|
+
) -> T:
|
|
187
|
+
ex = self._execute_sync(sql, page_size)
|
|
188
|
+
item = next((i for i in ex.items if i.has_rows), None)
|
|
189
|
+
if item is None:
|
|
190
|
+
raise DbError("The statement did not return a result set")
|
|
191
|
+
|
|
192
|
+
cur = cast("_Cursor | None", item.cursor)
|
|
193
|
+
|
|
194
|
+
def rows() -> Iterator[tuple]:
|
|
195
|
+
yield from item.rows
|
|
196
|
+
while cur is not None and not cur.closed:
|
|
197
|
+
page, done = cur._read(page_size)
|
|
198
|
+
yield from page
|
|
199
|
+
if done:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
return consumer(item.columns, rows())
|
|
204
|
+
finally:
|
|
205
|
+
if cur is not None:
|
|
206
|
+
cur._close_sync()
|
|
207
|
+
|
|
208
|
+
def cancel(self) -> None:
|
|
209
|
+
"""Thread-safe. Aborts the running statement, if any."""
|
|
210
|
+
with self._lock:
|
|
211
|
+
stmt = self._stmt
|
|
212
|
+
if stmt is not None:
|
|
213
|
+
self._cancelled = True
|
|
214
|
+
with contextlib.suppress(jpype.JException):
|
|
215
|
+
stmt.cancel()
|
|
216
|
+
|
|
217
|
+
def _execute_sync(self, sql: str, page_size: int) -> Execution:
|
|
218
|
+
for c in list(self._cursors): # one live result per session, see module docs
|
|
219
|
+
c._close_sync()
|
|
220
|
+
self._cancelled = False
|
|
221
|
+
self._enter_paging_tx()
|
|
222
|
+
t0 = time.perf_counter()
|
|
223
|
+
stmt = self._conn.createStatement()
|
|
224
|
+
keep_open = False
|
|
225
|
+
try:
|
|
226
|
+
stmt.setFetchSize(page_size)
|
|
227
|
+
with self._lock:
|
|
228
|
+
self._stmt = stmt
|
|
229
|
+
items: list[ResultItem] = []
|
|
230
|
+
has_rs = bool(stmt.execute(sql))
|
|
231
|
+
while True:
|
|
232
|
+
if has_rs:
|
|
233
|
+
item, more = self._read_first_page(stmt.getResultSet(), stmt, page_size)
|
|
234
|
+
items.append(item)
|
|
235
|
+
if more: # cursor owns the statement now; further results are not reachable
|
|
236
|
+
keep_open = True
|
|
237
|
+
break
|
|
238
|
+
else:
|
|
239
|
+
n = int(stmt.getUpdateCount())
|
|
240
|
+
if n == -1:
|
|
241
|
+
break
|
|
242
|
+
items.append(ResultItem(update_count=n))
|
|
243
|
+
has_rs = bool(stmt.getMoreResults())
|
|
244
|
+
warnings = self._warnings(stmt)
|
|
245
|
+
if not self._want_autocommit:
|
|
246
|
+
self.pending_tx = True
|
|
247
|
+
return Execution(sql, items, warnings, time.perf_counter() - t0)
|
|
248
|
+
except jpype.JException as e:
|
|
249
|
+
err = _db_error(e, self._cancelled)
|
|
250
|
+
keep_open = False
|
|
251
|
+
self._rollback_paging_tx()
|
|
252
|
+
raise err from e
|
|
253
|
+
finally:
|
|
254
|
+
with self._lock:
|
|
255
|
+
self._stmt = None
|
|
256
|
+
if not keep_open:
|
|
257
|
+
with contextlib.suppress(jpype.JException):
|
|
258
|
+
stmt.close()
|
|
259
|
+
self._leave_paging_tx()
|
|
260
|
+
|
|
261
|
+
def _read_first_page(self, rs: Any, stmt: Any, page_size: int) -> tuple[ResultItem, bool]:
|
|
262
|
+
md = rs.getMetaData()
|
|
263
|
+
n = int(md.getColumnCount())
|
|
264
|
+
cols = [
|
|
265
|
+
Column(
|
|
266
|
+
str(md.getColumnLabel(i)), str(md.getColumnTypeName(i)), int(md.getColumnType(i))
|
|
267
|
+
)
|
|
268
|
+
for i in range(1, n + 1)
|
|
269
|
+
]
|
|
270
|
+
convs = [make_converter(i, c.jdbc_type) for i, c in zip(range(1, n + 1), cols, strict=True)]
|
|
271
|
+
cur = _Cursor(self, rs, stmt, convs, pending=False)
|
|
272
|
+
rows, done = cur._read(page_size)
|
|
273
|
+
item = ResultItem(columns=cols, rows=rows)
|
|
274
|
+
if done:
|
|
275
|
+
return item, False
|
|
276
|
+
cur.owns_stmt = True
|
|
277
|
+
self._cursors.add(cur)
|
|
278
|
+
item.cursor = cur
|
|
279
|
+
return item, True
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _warnings(stmt: Any) -> list[str]:
|
|
283
|
+
out, w = [], stmt.getWarnings()
|
|
284
|
+
while w is not None:
|
|
285
|
+
out.append(str(w.getMessage()))
|
|
286
|
+
w = w.getNextWarning()
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
# --- postgres: fetchSize is ignored in autocommit mode, so page inside a transaction ---
|
|
290
|
+
def _enter_paging_tx(self) -> None:
|
|
291
|
+
if self._dialect == "postgres" and self._want_autocommit and not self._paging_tx:
|
|
292
|
+
self._conn.setAutoCommit(False)
|
|
293
|
+
self._paging_tx = True
|
|
294
|
+
|
|
295
|
+
def _leave_paging_tx(self) -> None:
|
|
296
|
+
if self._paging_tx and not self._cursors and self._conn is not None:
|
|
297
|
+
self._paging_tx = False
|
|
298
|
+
with contextlib.suppress(jpype.JException):
|
|
299
|
+
self._conn.commit()
|
|
300
|
+
self._conn.setAutoCommit(True)
|
|
301
|
+
|
|
302
|
+
def _rollback_paging_tx(self) -> None:
|
|
303
|
+
if self._paging_tx:
|
|
304
|
+
self._paging_tx = False
|
|
305
|
+
with contextlib.suppress(jpype.JException):
|
|
306
|
+
self._conn.rollback()
|
|
307
|
+
self._conn.setAutoCommit(True)
|
|
308
|
+
|
|
309
|
+
# --- transactions ---
|
|
310
|
+
async def set_autocommit(self, on: bool) -> None:
|
|
311
|
+
def f(conn: Any) -> None:
|
|
312
|
+
try:
|
|
313
|
+
conn.setAutoCommit(on)
|
|
314
|
+
except jpype.JException as e:
|
|
315
|
+
raise _db_error(e) from e
|
|
316
|
+
self._want_autocommit = on
|
|
317
|
+
self.pending_tx = False
|
|
318
|
+
|
|
319
|
+
await self.call(f)
|
|
320
|
+
|
|
321
|
+
async def commit(self) -> None:
|
|
322
|
+
await self._finish_tx("commit")
|
|
323
|
+
|
|
324
|
+
async def rollback(self) -> None:
|
|
325
|
+
await self._finish_tx("rollback")
|
|
326
|
+
|
|
327
|
+
async def _finish_tx(self, how: str) -> None:
|
|
328
|
+
def f(conn: Any) -> None:
|
|
329
|
+
try:
|
|
330
|
+
getattr(conn, how)()
|
|
331
|
+
except jpype.JException as e:
|
|
332
|
+
raise _db_error(e) from e
|
|
333
|
+
self.pending_tx = False
|
|
334
|
+
|
|
335
|
+
await self.call(f)
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def autocommit(self) -> bool:
|
|
339
|
+
return self._want_autocommit
|
sqlide/db/types.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""JDBC value -> Python value. Values stay typed so sorting and xlsx export are correct."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# java.sql.Types constants (stable by spec)
|
|
11
|
+
BIT, TINYINT, SMALLINT, INTEGER, BIGINT = -7, -6, 5, 4, -5
|
|
12
|
+
FLOAT, REAL, DOUBLE, NUMERIC, DECIMAL = 6, 7, 8, 2, 3
|
|
13
|
+
DATE, TIME, TIMESTAMP, BOOLEAN = 91, 92, 93, 16
|
|
14
|
+
BINARY, VARBINARY, LONGVARBINARY, BLOB = -2, -3, -4, 2004
|
|
15
|
+
|
|
16
|
+
_INTS = {TINYINT, SMALLINT, INTEGER, BIGINT}
|
|
17
|
+
_FLOATS = {FLOAT, REAL, DOUBLE}
|
|
18
|
+
_DECIMALS = {NUMERIC, DECIMAL}
|
|
19
|
+
_BOOLS = {BIT, BOOLEAN}
|
|
20
|
+
_BYTES = {BINARY, VARBINARY, LONGVARBINARY, BLOB}
|
|
21
|
+
|
|
22
|
+
Converter = Callable[[Any], Any] # (java ResultSet) -> python value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_converter(index: int, jdbc_type: int) -> Converter:
|
|
26
|
+
"""Converter for column `index` (1-based). Any conversion failure falls back to text."""
|
|
27
|
+
primary = _primary(index, jdbc_type)
|
|
28
|
+
|
|
29
|
+
def convert(rs: Any) -> Any:
|
|
30
|
+
try:
|
|
31
|
+
return primary(rs)
|
|
32
|
+
except Exception: # java exceptions too: out-of-range dates, unsigned bigint, ...
|
|
33
|
+
try:
|
|
34
|
+
s = rs.getString(index)
|
|
35
|
+
return None if s is None else str(s)
|
|
36
|
+
except Exception:
|
|
37
|
+
return "<unreadable>"
|
|
38
|
+
|
|
39
|
+
return convert
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _primary(i: int, t: int) -> Converter:
|
|
43
|
+
if t in _INTS:
|
|
44
|
+
return lambda rs: _prim(rs, int(rs.getLong(i)))
|
|
45
|
+
if t in _FLOATS:
|
|
46
|
+
return lambda rs: _prim(rs, float(rs.getDouble(i)))
|
|
47
|
+
if t in _BOOLS:
|
|
48
|
+
return lambda rs: _prim(rs, bool(rs.getBoolean(i)))
|
|
49
|
+
if t in _DECIMALS:
|
|
50
|
+
return lambda rs: _obj(rs.getBigDecimal(i), lambda v: Decimal(str(v.toPlainString())))
|
|
51
|
+
if t == DATE:
|
|
52
|
+
return lambda rs: _obj(rs.getDate(i), lambda v: _date(v.toLocalDate()))
|
|
53
|
+
if t == TIME:
|
|
54
|
+
return lambda rs: _obj(rs.getTime(i), lambda v: _time(v.toLocalTime()))
|
|
55
|
+
if t == TIMESTAMP:
|
|
56
|
+
return lambda rs: _obj(rs.getTimestamp(i), lambda v: _datetime(v.toLocalDateTime()))
|
|
57
|
+
if t in _BYTES:
|
|
58
|
+
return lambda rs: _obj(rs.getBytes(i), bytes)
|
|
59
|
+
return lambda rs: _obj(rs.getString(i), str)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _prim(rs: Any, value: Any) -> Any:
|
|
63
|
+
return None if rs.wasNull() else value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _obj(value: Any, f: Callable[[Any], Any]) -> Any:
|
|
67
|
+
return None if value is None else f(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _date(d: Any) -> dt.date:
|
|
71
|
+
return dt.date(int(d.getYear()), int(d.getMonthValue()), int(d.getDayOfMonth()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _time(t: Any) -> dt.time:
|
|
75
|
+
return dt.time(
|
|
76
|
+
int(t.getHour()), int(t.getMinute()), int(t.getSecond()), int(t.getNano()) // 1000
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _datetime(v: Any) -> dt.datetime:
|
|
81
|
+
return dt.datetime(
|
|
82
|
+
int(v.getYear()), int(v.getMonthValue()), int(v.getDayOfMonth()),
|
|
83
|
+
int(v.getHour()), int(v.getMinute()), int(v.getSecond()), int(v.getNano()) // 1000,
|
|
84
|
+
) # fmt: skip
|
sqlide/doctor.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`sqlide doctor`: environment checks. Each check is independent; add new ones to CHECKS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from sqlide.config import paths
|
|
8
|
+
from sqlide.drivers.registry import DriverRegistry
|
|
9
|
+
from sqlide.jvm.locate import JvmNotFound, locate_jvm
|
|
10
|
+
|
|
11
|
+
Check = Callable[[], tuple[bool, str]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_jvm() -> tuple[bool, str]:
|
|
15
|
+
try:
|
|
16
|
+
info = locate_jvm()
|
|
17
|
+
except JvmNotFound as e:
|
|
18
|
+
return False, str(e)
|
|
19
|
+
return True, f"Java {info.major} at {info.home}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_drivers() -> tuple[bool, str]:
|
|
23
|
+
reg = DriverRegistry()
|
|
24
|
+
have = [d for d in reg.all() if reg.is_installed(d)]
|
|
25
|
+
return bool(have), ", ".join(have) if have else "none installed: sqlide driver install postgres"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_dirs() -> tuple[bool, str]:
|
|
29
|
+
return True, f"config {paths.config_dir()}, data {paths.data_dir()}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_clipboard() -> tuple[bool, str]:
|
|
33
|
+
from sqlide import clipboard
|
|
34
|
+
|
|
35
|
+
# OSC52 still works without a native tool, so this is informational, never a failure
|
|
36
|
+
return True, clipboard.available()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_exporters() -> tuple[bool, str]:
|
|
40
|
+
from sqlide import export
|
|
41
|
+
|
|
42
|
+
return True, ", ".join(e.name for e in export.all_exporters())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CHECKS: list[tuple[str, Check]] = [
|
|
46
|
+
("java", check_jvm),
|
|
47
|
+
("drivers", check_drivers),
|
|
48
|
+
("clipboard", check_clipboard),
|
|
49
|
+
("export", check_exporters),
|
|
50
|
+
("dirs", check_dirs),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run() -> int:
|
|
55
|
+
bad = 0
|
|
56
|
+
for label, fn in CHECKS:
|
|
57
|
+
ok, detail = fn()
|
|
58
|
+
bad += not ok
|
|
59
|
+
print(f"[{'ok' if ok else '!!'}] {label:<10} {detail}")
|
|
60
|
+
return 1 if bad else 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Drivers block: catalog of JDBC drivers, Maven download, class loading."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Built-in driver presets. Users add/override entries in drivers.toml (same keys).
|
|
2
|
+
# classifiers: tried in order, first that exists on Maven Central wins ("" = plain jar).
|
|
3
|
+
# version_suffix: keep only versions ending with it (mssql ships jre8/jre11 builds).
|
|
4
|
+
|
|
5
|
+
[[driver]]
|
|
6
|
+
id = "postgres"
|
|
7
|
+
name = "PostgreSQL / Greenplum"
|
|
8
|
+
class_name = "org.postgresql.Driver"
|
|
9
|
+
url_template = "jdbc:postgresql://{host}:{port}/{database}"
|
|
10
|
+
default_port = 5432
|
|
11
|
+
dialect = "postgres"
|
|
12
|
+
group = "org.postgresql"
|
|
13
|
+
artifact = "postgresql"
|
|
14
|
+
classifiers = [""]
|
|
15
|
+
|
|
16
|
+
[[driver]]
|
|
17
|
+
id = "clickhouse"
|
|
18
|
+
name = "ClickHouse"
|
|
19
|
+
class_name = "com.clickhouse.jdbc.ClickHouseDriver"
|
|
20
|
+
url_template = "jdbc:clickhouse://{host}:{port}/{database}"
|
|
21
|
+
default_port = 8123
|
|
22
|
+
dialect = "clickhouse"
|
|
23
|
+
group = "com.clickhouse"
|
|
24
|
+
artifact = "clickhouse-jdbc"
|
|
25
|
+
classifiers = ["all-dependencies", "all"]
|
|
26
|
+
|
|
27
|
+
[[driver]]
|
|
28
|
+
id = "mysql"
|
|
29
|
+
name = "MySQL"
|
|
30
|
+
class_name = "com.mysql.cj.jdbc.Driver"
|
|
31
|
+
url_template = "jdbc:mysql://{host}:{port}/{database}"
|
|
32
|
+
default_port = 3306
|
|
33
|
+
dialect = "mysql"
|
|
34
|
+
group = "com.mysql"
|
|
35
|
+
artifact = "mysql-connector-j"
|
|
36
|
+
classifiers = [""]
|
|
37
|
+
|
|
38
|
+
[[driver]]
|
|
39
|
+
id = "mariadb"
|
|
40
|
+
name = "MariaDB"
|
|
41
|
+
class_name = "org.mariadb.jdbc.Driver"
|
|
42
|
+
url_template = "jdbc:mariadb://{host}:{port}/{database}"
|
|
43
|
+
default_port = 3306
|
|
44
|
+
dialect = "mysql"
|
|
45
|
+
group = "org.mariadb.jdbc"
|
|
46
|
+
artifact = "mariadb-java-client"
|
|
47
|
+
classifiers = [""]
|
|
48
|
+
|
|
49
|
+
[[driver]]
|
|
50
|
+
id = "oracle"
|
|
51
|
+
name = "Oracle"
|
|
52
|
+
class_name = "oracle.jdbc.OracleDriver"
|
|
53
|
+
url_template = "jdbc:oracle:thin:@//{host}:{port}/{database}"
|
|
54
|
+
default_port = 1521
|
|
55
|
+
dialect = "oracle"
|
|
56
|
+
group = "com.oracle.database.jdbc"
|
|
57
|
+
artifact = "ojdbc11"
|
|
58
|
+
classifiers = [""]
|
|
59
|
+
|
|
60
|
+
[[driver]]
|
|
61
|
+
id = "mssql"
|
|
62
|
+
name = "SQL Server"
|
|
63
|
+
class_name = "com.microsoft.sqlserver.jdbc.SQLServerDriver"
|
|
64
|
+
url_template = "jdbc:sqlserver://{host}:{port};databaseName={database}"
|
|
65
|
+
default_port = 1433
|
|
66
|
+
dialect = "mssql"
|
|
67
|
+
group = "com.microsoft.sqlserver"
|
|
68
|
+
artifact = "mssql-jdbc"
|
|
69
|
+
classifiers = [""]
|
|
70
|
+
version_suffix = "jre11"
|
|
71
|
+
|
|
72
|
+
[[driver]]
|
|
73
|
+
id = "h2"
|
|
74
|
+
name = "H2"
|
|
75
|
+
class_name = "org.h2.Driver"
|
|
76
|
+
url_template = "jdbc:h2:{database}"
|
|
77
|
+
default_port = 0
|
|
78
|
+
dialect = "generic"
|
|
79
|
+
group = "com.h2database"
|
|
80
|
+
artifact = "h2"
|
|
81
|
+
classifiers = [""]
|
|
82
|
+
|
|
83
|
+
[[driver]]
|
|
84
|
+
id = "sqlite"
|
|
85
|
+
name = "SQLite"
|
|
86
|
+
class_name = "org.sqlite.JDBC"
|
|
87
|
+
url_template = "jdbc:sqlite:{database}"
|
|
88
|
+
default_port = 0
|
|
89
|
+
dialect = "sqlite"
|
|
90
|
+
group = "org.xerial"
|
|
91
|
+
artifact = "sqlite-jdbc"
|
|
92
|
+
classifiers = [""]
|
|
93
|
+
|
|
94
|
+
[[driver]]
|
|
95
|
+
id = "duckdb"
|
|
96
|
+
name = "DuckDB"
|
|
97
|
+
class_name = "org.duckdb.DuckDBDriver"
|
|
98
|
+
url_template = "jdbc:duckdb:{database}"
|
|
99
|
+
default_port = 0
|
|
100
|
+
dialect = "generic"
|
|
101
|
+
group = "org.duckdb"
|
|
102
|
+
artifact = "duckdb_jdbc"
|
|
103
|
+
classifiers = [""]
|
sqlide/drivers/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""`sqlide driver ...` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from sqlide.config._toml import ConfigError
|
|
9
|
+
from sqlide.drivers.custom import build_custom_driver
|
|
10
|
+
from sqlide.drivers.maven import MavenError
|
|
11
|
+
from sqlide.drivers.registry import DriverRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(sub: argparse._SubParsersAction) -> None:
|
|
15
|
+
p = sub.add_parser("driver", help="manage JDBC drivers")
|
|
16
|
+
ds = p.add_subparsers(dest="driver_cmd", required=True)
|
|
17
|
+
ds.add_parser("list", help="show known drivers and install state")
|
|
18
|
+
ins = ds.add_parser("install", help="download a driver from Maven Central")
|
|
19
|
+
ins.add_argument("id")
|
|
20
|
+
ins.add_argument("--version", help="default: latest stable")
|
|
21
|
+
add = ds.add_parser("add", help="add a custom driver (local jars or Maven coordinates)")
|
|
22
|
+
add.add_argument("id")
|
|
23
|
+
add.add_argument("--name")
|
|
24
|
+
add.add_argument("--jar", action="append", default=[], help="local jar (repeatable)")
|
|
25
|
+
add.add_argument("--maven", help="group:artifact[:classifier]")
|
|
26
|
+
add.add_argument("--class", dest="class_name", default="", help="default: auto-detect")
|
|
27
|
+
add.add_argument("--url-template", default="")
|
|
28
|
+
add.add_argument("--dialect", default="generic")
|
|
29
|
+
p.set_defaults(handler=run)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(args: argparse.Namespace) -> int:
|
|
33
|
+
reg = DriverRegistry()
|
|
34
|
+
try:
|
|
35
|
+
return {"list": _list, "install": _install, "add": _add}[args.driver_cmd](reg, args)
|
|
36
|
+
except (ConfigError, MavenError) as e:
|
|
37
|
+
print(f"error: {e}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _list(reg: DriverRegistry, _: argparse.Namespace) -> int:
|
|
42
|
+
for d in reg.all().values():
|
|
43
|
+
vs = reg.installed_versions(d.id)
|
|
44
|
+
state = "local jars" if d.jars else (vs[-1] if vs else "-")
|
|
45
|
+
print(f"{d.id:<12} {d.name:<24} {state}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _install(reg: DriverRegistry, args: argparse.Namespace) -> int:
|
|
50
|
+
def progress(done: int, total: int | None) -> None:
|
|
51
|
+
size = f"{done / 1e6:.1f}" + (f"/{total / 1e6:.1f}" if total else "") + " MB"
|
|
52
|
+
print(f"\r {size}", end="", file=sys.stderr, flush=True)
|
|
53
|
+
|
|
54
|
+
jar = reg.install(args.id, version=args.version, progress=progress)
|
|
55
|
+
print(f"\ninstalled {jar}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add(reg: DriverRegistry, args: argparse.Namespace) -> int:
|
|
60
|
+
reg.add_custom(
|
|
61
|
+
build_custom_driver(
|
|
62
|
+
args.id,
|
|
63
|
+
args.name,
|
|
64
|
+
maven=args.maven or "",
|
|
65
|
+
jars=args.jar,
|
|
66
|
+
class_name=args.class_name,
|
|
67
|
+
url_template=args.url_template,
|
|
68
|
+
dialect=args.dialect,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
print(f"driver '{args.id}' saved to {reg.user_file}")
|
|
72
|
+
if args.maven:
|
|
73
|
+
print(f"download it with: sqlide driver install {args.id}")
|
|
74
|
+
return 0
|