sqlobjects 1.0.4__tar.gz → 1.0.6__tar.gz

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 (57) hide show
  1. {sqlobjects-1.0.4/sqlobjects.egg-info → sqlobjects-1.0.6}/PKG-INFO +1 -1
  2. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/pyproject.toml +1 -1
  3. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/cascade.py +321 -76
  4. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/functions.py +1 -1
  5. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/__init__.py +5 -1
  6. sqlobjects-1.0.6/sqlobjects/fields/relations/prefetch.py +241 -0
  7. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/utils.py +138 -0
  8. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/metadata.py +15 -12
  9. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/mixins.py +167 -173
  10. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/model.py +64 -179
  11. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/core.py +9 -6
  12. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/builder.py +15 -3
  13. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/executor.py +16 -43
  14. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queryset.py +38 -205
  15. {sqlobjects-1.0.4 → sqlobjects-1.0.6/sqlobjects.egg-info}/PKG-INFO +1 -1
  16. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/SOURCES.txt +1 -0
  17. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/LICENSE +0 -0
  18. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/README.md +0 -0
  19. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/setup.cfg +0 -0
  20. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/__init__.py +0 -0
  21. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/__init__.py +0 -0
  22. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/config.py +0 -0
  23. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/manager.py +0 -0
  24. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/exceptions.py +0 -0
  25. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/__init__.py +0 -0
  26. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/aggregate.py +0 -0
  27. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/base.py +0 -0
  28. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/function.py +0 -0
  29. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/mixins.py +0 -0
  30. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/scalar.py +0 -0
  31. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/subquery.py +0 -0
  32. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/terminal.py +0 -0
  33. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/__init__.py +0 -0
  34. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/core.py +0 -0
  35. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/proxies.py +0 -0
  36. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/descriptors.py +0 -0
  37. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/managers.py +0 -0
  38. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/proxies.py +0 -0
  39. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/shortcuts.py +0 -0
  40. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/__init__.py +0 -0
  41. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/base.py +0 -0
  42. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/comparators.py +0 -0
  43. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/registry.py +0 -0
  44. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/utils.py +0 -0
  45. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/__init__.py +0 -0
  46. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/bulk.py +0 -0
  47. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/__init__.py +0 -0
  48. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/session.py +0 -0
  49. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/signals.py +0 -0
  50. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/__init__.py +0 -0
  51. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/inspect.py +0 -0
  52. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/naming.py +0 -0
  53. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/pattern.py +0 -0
  54. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/validators.py +0 -0
  55. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/dependency_links.txt +0 -0
  56. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/requires.txt +0 -0
  57. {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
5
5
  Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
6
6
  Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlobjects"
3
- version = "1.0.4"
3
+ version = "1.0.6"
4
4
  description = "Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -76,10 +76,10 @@ OnUpdateType = Union[OnUpdate, Literal["CASCADE", "SET NULL", "RESTRICT", "NO AC
76
76
  CascadeType = Union[CascadeOption, set[CascadeOption], str, None] # noqa: UP007
77
77
 
78
78
 
79
- def normalize_ondelete(ondelete: OnDeleteType) -> str | None:
79
+ def normalize_ondelete(ondelete: OnDeleteType) -> str:
80
80
  """Normalize ondelete parameter to SQLAlchemy string format."""
81
81
  if ondelete is None:
82
- return "NO ACTION" # Default value
82
+ return "NO ACTION"
83
83
  if isinstance(ondelete, OnDelete):
84
84
  return ondelete.value
85
85
  if isinstance(ondelete, str):
@@ -89,13 +89,14 @@ def normalize_ondelete(ondelete: OnDeleteType) -> str | None:
89
89
  return ondelete_upper
90
90
  raise ValueError(f"Invalid ondelete value: {ondelete}. Must be one of {valid_values}")
91
91
 
92
+ # This should never be reached due to type constraints, but kept for safety
92
93
  raise TypeError(f"ondelete must be OnDelete enum or string, got {type(ondelete)}")
93
94
 
94
95
 
95
- def normalize_onupdate(onupdate: OnUpdateType) -> str | None:
96
+ def normalize_onupdate(onupdate: OnUpdateType) -> str:
96
97
  """Normalize onupdate parameter to SQLAlchemy string format."""
97
98
  if onupdate is None:
98
- return "NO ACTION" # Default value
99
+ return "NO ACTION"
99
100
  if isinstance(onupdate, OnUpdate):
100
101
  return onupdate.value
101
102
  if isinstance(onupdate, str):
@@ -105,6 +106,7 @@ def normalize_onupdate(onupdate: OnUpdateType) -> str | None:
105
106
  return onupdate_upper
106
107
  raise ValueError(f"Invalid onupdate value: {onupdate}. Must be one of {valid_values}")
107
108
 
109
+ # This should never be reached due to type constraints, but kept for safety
108
110
  raise TypeError(f"onupdate must be OnUpdate enum or string, got {type(onupdate)}")
109
111
 
110
112
 
@@ -303,6 +305,53 @@ class CascadeExecutor:
303
305
  def __init__(self):
304
306
  self.resolver = DependencyResolver()
305
307
 
308
+ async def execute_save_operation(
309
+ self, instance: "ObjectModel", validate: bool = True, session: "AsyncSession | None" = None
310
+ ) -> "ObjectModel":
311
+ """Execute save operation with cascade handling."""
312
+ if session is None:
313
+ session = get_session()
314
+
315
+ # Save root instance first to get primary key
316
+ await instance._save_internal(validate=validate, session=session) # noqa
317
+
318
+ # Process cascade relationships if needed
319
+ if hasattr(instance, "_state_manager"):
320
+ cascade_relationships = instance._state_manager.get_cascade_relationships() # noqa
321
+ if cascade_relationships:
322
+ await self._process_cascade_relationships(instance, session)
323
+
324
+ return instance
325
+
326
+ async def execute_delete_operation(
327
+ self, target, cascade_strategy: str = "full", session: "AsyncSession | None" = None
328
+ ) -> int:
329
+ """Execute delete operation with cascade handling."""
330
+ if session is None:
331
+ session = get_session()
332
+
333
+ # Handle QuerySet deletion
334
+ if hasattr(target, "_table") and hasattr(target, "_model_class"):
335
+ return await self._execute_queryset_delete(target, cascade_strategy, session)
336
+
337
+ # Handle single instance deletion
338
+ await self._delete_related_objects(target, session)
339
+ await target._delete_internal(session=session) # noqa
340
+ return 1
341
+
342
+ @staticmethod
343
+ async def execute_update_operation(queryset, values: dict, session: "AsyncSession | None" = None) -> int:
344
+ """Execute update operation with cascade handling."""
345
+ # Use the provided session or get from queryset's executor
346
+ if session is not None:
347
+ # Create a new queryset with the specified session
348
+ queryset = queryset.using(session)
349
+
350
+ # Execute the update operation
351
+ query = queryset._builder.build(queryset._table) # noqa
352
+ result = await queryset._executor.execute(query, "update", values=values) # noqa
353
+ return result if isinstance(result, int) else 0
354
+
306
355
  @staticmethod
307
356
  async def cascade_save_optimized(instances: list["ObjectModel"], session: "AsyncSession | None" = None) -> None:
308
357
  """Optimized cascade save using bulk operations."""
@@ -407,94 +456,310 @@ class CascadeExecutor:
407
456
  setattr(instance, field, value)
408
457
  await instance.using(session).save(cascade=False)
409
458
 
410
- async def execute_cascade_operation(
411
- self, root_instance: "ObjectModel", operation: str, session: "AsyncSession | None" = None, **kwargs: Any
459
+ async def _execute_queryset_delete(self, queryset, cascade_strategy: str, session: "AsyncSession") -> int:
460
+ """Execute QuerySet delete with different cascade strategies."""
461
+ if cascade_strategy == "full":
462
+ return await self._delete_with_full_cascade(queryset, session)
463
+ elif cascade_strategy == "fast":
464
+ return await self._delete_with_fast_cascade(queryset, session)
465
+ else: # "none"
466
+ return await self._delete_with_no_cascade(queryset, session)
467
+
468
+ @staticmethod
469
+ async def _delete_with_full_cascade(queryset, session: "AsyncSession") -> int:
470
+ """Execute complete cascade deletion with full ORM functionality."""
471
+ total_count = await queryset.count()
472
+ if total_count == 0:
473
+ return 0
474
+
475
+ batch_size = 50 if total_count > 100 else total_count
476
+ deleted_count = 0
477
+ offset = 0
478
+
479
+ while True:
480
+ batch = await queryset.offset(offset).limit(batch_size).all()
481
+ if not batch:
482
+ break
483
+
484
+ for instance in batch:
485
+ await instance.using(session).delete(cascade=False)
486
+ deleted_count += 1
487
+
488
+ offset += batch_size
489
+
490
+ return deleted_count
491
+
492
+ async def _delete_with_fast_cascade(self, queryset, session: "AsyncSession") -> int:
493
+ """Execute fast cascade deletion with minimal ORM processing."""
494
+ # Get all referenced field values
495
+ referenced_values = await self._get_referenced_field_values(queryset)
496
+ if not any(referenced_values.values()):
497
+ return 0
498
+
499
+ # Process necessary foreign key relationships
500
+ await self._process_fast_cascade_relationships(queryset, referenced_values, session)
501
+
502
+ # Execute bulk delete
503
+ query = queryset._builder.build(queryset._table) # noqa
504
+ result = await queryset._executor.execute(query, "delete") # noqa
505
+ return result if isinstance(result, int) else 0
506
+
507
+ async def _delete_with_no_cascade(self, queryset, session: "AsyncSession") -> int:
508
+ """Execute direct SQL deletion without ORM cascade processing."""
509
+ query = queryset._builder.build(queryset._table) # noqa
510
+ result = await queryset._executor.execute(query, "delete") # noqa
511
+ return result if isinstance(result, int) else 0
512
+
513
+ async def _get_referenced_field_values(self, queryset) -> dict[str, list]:
514
+ """Get all referenced field values for cascade deletion."""
515
+ relationships = self._find_referencing_relationships(queryset._table) # noqa
516
+
517
+ # Find all referenced fields
518
+ referenced_fields = set()
519
+ for _, _, referenced_column in relationships:
520
+ referenced_fields.add(referenced_column)
521
+
522
+ # Get values for each referenced field
523
+ field_values = {}
524
+ for field in referenced_fields:
525
+ try:
526
+ values = await queryset.values_list(field, flat=True)
527
+ field_values[field] = values
528
+ except Exception: # noqa
529
+ field_values[field] = []
530
+
531
+ return field_values
532
+
533
+ def _find_referencing_relationships(self, table) -> list[tuple]:
534
+ """Find all foreign key relationships that reference this table."""
535
+ relationships = []
536
+
537
+ if hasattr(table, "metadata"):
538
+ for ref_table in table.metadata.tables.values():
539
+ for column in ref_table.columns:
540
+ for fk in column.foreign_keys:
541
+ if fk.column.table == table:
542
+ relationships.append((ref_table, column.name, fk.column.name))
543
+
544
+ return relationships
545
+
546
+ async def _process_fast_cascade_relationships(
547
+ self, queryset, referenced_values: dict[str, list], session: "AsyncSession"
412
548
  ) -> None:
413
- """Execute a cascade operation starting from a root instance."""
414
- if session is None:
415
- session = get_session()
549
+ """Process necessary foreign key relationships for fast cascade."""
550
+ relationships = self._find_referencing_relationships(queryset._table)
416
551
 
417
- if operation == "save":
418
- await self._cascade_save_with_relationships(root_instance, session)
419
- elif operation == "delete":
420
- await self._cascade_delete_with_relationships(root_instance, session)
421
- elif operation == "update":
422
- update_data = kwargs.get("update_data", {})
423
- instances_to_process = self._collect_cascade_instances(root_instance, operation)
424
- await self.cascade_update(instances_to_process, update_data, session)
425
- else:
426
- raise ValueError(f"Unsupported cascade operation: {operation}")
552
+ for ref_table, fk_column, referenced_column in relationships:
553
+ values = referenced_values.get(referenced_column, [])
554
+ if values:
555
+ await self._cascade_delete_related_records(ref_table, fk_column, values, queryset, session)
427
556
 
428
- @staticmethod
429
- async def _process_cascade_relationships(root_instance: "ObjectModel", session: "AsyncSession") -> None:
430
- """Process cascade relationships for an instance."""
431
- if not hasattr(root_instance, "_state_manager"):
557
+ async def _cascade_delete_related_records(
558
+ self, ref_table, fk_column: str, values: list, original_queryset, session: "AsyncSession"
559
+ ) -> None:
560
+ """Delete related records in referencing table using ORM methods."""
561
+ if not values:
432
562
  return
433
563
 
434
- cascade_relationships = root_instance._state_manager.get("cascade_relationships", {}) # noqa
564
+ # Find the model class for the referencing table
565
+ related_model_class = self._find_model_class_for_table(ref_table, original_queryset)
566
+ if not related_model_class:
567
+ return
568
+
569
+ # Create QuerySet for the related model and delete using ORM
570
+ from .queryset import QuerySet
571
+
572
+ related_queryset = QuerySet(ref_table, related_model_class, session)
573
+
574
+ # Build filter condition for foreign key values
575
+ fk_column_obj = ref_table.c[fk_column]
576
+ filter_condition = fk_column_obj.in_(values)
577
+
578
+ # Use ORM delete with cascade=none to avoid infinite recursion
579
+ await related_queryset.filter(filter_condition).delete(cascade="none")
580
+
581
+ def _find_model_class_for_table(self, table, original_queryset):
582
+ """Find the model class associated with a table from registry."""
583
+ # Get registry from model class and use native method
584
+ if hasattr(original_queryset._model_class, "__registry__"):
585
+ registry = original_queryset._model_class.__registry__
586
+ return registry.get_model_by_table(table.name)
587
+ return None
588
+
589
+ async def _process_cascade_relationships(self, root_instance: "ObjectModel", session: "AsyncSession") -> None:
590
+ """Process cascade relationships for an instance."""
591
+ cascade_relationships = root_instance._state_manager.get_cascade_relationships()
435
592
  if not cascade_relationships:
436
593
  return
437
594
 
438
- # Process each relationship
439
- for _, related_objects in cascade_relationships.items():
440
- for related_obj in related_objects:
441
- if hasattr(related_obj, "save"):
442
- # Set foreign key relationship
443
- ForeignKeyInferrer.set_foreign_key(root_instance, related_obj)
444
- # Save related object
445
- await related_obj.using(session).save(cascade=False)
595
+ # Process each relationship with full update logic
596
+ for rel_name, new_related_objects in cascade_relationships.items():
597
+ await self._process_relationship_update(root_instance, rel_name, new_related_objects, session)
446
598
 
447
599
  # Clear cascade state
448
- root_instance._state_manager.set("cascade_relationships", {}) # noqa
449
- root_instance._state_manager.set("needs_cascade_save", False) # noqa
600
+ for rel_name in list(cascade_relationships.keys()):
601
+ root_instance._state_manager.clear_cache_entry(rel_name)
602
+ root_instance._state_manager.clear_cascade_save_flag()
603
+
604
+ async def _process_relationship_update(
605
+ self, root_instance: "ObjectModel", rel_name: str, new_related_objects, session: "AsyncSession"
606
+ ) -> None:
607
+ """Process complete relationship update: add, remove, modify."""
608
+ # Get relationship configuration
609
+ relationships = getattr(root_instance.__class__, "_relationships", {})
610
+ if rel_name not in relationships:
611
+ return
612
+
613
+ rel_descriptor = relationships[rel_name]
614
+ if not (hasattr(rel_descriptor, "property") and hasattr(rel_descriptor.property, "cascade")):
615
+ return
616
+
617
+ cascade_str = rel_descriptor.property.cascade or ""
618
+ has_delete_orphan = "delete-orphan" in cascade_str
450
619
 
451
- async def _cascade_save_with_relationships(self, root_instance: "ObjectModel", session: "AsyncSession") -> None:
452
- """Handle cascade save with automatic foreign key setup."""
453
- # Step 1: Save root instance to get primary key
454
- await root_instance.using(session).save(cascade=False)
620
+ # Get current related objects from database
621
+ current_objects = await self._fetch_current_related_objects(root_instance, rel_name, session)
455
622
 
456
- # Step 2: Process relationship attributes and set foreign keys
457
- await self._process_relationship_attributes(root_instance, session)
623
+ # Convert to lists for processing
624
+ if new_related_objects is None:
625
+ new_objects = []
626
+ elif isinstance(new_related_objects, list):
627
+ new_objects = new_related_objects
628
+ else:
629
+ new_objects = [new_related_objects]
458
630
 
459
- async def _cascade_delete_with_relationships(self, root_instance: "ObjectModel", session: "AsyncSession") -> None:
460
- """Handle cascade delete with proper relationship handling."""
461
- # Step 1: Collect and delete related objects first (reverse dependency order)
462
- await self._delete_related_objects(root_instance, session)
631
+ # Process updates
632
+ await self._update_relationship_objects(current_objects, new_objects, has_delete_orphan, session)
463
633
 
464
- # Step 2: Delete the root instance
465
- await root_instance.using(session).delete(cascade=False)
634
+ # Set foreign keys for new/updated objects
635
+ for obj in new_objects:
636
+ if hasattr(obj, "save"):
637
+ ForeignKeyInferrer.set_foreign_key(root_instance, obj)
638
+ await obj.using(session).save(cascade=False)
639
+
640
+ async def _fetch_current_related_objects(
641
+ self, root_instance: "ObjectModel", rel_name: str, session: "AsyncSession"
642
+ ) -> list:
643
+ """Fetch current related objects from database."""
644
+ relationships = getattr(root_instance.__class__, "_relationships", {})
645
+ if rel_name not in relationships:
646
+ return []
647
+
648
+ rel_descriptor = relationships[rel_name]
649
+ if not hasattr(rel_descriptor.property, "resolved_model") or not rel_descriptor.property.resolved_model:
650
+ return []
651
+
652
+ related_model = rel_descriptor.property.resolved_model
653
+ foreign_keys = rel_descriptor.property.foreign_keys
654
+
655
+ # For reverse relationships (one-to-many), foreign_keys will be None
656
+ # We need to infer the foreign key field name
657
+ if not foreign_keys:
658
+ # Try to get foreign key field from back_populates relationship
659
+ back_populates = rel_descriptor.property.back_populates
660
+ if back_populates and hasattr(related_model, back_populates):
661
+ back_attr = getattr(related_model, back_populates)
662
+ if hasattr(back_attr, "property") and hasattr(back_attr.property, "foreign_keys"):
663
+ back_fk = back_attr.property.foreign_keys
664
+ if back_fk:
665
+ fk_field = back_fk if isinstance(back_fk, str) else back_fk[0]
666
+ else:
667
+ # Fallback to convention
668
+ fk_field = f"{back_populates}_id"
669
+ else:
670
+ # Fallback to convention
671
+ fk_field = f"{root_instance.__class__.__name__.lower()}_id"
672
+ else:
673
+ # Fallback to convention
674
+ fk_field = f"{root_instance.__class__.__name__.lower()}_id"
675
+ else:
676
+ # For forward relationships, use the specified foreign key
677
+ fk_field = foreign_keys if isinstance(foreign_keys, str) else foreign_keys[0]
678
+
679
+ # Check if the foreign key field exists on the related model
680
+ if not hasattr(related_model, fk_field):
681
+ # Try alternative field names
682
+ alt_fields = [f"{root_instance.__class__.__name__.lower()}_id", "author_id", "user_id", "parent_id"]
683
+ for alt_field in alt_fields:
684
+ if hasattr(related_model, alt_field):
685
+ fk_field = alt_field
686
+ break
687
+ else:
688
+ return []
689
+
690
+ # Get primary key value
691
+ pk_value = getattr(root_instance, root_instance._get_primary_key_field())
692
+ if pk_value is None:
693
+ return []
694
+
695
+ current_objects = (
696
+ await related_model.objects.using(session).filter(getattr(related_model, fk_field) == pk_value).all()
697
+ )
698
+
699
+ return current_objects
700
+
701
+ async def _update_relationship_objects(
702
+ self, current_objects: list, new_objects: list, has_delete_orphan: bool, session: "AsyncSession"
703
+ ) -> None:
704
+ """Update relationship objects: handle add, remove, modify."""
705
+ # Create ID mappings
706
+ current_by_id = {getattr(obj, "id", None): obj for obj in current_objects if getattr(obj, "id", None)}
707
+ new_by_id = {getattr(obj, "id", None): obj for obj in new_objects if getattr(obj, "id", None)}
708
+
709
+ # Find objects to remove (orphans)
710
+ if has_delete_orphan:
711
+ for obj_id, obj in current_by_id.items():
712
+ if obj_id and obj_id not in new_by_id:
713
+ # This object is no longer in the relationship - delete it as orphan
714
+ await obj.using(session).delete(cascade=False)
715
+
716
+ # Process existing objects for updates
717
+ for obj in new_objects:
718
+ obj_id = getattr(obj, "id", None)
719
+ if obj_id and obj_id in current_by_id:
720
+ # This is an existing object - check if it needs updating
721
+ current_obj = current_by_id[obj_id]
722
+ if self._object_has_changes(obj, current_obj):
723
+ # Object has changes - it will be saved in the main loop
724
+ pass
725
+
726
+ @staticmethod
727
+ def _object_has_changes(new_obj, current_obj) -> bool:
728
+ """Check if object has changes by comparing field values."""
729
+ # Simple implementation - compare key fields
730
+ field_names = getattr(new_obj, "_get_field_names", lambda: [])() or []
731
+ for field_name in field_names:
732
+ if field_name.startswith("_"):
733
+ continue
734
+ new_value = getattr(new_obj, field_name, None)
735
+ current_value = getattr(current_obj, field_name, None)
736
+ if new_value != current_value:
737
+ return True
738
+ return False
466
739
 
467
740
  async def _delete_related_objects(self, root_instance: "ObjectModel", session: "AsyncSession") -> None:
468
741
  """Delete related objects based on cascade configuration."""
469
742
  relationships = getattr(root_instance.__class__, "_relationships", {})
470
- print(f"DEBUG: Found {len(relationships)} relationships for {root_instance.__class__.__name__}")
471
743
 
472
744
  for rel_name, rel_descriptor in relationships.items():
473
- print(f"DEBUG: Processing relationship {rel_name}")
474
745
  if not (hasattr(rel_descriptor, "property") and hasattr(rel_descriptor.property, "cascade")):
475
- print(f"DEBUG: No cascade property for {rel_name}")
476
746
  continue
477
747
 
478
748
  cascade_str = rel_descriptor.property.cascade
479
- print(f"DEBUG: Cascade string for {rel_name}: {cascade_str}")
480
749
  if not cascade_str:
481
750
  continue
482
751
 
483
752
  # Check if cascade string contains delete operations
484
753
  if "delete" not in cascade_str and "all" not in cascade_str:
485
- print(f"DEBUG: No delete cascade for {rel_name}")
486
754
  continue
487
755
 
488
- print(f"DEBUG: Will cascade delete for {rel_name}")
489
- # Get related objects from database (not from memory attributes)
756
+ # Get related objects from database
490
757
  related_objects = await self._fetch_related_objects(root_instance, rel_name, session)
491
- print(f"DEBUG: Found {len(related_objects)} related objects for {rel_name}")
492
758
 
493
759
  # Delete related objects
494
760
  for related_obj in related_objects:
495
761
  if hasattr(related_obj, "delete"):
496
- print(f"DEBUG: Deleting related object {related_obj}")
497
- await related_obj.using(session).delete(cascade=True) # Recursive cascade
762
+ await related_obj.using(session).delete(cascade=True)
498
763
 
499
764
  async def _fetch_related_objects(
500
765
  self, root_instance: "ObjectModel", rel_name: str, session: "AsyncSession"
@@ -538,26 +803,6 @@ class CascadeExecutor:
538
803
  return CascadeProfile
539
804
  return None
540
805
 
541
- async def _process_relationship_attributes(self, root_instance: "ObjectModel", session: "AsyncSession") -> None:
542
- """Process relationship attributes and cascade save related objects."""
543
- # Simple relationship mapping for common patterns
544
- relationship_mappings = self._get_relationship_mappings(root_instance.__class__.__name__)
545
-
546
- for attr_name, (_, fk_field) in relationship_mappings.items():
547
- related_objects = getattr(root_instance, attr_name, None)
548
- if related_objects is None:
549
- continue
550
-
551
- # Handle both single objects and collections
552
- if not isinstance(related_objects, list | tuple):
553
- related_objects = [related_objects]
554
-
555
- # Set foreign keys and save related objects
556
- for related_obj in related_objects:
557
- if hasattr(related_obj, fk_field) and hasattr(related_obj, "save"):
558
- setattr(related_obj, fk_field, root_instance.id)
559
- await related_obj.using(session).save(cascade=False)
560
-
561
806
  @staticmethod
562
807
  def _get_relationship_mappings(model_name: str) -> dict:
563
808
  """Get relationship mappings for a model class."""
@@ -3,7 +3,7 @@ from typing import Any
3
3
  from sqlalchemy import ForeignKey
4
4
  from sqlalchemy.sql.elements import ColumnElement
5
5
 
6
- from ..cascade import OnDeleteType, OnUpdateType, normalize_ondelete, normalize_onupdate # pyright: ignore
6
+ from ..cascade import OnDeleteType, OnUpdateType, normalize_ondelete, normalize_onupdate
7
7
  from .core import Column, column
8
8
  from .shortcuts import ComputedColumn, IdentityColumn
9
9
 
@@ -1,4 +1,5 @@
1
1
  from .descriptors import RelationshipDescriptor, RelationshipProperty, RelationshipType
2
+ from .prefetch import PrefetchHandler
2
3
  from .proxies import (
3
4
  BaseRelatedCollection,
4
5
  M2MRelatedCollection,
@@ -8,7 +9,7 @@ from .proxies import (
8
9
  RelatedObjectProxy,
9
10
  RelatedQuerySet,
10
11
  )
11
- from .utils import M2MTable, RelationshipResolver, relationship
12
+ from .utils import M2MTable, RelationshipAnalyzer, RelationshipResolver, relationship
12
13
 
13
14
 
14
15
  __all__ = [
@@ -28,4 +29,7 @@ __all__ = [
28
29
  # Utilities
29
30
  "M2MTable",
30
31
  "relationship",
32
+ # New components
33
+ "RelationshipAnalyzer",
34
+ "PrefetchHandler",
31
35
  ]