Supporting multimap return type for JOIN statements in Room.

This CL mainly focuses on supporting the multimaps of type Map<Key, List<Value>>. In the follow-up CLs we will be supporting Map<Key, Set<Value>> as well as 1-1 mappings.

Bug: 187490856
Test: JoinQueryTest.java

Change-Id: I27c0654cc2eb167d59a054c0ac0c1a5408b627b4
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/MusicTestDatabase.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/MusicTestDatabase.java
index 22917de..f9b4e1a 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/MusicTestDatabase.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/MusicTestDatabase.java
@@ -19,13 +19,15 @@
 import androidx.room.Database;
 import androidx.room.RoomDatabase;
 import androidx.room.integration.testapp.dao.MusicDao;
+import androidx.room.integration.testapp.vo.Album;
+import androidx.room.integration.testapp.vo.Artist;
 import androidx.room.integration.testapp.vo.Playlist;
 import androidx.room.integration.testapp.vo.PlaylistMultiSongXRefView;
 import androidx.room.integration.testapp.vo.PlaylistSongXRef;
 import androidx.room.integration.testapp.vo.Song;
 
 @Database(
-        entities = {Song.class, Playlist.class, PlaylistSongXRef.class},
+        entities = {Song.class, Playlist.class, PlaylistSongXRef.class, Artist.class, Album.class},
         views = {PlaylistMultiSongXRefView.class},
         version = 1,
         exportSchema = false)
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
index 957fbda..604ec0e 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
@@ -20,23 +20,42 @@
 import androidx.room.Dao;
 import androidx.room.Insert;
 import androidx.room.Query;
+import androidx.room.RawQuery;
+import androidx.room.RoomWarnings;
 import androidx.room.Transaction;
+import androidx.room.integration.testapp.vo.Album;
+import androidx.room.integration.testapp.vo.AlbumNameAndBandName;
+import androidx.room.integration.testapp.vo.AlbumWithSongs;
+import androidx.room.integration.testapp.vo.Artist;
 import androidx.room.integration.testapp.vo.MultiSongPlaylistWithSongs;
 import androidx.room.integration.testapp.vo.Playlist;
 import androidx.room.integration.testapp.vo.PlaylistSongXRef;
 import androidx.room.integration.testapp.vo.PlaylistWithSongTitles;
 import androidx.room.integration.testapp.vo.PlaylistWithSongs;
+import androidx.room.integration.testapp.vo.ReleasedAlbum;
 import androidx.room.integration.testapp.vo.Song;
+import androidx.sqlite.db.SupportSQLiteQuery;
 
 import java.util.List;
+import java.util.Map;
+
+import io.reactivex.Flowable;
 
 @Dao
