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,417 @@
|
|
|
1
|
+
"""How the SQL is written for SQLite. It supports almost all of `query/` (RETURNING, ON CONFLICT,
|
|
2
|
+
row constructor, WITH RECURSIVE, window functions).
|
|
3
|
+
|
|
4
|
+
What it does NOT have, and what it has only HALFWAY, is declared in `capabilities`: the absences
|
|
5
|
+
(`ADD CONSTRAINT`, `ALTER COLUMN`, `CREATE SCHEMA`, `FOR UPDATE`, `COMMENT ON`) fail at COMPILE time
|
|
6
|
+
with the alternative —SQL the engine would not understand is never emitted—, and the degradations (a
|
|
7
|
+
`Decimal` that comes back exact but sorts as text) are warned about when the session opens, once
|
|
8
|
+
each.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from datetime import date, datetime, time, timedelta
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import get_origin
|
|
18
|
+
from uuid import UUID
|
|
19
|
+
|
|
20
|
+
from snakeorm.dialects.literals import numeric_literal
|
|
21
|
+
from snakeorm.core.exceptions import SnakeDialectError
|
|
22
|
+
import sqlite3
|
|
23
|
+
|
|
24
|
+
from snakeorm.dialects.matrix import Engine, capabilities_for
|
|
25
|
+
from snakeorm.dialects.capabilities import (
|
|
26
|
+
AlterColumnStyle,
|
|
27
|
+
CommentStyle,
|
|
28
|
+
EmptyInsertStyle,
|
|
29
|
+
DerivedFlags,
|
|
30
|
+
SnakeLimits,
|
|
31
|
+
SnakeSyntax,
|
|
32
|
+
)
|
|
33
|
+
from snakeorm.times import SnakeUtc
|
|
34
|
+
from snakeorm.expressions import SnakeFunc
|
|
35
|
+
from snakeorm.metadata import (
|
|
36
|
+
SnakeIndexMethod,
|
|
37
|
+
SnakeServerDefault,
|
|
38
|
+
SnakeTypeParams,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_SQLITE_TYPES: dict[object, str] = {
|
|
42
|
+
SnakeUtc: "TEXT",
|
|
43
|
+
# SQLite has FIVE storage classes; everything lands in one. The engine tells nothing else apart.
|
|
44
|
+
int: "INTEGER",
|
|
45
|
+
bool: "INTEGER", # 0/1: SQLite has no boolean
|
|
46
|
+
str: "TEXT",
|
|
47
|
+
float: "REAL",
|
|
48
|
+
bytes: "BLOB",
|
|
49
|
+
# TEXT, not NUMERIC: the NUMERIC affinity converts any numeric text to REAL, destroying the
|
|
50
|
+
# exactness Decimal exists to preserve. With TEXT it comes back exact.
|
|
51
|
+
Decimal: "TEXT",
|
|
52
|
+
# No date types: ISO-8601 text, the only thing that sorts and compares properly (SQLite recommends it).
|
|
53
|
+
datetime: "TEXT",
|
|
54
|
+
date: "TEXT",
|
|
55
|
+
time: "TEXT",
|
|
56
|
+
timedelta: "TEXT",
|
|
57
|
+
UUID: "TEXT",
|
|
58
|
+
dict: "TEXT", # JSON as text: SQLite's json_* functions operate on TEXT
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_SQLITE_FUNCTIONS: dict[SnakeFunc, str] = {
|
|
62
|
+
SnakeFunc.LOWER: "LOWER",
|
|
63
|
+
SnakeFunc.UPPER: "UPPER",
|
|
64
|
+
SnakeFunc.TRIM: "TRIM",
|
|
65
|
+
SnakeFunc.LENGTH: "LENGTH",
|
|
66
|
+
SnakeFunc.CONCAT: "CONCAT",
|
|
67
|
+
SnakeFunc.SUBSTRING: "SUBSTRING",
|
|
68
|
+
SnakeFunc.REPLACE: "REPLACE",
|
|
69
|
+
SnakeFunc.ABS: "ABS",
|
|
70
|
+
SnakeFunc.ROUND: "ROUND",
|
|
71
|
+
# `CEIL`, `FLOOR`, `SQRT` and `POWER` are a COMPILE-TIME option (`ENABLE_MATH_FUNCTIONS`); a
|
|
72
|
+
# build without it answers `no such function: ceil` at runtime. That is a property of the
|
|
73
|
+
# BINARY, not of "SQLite", so it cannot be a `Cap`: a capability is answered by the class, which
|
|
74
|
+
# knows nothing about which library got linked. `ABS` and `ROUND` are NOT in that group — every
|
|
75
|
+
# build ships them, which is why their absence here was a bug and not a limit.
|
|
76
|
+
SnakeFunc.CEIL: "CEIL",
|
|
77
|
+
SnakeFunc.FLOOR: "FLOOR",
|
|
78
|
+
SnakeFunc.SQRT: "SQRT",
|
|
79
|
+
SnakeFunc.POWER: "POWER",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_SQLITE_CANNOT: dict[SnakeFunc, str] = {
|
|
83
|
+
SnakeFunc.DATE_TRUNC: "SQLite has no DATE_TRUNC.",
|
|
84
|
+
SnakeFunc.EXTRACT: "SQLite has no EXTRACT.",
|
|
85
|
+
}
|
|
86
|
+
"""What the engine genuinely cannot do, and why.
|
|
87
|
+
|
|
88
|
+
Declared rather than left out, because a function missing from the table above reads exactly like
|
|
89
|
+
one nobody got round to. That is how `ABS` and `ROUND` sat absent while Postgres and MySQL had both:
|
|
90
|
+
silence meant two different things and nothing could tell them apart.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
_missing = set(SnakeFunc) - set(_SQLITE_FUNCTIONS) - set(_SQLITE_CANNOT)
|
|
94
|
+
if (
|
|
95
|
+
_missing
|
|
96
|
+
): # pragma: no cover - the guard exists so this can never be reached in a good build
|
|
97
|
+
raise SnakeDialectError(
|
|
98
|
+
"SQLiteDialect does not answer for every scalar function: "
|
|
99
|
+
f"{sorted(f.name for f in _missing)}. Add it to _SQLITE_FUNCTIONS, or to _SQLITE_CANNOT "
|
|
100
|
+
"with the reason. Like the `Cap` catalogue, forgetting one fails at import."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_SQLITE_SERVER_DEFAULTS: dict[SnakeServerDefault, str] = {
|
|
104
|
+
SnakeServerDefault.NOW: "CURRENT_TIMESTAMP",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SQLiteDialect(DerivedFlags):
|
|
109
|
+
"""How the SQL is written for SQLite (with the stdlib's `sqlite3` driver)."""
|
|
110
|
+
|
|
111
|
+
capabilities = capabilities_for(Engine.SQLITE, sqlite3.sqlite_version_info)
|
|
112
|
+
|
|
113
|
+
syntax = SnakeSyntax(
|
|
114
|
+
# A SQLite trigger is GLOBAL, not per table: `DROP TRIGGER x` without `ON table` (Postgres's
|
|
115
|
+
# shape gave `near "ON": syntax error` and left ORM triggers the ORM could not drop).
|
|
116
|
+
triggers_are_table_scoped=False,
|
|
117
|
+
indexes_are_table_scoped=False, # `DROP INDEX x`, without `ON table`
|
|
118
|
+
empty_insert_style=EmptyInsertStyle.DEFAULT_VALUES,
|
|
119
|
+
# `NULLS FIRST|LAST` since SQLite 3.30; measured on 3.50.4, which is what this
|
|
120
|
+
# interpreter carries. Not shared with MySQL, which answers 1064 to the same words.
|
|
121
|
+
has_nulls_ordering=True,
|
|
122
|
+
alter_column_style=AlterColumnStyle.UNSUPPORTED,
|
|
123
|
+
# It stores no comment at all, so there is no spelling to pick. Same as the line above: the
|
|
124
|
+
# plan stops on `Cap.COMMENTS` before any emitter runs.
|
|
125
|
+
comment_style=CommentStyle.UNSUPPORTED,
|
|
126
|
+
# MEASURED, and it is not the pragma the design named. `PRAGMA foreign_keys = OFF` is
|
|
127
|
+
# documented as a NO-OP inside a transaction, and `SQLiteDriver` opens one lazily before the
|
|
128
|
+
# first statement — so a rebuild that trusted it would run with the keys still armed and
|
|
129
|
+
# nothing would say so. `defer_foreign_keys` is the one built for this: it is set INSIDE the
|
|
130
|
+
# transaction, it resets itself at COMMIT, and the violations it postpones are reported by
|
|
131
|
+
# that COMMIT.
|
|
132
|
+
defer_constraints_statement="PRAGMA defer_foreign_keys = ON",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# `None`, and not some big number: SQLite has no ceilings to break because it does NOT STORE the
|
|
136
|
+
# declared parameter —it has a per-column affinity and nothing else—. A number would assert a
|
|
137
|
+
# limit that does not exist; a small one would reject models this engine stores just as well.
|
|
138
|
+
limits = SnakeLimits(
|
|
139
|
+
bind_params=32766, # the default SQLITE_MAX_VARIABLE_NUMBER since 3.32
|
|
140
|
+
numeric_precision=None,
|
|
141
|
+
numeric_scale=None,
|
|
142
|
+
fractional_seconds=None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def __init__(self) -> None:
|
|
146
|
+
# A PER-INSTANCE copy of the type table: `register_type` writes here, not into the module's
|
|
147
|
+
# dict. Global, importing a library that registers a type would sneak it into every dialect
|
|
148
|
+
# in the process, including those of a database that does not support it.
|
|
149
|
+
self._types: dict[object, str] = dict(_SQLITE_TYPES)
|
|
150
|
+
|
|
151
|
+
def drop_all_sql(self, tables: Sequence[str]) -> tuple[str, ...]:
|
|
152
|
+
"""Plain drops, with the key checking POSTPONED to the COMMIT.
|
|
153
|
+
|
|
154
|
+
No `CASCADE`: SQLite does not parse the keyword and answers a syntax error.
|
|
155
|
+
|
|
156
|
+
And not `PRAGMA foreign_keys = OFF` either. MEASURED, it makes nothing work: the pragma is a
|
|
157
|
+
no-op inside a transaction — the same finding written twenty lines above, about
|
|
158
|
+
`defer_constraints_statement` — and `SQLiteDriver._ensure_tx` opens one before every
|
|
159
|
+
statement that reaches this engine, so the value still reads 1 immediately after the OFF is
|
|
160
|
+
sent.
|
|
161
|
+
|
|
162
|
+
`defer_foreign_keys` is the pragma built for this and the one `syntax` already names: it
|
|
163
|
+
takes effect INSIDE the transaction and moves every check to the COMMIT, by which point
|
|
164
|
+
every table is gone and there is nothing left to violate. That also covers the case no
|
|
165
|
+
ordering can reach — two tables pointing at each other — which is what Postgres's `CASCADE`
|
|
166
|
+
and MySQL's switch survive today. And it resets ITSELF at that COMMIT, so unlike MySQL's
|
|
167
|
+
switch there is nothing to put back: no session is ever handed over with its keys disarmed.
|
|
168
|
+
|
|
169
|
+
It is a COMMIT-time promise, so the one thing it asks of the caller is the one thing the
|
|
170
|
+
Protocol already asks: run the batch as it comes. A caller that committed between two drops
|
|
171
|
+
would be back to needing a safe order — which is what `fresh` used to do, and it died here
|
|
172
|
+
with `FOREIGN KEY constraint failed` halfway through, schema half gone, because a model
|
|
173
|
+
declared BEFORE the one pointing at it is perfectly legal.
|
|
174
|
+
"""
|
|
175
|
+
if not tables:
|
|
176
|
+
return ()
|
|
177
|
+
drops = tuple(
|
|
178
|
+
f"DROP TABLE IF EXISTS {self.quote_ident(table)}" for table in tables
|
|
179
|
+
)
|
|
180
|
+
defer = self.syntax.defer_constraints_statement
|
|
181
|
+
return ((defer,) if defer is not None else ()) + drops
|
|
182
|
+
|
|
183
|
+
def explain_sql(self, sql: str) -> str:
|
|
184
|
+
"""`EXPLAIN QUERY PLAN`, and the two words matter.
|
|
185
|
+
|
|
186
|
+
A bare `EXPLAIN` here dumps the VDBE bytecode — a real answer to a different question, and
|
|
187
|
+
the one a user reading "explain" never wants.
|
|
188
|
+
"""
|
|
189
|
+
return f"EXPLAIN QUERY PLAN {sql}"
|
|
190
|
+
|
|
191
|
+
def statement_timeout_sql(self, milliseconds: int) -> str | None:
|
|
192
|
+
"""None: SQLite has no server-side statement timeout, and `busy_timeout` is not one.
|
|
193
|
+
|
|
194
|
+
`busy_timeout` waits for a LOCK to be released; it does nothing about a query that is simply
|
|
195
|
+
slow. Returning it here would be answering a different question with a value that looks
|
|
196
|
+
right, which is worse than answering nothing.
|
|
197
|
+
"""
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def register_type(self, python_type: object, sql_type: str) -> None:
|
|
201
|
+
"""Adds (or rewrites) the SQL spelling of a Python type in THIS dialect.
|
|
202
|
+
|
|
203
|
+
See `SnakeDialect.register_type`. It goes per dialect because the same Python type is
|
|
204
|
+
written differently on each engine: an `Inet` is `INET` on Postgres and `TEXT` here.
|
|
205
|
+
"""
|
|
206
|
+
self._types[python_type] = sql_type
|
|
207
|
+
|
|
208
|
+
def placeholder(self, index: int) -> str:
|
|
209
|
+
"""SQLite uses a positional '?' (like psycopg2's `%s`): params in TEXTUAL ORDER for both."""
|
|
210
|
+
return "?"
|
|
211
|
+
|
|
212
|
+
def trigger_statements(self, name: str, body: str) -> tuple[list[str], str]:
|
|
213
|
+
"""`BEGIN ... END` around the body. Without them SQLite answers `near "UPDATE": syntax error`.
|
|
214
|
+
|
|
215
|
+
Measured: it is not optional even for a single statement, which is the difference from MySQL
|
|
216
|
+
and the reason this is three implementations and not two.
|
|
217
|
+
|
|
218
|
+
A body that ALREADY carries them is left alone, the same rule PostgreSQL applies to a body
|
|
219
|
+
that already calls a function. Wrapping blindly produced `BEGIN BEGIN SELECT 1; END END` and
|
|
220
|
+
SQLite answered `near "BEGIN": syntax error` — the asymmetry was written into this very
|
|
221
|
+
method, in the same session that had just fixed it one file over.
|
|
222
|
+
"""
|
|
223
|
+
stripped = body.strip()
|
|
224
|
+
if stripped.upper().startswith("BEGIN"):
|
|
225
|
+
return [], stripped
|
|
226
|
+
return [], f"BEGIN {body} END"
|
|
227
|
+
|
|
228
|
+
def quote_ident(self, name: str) -> str:
|
|
229
|
+
"""Quotes with double quotes and doubles the inner ones (SQLite accepts the standard)."""
|
|
230
|
+
escaped = name.replace('"', '""')
|
|
231
|
+
return f'"{escaped}"'
|
|
232
|
+
|
|
233
|
+
def json_get_sql(
|
|
234
|
+
self, source: str, key_path: tuple[str, ...], as_type: type
|
|
235
|
+
) -> str:
|
|
236
|
+
"""`json_extract` with a `$.a.b` path. The CAST is kept even though SQLite types the result.
|
|
237
|
+
|
|
238
|
+
Keeping it is not belt and braces: SQLite's `json_extract` gives back whatever the document
|
|
239
|
+
held, so a `"5"` stored as text comes back as text and the comparison would be lexicographic
|
|
240
|
+
— the very failure the declared type exists to stop, and the one SQLite is most prone to.
|
|
241
|
+
"""
|
|
242
|
+
path = "$." + ".".join(key_path)
|
|
243
|
+
access = f"json_extract({source}, '{path}')"
|
|
244
|
+
cast = _SQLITE_CAST.get(as_type)
|
|
245
|
+
return access if cast is None else f"CAST({access} AS {cast})"
|
|
246
|
+
|
|
247
|
+
def date_shift_sql(
|
|
248
|
+
self, source: str, amount_placeholder: str, unit: str, keeps_time: bool
|
|
249
|
+
) -> str:
|
|
250
|
+
"""`date(col, ? || ' days')` — the modifier is TEXT, built in SQL and never in Python.
|
|
251
|
+
|
|
252
|
+
`keeps_time` is what picks the function, and it is the only dialect that needs it: SQLite has
|
|
253
|
+
no date type to inspect, and `date()` on a timestamp would silently drop the clock. The
|
|
254
|
+
compiled type stamped on the expression is what answers it.
|
|
255
|
+
|
|
256
|
+
The plural is not cosmetic: SQLite's modifiers are `days`, `months`, `years`.
|
|
257
|
+
"""
|
|
258
|
+
function = "datetime" if keeps_time else "date"
|
|
259
|
+
return f"{function}({source}, {amount_placeholder} || ' {unit}s')"
|
|
260
|
+
|
|
261
|
+
def string_agg_sql(
|
|
262
|
+
self, value: str, separator: str, order_by: str, params: list[object]
|
|
263
|
+
) -> str:
|
|
264
|
+
"""`group_concat(col, ? ORDER BY ...)`: a different NAME, the same shape as Postgres.
|
|
265
|
+
|
|
266
|
+
The `ORDER BY` inside the call only arrived in SQLite 3.44, which is why it was measured
|
|
267
|
+
rather than assumed.
|
|
268
|
+
"""
|
|
269
|
+
params.append(separator)
|
|
270
|
+
placeholder = self.placeholder(len(params))
|
|
271
|
+
inside = f"{value}, {placeholder}"
|
|
272
|
+
return (
|
|
273
|
+
f"group_concat({inside} ORDER BY {order_by})"
|
|
274
|
+
if order_by
|
|
275
|
+
else f"group_concat({inside})"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def integer_division_op(self) -> str:
|
|
279
|
+
"""`/` — measured: `SELECT 45/50` is `0` here too. SQLite agrees with PostgreSQL on this."""
|
|
280
|
+
return "/"
|
|
281
|
+
|
|
282
|
+
def cast_sql(self, source: str, as_type: type) -> str:
|
|
283
|
+
"""`CAST(x AS REAL)` — and REAL is the point, not a detail.
|
|
284
|
+
|
|
285
|
+
MEASURED: `CAST(45 AS NUMERIC) / 50` answers `0` here and `0.9` on PostgreSQL. SQLite's
|
|
286
|
+
NUMERIC affinity converts back to an integer when the value is integral, so the tempting
|
|
287
|
+
single spelling for the three engines would lose the decimals on this one, in silence.
|
|
288
|
+
"""
|
|
289
|
+
return f"CAST({source} AS {_SQLITE_CAST[as_type]})"
|
|
290
|
+
|
|
291
|
+
def map_type(
|
|
292
|
+
self,
|
|
293
|
+
python_type: object,
|
|
294
|
+
autoincrement: bool = False,
|
|
295
|
+
params: SnakeTypeParams | None = None,
|
|
296
|
+
) -> str:
|
|
297
|
+
"""Translates the Python type into one of SQLite's five storage classes.
|
|
298
|
+
|
|
299
|
+
With `autoincrement` it returns `INTEGER`: SQLite's autoincrement is `INTEGER PRIMARY KEY`
|
|
300
|
+
(an alias of ROWID). The `params` are accepted but IGNORED: SQLite has a single affinity per
|
|
301
|
+
class, so a width, a length or a precision have nowhere to be written. They are accepted so
|
|
302
|
+
that the same model works on both engines; that they change nothing here is said by the
|
|
303
|
+
dialect's fidelity warning, not by silence.
|
|
304
|
+
"""
|
|
305
|
+
if autoincrement:
|
|
306
|
+
return "INTEGER"
|
|
307
|
+
origin = get_origin(python_type)
|
|
308
|
+
if origin is not None:
|
|
309
|
+
# SQLite has no arrays: the list falls back to TEXT and travels as JSON (`adapt_param`
|
|
310
|
+
# serialises it, `_to_list` rebuilds it, so the attribute is still a list). It used to
|
|
311
|
+
# be REJECTED so as not to lose the type in silence, and that was right while there was
|
|
312
|
+
# no way to say so; with the degradation declared in `capabilities` there is one, and
|
|
313
|
+
# rejecting only achieved that a list column existed on Postgres alone.
|
|
314
|
+
if origin is list:
|
|
315
|
+
return "TEXT"
|
|
316
|
+
if origin is dict:
|
|
317
|
+
return "TEXT"
|
|
318
|
+
if isinstance(python_type, type) and issubclass(python_type, Enum):
|
|
319
|
+
# An enum is stored as its BASE type (str or int) with its CHECK alongside, as on Postgres.
|
|
320
|
+
base = str if issubclass(python_type, str) else int
|
|
321
|
+
return _SQLITE_TYPES[base]
|
|
322
|
+
try:
|
|
323
|
+
return self._types[python_type]
|
|
324
|
+
except (KeyError, TypeError):
|
|
325
|
+
raise SnakeDialectError(
|
|
326
|
+
f"SQLiteDialect does not know how to translate {python_type!r} into a SQLite type"
|
|
327
|
+
) from None
|
|
328
|
+
|
|
329
|
+
def limit_offset(
|
|
330
|
+
self, limit: int | None, offset: int | None, params: list[object]
|
|
331
|
+
) -> str:
|
|
332
|
+
"""A parametrised `LIMIT ? OFFSET ?`. With `offset` and no `limit` it uses `LIMIT -1`: SQLite does not accept a bare OFFSET."""
|
|
333
|
+
parts: list[str] = []
|
|
334
|
+
if limit is not None:
|
|
335
|
+
params.append(limit)
|
|
336
|
+
parts.append(f"LIMIT {self.placeholder(len(params))}")
|
|
337
|
+
elif offset is not None:
|
|
338
|
+
parts.append("LIMIT -1")
|
|
339
|
+
if offset is not None:
|
|
340
|
+
params.append(offset)
|
|
341
|
+
parts.append(f"OFFSET {self.placeholder(len(params))}")
|
|
342
|
+
return " ".join(parts)
|
|
343
|
+
|
|
344
|
+
def on_conflict_clause(
|
|
345
|
+
self, conflict_columns: Sequence[str], update_columns: Sequence[str]
|
|
346
|
+
) -> str:
|
|
347
|
+
"""`ON CONFLICT (<cols>) DO NOTHING` / `DO UPDATE SET c = excluded.c`. Like Postgres, but with `excluded` in lower case."""
|
|
348
|
+
conflict = ", ".join(self.quote_ident(column) for column in conflict_columns)
|
|
349
|
+
if not update_columns:
|
|
350
|
+
return f"ON CONFLICT ({conflict}) DO NOTHING"
|
|
351
|
+
assignments = ", ".join(
|
|
352
|
+
f"{self.quote_ident(column)} = excluded.{self.quote_ident(column)}"
|
|
353
|
+
for column in update_columns
|
|
354
|
+
)
|
|
355
|
+
return f"ON CONFLICT ({conflict}) DO UPDATE SET {assignments}"
|
|
356
|
+
|
|
357
|
+
def literal(self, value: object) -> str:
|
|
358
|
+
"""Formats a value as a SQLite SQL literal (DDL, no params).
|
|
359
|
+
A bool goes as 0/1: SQLite has no boolean type, it stores integers.
|
|
360
|
+
"""
|
|
361
|
+
if isinstance(value, Enum):
|
|
362
|
+
value = value.value
|
|
363
|
+
if value is None:
|
|
364
|
+
return "NULL"
|
|
365
|
+
if isinstance(value, bool): # before int: bool is a subclass of int
|
|
366
|
+
return "1" if value else "0"
|
|
367
|
+
if isinstance(value, (int, float, Decimal)):
|
|
368
|
+
return numeric_literal(value, "SQLiteDialect")
|
|
369
|
+
if isinstance(value, str):
|
|
370
|
+
escaped = value.replace("'", "''")
|
|
371
|
+
return f"'{escaped}'"
|
|
372
|
+
raise SnakeDialectError(
|
|
373
|
+
f"SQLiteDialect does not know how to format {value!r} as a SQL literal"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def function_name(self, func: SnakeFunc) -> str:
|
|
377
|
+
"""Translates the agnostic name of a scalar function into SQLite's own."""
|
|
378
|
+
if func in _SQLITE_FUNCTIONS:
|
|
379
|
+
return _SQLITE_FUNCTIONS[func]
|
|
380
|
+
reason = _SQLITE_CANNOT.get(func)
|
|
381
|
+
if reason is None:
|
|
382
|
+
# Not a catalogue member at all. A real one cannot reach here: the import
|
|
383
|
+
# guard above refuses to load a dialect that skipped any.
|
|
384
|
+
raise SnakeDialectError(
|
|
385
|
+
f"SQLiteDialect does not know how to translate the function {func!r}"
|
|
386
|
+
)
|
|
387
|
+
raise SnakeDialectError(
|
|
388
|
+
f"SQLiteDialect cannot translate {func.name}: {reason} "
|
|
389
|
+
"Reach for it through `raw()` with the engine's own spelling."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def index_method(self, method: SnakeIndexMethod) -> str:
|
|
393
|
+
"""SQLite has ONE kind of index: there is no `USING`.
|
|
394
|
+
It is rejected instead of ignored: accepting `method=GIN` and emitting a plain index would lie in silence.
|
|
395
|
+
"""
|
|
396
|
+
raise SnakeDialectError(
|
|
397
|
+
f"SQLite has only one kind of index, so it does not accept method={method.name}. "
|
|
398
|
+
f"Drop the `method=` for this engine."
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def server_default_sql(self, value: SnakeServerDefault) -> str:
|
|
402
|
+
"""Translates the server-side default into its SQLite expression."""
|
|
403
|
+
try:
|
|
404
|
+
return _SQLITE_SERVER_DEFAULTS[value]
|
|
405
|
+
except KeyError:
|
|
406
|
+
raise SnakeDialectError(
|
|
407
|
+
f"SQLiteDialect does not know how to translate the server_default {value!r} to SQL"
|
|
408
|
+
) from None
|
|
409
|
+
|
|
410
|
+
def __repr__(self) -> str:
|
|
411
|
+
"""The dialect's name, useful in the capability error messages."""
|
|
412
|
+
return "SQLiteDialect()"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
_SQLITE_CAST = {int: "INTEGER", float: "REAL", bool: "INTEGER"}
|
|
416
|
+
"""What a declared type becomes in a SQLite cast. `str` is absent: the value is already text,
|
|
417
|
+
and SQLite has no boolean, so a bool is the INTEGER it stores."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SnakeORM drivers: how the SQL is EXECUTED for each engine."""
|
|
2
|
+
|
|
3
|
+
from snakeorm.drivers.asyncbase import AsyncDriver as AsyncDriver
|
|
4
|
+
from snakeorm.drivers.asyncdecorators import AsyncLoggingDriver as AsyncLoggingDriver
|
|
5
|
+
from snakeorm.drivers.asyncdecorators import AsyncTimeoutDriver as AsyncTimeoutDriver
|
|
6
|
+
from snakeorm.drivers.asyncpsycopg import AsyncPsycopgDriver as AsyncPsycopgDriver
|
|
7
|
+
from snakeorm.drivers.asyncpymysql import AsyncPyMySQLDriver as AsyncPyMySQLDriver
|
|
8
|
+
from snakeorm.drivers.asyncsqlite import AsyncSQLiteDriver as AsyncSQLiteDriver
|
|
9
|
+
from snakeorm.drivers.threaded import ThreadedAsyncDriver as ThreadedAsyncDriver
|
|
10
|
+
from snakeorm.drivers.asyncpool import AsyncSnakePool as AsyncSnakePool
|
|
11
|
+
from snakeorm.drivers.base import SnakeDriver as SnakeDriver
|
|
12
|
+
from snakeorm.drivers.logging import LoggingDriver as LoggingDriver
|
|
13
|
+
from snakeorm.drivers.pool import SnakePool as SnakePool
|
|
14
|
+
from snakeorm.drivers.pool import psycopg_pool as psycopg_pool
|
|
15
|
+
from snakeorm.drivers.psycopg import PsycopgDriver as PsycopgDriver
|
|
16
|
+
from snakeorm.drivers.pymysql import PyMySQLDriver as PyMySQLDriver
|
|
17
|
+
from snakeorm.drivers.sqlite import SQLiteDriver as SQLiteDriver
|
|
18
|
+
from snakeorm.drivers.timeout import TimeoutDriver as TimeoutDriver
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""`AsyncDriver`: the async mirror of `SnakeDriver` (same methods, `async`). The only piece
|
|
2
|
+
with "colour" on the execution side: SQL generation has none, so the compiler, the dialect,
|
|
3
|
+
the query, the expressions and the migrations are reused AS THEY ARE between sync and async.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Sequence
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncDriver(Protocol):
|
|
13
|
+
"""How the SQL is EXECUTED asynchronously. It cannot write it: that is the dialect."""
|
|
14
|
+
|
|
15
|
+
async def fetch_all(
|
|
16
|
+
self, sql: str, params: Sequence[object]
|
|
17
|
+
) -> list[tuple[object, ...]]:
|
|
18
|
+
"""Runs the query and returns every row as tuples."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def fetch_iter(
|
|
22
|
+
self, sql: str, params: Sequence[object], *, chunk: int = 1000
|
|
23
|
+
) -> AsyncIterator[tuple[object, ...]]:
|
|
24
|
+
"""Yields the rows WITHOUT materialising them all. Mirror of `SnakeDriver.fetch_iter`.
|
|
25
|
+
|
|
26
|
+
It is NOT an `async def`: it returns the async iterator directly. An `async def` that
|
|
27
|
+
were also a generator would force an `await` before the `async for`, and the contract
|
|
28
|
+
would end up different from the synchronous one for no reason at all.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def execute(self, sql: str, params: Sequence[object]) -> int:
|
|
33
|
+
"""Runs a statement fetching no rows; returns how many it affected."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def last_insert_id(self) -> int:
|
|
38
|
+
"""The autoincrement id of the last INSERT. Exact mirror of `SnakeDriver.last_insert_id`.
|
|
39
|
+
|
|
40
|
+
It was missing from this Protocol, and it bothered nobody because the only async driver
|
|
41
|
+
was the Postgres one, where the PK comes back through RETURNING and this is irrelevant.
|
|
42
|
+
It is on MySQL —where there is no RETURNING; MariaDB does have it— that the PK depends on this
|
|
43
|
+
value: without the member in the contract, an async MySQL driver would have been born
|
|
44
|
+
leaving the PK at `None` without saying a word.
|
|
45
|
+
|
|
46
|
+
It is NOT `async`: it does not travel to the database, the cursor of the last write
|
|
47
|
+
stored it.
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def commit(self) -> None:
|
|
52
|
+
"""Commits the transaction in progress."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
async def rollback(self) -> None:
|
|
56
|
+
"""Rolls back the transaction in progress."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def savepoint(self, name: str) -> None:
|
|
60
|
+
"""Opens a named savepoint."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
async def release_savepoint(self, name: str) -> None:
|
|
64
|
+
"""Commits the savepoint."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
async def rollback_to_savepoint(self, name: str) -> None:
|
|
68
|
+
"""Goes back to the savepoint, undoing what was done since it was opened."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None:
|
|
72
|
+
"""Closes the connection."""
|
|
73
|
+
...
|