oxyde-postgres 0.1.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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Mutation mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from functools import cache
|
|
9
|
+
from typing import (
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Any,
|
|
12
|
+
Generic,
|
|
13
|
+
Literal,
|
|
14
|
+
TypeVar,
|
|
15
|
+
get_args,
|
|
16
|
+
get_origin,
|
|
17
|
+
overload,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from pydantic import TypeAdapter
|
|
21
|
+
|
|
22
|
+
from oxyde._msgpack import msgpack
|
|
23
|
+
from oxyde.core import ir
|
|
24
|
+
from oxyde.exceptions import ManagerError
|
|
25
|
+
from oxyde.models.serializers import (
|
|
26
|
+
_dump_insert_data,
|
|
27
|
+
_normalize_instance,
|
|
28
|
+
)
|
|
29
|
+
from oxyde.models.utils import _unpack_annotated, _unwrap_optional
|
|
30
|
+
from oxyde.queries.base import (
|
|
31
|
+
SupportsExecute,
|
|
32
|
+
_build_column_db_types,
|
|
33
|
+
_build_column_types,
|
|
34
|
+
_map_values_to_columns,
|
|
35
|
+
_model_key,
|
|
36
|
+
_resolve_execution_client,
|
|
37
|
+
_translate_core_error,
|
|
38
|
+
)
|
|
39
|
+
from oxyde.queries.expressions import F, _Expression, _serialize_value_for_ir
|
|
40
|
+
from oxyde.queries.insert import InsertQuery
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from oxyde.models.base import Model
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _hydrate_models(
|
|
50
|
+
model_class: type[TModel], columns: list[str], rows: list[list[Any]]
|
|
51
|
+
) -> list[TModel]:
|
|
52
|
+
"""Convert raw columns + rows into validated model instances."""
|
|
53
|
+
rmap = model_class._db_meta.reverse_column_map
|
|
54
|
+
field_columns = [rmap.get(c, c) for c in columns] if rmap else columns
|
|
55
|
+
return [model_class.model_validate(dict(zip(field_columns, row))) for row in rows]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decode_returning_models(
|
|
59
|
+
model_class: type[TModel], result: dict[str, Any]
|
|
60
|
+
) -> list[TModel]:
|
|
61
|
+
"""Decode mutation-returning result {columns, rows} into model instances."""
|
|
62
|
+
rows = result.get("rows", [])
|
|
63
|
+
if not rows:
|
|
64
|
+
return []
|
|
65
|
+
return _hydrate_models(model_class, result.get("columns", []), rows)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decode_columnar_models(
|
|
69
|
+
model_class: type[TModel], result: list[Any]
|
|
70
|
+
) -> list[TModel]:
|
|
71
|
+
"""Decode columnar SELECT result [columns, rows] into model instances."""
|
|
72
|
+
if len(result) < 2 or not result[1]:
|
|
73
|
+
return []
|
|
74
|
+
return _hydrate_models(model_class, result[0], result[1])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_enum_annotation(annotation: Any) -> bool:
|
|
78
|
+
annotation, _ = _unpack_annotated(annotation)
|
|
79
|
+
annotation, _ = _unwrap_optional(annotation)
|
|
80
|
+
origin = get_origin(annotation)
|
|
81
|
+
if origin is list:
|
|
82
|
+
args = get_args(annotation)
|
|
83
|
+
return bool(args) and _is_enum_annotation(args[0])
|
|
84
|
+
if origin is not None:
|
|
85
|
+
return any(
|
|
86
|
+
arg is not type(None) and _is_enum_annotation(arg)
|
|
87
|
+
for arg in get_args(annotation)
|
|
88
|
+
)
|
|
89
|
+
return isinstance(annotation, type) and issubclass(annotation, Enum)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cache
|
|
93
|
+
def _enum_type_adapter(python_type: Any) -> TypeAdapter:
|
|
94
|
+
return TypeAdapter(python_type)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_enum_update_values(
|
|
98
|
+
model_class: type[Model],
|
|
99
|
+
values: dict[str, Any],
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
validated = dict(values)
|
|
102
|
+
metadata = model_class._db_meta.field_metadata
|
|
103
|
+
for field, value in values.items():
|
|
104
|
+
if isinstance(value, (F, _Expression)):
|
|
105
|
+
continue
|
|
106
|
+
meta = metadata.get(field)
|
|
107
|
+
if meta is not None and _is_enum_annotation(meta.python_type):
|
|
108
|
+
if value is None and meta.nullable:
|
|
109
|
+
validated[field] = None
|
|
110
|
+
else:
|
|
111
|
+
adapter = _enum_type_adapter(meta.python_type)
|
|
112
|
+
validated[field] = adapter.validate_python(value)
|
|
113
|
+
return validated
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MutationMixin(Generic[TModel]):
|
|
117
|
+
"""Mixin providing data mutation capabilities."""
|
|
118
|
+
|
|
119
|
+
# These attributes are defined in the base Query class
|
|
120
|
+
model_class: type[TModel]
|
|
121
|
+
|
|
122
|
+
def _build_filter_tree(self) -> ir.FilterNode | None:
|
|
123
|
+
"""Must be implemented by FilteringMixin."""
|
|
124
|
+
raise NotImplementedError
|
|
125
|
+
|
|
126
|
+
async def increment(
|
|
127
|
+
self,
|
|
128
|
+
field: str,
|
|
129
|
+
by: int | float = 1,
|
|
130
|
+
*,
|
|
131
|
+
using: str | None = None,
|
|
132
|
+
client: SupportsExecute | None = None,
|
|
133
|
+
) -> int:
|
|
134
|
+
"""
|
|
135
|
+
Atomically increment a field value.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
field: Field name to increment
|
|
139
|
+
by: Amount to increment (default 1)
|
|
140
|
+
using: Database alias
|
|
141
|
+
client: Optional database client
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Number of affected rows
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
await Post.objects.filter(id=42).increment("views", by=1)
|
|
148
|
+
await User.objects.filter(is_active=True).increment("login_count")
|
|
149
|
+
"""
|
|
150
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
151
|
+
column_types = _build_column_types(self.model_class)
|
|
152
|
+
update_ir = ir.build_update_ir(
|
|
153
|
+
table=self.model_class.get_table_name(),
|
|
154
|
+
values={field: _serialize_value_for_ir(F(field) + by)},
|
|
155
|
+
filter_tree=self._build_filter_tree(),
|
|
156
|
+
column_types=column_types,
|
|
157
|
+
column_db_types=_build_column_db_types(self.model_class),
|
|
158
|
+
model=_model_key(self.model_class),
|
|
159
|
+
)
|
|
160
|
+
result_bytes = await exec_client.execute(update_ir)
|
|
161
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
162
|
+
affected: int = result.get("affected", 0)
|
|
163
|
+
return affected
|
|
164
|
+
|
|
165
|
+
@overload
|
|
166
|
+
async def update(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
returning: Literal[True],
|
|
170
|
+
using: str | None = ...,
|
|
171
|
+
client: SupportsExecute | None = ...,
|
|
172
|
+
**values: Any,
|
|
173
|
+
) -> list[TModel]: ...
|
|
174
|
+
|
|
175
|
+
@overload
|
|
176
|
+
async def update(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
returning: Literal[False] = ...,
|
|
180
|
+
using: str | None = ...,
|
|
181
|
+
client: SupportsExecute | None = ...,
|
|
182
|
+
**values: Any,
|
|
183
|
+
) -> int: ...
|
|
184
|
+
|
|
185
|
+
async def update(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
returning: bool = False,
|
|
189
|
+
using: str | None = None,
|
|
190
|
+
client: SupportsExecute | None = None,
|
|
191
|
+
**values: Any,
|
|
192
|
+
) -> int | list[TModel]:
|
|
193
|
+
"""
|
|
194
|
+
Update records matching the query.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
returning: If True, return updated model instances.
|
|
198
|
+
Default False — returns number of affected rows (Django-compatible).
|
|
199
|
+
using: Database alias
|
|
200
|
+
client: Optional database client
|
|
201
|
+
**values: Field values to update
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Number of affected rows (default), or list of updated model instances
|
|
205
|
+
if returning=True.
|
|
206
|
+
|
|
207
|
+
Examples:
|
|
208
|
+
count = await Post.objects.filter(id=42).update(status="published")
|
|
209
|
+
posts = await Post.objects.filter(id=42).update(
|
|
210
|
+
status="published", returning=True
|
|
211
|
+
)
|
|
212
|
+
"""
|
|
213
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
214
|
+
column_types = _build_column_types(self.model_class)
|
|
215
|
+
values = _validate_enum_update_values(self.model_class, values)
|
|
216
|
+
mapped_values = _map_values_to_columns(self.model_class, values)
|
|
217
|
+
serialized_values = {
|
|
218
|
+
key: _serialize_value_for_ir(value) for key, value in mapped_values.items()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
update_ir = ir.build_update_ir(
|
|
222
|
+
table=self.model_class.get_table_name(),
|
|
223
|
+
values=serialized_values,
|
|
224
|
+
filter_tree=self._build_filter_tree(),
|
|
225
|
+
column_types=column_types,
|
|
226
|
+
column_db_types=_build_column_db_types(self.model_class),
|
|
227
|
+
model=_model_key(self.model_class),
|
|
228
|
+
returning=returning,
|
|
229
|
+
)
|
|
230
|
+
result_bytes = await exec_client.execute(update_ir)
|
|
231
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
232
|
+
if returning:
|
|
233
|
+
return _decode_returning_models(self.model_class, result)
|
|
234
|
+
affected: int = result.get("affected", 0)
|
|
235
|
+
return affected
|
|
236
|
+
|
|
237
|
+
async def delete(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
using: str | None = None,
|
|
241
|
+
client: SupportsExecute | None = None,
|
|
242
|
+
) -> int:
|
|
243
|
+
"""
|
|
244
|
+
Delete records matching the query.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
using: Database alias
|
|
248
|
+
client: Optional database client
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Number of affected rows
|
|
252
|
+
|
|
253
|
+
Examples:
|
|
254
|
+
await Post.objects.filter(id=42).delete()
|
|
255
|
+
await User.objects.filter(is_active=False).delete()
|
|
256
|
+
"""
|
|
257
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
258
|
+
delete_ir = ir.build_delete_ir(
|
|
259
|
+
table=self.model_class.get_table_name(),
|
|
260
|
+
filter_tree=self._build_filter_tree(),
|
|
261
|
+
column_types=_build_column_types(self.model_class),
|
|
262
|
+
column_db_types=_build_column_db_types(self.model_class),
|
|
263
|
+
model=_model_key(self.model_class),
|
|
264
|
+
)
|
|
265
|
+
result_bytes = await exec_client.execute(delete_ir)
|
|
266
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
267
|
+
affected: int = result.get("affected", 0)
|
|
268
|
+
return affected
|
|
269
|
+
|
|
270
|
+
def _primary_key_field(self) -> str | None:
|
|
271
|
+
"""Get primary key field name."""
|
|
272
|
+
return self.model_class._db_meta.pk_field
|
|
273
|
+
|
|
274
|
+
async def _run_mutation(
|
|
275
|
+
self, query: Any, client: SupportsExecute
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Execute mutation query with error handling."""
|
|
278
|
+
try:
|
|
279
|
+
result = await query.execute(client)
|
|
280
|
+
except ManagerError:
|
|
281
|
+
raise
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
translated = _translate_core_error(exc)
|
|
284
|
+
if translated is not None:
|
|
285
|
+
raise translated from exc
|
|
286
|
+
raise ManagerError(str(exc)) from exc
|
|
287
|
+
if not isinstance(result, dict):
|
|
288
|
+
raise ManagerError("Mutation response must be a dict")
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
async def create(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
instance: TModel | None = None,
|
|
295
|
+
using: str | None = None,
|
|
296
|
+
client: SupportsExecute | None = None,
|
|
297
|
+
_skip_hooks: bool = False,
|
|
298
|
+
**data: Any,
|
|
299
|
+
) -> TModel:
|
|
300
|
+
"""
|
|
301
|
+
Create a new record in the database.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
instance: Model instance to create (alternative to **data)
|
|
305
|
+
using: Database alias
|
|
306
|
+
client: Optional database client
|
|
307
|
+
_skip_hooks: Skip pre_save/post_save hooks
|
|
308
|
+
**data: Field values for the new record
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Created model instance with populated PK
|
|
312
|
+
|
|
313
|
+
Examples:
|
|
314
|
+
user = await User.objects.create(name="Alice", email="alice@example.com")
|
|
315
|
+
# Or with instance:
|
|
316
|
+
user = User(name="Alice")
|
|
317
|
+
user = await User.objects.create(instance=user)
|
|
318
|
+
"""
|
|
319
|
+
if instance is not None and data:
|
|
320
|
+
raise ManagerError(
|
|
321
|
+
"create() accepts either 'instance' or field values, not both"
|
|
322
|
+
)
|
|
323
|
+
if instance is None:
|
|
324
|
+
if not data:
|
|
325
|
+
raise ManagerError("create() requires an instance or field values")
|
|
326
|
+
instance = self.model_class(**data)
|
|
327
|
+
|
|
328
|
+
# Call pre_save hook
|
|
329
|
+
if not _skip_hooks:
|
|
330
|
+
await instance.pre_save(is_create=True, update_fields=None)
|
|
331
|
+
|
|
332
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
333
|
+
payload = _dump_insert_data(instance)
|
|
334
|
+
if not payload:
|
|
335
|
+
raise ManagerError("create() requires at least one value")
|
|
336
|
+
query = InsertQuery(self.model_class).values(**payload)
|
|
337
|
+
result = await self._run_mutation(query, exec_client)
|
|
338
|
+
|
|
339
|
+
# Update instance from RETURNING * result (with Pydantic validation)
|
|
340
|
+
if "rows" in result and result["rows"]:
|
|
341
|
+
rmap = self.model_class._db_meta.reverse_column_map
|
|
342
|
+
columns = result.get("columns", [])
|
|
343
|
+
row = result["rows"][0]
|
|
344
|
+
row_dict = {rmap.get(col, col): value for col, value in zip(columns, row)}
|
|
345
|
+
instance = self.model_class.model_validate(row_dict)
|
|
346
|
+
elif "inserted_ids" in result and result["inserted_ids"]:
|
|
347
|
+
pk_field = self._primary_key_field()
|
|
348
|
+
if pk_field:
|
|
349
|
+
row_dict = instance.model_dump()
|
|
350
|
+
row_dict[pk_field] = result["inserted_ids"][0]
|
|
351
|
+
instance = self.model_class.model_validate(row_dict)
|
|
352
|
+
|
|
353
|
+
# Call post_save hook
|
|
354
|
+
if not _skip_hooks:
|
|
355
|
+
await instance.post_save(is_create=True, update_fields=None)
|
|
356
|
+
|
|
357
|
+
return instance
|
|
358
|
+
|
|
359
|
+
async def bulk_create(
|
|
360
|
+
self,
|
|
361
|
+
objects: Iterable[TModel | dict[str, Any]],
|
|
362
|
+
*,
|
|
363
|
+
using: str | None = None,
|
|
364
|
+
client: SupportsExecute | None = None,
|
|
365
|
+
batch_size: int | None = None,
|
|
366
|
+
) -> list[TModel]:
|
|
367
|
+
"""
|
|
368
|
+
Insert multiple objects efficiently.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
objects: Iterable of model instances or dicts
|
|
372
|
+
using: Database alias
|
|
373
|
+
client: Optional database client
|
|
374
|
+
batch_size: Optional batch size. If None, inserts all in one query.
|
|
375
|
+
Use if hitting PostgreSQL parameter limits (65535).
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of created model instances
|
|
379
|
+
"""
|
|
380
|
+
instances = [_normalize_instance(self.model_class, obj) for obj in objects]
|
|
381
|
+
if not instances:
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
385
|
+
|
|
386
|
+
# Determine batches: all at once or user-specified batch_size
|
|
387
|
+
if batch_size is not None and batch_size <= 0:
|
|
388
|
+
raise ValueError("batch_size must be a positive integer")
|
|
389
|
+
if batch_size is None:
|
|
390
|
+
batches = [instances]
|
|
391
|
+
else:
|
|
392
|
+
batches = [
|
|
393
|
+
instances[i : i + batch_size]
|
|
394
|
+
for i in range(0, len(instances), batch_size)
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
for batch in batches:
|
|
398
|
+
payloads = []
|
|
399
|
+
for instance in batch:
|
|
400
|
+
payload = _dump_insert_data(instance)
|
|
401
|
+
if not payload:
|
|
402
|
+
raise ManagerError("bulk_create() encountered an empty payload")
|
|
403
|
+
payloads.append(payload)
|
|
404
|
+
|
|
405
|
+
query = InsertQuery(self.model_class).bulk_values(payloads)
|
|
406
|
+
result = await self._run_mutation(query, exec_client)
|
|
407
|
+
|
|
408
|
+
# Assign auto-generated PKs to instances
|
|
409
|
+
inserted_ids = result.get("inserted_ids", [])
|
|
410
|
+
pk_field = self._primary_key_field()
|
|
411
|
+
|
|
412
|
+
if inserted_ids and len(inserted_ids) != len(batch):
|
|
413
|
+
warnings.warn(
|
|
414
|
+
f"bulk_create: received {len(inserted_ids)} IDs for {len(batch)} rows. "
|
|
415
|
+
"Check conflict handling and the returned primary keys. "
|
|
416
|
+
"non-sequential auto_increment, or non-integer primary keys. "
|
|
417
|
+
"Assigned IDs may be incorrect.",
|
|
418
|
+
RuntimeWarning,
|
|
419
|
+
stacklevel=2,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if pk_field and inserted_ids:
|
|
423
|
+
for instance, pk_value in zip(batch, inserted_ids):
|
|
424
|
+
setattr(instance, pk_field, pk_value)
|
|
425
|
+
|
|
426
|
+
return instances
|
|
427
|
+
|
|
428
|
+
async def bulk_update(
|
|
429
|
+
self,
|
|
430
|
+
objects: Iterable[TModel],
|
|
431
|
+
fields: Iterable[str],
|
|
432
|
+
*,
|
|
433
|
+
using: str | None = None,
|
|
434
|
+
client: SupportsExecute | None = None,
|
|
435
|
+
) -> int:
|
|
436
|
+
"""
|
|
437
|
+
Bulk update multiple objects with CASE WHEN.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
objects: Instances to update
|
|
441
|
+
fields: Field names to update
|
|
442
|
+
using: Database alias
|
|
443
|
+
client: Optional database client
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Number of affected rows
|
|
447
|
+
|
|
448
|
+
Examples:
|
|
449
|
+
users = await User.objects.filter(is_active=True).all()
|
|
450
|
+
for user in users:
|
|
451
|
+
user.login_count += 1
|
|
452
|
+
await User.objects.bulk_update(users, ["login_count"])
|
|
453
|
+
"""
|
|
454
|
+
objects_list = list(objects)
|
|
455
|
+
if not objects_list:
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
fields_list = list(fields)
|
|
459
|
+
if not fields_list:
|
|
460
|
+
raise ManagerError("bulk_update() requires at least one field")
|
|
461
|
+
|
|
462
|
+
pk_field = self._primary_key_field()
|
|
463
|
+
if not pk_field:
|
|
464
|
+
raise ManagerError("bulk_update() requires a primary key")
|
|
465
|
+
|
|
466
|
+
pk_column = self.model_class._db_meta.field_metadata[pk_field].db_column
|
|
467
|
+
|
|
468
|
+
exec_client = await _resolve_execution_client(using, client)
|
|
469
|
+
|
|
470
|
+
# Build bulk_update payload
|
|
471
|
+
bulk_entries: list[dict[str, Any]] = []
|
|
472
|
+
for obj in objects_list:
|
|
473
|
+
pk_value = getattr(obj, pk_field)
|
|
474
|
+
if pk_value is None:
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
all_values = obj.model_dump(mode="python", exclude_none=False)
|
|
478
|
+
|
|
479
|
+
# Extract requested fields and map to db_column names
|
|
480
|
+
values = {
|
|
481
|
+
field_name: _serialize_value_for_ir(all_values[field_name])
|
|
482
|
+
for field_name in fields_list
|
|
483
|
+
if field_name in all_values
|
|
484
|
+
}
|
|
485
|
+
mapped_values = _map_values_to_columns(self.model_class, values)
|
|
486
|
+
|
|
487
|
+
if mapped_values:
|
|
488
|
+
bulk_entries.append(
|
|
489
|
+
{
|
|
490
|
+
"filters": {pk_column: pk_value},
|
|
491
|
+
"values": mapped_values,
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if not bulk_entries:
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
column_types = _build_column_types(self.model_class)
|
|
499
|
+
update_ir = ir.build_update_ir(
|
|
500
|
+
table=self.model_class.get_table_name(),
|
|
501
|
+
bulk_update=bulk_entries,
|
|
502
|
+
column_types=column_types,
|
|
503
|
+
model=_model_key(self.model_class),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
result_bytes = await exec_client.execute(update_ir)
|
|
507
|
+
result = msgpack.unpackb(result_bytes, raw=False)
|
|
508
|
+
return int(result.get("affected", 0))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Pagination mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from typing import Literal, overload
|
|
11
|
+
|
|
12
|
+
from oxyde.models.base import Model
|
|
13
|
+
from oxyde.queries.typed import (
|
|
14
|
+
FlatValuesListQuery,
|
|
15
|
+
ValuesListQuery,
|
|
16
|
+
ValuesQuery,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PaginationMixin(Generic[TModel]):
|
|
24
|
+
"""Mixin providing pagination and ordering capabilities."""
|
|
25
|
+
|
|
26
|
+
# These attributes are defined in the base Query class
|
|
27
|
+
_limit_value: int | None
|
|
28
|
+
_offset_value: int | None
|
|
29
|
+
_order_by_fields: list[tuple[str, str]]
|
|
30
|
+
_distinct: bool
|
|
31
|
+
_result_mode: str | None
|
|
32
|
+
_values_flat: bool
|
|
33
|
+
_selected_fields: list[str] | None
|
|
34
|
+
|
|
35
|
+
def _clone(self) -> Self:
|
|
36
|
+
"""Must be implemented by the main Query class."""
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def select(self, *fields: str) -> Self:
|
|
40
|
+
"""Must be implemented by the main Query class."""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def limit(self, value: int) -> Self:
|
|
44
|
+
"""Set LIMIT."""
|
|
45
|
+
if value < 0:
|
|
46
|
+
raise ValueError(f"limit() requires a non-negative value, got {value}")
|
|
47
|
+
clone = self._clone()
|
|
48
|
+
clone._limit_value = value
|
|
49
|
+
return clone
|
|
50
|
+
|
|
51
|
+
def offset(self, value: int) -> Self:
|
|
52
|
+
"""Set OFFSET."""
|
|
53
|
+
if value < 0:
|
|
54
|
+
raise ValueError(f"offset() requires a non-negative value, got {value}")
|
|
55
|
+
clone = self._clone()
|
|
56
|
+
clone._offset_value = value
|
|
57
|
+
return clone
|
|
58
|
+
|
|
59
|
+
def order_by(self, *fields: str) -> Self:
|
|
60
|
+
"""Set ORDER BY fields. Use "?" for random ordering (ORDER BY RANDOM())."""
|
|
61
|
+
clone = self._clone()
|
|
62
|
+
for field in fields:
|
|
63
|
+
if field == "?":
|
|
64
|
+
clone._order_by_fields.append(("?", "RANDOM"))
|
|
65
|
+
elif field.startswith("-"):
|
|
66
|
+
clone._order_by_fields.append((field[1:], "DESC"))
|
|
67
|
+
else:
|
|
68
|
+
clone._order_by_fields.append((field, "ASC"))
|
|
69
|
+
return clone
|
|
70
|
+
|
|
71
|
+
def distinct(self, distinct: bool = True) -> Self:
|
|
72
|
+
"""Set DISTINCT."""
|
|
73
|
+
clone = self._clone()
|
|
74
|
+
clone._distinct = bool(distinct)
|
|
75
|
+
return clone
|
|
76
|
+
|
|
77
|
+
# values()/values_list() flip a result-mode flag on a clone at runtime;
|
|
78
|
+
# statically they are declared to return the typing facades from
|
|
79
|
+
# oxyde.queries.typed so terminal methods reflect the actual row shape
|
|
80
|
+
# (dicts / tuples / scalars) instead of model instances.
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
|
|
83
|
+
def values(self, *fields: str) -> ValuesQuery[TModel]:
|
|
84
|
+
"""Return results as dictionaries."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@overload
|
|
88
|
+
def values_list(
|
|
89
|
+
self, field: str, /, *, flat: Literal[True]
|
|
90
|
+
) -> FlatValuesListQuery[TModel]: ...
|
|
91
|
+
|
|
92
|
+
@overload
|
|
93
|
+
def values_list(
|
|
94
|
+
self, *fields: str, flat: Literal[False] = ...
|
|
95
|
+
) -> ValuesListQuery[TModel]: ...
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def values_list(
|
|
99
|
+
self, *fields: str, flat: bool
|
|
100
|
+
) -> ValuesListQuery[TModel] | FlatValuesListQuery[TModel]: ...
|
|
101
|
+
|
|
102
|
+
def values_list(self, *fields: str, flat: bool = False) -> Any:
|
|
103
|
+
"""Return results as tuples (or flat list if flat=True)."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
else:
|
|
107
|
+
|
|
108
|
+
def values(self, *fields):
|
|
109
|
+
"""Return results as dictionaries."""
|
|
110
|
+
clone = self._clone()
|
|
111
|
+
if fields:
|
|
112
|
+
clone = clone.select(*fields)
|
|
113
|
+
clone._result_mode = "dict"
|
|
114
|
+
return clone
|
|
115
|
+
|
|
116
|
+
def values_list(self, *fields, flat=False):
|
|
117
|
+
"""Return results as tuples (or flat list if flat=True)."""
|
|
118
|
+
clone = self._clone()
|
|
119
|
+
if fields:
|
|
120
|
+
clone = clone.select(*fields)
|
|
121
|
+
if flat and fields and len(fields) != 1:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"flat=True is only valid when a single field is selected"
|
|
124
|
+
)
|
|
125
|
+
clone._result_mode = "list"
|
|
126
|
+
clone._values_flat = flat
|
|
127
|
+
return clone
|
|
128
|
+
|
|
129
|
+
def __getitem__(self, key: slice | int) -> Self:
|
|
130
|
+
"""Support slicing: query[0:10] or query[5]."""
|
|
131
|
+
if isinstance(key, slice):
|
|
132
|
+
start = key.start or 0
|
|
133
|
+
stop = key.stop
|
|
134
|
+
if stop is None:
|
|
135
|
+
raise ValueError("Slicing a Query requires an end index")
|
|
136
|
+
if start < 0 or (stop is not None and stop < 0):
|
|
137
|
+
raise ValueError("Negative slicing is not supported")
|
|
138
|
+
clone = self.offset(start)
|
|
139
|
+
length = max(0, stop - start)
|
|
140
|
+
return clone.limit(length)
|
|
141
|
+
if isinstance(key, int):
|
|
142
|
+
if key < 0:
|
|
143
|
+
raise ValueError("Negative indexing is not supported")
|
|
144
|
+
return self.offset(key).limit(1)
|
|
145
|
+
raise TypeError("Invalid argument type for slicing Query")
|