+@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
+// TODO: (b/191693863) Cannot use @RewriteQueriesToDropUnusedColumns due to this bug.
 public interface MusicDao {
 
     @Insert
     void addSongs(Song... songs);
 
     @Insert
+    void addArtists(Artist... artists);
+
+    @Insert
+    void addAlbums(Album... albums);
+
+    @Insert
     void addPlaylists(Playlist... playlists);
 
     @Insert
@@ -57,4 +76,26 @@
     @Transaction
     @Query("SELECT * FROM Playlist")
     List<MultiSongPlaylistWithSongs> getAllMultiSongPlaylistWithSongs();
+
+    @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+    Map<Artist, List<Song>> getAllArtistAndTheirSongs();
+
+    @Query("SELECT * FROM Artist JOIN Album ON Artist.mArtistName = Album.mAlbumArtist")
+    Map<Artist, List<AlbumWithSongs>> getAllArtistAndTheirAlbumsWithSongs();
+
+    @RawQuery
+    Map<Artist, List<Song>> getAllArtistAndTheirSongsRawQuery(SupportSQLiteQuery query);
+
+    @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+    LiveData<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsLiveData();
+
+    @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+    Flowable<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsFlowable();
+
+    @Query("SELECT Album.mAlbumReleaseYear as mReleaseYear, Album.mAlbumName, Album.mAlbumArtist "
+            + "as mBandName"
+            + " from Album "
+            + "JOIN Song "
+            + "ON Album.mAlbumArtist = Song.mArtist AND Album.mAlbumName = Song.mAlbum")
+    Map<ReleasedAlbum, List<AlbumNameAndBandName>> getReleaseYearToAlbumsAndBands();
 }
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java
new file mode 100644
index 0000000..b96e7b4
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java
@@ -0,0 +1,344 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.test;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+
+import androidx.arch.core.executor.testing.CountingTaskExecutorRule;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.testing.TestLifecycleOwner;
+import androidx.room.Room;
+import androidx.room.integration.testapp.MusicTestDatabase;
+import androidx.room.integration.testapp.dao.MusicDao;
+import androidx.room.integration.testapp.vo.Album;
+import androidx.room.integration.testapp.vo.AlbumNameAndBandName;
+import androidx.room.integration.testapp.vo.AlbumWithSongs;
+import androidx.room.integration.testapp.vo.Artist;
+import androidx.room.integration.testapp.vo.ReleasedAlbum;
+import androidx.room.integration.testapp.vo.Song;
+import androidx.sqlite.db.SimpleSQLiteQuery;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.MediumTest;
+
+import org.hamcrest.MatcherAssert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import io.reactivex.Flowable;
+
+/**
+ * Tests multimap return type for JOIN statements.
+ */
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class MultimapQueryTest {
+    // TODO: (b/191265082) Handle duplicate column names in JOINs
+    private MusicDao mMusicDao;
+
+    private final Song mSong1 = new Song(
+            1,
+            "Dani California",
+            "Red Hot Chili Peppers",
+            "Stadium Arcadium",
+            442,
+            2006);
+    private final Song mSong2 = new Song(
+            2,
+            "Snow (Hey Oh)",
+            "Red Hot Chili Peppers",
+            "Stadium Arcadium",
+            514,
+            2006);
+    private final Song mSong3 = new Song(
+            3,
+            "Highway to Hell",
+            "AC/DC",
+            "Highway to Hell",
+            328,
+            1979);
+    private final Song mSong4 = new Song(
+            4,
+            "The Great Gig in the Sky",
+            "Pink Floyd",
+            "The Dark Side of the Moon",
+            443,
+            1973);
+
+
+    private final Artist mRhcp = new Artist(
+            1,
+            "Red Hot Chili Peppers"
+    );
+    private final Artist mAcDc = new Artist(
+            2,
+            "AC/DC"
+    );
+    private final Artist mTheClash = new Artist(
+            3,
+            "The Clash"
+    );
+    private final Artist mPinkFloyd = new Artist(
+            4,
+            "Pink Floyd"
+    );
+
+    private final Album mStadiumArcadium = new Album(
+            1,
+            "Stadium Arcadium",
+            "Red Hot Chili Peppers",
+            "2006"
+    );
+
+    private final Album mCalifornication = new Album(
+            2,
+            "Californication",
+            "Red Hot Chili Peppers",
+            "1999"
+    );
+
+    private final Album mHighwayToHell = new Album(
+            3,
+            "Highway to Hell",
+            "AC/DC",
+            "1979"
+    );
+
+    private final Album mTheDarkSideOfTheMoon = new Album(
+            4,
+            "The Dark Side of the Moon",
+            "Pink Floyd",
+            "1973"
+    );
+
+    @Rule
+    public CountingTaskExecutorRule mExecutorRule = new CountingTaskExecutorRule();
+
+    private void drain() throws TimeoutException, InterruptedException {
+        mExecutorRule.drainTasks(1, TimeUnit.MINUTES);
+        assertThat(mExecutorRule.isIdle()).isTrue();
+    }
+
+    private class MyTestObserver<T> extends TestObserver<T> {
+        @Override
+        protected void drain() throws TimeoutException, InterruptedException {
+            MultimapQueryTest.this.drain();
+        }
+    }
+
+    @Before
+    public void createDb() {
+        Context context = ApplicationProvider.getApplicationContext();
+        MusicTestDatabase db = Room.inMemoryDatabaseBuilder(context, MusicTestDatabase.class)
+                .build();
+        mMusicDao = db.getDao();
+    }
+
+    /**
+     * Tests a simple JOIN query between two tables.
+     */
+    @Test
+    public void testJoinByArtistName() {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+        Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongs();
+        assertContentsOfResultMap(artistToSongsMap);
+    }
+
+    /**
+     * Tests a JOIN {@link androidx.room.RawQuery} between two tables.
+     */
+    @Test
+    public void testJoinByArtistNameRawQuery() {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+        Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsRawQuery(
+                new SimpleSQLiteQuery(
+                        "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist"
+                )
+        );
+        assertContentsOfResultMap(artistToSongsMap);
+    }
+
+    /**
+     * Tests a simple JOIN query between two tables with a {@link LiveData} return type.
+     */
+    @Test
+    public void testJoinByArtistNameLiveData()
+            throws ExecutionException, InterruptedException, TimeoutException {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+        LiveData<Map<Artist, List<Song>>> artistToSongsMapLiveData =
+                mMusicDao.getAllArtistAndTheirSongsAsLiveData();
+        final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+        final TestObserver<Map<Artist, List<Song>>> observer = new MyTestObserver<>();
+        TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+        MatcherAssert.assertThat(observer.hasValue(), is(false));
+        observer.reset();
+        testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+        assertThat(observer.get()).isNotNull();
+        assertContentsOfResultMap(observer.get());
+    }
+
+    /**
+     * Tests a simple JOIN query between two tables with a {@link Flowable} return type.
+     */
+    @Test
+    public void testJoinByArtistNameFlowable() {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+        Flowable<Map<Artist, List<Song>>> artistToSongsMapFlowable =
+                mMusicDao.getAllArtistAndTheirSongsAsFlowable();
+        assertContentsOfResultMap(artistToSongsMapFlowable.blockingFirst());
+    }
+
+    /**
+     * Tests a simple JOIN query between two tables with a return type of a map with a key that
+     * is an entity {@link Artist} and a list of entity POJOs {@link AlbumWithSongs} that use
+     * {@link androidx.room.Embedded} and {@link androidx.room.Relation}.
+     */
+    @Test
+    public void testPojoWithEmbeddedAndRelation() {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+        mMusicDao.addAlbums(
+                mStadiumArcadium,
+                mCalifornication,
+                mTheDarkSideOfTheMoon,
+                mHighwayToHell
+        );
+
+        Map<Artist, List<AlbumWithSongs>> artistToAlbumsWithSongsMap =
+                mMusicDao.getAllArtistAndTheirAlbumsWithSongs();
+        List<AlbumWithSongs> rhcpList = artistToAlbumsWithSongsMap.get(mRhcp);
+
+        assertThat(artistToAlbumsWithSongsMap.keySet()).containsExactlyElementsIn(
+                Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+        assertThat(artistToAlbumsWithSongsMap.containsKey(mTheClash)).isFalse();
+        assertThat(artistToAlbumsWithSongsMap.get(mPinkFloyd).get(0).getAlbum())
+                .isEqualTo(mTheDarkSideOfTheMoon);
+        assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getAlbum())
+                .isEqualTo(mHighwayToHell);
+        assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getSongs().get(0))
+                .isEqualTo(mSong3);
+
+        for (AlbumWithSongs albumAndSong : rhcpList) {
+            if (albumAndSong.getAlbum().equals(mStadiumArcadium)) {
+                assertThat(albumAndSong.getSongs()).containsExactlyElementsIn(
+                        Arrays.asList(mSong1, mSong2)
+                );
+            } else if (albumAndSong.getAlbum().equals(mCalifornication)) {
+                assertThat(albumAndSong.getSongs()).isEmpty();
+            } else {
+                fail();
+            }
+        }
+    }
+
+    /**
+     * Tests a simple JOIN query between two tables with a return type of a map with a key
+     * {@link ReleasedAlbum} and value (list of {@link AlbumNameAndBandName}) that are non-entity
+     * POJOs.
+     */
+    @Test
+    public void testNonEntityPojos() {
+        mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+        mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+        mMusicDao.addAlbums(
+                mStadiumArcadium,
+                mCalifornication,
+                mTheDarkSideOfTheMoon,
+                mHighwayToHell
+        );
+
+        Map<ReleasedAlbum, List<AlbumNameAndBandName>> map =
+                mMusicDao.getReleaseYearToAlbumsAndBands();
+        Set<ReleasedAlbum> allReleasedAlbums = map.keySet();
+
+        assertThat(allReleasedAlbums.size()).isEqualTo(3);
+
+        for (ReleasedAlbum album : allReleasedAlbums) {
+            if (album.getAlbumName().equals(mStadiumArcadium.mAlbumName)) {
+                assertThat(album.getReleaseYear()).isEqualTo(mStadiumArcadium.mAlbumReleaseYear);
+                assertThat(map.get(album).size()).isEqualTo(2);
+                assertThat(map.get(album).get(0).getBandName()).isEqualTo(mRhcp.mArtistName);
+                assertThat(map.get(album).get(0).getAlbumName())
+                        .isEqualTo(mStadiumArcadium.mAlbumName);
+                assertThat(map.get(album).get(1).getBandName()).isEqualTo(mRhcp.mArtistName);
+                assertThat(map.get(album).get(1).getAlbumName())
+                        .isEqualTo(mStadiumArcadium.mAlbumName);
+
+            } else if (album.getAlbumName().equals(mHighwayToHell.mAlbumName)) {
+                assertThat(album.getReleaseYear()).isEqualTo(mHighwayToHell.mAlbumReleaseYear);
+                assertThat(map.get(album).size()).isEqualTo(1);
+                assertThat(map.get(album).get(0).getBandName()).isEqualTo(mAcDc.mArtistName);
+                assertThat(map.get(album).get(0).getAlbumName())
+                        .isEqualTo(mHighwayToHell.mAlbumName);
+
+            } else if (album.getAlbumName().equals(mTheDarkSideOfTheMoon.mAlbumName)) {
+                assertThat(album.getReleaseYear())
+                        .isEqualTo(mTheDarkSideOfTheMoon.mAlbumReleaseYear);
+                assertThat(map.get(album).size()).isEqualTo(1);
+                assertThat(map.get(album).get(0).getBandName())
+                        .isEqualTo(mPinkFloyd.mArtistName);
+                assertThat(map.get(album).get(0).getAlbumName())
+                        .isEqualTo(mTheDarkSideOfTheMoon.mAlbumName);
+
+            } else {
+                // Shouldn't get here as we expect only the 3 albums to be keys in the map
+                fail();
+            }
+        }
+    }
+
+    /**
+     * Checks that the contents of the map are as expected.
+     *
+     * @param artistToSongsMap Map of Artists to Songs joined by the artist name
+     */
+    private void assertContentsOfResultMap(Map<Artist, List<Song>> artistToSongsMap) {
+        assertThat(artistToSongsMap.keySet()).containsExactlyElementsIn(
+                Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+        assertThat(artistToSongsMap.containsKey(mTheClash)).isFalse();
+        assertThat(artistToSongsMap.get(mPinkFloyd)).containsExactly(mSong4);
+        assertThat(artistToSongsMap.get(mRhcp)).containsExactlyElementsIn(
+                Arrays.asList(mSong1, mSong2)
+        );
+        assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mSong3);
+    }
+}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Album.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Album.java
new file mode 100644
index 0000000..4c445d4
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Album.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.vo;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity
+public class Album {
+    @PrimaryKey
+    public final int mAlbumId;
+    public final String mAlbumName;
+    public final String mAlbumArtist;
+    public final String mAlbumReleaseYear;
+
+    public Album(int albumId, String albumName, String albumArtist, String albumReleaseYear) {
+        mAlbumId = albumId;
+        mAlbumName = albumName;
+        mAlbumArtist = albumArtist;
+        mAlbumReleaseYear = albumReleaseYear;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Album album = (Album) o;
+
+        if (mAlbumId != album.mAlbumId) return false;
+        if (mAlbumName != null ? !mAlbumName.equals(album.mAlbumName) :
+                album.mAlbumName != null) {
+            return false;
+        }
+        if (mAlbumArtist != null ? !mAlbumArtist.equals(album.mAlbumArtist) :
+                album.mAlbumArtist != null) {
+            return false;
+        }
+        if (mAlbumReleaseYear != null ? !mAlbumReleaseYear.equals(album.mAlbumReleaseYear) :
+                album.mAlbumReleaseYear != null) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = mAlbumId;
+        result = 31 * result + (mAlbumName != null ? mAlbumName.hashCode() : 0);
+        result = 31 * result + (mAlbumArtist != null ? mAlbumArtist.hashCode() : 0);
+        result = 31 * result + (mAlbumReleaseYear != null ? mAlbumReleaseYear.hashCode() : 0);
+        return result;
+    }
+}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumNameAndBandName.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumNameAndBandName.java
new file mode 100644
index 0000000..854c0bf
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumNameAndBandName.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.vo;
+
+
+public class AlbumNameAndBandName {
+    private String mAlbumName;
+    private String mBandName;
+
+    public AlbumNameAndBandName(String albumName, String bandName) {
+        mAlbumName = albumName;
+        mBandName = bandName;
+    }
+
+    public String getAlbumName() {
+        return mAlbumName;
+    }
+
+    public String getBandName() {
+        return mBandName;
+    }
+
+    @SuppressWarnings("SimplifiableIfStatement")
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        AlbumNameAndBandName that = (AlbumNameAndBandName) o;
+
+        if (mAlbumName != null ? !mAlbumName.equals(that.mAlbumName) :
+                that.mAlbumName != null) {
+            return false;
+        }
+        return mBandName != null ? mBandName.equals(that.mBandName) : that.mBandName == null;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = mAlbumName != null ? mAlbumName.hashCode() : 0;
+        result = 31 * result + (mBandName != null ? mBandName.hashCode() : 0);
+        return result;
+    }
+}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumWithSongs.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumWithSongs.java
new file mode 100644
index 0000000..980fb01
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/AlbumWithSongs.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.vo;
+
+import androidx.room.Embedded;
+import androidx.room.Relation;
+
+import java.util.List;
+
+public class AlbumWithSongs {
+    @Embedded
+    private final Album mAlbum;
+
+    @Relation(parentColumn = "mAlbumName", entityColumn = "mAlbum")
+    private final List<Song> mSongs;
+
+    public AlbumWithSongs(Album album,
+            List<Song> songs) {
+        this.mAlbum = album;
+        this.mSongs = songs;
+    }
+
+    public Album getAlbum() {
+        return mAlbum;
+    }
+
+    public List<Song> getSongs() {
+        return mSongs;
+    }
+}
+
+
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Artist.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Artist.java
new file mode 100644
index 0000000..603cde0
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/Artist.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.vo;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity
+public class Artist {
+    @PrimaryKey
+    public final int mArtistId;
+    public final String mArtistName;
+
+    public Artist(int artistId, String artistName) {
+        mArtistId = artistId;
+        mArtistName = artistName;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Artist artist = (Artist) o;
+
+        if (mArtistId != artist.mArtistId) return false;
+        if (mArtistName != null ? !mArtistName.equals(artist.mArtistName) :
+                artist.mArtistName != null) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = mArtistId;
+        result = 31 * result + (mArtistName != null ? mArtistName.hashCode() : 0);
+        return result;
+    }
+}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/ReleasedAlbum.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/ReleasedAlbum.java
new file mode 100644
index 0000000..c79e8a2
--- /dev/null
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/vo/ReleasedAlbum.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.integration.testapp.vo;
+
+
+public class ReleasedAlbum {
+    private String mReleaseYear;
+    private String mAlbumName;
+
+    public ReleasedAlbum(String releaseYear, String albumName) {
+        mReleaseYear = releaseYear;
+
+        mAlbumName = albumName;
+    }
+
+    public String getReleaseYear() {
+        return mReleaseYear;
+    }
+
+    public String getAlbumName() {
+        return mAlbumName;
+    }
+
+    @SuppressWarnings("SimplifiableIfStatement")
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        ReleasedAlbum that = (ReleasedAlbum) o;
+        if (mReleaseYear != null ? !mReleaseYear.equals(that.mReleaseYear) :
+                that.mReleaseYear != null) {
+            return false;
+        }
+        return mAlbumName != null ? mAlbumName.equals(that.mAlbumName) : that.mAlbumName == null;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = mReleaseYear != null ? mReleaseYear.hashCode() : 0;
+        result = 31 * result + (mAlbumName != null ? mAlbumName.hashCode() : 0);
+        return result;
+    }
+}
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 127d0fe..5147586 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
@@ -57,6 +57,7 @@
 import androidx.room.solver.query.result.GuavaOptionalQueryResultAdapter
 import androidx.room.solver.query.result.ImmutableListQueryResultAdapter
 import androidx.room.solver.query.result.ListQueryResultAdapter
