@@ -532,8 +532,18 @@ def _collapse_changes_for_object(changes, logger):
532532 Collapse a list of ObjectChanges for a single object.
533533 Returns: (final_action, merged_data, last_change)
534534
535- Simplified DELETE logic: If DELETE appears anywhere in the changes,
536- ignore all other changes and keep only the DELETE.
535+ A key point is that we only care about the final state of the object. Also
536+ each ChangeObject needs to be correct so the final state is correct, i.e.
537+ if we delete an object, there aren't going to be other objects still referencing it.
538+
539+ ChangeObject can have CREATE, UPDATE, and DELETE actions.
540+ We need to collapse these changes into a single action.
541+ We can have the following cases:
542+ - CREATE + (any updates) + DELETE = skip entirely
543+ - (anything other than CREATE) + DELETE = DELETE
544+ - CREATE + UPDATEs = CREATE
545+ - multiple UPDATEs = UPDATE
546+
537547 """
538548 if not changes :
539549 return None , None , None
@@ -616,106 +626,6 @@ def _get_fk_references(model_class, data, changed_objects):
616626
617627 return references
618628
619- @staticmethod
620- def _removed_reference_to (collapsed_change , target_key , logger ):
621- """
622- Check if this collapsed change removed an FK reference to target_key.
623- Returns True if:
624- - The object previously referenced target_key (in its initial state)
625- - The final state does NOT reference target_key (or object is deleted)
626- """
627- if not collapsed_change .changes :
628- return False
629-
630- target_ct_id , target_obj_id = target_key
631- first_change = collapsed_change .changes [0 ]
632-
633- # If this object is being deleted, check if it referenced target initially
634- if collapsed_change .final_action == 'delete' :
635- initial_refs = Branch ._get_fk_references (
636- collapsed_change .model_class ,
637- first_change .prechange_data or {},
638- {target_key }
639- )
640- return target_key in initial_refs
641-
642- # If created in branch, it couldn't have had an initial reference
643- if first_change .action == 'create' :
644- return False
645-
646- # It's an update - check if FK reference changed
647- initial_state = first_change .prechange_data or {}
648- final_state = collapsed_change .merged_data or {}
649-
650- # Check each FK field
651- for field in collapsed_change .model_class ._meta .get_fields ():
652- if isinstance (field , models .ForeignKey ):
653- related_model = field .related_model
654- related_ct = ContentType .objects .get_for_model (related_model )
655-
656- # Only check if this FK could point to our target
657- if related_ct .id != target_ct_id :
658- continue
659-
660- fk_field_name = field .attname # e.g., 'device_id'
661- initial_value = initial_state .get (fk_field_name )
662- final_value = final_state .get (fk_field_name )
663-
664- # Reference was removed or changed from target
665- if initial_value == target_obj_id and initial_value != final_value :
666- logger .debug (f" Found removed reference: { field .name } was { initial_value } , now { final_value } " )
667- return True
668-
669- return False
670-
671- @staticmethod
672- def _build_dependency_graph (collapsed_changes , logger ):
673- """
674- Build dependency graph between collapsed changes.
675- Modifies collapsed_changes in place to set depends_on/depended_by.
676- """
677- logger .info ("Building dependency graph..." )
678-
679- # 1. FK dependencies for creates/updates
680- # If we CREATE/UPDATE object A with FK to object B,
681- # and B is being created, then B must be created first
682- logger .debug (" Analyzing FK dependencies for creates/updates..." )
683- for key , collapsed in collapsed_changes .items ():
684- if collapsed .final_action in ('create' , 'update' ):
685- fk_refs = Branch ._get_fk_references (
686- collapsed .model_class ,
687- collapsed .merged_data ,
688- collapsed_changes .keys ()
689- )
690-
691- for ref_key in fk_refs :
692- ref_collapsed = collapsed_changes [ref_key ]
693- # Only add dependency if the referenced object is being created
694- if ref_collapsed .final_action == 'create' :
695- collapsed .depends_on .add (ref_key )
696- ref_collapsed .depended_by .add (key )
697- logger .debug (f" { collapsed } depends on { ref_collapsed } (FK reference)" )
698-
699- # 2. Delete dependencies
700- # If we DELETE object A, and object B removes its reference to A,
701- # then B's change must happen before A's delete
702- logger .debug (" Analyzing dependencies for deletes..." )
703- for key , collapsed in collapsed_changes .items ():
704- if collapsed .final_action == 'delete' :
705- # Find all changes that removed references to this object
706- for other_key , other_collapsed in collapsed_changes .items ():
707- if other_key == key :
708- continue
709-
710- if Branch ._removed_reference_to (other_collapsed , key , logger ):
711- # other_collapsed must happen before collapsed (the delete)
712- collapsed .depends_on .add (other_key )
713- other_collapsed .depended_by .add (key )
714- logger .debug (f" { collapsed } depends on { other_collapsed } (removed reference)" )
715-
716- total_deps = sum (len (c .depends_on ) for c in collapsed_changes .values ())
717- logger .info (f" Dependency graph built: { total_deps } dependencies" )
718-
719629 @staticmethod
720630 def _topological_sort_with_cycle_detection (collapsed_changes , logger ):
721631 """
@@ -767,7 +677,24 @@ def _topological_sort_with_cycle_detection(collapsed_changes, logger):
767677 @staticmethod
768678 def _order_collapsed_changes (collapsed_changes , logger ):
769679 """
770- Order collapsed changes respecting dependencies and constraints.
680+ Order collapsed changes respecting dependencies and time.
681+
682+ Algorithm:
683+ 1. Initial ordering by time: DELETEs, UPDATEs, CREATEs (each group sorted by time)
684+ 2. Build dependency graph:
685+ - If UPDATE references deleted object in prechange_data → UPDATE must come before DELETE
686+ (UPDATE removes the FK reference, allowing the DELETE to proceed)
687+ - If UPDATE references created object in postchange_data → CREATE must come before UPDATE
688+ (CREATE must exist before UPDATE can reference it)
689+ - If CREATE references another created object in postchange_data → referenced CREATE must come first
690+ (Referenced object must exist before referencing object is created)
691+ 3. Topological sort respecting dependencies
692+
693+ This ensures:
694+ - DELETEs generally happen first to free unique constraints (time order within group)
695+ - UPDATEs that remove FK references happen before their associated DELETEs
696+ - CREATEs happen before UPDATEs/CREATEs that reference them
697+
771698 Returns: ordered list of CollapsedChange objects
772699 """
773700 logger .info (f"Ordering { len (collapsed_changes )} collapsed changes..." )
@@ -782,36 +709,106 @@ def _order_collapsed_changes(collapsed_changes, logger):
782709 if not to_process :
783710 return []
784711
785- # Build dependency graph
786- Branch ._build_dependency_graph (to_process , logger )
712+ # Group by action and sort each group by time
713+ deletes = sorted (
714+ [v for v in to_process .values () if v .final_action == 'delete' ],
715+ key = lambda c : c .last_change .time
716+ )
717+ updates = sorted (
718+ [v for v in to_process .values () if v .final_action == 'update' ],
719+ key = lambda c : c .last_change .time
720+ )
721+ creates = sorted (
722+ [v for v in to_process .values () if v .final_action == 'create' ],
723+ key = lambda c : c .last_change .time
724+ )
787725
788- # Topological sort
789- ordered_keys = Branch ._topological_sort_with_cycle_detection (to_process , logger )
726+ logger .info (
727+ f" Initial time-based groups: { len (deletes )} deletes, "
728+ f"{ len (updates )} updates, { len (creates )} creates"
729+ )
730+
731+ # Reset dependencies
732+ for collapsed in to_process .values ():
733+ collapsed .depends_on = set ()
734+ collapsed .depended_by = set ()
735+
736+ # Build lookup maps for efficient dependency checking
737+ deletes_map = {c .key : c for c in deletes }
738+ creates_map = {c .key : c for c in creates }
790739
791- # Group by model and refine order within each model
792- logger .info ("Refining order within models (deletes before creates to free unique constraints)..." )
793- by_model = defaultdict (list )
794- for key in ordered_keys :
795- collapsed = to_process [key ]
796- by_model [collapsed .model_class ].append (collapsed )
797-
798- # Within each model: updates, then deletes, then creates
799- # This ensures deletes free up unique constraints (like slugs) before creates claim them
800- result = []
801- for model_class , changes in by_model .items ():
802- updates = [c for c in changes if c .final_action == 'update' ]
803- deletes = [c for c in changes if c .final_action == 'delete' ]
804- creates = [c for c in changes if c .final_action == 'create' ]
805-
806- if updates or creates or deletes :
807- logger .debug (
808- f" { model_class .__name__ } : { len (updates )} updates, "
809- f"{ len (deletes )} deletes, { len (creates )} creates"
740+ logger .info ("Building dependency graph..." )
741+
742+ # 1. Check UPDATEs for dependencies
743+ logger .debug (" Analyzing UPDATE dependencies..." )
744+ for update in updates :
745+ # Check if UPDATE references deleted object in prechange_data
746+ # This means the UPDATE had a reference that it's removing
747+ # The UPDATE must happen BEFORE the DELETE so the FK reference is removed first
748+ if update .changes [0 ].prechange_data :
749+ prechange_refs = Branch ._get_fk_references (
750+ update .model_class ,
751+ update .changes [0 ].prechange_data ,
752+ deletes_map .keys ()
810753 )
754+ for ref_key in prechange_refs :
755+ # DELETE depends on UPDATE (UPDATE removes reference, then DELETE can proceed)
756+ delete_collapsed = deletes_map [ref_key ]
757+ delete_collapsed .depends_on .add (update .key )
758+ update .depended_by .add (ref_key )
759+ logger .debug (
760+ f" { delete_collapsed } depends on { update } "
761+ f"(UPDATE removes FK reference before DELETE)"
762+ )
763+
764+ # Check if UPDATE references created object in postchange_data
765+ # This means the UPDATE needs the CREATE to exist first
766+ # The CREATE must happen BEFORE the UPDATE
767+ if update .merged_data :
768+ postchange_refs = Branch ._get_fk_references (
769+ update .model_class ,
770+ update .merged_data ,
771+ creates_map .keys ()
772+ )
773+ for ref_key in postchange_refs :
774+ # UPDATE depends on CREATE
775+ create_collapsed = creates_map [ref_key ]
776+ update .depends_on .add (ref_key )
777+ create_collapsed .depended_by .add (update .key )
778+ logger .debug (
779+ f" { update } depends on { create_collapsed } "
780+ f"(UPDATE references created object)"
781+ )
782+
783+ # 2. Check CREATEs for dependencies on other CREATEs
784+ logger .debug (" Analyzing CREATE dependencies..." )
785+ for create in creates :
786+ if create .merged_data :
787+ # Check if this CREATE references other created objects
788+ refs = Branch ._get_fk_references (
789+ create .model_class ,
790+ create .merged_data ,
791+ creates_map .keys ()
792+ )
793+ for ref_key in refs :
794+ if ref_key != create .key : # Don't self-reference
795+ # CREATE depends on another CREATE
796+ ref_create = creates_map [ref_key ]
797+ create .depends_on .add (ref_key )
798+ ref_create .depended_by .add (create .key )
799+ logger .debug (
800+ f" { create } depends on { ref_create } "
801+ f"(CREATE references another created object)"
802+ )
803+
804+ total_deps = sum (len (c .depends_on ) for c in to_process .values ())
805+ logger .info (f" Dependency graph built: { total_deps } dependencies" )
806+
807+ # Topological sort to respect dependencies
808+ ordered_keys = Branch ._topological_sort_with_cycle_detection (to_process , logger )
811809
812- result .extend (updates )
813- result .extend (deletes )
814- result .extend (creates )
810+ # Convert keys back to collapsed changes
811+ result = [to_process [key ] for key in ordered_keys ]
815812
816813 logger .info (f"Ordering complete: { len (result )} changes to apply" )
817814 return result
0 commit comments