sqlobjects 1.9.1__py3-none-any.whl → 2.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.
- sqlobjects/__init__.py +1 -1
- sqlobjects/_install_rules.py +18 -2
- sqlobjects/exceptions.py +18 -0
- sqlobjects/expressions/cte.py +1 -1
- sqlobjects/expressions/terminal.py +8 -14
- sqlobjects/mixins.py +14 -0
- sqlobjects/objects/bulk.py +26 -4
- sqlobjects/py.typed +0 -0
- sqlobjects/queries/builder.py +72 -27
- sqlobjects/queries/executor.py +4 -0
- sqlobjects/queryset.py +42 -16
- sqlobjects/session.py +24 -1
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/01-database-session-guide.md +77 -9
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/02-model-definition-guide.md +35 -2
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/03-query-operations-guide.md +25 -10
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/04-crud-operations-guide.md +125 -7
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/05-relationships-guide.md +96 -6
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/06-validation-signals-guide.md +9 -9
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/07-performance-guide.md +7 -6
- {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/README.md +34 -3
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/METADATA +12 -5
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/RECORD +26 -25
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/WHEEL +1 -1
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/entry_points.txt +0 -0
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/top_level.txt +0 -0
sqlobjects/__init__.py
CHANGED
sqlobjects/_install_rules.py
CHANGED
|
@@ -5,6 +5,16 @@ import sys
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def _get_package_version() -> str:
|
|
9
|
+
"""Get the installed sqlobjects version."""
|
|
10
|
+
try:
|
|
11
|
+
from . import __version__
|
|
12
|
+
|
|
13
|
+
return __version__
|
|
14
|
+
except Exception:
|
|
15
|
+
return "unknown"
|
|
16
|
+
|
|
17
|
+
|
|
8
18
|
def find_project_root() -> Path:
|
|
9
19
|
"""Find project root by looking for common markers."""
|
|
10
20
|
current = Path.cwd()
|
|
@@ -73,13 +83,19 @@ def install_rules(target_name: str, target_dir: Path | None = None) -> bool:
|
|
|
73
83
|
print(f"Error: Rules directory not found at {rules_dir}", file=sys.stderr)
|
|
74
84
|
return False
|
|
75
85
|
|
|
76
|
-
# Create target directory and copy files
|
|
86
|
+
# Create target directory and copy files, stamping the package version so
|
|
87
|
+
# AI assistants reading the rules know which behaviors they document
|
|
77
88
|
try:
|
|
78
89
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
79
90
|
|
|
91
|
+
version = _get_package_version()
|
|
92
|
+
stamp = f"<!-- Generated for SQLObjects {version} — behaviors documented here match this version -->\n\n"
|
|
93
|
+
|
|
80
94
|
copied_count = 0
|
|
81
95
|
for file in rules_dir.glob("*.md"):
|
|
82
|
-
|
|
96
|
+
content = file.read_text(encoding="utf-8")
|
|
97
|
+
(target_dir / file.name).write_text(stamp + content, encoding="utf-8")
|
|
98
|
+
shutil.copystat(file, target_dir / file.name)
|
|
83
99
|
copied_count += 1
|
|
84
100
|
|
|
85
101
|
if copied_count > 0:
|
sqlobjects/exceptions.py
CHANGED
|
@@ -20,6 +20,7 @@ __all__ = [
|
|
|
20
20
|
"ConfigurationError",
|
|
21
21
|
"DeferredFieldError",
|
|
22
22
|
"PrimaryKeyError",
|
|
23
|
+
"QueryError",
|
|
23
24
|
"SQLError",
|
|
24
25
|
"OperationalError",
|
|
25
26
|
"DataError",
|
|
@@ -400,6 +401,23 @@ class PrimaryKeyError(SQLObjectsError):
|
|
|
400
401
|
super().__init__(message)
|
|
401
402
|
|
|
402
403
|
|
|
404
|
+
class QueryError(SQLObjectsError):
|
|
405
|
+
"""Raised when a query is constructed in a way that cannot be executed safely.
|
|
406
|
+
|
|
407
|
+
SQLObjects raises this instead of silently rewriting the query semantics —
|
|
408
|
+
for example when annotated GROUP BY queries select columns outside the
|
|
409
|
+
grouping columns, which would degenerate every group to a single row.
|
|
410
|
+
|
|
411
|
+
Examples:
|
|
412
|
+
>>> try:
|
|
413
|
+
... await User.objects.annotate(cnt=func.count()).group_by("department").all()
|
|
414
|
+
... except QueryError:
|
|
415
|
+
... print("Use .values()/.only() with the grouping columns instead")
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
|
|
403
421
|
class SQLError(SQLObjectsError):
|
|
404
422
|
"""Base class for SQLAlchemy operation errors.
|
|
405
423
|
|
sqlobjects/expressions/cte.py
CHANGED
|
@@ -33,7 +33,7 @@ class CTEExpression:
|
|
|
33
33
|
>>> base = Employee.objects.filter(Employee.manager_id.is_(None)).cte("hierarchy", recursive=True)
|
|
34
34
|
>>> recursive = Employee.objects.join(base, Employee.manager_id == base.c.id)
|
|
35
35
|
>>> hierarchy = base.union_all(recursive)
|
|
36
|
-
>>> all_employees = await Employee.objects.with_cte(hierarchy).
|
|
36
|
+
>>> all_employees = await Employee.objects.with_cte(hierarchy).all()
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
39
|
def __init__(self, queryset: QuerySet, name: str, recursive: bool = False):
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
from datetime import date, datetime
|
|
4
4
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
5
5
|
|
|
6
|
-
from sqlalchemy import and_, select
|
|
7
|
-
|
|
8
6
|
from .base import QueryExpression
|
|
9
7
|
|
|
10
8
|
|
|
@@ -173,23 +171,19 @@ class ValuesExpression(QueryExpression[list[dict[str, Any]]]):
|
|
|
173
171
|
|
|
174
172
|
def get_query(self):
|
|
175
173
|
"""Return SQLAlchemy query object."""
|
|
176
|
-
return self._builder.build(self._builder.model_class.get_table())
|
|
174
|
+
return self._builder.build(self._builder.model_class.get_table(), values_fields=self._fields)
|
|
177
175
|
|
|
178
176
|
async def execute(self) -> list[dict[str, Any]]:
|
|
179
177
|
if not self._executor:
|
|
180
178
|
raise RuntimeError("No executor available for values execution")
|
|
181
|
-
query = self.
|
|
182
|
-
result = await self._executor.execute(query, "values", fields=self._fields)
|
|
179
|
+
query = self.get_query()
|
|
180
|
+
result = await self._executor.execute(query, "values", fields=self._fields, prebuilt=True)
|
|
183
181
|
if isinstance(result, list):
|
|
184
182
|
return [dict(zip(self._fields, row, strict=False)) for row in result]
|
|
185
183
|
return []
|
|
186
184
|
|
|
187
185
|
def get_sql(self) -> str:
|
|
188
|
-
|
|
189
|
-
columns = [table.c[field] for field in self._fields if field in table.c]
|
|
190
|
-
query = select(*columns).select_from(table)
|
|
191
|
-
if hasattr(self._builder, "conditions") and self._builder.conditions:
|
|
192
|
-
query = query.where(and_(*self._builder.conditions))
|
|
186
|
+
query = self.get_query()
|
|
193
187
|
return str(query.compile(compile_kwargs={"literal_binds": True}))
|
|
194
188
|
|
|
195
189
|
|
|
@@ -204,13 +198,13 @@ class ValuesListExpression(QueryExpression[list[Any] | list[tuple[Any, ...]]]):
|
|
|
204
198
|
|
|
205
199
|
def get_query(self):
|
|
206
200
|
"""Return SQLAlchemy query object."""
|
|
207
|
-
return self._builder.build(self._builder.model_class.get_table())
|
|
201
|
+
return self._builder.build(self._builder.model_class.get_table(), values_fields=self._fields)
|
|
208
202
|
|
|
209
203
|
async def execute(self) -> list[Any] | list[tuple[Any, ...]]:
|
|
210
204
|
if not self._executor:
|
|
211
205
|
raise RuntimeError("No executor available for values_list execution")
|
|
212
|
-
query = self.
|
|
213
|
-
result = await self._executor.execute(query, "values_list", fields=list(self._fields))
|
|
206
|
+
query = self.get_query()
|
|
207
|
+
result = await self._executor.execute(query, "values_list", fields=list(self._fields), prebuilt=True)
|
|
214
208
|
if isinstance(result, list):
|
|
215
209
|
if self._flat and len(self._fields) == 1:
|
|
216
210
|
return [row[0] for row in result]
|
|
@@ -218,7 +212,7 @@ class ValuesListExpression(QueryExpression[list[Any] | list[tuple[Any, ...]]]):
|
|
|
218
212
|
return []
|
|
219
213
|
|
|
220
214
|
def get_sql(self) -> str:
|
|
221
|
-
query = self.
|
|
215
|
+
query = self.get_query()
|
|
222
216
|
return str(query.compile(compile_kwargs={"literal_binds": True}))
|
|
223
217
|
|
|
224
218
|
|
sqlobjects/mixins.py
CHANGED
|
@@ -136,6 +136,14 @@ class BaseMixin:
|
|
|
136
136
|
if not hasattr(self, "_state_manager"):
|
|
137
137
|
self._state_manager = _StateManager()
|
|
138
138
|
|
|
139
|
+
def get_dirty_fields(self) -> set[str]:
|
|
140
|
+
"""Get fields modified since the last save or database load.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Set of field names with unsaved changes
|
|
144
|
+
"""
|
|
145
|
+
return self._state_manager.get_dirty_fields().copy()
|
|
146
|
+
|
|
139
147
|
@classmethod
|
|
140
148
|
def get_table(cls) -> Table:
|
|
141
149
|
"""Get SQLAlchemy Core Table definition.
|
|
@@ -258,6 +266,12 @@ class ValidationMixin(PrimaryKeyMixin):
|
|
|
258
266
|
)
|
|
259
267
|
if validators:
|
|
260
268
|
value = getattr(self, field_name, None)
|
|
269
|
+
# SQL expressions (func.now(), text(...), column arithmetic) are
|
|
270
|
+
# evaluated by the database — Python-side validators don't apply
|
|
271
|
+
from sqlalchemy.sql import ClauseElement
|
|
272
|
+
|
|
273
|
+
if isinstance(value, ClauseElement):
|
|
274
|
+
return
|
|
261
275
|
try:
|
|
262
276
|
from .validators import validate_field_value
|
|
263
277
|
|
sqlobjects/objects/bulk.py
CHANGED
|
@@ -4,6 +4,7 @@ This module provides bulk operations functionality and transaction control,
|
|
|
4
4
|
merged from the original bulk_transaction.py module.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from collections.abc import Iterator
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from enum import Enum
|
|
9
10
|
from typing import Any, Callable, Generic, TypeVar
|
|
@@ -82,7 +83,16 @@ class FailedRecord:
|
|
|
82
83
|
|
|
83
84
|
@dataclass
|
|
84
85
|
class BulkResult(Generic[T]):
|
|
85
|
-
"""Result object for bulk operations with detailed information.
|
|
86
|
+
"""Result object for bulk operations with detailed information.
|
|
87
|
+
|
|
88
|
+
Acts as a sequence over ``objects``: iteration, indexing and ``len()``
|
|
89
|
+
all operate on the successfully returned objects. Use ``total_count`` /
|
|
90
|
+
``success_count`` / ``error_count`` for operation statistics — on partial
|
|
91
|
+
failure ``len(result)`` and ``total_count`` differ.
|
|
92
|
+
|
|
93
|
+
For insert operations the order of ``objects`` matches the order of the
|
|
94
|
+
input rows (RETURNING is sorted by parameter order).
|
|
95
|
+
"""
|
|
86
96
|
|
|
87
97
|
success_count: int
|
|
88
98
|
error_count: int
|
|
@@ -111,8 +121,16 @@ class BulkResult(Generic[T]):
|
|
|
111
121
|
return 0 < self.success_count < self.total_count
|
|
112
122
|
|
|
113
123
|
def __len__(self) -> int:
|
|
114
|
-
"""Return
|
|
115
|
-
return self.
|
|
124
|
+
"""Return the number of returned objects (see ``total_count`` for input size)."""
|
|
125
|
+
return len(self.objects)
|
|
126
|
+
|
|
127
|
+
def __iter__(self) -> Iterator[T | dict[str, Any]]:
|
|
128
|
+
"""Iterate over the returned objects."""
|
|
129
|
+
return iter(self.objects)
|
|
130
|
+
|
|
131
|
+
def __getitem__(self, index):
|
|
132
|
+
"""Index into the returned objects."""
|
|
133
|
+
return self.objects[index]
|
|
116
134
|
|
|
117
135
|
|
|
118
136
|
class BulkTransactionManager:
|
|
@@ -269,7 +287,11 @@ class BulkOperationHandler:
|
|
|
269
287
|
exec_session = session or self.session
|
|
270
288
|
|
|
271
289
|
if return_columns and self.supports_returning(operation):
|
|
272
|
-
|
|
290
|
+
if operation == "insert":
|
|
291
|
+
# Guarantee RETURNING row order matches input row order for executemany
|
|
292
|
+
stmt_with_returning = stmt.returning(*return_columns, sort_by_parameter_order=True)
|
|
293
|
+
else:
|
|
294
|
+
stmt_with_returning = stmt.returning(*return_columns)
|
|
273
295
|
# For INSERT operations, use the data directly as parameters
|
|
274
296
|
if operation == "insert" and isinstance(parameters, list):
|
|
275
297
|
result = await exec_session.execute(stmt_with_returning, parameters)
|
sqlobjects/py.typed
ADDED
|
File without changes
|
sqlobjects/queries/builder.py
CHANGED
|
@@ -11,6 +11,8 @@ from sqlalchemy import (
|
|
|
11
11
|
)
|
|
12
12
|
from sqlalchemy.sql.selectable import Subquery
|
|
13
13
|
|
|
14
|
+
from ..exceptions import QueryError
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
# Export classes for use in other modules
|
|
16
18
|
__all__ = ["QueryBuilder"]
|
|
@@ -447,11 +449,14 @@ class QueryBuilder:
|
|
|
447
449
|
|
|
448
450
|
return related_columns
|
|
449
451
|
|
|
450
|
-
def build(self, table):
|
|
452
|
+
def build(self, table, values_fields: tuple[str, ...] | None = None):
|
|
451
453
|
"""Build final SQLAlchemy query object from accumulated clauses.
|
|
452
454
|
|
|
453
455
|
Args:
|
|
454
456
|
table: SQLAlchemy Table object to query
|
|
457
|
+
values_fields: When given (values()/values_list() mode), select exactly
|
|
458
|
+
these fields in order. Each name must resolve to a table column,
|
|
459
|
+
an annotation alias, or an extra column alias.
|
|
455
460
|
|
|
456
461
|
Returns:
|
|
457
462
|
SQLAlchemy Select object ready for execution
|
|
@@ -479,8 +484,34 @@ class QueryBuilder:
|
|
|
479
484
|
# Collect all columns to select (base table + related tables)
|
|
480
485
|
columns_to_select = []
|
|
481
486
|
|
|
487
|
+
if values_fields is not None:
|
|
488
|
+
# values()/values_list() mode: select exactly the requested fields in
|
|
489
|
+
# order so result rows align with the field names positionally
|
|
490
|
+
unknown = [
|
|
491
|
+
f
|
|
492
|
+
for f in values_fields
|
|
493
|
+
if f not in table.c and f not in self.annotations and f not in self.extra_columns
|
|
494
|
+
]
|
|
495
|
+
if unknown:
|
|
496
|
+
raise QueryError(
|
|
497
|
+
f"Unknown field(s) in values()/values_list(): {unknown}. "
|
|
498
|
+
"Each field must be a model column, an annotation alias, or an extra() column alias."
|
|
499
|
+
)
|
|
500
|
+
for field_name in values_fields:
|
|
501
|
+
if field_name in table.c:
|
|
502
|
+
columns_to_select.append(table.c[field_name])
|
|
503
|
+
elif field_name in self.annotations:
|
|
504
|
+
expr = self.annotations[field_name]
|
|
505
|
+
resolved = expr.resolve(table) if hasattr(expr, "resolve") else expr
|
|
506
|
+
columns_to_select.append(resolved.label(field_name))
|
|
507
|
+
else:
|
|
508
|
+
sql = self.extra_columns[field_name]
|
|
509
|
+
if self.extra_params:
|
|
510
|
+
columns_to_select.append(text(sql).bindparams(**self.extra_params).label(field_name))
|
|
511
|
+
else:
|
|
512
|
+
columns_to_select.append(text(sql).label(field_name))
|
|
482
513
|
# Handle field selection (only() method)
|
|
483
|
-
|
|
514
|
+
elif self.selected_fields:
|
|
484
515
|
columns_to_select.extend([table.c[field] for field in self.selected_fields if field in table.c])
|
|
485
516
|
elif self.deferred_fields or auto_deferred_fields:
|
|
486
517
|
# For defer() or auto-deferred fields, select all fields except deferred ones
|
|
@@ -492,7 +523,7 @@ class QueryBuilder:
|
|
|
492
523
|
columns_to_select.extend(table.c)
|
|
493
524
|
|
|
494
525
|
# Add related table columns for select_related (only for select_related, not prefetch_related)
|
|
495
|
-
if self.relationships:
|
|
526
|
+
if self.relationships and values_fields is None:
|
|
496
527
|
related_columns = self._get_select_related_columns(table)
|
|
497
528
|
columns_to_select.extend(related_columns)
|
|
498
529
|
|
|
@@ -554,8 +585,8 @@ class QueryBuilder:
|
|
|
554
585
|
else:
|
|
555
586
|
query = query.distinct()
|
|
556
587
|
|
|
557
|
-
# Apply annotations
|
|
558
|
-
if self.annotations:
|
|
588
|
+
# Apply annotations (values mode already selected the requested ones)
|
|
589
|
+
if self.annotations and values_fields is None:
|
|
559
590
|
annotation_columns = []
|
|
560
591
|
for alias, expr in self.annotations.items():
|
|
561
592
|
if hasattr(expr, "resolve"):
|
|
@@ -564,8 +595,8 @@ class QueryBuilder:
|
|
|
564
595
|
annotation_columns.append(expr.label(alias))
|
|
565
596
|
query = query.add_columns(*annotation_columns)
|
|
566
597
|
|
|
567
|
-
# Apply extra columns
|
|
568
|
-
if self.extra_columns:
|
|
598
|
+
# Apply extra columns (values mode already selected the requested ones)
|
|
599
|
+
if self.extra_columns and values_fields is None:
|
|
569
600
|
extra_cols = []
|
|
570
601
|
for alias, sql in self.extra_columns.items():
|
|
571
602
|
if self.extra_params:
|
|
@@ -577,34 +608,48 @@ class QueryBuilder:
|
|
|
577
608
|
# Apply group by
|
|
578
609
|
if self.group_clauses:
|
|
579
610
|
group_columns = []
|
|
611
|
+
group_names = set()
|
|
580
612
|
for field in self.group_clauses:
|
|
581
613
|
if isinstance(field, str) and field in table.c:
|
|
582
614
|
group_columns.append(table.c[field])
|
|
615
|
+
group_names.add(field)
|
|
583
616
|
elif hasattr(field, "resolve") and not isinstance(field, str):
|
|
584
|
-
|
|
617
|
+
resolved = field.resolve(table)
|
|
618
|
+
group_columns.append(resolved)
|
|
619
|
+
if getattr(resolved, "name", None):
|
|
620
|
+
group_names.add(resolved.name)
|
|
585
621
|
else:
|
|
586
622
|
group_columns.append(field)
|
|
587
|
-
|
|
588
|
-
|
|
623
|
+
field_name = getattr(field, "name", None)
|
|
624
|
+
if field_name:
|
|
625
|
+
group_names.add(field_name)
|
|
626
|
+
|
|
627
|
+
# Grouped aggregation must not select columns outside GROUP BY.
|
|
628
|
+
# Previous versions silently added the selected columns to GROUP BY
|
|
629
|
+
# "for PostgreSQL compatibility", which degenerated every group to a
|
|
630
|
+
# single row and produced wrong aggregate values. Grouping by the
|
|
631
|
+
# full primary key is exempt: every column of the table is
|
|
632
|
+
# functionally dependent on it, so one group == one model row.
|
|
589
633
|
if self.annotations:
|
|
590
|
-
|
|
591
|
-
if
|
|
592
|
-
for
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
elif not self.deferred_fields:
|
|
596
|
-
# If no specific fields selected and no deferred fields, add all columns
|
|
597
|
-
for column in table.c:
|
|
598
|
-
if column not in group_columns:
|
|
599
|
-
group_columns.append(column)
|
|
634
|
+
pk_names = {col.name for col in table.primary_key.columns}
|
|
635
|
+
if values_fields is not None:
|
|
636
|
+
selected_names = {f for f in values_fields if f in table.c}
|
|
637
|
+
elif self.selected_fields:
|
|
638
|
+
selected_names = {f for f in self.selected_fields if f in table.c}
|
|
600
639
|
else:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
640
|
+
selected_names = set(table.columns.keys()) - self.deferred_fields - auto_deferred_fields
|
|
641
|
+
|
|
642
|
+
grouped_by_pk = bool(pk_names) and pk_names <= group_names
|
|
643
|
+
extra_selected = selected_names - group_names
|
|
644
|
+
if not grouped_by_pk and extra_selected:
|
|
645
|
+
raise QueryError(
|
|
646
|
+
f"column(s) {sorted(extra_selected)} are selected but not in GROUP BY. "
|
|
647
|
+
"SQLObjects no longer adds selected columns to GROUP BY silently — "
|
|
648
|
+
"that would collapse each group to a single row and corrupt aggregates. "
|
|
649
|
+
"Use .values(*group_fields, *aggregate_aliases) for aggregation rows, "
|
|
650
|
+
".only(*group_fields) to hydrate partial instances, "
|
|
651
|
+
"or group by the primary key to aggregate per model row."
|
|
652
|
+
)
|
|
608
653
|
|
|
609
654
|
query = query.group_by(*group_columns)
|
|
610
655
|
|
sqlobjects/queries/executor.py
CHANGED
|
@@ -191,6 +191,10 @@ class QueryExecutor:
|
|
|
191
191
|
delete_query = delete_query.where(query.whereclause)
|
|
192
192
|
return delete_query
|
|
193
193
|
elif query_type in ("values", "values_list"):
|
|
194
|
+
if kwargs.get("prebuilt"):
|
|
195
|
+
# Query was already built with the exact requested fields
|
|
196
|
+
# (including annotations and GROUP BY) — execute as-is
|
|
197
|
+
return query
|
|
194
198
|
fields = kwargs.get("fields", [])
|
|
195
199
|
if fields:
|
|
196
200
|
table = from_table
|
sqlobjects/queryset.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from collections.abc import AsyncGenerator
|
|
3
3
|
from datetime import date, datetime
|
|
4
|
-
from typing import Any, Generic, Literal, TypeVar, Union
|
|
4
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar, Union
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import (
|
|
7
7
|
BinaryExpression,
|
|
@@ -237,6 +237,30 @@ class QuerySet(Generic[T]):
|
|
|
237
237
|
ordering = getattr(self._model_class, "_default_ordering", [])
|
|
238
238
|
self._builder = self._builder.add_ordering(*ordering)
|
|
239
239
|
|
|
240
|
+
# Manager-only method names mapped to their QuerySet equivalent (None if no equivalent)
|
|
241
|
+
_MANAGER_ONLY_METHODS: ClassVar[dict[str, str | None]] = {
|
|
242
|
+
"delete_all": "delete()",
|
|
243
|
+
"update_all": "update(**values)",
|
|
244
|
+
"bulk_create": None,
|
|
245
|
+
"bulk_update": None,
|
|
246
|
+
"bulk_delete": None,
|
|
247
|
+
"create": None,
|
|
248
|
+
"get_or_create": None,
|
|
249
|
+
"update_or_create": None,
|
|
250
|
+
"in_bulk": None,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if not TYPE_CHECKING:
|
|
254
|
+
# Hidden from type checkers so unknown attributes still fail static
|
|
255
|
+
# analysis; at runtime it turns bare AttributeErrors on manager-only
|
|
256
|
+
# methods into a hint pointing at the correct API
|
|
257
|
+
def __getattr__(self, name: str):
|
|
258
|
+
if name in self._MANAGER_ONLY_METHODS:
|
|
259
|
+
equivalent = self._MANAGER_ONLY_METHODS[name]
|
|
260
|
+
hint = f"; for a filtered queryset use .{equivalent}" if equivalent else ""
|
|
261
|
+
raise AttributeError(f"'{name}' is defined on Model.objects (manager), not on QuerySet{hint}")
|
|
262
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
263
|
+
|
|
240
264
|
@staticmethod
|
|
241
265
|
def _get_field_name(field) -> str:
|
|
242
266
|
"""Extract field name from various field types.
|
|
@@ -762,7 +786,7 @@ class QuerySet(Generic[T]):
|
|
|
762
786
|
hierarchy = base.union_all(recursive_part)
|
|
763
787
|
all_employees = await Employee.objects.with_cte(
|
|
764
788
|
hierarchy
|
|
765
|
-
).
|
|
789
|
+
).all()
|
|
766
790
|
"""
|
|
767
791
|
return CTEExpression(self, name, recursive)
|
|
768
792
|
|
|
@@ -799,20 +823,35 @@ class QuerySet(Generic[T]):
|
|
|
799
823
|
def aggregate(self, **kwargs) -> AggregateExpression:
|
|
800
824
|
"""Create aggregation expression that can be executed or used as subquery.
|
|
801
825
|
|
|
826
|
+
Returns a single row aggregated over all matching rows. Incompatible
|
|
827
|
+
with group_by() — for per-group aggregation use
|
|
828
|
+
``annotate(...).group_by(...).values(*group_fields, *aliases)``.
|
|
829
|
+
|
|
802
830
|
Args:
|
|
803
831
|
**kwargs: Aggregation expressions with aliases
|
|
804
832
|
|
|
805
833
|
Returns:
|
|
806
834
|
AggregateExpression that can be awaited or used in comparisons
|
|
807
835
|
|
|
836
|
+
Raises:
|
|
837
|
+
QueryError: If the queryset has GROUP BY clauses
|
|
838
|
+
|
|
808
839
|
Examples:
|
|
809
840
|
# Direct execution
|
|
810
841
|
stats = await User.objects.aggregate(avg_age=User.age.avg())
|
|
811
842
|
|
|
812
843
|
# Use as subquery condition
|
|
813
|
-
avg_age = User.objects.aggregate(User.age.avg())
|
|
844
|
+
avg_age = User.objects.aggregate(avg_age=User.age.avg())
|
|
814
845
|
older_users = await User.objects.filter(User.age > avg_age).all()
|
|
815
846
|
"""
|
|
847
|
+
if self._builder.group_clauses:
|
|
848
|
+
from .exceptions import QueryError
|
|
849
|
+
|
|
850
|
+
raise QueryError(
|
|
851
|
+
"aggregate() returns a single row and ignores GROUP BY. "
|
|
852
|
+
"Use .annotate(...).group_by(...).values(*group_fields, *aggregate_aliases) "
|
|
853
|
+
"for per-group aggregation."
|
|
854
|
+
)
|
|
816
855
|
return AggregateExpression(self._builder, kwargs, self._executor)
|
|
817
856
|
|
|
818
857
|
def count(self) -> CountExpression:
|
|
@@ -1199,19 +1238,6 @@ class QuerySet(Generic[T]):
|
|
|
1199
1238
|
# Data Operations Methods - Create, update, and delete data
|
|
1200
1239
|
# ========================================
|
|
1201
1240
|
|
|
1202
|
-
async def create(self, validate: bool = True, **kwargs) -> T:
|
|
1203
|
-
"""Create new object with given field values."""
|
|
1204
|
-
# Create instance for validation
|
|
1205
|
-
instance = self._model_class.from_dict(kwargs, validate=validate) # type: ignore[reportAttributeAccessIssue]
|
|
1206
|
-
if validate and hasattr(instance, "validate_all"):
|
|
1207
|
-
validate_method = getattr(instance, "validate_all", None)
|
|
1208
|
-
if validate_method:
|
|
1209
|
-
validate_method()
|
|
1210
|
-
|
|
1211
|
-
# Actual insertion would be implemented here
|
|
1212
|
-
# For now, return the created instance (simplified)
|
|
1213
|
-
return instance
|
|
1214
|
-
|
|
1215
1241
|
@emit_signals(Operation.UPDATE, is_bulk=True)
|
|
1216
1242
|
async def update(self, **values) -> int:
|
|
1217
1243
|
"""Perform bulk update on objects matching query conditions."""
|
sqlobjects/session.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import contextvars
|
|
2
|
+
import logging
|
|
2
3
|
from collections.abc import AsyncGenerator
|
|
3
4
|
from contextlib import asynccontextmanager
|
|
4
5
|
from typing import Any
|
|
@@ -13,6 +14,8 @@ from .exceptions import convert_sqlalchemy_error
|
|
|
13
14
|
|
|
14
15
|
__all__ = ["AsyncSession", "ctx_session", "ctx_sessions", "get_session", "has_session"]
|
|
15
16
|
|
|
17
|
+
logger = logging.getLogger("sqlobjects.session")
|
|
18
|
+
|
|
16
19
|
# Explicit session management (highest priority)
|
|
17
20
|
_explicit_sessions: contextvars.ContextVar[dict[str, "AsyncSession"]] = contextvars.ContextVar("explicit_sessions")
|
|
18
21
|
|
|
@@ -298,7 +301,7 @@ class _SessionContextManager:
|
|
|
298
301
|
|
|
299
302
|
|
|
300
303
|
@asynccontextmanager
|
|
301
|
-
async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession, None]:
|
|
304
|
+
async def ctx_session(db_name: str | None = None, *, join_ambient: bool = False) -> AsyncGenerator[AsyncSession, None]:
|
|
302
305
|
"""Get async context manager for single database transactional session.
|
|
303
306
|
|
|
304
307
|
Creates a transactional session with manual commit control (auto_commit=False).
|
|
@@ -306,11 +309,31 @@ async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession
|
|
|
306
309
|
|
|
307
310
|
Args:
|
|
308
311
|
db_name: Database name (uses default database if None)
|
|
312
|
+
join_ambient: If True and an explicit session already exists in the current
|
|
313
|
+
context, reuse it instead of creating a new one. The ambient session's
|
|
314
|
+
lifecycle (commit/rollback/close) stays with its outer owner; exceptions
|
|
315
|
+
propagate to the owner for rollback. This avoids a second physical
|
|
316
|
+
connection whose row locks would deadlock against the outer transaction
|
|
317
|
+
in a way the database deadlock detector cannot see.
|
|
309
318
|
|
|
310
319
|
Yields:
|
|
311
320
|
AsyncSession: Transactional session with manual commit control
|
|
312
321
|
"""
|
|
313
322
|
name = db_name or get_default()
|
|
323
|
+
|
|
324
|
+
if join_ambient and has_session(name):
|
|
325
|
+
yield get_session(name, readonly=False)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
if has_session(name):
|
|
329
|
+
logger.warning(
|
|
330
|
+
"ctx_session(%r) is creating a new session while an ambient session already exists "
|
|
331
|
+
"in this context. The two sessions use separate physical connections; writes to rows "
|
|
332
|
+
"locked by the outer transaction will block undetectably. "
|
|
333
|
+
"Pass join_ambient=True to reuse the ambient session.",
|
|
334
|
+
name,
|
|
335
|
+
)
|
|
336
|
+
|
|
314
337
|
session = AsyncSession(name, readonly=False, auto_commit=False)
|
|
315
338
|
|
|
316
339
|
# Set as explicit session in context, save token for nested restore
|