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,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))
|
snakeorm/sql/__init__.py
ADDED
|
@@ -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
|