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,37 @@
|
|
|
1
|
+
"""Immutable metadata of a CHECK constraint: a domain rule the DATABASE enforces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from snakeorm.expressions import SnakeCondition, condition_paths
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class SnakeCheckInfo:
|
|
12
|
+
"""A `CHECK (...)` rule over the table's columns.
|
|
13
|
+
|
|
14
|
+
It stores the `SnakeCondition` as an AST, not as emitted SQL: that keeps the metadata
|
|
15
|
+
engine-agnostic (the dialect supplies quoting and syntax) and the condition is the SAME one
|
|
16
|
+
`.filter()` takes, so it is validated at type-check time (`User.age >= 18` stops compiling if
|
|
17
|
+
you rename `age`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
condition: SnakeCondition
|
|
21
|
+
name: str | None = None
|
|
22
|
+
|
|
23
|
+
def resolved_name(self, table_name: str) -> str:
|
|
24
|
+
"""The name the DB knows the constraint by: the explicit one, or `ck_{table}_{columns}`.
|
|
25
|
+
|
|
26
|
+
Creation, removal and diff must agree on the name or you create duplicates and drop things
|
|
27
|
+
that do not exist. The columns come from the condition, deduplicated (`age > 0 AND
|
|
28
|
+
age < 150` -> `ck_users_age`).
|
|
29
|
+
"""
|
|
30
|
+
if self.name:
|
|
31
|
+
return self.name
|
|
32
|
+
columns: list[str] = []
|
|
33
|
+
for path in condition_paths(self.condition):
|
|
34
|
+
column = path[-1]
|
|
35
|
+
if column not in columns:
|
|
36
|
+
columns.append(column)
|
|
37
|
+
return f"ck_{table_name}_{'_'.join(columns)}"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Immutable metadata of columns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from snakeorm.core.exceptions import SnakeModelDefinitionError
|
|
10
|
+
from snakeorm.metadata.enum_storage import SnakeEnumStorage
|
|
11
|
+
from snakeorm.metadata.int_size import SnakeIntSize
|
|
12
|
+
from snakeorm.metadata.json_storage import SnakeJsonStorage
|
|
13
|
+
from snakeorm.metadata.server_default import SnakeServerDefault
|
|
14
|
+
from snakeorm.metadata.type_params import (
|
|
15
|
+
SnakeDateTimeParams,
|
|
16
|
+
SnakeDecimalParams,
|
|
17
|
+
SnakeIntParams,
|
|
18
|
+
SnakeJsonParams,
|
|
19
|
+
SnakeStrParams,
|
|
20
|
+
SnakeTypeParams,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _width_of(enum_type: type[Enum]) -> SnakeStrParams | None:
|
|
25
|
+
"""The WIDTH a text-backed enum needs, or `None` when there is none to derive.
|
|
26
|
+
|
|
27
|
+
An enum knows exactly how wide its column has to be: it has a longest member, and the length of
|
|
28
|
+
that member's VALUE is an exact number available at compile time. The value and not the name,
|
|
29
|
+
because the value is what `adapt_param` writes and what the `CHECK` enumerates.
|
|
30
|
+
|
|
31
|
+
Three cases give nothing back, each for its own reason:
|
|
32
|
+
|
|
33
|
+
- an `IntEnum`, because an integer has no length (it has a `SnakeIntSize`, which is not this);
|
|
34
|
+
- an enum with no members, because there is no longest one — and `SnakeStrParams` rejects
|
|
35
|
+
`max_length=0` on purpose, so guessing would turn a strange model into a crash at import;
|
|
36
|
+
- anything that is not a text-backed enum at all, because the shout for that already lives in
|
|
37
|
+
`storage_type`, and saying it twice from a `__post_init__` would move the failure to a place
|
|
38
|
+
nobody is looking at.
|
|
39
|
+
|
|
40
|
+
`int` is checked before `str` for the same reason `storage_type` checks it first: the two orders
|
|
41
|
+
have to agree, or a column could be stored as an integer and sized as text.
|
|
42
|
+
"""
|
|
43
|
+
if not (isinstance(enum_type, type) and issubclass(enum_type, Enum)):
|
|
44
|
+
return None
|
|
45
|
+
if issubclass(enum_type, int) or not issubclass(enum_type, str):
|
|
46
|
+
return None
|
|
47
|
+
widths = [len(str(member.value)) for member in enum_type]
|
|
48
|
+
if not widths:
|
|
49
|
+
return None
|
|
50
|
+
return SnakeStrParams(max_length=max(widths))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class SnakeColumnInfo:
|
|
55
|
+
"""Metadata of a column.
|
|
56
|
+
|
|
57
|
+
The type ALWAYS comes from Python (`python_type`); everything else only adds SQL info.
|
|
58
|
+
Immutable (frozen) and with `slots`: it is part of the compiled graph, which never changes.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
python_type: type
|
|
63
|
+
nullable: bool = False
|
|
64
|
+
unique: bool = False
|
|
65
|
+
default: object | None = None
|
|
66
|
+
index: bool = False
|
|
67
|
+
db_comment: str | None = None # COMMENT ON COLUMN
|
|
68
|
+
attr_name: str = "" # name of the Python attribute; enables the SQL→Python mapping
|
|
69
|
+
has_default: bool = (
|
|
70
|
+
False # tells "no default" apart from "default = None" (for the DDL)
|
|
71
|
+
)
|
|
72
|
+
autoincrement: bool = False # the DB generates the value; the dialect picks how (SERIAL, AUTOINCREMENT...)
|
|
73
|
+
# Callable that fills the value in Python when building the object; NEVER touches the DDL. Mutually exclusive with `default`.
|
|
74
|
+
default_factory: Callable[[], object] | None = None
|
|
75
|
+
# SERVER default, engine-agnostic (the dialect translates it): the column is OMITTED from the
|
|
76
|
+
# INSERT and the RETURNING brings it back. Mutually exclusive with `default`.
|
|
77
|
+
server_default: SnakeServerDefault | None = None
|
|
78
|
+
# Escape hatch: raw SQL for the server default. NOT PORTABLE. Mutually exclusive with `server_default`.
|
|
79
|
+
server_default_sql: str | None = None
|
|
80
|
+
|
|
81
|
+
# SQL parameters of the type's FAMILY (an int's width, a str's length, a Decimal's precision, a
|
|
82
|
+
# dict's backing), or None if the type has none (`bool`, `date`, `UUID`...).
|
|
83
|
+
# They travel grouped and not loose because loose they allowed writing `max_length` AND
|
|
84
|
+
# `precision` on the same column: a state no engine represents, living in the graph that IS the
|
|
85
|
+
# truth.
|
|
86
|
+
type_params: SnakeTypeParams | None = None
|
|
87
|
+
# Enum: the enum's type and how it is backed. Primitives only; the conversions are not stored,
|
|
88
|
+
# they are derived from `enum_type`.
|
|
89
|
+
enum_type: type[Enum] | None = None
|
|
90
|
+
enum_storage: SnakeEnumStorage | None = None
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
"""Fills in the width of a text-backed enum, which the column already knew and threw away.
|
|
94
|
+
|
|
95
|
+
`storage_type` derives ONE thing from `enum_type` —the base type, so the dialect never has
|
|
96
|
+
to know what an enum is— and this is the SECOND, derived in the same place and for the same
|
|
97
|
+
reason. Without it `type_params` stayed `None`, MySQL read "a `str` with no declared length",
|
|
98
|
+
wrote `TEXT`, and a composite index over the column died with `1071, Specified key was too
|
|
99
|
+
long`. The information was there the whole time.
|
|
100
|
+
|
|
101
|
+
It is NOT a new knob: `snake_enum` grows no `max_length=`, because there is nothing left for
|
|
102
|
+
anybody to decide. The type ALWAYS comes from Python, and this is the metadata reading all
|
|
103
|
+
of what Python said instead of half.
|
|
104
|
+
|
|
105
|
+
Params written out by hand WIN, and that is what keeps a historical migration honest: a
|
|
106
|
+
generated file spells the width the enum had the day it was written, so replaying it rebuilds
|
|
107
|
+
the column the database really has and the diff can see that today's longer member is a
|
|
108
|
+
change. Re-deriving from the live class would make that change invisible.
|
|
109
|
+
"""
|
|
110
|
+
if self.type_params is not None or self.enum_type is None:
|
|
111
|
+
return
|
|
112
|
+
derived = _width_of(self.enum_type)
|
|
113
|
+
if derived is None:
|
|
114
|
+
return
|
|
115
|
+
# `object.__setattr__` is how a frozen dataclass normalises itself: the guarantee is that
|
|
116
|
+
# nobody changes the column AFTERWARDS, and this runs before anyone has it.
|
|
117
|
+
object.__setattr__(self, "type_params", derived)
|
|
118
|
+
|
|
119
|
+
# The five properties below READ the family's parameters under their usual names.
|
|
120
|
+
# Writing remains impossible other than through `type_params`: reading is comfortable, writing
|
|
121
|
+
# is narrow. They return the family's default when the column is not of that family, which is
|
|
122
|
+
# exactly what a column with the knob unset used to read before.
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def int_size(self) -> SnakeIntSize:
|
|
126
|
+
"""A DB-side `int` width. Default `BIGINT`: the widest of both engines, so Python's
|
|
127
|
+
unbounded `int` lines up in Postgres and SQLite."""
|
|
128
|
+
return (
|
|
129
|
+
self.type_params.size
|
|
130
|
+
if isinstance(self.type_params, SnakeIntParams)
|
|
131
|
+
else SnakeIntSize.BIGINT
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def max_length(self) -> int | None:
|
|
136
|
+
"""Maximum length of a `str`. Without it TEXT; with it Postgres emits VARCHAR(n)."""
|
|
137
|
+
return (
|
|
138
|
+
self.type_params.max_length
|
|
139
|
+
if isinstance(self.type_params, SnakeStrParams)
|
|
140
|
+
else None
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def json_storage(self) -> SnakeJsonStorage:
|
|
145
|
+
"""Backing of a `dict`. Default `JSONB` (indexable, normalised); `JSON` preserves the
|
|
146
|
+
exact text. SQLite collapses both to TEXT."""
|
|
147
|
+
return (
|
|
148
|
+
self.type_params.storage
|
|
149
|
+
if isinstance(self.type_params, SnakeJsonParams)
|
|
150
|
+
else SnakeJsonStorage.JSONB
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def precision(self) -> int | None:
|
|
155
|
+
"""Digits of a NUMERIC. Without it Postgres accepts any number (rounding on money)."""
|
|
156
|
+
return (
|
|
157
|
+
self.type_params.precision
|
|
158
|
+
if isinstance(self.type_params, SnakeDecimalParams)
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def with_timezone(self) -> bool:
|
|
164
|
+
"""Whether the date column carries a zone (`TIMESTAMPTZ`) or is wall-clock (`TIMESTAMP`).
|
|
165
|
+
|
|
166
|
+
The declarator sets it (`snake_datetimetz()` / `snake_datetime()`) and the compiler demands
|
|
167
|
+
it match the annotation, so reading it from here is reading what both of them say.
|
|
168
|
+
"""
|
|
169
|
+
return isinstance(self.type_params, SnakeDateTimeParams) and self.type_params.tz
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def scale(self) -> int | None:
|
|
173
|
+
"""Decimal places of a NUMERIC."""
|
|
174
|
+
return (
|
|
175
|
+
self.type_params.scale
|
|
176
|
+
if isinstance(self.type_params, SnakeDecimalParams)
|
|
177
|
+
else None
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def storage_type(self) -> type:
|
|
182
|
+
"""The Python type the DIALECT maps to SQL.
|
|
183
|
+
|
|
184
|
+
Normal column: its own type. Enum: its BASE type (`str`/`int`), so the dialect never needs
|
|
185
|
+
to know anything about enums.
|
|
186
|
+
"""
|
|
187
|
+
if self.enum_type is None:
|
|
188
|
+
return self.python_type
|
|
189
|
+
if issubclass(self.enum_type, int):
|
|
190
|
+
return int
|
|
191
|
+
if issubclass(self.enum_type, str):
|
|
192
|
+
return str
|
|
193
|
+
raise SnakeModelDefinitionError(
|
|
194
|
+
f"Enum {self.enum_type.__name__} is neither a StrEnum nor an IntEnum, which leaves "
|
|
195
|
+
f"it without a base type to store it with."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def has_server_default(self) -> bool:
|
|
200
|
+
"""Tells whether the DB sets the value: the column is excluded from `__init__` and INSERT."""
|
|
201
|
+
return self.server_default is not None or self.server_default_sql is not None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""How an enum is backed in the database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeEnumStorage(Enum):
|
|
9
|
+
"""Which DB object checks that the column only ever holds valid enum members.
|
|
10
|
+
|
|
11
|
+
Engine-agnostic values (the dialect translates), like `SnakeServerDefault`/`SnakeFkAction`.
|
|
12
|
+
|
|
13
|
+
- `CHECK` (default): the base type plus a `CHECK col IN (...)`. Adding a value is reversible;
|
|
14
|
+
removing one fails at `migrate` if rows are still using it.
|
|
15
|
+
- `PLAIN`: base type only, no validation. An invalid value slipped in through raw SQL blows up
|
|
16
|
+
ON READ.
|
|
17
|
+
|
|
18
|
+
The BASE TYPE is not spelled out here, and the omission is deliberate: this class picks the DB
|
|
19
|
+
object that validates the column, not the SQL type, and the type is the dialect's answer to
|
|
20
|
+
`storage_type`. Measured on the three: `SnakeColumnInfo.__post_init__` derives a text enum's
|
|
21
|
+
width from its longest member, so a text-backed enum is `VARCHAR(n)` on PostgreSQL and MySQL
|
|
22
|
+
and `TEXT` only on SQLite, which has affinities and no widths; an int-backed one is `BIGINT` on
|
|
23
|
+
the first two and `INTEGER` on SQLite for the same reason.
|
|
24
|
+
|
|
25
|
+
There is no `NATIVE` on purpose: in Postgres `ADD VALUE` has no inverse (recreating the type
|
|
26
|
+
rewrites the table under `ACCESS EXCLUSIVE`), the value cannot be used in the same transaction
|
|
27
|
+
that adds it (and migrations are transactional), and if two models share the enum they share
|
|
28
|
+
the type. With `CHECK` none of that happens. A `NATIVE` that throws when used would be dead
|
|
29
|
+
metadata.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
CHECK = "check"
|
|
33
|
+
PLAIN = "plain"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Referential actions of a foreign key (ON DELETE / ON UPDATE)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeFkAction(Enum):
|
|
9
|
+
"""Referential action of an FK, valid for both ON DELETE and ON UPDATE.
|
|
10
|
+
|
|
11
|
+
Typed constants instead of magic strings. Each member's `value` is its SQL
|
|
12
|
+
fragment, ready for the DDL generator.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
NO_ACTION = "NO ACTION"
|
|
16
|
+
RESTRICT = "RESTRICT"
|
|
17
|
+
CASCADE = "CASCADE"
|
|
18
|
+
SET_NULL = "SET NULL"
|
|
19
|
+
SET_DEFAULT = "SET DEFAULT"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Immutable metadata of the foreign key (simple or composite)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from snakeorm.metadata.fk_action import SnakeFkAction
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class SnakeForeignKeyInfo:
|
|
12
|
+
"""Foreign key: target model + (source_column, target_column) pairs.
|
|
13
|
+
|
|
14
|
+
ONE single structure for a simple FK (1 pair) and a composite one (N pairs). The join is
|
|
15
|
+
generated by AND-ing every pair together. `target` is the model's NAME (resolved in the Linker).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
target: str
|
|
19
|
+
pairs: tuple[tuple[str, str], ...]
|
|
20
|
+
on_delete: SnakeFkAction = SnakeFkAction.NO_ACTION
|
|
21
|
+
on_update: SnakeFkAction = SnakeFkAction.NO_ACTION
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_composite(self) -> bool:
|
|
25
|
+
"""Tells whether the key spans more than one column."""
|
|
26
|
+
return len(self.pairs) > 1
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Immutable metadata of an index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from snakeorm.expressions import SnakeCondition
|
|
8
|
+
from snakeorm.metadata.index_method import SnakeIndexMethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class SnakeIndexInfo:
|
|
13
|
+
"""Index over one or more of the table's columns. 1 column = simple, N = composite."""
|
|
14
|
+
|
|
15
|
+
columns: tuple[str, ...]
|
|
16
|
+
unique: bool = False
|
|
17
|
+
name: str | None = None
|
|
18
|
+
# PARTIAL index: only indexes the rows that satisfy the condition (`WHERE deleted_at IS NULL`).
|
|
19
|
+
# It is what makes soft-delete and multi-tenancy usable.
|
|
20
|
+
where: SnakeCondition | None = None
|
|
21
|
+
# Access method, engine-agnostic (the dialect translates it). `None` = the default one.
|
|
22
|
+
method: SnakeIndexMethod | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_constraint(self) -> bool:
|
|
26
|
+
"""Whether it produces a uniqueness CONSTRAINT instead of an index.
|
|
27
|
+
|
|
28
|
+
A plain `unique=True` is a constraint (`uq_*`); a PARTIAL one cannot be (Postgres does not
|
|
29
|
+
accept `CONSTRAINT ... UNIQUE ... WHERE`), so with `where=` uniqueness is a unique index and
|
|
30
|
+
nothing more.
|
|
31
|
+
"""
|
|
32
|
+
return self.unique and self.where is None
|
|
33
|
+
|
|
34
|
+
def resolved_name(self, table_name: str) -> str:
|
|
35
|
+
"""The name the DB knows the object by: the explicit one, or the one generated from its type.
|
|
36
|
+
|
|
37
|
+
The prefix says which object it is (`uq_` constraint, `ix_` index). It lives here and not in
|
|
38
|
+
the DDL emitter because the diff needs the same name as creation and removal, or it creates
|
|
39
|
+
duplicates and drops things that do not exist.
|
|
40
|
+
"""
|
|
41
|
+
if self.name:
|
|
42
|
+
return self.name
|
|
43
|
+
return f"{'uq' if self.is_constraint else 'ix'}_{table_name}_{'_'.join(self.columns)}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Access method of an index, declared ENGINE-AGNOSTIC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeIndexMethod(Enum):
|
|
9
|
+
"""Structure the engine builds the index with.
|
|
10
|
+
|
|
11
|
+
Agnostic values (the dialect translates). `BTREE` is the de facto default and is emitted
|
|
12
|
+
IMPLICITLY (no `USING`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
BTREE = "btree" # the usual one: equality and ordered ranges
|
|
16
|
+
HASH = "hash" # equality only, more compact
|
|
17
|
+
GIN = "gin" # composite values: JSONB, arrays, text search
|
|
18
|
+
GIST = "gist" # geometry, ranges, proximity
|
|
19
|
+
BRIN = "brin" # huge tables whose data correlates with physical order
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Storage size of an integer in the DATABASE (not in Python)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeIntSize(StrEnum):
|
|
9
|
+
"""How many bits an `int` reserves in the database.
|
|
10
|
+
|
|
11
|
+
Python always uses an unbounded `int`; this only decides how much room the engine takes. The
|
|
12
|
+
members are the SQL STANDARD (which is why the `StrEnum` earns its type); the dialect
|
|
13
|
+
translates (SQLite collapses them all to `INTEGER`). `BIGINT` is the default on purpose: it is
|
|
14
|
+
the widest of both engines, so Python's uncapped `int` lines up in Postgres and SQLite. You
|
|
15
|
+
step it down by hand when saving bytes matters.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
SMALLINT = "SMALLINT" # 16 signed bits (±32,767)
|
|
19
|
+
INTEGER = "INTEGER" # 32 signed bits (±2,147,483,647)
|
|
20
|
+
BIGINT = "BIGINT" # 64 signed bits (±9.2·10¹⁸) — DEFAULT: the engine's widest
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""How a `dict` is backed in the database: normalised binary or exact text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeJsonStorage(StrEnum):
|
|
9
|
+
"""The DB object that stores a `dict`.
|
|
10
|
+
|
|
11
|
+
Members = the literal name of the type in Postgres; the dialect translates (SQLite collapses
|
|
12
|
+
both to TEXT).
|
|
13
|
+
|
|
14
|
+
- `JSONB` (default): binary, indexable, NORMALISES (reorders keys, drops duplicates, loses
|
|
15
|
+
`100.0` vs `100`). What nearly every real case wants.
|
|
16
|
+
- `JSON`: stores the text as-is (not indexable, preserved bit for bit). It also makes Postgres
|
|
17
|
+
line up with SQLite.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
JSONB = "JSONB" # binary, indexable, normalised — DEFAULT
|
|
21
|
+
JSON = "JSON" # exact text, not indexable
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""POLYMORPHIC inheritance: several classes share ONE table and are told apart by a column.
|
|
2
|
+
|
|
3
|
+
Against concrete inheritance (a table per class, duplicated columns), here `session.all(Animal)`
|
|
4
|
+
returns `Dog`/`Cat` with their real class and `session.all(Dog)` filters by the discriminator.
|
|
5
|
+
The metadata is just two strings (column and value): no references to Python classes, because this
|
|
6
|
+
travels into a rendered migration file and a class reference would couple it to the generating code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class SnakePolymorphicInfo:
|
|
16
|
+
"""How a class takes part in a polymorphic hierarchy.
|
|
17
|
+
|
|
18
|
+
`column` (the discriminator) is carried by every class. `value` is `None` on the BASE (which
|
|
19
|
+
sees the whole hierarchy) and, on each CHILD, its own value, used as an automatic filter and on
|
|
20
|
+
insert. An optional field instead of a separate `is_base`: two fields would allow illegal
|
|
21
|
+
combinations.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
column: str
|
|
25
|
+
value: str | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_base(self) -> bool:
|
|
29
|
+
"""The root of the hierarchy: the one that sees every row, no discriminator filter."""
|
|
30
|
+
return self.value is None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Immutable metadata of the primary key (simple or composite)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from snakeorm.metadata.column import SnakeColumnInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class SnakePrimaryKeyInfo:
|
|
12
|
+
"""Primary key as a tuple of columns.
|
|
13
|
+
|
|
14
|
+
ONE single structure for a simple PK (1 column) and a composite one (N columns): no special
|
|
15
|
+
cases. Order is preserved because it matters for mapping FKs by position.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
columns: tuple[SnakeColumnInfo, ...]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_composite(self) -> bool:
|
|
22
|
+
"""Tells whether the key spans more than one column."""
|
|
23
|
+
return len(self.columns) > 1
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Immutable metadata of a relationship between models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from snakeorm.metadata.foreign_key import SnakeForeignKeyInfo
|
|
8
|
+
from snakeorm.metadata.relationship_kind import SnakeRelationshipKind
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class SnakeThroughInfo:
|
|
13
|
+
"""The BRIDGE of a many-to-many: its table and both hops, already resolved.
|
|
14
|
+
|
|
15
|
+
`to_parent`/`to_target` are `(bridge_column, endpoint_column)` pairs, resolved by the linker and
|
|
16
|
+
not by name (bug #14: resolving by class name sent the FK to a different table with the same
|
|
17
|
+
name). A composite FK fits with no special case: tuples of pairs, as in `SnakeForeignKeyInfo`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
table: str
|
|
21
|
+
"""The bridge's table, QUALIFIED (`schema.table`)."""
|
|
22
|
+
to_parent: tuple[tuple[str, str], ...]
|
|
23
|
+
to_target: tuple[tuple[str, str], ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class SnakeRelationshipInfo:
|
|
28
|
+
"""A navigation view over an FK.
|
|
29
|
+
|
|
30
|
+
`kind` tells to_one (`user.owner` -> `User`) apart from to_many (`country.cities` ->
|
|
31
|
+
`list[City]`). `target` is the NAME of the target model.
|
|
32
|
+
|
|
33
|
+
`target_table` is that target already resolved and qualified (`schema.table`). It exists
|
|
34
|
+
because a class name does not identify a model (two apps each with their own `Customer`; the
|
|
35
|
+
index keyed by `__name__` was kept by whichever registered last -> FK to the wrong table,
|
|
36
|
+
depending on import order). The qualified table IS unique (the registry's collision guard).
|
|
37
|
+
|
|
38
|
+
A DERIVED field, not a declared one: the linker fills it in, it is NOT serialised and does NOT
|
|
39
|
+
enter the diff. Old migrations keep replaying with an empty `target_table` and fall back to
|
|
40
|
+
resolving by name.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
target: str
|
|
45
|
+
kind: SnakeRelationshipKind
|
|
46
|
+
foreign_key: SnakeForeignKeyInfo
|
|
47
|
+
through: SnakeThroughInfo | None = None
|
|
48
|
+
"""The bridge, only on `to_many_through`. It tells a to-many over a direct FK apart from one
|
|
49
|
+
that crosses an intermediate table: they load differently, and mixing them up would fire a
|
|
50
|
+
select-in against the wrong table."""
|
|
51
|
+
target_table: str = ""
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
"""Normalises `kind` to the enum, accepting the string of already-written history.
|
|
55
|
+
|
|
56
|
+
Converting at the boundary lets everything inside compare with `is`, no exceptions.
|
|
57
|
+
`object.__setattr__` because the dataclass is `frozen` (the same mechanism the generated
|
|
58
|
+
`__init__` uses).
|
|
59
|
+
"""
|
|
60
|
+
object.__setattr__(self, "kind", SnakeRelationshipKind.coerce(self.kind))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What CARDINALITY a relationship has: one, many, or many through a bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeRelationshipKind(Enum):
|
|
9
|
+
"""Cardinality of a relationship. An enum, not a `Literal[...]` of strings, on purpose.
|
|
10
|
+
|
|
11
|
+
A `Literal` protects assignment but NOT comparison, and every use of the field is a comparison
|
|
12
|
+
(`rel.kind == "to_onee"` compiles clean in both checkers and switches off a branch without
|
|
13
|
+
warning: a JOIN that never gets emitted, an FK that never gets created). The enum makes the typo
|
|
14
|
+
unwritable. Same decision as `SnakeTableKind`.
|
|
15
|
+
|
|
16
|
+
- `TO_ONE`: FK. This model points to ONE on the other side.
|
|
17
|
+
- `TO_MANY`: the inverse. N on the other side point here.
|
|
18
|
+
- `TO_MANY_THROUGH`: many-to-many via a DECLARED bridge model.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
TO_ONE = "to_one"
|
|
22
|
+
TO_MANY = "to_many"
|
|
23
|
+
TO_MANY_THROUGH = "to_many_through"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def coerce(cls, value: object) -> SnakeRelationshipKind:
|
|
27
|
+
"""Accepts the enum, or the string carried by already-generated history.
|
|
28
|
+
|
|
29
|
+
Old migrations carry the literal string (`"to_one"`) and are immutable; without converting,
|
|
30
|
+
`is` comparisons would return `False` (the very silent failure the enum came to kill off).
|
|
31
|
+
An unknown value blows up HERE, not twelve branches later.
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, cls):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
try:
|
|
37
|
+
return cls(value)
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
valid = ", ".join(repr(member.value) for member in cls)
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Invalid relationship cardinality: {value!r}. The available ones are: {valid}."
|
|
43
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Immutable metadata of a DB ROUTINE (stored function or procedure).
|
|
2
|
+
|
|
3
|
+
OPAQUE SQL that lives in the DB: the ORM does not check what it does. `body` is the whole
|
|
4
|
+
`CREATE [OR REPLACE] FUNCTION ...`, raw and non-portable; with no columns to diff, the diff only
|
|
5
|
+
compares the `body` string. It is declared with `snake_function(...)` and autodetect emits
|
|
6
|
+
Create/Alter/DropFunction.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class SnakeRoutineInfo:
|
|
18
|
+
"""Metadata of a routine: name, raw SQL body and schema.
|
|
19
|
+
|
|
20
|
+
`body` is the whole `CREATE OR REPLACE FUNCTION ...` (opaque): the diff only compares the
|
|
21
|
+
string, and the creation DDL IS the body itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
body: str
|
|
26
|
+
schema: str = DEFAULT_SCHEMA
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Default value set by the SERVER (not by the Python client)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnakeServerDefault(Enum):
|
|
9
|
+
"""Default value generated by the DATABASE when the row is inserted.
|
|
10
|
+
|
|
11
|
+
Unlike `default` (a literal in the INSERT) and `default_factory` (a client-side callable), it
|
|
12
|
+
OMITS the column from the INSERT so the server fills it in, and the RETURNING brings it back.
|
|
13
|
+
Engine-agnostic values (not SQL): the dialect translates (`dialect.server_default_sql`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
NOW = "now" # the server's current timestamp
|
|
17
|
+
UUID_V4 = "uuid_v4" # random uuid (version 4) generated by the DB
|
|
18
|
+
TRUE = "true" # boolean true
|
|
19
|
+
FALSE = "false" # boolean false
|
|
20
|
+
ZERO = "zero" # numeric zero
|