DatabaseProcessingStep.kt

/*
 * Copyright (C) 2020 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

import androidx.room.compiler.processing.XElement
import androidx.room.compiler.processing.XProcessingEnv
import androidx.room.compiler.processing.XProcessingEnvConfig
import androidx.room.compiler.processing.XProcessingStep
import androidx.room.compiler.processing.XTypeElement
import androidx.room.log.RLog
import androidx.room.processor.Context
import androidx.room.processor.Context.BooleanProcessorOptions.GENERATE_KOTLIN
import androidx.room.processor.DatabaseProcessor
import androidx.room.processor.ProcessorErrors
import androidx.room.util.SchemaFileResolver
import androidx.room.vo.DaoMethod
import androidx.room.vo.Warning
import androidx.room.writer.AutoMigrationWriter
import androidx.room.writer.DaoWriter
import androidx.room.writer.DatabaseWriter
import java.io.File
import java.nio.file.Path

class DatabaseProcessingStep : XProcessingStep {

    override fun annotations(): Set<String> {
        return mutableSetOf(Database::class.qualifiedName!!)
    }

    override fun process(
        env: XProcessingEnv,
        elementsByAnnotation: Map<String, Set<XElement>>,
        isLastRound: Boolean
    ): Set<XTypeElement> {
        check(env.config == getEnvConfig(env.options)) {
            "Room Processor expected ${getEnvConfig(env.options)} " +
                "but was invoked with a different " +
                "configuration: ${env.config}"
        }
        val context = Context(env)

        val rejectedElements = mutableSetOf<XTypeElement>()
        val databases = elementsByAnnotation[Database::class.qualifiedName]
            ?.filterIsInstance<XTypeElement>()
            ?.mapNotNull { annotatedElement ->
                if (isLastRound && !annotatedElement.validate()) {
                    context.reportMissingTypeReference(annotatedElement.qualifiedName)
                    return@mapNotNull null
                }
                val (database, logs) = context.collectLogs { subContext ->
                    DatabaseProcessor(
                        subContext,
                        annotatedElement
                    ).process()
                }
                if (logs.hasMissingTypeErrors()) {
                    if (isLastRound) {
                        // Processing is done yet there are still missing type errors, only report
                        // those and don't generate code for the database class since compilation
                        // will fail anyway.
                        logs.writeTo(context, RLog.MissingTypeErrorFilter)
                        return@mapNotNull null
                    } else {
                        // Abandon processing this database class since it needed a type element
                        // that is missing. It is possible that the type will be generated by a
                        // further annotation processing round, so we will try again by adding
                        // this class element to a deferred set.
                        rejectedElements.add(annotatedElement)
                        return@mapNotNull null
                    }
                } else {
                    logs.writeTo(context)
                    return@mapNotNull database
                }
            }

        val daoMethodsMap = databases?.flatMap { db -> db.daoMethods.map { it to db } }?.toMap()
        daoMethodsMap?.let {
            prepareDaosForWriting(databases, it.keys.toList())
            it.forEach { (daoMethod, db) ->
                DaoWriter(
                    daoMethod.dao,
                    db.element,
                    context.codeLanguage
                ).write(context.processingEnv)
            }
        }

        databases?.forEach { db ->
            DatabaseWriter(db, context.codeLanguage).write(context.processingEnv)
            if (db.exportSchema) {
                val qName = db.element.qualifiedName
                val filename = "${db.version}.json"
                val exportToResources =
                    Context.BooleanProcessorOptions.EXPORT_SCHEMA_RESOURCE.getValue(env)
                val schemaOutFolderPath = context.schemaOutFolderPath
                if (exportToResources) {
                    context.logger.w(ProcessorErrors.EXPORTING_SCHEMA_TO_RESOURCES)
                    val schemaFileOutputStream = env.filer.writeResource(
                        filePath = Path.of("schemas", qName, filename),
                        originatingElements = listOf(db.element)
                    )
                    db.exportSchema(schemaFileOutputStream)
                } else if (schemaOutFolderPath != null) {
                    val schemaOutFolder = SchemaFileResolver.RESOLVER.getFile(
                        Path.of(schemaOutFolderPath)
                    )
                    if (!schemaOutFolder.exists()) {
                        schemaOutFolder.mkdirs()
                    }
                    val dbSchemaFolder = File(schemaOutFolder, qName)
                    if (!dbSchemaFolder.exists()) {
                        dbSchemaFolder.mkdirs()
                    }
                    db.exportSchema(
                        File(dbSchemaFolder, "${db.version}.json")
                    )
                } else {
                    context.logger.w(
                        warning = Warning.MISSING_SCHEMA_LOCATION,
                        element = db.element,
                        msg = ProcessorErrors.MISSING_SCHEMA_EXPORT_DIRECTORY
                    )
                }
            }
            db.autoMigrations.forEach { autoMigration ->
                AutoMigrationWriter(db.element, autoMigration, context.codeLanguage)
                    .write(context.processingEnv)
            }
        }

        return rejectedElements
    }

    /**
     * Traverses all dao methods and assigns them suffix if they are used in multiple databases.
     */
    private fun prepareDaosForWriting(
        databases: List<androidx.room.vo.Database>,
        daoMethods: List<DaoMethod>
    ) {
        daoMethods.groupBy { it.dao.typeName }
            // if used only in 1 database, nothing to do.
            .filter { entry -> entry.value.size > 1 }
            .forEach { entry ->
                entry.value.groupBy { daoMethod ->
                    // first suffix guess: Database's simple name
                    val db = databases.first { db -> db.daoMethods.contains(daoMethod) }
                    db.typeName.simpleNames.last()
                }.forEach { (dbName, methods) ->
                    if (methods.size == 1) {
                        // good, db names do not clash, use db name as suffix
                        methods.first().dao.setSuffix(dbName)
                    } else {
                        // ok looks like a dao is used in 2 different databases both of
                        // which have the same name. enumerate.
                        methods.forEachIndexed { index, method ->
                            method.dao.setSuffix("${dbName}_$index")
                        }
                    }
                }
            }
    }

    companion object {
        internal fun getEnvConfig(options: Map<String, String>) =
            XProcessingEnvConfig.DEFAULT.copy(
                excludeMethodsWithInvalidJvmSourceNames = !GENERATE_KOTLIN.getValue(options)
            )
    }
}