Skip to content

Commit ed185fd

Browse files
authored
fix(data): Fix GroupQueryPredicate Serialization for LastSyncMetadata stored syncExpression (#3124)
1 parent d9eb7a9 commit ed185fd

File tree

14 files changed

+516
-22
lines changed

14 files changed

+516
-22
lines changed

aws-api-appsync/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies {
3838
testImplementation(libs.test.junit)
3939
testImplementation(libs.test.robolectric)
4040
testImplementation(libs.test.jsonassert)
41+
testImplementation(libs.test.kotest.assertions)
4142
testImplementation(project(":testmodels"))
4243
testImplementation(project(":testutils"))
4344
}

aws-api-appsync/src/main/java/com/amplifyframework/core/model/query/predicate/GsonPredicateAdapters.java

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.google.gson.Gson;
1919
import com.google.gson.GsonBuilder;
20+
import com.google.gson.JsonArray;
2021
import com.google.gson.JsonDeserializationContext;
2122
import com.google.gson.JsonDeserializer;
2223
import com.google.gson.JsonElement;
@@ -26,6 +27,8 @@
2627
import com.google.gson.JsonSerializer;
2728

2829
import java.lang.reflect.Type;
30+
import java.util.ArrayList;
31+
import java.util.List;
2932

3033
/**
3134
* Gson adapters to serialize/deserialize to/from data modeling types.
@@ -159,7 +162,9 @@ public QueryPredicate deserialize(JsonElement json, Type type, JsonDeserializati
159162
case OPERATION:
160163
return gson.fromJson(json, QueryPredicateOperation.class);
161164
case GROUP:
162-
return gson.fromJson(json, QueryPredicateGroup.class);
165+
// We need to manually deserialize Groups to ensure we handle nested groups
166+
// and update _types correctly.
167+
return deserializeQueryPredicateGroup(jsonObject);
163168
case ALL:
164169
return gson.fromJson(json, MatchAllQueryPredicate.class);
165170
case NONE:
@@ -176,22 +181,98 @@ public QueryPredicate deserialize(JsonElement json, Type type, JsonDeserializati
176181
@Override
177182
public JsonElement serialize(QueryPredicate predicate, Type type, JsonSerializationContext context)
178183
throws JsonParseException {
179-
JsonElement json = gson.toJsonTree(predicate);
184+
JsonElement json;
180185
PredicateType predicateType;
181-
if (predicate instanceof MatchAllQueryPredicate) {
182-
predicateType = PredicateType.ALL;
183-
} else if (predicate instanceof MatchNoneQueryPredicate) {
184-
predicateType = PredicateType.NONE;
185-
} else if (predicate instanceof QueryPredicateOperation) {
186-
predicateType = PredicateType.OPERATION;
187-
} else if (predicate instanceof QueryPredicateGroup) {
186+
if (predicate instanceof QueryPredicateGroup) {
187+
// We need to manually serialize Groups to ensure we handle nested groups and
188+
// update _types correctly.
188189
predicateType = PredicateType.GROUP;
190+
json = serializeQueryPredicateGroup((QueryPredicateGroup) predicate, context);
189191
} else {
190-
throw new JsonParseException("Unable to identify the predicate type.");
192+
json = gson.toJsonTree(predicate);
193+
if (predicate instanceof MatchAllQueryPredicate) {
194+
predicateType = PredicateType.ALL;
195+
} else if (predicate instanceof MatchNoneQueryPredicate) {
196+
predicateType = PredicateType.NONE;
197+
} else if (predicate instanceof QueryPredicateOperation) {
198+
predicateType = PredicateType.OPERATION;
199+
} else {
200+
throw new JsonParseException("Unable to identify the predicate type.");
201+
}
191202
}
192203
JsonObject jsonObject = json.getAsJsonObject();
193204
jsonObject.addProperty(TYPE, predicateType.name());
194205
return jsonObject;
195206
}
207+
208+
/**
209+
* Serializes a QueryPredicateGroup to JSON format.
210+
* <p>
211+
* This method is necessary because QueryPredicateGroup contains nested QueryPredicate objects
212+
* that need to be recursively serialized. We cannot use context.serialize() directly on
213+
* QueryPredicateGroup because:
214+
* 1. Using context.serialize() would cause infinite recursion back to this adapter
215+
* 2. We need to manually construct the JSON structure with proper "_type" fields
216+
* <p>
217+
* The method handles:
218+
* - Serializing the group type (AND, OR, NOT)
219+
* - Recursively serializing each nested predicate in the predicates array
220+
* - Maintaining the correct JSON structure expected by the deserializer
221+
*
222+
* @param group The QueryPredicateGroup to serialize
223+
* @param context The serialization context for handling nested QueryOperator objects
224+
* @return JsonElement representing the serialized group
225+
*/
226+
private JsonElement serializeQueryPredicateGroup(QueryPredicateGroup group, JsonSerializationContext context) {
227+
JsonObject jsonObject = new JsonObject();
228+
jsonObject.addProperty("type", group.type().name());
229+
230+
JsonArray predicatesArray = new JsonArray();
231+
for (QueryPredicate predicate : group.predicates()) {
232+
// Recursively serialize nested predicates using this adapter
233+
predicatesArray.add(serialize(predicate, QueryPredicate.class, context));
234+
}
235+
jsonObject.add("predicates", predicatesArray);
236+
237+
return jsonObject;
238+
}
239+
240+
/**
241+
* Deserializes a JSON object into a QueryPredicateGroup.
242+
* <p>
243+
* This method is necessary because QueryPredicateGroup contains nested QueryPredicate objects
244+
* that need to be recursively deserialized. We cannot use context.deserialize() directly
245+
* because:
246+
* 1. Using context.deserialize() would cause infinite recursion back to this adapter
247+
* 2. We need to manually parse the JSON structure and handle nested predicates
248+
* <p>
249+
* The method handles:
250+
* - Parsing the group type (AND, OR, NOT) from the "type" field
251+
* - Recursively deserializing each predicate in the "predicates" array
252+
* - Creating the QueryPredicateGroup with the correct constructor (no builder available)
253+
* <p>
254+
* This is critical for DataStore sync expressions that contain nested predicate groups,
255+
* which caused the "Interfaces can't be instantiated" error before this fix.
256+
*
257+
* @param jsonObject The JSON object containing the group data
258+
* @return QueryPredicateGroup instance with all nested predicates deserialized
259+
*/
260+
private QueryPredicateGroup deserializeQueryPredicateGroup(JsonObject jsonObject) {
261+
QueryPredicateGroup.Type type = QueryPredicateGroup.Type.valueOf(
262+
jsonObject.get("type").getAsString()
263+
);
264+
265+
List<QueryPredicate> predicates = new ArrayList<>();
266+
JsonArray predicatesArray = jsonObject.getAsJsonArray("predicates");
267+
for (JsonElement predicateElement : predicatesArray) {
268+
// Recursively deserialize nested predicates using this adapter
269+
// Note: Passing null for context since we handle recursion manually
270+
QueryPredicate predicate = deserialize(predicateElement, QueryPredicate.class, null);
271+
predicates.add(predicate);
272+
}
273+
274+
// Use constructor since QueryPredicateGroup doesn't have a builder
275+
return new QueryPredicateGroup(type, predicates);
276+
}
196277
}
197278
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2025 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+
package com.amplifyframework.core.model.query.predicate
16+
17+
import com.amplifyframework.testmodels.todo.Todo
18+
import com.google.gson.Gson
19+
import io.kotest.matchers.shouldBe
20+
import org.junit.Test
21+
22+
class GsonPredicateAdaptersTests {
23+
24+
private val gson = Gson().newBuilder().apply {
25+
GsonPredicateAdapters.register(this)
26+
}.create()
27+
28+
@Test
29+
fun `serialize and deserialize single predicate`() {
30+
val queryPredicate = Todo.ID.eq("123")
31+
val queryPredicateString = gson.toJson(queryPredicate)
32+
val expectedString = """
33+
{"field":"id","operator":{"value":"123","type":"EQUAL"},"_type":"OPERATION"}
34+
""".replace("\\s".toRegex(), "")
35+
queryPredicateString shouldBe expectedString
36+
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
37+
deserializedPredicate shouldBe queryPredicate
38+
}
39+
40+
@Test
41+
fun `serialize and deserialize group predicate`() {
42+
val queryPredicate = Todo.TITLE.eq("Title").and(Todo.ID.eq("123"))
43+
val queryPredicateString = gson.toJson(queryPredicate)
44+
val expectedString = """
45+
{
46+
"type":"AND",
47+
"predicates":[
48+
{"field":"title","operator":{"value":"Title","type":"EQUAL"},"_type":"OPERATION"},
49+
{"field":"id","operator":{"value":"123","type":"EQUAL"},"_type":"OPERATION"}
50+
],
51+
"_type":"GROUP"
52+
}
53+
""".replace("\\s".toRegex(), "")
54+
queryPredicateString shouldBe expectedString
55+
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
56+
deserializedPredicate shouldBe queryPredicate
57+
}
58+
59+
@Test
60+
fun `serialize and deserialize nested group predicates`() {
61+
val queryPredicate = Todo.TITLE.eq("Title")
62+
.and(Todo.ID.eq("123").or(Todo.ID.eq("456")))
63+
val queryPredicateString = gson.toJson(queryPredicate)
64+
val expectedString = """
65+
{
66+
"type": "AND",
67+
"predicates": [
68+
{
69+
"field": "title",
70+
"operator": {
71+
"value": "Title",
72+
"type": "EQUAL"
73+
},
74+
"_type": "OPERATION"
75+
},
76+
{
77+
"type": "OR",
78+
"predicates": [
79+
{
80+
"field": "id",
81+
"operator": {
82+
"value": "123",
83+
"type": "EQUAL"
84+
},
85+
"_type": "OPERATION"
86+
},
87+
{
88+
"field": "id",
89+
"operator": {
90+
"value": "456",
91+
"type": "EQUAL"
92+
},
93+
"_type": "OPERATION"
94+
}
95+
],
96+
"_type": "GROUP"
97+
}
98+
],
99+
"_type": "GROUP"
100+
}
101+
""".replace("\\s".toRegex(), "")
102+
queryPredicateString shouldBe expectedString
103+
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
104+
deserializedPredicate shouldBe queryPredicate
105+
}
106+
}

