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,355 @@
1
+ """The helpers BOTH sessions use, in one module that neither of them owns.
2
+
3
+ They lived in `session.py` with a leading underscore, and `asyncsession.py` imported twelve of them
4
+ across the module boundary — three of those from inside functions, dodging a cycle that does not
5
+ exist (`rg asyncsession session/session.py` finds nothing). One of the lazy ones ran on every
6
+ `AsyncSession()`.
7
+
8
+ The underscore was API lying about being private: renaming `_pk_condition` would have broken the
9
+ other colour, and nothing said so. Here the names carry no underscore, because that is what they
10
+ are — the shared half of a seam whose whole doctrine is that the two colours consume the same thing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import warnings
16
+ from collections.abc import Sequence
17
+ from datetime import timedelta
18
+ from decimal import Decimal
19
+ from typing import Any, get_origin
20
+ from uuid import UUID
21
+
22
+ from snakeorm.core.exceptions import (
23
+ SnakeRegistryError,
24
+ SnakeUnsupportedFeature,
25
+ SnakeWarning,
26
+ )
27
+ from snakeorm.dialects import SnakeDialect
28
+ from snakeorm.dialects.capabilities import Cap
29
+ from snakeorm.drivers import SnakeDriver
30
+ from snakeorm.drivers.asyncbase import AsyncDriver
31
+ from snakeorm.expressions import SnakeAnd, SnakeCondition, SnakeExpr, SnakeValue
32
+ from snakeorm.fields import MISSING
33
+ from snakeorm.metadata import SnakeTableInfo
34
+ from snakeorm.query import SnakeCompound, SnakeRecursive
35
+ from snakeorm.registry import SnakeRegistry, registry, registry_of
36
+ from snakeorm.session.coercion import coerce
37
+ from snakeorm.session.guards import _guard_declared_limits, _guard_required_values
38
+ from snakeorm.times import SnakeUtc
39
+
40
+
41
+ def column_name(column: SnakeExpr[Any]) -> str:
42
+ """SQL name of a column of the bulk SET: it must be DIRECT (one hop). A navigation
43
+ (`User.maker.x`) would point at another table a plain UPDATE cannot reach."""
44
+ if len(column.path) != 1:
45
+ raise SnakeUnsupportedFeature(
46
+ "the SET of a bulk UPDATE uses direct columns of the table, not relationship "
47
+ "navigations."
48
+ )
49
+ return column.path[0]
50
+
51
+
52
+ def guard_set_value(value: object) -> None:
53
+ """Rejects a SET VALUE that navigates a relationship (a path of more than one hop).
54
+
55
+ A value like `Truck.maker.nation.id` would point at a joined table, impossible to assign without
56
+ a FROM. A literal or arithmetic over direct columns (`views + 1`) passes through untouched.
57
+ """
58
+ if isinstance(value, SnakeValue) and any(len(path) > 1 for path in value.paths()):
59
+ raise SnakeUnsupportedFeature(
60
+ "the value in the SET of a bulk UPDATE uses columns/arithmetic of the base table, "
61
+ "not relationship navigations (you cannot assign from a joined table without FROM)."
62
+ )
63
+
64
+
65
+ def guard_plain_query(query: object, method: str) -> None:
66
+ """Rejects a COMPOUND or a RECURSIVE where only a plain query fits.
67
+
68
+ `count`/`exists`/`select` rewrite the SELECT (they wrap it in `COUNT(*)`/`EXISTS` or change the
69
+ projection), and a set or a CTE do not get rewritten that way: they would have to be wrapped in a
70
+ subquery, which is not emitted yet. The guard lives in ONE place so as not to leave a sibling
71
+ throwing a different error.
72
+ """
73
+ if isinstance(query, (SnakeCompound, SnakeRecursive)):
74
+ raise SnakeUnsupportedFeature(
75
+ f"{method}() does not accept a UNION/EXCEPT/INTERSECT nor a WITH RECURSIVE: they "
76
+ f"would have to be wrapped in a subquery and that is not emitted yet. Fetch the rows "
77
+ f"with session.all(...) and resolve it in Python."
78
+ )
79
+
80
+
81
+ def table_of(instance: object) -> SnakeTableInfo:
82
+ """Resolves an instance's SnakeTableInfo; it fails if its class is not a @snake_model.
83
+
84
+ It reinforces the read-only lock at RUNTIME (if it is a VIEW, it rejects the write): the main
85
+ lock is one of TYPES, but this covers whoever switches the checker off or arrives via `Any`.
86
+ """
87
+ table = registry_of(type(instance)).table_of(type(instance))
88
+ if table is None:
89
+ raise SnakeRegistryError(
90
+ f"{type(instance).__name__} is not registered: is it missing @snake_model?"
91
+ )
92
+ if table.is_view:
93
+ raise SnakeUnsupportedFeature(
94
+ f"'{table.name}' is a READ-ONLY view: it does not accept add/update/delete/upsert. "
95
+ f"Creating and editing it live in the migrations (CreateView/AlterView/DropView)."
96
+ )
97
+ return table
98
+
99
+
100
+ def table_with_pk(instance: object) -> SnakeTableInfo:
101
+ """Like `table_of`, but it demands that the table know how to identify ONE row (for
102
+ `refresh`/`update`/`delete`, which work by PK).
103
+
104
+ A table with no PK cannot name a row; without this guard the condition came out EMPTY and the
105
+ `WHERE ()` was rejected by Postgres with an opaque error. Inserting is still allowed.
106
+ """
107
+ table = table_of(instance)
108
+ if not table.primary_key.columns:
109
+ raise SnakeUnsupportedFeature(
110
+ f"'{table.name}' has no primary key, so a specific row cannot be identified: "
111
+ f"refresh/update/delete are out. To write into it use add(), and to modify several "
112
+ f"rows at once update_where()/delete_where() with a filter of your own."
113
+ )
114
+ return table
115
+
116
+
117
+ def insert_values(instance: object, table: SnakeTableInfo) -> dict[str, object]:
118
+ """Columns to insert: the ones that have a value (a MISSING, e.g. an autoincrementing PK, is
119
+ OMITTED so the server fills it in). It maps by SQL column name.
120
+
121
+ The raw dict is collected BEFORE filtering because the guard needs to see the `MISSING`es:
122
+ omitting one is the normal thing, except on a column nobody else is going to fill in — and that
123
+ is the one that had to be shouted about.
124
+ """
125
+ raw = {
126
+ column.name: getattr(instance, column.attr_name or column.name)
127
+ for column in table.columns
128
+ }
129
+ _guard_required_values(table, raw)
130
+ values = {name: value for name, value in raw.items() if value is not MISSING}
131
+ _guard_declared_limits(table, values)
132
+ return values
133
+
134
+
135
+ _warned_bulk_keys: set[str] = set()
136
+ """Engines already warned that their `add_all` does not give the PKs back. ONCE per process.
137
+
138
+ A seeding run does dozens of `add_all`s; one warning per batch is noise, and the noise ends up in a
139
+ `filterwarnings("ignore")` that also takes down the warnings that did matter.
140
+ """
141
+
142
+
143
+ def warn_bulk_loses_generated_keys(
144
+ dialect: SnakeDialect, table: SnakeTableInfo
145
+ ) -> None:
146
+ """Warns that on this engine an `add_all` leaves the autoincrementing PKs UNfilled.
147
+
148
+ It warns rather than forbids: inserting in bulk without needing the ids is legitimate and is the
149
+ majority case. What cannot happen is that whoever DOES need them fails to find out — because the
150
+ id that never comes back ends up being the foreign key of the next row, and by the time something
151
+ fails, the error is given by the engine over a table that has nothing to do with it.
152
+
153
+ `add()` does recover them, with `last_insert_id`. What cannot be done is deducing them for a
154
+ batch: `last_insert_id` speaks of ONE row, and whether the ids of a multi-row INSERT are
155
+ consecutive depends on the server's configuration (`innodb_autoinc_lock_mode`), not on the ORM.
156
+ Guessing there would be writing somebody else's keys in silence, which is worse than not filling
157
+ them in.
158
+ """
159
+ if dialect.supports_returning:
160
+ return
161
+ if not any(column.autoincrement for column in table.primary_key.columns):
162
+ return
163
+ engine = type(dialect).__name__
164
+ if engine in _warned_bulk_keys:
165
+ return
166
+ _warned_bulk_keys.add(engine)
167
+ warnings.warn(
168
+ f"{engine}: add_all() does not fill in the instances' autoincrement id, because this engine "
169
+ f"has no RETURNING and last_insert_id speaks of ONE row only. The rows ARE INSERTED; what is "
170
+ f"left without a value is the `id` in memory. If you need that id afterwards (say, as another "
171
+ f"row's foreign key), use add() per instance. Silence this warning with "
172
+ f"warnings.filterwarnings('ignore', category=SnakeWarning).",
173
+ SnakeWarning,
174
+ stacklevel=3,
175
+ )
176
+
177
+
178
+ def apply_last_insert_id(
179
+ instance: object, table: SnakeTableInfo, driver: SnakeDriver | AsyncDriver
180
+ ) -> None:
181
+ """Fills the autoincrementing PK in from `last_insert_id`, on engines WITHOUT RETURNING (MySQL).
182
+
183
+ The counterpart of `apply_returned`: MySQL only returns the generated id, via `lastrowid`. Only
184
+ a SIMPLE autoincrementing PK and only if it did not arrive set by hand (an explicit PK is not
185
+ clobbered).
186
+
187
+ It accepts BOTH drivers because `last_insert_id` does not travel to the database — the cursor of
188
+ the last write stored it — so it is not `async` in either of the two Protocols and this body
189
+ works the same. Having it typed only to the synchronous one is what left the asynchronous session
190
+ never calling it.
191
+ """
192
+ pk_autoinc = [
193
+ column for column in table.primary_key.columns if column.autoincrement
194
+ ]
195
+ if len(pk_autoinc) != 1:
196
+ return
197
+ column = pk_autoinc[0]
198
+ attr = column.attr_name or column.name
199
+ if getattr(instance, attr) is MISSING:
200
+ setattr(instance, attr, driver.last_insert_id)
201
+
202
+
203
+ _warned_caveats: set[tuple[str, Cap]] = set()
204
+ """Caveats already warned about, by (engine, capability). The dedup is carried by the ORM and not by
205
+ `warnings` (its "once per process" filter is reset by pytest on every test), so it really is ONCE
206
+ whatever happens with the filters.
207
+
208
+ The key is the CAPABILITY and not the message's text, which is what it used to be: with the textual
209
+ key, retouching one comma in a reason made the whole string a different one and everything got
210
+ warned about all over again."""
211
+
212
+
213
+ def update_values(instance: object, table: SnakeTableInfo) -> dict[str, object]:
214
+ """Non-PK columns to update: the ones that have a value (a MISSING is OMITTED, it does not write
215
+ the sentinel that used to blow up in `adapt_param`). It validates the `Decimal`s' scale before
216
+ emitting, like the INSERT does. It lives here so the synchronous and asynchronous sessions share
217
+ the SAME path."""
218
+ pk_names = {column.name for column in table.primary_key.columns}
219
+ values = {
220
+ column.name: value
221
+ for column in table.columns
222
+ if column.name not in pk_names
223
+ and (value := getattr(instance, column.attr_name or column.name)) is not MISSING
224
+ }
225
+ _guard_declared_limits(table, values)
226
+ return values
227
+
228
+
229
+ # The Python type that makes each fidelity caveat VISIBLE. It is only warned about if some
230
+ # registered model declares that type: telling somebody what happens to a `Decimal` when they have
231
+ # none is noise, and the noise ends up in a `filterwarnings("ignore")` for the whole category.
232
+ #
233
+ # The capabilities that are NOT here are STRUCTURAL and always get warned about: whether the dev is
234
+ # going to call `upsert()` or `for_update()` cannot be known by reading the models.
235
+ _CAP_PYTHON_TYPE: dict[Cap, object] = {
236
+ Cap.DECIMAL_ORDERING: Decimal,
237
+ Cap.TIMESTAMPTZ: SnakeUtc,
238
+ Cap.INTERVAL: timedelta,
239
+ Cap.JSON: dict,
240
+ Cap.UUID: UUID,
241
+ Cap.BOOLEAN: bool,
242
+ Cap.INT_WIDTHS: int,
243
+ Cap.ARRAYS: list,
244
+ Cap.FLOAT_SPECIALS: float,
245
+ }
246
+
247
+
248
+ def declared_python_types(reg: SnakeRegistry) -> set[object]:
249
+ """The Python types the models of THAT registry declare, generics reduced to their base.
250
+
251
+ `list[str]` counts as `list`: the caveat belongs to the container (the engine has no arrays), not
252
+ to the type inside it.
253
+
254
+ It takes the registry rather than reaching for the global one, because a project built entirely
255
+ on `@snake_model(registry=...)` used to walk an empty list and say NOTHING — and saying nothing
256
+ is precisely what this warning exists to stop.
257
+ """
258
+ used: set[object] = set()
259
+ for model in reg.models():
260
+ # The SAME registry being enumerated, deliberately. This is the one place in the session
261
+ # that ENUMERATES rather than resolves: asking each model for its own registry here would
262
+ # mean walking one registry's list and reading another one's tables, which is a different
263
+ # question from the one the loop is asking.
264
+ table = reg.table_of(model)
265
+ if table is None:
266
+ continue
267
+ for column in table.columns:
268
+ used.add(get_origin(column.python_type) or column.python_type)
269
+ return used
270
+
271
+
272
+ def relevant_caveats(
273
+ dialect: SnakeDialect, reg: SnakeRegistry
274
+ ) -> tuple[tuple[Cap, str], ...]:
275
+ """The engine's caveats this project is going to notice, with their reason.
276
+
277
+ All the structural ones, and the type ones only if there is a model that uses that type — read
278
+ from THAT registry, which is what makes the filtering work for a project that does not use the
279
+ global one.
280
+ """
281
+ declared = declared_python_types(reg)
282
+ return tuple(
283
+ (cap, reason)
284
+ for cap, reason in dialect.capabilities.caveats()
285
+ if cap not in _CAP_PYTHON_TYPE or _CAP_PYTHON_TYPE[cap] in declared
286
+ )
287
+
288
+
289
+ def warn_reduced_fidelity(
290
+ dialect: SnakeDialect, reg: SnakeRegistry | None = None
291
+ ) -> None:
292
+ """Emits ONE warning per caveat of the engine, each of them ONCE per process (Postgres stays quiet).
293
+
294
+ One per capability and not one concatenated: that way the dev can locate the one affecting them,
295
+ and silencing the one they already have under control does not silence the other six.
296
+
297
+ `reg` is the registry whose models decide which TYPE caveats are worth mentioning; the global one
298
+ when nobody says otherwise. Without it, a project on a private registry heard nothing at all —
299
+ the quiet end of the same defect the rest of the session had loudly.
300
+ """
301
+ engine = type(dialect).__name__
302
+ for cap, reason in relevant_caveats(dialect, registry if reg is None else reg):
303
+ if (engine, cap) in _warned_caveats:
304
+ continue
305
+ _warned_caveats.add((engine, cap))
306
+ warnings.warn(
307
+ f"{engine}: {reason}. Silence this warning with "
308
+ f"warnings.filterwarnings('ignore', category=SnakeWarning).",
309
+ SnakeWarning,
310
+ stacklevel=3,
311
+ )
312
+
313
+
314
+ def apply_returned(
315
+ instance: object, table: SnakeTableInfo, row: Sequence[object]
316
+ ) -> None:
317
+ """Assigns to the instance the columns returned by RETURNING (all of them), coercing the type.
318
+
319
+ The RETURNING lists the columns in order, so the row matches `table.columns` 1-to-1.
320
+
321
+ `column.scale` travels, exactly as `mapper` already passes it on the hydration path. Without it
322
+ this door disagreed with that one over the padding of a `Decimal`, and `refresh()` — whose whole
323
+ job is to fetch the current truth — took an object that `all()` had got right and made it worse.
324
+ """
325
+ for column, value in zip(table.columns, row, strict=True):
326
+ setattr(
327
+ instance,
328
+ column.attr_name or column.name,
329
+ coerce(value, column.python_type, column.scale),
330
+ )
331
+
332
+
333
+ def direct_column(column: SnakeExpr[Any]) -> str:
334
+ """SQL name of a DIRECT column (one hop) used in an upsert's on_conflict/update. A relationship
335
+ navigation would point at another table an INSERT cannot reach."""
336
+ if len(column.path) != 1:
337
+ raise SnakeUnsupportedFeature(
338
+ "the on_conflict/update of an upsert uses direct columns of the table, not "
339
+ "relationship navigations."
340
+ )
341
+ return column.path[0]
342
+
343
+
344
+ def pk_condition(table: SnakeTableInfo, instance: object) -> SnakeCondition:
345
+ """Condition identifying the instance's row by its primary key (simple or composite)."""
346
+ conditions: list[SnakeCondition] = []
347
+ for column in table.primary_key.columns:
348
+ # `getattr` returns `Any` and would contaminate the `==` (the whole tree would go to `Any`);
349
+ # anchoring it to `object` forces `SnakeValue.__eq__(self, other: object) -> SnakeCondition`,
350
+ # with no `Any`.
351
+ value: object = getattr(instance, column.attr_name)
352
+ conditions.append(SnakeExpr(path=(column.name,)) == value)
353
+ if len(conditions) == 1:
354
+ return conditions[0]
355
+ return SnakeAnd(parts=tuple(conditions))
@@ -0,0 +1,18 @@
1
+ """SnakeORM SQL emission: metadata + AST + dialect -> (sql, params)."""
2
+
3
+ from snakeorm.sql.aggregate import emit_count as emit_count
4
+ from snakeorm.sql.aggregate import emit_exists as emit_exists
5
+ from snakeorm.sql.aggregate import emit_project as emit_project
6
+ from snakeorm.sql.condition import emit_condition as emit_condition
7
+ from snakeorm.sql.delete import emit_delete as emit_delete
8
+ from snakeorm.sql.delete import emit_delete_pk_in_subquery as emit_delete_pk_in_subquery
9
+ from snakeorm.sql.insert import emit_insert as emit_insert
10
+ from snakeorm.sql.insert import emit_insert_many as emit_insert_many
11
+ from snakeorm.sql.insert import emit_upsert as emit_upsert
12
+ from snakeorm.sql.joins import JoinPlan as JoinPlan
13
+ from snakeorm.sql.select import emit_select as emit_select
14
+ from snakeorm.sql.select import emit_select_with_includes as emit_select_with_includes
15
+ from snakeorm.sql.update import emit_update as emit_update
16
+ from snakeorm.sql.update import emit_update_pk_in_subquery as emit_update_pk_in_subquery
17
+ from snakeorm.sql.value import emit_operand as emit_operand
18
+ from snakeorm.sql.value import emit_value as emit_value
snakeorm/sql/adapt.py ADDED
@@ -0,0 +1,104 @@
1
+ """Adapts Python values a DBAPI driver cannot send, at EXECUTION time (not emission).
2
+
3
+ It has to live in execution: the emitters produce the original values the DDL needs as literals;
4
+ adapting earlier would write a `Decimal` as `'9.99'` inside a CHECK and cause phantom migrations.
5
+ Almost all of it is engine-agnostic: a single conversion, verified on all three.
6
+
7
+ The one exception is LISTS, and that is why `native_arrays` is a parameter and not an assumption.
8
+ Postgres has arrays and psycopg2 adapts them on its own; SQLite and MySQL do not have them, so the
9
+ list falls back to TEXT and travels as JSON. The one answering is the driver, which is the piece that
10
+ knows what its DBAPI understands. The answer used to be written in a comment in this module, the same
11
+ one for everybody, and that is why a list column only existed on Postgres.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Sequence
18
+ from datetime import date, datetime, time, timedelta
19
+ from decimal import Decimal
20
+ from uuid import UUID
21
+
22
+ from snakeorm.core.converters import to_db_for
23
+
24
+
25
+ def adapt_param(value: object, *, native_arrays: bool) -> object:
26
+ """Converts a value into something any DBAPI driver knows how to send.
27
+
28
+ It only acts on what some driver does not understand; the rest passes through untouched
29
+ (converting "just in case" ends up converting twice). The conversion happens for every engine
30
+ even when one of them does not need it: a single path, verified on all three by
31
+ `test/integration/test_type_round_trip.py`.
32
+
33
+ `native_arrays` is the ONLY point where the engine matters, and the driver answers it because it
34
+ is the piece that knows what its DBAPI understands. This used to be an assumption hidden in a
35
+ comment ("lists are left alone, psycopg2 adapts them"), which left the other two engines with no
36
+ way to store a list. Now psycopg says yes and the others say no.
37
+ """
38
+ registered = to_db_for(value)
39
+ if registered is not None:
40
+ # A domain type declared with `register_converter`. It goes FIRST because a subclass of a
41
+ # type the ORM already adapts has to be able to decide its own trip; otherwise this cascade
42
+ # would treat it as its base and the declared `to_db` would never be called.
43
+ return registered(value)
44
+ if isinstance(value, list):
45
+ # With native arrays, the list travels as-is and the driver adapts it. Without them it falls
46
+ # back to TEXT, and JSON is the shape that knows how to come back: `_to_list` rebuilds it on
47
+ # read.
48
+ return value if native_arrays else json.dumps(value)
49
+ if isinstance(value, dict):
50
+ # `dict` -> JSON text; Postgres casts it to `jsonb` (and returns it parsed), SQLite stores
51
+ # it as TEXT. `converter_for(dict)` closes the trip on both.
52
+ return json.dumps(value)
53
+ if isinstance(value, UUID):
54
+ # Canonical text form, accepted by both engines. `_to_uuid` rebuilds it on read.
55
+ return str(value)
56
+ if isinstance(value, Decimal):
57
+ # sqlite3 cannot send a `Decimal`. As TEXT (not `float`): a float would lose the exactness
58
+ # that motivates declaring `Decimal`, silently and on one engine only.
59
+ return str(value)
60
+ if isinstance(value, timedelta):
61
+ # `str(timedelta)` ("1 day, 2:30:00") is cast by Postgres to `interval` and stored by SQLite
62
+ # as text; total seconds would demand a second representation for one engine alone.
63
+ return str(value)
64
+ if isinstance(value, (datetime, date, time)):
65
+ # Python 3.12 removed sqlite3's date adapters. ISO 8601 is cast by Postgres without
66
+ # ambiguity, time zone included.
67
+ # `datetime` goes BEFORE `date`: it inherits from it, and the reverse order would truncate
68
+ # the time.
69
+ return value.isoformat()
70
+ return value
71
+
72
+
73
+ def adapt_params(
74
+ params: Sequence[object], *, native_arrays: bool, percent_formatting: bool
75
+ ) -> tuple[object, ...] | None:
76
+ """Adapts every param right before handing it to the DBAPI (the drivers call this).
77
+
78
+ Returns a new tuple without mutating the one received: params can be reused (a retry), and
79
+ adapting twice would turn a text into the text of a text.
80
+
81
+ NEITHER flag has a default on purpose: a new driver has to answer, and answering wrong shows up
82
+ immediately. With a default, the driver that forgot would inherit another library's behaviour.
83
+
84
+ `native_arrays`: whether the engine has a real array type, or a list has to travel as text.
85
+
86
+ `percent_formatting`: whether this DBAPI re-reads the SQL as a format template when given
87
+ parameters. If it does, "no parameters" has to be spelled `None` — `()` still counts as being
88
+ given some — and any statement carrying a literal `%` blows up before reaching the server. That
89
+ is every DDL statement the runner emits, since DDL cannot be parametrised: a
90
+ `CHECK (email LIKE '%mail%')`, a `DEFAULT '50% off'`, the body of a routine.
91
+
92
+ Measured, and they do not agree, which is why this is a flag and not a blanket `or None`:
93
+
94
+ psycopg 3 () -> ProgrammingError None -> fine
95
+ PyMySQL () -> TypeError None -> fine
96
+ sqlite3 () -> fine None -> ProgrammingError (it REFUSES None)
97
+
98
+ This closes the EMPTY case only. A `%` alongside real parameters stays the caller's business,
99
+ which is the same reason `MOD` is not in the capability catalogue.
100
+ """
101
+ adapted = tuple(adapt_param(value, native_arrays=native_arrays) for value in params)
102
+ if not adapted and percent_formatting:
103
+ return None
104
+ return adapted