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,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)
|