44import json
55import logging
66import uuid
7+ from collections import defaultdict
8+ from collections import namedtuple
79from functools import reduce
810
911from django .core import exceptions
@@ -450,15 +452,16 @@ class Meta:
450452 models .Index (fields = ['partition' ], name = 'idx_morango_store_partition' ),
451453 ]
452454
453- def _deserialize_store_model (self , fk_cache ): # noqa: C901
455+ def _deserialize_store_model (self , fk_cache , defer_fks = False ): # noqa: C901
454456 """
455457 When deserializing a store model, we look at the deleted flags to know if we should delete the app model.
456458 Upon loading the app model in memory we validate the app models fields, if any errors occurs we follow
457459 foreign key relationships to see if the related model has been deleted to propagate that deletion to the target app model.
458460 We return:
459- None => if the model was deleted successfully
460- model => if the model validates successfully
461+ None => if the model was deleted successfully
462+ model => if the model validates successfully
461463 """
464+ deferred_fks = {}
462465 klass_model = syncable_models .get_model (self .profile , self .model_name )
463466 # if store model marked as deleted, attempt to delete in app layer
464467 if self .deleted :
@@ -470,7 +473,7 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
470473 pass
471474 else :
472475 klass_model .objects .filter (id = self .id ).delete ()
473- return None
476+ return None , deferred_fks
474477 else :
475478 # load model into memory
476479 app_model = klass_model .deserialize (json .loads (self .serialized ))
@@ -479,10 +482,12 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
479482 app_model ._morango_dirty_bit = False
480483
481484 try :
482-
483485 # validate and return the model
484- app_model .cached_clean_fields (fk_cache )
485- return app_model
486+ if defer_fks :
487+ deferred_fks = app_model .deferred_clean_fields ()
488+ else :
489+ app_model .cached_clean_fields (fk_cache )
490+ return app_model , deferred_fks
486491
487492 except (exceptions .ValidationError , exceptions .ObjectDoesNotExist ) as e :
488493
@@ -492,23 +497,24 @@ def _deserialize_store_model(self, fk_cache): # noqa: C901
492497 )
493498 )
494499
495- # check FKs in store to see if any of those models were deleted or hard_deleted to propagate to this model
496- fk_ids = [
497- getattr (app_model , field .attname )
498- for field in app_model ._meta .fields
499- if isinstance (field , ForeignKey )
500- ]
501- for fk_id in fk_ids :
502- try :
503- st_model = Store .objects .get (id = fk_id )
504- if st_model .deleted :
505- # if hard deleted, propagate to store model
506- if st_model .hard_deleted :
507- app_model ._update_hard_deleted_models ()
508- app_model ._update_deleted_models ()
509- return None
510- except Store .DoesNotExist :
511- pass
500+ if not defer_fks and isinstance (e , exceptions .ObjectDoesNotExist ):
501+ # check FKs in store to see if any of those models were deleted or hard_deleted to propagate to this model
502+ fk_ids = [
503+ getattr (app_model , field .attname )
504+ for field in app_model ._meta .fields
505+ if isinstance (field , ForeignKey )
506+ ]
507+ for fk_id in fk_ids :
508+ try :
509+ st_model = Store .objects .get (id = fk_id )
510+ if st_model .deleted :
511+ # if hard deleted, propagate to store model
512+ if st_model .hard_deleted :
513+ app_model ._update_hard_deleted_models ()
514+ app_model ._update_deleted_models ()
515+ return None , {}
516+ except Store .DoesNotExist :
517+ pass
512518
513519 # if we got here, it means the validation error wasn't handled by propagating deletion, so re-raise it
514520 raise e
@@ -771,6 +777,9 @@ class RecordMaxCounterBuffer(AbstractCounter):
771777 model_uuid = UUIDField (db_index = True )
772778
773779
780+ ForeignKeyReference = namedtuple ("ForeignKeyReference" , ["from_field" , "from_pk" , "to_pk" ])
781+
782+
774783class SyncableModel (UUIDModelMixin ):
775784 """
776785 ``SyncableModel`` is the base model class for syncing. Other models inherit from this class if they want to make
@@ -839,13 +848,21 @@ def delete(
839848 return collector .delete ()
840849
841850 def cached_clean_fields (self , fk_lookup_cache ):
851+ """
852+ Immediately validates all fields, but uses a cache for foreign key (FK) lookups to reduce
853+ repeated queries for many records with the same FK
854+
855+ :param fk_lookup_cache: A dictionary to use as a cache to prevent querying the database if a
856+ FK exists in the cache, having already been validated
857+ """
842858 excluded_fields = []
843859 fk_fields = [
844860 field for field in self ._meta .fields if isinstance (field , models .ForeignKey )
845861 ]
862+
846863 for f in fk_fields :
847864 raw_value = getattr (self , f .attname )
848- key = "morango_ {id}_{db_table}_foreignkey " .format (
865+ key = "{id}_{db_table}" .format (
849866 db_table = f .related_model ._meta .db_table , id = raw_value
850867 )
851868 try :
@@ -859,7 +876,44 @@ def cached_clean_fields(self, fk_lookup_cache):
859876 else :
860877 fk_lookup_cache [key ] = 1
861878 excluded_fields .append (f .name )
879+
880+ self .clean_fields (exclude = excluded_fields )
881+
882+ # after cleaning, we can confidently set ourselves in the fk_lookup_cache
883+ self_key = "{id}_{db_table}" .format (
884+ db_table = self ._meta .db_table ,
885+ id = self .id ,
886+ )
887+ fk_lookup_cache [self_key ] = 1
888+
889+ def deferred_clean_fields (self ):
890+ """
891+ Calls `.clean_fields()` but excludes all foreign key fields and instead returns them as a
892+ dictionary for deferred batch processing
893+
894+ :return: A dictionary containing lists of `ForeignKeyReference`s keyed by the name of the
895+ model being referenced by the FK
896+ """
897+ excluded_fields = []
898+ deferred_fks = defaultdict (list )
899+ for field in self ._meta .fields :
900+ if not isinstance (field , models .ForeignKey ):
901+ continue
902+ # by not excluding the field if it's null, the default validation logic will apply
903+ # and should raise a ValidationError if the FK field is not nullable
904+ if getattr (self , field .attname ) is None :
905+ continue
906+ excluded_fields .append (field .name )
907+ deferred_fks [field .related_model ._meta .verbose_name ].append (
908+ ForeignKeyReference (
909+ from_field = field .attname ,
910+ from_pk = self .pk ,
911+ to_pk = getattr (self , field .attname )
912+ )
913+ )
914+
862915 self .clean_fields (exclude = excluded_fields )
916+ return deferred_fks
863917
864918 def serialize (self ):
865919 """All concrete fields of the ``SyncableModel`` subclass, except for those specifically blacklisted, are returned in a dict."""
0 commit comments