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
|
@@ -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
|
sqlide/grid/__init__.py
ADDED
|
@@ -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
|
sqlide/history/store.py
ADDED
|
@@ -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."""
|