Add table names of key and value columns in @MapInfo

This allow users with ambiguous columns and tables alias to make it clear which table.column should be used as key or value via @MapInfo.

Note that this CL specifically does not use the table information to resolve the column index on a duplicate column query, but might do so with a latter change.

Relnote: "Added APIs for providing key and value tables names for disambiguation in @MapInfo"
Test: QueryMethodProcessorTest
Change-Id: Icc4b50a029d33c49fbfe7265d10b6be1b15da9c3
diff --git a/room/room-common/api/current.txt b/room/room-common/api/current.txt
index 6323e9e..596a7b2 100644
--- a/room/room-common/api/current.txt
+++ b/room/room-common/api/current.txt
@@ -255,9 +255,13 @@
 
   @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public @interface MapInfo {
     method public abstract String keyColumn() default "";
+    method public abstract String keyTable() default "";
     method public abstract String valueColumn() default "";
+    method public abstract String valueTable() default "";
     property public abstract String keyColumn;
+    property public abstract String keyTable;
     property public abstract String valueColumn;
+    property public abstract String valueTable;
   }
 
   @IntDef({androidx.room.OnConflictStrategy.Companion.NONE, androidx.room.OnConflictStrategy.Companion.REPLACE, androidx.room.OnConflictStrategy.Companion.ROLLBACK, androidx.room.OnConflictStrategy.Companion.ABORT, androidx.room.OnConflictStrategy.Companion.FAIL, androidx.room.OnConflictStrategy.Companion.IGNORE}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface OnConflictStrategy {
diff --git a/room/room-common/api/public_plus_experimental_current.txt b/room/room-common/api/public_plus_experimental_current.txt
index 6323e9e..596a7b2 100644
--- a/room/room-common/api/public_plus_experimental_current.txt
+++ b/room/room-common/api/public_plus_experimental_current.txt
@@ -255,9 +255,13 @@
 
   @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public @interface MapInfo {
     method public abstract String keyColumn() default "";
+    method public abstract String keyTable() default "";
     method public abstract String valueColumn() default "";
+    method public abstract String valueTable() default "";
     property public abstract String keyColumn;
+    property public abstract String keyTable;
     property public abstract String valueColumn;
+    property public abstract String valueTable;
   }
 
   @IntDef({androidx.room.OnConflictStrategy.Companion.NONE, androidx.room.OnConflictStrategy.Companion.REPLACE, androidx.room.OnConflictStrategy.Companion.ROLLBACK, androidx.room.OnConflictStrategy.Companion.ABORT, androidx.room.OnConflictStrategy.Companion.FAIL, androidx.room.OnConflictStrategy.Companion.IGNORE}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface OnConflictStrategy {
diff --git a/room/room-common/api/restricted_current.txt b/room/room-common/api/restricted_current.txt
index 80ed020..b3db08c 100644
--- a/room/room-common/api/restricted_current.txt
+++ b/room/room-common/api/restricted_current.txt
@@ -255,9 +255,13 @@
 
   @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.FUNCTION) public @interface MapInfo {
     method public abstract String keyColumn() default "";
+    method public abstract String keyTable() default "";
     method public abstract String valueColumn() default "";
+    method public abstract String valueTable() default "";
     property public abstract String keyColumn;
+    property public abstract String keyTable;
     property public abstract String valueColumn;
+    property public abstract String valueTable;
   }
 
   @IntDef({androidx.room.OnConflictStrategy.Companion.NONE, androidx.room.OnConflictStrategy.Companion.REPLACE, androidx.room.OnConflictStrategy.Companion.ROLLBACK, androidx.room.OnConflictStrategy.Companion.ABORT, androidx.room.OnConflictStrategy.Companion.FAIL, androidx.room.OnConflictStrategy.Companion.IGNORE}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface OnConflictStrategy {
diff --git a/room/room-common/src/main/java/androidx/room/MapInfo.kt b/room/room-common/src/main/java/androidx/room/MapInfo.kt
index 4a17d362..526f949 100644
--- a/room/room-common/src/main/java/androidx/room/MapInfo.kt
+++ b/room/room-common/src/main/java/androidx/room/MapInfo.kt
@@ -55,9 +55,35 @@
     val keyColumn: String = "",
 
     /**
+     * The name of the table or alias to be used for the map's keys.
+     *
+     * Providing this value is optional. Useful for disambiguating between duplicate column names.
+     * For example, consider the following query:
+     * `SELECT * FROM Artist AS a JOIN Song AS s ON a.id == s.artistId`, then the `@MapInfo`
+     * for a return type `Map<String, List<Song>>` would be
+     * `@MapInfo(keyColumn = "id", keyTable ="a")`.
+     *
+     * @return The key table name.
+     */
+    val keyTable: String = "",
+
+    /**
      * The name of the column to be used for the map's values.
      *
      * @return The value column name.
      */
-    val valueColumn: String = ""
+    val valueColumn: String = "",
+
+    /**
+     * The name of the table or alias to be used for the map's values.
+     *
+     * Providing this value is optional. Useful for disambiguating between duplicate column names.
+     * For example, consider the following query:
+     * `SELECT * FROM Song AS s JOIN Artist AS a ON s.artistId == a.id`, then the `@MapInfo`
+     * for a return type `Map<Song, String>` would be
+     * `@MapInfo(valueColumn = "id", valueTable ="a")`.
+     *
+     * @return The key table name.
+     */
+    val valueTable: String = "",
 )
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/parser/SqlParser.kt b/room/room-compiler/src/main/kotlin/androidx/room/parser/SqlParser.kt
index 7b9df5e..b76b817 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/parser/SqlParser.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/parser/SqlParser.kt
@@ -341,4 +341,4 @@
 enum class FtsVersion {
     FTS3,
     FTS4;
-}
\ No newline at end of file
+}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/preconditions/Checks.kt b/room/room-compiler/src/main/kotlin/androidx/room/preconditions/Checks.kt
index 39fc569..3d0466c 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/preconditions/Checks.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/preconditions/Checks.kt
@@ -40,6 +40,13 @@
         return predicate
     }
 
+    fun check(predicate: Boolean, element: XElement, errorMsgProducer: () -> String): Boolean {
+        if (!predicate) {
+            logger.e(element, errorMsgProducer.invoke())
+        }
+        return predicate
+    }
+
     fun hasAnnotation(
         element: XElement,
         annotation: KClass<out Annotation>,
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 b8344d5..0df368f 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
@@ -147,25 +147,23 @@
         " @Delete but does not have any parameters to delete."
 
     fun cannotMapInfoSpecifiedColumn(column: String, columnsInQuery: List<String>) =
-        "Column(s) specified in the provided @MapInfo annotation must be present in the query. " +
-            "Provided: $column. Columns Found: ${columnsInQuery.joinToString(", ")}"
+        "Column specified in the provided @MapInfo annotation must be present in the query. " +
+            "Provided: $column. Columns found: ${columnsInQuery.joinToString(", ")}"
 
     val MAP_INFO_MUST_HAVE_AT_LEAST_ONE_COLUMN_PROVIDED = "To use the @MapInfo annotation, you " +
         "must provide either the key column name, value column name, or both."
 
     fun keyMayNeedMapInfo(keyArg: TypeName): String {
         return """
-            Looks like you may need to use @MapInfo to clarify the 'keyColumnName' needed for
-            the return type of a method. Type argument that needs
-            @MapInfo: $keyArg
+            Looks like you may need to use @MapInfo to clarify the 'keyColumn' needed for
+            the return type of a method. Type argument that needs @MapInfo: $keyArg
             """.trim()
     }
 
     fun valueMayNeedMapInfo(valueArg: TypeName): String {
         return """
-            Looks like you may need to use @MapInfo to clarify the 'valueColumnName' needed for
-            the return type of a method. Type argument that needs
-            @MapInfo: $valueArg
+            Looks like you may need to use @MapInfo to clarify the 'valueColumn' needed for
+            the return type of a method. Type argument that needs @MapInfo: $valueArg
             """.trim()
     }
 
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/QueryMethodProcessor.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/QueryMethodProcessor.kt
index d9c5a0f..a3f5b03 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/QueryMethodProcessor.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/QueryMethodProcessor.kt
@@ -19,6 +19,7 @@
 import androidx.room.Query
 import androidx.room.SkipQueryVerification
 import androidx.room.Transaction
+import androidx.room.compiler.processing.XAnnotationBox
 import androidx.room.compiler.processing.XMethodElement
 import androidx.room.compiler.processing.XType
 import androidx.room.ext.isNotError
@@ -26,7 +27,9 @@
 import androidx.room.parser.QueryType
 import androidx.room.parser.SqlParser
 import androidx.room.processor.ProcessorErrors.cannotMapInfoSpecifiedColumn
+import androidx.room.solver.TypeAdapterExtras
 import androidx.room.solver.query.result.PojoRowAdapter
+import androidx.room.verifier.ColumnInfo
 import androidx.room.verifier.DatabaseVerificationErrors
 import androidx.room.verifier.DatabaseVerifier
 import androidx.room.vo.MapInfo
@@ -210,28 +213,7 @@
     ): QueryMethod {
         val resultBinder = delegate.findResultBinder(returnType, query) {
             delegate.executableElement.getAnnotation(androidx.room.MapInfo::class)?.let {
-                mapInfoAnnotation ->
-                // Check if method is annotated with @MapInfo, parse annotation and put information in
-                // bag of extras, it will be later used by the TypeAdapterStore
-                val resultColumns = query.resultInfo?.columns?.map { it.name } ?: emptyList()
-                val keyColumn = mapInfoAnnotation.value.keyColumn.toString()
-                val valueColumn = mapInfoAnnotation.value.valueColumn.toString()
-                context.checker.check(
-                    keyColumn.isEmpty() || resultColumns.contains(keyColumn),
-                    delegate.executableElement,
-                    cannotMapInfoSpecifiedColumn(keyColumn, resultColumns)
-                )
-                context.checker.check(
-                    valueColumn.isEmpty() || resultColumns.contains(valueColumn),
-                    delegate.executableElement,
-                    cannotMapInfoSpecifiedColumn(valueColumn, resultColumns)
-                )
-                context.checker.check(
-                    keyColumn.isNotEmpty() || valueColumn.isNotEmpty(),
-                    executableElement,
-                    ProcessorErrors.MAP_INFO_MUST_HAVE_AT_LEAST_ONE_COLUMN_PROVIDED
-                )
-                putData(MapInfo::class, MapInfo(keyColumn, valueColumn))
+                processMapInfo(it, query, delegate.executableElement, this)
             }
         }
         context.checker.check(
@@ -292,6 +274,67 @@
         )
     }
 
+    /**
+     * Parse @MapInfo annotation, validate its inputs and put information in the bag of extras,
+     * it will be later used by the TypeAdapterStore.
+     */
+    private fun processMapInfo(
+        mapInfoAnnotation: XAnnotationBox<androidx.room.MapInfo>,
+        query: ParsedQuery,
+        queryExecutableElement: XMethodElement,
+        adapterExtras: TypeAdapterExtras,
+    ) {
+        val keyColumn = mapInfoAnnotation.value.keyColumn
+        val keyTable = mapInfoAnnotation.value.keyTable.ifEmpty { null }
+        val valueColumn = mapInfoAnnotation.value.valueColumn
+        val valueTable = mapInfoAnnotation.value.valueTable.ifEmpty { null }
+
+        val resultTableAliases = query.tables.associate { it.name to it.alias }
+        // Checks if this list of columns contains one with matching name and origin table.
+        // Takes into account that projection tables names might be aliased but originTable uses
+        // sqlite3_column_origin_name which is un-aliased.
+        fun List<ColumnInfo>.contains(
+            columnName: String,
+            tableName: String?
+        ) = any { resultColumn ->
+            val resultTableAlias = resultColumn.originTable?.let { resultTableAliases[it] ?: it }
+            resultColumn.name == columnName && (
+                if (tableName != null) {
+                    resultTableAlias == tableName || resultColumn.originTable == tableName
+                } else true)
+        }
+
+        context.checker.check(
+            keyColumn.isNotEmpty() || valueColumn.isNotEmpty(),
+            queryExecutableElement,
+            ProcessorErrors.MAP_INFO_MUST_HAVE_AT_LEAST_ONE_COLUMN_PROVIDED
+        )
+
+        val resultColumns = query.resultInfo?.columns
+        if (resultColumns != null) {
+            context.checker.check(
+                keyColumn.isEmpty() || resultColumns.contains(keyColumn, keyTable),
+                queryExecutableElement
+            ) {
+                cannotMapInfoSpecifiedColumn(
+                    (if (keyTable != null) "$keyTable." else "") + keyColumn,
+                    resultColumns.map { it.name }
+                )
+            }
+            context.checker.check(
+                valueColumn.isEmpty() || resultColumns.contains(valueColumn, valueTable),
+                queryExecutableElement
+            ) {
+                cannotMapInfoSpecifiedColumn(
+                    (if (valueTable != null) "$valueTable." else "") + valueColumn,
+                    resultColumns.map { it.name }
+                )
+            }
+        }
+
+        adapterExtras.putData(MapInfo::class, MapInfo(keyColumn, valueColumn))
+    }
+
     companion object {
         val PREPARED_TYPES = arrayOf(QueryType.INSERT, QueryType.DELETE, QueryType.UPDATE)
     }
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 35285cf..19c43eb 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
@@ -1467,6 +1467,68 @@
     }
 
     @Test
+    fun testUseMapInfoWithTableAndColumnName() {
+        if (!enableVerification) {
+            return
+        }
+        singleQueryMethod<ReadQueryMethod>(
+            """
+                @SuppressWarnings(
+                    {RoomWarnings.CURSOR_MISMATCH, RoomWarnings.AMBIGUOUS_COLUMN_IN_RESULT}
+                )
+                @MapInfo(keyColumn = "uid", keyTable = "u")
+                @Query("SELECT * FROM User u JOIN Book b ON u.uid == b.uid")
+                abstract Map<Integer, Book> getMultimap();
+            """
+        ) { _, invocation ->
+            invocation.assertCompilationResult {
+                hasNoWarnings()
+            }
+        }
+    }
+
+    @Test
+    fun testUseMapInfoWithOriginalTableAndColumnName() {
+        if (!enableVerification) {
+            return
+        }
+        singleQueryMethod<ReadQueryMethod>(
+            """
+                @SuppressWarnings(
+                    {RoomWarnings.CURSOR_MISMATCH, RoomWarnings.AMBIGUOUS_COLUMN_IN_RESULT}
+                )
+                @MapInfo(keyColumn = "uid", keyTable = "User")
+                @Query("SELECT * FROM User u JOIN Book b ON u.uid == b.uid")
+                abstract Map<Integer, Book> getMultimap();
+            """
+        ) { _, invocation ->
+            invocation.assertCompilationResult {
+                hasNoWarnings()
+            }
+        }
+    }
+
+    @Test
+    fun testUseMapInfoWithColumnAlias() {
+        if (!enableVerification) {
+            return
+        }
+        singleQueryMethod<ReadQueryMethod>(
+            """
+                @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
+                @MapInfo(keyColumn = "name", valueColumn = "bookCount")
+                @Query("SELECT name, (SELECT count(*) FROM User u JOIN Book b ON u.uid == b.uid) "
+                    + "AS bookCount FROM User")
+                abstract Map<String, Integer> getMultimap();
+            """
+        ) { _, invocation ->
+            invocation.assertCompilationResult {
+                hasNoWarnings()
+            }
+        }
+    }
+
+    @Test
     fun testDoesNotImplementEqualsAndHashcodeQuery() {
         singleQueryMethod<ReadQueryMethod>(
             """
@@ -1671,11 +1733,11 @@
                 )
                 hasErrorCount(2)
                 hasErrorContaining(
-                    "Column(s) specified in the provided @MapInfo annotation must " +
+                    "Column specified in the provided @MapInfo annotation must " +
                         "be present in the query. Provided: cat."
                 )
                 hasErrorContaining(
-                    "Column(s) specified in the provided @MapInfo annotation must " +
+                    "Column specified in the provided @MapInfo annotation must " +
                         "be present in the query. Provided: dog."
                 )
             }