DatabaseVerifier.kt

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

import androidx.room.compiler.processing.XElement
import androidx.room.processor.Context
import androidx.room.vo.DatabaseView
import androidx.room.vo.Entity
import androidx.room.vo.EntityOrView
import androidx.room.vo.FtsEntity
import androidx.room.vo.FtsOptions
import androidx.room.vo.Warning
import org.sqlite.JDBC
import org.sqlite.SQLiteJDBCLoader
import java.io.File
import java.sql.Connection
import java.sql.SQLException
import java.util.regex.Pattern

/**
 * Builds an in-memory version of the database and verifies the queries against it.
 * This class is also used to resolve the return types.
 */
class DatabaseVerifier private constructor(
    val connection: Connection,
    val context: Context,
    entities: List<Entity>,
    views: List<DatabaseView>
) {
    val entitiesAndViews: List<EntityOrView> = entities + views

    companion object {
        private const val CONNECTION_URL = "jdbc:sqlite::memory:"

        /**
         * Taken from:
         * https://github.com/robolectric/robolectric/blob/master/shadows/framework/
         * src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java#L94
         *
         * This is actually not accurate because it might swap anything since it does not parse
         * SQL. That being said, for the verification purposes, it does not matter and clearly
         * much easier than parsing and rebuilding the query.
         */
        private val COLLATE_LOCALIZED_UNICODE_PATTERN = Pattern.compile(
            "\s+COLLATE\s+(LOCALIZED|UNICODE)", Pattern.CASE_INSENSITIVE
        )

        init {
            verifyTempDir()
            // Synchronize on a bootstrap loaded class so that parallel runs of Room in the same JVM
            // with isolated class loaders (such as the Gradle daemon) don't conflict with each
            // other when extracting the native library. SQLiteJDBCLoader already handles
            // multiple library versions, process isolation and multiple class loaders by using
            // UUID named library files.
            synchronized(System::class.java) {
                SQLiteJDBCLoader.initialize() // extract and loads native library
                JDBC.isValidURL(CONNECTION_URL) // call to register driver
            }
        }

        private fun verifyTempDir() {
            val defaultTempDir = System.getProperty("java.io.tmpdir")
            val tempDir = System.getProperty("org.sqlite.tmpdir", defaultTempDir)
            checkNotNull(tempDir) {
                "Room needs the java.io.tmpdir or org.sqlite.tmpdir system property to be set to " +
                    "setup SQLite."
            }
            File(tempDir).also {
                check(
                    it.isDirectory &&
                        (it.exists() || it.mkdirs()) &&
                        it.canRead() &&
                        it.canWrite()
                ) {
                    "The temp dir [$tempDir] needs to be a directory, must be readable, writable " +
                        "and allow executables. Please, provide a temporary directory that " +
                        "fits the requirements via the 'org.sqlite.tmpdir' property."
                }
            }
        }

        /**
         * Tries to create a verifier but returns null if it cannot find the driver.
         */
        fun create(
            context: Context,
            element: XElement,
            entities: List<Entity>,
            views: List<DatabaseView>
        ): DatabaseVerifier? {
            try {
                val connection = JDBC.createConnection(CONNECTION_URL, java.util.Properties())
                return DatabaseVerifier(connection, context, entities, views)
            } catch (ex: Exception) {
                context.logger.w(
                    Warning.CANNOT_CREATE_VERIFICATION_DATABASE, element,
                    DatabaseVerificationErrors.cannotCreateConnection(ex)
                )
                return null
            }
        }
    }

    init {
        entities.forEach { entity ->
            val stmt = connection.createStatement()
            val createTableQuery = if (entity is FtsEntity &&
                !FtsOptions.defaultTokenizers.contains(entity.ftsOptions.tokenizer)
            ) {
                // Custom FTS tokenizer used, use create statement without custom tokenizer
                // since the DB used for verification probably doesn't have the tokenizer.
                entity.getCreateTableQueryWithoutTokenizer()
            } else {
                entity.createTableQuery
            }
            try {
                stmt.executeUpdate(stripLocalizeCollations(createTableQuery))
            } catch (e: SQLException) {
                context.logger.e(entity.element, "${e.message}")
            }
            entity.indices.forEach {
                stmt.executeUpdate(it.createQuery(entity.tableName))
            }
        }
        views.forEach { view ->
            val stmt = connection.createStatement()
            try {
                stmt.executeUpdate(stripLocalizeCollations(view.createViewQuery))
            } catch (e: SQLException) {
                context.logger.e(view.element, "${e.message}")
            }
        }
    }

    fun analyze(sql: String): QueryResultInfo {
        return try {
            val stmt = connection.prepareStatement(stripLocalizeCollations(sql))
            QueryResultInfo(stmt.columnInfo())
        } catch (ex: SQLException) {
            QueryResultInfo(emptyList(), ex)
        }
    }

    private fun stripLocalizeCollations(sql: String) =
        COLLATE_LOCALIZED_UNICODE_PATTERN.matcher(sql).replaceAll(" COLLATE NOCASE")

    fun closeConnection(context: Context) {
        if (!connection.isClosed) {
            try {
                connection.close()
            } catch (t: Throwable) {
                // ignore.
                context.logger.d("failed to close the database connection ${t.message}")
            }
        }
    }
}