python-corekit 0.1.1__py3-none-any.whl → 0.3.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.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Migration operations.
|
|
3
3
|
|
|
4
|
-
Each
|
|
4
|
+
Each MigrationOperation knows how to:
|
|
5
5
|
- render itself to SQL (for execution) OR execute Python logic
|
|
6
6
|
- render a canonical string (for checksumming)
|
|
7
7
|
|
|
@@ -22,9 +22,16 @@ from abc import ABC, abstractmethod
|
|
|
22
22
|
from dataclasses import dataclass
|
|
23
23
|
from typing import TYPE_CHECKING, Any, Callable
|
|
24
24
|
|
|
25
|
+
from sqlalchemy.dialects import postgresql
|
|
26
|
+
|
|
27
|
+
from corekit.connections.sql.table import NamedTable
|
|
28
|
+
|
|
25
29
|
if TYPE_CHECKING:
|
|
26
30
|
from corekit.connections.sql.connection import SQLConnection
|
|
27
31
|
|
|
32
|
+
#: Renders a table's name the way the database expects to read it.
|
|
33
|
+
_PREPARER = postgresql.dialect().identifier_preparer
|
|
34
|
+
|
|
28
35
|
|
|
29
36
|
# ---------------------------------------------------------------------------
|
|
30
37
|
# Constants
|
|
@@ -151,7 +158,7 @@ class QueryBuilder:
|
|
|
151
158
|
return self._query.strip()
|
|
152
159
|
|
|
153
160
|
|
|
154
|
-
class
|
|
161
|
+
class MigrationOperation(ABC):
|
|
155
162
|
"""
|
|
156
163
|
Base class for all operations.
|
|
157
164
|
"""
|
|
@@ -159,6 +166,21 @@ class Operation(ABC):
|
|
|
159
166
|
def _builder(self) -> QueryBuilder:
|
|
160
167
|
return QueryBuilder()
|
|
161
168
|
|
|
169
|
+
@property
|
|
170
|
+
def table_name(self) -> str:
|
|
171
|
+
"""
|
|
172
|
+
The table this operation targets, spelled for SQL.
|
|
173
|
+
|
|
174
|
+
A model class is rendered by the dialect, which quotes a reserved word
|
|
175
|
+
such as ``user`` and leaves anything else bare. A plain string is used
|
|
176
|
+
as given, so a migration can name a table that has no model class --
|
|
177
|
+
one dropped or renamed since, or not yet defined.
|
|
178
|
+
"""
|
|
179
|
+
table = getattr(self, "table", None)
|
|
180
|
+
if isinstance(table, str) or table is None:
|
|
181
|
+
return table
|
|
182
|
+
return _PREPARER.format_table(table.__table__)
|
|
183
|
+
|
|
162
184
|
@abstractmethod
|
|
163
185
|
def to_sql(self) -> str:
|
|
164
186
|
"""
|
|
@@ -193,7 +215,7 @@ class Operation(ABC):
|
|
|
193
215
|
|
|
194
216
|
|
|
195
217
|
@dataclass
|
|
196
|
-
class AddColumn(
|
|
218
|
+
class AddColumn(MigrationOperation):
|
|
197
219
|
"""
|
|
198
220
|
Add a column to an existing table.
|
|
199
221
|
|
|
@@ -201,7 +223,7 @@ class AddColumn(Operation):
|
|
|
201
223
|
not. Set it to False when targeting SQLite.
|
|
202
224
|
"""
|
|
203
225
|
|
|
204
|
-
table: str
|
|
226
|
+
table: type[NamedTable] | str
|
|
205
227
|
column: str
|
|
206
228
|
dtype: str
|
|
207
229
|
safe: bool = True
|
|
@@ -212,7 +234,7 @@ class AddColumn(Operation):
|
|
|
212
234
|
"""
|
|
213
235
|
Example: ALTER TABLE <table_name> ADD COLUMN [IF NOT EXISTS] <column_name> <dtype>
|
|
214
236
|
"""
|
|
215
|
-
builder = self._builder().alter(TABLE, self.
|
|
237
|
+
builder = self._builder().alter(TABLE, self.table_name).add(COLUMN)
|
|
216
238
|
if self.safe:
|
|
217
239
|
builder.if_not_exists()
|
|
218
240
|
|
|
@@ -227,29 +249,29 @@ class AddColumn(Operation):
|
|
|
227
249
|
|
|
228
250
|
def canonical(self) -> str:
|
|
229
251
|
return (
|
|
230
|
-
f"AddColumn(table={self.
|
|
252
|
+
f"AddColumn(table={self.table_name}, column={self.column}, dtype={self.dtype}, "
|
|
231
253
|
f"nullable={self.nullable}, default={self.default})"
|
|
232
254
|
)
|
|
233
255
|
|
|
234
256
|
|
|
235
257
|
@dataclass
|
|
236
|
-
class DropColumn(
|
|
237
|
-
table: str
|
|
258
|
+
class DropColumn(MigrationOperation):
|
|
259
|
+
table: type[NamedTable] | str
|
|
238
260
|
column: str
|
|
239
261
|
|
|
240
262
|
def to_sql(self) -> str:
|
|
241
263
|
"""
|
|
242
264
|
Example: ALTER TABLE <table_name> DROP COLUMN [IF EXISTS] <column_name>
|
|
243
265
|
"""
|
|
244
|
-
return self._builder().alter(TABLE, self.
|
|
266
|
+
return self._builder().alter(TABLE, self.table_name).drop(COLUMN).if_exists().append(self.column).build()
|
|
245
267
|
|
|
246
268
|
def canonical(self) -> str:
|
|
247
|
-
return f"DropColumn(table={self.
|
|
269
|
+
return f"DropColumn(table={self.table_name}, column={self.column})"
|
|
248
270
|
|
|
249
271
|
|
|
250
272
|
@dataclass
|
|
251
|
-
class AlterColumnType(
|
|
252
|
-
table: str
|
|
273
|
+
class AlterColumnType(MigrationOperation):
|
|
274
|
+
table: type[NamedTable] | str
|
|
253
275
|
column: str
|
|
254
276
|
new_dtype: str
|
|
255
277
|
using: str | None = None
|
|
@@ -258,21 +280,19 @@ class AlterColumnType(Operation):
|
|
|
258
280
|
"""
|
|
259
281
|
Example: ALTER TABLE <table_name> ALTER COLUMN <column_name> TYPE <new_dtype> [USING <using>]
|
|
260
282
|
"""
|
|
261
|
-
builder = self._builder().alter(TABLE, self.
|
|
283
|
+
builder = self._builder().alter(TABLE, self.table_name).alter(COLUMN, self.column).type(self.new_dtype)
|
|
262
284
|
if self.using:
|
|
263
285
|
builder.using(self.using)
|
|
264
286
|
|
|
265
287
|
return builder.build()
|
|
266
288
|
|
|
267
289
|
def canonical(self) -> str:
|
|
268
|
-
return (
|
|
269
|
-
f"AlterColumnType(table={self.table}, column={self.column}, new_dtype={self.new_dtype}, using={self.using})"
|
|
270
|
-
)
|
|
290
|
+
return f"AlterColumnType(table={self.table_name}, column={self.column}, new_dtype={self.new_dtype}, using={self.using})"
|
|
271
291
|
|
|
272
292
|
|
|
273
293
|
@dataclass
|
|
274
|
-
class SetColumnDefault(
|
|
275
|
-
table: str
|
|
294
|
+
class SetColumnDefault(MigrationOperation):
|
|
295
|
+
table: type[NamedTable] | str
|
|
276
296
|
column: str
|
|
277
297
|
default: str
|
|
278
298
|
|
|
@@ -280,25 +300,27 @@ class SetColumnDefault(Operation):
|
|
|
280
300
|
"""
|
|
281
301
|
Example: ALTER TABLE <table_name> ALTER COLUMN <column_name> SET DEFAULT <default>
|
|
282
302
|
"""
|
|
283
|
-
return
|
|
303
|
+
return (
|
|
304
|
+
self._builder().alter(TABLE, self.table_name).alter(COLUMN, self.column).set_default(self.default).build()
|
|
305
|
+
)
|
|
284
306
|
|
|
285
307
|
def canonical(self) -> str:
|
|
286
|
-
return f"SetColumnDefault(table={self.
|
|
308
|
+
return f"SetColumnDefault(table={self.table_name}, column={self.column}, default={self.default})"
|
|
287
309
|
|
|
288
310
|
|
|
289
311
|
@dataclass
|
|
290
|
-
class DropColumnDefault(
|
|
291
|
-
table: str
|
|
312
|
+
class DropColumnDefault(MigrationOperation):
|
|
313
|
+
table: type[NamedTable] | str
|
|
292
314
|
column: str
|
|
293
315
|
|
|
294
316
|
def to_sql(self) -> str:
|
|
295
317
|
"""
|
|
296
318
|
Example: ALTER TABLE <table_name> ALTER COLUMN <column_name> DROP DEFAULT
|
|
297
319
|
"""
|
|
298
|
-
return self._builder().alter(TABLE, self.
|
|
320
|
+
return self._builder().alter(TABLE, self.table_name).alter(COLUMN, self.column).drop_default().build()
|
|
299
321
|
|
|
300
322
|
def canonical(self) -> str:
|
|
301
|
-
return f"DropColumnDefault(table={self.
|
|
323
|
+
return f"DropColumnDefault(table={self.table_name}, column={self.column})"
|
|
302
324
|
|
|
303
325
|
|
|
304
326
|
# ---------------------------------------------------------------------------
|
|
@@ -307,23 +329,23 @@ class DropColumnDefault(Operation):
|
|
|
307
329
|
|
|
308
330
|
|
|
309
331
|
@dataclass
|
|
310
|
-
class CreateIndex(
|
|
332
|
+
class CreateIndex(MigrationOperation):
|
|
311
333
|
index_name: str
|
|
312
|
-
table: str
|
|
334
|
+
table: type[NamedTable] | str
|
|
313
335
|
columns: list[str]
|
|
314
336
|
unique: bool = False
|
|
315
337
|
|
|
316
338
|
def to_sql(self) -> str:
|
|
317
339
|
unique_clause = "UNIQUE " if self.unique else ""
|
|
318
340
|
cols = ", ".join(self.columns)
|
|
319
|
-
return f"CREATE {unique_clause}INDEX IF NOT EXISTS {self.index_name} ON {self.
|
|
341
|
+
return f"CREATE {unique_clause}INDEX IF NOT EXISTS {self.index_name} ON {self.table_name} ({cols})"
|
|
320
342
|
|
|
321
343
|
def canonical(self) -> str:
|
|
322
|
-
return f"CreateIndex(name={self.index_name}, table={self.
|
|
344
|
+
return f"CreateIndex(name={self.index_name}, table={self.table_name}, columns={self.columns}, unique={self.unique})"
|
|
323
345
|
|
|
324
346
|
|
|
325
347
|
@dataclass
|
|
326
|
-
class DropIndex(
|
|
348
|
+
class DropIndex(MigrationOperation):
|
|
327
349
|
index_name: str
|
|
328
350
|
|
|
329
351
|
def to_sql(self) -> str:
|
|
@@ -338,21 +360,87 @@ class DropIndex(Operation):
|
|
|
338
360
|
# ---------------------------------------------------------------------------
|
|
339
361
|
|
|
340
362
|
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class ColumnSpec:
|
|
365
|
+
"""
|
|
366
|
+
One column in a ``CreateTable`` statement.
|
|
367
|
+
|
|
368
|
+
Types and defaults are SQL text, as on ``AddColumn``, not Python values.
|
|
369
|
+
Write ``default="FALSE"`` or ``default="'red'"``, not ``False`` or
|
|
370
|
+
``"red"``.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
name: str
|
|
374
|
+
dtype: str
|
|
375
|
+
nullable: bool = True
|
|
376
|
+
primary_key: bool = False
|
|
377
|
+
default: str | None = None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class CreateTable(MigrationOperation):
|
|
382
|
+
"""
|
|
383
|
+
Create a table from an explicit column list.
|
|
384
|
+
|
|
385
|
+
This is a new operation. It does not change the SQL or checksum of
|
|
386
|
+
``AddColumn`` and the other existing operations. Listing the columns here,
|
|
387
|
+
rather than reading a model at run time, is what keeps an applied
|
|
388
|
+
migration's checksum stable when that model later gains a field.
|
|
389
|
+
|
|
390
|
+
``if_not_exists`` defaults to True. Both SQLite and Postgres accept
|
|
391
|
+
``CREATE TABLE IF NOT EXISTS``.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
table: type[NamedTable] | str
|
|
395
|
+
columns: list[ColumnSpec]
|
|
396
|
+
if_not_exists: bool = True
|
|
397
|
+
|
|
398
|
+
def to_sql(self) -> str:
|
|
399
|
+
"""
|
|
400
|
+
Example: CREATE TABLE [IF NOT EXISTS] <table_name> (<column> <dtype> ...)
|
|
401
|
+
"""
|
|
402
|
+
if not self.columns:
|
|
403
|
+
raise ValueError("CreateTable requires at least one column")
|
|
404
|
+
|
|
405
|
+
definitions = ", ".join(self._column_sql(column) for column in self.columns)
|
|
406
|
+
exists = "IF NOT EXISTS " if self.if_not_exists else ""
|
|
407
|
+
return f"CREATE TABLE {exists}{self.table_name} ({definitions})"
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _column_sql(column: ColumnSpec) -> str:
|
|
411
|
+
parts = [column.name, column.dtype]
|
|
412
|
+
if column.primary_key:
|
|
413
|
+
parts.append("PRIMARY KEY")
|
|
414
|
+
if not column.nullable:
|
|
415
|
+
parts.append("NOT NULL")
|
|
416
|
+
if column.default is not None:
|
|
417
|
+
parts.append(f"DEFAULT {column.default}")
|
|
418
|
+
return " ".join(parts)
|
|
419
|
+
|
|
420
|
+
def canonical(self) -> str:
|
|
421
|
+
rendered = ", ".join(
|
|
422
|
+
f"{column.name}:{column.dtype}:nullable={column.nullable}:"
|
|
423
|
+
f"primary_key={column.primary_key}:default={column.default}"
|
|
424
|
+
for column in self.columns
|
|
425
|
+
)
|
|
426
|
+
return f"CreateTable(table={self.table_name}, columns=[{rendered}], if_not_exists={self.if_not_exists})"
|
|
427
|
+
|
|
428
|
+
|
|
341
429
|
@dataclass
|
|
342
|
-
class DropTable(
|
|
343
|
-
table: str
|
|
430
|
+
class DropTable(MigrationOperation):
|
|
431
|
+
table: type[NamedTable] | str
|
|
344
432
|
cascade: bool = False
|
|
345
433
|
|
|
346
434
|
def to_sql(self) -> str:
|
|
347
435
|
cascade_clause = " CASCADE" if self.cascade else ""
|
|
348
|
-
return f"DROP TABLE IF EXISTS {self.
|
|
436
|
+
return f"DROP TABLE IF EXISTS {self.table_name}{cascade_clause}"
|
|
349
437
|
|
|
350
438
|
def canonical(self) -> str:
|
|
351
|
-
return f"DropTable(table={self.
|
|
439
|
+
return f"DropTable(table={self.table_name}, cascade={self.cascade})"
|
|
352
440
|
|
|
353
441
|
|
|
354
442
|
@dataclass
|
|
355
|
-
class RenameTable(
|
|
443
|
+
class RenameTable(MigrationOperation):
|
|
356
444
|
old_name: str
|
|
357
445
|
new_name: str
|
|
358
446
|
|
|
@@ -364,16 +452,16 @@ class RenameTable(Operation):
|
|
|
364
452
|
|
|
365
453
|
|
|
366
454
|
@dataclass
|
|
367
|
-
class RenameColumn(
|
|
368
|
-
table: str
|
|
455
|
+
class RenameColumn(MigrationOperation):
|
|
456
|
+
table: type[NamedTable] | str
|
|
369
457
|
old_name: str
|
|
370
458
|
new_name: str
|
|
371
459
|
|
|
372
460
|
def to_sql(self) -> str:
|
|
373
|
-
return f"ALTER TABLE {self.
|
|
461
|
+
return f"ALTER TABLE {self.table_name} RENAME COLUMN {self.old_name} TO {self.new_name}"
|
|
374
462
|
|
|
375
463
|
def canonical(self) -> str:
|
|
376
|
-
return f"RenameColumn(table={self.
|
|
464
|
+
return f"RenameColumn(table={self.table_name}, old={self.old_name}, new={self.new_name})"
|
|
377
465
|
|
|
378
466
|
|
|
379
467
|
# ---------------------------------------------------------------------------
|
|
@@ -382,21 +470,22 @@ class RenameColumn(Operation):
|
|
|
382
470
|
|
|
383
471
|
|
|
384
472
|
@dataclass
|
|
385
|
-
class
|
|
473
|
+
class DataMigrationOperation(MigrationOperation):
|
|
386
474
|
"""
|
|
387
475
|
Executes a Python function for data migrations (seeding, transformations).
|
|
388
476
|
|
|
389
477
|
The function receives a SQLConnection and should use ORM methods to
|
|
390
|
-
read/write data. The
|
|
391
|
-
|
|
478
|
+
read/write data. The checksum is the ``name`` only, not the function.
|
|
479
|
+
Editing the callable without a new name is invisible to history, so a
|
|
480
|
+
changed function requires a new name. Choose a stable, descriptive one.
|
|
392
481
|
|
|
393
482
|
Example:
|
|
394
|
-
|
|
483
|
+
DataMigrationOperation(
|
|
395
484
|
name="seed_user_permissions_v1",
|
|
396
485
|
func=seed_permissions_from_roles,
|
|
397
486
|
)
|
|
398
487
|
|
|
399
|
-
IMPORTANT: It is not appropriate to use
|
|
488
|
+
IMPORTANT: It is not appropriate to use DataMigrationOperation in place of other operations. This is
|
|
400
489
|
reserved for the most complex operations that CANNOT be expressed otherwise.
|
|
401
490
|
"""
|
|
402
491
|
|
|
@@ -410,7 +499,9 @@ class DataOperation(Operation):
|
|
|
410
499
|
self.func(conn)
|
|
411
500
|
|
|
412
501
|
def to_sql(self) -> str:
|
|
413
|
-
raise NotImplementedError("
|
|
502
|
+
raise NotImplementedError("DataMigrationOperation does not produce SQL; use execute() instead")
|
|
414
503
|
|
|
415
504
|
def canonical(self) -> str:
|
|
505
|
+
# This spelling is part of every applied data migration's checksum, so it
|
|
506
|
+
# is fixed regardless of what this class is called.
|
|
416
507
|
return f"DataOperation(name={self.name})"
|
|
@@ -20,7 +20,7 @@ from types import ModuleType
|
|
|
20
20
|
|
|
21
21
|
from corekit.connections.sql.connection import SQLConnection
|
|
22
22
|
from corekit.connections.sql.migration.base import Migration
|
|
23
|
-
from corekit.connections.sql.migration.operations import
|
|
23
|
+
from corekit.connections.sql.migration.operations import DataMigrationOperation
|
|
24
24
|
from corekit.connections.sql.migration.table import SchemaMigration
|
|
25
25
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
26
26
|
|
|
@@ -120,7 +120,7 @@ class MigrationRegistry(Benchmarkable):
|
|
|
120
120
|
self.info(f"Applying migration v{migration.version}: {migration.name}")
|
|
121
121
|
for operation in migration.operations():
|
|
122
122
|
self.info(f" {operation.canonical()}")
|
|
123
|
-
if isinstance(operation,
|
|
123
|
+
if isinstance(operation, DataMigrationOperation):
|
|
124
124
|
operation.execute(conn)
|
|
125
125
|
else:
|
|
126
126
|
conn.exec_ddl(operation.to_sql())
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Statement primitives, from the shared base to the statements themselves.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.connections.sql.operations.base import (
|
|
6
|
+
Conditional,
|
|
7
|
+
DdlOperation,
|
|
8
|
+
DmlOperation,
|
|
9
|
+
DqlOperation,
|
|
10
|
+
Operation,
|
|
11
|
+
)
|
|
12
|
+
from corekit.connections.sql.operations.statements import Delete, Insert, Select, Update
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Conditional",
|
|
16
|
+
"DdlOperation",
|
|
17
|
+
"Delete",
|
|
18
|
+
"DmlOperation",
|
|
19
|
+
"DqlOperation",
|
|
20
|
+
"Insert",
|
|
21
|
+
"Operation",
|
|
22
|
+
"Select",
|
|
23
|
+
"Update",
|
|
24
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Statement primitives: the shapes a database operation can take.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from corekit.connections.sql.table import NamedTable
|
|
11
|
+
from corekit.data.expressions import Expression
|
|
12
|
+
|
|
13
|
+
__all__ = ["Conditional", "DdlOperation", "DmlOperation", "DqlOperation", "Operation"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Operation(BaseModel, ABC):
|
|
17
|
+
"""
|
|
18
|
+
A statement waiting to be executed.
|
|
19
|
+
|
|
20
|
+
An operation compiles itself and nothing more: it holds no session and
|
|
21
|
+
runs no SQL, so building one is free of side effects and a connection
|
|
22
|
+
stays the only thing that owns a transaction.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
26
|
+
|
|
27
|
+
#: True when executing this operation changes rows and must be committed.
|
|
28
|
+
#: Conditional writes (update, delete) and insert both set this; reads do not.
|
|
29
|
+
writes: ClassVar[bool] = False
|
|
30
|
+
|
|
31
|
+
table: type[NamedTable]
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def build(self) -> Any:
|
|
35
|
+
"""
|
|
36
|
+
Produce the statement to execute.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"{type(self).__name__}({self.table.__name__})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Conditional(Operation, ABC):
|
|
44
|
+
"""
|
|
45
|
+
An operation narrowed by conditions, e.g. everything but an insert.
|
|
46
|
+
|
|
47
|
+
Conditions accumulate and are combined with AND. Each is either a native
|
|
48
|
+
SQLModel clause, which names columns so an IDE checks them, or a corekit
|
|
49
|
+
expression, which names fields as strings and works against any backend.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
conditions: list[Any] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def where(self, condition: Any) -> "Conditional":
|
|
55
|
+
"""
|
|
56
|
+
Narrow this operation. Repeated calls combine with AND.
|
|
57
|
+
"""
|
|
58
|
+
self.conditions.append(condition)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def _as_clause(self, condition: Any) -> Any:
|
|
62
|
+
"""
|
|
63
|
+
Compile a corekit expression; pass a native SQLModel clause through.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(condition, Expression):
|
|
66
|
+
return condition.to_sqlalchemy(self.table)
|
|
67
|
+
return condition
|
|
68
|
+
|
|
69
|
+
def _apply_conditions(self, statement: Any) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Attach every condition to ``statement``.
|
|
72
|
+
"""
|
|
73
|
+
for condition in self.conditions:
|
|
74
|
+
statement = statement.where(self._as_clause(condition))
|
|
75
|
+
return statement
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DqlOperation(Conditional, ABC):
|
|
79
|
+
"""
|
|
80
|
+
A read. Returns rows and changes nothing.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DmlOperation(Conditional, ABC):
|
|
85
|
+
"""
|
|
86
|
+
A conditional write against rows: update or delete.
|
|
87
|
+
|
|
88
|
+
Insert is also a write, but it has no ``where``, so it is not this class.
|
|
89
|
+
Both set ``writes`` so a connection commits them.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
writes: ClassVar[bool] = True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DdlOperation(Operation, ABC):
|
|
96
|
+
"""
|
|
97
|
+
A change to the schema itself.
|
|
98
|
+
|
|
99
|
+
Schema changes are checksummed so a migration that has already run can be
|
|
100
|
+
recognised, which reads and writes never need. That is what separates
|
|
101
|
+
this branch from the others.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def canonical(self) -> str:
|
|
106
|
+
"""
|
|
107
|
+
A deterministic, human-readable rendering of this operation's intent.
|
|
108
|
+
|
|
109
|
+
It feeds a checksum, so it must change whenever the intent changes and
|
|
110
|
+
must not change for anything else -- formatting or field order included.
|
|
111
|
+
"""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The statements an application runs: select, insert, update and delete.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from sqlmodel import delete as _delete
|
|
9
|
+
from sqlmodel import desc as _desc
|
|
10
|
+
from sqlmodel import insert as _insert
|
|
11
|
+
from sqlmodel import select as _select
|
|
12
|
+
from sqlmodel import update as _update
|
|
13
|
+
|
|
14
|
+
from corekit.connections.sql.operations.base import DmlOperation, DqlOperation, Operation
|
|
15
|
+
|
|
16
|
+
__all__ = ["Delete", "Insert", "Select", "Update"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Select(DqlOperation):
|
|
20
|
+
"""
|
|
21
|
+
Read rows, optionally ordered, offset and capped.
|
|
22
|
+
|
|
23
|
+
Select(table=User).where(User.age >= 18).order_by(User.name).limit(10)
|
|
24
|
+
|
|
25
|
+
``where`` also accepts a ``corekit.data`` expression, which is the same
|
|
26
|
+
predicate language ``Dataset`` filters with::
|
|
27
|
+
|
|
28
|
+
Select(table=User).where(Field("age") >= 18)
|
|
29
|
+
|
|
30
|
+
``limit(-1)`` means no cap. ``limit(0)`` returns no rows.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
order_by_field: Any = None
|
|
34
|
+
order_by_desc: bool = False
|
|
35
|
+
limit_count: int = -1
|
|
36
|
+
offset_count: int = 0
|
|
37
|
+
|
|
38
|
+
def order_by(self, field: Any, desc: bool = False) -> "Select":
|
|
39
|
+
"""
|
|
40
|
+
Order results by a field.
|
|
41
|
+
"""
|
|
42
|
+
self.order_by_field = field
|
|
43
|
+
self.order_by_desc = desc
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def limit(self, limit: int) -> "Select":
|
|
47
|
+
"""
|
|
48
|
+
Cap the number of rows returned.
|
|
49
|
+
|
|
50
|
+
``-1`` means no limit. ``0`` means zero rows.
|
|
51
|
+
"""
|
|
52
|
+
self.limit_count = limit
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def offset(self, offset: int) -> "Select":
|
|
56
|
+
"""
|
|
57
|
+
Skip the first ``offset`` rows.
|
|
58
|
+
"""
|
|
59
|
+
self.offset_count = offset
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def build(self) -> Any:
|
|
63
|
+
"""
|
|
64
|
+
Produce the select statement.
|
|
65
|
+
"""
|
|
66
|
+
statement = self._apply_conditions(_select(self.table))
|
|
67
|
+
|
|
68
|
+
if self.order_by_field is not None:
|
|
69
|
+
order_by_field = self.order_by_field
|
|
70
|
+
if self.order_by_desc:
|
|
71
|
+
order_by_field = _desc(order_by_field)
|
|
72
|
+
statement = statement.order_by(order_by_field)
|
|
73
|
+
|
|
74
|
+
if self.offset_count > 0:
|
|
75
|
+
statement = statement.offset(self.offset_count)
|
|
76
|
+
|
|
77
|
+
if self.limit_count >= 0:
|
|
78
|
+
statement = statement.limit(self.limit_count)
|
|
79
|
+
|
|
80
|
+
return statement
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Delete(DmlOperation):
|
|
84
|
+
"""
|
|
85
|
+
Delete every row matching the conditions, in one statement.
|
|
86
|
+
|
|
87
|
+
Delete(table=Session).where(Session.left_at < cutoff)
|
|
88
|
+
|
|
89
|
+
Deleting without a condition would empty the table, so it is refused:
|
|
90
|
+
a missing ``where`` is far more often a mistake than an intent.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def build(self) -> Any:
|
|
94
|
+
"""
|
|
95
|
+
Produce the delete statement.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: If no condition was given.
|
|
99
|
+
"""
|
|
100
|
+
if not self.conditions:
|
|
101
|
+
raise ValueError(f"Delete on {self.table.__name__} needs a condition; use truncate to empty a table")
|
|
102
|
+
return self._apply_conditions(_delete(self.table))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Update(DmlOperation):
|
|
106
|
+
"""
|
|
107
|
+
Set columns on every row matching the conditions, in one statement.
|
|
108
|
+
|
|
109
|
+
Update(table=User, values={"active": False}).where(User.age < 18)
|
|
110
|
+
|
|
111
|
+
Updating without a condition would rewrite the table, so it is refused.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
values: dict[str, Any] = Field(default_factory=dict)
|
|
115
|
+
|
|
116
|
+
def set(self, **values: Any) -> "Update":
|
|
117
|
+
"""
|
|
118
|
+
Columns to write, as keyword arguments.
|
|
119
|
+
"""
|
|
120
|
+
self.values.update(values)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def build(self) -> Any:
|
|
124
|
+
"""
|
|
125
|
+
Produce the update statement.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If no condition or no value was given.
|
|
129
|
+
"""
|
|
130
|
+
if not self.conditions:
|
|
131
|
+
raise ValueError(f"Update on {self.table.__name__} needs a condition")
|
|
132
|
+
if not self.values:
|
|
133
|
+
raise ValueError(f"Update on {self.table.__name__} needs values to set")
|
|
134
|
+
return self._apply_conditions(_update(self.table)).values(**self.values)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Insert(Operation):
|
|
138
|
+
"""
|
|
139
|
+
Add rows. A write, so ``SQLConnection.execute`` commits it.
|
|
140
|
+
|
|
141
|
+
Not a ``DmlOperation``: an insert has no rows to narrow, so it does not
|
|
142
|
+
take ``where``.
|
|
143
|
+
|
|
144
|
+
Insert(table=User, rows=[{"name": "Ada"}, {"name": "Bob"}])
|
|
145
|
+
|
|
146
|
+
Rows are dictionaries rather than instances, so this compiles to a single
|
|
147
|
+
statement. To add model instances, use ``SQLConnection.insert``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
writes: ClassVar[bool] = True
|
|
151
|
+
|
|
152
|
+
rows: list[dict[str, Any]] = Field(default_factory=list)
|
|
153
|
+
|
|
154
|
+
def add(self, **values: Any) -> "Insert":
|
|
155
|
+
"""
|
|
156
|
+
Append one row.
|
|
157
|
+
"""
|
|
158
|
+
self.rows.append(values)
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def build(self) -> Any:
|
|
162
|
+
"""
|
|
163
|
+
Produce the insert statement.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: If there is nothing to insert.
|
|
167
|
+
"""
|
|
168
|
+
if not self.rows:
|
|
169
|
+
raise ValueError(f"Insert on {self.table.__name__} needs at least one row")
|
|
170
|
+
return _insert(self.table).values(self.rows)
|