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,37 @@
1
+ """SQL INSERT statements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ from sqlide.db.result import Column
9
+ from sqlide.export.base import Exporter, ExportOptions, Row, register
10
+ from sqlide.grid.copyfmt import sql_ident, sql_literal
11
+
12
+
13
+ @register
14
+ class SqlInsertExporter(Exporter):
15
+ name, label, extension = "sql", "SQL INSERT statements", ".sql"
16
+
17
+ def _write(
18
+ self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
19
+ ) -> int:
20
+ cols = ", ".join(sql_ident(c.name) for c in columns)
21
+ head = f"INSERT INTO {opts.table_name} ({cols}) VALUES"
22
+ batch = max(1, opts.batch_size)
23
+ count, pending = 0, []
24
+ with tmp.open("w", encoding="utf-8") as f:
25
+
26
+ def flush() -> None:
27
+ if pending:
28
+ f.write(head + "\n " + ",\n ".join(pending) + ";\n")
29
+ pending.clear()
30
+
31
+ for row in rows:
32
+ pending.append("(" + ", ".join(sql_literal(v) for v in row) + ")")
33
+ count += 1
34
+ if len(pending) >= batch:
35
+ flush()
36
+ flush()
37
+ return count
sqlide/export/xlsx.py ADDED
@@ -0,0 +1,109 @@
1
+ """Excel .xlsx via openpyxl write-only mode (constant memory).
2
+
3
+ Handles Excel limits: 1,048,576 rows per sheet (continues on 'Name 2', ...), 32,767 chars
4
+ per cell, control characters Excel rejects, and the 15-digit float precision (larger
5
+ integers / decimals are written as text so no digits are silently lost).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import math
12
+ import re
13
+ from collections.abc import Iterator
14
+ from decimal import Decimal
15
+ from itertools import chain, islice
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from openpyxl import Workbook
20
+ from openpyxl.cell import WriteOnlyCell
21
+ from openpyxl.styles import Font
22
+ from openpyxl.utils import get_column_letter
23
+
24
+ from sqlide.db.result import Column
25
+ from sqlide.export.base import Exporter, ExportOptions, Row, register
26
+
27
+ MAX_ROWS_PER_SHEET = 1_048_576 - 1 # minus the header row
28
+ MAX_CELL_CHARS = 32_767
29
+ SAMPLE_ROWS = 100
30
+ MAX_PRECISE = 10**15
31
+ _ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
32
+ _BAD_SHEET = re.compile(r"[\[\]:*?/\\]")
33
+ _FORMATS = {dt.datetime: "yyyy-mm-dd hh:mm:ss", dt.date: "yyyy-mm-dd", dt.time: "hh:mm:ss"}
34
+
35
+
36
+ def sheet_title(name: str, index: int) -> str:
37
+ base = _BAD_SHEET.sub("_", name).strip("'") or "Result"
38
+ suffix = f" {index}" if index > 1 else ""
39
+ return base[: 31 - len(suffix)] + suffix
40
+
41
+
42
+ def to_cell_value(ws: Any, v: Any) -> Any:
43
+ if v is None or isinstance(v, bool):
44
+ return v
45
+ if isinstance(v, int):
46
+ return v if abs(v) < MAX_PRECISE else str(v)
47
+ if isinstance(v, float):
48
+ return v if math.isfinite(v) else str(v)
49
+ if isinstance(v, Decimal):
50
+ return float(v) if v.is_finite() and len(v.as_tuple().digits) <= 15 else str(v)
51
+ for typ, fmt in _FORMATS.items(): # datetime before date: it is a subclass
52
+ if isinstance(v, typ):
53
+ cell = WriteOnlyCell(ws, value=v)
54
+ cell.number_format = fmt
55
+ return cell
56
+ if isinstance(v, bytes):
57
+ return "0x" + v.hex()
58
+ return _ILLEGAL.sub("", str(v))[:MAX_CELL_CHARS]
59
+
60
+
61
+ @register
62
+ class XlsxExporter(Exporter):
63
+ name, label, extension = "xlsx", "Excel (.xlsx)", ".xlsx"
64
+
65
+ def _write(
66
+ self, columns: list[Column], rows: Iterator[Row], tmp: Path, opts: ExportOptions
67
+ ) -> int:
68
+ wb = Workbook(write_only=True)
69
+ total = in_sheet = sheet_no = 0
70
+ ws: Any = None
71
+ names = [c.name for c in columns]
72
+ sample = list(islice(rows, SAMPLE_ROWS))
73
+ widths = [
74
+ min(60, max(len(n) + 2, *(len(str(r[i])) if r[i] is not None else 0 for r in sample)))
75
+ for i, n in enumerate(names)
76
+ ]
77
+
78
+ def new_sheet() -> Any:
79
+ nonlocal sheet_no
80
+ sheet_no += 1
81
+ sheet = wb.create_sheet(sheet_title(opts.sheet_name, sheet_no))
82
+ for i, w in enumerate(widths, 1):
83
+ sheet.column_dimensions[get_column_letter(i)].width = w + 1
84
+ if opts.header:
85
+ sheet.freeze_panes = "A2"
86
+ bold = Font(bold=True)
87
+ cells = []
88
+ for n in names:
89
+ cell = WriteOnlyCell(sheet, value=_ILLEGAL.sub("", n))
90
+ cell.font = bold
91
+ cells.append(cell)
92
+ sheet.append(cells)
93
+ return sheet
94
+
95
+ def finish(sheet: Any, n_rows: int) -> None:
96
+ if opts.header and names and n_rows:
97
+ sheet.auto_filter.ref = f"A1:{get_column_letter(len(names))}{n_rows + 1}"
98
+
99
+ ws = new_sheet()
100
+ for row in chain(sample, rows):
101
+ if in_sheet >= MAX_ROWS_PER_SHEET:
102
+ finish(ws, in_sheet)
103
+ ws, in_sheet = new_sheet(), 0
104
+ ws.append([to_cell_value(ws, v) for v in row])
105
+ in_sheet += 1
106
+ total += 1
107
+ finish(ws, in_sheet)
108
+ wb.save(tmp)
109
+ return total
@@ -0,0 +1 @@
1
+ """Grid block: result-table model and value formatting. No UI imports."""
sqlide/grid/copyfmt.py ADDED
@@ -0,0 +1,97 @@
1
+ """Text renderings of a block of cells, for the clipboard. Pure functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import datetime as dt
7
+ import io
8
+ import json
9
+ import re
10
+ from collections.abc import Sequence
11
+ from decimal import Decimal
12
+ from typing import Any
13
+
14
+ from sqlide.grid.formatting import raw_text
15
+
16
+ Row = tuple[Any, ...]
17
+
18
+ _PLAIN_IDENT = re.compile(r"^[a-z_][a-z0-9_]*$")
19
+
20
+
21
+ def _tsv_cell(v: Any) -> str:
22
+ s = raw_text(v)
23
+ if any(ch in s for ch in '\t\n\r"'):
24
+ return '"' + s.replace('"', '""') + '"' # Excel/Sheets convention
25
+ return s
26
+
27
+
28
+ def to_tsv(rows: Sequence[Row], header: Sequence[str] | None = None) -> str:
29
+ lines = []
30
+ if header is not None:
31
+ lines.append("\t".join(_tsv_cell(h) for h in header))
32
+ lines += ["\t".join(_tsv_cell(v) for v in row) for row in rows]
33
+ return "\n".join(lines)
34
+
35
+
36
+ def to_csv(rows: Sequence[Row], header: Sequence[str] | None = None) -> str:
37
+ buf = io.StringIO()
38
+ w = csv.writer(buf, lineterminator="\n")
39
+ if header is not None:
40
+ w.writerow(header)
41
+ for row in rows:
42
+ w.writerow([raw_text(v) for v in row])
43
+ return buf.getvalue().rstrip("\n")
44
+
45
+
46
+ def md_cell(v: Any) -> str:
47
+ return raw_text(v).replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>")
48
+
49
+
50
+ def to_markdown(rows: Sequence[Row], header: Sequence[str]) -> str:
51
+ lines = ["| " + " | ".join(md_cell(h) for h in header) + " |"]
52
+ lines.append("| " + " | ".join("---" for _ in header) + " |")
53
+ lines += ["| " + " | ".join(md_cell(v) for v in row) + " |" for row in rows]
54
+ return "\n".join(lines)
55
+
56
+
57
+ def json_default(v: Any) -> Any:
58
+ if isinstance(v, Decimal):
59
+ return int(v) if v == v.to_integral_value() else float(v)
60
+ if isinstance(v, dt.datetime):
61
+ return v.isoformat(sep=" ")
62
+ if isinstance(v, dt.date | dt.time):
63
+ return v.isoformat()
64
+ if isinstance(v, bytes):
65
+ return "0x" + v.hex()
66
+ return str(v)
67
+
68
+
69
+ def to_json(rows: Sequence[Row], header: Sequence[str]) -> str:
70
+ objs = [dict(zip(header, row, strict=True)) for row in rows]
71
+ return json.dumps(objs, default=json_default, ensure_ascii=False, indent=2)
72
+
73
+
74
+ def sql_literal(v: Any) -> str:
75
+ if v is None:
76
+ return "NULL"
77
+ if isinstance(v, bool):
78
+ return "TRUE" if v else "FALSE"
79
+ if isinstance(v, int | Decimal):
80
+ return raw_text(v)
81
+ if isinstance(v, float):
82
+ return repr(v)
83
+ if isinstance(v, bytes):
84
+ return f"X'{v.hex()}'"
85
+ return "'" + raw_text(v).replace("'", "''") + "'"
86
+
87
+
88
+ def sql_ident(name: str) -> str:
89
+ return name if _PLAIN_IDENT.match(name) else '"' + name.replace('"', '""') + '"'
90
+
91
+
92
+ def to_insert_sql(rows: Sequence[Row], header: Sequence[str], table: str = "table_name") -> str:
93
+ cols = ", ".join(sql_ident(h) for h in header)
94
+ return "\n".join(
95
+ f"INSERT INTO {table} ({cols}) VALUES ({', '.join(sql_literal(v) for v in row)});"
96
+ for row in rows
97
+ )
@@ -0,0 +1,43 @@
1
+ """Human-readable cell text. The only place that decides how a value looks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+ NULL_TEXT = "<null>"
10
+ _BYTES_PREVIEW = 16
11
+
12
+
13
+ def format_value(v: Any) -> str:
14
+ if v is None:
15
+ return NULL_TEXT
16
+ if isinstance(v, bool):
17
+ return "true" if v else "false"
18
+ if isinstance(v, bytes):
19
+ head = v[:_BYTES_PREVIEW].hex()
20
+ return f"0x{head}" + (f"… ({len(v)} bytes)" if len(v) > _BYTES_PREVIEW else "")
21
+ if isinstance(v, dt.datetime):
22
+ text = v.isoformat(sep=" ")
23
+ return text
24
+ if isinstance(v, Decimal):
25
+ return format(v, "f")
26
+ if isinstance(v, float):
27
+ return repr(v)
28
+ return str(v).replace("\r\n", "⏎").replace("\n", "⏎").replace("\t", "⇥")
29
+
30
+
31
+ def raw_text(v: Any) -> str:
32
+ """Full-fidelity text for copy/export: no truncation, NULL is empty."""
33
+ if v is None:
34
+ return ""
35
+ if isinstance(v, bool):
36
+ return "true" if v else "false"
37
+ if isinstance(v, bytes):
38
+ return "0x" + v.hex()
39
+ if isinstance(v, dt.datetime):
40
+ return v.isoformat(sep=" ")
41
+ if isinstance(v, Decimal):
42
+ return format(v, "f")
43
+ return str(v)
sqlide/grid/model.py ADDED
@@ -0,0 +1,109 @@
1
+ """Table state behind the result grid: rows, multi-column sort, filter, paging. Pure Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from collections.abc import Iterable, Sequence
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from sqlide.db.result import Column
11
+ from sqlide.grid.formatting import format_value
12
+
13
+ Row = tuple[Any, ...]
14
+
15
+
16
+ def _sort_key(v: Any) -> tuple[int, Any]:
17
+ """Orders values of mixed types without ever raising (NULLs are handled by the caller)."""
18
+ if isinstance(v, bool | int | float | Decimal):
19
+ return (0, v if v == v else float("-inf")) # NaN sorts first
20
+ if isinstance(v, dt.datetime):
21
+ return (1, v)
22
+ if isinstance(v, dt.date):
23
+ return (2, v)
24
+ if isinstance(v, dt.time):
25
+ return (3, v)
26
+ if isinstance(v, bytes):
27
+ return (4, v)
28
+ return (5, str(v).casefold())
29
+
30
+
31
+ class GridModel:
32
+ def __init__(self, columns: Sequence[Column], rows: Iterable[Row] = ()) -> None:
33
+ self.columns = list(columns)
34
+ self._rows: list[Row] = list(rows)
35
+ self._view: list[int] = list(range(len(self._rows)))
36
+ self._search: list[str | None] = [None] * len(self._rows)
37
+ self.sort: list[tuple[int, bool]] = [] # (column, descending), most significant first
38
+ self.filter_text = ""
39
+
40
+ # --- reading ---
41
+ def __len__(self) -> int:
42
+ return len(self._view)
43
+
44
+ @property
45
+ def total_rows(self) -> int:
46
+ return len(self._rows)
47
+
48
+ def row(self, view_row: int) -> Row:
49
+ return self._rows[self._view[view_row]]
50
+
51
+ def value(self, view_row: int, col: int) -> Any:
52
+ return self._rows[self._view[view_row]][col]
53
+
54
+ def rows(self, first: int, last: int) -> list[Row]:
55
+ """View rows first..last inclusive."""
56
+ return [self._rows[i] for i in self._view[first : last + 1]]
57
+
58
+ # --- changing ---
59
+ def append(self, rows: Sequence[Row]) -> None:
60
+ start = len(self._rows)
61
+ self._rows.extend(rows)
62
+ self._search.extend([None] * len(rows))
63
+ if self.sort or self.filter_text:
64
+ self._rebuild()
65
+ else:
66
+ self._view.extend(range(start, len(self._rows)))
67
+
68
+ def set_filter(self, text: str) -> None:
69
+ self.filter_text = text.strip()
70
+ self._rebuild()
71
+
72
+ def cycle_sort(self, col: int, add: bool = False) -> None:
73
+ """none -> ascending -> descending -> none. `add` keeps other sort columns."""
74
+ current = dict(self.sort).get(col)
75
+ new = {None: False, False: True, True: None}[current]
76
+ others = [(c, d) for c, d in self.sort if c != col] if add else []
77
+ if new is None:
78
+ self.sort = others
79
+ elif add and current is not None:
80
+ self.sort = [(c, new if c == col else d) for c, d in self.sort]
81
+ else:
82
+ self.sort = [*others, (col, new)]
83
+ self._rebuild()
84
+
85
+ def sort_state(self, col: int) -> tuple[bool, int] | None:
86
+ """(descending, 1-based priority) or None if the column is not sorted."""
87
+ for rank, (c, desc) in enumerate(self.sort, 1):
88
+ if c == col:
89
+ return desc, rank
90
+ return None
91
+
92
+ # --- internals ---
93
+ def _matches(self, i: int, needle: str) -> bool:
94
+ text = self._search[i]
95
+ if text is None:
96
+ text = self._search[i] = "\x00".join(format_value(v).lower() for v in self._rows[i])
97
+ return needle in text
98
+
99
+ def _rebuild(self) -> None:
100
+ idx = list(range(len(self._rows)))
101
+ if self.filter_text:
102
+ needle = self.filter_text.lower()
103
+ idx = [i for i in idx if self._matches(i, needle)]
104
+ for col, desc in reversed(self.sort): # stable sorts, least significant key first
105
+ present = [i for i in idx if self._rows[i][col] is not None]
106
+ nulls = [i for i in idx if self._rows[i][col] is None]
107
+ present.sort(key=lambda i, c=col: _sort_key(self._rows[i][c]), reverse=desc)
108
+ idx = present + nulls # NULLs last in both directions
109
+ self._view = idx
@@ -0,0 +1,5 @@
1
+ """Query history (sqlite). Depends only on config paths."""
2
+
3
+ from sqlide.history.store import HistoryEntry, HistoryStore
4
+
5
+ __all__ = ["HistoryEntry", "HistoryStore"]
@@ -0,0 +1,117 @@
1
+ """Persistent log of executed statements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from sqlide.config import paths
11
+
12
+ _SCHEMA = """
13
+ CREATE TABLE IF NOT EXISTS history (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ ts REAL NOT NULL,
16
+ connection TEXT NOT NULL,
17
+ sql TEXT NOT NULL,
18
+ ok INTEGER NOT NULL,
19
+ elapsed_ms INTEGER NOT NULL,
20
+ error TEXT NOT NULL DEFAULT ''
21
+ );
22
+ CREATE INDEX IF NOT EXISTS history_ts ON history (ts DESC);
23
+ """
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class HistoryEntry:
28
+ id: int
29
+ ts: float
30
+ connection: str
31
+ sql: str
32
+ ok: bool
33
+ elapsed_ms: int
34
+ error: str = ""
35
+
36
+
37
+ def _like(word: str) -> str:
38
+ return "%" + word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
39
+
40
+
41
+ class HistoryStore:
42
+ """Opens its file on first use. Call from one thread (the UI thread)."""
43
+
44
+ def __init__(self, path: Path | None = None, limit: int = 5000) -> None:
45
+ self._path = path
46
+ self.limit = limit
47
+ self._db: sqlite3.Connection | None = None
48
+
49
+ def _conn(self) -> sqlite3.Connection:
50
+ if self._db is None:
51
+ path = self._path or paths.data_dir() / "history.sqlite"
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ self._db = sqlite3.connect(path)
54
+ self._db.executescript(_SCHEMA)
55
+ return self._db
56
+
57
+ def add(
58
+ self,
59
+ connection: str,
60
+ sql: str,
61
+ ok: bool,
62
+ elapsed_ms: int,
63
+ error: str = "",
64
+ ts: float | None = None,
65
+ ) -> None:
66
+ sql = sql.strip()
67
+ if not sql:
68
+ return
69
+ db = self._conn()
70
+ db.execute(
71
+ "INSERT INTO history (ts, connection, sql, ok, elapsed_ms, error) VALUES (?,?,?,?,?,?)",
72
+ (time.time() if ts is None else ts, connection, sql, int(ok), elapsed_ms, error),
73
+ )
74
+ db.execute(
75
+ "DELETE FROM history WHERE id <= (SELECT MAX(id) FROM history) - ?", (self.limit,)
76
+ )
77
+ db.commit()
78
+
79
+ def search(
80
+ self,
81
+ text: str = "",
82
+ connection: str | None = None,
83
+ only_ok: bool = False,
84
+ limit: int = 200,
85
+ ) -> list[HistoryEntry]:
86
+ """Newest first. Every word of `text` must occur in the statement (case-insensitive)."""
87
+ where, args = [], []
88
+ for word in text.split():
89
+ where.append("sql LIKE ? ESCAPE '\\'")
90
+ args.append(_like(word))
91
+ if connection is not None:
92
+ where.append("connection = ?")
93
+ args.append(connection)
94
+ if only_ok:
95
+ where.append("ok = 1")
96
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
97
+ rows = self._conn().execute(
98
+ "SELECT id, ts, connection, sql, ok, elapsed_ms, error FROM history "
99
+ f"{clause} ORDER BY id DESC LIMIT ?",
100
+ (*args, limit),
101
+ )
102
+ return [HistoryEntry(r[0], r[1], r[2], r[3], bool(r[4]), r[5], r[6]) for r in rows]
103
+
104
+ def delete(self, entry_id: int) -> None:
105
+ db = self._conn()
106
+ db.execute("DELETE FROM history WHERE id = ?", (entry_id,))
107
+ db.commit()
108
+
109
+ def clear(self) -> None:
110
+ db = self._conn()
111
+ db.execute("DELETE FROM history")
112
+ db.commit()
113
+
114
+ def close(self) -> None:
115
+ if self._db is not None:
116
+ self._db.close()
117
+ self._db = None
sqlide/jvm/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """JVM block: locate a Java runtime and start it once inside this process."""
sqlide/jvm/locate.py ADDED
@@ -0,0 +1,98 @@
1
+ """Find a usable JVM (Java 11+). Pure Python, does not start anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ MIN_JAVA = 11
14
+
15
+ INSTALL_HINT = (
16
+ "Java 11+ not found. Install one:\n"
17
+ " macOS: brew install openjdk\n"
18
+ " Debian/Ubuntu/WSL: sudo apt install openjdk-21-jre-headless\n"
19
+ " or set JAVA_HOME to an existing JDK/JRE."
20
+ )
21
+
22
+
23
+ class JvmNotFound(Exception):
24
+ """No suitable JVM on this machine; str(e) is a user-facing hint."""
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class JvmInfo:
29
+ home: Path
30
+ libjvm: Path
31
+ major: int
32
+
33
+
34
+ def _libjvm(home: Path) -> Path | None:
35
+ names = {"darwin": ("libjvm.dylib",), "win32": ("jvm.dll",)}.get(sys.platform, ("libjvm.so",))
36
+ for sub in ("lib/server", "bin/server", "jre/lib/server"):
37
+ for n in names:
38
+ p = home / sub / n
39
+ if p.is_file():
40
+ return p
41
+ return None
42
+
43
+
44
+ def _major(home: Path) -> int | None:
45
+ """Parse JAVA_VERSION from the `release` file: '21.0.1' -> 21, '1.8.0_1' -> 8."""
46
+ try:
47
+ text = (home / "release").read_text()
48
+ except OSError:
49
+ return None
50
+ m = re.search(r'JAVA_VERSION="?(\d+)(?:\.(\d+))?', text)
51
+ if not m:
52
+ return None
53
+ first, second = int(m.group(1)), m.group(2)
54
+ return int(second) if first == 1 and second else first
55
+
56
+
57
+ def _candidates() -> list[Path]:
58
+ out: list[Path] = []
59
+ if env := os.environ.get("JAVA_HOME"):
60
+ out.append(Path(env))
61
+ if java := shutil.which("java"):
62
+ out.append(Path(java).resolve().parent.parent)
63
+ if sys.platform == "darwin":
64
+ try:
65
+ r = subprocess.run(
66
+ ["/usr/libexec/java_home"], capture_output=True, text=True, timeout=5
67
+ )
68
+ if r.returncode == 0 and r.stdout.strip():
69
+ out.append(Path(r.stdout.strip()))
70
+ except (OSError, subprocess.SubprocessError):
71
+ pass
72
+ # Homebrew's openjdk is keg-only: the JDK sits under libexec, and is not on PATH
73
+ for prefix in (Path("/opt/homebrew/opt/openjdk"), Path("/usr/local/opt/openjdk")):
74
+ out += [prefix / "libexec/openjdk.jdk/Contents/Home", prefix]
75
+ elif sys.platform.startswith("linux"):
76
+ out += [Path("/home/linuxbrew/.linuxbrew/opt/openjdk")]
77
+ return out
78
+
79
+
80
+ def locate_jvm() -> JvmInfo:
81
+ """First candidate that has a libjvm and is Java >= MIN_JAVA."""
82
+ seen: set[Path] = set()
83
+ too_old: list[str] = []
84
+ for home in _candidates():
85
+ if home in seen:
86
+ continue
87
+ seen.add(home)
88
+ lib, major = _libjvm(home), _major(home)
89
+ if lib is None:
90
+ continue
91
+ if major is not None and major < MIN_JAVA:
92
+ too_old.append(f"{home} (Java {major})")
93
+ continue
94
+ return JvmInfo(home=home, libjvm=lib, major=major or 0)
95
+ msg = INSTALL_HINT
96
+ if too_old:
97
+ msg = f"Found only too old Java: {', '.join(too_old)}.\n{msg}"
98
+ raise JvmNotFound(msg)
sqlide/jvm/runtime.py ADDED
@@ -0,0 +1,44 @@
1
+ """Start the in-process JVM exactly once (JPype cannot restart it)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ import jpype
8
+ import jpype.config
9
+
10
+ from sqlide.jvm.locate import JvmInfo, locate_jvm
11
+
12
+ _lock = threading.Lock()
13
+ _info: JvmInfo | None = None
14
+
15
+
16
+ def ensure_jvm() -> JvmInfo:
17
+ """Idempotent and thread-safe. Raises JvmNotFound with an install hint."""
18
+ global _info
19
+ with _lock:
20
+ if _info is not None:
21
+ return _info
22
+ info = locate_jvm()
23
+ if not jpype.isJVMStarted():
24
+ # We start the JVM from worker threads; DestroyJavaVM at interpreter exit then
25
+ # hangs. Sessions are closed explicitly, so skipping the JVM teardown loses nothing.
26
+ jpype.config.destroy_jvm = False
27
+ jpype.startJVM(str(info.libjvm), "-Djava.awt.headless=true", convertStrings=False)
28
+ _silence_jvm_streams()
29
+ _info = info
30
+ return info
31
+
32
+
33
+ def jvm_started() -> bool:
34
+ return jpype.isJVMStarted()
35
+
36
+
37
+ def _silence_jvm_streams() -> None:
38
+ """Drivers (SLF4J etc.) print straight to the JVM's fd 1/2, which would corrupt the TUI."""
39
+ System = jpype.JClass("java.lang.System")
40
+ sink = jpype.JClass("java.io.PrintStream")(
41
+ jpype.JClass("java.io.OutputStream").nullOutputStream()
42
+ )
43
+ System.setOut(sink)
44
+ System.setErr(sink)
sqlide/sql/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """SQL block: pure-Python lexer and statement splitter. No JVM, no UI."""