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