Merge "Detecting all "complex" schema changes." into androidx-main
diff --git a/room/compiler/src/main/kotlin/androidx/room/processor/AutoMigrationProcessor.kt b/room/compiler/src/main/kotlin/androidx/room/processor/AutoMigrationProcessor.kt
index a07f3e2..d8818644 100644
--- a/room/compiler/src/main/kotlin/androidx/room/processor/AutoMigrationProcessor.kt
+++ b/room/compiler/src/main/kotlin/androidx/room/processor/AutoMigrationProcessor.kt
@@ -26,6 +26,9 @@
 import androidx.room.vo.AutoMigrationResult
 import java.io.File
 
+// TODO: (b/183435544) Support downgrades in AutoMigrations.
+// TODO: (b/183007590) Use the callback in the AutoMigration annotation while end-to-end
+//  testing, when column/table rename/deletes are supported
 class AutoMigrationProcessor(
     val context: Context,
     val element: XTypeElement,
@@ -101,18 +104,18 @@
             return null
         }
 
+        // TODO: (b/183434667) Update the automigration result data object to handle complex
+        //  schema changes' presence when writer code is introduced
         return AutoMigrationResult(
             element = element,
             from = fromSchemaBundle.version,
             to = toSchemaBundle.version,
-            addedColumns = schemaDiff.addedColumn,
-            addedTables = schemaDiff.addedTable
+            addedColumns = schemaDiff.addedColumns,
+            addedTables = schemaDiff.addedTables
         )
     }
 
