snake-orm 0.1.0b1__py3-none-any.whl

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