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,504 @@
1
+ """PostgreSQL dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from datetime import date, datetime, time, timedelta
7
+ from decimal import Decimal
8
+ from enum import Enum
9
+ from typing import get_args, get_origin
10
+ from uuid import UUID
11
+
12
+ from snakeorm.dialects.literals import numeric_literal
13
+ from snakeorm.core.exceptions import SnakeDialectError
14
+ from snakeorm.dialects.matrix import Engine, capabilities_for
15
+ from snakeorm.dialects.capabilities import (
16
+ AlterColumnStyle,
17
+ CommentStyle,
18
+ EmptyInsertStyle,
19
+ DerivedFlags,
20
+ SnakeLimits,
21
+ SnakeSyntax,
22
+ )
23
+ from snakeorm.expressions.scalar import SnakeFunc
24
+ from snakeorm.times import SnakeUtc
25
+ from snakeorm.metadata.type_params import base_type
26
+ from snakeorm.metadata import (
27
+ SnakeDateTimeParams,
28
+ SnakeDecimalParams,
29
+ SnakeIndexMethod,
30
+ SnakeIntParams,
31
+ SnakeIntSize,
32
+ SnakeJsonParams,
33
+ SnakeJsonStorage,
34
+ SnakeServerDefault,
35
+ SnakeFloatParams,
36
+ SnakeStrParams,
37
+ SnakeTimeParams,
38
+ SnakeTypeParams,
39
+ )
40
+
41
+ # Scalar function (agnostic) -> its name on Postgres. The table centralises the per-engine mapping.
42
+ _POSTGRES_FUNCTIONS: dict[SnakeFunc, str] = {
43
+ SnakeFunc.LOWER: "LOWER",
44
+ SnakeFunc.UPPER: "UPPER",
45
+ SnakeFunc.TRIM: "TRIM",
46
+ SnakeFunc.LENGTH: "LENGTH",
47
+ SnakeFunc.CONCAT: "CONCAT",
48
+ SnakeFunc.DATE_TRUNC: "DATE_TRUNC",
49
+ SnakeFunc.EXTRACT: "EXTRACT",
50
+ SnakeFunc.ABS: "ABS",
51
+ SnakeFunc.ROUND: "ROUND",
52
+ SnakeFunc.SUBSTRING: "SUBSTRING",
53
+ SnakeFunc.REPLACE: "REPLACE",
54
+ SnakeFunc.CEIL: "CEIL",
55
+ SnakeFunc.FLOOR: "FLOOR",
56
+ SnakeFunc.SQRT: "SQRT",
57
+ SnakeFunc.POWER: "POWER",
58
+ }
59
+
60
+ # Index method (agnostic) -> Postgres's jargon. `BTREE` does not appear: it is the default, implicit without `USING`.
61
+ _POSTGRES_INDEX_METHODS: dict[SnakeIndexMethod, str] = {
62
+ SnakeIndexMethod.HASH: "HASH",
63
+ SnakeIndexMethod.GIN: "GIN",
64
+ SnakeIndexMethod.GIST: "GIST",
65
+ SnakeIndexMethod.BRIN: "BRIN",
66
+ }
67
+
68
+ # Server-side default (agnostic) -> its SQL expression on Postgres. `NOW` uses the standard
69
+ # `CURRENT_TIMESTAMP`, not `now()`; `UUID_V4` uses `gen_random_uuid()`.
70
+
71
+ _POSTGRES_CANNOT: dict[SnakeFunc, str] = {}
72
+ """What the engine genuinely cannot do, and why.
73
+
74
+ Declared rather than left out: a function missing from the table above reads exactly like one
75
+ nobody got round to, and silence meaning two different things is how `ABS` and `ROUND` sat absent
76
+ from SQLite while the other two had both.
77
+ """
78
+
79
+ _missing = set(SnakeFunc) - set(_POSTGRES_FUNCTIONS) - set(_POSTGRES_CANNOT)
80
+ if (
81
+ _missing
82
+ ): # pragma: no cover - the guard exists so this can never be reached in a good build
83
+ raise SnakeDialectError(
84
+ "PostgresDialect does not answer for every scalar function: "
85
+ f"{sorted(f.name for f in _missing)}. Add it to _POSTGRES_FUNCTIONS, or to _POSTGRES_CANNOT "
86
+ "with the reason. Like the `Cap` catalogue, forgetting one fails at import."
87
+ )
88
+
89
+ _POSTGRES_SERVER_DEFAULTS: dict[SnakeServerDefault, str] = {
90
+ SnakeServerDefault.NOW: "CURRENT_TIMESTAMP",
91
+ SnakeServerDefault.UUID_V4: "gen_random_uuid()",
92
+ SnakeServerDefault.TRUE: "TRUE",
93
+ SnakeServerDefault.FALSE: "FALSE",
94
+ SnakeServerDefault.ZERO: "0",
95
+ }
96
+
97
+ # An integer's width -> its type on Postgres, which really does tell them apart (2/4/8 bytes), unlike SQLite.
98
+ _POSTGRES_INT_SIZES: dict[SnakeIntSize, str] = {
99
+ SnakeIntSize.SMALLINT: "SMALLINT",
100
+ SnakeIntSize.INTEGER: "INTEGER",
101
+ SnakeIntSize.BIGINT: "BIGINT",
102
+ }
103
+
104
+ # The SERIAL family for the autoincrement: the PK's width rules. Emitting `SERIAL` (int4) for a PK
105
+ # declared wide would leave it short in silence.
106
+ _POSTGRES_SERIALS: dict[SnakeIntSize, str] = {
107
+ SnakeIntSize.SMALLINT: "SMALLSERIAL",
108
+ SnakeIntSize.INTEGER: "SERIAL",
109
+ SnakeIntSize.BIGINT: "BIGSERIAL",
110
+ }
111
+
112
+ # Python type -> Postgres SQL type. `int` and `dict` are NOT here: their type depends on a knob
113
+ # (`int_size`, `json_storage`) that `map_type` resolves.
114
+ _POSTGRES_TYPES: dict[object, str] = {
115
+ str: "TEXT",
116
+ bool: "BOOLEAN",
117
+ float: "DOUBLE PRECISION",
118
+ Decimal: "NUMERIC",
119
+ bytes: "BYTEA",
120
+ date: "DATE",
121
+ # The TWO date types, which are different types in the database and not a knob: `SnakeUtc` stores
122
+ # an INSTANT (TIMESTAMPTZ: the moment, without the offset it was written with) and a plain
123
+ # `datetime` a WALL-CLOCK TIME (TIMESTAMP), which identifies no instant until somebody says what
124
+ # zone it is in. The declarator picks it —`snake_datetimetz()` / `snake_datetime()`— and a guard
125
+ # in the compiler demands that it match the annotation.
126
+ SnakeUtc: "TIMESTAMPTZ",
127
+ datetime: "TIMESTAMP",
128
+ time: "TIME",
129
+ timedelta: "INTERVAL",
130
+ UUID: "UUID",
131
+ }
132
+
133
+
134
+ def _element_type(annotation: object) -> object:
135
+ """The element type of a `list[...]`; it fails clearly if the list does not declare it."""
136
+ args = get_args(annotation)
137
+ if not args:
138
+ raise SnakeDialectError(
139
+ f"{annotation!r} does not declare its element type: there is no untyped array in "
140
+ f"SQL. Annotate it as `list[int]`, `list[str]`, etc."
141
+ )
142
+ return args[0]
143
+
144
+
145
+ class PostgresDialect(DerivedFlags):
146
+ """How the SQL is written for PostgreSQL (psycopg2 driver).
147
+
148
+ Placeholder '%s'; it depends on the driver (asyncpg would use '$1'), today only psycopg2.
149
+ """
150
+
151
+ # It answers `Full()` to the WHOLE catalogue, and that is a fact, not a template: it is the only
152
+ # engine with no caveats, and that is why it is the yardstick the other two are measured against.
153
+ # `caveats()` being empty here is what makes the session emit no warning at all about Postgres.
154
+ capabilities = capabilities_for(Engine.POSTGRES)
155
+
156
+ syntax = SnakeSyntax(
157
+ triggers_are_table_scoped=True, # `DROP TRIGGER x ON table`
158
+ indexes_are_table_scoped=False, # `DROP INDEX x`, without `ON table`
159
+ empty_insert_style=EmptyInsertStyle.DEFAULT_VALUES,
160
+ alter_column_style=AlterColumnStyle.POSTGRES_TYPE_USING,
161
+ comment_style=CommentStyle.COMMENT_ON, # `COMMENT ON TABLE t IS 'x'`, a statement of its own
162
+ has_ilike=True,
163
+ has_nulls_ordering=True,
164
+ # `ROUND(double precision, int)` does not exist here; `ROUND(numeric, int)` does.
165
+ round_casts_first_argument_to="numeric",
166
+ )
167
+
168
+ # Ceilings of the declared parameters. They live here, and not in the metadata, because they
169
+ # belong to the ENGINE: the model has to be able to say NUMERIC(500,2) without knowing which
170
+ # database it will end up on.
171
+ limits = SnakeLimits(
172
+ bind_params=65535, # unsigned int16 in the protocol
173
+ numeric_precision=1000,
174
+ numeric_scale=1000, # the standard already demands scale <= precision
175
+ fractional_seconds=6, # microseconds: the resolution of Python's datetime
176
+ )
177
+
178
+ def placeholder(self, index: int) -> str:
179
+ """psycopg2 uses a positional '%s'; the index is not needed on this engine."""
180
+ return "%s"
181
+
182
+ def trigger_statements(self, name: str, body: str) -> tuple[list[str], str]:
183
+ """The body goes in a function and the trigger calls it: PostgreSQL takes no statements here.
184
+
185
+ A body that ALREADY calls a function is left alone — wrapping it would make a function that
186
+ calls a function. `RETURN NEW` is appended because a trigger function must return something,
187
+ and for an `AFTER` trigger the value is ignored but its absence is an error.
188
+ """
189
+ if body.strip().upper().startswith(("EXECUTE FUNCTION", "EXECUTE PROCEDURE")):
190
+ return [], body
191
+ function = self.quote_ident(f"{name}_fn")
192
+ return (
193
+ [
194
+ f"CREATE OR REPLACE FUNCTION {function}() RETURNS trigger AS $$ "
195
+ f"BEGIN {body} RETURN NEW; END; $$ LANGUAGE plpgsql"
196
+ ],
197
+ f"EXECUTE FUNCTION {function}()",
198
+ )
199
+
200
+ def quote_ident(self, name: str) -> str:
201
+ """Quotes with double quotes and doubles the inner quotes."""
202
+ escaped = name.replace('"', '""')
203
+ return f'"{escaped}"'
204
+
205
+ def json_get_sql(
206
+ self, source: str, key_path: tuple[str, ...], as_type: type
207
+ ) -> str:
208
+ """`->>` for one key, `#>>` with a `{a,b}` path for several, cast to the declared type.
209
+
210
+ `str` gets NO cast: `->>` already returns text, and a `::text` on every statement would be
211
+ noise saying nothing. The others do, because without it the comparison is a TEXT comparison
212
+ and `'9' > '100'` is true.
213
+ """
214
+ if len(key_path) == 1:
215
+ access = f"({source} ->> '{key_path[0]}')"
216
+ else:
217
+ access = f"({source} #>> '{{{','.join(key_path)}}}')"
218
+ cast = _PG_CAST.get(as_type)
219
+ return access if cast is None else f"({access})::{cast}"
220
+
221
+ def date_shift_sql(
222
+ self, source: str, amount_placeholder: str, unit: str, keeps_time: bool
223
+ ) -> str:
224
+ """`(col + (%s * INTERVAL '1 day'))`, and the multiplication is what parameterises it.
225
+
226
+ `INTERVAL '30 days'` would mean interpolating the amount into the statement. Multiplying a
227
+ ONE-unit interval by a placeholder was measured to answer the same date and keeps the value
228
+ in `params`, where every value in this ORM belongs. `keeps_time` is unused: Postgres has real
229
+ date and timestamp types and the result follows the operand.
230
+ """
231
+ return f"({source} + ({amount_placeholder} * INTERVAL '1 {unit}'))"
232
+
233
+ def string_agg_sql(
234
+ self, value: str, separator: str, order_by: str, params: list[object]
235
+ ) -> str:
236
+ """`STRING_AGG(col, %s ORDER BY ...)`: the separator is an argument, so it parameterises."""
237
+ params.append(separator)
238
+ placeholder = self.placeholder(len(params))
239
+ inside = f"{value}, {placeholder}"
240
+ return (
241
+ f"STRING_AGG({inside} ORDER BY {order_by})"
242
+ if order_by
243
+ else f"STRING_AGG({inside})"
244
+ )
245
+
246
+ def integer_division_op(self) -> str:
247
+ """`/` — measured: `SELECT 45/50` is `0` here, of type `integer`. Nothing to translate."""
248
+ return "/"
249
+
250
+ def cast_sql(self, source: str, as_type: type) -> str:
251
+ """`CAST(x AS double precision)`, reading the SAME table `json_get_sql` reads.
252
+
253
+ Two tables of one thing drift, and the one that drifts is the one with fewer readers.
254
+ """
255
+ return f"CAST({source} AS {_PG_CAST[as_type]})"
256
+
257
+ def __init__(self) -> None:
258
+ # A PER-INSTANCE copy of the type table: `register_type` writes here, not into the module's
259
+ # dict. If it were global, importing a library that registers a type would sneak it into
260
+ # every dialect in the process, including those of a database that does not support it.
261
+ self._types: dict[object, str] = dict(_POSTGRES_TYPES)
262
+
263
+ def drop_all_sql(self, tables: Sequence[str]) -> tuple[str, ...]:
264
+ """`DROP TABLE IF EXISTS ... CASCADE`, one per table: Postgres resolves the order itself.
265
+
266
+ `CASCADE` drops whatever depends on the table —the foreign keys pointing at it, the views
267
+ built on it— so no bracketing switch is needed and the order does not matter.
268
+ """
269
+ return tuple(
270
+ f"DROP TABLE IF EXISTS {self.quote_ident(table)} CASCADE"
271
+ for table in tables
272
+ )
273
+
274
+ def explain_sql(self, sql: str) -> str:
275
+ """`EXPLAIN <statement>`: one text column per line of the plan."""
276
+ return f"EXPLAIN {sql}"
277
+
278
+ def statement_timeout_sql(self, milliseconds: int) -> str | None:
279
+ """`SET statement_timeout`, already in milliseconds: no conversion needed."""
280
+ return f"SET statement_timeout = {milliseconds}"
281
+
282
+ def register_type(self, python_type: object, sql_type: str) -> None:
283
+ """Adds (or rewrites) the SQL spelling of a Python type in THIS dialect.
284
+
285
+ dialect.register_type(Inet, "INET")
286
+ address: SnakeColumn[Inet] = snake_column()
287
+
288
+ It is the extension point the thesis was missing: the type system was the single source of
289
+ truth, but its VOCABULARY was closed and putting in an `INET`, a `CITEXT` or a domain type
290
+ meant editing the dialect. It goes per dialect because the same Python type is written
291
+ differently on each engine, which is exactly what this axis exists for.
292
+
293
+ Rewriting a native type is allowed (e.g. `str` → `CITEXT` across the whole database): it is
294
+ an explicit escape hatch, and forbidding it would force forking the entire dialect over one
295
+ line.
296
+ """
297
+ self._types[python_type] = sql_type
298
+
299
+ def _guard_numeric(self, params: SnakeDecimalParams) -> None:
300
+ """Checks that the declared NUMERIC fits within what Postgres accepts.
301
+
302
+ Whatever is structurally impossible already died at declaration time; all that is left here
303
+ is the engine's ceiling, which is different on each one: `NUMERIC(500,2)` is valid and on
304
+ MySQL it would be impossible.
305
+ """
306
+ assert self.max_numeric_precision is not None
307
+ if params.precision > self.max_numeric_precision:
308
+ raise SnakeDialectError(
309
+ f"Postgres does not accept more than {self.max_numeric_precision} digits in a "
310
+ f"NUMERIC, and {params.precision} were requested. If you need more, the column is "
311
+ f"not a NUMERIC: store the number as text and operate outside the database."
312
+ )
313
+
314
+ def map_type(
315
+ self,
316
+ python_type: object,
317
+ autoincrement: bool = False,
318
+ params: SnakeTypeParams | None = None,
319
+ ) -> str:
320
+ """Translates the Python type into its COMPLETE Postgres type, parameters included.
321
+
322
+ It accepts `object`, not `type`: `list[int]` is a generic alias, not a class, and is
323
+ resolved by origin+argument; a bare `list` is rejected (an array with no element type does
324
+ not exist in SQL).
325
+
326
+ `params` are those of the type's FAMILY and arrive as ONE object, not as five loose knobs.
327
+ `precision` being one more of them is not cosmetic: it used to be glued onto the type from
328
+ outside here, with an f-string in `migration/ddl.py`, and that is why it was the only
329
+ parameter nobody validated.
330
+ """
331
+ # The TYPE rules; the `params` only FINE-TUNE. The other way round (dispatching by the
332
+ # params family) an `int` column with no parameters would not find its branch, because `int`
333
+ # and `dict` are not in the table: their SQL type always depended on a parameter and their
334
+ # default lives here.
335
+ if python_type is int:
336
+ size = (
337
+ params.size
338
+ if isinstance(params, SnakeIntParams)
339
+ else SnakeIntSize.BIGINT
340
+ )
341
+ # The integer PK rules its width: BIGINT->BIGSERIAL, INTEGER->SERIAL, SMALLINT->SMALLSERIAL.
342
+ return (
343
+ _POSTGRES_SERIALS[size] if autoincrement else _POSTGRES_INT_SIZES[size]
344
+ )
345
+ if autoincrement:
346
+ return _POSTGRES_SERIALS[SnakeIntSize.BIGINT]
347
+ # `base_type`: a `dict[str, object]` IS a dict column. Comparing the annotation
348
+ # itself let a parameterised one fall through to the default type, so the column
349
+ # came out TEXT and the JSON storage the user asked for was silently dropped.
350
+ if base_type(python_type) is dict:
351
+ # JSONB (indexable) or JSON (exact text). The member IS the literal type.
352
+ storage = params.storage if isinstance(params, SnakeJsonParams) else None
353
+ return (
354
+ storage.value if storage is not None else SnakeJsonStorage.JSONB.value
355
+ )
356
+ if (
357
+ python_type is str
358
+ and isinstance(params, SnakeStrParams)
359
+ and params.max_length
360
+ ):
361
+ # VARCHAR(n) = TEXT + a limit. It is not faster; it STATES a domain rule.
362
+ # With `fixed`, CHAR(n): a FIXED length, which pads with spaces and compares ignoring them.
363
+ return (
364
+ f"CHAR({params.max_length})"
365
+ if params.fixed
366
+ else f"VARCHAR({params.max_length})"
367
+ )
368
+ if python_type is float and isinstance(params, SnakeFloatParams):
369
+ # 4 bytes or 8. The default (8) comes from the type table, so that `register_type` can
370
+ # rewrite it; the 4 is an explicit storage decision.
371
+ return "REAL" if params.size == 4 else self._types[float]
372
+ if python_type is time and isinstance(params, SnakeTimeParams):
373
+ # The column's TYPE is decided by the declarator, not by whatever value arrives first.
374
+ # It is the same doctrine that split snake_datetime from snake_datetimetz.
375
+ return "TIMETZ" if params.with_timezone else self._types[time]
376
+ if isinstance(python_type, type) and issubclass(python_type, datetime):
377
+ # The TYPE decides the time zone, not a knob, and each one's spelling comes from the
378
+ # TABLE instead of being repeated here. It is not cosmetic: while the branch carried its
379
+ # own two literals, the table said `datetime -> TIMESTAMPTZ` and was never read, so it
380
+ # was carrying the OLD doctrine with nothing to give it away. An entry nobody runs is an
381
+ # entry nobody fixes. On top of that, `register_type()` now works on a subclass of your
382
+ # own.
383
+ base = self._types.get(python_type)
384
+ if base is None:
385
+ base = self._types[
386
+ SnakeUtc if issubclass(python_type, SnakeUtc) else datetime
387
+ ]
388
+ digits = (
389
+ params.precision if isinstance(params, SnakeDateTimeParams) else None
390
+ )
391
+ assert self.max_fractional_seconds is not None
392
+ if digits is not None and digits > self.max_fractional_seconds:
393
+ raise SnakeDialectError(
394
+ f"Postgres does not store more than {self.max_fractional_seconds} "
395
+ f"fractional-second digits, and {digits} were requested. With "
396
+ f"{self.max_fractional_seconds} you already reach microseconds, which is the "
397
+ f"whole resolution Python's datetime has."
398
+ )
399
+ return base if digits is None else f"{base}({digits})"
400
+ if python_type is Decimal and isinstance(params, SnakeDecimalParams):
401
+ # The precision is emitted HERE. It used to be concatenated in `migration/ddl.py`,
402
+ # outside the dialect, and that is why it was the only parameter that never went through
403
+ # a validation.
404
+ self._guard_numeric(params)
405
+ scale = f",{params.scale}" if params.scale is not None else ""
406
+ return f"NUMERIC({params.precision}{scale})"
407
+ origin = get_origin(python_type)
408
+ if origin is list or python_type is list:
409
+ return f"{self.map_type(_element_type(python_type))}[]"
410
+ try:
411
+ return self._types[python_type]
412
+ except (KeyError, TypeError):
413
+ raise SnakeDialectError(
414
+ f"PostgresDialect does not know how to map {python_type!r} to a SQL type. If it "
415
+ f"is a type of your own, or a Postgres one the ORM does not ship with, register "
416
+ f"it: "
417
+ f'dialect.register_type({getattr(python_type, "__name__", python_type)}, "INET")'
418
+ ) from None
419
+
420
+ def limit_offset(
421
+ self, limit: int | None, offset: int | None, params: list[object]
422
+ ) -> str:
423
+ """A parametrised `LIMIT %s OFFSET %s` (Postgres accepts parameters in both)."""
424
+ parts: list[str] = []
425
+ if limit is not None:
426
+ params.append(limit)
427
+ parts.append(f"LIMIT {self.placeholder(len(params))}")
428
+ if offset is not None:
429
+ params.append(offset)
430
+ parts.append(f"OFFSET {self.placeholder(len(params))}")
431
+ return " ".join(parts)
432
+
433
+ def on_conflict_clause(
434
+ self, conflict_columns: Sequence[str], update_columns: Sequence[str]
435
+ ) -> str:
436
+ """`ON CONFLICT (<cols>) DO NOTHING`; with `update_columns`, `DO UPDATE SET c = EXCLUDED.c`.
437
+ `EXCLUDED` is the row that was attempted, so the UPDATE rewrites with the incoming value.
438
+ """
439
+ conflict = ", ".join(self.quote_ident(column) for column in conflict_columns)
440
+ if not update_columns:
441
+ return f"ON CONFLICT ({conflict}) DO NOTHING"
442
+ assignments = ", ".join(
443
+ f"{self.quote_ident(column)} = EXCLUDED.{self.quote_ident(column)}"
444
+ for column in update_columns
445
+ )
446
+ return f"ON CONFLICT ({conflict}) DO UPDATE SET {assignments}"
447
+
448
+ def literal(self, value: object) -> str:
449
+ """Formats a default value as a Postgres SQL literal (DDL DEFAULT).
450
+ The enum is unwrapped to its value EXPLICITLY, without relying on IntEnum/StrEnum's `str()`.
451
+ """
452
+ if isinstance(value, Enum):
453
+ value = value.value
454
+ if value is None:
455
+ return "NULL"
456
+ if isinstance(value, bool): # before int: bool is a subclass of int
457
+ return "TRUE" if value else "FALSE"
458
+ if isinstance(value, (int, float, Decimal)):
459
+ return numeric_literal(value, "PostgresDialect")
460
+ if isinstance(value, str):
461
+ escaped = value.replace("'", "''")
462
+ return f"'{escaped}'"
463
+ raise SnakeDialectError(
464
+ f"PostgresDialect does not know how to format {value!r} as a SQL literal"
465
+ )
466
+
467
+ def function_name(self, func: SnakeFunc) -> str:
468
+ """Translates the agnostic name of a scalar function into the engine's own."""
469
+ if func in _POSTGRES_FUNCTIONS:
470
+ return _POSTGRES_FUNCTIONS[func]
471
+ reason = _POSTGRES_CANNOT.get(func)
472
+ if reason is None:
473
+ # Not a catalogue member at all. A real one cannot reach here: the import
474
+ # guard above refuses to load a dialect that skipped any.
475
+ raise SnakeDialectError(
476
+ f"PostgresDialect does not know how to translate the function {func!r}"
477
+ )
478
+ raise SnakeDialectError(
479
+ f"PostgresDialect cannot translate {func.name}: {reason} "
480
+ "Reach for it through `raw()` with the engine's own spelling."
481
+ )
482
+
483
+ def index_method(self, method: SnakeIndexMethod) -> str:
484
+ """Translates the index method (agnostic) into Postgres's jargon for the `USING`."""
485
+ try:
486
+ return _POSTGRES_INDEX_METHODS[method]
487
+ except KeyError:
488
+ raise SnakeDialectError(
489
+ f"PostgresDialect does not know how to translate the index method {method!r}"
490
+ ) from None
491
+
492
+ def server_default_sql(self, value: SnakeServerDefault) -> str:
493
+ """Translates the server-side default (agnostic) into its Postgres SQL expression."""
494
+ try:
495
+ return _POSTGRES_SERVER_DEFAULTS[value]
496
+ except KeyError:
497
+ raise SnakeDialectError(
498
+ f"PostgresDialect does not know how to translate the server_default {value!r} to SQL"
499
+ ) from None
500
+
501
+
502
+ _PG_CAST = {int: "integer", float: "double precision", bool: "boolean"}
503
+ """What a declared type becomes in a Postgres cast. `str` is absent on purpose: `->>` already gives
504
+ text, and casting it would put `::text` on every statement to say nothing."""