Skip to content

Commit bf160c4

Browse files
authored
fix(DataStore): Remove from snapshot when item no longer matches predicate (#1522)
* fix(DataStore): Remove from snapshot when item no longer matches predicate * address PR comments
1 parent 598a3f6 commit bf160c4

File tree

8 files changed

+662
-73
lines changed

8 files changed

+662
-73
lines changed

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Subscribe/DataStoreObserveQueryOperation.swift

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,7 @@ public class AWSDataStoreObserveQueryOperation<M: Model>: AsynchronousOperation,
234234
finish()
235235
return
236236
}
237-
if log.logLevel >= .debug {
238-
stopwatch.start()
239-
}
237+
startSnapshotStopWatch()
240238
storageEngine.query(
241239
modelType,
242240
modelSchema: modelSchema,
@@ -252,7 +250,7 @@ public class AWSDataStoreObserveQueryOperation<M: Model>: AsynchronousOperation,
252250
switch queryResult {
253251
case .success(let queriedModels):
254252
currentItems.sortedModels = queriedModels
255-
generateQuerySnapshot()
253+
sendSnapshot()
256254
case .failure(let error):
257255
self.passthroughPublisher.send(completion: .failure(error))
258256
self.finish()
@@ -263,45 +261,50 @@ public class AWSDataStoreObserveQueryOperation<M: Model>: AsynchronousOperation,
263261

264262
// MARK: Observe item changes
265263

264+
/// Subscribe to item changes with two subscribers (During Sync and After Sync). During Sync, the items are filtered
265+
/// by name and predicate, then collected by the timeOrCount grouping, before sent for processing the snapshot.
266+
/// After Sync, the item is only filtered by name, and not the predicate filter because updates to the item may
267+
/// make it so that the item no longer matches the predicate and requires to be removed from `currentItems`.
268+
/// This check is defered until `onItemChangedAfterSync` where the predicate is then used, and `currentItems` is
269+
/// accessed under the serial queue.
266270
func subscribeToItemChanges() {
267271
batchItemsChangedSink = dataStorePublisher.publisher
268272
.filter { _ in !self.dispatchedModelSyncedEvent.get() }
269-
.filter(onItemChangedFilter(mutationEvent:))
273+
.filter(filterByModelName(mutationEvent:))
274+
.filter(filterByPredicateMatch(mutationEvent:))
270275
.collect(.byTimeOrCount(serialQueue, itemsChangedPeriodicPublishTimeInSeconds, itemsChangedMaxSize))
271276
.sink(receiveCompletion: onReceiveCompletion(completed:),
272-
receiveValue: onItemsChange(mutationEvents:))
277+
receiveValue: onItemsChangeDuringSync(mutationEvents:))
273278

274279
itemsChangedSink = dataStorePublisher.publisher
275280
.filter { _ in self.dispatchedModelSyncedEvent.get() }
276-
.filter(onItemChangedFilter(mutationEvent:))
281+
.filter(filterByModelName(mutationEvent:))
277282
.receive(on: serialQueue)
278283
.sink(receiveCompletion: onReceiveCompletion(completed:),
279-
receiveValue: onItemChange(mutationEvent:))
284+
receiveValue: onItemChangeAfterSync(mutationEvent:))
280285
}
281286

