1+ import json
2+ from collections import namedtuple
3+ from enum import Enum
14from typing import Dict
25from typing import Generator
36from typing import List
47from typing import Optional
8+ from typing import Set
9+ from typing import Tuple
510from typing import Type
611
12+ from django .core import exceptions
13+ from django .db .models import ForeignKey
14+ from django .db .utils import IntegrityError
15+
16+ from morango .errors import MorangoDirtyParent
17+ from morango .errors import MorangoMissingParent
718from morango .models .certificates import Filter
819from morango .models .core import Store
920from morango .models .core import SyncableModel
1021from morango .registry import syncable_models
22+ from morango .sync .stream .core import Transform
1123from morango .sync .stream .source import MorangoSource
1224from morango .sync .stream .source import SourceTask
1325
26+ DIRTY_PARENT_MESSAGE = "Parent is dirty; could not deserialize."
27+ MISSING_PARENT_MESSAGE = "Parent does not exist in Store; could not deserialize."
28+
29+ # the store state of an FK target, used to resolve validation failures
30+ _TargetState = namedtuple ("_TargetState" , ["deleted" , "hard_deleted" , "dirty_bit" ])
31+
32+
33+ def _fk_cache_key (model : Type [SyncableModel ], record_id : str ) -> str :
34+ """The key `SyncableModel.cached_clean_fields` uses for a record in the `fk_cache`"""
35+ return "{id}_{db_table}" .format (id = record_id , db_table = model ._meta .db_table )
36+
37+
38+ class DeserializeOutcome (Enum ):
39+ """The resolved action for a `DeserializeTask`, which the sink applies"""
40+
41+ SAVE = "save"
42+ DELETE = "delete"
43+ PROPAGATE_DELETE = "propagate_delete"
44+ PROPAGATE_HARD_DELETE = "propagate_hard_delete"
45+ ERROR = "error"
46+
1447
1548class DeserializeTask (SourceTask ):
1649 """Carrier class for providing context through the deserialization pipeline."""
1750
18- __slots__ = ("store" , "app_model" , "fk_cache" , "errors" )
51+ __slots__ = ("store" , "app_model" , "fk_cache" , "errors" , "_propagated_deletion" )
52+
53+ HARD_DELETION = "hard"
54+ SOFT_DELETION = "soft"
1955
2056 def __init__ (self , store : Store , fk_cache : Dict ):
2157 self .store = store
2258 self .fk_cache : Dict = fk_cache
2359 self .app_model : Optional [SyncableModel ] = None
2460 self .errors : List [Exception ] = []
61+ self ._propagated_deletion : Optional [str ] = None
2562
2663 @property
2764 def id (self ) -> str :
@@ -35,9 +72,35 @@ def model(self) -> Type[SyncableModel]:
3572 def has_errors (self ) -> bool :
3673 return len (self .errors ) > 0
3774
75+ @property
76+ def outcome (self ) -> DeserializeOutcome :
77+ """
78+ The resolved `DeserializeOutcome` for this task, in order of precedence
79+ :raises AssertionError: if the pipeline has not resolved an outcome
80+ """
81+ if self .has_errors :
82+ return DeserializeOutcome .ERROR
83+ if self .store .deleted :
84+ return DeserializeOutcome .DELETE
85+ if self ._propagated_deletion == self .HARD_DELETION :
86+ return DeserializeOutcome .PROPAGATE_HARD_DELETE
87+ if self ._propagated_deletion == self .SOFT_DELETION :
88+ return DeserializeOutcome .PROPAGATE_DELETE
89+ if self .app_model is not None :
90+ return DeserializeOutcome .SAVE
91+ raise AssertionError ("DeserializeTask has no resolved outcome" )
92+
93+ def self_referential_fk (self ) -> Optional [str ]:
94+ """Return the attname of the self-referential FK on the task's model, or ``None``."""
95+ return syncable_models .get_self_referential_fk (self .model )
96+
3897 def set_app_model (self , app_model : Optional [SyncableModel ]) -> None :
3998 self .app_model = app_model
4099
100+ def set_propagated_deletion (self , hard : bool ) -> None :
101+ """Mark this task's record as deleted because a record it references was deleted"""
102+ self ._propagated_deletion = self .HARD_DELETION if hard else self .SOFT_DELETION
103+
41104 def add_error (self , error : Exception ) -> None :
42105 self .errors .append (error )
43106
@@ -92,3 +155,209 @@ def stream_for_filter(
92155
93156 for store_model in qs .iterator ():
94157 yield DeserializeTask (store_model , self .fk_cache )
158+
159+
160+ class AppModelDeserialize (Transform [DeserializeTask ]):
161+ """
162+ Builds the unsaved app model from each task's serialized store data. Deleted store records
163+ pass through untouched, since deleting the app model is left to the sink.
164+ """
165+
166+ def __init__ (self , sync_filter : Optional [Filter ] = None ):
167+ self .sync_filter = sync_filter
168+
169+ def transform (self , task : DeserializeTask ) -> DeserializeTask :
170+ if task .store .deleted :
171+ return task
172+
173+ try :
174+ app_model = task .model .deserialize (
175+ json .loads (task .store .serialized ), sync_filter = self .sync_filter
176+ )
177+ except (ValueError , exceptions .ValidationError ) as e :
178+ # `json.JSONDecodeError` is a `ValueError`
179+ task .add_error (e )
180+ return task
181+
182+ app_model ._morango_source_id = task .store .source_id
183+ app_model ._morango_partition = task .store .partition
184+ app_model ._morango_dirty_bit = False
185+ task .set_app_model (app_model )
186+ return task
187+
188+
189+ class AppModelValidate (Transform [DeserializeTask ]):
190+ """
191+ Validates each task's app model and resolves its outcome. The source streams FK targets ahead
192+ of the records that reference them, so a target validated earlier in the run is already in
193+ the shared `fk_cache` even though the sink may not have written it yet. A cache miss falls back
194+ to looking up the target in the app tables.
195+
196+ Must be instantiated per pipeline run, since it tracks which records failed or were deleted
197+ during the run, so that the records referencing them can be resolved accordingly.
198+
199+ Deletions spread to referencing records as in legacy deserialization. For models without a
200+ self-referential FK, a reference to any deleted record spreads its deletion. For models with
201+ one, only a validation failure of `ObjectDoesNotExist` spreads it.
202+ """
203+
204+ def __init__ (self , sync_filter : Optional [Filter ] = None ):
205+ self .sync_filter = sync_filter
206+ self .failed_ids : Set [str ] = set ()
207+ # maps the ID of a deleted record to whether the deletion is a hard deletion
208+ self .deleted_ids : Dict [str , bool ] = {}
209+
210+ def transform (self , task : DeserializeTask ) -> DeserializeTask :
211+ if task .has_errors :
212+ self .failed_ids .add (task .id )
213+ return task
214+
215+ if task .store .deleted :
216+ self .deleted_ids [task .id ] = task .store .hard_deleted
217+ return task
218+
219+ fk_references = self ._fk_references (task .app_model )
220+
221+ for field , target_id in fk_references :
222+ if target_id in self .deleted_ids :
223+ if task .self_referential_fk () is not None :
224+ # the sink has not deleted the target's app row yet, so validation can't
225+ # report the missing target; fail as legacy does once the row is gone
226+ self ._fail (task , self ._deleted_target_error (field , target_id ))
227+ else :
228+ self ._propagate_deletion (task , hard = self .deleted_ids [target_id ])
229+ return task
230+
231+ for field , target_id in fk_references :
232+ if target_id in self .failed_ids :
233+ self ._fail (task , self ._failed_target_error (task , field , target_id ))
234+ return task
235+
236+ try :
237+ task .app_model .cached_clean_fields (task .fk_cache , sync_filter = self .sync_filter )
238+ except (exceptions .ValidationError , exceptions .ObjectDoesNotExist , ValueError ) as e :
239+ self ._classify_failure (task , fk_references , e )
240+ return task
241+
242+ @staticmethod
243+ def _fk_references (app_model : SyncableModel ) -> List [Tuple [ForeignKey , str ]]:
244+ """The FK fields on the app model that hold a value, paired with that value"""
245+ references = []
246+ for field in app_model ._meta .fields :
247+ if not isinstance (field , ForeignKey ):
248+ continue
249+ target_id = getattr (app_model , field .attname )
250+ if target_id is not None :
251+ references .append ((field , target_id ))
252+ return references
253+
254+ @staticmethod
255+ def _failed_target_error (task : DeserializeTask , field : ForeignKey , target_id : str ) -> Exception :
256+ if field .attname == task .self_referential_fk ():
257+ return MorangoDirtyParent (DIRTY_PARENT_MESSAGE )
258+ return exceptions .ValidationError (
259+ "{} with id {} failed to deserialize" .format (
260+ field .related_model ._meta .verbose_name , target_id
261+ )
262+ )
263+
264+ @staticmethod
265+ def _deleted_target_error (field : ForeignKey , target_id : str ) -> Exception :
266+ return exceptions .ValidationError (
267+ "{} with id {} was deleted" .format (field .related_model ._meta .verbose_name , target_id )
268+ )
269+
270+ def _classify_failure (
271+ self ,
272+ task : DeserializeTask ,
273+ fk_references : List [Tuple [ForeignKey , str ]],
274+ error : Exception ,
275+ ) -> None :
276+ """
277+ Resolves a validation failure using the store records of the FK targets. Only runs on
278+ failure, so it costs at most one query per failed record and none per valid record.
279+ """
280+ targets = self ._lookup_targets (fk_references )
281+
282+ # a reference to a deleted record propagates the deletion, instead of failing
283+ if task .self_referential_fk () is None or isinstance (error , exceptions .ObjectDoesNotExist ):
284+ for _ , target_id in fk_references :
285+ target = targets .get (target_id )
286+ if target is not None and (target .deleted or target .hard_deleted ):
287+ self ._propagate_deletion (task , hard = target .hard_deleted )
288+ return
289+
290+ self ._fail (task , self ._failure_error (task , fk_references , targets , error ))
291+
292+ @staticmethod
293+ def _lookup_targets (
294+ fk_references : List [Tuple [ForeignKey , str ]],
295+ ) -> Dict [str , _TargetState ]:
296+ """The store state of each FK target that has a store record, keyed by the target ID"""
297+ if not fk_references :
298+ return {}
299+ target_ids = [target_id for _ , target_id in fk_references ]
300+ return {
301+ target_id : _TargetState (deleted , hard_deleted , dirty_bit )
302+ for target_id , deleted , hard_deleted , dirty_bit in Store .objects .filter (
303+ id__in = target_ids
304+ ).values_list ("id" , "deleted" , "hard_deleted" , "dirty_bit" )
305+ }
306+
307+ def _failure_error (
308+ self ,
309+ task : DeserializeTask ,
310+ fk_references : List [Tuple [ForeignKey , str ]],
311+ targets : Dict [str , _TargetState ],
312+ error : Exception ,
313+ ) -> Exception :
314+ """
315+ The error to record for a failure that did not propagate a deletion. Problems with the
316+ parent take precedence, then missing targets, otherwise the original validation error.
317+ """
318+ self_ref_fk = task .self_referential_fk ()
319+ for field , target_id in fk_references :
320+ if field .attname != self_ref_fk :
321+ continue
322+ if target_id not in targets :
323+ return MorangoMissingParent (MISSING_PARENT_MESSAGE )
324+ if targets [target_id ].dirty_bit and self ._parent_not_written (
325+ task , field , target_id , error
326+ ):
327+ return MorangoDirtyParent (DIRTY_PARENT_MESSAGE )
328+
329+ for field , target_id in fk_references :
330+ if target_id not in targets :
331+ return IntegrityError (
332+ "{from_model}.{from_field} references non-existent {to_model} instance with "
333+ "id '{to_pk}'" .format (
334+ from_model = task .model .__name__ ,
335+ from_field = field .attname ,
336+ to_model = field .related_model ._meta .verbose_name ,
337+ to_pk = target_id ,
338+ )
339+ )
340+
341+ return error
342+
343+ @staticmethod
344+ def _parent_not_written (
345+ task : DeserializeTask , field : ForeignKey , parent_id : str , error : Exception
346+ ) -> bool :
347+ """
348+ Whether a failure is explained by a dirty parent's app row not being written. The parent
349+ is in `fk_cache` when its FK resolved, either because it was accepted earlier in the run or
350+ because an older app row exists, and then only loading the parent object can fail on it.
351+ Any other error is the record's own.
352+ """
353+ return _fk_cache_key (field .related_model , parent_id ) not in task .fk_cache or isinstance (
354+ error , exceptions .ObjectDoesNotExist
355+ )
356+
357+ def _propagate_deletion (self , task : DeserializeTask , hard : bool ) -> None :
358+ task .set_propagated_deletion (hard )
359+ self .deleted_ids [task .id ] = hard
360+
361+ def _fail (self , task : DeserializeTask , error : Exception ) -> None :
362+ task .add_error (error )
363+ self .failed_ids .add (task .id )
0 commit comments