Implementing a check for verifying that equals() and hashCode() are implemented by the key of a multimap query result.

The function implementsEqualsAndHashcode() takes in the XType representing the type argument of the map key, and recursively looks for the equals() and hashCode() methods among the declared methods of type element class. If they are not found at the current class level, the method recursively moves on to the super class level and continues to look for these declared methods until they are either found, or the method reaches the topmost level (the Object class) in which case the method returns false.

Bug: 187490856
Test: QueryMethodProcessorTest.kt
Relnote: Added a check for verifying that equals() and hashCode() are implemented by the key of a multimap query result.
Change-Id: I828b0612735f0d2702226b4e6238e857b3fbd7f0
diff --git a/room/room-common/api/current.txt b/room/room-common/api/current.txt
index a7a1d84..3ad7e2a 100644
--- a/room/room-common/api/current.txt
+++ b/room/room-common/api/current.txt
@@ -215,6 +215,7 @@
     field public static final String CANNOT_CREATE_VERIFICATION_DATABASE = "ROOM_CANNOT_CREATE_VERIFICATION_DATABASE";
     field public static final String CURSOR_MISMATCH = "ROOM_CURSOR_MISMATCH";
     field public static final String DEFAULT_CONSTRUCTOR = "ROOM_DEFAULT_CONSTRUCTOR";
+    field public static final String DOES_NOT_IMPLEMENT_EQUALS_HASHCODE = "ROOM_TYPE_DOES_NOT_IMPLEMENT_EQUALS_HASHCODE";
     field public static final String INDEX_FROM_EMBEDDED_ENTITY_IS_DROPPED = "ROOM_EMBEDDED_ENTITY_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_EMBEDDED_FIELD_IS_DROPPED = "ROOM_EMBEDDED_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_PARENT_FIELD_IS_DROPPED = "ROOM_PARENT_FIELD_INDEX_IS_DROPPED";
diff --git a/room/room-common/api/public_plus_experimental_current.txt b/room/room-common/api/public_plus_experimental_current.txt
index a7a1d84..3ad7e2a 100644
--- a/room/room-common/api/public_plus_experimental_current.txt
+++ b/room/room-common/api/public_plus_experimental_current.txt
@@ -215,6 +215,7 @@
     field public static final String CANNOT_CREATE_VERIFICATION_DATABASE = "ROOM_CANNOT_CREATE_VERIFICATION_DATABASE";
     field public static final String CURSOR_MISMATCH = "ROOM_CURSOR_MISMATCH";
     field public static final String DEFAULT_CONSTRUCTOR = "ROOM_DEFAULT_CONSTRUCTOR";
+    field public static final String DOES_NOT_IMPLEMENT_EQUALS_HASHCODE = "ROOM_TYPE_DOES_NOT_IMPLEMENT_EQUALS_HASHCODE";
     field public static final String INDEX_FROM_EMBEDDED_ENTITY_IS_DROPPED = "ROOM_EMBEDDED_ENTITY_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_EMBEDDED_FIELD_IS_DROPPED = "ROOM_EMBEDDED_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_PARENT_FIELD_IS_DROPPED = "ROOM_PARENT_FIELD_INDEX_IS_DROPPED";
diff --git a/room/room-common/api/restricted_current.txt b/room/room-common/api/restricted_current.txt
index 220d387..722a507 100644
--- a/room/room-common/api/restricted_current.txt
+++ b/room/room-common/api/restricted_current.txt
@@ -224,6 +224,7 @@
     field public static final String CANNOT_CREATE_VERIFICATION_DATABASE = "ROOM_CANNOT_CREATE_VERIFICATION_DATABASE";
     field public static final String CURSOR_MISMATCH = "ROOM_CURSOR_MISMATCH";
     field public static final String DEFAULT_CONSTRUCTOR = "ROOM_DEFAULT_CONSTRUCTOR";
+    field public static final String DOES_NOT_IMPLEMENT_EQUALS_HASHCODE = "ROOM_TYPE_DOES_NOT_IMPLEMENT_EQUALS_HASHCODE";
     field public static final String INDEX_FROM_EMBEDDED_ENTITY_IS_DROPPED = "ROOM_EMBEDDED_ENTITY_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_EMBEDDED_FIELD_IS_DROPPED = "ROOM_EMBEDDED_INDEX_IS_DROPPED";
     field public static final String INDEX_FROM_PARENT_FIELD_IS_DROPPED = "ROOM_PARENT_FIELD_INDEX_IS_DROPPED";