-    // TODO: File bug for not supporting downgrades.
-    // TODO: (b/180389433) If the files don't exist the getSchemaFile() method should return
-    //  null and before calling process
+    // TODO: (b/180389433) Verify automigration schemas before calling the AutoMigrationProcessor
     private fun getValidatedSchemaFile(version: Int): File? {
         val schemaFile = File(
             context.schemaOutFolder,
diff --git a/room/compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt b/room/compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
index 05a3b6a..a62aac8 100644
--- a/room/compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
+++ b/room/compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
@@ -841,8 +841,9 @@
     }
 
     fun columnWithChangedSchemaFound(columnName: String): String {
-        return "Encountered column '$columnName' with a changed FieldBundle schema. This change " +
-            "is not currently supported by AutoMigration."
+        return "Encountered column '$columnName' with an unsupported schema change at the column " +
+            "level (e.g. affinity change). These changes are not yet " +
+            "supported by AutoMigration."
     }
 
     fun removedOrRenamedColumnFound(columnName: String): String {
@@ -850,6 +851,12 @@
             "renamed. This change is not currently supported by AutoMigration."
     }
 
+    fun tableWithComplexChangedSchemaFound(tableName: String): String {
+        return "Encountered table '$tableName' with an unsupported schema change at the table " +
+            "level (e.g. primary key, foreign key or index change). These changes are not yet " +
+            "supported by AutoMigration."
+    }
+
     fun removedOrRenamedTableFound(tableName: String): String {
         return "Table '$tableName' has been either removed or " +
             "renamed. This change is not currently supported by AutoMigration."
diff --git a/room/compiler/src/main/kotlin/androidx/room/util/SchemaDiffer.kt b/room/compiler/src/main/kotlin/androidx/room/util/SchemaDiffer.kt
index a0a327d..53f1f231 100644
--- a/room/compiler/src/main/kotlin/androidx/room/util/SchemaDiffer.kt
+++ b/room/compiler/src/main/kotlin/androidx/room/util/SchemaDiffer.kt
@@ -17,6 +17,9 @@
 package androidx.room.util
 
 import androidx.room.migration.bundle.DatabaseBundle
+import androidx.room.migration.bundle.EntityBundle
+import androidx.room.migration.bundle.ForeignKeyBundle
+import androidx.room.migration.bundle.IndexBundle
 import androidx.room.processor.ProcessorErrors
 import androidx.room.vo.AutoMigrationResult
 
@@ -29,11 +32,12 @@
  * Contains the added, changed and removed columns detected.
  */
 data class SchemaDiffResult(
-    val addedColumn: MutableList<AutoMigrationResult.AddedColumn>,
-    val changedColumn: List<AutoMigrationResult.ChangedColumn>,
-    val removedColumn: List<AutoMigrationResult.RemovedColumn>,
-    val addedTable: List<AutoMigrationResult.AddedTable>,
-    val removedTable: List<AutoMigrationResult.RemovedTable>
+    val addedColumns: List<AutoMigrationResult.AddedColumn>,
+    val changedColumns: List<AutoMigrationResult.ChangedColumn>,
+    val removedColumns: List<AutoMigrationResult.RemovedColumn>,
+    val addedTables: List<AutoMigrationResult.AddedTable>,
+    val complexChangedTables: List<AutoMigrationResult.ComplexChangedTable>,
+    val removedTables: List<AutoMigrationResult.RemovedTable>
 )
 
 /**
@@ -55,6 +59,7 @@
      */
     fun diffSchemas(): SchemaDiffResult {
         val addedTables = mutableListOf<AutoMigrationResult.AddedTable>()
+        val complexChangedTables = mutableListOf<AutoMigrationResult.ComplexChangedTable>()
         val removedTables = mutableListOf<AutoMigrationResult.RemovedTable>()
 
         val addedColumns = mutableListOf<AutoMigrationResult.AddedColumn>()
@@ -63,28 +68,32 @@
 
         // Check going from the original version of the schema to the new version for changed and
         // removed columns/tables
-        fromSchemaBundle.entitiesByTableName.forEach { v1Table ->
-            val v2Table = toSchemaBundle.entitiesByTableName[v1Table.key]
-            if (v2Table == null) {
-                removedTables.add(AutoMigrationResult.RemovedTable(v1Table.value))
+        fromSchemaBundle.entitiesByTableName.forEach { fromTable ->
+            val toTable = toSchemaBundle.entitiesByTableName[fromTable.key]
+            if (toTable == null) {
+                removedTables.add(AutoMigrationResult.RemovedTable(fromTable.value))
             } else {
-                val v1Columns = v1Table.value.fieldsByColumnName
-                val v2Columns = v2Table.fieldsByColumnName
-                v1Columns.entries.forEach { v1Column ->
-                    val match = v2Columns[v1Column.key]
-                    if (match != null && !match.isSchemaEqual(v1Column.value)) {
+                val complexChangedTable = tableContainsComplexChanges(fromTable.value, toTable)
+                if (complexChangedTable != null) {
+                    complexChangedTables.add(complexChangedTable)
+                }
+                val fromColumns = fromTable.value.fieldsByColumnName
+                val toColumns = toTable.fieldsByColumnName
+                fromColumns.entries.forEach { fromColumn ->
+                    val match = toColumns[fromColumn.key]
+                    if (match != null && !match.isSchemaEqual(fromColumn.value)) {
+                        // Any change in the field bundle schema of a column will be complex
                         changedColumns.add(
                             AutoMigrationResult.ChangedColumn(
-                                v1Table.key,
-                                v1Column.value,
-                                match
+                                fromTable.key,
+                                fromColumn.value
                             )
                         )
                     } else if (match == null) {
                         removedColumns.add(
                             AutoMigrationResult.RemovedColumn(
-                                v1Table.key,
-                                v1Column.value
+                                fromTable.key,
+                                fromColumn.value
                             )
                         )
                     }
@@ -94,25 +103,25 @@
         // Check going from the new version of the schema to the original version for added
         // tables/columns. Skip the columns with the same name as the previous loop would have
         // processed them already.
-        toSchemaBundle.entitiesByTableName.forEach { v2Table ->
-            val v1Table = fromSchemaBundle.entitiesByTableName[v2Table.key]
-            if (v1Table == null) {
-                addedTables.add(AutoMigrationResult.AddedTable(v2Table.value))
+        toSchemaBundle.entitiesByTableName.forEach { toTable ->
+            val fromTable = fromSchemaBundle.entitiesByTableName[toTable.key]
+            if (fromTable == null) {
+                addedTables.add(AutoMigrationResult.AddedTable(toTable.value))
             } else {
-                val v2Columns = v2Table.value.fieldsByColumnName
-                val v1Columns = v1Table.fieldsByColumnName
-                v2Columns.entries.forEach { v2Column ->
-                    val match = v1Columns[v2Column.key]
+                val fromColumns = fromTable.fieldsByColumnName
+                val toColumns = toTable.value.fieldsByColumnName
+                toColumns.entries.forEach { toColumn ->
+                    val match = fromColumns[toColumn.key]
                     if (match == null) {
-                        if (v2Column.value.isNonNull && v2Column.value.defaultValue == null) {
+                        if (toColumn.value.isNonNull && toColumn.value.defaultValue == null) {
                             diffError(
-                                ProcessorErrors.newNotNullColumnMustHaveDefaultValue(v2Column.key)
+                                ProcessorErrors.newNotNullColumnMustHaveDefaultValue(toColumn.key)
                             )
                         }
                         addedColumns.add(
                             AutoMigrationResult.AddedColumn(
-                                v2Table.key,
-                                v2Column.value
+                                toTable.key,
+                                toColumn.value
                             )
                         )
                     }
@@ -120,11 +129,13 @@
             }
         }
 
+        // TODO: (b/183007590) Remove the Processor Errors thrown when a complex change is
+        //  encountered after AutoMigrationWriter supports generating the necessary migrations.
         if (changedColumns.isNotEmpty()) {
             changedColumns.forEach { changedColumn ->
                 diffError(
                     ProcessorErrors.columnWithChangedSchemaFound(
-                        changedColumn.originalFieldBundle.columnName
+                        changedColumn.fieldBundle.columnName
                     )
                 )
             }
@@ -140,6 +151,16 @@
             }
         }
 
+        if (complexChangedTables.isNotEmpty()) {
+            complexChangedTables.forEach { changedTable ->
+                diffError(
+                    ProcessorErrors.tableWithComplexChangedSchemaFound(
+                        changedTable.tableName
+                    )
+                )
+            }
+        }
+
         if (removedTables.isNotEmpty()) {
             removedTables.forEach { removedTable ->
                 diffError(
@@ -151,15 +172,98 @@
         }
 
         return SchemaDiffResult(
-            addedColumn = addedColumns,
-            changedColumn = changedColumns,
-            removedColumn = removedColumns,
-            addedTable = addedTables,
-            removedTable = removedTables
+            addedColumns = addedColumns,
+            changedColumns = changedColumns,
+            removedColumns = removedColumns,
+            addedTables = addedTables,
+            complexChangedTables = complexChangedTables,
+            removedTables = removedTables
         )
     }
 
+    /**
+     * Check for complex schema changes at a Table level and returns a ComplexTableChange
+     * including information on which table changes were found on, and whether foreign key or
+     * index related changes have occurred.
+     *
+     * @return null if complex schema change has not been found
+     */
+    // TODO: (b/181777611) Handle FTS tables
+    private fun tableContainsComplexChanges(
+        fromTable: EntityBundle,
+        toTable: EntityBundle
+    ): AutoMigrationResult.ComplexChangedTable? {
+        val foreignKeyChanged = !isForeignKeyBundlesListEqual(
+            fromTable.foreignKeys,
+            toTable.foreignKeys
+        )
+        val indexChanged = !isIndexBundlesListEqual(fromTable.indices, toTable.indices)
+        val primaryKeyChanged = !fromTable.primaryKey.isSchemaEqual(toTable.primaryKey)
+
+        if (primaryKeyChanged || foreignKeyChanged || indexChanged) {
+            return AutoMigrationResult.ComplexChangedTable(
+                tableName = toTable.tableName,
+                foreignKeyChanged = foreignKeyChanged,
+                indexChanged = indexChanged
+            )
+        }
+        return null
+    }
+
     private fun diffError(errorMsg: String) {
         throw DiffException(errorMsg)
     }
+
+    /**
+     * Takes in two ForeignKeyBundle lists, attempts to find potential matches based on the columns
+     * of the Foreign Keys. Processes these potential matches by checking for schema equality.
+     *
+     * @return true if the two lists of foreign keys are equal
+     */
+    private fun isForeignKeyBundlesListEqual(
+        fromBundle: List<ForeignKeyBundle>,
+        toBundle: List<ForeignKeyBundle>
+    ): Boolean {
+        val set = fromBundle + toBundle
+        val matches = set.groupBy { it.columns }.entries
+
+        matches.forEach { (_, bundles) ->
+            if (bundles.size < 2) {
+                // A bundle was not matched at all, there must be a change between two versions
+                return false
+            }
+            val fromForeignKeyBundle = bundles[0]
+            val toForeignKeyBundle = bundles[1]
+            if (!fromForeignKeyBundle.isSchemaEqual(toForeignKeyBundle)) {
+                // A potential match for a bundle was found, but schemas did not match
+                return false
+            }
+        }
+        return true
+    }
+
+    /**
+     * Takes in two IndexBundle lists, attempts to find potential matches based on the names
+     * of the indexes. Processes these potential matches by checking for schema equality.
+     *
+     * @return true if the two lists of indexes are equal
+     */
+    private fun isIndexBundlesListEqual(
+        fromBundle: List<IndexBundle>,
+        toBundle: List<IndexBundle>
+    ): Boolean {
+        val set = fromBundle + toBundle
+        val matches = set.groupBy { it.name }.entries
+
+        matches.forEach { bundlesWithSameName ->
+            if (bundlesWithSameName.value.size < 2) {
+                // A bundle was not matched at all, there must be a change between two versions
+                return false
+            } else if (!bundlesWithSameName.value[0].isSchemaEqual(bundlesWithSameName.value[1])) {
+                // A potential match for a bundle was found, but schemas did not match
+                return false
+            }
+        }
+        return true
+    }
 }
\ No newline at end of file
diff --git a/room/compiler/src/main/kotlin/androidx/room/vo/AutoMigrationResult.kt b/room/compiler/src/main/kotlin/androidx/room/vo/AutoMigrationResult.kt
index 91d4bd6..f6fcc1a 100644
--- a/room/compiler/src/main/kotlin/androidx/room/vo/AutoMigrationResult.kt
+++ b/room/compiler/src/main/kotlin/androidx/room/vo/AutoMigrationResult.kt
@@ -49,8 +49,7 @@
      */
     data class ChangedColumn(
         val tableName: String,
-        val originalFieldBundle: FieldBundle,
-        val newFieldBundle: FieldBundle
+        val fieldBundle: FieldBundle
     )
 
     /**
@@ -69,6 +68,25 @@
     data class AddedTable(val entityBundle: EntityBundle)
 
     /**
+     * Stores the table name that contains a change in the primary key, foreign key(s) or index(es)
+     * in a newer version. Explicitly provides information on whether a foreign key change and/or
+     * an index change has occurred.
+     *
+     * As it is possible to have a table with only simple (non-complex) changes, which will be
+     * categorized as "AddedColumn" or "RemovedColumn" changes, all other
+     * changes at the table level are categorized as "complex" changes, using the category
+     * "ComplexChangedTable".
+     *
+     * At the column level, any change that is not a column add or a
+     * removal will be categorized as "ChangedColumn".
+     */
+    data class ComplexChangedTable(
+        val tableName: String,
+        val foreignKeyChanged: Boolean,
+        val indexChanged: Boolean
+    )
+
+    /**
      * Stores the table that was present in the old version of a database but is not present in a
      * new version of the same database, either because it was removed or renamed.
      *
diff --git a/room/compiler/src/test/kotlin/androidx/room/util/SchemaDifferTest.kt b/room/compiler/src/test/kotlin/androidx/room/util/SchemaDifferTest.kt
index 0dd90dc..94ed63a 100644
--- a/room/compiler/src/test/kotlin/androidx/room/util/SchemaDifferTest.kt
+++ b/room/compiler/src/test/kotlin/androidx/room/util/SchemaDifferTest.kt
@@ -19,6 +19,8 @@
 import androidx.room.migration.bundle.DatabaseBundle
 import androidx.room.migration.bundle.EntityBundle
 import androidx.room.migration.bundle.FieldBundle
+import androidx.room.migration.bundle.ForeignKeyBundle
+import androidx.room.migration.bundle.IndexBundle
 import androidx.room.migration.bundle.PrimaryKeyBundle
 import androidx.room.migration.bundle.SchemaBundle
 import androidx.room.processor.ProcessorErrors
@@ -29,12 +31,57 @@
 class SchemaDifferTest {
 
     @Test
+    fun testPrimaryKeyChanged() {
+        try {
+            SchemaDiffer(
+                fromSchemaBundle = from.database,
+                toSchemaBundle = toChangeInPrimaryKey.database
+            ).diffSchemas()
+            fail("DiffException should have been thrown.")
+        } catch (ex: DiffException) {
+            assertThat(ex.errorMessage).isEqualTo(
+                ProcessorErrors.tableWithComplexChangedSchemaFound("Song")
+            )
+        }
+    }
+
+    @Test
+    fun testForeignKeyFieldChanged() {
+        try {
+            SchemaDiffer(
+                fromSchemaBundle = from.database,
+                toSchemaBundle = toForeignKeyAdded.database
+            ).diffSchemas()
+            fail("DiffException should have been thrown.")
+        } catch (ex: DiffException) {
+            assertThat(ex.errorMessage).isEqualTo(
+                ProcessorErrors.tableWithComplexChangedSchemaFound("Song")
+            )
+        }
+    }
+
+    @Test
+    fun testComplexChangeInvolvingIndex() {
+        try {
+            SchemaDiffer(
+                fromSchemaBundle = from.database,
+                toSchemaBundle = toIndexAdded.database
+            ).diffSchemas()
+            fail("DiffException should have been thrown.")
+        } catch (ex: DiffException) {
+            assertThat(ex.errorMessage).isEqualTo(
+                ProcessorErrors.tableWithComplexChangedSchemaFound("Song")
+            )
+        }
+    }
+
+    @Test
     fun testColumnAddedWithColumnInfoDefaultValue() {
         val schemaDiffResult = SchemaDiffer(
             fromSchemaBundle = from.database,
             toSchemaBundle = toColumnAddedWithColumnInfoDefaultValue.database
         ).diffSchemas()
-        assertThat(schemaDiffResult.addedColumn[0].fieldBundle.columnName).isEqualTo("artistId")
+        assertThat(schemaDiffResult.addedColumns[0].fieldBundle.columnName).isEqualTo("artistId")
     }
 
     @Test
@@ -58,8 +105,8 @@
             fromSchemaBundle = from.database,
             toSchemaBundle = toTableAddedWithColumnInfoDefaultValue.database
         ).diffSchemas()
-        assertThat(schemaDiffResult.addedTable[0].entityBundle.tableName).isEqualTo("Artist")
-        assertThat(schemaDiffResult.addedTable[1].entityBundle.tableName).isEqualTo("Album")
+        assertThat(schemaDiffResult.addedTables[0].entityBundle.tableName).isEqualTo("Artist")
+        assertThat(schemaDiffResult.addedTables[1].entityBundle.tableName).isEqualTo("Album")
     }
 
     @Test
@@ -78,7 +125,7 @@
     }
 
     @Test
-    fun testColumnAffinityChanged() {
+    fun testColumnFieldBundleChanged() {
         try {
             SchemaDiffer(
                 fromSchemaBundle = from.database,
@@ -496,10 +543,74 @@
         )
     )
 
-    val toTableAddedWithNoDefaultValue = SchemaBundle(
-        1,
+    private val toForeignKeyAdded = SchemaBundle(
+        2,
         DatabaseBundle(
-            1,
+            2,
+            "",
+            listOf(
+                EntityBundle(
+                    "Song",
+                    "CREATE TABLE IF NOT EXISTS `Song` (`id` INTEGER NOT NULL, " +
+                        "`title` TEXT NOT NULL, `length` INTEGER NOT NULL, `artistId` " +
+                        "INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`title`) " +
+                        "REFERENCES `Song`(`artistId`) ON UPDATE NO ACTION ON DELETE NO " +
+                        "ACTION DEFERRABLE INITIALLY DEFERRED))",
+                    listOf(
+                        FieldBundle(
+                            "id",
+                            "id",
+                            "INTEGER",
+                            true,
+                            "1"
+                        ),
+                        FieldBundle(
+                            "title",
+                            "title",
+                            "TEXT",
+                            true,
+                            ""
+                        ),
+                        FieldBundle(
+                            "length",
+                            "length",
+                            "INTEGER",
+                            true,
+                            "1"
+                        ),
+                        FieldBundle(
+                            "artistId",
+                            "artistId",
+                            "INTEGER",
+                            true,
+                            "0"
+                        )
+                    ),
+                    PrimaryKeyBundle(
+                        false,
+                        mutableListOf("id")
+                    ),
+                    emptyList(),
+                    listOf(
+                        ForeignKeyBundle(
+                            "Song",
+                            "onDelete",
+                            "onUpdate",
+                            listOf("title"),
+                            listOf("artistId")
+                        )
+                    )
+                )
+            ),
+            mutableListOf(),
+            mutableListOf()
+        )
+    )
+
+    val toIndexAdded = SchemaBundle(
+        2,
+        DatabaseBundle(
+            2,
             "",
             mutableListOf(
                 EntityBundle(
@@ -533,25 +644,62 @@
                         false,
                         mutableListOf("id")
                     ),
-                    mutableListOf(),
-                    mutableListOf()
-                ),
-                EntityBundle(
-                    "Album",
-                    "CREATE TABLE IF NOT EXISTS `Album` (`id` INTEGER NOT NULL, `name` TEXT NOT " +
-                        "NULL, PRIMARY KEY(`id`))",
                     listOf(
-                        FieldBundle(
-                            "albumId",
-                            "albumId",
-                            "INTEGER",
+                        IndexBundle(
+                            "index1",
                             true,
-                            null
+                            listOf("title"),
+                            "CREATE UNIQUE INDEX IF NOT EXISTS `index1` ON `Song`" +
+                                "(`title`)"
                         )
                     ),
-                    PrimaryKeyBundle(true, listOf("id")),
-                    listOf(),
-                    listOf()
+                    mutableListOf()
+                )
+            ),
+            mutableListOf(),
+            mutableListOf()
+        )
+    )
+
+    val toChangeInPrimaryKey = SchemaBundle(
+        2,
+        DatabaseBundle(
+            2,
+            "",
+            mutableListOf(
+                EntityBundle(
+                    "Song",
+                    "CREATE TABLE IF NOT EXISTS `Song` (`id` INTEGER NOT NULL, " +
+                        "`title` TEXT NOT NULL, `length` INTEGER NOT NULL, PRIMARY KEY(`title`))",
+                    listOf(
+                        FieldBundle(
+                            "id",
+                            "id",
+                            "INTEGER",
+                            true,
+                            "1"
+                        ),
+                        FieldBundle(
+                            "title",
+                            "title",
+                            "TEXT",
+                            true,
+                            ""
+                        ),
+                        FieldBundle(
+                            "length",
+                            "length",
+                            "INTEGER",
+                            true,
+                            "1"
+                        )
+                    ),
+                    PrimaryKeyBundle(
+                        false,
+                        mutableListOf("title")
+                    ),
+                    mutableListOf(),
+                    mutableListOf()
                 )
             ),
             mutableListOf(),