Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aws-api-appsync/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.jsonassert)
testImplementation(libs.test.kotest.assertions)
testImplementation(project(":testmodels"))
testImplementation(project(":testutils"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
Expand All @@ -26,6 +27,8 @@
import com.google.gson.JsonSerializer;

import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;

/**
* Gson adapters to serialize/deserialize to/from data modeling types.
Expand Down Expand Up @@ -159,7 +162,9 @@ public QueryPredicate deserialize(JsonElement json, Type type, JsonDeserializati
case OPERATION:
return gson.fromJson(json, QueryPredicateOperation.class);
case GROUP:
return gson.fromJson(json, QueryPredicateGroup.class);
// We need to manually deserialize Groups to ensure we handle nested groups
// and update _types correctly.
return deserializeQueryPredicateGroup(jsonObject);
case ALL:
return gson.fromJson(json, MatchAllQueryPredicate.class);
case NONE:
Expand All @@ -176,22 +181,98 @@ public QueryPredicate deserialize(JsonElement json, Type type, JsonDeserializati
@Override
public JsonElement serialize(QueryPredicate predicate, Type type, JsonSerializationContext context)
throws JsonParseException {
JsonElement json = gson.toJsonTree(predicate);
JsonElement json;
PredicateType predicateType;
if (predicate instanceof MatchAllQueryPredicate) {
predicateType = PredicateType.ALL;
} else if (predicate instanceof MatchNoneQueryPredicate) {
predicateType = PredicateType.NONE;
} else if (predicate instanceof QueryPredicateOperation) {
predicateType = PredicateType.OPERATION;
} else if (predicate instanceof QueryPredicateGroup) {
if (predicate instanceof QueryPredicateGroup) {
// We need to manually serialize Groups to ensure we handle nested groups and
// update _types correctly.
predicateType = PredicateType.GROUP;
json = serializeQueryPredicateGroup((QueryPredicateGroup) predicate, context);
} else {
throw new JsonParseException("Unable to identify the predicate type.");
json = gson.toJsonTree(predicate);
if (predicate instanceof MatchAllQueryPredicate) {
predicateType = PredicateType.ALL;
} else if (predicate instanceof MatchNoneQueryPredicate) {
predicateType = PredicateType.NONE;
} else if (predicate instanceof QueryPredicateOperation) {
predicateType = PredicateType.OPERATION;
} else {
throw new JsonParseException("Unable to identify the predicate type.");
}
}
JsonObject jsonObject = json.getAsJsonObject();
jsonObject.addProperty(TYPE, predicateType.name());
return jsonObject;
}

/**
* Serializes a QueryPredicateGroup to JSON format.
* <p>
* This method is necessary because QueryPredicateGroup contains nested QueryPredicate objects
* that need to be recursively serialized. We cannot use context.serialize() directly on
* QueryPredicateGroup because:
* 1. Using context.serialize() would cause infinite recursion back to this adapter
* 2. We need to manually construct the JSON structure with proper "_type" fields
* <p>
* The method handles:
* - Serializing the group type (AND, OR, NOT)
* - Recursively serializing each nested predicate in the predicates array
* - Maintaining the correct JSON structure expected by the deserializer
*
* @param group The QueryPredicateGroup to serialize
* @param context The serialization context for handling nested QueryOperator objects
* @return JsonElement representing the serialized group
*/
private JsonElement serializeQueryPredicateGroup(QueryPredicateGroup group, JsonSerializationContext context) {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("type", group.type().name());

JsonArray predicatesArray = new JsonArray();
for (QueryPredicate predicate : group.predicates()) {
// Recursively serialize nested predicates using this adapter
predicatesArray.add(serialize(predicate, QueryPredicate.class, context));
}
jsonObject.add("predicates", predicatesArray);

return jsonObject;
}

/**
* Deserializes a JSON object into a QueryPredicateGroup.
* <p>
* This method is necessary because QueryPredicateGroup contains nested QueryPredicate objects
* that need to be recursively deserialized. We cannot use context.deserialize() directly
* because:
* 1. Using context.deserialize() would cause infinite recursion back to this adapter
* 2. We need to manually parse the JSON structure and handle nested predicates
* <p>
* The method handles:
* - Parsing the group type (AND, OR, NOT) from the "type" field
* - Recursively deserializing each predicate in the "predicates" array
* - Creating the QueryPredicateGroup with the correct constructor (no builder available)
* <p>
* This is critical for DataStore sync expressions that contain nested predicate groups,
* which caused the "Interfaces can't be instantiated" error before this fix.
*
* @param jsonObject The JSON object containing the group data
* @return QueryPredicateGroup instance with all nested predicates deserialized
*/
private QueryPredicateGroup deserializeQueryPredicateGroup(JsonObject jsonObject) {
QueryPredicateGroup.Type type = QueryPredicateGroup.Type.valueOf(
jsonObject.get("type").getAsString()
);

List<QueryPredicate> predicates = new ArrayList<>();
JsonArray predicatesArray = jsonObject.getAsJsonArray("predicates");
for (JsonElement predicateElement : predicatesArray) {
// Recursively deserialize nested predicates using this adapter
// Note: Passing null for context since we handle recursion manually
QueryPredicate predicate = deserialize(predicateElement, QueryPredicate.class, null);
predicates.add(predicate);
}

// Use constructor since QueryPredicateGroup doesn't have a builder
return new QueryPredicateGroup(type, predicates);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.core.model.query.predicate

import com.amplifyframework.testmodels.todo.Todo
import com.google.gson.Gson
import io.kotest.matchers.shouldBe
import org.junit.Test

class GsonPredicateAdaptersTests {

private val gson = Gson().newBuilder().apply {
GsonPredicateAdapters.register(this)
}.create()

@Test
fun `serialize and deserialize single predicate`() {
val queryPredicate = Todo.ID.eq("123")
val queryPredicateString = gson.toJson(queryPredicate)
val expectedString = """
{"field":"id","operator":{"value":"123","type":"EQUAL"},"_type":"OPERATION"}
""".replace("\\s".toRegex(), "")
queryPredicateString shouldBe expectedString
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
deserializedPredicate shouldBe queryPredicate
}

@Test
fun `serialize and deserialize group predicate`() {
val queryPredicate = Todo.TITLE.eq("Title").and(Todo.ID.eq("123"))
val queryPredicateString = gson.toJson(queryPredicate)
val expectedString = """
{
"type":"AND",
"predicates":[
{"field":"title","operator":{"value":"Title","type":"EQUAL"},"_type":"OPERATION"},
{"field":"id","operator":{"value":"123","type":"EQUAL"},"_type":"OPERATION"}
],
"_type":"GROUP"
}
""".replace("\\s".toRegex(), "")
queryPredicateString shouldBe expectedString
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
deserializedPredicate shouldBe queryPredicate
}

@Test
fun `serialize and deserialize nested group predicates`() {
val queryPredicate = Todo.TITLE.eq("Title")
.and(Todo.ID.eq("123").or(Todo.ID.eq("456")))
val queryPredicateString = gson.toJson(queryPredicate)
val expectedString = """
{
"type": "AND",
"predicates": [
{
"field": "title",
"operator": {
"value": "Title",
"type": "EQUAL"
},
"_type": "OPERATION"
},
{
"type": "OR",
"predicates": [
{
"field": "id",
"operator": {
"value": "123",
"type": "EQUAL"
},
"_type": "OPERATION"
},
{
"field": "id",
"operator": {
"value": "456",
"type": "EQUAL"
},
"_type": "OPERATION"
}
],
"_type": "GROUP"
}
],
"_type": "GROUP"
}
""".replace("\\s".toRegex(), "")
queryPredicateString shouldBe expectedString
val deserializedPredicate = gson.fromJson(queryPredicateString, QueryPredicate::class.java)
deserializedPredicate shouldBe queryPredicate
}
}
2 changes: 2 additions & 0 deletions aws-datastore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies {
testImplementation(libs.test.androidx.core)
testImplementation(libs.test.mockk)
testImplementation(libs.test.kotlin.coroutines)
testImplementation(libs.test.kotest.assertions)

androidTestImplementation(libs.test.mockito.core)
androidTestImplementation(project(":testmodels"))
Expand All @@ -61,4 +62,5 @@ dependencies {
androidTestImplementation(libs.rxjava)
androidTestImplementation(libs.okhttp)
androidTestImplementation(libs.oauth2)
androidTestImplementation(libs.test.kotest.assertions)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.datastore.storage.sqlite

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.amplifyframework.datastore.storage.SynchronousStorageAdapter
import com.amplifyframework.datastore.syncengine.MigrationFlagsTable
import com.amplifyframework.testmodels.commentsblog.AmplifyModelProvider
import io.kotest.assertions.withClue
import io.kotest.matchers.ints.shouldBePositive
import org.junit.After
import org.junit.Before
import org.junit.Test

/**
* Test the creation functionality of [SQLiteStorageAdapter] operations.
*/
class SQLiteStorageAdapterCreateTest {
private lateinit var adapter: SynchronousStorageAdapter

/**
* Remove any old database files, and then re-provision a new storage adapter,
* that is able to store the Comment-Blog family of models.
*/
@Before
fun setup() {
TestStorageAdapter.cleanup()
this.adapter = TestStorageAdapter.create(AmplifyModelProvider.getInstance())
}

/**
* Close the open database, and cleanup any database files that it left.
*/
@After
fun teardown() {
TestStorageAdapter.cleanup(adapter)
}

/**
* Test that initial creation creates migration flags table with initial entries.
*/
@Test
fun verifyMigrationFlagsTableExistsAndContainsRecordsOnCreate() {
// Verify migration flags table exists and has initial entries
val dbPath = ApplicationProvider.getApplicationContext<Context>()
.getDatabasePath(SQLiteStorageAdapter.DEFAULT_DATABASE_NAME).absolutePath
val database = SQLiteDatabase.openDatabase(
dbPath,
null,
SQLiteDatabase.OPEN_READONLY
)
try {
database.rawQuery(
"SELECT 1 FROM " + MigrationFlagsTable.TABLE_NAME +
" WHERE " + MigrationFlagsTable.COLUMN_FLAG_NAME + " = ?",
arrayOf(
MigrationFlagsTable.CLEARED_V2_30_0_AND_BELOW_GROUP_SYNC_EXPRESSIONS
)
).use { cursor ->
withClue("Migration flag row was not created") {
cursor.count.shouldBePositive()
}
}
} finally {
database.close()
}
}
}
Loading