diff --git a/room/room-common/src/main/java/androidx/room/RoomWarnings.java b/room/room-common/src/main/java/androidx/room/RoomWarnings.java
index 919e42f..df8868b 100644
--- a/room/room-common/src/main/java/androidx/room/RoomWarnings.java
+++ b/room/room-common/src/main/java/androidx/room/RoomWarnings.java
@@ -31,6 +31,13 @@
     public static final String CURSOR_MISMATCH = "ROOM_CURSOR_MISMATCH";
 
     /**
+     * The warning dispatched by Room when the object in the provided method's multimap return
+     * type does not implement equals() and hashCode().
+     */
+    public static final String DOES_NOT_IMPLEMENT_EQUALS_HASHCODE =
+            "ROOM_TYPE_DOES_NOT_IMPLEMENT_EQUALS_HASHCODE";
+
+    /**
      * Reported when Room cannot verify database queries during compilation due to lack of
      * tmp dir access in JVM.
      */
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/ext/xtype_ext.kt b/room/room-compiler/src/main/kotlin/androidx/room/ext/xtype_ext.kt
index cc94880..f003876 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/ext/xtype_ext.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/ext/xtype_ext.kt
@@ -21,6 +21,8 @@
 import androidx.room.compiler.processing.isKotlinUnit
 import androidx.room.compiler.processing.isVoid
 import androidx.room.compiler.processing.isVoidObject
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.TypeName
 
 /**
  * Returns `true` if this type is not the `void` type.
@@ -51,3 +53,36 @@
  * Returns `true` if this is not `byte` type.
  */
 fun XType.isNotByte() = !isByte()
+
+/**
+ * Checks if the class of the provided type has the equals() and hashCode() methods declared.
+ * If they are not found at the current class level, the method recursively moves on to the
+ * super class level and continues to look for these declared methods.
+ */
+fun XType.implementsEqualsAndHashcode(): Boolean {
+    if (this.typeName.isPrimitive || this.typeName.isBoxedPrimitive) {
+        return true
+    }
+    val typeElement = this.typeElement ?: return false
+
+    if (typeElement.className == ClassName.OBJECT) {
+        return false
+    }
+
+    val hasEquals = typeElement.getDeclaredMethods().any {
+        it.name == "equals" &&
+            it.returnType.typeName == TypeName.BOOLEAN &&
+            it.parameters.count() == 1 &&
+            it.parameters[0].type.typeName == TypeName.OBJECT
+    }
+    val hasHashCode = typeElement.getDeclaredMethods().any {
+        it.name == "hashCode" &&
+            it.returnType.typeName == TypeName.INT &&
+            it.parameters.count() == 0
+    }
+
+    if (hasEquals && hasHashCode) {
+        return true
+    }
+    return typeElement.superType?.let { it.implementsEqualsAndHashcode() } ?: false
+}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
index eecf7b1..656b9e8 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
@@ -132,6 +132,10 @@
     fun cannotFindQueryResultAdapter(returnTypeName: TypeName) = "Not sure how to convert a " +
         "Cursor to this method's return type ($returnTypeName)."
 
+    fun classMustImplementEqualsAndHashCode(mapType: TypeName, keyType: TypeName) = "The key" +
+        " of the provided method's multimap return type ($mapType) must implement equals() and " +
+        "hashCode(). Key type is: $keyType."
+
     val INSERTION_DOES_NOT_HAVE_ANY_PARAMETERS_TO_INSERT = "Method annotated with" +
         " @Insert but does not have any parameters to insert."
 
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
index e42de73..ae7a141 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
@@ -21,6 +21,7 @@
 import androidx.room.compiler.processing.isEnum
 import androidx.room.ext.CommonTypeNames
 import androidx.room.ext.GuavaBaseTypeNames
+import androidx.room.ext.implementsEqualsAndHashcode
 import androidx.room.ext.isEntityElement
 import androidx.room.ext.isNotByte
 import androidx.room.ext.isNotKotlinUnit
@@ -33,6 +34,7 @@
 import androidx.room.processor.FieldProcessor
 import androidx.room.processor.PojoProcessor
 import androidx.room.processor.ProcessorErrors.DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP
+import androidx.room.processor.ProcessorErrors.classMustImplementEqualsAndHashCode
 import androidx.room.solver.binderprovider.CoroutineFlowResultBinderProvider
 import androidx.room.solver.binderprovider.CursorQueryResultBinderProvider
 import androidx.room.solver.binderprovider.DataSourceFactoryQueryResultBinderProvider
@@ -96,13 +98,14 @@
 import androidx.room.solver.types.StringColumnTypeAdapter
 import androidx.room.solver.types.TypeConverter
 import androidx.room.vo.ShortcutQueryParameter
