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,384 @@
1
+ """THE MATRIX: what every engine answers to every capability, in one place.
2
+
3
+ Here and not in each dialect because the question is horizontal — "what does each engine do about
4
+ this?" — and answering it meant opening four files. WHAT an engine can do lives here; HOW it does it
5
+ stays in its dialect.
6
+
7
+ It is also what makes MariaDB and MySQL two columns: one dialect serves both, and they differ.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import Enum, auto
13
+
14
+ from snakeorm.dialects.capabilities import (
15
+ Cap,
16
+ Degraded,
17
+ Full,
18
+ Nope,
19
+ Since,
20
+ SnakeCapabilities,
21
+ Support,
22
+ )
23
+
24
+
25
+ class Engine(Enum):
26
+ """The engines, one per column. MariaDB and MySQL are two: measured, they are not the same."""
27
+
28
+ POSTGRES = auto()
29
+ MARIADB = auto()
30
+ MYSQL = auto()
31
+ SQLITE = auto()
32
+
33
+
34
+ MATRIX: dict[Cap, dict[Engine, Support | Since]] = {
35
+ Cap.RETURNING: {
36
+ Engine.POSTGRES: Full(),
37
+ Engine.MARIADB: Full(), # `INSERT ... RETURNING id` answers the rows, measured 10.11-11.8
38
+ Engine.MYSQL: Nope(
39
+ "it has no RETURNING: the autoincrement PK is recovered with lastrowid, so a write that needs the returned rows makes one extra round trip"
40
+ ),
41
+ Engine.SQLITE: Full(),
42
+ },
43
+ Cap.ROW_CONSTRUCTOR: {
44
+ Engine.POSTGRES: Full(),
45
+ Engine.MARIADB: Full(),
46
+ Engine.MYSQL: Full(),
47
+ Engine.SQLITE: Full(),
48
+ },
49
+ Cap.TRANSACTIONAL_DDL: {
50
+ Engine.POSTGRES: Full(),
51
+ Engine.MARIADB: Nope(
52
+ "DDL commits implicitly: an N-step migration is NOT all-or-nothing, and if step 3 fails, the first two are already applied"
53
+ ),
54
+ Engine.MYSQL: Nope(
55
+ "DDL commits implicitly: an N-step migration is NOT all-or-nothing, and if step 3 fails, the first two are already applied"
56
+ ),
57
+ Engine.SQLITE: Full(),
58
+ },
59
+ Cap.UPSERT: {
60
+ Engine.POSTGRES: Full(),
61
+ Engine.MARIADB: Full(),
62
+ Engine.MYSQL: Full(),
63
+ Engine.SQLITE: Full(),
64
+ },
65
+ Cap.CHECK_CONSTRAINT_DDL: {
66
+ Engine.POSTGRES: Full(),
67
+ Engine.MARIADB: Full(),
68
+ Engine.MYSQL: Full(),
69
+ Engine.SQLITE: Since(
70
+ (3, 53, 0),
71
+ "ALTER TABLE ... ADD CONSTRAINT ... CHECK",
72
+ below=Nope(
73
+ "it does not accept ALTER TABLE ... ADD CONSTRAINT: a CHECK can only travel inside the CREATE TABLE, so putting one on an existing table takes rebuilding it, which is what `RebuildTable` does"
74
+ ),
75
+ ),
76
+ },
77
+ Cap.ADD_CONSTRAINT: {
78
+ Engine.POSTGRES: Full(),
79
+ Engine.MARIADB: Full(),
80
+ Engine.MYSQL: Full(),
81
+ Engine.SQLITE: Nope(
82
+ "it does not accept ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY: FKs go INSIDE the CREATE TABLE, so the plan emits them there and orders the tables topologically"
83
+ ),
84
+ },
85
+ Cap.ALTER_COLUMN: {
86
+ Engine.POSTGRES: Full(),
87
+ Engine.MARIADB: Full(),
88
+ Engine.MYSQL: Full(),
89
+ Engine.SQLITE: Nope(
90
+ "it cannot change the type nor the nullability of an existing column: it would demand rebuilding the whole table"
91
+ ),
92
+ },
93
+ Cap.SCHEMAS: {
94
+ Engine.POSTGRES: Full(),
95
+ Engine.MARIADB: Nope(
96
+ "it has no named schemas: in MySQL a 'schema' IS a database, not a namespace inside one"
97
+ ),
98
+ Engine.MYSQL: Nope(
99
+ "it has no named schemas: in MySQL a 'schema' IS a database, not a namespace inside one"
100
+ ),
101
+ Engine.SQLITE: Nope(
102
+ "it has no named schemas; its 'schemas' are ATTACHED databases (ATTACH)"
103
+ ),
104
+ },
105
+ Cap.STORED_FUNCTIONS: {
106
+ Engine.POSTGRES: Full(),
107
+ Engine.MARIADB: Full(), # `CREATE OR REPLACE FUNCTION`, which is what a re-run needs
108
+ Engine.MYSQL: Nope(
109
+ "a routine's body is raw SQL and replacing one relies on CREATE OR REPLACE FUNCTION, which MariaDB accepts and MySQL rejects outright. This dialect serves both, so it cannot promise what only one of them does"
110
+ ),
111
+ Engine.SQLITE: Nope(
112
+ "it does not store functions: SQLite's are registered from the process that opens the connection, so they do not live in the database and a migration cannot create them"
113
+ ),
114
+ },
115
+ Cap.ROW_LOCKING: {
116
+ Engine.POSTGRES: Full(),
117
+ Engine.MARIADB: Full(),
118
+ Engine.MYSQL: Full(),
119
+ Engine.SQLITE: Nope(
120
+ "it cannot lock rows (SELECT ... FOR UPDATE): it locks the whole FILE"
121
+ ),
122
+ },
123
+ Cap.SET_ISOLATION: {
124
+ Engine.POSTGRES: Full(),
125
+ Engine.MARIADB: Full(),
126
+ Engine.MYSQL: Full(),
127
+ Engine.SQLITE: Nope(
128
+ "it has no SET TRANSACTION ISOLATION LEVEL: one writer at a time makes its transactions serialisable already, and the only knob it offers, PRAGMA read_uncommitted, LOWERS the isolation instead of raising it"
129
+ ),
130
+ },
131
+ Cap.TEXT_IN_PRIMARY_KEY: {
132
+ Engine.POSTGRES: Full(),
133
+ Engine.MARIADB: Nope(
134
+ "a key needs a length and TEXT has none, so it answers error 1170 and the whole CREATE TABLE dies. Give the column a `max_length` and it becomes a VARCHAR"
135
+ ),
136
+ Engine.MYSQL: Nope(
137
+ "a key needs a length and TEXT has none, so it answers error 1170 and the whole CREATE TABLE dies. Give the column a `max_length` and it becomes a VARCHAR"
138
+ ),
139
+ Engine.SQLITE: Full(),
140
+ },
141
+ Cap.COMMENTS: {
142
+ Engine.POSTGRES: Full(),
143
+ Engine.MARIADB: Degraded(
144
+ "it has no COMMENT ON: a table comment is a clause (CREATE TABLE ... COMMENT =, ALTER TABLE ... COMMENT =) and a COLUMN comment can only change by rewriting the whole column with MODIFY COLUMN. The definition is respelled from the model, so what the model declares survives; anything the database holds that the model does not describe (a collation, ON UPDATE CURRENT_TIMESTAMP, a generated expression) does not. An empty comment and no comment are also the same value here"
145
+ ),
146
+ Engine.MYSQL: Degraded(
147
+ "it has no COMMENT ON: a table comment is a clause (CREATE TABLE ... COMMENT =, ALTER TABLE ... COMMENT =) and a COLUMN comment can only change by rewriting the whole column with MODIFY COLUMN. The definition is respelled from the model, so what the model declares survives; anything the database holds that the model does not describe (a collation, ON UPDATE CURRENT_TIMESTAMP, a generated expression) does not. An empty comment and no comment are also the same value here"
148
+ ),
149
+ Engine.SQLITE: Nope(
150
+ "it does not store COMMENT ON, so db_comment values are omitted"
151
+ ),
152
+ },
153
+ Cap.REPLACE_VIEW: {
154
+ Engine.POSTGRES: Full(),
155
+ Engine.MARIADB: Full(),
156
+ Engine.MYSQL: Full(),
157
+ Engine.SQLITE: Nope(
158
+ "it has no CREATE OR REPLACE VIEW: altering a view is emulated with DROP + CREATE"
159
+ ),
160
+ },
161
+ Cap.PARENTHESISED_COMPOUND: {
162
+ Engine.POSTGRES: Full(),
163
+ Engine.MARIADB: Full(),
164
+ Engine.MYSQL: Full(),
165
+ Engine.SQLITE: Nope(
166
+ "it rejects parentheses in the branches of a UNION/EXCEPT/INTERSECT, so a LIMIT cannot be confined to one branch"
167
+ ),
168
+ },
169
+ Cap.CTE_IN_COMPOUND_BRANCH: {
170
+ Engine.POSTGRES: Full(),
171
+ Engine.MARIADB: Nope(
172
+ "it does not accept a WITH RECURSIVE as a branch of a UNION/EXCEPT/INTERSECT (error 1064) even though it parenthesises branches perfectly well, so a recursion cannot be composed with a set operation: run it on its own"
173
+ ),
174
+ Engine.MYSQL: Full(), # takes it as a branch of a compound, measured 8.0-9.7
175
+ Engine.SQLITE: Nope(
176
+ 'it does not accept a WITH RECURSIVE as a branch of a UNION/EXCEPT/INTERSECT (near "WITH": syntax error), so a recursion cannot be composed with a set operation: run it on its own'
177
+ ),
178
+ },
179
+ Cap.ILIKE: {
180
+ Engine.POSTGRES: Full(),
181
+ Engine.MARIADB: Degraded(
182
+ "it has no ILIKE, so the case-insensitive match is written LOWER(a) LIKE LOWER(b): it matches, and what it folds is whatever the column's collation folds, which is a decision of the schema and not of the query"
183
+ ),
184
+ Engine.MYSQL: Degraded(
185
+ "it has no ILIKE, so the case-insensitive match is written LOWER(a) LIKE LOWER(b): it matches, and what it folds is whatever the column's collation folds, which is a decision of the schema and not of the query"
186
+ ),
187
+ Engine.SQLITE: Degraded(
188
+ "it has no ILIKE: the case-insensitive match is written LOWER(a) LIKE LOWER(b), which matches and folds only ASCII"
189
+ ),
190
+ },
191
+ Cap.INDEX_METHODS: {
192
+ Engine.POSTGRES: Full(),
193
+ Engine.MARIADB: Degraded(
194
+ "it has USING BTREE and HASH, but not the Postgres methods (GIN, GIST, BRIN)"
195
+ ),
196
+ Engine.MYSQL: Degraded(
197
+ "it has USING BTREE and HASH, but not the Postgres methods (GIN, GIST, BRIN)"
198
+ ),
199
+ Engine.SQLITE: Nope(
200
+ "it has only one kind of index, so it does not accept method= (GIN, GIST...)"
201
+ ),
202
+ },
203
+ Cap.PARTIAL_INDEXES: {
204
+ Engine.POSTGRES: Full(),
205
+ Engine.MARIADB: Nope(
206
+ "it has no partial indexes: WHERE is not part of its CREATE INDEX, so a SEARCH index declared with where= is created over the WHOLE table — it finds the same rows and only costs more space — while a partial UNIQUE one is refused, because widening it would forbid duplicates the domain allows. If you need the partial uniqueness on this engine, enforce it with a generated column plus a plain UNIQUE over it, which is the one MySQL idiom that expresses the same rule"
207
+ ),
208
+ Engine.MYSQL: Nope(
209
+ "it has no partial indexes: WHERE is not part of its CREATE INDEX, so a SEARCH index declared with where= is created over the WHOLE table — it finds the same rows and only costs more space — while a partial UNIQUE one is refused, because widening it would forbid duplicates the domain allows. If you need the partial uniqueness on this engine, enforce it with a generated column plus a plain UNIQUE over it, which is the one MySQL idiom that expresses the same rule"
210
+ ),
211
+ Engine.SQLITE: Full(),
212
+ },
213
+ Cap.DROP_COLUMN_CASCADES_FK: {
214
+ Engine.POSTGRES: Full(),
215
+ Engine.MARIADB: Nope(
216
+ "dropping a column that a foreign key still holds answers error 1553: InnoDB needs the index the key sits on. The key has to be dropped first, in its own operation — declare the `DropForeignKey` before the `DropColumn` and the same migration runs on all three engines"
217
+ ),
218
+ Engine.MYSQL: Nope(
219
+ "dropping a column that a foreign key still holds answers error 1553: InnoDB needs the index the key sits on. The key has to be dropped first, in its own operation — declare the `DropForeignKey` before the `DropColumn` and the same migration runs on all three engines"
220
+ ),
221
+ Engine.SQLITE: Nope(
222
+ "it refuses to drop a column that a foreign key names, and it has no DROP CONSTRAINT to take the key out of the way first: the table has to be rebuilt (create the new one without the column, copy the rows, drop the old one and rename), which is the user's call and goes in an explicit RunSQL"
223
+ ),
224
+ },
225
+ Cap.DECIMAL_ORDERING: {
226
+ Engine.POSTGRES: Full(),
227
+ Engine.MARIADB: Full(),
228
+ Engine.MYSQL: Full(),
229
+ Engine.SQLITE: Degraded(
230
+ "a Decimal is stored as TEXT and comes back exact, but ORDER BY and comparisons sort it lexicographically: '9.99' comes after '10.00'"
231
+ ),
232
+ },
233
+ Cap.TIMESTAMPTZ: {
234
+ Engine.POSTGRES: Full(),
235
+ Engine.MARIADB: Degraded(
236
+ "it has no usable type with a time zone: TIMESTAMP tops out in 2038 and DATETIME is not tz-aware, so a SnakeUtc is stored as ISO-8601 TEXT. The instant comes back whole, but the engine does not treat it as a date when sorting, comparing or operating"
237
+ ),
238
+ Engine.MYSQL: Degraded(
239
+ "it has no usable type with a time zone: TIMESTAMP tops out in 2038 and DATETIME is not tz-aware, so a SnakeUtc is stored as ISO-8601 TEXT. The instant comes back whole, but the engine does not treat it as a date when sorting, comparing or operating"
240
+ ),
241
+ Engine.SQLITE: Degraded(
242
+ "it does not tell timestamptz from timestamp: both are ISO-8601 TEXT, and the time zone travels in the text instead of being something the engine understands"
243
+ ),
244
+ },
245
+ Cap.INTERVAL: {
246
+ Engine.POSTGRES: Full(),
247
+ Engine.MARIADB: Degraded(
248
+ "it has no interval type: a timedelta is stored as TEXT, so the engine cannot compare it as a duration"
249
+ ),
250
+ Engine.MYSQL: Degraded(
251
+ "it has no interval type: a timedelta is stored as TEXT, so the engine cannot compare it as a duration"
252
+ ),
253
+ Engine.SQLITE: Degraded(
254
+ "it has no interval type: a timedelta is stored as TEXT, so the engine cannot compare it as a duration"
255
+ ),
256
+ },
257
+ Cap.JSON: {
258
+ Engine.POSTGRES: Full(),
259
+ Engine.MARIADB: Degraded(
260
+ "it has a JSON type, but it ignores the declared backing: there is no JSONB, so storage= changes nothing here"
261
+ ),
262
+ Engine.MYSQL: Degraded(
263
+ "it has a JSON type, but it ignores the declared backing: there is no JSONB, so storage= changes nothing here"
264
+ ),
265
+ Engine.SQLITE: Degraded(
266
+ "JSON is TEXT: the json_* functions operate on it, but there is no type, no validation on write, and no indexes over its keys"
267
+ ),
268
+ },
269
+ Cap.UUID: {
270
+ Engine.POSTGRES: Full(),
271
+ Engine.MARIADB: Full(), # native UUID type since 10.7, and it validates: bad input answers 1292
272
+ Engine.MYSQL: Degraded(
273
+ "it has no UUID type: it goes as CHAR(36), with no validation from the engine"
274
+ ),
275
+ Engine.SQLITE: Degraded(
276
+ "a UUID is stored as TEXT, with no type and no validation"
277
+ ),
278
+ },
279
+ Cap.BOOLEAN: {
280
+ Engine.POSTGRES: Full(),
281
+ Engine.MARIADB: Degraded(
282
+ "it has no boolean: a bool is TINYINT(1), so the engine accepts any one-byte integer there"
283
+ ),
284
+ Engine.MYSQL: Degraded(
285
+ "it has no boolean: a bool is TINYINT(1), so the engine accepts any one-byte integer there"
286
+ ),
287
+ Engine.SQLITE: Degraded(
288
+ "it has no boolean: a bool is stored as 0/1 in an INTEGER"
289
+ ),
290
+ },
291
+ Cap.INT_WIDTHS: {
292
+ Engine.POSTGRES: Full(),
293
+ Engine.MARIADB: Full(),
294
+ Engine.MYSQL: Full(),
295
+ Engine.SQLITE: Degraded(
296
+ "it does not tell integer widths apart: SMALLINT, INTEGER and BIGINT are the same INTEGER, so a model that depends on the range does not fail here and does fail on Postgres"
297
+ ),
298
+ },
299
+ Cap.ARRAYS: {
300
+ Engine.POSTGRES: Full(),
301
+ Engine.MARIADB: Degraded(
302
+ "it has no arrays: a list[T] is stored as JSON in a TEXT column and comes back as the same list, but the engine cannot query INSIDE it"
303
+ ),
304
+ Engine.MYSQL: Degraded(
305
+ "it has no arrays: a list[T] is stored as JSON in a TEXT column and comes back as the same list, but the engine cannot query INSIDE it"
306
+ ),
307
+ Engine.SQLITE: Degraded(
308
+ "it has no arrays: a list[T] is stored as JSON in a TEXT column and comes back as the same list, but the engine cannot query INSIDE it nor index its elements"
309
+ ),
310
+ },
311
+ Cap.FLOAT_SPECIALS: {
312
+ Engine.POSTGRES: Full(),
313
+ Engine.MARIADB: Degraded(
314
+ "it does not store the floating-point specials: a NaN or an infinity is an error, not a value"
315
+ ),
316
+ Engine.MYSQL: Degraded(
317
+ "it does not store the floating-point specials: a NaN or an infinity is an error, not a value"
318
+ ),
319
+ Engine.SQLITE: Degraded(
320
+ "it does not store the floating-point specials: a NaN float comes back NULL"
321
+ ),
322
+ },
323
+ Cap.CALENDAR_INTERVAL: {
324
+ Engine.POSTGRES: Full(),
325
+ Engine.MARIADB: Full(),
326
+ Engine.MYSQL: Full(),
327
+ Engine.SQLITE: Degraded(
328
+ "months and years OVERFLOW instead of clamping to the end of the month: 2026-01-31 plus one month is 2026-03-03 here and 2026-02-28 on the other two. Days, hours, minutes and seconds are identical everywhere"
329
+ ),
330
+ },
331
+ }
332
+ """Every capability against every engine. Incomplete rows do not get past `capabilities_for`."""
333
+
334
+
335
+ def flavour_of(version_string: str) -> Engine | None:
336
+ """Which of the two servers said that, or `None` when it is not recognisable.
337
+
338
+ MariaDB writes its name into `SELECT VERSION()` and MySQL does not; Django reads it the same way.
339
+ Anything else answers `None` and not a guess: MySQL is the restrictive one for `RETURNING` and
340
+ the permissive one for `CTE_IN_COMPOUND_BRANCH`, so no flavour is the safe bet both ways.
341
+ """
342
+ if "mariadb" in version_string.lower():
343
+ return Engine.MARIADB
344
+ if version_string[:1].isdigit():
345
+ return Engine.MYSQL
346
+ return None
347
+
348
+
349
+ def _strictest(supports: tuple[Support | Since, ...]) -> Support | Since:
350
+ """The most restrictive of several answers: `Nope` beats `Degraded`, which beats `Full`."""
351
+ for kind in (Nope, Degraded, Since):
352
+ for support in supports:
353
+ if isinstance(support, kind):
354
+ return support
355
+ return supports[0]
356
+
357
+
358
+ def capabilities_for(
359
+ engine: Engine, engine_version: tuple[int, ...] | None = None
360
+ ) -> SnakeCapabilities:
361
+ """One engine's column, ready for its dialect.
362
+
363
+ `engine_version` only matters to a `Since`; the other states ignore it.
364
+ """
365
+ return SnakeCapabilities(
366
+ {cap: row[engine] for cap, row in MATRIX.items()}, engine_version=engine_version
367
+ )
368
+
369
+
370
+ def strictest_of(
371
+ engines: tuple[Engine, ...], engine_version: tuple[int, ...] | None = None
372
+ ) -> SnakeCapabilities:
373
+ """What ALL of these engines can do, capability by capability.
374
+
375
+ The honest answer while the flavour is unknown, and the same one the dialect gave before the two
376
+ had columns — so a dialect built without a connection behaves as it always did.
377
+ """
378
+ return SnakeCapabilities(
379
+ {
380
+ cap: _strictest(tuple(row[e] for e in engines))
381
+ for cap, row in MATRIX.items()
382
+ },
383
+ engine_version=engine_version,
384
+ )