SchemaDiffer.kt

/*
 * 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.util

import androidx.room.migration.bundle.DatabaseBundle
import androidx.room.processor.ProcessorErrors
import androidx.room.vo.AutoMigrationResult

/**
 * This exception should be thrown to abandon processing an @AutoMigration.
 */
class DiffException(val errorMessage: String) : RuntimeException(errorMessage)

/**
 * 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>
)

/**
 * Receives the two bundles, diffs and returns a @SchemaDiffResult.
 *
 * Throws an @AutoMigrationException with a detailed error message when an AutoMigration cannot
 * be generated.
 */
class SchemaDiffer(
    val fromSchemaBundle: DatabaseBundle,
    val toSchemaBundle: DatabaseBundle
) {

    /**
     * Compares the two versions of the database based on the schemas provided, and detects
     * schema changes.
     *
     * @return the AutoMigrationResult containing the schema changes detected
     */
    fun diffSchemas(): SchemaDiffResult {
        val addedTables = mutableListOf<AutoMigrationResult.AddedTable>()
        val removedTables = mutableListOf<AutoMigrationResult.RemovedTable>()

        val addedColumns = mutableListOf<AutoMigrationResult.AddedColumn>()
        val changedColumns = mutableListOf<AutoMigrationResult.ChangedColumn>()
        val removedColumns = mutableListOf<AutoMigrationResult.RemovedColumn>()

        // 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))
            } 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)) {
                        changedColumns.add(
                            AutoMigrationResult.ChangedColumn(
                                v1Table.key,
                                v1Column.value,
                                match
                            )
                        )
                    } else if (match == null) {
                        removedColumns.add(
                            AutoMigrationResult.RemovedColumn(
                                v1Table.key,
                                v1Column.value
                            )
                        )
                    }
                }
            }
        }
        // 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))
            } else {
                val v2Columns = v2Table.value.fieldsByColumnName
                val v1Columns = v1Table.fieldsByColumnName
                v2Columns.entries.forEach { v2Column ->
                    val match = v1Columns[v2Column.key]
                    if (match == null) {
                        if (v2Column.value.isNonNull && v2Column.value.defaultValue == null) {
                            diffError(
                                ProcessorErrors.newNotNullColumnMustHaveDefaultValue(v2Column.key)
                            )
                        }
                        addedColumns.add(
                            AutoMigrationResult.AddedColumn(
                                v2Table.key,
                                v2Column.value
                            )
                        )
                    }
                }
            }
        }

        if (changedColumns.isNotEmpty()) {
            changedColumns.forEach { changedColumn ->
                diffError(
                    ProcessorErrors.columnWithChangedSchemaFound(
                        changedColumn.originalFieldBundle.columnName
                    )
                )
            }
        }

        if (removedColumns.isNotEmpty()) {
            removedColumns.forEach { removedColumn ->
                diffError(
                    ProcessorErrors.removedOrRenamedColumnFound(
                        removedColumn.fieldBundle.columnName
                    )
                )
            }
        }

        if (removedTables.isNotEmpty()) {
            removedTables.forEach { removedTable ->
                diffError(
                    ProcessorErrors.removedOrRenamedTableFound(
                        removedTable.entityBundle.tableName
                    )
                )
            }
        }

        return SchemaDiffResult(
            addedColumn = addedColumns,
            changedColumn = changedColumns,
            removedColumn = removedColumns,
            addedTable = addedTables,
            removedTable = removedTables
        )
    }

    private fun diffError(errorMsg: String) {
        throw DiffException(errorMsg)
    }
}