+import androidx.room.solver.query.result.MapQueryResultAdapter
 import androidx.room.solver.query.result.OptionalQueryResultAdapter
 import androidx.room.solver.query.result.PojoRowAdapter
 import androidx.room.solver.query.result.QueryResultAdapter
@@ -461,6 +462,26 @@
                     typeArg = typeArg,
                     rowAdapter = rowAdapter
                 )
+            } else if (typeMirror.isTypeOf(java.util.Map::class)) {
+                val keyArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
+                val secondTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
+
+                // TODO: Support Set::class here as well.
+                if (!secondTypeArg.isTypeOf(java.util.List::class)) {
+                    context.logger.e("Only supporting Map<Key, List<Value>> for now.")
+                    return null
+                }
+                val valueArg = secondTypeArg.typeArguments.first().extendsBoundOrSelf()
+
+                val keyRowAdapter = findRowAdapter(keyArg, query) ?: return null
+                val valueRowAdapter = findRowAdapter(valueArg, query) ?: return null
+
+                return MapQueryResultAdapter(
+                    keyTypeArg = keyArg,
+                    valueTypeArg = valueArg,
+                    keyRowAdapter = keyRowAdapter,
+                    valueRowAdapter = valueRowAdapter
+                )
             }
             return null
         }
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt
new file mode 100644
index 0000000..520b1de
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License 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 androidx.room.solver.query.result
+
+import androidx.room.compiler.processing.XType
+import androidx.room.ext.L
+import androidx.room.ext.T
+import androidx.room.solver.CodeGenScope
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.ParameterizedTypeName
+
+class MapQueryResultAdapter(
+    private val keyTypeArg: XType,
+    private val valueTypeArg: XType,
+    private val keyRowAdapter: RowAdapter,
+    private val valueRowAdapter: RowAdapter,
+) : QueryResultAdapter(null) {
+    private val listType = ParameterizedTypeName.get(
+        ClassName.get(List::class.java),
+        valueTypeArg.typeName
+    )
+
+    private val arrayListType = ParameterizedTypeName
+        .get(ClassName.get(ArrayList::class.java), valueTypeArg.typeName)
+
+    private val mapType = ParameterizedTypeName.get(
+        ClassName.get(Map::class.java),
+        keyTypeArg.typeName,
+        listType
+    )
+
+    private val hashMapType = ParameterizedTypeName.get(
+        ClassName.get(HashMap::class.java),
+        keyTypeArg.typeName,
+        listType
+    )
+
+    override fun convert(outVarName: String, cursorVarName: String, scope: CodeGenScope) {
+        scope.builder().apply {
+            keyRowAdapter.onCursorReady(cursorVarName, scope)
+            valueRowAdapter.onCursorReady(cursorVarName, scope)
+            addStatement(
+                "final $T $L = new $T()",
+                mapType, outVarName, hashMapType
+            )
+            val tmpKeyVarName = scope.getTmpVar("_key")
+            val tmpValueVarName = scope.getTmpVar("_value")
+            beginControlFlow("while ($L.moveToNext())", cursorVarName).apply {
+                addStatement("final $T $L", keyTypeArg.typeName, tmpKeyVarName)
+                keyRowAdapter.convert(tmpKeyVarName, cursorVarName, scope)
+
+                addStatement("final $T $L", valueTypeArg.typeName, tmpValueVarName)
+                valueRowAdapter.convert(tmpValueVarName, cursorVarName, scope)
+
+                val tmpListVarName = scope.getTmpVar("_values")
+                addStatement("$T $L", listType, tmpListVarName)
+                beginControlFlow("if ($L.containsKey($L))", outVarName, tmpKeyVarName).apply {
+                    addStatement("$L = $L.get($L)", tmpListVarName, outVarName, tmpKeyVarName)
+                }
+                nextControlFlow("else").apply {
+                    addStatement("$L = new $T()", tmpListVarName, arrayListType)
+                    addStatement("$L.put($L, $L)", outVarName, tmpKeyVarName, tmpListVarName)
+                }
+                endControlFlow()
+                addStatement("$L.add($L)", tmpListVarName, tmpValueVarName)
+            }
+            endControlFlow()
+            keyRowAdapter.onCursorFinished()?.invoke(scope)
+            valueRowAdapter.onCursorFinished()?.invoke(scope)
+        }
+    }
+
+    override fun shouldCopyCursor() =
+        (keyRowAdapter is PojoRowAdapter && keyRowAdapter.relationCollectors.isNotEmpty()) ||
+            (valueRowAdapter is PojoRowAdapter && valueRowAdapter.relationCollectors.isNotEmpty())
+
+    override fun accessedTableNames() = mutableListOf<String>().apply {
+        (keyRowAdapter as? PojoRowAdapter)?.relationTableNames()?.let { addAll(it) }
+        (valueRowAdapter as? PojoRowAdapter)?.relationTableNames()?.let { addAll(it) }
+    }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/QueryResultAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/QueryResultAdapter.kt
index dc27eaf..68b5242 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/QueryResultAdapter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/QueryResultAdapter.kt
@@ -29,7 +29,7 @@
     open fun shouldCopyCursor() = rowAdapter is PojoRowAdapter &&
         rowAdapter.relationCollectors.isNotEmpty()
 
-    fun accessedTableNames(): List<String> {
+    open fun accessedTableNames(): List<String> {
         return (rowAdapter as? PojoRowAdapter)?.relationTableNames() ?: emptyList()
     }
 }