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.
Files changed (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {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 Operation knows how to:
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 Operation(ABC):
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(Operation):
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.table).add(COLUMN)
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.table}, column={self.column}, dtype={self.dtype}, "
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(Operation):
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.table).drop(COLUMN).if_exists().append(self.column).build()
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.table}, column={self.column})"
269
+ return f"DropColumn(table={self.table_name}, column={self.column})"
248
270
 
249
271
 
250
272
  @dataclass
251
- class AlterColumnType(Operation):
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.table).alter(COLUMN, self.column).type(self.new_dtype)
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(Operation):
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 self._builder().alter(TABLE, self.table).alter(COLUMN, self.column).set_default(self.default).build()
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.table}, column={self.column}, default={self.default})"
308
+ return f"SetColumnDefault(table={self.table_name}, column={self.column}, default={self.default})"
287
309
 
288
310
 
289
311
  @dataclass
290
- class DropColumnDefault(Operation):
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.table).alter(COLUMN, self.column).drop_default().build()
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.table}, column={self.column})"
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(Operation):
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.table} ({cols})"
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.table}, columns={self.columns}, unique={self.unique})"
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(Operation):
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(Operation):
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.table}{cascade_clause}"
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.table}, cascade={self.cascade})"
439
+ return f"DropTable(table={self.table_name}, cascade={self.cascade})"
352
440
 
353
441
 
354
442
  @dataclass
355
- class RenameTable(Operation):
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(Operation):
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.table} RENAME COLUMN {self.old_name} TO {self.new_name}"
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.table}, old={self.old_name}, new={self.new_name})"
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 DataOperation(Operation):
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 `name` is used for checksumming - changing it will
391
- invalidate the checksum, so choose a stable, descriptive name.
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
- DataOperation(
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 DataOperation in place of other operations. This is
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("DataOperation does not produce SQL; use execute() instead")
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 DataOperation
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, DataOperation):
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)