MultimapQueryResultAdapter.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.solver.query.result

import androidx.room.MapColumn
import androidx.room.compiler.codegen.CodeLanguage
import androidx.room.compiler.codegen.XClassName
import androidx.room.compiler.codegen.XCodeBlock
import androidx.room.compiler.codegen.asClassName
import androidx.room.compiler.processing.XType
import androidx.room.ext.CollectionTypeNames
import androidx.room.ext.CommonTypeNames
import androidx.room.ext.implementsEqualsAndHashcode
import androidx.room.parser.ParsedQuery
import androidx.room.processor.Context
import androidx.room.processor.ProcessorErrors
import androidx.room.processor.ProcessorErrors.AmbiguousColumnLocation.ENTITY
import androidx.room.processor.ProcessorErrors.AmbiguousColumnLocation.MAP_INFO
import androidx.room.processor.ProcessorErrors.AmbiguousColumnLocation.POJO
import androidx.room.solver.types.CursorValueReader
import androidx.room.verifier.ColumnInfo
import androidx.room.vo.ColumnIndexVar
import androidx.room.vo.Warning

/**
 * Abstract class for Map and Multimap result adapters.
 */
abstract class MultimapQueryResultAdapter(
    context: Context,
    parsedQuery: ParsedQuery,
    rowAdapters: List<RowAdapter>,
) : QueryResultAdapter(rowAdapters) {

    // List of duplicate columns in the query result. Note that if the query result info is not
    // available then we use the adapter mappings to determine if there are duplicate columns.
    // The latter approach might yield false positive (i.e. two POJOs that want the same column)
    // but the resolver will still produce correct results based on the result columns at runtime.
    val duplicateColumns: Set<String>

    val dupeColumnsIndexAdapter: AmbiguousColumnIndexAdapter?

    init {
        val resultColumns =
            parsedQuery.resultInfo?.columns?.map { it.name } ?: mappings.flatMap { it.usedColumns }
        duplicateColumns = buildSet {
            val visitedColumns = mutableSetOf<String>()
            resultColumns.forEach {
                // When Set.add() returns false the column is already visited and therefore a dupe.
                if (!visitedColumns.add(it)) {
                    add(it)
                }
            }
        }
        dupeColumnsIndexAdapter = if (duplicateColumns.isNotEmpty()) {
            AmbiguousColumnIndexAdapter(mappings, parsedQuery)
        } else {
            null
        }

        if (parsedQuery.resultInfo != null && duplicateColumns.isNotEmpty()) {
            // If there are duplicate columns and one of the result object is for a single column
            // then we should warn the user to disambiguate in the query projections since the
            // current AmbiguousColumnResolver will choose the first matching column. Only show
            // this warning if the query has been analyzed or else we risk false positives.
            mappings.filter {
                it.usedColumns.size == 1 && duplicateColumns.contains(it.usedColumns.first())
            }.forEach {
                val ambiguousColumnName = it.usedColumns.first()
                val (location, objectTypeName) = when (it) {
                    is SingleNamedColumnRowAdapter.SingleNamedColumnRowMapping ->
                        MAP_INFO to null
                    is PojoRowAdapter.PojoMapping ->
                        POJO to it.pojo.typeName
                    is EntityRowAdapter.EntityMapping ->
                        ENTITY to it.entity.typeName
                    else -> error("Unknown mapping type: $it")
                }
                context.logger.w(
                    Warning.AMBIGUOUS_COLUMN_IN_RESULT,
                    ProcessorErrors.ambiguousColumn(
                        columnName = ambiguousColumnName,
                        location = location,
                        typeName = objectTypeName?.toString(context.codeLanguage)
                    )
                )
            }
        }
    }

    enum class MapType(val className: XClassName) {
        DEFAULT(CommonTypeNames.MUTABLE_MAP),
        ARRAY_MAP(CollectionTypeNames.ARRAY_MAP),
        LONG_SPARSE(CollectionTypeNames.LONG_SPARSE_ARRAY),
        INT_SPARSE(CollectionTypeNames.INT_SPARSE_ARRAY);

        companion object {
            fun MapType.isSparseArray() = this == LONG_SPARSE || this == INT_SPARSE
        }
    }

    enum class CollectionValueType(val className: XClassName) {
        LIST(CommonTypeNames.MUTABLE_LIST),
        SET(CommonTypeNames.MUTABLE_SET)
    }

    companion object {

        /**
         * Checks if the @MapColumn annotation is needed for clarification regarding the key type
         * arg of a Map return type.
         */
        fun validateMapKeyTypeArg(
            context: Context,
            keyTypeArg: XType,
            keyReader: CursorValueReader?,
            keyColumnName: String?,
        ) {
            if (!keyTypeArg.implementsEqualsAndHashcode()) {
                context.logger.w(
                    Warning.DOES_NOT_IMPLEMENT_EQUALS_HASHCODE,
                    ProcessorErrors.classMustImplementEqualsAndHashCode(
                        keyTypeArg.asTypeName().toString(context.codeLanguage)
                    )
                )
            }

            val hasKeyColumnName = keyColumnName?.isNotEmpty() ?: false
            if (!hasKeyColumnName && keyReader != null) {
                context.logger.e(
                    ProcessorErrors.mayNeedMapColumn(
                        keyTypeArg.asTypeName().toString(context.codeLanguage)
                    )
                )
            }
        }

        /**
         * Checks if the @MapColumn annotation is needed for clarification regarding the value type
         * arg of a Map return type.
         */
        fun validateMapValueTypeArg(
            context: Context,
            valueTypeArg: XType,
            valueReader: CursorValueReader?,
            valueColumnName: String?,
        ) {
            val hasValueColumnName = valueColumnName?.isNotEmpty() ?: false
            if (!hasValueColumnName && valueReader != null) {
                context.logger.e(
                    ProcessorErrors.mayNeedMapColumn(
                        valueTypeArg.asTypeName().toString(context.codeLanguage)
                    )
                )
            }
        }

        /**
         * Retrieves the `columnName` value from a @MapColumn annotation.
         */
        fun getMapColumnName(context: Context, query: ParsedQuery, type: XType): String? {
            val resultColumns = query.resultInfo?.columns
            val resultTableAliases = query.tables.associate { it.name to it.alias }
            val annotation = type.getAnnotation(MapColumn::class.asClassName()) ?: return null

            val mapColumnName = annotation.getAsString("columnName")
            val mapColumnTableName = annotation.getAsString("tableName")

            fun List<ColumnInfo>.contains(
                columnName: String,
                tableName: String?
            ) = any { resultColumn ->
                val resultTableAlias = resultColumn.originTable?.let {
                    resultTableAliases[it] ?: it
                }
                resultColumn.name == columnName && (
                    if (!tableName.isNullOrEmpty()) {
                        resultTableAlias == tableName || resultColumn.originTable == tableName
                    } else true)
            }

            if (resultColumns != null) {
                // Disambiguation check for MapColumn
                if (!resultColumns.contains(mapColumnName, mapColumnTableName)) {
                    val errorColumn = if (mapColumnTableName.isNotEmpty()) {
                        "$mapColumnTableName."
                    } else {
                        ""
                    } + mapColumnName
                    context.logger.e(
                        ProcessorErrors.cannotMapSpecifiedColumn(
                            errorColumn,
                            resultColumns.map { it.name },
                            MapColumn::class.java.simpleName
                        )
                    )
                }
            }
            return mapColumnName
        }
    }

    /**
     * Generates a code expression that verifies if all matched fields are null.
     */
    fun getColumnNullCheckCode(
        language: CodeLanguage,
        cursorVarName: String,
        indexVars: List<ColumnIndexVar>
    ) = XCodeBlock.builder(language).apply {
        val space = when (language) {
            CodeLanguage.JAVA -> "%W"
            CodeLanguage.KOTLIN -> " "
        }
        val conditions = indexVars.map {
            XCodeBlock.of(
                language,
                "%L.isNull(%L)",
                cursorVarName,
                it.indexVar
            )
        }
        val placeholders = conditions.joinToString(separator = "$space&&$space") { "%L" }
        add(placeholders, *conditions.toTypedArray())
    }.build()
}