snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""The @snake_model decorator: it turns a class into a compiled model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar, dataclass_transform, overload
|
|
7
|
+
|
|
8
|
+
from snakeorm.compiler import compile_model
|
|
9
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
10
|
+
from snakeorm.core.exceptions import SnakeModelError
|
|
11
|
+
from snakeorm.core.sentinels import NOT_LOADED
|
|
12
|
+
from snakeorm.fields import (
|
|
13
|
+
SnakeColumn,
|
|
14
|
+
snake_auto,
|
|
15
|
+
snake_column,
|
|
16
|
+
snake_datetime,
|
|
17
|
+
snake_datetimetz,
|
|
18
|
+
snake_float,
|
|
19
|
+
snake_time,
|
|
20
|
+
snake_timetz,
|
|
21
|
+
snake_decimal,
|
|
22
|
+
snake_discriminator,
|
|
23
|
+
snake_enum,
|
|
24
|
+
snake_int,
|
|
25
|
+
snake_json,
|
|
26
|
+
snake_str,
|
|
27
|
+
snake_to_many,
|
|
28
|
+
snake_to_many_through,
|
|
29
|
+
snake_to_one,
|
|
30
|
+
)
|
|
31
|
+
from snakeorm.helpers.inheritance import collect_inherited
|
|
32
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
33
|
+
from snakeorm.decorators.polymorphic import guard_child_columns, resolve_polymorphic
|
|
34
|
+
from snakeorm.registry import SnakeRegistry
|
|
35
|
+
from snakeorm.registry import registry as default_registry
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_init(cls: type, discriminator: tuple[str, str] | None = None) -> Any:
|
|
41
|
+
"""Generate a keyword-only __init__ that assigns each column through its descriptor.
|
|
42
|
+
|
|
43
|
+
It is built by hand because @dataclass would take the descriptors as defaults. `discriminator`
|
|
44
|
+
is the `(column, value)` pair of a polymorphic subclass: the column is excluded from the
|
|
45
|
+
constructor (the CLASS decides its value, like an autoincrement) and gets filled in here. The
|
|
46
|
+
value travels in the closure, not in global state, because the source of truth is already
|
|
47
|
+
`table.polymorphic.value`.
|
|
48
|
+
"""
|
|
49
|
+
# It walks the MRO (base→child): the child's __init__ also demands/fills the columns inherited
|
|
50
|
+
# from an abstract base, not only its own.
|
|
51
|
+
columns = list(collect_inherited(cls, SnakeColumn).items())
|
|
52
|
+
discriminator_column = discriminator[0] if discriminator is not None else None
|
|
53
|
+
discriminator_value = discriminator[1] if discriminator is not None else None
|
|
54
|
+
|
|
55
|
+
def __init__(self: Any, **kwargs: Any) -> None:
|
|
56
|
+
for attr, descriptor in columns:
|
|
57
|
+
if descriptor.column_name == discriminator_column:
|
|
58
|
+
setattr(self, attr, discriminator_value)
|
|
59
|
+
continue
|
|
60
|
+
if descriptor.autoincrement or descriptor.has_server_default:
|
|
61
|
+
continue # excluded from the constructor (the DB supplies it); not an argument
|
|
62
|
+
if attr in kwargs:
|
|
63
|
+
setattr(self, attr, kwargs.pop(attr))
|
|
64
|
+
elif descriptor.has_default:
|
|
65
|
+
setattr(self, attr, descriptor.default)
|
|
66
|
+
elif descriptor.default_factory is not None:
|
|
67
|
+
setattr(
|
|
68
|
+
self, attr, descriptor.default_factory()
|
|
69
|
+
) # a fresh value per instance
|
|
70
|
+
else:
|
|
71
|
+
raise TypeError(f"{cls.__name__}() missing required argument: '{attr}'")
|
|
72
|
+
if kwargs:
|
|
73
|
+
raise TypeError(f"{cls.__name__}() unexpected arguments: {sorted(kwargs)}")
|
|
74
|
+
|
|
75
|
+
return __init__
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_UNSET = "<unassigned>"
|
|
79
|
+
_NOT_LOADED_LABEL = "<not loaded>"
|
|
80
|
+
"""What a column the query deliberately left out prints as. A repr must never raise."""
|
|
81
|
+
_MISSING = object()
|
|
82
|
+
"""Sentinel for "the storage key is not there at all", which is not the same as "left out"."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _install_dunders(cls: type, reg: SnakeRegistry) -> None:
|
|
86
|
+
"""Install `__repr__`, `__eq__` and `__hash__` by reading the PK off the already compiled GRAPH.
|
|
87
|
+
|
|
88
|
+
Equality goes by PRIMARY KEY, not by value: two objects of the same row are the same row even
|
|
89
|
+
if one of them is stale. The columns come from `SnakePrimaryKeyInfo.columns` (not from a magic
|
|
90
|
+
`pk`), so a composite PK works with the SAME code.
|
|
91
|
+
"""
|
|
92
|
+
table = reg.table_of(cls)
|
|
93
|
+
if table is None: # pragma: no cover - defensive: the decorator just registered it
|
|
94
|
+
return
|
|
95
|
+
column_attrs = tuple(column.attr_name or column.name for column in table.columns)
|
|
96
|
+
pk_attrs = tuple(
|
|
97
|
+
column.attr_name or column.name for column in table.primary_key.columns
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _pk_values(instance: object) -> tuple[object, ...] | None:
|
|
101
|
+
"""The PK values, or None if there is no row identity to read.
|
|
102
|
+
|
|
103
|
+
It returns None in two cases: a table WITHOUT a PK (a `@snake_db_first` mirror, which falls
|
|
104
|
+
back to identity) and a table WITH a PK not yet set (an object that has not been inserted).
|
|
105
|
+
The first case is the critical one: without it, an empty `pk_attrs` would return `()` and
|
|
106
|
+
any two rows would compare equal → data loss inside a `set`.
|
|
107
|
+
"""
|
|
108
|
+
if not pk_attrs:
|
|
109
|
+
return None
|
|
110
|
+
values: list[object] = []
|
|
111
|
+
for attr in pk_attrs:
|
|
112
|
+
if not hasattr(instance, f"__snake_{attr}"):
|
|
113
|
+
return None
|
|
114
|
+
values.append(getattr(instance, attr))
|
|
115
|
+
return tuple(values)
|
|
116
|
+
|
|
117
|
+
def _printable(instance: object, attr: str) -> str:
|
|
118
|
+
"""One field of the repr. THREE states, the same three the descriptor tells apart.
|
|
119
|
+
|
|
120
|
+
Reading an unloaded column RAISES, which is the feature, and `__repr__` used to inherit that
|
|
121
|
+
raise: printing a narrowed row killed the inspection it was part of. So the sentinel is read
|
|
122
|
+
off the storage key here rather than through the descriptor.
|
|
123
|
+
"""
|
|
124
|
+
stored = getattr(instance, f"__snake_{attr}", _MISSING)
|
|
125
|
+
if stored is _MISSING:
|
|
126
|
+
# A half-built object is STILL printable (useful right before the INSERT).
|
|
127
|
+
return f"{attr}={_UNSET}"
|
|
128
|
+
if stored is NOT_LOADED:
|
|
129
|
+
return f"{attr}={_NOT_LOADED_LABEL}"
|
|
130
|
+
return f"{attr}={getattr(instance, attr)!r}"
|
|
131
|
+
|
|
132
|
+
def __repr__(self: object) -> str:
|
|
133
|
+
fields = ", ".join(_printable(self, attr) for attr in column_attrs)
|
|
134
|
+
return f"{type(self).__name__}({fields})"
|
|
135
|
+
|
|
136
|
+
def __eq__(self: object, other: object) -> bool:
|
|
137
|
+
if type(self) is not type(other):
|
|
138
|
+
return NotImplemented
|
|
139
|
+
mine, theirs = _pk_values(self), _pk_values(other)
|
|
140
|
+
if mine is None or theirs is None:
|
|
141
|
+
# Without a PK there is no row identity to compare: it falls back to object identity.
|
|
142
|
+
return self is other
|
|
143
|
+
return mine == theirs
|
|
144
|
+
|
|
145
|
+
def __hash__(self: object) -> int:
|
|
146
|
+
# A table WITHOUT a PK (a legacy mirror): hashable by IDENTITY; nothing the INSERT fills in
|
|
147
|
+
# later, so the hash cannot mutate.
|
|
148
|
+
if not pk_attrs:
|
|
149
|
+
return object.__hash__(self)
|
|
150
|
+
values = _pk_values(self)
|
|
151
|
+
if values is None:
|
|
152
|
+
raise TypeError(
|
|
153
|
+
f"{type(self).__name__} is not hashable until it has a primary key: its hash "
|
|
154
|
+
f"would come from the PK, and the INSERT would fill it in afterwards MUTATING the "
|
|
155
|
+
f"hash inside whatever set or dict it was sitting in. Insert it first."
|
|
156
|
+
)
|
|
157
|
+
return hash((type(self).__name__, values))
|
|
158
|
+
|
|
159
|
+
for name, value in (
|
|
160
|
+
("__repr__", __repr__),
|
|
161
|
+
("__eq__", __eq__),
|
|
162
|
+
("__hash__", __hash__),
|
|
163
|
+
):
|
|
164
|
+
setattr(cls, name, value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@overload
|
|
168
|
+
def snake_model(cls: type[T]) -> type[T]: ...
|
|
169
|
+
@overload
|
|
170
|
+
def snake_model(
|
|
171
|
+
*,
|
|
172
|
+
table: str | None = ...,
|
|
173
|
+
prefix: str | None = ...,
|
|
174
|
+
schema: str = ...,
|
|
175
|
+
database: str = ...,
|
|
176
|
+
discriminator_value: str | None = ...,
|
|
177
|
+
registry: SnakeRegistry = ...,
|
|
178
|
+
) -> Callable[[type[T]], type[T]]: ...
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# A LITERAL tuple as PEP 681 demands; tied to SNAKE_FIELD_SPECIFIERS by a test.
|
|
182
|
+
@dataclass_transform(
|
|
183
|
+
kw_only_default=True,
|
|
184
|
+
field_specifiers=(
|
|
185
|
+
snake_column,
|
|
186
|
+
snake_auto,
|
|
187
|
+
snake_enum,
|
|
188
|
+
snake_int,
|
|
189
|
+
snake_str,
|
|
190
|
+
snake_decimal,
|
|
191
|
+
snake_datetime,
|
|
192
|
+
snake_datetimetz,
|
|
193
|
+
snake_float,
|
|
194
|
+
snake_time,
|
|
195
|
+
snake_timetz,
|
|
196
|
+
snake_json,
|
|
197
|
+
snake_to_one,
|
|
198
|
+
snake_to_many,
|
|
199
|
+
snake_to_many_through,
|
|
200
|
+
snake_discriminator,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
def snake_model(
|
|
204
|
+
cls: type[T] | None = None,
|
|
205
|
+
*,
|
|
206
|
+
table: str | None = None,
|
|
207
|
+
prefix: str | None = None,
|
|
208
|
+
schema: str = DEFAULT_SCHEMA,
|
|
209
|
+
database: str = "default",
|
|
210
|
+
discriminator_value: str | None = None,
|
|
211
|
+
registry: SnakeRegistry = default_registry,
|
|
212
|
+
) -> Any:
|
|
213
|
+
"""Compile the model, store its SnakeTableInfo and install the __init__ at runtime.
|
|
214
|
+
|
|
215
|
+
It is used bare (`@snake_model`) or parametric. The table name is `{prefix}_{table}`: `table=`
|
|
216
|
+
changes only the table (keeping the prefix), `prefix=` changes the namespace.
|
|
217
|
+
|
|
218
|
+
A `snake_discriminator()` column opens a POLYMORPHIC hierarchy: the whole family shares this
|
|
219
|
+
table and that column says what each row is. The children inherit it in Python and declare
|
|
220
|
+
their `discriminator_value=`; they do not pick a table, because there is none to pick.
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
@snake_model(table="animals")
|
|
224
|
+
class Animal(SnakeModel):
|
|
225
|
+
id: SnakeColumn[int] = snake_auto()
|
|
226
|
+
kind: SnakeColumn[str] = snake_discriminator()
|
|
227
|
+
|
|
228
|
+
@snake_model(discriminator_value="dog")
|
|
229
|
+
class Dog(Animal):
|
|
230
|
+
breed: SnakeColumn[str | None] = snake_str()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
There is no `inherits=Animal`: Python inheritance ALREADY says who the base is (it avoids a
|
|
234
|
+
second source).
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def wrap(klass: type[T]) -> type[T]:
|
|
238
|
+
placement = resolve_polymorphic(klass, discriminator_value, registry)
|
|
239
|
+
compiled = compile_model(
|
|
240
|
+
klass,
|
|
241
|
+
# A polymorphic child does NOT pick a table: it shares its base's, and name, schema
|
|
242
|
+
# and connection all come from there.
|
|
243
|
+
table=placement.table if placement.table is not None else table,
|
|
244
|
+
prefix=None if placement.table is not None else prefix,
|
|
245
|
+
schema=placement.schema if placement.schema is not None else schema,
|
|
246
|
+
database=placement.database if placement.database is not None else database,
|
|
247
|
+
polymorphic=placement.info,
|
|
248
|
+
)
|
|
249
|
+
# The guard operates on the ALREADY compiled table (without recompiling).
|
|
250
|
+
guard_child_columns(klass, compiled, placement)
|
|
251
|
+
registry.register(klass, compiled)
|
|
252
|
+
# The model remembers WHICH registry it lives in: typed navigation resolves relations
|
|
253
|
+
# against THAT registry, not the global one. Without this, `@snake_model(registry=reg)`
|
|
254
|
+
# broke `User.car.brand`.
|
|
255
|
+
setattr(klass, "__snake_registry__", registry)
|
|
256
|
+
setattr(klass, "__init__", _make_init(klass, placement.constructor_fills()))
|
|
257
|
+
_install_dunders(klass, registry)
|
|
258
|
+
return klass
|
|
259
|
+
|
|
260
|
+
return wrap if cls is None else wrap(cls)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def snake_table(cls: type, reg: SnakeRegistry = default_registry) -> SnakeTableInfo:
|
|
264
|
+
"""Return the compiled SnakeTableInfo of a @snake_model model."""
|
|
265
|
+
table = reg.table_of(cls)
|
|
266
|
+
if table is None:
|
|
267
|
+
raise SnakeModelError(f"{cls.__name__} is not a @snake_model.")
|
|
268
|
+
return table
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Resolving a polymorphic hierarchy: who the base is, which table they share and what is banned.
|
|
2
|
+
|
|
3
|
+
All the judgement about polymorphic inheritance lives HERE (not scattered across the decorator, the
|
|
4
|
+
compiler and the linker) so that it cannot decide differently in each place. Python's MRO says who
|
|
5
|
+
the base is; there is no `inherits=Animal` because that would be a second source of truth next to
|
|
6
|
+
`class Dog(Animal)`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from snakeorm.core.exceptions import SnakeModelDefinitionError
|
|
14
|
+
from snakeorm.metadata import SnakePolymorphicInfo, SnakeTableInfo
|
|
15
|
+
from snakeorm.registry import SnakeRegistry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class PolymorphicPlacement:
|
|
20
|
+
"""Where a class of the hierarchy lives: its inherited table, its role and its base.
|
|
21
|
+
|
|
22
|
+
`table`/`schema`/`database` are `None` when the class is NOT a polymorphic child: "I impose
|
|
23
|
+
nothing, the decorator decides with its usual parameters".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
info: SnakePolymorphicInfo | None = None
|
|
27
|
+
table: str | None = None
|
|
28
|
+
schema: str | None = None
|
|
29
|
+
database: str | None = None
|
|
30
|
+
base: type | None = None
|
|
31
|
+
inherited: frozenset[str] = frozenset()
|
|
32
|
+
"""Column names the child inherits from the base, so we know which ones are ITS OWN.
|
|
33
|
+
|
|
34
|
+
They get captured here because whoever resolves the hierarchy already had the base's table in
|
|
35
|
+
hand.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def constructor_fills(self) -> tuple[str, str] | None:
|
|
39
|
+
"""The `(column, value)` pair the `__init__` must fill in by itself. `None` if it does not
|
|
40
|
+
apply.
|
|
41
|
+
|
|
42
|
+
It lives here, not in the decorator, so it is not recomposed from two loose fields and put
|
|
43
|
+
at risk of getting it wrong.
|
|
44
|
+
"""
|
|
45
|
+
if self.info is None or self.info.value is None:
|
|
46
|
+
return None
|
|
47
|
+
return (self.info.column, self.info.value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discriminator_column(cls: type) -> str | None:
|
|
51
|
+
"""The SQL name of the column marked with `snake_discriminator()`, if there is one.
|
|
52
|
+
|
|
53
|
+
It is read from the MODEL (there is no `discriminator="kind"` in the decorator): it avoids a
|
|
54
|
+
second source, and only the field specifier can carry the `init: Literal[False]` that lines
|
|
55
|
+
mypy/pyright up with the runtime.
|
|
56
|
+
"""
|
|
57
|
+
from snakeorm.fields import SnakeColumn
|
|
58
|
+
from snakeorm.helpers.inheritance import collect_inherited
|
|
59
|
+
|
|
60
|
+
for descriptor in collect_inherited(cls, SnakeColumn).values():
|
|
61
|
+
if descriptor.is_discriminator:
|
|
62
|
+
return descriptor.column_name
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_polymorphic(
|
|
67
|
+
cls: type,
|
|
68
|
+
discriminator_value: str | None,
|
|
69
|
+
registry: SnakeRegistry,
|
|
70
|
+
) -> PolymorphicPlacement:
|
|
71
|
+
"""Decide this class's role in a hierarchy, or that it is in none.
|
|
72
|
+
|
|
73
|
+
1. Without `snake_discriminator()` nor `discriminator_value` → there is no hierarchy.
|
|
74
|
+
2. With its own `snake_discriminator()` → it is the BASE. The table is its own.
|
|
75
|
+
3. With `discriminator_value` → it is a CHILD. It looks the base up in the MRO and inherits its
|
|
76
|
+
whole table.
|
|
77
|
+
"""
|
|
78
|
+
if discriminator_value is None:
|
|
79
|
+
column = discriminator_column(cls)
|
|
80
|
+
if column is None:
|
|
81
|
+
return PolymorphicPlacement()
|
|
82
|
+
return PolymorphicPlacement(info=SnakePolymorphicInfo(column=column))
|
|
83
|
+
|
|
84
|
+
base, base_table = _find_base(cls, registry)
|
|
85
|
+
assert base_table.polymorphic is not None # _find_base guarantees it
|
|
86
|
+
return PolymorphicPlacement(
|
|
87
|
+
info=SnakePolymorphicInfo(
|
|
88
|
+
column=base_table.polymorphic.column, value=discriminator_value
|
|
89
|
+
),
|
|
90
|
+
table=base_table.name,
|
|
91
|
+
schema=base_table.schema,
|
|
92
|
+
database=base_table.database,
|
|
93
|
+
base=base,
|
|
94
|
+
inherited=frozenset(column.name for column in base_table.columns),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _find_base(cls: type, registry: SnakeRegistry) -> tuple[type, SnakeTableInfo]:
|
|
99
|
+
"""The class of the hierarchy, looked up in the MRO. The FIRST one that opened one wins.
|
|
100
|
+
|
|
101
|
+
It walks the class's MRO upwards. In a deep hierarchy the nearest ancestor wins, but its
|
|
102
|
+
`polymorphic.column` is the same as the root's: the answer does not depend on the level, so a
|
|
103
|
+
deep hierarchy is not a special case.
|
|
104
|
+
"""
|
|
105
|
+
for ancestor in cls.__mro__[1:]:
|
|
106
|
+
table = registry.table_of(ancestor)
|
|
107
|
+
if table is not None and table.polymorphic is not None:
|
|
108
|
+
return ancestor, table
|
|
109
|
+
raise SnakeModelDefinitionError(
|
|
110
|
+
f"{cls.__name__} declares `discriminator_value`, but none of its base classes opens a "
|
|
111
|
+
f"polymorphic hierarchy. Add `discriminator='<column>'` to the `@snake_model` of the base, "
|
|
112
|
+
f"and make sure {cls.__name__} inherits from it in Python."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def guard_child_columns(
|
|
117
|
+
cls: type, compiled: SnakeTableInfo, placement: PolymorphicPlacement
|
|
118
|
+
) -> None:
|
|
119
|
+
"""A child's OWN columns have to be nullable.
|
|
120
|
+
|
|
121
|
+
The whole hierarchy shares ONE table, so `Dog`'s `breed` column also exists in `Cat`'s rows,
|
|
122
|
+
where there is no value: a `NOT NULL` would make inserting a cat impossible. It gets checked at
|
|
123
|
+
declaration time (fixable with `| None`), not when inserting in production. It operates on the
|
|
124
|
+
ALREADY compiled table, without recompiling.
|
|
125
|
+
"""
|
|
126
|
+
if placement.base is None: # not a polymorphic child: there is nothing to check
|
|
127
|
+
return
|
|
128
|
+
not_nullable = [
|
|
129
|
+
column.name
|
|
130
|
+
for column in compiled.columns
|
|
131
|
+
if column.name not in placement.inherited and not column.nullable
|
|
132
|
+
]
|
|
133
|
+
if not_nullable:
|
|
134
|
+
raise SnakeModelDefinitionError(
|
|
135
|
+
f"The columns that belong to {cls.__name__} itself have to accept NULL, and "
|
|
136
|
+
f"{not_nullable} do not. The whole hierarchy shares the table of "
|
|
137
|
+
f"{placement.base.__name__}, so those "
|
|
138
|
+
f"columns also exist in the rows of its siblings, where there is nothing to put in "
|
|
139
|
+
f"them. Declare them as `SnakeColumn[T | None]`."
|
|
140
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The @snake_result decorator: a TYPED result container for `session.annotate()`.
|
|
2
|
+
|
|
3
|
+
You declare a class that inherits from `SnakeResult[Model]`: EXACTLY one field is the
|
|
4
|
+
`@snake_model` model (the base row, = the generic parameter) and the rest are scalars.
|
|
5
|
+
`@dataclass_transform` types its `__init__`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
@snake_result
|
|
9
|
+
class RealmStats(SnakeResult[Realm]):
|
|
10
|
+
realm: Realm # the base row (a @snake_model), matching the SnakeResult[Realm] parameter
|
|
11
|
+
forge_count: int # an aggregate
|
|
12
|
+
avg_size: float
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Inheriting from `SnakeResult[Model]` serves to (a) reject in the checker a class that does not
|
|
16
|
+
inherit and give `annotate` the concrete return type (`list[RealmStats]`), and (b) validate at
|
|
17
|
+
runtime that the generic matches the base field and that the query is over that model.
|
|
18
|
+
|
|
19
|
+
A typing NOTE: the STATIC tie between query and base model (catching
|
|
20
|
+
`annotate(query_of_AnotherModel, RealmStats)`) is NOT expressible under `annotate(query, result)`:
|
|
21
|
+
it would demand a dependent bound `R <: SnakeResult[T]`, and `TypeVar` bounds cannot be generic
|
|
22
|
+
(mypy and pyright both reject it). That is why that mismatch stays as a runtime validation
|
|
23
|
+
(`SnakeEmitError`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import (
|
|
30
|
+
Generic,
|
|
31
|
+
TypeVar,
|
|
32
|
+
dataclass_transform,
|
|
33
|
+
get_args,
|
|
34
|
+
get_origin,
|
|
35
|
+
get_type_hints,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from snakeorm.helpers.annotations import unwrap_optional
|
|
39
|
+
from snakeorm.core.exceptions import SnakeModelDefinitionError
|
|
40
|
+
from snakeorm.registry import registry
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
TModel = TypeVar("TModel")
|
|
44
|
+
|
|
45
|
+
# The key where the compiled SnakeResultInfo is stored on the decorated class.
|
|
46
|
+
_RESULT_INFO_KEY = "__snake_result__"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SnakeResult(Generic[TModel]):
|
|
50
|
+
"""Generic base of a `@snake_result` container; the parameter is the base model (the row).
|
|
51
|
+
|
|
52
|
+
A typing marker with no runtime (see the module note). `TModel` MUST match the base field
|
|
53
|
+
declared in the subclass (`@snake_result` verifies it).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class SnakeResultInfo:
|
|
61
|
+
"""Compiled metadata of a `@snake_result`: the base row and the scalars, in order.
|
|
62
|
+
|
|
63
|
+
`scalars` preserves the declaration order: the session projects the aggregates in that order
|
|
64
|
+
behind the columns of the base model.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
base_field: str
|
|
68
|
+
base_model: type
|
|
69
|
+
scalars: tuple[tuple[str, type], ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass_transform()
|
|
73
|
+
def snake_result(cls: type[T]) -> type[T]:
|
|
74
|
+
"""Compile the class into a typed result container and install its `__init__` (a dataclass).
|
|
75
|
+
|
|
76
|
+
The ONLY annotation that is a `@snake_model` is the base row; the rest are scalars. A scalar
|
|
77
|
+
`X | None` compiles to `X` (the correct declaration for SUM/AVG/MIN/MAX, NULL over zero rows;
|
|
78
|
+
the unwrapped type is the key of the coercion converter, and `coerce` does not touch nulls).
|
|
79
|
+
|
|
80
|
+
It fails with `SnakeModelDefinitionError` if: there are 0 or 2+ base models; it does not
|
|
81
|
+
inherit from `SnakeResult[Model]`; it inherits without parametrising; or the generic does not
|
|
82
|
+
match the base field.
|
|
83
|
+
"""
|
|
84
|
+
if not (isinstance(cls, type) and issubclass(cls, SnakeResult)):
|
|
85
|
+
raise SnakeModelDefinitionError(
|
|
86
|
+
f"{cls.__name__} does not inherit from SnakeResult[Model]: a @snake_result has to be "
|
|
87
|
+
f"declared as `class {cls.__name__}(SnakeResult[YourModel])` so that "
|
|
88
|
+
f"session.annotate(...) accepts it and returns a typed list."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
hints = get_type_hints(cls)
|
|
92
|
+
base_fields: list[tuple[str, type]] = []
|
|
93
|
+
scalars: list[tuple[str, type]] = []
|
|
94
|
+
for name, annotation in hints.items():
|
|
95
|
+
declared, _ = unwrap_optional(annotation)
|
|
96
|
+
if isinstance(declared, type) and registry.table_of(declared) is not None:
|
|
97
|
+
base_fields.append((name, declared))
|
|
98
|
+
else:
|
|
99
|
+
scalars.append((name, declared))
|
|
100
|
+
|
|
101
|
+
if len(base_fields) > 1:
|
|
102
|
+
names = ", ".join(name for name, _ in base_fields)
|
|
103
|
+
raise SnakeModelDefinitionError(
|
|
104
|
+
f"{cls.__name__} declares {len(base_fields)} base models ({names}); a @snake_result "
|
|
105
|
+
f"has to have EXACTLY one. The expected shape: one @snake_model field (the base row) "
|
|
106
|
+
f"and the rest scalars (int, float, str...)."
|
|
107
|
+
)
|
|
108
|
+
if not base_fields:
|
|
109
|
+
raise SnakeModelDefinitionError(
|
|
110
|
+
f"{cls.__name__} declares not a single base model: there is nothing to annotate. A "
|
|
111
|
+
f"@snake_result needs EXACTLY one @snake_model field (the base row) plus the "
|
|
112
|
+
f"scalars that session.annotate(...) will fill in."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
base_field, base_model = base_fields[0]
|
|
116
|
+
_validate_generic_parameter(cls, base_field, base_model)
|
|
117
|
+
info = SnakeResultInfo(
|
|
118
|
+
base_field=base_field, base_model=base_model, scalars=tuple(scalars)
|
|
119
|
+
)
|
|
120
|
+
result_cls = dataclass(cls)
|
|
121
|
+
setattr(result_cls, _RESULT_INFO_KEY, info)
|
|
122
|
+
return result_cls
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _validate_generic_parameter(cls: type, base_field: str, base_model: type) -> None:
|
|
126
|
+
"""Check that the subclass's `SnakeResult[Model]` matches the `@snake_model` base field.
|
|
127
|
+
|
|
128
|
+
It reads the generic off `__orig_bases__`. It fails if it was inherited without parametrising
|
|
129
|
+
or if the model is not the one of the base field: the static type and the runtime metadata
|
|
130
|
+
must tell the same story.
|
|
131
|
+
"""
|
|
132
|
+
declared = _declared_base_model(cls)
|
|
133
|
+
if declared is _UNPARAMETRIZED:
|
|
134
|
+
raise SnakeModelDefinitionError(
|
|
135
|
+
f"{cls.__name__} inherits SnakeResult without the base model; declare "
|
|
136
|
+
f"`SnakeResult[{base_model.__name__}]` so that the type matches field '{base_field}'."
|
|
137
|
+
)
|
|
138
|
+
if declared is not base_model:
|
|
139
|
+
declared_name = getattr(declared, "__name__", repr(declared))
|
|
140
|
+
raise SnakeModelDefinitionError(
|
|
141
|
+
f"{cls.__name__} inherits SnakeResult[{declared_name}], but its base field "
|
|
142
|
+
f"'{base_field}' is {base_model.__name__}: they do not match. The generic parameter "
|
|
143
|
+
f"has to be the SAME model as the base row."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A sentinel to tell "SnakeResult without parameters" apart from a parameter that does exist.
|
|
148
|
+
_UNPARAMETRIZED = object()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _declared_base_model(cls: type) -> object:
|
|
152
|
+
"""Model of the bases' `SnakeResult[Model]` parameter; `_UNPARAMETRIZED` if there is none."""
|
|
153
|
+
for base in getattr(cls, "__orig_bases__", ()):
|
|
154
|
+
if get_origin(base) is SnakeResult:
|
|
155
|
+
args = get_args(base)
|
|
156
|
+
return args[0] if args else _UNPARAMETRIZED
|
|
157
|
+
return _UNPARAMETRIZED
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def snake_result_info(cls: type) -> SnakeResultInfo:
|
|
161
|
+
"""Return the compiled SnakeResultInfo of a `@snake_result` class; fail if it is not one."""
|
|
162
|
+
info = getattr(cls, _RESULT_INFO_KEY, None)
|
|
163
|
+
if not isinstance(info, SnakeResultInfo):
|
|
164
|
+
raise SnakeModelDefinitionError(
|
|
165
|
+
f"{cls.__name__} is not a @snake_result: it cannot be used in session.annotate(...)."
|
|
166
|
+
)
|
|
167
|
+
return info
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The @snake_row decorator: a typed ROW container for `session.call(...)`.
|
|
2
|
+
|
|
3
|
+
A function/procedure is OPAQUE SQL: the ORM cannot verify what it returns. The contract is
|
|
4
|
+
DECLARED, not verified: the user declares the expected shape (a @snake_row whose fields are ALL
|
|
5
|
+
scalars) and the session hydrates each row into it (the same guarantee as reading a raw SELECT into
|
|
6
|
+
a dataclass).
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
@snake_row
|
|
10
|
+
class Payroll(SnakeRow):
|
|
11
|
+
employee_id: int
|
|
12
|
+
gross: Decimal
|
|
13
|
+
net: Decimal
|
|
14
|
+
|
|
15
|
+
rows: list[Payroll] = session.call("calculate_payroll", [1234], into=Payroll)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The difference with `@snake_result`: this one has NO base model, all its fields are scalars.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TypeVar, dataclass_transform, get_type_hints
|
|
25
|
+
|
|
26
|
+
from snakeorm.helpers.annotations import unwrap_optional
|
|
27
|
+
from snakeorm.core.exceptions import SnakeModelDefinitionError
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
# The key where the compiled SnakeRowInfo is stored on the decorated class.
|
|
32
|
+
_ROW_INFO_KEY = "__snake_row__"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SnakeRow:
|
|
36
|
+
"""Marker base for a `@snake_row`: a container of scalar rows, WITHOUT a base model.
|
|
37
|
+
|
|
38
|
+
A typing marker with no runtime: `session.call` bounds its `into` to `type[R]` with
|
|
39
|
+
`R: SnakeRow`, so a class that does not inherit from here is rejected IN THE CHECKER.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__slots__ = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class SnakeRowInfo:
|
|
47
|
+
"""Compiled metadata of a `@snake_row`: its columns (name + type), in order.
|
|
48
|
+
|
|
49
|
+
`columns` preserves the declaration order: `session.call` maps each row by POSITION (field
|
|
50
|
+
order == column order of the function).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
columns: tuple[tuple[str, type], ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass_transform()
|
|
57
|
+
def snake_row(cls: type[T]) -> type[T]:
|
|
58
|
+
"""Compile the class into a typed row container and install its `__init__` (a dataclass).
|
|
59
|
+
|
|
60
|
+
A field `X | None` compiles to `X` (the key of the coercion converter; the `None` survives
|
|
61
|
+
because `coerce` does not touch nulls). It fails with `SnakeModelDefinitionError` if it does
|
|
62
|
+
not inherit from `SnakeRow`.
|
|
63
|
+
"""
|
|
64
|
+
if not (isinstance(cls, type) and issubclass(cls, SnakeRow)):
|
|
65
|
+
raise SnakeModelDefinitionError(
|
|
66
|
+
f"{cls.__name__} does not inherit from SnakeRow: a @snake_row has to be declared as "
|
|
67
|
+
f"`class {cls.__name__}(SnakeRow)` so that session.call(...) accepts it and returns "
|
|
68
|
+
f"a typed list."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
hints = get_type_hints(cls)
|
|
72
|
+
columns: list[tuple[str, type]] = []
|
|
73
|
+
for name, annotation in hints.items():
|
|
74
|
+
declared, _ = unwrap_optional(annotation)
|
|
75
|
+
columns.append((name, declared))
|
|
76
|
+
|
|
77
|
+
info = SnakeRowInfo(columns=tuple(columns))
|
|
78
|
+
row_cls = dataclass(cls)
|
|
79
|
+
setattr(row_cls, _ROW_INFO_KEY, info)
|
|
80
|
+
return row_cls
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def snake_row_info(cls: type) -> SnakeRowInfo:
|
|
84
|
+
"""Return the compiled SnakeRowInfo of a `@snake_row` class; fail if it is not one."""
|
|
85
|
+
info = getattr(cls, _ROW_INFO_KEY, None)
|
|
86
|
+
if not isinstance(info, SnakeRowInfo):
|
|
87
|
+
raise SnakeModelDefinitionError(
|
|
88
|
+
f"{cls.__name__} is not a @snake_row: it cannot be used in session.call(...)."
|
|
89
|
+
)
|
|
90
|
+
return info
|