Merge "Implementing a check for verifying that equals() and hashCode() are implemented by the key of a multimap query result." into androidx-main
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)