composeui 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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
"""A model keeping the app's data in a SQLite database of its own.
|
|
2
|
+
|
|
3
|
+
The app writes its schema and its queries, in `SqliteTable` classes; the model gives
|
|
4
|
+
them a connection, runs each block of work as one transaction, and saves and opens the
|
|
5
|
+
database with the study. It works on a scratch copy in a temporary file: the user's
|
|
6
|
+
study only changes when it's saved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import csv
|
|
13
|
+
import datetime
|
|
14
|
+
import decimal
|
|
15
|
+
import functools
|
|
16
|
+
import gzip
|
|
17
|
+
import io
|
|
18
|
+
import itertools
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
import tempfile
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import weakref
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from types import MappingProxyType
|
|
28
|
+
from typing import (
|
|
29
|
+
TYPE_CHECKING,
|
|
30
|
+
Any,
|
|
31
|
+
Callable,
|
|
32
|
+
ClassVar,
|
|
33
|
+
Generic,
|
|
34
|
+
NamedTuple,
|
|
35
|
+
Optional,
|
|
36
|
+
TypeVar,
|
|
37
|
+
Union,
|
|
38
|
+
cast,
|
|
39
|
+
get_args,
|
|
40
|
+
overload,
|
|
41
|
+
)
|
|
42
|
+
from urllib.parse import urlsplit
|
|
43
|
+
from urllib.request import urlopen
|
|
44
|
+
|
|
45
|
+
import attrs
|
|
46
|
+
from typing_extensions import dataclass_transform
|
|
47
|
+
|
|
48
|
+
from composeui.core.jobs import current_job
|
|
49
|
+
from composeui.core.model import InvalidStateError, Model, RefusedError
|
|
50
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
51
|
+
from composeui.core.view import (
|
|
52
|
+
CONVERTER,
|
|
53
|
+
ColumnKind,
|
|
54
|
+
Filter,
|
|
55
|
+
FilterOperator,
|
|
56
|
+
Sort,
|
|
57
|
+
SourceColumn,
|
|
58
|
+
column_kind,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from collections.abc import Generator, Iterable, Mapping, Sequence
|
|
63
|
+
|
|
64
|
+
from attrs import AttrsInstance
|
|
65
|
+
from typing_extensions import TypeAlias, Unpack
|
|
66
|
+
|
|
67
|
+
#: a query's parameters: by position, or by name
|
|
68
|
+
Parameters: TypeAlias = Union[Sequence[Any], Mapping[str, Any]]
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"CSVSqliteTable",
|
|
72
|
+
"SqliteModel",
|
|
73
|
+
"SqliteSource",
|
|
74
|
+
"SqliteTable",
|
|
75
|
+
"as_parameters",
|
|
76
|
+
"csv_field",
|
|
77
|
+
"define_csv_row",
|
|
78
|
+
"open_csv_url",
|
|
79
|
+
"table",
|
|
80
|
+
"unique_name",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
_T = TypeVar("_T")
|
|
84
|
+
SqliteTableT = TypeVar("SqliteTableT", bound="SqliteTable")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def as_parameters(row: object) -> dict[str, Any]:
|
|
88
|
+
"""Get an `attrs` instance's fields by name, as named parameters of a query."""
|
|
89
|
+
parameters: dict[str, Any] = CONVERTER.unstructure(row)
|
|
90
|
+
return parameters
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _identifier(name: str) -> str:
|
|
94
|
+
"""Quote a table's or a column's name for SQL, which can't take it as a parameter."""
|
|
95
|
+
return '"' + name.replace('"', '""') + '"'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def unique_name(db: sqlite3.Connection, table: str, column: str, base: str) -> str:
|
|
99
|
+
"""Get `base`, or else "`base` 2", "`base` 3"..., the first no row of `table` has.
|
|
100
|
+
|
|
101
|
+
Names differing only by case are the same name (a ``COLLATE NOCASE`` column's), e.g. to
|
|
102
|
+
add a "Room" whatever rooms there are already.
|
|
103
|
+
"""
|
|
104
|
+
sql = f"SELECT {_identifier(column)} FROM {_identifier(table)}" # noqa: S608 (quoted)
|
|
105
|
+
taken = {str(name).casefold() for (name,) in db.execute(sql) if name is not None}
|
|
106
|
+
name, number = base, 1
|
|
107
|
+
while name.casefold() in taken:
|
|
108
|
+
number += 1
|
|
109
|
+
name = f"{base} {number}"
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SqliteTable:
|
|
114
|
+
"""A table of a `SqliteModel` (or a few related ones), with the queries the app needs.
|
|
115
|
+
|
|
116
|
+
`create` is the SQL script that creates it, run in every new study. Its methods read
|
|
117
|
+
and write through `connection()`. `changed` is emitted once a transaction that changed
|
|
118
|
+
rows through the table has committed, so the views that show them can refresh.
|
|
119
|
+
|
|
120
|
+
A constraint that fails in a transaction refuses it: `connection()` raises a
|
|
121
|
+
`RefusedError`. Its message is the one of the first key of `refusals` found in
|
|
122
|
+
SQLite's message: a constraint's name, or e.g. "UNIQUE constraint failed: room.name".
|
|
123
|
+
Else it is SQLite's message.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
create: ClassVar[str] = ""
|
|
127
|
+
#: by a part of SQLite's message, the user's message when a constraint fails, as
|
|
128
|
+
#: written in the code: the view showing it translates it (see `View.refusing`)
|
|
129
|
+
refusals: ClassVar[Mapping[str, str]] = MappingProxyType({})
|
|
130
|
+
|
|
131
|
+
def __init__(self, model: SqliteModel) -> None:
|
|
132
|
+
# weak: the model owns its tables, and should go (with its file) once unused
|
|
133
|
+
self._model = cast("SqliteModel", weakref.proxy(model))
|
|
134
|
+
self.changed = SignalNoArgs()
|
|
135
|
+
|
|
136
|
+
@contextlib.contextmanager
|
|
137
|
+
def connection(self) -> Generator[sqlite3.Connection]:
|
|
138
|
+
"""Give the database's connection for one transaction; see `SqliteModel.connection`.
|
|
139
|
+
|
|
140
|
+
Raises `RefusedError` when a constraint fails, the transaction rolled back.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
with self._model.connection(changing=self) as db:
|
|
144
|
+
yield db
|
|
145
|
+
except sqlite3.IntegrityError as error:
|
|
146
|
+
message = str(error)
|
|
147
|
+
refusal = next((text for key, text in self.refusals.items() if key in message), message)
|
|
148
|
+
raise RefusedError(refusal) from error
|
|
149
|
+
|
|
150
|
+
@contextlib.contextmanager
|
|
151
|
+
def reading(self) -> Generator[sqlite3.Connection]:
|
|
152
|
+
"""Give a connection reading the database alone; see `SqliteModel.reading`."""
|
|
153
|
+
with self._model.reading() as db:
|
|
154
|
+
yield db
|
|
155
|
+
|
|
156
|
+
def fetch(self, cls: type[_T], sql: str, parameters: Parameters = ()) -> list[_T]:
|
|
157
|
+
"""Run the query `sql` and get its rows as `cls` instances (an `attrs` class)."""
|
|
158
|
+
with self.connection() as db:
|
|
159
|
+
return [CONVERTER.structure(dict(row), cls) for row in db.execute(sql, parameters)]
|
|
160
|
+
|
|
161
|
+
def fetch_one(self, cls: type[_T], sql: str, parameters: Parameters = ()) -> _T | None:
|
|
162
|
+
"""Run the query `sql` and get its first row as a `cls` instance; None if none."""
|
|
163
|
+
with self.connection() as db:
|
|
164
|
+
row = db.execute(sql, parameters).fetchone()
|
|
165
|
+
return None if row is None else CONVERTER.structure(dict(row), cls)
|
|
166
|
+
|
|
167
|
+
def fetch_column(self, sql: str, parameters: Parameters = ()) -> list[Any]:
|
|
168
|
+
"""Run the query `sql` and get its rows' first column, e.g. a list of names."""
|
|
169
|
+
with self.connection() as db:
|
|
170
|
+
return [row[0] for row in db.execute(sql, parameters)]
|
|
171
|
+
|
|
172
|
+
def insert_batches(
|
|
173
|
+
self,
|
|
174
|
+
sql: str,
|
|
175
|
+
rows: Iterable[Parameters],
|
|
176
|
+
*,
|
|
177
|
+
db: sqlite3.Connection | None = None,
|
|
178
|
+
batch_size: int = 100_000,
|
|
179
|
+
limit: int | None = None,
|
|
180
|
+
) -> Generator[int]:
|
|
181
|
+
"""Insert rows in batches and yield the cumulative number of rows submitted.
|
|
182
|
+
|
|
183
|
+
Rows are positional or named SQL parameters. Read at most ``limit`` rows, or all
|
|
184
|
+
rows if None. ``batch_size`` must be positive; ``limit`` must be nonnegative.
|
|
185
|
+
Counts describe submitted rows, not affected rows (e.g. with INSERT OR IGNORE).
|
|
186
|
+
|
|
187
|
+
Exhaust the generator to commit, or use ``contextlib.closing`` to roll back when
|
|
188
|
+
stopping early. All batches share one transaction. Inside ``connection()``, they
|
|
189
|
+
join its transaction instead: the caller must raise to roll that transaction back.
|
|
190
|
+
If ``db`` is supplied, use it without committing, rolling back or closing it.
|
|
191
|
+
Pass a connection from ``connection()`` to keep the table's change notifications.
|
|
192
|
+
"""
|
|
193
|
+
if batch_size <= 0:
|
|
194
|
+
msg = "batch_size must be positive"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
if limit is not None and limit < 0:
|
|
197
|
+
msg = "limit must be nonnegative"
|
|
198
|
+
raise ValueError(msg)
|
|
199
|
+
iterator = iter(rows)
|
|
200
|
+
count = 0
|
|
201
|
+
with self.connection() if db is None else contextlib.nullcontext(db) as connection:
|
|
202
|
+
while limit is None or count < limit:
|
|
203
|
+
size = batch_size if limit is None else min(batch_size, limit - count)
|
|
204
|
+
batch = list(itertools.islice(iterator, size))
|
|
205
|
+
if not batch:
|
|
206
|
+
break
|
|
207
|
+
connection.executemany(sql, batch)
|
|
208
|
+
count += len(batch)
|
|
209
|
+
yield count
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@overload
|
|
213
|
+
def csv_field(
|
|
214
|
+
name: str,
|
|
215
|
+
*,
|
|
216
|
+
date_format: str,
|
|
217
|
+
default: Any = attrs.NOTHING,
|
|
218
|
+
factory: Optional[Callable[[], Any]] = None,
|
|
219
|
+
converter: Any = None,
|
|
220
|
+
init: bool = True,
|
|
221
|
+
alias: Optional[str] = None,
|
|
222
|
+
kw_only: bool = False,
|
|
223
|
+
**kwargs: Any,
|
|
224
|
+
) -> datetime.datetime: ...
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@overload
|
|
228
|
+
def csv_field(
|
|
229
|
+
name: str,
|
|
230
|
+
*,
|
|
231
|
+
date_format: None = None,
|
|
232
|
+
default: Any = attrs.NOTHING,
|
|
233
|
+
factory: Optional[Callable[[], Any]] = None,
|
|
234
|
+
converter: Any = None,
|
|
235
|
+
init: bool = True,
|
|
236
|
+
alias: Optional[str] = None,
|
|
237
|
+
kw_only: bool = False,
|
|
238
|
+
**kwargs: Any,
|
|
239
|
+
) -> Any: ...
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def csv_field(name: str, *, date_format: str | None = None, **kwargs: Any) -> Any:
|
|
243
|
+
"""Declare a CSV field, with an optional strptime format for a date or datetime."""
|
|
244
|
+
if date_format is not None and kwargs.get("converter") is not None:
|
|
245
|
+
msg = "Use either date_format or converter, not both"
|
|
246
|
+
raise TypeError(msg)
|
|
247
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
248
|
+
metadata["csv_name"] = name
|
|
249
|
+
if date_format is not None:
|
|
250
|
+
metadata["csv_date_format"] = date_format
|
|
251
|
+
return attrs.field(metadata=metadata, **kwargs)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_RowClassT = TypeVar("_RowClassT", bound=type)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass_transform(field_specifiers=(attrs.field, csv_field))
|
|
258
|
+
def define_csv_row(cls: _RowClassT) -> _RowClassT:
|
|
259
|
+
"""Define an attrs row class with typed CSV field specifiers.
|
|
260
|
+
|
|
261
|
+
Use ``@define_csv_row`` instead of ``@attrs.define`` when fields use `csv_field`.
|
|
262
|
+
For mypy, enable the ``composeui.mypy`` plugin.
|
|
263
|
+
"""
|
|
264
|
+
return cast("_RowClassT", attrs.define(cls))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _csv_structure_hook(field: attrs.Attribute[Any]) -> Callable[[Any, Any], Any] | None:
|
|
268
|
+
date_format = field.metadata.get("csv_date_format")
|
|
269
|
+
if date_format is None:
|
|
270
|
+
return None if field.converter is not None else CONVERTER.get_structure_hook(field.type)
|
|
271
|
+
args = get_args(field.type)
|
|
272
|
+
non_null = tuple(arg for arg in args if arg is not type(None))
|
|
273
|
+
nullable = len(non_null) == 1 and type(None) in args
|
|
274
|
+
field_type = non_null[0] if nullable else field.type
|
|
275
|
+
if field_type not in (datetime.date, datetime.datetime):
|
|
276
|
+
msg = f"date_format requires a date or datetime field: {field.name}"
|
|
277
|
+
raise TypeError(msg)
|
|
278
|
+
|
|
279
|
+
def structure(value: Any, _type: Any) -> datetime.date | None:
|
|
280
|
+
if value is None and nullable:
|
|
281
|
+
return None
|
|
282
|
+
parsed = datetime.datetime.strptime(value, date_format) # noqa: DTZ007 (preserve CSV timezone semantics)
|
|
283
|
+
return parsed.date() if field_type is datetime.date else parsed
|
|
284
|
+
|
|
285
|
+
return structure
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _CSVField(NamedTuple):
|
|
289
|
+
header: str
|
|
290
|
+
alias: str
|
|
291
|
+
field_type: Any
|
|
292
|
+
structure: Callable[[Any, Any], Any] | None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@contextlib.contextmanager
|
|
296
|
+
def _csv_text(binary: io.BufferedIOBase, *, encoding: str) -> Generator[io.TextIOWrapper]:
|
|
297
|
+
"""Decode a binary CSV stream, detect gzip, and close its wrappers on exit."""
|
|
298
|
+
with contextlib.ExitStack() as stack:
|
|
299
|
+
buffer = stack.enter_context(io.BufferedReader(binary))
|
|
300
|
+
stream = (
|
|
301
|
+
stack.enter_context(gzip.GzipFile(fileobj=buffer, mode="rb"))
|
|
302
|
+
if buffer.peek(2).startswith(b"\x1f\x8b")
|
|
303
|
+
else buffer
|
|
304
|
+
)
|
|
305
|
+
text = stack.enter_context(io.TextIOWrapper(stream, encoding=encoding, newline=""))
|
|
306
|
+
yield text
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _replay_csv_sample(sample: str, text: io.TextIOWrapper) -> Generator[str]:
|
|
310
|
+
with io.StringIO(sample, newline="") as prefix:
|
|
311
|
+
for line in prefix:
|
|
312
|
+
# A sample can end inside a physical line, including a quoted field.
|
|
313
|
+
yield line if line.endswith(("\r", "\n")) else line + text.readline()
|
|
314
|
+
yield from text
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@contextlib.contextmanager
|
|
318
|
+
def open_csv_url(
|
|
319
|
+
url: str, *, encoding: str = "utf-8", timeout: float = 30.0
|
|
320
|
+
) -> Generator[io.TextIOWrapper]:
|
|
321
|
+
"""Open a trusted HTTP, HTTPS or file URL as CSV text, and close its streams on exit.
|
|
322
|
+
|
|
323
|
+
Detect gzip by its content, not its filename. Preserve CSV newlines and decode with
|
|
324
|
+
``encoding``. ``timeout`` sets the socket timeout in seconds, not a total download
|
|
325
|
+
deadline. Network, decompression and decoding errors propagate to the caller.
|
|
326
|
+
Do not pass untrusted URLs: the helper does not restrict hosts or local file access.
|
|
327
|
+
"""
|
|
328
|
+
if urlsplit(url).scheme not in {"http", "https", "file"}:
|
|
329
|
+
msg = "CSV URLs must use http, https or file"
|
|
330
|
+
raise ValueError(msg)
|
|
331
|
+
with (
|
|
332
|
+
urlopen(url, timeout=timeout) as response, # noqa: S310 (schemes checked above; trusted URLs only)
|
|
333
|
+
_csv_text(response, encoding=encoding) as text,
|
|
334
|
+
):
|
|
335
|
+
yield text
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class CSVSqliteTable(SqliteTable):
|
|
339
|
+
"""A CSV table whose schema comes from an attrs row class."""
|
|
340
|
+
|
|
341
|
+
row_type: ClassVar[type[AttrsInstance]]
|
|
342
|
+
name: ClassVar[str]
|
|
343
|
+
insert: ClassVar[str] = ""
|
|
344
|
+
_column_names: ClassVar[tuple[str, ...]]
|
|
345
|
+
_csv_fields: ClassVar[tuple[_CSVField, ...]]
|
|
346
|
+
|
|
347
|
+
def __init_subclass__(
|
|
348
|
+
cls, *, row_type: type[AttrsInstance] | None = None, name: str | None = None, **kwargs: Any
|
|
349
|
+
) -> None:
|
|
350
|
+
super().__init_subclass__(**kwargs)
|
|
351
|
+
if row_type is None and name is None:
|
|
352
|
+
return
|
|
353
|
+
if row_type is None or not name:
|
|
354
|
+
msg = "CSVSqliteTable requires an attrs row_type and a table name"
|
|
355
|
+
raise TypeError(msg)
|
|
356
|
+
attrs.resolve_types(row_type)
|
|
357
|
+
fields = attrs.fields(row_type)
|
|
358
|
+
if not fields:
|
|
359
|
+
msg = "CSV row classes must have at least one field"
|
|
360
|
+
raise TypeError(msg)
|
|
361
|
+
sql_types = {
|
|
362
|
+
str: "TEXT",
|
|
363
|
+
int: "INTEGER",
|
|
364
|
+
bool: "INTEGER",
|
|
365
|
+
float: "REAL",
|
|
366
|
+
bytes: "BLOB",
|
|
367
|
+
datetime.date: "DATE",
|
|
368
|
+
datetime.datetime: "TIMESTAMP",
|
|
369
|
+
decimal.Decimal: "REAL",
|
|
370
|
+
}
|
|
371
|
+
columns = []
|
|
372
|
+
for field in fields:
|
|
373
|
+
args = get_args(field.type)
|
|
374
|
+
non_null = tuple(arg for arg in args if arg is not type(None))
|
|
375
|
+
nullable = len(non_null) == 1 and type(None) in args
|
|
376
|
+
field_type = non_null[0] if nullable else field.type
|
|
377
|
+
if field_type not in sql_types:
|
|
378
|
+
msg = f"Unsupported CSV field type for {field.name}: {field.type}"
|
|
379
|
+
raise TypeError(msg)
|
|
380
|
+
constraint = "" if nullable else " NOT NULL"
|
|
381
|
+
columns.append(f"{_identifier(field.name)} {sql_types[field_type]}{constraint}")
|
|
382
|
+
cls.row_type = row_type
|
|
383
|
+
cls.name = name
|
|
384
|
+
cls._column_names = tuple(field.name for field in fields)
|
|
385
|
+
cls._csv_fields = tuple(
|
|
386
|
+
_CSVField(
|
|
387
|
+
header=field.metadata.get("csv_name", field.name),
|
|
388
|
+
alias=field.alias,
|
|
389
|
+
field_type=field.type,
|
|
390
|
+
structure=_csv_structure_hook(field),
|
|
391
|
+
)
|
|
392
|
+
for field in fields
|
|
393
|
+
if field.init
|
|
394
|
+
)
|
|
395
|
+
cls.create = f"CREATE TABLE {_identifier(name)} ({', '.join(columns)});"
|
|
396
|
+
names = ", ".join(_identifier(field.name) for field in fields)
|
|
397
|
+
placeholders = ", ".join("?" for _ in fields)
|
|
398
|
+
cls.insert = f"INSERT INTO {_identifier(name)} ({names}) VALUES ({placeholders})" # noqa: S608 (quoted identifiers)
|
|
399
|
+
|
|
400
|
+
def source(self, *, headers: Mapping[str, str] | None = None) -> SqliteSource:
|
|
401
|
+
"""Build a source with schema-derived types and SQLite row ids.
|
|
402
|
+
|
|
403
|
+
By default, show every field under its CSV header (or its field name).
|
|
404
|
+
``headers`` selects, orders and labels the fields to show. Raises `KeyError`
|
|
405
|
+
for an unknown field, or `ValueError` if fields shadow all SQLite row-id names.
|
|
406
|
+
Use `SqliteSource` directly for custom queries or explicit primary keys.
|
|
407
|
+
"""
|
|
408
|
+
fields = attrs.fields_dict(self.row_type)
|
|
409
|
+
if headers is None:
|
|
410
|
+
headers = {
|
|
411
|
+
field.name: field.metadata.get("csv_name", field.name) for field in fields.values()
|
|
412
|
+
}
|
|
413
|
+
columns = [
|
|
414
|
+
SourceColumn(name, header, fields[name].type) for name, header in headers.items()
|
|
415
|
+
]
|
|
416
|
+
names = {name.casefold() for name in fields}
|
|
417
|
+
row_id = next((name for name in ("rowid", "_rowid_", "oid") if name not in names), None)
|
|
418
|
+
if row_id is None:
|
|
419
|
+
msg = "CSV fields shadow all SQLite row-id names; use SqliteSource with an explicit key"
|
|
420
|
+
raise ValueError(msg)
|
|
421
|
+
# SQLite otherwise names every hidden row-id expression "rowid".
|
|
422
|
+
identifier = _identifier(row_id)
|
|
423
|
+
query = f"SELECT {identifier} AS {identifier}, * FROM {_identifier(self.name)}" # noqa: S608 (quoted identifiers)
|
|
424
|
+
return SqliteSource(self, query, id_column=row_id, columns=columns)
|
|
425
|
+
|
|
426
|
+
def row_parameters(self, row: dict[str, Any]) -> Parameters:
|
|
427
|
+
"""Convert CSV fields through the row class and return SQL parameters."""
|
|
428
|
+
values = {}
|
|
429
|
+
try:
|
|
430
|
+
for field in self._csv_fields:
|
|
431
|
+
if field.header in row:
|
|
432
|
+
value = row[field.header]
|
|
433
|
+
values[field.alias] = (
|
|
434
|
+
value
|
|
435
|
+
if field.structure is None
|
|
436
|
+
else field.structure(value, field.field_type)
|
|
437
|
+
)
|
|
438
|
+
instance = self.row_type(**values)
|
|
439
|
+
except (ValueError, TypeError) as error:
|
|
440
|
+
raise RefusedError(str(error)) from error
|
|
441
|
+
return tuple(_sql_value(getattr(instance, name)) for name in self._column_names)
|
|
442
|
+
|
|
443
|
+
def fill(
|
|
444
|
+
self,
|
|
445
|
+
text: Iterable[str],
|
|
446
|
+
*,
|
|
447
|
+
batch_size: int = 100_000,
|
|
448
|
+
limit: int | None = None,
|
|
449
|
+
**csv_options: Any,
|
|
450
|
+
) -> Generator[int]:
|
|
451
|
+
"""Import CSV text and yield cumulative row counts.
|
|
452
|
+
|
|
453
|
+
Pass an open text file (with ``newline=''``) or another iterable of lines.
|
|
454
|
+
The caller owns and closes it. Extra keywords go to ``csv.DictReader``,
|
|
455
|
+
for example ``delimiter=';'`` or ``fieldnames=['name', 'value']``.
|
|
456
|
+
Exhaust the generator to commit. Use ``contextlib.closing`` when stopping
|
|
457
|
+
early to roll back rows. Nested transactions follow
|
|
458
|
+
`SqliteTable.insert_batches`'s rules.
|
|
459
|
+
"""
|
|
460
|
+
rows = (self.row_parameters(row) for row in csv.DictReader(text, **csv_options))
|
|
461
|
+
yield from self.insert_batches(self.insert, rows, batch_size=batch_size, limit=limit)
|
|
462
|
+
|
|
463
|
+
def fill_from_file( # noqa: PLR0913 (keep decoding, detection and import options explicit)
|
|
464
|
+
self,
|
|
465
|
+
path: str | os.PathLike[str],
|
|
466
|
+
*,
|
|
467
|
+
encoding: str = "utf-8",
|
|
468
|
+
sniff: bool = False,
|
|
469
|
+
sniff_sample_size: int = 8192,
|
|
470
|
+
batch_size: int = 100_000,
|
|
471
|
+
limit: int | None = None,
|
|
472
|
+
**csv_options: Any,
|
|
473
|
+
) -> Generator[int]:
|
|
474
|
+
"""Import a plain or gzip CSV file and yield cumulative submitted-row counts.
|
|
475
|
+
|
|
476
|
+
Accept a path string or path-like object, relative or absolute. Detect gzip
|
|
477
|
+
from the content. Own and close the file; other options follow `fill`.
|
|
478
|
+
Exhaust the generator to commit, or use ``contextlib.closing`` when stopping
|
|
479
|
+
early to close the file and roll back. File and decoding errors propagate.
|
|
480
|
+
Set ``sniff=True`` to detect the dialect from at most ``sniff_sample_size``
|
|
481
|
+
decoded characters (8192 by default). The size must be positive when sniffing
|
|
482
|
+
and is ignored otherwise.
|
|
483
|
+
Explicit CSV options override detection. Detection failure raises `RefusedError`.
|
|
484
|
+
"""
|
|
485
|
+
with (
|
|
486
|
+
Path(path).open("rb") as binary,
|
|
487
|
+
_csv_text(binary, encoding=encoding) as text,
|
|
488
|
+
contextlib.closing(
|
|
489
|
+
self._fill_stream(
|
|
490
|
+
text,
|
|
491
|
+
sniff=sniff,
|
|
492
|
+
sniff_sample_size=sniff_sample_size,
|
|
493
|
+
batch_size=batch_size,
|
|
494
|
+
limit=limit,
|
|
495
|
+
**csv_options,
|
|
496
|
+
)
|
|
497
|
+
) as batches,
|
|
498
|
+
):
|
|
499
|
+
yield from batches
|
|
500
|
+
|
|
501
|
+
def fill_from_url( # noqa: PLR0913 (keep transport, detection and import options explicit)
|
|
502
|
+
self,
|
|
503
|
+
url: str,
|
|
504
|
+
*,
|
|
505
|
+
encoding: str = "utf-8",
|
|
506
|
+
timeout: float = 30.0,
|
|
507
|
+
sniff: bool = False,
|
|
508
|
+
sniff_sample_size: int = 8192,
|
|
509
|
+
batch_size: int = 100_000,
|
|
510
|
+
limit: int | None = None,
|
|
511
|
+
**csv_options: Any,
|
|
512
|
+
) -> Generator[int]:
|
|
513
|
+
"""Import CSV from a trusted URL and yield cumulative submitted-row counts.
|
|
514
|
+
|
|
515
|
+
`open_csv_url` handles decoding, gzip and stream cleanup. Other options follow
|
|
516
|
+
`fill`. Exhaust the generator to commit; use ``contextlib.closing`` if iteration
|
|
517
|
+
can stop early, to close the streams and roll back. Existing rows are retained.
|
|
518
|
+
``sniff=True`` detects the dialect as in `fill_from_file`, without seeking.
|
|
519
|
+
``sniff_sample_size`` sets its limit in decoded characters (8192 by default).
|
|
520
|
+
"""
|
|
521
|
+
with (
|
|
522
|
+
open_csv_url(url, encoding=encoding, timeout=timeout) as text,
|
|
523
|
+
contextlib.closing(
|
|
524
|
+
self._fill_stream(
|
|
525
|
+
text,
|
|
526
|
+
sniff=sniff,
|
|
527
|
+
sniff_sample_size=sniff_sample_size,
|
|
528
|
+
batch_size=batch_size,
|
|
529
|
+
limit=limit,
|
|
530
|
+
**csv_options,
|
|
531
|
+
)
|
|
532
|
+
) as batches,
|
|
533
|
+
):
|
|
534
|
+
yield from batches
|
|
535
|
+
|
|
536
|
+
def _fill_stream(
|
|
537
|
+
self,
|
|
538
|
+
text: io.TextIOWrapper,
|
|
539
|
+
*,
|
|
540
|
+
sniff: bool,
|
|
541
|
+
sniff_sample_size: int,
|
|
542
|
+
batch_size: int,
|
|
543
|
+
limit: int | None,
|
|
544
|
+
**csv_options: Any,
|
|
545
|
+
) -> Generator[int]:
|
|
546
|
+
lines: Iterable[str] = text
|
|
547
|
+
if sniff:
|
|
548
|
+
if sniff_sample_size <= 0:
|
|
549
|
+
msg = "sniff_sample_size must be positive"
|
|
550
|
+
raise ValueError(msg)
|
|
551
|
+
sample = text.read(sniff_sample_size)
|
|
552
|
+
try:
|
|
553
|
+
dialect = csv.Sniffer().sniff(sample)
|
|
554
|
+
except csv.Error as error:
|
|
555
|
+
msg = (
|
|
556
|
+
"Cannot detect the CSV dialect; increase sniff_sample_size or disable sniff"
|
|
557
|
+
" and supply explicit CSV options"
|
|
558
|
+
)
|
|
559
|
+
raise RefusedError(msg) from error
|
|
560
|
+
csv_options = {"dialect": dialect, **csv_options}
|
|
561
|
+
lines = _replay_csv_sample(sample, text)
|
|
562
|
+
yield from self.fill(lines, batch_size=batch_size, limit=limit, **csv_options)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
#: the seconds a connection waits for another's lock before it fails: in the WAL journal,
|
|
566
|
+
#: only a write for another's, or a `PRAGMA` changing the journal
|
|
567
|
+
WAIT_SECONDS = 60.0
|
|
568
|
+
#: the ids a query takes at most at once, below SQLite's limit of parameters
|
|
569
|
+
_IDS_PER_QUERY = 900
|
|
570
|
+
#: each text operator, as a `LIKE` pattern of the filter's value (escaped), negated or not
|
|
571
|
+
_LIKES: Mapping[FilterOperator, tuple[str, bool]] = MappingProxyType(
|
|
572
|
+
{
|
|
573
|
+
FilterOperator.CONTAINS: ("%{}%", False),
|
|
574
|
+
FilterOperator.NOT_CONTAINS: ("%{}%", True),
|
|
575
|
+
FilterOperator.STARTS_WITH: ("{}%", False),
|
|
576
|
+
FilterOperator.ENDS_WITH: ("%{}", False),
|
|
577
|
+
}
|
|
578
|
+
)
|
|
579
|
+
#: each text comparison, in SQL, ignoring the case of ASCII letters
|
|
580
|
+
_TEXT_COMPARISONS: Mapping[FilterOperator, str] = MappingProxyType(
|
|
581
|
+
{FilterOperator.TEXT_EQUAL: "=", FilterOperator.TEXT_NOT_EQUAL: "<>"}
|
|
582
|
+
)
|
|
583
|
+
#: each comparison, in SQL
|
|
584
|
+
_COMPARISONS: Mapping[FilterOperator, str] = MappingProxyType(
|
|
585
|
+
{
|
|
586
|
+
FilterOperator.EQUAL: "=",
|
|
587
|
+
FilterOperator.NOT_EQUAL: "<>",
|
|
588
|
+
FilterOperator.LESS: "<",
|
|
589
|
+
FilterOperator.LESS_OR_EQUAL: "<=",
|
|
590
|
+
FilterOperator.GREATER: ">",
|
|
591
|
+
FilterOperator.GREATER_OR_EQUAL: ">=",
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _escaped(text: str) -> str:
|
|
597
|
+
r"""Escape `LIKE`'s wildcards in `text`, with a backslash (``ESCAPE '\'``)."""
|
|
598
|
+
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _sql_value(value: Any) -> Any:
|
|
602
|
+
"""Get a filter's value as SQLite stores it: a date as ISO text, a number as a number."""
|
|
603
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
604
|
+
return value.isoformat()
|
|
605
|
+
if isinstance(value, decimal.Decimal):
|
|
606
|
+
return float(value)
|
|
607
|
+
return value # an enum's column filters as text: never compared
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@functools.lru_cache(maxsize=32)
|
|
611
|
+
def _compiled(pattern: str) -> re.Pattern[str]:
|
|
612
|
+
return re.compile(pattern, re.IGNORECASE)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _casefold(value: Any) -> str:
|
|
616
|
+
"""SQL's ``casefold``: a cell's text as Python compares it, ignoring any case."""
|
|
617
|
+
return "" if value is None else str(value).casefold()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _folded(cell: str, *, is_ascii: bool) -> str:
|
|
621
|
+
"""Get the SQL of `cell`, case-folded by Python unless compared with ASCII text typed.
|
|
622
|
+
|
|
623
|
+
SQLite ignores the case of ASCII letters alone: "é" wouldn't find "É". Text typed in
|
|
624
|
+
ASCII keeps SQLite's comparison, which an index serves, without Python's call per row
|
|
625
|
+
(3 to 4 times slower). It doesn't find a cell whose letters fold into it: "strasse"
|
|
626
|
+
doesn't find "Straße", which "Straße" finds.
|
|
627
|
+
"""
|
|
628
|
+
return cell if is_ascii else f"casefold({cell})"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _regexp(pattern: str, value: Any) -> bool:
|
|
632
|
+
"""SQLite's ``REGEXP``: whether `pattern` is found in `value`'s text, ignoring case."""
|
|
633
|
+
return _compiled(pattern).search("" if value is None else str(value)) is not None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class SqliteSource:
|
|
637
|
+
"""A `TableSource` of a query's rows, which a large table reads a window at a time.
|
|
638
|
+
|
|
639
|
+
It takes the `SqliteTable` whose rows the query reads, the query itself, the column
|
|
640
|
+
of the rows' ids (e.g. a primary key) and the query's columns that the table shows.
|
|
641
|
+
SQLite sorts and filters the rows: the order is a ``SELECT`` of the ids, and the cells
|
|
642
|
+
a ``SELECT`` of some ids' rows, so no more rows than those shown come to Python. The
|
|
643
|
+
table's `changed` reads them again. Only the declared columns sort and filter, by name:
|
|
644
|
+
SQLite takes no column name as a parameter, the values are. It reads on connections
|
|
645
|
+
of its own (`SqliteModel.reading`): the rows committed, never waiting for a write, nor
|
|
646
|
+
the cells for the order a job reads.
|
|
647
|
+
|
|
648
|
+
A text compares ignoring its case as a `ListSource` does, but text typed in ASCII
|
|
649
|
+
alone compares in SQLite (see `_folded`). It sorts ignoring the case of ASCII letters
|
|
650
|
+
alone (``NOCASE``). Equal cells, and the rows unsorted, keep the order of their ids.
|
|
651
|
+
A ``/regex/`` filter, or a text not in ASCII, calls Python on each row: index the
|
|
652
|
+
columns that sort instead.
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
def __init__(
|
|
656
|
+
self,
|
|
657
|
+
table: SqliteTable,
|
|
658
|
+
query: str,
|
|
659
|
+
*,
|
|
660
|
+
id_column: str,
|
|
661
|
+
columns: Sequence[SourceColumn],
|
|
662
|
+
) -> None:
|
|
663
|
+
self._table = table
|
|
664
|
+
self._query = query
|
|
665
|
+
self._id = _identifier(id_column)
|
|
666
|
+
self._columns = {column.name: column for column in columns}
|
|
667
|
+
|
|
668
|
+
@property
|
|
669
|
+
def columns(self) -> Sequence[SourceColumn]:
|
|
670
|
+
return tuple(self._columns.values())
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def changed(self) -> SignalNoArgs:
|
|
674
|
+
return self._table.changed
|
|
675
|
+
|
|
676
|
+
def count(self) -> int:
|
|
677
|
+
with self._table.reading() as db:
|
|
678
|
+
# read to the end: the connection is reused (see `SqliteModel.reading`)
|
|
679
|
+
((count,),) = db.execute(f"SELECT COUNT(*) FROM ({self._query})").fetchall() # noqa: S608 (the app's query)
|
|
680
|
+
return int(count)
|
|
681
|
+
|
|
682
|
+
def ordered_ids(
|
|
683
|
+
self, sort: Sort | None, search: str, filters: Mapping[str, Filter]
|
|
684
|
+
) -> list[Any]:
|
|
685
|
+
conditions: list[str] = []
|
|
686
|
+
parameters: list[Any] = []
|
|
687
|
+
needle = search.strip()
|
|
688
|
+
texts = [
|
|
689
|
+
name
|
|
690
|
+
for name, column in self._columns.items()
|
|
691
|
+
if column_kind(column.type) is ColumnKind.TEXT
|
|
692
|
+
]
|
|
693
|
+
if needle != "" and len(texts) == 0:
|
|
694
|
+
conditions.append("0") # no text column holds it, as a `ListSource` finds
|
|
695
|
+
elif needle != "":
|
|
696
|
+
is_ascii = needle.isascii()
|
|
697
|
+
if not is_ascii:
|
|
698
|
+
needle = needle.casefold()
|
|
699
|
+
cells = [_folded(_identifier(name), is_ascii=is_ascii) for name in texts]
|
|
700
|
+
conditions.append(
|
|
701
|
+
"(" + " OR ".join(f"{cell} LIKE ? ESCAPE '\\'" for cell in cells) + ")"
|
|
702
|
+
)
|
|
703
|
+
parameters.extend([f"%{_escaped(needle)}%"] * len(texts))
|
|
704
|
+
for name, row_filter in filters.items():
|
|
705
|
+
condition, values = self._condition(self._column(name), row_filter)
|
|
706
|
+
conditions.append(condition)
|
|
707
|
+
parameters.extend(values)
|
|
708
|
+
# by id unsorted: else in the order of an index a filter reads by, no header showing it
|
|
709
|
+
queries = [(conditions, self._id)]
|
|
710
|
+
if sort is not None:
|
|
711
|
+
column = self._column(sort.column)
|
|
712
|
+
name = _identifier(column.name)
|
|
713
|
+
is_text = column_kind(column.type) is ColumnKind.TEXT
|
|
714
|
+
order = f"{name} COLLATE NOCASE" if is_text else name
|
|
715
|
+
direction = " DESC" if sort.descending else ""
|
|
716
|
+
empty = f"({name} IS NULL OR {name} = '')"
|
|
717
|
+
if len(conditions) > 0:
|
|
718
|
+
# the empty cells last, in both orders, SQLite sorting the rows passing:
|
|
719
|
+
# read in their column's index order, the table would be read row by row
|
|
720
|
+
queries = [(conditions, f"{empty} ASC, {order}{direction}, {self._id}")]
|
|
721
|
+
else:
|
|
722
|
+
# apart, the cells with a value read in their column's index order: an
|
|
723
|
+
# ORDER BY starting with the empty cells couldn't
|
|
724
|
+
queries = [
|
|
725
|
+
([f"NOT {empty}"], f"{order}{direction}, {self._id}"),
|
|
726
|
+
([empty], self._id),
|
|
727
|
+
]
|
|
728
|
+
with self._table.reading() as db:
|
|
729
|
+
db.create_function("regexp", 2, _regexp, deterministic=True)
|
|
730
|
+
db.create_function("casefold", 1, _casefold, deterministic=True)
|
|
731
|
+
db.execute("BEGIN") # the queries read the same rows, a write committed between
|
|
732
|
+
try:
|
|
733
|
+
return [
|
|
734
|
+
row[0]
|
|
735
|
+
for where, order_by in queries
|
|
736
|
+
for row in db.execute(self._select_ids(where, order_by), parameters)
|
|
737
|
+
]
|
|
738
|
+
finally:
|
|
739
|
+
db.rollback()
|
|
740
|
+
|
|
741
|
+
def _select_ids(self, conditions: list[str], order_by: str) -> str:
|
|
742
|
+
"""Get the query of the ids of the rows passing `conditions`, ordered by `order_by`."""
|
|
743
|
+
sql = f"SELECT {self._id} FROM ({self._query})" # noqa: S608 (quoted, the app's query)
|
|
744
|
+
if len(conditions) > 0:
|
|
745
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
746
|
+
return f"{sql} ORDER BY {order_by}"
|
|
747
|
+
|
|
748
|
+
def cells(self, ids: Sequence[Any], columns: Sequence[str]) -> dict[Any, list[Any]]:
|
|
749
|
+
types = [self._column(name).type for name in columns]
|
|
750
|
+
names = ", ".join(_identifier(name) for name in columns)
|
|
751
|
+
found: dict[Any, list[Any]] = {}
|
|
752
|
+
with self._table.reading() as db:
|
|
753
|
+
for start in range(0, len(ids), _IDS_PER_QUERY):
|
|
754
|
+
chunk = list(ids[start : start + _IDS_PER_QUERY])
|
|
755
|
+
marks = ", ".join("?" * len(chunk))
|
|
756
|
+
sql = (
|
|
757
|
+
f"SELECT {self._id}, {names} FROM ({self._query})" # noqa: S608 (quoted)
|
|
758
|
+
f" WHERE {self._id} IN ({marks})"
|
|
759
|
+
)
|
|
760
|
+
for row in db.execute(sql, chunk):
|
|
761
|
+
found[row[0]] = [
|
|
762
|
+
None if value is None else CONVERTER.structure(value, field_type)
|
|
763
|
+
for value, field_type in zip(tuple(row)[1:], types)
|
|
764
|
+
]
|
|
765
|
+
return found
|
|
766
|
+
|
|
767
|
+
def _column(self, name: str) -> SourceColumn:
|
|
768
|
+
column = self._columns.get(name)
|
|
769
|
+
if column is None:
|
|
770
|
+
msg = f"Cannot read the column {name!r} of a SqliteSource: it declares none so."
|
|
771
|
+
raise ValueError(msg)
|
|
772
|
+
return column
|
|
773
|
+
|
|
774
|
+
@staticmethod
|
|
775
|
+
def _condition(column: SourceColumn, row_filter: Filter) -> tuple[str, list[Any]]:
|
|
776
|
+
"""Get the SQL condition of `row_filter` on `column`, and its parameters.
|
|
777
|
+
|
|
778
|
+
As `Filter.matches` reads a cell: an empty one's text is "", and it fails a
|
|
779
|
+
comparison.
|
|
780
|
+
"""
|
|
781
|
+
name = _identifier(column.name)
|
|
782
|
+
text = f"COALESCE({name}, '')"
|
|
783
|
+
operator, value = row_filter.operator, row_filter.value
|
|
784
|
+
if operator is FilterOperator.EMPTY:
|
|
785
|
+
return f"({name} IS NULL OR {name} = '')", []
|
|
786
|
+
if operator in _LIKES:
|
|
787
|
+
pattern, is_negated = _LIKES[operator]
|
|
788
|
+
negation = "NOT " if is_negated else ""
|
|
789
|
+
cell = _folded(text, is_ascii=row_filter.typed_ascii)
|
|
790
|
+
return f"{cell} {negation}LIKE ? ESCAPE '\\'", [pattern.format(_escaped(value))]
|
|
791
|
+
if operator in _TEXT_COMPARISONS:
|
|
792
|
+
collation = " COLLATE NOCASE" if row_filter.typed_ascii else ""
|
|
793
|
+
cell = _folded(text, is_ascii=row_filter.typed_ascii)
|
|
794
|
+
return f"{cell} {_TEXT_COMPARISONS[operator]} ?{collation}", [value]
|
|
795
|
+
if operator is FilterOperator.MATCHES:
|
|
796
|
+
return f"regexp(?, {name})", [value.pattern]
|
|
797
|
+
if operator is FilterOperator.RANGE:
|
|
798
|
+
return f"{name} BETWEEN ? AND ?", [_sql_value(value), _sql_value(row_filter.upper)]
|
|
799
|
+
return f"{name} {_COMPARISONS[operator]} ?", [_sql_value(value)]
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class _DeclaredTable(Generic[SqliteTableT]):
|
|
803
|
+
"""A table a `SqliteModel` class declares, which each model builds (see `table`)."""
|
|
804
|
+
|
|
805
|
+
def __init__(self, table_type: type[SqliteTableT]) -> None:
|
|
806
|
+
self.table_type = table_type
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def table(table_type: type[SqliteTableT]) -> SqliteTableT:
|
|
810
|
+
"""Declare a table of a `SqliteModel`, in the model's class.
|
|
811
|
+
|
|
812
|
+
E.g. ``runs = table(Runs)``. Each model builds its own table, as an attribute of the
|
|
813
|
+
same name, before it creates its database. The tables are built in the order the
|
|
814
|
+
classes declare them, a base class's first.
|
|
815
|
+
"""
|
|
816
|
+
# the model's attribute is the table: typed as one, as `attrs.Factory` is
|
|
817
|
+
return cast("SqliteTableT", _DeclaredTable(table_type))
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
class SqliteModel(Model):
|
|
821
|
+
"""A model whose data is a SQLite database, saved and opened with the study.
|
|
822
|
+
|
|
823
|
+
Declare the tables in the class, ``runs = table(Runs)``, for the callbacks to reach
|
|
824
|
+
(``model.runs.add(...)``), or add them in `__init__` with `add_table`. A study's
|
|
825
|
+
database is created from the tables' `create` scripts at `schema_version`. When a
|
|
826
|
+
study saved at an older version is opened, `migrations` bring it up to date.
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
data_name: ClassVar[str | None] = "data.sqlite"
|
|
830
|
+
#: the tables' version, kept in the database (``PRAGMA user_version``); raise it with
|
|
831
|
+
#: every change to the tables, adding the migration from the previous one
|
|
832
|
+
schema_version: ClassVar[int] = 1
|
|
833
|
+
#: by version: the function bringing a database at that version to the next one
|
|
834
|
+
migrations: ClassVar[Mapping[int, Callable[[sqlite3.Connection], None]]] = {}
|
|
835
|
+
#: seconds opening a study's database may take, checks and migrations included: its
|
|
836
|
+
#: schema comes from the file, and a view of it may never finish (e.g. a recursive one)
|
|
837
|
+
open_time_limit: ClassVar[float] = 30.0
|
|
838
|
+
#: set on the connections of every study's database: SQLite keeps these per
|
|
839
|
+
#: connection, not in the file, and New and Open each start a connection of their own
|
|
840
|
+
pragmas: ClassVar[Mapping[str, int | str]] = MappingProxyType(
|
|
841
|
+
{
|
|
842
|
+
# the connections `reading` read during a write, its rows uncommitted (else a
|
|
843
|
+
# write spilling to the disk locks them out until it commits); the study file
|
|
844
|
+
# is saved in the default journal (`save_data`)
|
|
845
|
+
"journal_mode": "WAL",
|
|
846
|
+
"foreign_keys": "ON",
|
|
847
|
+
# the database is a scratch copy nothing reopens after a crash: waiting for the
|
|
848
|
+
# disk on every commit protects nothing, the study file is written apart
|
|
849
|
+
"synchronous": "OFF",
|
|
850
|
+
# a row replaced (`INSERT OR REPLACE`) fires its delete triggers, and a trigger
|
|
851
|
+
# may fire itself, so triggers keeping other tables up to date see every change
|
|
852
|
+
"recursive_triggers": "ON",
|
|
853
|
+
}
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
def __init__(self) -> None:
|
|
857
|
+
self._tables: list[SqliteTable] = []
|
|
858
|
+
declared: dict[str, _DeclaredTable[Any]] = {}
|
|
859
|
+
for cls in reversed(type(self).__mro__):
|
|
860
|
+
declared.update(
|
|
861
|
+
(name, value)
|
|
862
|
+
for name, value in vars(cls).items()
|
|
863
|
+
if isinstance(value, _DeclaredTable)
|
|
864
|
+
)
|
|
865
|
+
for name, declared_table in declared.items():
|
|
866
|
+
# before the database, which `_new_database` creates with them
|
|
867
|
+
built = declared_table.table_type(self)
|
|
868
|
+
self._tables.append(built)
|
|
869
|
+
setattr(self, name, built)
|
|
870
|
+
# one connection for the model, which threads (a worker, web requests) take in turn
|
|
871
|
+
self._lock = threading.RLock()
|
|
872
|
+
# which database the queries `reading` read, and how many: held for no query, so a
|
|
873
|
+
# read never waits for the turn a write holds
|
|
874
|
+
self._readers_lock = threading.Lock()
|
|
875
|
+
self._closed = False
|
|
876
|
+
self._depth = 0
|
|
877
|
+
self._changed: list[SqliteTable] = []
|
|
878
|
+
self._database = self._new_database()
|
|
879
|
+
|
|
880
|
+
@property
|
|
881
|
+
def signals(self) -> list[Signal[Unpack[tuple[Any, ...]]]]:
|
|
882
|
+
"""Get the model's signals, its tables' (`SqliteTable.changed`) among them."""
|
|
883
|
+
tables = [vars(table).values() for table in self._tables]
|
|
884
|
+
return [
|
|
885
|
+
*super().signals,
|
|
886
|
+
*(value for values in tables for value in values if isinstance(value, Signal)),
|
|
887
|
+
]
|
|
888
|
+
|
|
889
|
+
def add_table(self, table_type: type[SqliteTableT]) -> SqliteTableT:
|
|
890
|
+
"""Add a table, created in the current database too, and return it."""
|
|
891
|
+
table = table_type(self)
|
|
892
|
+
with self._lock:
|
|
893
|
+
self._database.connection.executescript(table.create)
|
|
894
|
+
self._tables.append(table)
|
|
895
|
+
return table
|
|
896
|
+
|
|
897
|
+
@contextlib.contextmanager
|
|
898
|
+
def connection(self, *, changing: SqliteTable | None = None) -> Generator[sqlite3.Connection]:
|
|
899
|
+
"""Give the database's connection for one transaction.
|
|
900
|
+
|
|
901
|
+
The transaction commits once the block ends, or rolls back if the block raises, so
|
|
902
|
+
its writes land together or not at all. A block inside another (one table's method
|
|
903
|
+
calling another's) joins the outer transaction. If the block changed rows,
|
|
904
|
+
`changing`'s `changed` is emitted after the commit. For a block in a job's thread, it
|
|
905
|
+
is emitted on the main thread (see `Job.call_on_main_thread`), where the views it
|
|
906
|
+
refreshes are.
|
|
907
|
+
"""
|
|
908
|
+
changed: list[SqliteTable] = []
|
|
909
|
+
with self._lock:
|
|
910
|
+
db = self._database.connection
|
|
911
|
+
before = db.total_changes
|
|
912
|
+
outermost = self._depth == 0
|
|
913
|
+
self._depth += 1
|
|
914
|
+
try:
|
|
915
|
+
yield db
|
|
916
|
+
if (
|
|
917
|
+
changing is not None
|
|
918
|
+
and db.total_changes != before
|
|
919
|
+
and changing not in self._changed
|
|
920
|
+
):
|
|
921
|
+
self._changed.append(changing)
|
|
922
|
+
if outermost:
|
|
923
|
+
db.commit()
|
|
924
|
+
changed, self._changed = self._changed, []
|
|
925
|
+
except BaseException:
|
|
926
|
+
if outermost:
|
|
927
|
+
db.rollback()
|
|
928
|
+
self._changed = []
|
|
929
|
+
raise
|
|
930
|
+
finally:
|
|
931
|
+
self._depth -= 1
|
|
932
|
+
# outside the lock: a view refreshing may query another thread's way
|
|
933
|
+
job = current_job()
|
|
934
|
+
for table in changed:
|
|
935
|
+
if job is None:
|
|
936
|
+
table.changed.emit()
|
|
937
|
+
else:
|
|
938
|
+
job.call_on_main_thread(table.changed.emit)
|
|
939
|
+
|
|
940
|
+
@contextlib.contextmanager
|
|
941
|
+
def reading(self) -> Generator[sqlite3.Connection]:
|
|
942
|
+
"""Give a connection that only reads the database, for a long query.
|
|
943
|
+
|
|
944
|
+
Unlike `connection()`, it waits for no other thread's turn, and holds none: the
|
|
945
|
+
model's other queries run meanwhile, e.g. a large table's cells while a job orders
|
|
946
|
+
its rows or writes some (`SqliteSource`). In the WAL journal, it reads the rows
|
|
947
|
+
committed, during a write too. A database New or Open replace meanwhile, or the
|
|
948
|
+
model's `close`, is deleted once the query is over. Once the model is closed,
|
|
949
|
+
raises `sqlite3.ProgrammingError`, as `connection()` does: e.g. a web download
|
|
950
|
+
its session's end cuts short.
|
|
951
|
+
|
|
952
|
+
The connection is reused, e.g. for the next window of a large table: read each
|
|
953
|
+
query's rows to the end in the block. A query left half read keeps the rows as
|
|
954
|
+
they were when it started, for the next reader too.
|
|
955
|
+
"""
|
|
956
|
+
with self._readers_lock:
|
|
957
|
+
if self._closed: # its file is gone
|
|
958
|
+
msg = "Cannot operate on a closed database."
|
|
959
|
+
raise sqlite3.ProgrammingError(msg)
|
|
960
|
+
database = self._database
|
|
961
|
+
database.readers += 1
|
|
962
|
+
db = database.idle_readers.pop() if len(database.idle_readers) > 0 else None
|
|
963
|
+
try:
|
|
964
|
+
if db is None:
|
|
965
|
+
db = database.open_reader()
|
|
966
|
+
yield db
|
|
967
|
+
finally:
|
|
968
|
+
with self._readers_lock:
|
|
969
|
+
database.readers -= 1
|
|
970
|
+
if db is not None:
|
|
971
|
+
database.idle_readers.append(db)
|
|
972
|
+
if database.readers == 0 and (database is not self._database or self._closed):
|
|
973
|
+
database.close()
|
|
974
|
+
|
|
975
|
+
def new(self) -> None:
|
|
976
|
+
self._replace_database(self._new_database())
|
|
977
|
+
|
|
978
|
+
def save_data(self, path: Path) -> None:
|
|
979
|
+
target = sqlite3.connect(str(path))
|
|
980
|
+
try:
|
|
981
|
+
with self._lock:
|
|
982
|
+
self._database.connection.backup(target)
|
|
983
|
+
# the backup copies the WAL journal: a file another app opens leaves none beside it
|
|
984
|
+
target.execute("PRAGMA journal_mode = DELETE")
|
|
985
|
+
finally:
|
|
986
|
+
target.close()
|
|
987
|
+
|
|
988
|
+
def open_data(self, path: Path) -> None:
|
|
989
|
+
"""Open a study's database, refused unless it is made as a new one is.
|
|
990
|
+
|
|
991
|
+
The file may come from anyone (e.g. a web upload): its schema is the file's own,
|
|
992
|
+
whose views and triggers would run in the app. Until it is checked, its views and
|
|
993
|
+
triggers may call no function with side effects (`trusted_schema`), and the whole
|
|
994
|
+
open is cut after `open_time_limit`.
|
|
995
|
+
"""
|
|
996
|
+
database = _Database(self.pragmas)
|
|
997
|
+
db = database.connection
|
|
998
|
+
try:
|
|
999
|
+
source = sqlite3.connect(str(path))
|
|
1000
|
+
try:
|
|
1001
|
+
source.backup(db)
|
|
1002
|
+
finally:
|
|
1003
|
+
source.close()
|
|
1004
|
+
database.start_journal() # after the backup: it keeps the file's page size
|
|
1005
|
+
db.execute("PRAGMA trusted_schema = OFF")
|
|
1006
|
+
deadline = time.monotonic() + self.open_time_limit
|
|
1007
|
+
# a true answer interrupts the statement running (`OperationalError`)
|
|
1008
|
+
db.set_progress_handler(lambda: time.monotonic() > deadline, 10_000)
|
|
1009
|
+
try:
|
|
1010
|
+
self._check(db)
|
|
1011
|
+
self._upgrade(db)
|
|
1012
|
+
self._check_schema(db)
|
|
1013
|
+
finally:
|
|
1014
|
+
db.set_progress_handler(None, 0)
|
|
1015
|
+
db.execute(
|
|
1016
|
+
_pragma_statement("trusted_schema", self.pragmas.get("trusted_schema", "ON"))
|
|
1017
|
+
)
|
|
1018
|
+
except sqlite3.DatabaseError as error:
|
|
1019
|
+
database.close()
|
|
1020
|
+
msg = "its data isn't a valid database"
|
|
1021
|
+
raise InvalidStateError(msg) from error
|
|
1022
|
+
except BaseException:
|
|
1023
|
+
database.close()
|
|
1024
|
+
raise
|
|
1025
|
+
self._replace_database(database)
|
|
1026
|
+
|
|
1027
|
+
def loaded(self) -> None:
|
|
1028
|
+
# every table may have changed; emitted only now, so the views refreshing read the
|
|
1029
|
+
# study's views (e.g. a filter), not the ones it replaces
|
|
1030
|
+
for table in self._tables:
|
|
1031
|
+
table.changed.emit()
|
|
1032
|
+
|
|
1033
|
+
def close(self) -> None:
|
|
1034
|
+
# not the write's turn: closing the app doesn't wait for a job's long write
|
|
1035
|
+
with self._readers_lock:
|
|
1036
|
+
self._closed = True
|
|
1037
|
+
if self._database.readers == 0: # else the last query `reading` it closes it
|
|
1038
|
+
self._database.close()
|
|
1039
|
+
|
|
1040
|
+
def _new_database(self) -> _Database:
|
|
1041
|
+
database = _Database(self.pragmas)
|
|
1042
|
+
database.start_journal()
|
|
1043
|
+
db = database.connection
|
|
1044
|
+
for table in self._tables:
|
|
1045
|
+
db.executescript(table.create)
|
|
1046
|
+
# an int, which a pragma can't take as a parameter
|
|
1047
|
+
db.execute(f"PRAGMA user_version = {int(self.schema_version)}")
|
|
1048
|
+
db.commit()
|
|
1049
|
+
return database
|
|
1050
|
+
|
|
1051
|
+
def _replace_database(self, database: _Database) -> None:
|
|
1052
|
+
with self._lock, self._readers_lock:
|
|
1053
|
+
previous, self._database = self._database, database
|
|
1054
|
+
if previous.readers > 0:
|
|
1055
|
+
return # closed once the queries `reading` it are over
|
|
1056
|
+
previous.close()
|
|
1057
|
+
|
|
1058
|
+
def _check(self, db: sqlite3.Connection) -> None:
|
|
1059
|
+
# not `quick_check`, which skips the indexes' content: a study's database is small
|
|
1060
|
+
(result,) = db.execute("PRAGMA integrity_check").fetchone()
|
|
1061
|
+
if result != "ok":
|
|
1062
|
+
msg = "its data is damaged"
|
|
1063
|
+
raise InvalidStateError(msg)
|
|
1064
|
+
|
|
1065
|
+
def _check_schema(self, db: sqlite3.Connection) -> None:
|
|
1066
|
+
"""Refuse a database (brought up to date) not made as a new one is."""
|
|
1067
|
+
new = self._new_database()
|
|
1068
|
+
try:
|
|
1069
|
+
expected = _schema(new.connection)
|
|
1070
|
+
finally:
|
|
1071
|
+
new.close()
|
|
1072
|
+
if _schema(db) != expected:
|
|
1073
|
+
msg = "its data doesn't match this application"
|
|
1074
|
+
raise InvalidStateError(msg)
|
|
1075
|
+
|
|
1076
|
+
def _upgrade(self, db: sqlite3.Connection) -> None:
|
|
1077
|
+
"""Bring a database opened from a study up to `schema_version`."""
|
|
1078
|
+
(version,) = db.execute("PRAGMA user_version").fetchone()
|
|
1079
|
+
if version > self.schema_version:
|
|
1080
|
+
msg = "it was saved by a newer version of the application"
|
|
1081
|
+
raise InvalidStateError(msg)
|
|
1082
|
+
if version < 1: # every study's database has a version: this one isn't a study's
|
|
1083
|
+
msg = "its data doesn't match this application"
|
|
1084
|
+
raise InvalidStateError(msg)
|
|
1085
|
+
for step in range(version, self.schema_version):
|
|
1086
|
+
migrate = self.migrations.get(step)
|
|
1087
|
+
if migrate is None:
|
|
1088
|
+
msg = f"its data (version {version}) can't be brought up to date"
|
|
1089
|
+
raise InvalidStateError(msg)
|
|
1090
|
+
migrate(db)
|
|
1091
|
+
db.execute(f"PRAGMA user_version = {int(self.schema_version)}")
|
|
1092
|
+
db.commit()
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
class _Database:
|
|
1096
|
+
"""A connection to a scratch database in a temporary file, deleted with it.
|
|
1097
|
+
|
|
1098
|
+
Its journal is SQLite's default until `start_journal` sets the one of `pragmas`: a
|
|
1099
|
+
backup into a WAL database from one of another page size fails.
|
|
1100
|
+
"""
|
|
1101
|
+
|
|
1102
|
+
def __init__(self, pragmas: Mapping[str, int | str]) -> None:
|
|
1103
|
+
statements = [
|
|
1104
|
+
_pragma_statement(name, value)
|
|
1105
|
+
for name, value in pragmas.items()
|
|
1106
|
+
if name != "journal_mode"
|
|
1107
|
+
]
|
|
1108
|
+
#: the model's `pragmas`, set on each connection: SQLite keeps them per connection
|
|
1109
|
+
self.pragma_statements = statements
|
|
1110
|
+
journal = pragmas.get("journal_mode")
|
|
1111
|
+
self._journal = None if journal is None else _pragma_statement("journal_mode", journal)
|
|
1112
|
+
fd, name = tempfile.mkstemp(prefix="composeui-", suffix=".sqlite")
|
|
1113
|
+
os.close(fd)
|
|
1114
|
+
self.path = Path(name)
|
|
1115
|
+
# threads take turns through the model's lock
|
|
1116
|
+
self.connection = sqlite3.connect(name, check_same_thread=False, timeout=WAIT_SECONDS)
|
|
1117
|
+
self.connection.row_factory = sqlite3.Row
|
|
1118
|
+
for statement in statements:
|
|
1119
|
+
self.connection.execute(statement)
|
|
1120
|
+
#: the read-only connections not in use, for the next queries `reading` it
|
|
1121
|
+
self.idle_readers: list[sqlite3.Connection] = []
|
|
1122
|
+
# also when the model is dropped unclosed (e.g. an expired web session's)
|
|
1123
|
+
self._finalizer = weakref.finalize(
|
|
1124
|
+
self, _discard, self.connection, self.path, self.idle_readers
|
|
1125
|
+
)
|
|
1126
|
+
#: the queries reading it on connections of their own (`SqliteModel.reading`)
|
|
1127
|
+
self.readers = 0
|
|
1128
|
+
|
|
1129
|
+
def open_reader(self) -> sqlite3.Connection:
|
|
1130
|
+
"""Open a read-only connection, with the model's `pragmas`: any thread uses it."""
|
|
1131
|
+
uri = f"{self.path.as_uri()}?mode=ro"
|
|
1132
|
+
reader = sqlite3.connect(uri, uri=True, timeout=WAIT_SECONDS, check_same_thread=False)
|
|
1133
|
+
for statement in self.pragma_statements:
|
|
1134
|
+
reader.execute(statement)
|
|
1135
|
+
return reader
|
|
1136
|
+
|
|
1137
|
+
def start_journal(self) -> None:
|
|
1138
|
+
"""Set the journal of the model's `pragmas`, which the file keeps."""
|
|
1139
|
+
if self._journal is not None:
|
|
1140
|
+
self.connection.execute(self._journal)
|
|
1141
|
+
|
|
1142
|
+
def close(self) -> None:
|
|
1143
|
+
self._finalizer()
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _schema(db: sqlite3.Connection) -> set[tuple[object, ...]]:
|
|
1147
|
+
"""Get what a database is made of: each table's columns, each other object's SQL.
|
|
1148
|
+
|
|
1149
|
+
A table's columns, not its SQL: a migration can only add a column (`ALTER TABLE ...
|
|
1150
|
+
ADD COLUMN`), without the `CHECK` or `REFERENCES` a new database's table may have.
|
|
1151
|
+
Neither can query the database, so the file's own are kept.
|
|
1152
|
+
"""
|
|
1153
|
+
schema: set[tuple[object, ...]] = set()
|
|
1154
|
+
for kind, name, table_name, sql in db.execute(
|
|
1155
|
+
"SELECT type, name, tbl_name, sql FROM sqlite_master"
|
|
1156
|
+
):
|
|
1157
|
+
if kind == "table":
|
|
1158
|
+
# by name, not position: a column added by a migration comes last
|
|
1159
|
+
rows = db.execute("SELECT * FROM pragma_table_xinfo(?)", (name,))
|
|
1160
|
+
schema.add((kind, name, frozenset(tuple(row)[1:] for row in rows)))
|
|
1161
|
+
else:
|
|
1162
|
+
schema.add((kind, name, table_name, " ".join((sql or "").split())))
|
|
1163
|
+
return schema
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _pragma_statement(name: str, value: int | str) -> str:
|
|
1167
|
+
"""Get the statement setting a pragma, which can't take its value as a parameter."""
|
|
1168
|
+
if re.fullmatch(r"[A-Za-z_]+", name) is None or (
|
|
1169
|
+
isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]+", value) is None
|
|
1170
|
+
):
|
|
1171
|
+
msg = f"Not a pragma setting: {name} = {value!r}"
|
|
1172
|
+
raise ValueError(msg)
|
|
1173
|
+
return f"PRAGMA {name} = {value}"
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _discard(connection: sqlite3.Connection, path: Path, readers: list[sqlite3.Connection]) -> None:
|
|
1177
|
+
for reader in readers: # first: the last connection closed deletes the WAL journal
|
|
1178
|
+
reader.close()
|
|
1179
|
+
readers.clear()
|
|
1180
|
+
connection.close()
|
|
1181
|
+
# the WAL journal's files too, which the last connection closed deletes, unless it crashed
|
|
1182
|
+
for name in (path.name, f"{path.name}-wal", f"{path.name}-shm"):
|
|
1183
|
+
path.with_name(name).unlink(missing_ok=True)
|