Skip to content

Commit 3fafb85

Browse files
fix(datastore): save metadata when merging even if mutation outbox has pending item (#1319)
1 parent ad0581d commit 3fafb85

File tree

9 files changed

+139
-82
lines changed

9 files changed

+139
-82
lines changed

aws-api-appsync/src/main/java/com/amplifyframework/datastore/appsync/SerializedModel.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public String toString() {
151151
return "SerializedModel{" +
152152
"id='" + modelId + '\'' +
153153
", serializedData=" + serializedData +
154-
", modelSchema=" + modelSchema +
154+
", modelSchema=" + modelSchema.getName() +
155155
'}';
156156
}
157157

aws-api/src/androidTest/java/com/amplifyframework/api/aws/CodeGenerationInstrumentationTest.java

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import com.amplifyframework.testmodels.ratingsblog.User;
3333
import com.amplifyframework.testmodels.teamproject.Projectfields;
3434
import com.amplifyframework.testmodels.teamproject.Team;
35-
import com.amplifyframework.testutils.FieldValue;
35+
import com.amplifyframework.testutils.ModelAssert;
3636
import com.amplifyframework.testutils.sync.SynchronousApi;
3737

3838
import org.junit.BeforeClass;
@@ -88,19 +88,11 @@ public void queryMatchesMutationResult() throws ApiException, ParseException {
8888
.relationship(MaritalStatus.married)
8989
.build();
9090
Person createdPerson = api.create(PERSON_API_NAME, david);
91-
try {
92-
// API creates model with "createdAt" and "updatedAt" fields, which local models don't have.
93-
// Manually write these in.
94-
FieldValue.set(david, "createdAt", createdPerson.getCreatedAt());
95-
FieldValue.set(david, "updatedAt", createdPerson.getUpdatedAt());
96-
} catch (NoSuchFieldException | IllegalAccessException error) {
97-
// Failure to override these fields will fail the assertion anyways
98-
}
99-
assertEquals(david, createdPerson);
91+
ModelAssert.assertEqualsIgnoringTimestamps(david, createdPerson);
10092

10193
// Query for that created person, expect him to be there
10294
Person queriedPerson = api.get(PERSON_API_NAME, Person.class, createdPerson.getId());
103-
// Do NOT override createdAt/updatedAt fields here to confirm that synced items have same values.
95+
// Do NOT ignore createdAt/updatedAt fields here to confirm that synced items have same values.
10496
assertEquals(createdPerson, queriedPerson);
10597
}
10698

aws-datastore/src/androidTest/java/com/amplifyframework/datastore/AppSyncClientInstrumentationTest.java

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
import com.amplifyframework.testmodels.commentsblog.Post;
4343
import com.amplifyframework.testmodels.commentsblog.PostStatus;
4444
import com.amplifyframework.testutils.Await;
45-
import com.amplifyframework.testutils.FieldValue;
45+
import com.amplifyframework.testutils.ModelAssert;
4646
import com.amplifyframework.testutils.Resources;
4747

4848
import org.junit.BeforeClass;
@@ -89,12 +89,10 @@ public static void onceBeforeTests() throws AmplifyException {
8989
* Tests the operations in AppSyncClient.
9090
* @throws DataStoreException If any call to AppSync endpoint fails to return a response
9191
* @throws AmplifyException On failure to obtain ModelSchema
92-
* @throws NoSuchFieldException On failure to null out createdAt, updatedAt fields in response
93-
* @throws IllegalAccessException On failure to null out createdAt, updatedAt fields in response
9492
*/
9593
@Test
9694
@SuppressWarnings("MethodLength")
97-
public void testAllOperations() throws AmplifyException, NoSuchFieldException, IllegalAccessException {
95+
public void testAllOperations() throws AmplifyException {
9896
ModelSchema blogOwnerSchema = ModelSchema.fromModelClass(BlogOwner.class);
9997
ModelSchema postSchema = ModelSchema.fromModelClass(Post.class);
10098
ModelSchema blogSchema = ModelSchema.fromModelClass(Blog.class);
@@ -108,12 +106,7 @@ public void testAllOperations() throws AmplifyException, NoSuchFieldException, I
108106
ModelWithMetadata<BlogOwner> blogOwnerCreateResult = create(owner, blogOwnerSchema);
109107
BlogOwner actual = blogOwnerCreateResult.getModel();
110108

111-
// The response from AppSync has createdAt and updatedAt fields. We can't actually know what values to expect
112-
// beforehand, so updated the "expected" object with the "actual" values.
113-
FieldValue.set(owner, "createdAt", actual.getCreatedAt());
114-
FieldValue.set(owner, "updatedAt", actual.getUpdatedAt());
115-
116-
assertEquals(owner, actual);
109+
ModelAssert.assertEqualsIgnoringTimestamps(owner, actual);
117110
assertEquals(new Integer(1), blogOwnerCreateResult.getSyncMetadata().getVersion());
118111
// TODO: BE AWARE THAT THE DELETED PROPERTY RETURNS NULL INSTEAD OF FALSE
119112
assertNull(blogOwnerCreateResult.getSyncMetadata().isDeleted());
@@ -163,18 +156,16 @@ public void testAllOperations() throws AmplifyException, NoSuchFieldException, I
163156
.blog(blog)
164157
.build();
165158
Post post1ModelResult = create(post1, postSchema).getModel();
166-
FieldValue.set(post1, "createdAt", post1ModelResult.getCreatedAt());
167159
Post post2ModelResult = create(post2, postSchema).getModel();
168-
FieldValue.set(post2, "createdAt", post2ModelResult.getCreatedAt());
169160

170161
// Results only have blog ID so strip out other information from the original post blog
171-
assertEquals(
162+
ModelAssert.assertEqualsIgnoringTimestamps(
172163
post1.copyOfBuilder()
173164
.blog(Blog.justId(blog.getId()))
174165
.build(),
175166
post1ModelResult
176167
);
177-
assertEquals(
168+
ModelAssert.assertEqualsIgnoringTimestamps(
178169
post2.copyOfBuilder()
179170
.blog(Blog.justId(blog.getId()))
180171
.build(),
@@ -201,7 +192,7 @@ public void testAllOperations() throws AmplifyException, NoSuchFieldException, I
201192

202193
// Delete one of the posts
203194
ModelWithMetadata<Post> post1DeleteResult = delete(post1, postSchema, 1);
204-
assertEquals(
195+
ModelAssert.assertEqualsIgnoringTimestamps(
205196
post1.copyOfBuilder()
206197
.blog(Blog.justId(blog.getId()))
207198
.build(),

aws-datastore/src/androidTest/java/com/amplifyframework/datastore/BasicCloudSyncInstrumentationTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import com.amplifyframework.testmodels.commentsblog.Post;
4545
import com.amplifyframework.testmodels.commentsblog.PostAuthorJoin;
4646
import com.amplifyframework.testutils.HubAccumulator;
47+
import com.amplifyframework.testutils.ModelAssert;
4748
import com.amplifyframework.testutils.Resources;
4849
import com.amplifyframework.testutils.sync.SynchronousApi;
4950
import com.amplifyframework.testutils.sync.SynchronousDataStore;
@@ -199,4 +200,42 @@ public void syncDownFromCloudIsWorking() throws AmplifyException {
199200
assertEquals("Jameson Williams", owner.getName());
200201
assertEquals(jameson.getId(), owner.getId());
201202
}
203+
204+
/**
205+
* Verify that updating an item shortly after creating it succeeds. This can be tricky because the _version
206+
* returned in the response from the create request must be included in the input for the subsequent update request.
207+
* @throws DataStoreException On failure to save or query items from DataStore.
208+
* @throws ApiException On failure to query the API.
209+
*/
210+
@Test
211+
public void updateAfterCreate() throws DataStoreException, ApiException {
212+
// Setup
213+
BlogOwner richard = BlogOwner.builder()
214+
.name("Richard")
215+
.build();
216+
BlogOwner updatedRichard = richard.copyOfBuilder()
217+
.name("Richard McClellan")
218+
.build();
219+
String modelName = BlogOwner.class.getSimpleName();
220+
221+
// Expect two mutations to be published to AppSync.
222+
HubAccumulator richardAccumulator =
223+
HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, richard.getId()), 2)
224+
.start();
225+
226+
// Create an item, then update it and save it again.
227+
dataStore.save(richard);
228+
dataStore.save(updatedRichard);
229+
230+
// Verify that 2 mutations were published.
231+
richardAccumulator.await(30, TimeUnit.SECONDS);
232+
233+
// Verify that the updatedRichard is saved in the DataStore.
234+
BlogOwner localRichard = dataStore.get(BlogOwner.class, richard.getId());
235+
ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, localRichard);
236+
237+
// Verify that the updatedRichard is saved on the backend.
238+
BlogOwner remoteRichard = api.get(BlogOwner.class, richard.getId());
239+
ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, remoteRichard);
240+
}
202241
}

aws-datastore/src/main/java/com/amplifyframework/datastore/syncengine/Merger.java

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,6 @@ <T extends Model> Completable merge(
9090
int incomingVersion = metadata.getVersion() == null ? -1 : metadata.getVersion();
9191
T model = modelWithMetadata.getModel();
9292

93-
// Check if there is a pending mutation for this model, in the outbox.
94-
if (mutationOutbox.hasPendingMutation(model.getId())) {
95-
LOG.info("Mutation outbox has pending mutation for " + model.getId() + ", refusing to merge.");
96-
return Completable.complete();
97-
}
98-
9993
return versionRepository.findModelVersion(model)
10094
.onErrorReturnItem(-1)
10195
// If the incoming version is strictly less than the current version, it's "out of date,"
@@ -105,10 +99,17 @@ <T extends Model> Completable merge(
10599
// and the version would get bumped up.
106100
.filter(currentVersion -> currentVersion == -1 || incomingVersion > currentVersion)
107101
// If we should merge, then do so now, starting with the model data.
108-
.flatMapCompletable(shouldMerge ->
109-
(isDelete ? delete(model, changeTypeConsumer) : save(model, changeTypeConsumer))
110-
.andThen(save(metadata, NoOpConsumer.create()))
111-
)
102+
.flatMapCompletable(shouldMerge -> {
103+
Completable firstStep;
104+
if (mutationOutbox.hasPendingMutation(model.getId())) {
105+
LOG.info("Mutation outbox has pending mutation for " + model.getId()
106+
+ ". Saving the metadata, but not model itself.");
107+
firstStep = Completable.complete();
108+
} else {
109+
firstStep = (isDelete ? delete(model, changeTypeConsumer) : save(model, changeTypeConsumer));
110+
}
111+
return firstStep.andThen(save(metadata, NoOpConsumer.create()));
112+
})
112113
// Let the world know that we've done a good thing.
113114
.doOnComplete(() -> {
114115
announceSuccessfulMerge(modelWithMetadata);
@@ -132,7 +133,6 @@ <T extends Model> Completable merge(
132133
long duration = System.currentTimeMillis() - startTime.get();
133134
LOG.verbose("Merged a single item in " + duration + " ms.");
134135
});
135-
136136
}
137137

138138
/**

aws-datastore/src/main/java/com/amplifyframework/datastore/syncengine/PendingMutation.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ public int hashCode() {
246246
public String toString() {
247247
return "PendingMutation{" +
248248
"mutatedItem=" + mutatedItem +
249-
", modelSchema=" + modelSchema +
250249
", mutationType=" + mutationType +
251250
", mutationId=" + mutationId +
252251
", predicate=" + predicate +

aws-datastore/src/main/java/com/amplifyframework/datastore/syncengine/PersistentMutationOutbox.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,9 @@ private IncomingMutationConflictHandler(@NonNull PendingMutation<T> existing,
283283
* @return A completable with the actions to resolve the conflict.
284284
*/
285285
Completable resolve() {
286+
LOG.debug("IncomingMutationConflict - "
287+
+ " existing " + existing.getMutationType()
288+
+ " incoming " + incoming.getMutationType());
286289
switch (incoming.getMutationType()) {
287290
case CREATE:
288291
return handleIncomingCreate();

testutils/src/main/java/com/amplifyframework/testutils/FieldValue.java

Lines changed: 0 additions & 43 deletions
This file was deleted.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.testutils;
17+
18+
import com.amplifyframework.core.model.Model;
19+
20+
import org.junit.Assert;
21+
22+
import java.lang.reflect.Field;
23+
import java.util.Arrays;
24+
import java.util.HashMap;
25+
import java.util.Map;
26+
27+
/**
28+
* A utility for comparing {@class Model} objects.
29+
*/
30+
public final class ModelAssert {
31+
private ModelAssert() {}
32+
33+
/**
34+
* Asserts whether two models are equal, ignoring the createdAt and updatedAt values. This method modifies the
35+
* timestamp fields (createdAt and updatedAt) on the expected object, with the values, from the actual, does the
36+
* assert, and then sets them back to the original values at the end, so that this method doesn't have side effects.
37+
* @param expected expected value
38+
* @param actual the value to check against <code>expected</code>
39+
* @param <T> type of model
40+
*/
41+
public static <T extends Model> void assertEqualsIgnoringTimestamps(T expected, T actual) {
42+
Map<String, Object> originalValues = new HashMap<>();
43+
for (String fieldName : Arrays.asList("createdAt", "updatedAt")) {
44+
try {
45+
Field privateField = actual.getClass().getDeclaredField(fieldName); // can throw NoSuchFieldException
46+
privateField.setAccessible(true);
47+
Object actualValue = privateField.get(actual); // can throw IllegalAccessException
48+
Object expectedValue = privateField.get(expected); // can throw IllegalAccessException
49+
privateField.set(expected, actualValue); // can throw IllegalAccessException
50+
// if we got here, we successfully updated the expected object, so we should remember the original
51+
// expected value, so we can restore it at the end.
52+
originalValues.put(fieldName, expectedValue);
53+
} catch (NoSuchFieldException exception) {
54+
// Field doesn't exist, just proceed with assertEquals
55+
} catch (IllegalAccessException exception) {
56+
// Field was not accessible, just proceed by calling assertEquals anyway.
57+
}
58+
}
59+
60+
// Do the assert!
61+
Assert.assertEquals(expected, actual);
62+
63+
// Reset values back to original, so that this method has no side effects.
64+
for (String fieldName : originalValues.keySet()) {
65+
try {
66+
Field privateField = actual.getClass().getDeclaredField(fieldName); // can throw NoSuchFieldException
67+
privateField.setAccessible(true);
68+
privateField.set(expected, originalValues.get(fieldName)); // can throw IllegalAccessException
69+
} catch (NoSuchFieldException exception) {
70+
// Field doesn't exist. Shouldn't happen, since it didn't happen above.
71+
} catch (IllegalAccessException exception) {
72+
// Field was not accessible. Shouldn't happen, since it didn't happen above.
73+
}
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)