cygnet-orm 1.0.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.
- cygnet/__init__.py +521 -0
- cygnet/annotations.py +86 -0
- cygnet/arrays.py +111 -0
- cygnet/builders.py +1070 -0
- cygnet/cte.py +321 -0
- cygnet/executor.py +1442 -0
- cygnet/expression.py +611 -0
- cygnet/fts.py +125 -0
- cygnet/functions.py +101 -0
- cygnet/jsonb.py +112 -0
- cygnet/meta.py +264 -0
- cygnet/predicate.py +263 -0
- cygnet/proxy.py +168 -0
- cygnet/psycopg_db.py +185 -0
- cygnet/py.typed +0 -0
- cygnet/stubs.py +170 -0
- cygnet_orm-1.0.0.dist-info/METADATA +1090 -0
- cygnet_orm-1.0.0.dist-info/RECORD +20 -0
- cygnet_orm-1.0.0.dist-info/WHEEL +4 -0
- cygnet_orm-1.0.0.dist-info/licenses/LICENSE +21 -0
cygnet/__init__.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CYGNET — The Littlest ORM.
|
|
3
|
+
|
|
4
|
+
Bring your own objects. Write real SQL.
|
|
5
|
+
|
|
6
|
+
This module is the public API surface. All user-facing functions are defined
|
|
7
|
+
here (or re-exported from submodules). Internal modules (meta, proxy,
|
|
8
|
+
executor, etc.) are not part of the public API.
|
|
9
|
+
|
|
10
|
+
Query verbs are UPPER_CASE (SELECT, INSERT, UPDATE, DELETE, TRUNCATE) to
|
|
11
|
+
mirror SQL and read naturally in Python: `await cygnet.SELECT(db).FROM(T)`.
|
|
12
|
+
The noqa: N802 suppressions silence PEP 8 naming complaints on these
|
|
13
|
+
intentionally-named functions.
|
|
14
|
+
|
|
15
|
+
This file also hosts cross-cutting helpers that don't belong inside a
|
|
16
|
+
specific builder module — `transaction` (savepoint-based nesting context
|
|
17
|
+
manager), `get` (PK fetch), `save` (insert-or-upsert dispatch), `create`
|
|
18
|
+
(insert-without-upsert), `follow` (FK traversal), and `TRUNCATE` (no
|
|
19
|
+
builder needed). These are kept here because they sit between the
|
|
20
|
+
builders and the executor: they coordinate across multiple builder
|
|
21
|
+
operations or sit outside the builder pattern entirely.
|
|
22
|
+
|
|
23
|
+
Naming convention recap for callers reading the export list:
|
|
24
|
+
- UPPER_CASE → SQL verbs / statement entry points
|
|
25
|
+
(SELECT, INSERT, UPDATE, DELETE, TRUNCATE)
|
|
26
|
+
- PascalCase → factories returning a proxy / decorator / class
|
|
27
|
+
(Table, Column, DBKey, AppKey, ForeignKey, table, CTE, Lateral,
|
|
28
|
+
RecursiveCTE)
|
|
29
|
+
- lower_case → helpers, sentinels, and convenience functions
|
|
30
|
+
(get, save, create, follow, transaction, lit, op, ops, is_null,
|
|
31
|
+
is_not_null, exists, not_exists, fn, cte, lateral, recursive_cte,
|
|
32
|
+
all)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
from typing import Any, cast
|
|
39
|
+
|
|
40
|
+
# Re-exports are grouped by role: annotations (used in model definitions),
|
|
41
|
+
# builders (returned by query-verb factories, not constructed directly by
|
|
42
|
+
# users), expression helpers (op/ops/is_null/is_not_null), and the `all`
|
|
43
|
+
# sentinel (see predicate.py — required for unrestricted UPDATE/DELETE).
|
|
44
|
+
# `cygnet.all` shadows Python's builtin `all` *inside this module only*;
|
|
45
|
+
# callers do `cygnet.all` which is unambiguous at the call site.
|
|
46
|
+
from .annotations import AppKey, Column, DBKey, ForeignKey, table
|
|
47
|
+
from .builders import DeleteBuilder, InsertBuilder, SelectBuilder, UpdateBuilder
|
|
48
|
+
from .cte import CTE, Lateral, RecursiveCTE, cte, lateral, recursive_cte
|
|
49
|
+
from .executor import Executor
|
|
50
|
+
from .expression import (
|
|
51
|
+
DBAdapter,
|
|
52
|
+
exists,
|
|
53
|
+
fn,
|
|
54
|
+
is_not_null,
|
|
55
|
+
is_null,
|
|
56
|
+
not_exists,
|
|
57
|
+
op,
|
|
58
|
+
ops,
|
|
59
|
+
)
|
|
60
|
+
from .predicate import Literal, all
|
|
61
|
+
from .proxy import ColumnProxy, TableProxy
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
# Annotations
|
|
65
|
+
"DBKey",
|
|
66
|
+
"AppKey",
|
|
67
|
+
"Column",
|
|
68
|
+
"ForeignKey",
|
|
69
|
+
"table",
|
|
70
|
+
# Adapter contract
|
|
71
|
+
"DBAdapter",
|
|
72
|
+
# Table factory
|
|
73
|
+
"Table",
|
|
74
|
+
# Query verbs
|
|
75
|
+
"SELECT",
|
|
76
|
+
"INSERT",
|
|
77
|
+
"UPDATE",
|
|
78
|
+
"DELETE",
|
|
79
|
+
"TRUNCATE",
|
|
80
|
+
# Predicates
|
|
81
|
+
"all",
|
|
82
|
+
"lit",
|
|
83
|
+
"op",
|
|
84
|
+
"ops",
|
|
85
|
+
"is_null",
|
|
86
|
+
"is_not_null",
|
|
87
|
+
"exists",
|
|
88
|
+
"not_exists",
|
|
89
|
+
"fn",
|
|
90
|
+
# Convenience
|
|
91
|
+
"create",
|
|
92
|
+
"follow",
|
|
93
|
+
"get",
|
|
94
|
+
"save",
|
|
95
|
+
"transaction",
|
|
96
|
+
"flush_column_defaults",
|
|
97
|
+
# CTEs
|
|
98
|
+
"CTE",
|
|
99
|
+
"cte",
|
|
100
|
+
"RecursiveCTE",
|
|
101
|
+
"recursive_cte",
|
|
102
|
+
"Lateral",
|
|
103
|
+
"lateral",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def Table[T](cls: type[T]) -> TableProxy[T]: # noqa: N802
|
|
108
|
+
"""Create a table proxy from a dataclass.
|
|
109
|
+
|
|
110
|
+
Returns a cached singleton per class — cygnet.Table(X) is cygnet.Table(X).
|
|
111
|
+
The TableProxy is generic on the model type so that downstream APIs
|
|
112
|
+
(e.g. cygnet.get) can return the correct concrete type.
|
|
113
|
+
"""
|
|
114
|
+
return TableProxy(cls)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Query verb entry points ──────────────────────────────────────────────────
|
|
118
|
+
# These are thin wrappers that create builders. The db object is threaded
|
|
119
|
+
# through to the builder and eventually to the Executor, which calls
|
|
120
|
+
# db.execute() / db.execute_one(). Cygnet never imports a specific database
|
|
121
|
+
# driver — the db protocol is duck-typed (see tests/conftest.py:FakeDB for
|
|
122
|
+
# the minimal interface).
|
|
123
|
+
#
|
|
124
|
+
# Each builder is awaitable: `await SELECT(db).FROM(T)` triggers execution
|
|
125
|
+
# via the builder's __await__. Builders also expose `.sql()` for caller
|
|
126
|
+
# inspection without execution.
|
|
127
|
+
#
|
|
128
|
+
# UPDATE and DELETE refuse to run without an explicit .WHERE() — pass
|
|
129
|
+
# cygnet.all to act on every row intentionally. See predicate.py for the
|
|
130
|
+
# sentinel; the guard lives in the builder, not here.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def SELECT(db: DBAdapter, *columns: Any) -> SelectBuilder: # noqa: N802
|
|
134
|
+
return SelectBuilder(db, *columns)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def INSERT(db: DBAdapter) -> InsertBuilder: # noqa: N802
|
|
138
|
+
return InsertBuilder(db)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def UPDATE(db: DBAdapter) -> UpdateBuilder: # noqa: N802
|
|
142
|
+
return UpdateBuilder(db)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def DELETE(db: DBAdapter) -> DeleteBuilder: # noqa: N802
|
|
146
|
+
return DeleteBuilder(db)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def TRUNCATE( # noqa: N802
|
|
150
|
+
db: DBAdapter, *tables: TableProxy[Any], cascade: bool = False
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Truncate one or more tables. Use cascade=True to drop dependent rows.
|
|
153
|
+
|
|
154
|
+
Unlike SELECT/INSERT/UPDATE/DELETE, TRUNCATE has no builder — it's a
|
|
155
|
+
single statement with no clauses to chain, so a direct async function
|
|
156
|
+
is simpler.
|
|
157
|
+
"""
|
|
158
|
+
# TRUNCATE doesn't go through the Executor: there are no rows to map
|
|
159
|
+
# back to objects and no params to bind, so this is a direct
|
|
160
|
+
# db.execute() with the empty-params list the protocol requires.
|
|
161
|
+
if not tables:
|
|
162
|
+
raise ValueError("TRUNCATE requires at least one table")
|
|
163
|
+
names = ", ".join(t._meta.table_name for t in tables)
|
|
164
|
+
sql = f"TRUNCATE TABLE {names}"
|
|
165
|
+
if cascade:
|
|
166
|
+
sql += " CASCADE"
|
|
167
|
+
await db.execute(sql, [])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def lit(sql: str) -> Literal:
|
|
171
|
+
"""Create a raw SQL literal for use in any expression position.
|
|
172
|
+
|
|
173
|
+
The SQL is emitted verbatim — no escaping, no parameter substitution.
|
|
174
|
+
This is the escape hatch when Cygnet's expression API doesn't cover
|
|
175
|
+
your SQL construct. Use with care: the string is trusted.
|
|
176
|
+
|
|
177
|
+
Caveat for adapters that translate placeholder syntax: the reference
|
|
178
|
+
psycopg adapter (cygnet.psycopg_db.PsycopgDB) rewrites every ``$\\d+``
|
|
179
|
+
substring in the final SQL to psycopg's ``%s`` form — including any
|
|
180
|
+
such substrings inside a ``lit()`` payload. If you need a literal
|
|
181
|
+
``$1`` string in a SQL fragment going through that adapter, write it
|
|
182
|
+
as ``'$' || '1'`` or similar. Custom adapters that don't translate
|
|
183
|
+
placeholders are unaffected.
|
|
184
|
+
"""
|
|
185
|
+
return Literal(sql=sql)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def flush_column_defaults(db: DBAdapter | None = None) -> None:
|
|
189
|
+
"""Evict cached column-DEFAULT introspection results.
|
|
190
|
+
|
|
191
|
+
Cygnet caches the set of columns carrying a non-NULL DEFAULT on
|
|
192
|
+
first INSERT against each (adapter, table) pair, then reuses it on
|
|
193
|
+
every subsequent INSERT — the round-trip to PG's catalog amortises
|
|
194
|
+
away. The cache is stable for the lifetime of the schema, but
|
|
195
|
+
adapters in long-running services (pooled connections, daemons)
|
|
196
|
+
typically outlive the schemas they were populated against.
|
|
197
|
+
|
|
198
|
+
After a migration that ALTERs a DEFAULT clause, call this function
|
|
199
|
+
so the next INSERT re-introspects. Otherwise Cygnet will keep
|
|
200
|
+
omitting columns whose DEFAULT was dropped (writing NULL where the
|
|
201
|
+
schema expected a value) or vice versa. Cygnet has no way to
|
|
202
|
+
detect migrations on its own.
|
|
203
|
+
|
|
204
|
+
With ``db=None``: clears every adapter's entries (covers "any
|
|
205
|
+
connection that goes through this process is post-migration").
|
|
206
|
+
With a specific adapter: evicts only that adapter — useful for
|
|
207
|
+
sharded / per-tenant migration patterns. Either form is a no-op
|
|
208
|
+
when the adapter has no cached entries.
|
|
209
|
+
"""
|
|
210
|
+
Executor.flush_column_defaults(db)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ── Convenience functions ────────────────────────────────────────────────────
|
|
214
|
+
# These wrap common patterns (get-by-PK, insert-without-upsert, upsert)
|
|
215
|
+
# so callers don't have to spell out the full builder chain for simple cases.
|
|
216
|
+
#
|
|
217
|
+
# The save / create / INSERT distinction is intentional and worth keeping
|
|
218
|
+
# straight when reading caller code:
|
|
219
|
+
# - INSERT(db).INTO(T).VALUES(...) — generic builder; user controls
|
|
220
|
+
# ON CONFLICT, RETURNING, etc. Most flexible, most verbose.
|
|
221
|
+
# - create(db, obj) — INSERT with no ON CONFLICT. Duplicates raise
|
|
222
|
+
# IntegrityError from the driver. PK populated on obj for DBKey.
|
|
223
|
+
# - save(db, obj) — INSERT ... ON CONFLICT DO UPDATE (upsert) when a
|
|
224
|
+
# PK is known; plain INSERT ... RETURNING when DBKey + PK is None.
|
|
225
|
+
# Idempotent; the default "persist this object" call.
|
|
226
|
+
# follow() and get() are read-side conveniences; they always SELECT and
|
|
227
|
+
# never mutate.
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def get[T](db: DBAdapter, table: TableProxy[T], **pk_kwargs: Any) -> T | None:
|
|
231
|
+
"""Fetch a single object by primary key. Returns None if not found.
|
|
232
|
+
|
|
233
|
+
The pk kwarg name must match the Python attribute name (not the DB
|
|
234
|
+
column name): cygnet.get(db, T, id=1), not cygnet.get(db, T, user_id=1)
|
|
235
|
+
if the attr is `id` but the column is `user_id`.
|
|
236
|
+
|
|
237
|
+
Returns T | None thanks to the generic TableProxy[T] — callers get
|
|
238
|
+
the correct concrete type for the model under inspection.
|
|
239
|
+
"""
|
|
240
|
+
meta = table._meta
|
|
241
|
+
# Defensive: TableMeta now enforces "exactly one PK" at introspection
|
|
242
|
+
# time, so this branch is unreachable through the public API. Kept as
|
|
243
|
+
# a clear error in case meta.pk is set None elsewhere in future work.
|
|
244
|
+
if meta.pk is None:
|
|
245
|
+
raise TypeError(f"{meta.cls.__name__} has no primary key")
|
|
246
|
+
# Validate the kwarg name explicitly so a wrong key (e.g. user_id when
|
|
247
|
+
# the attr is id) raises a TypeError naming both the model and the
|
|
248
|
+
# expected kwarg, rather than a bare KeyError on the attr name.
|
|
249
|
+
if meta.pk.attr_name not in pk_kwargs:
|
|
250
|
+
raise TypeError(
|
|
251
|
+
f"{meta.cls.__name__}.get() missing PK kwarg {meta.pk.attr_name!r}"
|
|
252
|
+
)
|
|
253
|
+
val = pk_kwargs[meta.pk.attr_name]
|
|
254
|
+
pred = getattr(table, meta.pk.attr_name) == val
|
|
255
|
+
results = await SELECT(db).FROM(table).WHERE(pred)
|
|
256
|
+
# SELECT.run_select returns list[Any] (each entry is an instance of the
|
|
257
|
+
# model class via _row_to_obj). Cast at the boundary so the public
|
|
258
|
+
# signature carries T | None without forcing internal SELECT machinery
|
|
259
|
+
# to be generic.
|
|
260
|
+
return cast("T | None", results[0] if results else None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def follow(db: DBAdapter, obj: Any, fk_column: Any) -> Any:
|
|
264
|
+
"""Load the object that a foreign key points to.
|
|
265
|
+
|
|
266
|
+
Returns None if the FK value is None or no matching row exists.
|
|
267
|
+
Raises ValueError if fk_column is not a foreign key.
|
|
268
|
+
Raises TypeError if obj is not an instance of the FK column's table.
|
|
269
|
+
"""
|
|
270
|
+
if not isinstance(fk_column, ColumnProxy):
|
|
271
|
+
raise ValueError(f"{fk_column!r} is not a column proxy")
|
|
272
|
+
|
|
273
|
+
# Order of checks below: type-validate fk_column → type-validate obj →
|
|
274
|
+
# check FK metadata → read FK value → null-short-circuit → SELECT.
|
|
275
|
+
# Validating obj before reading field metadata gives a clearer error
|
|
276
|
+
# for the common "passed the wrong instance" mistake.
|
|
277
|
+
field = fk_column._field
|
|
278
|
+
source_meta = fk_column._table._meta
|
|
279
|
+
|
|
280
|
+
if not isinstance(obj, source_meta.cls):
|
|
281
|
+
raise TypeError(
|
|
282
|
+
f"Expected {source_meta.cls.__name__}, got {type(obj).__name__}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if field.foreign_key is None:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"{source_meta.cls.__name__}.{field.attr_name} is not a foreign key"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
fk_value = getattr(obj, field.attr_name)
|
|
291
|
+
if fk_value is None:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
target_proxy: TableProxy[Any] = TableProxy(field.foreign_key.target)
|
|
295
|
+
# FK validation in _introspect() guarantees the target has a PK.
|
|
296
|
+
assert target_proxy._meta.pk is not None
|
|
297
|
+
target_pk = target_proxy._meta.pk
|
|
298
|
+
return await get(db, target_proxy, **{target_pk.attr_name: fk_value})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def create(db: DBAdapter, obj: Any) -> Any:
|
|
302
|
+
"""
|
|
303
|
+
INSERT obj into its table. No ON CONFLICT — duplicates raise from the DB.
|
|
304
|
+
|
|
305
|
+
Returns the object with PK populated (for DBKey).
|
|
306
|
+
"""
|
|
307
|
+
return await Executor(db).run_create(obj)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def save(db: DBAdapter, obj: Any) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Persist obj to its table.
|
|
313
|
+
|
|
314
|
+
- DBKey + pk is None → INSERT ... RETURNING (pk populated on obj)
|
|
315
|
+
- DBKey + pk is set → INSERT ... ON CONFLICT DO UPDATE
|
|
316
|
+
- AppKey + pk is None → ValueError
|
|
317
|
+
- AppKey + pk is set → INSERT ... ON CONFLICT DO UPDATE
|
|
318
|
+
|
|
319
|
+
The DBKey/AppKey distinction drives the dispatch: a None PK on a DBKey
|
|
320
|
+
field means "the database will assign one" (safe to INSERT), but a None
|
|
321
|
+
PK on an AppKey field means the caller forgot to set it (error). Upsert
|
|
322
|
+
semantics (ON CONFLICT DO UPDATE) are the default whenever a PK is
|
|
323
|
+
present — save() is idempotent in that sense, unlike create().
|
|
324
|
+
"""
|
|
325
|
+
await Executor(db).run_save(obj)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# State machine summary for transaction:
|
|
329
|
+
#
|
|
330
|
+
# db._in_transaction = False ──BEGIN──▶ db._in_transaction = True
|
|
331
|
+
# ▲ │
|
|
332
|
+
# └────COMMIT or ROLLBACK────────────┘
|
|
333
|
+
#
|
|
334
|
+
# A nested `async with cygnet.transaction(db)` finds the flag already
|
|
335
|
+
# True and issues SAVEPOINT spN / RELEASE / ROLLBACK TO instead of
|
|
336
|
+
# BEGIN / COMMIT / ROLLBACK. The flag is *not* a counter — nesting depth
|
|
337
|
+
# isn't tracked here; SAVEPOINTs nest naturally on the server side, and
|
|
338
|
+
# the unique-per-instance savepoint name (`sp_{id(self)}`) keeps the
|
|
339
|
+
# release pair matched.
|
|
340
|
+
#
|
|
341
|
+
# Why a bool and not a counter: a counter would require Cygnet to
|
|
342
|
+
# manage the depth explicitly, which doesn't add safety (the server
|
|
343
|
+
# rejects mismatched COMMITs anyway) but does add a failure mode where
|
|
344
|
+
# a counter and the server's view diverge after an unexpected exception.
|
|
345
|
+
# The flag-plus-savepoint approach is monotonic and self-healing.
|
|
346
|
+
#
|
|
347
|
+
# This is NOT task-local on purpose: Cygnet expects one db adapter
|
|
348
|
+
# instance per asyncio task. Sharing one PsycopgDB across concurrent
|
|
349
|
+
# tasks is now actively detected (S10): outermost __aenter__ captures
|
|
350
|
+
# `asyncio.current_task()` onto `db._transaction_task`, and nested
|
|
351
|
+
# __aenter__ verifies the same task is owning the transaction —
|
|
352
|
+
# cross-task nesting raises with a clear message instead of silently
|
|
353
|
+
# turning into a SAVEPOINT against another task's transaction. The
|
|
354
|
+
# guard is best-effort: it requires the outer layer to use
|
|
355
|
+
# cygnet.transaction (not an externally-managed BEGIN), and treats a
|
|
356
|
+
# None current_task (e.g., outside any task context) as "no claim".
|
|
357
|
+
# See PsycopgDB's docstring for the same warning at the adapter layer.
|
|
358
|
+
class transaction:
|
|
359
|
+
"""
|
|
360
|
+
Async context manager for database transactions.
|
|
361
|
+
|
|
362
|
+
Outermost usage opens BEGIN/COMMIT.
|
|
363
|
+
Nested usage transparently promotes to SAVEPOINT/RELEASE.
|
|
364
|
+
Any exception triggers ROLLBACK or ROLLBACK TO SAVEPOINT.
|
|
365
|
+
|
|
366
|
+
Nesting detection relies on a `_in_transaction` flag on the db object.
|
|
367
|
+
This is a simple boolean, not a counter — nested transactions always
|
|
368
|
+
use SAVEPOINTs, and only the outermost context manager issues BEGIN/COMMIT.
|
|
369
|
+
The `__aenter__` returns the same db object (not a wrapper), so all
|
|
370
|
+
queries inside the block use the same connection.
|
|
371
|
+
|
|
372
|
+
Invariant: the db adapter is responsible for initializing
|
|
373
|
+
`_in_transaction` (usually False on a fresh connection). Cygnet toggles
|
|
374
|
+
it only on BEGIN/COMMIT/ROLLBACK at the outermost level; SAVEPOINT
|
|
375
|
+
operations leave it alone, which is what makes nesting work without a
|
|
376
|
+
counter. Concurrent use of the same db handle across tasks is not
|
|
377
|
+
supported — the flag is not task-local — but Cygnet actively detects
|
|
378
|
+
cross-task misuse (S10): the outermost ``__aenter__`` records the
|
|
379
|
+
owning ``asyncio.current_task()`` on the db, and a nested entry from
|
|
380
|
+
a different task raises ``RuntimeError`` rather than silently
|
|
381
|
+
SAVEPOINTing inside the other task's transaction.
|
|
382
|
+
|
|
383
|
+
Instance reuse: a single `transaction(db)` instance can be reused
|
|
384
|
+
across sequential `async with` blocks — `__aenter__` resets
|
|
385
|
+
`self._savepoint` so the prior nested savepoint name never leaks
|
|
386
|
+
into a subsequent outermost BEGIN/COMMIT. Concurrent re-entry of
|
|
387
|
+
the SAME transaction instance is unsupported (the `self._savepoint`
|
|
388
|
+
field would race); construct a fresh `transaction(db)` per task if
|
|
389
|
+
you need parallel transactional contexts on the same db handle —
|
|
390
|
+
and remember that the db adapter itself is also not task-local
|
|
391
|
+
(see the previous paragraph).
|
|
392
|
+
|
|
393
|
+
Usage::
|
|
394
|
+
|
|
395
|
+
async with cygnet.transaction(db) as tx:
|
|
396
|
+
await cygnet.INSERT(tx).INTO(AccountTable).VALUES(acc)
|
|
397
|
+
async with cygnet.transaction(tx) as tx2:
|
|
398
|
+
await cygnet.UPDATE(tx2).SET(LogTable, entry).WHERE(...)
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self, db: DBAdapter) -> None:
|
|
402
|
+
self._db = db
|
|
403
|
+
# Set to a savepoint name on __aenter__ when nested; stays None at
|
|
404
|
+
# the outermost level. Acts as the "am I the outermost?" signal
|
|
405
|
+
# in __aexit__ — see the branch there.
|
|
406
|
+
self._savepoint: str | None = None
|
|
407
|
+
|
|
408
|
+
async def __aenter__(self) -> Any:
|
|
409
|
+
# Reset _savepoint at every enter so a transaction instance can be
|
|
410
|
+
# reused across multiple `async with` blocks without a stale
|
|
411
|
+
# savepoint name leaking from a prior nested entry into a fresh
|
|
412
|
+
# BEGIN/COMMIT cycle.
|
|
413
|
+
self._savepoint = None
|
|
414
|
+
# S10: task-locality guard. asyncio.current_task() returns None
|
|
415
|
+
# outside any task context (rare for async code — needs to be
|
|
416
|
+
# awaited from somewhere), in which case we skip the check rather
|
|
417
|
+
# than fight the runtime. The captured-vs-current comparison
|
|
418
|
+
# uses `is`, not equality, because tasks have no meaningful
|
|
419
|
+
# __eq__ — identity is the right relation.
|
|
420
|
+
current_task = asyncio.current_task()
|
|
421
|
+
# getattr-with-default rather than direct access: adapters in the
|
|
422
|
+
# wild (or freshly-constructed fakes in tests) may not have set
|
|
423
|
+
# the attribute yet. Treat absence as "not in a transaction".
|
|
424
|
+
if getattr(self._db, "_in_transaction", False):
|
|
425
|
+
# Already in a transaction → SAVEPOINT path, but first verify
|
|
426
|
+
# we're in the same task that opened the outer transaction.
|
|
427
|
+
# If `owner` is None (older code populated _in_transaction
|
|
428
|
+
# without going through cygnet.transaction), be permissive:
|
|
429
|
+
# the user has opted out of the guard by managing transactions
|
|
430
|
+
# externally and we have no signal to compare against.
|
|
431
|
+
owner = getattr(self._db, "_transaction_task", None)
|
|
432
|
+
if (
|
|
433
|
+
owner is not None
|
|
434
|
+
and current_task is not None
|
|
435
|
+
and owner is not current_task
|
|
436
|
+
):
|
|
437
|
+
raise RuntimeError(
|
|
438
|
+
"cygnet.transaction: nested entry from a different "
|
|
439
|
+
"asyncio task than the one that opened the outer "
|
|
440
|
+
"transaction — db adapters are not task-safe. Use "
|
|
441
|
+
"one db adapter (and connection) per task, or "
|
|
442
|
+
"serialise transactional access across tasks."
|
|
443
|
+
)
|
|
444
|
+
# id(self) produces a unique name per context manager instance,
|
|
445
|
+
# avoiding collisions even with deeply nested savepoints. The
|
|
446
|
+
# id is stable for the lifetime of `self`, which covers enter
|
|
447
|
+
# through exit — we need the same name in both halves to issue
|
|
448
|
+
# the matching RELEASE / ROLLBACK TO SAVEPOINT.
|
|
449
|
+
self._savepoint = f"sp_{id(self)}"
|
|
450
|
+
await self._db.execute(f"SAVEPOINT {self._savepoint}")
|
|
451
|
+
else:
|
|
452
|
+
# Outermost layer: open a real transaction and claim the flag.
|
|
453
|
+
# Ordering matters: BEGIN is sent first so a failing execute()
|
|
454
|
+
# leaves the flag at False and the next transaction() call
|
|
455
|
+
# opens cleanly instead of trying to SAVEPOINT against nothing.
|
|
456
|
+
await self._db.execute("BEGIN")
|
|
457
|
+
self._db._in_transaction = True
|
|
458
|
+
# Claim the task identity for the duration of the outermost
|
|
459
|
+
# transaction. Stored on the db (not self) because the
|
|
460
|
+
# `_in_transaction` state is on the db — keeping ownership
|
|
461
|
+
# next to the state it protects.
|
|
462
|
+
self._db._transaction_task = current_task
|
|
463
|
+
# Returns the original db, not self — the caller uses the same
|
|
464
|
+
# connection handle for queries inside the block.
|
|
465
|
+
return self._db
|
|
466
|
+
|
|
467
|
+
async def __aexit__(
|
|
468
|
+
self,
|
|
469
|
+
exc_type: type | None,
|
|
470
|
+
exc_val: BaseException | None,
|
|
471
|
+
exc_tb: Any,
|
|
472
|
+
) -> None:
|
|
473
|
+
# Branch on "did __aenter__ open a savepoint?" rather than re-reading
|
|
474
|
+
# _in_transaction: the flag is True for both inner and outer layers
|
|
475
|
+
# while a nested transaction is active, so it can't distinguish them.
|
|
476
|
+
if self._savepoint:
|
|
477
|
+
# Nested layer: leave _in_transaction alone — the outermost
|
|
478
|
+
# context manager owns it. We do NOT swallow exc_type; returning
|
|
479
|
+
# None re-raises it after the savepoint is dealt with.
|
|
480
|
+
if exc_type:
|
|
481
|
+
# Roll the savepoint back, then RELEASE it (S33). ROLLBACK TO
|
|
482
|
+
# SAVEPOINT leaves the savepoint defined in PostgreSQL, so
|
|
483
|
+
# without the RELEASE it lingers on the savepoint stack for the
|
|
484
|
+
# rest of the outer transaction whenever the outer block
|
|
485
|
+
# catches the inner exception and continues. Both run inside a
|
|
486
|
+
# try so a failing savepoint command chains the original
|
|
487
|
+
# exception (B8) rather than silently replacing it: with no
|
|
488
|
+
# explicit `from`, the new error would propagate with only an
|
|
489
|
+
# implicit __context__, losing the user's real error as the
|
|
490
|
+
# __cause__ that tooling and `raise ... from` semantics expect.
|
|
491
|
+
try:
|
|
492
|
+
await self._db.execute(f"ROLLBACK TO SAVEPOINT {self._savepoint}")
|
|
493
|
+
await self._db.execute(f"RELEASE SAVEPOINT {self._savepoint}")
|
|
494
|
+
except Exception as savepoint_err:
|
|
495
|
+
raise savepoint_err from exc_val
|
|
496
|
+
else:
|
|
497
|
+
await self._db.execute(f"RELEASE SAVEPOINT {self._savepoint}")
|
|
498
|
+
else:
|
|
499
|
+
# The outermost context manager owns _in_transaction; reset it
|
|
500
|
+
# in a `finally` so a failure inside ROLLBACK/COMMIT itself
|
|
501
|
+
# doesn't strand the flag at True and silently turn the next
|
|
502
|
+
# transaction on this connection into a SAVEPOINT against a
|
|
503
|
+
# server-side-nonexistent transaction. The task-ownership
|
|
504
|
+
# claim is paired with the flag and cleared in the same
|
|
505
|
+
# finally block — sequential cross-task reuse needs both
|
|
506
|
+
# gone, not just _in_transaction.
|
|
507
|
+
try:
|
|
508
|
+
if exc_type:
|
|
509
|
+
# On the error path, a failing ROLLBACK must not silently
|
|
510
|
+
# replace the user's original exception (B8): chain it so
|
|
511
|
+
# the real error survives as __cause__. When ROLLBACK
|
|
512
|
+
# succeeds, returning None re-raises exc_val normally.
|
|
513
|
+
try:
|
|
514
|
+
await self._db.execute("ROLLBACK")
|
|
515
|
+
except Exception as rollback_err:
|
|
516
|
+
raise rollback_err from exc_val
|
|
517
|
+
else:
|
|
518
|
+
await self._db.execute("COMMIT")
|
|
519
|
+
finally:
|
|
520
|
+
self._db._in_transaction = False
|
|
521
|
+
self._db._transaction_task = None
|
cygnet/annotations.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# annotations.py — Marker types carried inside Annotated[] type hints.
|
|
2
|
+
#
|
|
3
|
+
# These are passive metadata: they don't alter the dataclass at decoration
|
|
4
|
+
# time. Instead, meta.py reads them back via get_type_hints(include_extras=True)
|
|
5
|
+
# during introspection. Keeping them as plain frozen dataclasses (rather than
|
|
6
|
+
# enums or strings) lets us use isinstance() checks unambiguously, even when
|
|
7
|
+
# multiple annotation objects coexist in the same Annotated[] bracket.
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Frozen so the two module-level singletons below (DBKey / AppKey) are
|
|
16
|
+
# hashable and safely shared across all dataclass annotations without
|
|
17
|
+
# mutation hazards. The class itself is intentionally underscore-prefixed:
|
|
18
|
+
# users never construct one directly — they reference the DBKey / AppKey
|
|
19
|
+
# singletons. Two PK fields on the same model is a configuration error,
|
|
20
|
+
# but a misconfigured user reusing the same singleton across fields would
|
|
21
|
+
# otherwise quietly succeed; meta._introspect's "more than one primary
|
|
22
|
+
# key" check catches that case.
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _PrimaryKey:
|
|
25
|
+
# "db" means the database assigns the value (e.g., SERIAL / IDENTITY);
|
|
26
|
+
# "app" means the application must supply it before INSERT.
|
|
27
|
+
# This distinction drives two behaviours in executor.py:
|
|
28
|
+
# 1. DBKey fields with value None are omitted from INSERT column lists
|
|
29
|
+
# and the generated SQL includes RETURNING <pk>.
|
|
30
|
+
# 2. AppKey fields with value None raise at insert time — there's no
|
|
31
|
+
# server-side default to fall back on.
|
|
32
|
+
assigned_by: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Module-level singletons. Because _PrimaryKey is frozen, these are
|
|
36
|
+
# compared by value (==) in meta.py and executor.py, not by identity.
|
|
37
|
+
DBKey = _PrimaryKey(assigned_by="db")
|
|
38
|
+
AppKey = _PrimaryKey(assigned_by="app")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Column-name override carrier. Frozen+hashable so the same _Column
|
|
42
|
+
# instance could in principle be shared, though in practice every
|
|
43
|
+
# Column("…") call produces a fresh instance — sharing has no measurable
|
|
44
|
+
# benefit and reads worse at the use site.
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _Column:
|
|
47
|
+
# When None, the Python attribute name is used as the column name.
|
|
48
|
+
name: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def Column(name: str) -> _Column: # noqa: N802
|
|
52
|
+
# Factory function so users write Column("col") rather than _Column("col").
|
|
53
|
+
# The leading underscore on the class signals that it's internal; only
|
|
54
|
+
# this function and the two PK singletons above are public API.
|
|
55
|
+
return _Column(name=name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class _ForeignKey:
|
|
60
|
+
# The target dataclass whose primary key this field references.
|
|
61
|
+
# Resolved lazily by meta.py via get_type_hints(include_extras=True),
|
|
62
|
+
# so forward references and circular imports are not a problem.
|
|
63
|
+
target: type
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ForeignKey(target: type) -> _ForeignKey: # noqa: N802
|
|
67
|
+
# Factory function matching the Column() / DBKey / AppKey pattern.
|
|
68
|
+
# Users write ForeignKey(Customer), not _ForeignKey(target=Customer).
|
|
69
|
+
return _ForeignKey(target=target)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def table(name: str) -> Any:
|
|
73
|
+
"""Class decorator: @cygnet.table("my_table_name")"""
|
|
74
|
+
|
|
75
|
+
# Stamps a dunder on the class that meta.py checks before falling back to
|
|
76
|
+
# the default naming convention (lowercase class name + "s"). The decorator
|
|
77
|
+
# returns the class unmodified — no wrapper, no metaclass — so dataclass
|
|
78
|
+
# semantics are fully preserved.
|
|
79
|
+
# Ordering note: @cygnet.table must be applied to a class that is already
|
|
80
|
+
# (or will be) a dataclass; the stamp survives @dataclass either way
|
|
81
|
+
# because @dataclass mutates the class in place rather than wrapping it.
|
|
82
|
+
def decorator(cls: Any) -> Any:
|
|
83
|
+
cls.__cygnet_table__ = name
|
|
84
|
+
return cls
|
|
85
|
+
|
|
86
|
+
return decorator
|