All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.zeoflow.depot.processor.DatabaseProcessor.kt Maven / Gradle / Ivy

Go to download

The Depot persistence library provides an abstraction layer over SQLite to allow for more robust database access while using the full power of SQLite.

The newest version!
/*
 * Copyright (C) 2021 ZeoFlow SRL
 *
 * 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 com.zeoflow.depot.processor

import com.zeoflow.depot.compiler.processing.XAnnotationBox
import com.zeoflow.depot.compiler.processing.XElement
import com.zeoflow.depot.compiler.processing.XType
import com.zeoflow.depot.compiler.processing.XTypeElement
import com.zeoflow.depot.ext.DepotTypeNames
import com.zeoflow.depot.processor.ProcessorErrors.AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF
import com.zeoflow.depot.processor.ProcessorErrors.AUTO_MIGRATION_SCHEMA_OUT_FOLDER_NULL
import com.zeoflow.depot.processor.ProcessorErrors.autoMigrationSchemasMustBeDepotGenerated
import com.zeoflow.depot.processor.ProcessorErrors.invalidAutoMigrationSchema
import com.zeoflow.depot.verifier.DatabaseVerificationErrors
import com.zeoflow.depot.verifier.DatabaseVerifier
import com.zeoflow.depot.vo.Dao
import com.zeoflow.depot.vo.DaoMethod
import com.zeoflow.depot.vo.Database
import com.zeoflow.depot.vo.DatabaseView
import com.zeoflow.depot.vo.Entity
import com.zeoflow.depot.vo.FtsEntity
import com.zeoflow.depot.vo.columnNames
import com.zeoflow.depot.vo.findFieldByColumnName
import com.squareup.javapoet.TypeName
import java.io.File
import java.io.FileInputStream
import java.util.Locale

class DatabaseProcessor(baseContext: Context, val element: XTypeElement) {
    val context = baseContext.fork(element)

    val depotDatabaseType: XType by lazy {
        context.processingEnv.requireType(
            DepotTypeNames.DEPOT_DB.packageName() + "." + DepotTypeNames.DEPOT_DB.simpleName()
        )
    }

    fun process(): Database {
        try {
            return doProcess()
        } finally {
            context.databaseVerifier?.closeConnection(context)
        }
    }

    private fun doProcess(): Database {
        val dbAnnotation = element.getAnnotation(com.zeoflow.depot.Database::class)!!

        val entities = processEntities(dbAnnotation, element)
        val viewsMap = processDatabaseViews(dbAnnotation)
        validateForeignKeys(element, entities)
        validateExternalContentFts(element, entities)

        val extendsDepotDb = depotDatabaseType.isAssignableFrom(element.type)
        context.checker.check(extendsDepotDb, element, ProcessorErrors.DB_MUST_EXTEND_DEPOT_DB)

        val views = resolveDatabaseViews(viewsMap.values.toList())
        val dbVerifier = if (element.hasAnnotation(com.zeoflow.depot.SkipQueryVerification::class)) {
            null
        } else {
            DatabaseVerifier.create(context, element, entities, views)
        }

        if (dbVerifier != null) {
            context.attachDatabaseVerifier(dbVerifier)
            verifyDatabaseViews(viewsMap, dbVerifier)
        }
        validateUniqueTableAndViewNames(element, entities, views)

        val declaredType = element.type
        val daoMethods = element.getAllMethods().filter {
            it.isAbstract()
        }.filterNot {
            // remove methods that belong to depot
            it.enclosingElement.className == DepotTypeNames.DEPOT_DB
        }.mapNotNull { executable ->
            // TODO when we add support for non Dao return types (e.g. database), this code needs
            // to change
            val daoType = executable.returnType
            val daoElement = daoType.typeElement
            if (daoElement == null) {
                context.logger.e(
                    executable,
                    ProcessorErrors.DATABASE_INVALID_DAO_METHOD_RETURN_TYPE
                )
                null
            } else {
                val dao = DaoProcessor(context, daoElement, declaredType, dbVerifier)
                    .process()
                DaoMethod(executable, executable.name, dao)
            }
        }

        validateUniqueDaoClasses(element, daoMethods, entities)
        validateUniqueIndices(element, entities)

        val hasForeignKeys = entities.any { it.foreignKeys.isNotEmpty() }

        val database = Database(
            version = dbAnnotation.value.version,
            element = element,
            type = element.type,
            entities = entities,
            views = views,
            daoMethods = daoMethods,
            exportSchema = dbAnnotation.value.exportSchema,
            enableForeignKeys = hasForeignKeys
        )
        database.autoMigrations = processAutoMigrations(element, database.bundle)
        return database
    }

    private fun processAutoMigrations(
        element: XTypeElement,
        latestDbSchema: com.zeoflow.depot.migration.bundle.DatabaseBundle
    ): List {
        val dbAnnotation = element.getAnnotation(com.zeoflow.depot.Database::class)!!

        val autoMigrationList = dbAnnotation
            .getAsAnnotationBoxArray("autoMigrations")

        if (autoMigrationList.isNotEmpty()) {
            if (!dbAnnotation.value.exportSchema) {
                context.logger.e(
                    element,
                    AUTO_MIGRATION_FOUND_BUT_EXPORT_SCHEMA_OFF
                )
                return emptyList()
            }
            if (context.schemaOutFolder == null) {
                context.logger.e(
                    element,
                    AUTO_MIGRATION_SCHEMA_OUT_FOLDER_NULL
                )
                return emptyList()
            }
        }

        return autoMigrationList.mapNotNull {
            val schemaOutFolderPath = context.schemaOutFolder!!.absolutePath +
                File.separator + element.className.canonicalName()
            val autoMigration = it.value
            val validatedFromSchemaFile = getValidatedSchemaFile(
                autoMigration.from,
                schemaOutFolderPath
            )

            fun deserializeSchemaFile(fileInputStream: FileInputStream, versionNumber: Int): Any {
                return try {
                    com.zeoflow.depot.migration.bundle.SchemaBundle.deserialize(fileInputStream).database
                } catch (th: Throwable) {
                    invalidAutoMigrationSchema(
                        "$versionNumber.json",
                        schemaOutFolderPath
                    )
                }
            }

            if (validatedFromSchemaFile != null) {
                val fromSchemaBundle = validatedFromSchemaFile.inputStream().use {
                    deserializeSchemaFile(it, autoMigration.from)
                }
                val toSchemaBundle = if (autoMigration.to == latestDbSchema.version) {
                    latestDbSchema
                } else {
                    val validatedToSchemaFile = getValidatedSchemaFile(
                        autoMigration.to,
                        schemaOutFolderPath
                    )
                    if (validatedToSchemaFile != null) {
                        validatedToSchemaFile.inputStream().use {
                            deserializeSchemaFile(it, autoMigration.to)
                        }
                    } else {
                        return@mapNotNull null
                    }
                }
                if (fromSchemaBundle !is com.zeoflow.depot.migration.bundle.DatabaseBundle || toSchemaBundle !is com.zeoflow.depot.migration.bundle.DatabaseBundle) {
                    context.logger.e(
                        element,
                        autoMigrationSchemasMustBeDepotGenerated(
                            autoMigration.from,
                            autoMigration.to
                        )
                    )
                    return@mapNotNull null
                }

                AutoMigrationProcessor(
                    element = element,
                    context = context,
                    spec = it.getAsType("spec")!!,
                    fromSchemaBundle = fromSchemaBundle,
                    toSchemaBundle = toSchemaBundle
                ).process()
            } else {
                null
            }
        }
    }

    private fun getValidatedSchemaFile(version: Int, schemaOutFolderPath: String): File? {
        val schemaFile = File(
            context.schemaOutFolder,
            element.className.canonicalName() + File.separatorChar + "$version.json"
        )
        if (!schemaFile.exists()) {
            context.logger.e(
                ProcessorErrors.autoMigrationSchemasNotFound(
                    "$version.json",
                    schemaOutFolderPath
                ),
                element
            )
            return null
        }

        if (schemaFile.length() <= 0) {
            context.logger.e(
                ProcessorErrors.autoMigrationSchemaIsEmpty(
                    "$version.json",
                    schemaOutFolderPath
                ),
                element
            )
            return null
        }
        return schemaFile
    }

    private fun validateForeignKeys(element: XTypeElement, entities: List) {
        val byTableName = entities.associateBy { it.tableName }
        entities.forEach { entity ->
            entity.foreignKeys.forEach foreignKeyLoop@{ foreignKey ->
                val parent = byTableName[foreignKey.parentTable]
                if (parent == null) {
                    context.logger.e(
                        element,
                        ProcessorErrors
                            .foreignKeyMissingParentEntityInDatabase(
                                foreignKey.parentTable,
                                entity.element.qualifiedName
                            )
                    )
                    return@foreignKeyLoop
                }
                val parentFields = foreignKey.parentColumns.mapNotNull { columnName ->
                    val parentField = parent.findFieldByColumnName(columnName)
                    if (parentField == null) {
                        context.logger.e(
                            entity.element,
                            ProcessorErrors.foreignKeyParentColumnDoesNotExist(
                                parentEntity = parent.element.qualifiedName,
                                missingColumn = columnName,
                                allColumns = parent.columnNames
                            )
                        )
                    }
                    parentField
                }
                if (parentFields.size != foreignKey.parentColumns.size) {
                    return@foreignKeyLoop
                }
                // ensure that it is indexed in the parent
                if (!parent.isUnique(foreignKey.parentColumns)) {
                    context.logger.e(
                        parent.element,
                        ProcessorErrors
                            .foreignKeyMissingIndexInParent(
                                parentEntity = parent.element.qualifiedName,
                                childEntity = entity.element.qualifiedName,
                                parentColumns = foreignKey.parentColumns,
                                childColumns = foreignKey.childFields
                                    .map { it.columnName }
                            )
                    )
                    return@foreignKeyLoop
                }
            }
        }
    }

    private fun validateUniqueIndices(element: XTypeElement, entities: List) {
        entities
            .flatMap { entity ->
                // associate each index with its entity
                entity.indices.map { Pair(it.name, entity) }
            }
            .groupBy { it.first } // group by index name
            .filter { it.value.size > 1 } // get the ones with duplicate names
            .forEach {
                // do not report duplicates from the same entity
                if (it.value.distinctBy { it.second.typeName }.size > 1) {
                    context.logger.e(
                        element,
                        ProcessorErrors.duplicateIndexInDatabase(
                            it.key,
                            it.value.map { "${it.second.typeName} > ${it.first}" }
                        )
                    )
                }
            }
    }

    private fun validateUniqueDaoClasses(
        dbElement: XTypeElement,
        daoMethods: List,
        entities: List
    ) {
        val entityTypeNames = entities.map { it.typeName }.toSet()
        daoMethods.groupBy { it.dao.typeName }
            .forEach {
                if (it.value.size > 1) {
                    val error = ProcessorErrors.duplicateDao(it.key, it.value.map { it.name })
                    it.value.forEach { daoMethod ->
                        context.logger.e(
                            daoMethod.element,
                            ProcessorErrors.DAO_METHOD_CONFLICTS_WITH_OTHERS
                        )
                    }
                    // also report the full error for the database
                    context.logger.e(dbElement, error)
                }
            }
        val check = fun(
            element: XElement,
            dao: Dao,
            typeName: TypeName?
        ) {
            typeName?.let {
                if (!entityTypeNames.contains(typeName)) {
                    context.logger.e(
                        element,
                        ProcessorErrors.shortcutEntityIsNotInDatabase(
                            database = dbElement.qualifiedName,
                            dao = dao.typeName.toString(),
                            entity = typeName.toString()
                        )
                    )
                }
            }
        }
        daoMethods.forEach { daoMethod ->
            daoMethod.dao.shortcutMethods.forEach { method ->
                method.entities.forEach {
                    check(method.element, daoMethod.dao, it.value.entityTypeName)
                }
            }
            daoMethod.dao.insertionMethods.forEach { method ->
                method.entities.forEach {
                    check(method.element, daoMethod.dao, it.value.entityTypeName)
                }
            }
        }
    }

    private fun validateUniqueTableAndViewNames(
        dbElement: XTypeElement,
        entities: List,
        views: List
    ) {
        val entitiesInfo = entities.map {
            Triple(it.tableName.lowercase(Locale.US), it.typeName.toString(), it.element)
        }
        val viewsInfo = views.map {
            Triple(it.viewName.lowercase(Locale.US), it.typeName.toString(), it.element)
        }
        (entitiesInfo + viewsInfo)
            .groupBy { (name, _, _) -> name }
            .filter { it.value.size > 1 }
            .forEach { byName ->
                val error = ProcessorErrors.duplicateTableNames(
                    byName.key,
                    byName.value.map { (_, typeName, _) -> typeName }
                )
                // report it for each of them and the database to make it easier
                // for the developer
                byName.value.forEach { (_, _, element) ->
                    context.logger.e(element, error)
                }
                context.logger.e(dbElement, error)
            }
    }

    private fun validateExternalContentFts(dbElement: XTypeElement, entities: List) {
        // Validate FTS external content entities are present in the same database.
        entities.filterIsInstance(FtsEntity::class.java)
            .filterNot {
                it.ftsOptions.contentEntity == null ||
                    entities.contains(it.ftsOptions.contentEntity)
            }
            .forEach {
                context.logger.e(
                    dbElement,
                    ProcessorErrors.missingExternalContentEntity(
                        it.element.qualifiedName,
                        it.ftsOptions.contentEntity!!.element.qualifiedName
                    )
                )
            }
    }

    private fun processEntities(
        dbAnnotation: XAnnotationBox,
        element: XTypeElement
    ): List {
        val entityList = dbAnnotation.getAsTypeList("entities")
        context.checker.check(
            entityList.isNotEmpty(), element,
            ProcessorErrors.DATABASE_ANNOTATION_MUST_HAVE_LIST_OF_ENTITIES
        )
        return entityList.mapNotNull {
            val typeElement = it.typeElement
            if (typeElement == null) {
                context.logger.e(
                    element,
                    ProcessorErrors.invalidEntityTypeInDatabaseAnnotation(
                        it.typeName
                    )
                )
                null
            } else {
                EntityProcessor(context, typeElement).process()
            }
        }
    }

    private fun processDatabaseViews(
        dbAnnotation: XAnnotationBox
    ): Map {
        val viewList = dbAnnotation.getAsTypeList("views")
        return viewList.mapNotNull {
            val viewElement = it.typeElement
            if (viewElement == null) {
                context.logger.e(
                    element,
                    ProcessorErrors.invalidViewTypeInDatabaseAnnotation(
                        it.typeName
                    )
                )
                null
            } else {
                viewElement to DatabaseViewProcessor(context, viewElement).process()
            }
        }.toMap()
    }

    private fun verifyDatabaseViews(
        map: Map,
        dbVerifier: DatabaseVerifier
    ) {
        for ((viewElement, view) in map) {
            if (viewElement.hasAnnotation(com.zeoflow.depot.SkipQueryVerification::class)) {
                continue
            }
            view.query.resultInfo = dbVerifier.analyze(view.query.original)
            if (view.query.resultInfo?.error != null) {
                context.logger.e(
                    viewElement,
                    DatabaseVerificationErrors.cannotVerifyQuery(
                        view.query.resultInfo!!.error!!
                    )
                )
            }
        }
    }

    /**
     * Resolves all the underlying tables for each of the [DatabaseView]. All the tables
     * including those that are indirectly referenced are included.
     *
     * @param views The list of all the [DatabaseView]s in this database. The order in this list is
     * important. A view always comes after all of the tables and views that it depends on.
     */
    fun resolveDatabaseViews(views: List): List {
        if (views.isEmpty()) {
            return emptyList()
        }
        val viewNames = views.map { it.viewName }
        fun isTable(name: String) = viewNames.none { it.equals(name, ignoreCase = true) }
        for (view in views) {
            // Some of these "tables" might actually be views.
            view.tables.addAll(view.query.tables.map { (name, _) -> name })
        }
        val unresolvedViews = views.toMutableList()
        // We will resolve nested views step by step, and store the results here.
        val resolvedViews = mutableMapOf>()
        val result = mutableListOf()
        do {
            for ((viewName, tables) in resolvedViews) {
                for (view in unresolvedViews) {
                    // If we find a nested view, replace it with the list of concrete tables.
                    if (view.tables.removeIf { it.equals(viewName, ignoreCase = true) }) {
                        view.tables.addAll(tables)
                    }
                }
            }
            var countNewlyResolved = 0
            // Separate out views that have all of their underlying tables resolved.
            unresolvedViews
                .filter { view -> view.tables.all { isTable(it) } }
                .forEach { view ->
                    resolvedViews[view.viewName] = view.tables
                    unresolvedViews.remove(view)
                    result.add(view)
                    countNewlyResolved++
                }
            // We couldn't resolve a single view in this step. It indicates circular reference.
            if (countNewlyResolved == 0) {
                context.logger.e(
                    element,
                    ProcessorErrors.viewCircularReferenceDetected(
                        unresolvedViews.map { it.viewName }
                    )
                )
                break
            }
            // We are done if we have resolved tables for all the views.
        } while (unresolvedViews.isNotEmpty())
        return result
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy