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.
Files changed (26) hide show
  1. sqlobjects/__init__.py +1 -1
  2. sqlobjects/_install_rules.py +18 -2
  3. sqlobjects/exceptions.py +18 -0
  4. sqlobjects/expressions/cte.py +1 -1
  5. sqlobjects/expressions/terminal.py +8 -14
  6. sqlobjects/mixins.py +14 -0
  7. sqlobjects/objects/bulk.py +26 -4
  8. sqlobjects/py.typed +0 -0
  9. sqlobjects/queries/builder.py +72 -27
  10. sqlobjects/queries/executor.py +4 -0
  11. sqlobjects/queryset.py +42 -16
  12. sqlobjects/session.py +24 -1
  13. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/01-database-session-guide.md +77 -9
  14. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/02-model-definition-guide.md +35 -2
  15. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/03-query-operations-guide.md +25 -10
  16. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/04-crud-operations-guide.md +125 -7
  17. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/05-relationships-guide.md +96 -6
  18. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/06-validation-signals-guide.md +9 -9
  19. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/07-performance-guide.md +7 -6
  20. {sqlobjects-1.9.1.data → sqlobjects-2.0.0.data}/data/share/sqlobjects/rules/README.md +34 -3
  21. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/METADATA +12 -5
  22. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/RECORD +26 -25
  23. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/WHEEL +1 -1
  24. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/entry_points.txt +0 -0
  25. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/licenses/LICENSE +0 -0
  26. {sqlobjects-1.9.1.dist-info → sqlobjects-2.0.0.dist-info}/top_level.txt +0 -0
sqlobjects/__init__.py CHANGED
@@ -19,7 +19,7 @@ from .queryset import Q, QuerySet
19
19
  from .sql_logging import ObjectLogger, get_caller_frame
20
20
 
21
21
 
22
- __version__ = "0.3.0"
22
+ __version__ = "2.0.0"
23
23
 
24
24
  __all__ = [
25
25
  # Core classes
@@ -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
- shutil.copy2(file, target_dir / file.name)
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
 
@@ -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).select_from(hierarchy).all()
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._builder.build(self._builder.model_class.get_table())
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
- table = self._builder.model_class.get_table()
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._builder.build(self._builder.model_class.get_table())
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._builder.build(self._builder.model_class.get_table())
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
 
@@ -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 total count for len() support."""
115
- return self.total_count
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
- stmt_with_returning = stmt.returning(*return_columns)
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
@@ -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
- if self.selected_fields:
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
- group_columns.append(field.resolve(table))
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
- # For PostgreSQL compatibility, include all non-aggregated columns in GROUP BY
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
- # Add all base table columns that are being selected
591
- if self.selected_fields:
592
- for field in self.selected_fields:
593
- if field in table.c and table.c[field] not in group_columns:
594
- group_columns.append(table.c[field])
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
- # Add non-deferred columns
602
- all_fields = set(table.columns.keys())
603
- combined_deferred = self.deferred_fields | auto_deferred_fields
604
- selected_fields = all_fields - combined_deferred
605
- for field in selected_fields:
606
- if field in table.c and table.c[field] not in group_columns:
607
- group_columns.append(table.c[field])
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
 
@@ -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
- ).select_from(hierarchy).all()
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