pyoq-sql 1.0.0__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.
- pyoq/__init__.py +7 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +19 -0
- pyoq/config/loader.py +204 -0
- pyoq/config/models.py +243 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +72 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +62 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +19 -0
- pyoq/serving/databases.py +107 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
- pyoq_sql-1.0.0.dist-info/RECORD +264 -0
- pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
pyoq/django/execution.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Running PyOQ statements on the connection Django already has open.
|
|
2
|
+
|
|
3
|
+
Django owns its connections, their aliases, and whatever transaction is in
|
|
4
|
+
progress. PyOQ borrows one rather than opening a pool beside it, because two
|
|
5
|
+
pools against one database is two views of what has been committed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Generator, Sequence
|
|
11
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol, TypeVar, cast
|
|
14
|
+
|
|
15
|
+
from django.db import DEFAULT_DB_ALIAS, Error, connections, router, transaction
|
|
16
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
17
|
+
from django.db.models import Model
|
|
18
|
+
|
|
19
|
+
from pyoq.django.parameters import DjangoValue, adapt_parameters
|
|
20
|
+
from pyoq.django.timeouts import (
|
|
21
|
+
MYSQL_TIMEOUT,
|
|
22
|
+
POSTGRES_TIMEOUT,
|
|
23
|
+
TimeoutSetting,
|
|
24
|
+
applied,
|
|
25
|
+
)
|
|
26
|
+
from pyoq.errors import (
|
|
27
|
+
OperationUnavailableError,
|
|
28
|
+
QueryCancelledError,
|
|
29
|
+
QueryExecutionError,
|
|
30
|
+
)
|
|
31
|
+
from pyoq.query.execution import (
|
|
32
|
+
BulkPlan,
|
|
33
|
+
CompiledQuery,
|
|
34
|
+
DatabaseCursor,
|
|
35
|
+
ExecutionControl,
|
|
36
|
+
QueryOperations,
|
|
37
|
+
WriteProvider,
|
|
38
|
+
)
|
|
39
|
+
from pyoq.query.statements import StatementCompiler
|
|
40
|
+
from pyoq.query.write_nodes import WriteNode
|
|
41
|
+
|
|
42
|
+
OperationResult = TypeVar("OperationResult")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BulkPlanner(Protocol):
|
|
46
|
+
def plan(self, statement: WriteProvider | WriteNode, /) -> BulkPlan: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LastRowCursor(Protocol):
|
|
50
|
+
@property
|
|
51
|
+
def lastrowid(self) -> object: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Dialect:
|
|
56
|
+
"""What the backend Django is already speaking needs from PyOQ.
|
|
57
|
+
|
|
58
|
+
Values are adapted the same way they are on PyOQ's own connections, because
|
|
59
|
+
a backend does not care whose connection it is: a decimal or a date still
|
|
60
|
+
has to reach it in the shape its driver accepts.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
compiler: StatementCompiler
|
|
64
|
+
planner: BulkPlanner
|
|
65
|
+
timeout: TimeoutSetting | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sqlite() -> Dialect:
|
|
69
|
+
from pyoq.query.execution import ParameterStyle
|
|
70
|
+
from pyoq.query.sqlite import (
|
|
71
|
+
SQLiteBulkPlanner,
|
|
72
|
+
SQLiteCapabilities,
|
|
73
|
+
SQLiteCompiler,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
compiler = SQLiteCompiler(SQLiteCapabilities(parameter_style=ParameterStyle.FORMAT))
|
|
77
|
+
return Dialect(compiler, SQLiteBulkPlanner(compiler))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _postgres() -> Dialect:
|
|
81
|
+
from pyoq.query.postgres import PostgresBulkPlanner, PostgresCompiler
|
|
82
|
+
|
|
83
|
+
compiler = PostgresCompiler()
|
|
84
|
+
return Dialect(compiler, PostgresBulkPlanner(compiler), POSTGRES_TIMEOUT)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mysql() -> Dialect:
|
|
88
|
+
from pyoq.query.mysql import MySQLBulkPlanner, MySQLCompiler
|
|
89
|
+
|
|
90
|
+
compiler = MySQLCompiler()
|
|
91
|
+
return Dialect(compiler, MySQLBulkPlanner(compiler), MYSQL_TIMEOUT)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_DIALECTS: dict[str, Callable[[], Dialect]] = {
|
|
95
|
+
"sqlite": _sqlite,
|
|
96
|
+
"postgresql": _postgres,
|
|
97
|
+
"mysql": _mysql,
|
|
98
|
+
}
|
|
99
|
+
"""How to build each dialect, called once a connection turns out to be one.
|
|
100
|
+
|
|
101
|
+
A dialect package imports the driver it speaks to, so building these eagerly
|
|
102
|
+
would make the Django extra need every driver PyOQ supports.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
_DRIVER_EXTRAS: dict[str, str] = {"postgresql": "postgres", "mysql": "mysql"}
|
|
106
|
+
"""What a project installs to reach each backend.
|
|
107
|
+
|
|
108
|
+
SQLite is absent because it needs nothing, and so can never be the one that
|
|
109
|
+
is missing.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def dialect_for(connection: BaseDatabaseWrapper, /) -> Dialect:
|
|
114
|
+
"""Choose the dialect Django is already speaking.
|
|
115
|
+
|
|
116
|
+
The connection decides, not the caller. Speaking a different dialect than
|
|
117
|
+
the one on the other end of the socket is not a choice worth offering.
|
|
118
|
+
"""
|
|
119
|
+
factory = _DIALECTS.get(connection.vendor)
|
|
120
|
+
if factory is None:
|
|
121
|
+
message = (
|
|
122
|
+
f"PyOQ has no dialect for the Django backend {connection.vendor!r}; "
|
|
123
|
+
f"supported backends: {', '.join(sorted(_DIALECTS))}"
|
|
124
|
+
)
|
|
125
|
+
raise OperationUnavailableError(message)
|
|
126
|
+
return _built(factory, connection.vendor)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _built(factory: Callable[[], Dialect], vendor: str) -> Dialect:
|
|
130
|
+
"""Say which extra is missing, rather than which module is.
|
|
131
|
+
|
|
132
|
+
A project installs the drivers it uses, so reaching a backend it did not
|
|
133
|
+
install is an ordinary mistake and worth answering with the fix.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
return factory()
|
|
137
|
+
except ImportError as error:
|
|
138
|
+
extra = _DRIVER_EXTRAS[vendor]
|
|
139
|
+
message = (
|
|
140
|
+
f"the Django backend {vendor!r} needs a driver PyOQ does not "
|
|
141
|
+
f"install by default; add it with pip install "
|
|
142
|
+
f"'pyoq-sql[django,{extra}]'"
|
|
143
|
+
)
|
|
144
|
+
raise OperationUnavailableError(message) from error
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def alias_for(
|
|
148
|
+
model: type[Model],
|
|
149
|
+
/,
|
|
150
|
+
*,
|
|
151
|
+
write: bool = False,
|
|
152
|
+
**hints: object,
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Ask Django's routers which database a model lives in.
|
|
155
|
+
|
|
156
|
+
Routing is a question about a model, because that is the only thing a
|
|
157
|
+
router is given to decide on. A caller with a model gets the answer its own
|
|
158
|
+
routers would give; a caller without one names the alias instead.
|
|
159
|
+
"""
|
|
160
|
+
if write:
|
|
161
|
+
return str(router.db_for_write(model, **hints) or DEFAULT_DB_ALIAS)
|
|
162
|
+
return str(router.db_for_read(model, **hints) or DEFAULT_DB_ALIAS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class DjangoOperations(QueryOperations):
|
|
166
|
+
"""Typed operations against one Django database alias.
|
|
167
|
+
|
|
168
|
+
The connection is resolved for each statement rather than held, because
|
|
169
|
+
Django hands a different connection to each thread and closes them between
|
|
170
|
+
requests, so holding one would outlive what it belongs to.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
__slots__ = ("_alias", "_dialect")
|
|
174
|
+
|
|
175
|
+
def __init__(self, alias: str = DEFAULT_DB_ALIAS) -> None:
|
|
176
|
+
self._alias = alias
|
|
177
|
+
self._dialect = dialect_for(self.connection)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def for_model(
|
|
181
|
+
cls,
|
|
182
|
+
model: type[Model],
|
|
183
|
+
/,
|
|
184
|
+
*,
|
|
185
|
+
write: bool = False,
|
|
186
|
+
**hints: object,
|
|
187
|
+
) -> DjangoOperations:
|
|
188
|
+
"""Run against whichever database this project routes a model to."""
|
|
189
|
+
return cls(alias_for(model, write=write, **hints))
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def alias(self) -> str:
|
|
193
|
+
return self._alias
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def connection(self) -> BaseDatabaseWrapper:
|
|
197
|
+
"""Whatever connection Django holds for this alias right now."""
|
|
198
|
+
return connections[self._alias]
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def compiler(self) -> StatementCompiler:
|
|
202
|
+
return self._dialect.compiler
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def in_transaction(self) -> bool:
|
|
206
|
+
"""Whether this database is inside a transaction right now."""
|
|
207
|
+
return self.connection.in_atomic_block
|
|
208
|
+
|
|
209
|
+
def atomic(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
savepoint: bool = True,
|
|
213
|
+
durable: bool = False,
|
|
214
|
+
) -> AbstractContextManager[None]:
|
|
215
|
+
"""A transaction on this database, rather than on whichever is default.
|
|
216
|
+
|
|
217
|
+
Django's own block covers the default alias unless told otherwise, so a
|
|
218
|
+
caller running here against another alias would get no transaction at
|
|
219
|
+
all and no indication of it. Nesting one of these is a savepoint, which
|
|
220
|
+
is what Django already does.
|
|
221
|
+
|
|
222
|
+
PyOQ never commits or rolls back. The block that opened a transaction is
|
|
223
|
+
the one that ends it, and here that block is Django's.
|
|
224
|
+
"""
|
|
225
|
+
return transaction.atomic(
|
|
226
|
+
using=self._alias, savepoint=savepoint, durable=durable
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def on_commit(
|
|
230
|
+
self,
|
|
231
|
+
callback: Callable[[], object],
|
|
232
|
+
/,
|
|
233
|
+
*,
|
|
234
|
+
robust: bool = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Run something once this database's transaction has committed.
|
|
237
|
+
|
|
238
|
+
Outside a transaction it runs immediately, which is Django's own rule
|
|
239
|
+
and the right one: there is nothing left to wait for.
|
|
240
|
+
"""
|
|
241
|
+
transaction.on_commit(callback, using=self._alias, robust=robust)
|
|
242
|
+
|
|
243
|
+
def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
|
|
244
|
+
return self._dialect.planner.plan(statement)
|
|
245
|
+
|
|
246
|
+
def last_inserted_id(self, cursor: DatabaseCursor) -> int | None:
|
|
247
|
+
identifier = _last_row_identifier(cursor)
|
|
248
|
+
if not isinstance(identifier, int) or identifier <= 0:
|
|
249
|
+
return None
|
|
250
|
+
return identifier
|
|
251
|
+
|
|
252
|
+
def _run(
|
|
253
|
+
self,
|
|
254
|
+
statement: CompiledQuery,
|
|
255
|
+
operation: Callable[[DatabaseCursor], OperationResult],
|
|
256
|
+
control: ExecutionControl | None,
|
|
257
|
+
) -> OperationResult:
|
|
258
|
+
_checkpoint(control)
|
|
259
|
+
connection = self.connection
|
|
260
|
+
adapted = adapt_parameters(connection, statement.parameters)
|
|
261
|
+
parameters = cast("Sequence[DjangoValue]", adapted)
|
|
262
|
+
with self._bounded(control), connection.cursor() as cursor:
|
|
263
|
+
try:
|
|
264
|
+
# Django's stub omits the duration values its own interval
|
|
265
|
+
# backends accept, so the call is wider than the stub says.
|
|
266
|
+
cursor.execute(statement.sql, parameters) # type: ignore[arg-type]
|
|
267
|
+
result = operation(cast("DatabaseCursor", cursor))
|
|
268
|
+
except Error as error:
|
|
269
|
+
message = f"Django query execution failed: {error}"
|
|
270
|
+
raise QueryExecutionError(message) from error
|
|
271
|
+
_checkpoint(control)
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
@contextmanager
|
|
275
|
+
def _bounded(self, control: ExecutionControl | None) -> Generator[None]:
|
|
276
|
+
"""Bound the statement where the backend offers a way to.
|
|
277
|
+
|
|
278
|
+
SQLite has no session setting for this, and MySQL's covers reads only,
|
|
279
|
+
so a caller is given what the backend can honour rather than a promise
|
|
280
|
+
it cannot keep.
|
|
281
|
+
"""
|
|
282
|
+
setting = self._dialect.timeout
|
|
283
|
+
seconds = None if control is None else control.timeout
|
|
284
|
+
if setting is None or seconds is None:
|
|
285
|
+
yield
|
|
286
|
+
return
|
|
287
|
+
with applied(setting, self.connection, seconds):
|
|
288
|
+
yield
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _last_row_identifier(cursor: DatabaseCursor) -> object:
|
|
292
|
+
"""Not every backend offers one, and none of them promise a type."""
|
|
293
|
+
return getattr(cursor, "lastrowid", None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _checkpoint(control: ExecutionControl | None) -> None:
|
|
297
|
+
"""Honour a caller's own limits without touching Django's connection.
|
|
298
|
+
|
|
299
|
+
A server-side timeout is a change to session state, and the session belongs
|
|
300
|
+
to Django. What is left is refusing before and after a statement, which is
|
|
301
|
+
what a caller asking for cancellation can be given honestly.
|
|
302
|
+
"""
|
|
303
|
+
if control is None:
|
|
304
|
+
return
|
|
305
|
+
token = control.cancellation_token
|
|
306
|
+
if token is not None and token.cancelled:
|
|
307
|
+
message = "Django query execution was cancelled"
|
|
308
|
+
raise QueryCancelledError(message)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
__all__ = (
|
|
312
|
+
"BulkPlanner",
|
|
313
|
+
"Dialect",
|
|
314
|
+
"DjangoOperations",
|
|
315
|
+
"alias_for",
|
|
316
|
+
"dialect_for",
|
|
317
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Running the shared generation pipeline for a Django project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
8
|
+
|
|
9
|
+
from pyoq.cli.defaults import default_generation_pipeline
|
|
10
|
+
from pyoq.config import DatabaseDialect
|
|
11
|
+
from pyoq.django.schema import MigrationStateSchemaSource
|
|
12
|
+
from pyoq.django.settings import django_configuration
|
|
13
|
+
from pyoq.errors import OperationUnavailableError
|
|
14
|
+
from pyoq.generation import GenerationMode
|
|
15
|
+
|
|
16
|
+
_DIALECTS = {
|
|
17
|
+
"sqlite": "sqlite",
|
|
18
|
+
"postgresql": "postgres",
|
|
19
|
+
"mysql": "mysql",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class GenerationRequest:
|
|
25
|
+
"""One run of generation, described before anything is written."""
|
|
26
|
+
|
|
27
|
+
alias: str = DEFAULT_DB_ALIAS
|
|
28
|
+
app_labels: tuple[str, ...] = ()
|
|
29
|
+
mode: GenerationMode = GenerationMode.WRITE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate(request: GenerationRequest, /) -> str:
|
|
33
|
+
"""The pipeline every other entry point uses, pointed at Django.
|
|
34
|
+
|
|
35
|
+
Generation is not reimplemented here. Staging, validation, the manifest,
|
|
36
|
+
drift, and the atomic replacement all belong to the shared pipeline, and a
|
|
37
|
+
second copy of them would be a second set of answers.
|
|
38
|
+
"""
|
|
39
|
+
dialect = DatabaseDialect(_dialect_name(request.alias))
|
|
40
|
+
source = MigrationStateSchemaSource(
|
|
41
|
+
alias=request.alias, app_labels=request.app_labels
|
|
42
|
+
)
|
|
43
|
+
pipeline = default_generation_pipeline(source)
|
|
44
|
+
return pipeline.execute(django_configuration(dialect), request.mode)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dialect_name(alias: str) -> str:
|
|
48
|
+
vendor = connections[alias].vendor
|
|
49
|
+
name = _DIALECTS.get(vendor)
|
|
50
|
+
if name is None:
|
|
51
|
+
message = (
|
|
52
|
+
f"PyOQ cannot generate for the Django backend {vendor!r}; "
|
|
53
|
+
f"supported backends: {', '.join(sorted(_DIALECTS))}"
|
|
54
|
+
)
|
|
55
|
+
raise OperationUnavailableError(message)
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ("GenerationRequest", "generate")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Django's own `makemigrations`, with generation after it.
|
|
2
|
+
|
|
3
|
+
A project opts in to this by putting PyOQ ahead of the app that supplies the
|
|
4
|
+
original command, and by setting `codegen_after_makemigrations`. Overriding a
|
|
5
|
+
command a project already runs is not something to do to somebody quietly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from django.core.management.base import CommandError
|
|
13
|
+
from django.core.management.commands.makemigrations import (
|
|
14
|
+
Command as MakeMigrations,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from pyoq.django.generation import GenerationRequest, generate
|
|
18
|
+
from pyoq.django.settings import codegen_after_makemigrations
|
|
19
|
+
from pyoq.errors import PyOQError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Command(MakeMigrations):
|
|
23
|
+
def handle(self, *app_labels: str, **options: Any) -> None:
|
|
24
|
+
super().handle(*app_labels, **options)
|
|
25
|
+
if not codegen_after_makemigrations():
|
|
26
|
+
return
|
|
27
|
+
if _writes_nothing(options):
|
|
28
|
+
self.stdout.write(
|
|
29
|
+
"PyOQ: migrations were not written, so nothing was generated"
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
self.stdout.write(f"PyOQ: {_generated(app_labels, options)}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _writes_nothing(options: dict[str, Any]) -> bool:
|
|
36
|
+
"""Generation reads migration files, so it must not run before they exist.
|
|
37
|
+
|
|
38
|
+
A run asked to check or to report leaves the files as they were. Generating
|
|
39
|
+
then would describe the schema before this change rather than after it, and
|
|
40
|
+
would look like it had worked.
|
|
41
|
+
"""
|
|
42
|
+
return bool(options.get("dry_run") or options.get("check_changes"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _generated(app_labels: tuple[str, ...], options: dict[str, Any]) -> str:
|
|
46
|
+
request = GenerationRequest(
|
|
47
|
+
alias=str(options.get("database") or "default"),
|
|
48
|
+
app_labels=app_labels,
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
return generate(request)
|
|
52
|
+
except PyOQError as error:
|
|
53
|
+
raise CommandError(str(error)) from error
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Generate PyOQ types from the migrations this project has written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
8
|
+
from django.db import DEFAULT_DB_ALIAS
|
|
9
|
+
|
|
10
|
+
from pyoq.django.generation import GenerationRequest, generate
|
|
11
|
+
from pyoq.errors import PyOQError
|
|
12
|
+
from pyoq.generation import GenerationMode
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from argparse import ArgumentParser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Command(BaseCommand):
|
|
19
|
+
help = "Generate typed PyOQ code from this project's migration state."
|
|
20
|
+
|
|
21
|
+
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"app_label",
|
|
24
|
+
nargs="*",
|
|
25
|
+
help="Limit generation to these apps. Defaults to all of them.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--database",
|
|
29
|
+
default=DEFAULT_DB_ALIAS,
|
|
30
|
+
help="The alias whose backend decides the column types.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--dry-run",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Report what would be written without writing it.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--check",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Fail when the generated package is not up to date.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
44
|
+
del args
|
|
45
|
+
request = GenerationRequest(
|
|
46
|
+
alias=str(options["database"]),
|
|
47
|
+
app_labels=tuple(options["app_label"]),
|
|
48
|
+
mode=_mode(check=options["check"], dry_run=options["dry_run"]),
|
|
49
|
+
)
|
|
50
|
+
self.stdout.write(_report(request))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mode(*, check: bool, dry_run: bool) -> GenerationMode:
|
|
54
|
+
"""Checking and reporting are different questions, so asking both is not.
|
|
55
|
+
|
|
56
|
+
One answers whether the package is current and the other describes what a
|
|
57
|
+
run would produce. A command that quietly picked one would give an answer
|
|
58
|
+
to a question that was not asked.
|
|
59
|
+
"""
|
|
60
|
+
if check and dry_run:
|
|
61
|
+
message = "--check and --dry-run cannot be combined"
|
|
62
|
+
raise CommandError(message)
|
|
63
|
+
if check:
|
|
64
|
+
return GenerationMode.CHECK
|
|
65
|
+
if dry_run:
|
|
66
|
+
return GenerationMode.DRY_RUN
|
|
67
|
+
return GenerationMode.WRITE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _report(request: GenerationRequest) -> str:
|
|
71
|
+
"""A PyOQ failure is the operator's problem, not a traceback."""
|
|
72
|
+
try:
|
|
73
|
+
return generate(request)
|
|
74
|
+
except PyOQError as error:
|
|
75
|
+
raise CommandError(str(error)) from error
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Binding values the way Django binds them on the connection it owns.
|
|
2
|
+
|
|
3
|
+
A dialect adapter writes what its driver accepts. That is not enough here,
|
|
4
|
+
because the rows are shared: Django reads back what PyOQ writes and filters on
|
|
5
|
+
it, so a value has to reach the column in the form Django's own fields put
|
|
6
|
+
there. That form differs per backend and per type, and Django already decides
|
|
7
|
+
it, so each value is handed to the field Django would have used rather than to
|
|
8
|
+
a rule restated here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import date, datetime, time, timedelta
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import TypeAlias, cast
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from django.db import models
|
|
20
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
21
|
+
from django.db.models import Field
|
|
22
|
+
|
|
23
|
+
from pyoq.errors import ParameterBindingError
|
|
24
|
+
|
|
25
|
+
DjangoValue: TypeAlias = (
|
|
26
|
+
bool
|
|
27
|
+
| int
|
|
28
|
+
| float
|
|
29
|
+
| Decimal
|
|
30
|
+
| str
|
|
31
|
+
| bytes
|
|
32
|
+
| date
|
|
33
|
+
| datetime
|
|
34
|
+
| time
|
|
35
|
+
| timedelta
|
|
36
|
+
| UUID
|
|
37
|
+
| None
|
|
38
|
+
)
|
|
39
|
+
"""What a Django backend accepts as a bound value.
|
|
40
|
+
|
|
41
|
+
Django declares this only in its stubs and under a private name, so PyOQ states
|
|
42
|
+
it rather than reaching for one.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_FIELDS: tuple[tuple[type[object], Field[object, object]], ...] = (
|
|
46
|
+
(bool, models.BooleanField()),
|
|
47
|
+
(UUID, models.UUIDField()),
|
|
48
|
+
(timedelta, models.DurationField()),
|
|
49
|
+
(datetime, models.DateTimeField()),
|
|
50
|
+
(date, models.DateField()),
|
|
51
|
+
(time, models.TimeField()),
|
|
52
|
+
(Decimal, models.DecimalField()),
|
|
53
|
+
(dict, models.JSONField()),
|
|
54
|
+
(list, models.JSONField()),
|
|
55
|
+
)
|
|
56
|
+
"""The field Django would have used, for each type it stores its own way.
|
|
57
|
+
|
|
58
|
+
Order matters where one type is another: a datetime is a date, and a boolean
|
|
59
|
+
is an integer, so the narrower entry comes first. Each field prepares a value
|
|
60
|
+
without reading state of its own, so one instance serves every statement.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def adapt_parameters(
|
|
65
|
+
connection: BaseDatabaseWrapper,
|
|
66
|
+
values: tuple[object, ...],
|
|
67
|
+
/,
|
|
68
|
+
) -> tuple[DjangoValue, ...]:
|
|
69
|
+
"""Every bound value, in the form this backend stores it."""
|
|
70
|
+
return tuple(_adapted(connection, value) for value in values)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _adapted(connection: BaseDatabaseWrapper, value: object) -> DjangoValue:
|
|
74
|
+
if isinstance(value, Enum):
|
|
75
|
+
return _adapted(connection, value.value)
|
|
76
|
+
if isinstance(value, memoryview):
|
|
77
|
+
return value.tobytes()
|
|
78
|
+
for value_type, field in _FIELDS:
|
|
79
|
+
if isinstance(value, value_type):
|
|
80
|
+
return _prepared(field, connection, value)
|
|
81
|
+
return _plain(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _prepared(
|
|
85
|
+
field: Field[object, object],
|
|
86
|
+
connection: BaseDatabaseWrapper,
|
|
87
|
+
value: object,
|
|
88
|
+
) -> DjangoValue:
|
|
89
|
+
"""Every field above prepares a value into one a backend already binds."""
|
|
90
|
+
return cast("DjangoValue", field.get_db_prep_save(value, connection))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _plain(value: object) -> DjangoValue:
|
|
94
|
+
"""A value every backend stores alike, which no field has to prepare."""
|
|
95
|
+
if value is None or isinstance(value, (int, float, str, bytes)):
|
|
96
|
+
return value
|
|
97
|
+
message = f"unsupported Django parameter type: {type(value).__name__}"
|
|
98
|
+
raise ParameterBindingError(message)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ("DjangoValue", "adapt_parameters")
|