+import androidx.room.vo.Warning
 import com.google.common.annotations.VisibleForTesting
 import com.google.common.collect.ImmutableList
 import com.google.common.collect.ImmutableListMultimap
 import com.google.common.collect.ImmutableMultimap
 import com.google.common.collect.ImmutableSetMultimap
-import com.squareup.javapoet.ClassName
 import com.google.common.collect.ImmutableMap
+import com.squareup.javapoet.ClassName
 import java.util.LinkedList
 
 @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
@@ -521,9 +524,6 @@
                 immutableClassName = immutableClassName
             )
         } else if (typeMirror.isTypeOf(java.util.Map::class)) {
-            // TODO: Handle nested collection values in the map
-            // TODO: Verify that hashCode() and equals() are declared by the keyTypeArg
-
             val keyTypeArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
             val mapValueTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
 
@@ -534,9 +534,19 @@
                 )
                 return null
             }
+            // TODO: Handle nested collection values in the map
+            if (!keyTypeArg.implementsEqualsAndHashcode()) {
+                context.logger.w(
+                    Warning.DOES_NOT_IMPLEMENT_EQUALS_HASHCODE,
+                    keyTypeArg.typeElement,
+                    classMustImplementEqualsAndHashCode(
+                        typeMirror.typeName,
+                        keyTypeArg.typeName
+                    )
+                )
+            }
 
             val collectionTypeRaw = context.COMMON_TYPES.READONLY_COLLECTION.rawType
