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,367 @@
|
|
|
1
|
+
"""How MySQL/MariaDB's schema is READ (the third engine). Implements the `SnakeIntrospector` Protocol.
|
|
2
|
+
|
|
3
|
+
Everything comes out of `information_schema`, which MySQL implements and Postgres implements
|
|
4
|
+
differently enough to be worth its own file rather than a shared one with branches in it. The two
|
|
5
|
+
real differences:
|
|
6
|
+
|
|
7
|
+
- **There are no named schemas.** In MySQL a "schema" IS a database, so the `schema` argument names
|
|
8
|
+
the database and defaults to the connected one. Postgres's `public` means nothing here, and
|
|
9
|
+
passing it through as a database name would query a database that does not exist.
|
|
10
|
+
- **`tinyint(1)` is `bool`.** MySQL has no boolean, so this is the same one-byte integer that
|
|
11
|
+
everything else uses — but it is what the ORM emits for a `bool`, and reading it back as `int`
|
|
12
|
+
would make every boolean column a permanent drift.
|
|
13
|
+
|
|
14
|
+
NOT BIJECTIVE, and less so here than on Postgres. The ORM stores a `SnakeUtc` as ISO-8601 TEXT on
|
|
15
|
+
this engine and a `UUID` as `CHAR(36)`, so reading them back can only ever say `str`: the type was
|
|
16
|
+
already gone when it was written. That is what `drift` compares SQL types for — both sides map to
|
|
17
|
+
`TEXT` and agree — and what a scaffolded model shows honestly rather than guessing at.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datetime import date, datetime, time
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
|
|
25
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
26
|
+
from snakeorm.drivers import SnakeDriver
|
|
27
|
+
from snakeorm.introspection.unsupported import (
|
|
28
|
+
SnakeUnsupportedKind,
|
|
29
|
+
warnings_from_rows,
|
|
30
|
+
)
|
|
31
|
+
from snakeorm.metadata import (
|
|
32
|
+
SnakeColumnInfo,
|
|
33
|
+
SnakeDecimalParams,
|
|
34
|
+
SnakeFkAction,
|
|
35
|
+
SnakeForeignKeyInfo,
|
|
36
|
+
SnakeIndexInfo,
|
|
37
|
+
SnakeIntParams,
|
|
38
|
+
SnakeIntSize,
|
|
39
|
+
SnakePrimaryKeyInfo,
|
|
40
|
+
SnakeRelationshipKind,
|
|
41
|
+
SnakeRelationshipInfo,
|
|
42
|
+
SnakeStrParams,
|
|
43
|
+
SnakeTableInfo,
|
|
44
|
+
SnakeTypeParams,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PYTHON_TYPES: dict[str, type] = {
|
|
48
|
+
# Several SQL types collapse onto one Python type, the same way they do on Postgres.
|
|
49
|
+
"int": int,
|
|
50
|
+
"bigint": int,
|
|
51
|
+
"smallint": int,
|
|
52
|
+
"mediumint": int,
|
|
53
|
+
"tinyint": int, # `tinyint(1)` is handled apart: that one IS the bool
|
|
54
|
+
"varchar": str,
|
|
55
|
+
"char": str,
|
|
56
|
+
"text": str,
|
|
57
|
+
"tinytext": str,
|
|
58
|
+
"mediumtext": str,
|
|
59
|
+
"longtext": str,
|
|
60
|
+
"decimal": Decimal,
|
|
61
|
+
"double": float,
|
|
62
|
+
"float": float,
|
|
63
|
+
"date": date,
|
|
64
|
+
"datetime": datetime,
|
|
65
|
+
"timestamp": datetime,
|
|
66
|
+
"time": time,
|
|
67
|
+
"blob": bytes,
|
|
68
|
+
"tinyblob": bytes,
|
|
69
|
+
"mediumblob": bytes,
|
|
70
|
+
"longblob": bytes,
|
|
71
|
+
"binary": bytes,
|
|
72
|
+
"varbinary": bytes,
|
|
73
|
+
"json": dict,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_INT_SIZES: dict[str, SnakeIntSize] = {
|
|
77
|
+
# So a scaffolded model re-emits the width it read. Without this a legacy `INT` came back with
|
|
78
|
+
# the default `BIGINT` and the generated file widened the column in silence.
|
|
79
|
+
"smallint": SnakeIntSize.SMALLINT,
|
|
80
|
+
"int": SnakeIntSize.INTEGER,
|
|
81
|
+
"bigint": SnakeIntSize.BIGINT,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_FK_ACTIONS: dict[str, SnakeFkAction] = {
|
|
85
|
+
"CASCADE": SnakeFkAction.CASCADE,
|
|
86
|
+
"SET NULL": SnakeFkAction.SET_NULL,
|
|
87
|
+
"RESTRICT": SnakeFkAction.RESTRICT,
|
|
88
|
+
"NO ACTION": SnakeFkAction.NO_ACTION,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_UNSUPPORTED_QUERY = f"""
|
|
92
|
+
SELECT '{SnakeUnsupportedKind.TRIGGER.value}',
|
|
93
|
+
TRIGGER_NAME, EVENT_OBJECT_TABLE, NULL
|
|
94
|
+
FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = %s
|
|
95
|
+
UNION ALL
|
|
96
|
+
SELECT '{SnakeUnsupportedKind.ROUTINE.value}',
|
|
97
|
+
ROUTINE_NAME, NULL, NULL
|
|
98
|
+
FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = %s
|
|
99
|
+
UNION ALL
|
|
100
|
+
SELECT '{SnakeUnsupportedKind.UNREPRESENTABLE_COLUMN.value}',
|
|
101
|
+
TABLE_NAME, COLUMN_NAME, DATA_TYPE
|
|
102
|
+
FROM information_schema.COLUMNS
|
|
103
|
+
WHERE TABLE_SCHEMA = %s AND DATA_TYPE IN ('set', 'enum', 'geometry', 'bit', 'year')
|
|
104
|
+
UNION ALL
|
|
105
|
+
SELECT '{SnakeUnsupportedKind.CHECK.value}',
|
|
106
|
+
cc.CONSTRAINT_NAME, tc.TABLE_NAME, cc.CHECK_CLAUSE
|
|
107
|
+
FROM information_schema.CHECK_CONSTRAINTS cc
|
|
108
|
+
JOIN information_schema.TABLE_CONSTRAINTS tc
|
|
109
|
+
ON tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA
|
|
110
|
+
AND tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME
|
|
111
|
+
WHERE cc.CONSTRAINT_SCHEMA = %s
|
|
112
|
+
ORDER BY 1, 2, 3
|
|
113
|
+
"""
|
|
114
|
+
"""What the graph cannot represent, TAGGED so `scaffold` can WARN instead of dropping it.
|
|
115
|
+
|
|
116
|
+
Silence here is what makes somebody believe the generated model covers the whole database. `set`
|
|
117
|
+
and `enum` are MySQL's own column-level types and have no counterpart in a Python annotation;
|
|
118
|
+
`geometry`, `bit` and `year` have no counterpart at all.
|
|
119
|
+
|
|
120
|
+
The query says WHAT it found and hands over the pieces; the sentence is written once for the three
|
|
121
|
+
engines in `introspection.unsupported`. It used to be composed right here, and the wording drifted
|
|
122
|
+
away from the other two engines' — including into another language.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MySQLIntrospector:
|
|
127
|
+
"""Reads a MySQL/MariaDB database's schema and returns it as the SAME metadata graph."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, driver: SnakeDriver) -> None:
|
|
130
|
+
self._driver = driver
|
|
131
|
+
|
|
132
|
+
def _database(self, schema: str) -> str:
|
|
133
|
+
"""The database to read. `public` means "the connected one": it is Postgres's word.
|
|
134
|
+
|
|
135
|
+
The Protocol carries `schema` because Postgres needs it, and a caller that passes the
|
|
136
|
+
default would otherwise be asking MySQL for a database literally called `public`.
|
|
137
|
+
"""
|
|
138
|
+
if schema != DEFAULT_SCHEMA:
|
|
139
|
+
return schema
|
|
140
|
+
rows = self._driver.fetch_all("SELECT DATABASE()", ())
|
|
141
|
+
return str(rows[0][0]) if rows and rows[0][0] is not None else schema
|
|
142
|
+
|
|
143
|
+
def tables(self, schema: str = DEFAULT_SCHEMA) -> list[SnakeTableInfo]:
|
|
144
|
+
"""Every base table with its columns, PK, uniqueness, indexes, FKs and comments."""
|
|
145
|
+
database = self._database(schema)
|
|
146
|
+
names = [
|
|
147
|
+
str(row[0])
|
|
148
|
+
for row in self._driver.fetch_all(
|
|
149
|
+
"SELECT TABLE_NAME FROM information_schema.TABLES "
|
|
150
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE' "
|
|
151
|
+
"AND TABLE_NAME <> 'snake_migrations' ORDER BY TABLE_NAME",
|
|
152
|
+
(database,),
|
|
153
|
+
)
|
|
154
|
+
]
|
|
155
|
+
return [self._table(database, name) for name in names]
|
|
156
|
+
|
|
157
|
+
def _table(self, database: str, name: str) -> SnakeTableInfo:
|
|
158
|
+
"""Builds the `SnakeTableInfo` of one table."""
|
|
159
|
+
unique_columns = self._unique_columns(database, name)
|
|
160
|
+
columns = [
|
|
161
|
+
self._column(row, unique_columns)
|
|
162
|
+
for row in self._driver.fetch_all(
|
|
163
|
+
"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, "
|
|
164
|
+
"CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, "
|
|
165
|
+
"COLUMN_COMMENT, EXTRA, COLUMN_TYPE "
|
|
166
|
+
"FROM information_schema.COLUMNS "
|
|
167
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION",
|
|
168
|
+
(database, name),
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
by_name = {column.name: column for column in columns}
|
|
172
|
+
return SnakeTableInfo(
|
|
173
|
+
name=name,
|
|
174
|
+
columns=tuple(columns),
|
|
175
|
+
primary_key=SnakePrimaryKeyInfo(
|
|
176
|
+
columns=tuple(
|
|
177
|
+
by_name[column]
|
|
178
|
+
for column in self._primary_key_columns(database, name)
|
|
179
|
+
if column in by_name
|
|
180
|
+
)
|
|
181
|
+
),
|
|
182
|
+
relationships=self._relationships(database, name),
|
|
183
|
+
schema=DEFAULT_SCHEMA,
|
|
184
|
+
db_comment=self._table_comment(database, name),
|
|
185
|
+
indexes=self._indexes(database, name),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _column(
|
|
189
|
+
self, row: tuple[object, ...], unique_columns: set[str]
|
|
190
|
+
) -> SnakeColumnInfo:
|
|
191
|
+
"""Translates a row of `information_schema.COLUMNS` into column metadata."""
|
|
192
|
+
name, data_type, nullable = str(row[0]), str(row[1]), str(row[2])
|
|
193
|
+
default, extra, column_type = row[3], str(row[8]), str(row[9])
|
|
194
|
+
max_length = row[4] if isinstance(row[4], int) else None
|
|
195
|
+
precision = row[5] if isinstance(row[5], int) else None
|
|
196
|
+
scale = row[6] if isinstance(row[6], int) else None
|
|
197
|
+
comment = str(row[7]) if row[7] else None
|
|
198
|
+
|
|
199
|
+
# `tinyint(1)` is what this engine calls a boolean, and it is what the ORM writes for one.
|
|
200
|
+
# Reading it back as `int` would make every boolean column drift for ever.
|
|
201
|
+
python_type = (
|
|
202
|
+
bool
|
|
203
|
+
if column_type.lower().startswith("tinyint(1)")
|
|
204
|
+
else _PYTHON_TYPES.get(data_type, str)
|
|
205
|
+
)
|
|
206
|
+
return SnakeColumnInfo(
|
|
207
|
+
name=name,
|
|
208
|
+
python_type=python_type,
|
|
209
|
+
nullable=nullable == "YES",
|
|
210
|
+
unique=name in unique_columns,
|
|
211
|
+
attr_name=name,
|
|
212
|
+
# MySQL says it in `EXTRA`, not in the default: the type stays `int`.
|
|
213
|
+
autoincrement="auto_increment" in extra.lower(),
|
|
214
|
+
db_comment=comment,
|
|
215
|
+
# The DEFAULT is an expression (`CURRENT_TIMESTAMP`), never a Python literal.
|
|
216
|
+
server_default_sql=str(default) if default is not None else None,
|
|
217
|
+
type_params=_type_params(
|
|
218
|
+
data_type, python_type, max_length, precision, scale
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _primary_key_columns(self, database: str, name: str) -> list[str]:
|
|
223
|
+
"""The PK's columns, IN ORDER — which matters for a composite one."""
|
|
224
|
+
rows = self._driver.fetch_all(
|
|
225
|
+
"SELECT COLUMN_NAME FROM information_schema.STATISTICS "
|
|
226
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = 'PRIMARY' "
|
|
227
|
+
"ORDER BY SEQ_IN_INDEX",
|
|
228
|
+
(database, name),
|
|
229
|
+
)
|
|
230
|
+
return [str(row[0]) for row in rows]
|
|
231
|
+
|
|
232
|
+
def _unique_columns(self, database: str, name: str) -> set[str]:
|
|
233
|
+
"""Columns carrying uniqueness ON THEIR OWN.
|
|
234
|
+
|
|
235
|
+
A unique index over two columns is a table-level rule, not a property of either column, so
|
|
236
|
+
it stays out of here and comes back through `_indexes`.
|
|
237
|
+
"""
|
|
238
|
+
rows = self._driver.fetch_all(
|
|
239
|
+
"SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS "
|
|
240
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND NON_UNIQUE = 0 "
|
|
241
|
+
"AND INDEX_NAME <> 'PRIMARY'",
|
|
242
|
+
(database, name),
|
|
243
|
+
)
|
|
244
|
+
by_index: dict[str, list[str]] = {}
|
|
245
|
+
for index_name, column in rows:
|
|
246
|
+
by_index.setdefault(str(index_name), []).append(str(column))
|
|
247
|
+
return {columns[0] for columns in by_index.values() if len(columns) == 1}
|
|
248
|
+
|
|
249
|
+
def _indexes(self, database: str, name: str) -> tuple[SnakeIndexInfo, ...]:
|
|
250
|
+
"""The table's indexes, PK aside (that one is not an index in the graph)."""
|
|
251
|
+
rows = self._driver.fetch_all(
|
|
252
|
+
"SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS "
|
|
253
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME <> 'PRIMARY' "
|
|
254
|
+
"ORDER BY INDEX_NAME, SEQ_IN_INDEX",
|
|
255
|
+
(database, name),
|
|
256
|
+
)
|
|
257
|
+
grouped: dict[str, tuple[list[str], bool]] = {}
|
|
258
|
+
for index_name, column, non_unique in rows:
|
|
259
|
+
# `NON_UNIQUE` arrives as 0/1 and the driver decides whether that is an int or a string,
|
|
260
|
+
# so it is normalised through `str` before being read as a number.
|
|
261
|
+
unique = str(non_unique) == "0"
|
|
262
|
+
columns, _ = grouped.setdefault(str(index_name), ([], unique))
|
|
263
|
+
columns.append(str(column))
|
|
264
|
+
return tuple(
|
|
265
|
+
SnakeIndexInfo(columns=tuple(columns), name=index_name, unique=unique)
|
|
266
|
+
for index_name, (columns, unique) in sorted(grouped.items())
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _relationships(
|
|
270
|
+
self, database: str, name: str
|
|
271
|
+
) -> tuple[SnakeRelationshipInfo, ...]:
|
|
272
|
+
"""The outgoing FKs, as to-one relationships pointing at the target table's NAME."""
|
|
273
|
+
rows = self._driver.fetch_all(
|
|
274
|
+
"SELECT k.CONSTRAINT_NAME, k.REFERENCED_TABLE_NAME, k.COLUMN_NAME, "
|
|
275
|
+
"k.REFERENCED_COLUMN_NAME, r.DELETE_RULE, r.UPDATE_RULE "
|
|
276
|
+
"FROM information_schema.KEY_COLUMN_USAGE k "
|
|
277
|
+
"JOIN information_schema.REFERENTIAL_CONSTRAINTS r "
|
|
278
|
+
" ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA "
|
|
279
|
+
" AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME "
|
|
280
|
+
"WHERE k.TABLE_SCHEMA = %s AND k.TABLE_NAME = %s "
|
|
281
|
+
"AND k.REFERENCED_TABLE_NAME IS NOT NULL "
|
|
282
|
+
"ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION",
|
|
283
|
+
(database, name),
|
|
284
|
+
)
|
|
285
|
+
grouped: dict[str, list[tuple[object, ...]]] = {}
|
|
286
|
+
for row in rows:
|
|
287
|
+
grouped.setdefault(str(row[0]), []).append(row)
|
|
288
|
+
return tuple(
|
|
289
|
+
self._relationship(constraint, group)
|
|
290
|
+
for constraint, group in sorted(grouped.items())
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _relationship(
|
|
294
|
+
self, constraint: str, rows: list[tuple[object, ...]]
|
|
295
|
+
) -> SnakeRelationshipInfo:
|
|
296
|
+
"""One FK — COMPOSITE included — as a to-one relationship.
|
|
297
|
+
|
|
298
|
+
Grouped by constraint name and not one per column: a two-column FK is ONE relationship, and
|
|
299
|
+
splitting it would produce a mirror with two half-relationships that reference nothing.
|
|
300
|
+
"""
|
|
301
|
+
target = str(rows[0][1])
|
|
302
|
+
pairs = tuple((str(row[2]), str(row[3])) for row in rows)
|
|
303
|
+
return SnakeRelationshipInfo(
|
|
304
|
+
name=constraint,
|
|
305
|
+
target=target,
|
|
306
|
+
kind=SnakeRelationshipKind.TO_ONE,
|
|
307
|
+
foreign_key=SnakeForeignKeyInfo(
|
|
308
|
+
target=target,
|
|
309
|
+
pairs=pairs,
|
|
310
|
+
on_delete=_FK_ACTIONS.get(str(rows[0][4]), SnakeFkAction.NO_ACTION),
|
|
311
|
+
on_update=_FK_ACTIONS.get(str(rows[0][5]), SnakeFkAction.NO_ACTION),
|
|
312
|
+
),
|
|
313
|
+
target_table=target,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _table_comment(self, database: str, name: str) -> str | None:
|
|
317
|
+
"""The table's comment, if it carries one.
|
|
318
|
+
|
|
319
|
+
MySQL answers `''` rather than NULL when there is none, and for an InnoDB table it can also
|
|
320
|
+
answer engine chatter like `InnoDB free: ...`, which is not a comment anybody wrote.
|
|
321
|
+
"""
|
|
322
|
+
rows = self._driver.fetch_all(
|
|
323
|
+
"SELECT TABLE_COMMENT FROM information_schema.TABLES "
|
|
324
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
|
|
325
|
+
(database, name),
|
|
326
|
+
)
|
|
327
|
+
if not rows or not rows[0][0]:
|
|
328
|
+
return None
|
|
329
|
+
comment = str(rows[0][0])
|
|
330
|
+
return None if comment.startswith("InnoDB free") else comment
|
|
331
|
+
|
|
332
|
+
def unsupported(self, schema: str = DEFAULT_SCHEMA) -> list[str]:
|
|
333
|
+
"""Objects the ORM cannot represent, described so it can WARN (never dropped in silence)."""
|
|
334
|
+
database = self._database(schema)
|
|
335
|
+
rows = self._driver.fetch_all(
|
|
336
|
+
_UNSUPPORTED_QUERY, (database, database, database, database)
|
|
337
|
+
)
|
|
338
|
+
return warnings_from_rows(rows)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _type_params(
|
|
342
|
+
data_type: str,
|
|
343
|
+
python_type: type,
|
|
344
|
+
max_length: int | None,
|
|
345
|
+
precision: int | None,
|
|
346
|
+
scale: int | None,
|
|
347
|
+
) -> SnakeTypeParams | None:
|
|
348
|
+
"""The parameters of the family the type really belongs to — one object, chosen deliberately.
|
|
349
|
+
|
|
350
|
+
Same reasoning as the Postgres one: firing all the knobs at every column made an `int` come back
|
|
351
|
+
carrying a `max_length` that meant nothing. Choosing forces you to look at the type.
|
|
352
|
+
|
|
353
|
+
`bool` is checked FIRST, and it has to be: a `tinyint(1)` is an integer as far as
|
|
354
|
+
`information_schema` is concerned, so it would otherwise come back with an `int_size` and
|
|
355
|
+
scaffold as `snake_int()` over a `SnakeColumn[bool]` — a contradiction the compiler rejects.
|
|
356
|
+
"""
|
|
357
|
+
if python_type is bool:
|
|
358
|
+
return None
|
|
359
|
+
if data_type == "decimal" and precision is not None:
|
|
360
|
+
return SnakeDecimalParams(precision, scale)
|
|
361
|
+
if data_type in _INT_SIZES:
|
|
362
|
+
return SnakeIntParams(size=_INT_SIZES[data_type])
|
|
363
|
+
if data_type in ("varchar", "char") and max_length is not None:
|
|
364
|
+
# Only on the bounded types: `TEXT` also reports a length (65535), and re-emitting it would
|
|
365
|
+
# turn a TEXT column into a VARCHAR(65535) that says something the schema never said.
|
|
366
|
+
return SnakeStrParams(max_length=max_length, fixed=data_type == "char")
|
|
367
|
+
return None
|