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.compiler.codegen.CodeLanguage
import androidx.room.compiler.codegen.XClassName
import androidx.room.compiler.codegen.XCodeBlock
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.vo.ColumnIndexVar
import androidx.room.vo.MapInfo
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) {
    abstract val keyTypeArg: XType
    abstract val valueTypeArg: XType

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

    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)
                }
            }
        }

        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 @MapInfo annotation is needed for clarification regarding the return type
         * of a Dao method.
         */
        fun validateMapTypeArgs(
            context: Context,
            keyTypeArg: XType,
            valueTypeArg: XType,
            keyReader: CursorValueReader?,
            valueReader: CursorValueReader?,
            mapInfo: MapInfo?,
        ) {

            if (!keyTypeArg.implementsEqualsAndHashcode()) {
                context.logger.w(
                    Warning.DOES_NOT_IMPLEMENT_EQUALS_HASHCODE,
                    ProcessorErrors.classMustImplementEqualsAndHashCode(
                        keyTypeArg.asTypeName().toString(context.codeLanguage)
                    )
                )
            }

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

            val hasValueColumnName = mapInfo?.valueColumnName?.isNotEmpty() ?: false
            if (!hasValueColumnName && valueReader != null) {
                context.logger.e(
                    ProcessorErrors.valueMayNeedMapInfo(
                        valueTypeArg.asTypeName().toString(context.codeLanguage)
                    )
                )
            }
        }
    }

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