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,633 @@
1
+ """Generation of model CODE from an introspected graph (`scaffold`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyword
6
+ import os.path
7
+ import re
8
+ from dataclasses import dataclass, replace
9
+ from collections.abc import Sequence
10
+
11
+ from datetime import datetime
12
+ from decimal import Decimal
13
+
14
+ from snakeorm.metadata import (
15
+ SnakeColumnInfo,
16
+ SnakeIndexInfo,
17
+ SnakeIntSize,
18
+ SnakeJsonStorage,
19
+ SnakePrimaryKeyInfo,
20
+ SnakeRelationshipInfo,
21
+ SnakeRelationshipKind,
22
+ SnakeTableInfo,
23
+ )
24
+ from snakeorm.core.placement import DEFAULT_SCHEMA
25
+ from snakeorm.helpers.pyliteral import str_lit
26
+ from snakeorm.helpers.pytype import FROM_SNAKEORM, type_annotation
27
+
28
+ _HEADER = '''"""Models generated by `snakeorm scaffold`. DO NOT EDIT BY HAND.
29
+
30
+ This file TRANSLATES a live database into models. Point the scaffolder at any schema — one you own,
31
+ one that belongs to another repository, one a serverless function has to talk to — and what comes
32
+ out queries through the same typed API as a hand-written model, under Django, FastAPI or Flask, or
33
+ under plain Python with no framework at all.
34
+
35
+ It is a MIRROR, and `scaffold update` REGENERATES it whole: anything you write here is lost. Your
36
+ own code goes in another file.
37
+
38
+ There is no in-place adoption. Swapping `@snake_db_first` for `@snake_model` does NOT hand the
39
+ table to migrations — the history has never seen it, so the autogenerated `CreateTable` meets the
40
+ table that already exists and dies with `DuplicateTable`.
41
+
42
+ And a mirror carries what the DATABASE holds, which is a lossy projection of code. Signals,
43
+ `default_factory`, enum classes, methods, docstrings and domain constants live in a `.py`, and no
44
+ row of the catalogue mentions them: they are not here, and they cannot be.
45
+ """
46
+
47
+ from __future__ import annotations
48
+ '''
49
+
50
+ # `snakeorm` symbols the generated file ALWAYS imports. The knobs (SnakeIntSize, SnakeJsonStorage)
51
+ # are only added when some column uses them, so as not to leave dead imports behind.
52
+ _SNAKE_BASE = {"SnakeColumn", "SnakeModel", "snake_column", "snake_db_first"}
53
+
54
+
55
+ def _mirrors(name: str) -> bool:
56
+ """Whether this database name can be COPIED into Python as it stands.
57
+
58
+ ASCII, a valid identifier, and not a keyword. Nothing else is asked and nothing is transformed:
59
+ the name that comes back is the name that went in.
60
+
61
+ ASCII on purpose, even though Python 3 accepts `商品` as an identifier. A class nobody can type
62
+ on the keyboard they read the traceback with is a mirror nobody can use — and where the line
63
+ falls is a judgement, so it is written here rather than left to the shape of a regex.
64
+ """
65
+ return name.isascii() and name.isidentifier() and not keyword.iskeyword(name)
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class SnakeMirrorNames:
70
+ """How a table becomes a class name. Both switches default ON."""
71
+
72
+ capwords: bool = True
73
+ """Split on `_` and capitalise each part: `project_requests` → `ProjectRequests`.
74
+
75
+ MECHANICAL and that is why it is the default: it knows no language and loses nothing. What it
76
+ deliberately does NOT do is remove a trailing `s` — that is a guess about English, and it turned
77
+ `status` into `Statu`, `analysis` into `Analysi` and `direcciones` into `Direccione`.
78
+
79
+ Off, the underscores stay and only the first letter is raised: `Project_requests`. It is the
80
+ spelling somebody grepping between the model and the table asks for. Neither setting ever LOWERS
81
+ a letter, so `ProductReQuests` keeps its capitals both ways.
82
+ """
83
+ include_schema: bool = True
84
+ """Prefix the schema, `public` INCLUDED: `public.orders` → `PublicOrders`.
85
+
86
+ No carve-out, and the missing exception is the feature. Two schemas holding a table of the same
87
+ name is ordinary —`sales.orders` beside `hr.orders`— and without the prefix they are ONE class:
88
+ the mirror keeps whichever came last and says nothing. Prefixing only when the schema is not
89
+ `public` would be a rule with a special case, and a special case is what somebody falls through.
90
+
91
+ Off for a database with one schema, where the prefix is noise. `unrepresentable` then reports
92
+ the collisions the prefix was preventing.
93
+ """
94
+ strip_id_suffix: bool = True
95
+ """Name a to-one after its LOCAL COLUMN with a trailing `_id` removed: `brand_id` → `brand`.
96
+
97
+ It is the convention the hand-written models keep, and the reason the name does not come from
98
+ the target TABLE: the introspector's own `name` field holds `brands`, which would give
99
+ `cars.brands` where a person writes `cars.brand`.
100
+
101
+ A composite key takes the prefix its columns SHARE —`stock_warehouse_id` + `stock_sku_id` →
102
+ `stock`— because naming it after either column would be a coin toss.
103
+
104
+ Off, the relationship keeps the column's whole name and collides with the column itself. That
105
+ is not a bug in the switch: it is what a database whose FK columns carry no suffix does anyway,
106
+ and `unrepresentable` reports it either way rather than letting one attribute replace the other.
107
+ """
108
+
109
+
110
+ def _relationship_name(
111
+ relationship: SnakeRelationshipInfo, names: SnakeMirrorNames
112
+ ) -> str:
113
+ """What to call a to-one: its local column(s) without the `_id`, or their shared prefix."""
114
+ locals_ = [pair[0] for pair in relationship.foreign_key.pairs]
115
+ if not names.strip_id_suffix:
116
+ return locals_[0]
117
+ stripped = [
118
+ column[: -len("_id")] if column.endswith("_id") else column
119
+ for column in locals_
120
+ ]
121
+ if len(stripped) == 1:
122
+ return stripped[0]
123
+ shared = os.path.commonprefix(stripped).rstrip("_")
124
+ return shared or stripped[0]
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class _Collection:
129
+ """One `snake_to_many` on the parent: its attribute, the child class, the child's to-one."""
130
+
131
+ attribute: str
132
+ child_class: str
133
+ fk_name: str
134
+
135
+
136
+ def _collections(
137
+ tables: Sequence[SnakeTableInfo], names: SnakeMirrorNames
138
+ ) -> tuple[dict[tuple[str, str], list[_Collection]], list[str]]:
139
+ """The inverse of every to-one that gets written, grouped by the table it hangs off.
140
+
141
+ Computed BEFORE anything is emitted because a collection lives on the PARENT while the key
142
+ lives on the CHILD, and the two are rendered in whatever order the table names sort in.
143
+
144
+ The attribute is the CHILD TABLE's name as it stands — see this module's own naming note. Its
145
+ only failure is a collision, and a collision is decidable by looking at one class body: it
146
+ can hit a column, a to-one, or another collection when two keys of the same child point at
147
+ the same parent. All three are reported, and none of the three is emitted.
148
+ """
149
+ present = {(table.schema, table.name) for table in tables}
150
+ rendered = [table for table in tables if _mirrors_as_class(table, names)]
151
+ proposed: dict[tuple[str, str], dict[str, list[_Collection]]] = {}
152
+ for table in rendered:
153
+ for attribute, relationship in _emitted_to_ones(table, names, present):
154
+ parent = (table.schema, relationship.foreign_key.target)
155
+ proposed.setdefault(parent, {}).setdefault(table.name, []).append(
156
+ _Collection(
157
+ attribute=table.name,
158
+ child_class=_class_name(table, names),
159
+ fk_name=attribute,
160
+ )
161
+ )
162
+ by_parent: dict[tuple[str, str], list[_Collection]] = {}
163
+ complaints: list[str] = []
164
+ for table in rendered:
165
+ for child_name, candidates in proposed.get(
166
+ (table.schema, table.name), {}
167
+ ).items():
168
+ if len(candidates) > 1:
169
+ complaints.append(
170
+ f"table {child_name!r} has two foreign keys pointing at "
171
+ f"{table.name!r} ({', '.join(sorted(item.fk_name for item in candidates))}), "
172
+ f"so both collections would be called {child_name!r}: nothing in the "
173
+ f"database breaks the tie, and both are left out"
174
+ )
175
+ continue
176
+ collection = candidates[0]
177
+ taken = {_attribute(column.name) for column in table.columns} | {
178
+ attribute for attribute, _ in _emitted_to_ones(table, names, present)
179
+ }
180
+ if collection.attribute in taken:
181
+ complaints.append(
182
+ f"the inverse of {child_name!r} on table {table.name!r} would be called "
183
+ f"{collection.attribute!r}, which is already a column or a relationship: "
184
+ f"the collection is left out"
185
+ )
186
+ continue
187
+ by_parent.setdefault((table.schema, table.name), []).append(collection)
188
+ return by_parent, complaints
189
+
190
+
191
+ # What the generated file already has in scope. A mirror that derives one of these SHADOWS it, and
192
+ # that is the failure of this family that compiles: the file imports clean, `snake_link()` runs, and
193
+ # every class declared after the mirror inherits from the mirror instead of from the base.
194
+ _TAKEN_BY_THE_FILE = frozenset(
195
+ {"SnakeModel", "SnakeColumn", "SnakeToOne", "SnakeToMany", "snakeorm"}
196
+ )
197
+
198
+
199
+ def _mirrors_as_class(table: SnakeTableInfo, names: SnakeMirrorNames) -> bool:
200
+ """Whether this table becomes a class at all: its name, its schema, and the name it DERIVES.
201
+
202
+ The third one was missing, and it is the one that bites. `_class_name` capitalises AFTER this
203
+ check ran, so `none` passed —`none` is not a keyword— and came out as `None`, and the generated
204
+ file did not parse while `unrepresentable()` reported nothing. That contradicts its own
205
+ docstring, whose entire reason to exist is that the file must not fail to parse in silence.
206
+
207
+ Measured, the family fails three ways: `none`/`true`/`false` derive keywords; `_` derives the
208
+ empty string and writes `class (SnakeModel):`; and `snake_model` derives `SnakeModel`, which
209
+ PARSES and shadows the import. That last one is the dangerous one, and the only one a
210
+ "does it compile?" check cannot see.
211
+
212
+ It is the same shape as `_to_one_refusal` below — validating what gets WRITTEN, not what came
213
+ in — which is the neighbour that had already solved this pattern.
214
+ """
215
+ if not _mirrors(table.name):
216
+ return False
217
+ if names.include_schema and not _mirrors(table.schema):
218
+ return False
219
+ derived = _class_name(table, names)
220
+ return _mirrors(derived) and derived not in _TAKEN_BY_THE_FILE
221
+
222
+
223
+ def _to_one_refusal(
224
+ table: SnakeTableInfo,
225
+ relationship: SnakeRelationshipInfo,
226
+ names: SnakeMirrorNames,
227
+ present: set[tuple[str, str]],
228
+ ) -> str | None:
229
+ """WHY this to-one is not written, or `None` when it is. The single definition of that.
230
+
231
+ It used to be spelled four times — three complaints here and three `continue`s in the
232
+ renderer — and the inverse side turns that duplication from untidy into dangerous: a
233
+ `snake_to_many` naming a to-one the renderer skipped points at a relation nobody declared,
234
+ and `snake_link()` dies on import.
235
+ """
236
+ attribute = _relationship_name(relationship, names)
237
+ locals_ = [pair[0] for pair in relationship.foreign_key.pairs]
238
+ # A to-one whose name is already a column's would REPLACE it in the class body. Skipping it
239
+ # quietly is what the old `__` did with columns, and the mirror came back short.
240
+ if attribute in {_attribute(column.name) for column in table.columns}:
241
+ return (
242
+ f"foreign key of table {table.name!r} on {locals_} would be called "
243
+ f"{attribute!r}, which is already a column: the relationship is left out"
244
+ )
245
+ # A to-one whose TARGET is not in the batch would name a class nobody declares. The generator
246
+ # resolves nothing — it recomputes the target's name with the same rule — so there is no
247
+ # lookup to fail; the reference is simply written and the file does not import. `--schema` is
248
+ # the ordinary way in: one schema referencing another.
249
+ if (table.schema, relationship.foreign_key.target) not in present:
250
+ return (
251
+ f"foreign key of table {table.name!r} points at "
252
+ f"{relationship.foreign_key.target!r}, which is not in what is being generated "
253
+ f"(another schema, or filtered out): the relationship is left out"
254
+ )
255
+ # The name is DERIVED, so it can come out invalid where the column was fine: `class_id` is a
256
+ # perfectly good column and `class` is a keyword. Found by running the generator against a
257
+ # real database, which is the only place that column existed.
258
+ if not _mirrors(attribute):
259
+ return (
260
+ f"foreign key of table {table.name!r} on {locals_} would be called "
261
+ f"{attribute!r}, which Python will not take as an attribute name: the "
262
+ f"relationship is left out"
263
+ )
264
+ return None
265
+
266
+
267
+ def _emitted_to_ones(
268
+ table: SnakeTableInfo, names: SnakeMirrorNames, present: set[tuple[str, str]]
269
+ ) -> list[tuple[str, SnakeRelationshipInfo]]:
270
+ """The to-ones the mirror actually writes, each paired with the attribute name it gets."""
271
+ return [
272
+ (_relationship_name(relationship, names), relationship)
273
+ for relationship in table.relationships
274
+ if relationship.kind is SnakeRelationshipKind.TO_ONE
275
+ and _to_one_refusal(table, relationship, names, present) is None
276
+ ]
277
+
278
+
279
+ def _index_args(
280
+ table: SnakeTableInfo, index: SnakeIndexInfo, snake_symbols: set[str]
281
+ ) -> str:
282
+ """The `SnakeIndex(...)` arguments: its columns, and only the knobs that are not the default.
283
+
284
+ The NAME is emitted only when the database's differs from the one `resolved_name` would invent,
285
+ which is the same rule the schema follows: repeating the default is noise, and dropping a name
286
+ somebody chose makes a drift report demand an index that is already there.
287
+ """
288
+ parts = [_attribute(name) for name in index.columns]
289
+ if index.unique:
290
+ parts.append("unique=True")
291
+ if index.name is not None and index.name != replace(index, name=None).resolved_name(
292
+ table.name
293
+ ):
294
+ parts.append(f"name={str_lit(index.name)}")
295
+ if index.method is not None:
296
+ snake_symbols.add("SnakeIndexMethod")
297
+ parts.append(f"method=SnakeIndexMethod.{index.method.name}")
298
+ return ", ".join(parts)
299
+
300
+
301
+ def _to_one_is_optional(
302
+ table: SnakeTableInfo, relationship: SnakeRelationshipInfo
303
+ ) -> bool:
304
+ """Whether the to-one can come back empty, which is the LINKER's rule and not a preference.
305
+
306
+ `_guard_nullability_parity` demands the relationship say the same thing as its key about
307
+ possibly not existing, in both directions, and one nullable member of a composite key is
308
+ enough. The generator never looked, so a mirror of any schema with a nullable FK did not even
309
+ import — and only `snake_link()` says so, which is why comparing strings never caught it.
310
+ """
311
+ by_name = {column.name: column for column in table.columns}
312
+ return any(
313
+ by_name[pair[0]].nullable
314
+ for pair in relationship.foreign_key.pairs
315
+ if pair[0] in by_name
316
+ )
317
+
318
+
319
+ def _class_name(table: SnakeTableInfo, names: SnakeMirrorNames) -> str:
320
+ """The class name of a mirror, per the two switches above.
321
+
322
+ Reversibility does not ride on this. `@snake_db_first(table="...")` sits on the line above and
323
+ carries the SQL name exactly, so the class is free to read like Python.
324
+ """
325
+ raw = f"{table.schema}_{table.name}" if names.include_schema else table.name
326
+ if not names.capwords:
327
+ return raw[:1].upper() + raw[1:]
328
+ return "".join(part[:1].upper() + part[1:] for part in raw.split("_") if part)
329
+
330
+
331
+ def unrepresentable(
332
+ tables: Sequence[SnakeTableInfo], names: SnakeMirrorNames | None = None
333
+ ) -> list[str]:
334
+ """What the scaffold cannot NAME, described so somebody can decide about it.
335
+
336
+ Same channel the introspector's own warnings travel on, and for the same reason: what the model
337
+ does not cover is said out loud, because silence reads as "all covered".
338
+
339
+ Measured before this existed, guessing failed three ways — a table named `商品` produced
340
+ `class (SnakeModel):` and the file did not parse; `Ürünler` became `RNler`, nonsense that
341
+ compiles; and a column named `商品` became `__`, so TWO of them collided into one attribute and
342
+ the rows came back short with nothing said. The last is the worst precisely because it runs.
343
+ """
344
+ names = names or SnakeMirrorNames()
345
+ complaints: list[str] = []
346
+ by_class: dict[str, list[str]] = {}
347
+ present = {(table.schema, table.name) for table in tables}
348
+ for table in tables:
349
+ if not _mirrors_as_class(table, names):
350
+ # The complaint distinguishes the two ways this fails, because the reader's next move
351
+ # differs: an unwritable name is obvious once pointed at, but `snake_model` derives
352
+ # `SnakeModel`, which looks perfectly fine — and is only wrong because the generated
353
+ # file already imports that name.
354
+ derived = _class_name(table, names)
355
+ reason = (
356
+ f"it would be written {derived!r}, which is what the generated file imports, "
357
+ f"so the mirror would shadow it and every class after it would inherit from "
358
+ f"the mirror"
359
+ if derived in _TAKEN_BY_THE_FILE
360
+ else "cannot be a Python class name as it stands (ASCII identifier, not a "
361
+ "keyword), before or after the capitalisation"
362
+ )
363
+ complaints.append(
364
+ f"table {table.name!r}: {reason}. Rename it, or mirror it by hand"
365
+ )
366
+ continue
367
+ by_class.setdefault(_class_name(table, names), []).append(
368
+ f"{table.schema}.{table.name}" if names.include_schema else table.name
369
+ )
370
+ complaints += [
371
+ f"column {column.name!r} of table {table.name!r}: cannot be a Python "
372
+ f"attribute name as it stands (ASCII identifier, not a keyword)"
373
+ for column in table.columns
374
+ if not _mirrors(column.name)
375
+ ]
376
+ complaints += [
377
+ refusal
378
+ for relationship in table.relationships
379
+ if relationship.kind is SnakeRelationshipKind.TO_ONE
380
+ and (refusal := _to_one_refusal(table, relationship, names, present))
381
+ is not None
382
+ ]
383
+ # A PARTIAL index is skipped, and skipping it QUIETLY is the sin this whole function
384
+ # exists to prevent: the reader would diff the mirror and be told to create an index that
385
+ # is already there. Its `where` is a condition, and rendering one back to source is an
386
+ # expression printer the generator does not have.
387
+ complaints += [
388
+ f"index {index.resolved_name(table.name)!r} of table {table.name!r} is PARTIAL "
389
+ f"(it has a WHERE): the mirror cannot write its condition back, so the index is "
390
+ f"left out"
391
+ for index in table.indexes
392
+ if index.where is not None
393
+ ]
394
+ # CapWords can COLLIDE where a literal copy could not: `product_requests` and `productRequests`
395
+ # both land on `ProductRequests`, and the second class would silently replace the first in the
396
+ # generated file. That is the price of reading like Python, and it is paid by saying so.
397
+ complaints += [
398
+ f"tables {sorted(sources)} all map to class {klass!r}: the mirror would keep only one of them"
399
+ for klass, sources in by_class.items()
400
+ if len(sources) > 1
401
+ ]
402
+ # The inverse side comes last because it is computed over the whole batch, not per table: a
403
+ # collection lives on the parent and the key that creates it on the child.
404
+ complaints += _collections(tables, names)[1]
405
+ return complaints
406
+
407
+
408
+ def _attribute(name: str) -> str:
409
+ """A valid Python attribute name for a column."""
410
+ cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
411
+ return (
412
+ f"col_{cleaned}"
413
+ if not cleaned.isidentifier() or cleaned[0].isdigit()
414
+ else cleaned
415
+ )
416
+
417
+
418
+ # How a type is SPELLED lives in `helpers/pytype` (`type_annotation`, `FROM_SNAKEORM`), shared with
419
+ # the DTO generator: two generators writing annotations off the same metadata have to agree on the
420
+ # spelling, and a second copy here would be the second answer to that one question.
421
+
422
+
423
+ def render_models(
424
+ tables: list[SnakeTableInfo],
425
+ unsupported: list[str] | None = None,
426
+ names: SnakeMirrorNames | None = None,
427
+ ) -> str:
428
+ """Renders the introspected tables as `@snake_db_first` classes.
429
+
430
+ Whatever is NOT representable is written as a WARNING inside the file itself: whoever reads it later has to see what it does NOT cover.
431
+ """
432
+ imports: set[str] = set()
433
+ snake_symbols: set[str] = set(_SNAKE_BASE)
434
+ bodies: list[str] = []
435
+ names = names or SnakeMirrorNames()
436
+ refused = unrepresentable(tables, names)
437
+ linked = False
438
+ present = {(table.schema, table.name) for table in tables}
439
+ collections, _ = _collections(tables, names)
440
+ for table in sorted(tables, key=lambda item: item.name):
441
+ if not _mirrors_as_class(table, names):
442
+ continue
443
+ # `str_lit` on ALL text coming from the database: without it, quotes/newlines would escape
444
+ # the literal and become code executed when the generated `models.py` is imported (RCE for
445
+ # whoever controls the schema).
446
+ # The schema is emitted only when it is NOT the decorator's default, which is the same
447
+ # declaration with less noise. Leaving it out ALTOGETHER was the worst bug this generator
448
+ # had: `sales.orders` mirrored as `SalesOrders` over `table="orders"`, reading `public`.
449
+ placement = (
450
+ f", schema={str_lit(table.schema)}"
451
+ if table.schema != DEFAULT_SCHEMA
452
+ else ""
453
+ )
454
+ lines = [
455
+ f"@snake_db_first(table={str_lit(table.name)}{placement})",
456
+ f"class {_class_name(table, names)}(SnakeModel):",
457
+ f" {str_lit(f'Mirror of table `{table.name}`.')}",
458
+ "",
459
+ ]
460
+ if table.db_comment is not None:
461
+ # The compiler reads `SnakeComment` off the class body, so no decorator parameter is
462
+ # needed. Often the only thing that says what a table inherited as `t_cst_rel_04` is
463
+ # FOR, and all three introspectors were already reading it.
464
+ lines.append(f" SnakeComment = {str_lit(table.db_comment)}")
465
+ lines.append("")
466
+ for column in table.columns:
467
+ if not _mirrors(column.name):
468
+ continue
469
+ annotation, module = type_annotation(column.python_type)
470
+ if module == FROM_SNAKEORM:
471
+ snake_symbols.add(annotation)
472
+ module = None
473
+ if module is not None:
474
+ imports.add(f"import {module}")
475
+ optional = " | None" if column.nullable else ""
476
+ declarator = _declarator_for(table, column, snake_symbols)
477
+ lines.append(
478
+ f" {_attribute(column.name)}: SnakeColumn[{annotation}{optional}] = "
479
+ f"{declarator}({_column_args(table, column.name, declarator, snake_symbols)})"
480
+ )
481
+ for attribute, relationship in _emitted_to_ones(table, names, present):
482
+ target = _class_name(
483
+ SnakeTableInfo(
484
+ name=relationship.foreign_key.target,
485
+ schema=table.schema,
486
+ columns=(),
487
+ primary_key=SnakePrimaryKeyInfo(columns=()),
488
+ ),
489
+ names,
490
+ )
491
+ columns = ", ".join(
492
+ _attribute(pair[0]) for pair in relationship.foreign_key.pairs
493
+ )
494
+ snake_symbols |= {"SnakeToOne", "snake_to_one"}
495
+ linked = True
496
+ optional = " | None" if _to_one_is_optional(table, relationship) else ""
497
+ lines.append(
498
+ f" {attribute}: SnakeToOne[{target}{optional}] = snake_to_one({columns})"
499
+ )
500
+ for collection in collections.get((table.schema, table.name), []):
501
+ snake_symbols |= {"SnakeToMany", "snake_to_many"}
502
+ linked = True
503
+ lines.append(
504
+ f" {collection.attribute}: SnakeToMany[{collection.child_class}] = "
505
+ f"snake_to_many({str_lit(collection.fk_name)})"
506
+ )
507
+ # LAST in the body, because `SnakeIndex(brand_id)` refers to the descriptors declared
508
+ # above: the names resolve at compile time, once `__set_name__` has run.
509
+ emitted = [index for index in table.indexes if index.where is None]
510
+ if emitted:
511
+ snake_symbols.add("SnakeIndex")
512
+ lines.append("")
513
+ lines.append(" SnakeIndexes = [")
514
+ lines += [
515
+ f" SnakeIndex({_index_args(table, index, snake_symbols)}),"
516
+ for index in emitted
517
+ ]
518
+ lines.append(" ]")
519
+ bodies.append("\n".join(lines))
520
+
521
+ if linked:
522
+ # Added BEFORE the import line is built, which is the whole of a bug this had: appending it
523
+ # after meant `snake_link()` was emitted and never imported. The file PARSED — so `compile()`
524
+ # was happy — and raised `NameError` the moment anybody ran it.
525
+ snake_symbols.add("snake_link")
526
+ parts = [_HEADER]
527
+ parts.append(f"from snakeorm import {', '.join(sorted(snake_symbols))}")
528
+ if imports:
529
+ parts.append("\n".join(sorted(imports)) + "\n")
530
+ unsupported = [*(unsupported or []), *refused]
531
+ if unsupported:
532
+ # The warnings carry RAW names from the database: a `\n` would break the `#` comment and the
533
+ # next line would fall to module level (RCE on import). Collapsing the newlines is enough: a
534
+ # one-line comment cannot overflow.
535
+ warnings_ = "\n".join(
536
+ f"# - {item.replace(chr(13), ' ').replace(chr(10), ' ')}"
537
+ for item in unsupported
538
+ )
539
+ parts.append(
540
+ "# INTROSPECTION WARNINGS: this EXISTS in the database and the model does NOT\n"
541
+ "# represent it. It is still there and still acting; it just cannot be seen from here.\n"
542
+ + warnings_
543
+ + "\n"
544
+ )
545
+ parts.extend(bodies)
546
+ if linked:
547
+ # Two-phase on purpose: the classes declare, `snake_link()` resolves. A generated file that
548
+ # skipped it would import cleanly and raise `SnakeUnlinkedRelationship` at the first query.
549
+ parts.append("snake_link()\n")
550
+ return "\n\n".join(parts).rstrip() + "\n"
551
+
552
+
553
+ def _declarator_for(
554
+ table: SnakeTableInfo, column: SnakeColumnInfo, snake_symbols: set[str]
555
+ ) -> str:
556
+ """The declarator that matches the column's family.
557
+
558
+ Every family with parameters has its own (`snake_int(size=)`, `snake_str(max_length=)`...), and
559
+ `snake_column()` no longer accepts ANY of them: emitting it for everything produced a file that
560
+ does not even import. A date moreover has TWO, because `TIMESTAMP` and `TIMESTAMPTZ` are
561
+ different types and the mirror must reflect which one it is.
562
+ """
563
+ kind = column.python_type
564
+ if column.autoincrement:
565
+ # The database GENERATES the value, and `snake_auto` is the declarator that says so: it
566
+ # keeps the key out of `__init__`. Mirroring it as `snake_int(primary_key=True)` put the
567
+ # id back in the constructor, so the mirror asked the caller for a value only the sequence
568
+ # can produce — and the checker approved the call.
569
+ snake_symbols.add("snake_auto")
570
+ return "snake_auto"
571
+ if isinstance(kind, type) and issubclass(kind, datetime):
572
+ name = "snake_datetimetz" if column.with_timezone else "snake_datetime"
573
+ snake_symbols.add(name)
574
+ return name
575
+ if kind is Decimal and column.precision is None:
576
+ # An UNCONSTRAINED `NUMERIC` is legal on Postgres, and `snake_decimal()` demands
577
+ # `precision`: emitting it here produced a call missing a mandatory argument, and the file
578
+ # did not even import. `snake_column()` on a Decimal gives the bare NUMERIC, which is
579
+ # exactly what is in the database.
580
+ snake_symbols.add("snake_column")
581
+ return "snake_column"
582
+ name = {
583
+ int: "snake_int",
584
+ str: "snake_str",
585
+ Decimal: "snake_decimal",
586
+ dict: "snake_json",
587
+ }.get(kind, "snake_column")
588
+ snake_symbols.add(name)
589
+ return name
590
+
591
+
592
+ def _column_args(
593
+ table: SnakeTableInfo, column_name: str, declarator: str, snake_symbols: set[str]
594
+ ) -> str:
595
+ """The declarator's arguments with ALL the knobs of the introspected column.
596
+
597
+ Emitting only pk/unique/db_comment widened things silently (`VARCHAR(20)`→`TEXT`, `INTEGER`→`BIGINT`): the mirror must reflect the real width, length, precision and default.
598
+
599
+ `snake_auto` takes a DIFFERENT set, which is why the declarator has to be passed in: it is the
600
+ primary key by construction, so it refuses `primary_key=`, and it spells the width `int_size=`
601
+ rather than `size=`. Emitting the common set produced a call the compiler rejects.
602
+ """
603
+ column = table.get_column(column_name)
604
+ assert column is not None
605
+ args: list[str] = []
606
+ if declarator == "snake_auto":
607
+ if column.int_size is not SnakeIntSize.BIGINT:
608
+ snake_symbols.add("SnakeIntSize")
609
+ args.append(f"int_size=SnakeIntSize.{column.int_size.name}")
610
+ if column.db_comment is not None:
611
+ args.append(f"db_comment={str_lit(column.db_comment)}")
612
+ return ", ".join(args)
613
+ if any(pk.name == column_name for pk in table.primary_key.columns):
614
+ args.append("primary_key=True")
615
+ if column.unique:
616
+ args.append("unique=True")
617
+ if column.max_length is not None:
618
+ args.append(f"max_length={column.max_length}")
619
+ if column.precision is not None:
620
+ args.append(f"precision={column.precision}")
621
+ if column.scale is not None:
622
+ args.append(f"scale={column.scale}")
623
+ if column.int_size is not SnakeIntSize.BIGINT:
624
+ snake_symbols.add("SnakeIntSize")
625
+ args.append(f"size=SnakeIntSize.{column.int_size.name}")
626
+ if column.json_storage is not SnakeJsonStorage.JSONB:
627
+ snake_symbols.add("SnakeJsonStorage")
628
+ args.append(f"storage=SnakeJsonStorage.{column.json_storage.name}")
629
+ if column.server_default_sql is not None:
630
+ args.append(f"server_default_sql={str_lit(column.server_default_sql)}")
631
+ if column.db_comment is not None:
632
+ args.append(f"db_comment={str_lit(column.db_comment)}")
633
+ return ", ".join(args)