aws-datastore/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies {
4747
testImplementation(libs.test.androidx.core)
4848
testImplementation(libs.test.mockk)
4949
testImplementation(libs.test.kotlin.coroutines)
50+
testImplementation(libs.test.kotest.assertions)
5051

5152
androidTestImplementation(libs.test.mockito.core)
5253
androidTestImplementation(project(":testmodels"))
@@ -61,4 +62,5 @@ dependencies {
6162
androidTestImplementation(libs.rxjava)
6263
androidTestImplementation(libs.okhttp)
6364
androidTestImplementation(libs.oauth2)
65+
androidTestImplementation(libs.test.kotest.assertions)
6466
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2025 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+
package com.amplifyframework.datastore.storage.sqlite
16+
17+
import android.content.Context
18+
import android.database.sqlite.SQLiteDatabase
19+
import androidx.test.core.app.ApplicationProvider
20+
import com.amplifyframework.datastore.storage.SynchronousStorageAdapter
21+
import com.amplifyframework.datastore.syncengine.MigrationFlagsTable
22+
import com.amplifyframework.testmodels.commentsblog.AmplifyModelProvider
23+
import io.kotest.assertions.withClue
24+
import io.kotest.matchers.ints.shouldBePositive
25+
import org.junit.After
26+
import org.junit.Before
27+
import org.junit.Test
28+
29+
/**
30+
* Test the creation functionality of [SQLiteStorageAdapter] operations.
31+
*/
32+
class SQLiteStorageAdapterCreateTest {
33+
private lateinit var adapter: SynchronousStorageAdapter
34+
35+
/**
36+
* Remove any old database files, and then re-provision a new storage adapter,
37+
* that is able to store the Comment-Blog family of models.
38+
*/
39+
@Before
40+
fun setup() {
41+
TestStorageAdapter.cleanup()
42+
this.adapter = TestStorageAdapter.create(AmplifyModelProvider.getInstance())
43+
}
44+
45+
/**
46+
* Close the open database, and cleanup any database files that it left.
47+
*/
48+
@After
49+
fun teardown() {
50+
TestStorageAdapter.cleanup(adapter)
51+
}
52+
53+
/**
54+
* Test that initial creation creates migration flags table with initial entries.
55+
*/
56+
@Test
57+
fun verifyMigrationFlagsTableExistsAndContainsRecordsOnCreate() {
58+
// Verify migration flags table exists and has initial entries
59+
val dbPath = ApplicationProvider.getApplicationContext<Context>()
60+
.getDatabasePath(SQLiteStorageAdapter.DEFAULT_DATABASE_NAME).absolutePath
61+
val database = SQLiteDatabase.openDatabase(
62+
dbPath,
63+
null,
64+
SQLiteDatabase.OPEN_READONLY
65+
)
66+
try {
67+
database.rawQuery(
68+
"SELECT 1 FROM " + MigrationFlagsTable.TABLE_NAME +
69+
" WHERE " + MigrationFlagsTable.COLUMN_FLAG_NAME + " = ?",
70+
arrayOf(
71+
MigrationFlagsTable.CLEARED_V2_30_0_AND_BELOW_GROUP_SYNC_EXPRESSIONS
72+
)
73+
).use { cursor ->
74+
withClue("Migration flag row was not created") {
75+
cursor.count.shouldBePositive()
76+
}
77+
}
78+
} finally {
79+
database.close()
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)