Skip to content

Commit 806f742

Browse files
committed
Add new source for deserialization of store records
1 parent 72a605d commit 806f742

2 files changed

Lines changed: 211 additions & 0 deletions

File tree

morango/sync/stream/deserialize.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from typing import Dict
2+
from typing import Generator
3+
from typing import List
4+
from typing import Optional
5+
from typing import Type
6+
7+
from morango.models.certificates import Filter
8+
from morango.models.core import Store
9+
from morango.models.core import SyncableModel
10+
from morango.registry import syncable_models
11+
from morango.sync.stream.source import MorangoSource
12+
from morango.sync.stream.source import SourceTask
13+
14+
15+
class DeserializeTask(SourceTask):
16+
"""Carrier class for providing context through the deserialization pipeline."""
17+
18+
__slots__ = ("store", "app_model", "fk_cache", "errors")
19+
20+
def __init__(self, store: Store):
21+
self.store = store
22+
self.app_model: Optional[SyncableModel] = None
23+
self.fk_cache: Dict = {}
24+
self.errors: List[Exception] = []
25+
26+
@property
27+
def id(self) -> str:
28+
return self.store.id
29+
30+
@property
31+
def model(self) -> Type[SyncableModel]:
32+
return syncable_models.get_model(self.store.profile, self.store.model_name)
33+
34+
@property
35+
def has_errors(self) -> bool:
36+
return len(self.errors) > 0
37+
38+
def set_app_model(self, app_model: Optional[SyncableModel]) -> None:
39+
self.app_model = app_model
40+
41+
def add_error(self, error: Exception) -> None:
42+
self.errors.append(error)
43+
44+
45+
class StoreModelSource(MorangoSource[DeserializeTask]):
46+
"""
47+
Yields ``DeserializeTask`` objects for dirty store models that match the optional
48+
*sync_filter*.
49+
"""
50+
51+
def __init__(
52+
self,
53+
profile: str,
54+
sync_filter: Optional[Filter] = None,
55+
dirty_only: bool = True,
56+
partition_order: str = "asc",
57+
skip_errored: bool = True,
58+
):
59+
"""
60+
:param profile: The Morango model profile
61+
:param sync_filter: The Filter object for this sync
62+
:param dirty_only: Whether to filter on dirty records only
63+
:param partition_order: Controls how the filter specificity is applied, "asc" or "desc"
64+
"""
65+
super().__init__(profile, sync_filter, dirty_only, partition_order)
66+
self.skip_errored = skip_errored
67+
68+
def stream_for_filter(
69+
self, partition_condition: Optional[str]
70+
) -> Generator[DeserializeTask, None, None]:
71+
qs = Store.objects.filter(profile=self.profile)
72+
if partition_condition is not None:
73+
qs = qs.filter(partition__startswith=partition_condition)
74+
if self.dirty_only:
75+
qs = qs.filter(dirty_bit=True)
76+
if self.skip_errored:
77+
qs = qs.filter(deserialization_error="")
78+
for store_model in qs.iterator():
79+
yield DeserializeTask(store_model)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import mock
2+
from django.test import SimpleTestCase
3+
4+
from morango.models.certificates import Filter
5+
from morango.models.core import Store
6+
from morango.models.core import SyncableModel
7+
from morango.sync.stream.deserialize import DeserializeTask
8+
from morango.sync.stream.deserialize import StoreModelSource
9+
10+
11+
class DeserializeTaskTestCase(SimpleTestCase):
12+
def setUp(self):
13+
self.store = mock.Mock(spec_set=Store)
14+
self.store.profile = "test"
15+
self.store.model_name = "testmodel"
16+
self.task = DeserializeTask(self.store)
17+
18+
@mock.patch("morango.sync.stream.deserialize.syncable_models.get_model")
19+
def test_model(self, mock_get_model):
20+
model = mock.Mock(spec_set=SyncableModel)
21+
mock_get_model.return_value = model
22+
23+
self.assertEqual(self.task.model, model)
24+
mock_get_model.assert_called_once_with("test", "testmodel")
25+
26+
def test_has_errors(self):
27+
self.assertFalse(self.task.has_errors)
28+
self.task.add_error(ValueError("bad data"))
29+
self.assertTrue(self.task.has_errors)
30+
31+
def test_set_app_model(self):
32+
app_model = mock.Mock(spec_set=SyncableModel)
33+
self.task.set_app_model(app_model)
34+
self.assertEqual(self.task.app_model, app_model)
35+
36+
37+
class StoreModelSourceTestCase(SimpleTestCase):
38+
def test_prefix_conditions__none(self):
39+
source = StoreModelSource(profile="test")
40+
conditions = list(source.prefix_conditions())
41+
self.assertEqual(conditions, [None])
42+
43+
def test_prefix_conditions__with_filter_asc(self):
44+
sync_filter = Filter("b\na")
45+
source = StoreModelSource(
46+
profile="test", sync_filter=sync_filter, partition_order="asc"
47+
)
48+
conditions = list(source.prefix_conditions())
49+
self.assertEqual(
50+
conditions,
51+
["a", "b"],
52+
)
53+
54+
def test_prefix_conditions__with_filter_desc(self):
55+
sync_filter = Filter("a\nb")
56+
source = StoreModelSource(
57+
profile="test", sync_filter=sync_filter, partition_order="desc"
58+
)
59+
conditions = list(source.prefix_conditions())
60+
self.assertEqual(
61+
conditions,
62+
["b", "a"],
63+
)
64+
65+
@mock.patch("morango.sync.stream.deserialize.Store.objects.filter")
66+
def test_stream__no_partition(self, mock_store_filter):
67+
qs = mock.Mock()
68+
mock_store_filter.return_value = qs
69+
qs.filter.return_value = qs
70+
store = mock.Mock(spec_set=Store)
71+
store.id = "123"
72+
qs.iterator.return_value = [store]
73+
74+
source = StoreModelSource(profile="test")
75+
tasks = list(source.stream())
76+
77+
self.assertEqual(len(tasks), 1)
78+
self.assertEqual(tasks[0].store, store)
79+
mock_store_filter.assert_called_once_with(profile="test")
80+
self.assertEqual(
81+
qs.filter.call_args_list,
82+
[mock.call(dirty_bit=True), mock.call(deserialization_error="")],
83+
)
84+
85+
@mock.patch("morango.sync.stream.deserialize.Store.objects.filter")
86+
def test_stream__partition_order_and_seen_once(self, mock_store_filter):
87+
qs_a = mock.Mock()
88+
qs_ab = mock.Mock()
89+
qs_a.filter.return_value = qs_a
90+
qs_ab.filter.return_value = qs_ab
91+
92+
first = mock.Mock(spec_set=Store)
93+
first.id = "1"
94+
second = mock.Mock(spec_set=Store)
95+
second.id = "2"
96+
third = mock.Mock(spec_set=Store)
97+
third.id = "3"
98+
qs_a.iterator.return_value = [first, second]
99+
qs_ab.iterator.return_value = [second, third]
100+
101+
mock_store_filter.side_effect = [qs_a, qs_ab]
102+
103+
source = StoreModelSource(profile="test", sync_filter=Filter("a\na/b"))
104+
tasks = list(source.stream())
105+
106+
self.assertEqual([task.store.id for task in tasks], ["1", "2", "3"])
107+
self.assertEqual(
108+
mock_store_filter.call_args_list,
109+
[mock.call(profile="test"), mock.call(profile="test")],
110+
)
111+
self.assertEqual(
112+
qs_a.filter.call_args_list[0],
113+
mock.call(partition__startswith="a"),
114+
)
115+
self.assertEqual(
116+
qs_ab.filter.call_args_list[0],
117+
mock.call(partition__startswith="a/b"),
118+
)
119+
120+
@mock.patch("morango.sync.stream.deserialize.Store.objects.filter")
121+
def test_stream__no_dirty_filter(self, mock_store_filter):
122+
qs = mock.Mock()
123+
mock_store_filter.return_value = qs
124+
qs.filter.return_value = qs
125+
qs.iterator.return_value = []
126+
127+
source = StoreModelSource(profile="test", dirty_only=False)
128+
list(source.stream())
129+
130+
self.assertEqual(
131+
qs.filter.call_args_list, [mock.call(deserialization_error="")]
132+
)

0 commit comments

Comments
 (0)