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.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. 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