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.
Files changed (264) hide show
  1. pyoq/__init__.py +7 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +19 -0
  9. pyoq/config/loader.py +204 -0
  10. pyoq/config/models.py +243 -0
  11. pyoq/descriptors.py +165 -0
  12. pyoq/diagnostics/__init__.py +68 -0
  13. pyoq/diagnostics/budget.py +136 -0
  14. pyoq/diagnostics/events.py +137 -0
  15. pyoq/diagnostics/fingerprint.py +267 -0
  16. pyoq/diagnostics/instrumented.py +237 -0
  17. pyoq/diagnostics/metrics.py +61 -0
  18. pyoq/diagnostics/observation.py +227 -0
  19. pyoq/diagnostics/scoped.py +103 -0
  20. pyoq/django/__init__.py +15 -0
  21. pyoq/django/apps.py +17 -0
  22. pyoq/django/execution.py +317 -0
  23. pyoq/django/generation.py +59 -0
  24. pyoq/django/management/__init__.py +0 -0
  25. pyoq/django/management/commands/__init__.py +0 -0
  26. pyoq/django/management/commands/makemigrations.py +53 -0
  27. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  28. pyoq/django/parameters.py +101 -0
  29. pyoq/django/schema.py +379 -0
  30. pyoq/django/settings.py +87 -0
  31. pyoq/django/timeouts.py +105 -0
  32. pyoq/dsl/__init__.py +64 -0
  33. pyoq/dsl/aio/__init__.py +31 -0
  34. pyoq/dsl/aio/context.py +295 -0
  35. pyoq/dsl/aio/queries.py +335 -0
  36. pyoq/dsl/aio/writes.py +368 -0
  37. pyoq/dsl/context.py +326 -0
  38. pyoq/dsl/entry.py +37 -0
  39. pyoq/dsl/labels.py +36 -0
  40. pyoq/dsl/queries.py +339 -0
  41. pyoq/dsl/result.py +164 -0
  42. pyoq/dsl/writes.py +360 -0
  43. pyoq/errors.py +317 -0
  44. pyoq/fastapi/__init__.py +32 -0
  45. pyoq/fastapi/dependencies.py +167 -0
  46. pyoq/fastapi/lifespan.py +119 -0
  47. pyoq/fetching/__init__.py +55 -0
  48. pyoq/fetching/collections.py +136 -0
  49. pyoq/fetching/execution.py +587 -0
  50. pyoq/fetching/joined.py +79 -0
  51. pyoq/fetching/nesting.py +183 -0
  52. pyoq/fetching/plans.py +541 -0
  53. pyoq/fetching/select_in.py +149 -0
  54. pyoq/fetching/tables.py +110 -0
  55. pyoq/generation/__init__.py +54 -0
  56. pyoq/generation/cleanup.py +44 -0
  57. pyoq/generation/contracts.py +248 -0
  58. pyoq/generation/drift.py +169 -0
  59. pyoq/generation/lock.py +33 -0
  60. pyoq/generation/manifest.py +114 -0
  61. pyoq/generation/model.py +1001 -0
  62. pyoq/generation/pipeline.py +119 -0
  63. pyoq/generation/rendering/__init__.py +5 -0
  64. pyoq/generation/rendering/domains.py +51 -0
  65. pyoq/generation/rendering/enums.py +29 -0
  66. pyoq/generation/rendering/exports.py +70 -0
  67. pyoq/generation/rendering/imports.py +63 -0
  68. pyoq/generation/rendering/package.py +56 -0
  69. pyoq/generation/rendering/relations.py +133 -0
  70. pyoq/generation/rendering/routines.py +396 -0
  71. pyoq/generation/rendering/rows.py +79 -0
  72. pyoq/generation/rendering/source.py +121 -0
  73. pyoq/generation/rendering/tables.py +300 -0
  74. pyoq/generation/rendering/writes.py +514 -0
  75. pyoq/generation/validation.py +27 -0
  76. pyoq/generation/writer.py +184 -0
  77. pyoq/hydration/__init__.py +24 -0
  78. pyoq/hydration/engine.py +155 -0
  79. pyoq/hydration/identity.py +194 -0
  80. pyoq/hydration/plan.py +116 -0
  81. pyoq/migrations/__init__.py +9 -0
  82. pyoq/migrations/alembic.py +106 -0
  83. pyoq/migrations/hooks.py +75 -0
  84. pyoq/naming.py +261 -0
  85. pyoq/policies/__init__.py +47 -0
  86. pyoq/policies/bypass.py +122 -0
  87. pyoq/policies/governed.py +430 -0
  88. pyoq/policies/model.py +242 -0
  89. pyoq/policies/rewriting.py +263 -0
  90. pyoq/py.typed +1 -0
  91. pyoq/query/__init__.py +312 -0
  92. pyoq/query/aggregates.py +172 -0
  93. pyoq/query/arrays.py +65 -0
  94. pyoq/query/binding.py +52 -0
  95. pyoq/query/capabilities.py +317 -0
  96. pyoq/query/casts.py +73 -0
  97. pyoq/query/choices.py +185 -0
  98. pyoq/query/decoding.py +360 -0
  99. pyoq/query/documents.py +56 -0
  100. pyoq/query/execution/__init__.py +63 -0
  101. pyoq/query/execution/aio/__init__.py +31 -0
  102. pyoq/query/execution/aio/operations.py +228 -0
  103. pyoq/query/execution/aio/pooling.py +233 -0
  104. pyoq/query/execution/aio/streaming.py +161 -0
  105. pyoq/query/execution/aio/transactions.py +105 -0
  106. pyoq/query/execution/batch.py +96 -0
  107. pyoq/query/execution/binding_style.py +30 -0
  108. pyoq/query/execution/compilation.py +48 -0
  109. pyoq/query/execution/context.py +61 -0
  110. pyoq/query/execution/control.py +50 -0
  111. pyoq/query/execution/operations.py +224 -0
  112. pyoq/query/execution/planning.py +107 -0
  113. pyoq/query/execution/pooling.py +279 -0
  114. pyoq/query/execution/results.py +36 -0
  115. pyoq/query/execution/streaming.py +178 -0
  116. pyoq/query/execution/transactions.py +95 -0
  117. pyoq/query/expressions.py +1200 -0
  118. pyoq/query/fields.py +60 -0
  119. pyoq/query/mysql/__init__.py +59 -0
  120. pyoq/query/mysql/aio/__init__.py +38 -0
  121. pyoq/query/mysql/aio/commands.py +389 -0
  122. pyoq/query/mysql/aio/driver.py +196 -0
  123. pyoq/query/mysql/aio/executor.py +123 -0
  124. pyoq/query/mysql/aio/factory.py +26 -0
  125. pyoq/query/mysql/aio/operations.py +38 -0
  126. pyoq/query/mysql/aio/pool.py +53 -0
  127. pyoq/query/mysql/aio/transactions.py +313 -0
  128. pyoq/query/mysql/commands.py +354 -0
  129. pyoq/query/mysql/compiler.py +134 -0
  130. pyoq/query/mysql/context.py +20 -0
  131. pyoq/query/mysql/executor.py +126 -0
  132. pyoq/query/mysql/expressions.py +244 -0
  133. pyoq/query/mysql/factory.py +46 -0
  134. pyoq/query/mysql/health.py +66 -0
  135. pyoq/query/mysql/identifiers.py +9 -0
  136. pyoq/query/mysql/model.py +79 -0
  137. pyoq/query/mysql/operations.py +43 -0
  138. pyoq/query/mysql/parameters.py +69 -0
  139. pyoq/query/mysql/planning.py +20 -0
  140. pyoq/query/mysql/pool.py +67 -0
  141. pyoq/query/mysql/transactions.py +331 -0
  142. pyoq/query/mysql/writes.py +73 -0
  143. pyoq/query/nodes.py +750 -0
  144. pyoq/query/postgres/__init__.py +48 -0
  145. pyoq/query/postgres/aio/__init__.py +25 -0
  146. pyoq/query/postgres/aio/bulk.py +56 -0
  147. pyoq/query/postgres/aio/commands.py +264 -0
  148. pyoq/query/postgres/aio/executor.py +152 -0
  149. pyoq/query/postgres/aio/factory.py +26 -0
  150. pyoq/query/postgres/aio/operations.py +26 -0
  151. pyoq/query/postgres/aio/pool.py +40 -0
  152. pyoq/query/postgres/aio/transactions.py +295 -0
  153. pyoq/query/postgres/bulk.py +62 -0
  154. pyoq/query/postgres/commands.py +238 -0
  155. pyoq/query/postgres/compiler.py +114 -0
  156. pyoq/query/postgres/context.py +20 -0
  157. pyoq/query/postgres/executor.py +147 -0
  158. pyoq/query/postgres/expressions.py +311 -0
  159. pyoq/query/postgres/factory.py +24 -0
  160. pyoq/query/postgres/health.py +24 -0
  161. pyoq/query/postgres/identifiers.py +9 -0
  162. pyoq/query/postgres/model.py +81 -0
  163. pyoq/query/postgres/operations.py +25 -0
  164. pyoq/query/postgres/parameters.py +71 -0
  165. pyoq/query/postgres/planning.py +20 -0
  166. pyoq/query/postgres/pool.py +52 -0
  167. pyoq/query/postgres/transactions.py +295 -0
  168. pyoq/query/postgres/writes.py +37 -0
  169. pyoq/query/projections.py +105 -0
  170. pyoq/query/raw.py +90 -0
  171. pyoq/query/recursion.py +265 -0
  172. pyoq/query/rendering/__init__.py +1 -0
  173. pyoq/query/rendering/expressions.py +913 -0
  174. pyoq/query/rendering/identifiers.py +40 -0
  175. pyoq/query/rendering/projections.py +63 -0
  176. pyoq/query/rendering/queries.py +334 -0
  177. pyoq/query/rendering/sources.py +66 -0
  178. pyoq/query/rendering/writes.py +176 -0
  179. pyoq/query/results.py +459 -0
  180. pyoq/query/routines.py +196 -0
  181. pyoq/query/rows.py +156 -0
  182. pyoq/query/select.py +793 -0
  183. pyoq/query/select_nodes.py +277 -0
  184. pyoq/query/sources.py +236 -0
  185. pyoq/query/sqlite/__init__.py +43 -0
  186. pyoq/query/sqlite/commands.py +201 -0
  187. pyoq/query/sqlite/compiler.py +139 -0
  188. pyoq/query/sqlite/context.py +20 -0
  189. pyoq/query/sqlite/executor.py +119 -0
  190. pyoq/query/sqlite/expressions.py +224 -0
  191. pyoq/query/sqlite/factory.py +32 -0
  192. pyoq/query/sqlite/health.py +28 -0
  193. pyoq/query/sqlite/identifiers.py +9 -0
  194. pyoq/query/sqlite/model.py +73 -0
  195. pyoq/query/sqlite/operations.py +36 -0
  196. pyoq/query/sqlite/parameters.py +50 -0
  197. pyoq/query/sqlite/planning.py +20 -0
  198. pyoq/query/sqlite/pool.py +50 -0
  199. pyoq/query/sqlite/streaming.py +13 -0
  200. pyoq/query/sqlite/transactions.py +274 -0
  201. pyoq/query/sqlite/writes.py +35 -0
  202. pyoq/query/statements.py +27 -0
  203. pyoq/query/values.py +23 -0
  204. pyoq/query/vendor.py +162 -0
  205. pyoq/query/windows.py +424 -0
  206. pyoq/query/write_nodes.py +174 -0
  207. pyoq/query/writes.py +628 -0
  208. pyoq/relations/__init__.py +66 -0
  209. pyoq/relations/batching.py +219 -0
  210. pyoq/relations/derivation.py +111 -0
  211. pyoq/relations/fetching.py +355 -0
  212. pyoq/relations/graph.py +245 -0
  213. pyoq/relations/loading.py +74 -0
  214. pyoq/relations/model.py +75 -0
  215. pyoq/relations/planning.py +206 -0
  216. pyoq/runtime/__init__.py +9 -0
  217. pyoq/runtime/kernels.py +25 -0
  218. pyoq/runtime/python.py +43 -0
  219. pyoq/runtime/selection.py +73 -0
  220. pyoq/sanic/__init__.py +32 -0
  221. pyoq/sanic/scope.py +197 -0
  222. pyoq/sanic/workers.py +129 -0
  223. pyoq/schema/__init__.py +108 -0
  224. pyoq/schema/codec.py +711 -0
  225. pyoq/schema/models.py +604 -0
  226. pyoq/schema/mysql/__init__.py +16 -0
  227. pyoq/schema/mysql/connection.py +72 -0
  228. pyoq/schema/mysql/dsn.py +72 -0
  229. pyoq/schema/mysql/records.py +354 -0
  230. pyoq/schema/mysql/reflection.py +309 -0
  231. pyoq/schema/mysql/source.py +30 -0
  232. pyoq/schema/mysql/sql.py +128 -0
  233. pyoq/schema/mysql/types.py +105 -0
  234. pyoq/schema/postgres/__init__.py +13 -0
  235. pyoq/schema/postgres/connection.py +62 -0
  236. pyoq/schema/postgres/records.py +384 -0
  237. pyoq/schema/postgres/reflection.py +466 -0
  238. pyoq/schema/postgres/source.py +30 -0
  239. pyoq/schema/postgres/sql.py +246 -0
  240. pyoq/schema/postgres/types.py +98 -0
  241. pyoq/schema/registry.py +45 -0
  242. pyoq/schema/source.py +15 -0
  243. pyoq/schema/sqlite/__init__.py +6 -0
  244. pyoq/schema/sqlite/connection.py +54 -0
  245. pyoq/schema/sqlite/records.py +167 -0
  246. pyoq/schema/sqlite/reflection.py +393 -0
  247. pyoq/schema/sqlite/source.py +30 -0
  248. pyoq/schema/sqlite/sql.py +254 -0
  249. pyoq/schema/sqlite/types.py +74 -0
  250. pyoq/serving/__init__.py +19 -0
  251. pyoq/serving/databases.py +107 -0
  252. pyoq/snapshots/__init__.py +20 -0
  253. pyoq/snapshots/drift.py +312 -0
  254. pyoq/snapshots/files.py +96 -0
  255. pyoq/snapshots/routing.py +40 -0
  256. pyoq/snapshots/source.py +33 -0
  257. pyoq/tracing/__init__.py +5 -0
  258. pyoq/tracing/spans.py +89 -0
  259. pyoq/unset.py +14 -0
  260. pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
  261. pyoq_sql-1.0.0.dist-info/RECORD +264 -0
  262. pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
  263. pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
  264. pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
