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,127 @@
1
+ """Autocomplete candidates: a Context (what is typed) + MetaCache (what exists) + keywords."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from sqlide.db.metadata import MetaCache, Namespace, Table
8
+ from sqlide.db.result import DbError
9
+ from sqlide.sql.context import Context
10
+ from sqlide.sql.keywords import functions, keywords
11
+ from sqlide.sql.snippets import quote_ident
12
+
13
+ MAX_CANDIDATES = 60
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Candidate:
18
+ text: str # what gets inserted (already quoted when needed)
19
+ kind: str # column | table | view | schema | keyword | function
20
+ detail: str = ""
21
+
22
+
23
+ def _same(a: str, b: str) -> bool:
24
+ return a.lower() == b.lower()
25
+
26
+
27
+ async def _find_namespace(meta: MetaCache, name: str) -> Namespace | None:
28
+ return next((n for n in await meta.namespaces() if _same(n.name, name)), None)
29
+
30
+
31
+ async def _resolve(meta: MetaCache, ref_parts: tuple[str, ...]) -> Table | None:
32
+ """Table named by `ref_parts` ((table,) or (schema, table)) among the cached metadata."""
33
+ name = ref_parts[-1] if ref_parts else ""
34
+ if not name:
35
+ return None
36
+ if len(ref_parts) >= 2:
37
+ ns = await _find_namespace(meta, ref_parts[-2])
38
+ else:
39
+ current = await meta.current_namespace()
40
+ ns = await _find_namespace(meta, current)
41
+ if ns is None:
42
+ return None
43
+ return next((t for t in await meta.tables(ns) if _same(t.name, name)), None)
44
+
45
+
46
+ async def _columns_of(meta: MetaCache, parts: tuple[str, ...], dialect: str) -> list[Candidate]:
47
+ table = await _resolve(meta, parts)
48
+ if table is None:
49
+ return []
50
+ return [
51
+ Candidate(
52
+ quote_ident(c.name, dialect), "column", c.type_name + (" · pk" if c.primary_key else "")
53
+ )
54
+ for c in await meta.columns(table)
55
+ ]
56
+
57
+
58
+ def _table_candidates(tables: list[Table], dialect: str) -> list[Candidate]:
59
+ return [
60
+ Candidate(quote_ident(t.name, dialect), "view" if t.is_view else "table") for t in tables
61
+ ]
62
+
63
+
64
+ def _match(word: str, prefix: str) -> bool:
65
+ return word.lower().startswith(prefix.lower())
66
+
67
+
68
+ def _keyword_case(word: str, prefix: str) -> str:
69
+ return word.lower() if prefix and prefix.islower() else word
70
+
71
+
72
+ async def candidates(ctx: Context, meta: MetaCache | None, dialect: str) -> list[Candidate]:
73
+ """Ranked candidates for `ctx`. A metadata failure degrades to keywords only."""
74
+ found: list[Candidate] = []
75
+ if meta is not None:
76
+ try:
77
+ found = await _from_metadata(ctx, meta, dialect)
78
+ except DbError:
79
+ found = []
80
+ if ctx.kind != "qualified":
81
+ found += [Candidate(_keyword_case(k, ctx.prefix), "keyword") for k in keywords(dialect)]
82
+ if ctx.kind == "column":
83
+ found += [
84
+ Candidate(_keyword_case(f, ctx.prefix), "function") for f in functions(dialect)
85
+ ]
86
+ seen: set[tuple[str, str]] = set()
87
+ out: list[Candidate] = []
88
+ for c in found:
89
+ key = (c.text.lower(), c.kind)
90
+ if key in seen or not _match(c.text.strip('"`[]'), ctx.prefix):
91
+ continue
92
+ seen.add(key)
93
+ out.append(c)
94
+ if len(out) >= MAX_CANDIDATES:
95
+ break
96
+ return out
97
+
98
+
99
+ async def _from_metadata(ctx: Context, meta: MetaCache, dialect: str) -> list[Candidate]:
100
+ if ctx.kind == "qualified":
101
+ return await _qualified(ctx, meta, dialect)
102
+ spaces = await meta.namespaces()
103
+ out: list[Candidate] = []
104
+ if ctx.kind == "table":
105
+ current = await meta.current_namespace()
106
+ ns = await _find_namespace(meta, current)
107
+ if ns is not None:
108
+ out += _table_candidates(await meta.tables(ns), dialect)
109
+ out += [Candidate(quote_ident(n.name, dialect), "schema") for n in spaces]
110
+ return out
111
+ # column context: columns of every table the statement mentions, then table names
112
+ for ref in ctx.tables:
113
+ out += await _columns_of(meta, ref.parts, dialect)
114
+ return out
115
+
116
+
117
+ async def _qualified(ctx: Context, meta: MetaCache, dialect: str) -> list[Candidate]:
118
+ q = ctx.qualifier
119
+ ref = next((t for t in ctx.tables if t.alias and _same(t.alias, q[-1])), None)
120
+ if ref is not None:
121
+ return await _columns_of(meta, ref.parts, dialect)
122
+ if len(q) == 1:
123
+ ns = await _find_namespace(meta, q[0])
124
+ if ns is not None:
125
+ return _table_candidates(await meta.tables(ns), dialect)
126
+ # table-name qualifier: `users.` or `schema.users.`
127
+ return await _columns_of(meta, q, dialect)
sqlide/db/factory.py ADDED
@@ -0,0 +1,37 @@
1
+ """Glue: saved Connection + driver registry -> DbSession (not yet opened)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlide.config.connections import Connection
6
+ from sqlide.db.session import DbSession
7
+ from sqlide.drivers.loader import load_driver
8
+ from sqlide.drivers.registry import DriverRegistry
9
+
10
+
11
+ def create_session(
12
+ conn: Connection, password: str | None, registry: DriverRegistry | None = None
13
+ ) -> DbSession:
14
+ """Blocking (may start the JVM, ~1s): call from a worker thread in the UI."""
15
+ registry = registry or DriverRegistry()
16
+ defn = registry.get(conn.driver)
17
+ loaded = load_driver(defn, registry.jar_paths(conn.driver))
18
+ return DbSession(
19
+ loaded,
20
+ conn.url,
21
+ conn.user,
22
+ password,
23
+ conn.properties,
24
+ defn.dialect,
25
+ conn.autocommit,
26
+ )
27
+
28
+
29
+ _PRIVATE_MARKERS = (":mem:", ":memory:", "mode=memory", "jdbc:derby:memory", "jdbc:duckdb:")
30
+
31
+
32
+ def is_private_database(url: str) -> bool:
33
+ """True when a second connection would not see this connection's data (in-memory DBs)."""
34
+ low = url.lower()
35
+ if low.startswith("jdbc:duckdb:"):
36
+ return low in ("jdbc:duckdb:", "jdbc:duckdb::memory:") or ":memory:" in low
37
+ return any(m in low for m in _PRIVATE_MARKERS)
sqlide/db/metadata.py ADDED
@@ -0,0 +1,174 @@
1
+ """Lazy, cached database metadata (schemas, tables, columns) read through JDBC DatabaseMetaData.
2
+
3
+ Pure data + one class that needs a session; nothing here knows about the UI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import jpype
13
+
14
+ from sqlide.db.session import DbSession, _db_error
15
+
16
+ TABLE_TYPES = ["TABLE", "VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE", "FOREIGN TABLE"]
17
+
18
+
19
+ _SYSTEM = {
20
+ "information_schema", "pg_catalog", "pg_toast", "sys", "mysql", "performance_schema",
21
+ "system", "sysibm", "sysibmadm", "syscat", "sysstat", "ctxsys", "xdb", "mdsys",
22
+ "outln", "dbsnmp", "appqossys", "audsys", "gsmadmin_internal", "lbacsys", "ordsys",
23
+ "wmsys", "dvsys", "olapsys", "ojvmsys", "remote_scheduler_agent", "guest",
24
+ } # fmt: skip
25
+
26
+
27
+ def is_system_namespace(name: str) -> bool:
28
+ low = name.lower()
29
+ return low in _SYSTEM or low.startswith(("pg_temp", "pg_toast"))
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Namespace:
34
+ """A schema, or a catalog on databases that have no schemas (MySQL, ClickHouse)."""
35
+
36
+ name: str
37
+ is_catalog: bool = False
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Table:
42
+ namespace: str
43
+ name: str
44
+ kind: str # driver-specific: TABLE, BASE TABLE, VIEW, ...
45
+ is_catalog: bool = False
46
+
47
+ @property
48
+ def is_view(self) -> bool:
49
+ return "VIEW" in self.kind.upper()
50
+
51
+ @property
52
+ def qualified(self) -> str:
53
+ return f"{self.namespace}.{self.name}" if self.namespace else self.name
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Column:
58
+ name: str
59
+ type_name: str
60
+ nullable: bool
61
+ primary_key: bool = False
62
+
63
+
64
+ def _scope(name: str, is_catalog: bool) -> tuple[str | None, str | None]:
65
+ """(catalog, schema) arguments for DatabaseMetaData; "" means no filter at all."""
66
+ if not name:
67
+ return None, None
68
+ return (name, None) if is_catalog else (None, name)
69
+
70
+
71
+ def _rows(rs: Any, *cols: str) -> list[tuple[Any, ...]]:
72
+ out = []
73
+ try:
74
+ while rs.next():
75
+ out.append(tuple(None if (v := rs.getString(c)) is None else str(v) for c in cols))
76
+ finally:
77
+ with contextlib.suppress(jpype.JException):
78
+ rs.close()
79
+ return out
80
+
81
+
82
+ class MetaCache:
83
+ """Per-session cache. Every getter loads once; `refresh()` drops it."""
84
+
85
+ def __init__(self, session: DbSession) -> None:
86
+ self._s = session
87
+ self._namespaces: list[Namespace] | None = None
88
+ self._current: str | None = None
89
+ self._tables: dict[str, list[Table]] = {}
90
+ self._columns: dict[tuple[str, str], list[Column]] = {}
91
+
92
+ def use(self, session: DbSession) -> None:
93
+ """Read from another session (a dedicated one) from now on; the cache stays."""
94
+ self._s = session
95
+
96
+ def refresh(self) -> None:
97
+ self._namespaces = None
98
+ self._current = None
99
+ self._tables.clear()
100
+ self._columns.clear()
101
+
102
+ # --- loading ---
103
+ async def namespaces(self) -> list[Namespace]:
104
+ if self._namespaces is None:
105
+ self._namespaces = await self._s.call(self._load_namespaces)
106
+ return self._namespaces
107
+
108
+ @staticmethod
109
+ def _load_namespaces(conn: Any) -> list[Namespace]:
110
+ try:
111
+ md = conn.getMetaData()
112
+ schemas = [r[0] for r in _rows(md.getSchemas(), "TABLE_SCHEM")]
113
+ if schemas:
114
+ return [Namespace(s) for s in sorted(schemas, key=str.lower)]
115
+ cats = [r[0] for r in _rows(md.getCatalogs(), "TABLE_CAT")]
116
+ if cats:
117
+ return [Namespace(c, True) for c in sorted(cats, key=str.lower)]
118
+ return [Namespace("")] # SQLite & co: no schemas at all, tables are just there
119
+ except jpype.JException as e:
120
+ raise _db_error(e) from e
121
+
122
+ async def current_namespace(self) -> str:
123
+ """The schema (or catalog) unqualified names resolve to; "" when unknown."""
124
+ if self._current is None:
125
+ self._current = await self._s.call(self._load_current)
126
+ return self._current
127
+
128
+ @staticmethod
129
+ def _load_current(conn: Any) -> str:
130
+ for getter in (conn.getSchema, conn.getCatalog):
131
+ try:
132
+ value = getter()
133
+ except jpype.JException:
134
+ continue
135
+ if value is not None:
136
+ return str(value)
137
+ return ""
138
+
139
+ async def tables(self, ns: Namespace) -> list[Table]:
140
+ if ns.name not in self._tables:
141
+ self._tables[ns.name] = await self._s.call(lambda c: self._load_tables(c, ns))
142
+ return self._tables[ns.name]
143
+
144
+ @staticmethod
145
+ def _load_tables(conn: Any, ns: Namespace) -> list[Table]:
146
+ try:
147
+ md = conn.getMetaData()
148
+ cat, sch = (ns.name, None) if ns.is_catalog else (None, ns.name)
149
+ types: Any = jpype.JArray(jpype.JString)
150
+ rs = md.getTables(cat, sch, "%", types(TABLE_TYPES))
151
+ rows = _rows(rs, "TABLE_NAME", "TABLE_TYPE")
152
+ except jpype.JException as e:
153
+ raise _db_error(e) from e
154
+ rows.sort(key=lambda r: (r[0] or "").lower())
155
+ return [Table(ns.name, n or "", t or "TABLE", ns.is_catalog) for n, t in rows]
156
+
157
+ async def columns(self, table: Table) -> list[Column]:
158
+ key = (table.namespace, table.name)
159
+ if key not in self._columns:
160
+ self._columns[key] = await self._s.call(lambda c: self._load_columns(c, table))
161
+ return self._columns[key]
162
+
163
+ @staticmethod
164
+ def _load_columns(conn: Any, t: Table) -> list[Column]:
165
+ try:
166
+ md = conn.getMetaData()
167
+ cat, sch = _scope(t.namespace, t.is_catalog)
168
+ pks = {r[0] for r in _rows(md.getPrimaryKeys(cat, sch, t.name), "COLUMN_NAME")}
169
+ rows = _rows(
170
+ md.getColumns(cat, sch, t.name, "%"), "COLUMN_NAME", "TYPE_NAME", "IS_NULLABLE"
171
+ )
172
+ except jpype.JException as e:
173
+ raise _db_error(e) from e
174
+ return [Column(n or "", ty or "", nl != "NO", n in pks) for n, ty, nl in rows]
sqlide/db/result.py ADDED
@@ -0,0 +1,52 @@
1
+ """Plain result containers. No JVM or UI imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Protocol
7
+
8
+
9
+ class DbError(Exception):
10
+ def __init__(self, message: str, sql_state: str = "", vendor_code: int = 0) -> None:
11
+ super().__init__(message)
12
+ self.sql_state = sql_state
13
+ self.vendor_code = vendor_code
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Column:
18
+ name: str
19
+ type_name: str
20
+ jdbc_type: int
21
+
22
+
23
+ class RowSource(Protocol):
24
+ """Open server-side cursor: load further pages of the same result."""
25
+
26
+ async def fetch_more(self, n: int) -> tuple[list[tuple[Any, ...]], bool]:
27
+ """Return (rows, done)."""
28
+ ...
29
+
30
+ async def close(self) -> None: ...
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class ResultItem:
35
+ """One result of a statement: either rows or an update count."""
36
+
37
+ columns: list[Column] = field(default_factory=list)
38
+ rows: list[tuple[Any, ...]] = field(default_factory=list)
39
+ update_count: int | None = None
40
+ cursor: RowSource | None = None # set while more rows are pending
41
+
42
+ @property
43
+ def has_rows(self) -> bool:
44
+ return self.update_count is None
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class Execution:
49
+ sql: str
50
+ items: list[ResultItem]
51
+ warnings: list[str]
52
+ elapsed_s: float