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
@@ -0,0 +1,145 @@
1
+ """Schema tree: namespaces → tables/views → columns, loaded lazily from a MetaCache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.binding import Binding
7
+ from textual.message import Message
8
+ from textual.widgets import Tree
9
+ from textual.widgets.tree import TreeNode
10
+
11
+ from sqlide.db.metadata import Column, MetaCache, Namespace, Table, is_system_namespace
12
+ from sqlide.db.result import DbError
13
+
14
+ ICON_NS, ICON_TABLE, ICON_VIEW = "▣", "▤", "◫"
15
+
16
+
17
+ class SchemaTree(Tree[object]):
18
+ BINDINGS = [
19
+ Binding("f5", "refresh", "Refresh", id="schema.refresh"),
20
+ Binding("i", "insert_name", "Insert name", id="schema.insert_name"),
21
+ ]
22
+
23
+ class TableChosen(Message):
24
+ """`action` is "select" (open first rows) or "insert" (put the name into the editor)."""
25
+
26
+ def __init__(self, table: Table, action: str) -> None:
27
+ super().__init__()
28
+ self.table, self.action = table, action
29
+
30
+ def __init__(self, **kw) -> None:
31
+ super().__init__("not connected", **kw)
32
+ self.auto_expand = False # Enter on a table runs it; Space / arrows expand
33
+ self.show_root = True
34
+ self._meta: MetaCache | None = None
35
+ self._label = ""
36
+
37
+ # --- binding to a connection ---
38
+ def show(self, meta: MetaCache | None, label: str = "") -> None:
39
+ """Show another connection's metadata (None: disconnected)."""
40
+ self._meta, self._label = meta, label
41
+ self.root.set_label(label or "not connected")
42
+ self.root.remove_children()
43
+ self.root.data = None
44
+ if meta is not None:
45
+ self.root.data = meta
46
+ self.root.expand()
47
+ self.run_worker(self._load_namespaces(meta, self.root), group="schema", exclusive=True)
48
+
49
+ def action_refresh(self) -> None:
50
+ if self._meta is not None:
51
+ self._meta.refresh()
52
+ self.show(self._meta, self._label)
53
+
54
+ # --- lazy loading ---
55
+ def _placeholder(self, node: TreeNode, text: str = "loading…") -> None:
56
+ node.remove_children()
57
+ node.add_leaf(Text(text, style="dim italic"))
58
+
59
+ def _fail(self, node: TreeNode, e: Exception) -> None:
60
+ node.remove_children()
61
+ node.add_leaf(Text(f"✖ {str(e).splitlines()[0] if str(e) else e!r}", style="red"))
62
+
63
+ async def _load_namespaces(self, meta: MetaCache, node: TreeNode) -> None:
64
+ self._placeholder(node)
65
+ try:
66
+ spaces = await meta.namespaces()
67
+ except DbError as e:
68
+ self._fail(node, e)
69
+ return
70
+ if meta is not self._meta:
71
+ return # the user switched tabs meanwhile
72
+ try:
73
+ current = (await meta.current_namespace()).lower()
74
+ except DbError:
75
+ current = ""
76
+ # the working schema first, system schemas last and dimmed
77
+ spaces = sorted(
78
+ spaces, key=lambda n: (is_system_namespace(n.name), n.name.lower() != current)
79
+ )
80
+ node.remove_children()
81
+ opened = None
82
+ for ns in spaces:
83
+ style = "dim" if is_system_namespace(ns.name) else ""
84
+ child = node.add(Text(f"{ICON_NS} {ns.name or 'main'}", style=style), data=ns)
85
+ if opened is None and ns.name.lower() == current:
86
+ opened = child
87
+ if opened is None and len(spaces) == 1:
88
+ opened = node.children[0]
89
+ if opened is not None:
90
+ opened.expand()
91
+
92
+ async def _load_tables(self, meta: MetaCache, node: TreeNode, ns: Namespace) -> None:
93
+ self._placeholder(node)
94
+ try:
95
+ tables = await meta.tables(ns)
96
+ except DbError as e:
97
+ self._fail(node, e)
98
+ return
99
+ node.remove_children()
100
+ for t in tables:
101
+ icon = ICON_VIEW if t.is_view else ICON_TABLE
102
+ node.add(Text(f"{icon} {t.name}"), data=t)
103
+ if not tables:
104
+ node.add_leaf(Text("(empty)", style="dim italic"))
105
+
106
+ async def _load_columns(self, meta: MetaCache, node: TreeNode, table: Table) -> None:
107
+ self._placeholder(node)
108
+ try:
109
+ cols = await meta.columns(table)
110
+ except DbError as e:
111
+ self._fail(node, e)
112
+ return
113
+ node.remove_children()
114
+ for c in cols:
115
+ node.add_leaf(_column_label(c), data=c)
116
+
117
+ def on_tree_node_expanded(self, event: Tree.NodeExpanded[object]) -> None:
118
+ node, meta = event.node, self._meta
119
+ if meta is None or node.children:
120
+ return # already loaded
121
+ if isinstance(node.data, Namespace):
122
+ self.run_worker(self._load_tables(meta, node, node.data), group=f"ns-{node.id}")
123
+ elif isinstance(node.data, Table):
124
+ self.run_worker(self._load_columns(meta, node, node.data), group=f"t-{node.id}")
125
+
126
+ # --- actions ---
127
+ def on_tree_node_selected(self, event: Tree.NodeSelected[object]) -> None:
128
+ event.stop()
129
+ if isinstance(event.node.data, Table):
130
+ self.post_message(self.TableChosen(event.node.data, "select"))
131
+ elif event.node.allow_expand:
132
+ event.node.toggle()
133
+
134
+ def action_insert_name(self) -> None:
135
+ node = self.cursor_node
136
+ if node is not None and isinstance(node.data, Table):
137
+ self.post_message(self.TableChosen(node.data, "insert"))
138
+
139
+
140
+ def _column_label(c: Column) -> Text:
141
+ text = Text()
142
+ text.append("🔑 " if c.primary_key else " ")
143
+ text.append(c.name)
144
+ text.append(f" {c.type_name}{'' if c.nullable else ' not null'}", style="dim")
145
+ return text
@@ -0,0 +1,310 @@
1
+ """SQL editor: TextArea + a frame around the statement that would run now."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import cast
6
+
7
+ from rich.segment import Segment
8
+ from rich.style import Style
9
+ from textual.binding import Binding
10
+ from textual.color import Color
11
+ from textual.document._document import Document
12
+ from textual.events import Key
13
+ from textual.geometry import Offset
14
+ from textual.message import Message
15
+ from textual.strip import Strip
16
+ from textual.timer import Timer
17
+ from textual.widgets import TextArea
18
+
19
+ from sqlide.db.completion import Candidate
20
+ from sqlide.sql.format import FormatError, format_sql, toggle_line_comments
21
+ from sqlide.sql.splitter import Span, span_lines, split, statement_at
22
+ from sqlide.ui.widgets.completion_popup import CompletionPopup
23
+
24
+ IMMEDIATE_RECALC_LIMIT = 20_000 # chars; above this, recompute spans with a short debounce
25
+ DEBOUNCE_S = 0.08
26
+ COMPLETE_DEBOUNCE_S = 0.05
27
+
28
+
29
+ class SqlEditor(TextArea):
30
+ BINDINGS = [
31
+ Binding("f5,ctrl+j,ctrl+enter", "run_statement", "Run", priority=True, id="editor.run"),
32
+ Binding(
33
+ "shift+f5,ctrl+shift+enter", "run_all", "Run all", priority=True, id="editor.run_all"
34
+ ),
35
+ Binding("ctrl+space,ctrl+@", "complete", "Complete", show=False, id="editor.complete"),
36
+ Binding("ctrl+alt+l,f7", "format", "Format", show=False, id="editor.format"),
37
+ Binding(
38
+ "ctrl+slash,ctrl+underscore,alt+slash",
39
+ "toggle_comment",
40
+ "Comment",
41
+ show=False,
42
+ id="editor.toggle_comment",
43
+ ),
44
+ ]
45
+
46
+ class RunRequested(Message):
47
+ """User wants to execute these statements, in order."""
48
+
49
+ def __init__(self, statements: list[str]) -> None:
50
+ super().__init__()
51
+ self.statements = statements
52
+
53
+ class CompletionRequested(Message):
54
+ """Cursor context changed (or the user asked): the owner supplies candidates."""
55
+
56
+ def __init__(self, offset: int, text: str, manual: bool) -> None:
57
+ super().__init__()
58
+ self.offset, self.text, self.manual = offset, text, manual
59
+
60
+ def __init__(self, text: str = "", *, dialect: str = "generic", blank_line: bool = True,
61
+ **kw) -> None: # fmt: skip
62
+ super().__init__(
63
+ text,
64
+ language="sql",
65
+ theme="monokai",
66
+ show_line_numbers=True,
67
+ soft_wrap=False,
68
+ tab_behavior="indent",
69
+ placeholder="-- connect (sidebar: Enter), then F5 / Ctrl+J runs the framed statement",
70
+ **kw,
71
+ )
72
+ self._dialect = dialect
73
+ self._blank_line = blank_line
74
+ self._spans: list[Span] = []
75
+ self._frame: tuple[int, int] | None = None
76
+ self._timer: Timer | None = None
77
+ self._popup: CompletionPopup | None = None
78
+ self._complete_timer: Timer | None = None
79
+ self._prefix_len = 0
80
+ self._accepting = False
81
+
82
+ # --- configuration ---
83
+ @property
84
+ def dialect(self) -> str:
85
+ return self._dialect
86
+
87
+ @dialect.setter
88
+ def dialect(self, value: str) -> None:
89
+ self._dialect = value
90
+ self._recalc()
91
+
92
+ @property
93
+ def blank_line(self) -> bool:
94
+ return self._blank_line
95
+
96
+ @blank_line.setter
97
+ def blank_line(self, value: bool) -> None:
98
+ self._blank_line = value
99
+ self._recalc()
100
+
101
+ # --- spans and frame ---
102
+ def _recalc(self) -> None:
103
+ self._spans = split(self.text, self._dialect, self._blank_line)
104
+ self._update_frame()
105
+
106
+ def _cursor_index(self) -> int:
107
+ """Cursor as a character offset into `self.text`."""
108
+ return cast(Document, self.document).get_index_from_location(self.cursor_location)
109
+
110
+ def _update_frame(self) -> None:
111
+ frame = None
112
+ if self.selection.is_empty:
113
+ span = statement_at(self._spans, self.text, self._cursor_index())
114
+ if span:
115
+ frame = span_lines(self.text, span)
116
+ if frame != self._frame:
117
+ self._frame = frame
118
+ self.refresh()
119
+
120
+ @property
121
+ def frame_lines(self) -> tuple[int, int] | None:
122
+ """(first, last) 0-based lines of the framed statement, or None."""
123
+ return self._frame
124
+
125
+ def on_mount(self) -> None:
126
+ self._recalc()
127
+
128
+ def on_text_area_changed(self, _: TextArea.Changed) -> None:
129
+ self._maybe_complete()
130
+ if len(self.text) <= IMMEDIATE_RECALC_LIMIT:
131
+ self._recalc()
132
+ return
133
+ if self._timer:
134
+ self._timer.stop()
135
+ self._timer = self.set_timer(DEBOUNCE_S, self._recalc)
136
+
137
+ def on_text_area_selection_changed(self, _: TextArea.SelectionChanged) -> None:
138
+ self._update_frame()
139
+ if self.completing:
140
+ self._request_completion(manual=False) # re-filter, or close when the cursor left
141
+
142
+ # --- run actions ---
143
+ def statements_to_run(self) -> list[str]:
144
+ """Selection (split into statements) wins; otherwise the framed statement."""
145
+ sel = self.selected_text
146
+ if sel.strip():
147
+ parts = [s.text(sel) for s in split(sel, self._dialect, self._blank_line)]
148
+ return parts or [sel.strip()]
149
+ self._recalc() # never run from stale spans
150
+ span = statement_at(self._spans, self.text, self._cursor_index())
151
+ return [span.text(self.text)] if span else []
152
+
153
+ def action_run_statement(self) -> None:
154
+ stmts = self.statements_to_run()
155
+ if stmts:
156
+ self.post_message(self.RunRequested(stmts))
157
+ else:
158
+ self.app.notify("No statement at cursor", severity="warning")
159
+
160
+ def action_run_all(self) -> None:
161
+ self._recalc()
162
+ stmts = [s.text(self.text) for s in self._spans]
163
+ if stmts:
164
+ self.post_message(self.RunRequested(stmts))
165
+ else:
166
+ self.app.notify("Nothing to run", severity="warning")
167
+
168
+ # --- editing helpers ---
169
+ def action_format(self) -> None:
170
+ """Pretty-print the selection, or the framed statement."""
171
+ sel = self.selection
172
+ if not sel.is_empty:
173
+ start, end = sorted((sel.start, sel.end))
174
+ src = self.get_text_range(start, end)
175
+ else:
176
+ self._recalc()
177
+ span = statement_at(self._spans, self.text, self._cursor_index())
178
+ if span is None:
179
+ self.app.notify("No statement at cursor", severity="warning")
180
+ return
181
+ doc = cast(Document, self.document)
182
+ start, end = (
183
+ doc.get_location_from_index(span.start),
184
+ doc.get_location_from_index(span.end),
185
+ )
186
+ src = span.text(self.text)
187
+ try:
188
+ formatted = format_sql(src, self._dialect)
189
+ except FormatError as e:
190
+ self.app.notify(str(e), title="Cannot format", severity="warning")
191
+ return
192
+ if formatted != src:
193
+ self.replace(formatted, start, end)
194
+
195
+ def action_toggle_comment(self) -> None:
196
+ sel = self.selection
197
+ first, last = sorted((sel.start[0], sel.end[0]))
198
+ if not sel.is_empty and sel.end[1] == 0 and sel.end[0] > sel.start[0]:
199
+ last -= 1 # a selection ending at column 0 does not include that line
200
+ lines = [self.document.get_line(i) for i in range(first, last + 1)]
201
+ new = toggle_line_comments(lines)
202
+ if new != lines:
203
+ end_col = len(lines[-1])
204
+ self.replace("\n".join(new), (first, 0), (last, end_col))
205
+
206
+ # --- drawing ---
207
+ def render_line(self, y: int) -> Strip:
208
+ strip = super().render_line(y)
209
+ if self._frame is None or not self.show_line_numbers:
210
+ return strip
211
+ row = y + self.scroll_offset.y
212
+ if row >= self.wrapped_document.height: # empty space below the text: no frame
213
+ return strip
214
+ line, _ = self.wrapped_document.offset_to_location(Offset(0, row))
215
+ first, last = self._frame
216
+ if not first <= line <= last:
217
+ return strip
218
+ mark = "▏" if first == last else "┏" if line == first else "┗" if line == last else "┃"
219
+ accent, tint = self._frame_colors()
220
+ segs = list(strip)
221
+ head = segs[0]
222
+ segs[0] = Segment(mark + head.text[1:], (head.style or Style()) + Style(color=accent))
223
+ segs = [Segment(s.text, (s.style or Style()) + tint, s.control) for s in segs]
224
+ return Strip(segs, strip.cell_length)
225
+
226
+ def _frame_colors(self) -> tuple[str, Style]:
227
+ theme = self.app.current_theme
228
+ accent = Color.parse(theme.primary)
229
+ base = Color.parse(theme.background if theme.background else "#1e1e1e")
230
+ return accent.hex, Style(bgcolor=base.blend(accent, 0.14).hex)
231
+
232
+ # --- autocomplete (the data comes from the owner via CompletionRequested) ---
233
+ @property
234
+ def completing(self) -> bool:
235
+ return self._popup is not None and self._popup.shown
236
+
237
+ def action_complete(self) -> None:
238
+ self._request_completion(manual=True)
239
+
240
+ def _maybe_complete(self) -> None:
241
+ """Auto-open after a dot; keep an open popup in sync while typing."""
242
+ if self._accepting:
243
+ return
244
+ before = self.text[self._cursor_index() - 1 : self._cursor_index()]
245
+ if before == "." or self.completing:
246
+ self._request_completion(manual=False)
247
+
248
+ def _request_completion(self, manual: bool) -> None:
249
+ if self._complete_timer is not None:
250
+ self._complete_timer.stop()
251
+ self._complete_timer = self.set_timer(
252
+ COMPLETE_DEBOUNCE_S,
253
+ lambda: self.post_message(
254
+ self.CompletionRequested(self._cursor_index(), self.text, manual)
255
+ ),
256
+ )
257
+
258
+ def show_completions(self, items: list[Candidate], prefix_len: int) -> None:
259
+ if not items or not self.has_focus:
260
+ self.hide_completions()
261
+ return
262
+ if self._popup is None:
263
+ self._popup = CompletionPopup()
264
+ self.screen.mount(self._popup)
265
+ self._prefix_len = prefix_len
266
+ self._popup.show(items, self.cursor_screen_offset)
267
+
268
+ def hide_completions(self) -> None:
269
+ if self._popup is not None:
270
+ self._popup.hide()
271
+
272
+ def _accept_completion(self) -> None:
273
+ popup = self._popup
274
+ choice = popup.current if popup else None
275
+ self.hide_completions()
276
+ if choice is None:
277
+ return
278
+ end = self._cursor_index()
279
+ doc = cast(Document, self.document)
280
+ self._accepting = True
281
+ try:
282
+ self.replace(
283
+ choice.text,
284
+ doc.get_location_from_index(end - self._prefix_len),
285
+ doc.get_location_from_index(end),
286
+ )
287
+ finally:
288
+ self._accepting = False
289
+
290
+ def on_blur(self) -> None:
291
+ self.hide_completions()
292
+
293
+ # Tab indents instead of moving focus (tab_behavior="indent"); Shift+Tab moves focus out.
294
+ def on_key(self, event: Key) -> None:
295
+ if self.completing and self._popup is not None:
296
+ handled = {
297
+ "down": lambda: self._popup.move(1), # type: ignore[union-attr]
298
+ "up": lambda: self._popup.move(-1), # type: ignore[union-attr]
299
+ "enter": self._accept_completion,
300
+ "tab": self._accept_completion,
301
+ "escape": self.hide_completions,
302
+ }.get(event.key)
303
+ if handled:
304
+ event.stop()
305
+ event.prevent_default()
306
+ handled()
307
+ return
308
+ if event.key == "shift+tab":
309
+ event.stop()
310
+ self.screen.focus_previous()
@@ -0,0 +1,32 @@
1
+ """One-line status: connection, transaction mode, last run summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.widgets import Static
7
+
8
+
9
+ class StatusBar(Static):
10
+ DEFAULT_CSS = """
11
+ StatusBar { height: 1; padding: 0 1; background: $panel; color: $text-muted; }
12
+ """
13
+
14
+ def __init__(self, **kw) -> None:
15
+ super().__init__("", **kw)
16
+ self.connection = "not connected"
17
+ self.tx = ""
18
+ self.message = ""
19
+ self._refresh_text()
20
+
21
+ def update_state(self, **fields: str) -> None:
22
+ for k, v in fields.items():
23
+ setattr(self, k, v)
24
+ self._refresh_text()
25
+
26
+ def _refresh_text(self) -> None:
27
+ parts = [self.connection]
28
+ if self.tx:
29
+ parts.append(f"Tx: {self.tx}")
30
+ if self.message:
31
+ parts.append(self.message)
32
+ self.update(Text(" · ".join(parts)))
sqlide/workspace.py ADDED
@@ -0,0 +1,97 @@
1
+ """Application service: saved connections, drivers, passwords, sessions.
2
+
3
+ Everything the UI needs from the non-UI blocks, in one place. Holds no widgets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import Callable
10
+
11
+ from sqlide.config.connections import Connection, ConnectionStore
12
+ from sqlide.config.secrets import PasswordResolver
13
+ from sqlide.config.settings import Settings, load_settings
14
+ from sqlide.consoles import ConsoleStore
15
+ from sqlide.db.factory import create_session, is_private_database
16
+ from sqlide.db.session import DbSession
17
+ from sqlide.drivers.registry import DriverDef, DriverRegistry
18
+ from sqlide.history import HistoryStore
19
+ from sqlide.jvm.runtime import ensure_jvm
20
+
21
+
22
+ class Workspace:
23
+ def __init__(
24
+ self,
25
+ store: ConnectionStore | None = None,
26
+ registry: DriverRegistry | None = None,
27
+ resolver: PasswordResolver | None = None,
28
+ settings: Settings | None = None,
29
+ consoles: ConsoleStore | None = None,
30
+ history: HistoryStore | None = None,
31
+ ) -> None:
32
+ self.store = store or ConnectionStore()
33
+ self.registry = registry or DriverRegistry()
34
+ self.resolver = resolver or PasswordResolver()
35
+ self.settings = settings or load_settings()
36
+ self.consoles = consoles or ConsoleStore()
37
+ self.history = history or HistoryStore(limit=self.settings.history_limit)
38
+
39
+ # --- connections ---
40
+ def connections(self) -> list[Connection]:
41
+ return self.store.load()
42
+
43
+ def save_connection(self, conn: Connection, replaces: str | None = None) -> None:
44
+ if replaces and replaces != conn.name:
45
+ self.store.remove(replaces)
46
+ self.store.upsert(conn)
47
+
48
+ def delete_connection(self, name: str) -> None:
49
+ self.store.remove(name)
50
+ self.resolver.forget(name)
51
+
52
+ # --- drivers ---
53
+ def driver(self, driver_id: str) -> DriverDef:
54
+ return self.registry.get(driver_id)
55
+
56
+ def driver_ready(self, conn: Connection) -> bool:
57
+ return self.registry.is_installed(conn.driver)
58
+
59
+ async def install_driver(
60
+ self, driver_id: str, progress: Callable[[int, int | None], None] | None = None
61
+ ) -> None:
62
+ await asyncio.to_thread(self.registry.install, driver_id, None, None, progress)
63
+
64
+ # --- sessions ---
65
+ @staticmethod
66
+ def needs_password_prompt(conn: Connection) -> bool:
67
+ """No user means no credentials (sqlite, h2, duckdb): never ask."""
68
+ return bool(conn.user)
69
+
70
+ async def lookup_password(self, conn: Connection) -> str | None:
71
+ return await asyncio.to_thread(self.resolver.lookup, conn)
72
+
73
+ async def connect_for_metadata(self, conn: Connection) -> DbSession | None:
74
+ """A second connection for schema reads, so they never queue behind a running query.
75
+
76
+ None when that is not possible (in-memory database, password not known, any error):
77
+ the caller then keeps using the main session.
78
+ """
79
+ if is_private_database(conn.url):
80
+ return None
81
+ try:
82
+ password = await self.lookup_password(conn)
83
+ if password is None and self.needs_password_prompt(conn):
84
+ return None
85
+ return await self.connect(conn, password)
86
+ except Exception: # noqa: BLE001 - purely an optimisation
87
+ return None
88
+
89
+ async def connect(self, conn: Connection, password: str | None) -> DbSession:
90
+ await asyncio.to_thread(ensure_jvm)
91
+ session = await asyncio.to_thread(create_session, conn, password or None, self.registry)
92
+ try:
93
+ await session.open()
94
+ except BaseException:
95
+ await session.close()
96
+ raise
97
+ return session