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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. 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