282-
func onItemChangedFilter(mutationEvent: MutationEvent) -> Bool {
283-
guard mutationEvent.modelName == modelSchema.name else {
284-
return false
285-
}
287+
func filterByModelName(mutationEvent: MutationEvent) -> Bool {
288+
// Filter in the model when it matches the model name for this operation
289+
mutationEvent.modelName == modelSchema.name
290+
}
286291

292+
func filterByPredicateMatch(mutationEvent: MutationEvent) -> Bool {
293+
// Filter in the model when there is no predicate to check against.
287294
guard let predicate = self.predicate else {
288295
return true
289296
}
290-
291297
do {
292298
let model = try mutationEvent.decodeModel(as: modelType)
299+
// Filter in the model when the predicate matches, otherwise filter out
293300
return predicate.evaluate(target: model)
294301
} catch {
295302
log.error(error: error)
296303
return false
297304
}
298305
}
299306

300-
func onItemChange(mutationEvent: MutationEvent) {
301-
onItemsChange(mutationEvents: [mutationEvent])
302-
}
303-
304-
func onItemsChange(mutationEvents: [MutationEvent]) {
307+
func onItemsChangeDuringSync(mutationEvents: [MutationEvent]) {
305308
serialQueue.async {
306309
if self.isCancelled || self.isFinished {
307310
self.finish()
@@ -310,31 +313,77 @@ public class AWSDataStoreObserveQueryOperation<M: Model>: AsynchronousOperation,
310313
guard self.observeQueryStarted, !mutationEvents.isEmpty else {
311314
return
312315
}
313-
if self.log.logLevel >= .debug {
314-
self.stopwatch.start()
315-
}
316-
self.generateQuerySnapshot(itemsChanged: mutationEvents)
316+
317+
self.startSnapshotStopWatch()
318+
self.apply(itemsChanged: mutationEvents)
319+
self.sendSnapshot()
317320
}
318321
}
319322

320-
private func generateQuerySnapshot(itemsChanged: [MutationEvent] = []) {
321-
updateCurrentItems(with: itemsChanged)
322-
passthroughPublisher.send(currentSnapshot)
323-
if log.logLevel >= .debug {
324-
let time = stopwatch.stop()
325-
log.debug("Time to generate snapshot: \(time) seconds")
323+
// Item changes after sync is more elaborate than item changes during sync because the item was never filtered out
324+
// by the predicate (unlike during sync). An item that no longer matches the predicate may already exist in the
325+
// snapshot and now needs to be removed. The evaluation is done here under the serial queue since checking to
326+
// remove the item requires that check on `currentItems` and is required to be performed under the serial queue.
327+
func onItemChangeAfterSync(mutationEvent: MutationEvent) {
328+
serialQueue.async {
329+
if self.isCancelled || self.isFinished {
330+
self.finish()
331+
return
332+
}
333+
guard self.observeQueryStarted else {
334+
return
335+
}
336+
self.startSnapshotStopWatch()
337+
338+
do {
339+
let model = try mutationEvent.decodeModel(as: self.modelType)
340+
guard let mutationType = MutationEvent.MutationType(rawValue: mutationEvent.mutationType) else {
341+
return
342+
}
343+
344+
guard let predicate = self.predicate else {
345+
// 1. If there is no predicate, this item should be applied to the snapshot
346+
if self.currentItems.apply(model: model, mutationType: mutationType) {
347+
self.sendSnapshot()
348+
}
349+
return
350+
}
351+
352+
// 2. When there is a predicate, evaluate further
353+
let modelMatchesPredicate = predicate.evaluate(target: model)
354+
355+
guard !modelMatchesPredicate else {
356+
// 3. When the item matchs the predicate, the item should be applied to the snapshot
357+
if self.currentItems.apply(model: model, mutationType: mutationType) {
358+
self.sendSnapshot()
359+
}
360+
return
361+
}
362+
363+
// 4. When the item does not match the predicate, and is an update/delete, then the item needs to be
364+
// removed from `currentItems` because it no longer should be in the snapshot. If removing it was
365+
// was successfully, then send a new snapshot
366+
if mutationType == .update || mutationType == .delete, self.currentItems.remove(model) {
367+
self.sendSnapshot()
368+
}
369+
} catch {
370+
self.log.error(error: error)
371+
return
372+
}
373+
326374
}
327375
}
328376

329377
/// Update `curentItems` list with the changed items.
330378
/// This method is not thread safe unless executed under the serial DispatchQueue `serialQueue`.
331-
private func updateCurrentItems(with itemsChanged: [MutationEvent]) {
379+
private func apply(itemsChanged: [MutationEvent]) {
332380
for item in itemsChanged {
333381
do {
334382
let model = try item.decodeModel(as: modelType)
335383
guard let mutationType = MutationEvent.MutationType(rawValue: item.mutationType) else {
336384
return
337385
}
386+
338387
currentItems.apply(model: model, mutationType: mutationType)
339388
} catch {
340389
log.error(error: error)
@@ -343,6 +392,20 @@ public class AWSDataStoreObserveQueryOperation<M: Model>: AsynchronousOperation,
343392
}
344393
}
345394

395+
private func startSnapshotStopWatch() {
396+
if log.logLevel >= .debug {
397+
stopwatch.start()
398+
}
399+
}
400+
401+
private func sendSnapshot() {
402+
passthroughPublisher.send(currentSnapshot)
403+
if log.logLevel >= .debug {
404+
let time = stopwatch.stop()
405+
log.debug("Time to generate snapshot: \(time) seconds")
406+
}
407+
}
408+
346409
private func onReceiveCompletion(completed: Subscribers.Completion<DataStoreError>) {
347410
if isCancelled || isFinished {
348411
finish()

AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Subscribe/Support/SortedList.swift

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,47 @@ import Amplify
99

1010
class SortedList<ModelType: Model> {
1111
var sortedModels: [ModelType]
12+
var modelIds: Set<Model.Identifier>
1213
private let sortInput: [QuerySortDescriptor]?
1314
private let modelSchema: ModelSchema
1415

1516
init(sortInput: [QuerySortDescriptor]?, modelSchema: ModelSchema) {
1617
self.sortedModels = []
18+
self.modelIds = []
1719
self.sortInput = sortInput
1820
self.modelSchema = modelSchema
1921
}
2022

2123
/// Apply the incoming `model` to the sorted array based on the mutation type. This logic accounts for duplicate
2224
/// events since identical events may have different sources (local and remote). When the mutation type is delete,
23-
/// remove it if it exists in the array. When create/update, replace it if it exists in the array. If it does not
24-
/// exist in the array, then add the model to the array in the correct position.
25-
func apply(model: ModelType, mutationType: MutationEvent.MutationType) {
25+
/// remove it if it exists in the array. When create/update and it has an sort order, then remove and add it back in the
26+
/// correct sort order. if there is no sort order, replace it.
27+
/// Return `true` if something occured (added, replaced, deleted), otherwise `false`
28+
@discardableResult func apply(model: ModelType, mutationType: MutationEvent.MutationType) -> Bool {
2629
if mutationType == MutationEvent.MutationType.delete {
27-
if let index = sortedModels.firstIndex(where: { $0.id == model.id }) {
28-
sortedModels.remove(at: index)
29-
}
30-
} else if let index = sortedModels.firstIndex(where: { $0.id == model.id }) {
31-
sortedModels[index] = model
32-
} else {
33-
add(model: model)
30+
return remove(model)
31+
}
32+
33+
guard let sortInputs = sortInput else {
34+
// If there is no sort order, check if it exists to replace, otherwise add it to the end
35+
appendOrReplace(model)
36+
return true
3437
}
38+
39+
// When there is a sort input, always attempt to remove it before adding to the correct position.
40+
// If we had simply replaced it, and the update if applied to a field on the model that is the same as the
41+
// sort field, then it may no longer be in the correct position.
42+
_ = remove(model)
43+
add(model: model, sortInputs: sortInputs)
44+
return true
3545
}
3646

3747
/// Add the incoming `model` to the sorted array based on the sort input, or at the end if none is provided.
3848
/// Search for the index by comparing the incoming model with the current model in the binary search traversal.
3949
/// If the models are equal in terms of their sort order (comparator returns `nil`), then move onto the next sort
4050
/// input. If all sort comparators return `nil`, then the incoming model is equal to the current model on all
4151
/// sort inputs, and inserting at the index will maintain the overall sort order.
42-
func add(model: ModelType) {
43-
guard let sortInputs = sortInput else {
44-
sortedModels.append(model)
45-
return
46-
}
47-
52+
func add(model: ModelType, sortInputs: [QuerySortDescriptor]) {
4853
let index = sortedModels.binarySearch { existingModel in
4954
var sortOrder: Bool?
5055
var sortIndex: Int = 0
@@ -59,6 +64,28 @@ class SortedList<ModelType: Model> {
5964
}
6065

6166
sortedModels.insert(model, at: index)
67+
modelIds.insert(model.id)
68+
}
69+
70+
/// Tries to remove the `model`, if removed then return `true`, otherwise `false`
71+
func remove(_ model: ModelType) -> Bool {
72+
if modelIds.contains(model.id), let index = sortedModels.firstIndex(where: { $0.id == model.id }) {
73+
sortedModels.remove(at: index)
74+
modelIds.remove(model.id)
75+
return true
76+
} else {
77+
return false
78+
}
79+
}
80+
81+
/// Tries to replace the model with `model` if it already exists, otherwise append it to at the end
82+
func appendOrReplace(_ model: ModelType) {
83+
if modelIds.contains(model.id), let index = sortedModels.firstIndex(where: { $0.id == model.id }) {
84+
sortedModels[index] = model
85+
} else {
86+
sortedModels.append(model)
87+
modelIds.insert(model.id)
88+
}
6289
}
6390
}
6491

AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreLocalStoreTests.swift

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,15 +376,61 @@ class DataStoreLocalStoreTests: LocalStoreIntegrationTestBase {
376376
}
377377
}
378378

379+
/// DataStore without sync to cloud enabled.
380+
///
381+
/// - Given: DataStore is set up with models, and local store is populated with models
382+
/// - When:
383+
/// - ObserveQuery is called, add more models
384+
/// - Then:
385+
/// - The first snapshot will have initial models and may have additional models
386+
/// - There may be subsequent snapshots based on how the items are batched
387+
/// - The last snapshot will have a total of initial plus additional models
388+
@available(iOS 13.0, *)
389+
func testObserveQuery() throws {
390+
let cleared = expectation(description: "DataStore cleared")
391+
Amplify.DataStore.clear { result in
392+
switch result {
393+
case .success:
394+
cleared.fulfill()
395+
case .failure(let error):
396+
XCTFail("\(error)")
397+
}
398+
}
399+
wait(for: [cleared], timeout: 2)
400+
_ = setUpLocalStore(numberOfPosts: 14)
401+
var snapshotCount = 0
402+
let allSnapshotsReceived = expectation(description: "query snapshots received")
403+
404+
let sink = Amplify.DataStore.observeQuery(for: Post.self).sink { completed in
405+
switch completed {
406+
case .finished:
407+
break
408+
case .failure(let error):
409+
XCTFail("\(error)")
410+
}
411+
} receiveValue: { querySnapshot in
412+
snapshotCount += 1
413+
XCTAssertFalse(querySnapshot.isSynced)
414+
if querySnapshot.items.count == 30 {
415+
allSnapshotsReceived.fulfill()
416+
}
417+
}
418+
_ = setUpLocalStore(numberOfPosts: 15)
419+
wait(for: [allSnapshotsReceived], timeout: 100)
420+
XCTAssertTrue(snapshotCount >= 2)
421+
sink.cancel()
422+
}
423+
379424
func setUpLocalStore(numberOfPosts: Int) -> [Post] {
380425
var savedPosts = [Post]()
381-
for _ in 0 ..< numberOfPosts {
426+
for id in 0 ..< numberOfPosts {
382427
let saveSuccess = expectation(description: "Save post completed")
383428
let post = Post(title: "title\(Int.random(in: 0 ... 5))",
384429
content: "content",
385430
createdAt: .now(),
386431
rating: Double(Int.random(in: 0 ... 5)))
387432
savedPosts.append(post)
433+
print("\(id) \(post.id)")
388434
Amplify.DataStore.save(post) { result in
389435
switch result {
390436
case .success:
@@ -395,6 +441,7 @@ class DataStoreLocalStoreTests: LocalStoreIntegrationTestBase {
395441
}
396442
wait(for: [saveSuccess], timeout: 10)
397443
}
444+
398445
return savedPosts
399446
}
400447

0 commit comments

Comments
 (0)