termcore 0.2.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.
- termcore/__init__.py +6 -0
- termcore/cli/__init__.py +3 -0
- termcore/cli/output.py +70 -0
- termcore/io/__init__.py +7 -0
- termcore/io/app_state_storage.py +185 -0
- termcore/io/database.py +655 -0
- termcore/io/errors.py +71 -0
- termcore/io/file.py +263 -0
- termcore/io/textfile.py +92 -0
- termcore/py.typed +0 -0
- termcore/tui/__init__.py +9 -0
- termcore/tui/custom_bindings.py +551 -0
- termcore/tui/custom_widgets/__init__.py +1 -0
- termcore/tui/custom_widgets/custom_checkbox.py +41 -0
- termcore/tui/custom_widgets/custom_data_table.py +247 -0
- termcore/tui/custom_widgets/custom_selection.py +80 -0
- termcore/tui/custom_widgets/multiline_footer.py +240 -0
- termcore/tui/question_screen.py +107 -0
- termcore/tui/theme_loader.py +495 -0
- termcore/tui/themes/__init__.py +2 -0
- termcore/tui/themes/classic-black-saturated/style.css +57 -0
- termcore/tui/themes/classic-black-saturated/theme.py +22 -0
- termcore/tui/themes/classic-black-v1/style.css +57 -0
- termcore/tui/themes/classic-black-v1/theme.py +22 -0
- termcore/tui/themes/classic-black-v2/style.css +57 -0
- termcore/tui/themes/classic-black-v2/theme.py +24 -0
- termcore/tui/themes/classic-blue/theme.py +21 -0
- termcore/tui/themes/compact-gray/style.tcss +175 -0
- termcore/tui/themes/compact-gray/theme.py +22 -0
- termcore/tui/themes/mnml-black/style.css +57 -0
- termcore/tui/themes/mnml-black/theme.py +21 -0
- termcore/tui/themes/mnml-deepblack/style.css +57 -0
- termcore/tui/themes/mnml-deepblack/theme.py +21 -0
- termcore/tui/themes/pure-amber/theme.py +18 -0
- termcore/tui/themes/pure-black/theme.py +18 -0
- termcore/tui/themes/pure-blue/theme.py +18 -0
- termcore/tui/themes/pure-green/theme.py +18 -0
- termcore/tui/themes/pure-sweet16/theme.py +20 -0
- termcore/tui/themes/xplore-black/theme.py +18 -0
- termcore/tui/themes/xplore-blue/theme.py +18 -0
- termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
- termcore/tui/themes/xplore-teal/theme.py +18 -0
- termcore/util/__init__.py +10 -0
- termcore/util/datetime.py +173 -0
- termcore/util/debug.py +196 -0
- termcore/util/index.py +112 -0
- termcore/util/logger.py +36 -0
- termcore/util/singleton.py +46 -0
- termcore/util/string.py +263 -0
- termcore/util/validation.py +31 -0
- termcore/util/version.py +56 -0
- termcore-0.2.0.dist-info/METADATA +504 -0
- termcore-0.2.0.dist-info/RECORD +55 -0
- termcore-0.2.0.dist-info/WHEEL +4 -0
- termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
termcore/io/database.py
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight database management module for handling SQLite databases with ease.
|
|
3
|
+
|
|
4
|
+
This module provides an abstraction layer for SQLite databases, simplifying
|
|
5
|
+
common database operations such as fetching, inserting, updating and deleting
|
|
6
|
+
records. It includes classes for defining query conditions, sorting orders
|
|
7
|
+
and combination operators to facilitate SQL query construction.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
|
|
11
|
+
- Establish and manage SQLite database connections
|
|
12
|
+
- Query execution with debugging support
|
|
13
|
+
- Fetching data with filtering, ordering, and pagination
|
|
14
|
+
- Insert, update, and delete operations with structured condition handling
|
|
15
|
+
- Automatic resource management using context (`with` statement)
|
|
16
|
+
- Ensures proper connection closure on object deletion
|
|
17
|
+
|
|
18
|
+
Ideal for applications requiring a simple, efficient database interaction layer.
|
|
19
|
+
|
|
20
|
+
How statements are built
|
|
21
|
+
------------------------
|
|
22
|
+
The `# noqa: S608` markers below say the same thing in machine terms: the
|
|
23
|
+
rule sees an f-string in a statement and cannot see that every value is a
|
|
24
|
+
parameter and every identifier came through `_quoted_table` or
|
|
25
|
+
`_quoted_column`, which reject anything the schema does not know.
|
|
26
|
+
|
|
27
|
+
Values never reach the statement text. They are bound as parameters, so no
|
|
28
|
+
value can be read as SQL, whatever it contains.
|
|
29
|
+
|
|
30
|
+
Identifiers cannot be parameters - SQL has no placeholder for a table or a
|
|
31
|
+
column - so they are checked against the schema of the open database before
|
|
32
|
+
they are used, and quoted afterwards. A name that is not a table, or not a
|
|
33
|
+
column of the table being addressed, raises `UnknownIdentifierError` instead
|
|
34
|
+
of becoming a fragment of the query.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
import sqlite3
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from typing import TYPE_CHECKING, cast
|
|
43
|
+
|
|
44
|
+
from termcore.io.errors import EmptyConditionsError, UnknownIdentifierError
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"VALUELESS_OPERATORS",
|
|
48
|
+
"ColumnOrder",
|
|
49
|
+
"Condition",
|
|
50
|
+
"Database",
|
|
51
|
+
"SQLCombinationOperator",
|
|
52
|
+
"SQLComparisonOperator",
|
|
53
|
+
"SQLOrderByDirection",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from collections.abc import Mapping, Sequence
|
|
58
|
+
from types import TracebackType
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SQLComparisonOperator(Enum):
|
|
64
|
+
"""
|
|
65
|
+
Enumeration defining comparison operators for SQL queries.
|
|
66
|
+
|
|
67
|
+
`IS_NULL` and `IS_NOT_NULL` are complete on their own; the `value` of a
|
|
68
|
+
`Condition` using them is ignored.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
LT = "<"
|
|
72
|
+
LE = "<="
|
|
73
|
+
EQ = "="
|
|
74
|
+
NE = "!="
|
|
75
|
+
GE = ">="
|
|
76
|
+
GT = ">"
|
|
77
|
+
LIKE = "LIKE"
|
|
78
|
+
IS_NULL = "IS NULL"
|
|
79
|
+
IS_NOT_NULL = "IS NOT NULL"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# The operators that carry no value and therefore bind no parameter
|
|
83
|
+
VALUELESS_OPERATORS = (
|
|
84
|
+
SQLComparisonOperator.IS_NULL,
|
|
85
|
+
SQLComparisonOperator.IS_NOT_NULL,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SQLCombinationOperator(Enum):
|
|
90
|
+
"""Combination operators (AND, OR) joining two SQL conditions."""
|
|
91
|
+
|
|
92
|
+
AND = "AND"
|
|
93
|
+
OR = "OR"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SQLOrderByDirection(Enum):
|
|
97
|
+
"""Enumeration defining sorting order (ASC or DESC) for SQL queries."""
|
|
98
|
+
|
|
99
|
+
ASC = "ASC"
|
|
100
|
+
DESC = "DESC"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True, frozen=True)
|
|
104
|
+
class ColumnOrder:
|
|
105
|
+
"""
|
|
106
|
+
Class representing column sorting order in SQL queries.
|
|
107
|
+
|
|
108
|
+
Attributes
|
|
109
|
+
----------
|
|
110
|
+
column_name : str
|
|
111
|
+
Name of the column to sort by.
|
|
112
|
+
direction : SQLOrderByDirection
|
|
113
|
+
Direction of sorting (ASC or DESC).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
column_name: str
|
|
117
|
+
direction: SQLOrderByDirection
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True, frozen=True) # Higher memory efficiency with slots=True
|
|
121
|
+
class Condition:
|
|
122
|
+
"""
|
|
123
|
+
Class representing a condition for the WHERE statement of an SQL query.
|
|
124
|
+
|
|
125
|
+
Attributes
|
|
126
|
+
----------
|
|
127
|
+
column_name : str
|
|
128
|
+
Name of the column for the condition.
|
|
129
|
+
operator : SQLComparisonOperator
|
|
130
|
+
Comparison operator to use.
|
|
131
|
+
value : str or int or float or bytes or None, optional
|
|
132
|
+
Value to compare against. Ignored by `IS_NULL` and `IS_NOT_NULL`.
|
|
133
|
+
combination : SQLCombinationOperator, optional
|
|
134
|
+
Logical operator joining this condition to the previous one (default
|
|
135
|
+
is AND). The first condition of a list has nothing to join to, so its
|
|
136
|
+
combination is ignored.
|
|
137
|
+
|
|
138
|
+
Examples
|
|
139
|
+
--------
|
|
140
|
+
>>> model = Condition("model", SQLComparisonOperator.EQ, "standard")
|
|
141
|
+
>>> size = Condition("size", SQLComparisonOperator.GE, 4)
|
|
142
|
+
|
|
143
|
+
These conditions are translated by the Database class into this WHERE
|
|
144
|
+
statement, with both values bound as parameters::
|
|
145
|
+
|
|
146
|
+
WHERE "model" = ? AND "size" >= ?
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
column_name: str
|
|
150
|
+
operator: SQLComparisonOperator
|
|
151
|
+
value: str | int | float | bytes | None = None
|
|
152
|
+
combination: SQLCombinationOperator = SQLCombinationOperator.AND
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Database:
|
|
156
|
+
"""
|
|
157
|
+
Class for managing an SQLite database.
|
|
158
|
+
|
|
159
|
+
It provides a structured API for retrieving, inserting, updating and
|
|
160
|
+
deleting data.
|
|
161
|
+
|
|
162
|
+
Attributes
|
|
163
|
+
----------
|
|
164
|
+
debug_mode : bool
|
|
165
|
+
If this is True, every statement is written to the module logger at
|
|
166
|
+
debug level before it runs.
|
|
167
|
+
connection : sqlite3.Connection
|
|
168
|
+
Instance of the database connection.
|
|
169
|
+
cursor : sqlite3.Cursor
|
|
170
|
+
Instance of the database cursor.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
connection: sqlite3.Connection
|
|
174
|
+
cursor: sqlite3.Cursor
|
|
175
|
+
debug_mode: bool
|
|
176
|
+
_table_cache: frozenset[str] | None
|
|
177
|
+
_column_cache: dict[str, frozenset[str]]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def __init__(self, database: str) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Opens the connection to the database.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
database : str
|
|
187
|
+
Path of the SQLite database file.
|
|
188
|
+
"""
|
|
189
|
+
self.debug_mode = False
|
|
190
|
+
self._table_cache = None
|
|
191
|
+
self._column_cache = {}
|
|
192
|
+
|
|
193
|
+
# Establish database connection
|
|
194
|
+
self.connection = sqlite3.connect(database=database)
|
|
195
|
+
|
|
196
|
+
# Make sure that a dictionary in the form {column_name: value} is
|
|
197
|
+
# returned on queries (instead of a list with just the values)
|
|
198
|
+
self.connection.row_factory = sqlite3.Row
|
|
199
|
+
|
|
200
|
+
# Get cursor
|
|
201
|
+
self.cursor = self.connection.cursor()
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> Database:
|
|
204
|
+
"""
|
|
205
|
+
Enables the use of the `with` statement for the database connection.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
Database
|
|
210
|
+
The Database instance.
|
|
211
|
+
"""
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
def __exit__(
|
|
215
|
+
self,
|
|
216
|
+
exc_type: type[BaseException] | None,
|
|
217
|
+
exc_value: BaseException | None,
|
|
218
|
+
traceback: TracebackType | None
|
|
219
|
+
) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Commits and closes the connection when leaving a `with` block.
|
|
222
|
+
|
|
223
|
+
On the way out of an exception the transaction is rolled back
|
|
224
|
+
instead, so a block that failed half way through leaves nothing
|
|
225
|
+
behind.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
exc_type : type or None
|
|
230
|
+
Exception type if an exception was raised.
|
|
231
|
+
exc_value : Exception or None
|
|
232
|
+
Exception instance if an exception was raised.
|
|
233
|
+
traceback : traceback or None
|
|
234
|
+
Traceback object if an exception was raised.
|
|
235
|
+
"""
|
|
236
|
+
if exc_type is None:
|
|
237
|
+
self.connection.commit()
|
|
238
|
+
else:
|
|
239
|
+
self.connection.rollback()
|
|
240
|
+
|
|
241
|
+
self.close()
|
|
242
|
+
|
|
243
|
+
def __del__(self) -> None:
|
|
244
|
+
"""Closes the connection when the object is garbage collected."""
|
|
245
|
+
# __init__ may have failed before the connection was assigned, and an
|
|
246
|
+
# AttributeError raised in here would surface as an unraisable
|
|
247
|
+
if hasattr(self, "connection"):
|
|
248
|
+
self.close()
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
"""Closes the database connection."""
|
|
252
|
+
if self.connection:
|
|
253
|
+
self.connection.close()
|
|
254
|
+
|
|
255
|
+
def query(
|
|
256
|
+
self, sql: str, params: Sequence[object] = ()
|
|
257
|
+
) -> sqlite3.Cursor:
|
|
258
|
+
"""
|
|
259
|
+
Runs an SQL command.
|
|
260
|
+
|
|
261
|
+
Use this method for commands that are not wrapped by this class. It
|
|
262
|
+
is the one place where the statement text comes from the caller, so
|
|
263
|
+
pass values through `params` rather than formatting them into `sql`.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
sql : str
|
|
268
|
+
The SQL command, with a `?` for every value.
|
|
269
|
+
params : Sequence[object], optional
|
|
270
|
+
Values bound to the placeholders in `sql`.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
sqlite3.Cursor
|
|
275
|
+
The cursor.
|
|
276
|
+
"""
|
|
277
|
+
# The statement may have altered the schema, and the schema is what
|
|
278
|
+
# the identifier check is validated against
|
|
279
|
+
self._invalidate_schema_cache()
|
|
280
|
+
|
|
281
|
+
return self._execute(sql, params)
|
|
282
|
+
|
|
283
|
+
def fetch( # noqa: PLR0913 - the clauses of a SELECT are the point
|
|
284
|
+
self,
|
|
285
|
+
table: str,
|
|
286
|
+
columns: list[str] | None = None,
|
|
287
|
+
conditions: list[Condition] | None = None,
|
|
288
|
+
orderby: list[ColumnOrder] | None = None,
|
|
289
|
+
limit: int | None = None,
|
|
290
|
+
offset: int | None = None
|
|
291
|
+
) -> list[dict[str, str | int | float | bytes | None]]:
|
|
292
|
+
"""
|
|
293
|
+
Fetches entries from a table.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
table : str
|
|
298
|
+
Name of the table.
|
|
299
|
+
columns : list[str] or None, optional
|
|
300
|
+
Optional list of columns to be fetched; if None is given
|
|
301
|
+
all columns will be fetched.
|
|
302
|
+
conditions : list[Condition] or None, optional
|
|
303
|
+
Optional list of conditions.
|
|
304
|
+
orderby : list[ColumnOrder] or None, optional
|
|
305
|
+
Optional list defining column order.
|
|
306
|
+
limit : int or None, optional
|
|
307
|
+
Optional limit for the query.
|
|
308
|
+
offset : int or None, optional
|
|
309
|
+
Optional offset for the query.
|
|
310
|
+
|
|
311
|
+
Returns
|
|
312
|
+
-------
|
|
313
|
+
list[dict[str, Any]]
|
|
314
|
+
List of dictionaries in the form [{column_name: value}]
|
|
315
|
+
|
|
316
|
+
Raises
|
|
317
|
+
------
|
|
318
|
+
UnknownIdentifierError
|
|
319
|
+
If the table or one of the columns is not part of the schema.
|
|
320
|
+
ValueError
|
|
321
|
+
If `columns` is an empty list, or a limit or offset is negative.
|
|
322
|
+
"""
|
|
323
|
+
quoted_table = self._quoted_table(table)
|
|
324
|
+
sql = f"SELECT {self._select_list(table, columns)} FROM {quoted_table}" # noqa: S608
|
|
325
|
+
params: list[object] = []
|
|
326
|
+
|
|
327
|
+
# WHERE
|
|
328
|
+
if conditions:
|
|
329
|
+
where_sql, where_params = self._build_where(table, conditions)
|
|
330
|
+
sql += f" WHERE {where_sql}"
|
|
331
|
+
params.extend(where_params)
|
|
332
|
+
|
|
333
|
+
# ORDER BY
|
|
334
|
+
if orderby:
|
|
335
|
+
sql += f" ORDER BY {self._order_list(table, orderby)}"
|
|
336
|
+
|
|
337
|
+
# LIMIT / OFFSET
|
|
338
|
+
limit_sql, limit_params = self._build_limit(limit, offset)
|
|
339
|
+
sql += limit_sql
|
|
340
|
+
params.extend(limit_params)
|
|
341
|
+
|
|
342
|
+
rows: list[sqlite3.Row] = self._execute(sql, params).fetchall()
|
|
343
|
+
|
|
344
|
+
return [dict(row) for row in rows]
|
|
345
|
+
|
|
346
|
+
def insert(self, table: str, data: Sequence[Mapping[str, object]]) \
|
|
347
|
+
-> list[dict[str, str | int | float | bytes | None]]:
|
|
348
|
+
"""
|
|
349
|
+
Inserts rows into the table.
|
|
350
|
+
|
|
351
|
+
All rows are written in one transaction: if any of them fails, none
|
|
352
|
+
of them is kept.
|
|
353
|
+
|
|
354
|
+
Parameters
|
|
355
|
+
----------
|
|
356
|
+
table : str
|
|
357
|
+
Name of the table.
|
|
358
|
+
data : Sequence[Mapping[str, object]]
|
|
359
|
+
List of dictionaries, e.g.:
|
|
360
|
+
data = [{'col1': 'val1', 'col2': 'val2'}, ...]
|
|
361
|
+
|
|
362
|
+
Returns
|
|
363
|
+
-------
|
|
364
|
+
list[dict[str, str | int | float | bytes | None]]
|
|
365
|
+
List of dictionaries containing the inserted rows, e.g.:
|
|
366
|
+
[{'id': 1, 'col1': 'val1', 'col2': 'val2'}, ...]
|
|
367
|
+
|
|
368
|
+
Raises
|
|
369
|
+
------
|
|
370
|
+
UnknownIdentifierError
|
|
371
|
+
If the table or one of the columns is not part of the schema.
|
|
372
|
+
ValueError
|
|
373
|
+
If one of the rows has no columns.
|
|
374
|
+
"""
|
|
375
|
+
quoted_table = self._quoted_table(table)
|
|
376
|
+
inserted: list[dict[str, str | int | float | bytes | None]] = []
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
for row in data:
|
|
380
|
+
inserted.append(self._insert_row(table, quoted_table, row))
|
|
381
|
+
except Exception:
|
|
382
|
+
self.connection.rollback()
|
|
383
|
+
raise
|
|
384
|
+
|
|
385
|
+
self.connection.commit()
|
|
386
|
+
|
|
387
|
+
return inserted
|
|
388
|
+
|
|
389
|
+
def update(
|
|
390
|
+
self, table: str,
|
|
391
|
+
values: Mapping[str, object],
|
|
392
|
+
conditions: list[Condition]
|
|
393
|
+
) -> int:
|
|
394
|
+
"""
|
|
395
|
+
Updates every row matching the given conditions.
|
|
396
|
+
|
|
397
|
+
Parameters
|
|
398
|
+
----------
|
|
399
|
+
table : str
|
|
400
|
+
Name of the table.
|
|
401
|
+
values : Mapping[str, object]
|
|
402
|
+
New values, in the form {column_name: value}.
|
|
403
|
+
conditions : list[Condition]
|
|
404
|
+
Conditions selecting the rows to update.
|
|
405
|
+
|
|
406
|
+
Returns
|
|
407
|
+
-------
|
|
408
|
+
int
|
|
409
|
+
Number of rows that were changed.
|
|
410
|
+
|
|
411
|
+
Raises
|
|
412
|
+
------
|
|
413
|
+
EmptyConditionsError
|
|
414
|
+
If `conditions` is empty. Rewriting every row is not something
|
|
415
|
+
this method does by accident.
|
|
416
|
+
UnknownIdentifierError
|
|
417
|
+
If the table or one of the columns is not part of the schema.
|
|
418
|
+
ValueError
|
|
419
|
+
If `values` is empty.
|
|
420
|
+
"""
|
|
421
|
+
if not conditions:
|
|
422
|
+
raise EmptyConditionsError("update")
|
|
423
|
+
if not values:
|
|
424
|
+
raise ValueError("values must not be empty")
|
|
425
|
+
|
|
426
|
+
quoted_table = self._quoted_table(table)
|
|
427
|
+
columns = list(values.keys())
|
|
428
|
+
assignments = ", ".join(
|
|
429
|
+
f"{self._quoted_column(table, column)} = ?" for column in columns
|
|
430
|
+
)
|
|
431
|
+
where_sql, where_params = self._build_where(table, conditions)
|
|
432
|
+
|
|
433
|
+
sql = f"UPDATE {quoted_table} SET {assignments} WHERE {where_sql}" # noqa: S608
|
|
434
|
+
params = [values[column] for column in columns] + where_params
|
|
435
|
+
|
|
436
|
+
cursor = self._execute(sql, params)
|
|
437
|
+
self.connection.commit()
|
|
438
|
+
|
|
439
|
+
return cursor.rowcount
|
|
440
|
+
|
|
441
|
+
def remove(self, table: str, conditions: list[Condition]) -> int:
|
|
442
|
+
"""
|
|
443
|
+
Removes the rows from the table which match the list of conditions.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
table : str
|
|
448
|
+
Name of the table.
|
|
449
|
+
conditions : list[Condition]
|
|
450
|
+
List of conditions.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
int
|
|
455
|
+
Number of rows that were deleted.
|
|
456
|
+
|
|
457
|
+
Raises
|
|
458
|
+
------
|
|
459
|
+
EmptyConditionsError
|
|
460
|
+
If `conditions` is empty. Emptying the table is not something
|
|
461
|
+
this method does by accident.
|
|
462
|
+
UnknownIdentifierError
|
|
463
|
+
If the table or one of the columns is not part of the schema.
|
|
464
|
+
"""
|
|
465
|
+
if not conditions:
|
|
466
|
+
raise EmptyConditionsError("delete")
|
|
467
|
+
|
|
468
|
+
quoted_table = self._quoted_table(table)
|
|
469
|
+
where_sql, where_params = self._build_where(table, conditions)
|
|
470
|
+
|
|
471
|
+
sql = f"DELETE FROM {quoted_table} WHERE {where_sql}" # noqa: S608
|
|
472
|
+
|
|
473
|
+
cursor = self._execute(sql, where_params)
|
|
474
|
+
self.connection.commit()
|
|
475
|
+
|
|
476
|
+
return cursor.rowcount
|
|
477
|
+
|
|
478
|
+
def save(self) -> None:
|
|
479
|
+
"""Commits the current transaction."""
|
|
480
|
+
self.connection.commit()
|
|
481
|
+
|
|
482
|
+
def _insert_row(
|
|
483
|
+
self, table: str, quoted_table: str, row: Mapping[str, object]
|
|
484
|
+
) -> dict[str, str | int | float | bytes | None]:
|
|
485
|
+
"""Inserts one row and returns it as it was stored."""
|
|
486
|
+
if not row:
|
|
487
|
+
raise ValueError("cannot insert a row without any columns")
|
|
488
|
+
|
|
489
|
+
columns = list(row.keys())
|
|
490
|
+
column_list = ", ".join(
|
|
491
|
+
self._quoted_column(table, column) for column in columns
|
|
492
|
+
)
|
|
493
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
494
|
+
|
|
495
|
+
sql = (f"INSERT INTO {quoted_table} ({column_list}) " # noqa: S608
|
|
496
|
+
f"VALUES ({placeholders})")
|
|
497
|
+
cursor = self._execute(sql, [row[column] for column in columns])
|
|
498
|
+
|
|
499
|
+
# Read the row back by rowid: every ordinary table has one, whatever
|
|
500
|
+
# its own columns happen to be called
|
|
501
|
+
stored: sqlite3.Row | None = self._execute(
|
|
502
|
+
f"SELECT * FROM {quoted_table} WHERE rowid = ?", # noqa: S608
|
|
503
|
+
[cursor.lastrowid]
|
|
504
|
+
).fetchone()
|
|
505
|
+
|
|
506
|
+
# A table without a rowid cannot be read back this way; handing
|
|
507
|
+
# the caller what was written beats handing them nothing
|
|
508
|
+
if stored is None:
|
|
509
|
+
return cast(
|
|
510
|
+
"dict[str, str | int | float | bytes | None]", dict(row)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return dict(stored)
|
|
514
|
+
|
|
515
|
+
def _select_list(self, table: str, columns: list[str] | None) -> str:
|
|
516
|
+
"""Builds the column list of a SELECT, or `*` for all columns."""
|
|
517
|
+
if columns is None:
|
|
518
|
+
return "*"
|
|
519
|
+
if not columns:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
"columns must not be empty; pass None to select all columns"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return ", ".join(
|
|
525
|
+
self._quoted_column(table, column) for column in columns
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def _order_list(self, table: str, orderby: list[ColumnOrder]) -> str:
|
|
529
|
+
"""Builds the column list of an ORDER BY clause."""
|
|
530
|
+
return ", ".join(
|
|
531
|
+
f"{self._quoted_column(table, column.column_name)} "
|
|
532
|
+
f"{column.direction.value}"
|
|
533
|
+
for column in orderby
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _build_where(
|
|
537
|
+
self, table: str, conditions: list[Condition]
|
|
538
|
+
) -> tuple[str, list[object]]:
|
|
539
|
+
"""
|
|
540
|
+
Builds a WHERE clause plus the values bound to its placeholders.
|
|
541
|
+
|
|
542
|
+
The first condition has nothing to join to, so its `combination` is
|
|
543
|
+
ignored.
|
|
544
|
+
"""
|
|
545
|
+
fragments: list[str] = []
|
|
546
|
+
params: list[object] = []
|
|
547
|
+
|
|
548
|
+
for position, condition in enumerate(conditions):
|
|
549
|
+
# Add "AND"/"OR" if it's not the first condition
|
|
550
|
+
if position > 0:
|
|
551
|
+
fragments.append(condition.combination.value)
|
|
552
|
+
|
|
553
|
+
column = self._quoted_column(table, condition.column_name)
|
|
554
|
+
if condition.operator in VALUELESS_OPERATORS:
|
|
555
|
+
fragments.append(f"{column} {condition.operator.value}")
|
|
556
|
+
else:
|
|
557
|
+
fragments.append(f"{column} {condition.operator.value} ?")
|
|
558
|
+
params.append(condition.value)
|
|
559
|
+
|
|
560
|
+
return " ".join(fragments), params
|
|
561
|
+
|
|
562
|
+
def _build_limit(
|
|
563
|
+
self, limit: int | None, offset: int | None
|
|
564
|
+
) -> tuple[str, list[object]]:
|
|
565
|
+
"""
|
|
566
|
+
Builds the LIMIT and OFFSET clause.
|
|
567
|
+
|
|
568
|
+
SQLite accepts OFFSET only together with LIMIT, so an offset given on
|
|
569
|
+
its own is paired with -1, which is SQLite's "no limit".
|
|
570
|
+
"""
|
|
571
|
+
if limit is not None and limit < 0:
|
|
572
|
+
raise ValueError(f"limit must not be negative, got {limit}")
|
|
573
|
+
if offset is not None and offset < 0:
|
|
574
|
+
raise ValueError(f"offset must not be negative, got {offset}")
|
|
575
|
+
|
|
576
|
+
if limit is None and offset is None:
|
|
577
|
+
return "", []
|
|
578
|
+
|
|
579
|
+
clause = " LIMIT ?"
|
|
580
|
+
params: list[object] = [-1 if limit is None else limit]
|
|
581
|
+
|
|
582
|
+
if offset is not None:
|
|
583
|
+
clause += " OFFSET ?"
|
|
584
|
+
params.append(offset)
|
|
585
|
+
|
|
586
|
+
return clause, params
|
|
587
|
+
|
|
588
|
+
def _quoted_table(self, table: str) -> str:
|
|
589
|
+
"""Checks a table name against the schema and quotes it."""
|
|
590
|
+
known = self._table_names()
|
|
591
|
+
if table not in known:
|
|
592
|
+
raise UnknownIdentifierError(table, tuple(sorted(known)))
|
|
593
|
+
|
|
594
|
+
return _quote_identifier(table)
|
|
595
|
+
|
|
596
|
+
def _quoted_column(self, table: str, column: str) -> str:
|
|
597
|
+
"""Checks a column name against the table's schema and quotes it."""
|
|
598
|
+
known = self._column_names(table)
|
|
599
|
+
if column not in known:
|
|
600
|
+
raise UnknownIdentifierError(column, tuple(sorted(known)))
|
|
601
|
+
|
|
602
|
+
return _quote_identifier(column)
|
|
603
|
+
|
|
604
|
+
def _table_names(self) -> frozenset[str]:
|
|
605
|
+
"""Returns the names of every table and view in the database."""
|
|
606
|
+
if self._table_cache is None:
|
|
607
|
+
rows: list[sqlite3.Row] = self._execute(
|
|
608
|
+
"SELECT name FROM sqlite_master "
|
|
609
|
+
"WHERE type IN ('table', 'view')"
|
|
610
|
+
).fetchall()
|
|
611
|
+
self._table_cache = frozenset(
|
|
612
|
+
cast("str", row["name"]) for row in rows
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
return self._table_cache
|
|
616
|
+
|
|
617
|
+
def _column_names(self, table: str) -> frozenset[str]:
|
|
618
|
+
"""Returns the column names of the given table."""
|
|
619
|
+
if table not in self._column_cache:
|
|
620
|
+
quoted_table = self._quoted_table(table)
|
|
621
|
+
rows: list[sqlite3.Row] = self._execute(
|
|
622
|
+
f"PRAGMA table_info({quoted_table})"
|
|
623
|
+
).fetchall()
|
|
624
|
+
self._column_cache[table] = frozenset(
|
|
625
|
+
cast("str", row["name"]) for row in rows
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
return self._column_cache[table]
|
|
629
|
+
|
|
630
|
+
def _invalidate_schema_cache(self) -> None:
|
|
631
|
+
"""Forgets the cached schema, e.g. after a statement that altered it."""
|
|
632
|
+
self._table_cache = None
|
|
633
|
+
self._column_cache.clear()
|
|
634
|
+
|
|
635
|
+
def _execute(
|
|
636
|
+
self, sql: str, params: Sequence[object] = ()
|
|
637
|
+
) -> sqlite3.Cursor:
|
|
638
|
+
"""Runs one statement with its values bound as parameters."""
|
|
639
|
+
if self.debug_mode:
|
|
640
|
+
_logger.debug("SQL: %s -- params: %r", sql, list(params))
|
|
641
|
+
|
|
642
|
+
return self.cursor.execute(sql, params)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _quote_identifier(name: str) -> str:
|
|
646
|
+
"""
|
|
647
|
+
Quotes an identifier so that it can be used in a statement.
|
|
648
|
+
|
|
649
|
+
Quoting is what makes a reserved word or a name containing a space usable
|
|
650
|
+
as a column. It is not the safety mechanism on its own - the callers check
|
|
651
|
+
the name against the schema first.
|
|
652
|
+
"""
|
|
653
|
+
escaped = name.replace("\"", "\"\"")
|
|
654
|
+
|
|
655
|
+
return "\"" + escaped + "\""
|