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