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.
- {sqlobjects-1.0.4/sqlobjects.egg-info → sqlobjects-1.0.6}/PKG-INFO +1 -1
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/pyproject.toml +1 -1
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/cascade.py +321 -76
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/functions.py +1 -1
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/__init__.py +5 -1
- sqlobjects-1.0.6/sqlobjects/fields/relations/prefetch.py +241 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/utils.py +138 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/metadata.py +15 -12
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/mixins.py +167 -173
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/model.py +64 -179
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/core.py +9 -6
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/builder.py +15 -3
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/executor.py +16 -43
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queryset.py +38 -205
- {sqlobjects-1.0.4 → sqlobjects-1.0.6/sqlobjects.egg-info}/PKG-INFO +1 -1
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/SOURCES.txt +1 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/LICENSE +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/README.md +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/setup.cfg +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/database/manager.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/exceptions.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/function.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/expressions/terminal.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/core.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/proxies.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/descriptors.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/relations/proxies.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/objects/bulk.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/session.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.0.4 → sqlobjects-1.0.6}/sqlobjects.egg-info/requires.txt +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
411
|
-
|
|
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
|
-
"""
|
|
414
|
-
|
|
415
|
-
session = get_session()
|
|
549
|
+
"""Process necessary foreign key relationships for fast cascade."""
|
|
550
|
+
relationships = self._find_referencing_relationships(queryset._table)
|
|
416
551
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
|
440
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
#
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
#
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
]
|