Skip to content

Commit cf02ebe

Browse files
authored
fix(aws-datastore): publish each cascading delete (#1059)
1 parent dca2bd0 commit cf02ebe

File tree

6 files changed

+386
-71
lines changed

6 files changed

+386
-71
lines changed

aws-datastore/src/androidTest/java/com/amplifyframework/datastore/storage/sqlite/SQLiteStorageAdapterDeleteTest.java

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,22 @@
1818
import com.amplifyframework.core.model.query.predicate.QueryPredicate;
1919
import com.amplifyframework.datastore.DataStoreException;
2020
import com.amplifyframework.datastore.StrictMode;
21+
import com.amplifyframework.datastore.storage.StorageItemChange;
2122
import com.amplifyframework.datastore.storage.SynchronousStorageAdapter;
2223
import com.amplifyframework.testmodels.commentsblog.AmplifyModelProvider;
2324
import com.amplifyframework.testmodels.commentsblog.Blog;
2425
import com.amplifyframework.testmodels.commentsblog.BlogOwner;
26+
import com.amplifyframework.testmodels.commentsblog.Post;
27+
import com.amplifyframework.testmodels.commentsblog.PostStatus;
2528

2629
import org.junit.After;
2730
import org.junit.Before;
2831
import org.junit.BeforeClass;
2932
import org.junit.Test;
3033

34+
import java.util.HashSet;
3135
import java.util.List;
36+
import java.util.Set;
3237

3338
import static org.junit.Assert.assertEquals;
3439
import static org.junit.Assert.assertTrue;
@@ -93,30 +98,59 @@ public void deleteModelDeletesData() throws DataStoreException {
9398
*/
9499
@Test
95100
public void deleteModelCascades() throws DataStoreException {
96-
// Triggers an insert
97-
final BlogOwner raphael = BlogOwner.builder()
98-
.name("Raphael Kim")
99-
.build();
100-
adapter.save(raphael);
101-
102-
// Triggers a foreign key constraint check
103-
final Blog raphaelsBlog = Blog.builder()
104-
.name("Raphael's Blog")
105-
.owner(raphael)
101+
// Create 1 blog owner, which has 3 blogs each, which has 3 posts each.
102+
// Insert 1 blog owner, 3 blogs, 9 posts
103+
Set<String> expected = new HashSet<>();
104+
BlogOwner ownerModel = BlogOwner.builder()
105+
.name("Blog Owner 1")
106+
.build();
107+
adapter.save(ownerModel);
108+
expected.add(ownerModel.getId());
109+
for (int blog = 1; blog <= 3; blog++) {
110+
Blog blogModel = Blog.builder()
111+
.name("Blog " + blog)
112+
.owner(ownerModel)
106113
.build();
107-
adapter.save(raphaelsBlog);
108-
109-
// Triggers a delete
110-
// Deletes Raphael's Blog also to prevent foreign key violation
111-
adapter.delete(raphael);
112-
113-
// Get the BlogOwner from the database
114+
adapter.save(blogModel);
115+
expected.add(blogModel.getId());
116+
for (int post = 1; post <= 3; post++) {
117+
Post postModel = Post.builder()
118+
.title("Post " + post)
119+
.status(PostStatus.INACTIVE)
120+
.rating(5)
121+
.blog(blogModel)
122+
.build();
123+
adapter.save(postModel);
124+
expected.add(postModel.getId());
125+
}
126+
}
127+
128+
// Observe deletions
129+
Set<String> deleted = new HashSet<>();
130+
adapter.observe()
131+
.filter(change -> StorageItemChange.Type.DELETE.equals(change.type()))
132+
.map(StorageItemChange::item)
133+
.subscribe(model -> deleted.add(model.getId()));
134+
135+
// Triggers a delete.
136+
// Deletes every saved model to prevent foreign key constraint violation
137+
adapter.delete(ownerModel);
138+
139+
// Assert that cascaded deletions are observed.
140+
assertEquals(13, deleted.size());
141+
assertEquals(expected, deleted);
142+
143+
// Get the BlogOwner from the database.
114144
final List<BlogOwner> blogOwners = adapter.query(BlogOwner.class);
115145
assertTrue(blogOwners.isEmpty());
116146

117-
// Get the Blog from the database
147+
// Get the Blog from the database.
118148
final List<Blog> blogs = adapter.query(Blog.class);
119149
assertTrue(blogs.isEmpty());
150+
151+
// Get the Post from the database.
152+
final List<Post> posts = adapter.query(Post.class);
153+
assertTrue(posts.isEmpty());
120154
}
121155

122156
/**
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright 2020 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.datastore.storage.sqlite;
17+
18+
import android.database.Cursor;
19+
import android.database.sqlite.SQLiteDatabase;
20+
import androidx.annotation.NonNull;
21+
22+
import com.amplifyframework.core.Amplify;
23+
import com.amplifyframework.core.model.Model;
24+
import com.amplifyframework.core.model.ModelAssociation;
25+
import com.amplifyframework.core.model.ModelSchema;
26+
import com.amplifyframework.core.model.ModelSchemaRegistry;
27+
import com.amplifyframework.core.model.query.QueryOptions;
28+
import com.amplifyframework.core.model.query.Where;
29+
import com.amplifyframework.core.model.query.predicate.QueryField;
30+
import com.amplifyframework.core.model.query.predicate.QueryPredicate;
31+
import com.amplifyframework.core.model.query.predicate.QueryPredicateOperation;
32+
import com.amplifyframework.core.model.query.predicate.QueryPredicates;
33+
import com.amplifyframework.datastore.DataStoreException;
34+
import com.amplifyframework.datastore.storage.sqlite.adapter.SQLiteTable;
35+
import com.amplifyframework.logging.Logger;
36+
import com.amplifyframework.util.Empty;
37+
38+
import java.util.Collection;
39+
import java.util.HashSet;
40+
import java.util.LinkedHashMap;
41+
import java.util.Map;
42+
import java.util.Set;
43+
44+
/**
45+
* Utility class to help traverse a tree of models by relationship.
46+
*/
47+
final class SQLiteModelTree {
48+
private static final Logger LOG = Amplify.Logging.forNamespace("amplify:aws-datastore");
49+
50+
private final ModelSchemaRegistry registry;
51+
private final SQLCommandFactory commandFactory;
52+
private final SQLiteDatabase database;
53+
54+
/**
55+
* Constructs a model family tree traversing utility.
56+
* @param registry model registry to search schema from
57+
* @param commandFactory SQL command factory
58+
* @param database SQLite database connection handle
59+
*/
60+
SQLiteModelTree(ModelSchemaRegistry registry,
61+
SQLCommandFactory commandFactory,
62+
SQLiteDatabase database) {
63+
this.registry = registry;
64+
this.commandFactory = commandFactory;
65+
this.database = database;
66+
}
67+
68+
/**
69+
* Returns a map of descendants of a set of models (of same type).
70+
* A model is a child of its parent if it uses its parent's ID as foreign key.
71+
* @param root Collection of models to query its descendants of.
72+
* @return Map of descendants keyed by model schema. The value contains a set of
73+
* descendants' IDs for that model type.
74+
*/
75+
<T extends Model> Map<ModelSchema, Set<String>> descendantsOf(Collection<T> root) {
76+
if (Empty.check(root)) {
77+
throw new IllegalArgumentException("Cannot traverse tree from an empty root.");
78+
}
79+
Map<ModelSchema, Set<String>> descendants = new LinkedHashMap<>();
80+
ModelSchema rootSchema = registry.getModelSchemaForModelInstance(root.iterator().next());
81+
Set<String> rootIds = new HashSet<>();
82+
for (T model : root) {
83+
rootIds.add(model.getId());
84+
}
85+
recurseTree(descendants, rootSchema, rootIds);
86+
return descendants;
87+
}
88+
89+
private void recurseTree(
90+
Map<ModelSchema, Set<String>> map,
91+
ModelSchema modelSchema,
92+
Collection<String> parentIds
93+
) {
94+
SQLiteTable parentTable = SQLiteTable.fromSchema(modelSchema);
95+
for (ModelAssociation association : modelSchema.getAssociations().values()) {
96+
switch (association.getName()) {
97+
case "HasOne":
98+
case "HasMany":
99+
String childModel = association.getAssociatedType(); // model name
100+
ModelSchema childSchema = registry.getModelSchemaForModelClass(childModel);
101+
SQLiteTable childTable = SQLiteTable.fromSchema(childSchema);
102+
String childPrimaryKey = childTable.getPrimaryKey().getAliasedName();
103+
QueryField queryField = QueryField.field(parentTable.getPrimaryKeyColumnName());
104+
105+
// Chain predicates with OR operator.
106+
// No predicate = Match NONE.
107+
// 1 predicate = Match SELF.
108+
// 2 or more predicates = Match ANY.
109+
QueryPredicate predicate = QueryPredicates.none();
110+
for (String parentId : parentIds) {
111+
QueryPredicateOperation<Object> operation = queryField.eq(parentId);
112+
if (QueryPredicates.none().equals(predicate)) {
113+
predicate = operation;
114+
} else {
115+
predicate = operation.or(predicate);
116+
}
117+
}
118+
119+
// Collect every children one level deeper than current level
120+
// SELECT * FROM <CHILD_TABLE> WHERE <PARENT> = <ID_1> OR <PARENT> = <ID_2> OR ...
121+
QueryOptions options = Where.matches(predicate);
122+
Set<String> childrenIds = new HashSet<>();
123+
try (Cursor cursor = queryAll(childModel, options)) {
124+
if (cursor != null && cursor.moveToFirst()) {
125+
int index = cursor.getColumnIndexOrThrow(childPrimaryKey);
126+
do {
127+
childrenIds.add(cursor.getString(index));
128+
} while (cursor.moveToNext());
129+
}
130+
} catch (DataStoreException exception) {
131+
// Don't cut the search short. Populate rest of the tree.
132+
LOG.error("Failed to query children of deleted model(s).", exception);
133+
}
134+
135+
// Add queried result to the map
136+
if (!childrenIds.isEmpty()) {
137+
if (!map.containsKey(childSchema)) {
138+
map.put(childSchema, childrenIds);
139+
} else {
140+
map.get(childSchema).addAll(childrenIds);
141+
}
142+
recurseTree(map, childSchema, childrenIds);
143+
}
144+
break;
145+
case "BelongsTo":
146+
default:
147+
// Ignore other relationships
148+
}
149+
}
150+
}
151+
152+
private Cursor queryAll(
153+
@NonNull String tableName,
154+
@NonNull QueryOptions options
155+
) throws DataStoreException {
156+
final ModelSchema schema = registry.getModelSchemaForModelClass(tableName);
157+
final SqlCommand sqlCommand = commandFactory.queryFor(schema, options);
158+
final String rawQuery = sqlCommand.sqlStatement();
159+
final String[] bindings = sqlCommand.getBindingsAsArray();
160+
return database.rawQuery(rawQuery, bindings);
161+
}
162+
}

0 commit comments

Comments
 (0)