-
             if (collectionTypeRaw.isAssignableFrom(mapValueTypeArg.rawType)) {
                 // The Map's value type argument is assignable to a Collection, we need to make
                 // sure it is either a list or a set.
@@ -544,8 +554,7 @@
                     mapValueTypeArg.isTypeOf(java.util.List::class) ||
                     mapValueTypeArg.isTypeOf(java.util.Set::class)
                 ) {
-                    val valueTypeArg =
-                        mapValueTypeArg.typeArguments.single().extendsBoundOrSelf()
+                    val valueTypeArg = mapValueTypeArg.typeArguments.single().extendsBoundOrSelf()
                     return MapQueryResultAdapter(
                         keyTypeArg = keyTypeArg,
                         valueTypeArg = valueTypeArg,
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/vo/Warning.kt b/room/room-compiler/src/main/kotlin/androidx/room/vo/Warning.kt
index e825f29..fc3bd6a 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/vo/Warning.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/vo/Warning.kt
@@ -25,6 +25,7 @@
 enum class Warning(val publicKey: String) {
     ALL("ALL"),
     CURSOR_MISMATCH("ROOM_CURSOR_MISMATCH"),
+    DOES_NOT_IMPLEMENT_EQUALS_HASHCODE("ROOM_TYPE_DOES_NOT_IMPLEMENT_EQUALS_HASHCODE"),
     MISSING_JAVA_TMP_DIR("ROOM_MISSING_JAVA_TMP_DIR"),
     CANNOT_CREATE_VERIFICATION_DATABASE("ROOM_CANNOT_CREATE_VERIFICATION_DATABASE"),
     PRIMARY_KEY_FROM_EMBEDDED_IS_DROPPED("ROOM_EMBEDDED_PRIMARY_KEY_IS_DROPPED"),
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
index 8659c89..3e9bbfd 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
@@ -988,6 +988,18 @@
             """
                 public static class Username {
                     public String name;
+                    @Override
+                    public boolean equals(Object o) {
+                        if (this == o) return true;
+                        if (o == null || getClass() != o.getClass()) return false;
+                        Username username = (Username) o;
+                        if (name != username.name) return false;
+                        return true;
+                    }
+                    @Override
+                    public int hashCode() {
+                        return name.hashCode();
+                    }
                 }
                 @RewriteQueriesToDropUnusedColumns
                 @Query("SELECT * FROM User JOIN Relation ON (User.uid = Relation.userId)")
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeAdapterStoreTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeAdapterStoreTest.kt
index 4b7ebf6..e9c4b7f 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeAdapterStoreTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeAdapterStoreTest.kt
@@ -35,6 +35,7 @@
 import androidx.room.ext.RxJava2TypeNames
 import androidx.room.ext.RxJava3TypeNames
 import androidx.room.ext.T
+import androidx.room.ext.implementsEqualsAndHashcode
 import androidx.room.parser.SQLTypeAffinity
 import androidx.room.processor.Context
 import androidx.room.processor.CustomConverterProcessor
@@ -1139,6 +1140,152 @@
         }
     }
 
+    @Test
+    fun testEqualsAndHashcodeImplemented() {
+        val classExtendsClassWithEqualsAndHashcodeFunctions = Source.java(
+            "foo.bar.Human",
+            """
+            package foo.bar;
+            public class Human extends Username {
+                public String relationId;
+            }
+            """.trimIndent()
+        )
+        val classWithFncs = Source.java(
+            "foo.bar.Username",
+            """
+            package foo.bar;
+            public class Username extends Person {
+                public String name;
+                @Override
+                public boolean equals(Object o) {
+                    return false;
+                }
+                @Override
+                public int hashCode() {
+                    return 0;
+                }
+            }
+            """.trimIndent()
+        )
+        val classWithoutFncs = Source.java(
+            "foo.bar.Person",
+            """
+            package foo.bar;
+            public class Person {
+                public String userId;
+            }
+            """.trimIndent()
+        )
+        val enumClass = Source.java(
+            "foo.bar.Names",
+            """
+            package foo.bar;
+            public enum Names {
+                ELLA,
+                BOB,
+                JAMES
+            }
+            """.trimIndent()
+        )
+        val classWithWrongFncs = Source.java(
+            "foo.bar.UsernameWithWrongFncs",
+            """
+            package foo.bar;
+            public class UsernameWithWrongFncs {
+                public String name;
+                public boolean equals() {
+                    return true;
+                }
+                public int hashCode(int num) {
+                    return num;
+                }
+            }
+            """.trimIndent()
+        )
+        runProcessorTest(
+            sources = listOf(
+                classExtendsClassWithEqualsAndHashcodeFunctions,
+                classWithFncs,
+                classWithoutFncs,
+                enumClass,
+                classWithWrongFncs
+            )
+        ) { invocation ->
+            val enumCase = invocation.processingEnv.requireTypeElement("foo.bar.Names")
+            val inheritedCase = invocation.processingEnv.requireTypeElement("foo.bar.Human")
+            val wrongFunctionsCase = invocation.processingEnv.requireTypeElement(
+                "foo.bar.UsernameWithWrongFncs"
+            )
+            val noEqualsOrHashcodeCase = invocation.processingEnv.requireTypeElement(
+                "foo.bar.Person"
+            )
+            assertThat(enumCase.type.implementsEqualsAndHashcode()).isTrue()
+            assertThat(inheritedCase.type.implementsEqualsAndHashcode()).isTrue()
+            assertThat(wrongFunctionsCase.type.implementsEqualsAndHashcode()).isFalse()
+            assertThat(noEqualsOrHashcodeCase.type.implementsEqualsAndHashcode()).isFalse()
+        }
+    }
+
+    @Test
+    fun testEqualsAndHashcodeCheckWithJavaPrimitive() {
+        val inputSource = Source.java(
+            "foo.bar.Subject",
+            """
+            package foo.bar;
+            public class Subject {
+                public int primitiveInt = 0;
+                public Integer boxedInt = 1;
+                public boolean primitiveBool = true;
+                public Boolean boxedBool = false;
+                public double primitiveDouble = 2.2;
+                public Double boxedDouble = 3.3;
+                public long primitiveLong = 4L;
+                public Long boxedLong = 5L;
+            }
+            """.trimIndent()
+        )
+        runProcessorTest(
+            sources = listOf(
+                inputSource,
+                COMMON.USER,
+                COMMON.PAGING_SOURCE,
+                COMMON.LIMIT_OFFSET_PAGING_SOURCE,
+            ),
+        ) { invocation ->
+            val subjectTypeElement =
+                invocation.processingEnv.requireTypeElement("foo.bar.Subject")
+            subjectTypeElement.getAllFieldsIncludingPrivateSupers().forEach { field ->
+                assertThat(field.type.implementsEqualsAndHashcode()).isTrue()
+            }
+        }
+    }
+
+    @Test
+    fun testEqualsAndHashcodeCheckWithKotlinPrimitive() {
+        val source = Source.kotlin(
+            "Foo.kt",
+            """
+            import androidx.room.*
+            class Subject {
+               val anInteger = 0
+               val aBoolean = true
+               val aDouble = 2.2
+               val aLong = 5L
+            }
+            """.trimIndent()
+        )
+        runProcessorTest(
+            sources = listOf(source)
+        ) { invocation ->
+            val subjectTypeElement = invocation.processingEnv.requireTypeElement("Subject")
+
+            subjectTypeElement.getDeclaredFields().forEach {
+                assertThat(it.type.implementsEqualsAndHashcode()).isTrue()
+            }
+        }
+    }
+
     private fun createIntListToStringBinders(invocation: XTestInvocation): List<TypeConverter> {
         val intType = invocation.processingEnv.requireType(Integer::class)
         val listElement = invocation.processingEnv.requireTypeElement(java.util.List::class)