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,519 @@
|
|
|
1
|
+
"""MySQL / MariaDB dialect. It breaks Postgres/SQLite assumptions: no `RETURNING` on MySQL (the PK comes
|
|
2
|
+
through `lastrowid`), upsert with `ON DUPLICATE KEY UPDATE` (on ANY unique key, not a chosen one),
|
|
3
|
+
and non-transactional DDL (N steps are not atomic). Identifiers with backticks; literals escape `\\`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from datetime import date, datetime, time, timedelta
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import get_args, get_origin
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from snakeorm.dialects.literals import numeric_literal
|
|
16
|
+
from snakeorm.core.exceptions import SnakeDialectError
|
|
17
|
+
from snakeorm.dialects.matrix import Engine, capabilities_for, strictest_of
|
|
18
|
+
from snakeorm.dialects.capabilities import (
|
|
19
|
+
AlterColumnStyle,
|
|
20
|
+
CommentStyle,
|
|
21
|
+
EmptyInsertStyle,
|
|
22
|
+
DerivedFlags,
|
|
23
|
+
SnakeLimits,
|
|
24
|
+
SnakeSyntax,
|
|
25
|
+
)
|
|
26
|
+
from snakeorm.expressions.scalar import SnakeFunc
|
|
27
|
+
from snakeorm.times import SnakeUtc
|
|
28
|
+
from snakeorm.metadata.type_params import base_type
|
|
29
|
+
from snakeorm.metadata import (
|
|
30
|
+
SnakeDateTimeParams,
|
|
31
|
+
SnakeDecimalParams,
|
|
32
|
+
SnakeIndexMethod,
|
|
33
|
+
SnakeIntParams,
|
|
34
|
+
SnakeIntSize,
|
|
35
|
+
SnakeServerDefault,
|
|
36
|
+
SnakeFloatParams,
|
|
37
|
+
SnakeStrParams,
|
|
38
|
+
SnakeTimeParams,
|
|
39
|
+
SnakeTypeParams,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# The ones MySQL puts in when none are asked for. It is NOT 0: a bare `DATETIME` truncates to seconds
|
|
43
|
+
# IN SILENCE, and Python's `datetime` carries microseconds. The CEILING is a dialect flag, not a
|
|
44
|
+
# constant.
|
|
45
|
+
_MYSQL_DEFAULT_FRACTIONAL = 6
|
|
46
|
+
|
|
47
|
+
# An integer's width -> its type on MySQL. The canonical one is `INT`, not `INTEGER`.
|
|
48
|
+
_MYSQL_INT_SIZES: dict[SnakeIntSize, str] = {
|
|
49
|
+
SnakeIntSize.SMALLINT: "SMALLINT",
|
|
50
|
+
SnakeIntSize.INTEGER: "INT",
|
|
51
|
+
SnakeIntSize.BIGINT: "BIGINT",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Scalar function (agnostic) -> MySQL. DATE_TRUNC is not here: MySQL does not have it, asking for it fails clearly.
|
|
55
|
+
_MYSQL_FUNCTIONS: dict[SnakeFunc, str] = {
|
|
56
|
+
SnakeFunc.LOWER: "LOWER",
|
|
57
|
+
SnakeFunc.UPPER: "UPPER",
|
|
58
|
+
SnakeFunc.TRIM: "TRIM",
|
|
59
|
+
SnakeFunc.LENGTH: "CHAR_LENGTH", # LENGTH counts BYTES on MySQL; CHAR_LENGTH counts characters
|
|
60
|
+
SnakeFunc.CONCAT: "CONCAT",
|
|
61
|
+
SnakeFunc.EXTRACT: "EXTRACT",
|
|
62
|
+
SnakeFunc.ABS: "ABS",
|
|
63
|
+
SnakeFunc.ROUND: "ROUND",
|
|
64
|
+
SnakeFunc.SUBSTRING: "SUBSTRING",
|
|
65
|
+
SnakeFunc.REPLACE: "REPLACE",
|
|
66
|
+
SnakeFunc.CEIL: "CEIL",
|
|
67
|
+
SnakeFunc.FLOOR: "FLOOR",
|
|
68
|
+
SnakeFunc.SQRT: "SQRT",
|
|
69
|
+
SnakeFunc.POWER: "POWER",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# BTREE is the default (implicit). HASH gets translated; GIN/GIST/BRIN are Postgres's and fail clearly.
|
|
73
|
+
_MYSQL_INDEX_METHODS: dict[SnakeIndexMethod, str] = {
|
|
74
|
+
SnakeIndexMethod.HASH: "HASH",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_MYSQL_CANNOT: dict[SnakeFunc, str] = {
|
|
79
|
+
SnakeFunc.DATE_TRUNC: "MySQL has no DATE_TRUNC.",
|
|
80
|
+
}
|
|
81
|
+
"""What the engine genuinely cannot do, and why.
|
|
82
|
+
|
|
83
|
+
Declared rather than left out: a function missing from the table above reads exactly like one
|
|
84
|
+
nobody got round to, and silence meaning two different things is how `ABS` and `ROUND` sat absent
|
|
85
|
+
from SQLite while the other two had both.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
_missing = set(SnakeFunc) - set(_MYSQL_FUNCTIONS) - set(_MYSQL_CANNOT)
|
|
89
|
+
if (
|
|
90
|
+
_missing
|
|
91
|
+
): # pragma: no cover - the guard exists so this can never be reached in a good build
|
|
92
|
+
raise SnakeDialectError(
|
|
93
|
+
"MySQLDialect does not answer for every scalar function: "
|
|
94
|
+
f"{sorted(f.name for f in _missing)}. Add it to _MYSQL_FUNCTIONS, or to _MYSQL_CANNOT "
|
|
95
|
+
"with the reason. Like the `Cap` catalogue, forgetting one fails at import."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
_MYSQL_SERVER_DEFAULTS: dict[SnakeServerDefault, str] = {
|
|
99
|
+
SnakeServerDefault.NOW: "CURRENT_TIMESTAMP",
|
|
100
|
+
SnakeServerDefault.UUID_V4: "(UUID())", # MySQL 8: an expression, in parentheses inside a DEFAULT
|
|
101
|
+
SnakeServerDefault.TRUE: "1", # MySQL has no boolean: TINYINT(1)
|
|
102
|
+
SnakeServerDefault.FALSE: "0",
|
|
103
|
+
SnakeServerDefault.ZERO: "0",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Default Python type -> MySQL type mapping. `int` and `dict` are NOT here: they depend on a knob
|
|
107
|
+
# (`int_size`, `json_storage`) and `map_type` resolves them.
|
|
108
|
+
_MYSQL_TYPES: dict[object, str] = {
|
|
109
|
+
str: "TEXT",
|
|
110
|
+
bool: "TINYINT(1)", # MySQL has no native BOOLEAN: it is an alias of TINYINT(1)
|
|
111
|
+
float: "DOUBLE",
|
|
112
|
+
# `Decimal` is NOT here, for the same reason as `datetime`, `int` and `dict`: its type depends
|
|
113
|
+
# on the declared parameters and `map_type` resolves it. A bare `DECIMAL` in this table WAS the
|
|
114
|
+
# bug — MySQL reads it as `DECIMAL(10,0)`, so `9.99` came back `10`. The table won before anyone
|
|
115
|
+
# had looked at the model, and what it won was somebody's money.
|
|
116
|
+
bytes: "LONGBLOB", # BLOB tops out at 64KB; LONGBLOB for arbitrary bytes, like PG's BYTEA
|
|
117
|
+
date: "DATE",
|
|
118
|
+
# `datetime` is NOT here, same as `int` and `dict`: its type depends on the declared precision
|
|
119
|
+
# and `_datetime_type` resolves it. Having it here with the `(6)` baked in was what made the
|
|
120
|
+
# declared precision get lost — the table won before anyone had looked at it.
|
|
121
|
+
time: "TIME(6)",
|
|
122
|
+
UUID: "CHAR(36)", # MySQL has no native UUID: the canonical 36-character form
|
|
123
|
+
# timedelta is NOT here: MySQL has no interval type. It is rejected in map_type, not faked.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _element_type(annotation: object) -> object:
|
|
128
|
+
"""The element type of a `list[...]`; it fails clearly if the list does not declare it."""
|
|
129
|
+
args = get_args(annotation)
|
|
130
|
+
if not args:
|
|
131
|
+
raise SnakeDialectError(f"{annotation!r} does not declare its element type.")
|
|
132
|
+
return args[0]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class MySQLDialect(DerivedFlags):
|
|
136
|
+
"""How the SQL is written for MySQL/MariaDB (PyMySQL driver).
|
|
137
|
+
|
|
138
|
+
Placeholder `%s` (paramstyle `format`); it depends on the driver, but the common ones agree.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# No flavour until something connects, so the answer is what BOTH can do.
|
|
142
|
+
capabilities = strictest_of((Engine.MARIADB, Engine.MYSQL))
|
|
143
|
+
|
|
144
|
+
syntax = SnakeSyntax(
|
|
145
|
+
triggers_are_table_scoped=False, # `DROP TRIGGER name`, without `ON table`
|
|
146
|
+
indexes_are_table_scoped=True, # `DROP INDEX name ON table`
|
|
147
|
+
empty_insert_style=EmptyInsertStyle.EMPTY_ROW,
|
|
148
|
+
alter_column_style=AlterColumnStyle.MYSQL_MODIFY,
|
|
149
|
+
comment_style=CommentStyle.INLINE, # a CLAUSE (`COMMENT = 'x'`), never a statement
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Ceilings of the declared parameters, WAY below Postgres's. The scale is a SEPARATE ceiling and
|
|
153
|
+
# a lower one than the precision: `DECIMAL(40,35)` has one valid half and one impossible half.
|
|
154
|
+
limits = SnakeLimits(
|
|
155
|
+
bind_params=65535, # ceiling of placeholders per prepared statement
|
|
156
|
+
numeric_precision=65,
|
|
157
|
+
numeric_scale=30,
|
|
158
|
+
fractional_seconds=6,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def __init__(self, flavour: Engine | None = None) -> None:
|
|
162
|
+
"""`flavour` narrows the capabilities once a connection has said which server is there.
|
|
163
|
+
|
|
164
|
+
Without it they stay at what BOTH can do.
|
|
165
|
+
"""
|
|
166
|
+
# A PER-INSTANCE copy of the type table: `register_type` writes here, not into the module's
|
|
167
|
+
# dict. Global, importing a library that registers a type would sneak it into every dialect
|
|
168
|
+
# in the process, including those of a database that does not support it.
|
|
169
|
+
self._types: dict[object, str] = dict(_MYSQL_TYPES)
|
|
170
|
+
if flavour is not None:
|
|
171
|
+
self.capabilities = capabilities_for(flavour)
|
|
172
|
+
|
|
173
|
+
def drop_all_sql(self, tables: Sequence[str]) -> tuple[str, ...]:
|
|
174
|
+
"""The drops bracketed by the FK switch: MySQL refuses to drop a referenced table.
|
|
175
|
+
|
|
176
|
+
There is no `CASCADE` for a `DROP TABLE` here, and dropping in dependency order would mean
|
|
177
|
+
computing one — for a command whose whole point is that nothing survives. The switch is the
|
|
178
|
+
idiom MySQL itself documents, and it is put BACK: a session left with the checks off accepts
|
|
179
|
+
orphan rows in silence, which is a worse state than the one being repaired.
|
|
180
|
+
"""
|
|
181
|
+
if not tables:
|
|
182
|
+
return ()
|
|
183
|
+
drops = tuple(
|
|
184
|
+
f"DROP TABLE IF EXISTS {self.quote_ident(table)}" for table in tables
|
|
185
|
+
)
|
|
186
|
+
return ("SET FOREIGN_KEY_CHECKS = 0", *drops, "SET FOREIGN_KEY_CHECKS = 1")
|
|
187
|
+
|
|
188
|
+
def explain_sql(self, sql: str) -> str:
|
|
189
|
+
"""`EXPLAIN <statement>`: about a dozen columns per row, and that is the engine's shape."""
|
|
190
|
+
return f"EXPLAIN {sql}"
|
|
191
|
+
|
|
192
|
+
def statement_timeout_sql(self, milliseconds: int) -> str | None:
|
|
193
|
+
"""MariaDB's `max_statement_time`, converted to the SECONDS it expects.
|
|
194
|
+
|
|
195
|
+
THE FORK, and it decides what this line can be: MySQL and MariaDB do not share this
|
|
196
|
+
variable and neither accepts the other's. MariaDB has `max_statement_time` in seconds;
|
|
197
|
+
Oracle's MySQL has `max_execution_time` in milliseconds. One dialect, two spellings, no
|
|
198
|
+
overlap — and nothing in this ORM tells the two forks apart.
|
|
199
|
+
|
|
200
|
+
It emits MariaDB's, which is the fork the project tests against. On the other one the server
|
|
201
|
+
refuses by name (`1193 Unknown system variable`) the moment the driver is wrapped: loud, at
|
|
202
|
+
startup, and fixable — not a timeout that quietly never fires.
|
|
203
|
+
|
|
204
|
+
The conversion is the point of doing this here. Handing `max_statement_time` a value in
|
|
205
|
+
milliseconds would not fail; it would set a limit a THOUSAND times longer than asked for,
|
|
206
|
+
which is the kind of bug that only surfaces the day something hangs.
|
|
207
|
+
"""
|
|
208
|
+
return f"SET SESSION max_statement_time = {milliseconds / 1000:g}"
|
|
209
|
+
|
|
210
|
+
def register_type(self, python_type: object, sql_type: str) -> None:
|
|
211
|
+
"""Adds (or rewrites) the SQL spelling of a Python type in THIS dialect.
|
|
212
|
+
|
|
213
|
+
See `SnakeDialect.register_type`. It goes per dialect because the same Python type is
|
|
214
|
+
written differently on each engine: an `Inet` is `INET` on Postgres and `TEXT` here.
|
|
215
|
+
"""
|
|
216
|
+
self._types[python_type] = sql_type
|
|
217
|
+
|
|
218
|
+
def placeholder(self, index: int) -> str:
|
|
219
|
+
"""PyMySQL uses a positional `%s` (paramstyle `format`); the index is not needed."""
|
|
220
|
+
return "%s"
|
|
221
|
+
|
|
222
|
+
def trigger_statements(self, name: str, body: str) -> tuple[list[str], str]:
|
|
223
|
+
"""The body goes bare. MySQL accepts a single statement without `BEGIN`/`END`.
|
|
224
|
+
|
|
225
|
+
Several statements WOULD need them, and would also need the client delimiter changed, which
|
|
226
|
+
is a property of the client and not of the SQL. A body of one statement is what this ORM
|
|
227
|
+
emits, so that door stays closed until something needs it opened.
|
|
228
|
+
"""
|
|
229
|
+
return [], body
|
|
230
|
+
|
|
231
|
+
def quote_ident(self, name: str) -> str:
|
|
232
|
+
"""Quotes with BACKTICKS and doubles the inner backticks (MySQL's equivalent of the quoting)."""
|
|
233
|
+
escaped = name.replace("`", "``")
|
|
234
|
+
return f"`{escaped}`"
|
|
235
|
+
|
|
236
|
+
def json_get_sql(
|
|
237
|
+
self, source: str, key_path: tuple[str, ...], as_type: type
|
|
238
|
+
) -> str:
|
|
239
|
+
"""`JSON_EXTRACT` with a `$.a.b` path, UNQUOTED before the cast.
|
|
240
|
+
|
|
241
|
+
The unquote is the step that is easy to leave out and impossible to notice: `JSON_EXTRACT`
|
|
242
|
+
returns a JSON scalar, so a string arrives with its quotes still on and `= 'ada'` never
|
|
243
|
+
matches. MySQL's cast targets are its own (`SIGNED`, not `integer`), which is the reason this
|
|
244
|
+
method exists per dialect at all.
|
|
245
|
+
"""
|
|
246
|
+
path = "$." + ".".join(key_path)
|
|
247
|
+
access = f"JSON_UNQUOTE(JSON_EXTRACT({source}, '{path}'))"
|
|
248
|
+
cast = _MYSQL_CAST.get(as_type)
|
|
249
|
+
return access if cast is None else f"CAST({access} AS {cast})"
|
|
250
|
+
|
|
251
|
+
def date_shift_sql(
|
|
252
|
+
self, source: str, amount_placeholder: str, unit: str, keeps_time: bool
|
|
253
|
+
) -> str:
|
|
254
|
+
"""`DATE_ADD(col, INTERVAL %s DAY)`: the unit is a bare KEYWORD, not a string.
|
|
255
|
+
|
|
256
|
+
Measured to take the amount as a prepared parameter and to accept a negative one, which is
|
|
257
|
+
what lets subtraction be the same node with the sign flipped. `keeps_time` is unused: MySQL
|
|
258
|
+
has real date and datetime types.
|
|
259
|
+
"""
|
|
260
|
+
return f"DATE_ADD({source}, INTERVAL {amount_placeholder} {unit.upper()})"
|
|
261
|
+
|
|
262
|
+
def string_agg_sql(
|
|
263
|
+
self, value: str, separator: str, order_by: str, params: list[object]
|
|
264
|
+
) -> str:
|
|
265
|
+
"""`GROUP_CONCAT(col ORDER BY ... SEPARATOR ', ')`: here the separator is SYNTAX.
|
|
266
|
+
|
|
267
|
+
Measured, a placeholder after `SEPARATOR` is a syntax error, so this is the one engine where
|
|
268
|
+
the string reaches the statement. It goes through `literal()` — the same escaping every DDL
|
|
269
|
+
default already uses — which doubles the quote and escapes the backslash. `params` is left
|
|
270
|
+
untouched on purpose, and a test pins that it is.
|
|
271
|
+
"""
|
|
272
|
+
clauses = f" ORDER BY {order_by}" if order_by else ""
|
|
273
|
+
return f"GROUP_CONCAT({value}{clauses} SEPARATOR {self.literal(separator)})"
|
|
274
|
+
|
|
275
|
+
def integer_division_op(self) -> str:
|
|
276
|
+
"""`DIV`, and this is the whole reason the method exists.
|
|
277
|
+
|
|
278
|
+
MEASURED: `SELECT 45/50` answers `0.9000` here —type `decimal(6,4)`— while the other two
|
|
279
|
+
answer `0`. MySQL's `/` IS decimal division; `DIV` is the integer one. Emitting `/` for two
|
|
280
|
+
integer columns made `SnakeArith[int]` a lie on this engine and only on this engine.
|
|
281
|
+
"""
|
|
282
|
+
return "DIV"
|
|
283
|
+
|
|
284
|
+
def cast_sql(self, source: str, as_type: type) -> str:
|
|
285
|
+
"""`CAST(x AS DOUBLE)`, and DOUBLE rather than DECIMAL on purpose.
|
|
286
|
+
|
|
287
|
+
MEASURED: a bare `CAST(x AS DECIMAL)` is `DECIMAL(10,0)` — no decimal places at all — so it
|
|
288
|
+
would read as a float cast and truncate. The table this reads already says DOUBLE.
|
|
289
|
+
"""
|
|
290
|
+
return f"CAST({source} AS {_MYSQL_CAST[as_type]})"
|
|
291
|
+
|
|
292
|
+
def _guard_numeric(self, params: SnakeDecimalParams) -> None:
|
|
293
|
+
"""Checks that the declared DECIMAL fits within what MySQL accepts.
|
|
294
|
+
|
|
295
|
+
They are TWO different ceilings, not one: 65 total digits and 30 decimal places. With a
|
|
296
|
+
single number, a `DECIMAL(40,35)` would pass —the precision is valid— and blow up on the
|
|
297
|
+
engine.
|
|
298
|
+
"""
|
|
299
|
+
assert self.max_numeric_precision is not None
|
|
300
|
+
assert self.max_numeric_scale is not None
|
|
301
|
+
if params.precision > self.max_numeric_precision:
|
|
302
|
+
raise SnakeDialectError(
|
|
303
|
+
f"MySQL does not accept more than {self.max_numeric_precision} digits in a "
|
|
304
|
+
f"DECIMAL, and {params.precision} were requested. Postgres goes up to 1000, so "
|
|
305
|
+
f"this model is not portable as it stands: lower the precision or stay on Postgres."
|
|
306
|
+
)
|
|
307
|
+
if params.scale is not None and params.scale > self.max_numeric_scale:
|
|
308
|
+
raise SnakeDialectError(
|
|
309
|
+
f"MySQL does not accept more than {self.max_numeric_scale} decimal places in a "
|
|
310
|
+
f"DECIMAL, and {params.scale} were requested. The ceiling on decimal places is "
|
|
311
|
+
f"lower than the one on total digits ({self.max_numeric_precision}): they are two "
|
|
312
|
+
f"different limits."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _datetime_type(self, python_type: type, params: SnakeTypeParams | None) -> str:
|
|
316
|
+
"""The `DATETIME(n)` of a date column, or the reasoned refusal if there is nowhere to put it.
|
|
317
|
+
|
|
318
|
+
MySQL has no usable type with a time zone. `TIMESTAMP` does store the instant, but it tops
|
|
319
|
+
out in 2038, so the ORM uses `DATETIME`, which stores a wall clock and does NOT preserve the
|
|
320
|
+
instant across zones. That is why `SnakeUtc` —whose whole deal is precisely that, storing an
|
|
321
|
+
instant— does not fit in a `DATETIME`: it would satisfy the signature and break the promise,
|
|
322
|
+
in silence and only once somebody crossed a zone.
|
|
323
|
+
|
|
324
|
+
It used to be REJECTED, and with that the same model did not work on all three engines. Now
|
|
325
|
+
it falls back to ISO-8601 TEXT, which preserves the WHOLE instant —time zone included, and
|
|
326
|
+
with no 2038 ceiling—, and the degradation is declared in `capabilities`: what is lost is
|
|
327
|
+
the engine treating it as a date when sorting and comparing, and the session warns about
|
|
328
|
+
that at startup.
|
|
329
|
+
"""
|
|
330
|
+
if issubclass(python_type, SnakeUtc):
|
|
331
|
+
return "TEXT"
|
|
332
|
+
registered = self._types.get(python_type)
|
|
333
|
+
if registered is not None:
|
|
334
|
+
return registered # a registered subclass of your own beats the default
|
|
335
|
+
digits = params.precision if isinstance(params, SnakeDateTimeParams) else None
|
|
336
|
+
if digits is None:
|
|
337
|
+
digits = _MYSQL_DEFAULT_FRACTIONAL
|
|
338
|
+
assert self.max_fractional_seconds is not None
|
|
339
|
+
if digits > self.max_fractional_seconds:
|
|
340
|
+
raise SnakeDialectError(
|
|
341
|
+
f"MySQL does not store more than {self.max_fractional_seconds} fractional-second "
|
|
342
|
+
f"digits, and {digits} were requested. With {self.max_fractional_seconds} you "
|
|
343
|
+
f"already reach microseconds, which is the whole resolution of Python's datetime."
|
|
344
|
+
)
|
|
345
|
+
return f"DATETIME({digits})"
|
|
346
|
+
|
|
347
|
+
def map_type(
|
|
348
|
+
self,
|
|
349
|
+
python_type: object,
|
|
350
|
+
autoincrement: bool = False,
|
|
351
|
+
params: SnakeTypeParams | None = None,
|
|
352
|
+
) -> str:
|
|
353
|
+
"""Translates the Python type into the COMPLETE MySQL type. With autoincrement it adds `AUTO_INCREMENT`.
|
|
354
|
+
|
|
355
|
+
It honours the integer's width, the text's length, the NUMERIC's precision and the date's.
|
|
356
|
+
It ignores the JSON backing: MySQL only has `JSON` and does not distinguish JSONB, so the
|
|
357
|
+
knob has nothing to translate into (and it is not pretended otherwise).
|
|
358
|
+
"""
|
|
359
|
+
if python_type is int:
|
|
360
|
+
size = (
|
|
361
|
+
params.size
|
|
362
|
+
if isinstance(params, SnakeIntParams)
|
|
363
|
+
else SnakeIntSize.BIGINT
|
|
364
|
+
)
|
|
365
|
+
base = _MYSQL_INT_SIZES[size]
|
|
366
|
+
# AUTO_INCREMENT on top of the integer type; the column must be a key (the CREATE TABLE's PK gives that).
|
|
367
|
+
return f"{base} AUTO_INCREMENT" if autoincrement else base
|
|
368
|
+
if python_type is str:
|
|
369
|
+
text = params if isinstance(params, SnakeStrParams) else None
|
|
370
|
+
if text is None or text.max_length is None:
|
|
371
|
+
return "TEXT"
|
|
372
|
+
# CHAR(n) is a FIXED length: it pads with spaces and compares ignoring them.
|
|
373
|
+
return (
|
|
374
|
+
f"CHAR({text.max_length})"
|
|
375
|
+
if text.fixed
|
|
376
|
+
else f"VARCHAR({text.max_length})"
|
|
377
|
+
)
|
|
378
|
+
if python_type is float and isinstance(params, SnakeFloatParams):
|
|
379
|
+
# FLOAT is single precision on MySQL and DOUBLE is double, just like REAL/DOUBLE
|
|
380
|
+
# PRECISION on Postgres but under other names: that is why the translation lives in the
|
|
381
|
+
# dialect.
|
|
382
|
+
return "FLOAT" if params.size == 4 else self._types[float]
|
|
383
|
+
if python_type is time and isinstance(params, SnakeTimeParams):
|
|
384
|
+
# MySQL has no TIMETZ: a time with a zone falls back to TEXT, which keeps the zone in the
|
|
385
|
+
# ISO string. A plain TIME would have thrown it away in silence, satisfying the
|
|
386
|
+
# signature and breaking the deal.
|
|
387
|
+
return "TEXT" if params.with_timezone else self._types[time]
|
|
388
|
+
# `base_type`: a `dict[str, object]` IS a dict column. Comparing the annotation
|
|
389
|
+
# itself let a parameterised one fall through to the default type, so the column
|
|
390
|
+
# came out TEXT and the JSON storage the user asked for was silently dropped.
|
|
391
|
+
if base_type(python_type) is dict:
|
|
392
|
+
return "JSON" # MySQL 5.7+; it does not distinguish JSONB from JSON
|
|
393
|
+
if isinstance(python_type, type) and issubclass(python_type, datetime):
|
|
394
|
+
return self._datetime_type(python_type, params)
|
|
395
|
+
if isinstance(params, SnakeDecimalParams):
|
|
396
|
+
self._guard_numeric(params)
|
|
397
|
+
scale = f",{params.scale}" if params.scale is not None else ""
|
|
398
|
+
return f"DECIMAL({params.precision}{scale})"
|
|
399
|
+
if python_type is Decimal:
|
|
400
|
+
# Undeclared precision. Postgres maps this to an unbounded NUMERIC and loses nothing, so
|
|
401
|
+
# the model is only lossy HERE: MySQL has no unbounded decimal, and a bare `DECIMAL` is
|
|
402
|
+
# `DECIMAL(10,0)` — every fractional digit gone, silently. This is not a query
|
|
403
|
+
# capability that `Degraded` could cover; what is lost is the VALUE, so the plan stops.
|
|
404
|
+
raise SnakeDialectError(
|
|
405
|
+
"MySQL has no unbounded DECIMAL: a column of type Decimal must declare its "
|
|
406
|
+
"precision with snake_decimal(precision=..., scale=...). Without it the engine "
|
|
407
|
+
"uses DECIMAL(10,0) and rounds every value to an integer — 9.99 is stored as 10. "
|
|
408
|
+
"Postgres accepts this model because its NUMERIC is arbitrary precision, which is "
|
|
409
|
+
"why the same code is lossless there and lossy here."
|
|
410
|
+
)
|
|
411
|
+
origin = get_origin(python_type)
|
|
412
|
+
if origin is list or python_type is list:
|
|
413
|
+
# MySQL has no arrays: the list falls back to TEXT and travels as JSON (`adapt_param`
|
|
414
|
+
# serialises it, `_to_list` rebuilds it). Declared `Degraded`, because what is lost is
|
|
415
|
+
# being able to query INSIDE the array from SQL.
|
|
416
|
+
return "TEXT"
|
|
417
|
+
if python_type is timedelta:
|
|
418
|
+
# No interval type: TEXT with the `str(timedelta)` that `adapt_param` already writes. It
|
|
419
|
+
# used to fall through to the generic message at the end, which said it did not know how
|
|
420
|
+
# to map it and did not say what to do; it was the only type neither supported nor
|
|
421
|
+
# rejected with a reason.
|
|
422
|
+
return "TEXT"
|
|
423
|
+
try:
|
|
424
|
+
return self._types[python_type]
|
|
425
|
+
except (KeyError, TypeError):
|
|
426
|
+
raise SnakeDialectError(
|
|
427
|
+
f"MySQLDialect does not know how to map {python_type!r} to a SQL type"
|
|
428
|
+
) from None
|
|
429
|
+
|
|
430
|
+
def limit_offset(
|
|
431
|
+
self, limit: int | None, offset: int | None, params: list[object]
|
|
432
|
+
) -> str:
|
|
433
|
+
"""A parametrised `LIMIT %s OFFSET %s`. MySQL demands a LIMIT whenever there is an OFFSET, so
|
|
434
|
+
a bare OFFSET carries a huge LIMIT (MySQL's standard idiom for "everything from N on")."""
|
|
435
|
+
parts: list[str] = []
|
|
436
|
+
if limit is not None:
|
|
437
|
+
params.append(limit)
|
|
438
|
+
parts.append(f"LIMIT {self.placeholder(len(params))}")
|
|
439
|
+
elif offset is not None:
|
|
440
|
+
parts.append(
|
|
441
|
+
"LIMIT 18446744073709551615"
|
|
442
|
+
) # 2^64-1: "no limit" with an OFFSET, MySQL style
|
|
443
|
+
if offset is not None:
|
|
444
|
+
params.append(offset)
|
|
445
|
+
parts.append(f"OFFSET {self.placeholder(len(params))}")
|
|
446
|
+
return " ".join(parts)
|
|
447
|
+
|
|
448
|
+
def on_conflict_clause(
|
|
449
|
+
self, conflict_columns: Sequence[str], update_columns: Sequence[str]
|
|
450
|
+
) -> str:
|
|
451
|
+
"""`ON DUPLICATE KEY UPDATE`: MySQL's upsert, which fires on ANY unique key.
|
|
452
|
+
No conflict columns; for "do nothing" (MySQL has no `DO NOTHING`) it uses `col = col`.
|
|
453
|
+
"""
|
|
454
|
+
if not update_columns:
|
|
455
|
+
col = self.quote_ident(conflict_columns[0])
|
|
456
|
+
return f"ON DUPLICATE KEY UPDATE {col} = {col}"
|
|
457
|
+
assignments = ", ".join(
|
|
458
|
+
f"{self.quote_ident(column)} = VALUES({self.quote_ident(column)})"
|
|
459
|
+
for column in update_columns
|
|
460
|
+
)
|
|
461
|
+
return f"ON DUPLICATE KEY UPDATE {assignments}"
|
|
462
|
+
|
|
463
|
+
def literal(self, value: object) -> str:
|
|
464
|
+
"""Formats a default value as a MySQL SQL literal (DDL DEFAULT).
|
|
465
|
+
MySQL interprets the BACKSLASH in strings, so it is escaped as well as the quote; a bool goes as 1/0.
|
|
466
|
+
"""
|
|
467
|
+
if isinstance(value, Enum):
|
|
468
|
+
value = value.value
|
|
469
|
+
if value is None:
|
|
470
|
+
return "NULL"
|
|
471
|
+
if isinstance(value, bool): # before int: bool is a subclass of int
|
|
472
|
+
return "1" if value else "0"
|
|
473
|
+
if isinstance(value, (int, float, Decimal)):
|
|
474
|
+
return numeric_literal(value, "MySQLDialect")
|
|
475
|
+
if isinstance(value, str):
|
|
476
|
+
escaped = value.replace("\\", "\\\\").replace("'", "''")
|
|
477
|
+
return f"'{escaped}'"
|
|
478
|
+
raise SnakeDialectError(
|
|
479
|
+
f"MySQLDialect does not know how to format {value!r} as a SQL literal"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def function_name(self, func: SnakeFunc) -> str:
|
|
483
|
+
"""Translates the agnostic name of a scalar function into MySQL's own."""
|
|
484
|
+
if func in _MYSQL_FUNCTIONS:
|
|
485
|
+
return _MYSQL_FUNCTIONS[func]
|
|
486
|
+
reason = _MYSQL_CANNOT.get(func)
|
|
487
|
+
if reason is None:
|
|
488
|
+
# Not a catalogue member at all. A real one cannot reach here: the import
|
|
489
|
+
# guard above refuses to load a dialect that skipped any.
|
|
490
|
+
raise SnakeDialectError(
|
|
491
|
+
f"MySQLDialect does not know how to translate the function {func!r}"
|
|
492
|
+
)
|
|
493
|
+
raise SnakeDialectError(
|
|
494
|
+
f"MySQLDialect cannot translate {func.name}: {reason} "
|
|
495
|
+
"Reach for it through `raw()` with the engine's own spelling."
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def index_method(self, method: SnakeIndexMethod) -> str:
|
|
499
|
+
"""Translates the index method (agnostic) into MySQL's jargon for the `USING`."""
|
|
500
|
+
try:
|
|
501
|
+
return _MYSQL_INDEX_METHODS[method]
|
|
502
|
+
except KeyError:
|
|
503
|
+
raise SnakeDialectError(
|
|
504
|
+
f"MySQLDialect does not know how to translate the index method {method!r} (GIN/GIST/BRIN are Postgres ones)"
|
|
505
|
+
) from None
|
|
506
|
+
|
|
507
|
+
def server_default_sql(self, value: SnakeServerDefault) -> str:
|
|
508
|
+
"""Translates the server-side default (agnostic) into its MySQL SQL expression."""
|
|
509
|
+
try:
|
|
510
|
+
return _MYSQL_SERVER_DEFAULTS[value]
|
|
511
|
+
except KeyError:
|
|
512
|
+
raise SnakeDialectError(
|
|
513
|
+
f"MySQLDialect does not know how to translate the server_default {value!r}"
|
|
514
|
+
) from None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
_MYSQL_CAST = {int: "SIGNED", float: "DOUBLE", bool: "SIGNED"}
|
|
518
|
+
"""What a declared type becomes in a MySQL cast. Its targets are its own —`SIGNED`, not
|
|
519
|
+
`integer`— which is exactly why the emission lives in the dialect."""
|