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
sqlide/db/completion.py
ADDED
|
@@ -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
|