pyoq/django/schema.py ADDED
@@ -0,0 +1,379 @@
1
+ """The schema a Django project's migrations describe.
2
+
3
+ Read from migration state rather than from a database, because migrations are
4
+ the authority on what the schema will be and a developer generating types has
5
+ usually not applied them yet. Nothing here opens a connection: Django answers
6
+ what type a field takes on a backend without consulting the server.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, replace
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ from django.db import DEFAULT_DB_ALIAS, connections
15
+ from django.db.migrations.loader import MigrationLoader
16
+ from django.db.models import CheckConstraint as DjangoCheckConstraint
17
+ from django.db.models import ForeignKey
18
+
19
+ from pyoq.config import Configuration, DatabaseDialect
20
+ from pyoq.errors import OperationUnavailableError
21
+ from pyoq.schema import (
22
+ Catalog,
23
+ CheckConstraint,
24
+ Column,
25
+ GeneratedKind,
26
+ GeneratedValue,
27
+ Identifier,
28
+ Index,
29
+ IndexTerm,
30
+ Key,
31
+ KeyKind,
32
+ MetadataEntry,
33
+ ObjectReference,
34
+ Relation,
35
+ Schema,
36
+ SchemaSnapshot,
37
+ SqlExpression,
38
+ SqlType,
39
+ Table,
40
+ TypeKind,
41
+ )
42
+ from pyoq.schema.mysql.types import mysql_type
43
+ from pyoq.schema.postgres.types import postgres_type
44
+ from pyoq.schema.sqlite.types import sqlite_type
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Iterable, Sequence
48
+
49
+ from django.db.backends.base.base import BaseDatabaseWrapper
50
+ from django.db.models import Field, Model
51
+ from django.db.models.options import Options
52
+
53
+
54
+ class TypeParser(Protocol):
55
+ def __call__(self, declared_type: str, /) -> SqlType: ...
56
+
57
+
58
+ _PARSERS: dict[str, tuple[DatabaseDialect, TypeParser]] = {
59
+ "sqlite": (DatabaseDialect.SQLITE, sqlite_type),
60
+ "postgresql": (DatabaseDialect.POSTGRES, postgres_type),
61
+ "mysql": (DatabaseDialect.MYSQL, mysql_type),
62
+ }
63
+
64
+ _AUTO_FIELDS = frozenset({"AutoField", "BigAutoField", "SmallAutoField"})
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class MigrationStateSchemaSource:
69
+ """Every table the migrations on disk would create, and nothing else."""
70
+
71
+ alias: str = DEFAULT_DB_ALIAS
72
+ app_labels: tuple[str, ...] = ()
73
+
74
+ def load(self, configuration: Configuration) -> SchemaSnapshot:
75
+ del configuration
76
+ connection = connections[self.alias]
77
+ dialect, parse = _parser_for(connection)
78
+ models = _selected_models(self.app_labels)
79
+ tables = [_table(model, connection, parse) for model in models]
80
+ tables.extend(_join_tables(models, connection, parse))
81
+ schema = Schema(name=None, tables=tuple(_ordered(tables)))
82
+ return SchemaSnapshot(
83
+ dialect=dialect,
84
+ catalogs=(Catalog(name=None, schemas=(schema,)),),
85
+ )
86
+
87
+
88
+ def _parser_for(
89
+ connection: BaseDatabaseWrapper,
90
+ ) -> tuple[DatabaseDialect, TypeParser]:
91
+ parser = _PARSERS.get(connection.vendor)
92
+ if parser is None:
93
+ message = (
94
+ f"PyOQ cannot read a schema for the Django backend "
95
+ f"{connection.vendor!r}; supported backends: "
96
+ f"{', '.join(sorted(_PARSERS))}"
97
+ )
98
+ raise OperationUnavailableError(message)
99
+ return parser
100
+
101
+
102
+ def _selected_models(app_labels: tuple[str, ...]) -> tuple[type[Model], ...]:
103
+ """The models the migrations describe, with no database consulted.
104
+
105
+ A loader given no connection reads the migration files alone and never asks
106
+ which of them have been applied, which is the question that needs a server.
107
+ """
108
+ state = MigrationLoader(None).project_state()
109
+ wanted = frozenset(app_labels)
110
+ chosen = [
111
+ state.apps.get_model(label, name)
112
+ for label, name in sorted(state.models)
113
+ if not wanted or label in wanted
114
+ ]
115
+ _require_known_labels(wanted, frozenset(label for label, _ in state.models))
116
+ return tuple(model for model in chosen if model._meta.managed)
117
+
118
+
119
+ def _require_known_labels(
120
+ wanted: frozenset[str],
121
+ available: frozenset[str],
122
+ ) -> None:
123
+ missing = sorted(wanted - available)
124
+ if not missing:
125
+ return
126
+ message = (
127
+ f"no migrations describe the app {', '.join(missing)!r}; "
128
+ f"apps with migrations: {', '.join(sorted(available))}"
129
+ )
130
+ raise OperationUnavailableError(message)
131
+
132
+
133
+ def _table(
134
+ model: type[Model],
135
+ connection: BaseDatabaseWrapper,
136
+ parse: TypeParser,
137
+ ) -> Table:
138
+ meta = model._meta
139
+ columns = tuple(
140
+ _column(field, declared, parse)
141
+ for field, declared in _declared_types(meta, connection)
142
+ )
143
+ return Table(
144
+ name=Identifier(meta.db_table),
145
+ columns=columns,
146
+ keys=_keys(meta),
147
+ relations=_relations(meta),
148
+ indexes=_indexes(meta),
149
+ checks=_checks(meta),
150
+ )
151
+
152
+
153
+ def _declared_types(
154
+ meta: Options[Model],
155
+ connection: BaseDatabaseWrapper,
156
+ ) -> Iterable[tuple[Field[object, object], str]]:
157
+ """A field without a type of its own has no column to describe."""
158
+ for field in meta.local_fields:
159
+ declared = field.db_type(connection)
160
+ if declared is not None:
161
+ yield field, declared
162
+
163
+
164
+ _MEANT_KINDS: dict[str, TypeKind] = {
165
+ "JSONField": TypeKind.JSON,
166
+ "UUIDField": TypeKind.UUID,
167
+ "DurationField": TypeKind.INTERVAL,
168
+ }
169
+ """What a field means, where the column it asks for does not say so.
170
+
171
+ Django declares a JSON column as text, a UUID as a fixed width string, and a
172
+ duration as an integer, so reading the column back would hand a caller the
173
+ stored form rather than the value. The field is the one that knows.
174
+ """
175
+
176
+
177
+ def _data_type(
178
+ field: Field[object, object],
179
+ declared: str,
180
+ parse: TypeParser,
181
+ ) -> SqlType:
182
+ """The declared column, named by what the field puts in it."""
183
+ stored = parse(declared)
184
+ meant = _MEANT_KINDS.get(field.get_internal_type())
185
+ if meant is None or stored.kind is meant:
186
+ return stored
187
+ return replace(stored, kind=meant, element_type=None, named_type=None)
188
+
189
+
190
+ def _column(
191
+ field: Field[object, object],
192
+ declared: str,
193
+ parse: TypeParser,
194
+ ) -> Column:
195
+ return Column(
196
+ name=Identifier(_column_of(field)),
197
+ data_type=_data_type(field, declared, parse),
198
+ nullable=bool(field.null),
199
+ default=None,
200
+ generated=_generated(field),
201
+ metadata=_column_metadata(field),
202
+ )
203
+
204
+
205
+ def _generated(field: Field[object, object]) -> GeneratedValue | None:
206
+ """Only a field the database fills in counts as generated here.
207
+
208
+ A Django default is applied in Python before the row is sent, so it is not
209
+ a fact about the column. Recording it as one would tell a caller they may
210
+ omit a value that the database will then refuse.
211
+ """
212
+ if type(field).__name__ not in _AUTO_FIELDS:
213
+ return None
214
+ return GeneratedValue(kind=GeneratedKind.IDENTITY_BY_DEFAULT)
215
+
216
+
217
+ def _column_metadata(
218
+ field: Field[object, object],
219
+ ) -> tuple[MetadataEntry, ...]:
220
+ entries = [MetadataEntry(key="django-field", value=type(field).__name__)]
221
+ if field.has_default():
222
+ entries.append(MetadataEntry(key="django-default", value="python"))
223
+ if isinstance(field, ForeignKey):
224
+ entries.append(MetadataEntry(key="django-on-delete", value=_on_delete(field)))
225
+ return tuple(entries)
226
+
227
+
228
+ def _on_delete(field: ForeignKey[object]) -> str:
229
+ """What the model asked for, beside what the database was told.
230
+
231
+ Django deletes related rows itself, so the constraint carries no action at
232
+ all. Keeping the model's choice visible costs nothing and stops a reader
233
+ concluding the database will do it.
234
+ """
235
+ return str(getattr(field.remote_field.on_delete, "__name__", "unknown"))
236
+
237
+
238
+ def _keys(meta: Options[Model]) -> tuple[Key, ...]:
239
+ keys: list[Key] = []
240
+ keys.append(Key(kind=KeyKind.PRIMARY, columns=(Identifier(_column_of(meta.pk)),)))
241
+ keys.extend(
242
+ Key(kind=KeyKind.UNIQUE, columns=(Identifier(_column_of(field)),))
243
+ for field in meta.local_fields
244
+ if field.unique and not field.primary_key
245
+ )
246
+ keys.extend(
247
+ Key(kind=KeyKind.UNIQUE, columns=_columns(meta, group))
248
+ for group in meta.unique_together
249
+ )
250
+ keys.extend(_constraint_keys(meta))
251
+ return tuple(keys)
252
+
253
+
254
+ def _constraint_keys(meta: Options[Model]) -> Iterable[Key]:
255
+ """A unique constraint counts only when it holds for every row.
256
+
257
+ A constraint carrying a condition is unique among the rows it matches and
258
+ silently not unique among the rest. Treating one as a key would let a
259
+ relation be described as reaching a single row when it reaches many.
260
+ """
261
+ for constraint in meta.constraints:
262
+ fields = getattr(constraint, "fields", None)
263
+ if not fields or _is_partial(constraint):
264
+ continue
265
+ yield Key(
266
+ kind=KeyKind.UNIQUE,
267
+ columns=_columns(meta, fields),
268
+ name=Identifier(constraint.name),
269
+ )
270
+
271
+
272
+ def _is_partial(constraint: object) -> bool:
273
+ return any(
274
+ getattr(constraint, attribute, None)
275
+ for attribute in ("condition", "expressions", "nulls_distinct")
276
+ )
277
+
278
+
279
+ def _relations(meta: Options[Model]) -> tuple[Relation, ...]:
280
+ """Django enforces deletion in Python, so the database is told nothing.
281
+
282
+ The referential action recorded here is the one the database will actually
283
+ take. What the model asked for is kept beside it as a fact about Django.
284
+ """
285
+ return tuple(
286
+ Relation(
287
+ columns=(Identifier(_column_of(field)),),
288
+ target=ObjectReference(name=Identifier(_target_table(field))),
289
+ target_columns=(Identifier(_column_of(field.target_field)),),
290
+ )
291
+ for field in _foreign_keys(meta)
292
+ )
293
+
294
+
295
+ def _foreign_keys(meta: Options[Model]) -> Iterable[ForeignKey[object]]:
296
+ for field in meta.local_fields:
297
+ if isinstance(field, ForeignKey):
298
+ yield field
299
+
300
+
301
+ def _target_table(field: ForeignKey[object]) -> str:
302
+ return str(field.related_model._meta.db_table)
303
+
304
+
305
+ def _column_of(field: Field[object, object]) -> str:
306
+ return str(field.column)
307
+
308
+
309
+ def _indexes(meta: Options[Model]) -> tuple[Index, ...]:
310
+ """Only indexes the project named itself.
311
+
312
+ Django names the indexes it adds for itself inside a schema editor, which
313
+ reaches the server. Guessing those names here would put a name in the
314
+ schema that the database may not agree with.
315
+ """
316
+ return tuple(
317
+ Index(
318
+ name=Identifier(index.name),
319
+ terms=tuple(_terms(meta, index.fields)),
320
+ )
321
+ for index in meta.indexes
322
+ if index.fields
323
+ )
324
+
325
+
326
+ def _terms(meta: Options[Model], fields: Sequence[str]) -> Iterable[IndexTerm]:
327
+ for name in fields:
328
+ descending = name.startswith("-")
329
+ column = _column_name(meta, name.lstrip("-"))
330
+ yield IndexTerm(column=Identifier(column), descending=descending)
331
+
332
+
333
+ def _checks(meta: Options[Model]) -> tuple[CheckConstraint, ...]:
334
+ return tuple(
335
+ CheckConstraint(
336
+ expression=SqlExpression(text=str(constraint.condition)),
337
+ name=Identifier(constraint.name),
338
+ )
339
+ for constraint in meta.constraints
340
+ if isinstance(constraint, DjangoCheckConstraint)
341
+ )
342
+
343
+
344
+ def _columns(
345
+ meta: Options[Model],
346
+ names: Iterable[str],
347
+ ) -> tuple[Identifier, ...]:
348
+ return tuple(Identifier(_column_name(meta, name)) for name in names)
349
+
350
+
351
+ def _column_name(meta: Options[Model], name: str) -> str:
352
+ field = meta.get_field(name)
353
+ column = getattr(field, "column", None)
354
+ return column if isinstance(column, str) else name
355
+
356
+
357
+ def _join_tables(
358
+ models: tuple[type[Model], ...],
359
+ connection: BaseDatabaseWrapper,
360
+ parse: TypeParser,
361
+ ) -> Iterable[Table]:
362
+ """A many-to-many field owns a table that migration state does not list.
363
+
364
+ Django creates it from the field rather than from a model of its own, so it
365
+ has to be described here or the schema would be missing a real table. A
366
+ through model the project wrote is already a model and is left alone.
367
+ """
368
+ for model in models:
369
+ for field in model._meta.local_many_to_many:
370
+ through = field.remote_field.through
371
+ if through is not None and through._meta.auto_created:
372
+ yield _table(through, connection, parse)
373
+
374
+
375
+ def _ordered(tables: Iterable[Table]) -> list[Table]:
376
+ return sorted(tables, key=lambda table: table.name.value)
377
+
378
+
379
+ __all__ = ("MigrationStateSchemaSource",)
@@ -0,0 +1,87 @@
1
+ """PyOQ's own settings, read from the Django project that owns them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Final, cast
8
+
9
+ from django.conf import settings
10
+
11
+ from pyoq.config import Configuration, DatabaseDialect, DatabaseProfile
12
+ from pyoq.errors import OperationUnavailableError
13
+
14
+ SETTING: Final = "PYOQ"
15
+
16
+ _PROFILE: Final = "django"
17
+
18
+
19
+ def pyoq_settings() -> dict[str, object]:
20
+ """The project's PyOQ settings, which it has to have written itself.
21
+
22
+ There is no default output location worth guessing. A package written to
23
+ the wrong place is worse than being told where to put it.
24
+ """
25
+ configured: object = getattr(settings, SETTING, None)
26
+ if not isinstance(configured, Mapping):
27
+ message = (
28
+ f"Django settings define no {SETTING} mapping; add "
29
+ f'{SETTING} = {{"codegen_directory": ..., '
30
+ f'"codegen_package": ...}}'
31
+ )
32
+ raise OperationUnavailableError(message)
33
+ # Django settings carry no types of their own, so this is the one place
34
+ # that says what the mapping holds. Every read below re-checks the value.
35
+ entries = cast("Mapping[str, object]", configured)
36
+ return dict(entries)
37
+
38
+
39
+ def django_configuration(dialect: DatabaseDialect, /) -> Configuration:
40
+ """Where generated output goes, with no database named.
41
+
42
+ The schema comes from migration files, so a profile here carries the
43
+ dialect and nothing that would open a connection.
44
+ """
45
+ configured = pyoq_settings()
46
+ return Configuration(
47
+ project_root=_root(configured),
48
+ codegen_directory=Path(_required(configured, "codegen_directory")),
49
+ codegen_package=_required(configured, "codegen_package"),
50
+ selected_profile=_PROFILE,
51
+ profiles=(DatabaseProfile(name=_PROFILE, dialect=dialect),),
52
+ )
53
+
54
+
55
+ def codegen_after_makemigrations() -> bool:
56
+ """Overriding a command a project already runs has to be asked for."""
57
+ return bool(pyoq_settings().get("codegen_after_makemigrations", False))
58
+
59
+
60
+ def _root(configured: dict[str, object]) -> Path:
61
+ named = configured.get("project_root")
62
+ if named is not None:
63
+ return Path(str(named))
64
+ base = getattr(settings, "BASE_DIR", None)
65
+ if base is None:
66
+ message = (
67
+ f"neither {SETTING}['project_root'] nor BASE_DIR is set, so there "
68
+ f"is nowhere to write generated code relative to"
69
+ )
70
+ raise OperationUnavailableError(message)
71
+ return Path(str(base))
72
+
73
+
74
+ def _required(configured: dict[str, object], key: str) -> str:
75
+ value = configured.get(key)
76
+ if not isinstance(value, str) or not value:
77
+ message = f"{SETTING}[{key!r}] must be a non-empty string"
78
+ raise OperationUnavailableError(message)
79
+ return value
80
+
81
+
82
+ __all__ = (
83
+ "SETTING",
84
+ "codegen_after_makemigrations",
85
+ "django_configuration",
86
+ "pyoq_settings",
87
+ )
@@ -0,0 +1,105 @@
1
+ """Bounding a statement on a connection PyOQ does not own.
2
+
3
+ A timeout is session state, and the session belongs to Django, so it has to be
4
+ put back exactly as it was found. Inside a transaction the database does that
5
+ itself; outside one, the previous value is read first and restored after.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Generator
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass
13
+
14
+ from django.db import DatabaseError
15
+ from django.db.backends.base.base import BaseDatabaseWrapper
16
+
17
+ MILLISECONDS = 1000
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class TimeoutSetting:
22
+ """The session setting one backend uses to bound a statement."""
23
+
24
+ name: str
25
+ reads: bool = True
26
+ writes: bool = True
27
+
28
+ def read(self, connection: BaseDatabaseWrapper, /) -> str:
29
+ with connection.cursor() as cursor:
30
+ cursor.execute(f"SHOW {self.name}")
31
+ row = cursor.fetchone()
32
+ return "" if row is None else str(row[0])
33
+
34
+ def assign(
35
+ self,
36
+ connection: BaseDatabaseWrapper,
37
+ value: str,
38
+ /,
39
+ *,
40
+ local: bool,
41
+ ) -> None:
42
+ scope = "LOCAL " if local else ""
43
+ with connection.cursor() as cursor:
44
+ cursor.execute(f"SET {scope}{self.name} = {value}")
45
+
46
+
47
+ POSTGRES_TIMEOUT = TimeoutSetting("statement_timeout")
48
+ MYSQL_TIMEOUT = TimeoutSetting("@@max_execution_time", writes=False)
49
+
50
+
51
+ @contextmanager
52
+ def applied(
53
+ setting: TimeoutSetting,
54
+ connection: BaseDatabaseWrapper,
55
+ seconds: float,
56
+ /,
57
+ ) -> Generator[None]:
58
+ """Bound statements for the length of this block, and leave no trace.
59
+
60
+ Inside a transaction the setting is local, so the database restores it when
61
+ the transaction ends. Outside one there is nothing to restore it, so the
62
+ previous value is read first and put back afterwards.
63
+ """
64
+ milliseconds = max(int(seconds * MILLISECONDS), 1)
65
+ if connection.in_atomic_block:
66
+ setting.assign(connection, str(milliseconds), local=True)
67
+ yield
68
+ return
69
+ previous = setting.read(connection)
70
+ setting.assign(connection, str(milliseconds), local=False)
71
+ try:
72
+ yield
73
+ finally:
74
+ _restore(setting, connection, previous)
75
+
76
+
77
+ def _restore(
78
+ setting: TimeoutSetting,
79
+ connection: BaseDatabaseWrapper,
80
+ previous: str,
81
+ ) -> None:
82
+ """Put the setting back without ever replacing the reason we are here.
83
+
84
+ A statement that failed can leave a connection unable to accept the next
85
+ one, and a restore that raised there would hide the failure that caused it.
86
+ Django owns the connection and will deal with it.
87
+ """
88
+ try:
89
+ setting.assign(connection, _quoted(previous), local=False)
90
+ except DatabaseError:
91
+ return
92
+
93
+
94
+ def _quoted(previous: str) -> str:
95
+ """A read setting comes back with its unit, which has to go back quoted."""
96
+ return previous if previous.isdigit() else f"'{previous}'"
97
+
98
+
99
+ __all__ = (
100
+ "MILLISECONDS",
101
+ "MYSQL_TIMEOUT",
102
+ "POSTGRES_TIMEOUT",
103
+ "TimeoutSetting",
104
+ "applied",
105
+ )
pyoq/dsl/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Building a statement and running it in one chain.
2
+
3
+ `using(database)` gives a context to build against. The builders in
4
+ `pyoq.query` are unchanged and still cannot reach a database on their own.
5
+ """
6
+
7
+ from pyoq.dsl.aio import (
8
+ AsyncBoundConflictUpdate,
9
+ AsyncBoundDelete,
10
+ AsyncBoundInsert,
11
+ AsyncBoundJoinStep,
12
+ AsyncBoundMutation,
13
+ AsyncBoundQuery,
14
+ AsyncBoundReturning,
15
+ AsyncBoundSelect,
16
+ AsyncBoundSemiJoinStep,
17
+ AsyncBoundUpdate,
18
+ AsyncDslContext,
19
+ )
20
+ from pyoq.dsl.context import DslContext
21
+ from pyoq.dsl.entry import using
22
+ from pyoq.dsl.queries import (
23
+ BoundJoinStep,
24
+ BoundQuery,
25
+ BoundSelect,
26
+ BoundSemiJoinStep,
27
+ )
28
+ from pyoq.dsl.result import Result, Row
29
+ from pyoq.dsl.writes import (
30
+ BoundConflictUpdate,
31
+ BoundDelete,
32
+ BoundInsert,
33
+ BoundMutation,
34
+ BoundReturning,
35
+ BoundUpdate,
36
+ )
37
+
38
+ __all__ = (
39
+ "AsyncBoundConflictUpdate",
40
+ "AsyncBoundDelete",
41
+ "AsyncBoundInsert",
42
+ "AsyncBoundJoinStep",
43
+ "AsyncBoundMutation",
44
+ "AsyncBoundQuery",
45
+ "AsyncBoundReturning",
46
+ "AsyncBoundSelect",
47
+ "AsyncBoundSemiJoinStep",
48
+ "AsyncBoundUpdate",
49
+ "AsyncDslContext",
50
+ "BoundConflictUpdate",
51
+ "BoundDelete",
52
+ "BoundInsert",
53
+ "BoundJoinStep",
54
+ "BoundMutation",
55
+ "BoundQuery",
56
+ "BoundReturning",
57
+ "BoundSelect",
58
+ "BoundSemiJoinStep",
59
+ "BoundUpdate",
60
+ "DslContext",
61
+ "Result",
62
+ "Row",
63
+ "using",
64
+ )
@@ -0,0 +1,31 @@
1
+ """The asynchronous counterpart of the bound builders."""
2
+
3
+ from pyoq.dsl.aio.context import AsyncDslContext
4
+ from pyoq.dsl.aio.queries import (
5
+ AsyncBoundJoinStep,
6
+ AsyncBoundQuery,
7
+ AsyncBoundSelect,
8
+ AsyncBoundSemiJoinStep,
9
+ )
10
+ from pyoq.dsl.aio.writes import (
11
+ AsyncBoundConflictUpdate,
12
+ AsyncBoundDelete,
13
+ AsyncBoundInsert,
14
+ AsyncBoundMutation,
15
+ AsyncBoundReturning,
16
+ AsyncBoundUpdate,
17
+ )
18
+
19
+ __all__ = (
20
+ "AsyncBoundConflictUpdate",
21
+ "AsyncBoundDelete",
22
+ "AsyncBoundInsert",
23
+ "AsyncBoundJoinStep",
24
+ "AsyncBoundMutation",
25
+ "AsyncBoundQuery",
26
+ "AsyncBoundReturning",
27
+ "AsyncBoundSelect",
28
+ "AsyncBoundSemiJoinStep",
29
+ "AsyncBoundUpdate",
30
+ "AsyncDslContext",
31
+ )