Add alternative TypeConverterStore for KSP
This CL introduces a new TypeConverterStore implementation that uses a
more fine granular cost calculation.
The current TypeConverterStore has a constant cost for each type
converter. Moreover, it does not optimize for nullability, resulting in
Room possibly picking a less optimal path (or wrong). With KSP, we now
get better nullability information so this new converter takes advantage
of it. To avoid breaking existing clients, this new converter store is
implemented as an alternative implementation that is only enabled in KSP
and can be turned off/on with a flag. That being said, we will still run
java tests with this new converter with hopes to turn down the old one
eventually.
This new converter store tries to preserve nullability. That means, if
we are writing a nullable field into database, it will first try to
convert it into a nullable db column. Detailed design of the heuristic
can be found here: go/room-null-aware-converter
Moreover, it has the ability to wrap user provided type converters such
that if the converter receives a nonnull value, the new Store can
synthesize a converter that receives null and returns null and use that.
In practical terms, this is great for usability as developers don't need
to worry about creating nullable versions of their converters.
Furthermore, it takes these null checks or up casts into account when
calculating path cost. Last but not least, if it cannot find a type
converter path to return a non-null value, it tries to read the same
value as nullable and if it succeds, wraps it in a null checking
converter. Even though this sounds questionable, we don't always have
the right types from db so it is OK to assume this if developer asked
for it (rather than forcing developer to return nullable). More details
on the justification can be found in the design doc.
There is also an inefficiency in the original type converter where,
while doing N -> N path finding, it always goes from start to end. In
practice, this method usually gets called with N -> 1 or 1 -> N and
always searching from start means branching out more than necessary.
This new implementation always goes from the smaller node set to the
larger size, significantly reducing the branching factor. I noticed this
while profiling the old one so the impact is quite sigificant but didn't
change the original one not to introduce risk.
Relnote: "We've added a new TypeConverter analyzer that takes nullability
information in types into account. As this information is only available
in KSP, it is turned on by default only in KSP. If it causes any issues,
you can turn it off by passing
room.useNullAwareTypeAnalysis=false to the annotation
processor. If that happens, please a file bug as this flag will be
removed in the future.
With this new TypeConverter analyzer, it is suggested to only provide
non-null receiving TypeConverters as the new analyzer has the ability
to wrap them with a null check.
Note that this has no impact for users using KAPT or Java as the
annotation processors (unlike KSP), don't have nullability information
in types."
Bug: 193437407
Test: NullabilityAwareTypeConverterStoreTest, NullabilityAwareTypeConversionTest.
Also added a variant to the java test app even though it is not really a goal
here, it would be good to ensure this new converter store works without KSP.
Change-Id: Ia88f916de3c15424ac8cc275d23223c6b5e47a6d
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt b/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
index b04ea44..074b07d 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/uptodatedness/TaskUpToDateValidator.kt
@@ -185,6 +185,7 @@
"lintVitalDebug",
"lintWithExpandProjectionDebug",
"lintWithoutExpandProjectionDebug",
+ "lintWithNullAwareTypeConverterDebug",
"lintWithKaptDebug",
"lintWithKspDebug",
diff --git a/room/integration-tests/kotlintestapp/src/androidTestWithKsp/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt b/room/integration-tests/kotlintestapp/src/androidTestWithKsp/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt
new file mode 100644
index 0000000..85c86ea
--- /dev/null
+++ b/room/integration-tests/kotlintestapp/src/androidTestWithKsp/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt
@@ -0,0 +1,357 @@
+/*
+ * 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.integration.kotlintestapp
+
+import android.database.Cursor
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.PrimaryKey
+import androidx.room.ProvidedTypeConverter
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This test can only pass in KSP with the new type converter store, which is why it is only in the
+ * KSP specific source set.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class NullabilityAwareTypeConversionTest {
+ lateinit var dao: UserDao
+ private val nullableConvertors = NullableTypeConverters()
+
+ @Before
+ fun init() {
+ dao = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ NullAwareConverterDatabase::class.java
+ ).addTypeConverter(nullableConvertors).build().userDao
+ }
+
+ private fun assertNullableConverterIsNotUsed() {
+ assertWithMessage(
+ "should've not used nullable conversion since it is not available in this scope"
+ ).that(nullableConvertors.toStringInvocations).isEmpty()
+ assertWithMessage(
+ "should've not used nullable conversion since it is not available in this scope"
+ ).that(nullableConvertors.fromStringInvocations).isEmpty()
+ }
+
+ @Test
+ fun insert() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = Country.UNITED_KINGDOM
+ )
+ dao.insert(user)
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-FR-UK")
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun setNonNullColumn() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-FR-null")
+ dao.setNonNullCountry(id = 1, nonNullCountry = Country.UNITED_KINGDOM)
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-UK-null")
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun setNullableColumn() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = Country.UNITED_KINGDOM
+ )
+ dao.insert(user)
+ dao.setNullableCountry(id = 1, nullableCountry = null)
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-FR-null")
+ dao.setNullableCountry(id = 1, nullableCountry = Country.UNITED_KINGDOM)
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-FR-UK")
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun load() {
+ val user1 = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = Country.UNITED_KINGDOM
+ )
+ dao.insert(user1)
+ assertThat(
+ dao.getById(1)
+ ).isEqualTo(user1)
+ val user2 = User(
+ id = 2,
+ nonNullCountry = Country.UNITED_KINGDOM,
+ nullableCountry = null
+ )
+ dao.insert(user2)
+ assertThat(
+ dao.getById(2)
+ ).isEqualTo(user2)
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun useNullableConverter() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = Country.UNITED_KINGDOM
+ )
+ dao.insert(user)
+ dao.setNullableCountryWithNullableTypeConverter(
+ id = 1,
+ nullableCountry = null
+ )
+ assertThat(
+ dao.getRawData()
+ ).isEqualTo("1-FR-null")
+ assertThat(
+ nullableConvertors.toStringInvocations
+ ).containsExactly(null)
+ }
+
+ @Test
+ fun loadNonNullColumn() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ val country = dao.getNonNullCountry(id = 1)
+ assertThat(country).isEqualTo(Country.FRANCE)
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun loadNullableColumn() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ val country = dao.getNullableCountry(id = 1)
+ assertThat(country).isNull()
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun loadNonNullColumn_withNullableConverter() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ val country = dao.getNonNullCountryWithNullableTypeConverter(id = 1)
+ assertThat(country).isEqualTo(Country.FRANCE)
+ // return value is non-null so it is better to use non-null converter and assume
+ // column is non-null, instead of using the nullable converter
+ assertNullableConverterIsNotUsed()
+ }
+
+ @Test
+ fun loadNonNullColumn_asNullable_withNullableConverter() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ val country = dao.getNonNullCountryAsNullableWithNullableTypeConverter(id = 1)
+ assertThat(country).isEqualTo(Country.FRANCE)
+ // return value is nullable so we are using the nullable converter because room does not
+ // know that the column is non-null.
+ // if one day Room understands it and this test fails, feel free to update it.
+ // We still want this test because right now Room does not know column is non-null hence
+ // it should prefer the nullable converter.
+ assertThat(
+ nullableConvertors.fromStringInvocations
+ ).containsExactly("FR")
+ }
+
+ @Test
+ fun loadNullableColumn_withNullableConverter() {
+ val user = User(
+ id = 1,
+ nonNullCountry = Country.FRANCE,
+ nullableCountry = null
+ )
+ dao.insert(user)
+ val country = dao.getNullableCountryWithNullableTypeConverter(id = 1)
+ assertThat(country).isNull()
+ assertThat(
+ nullableConvertors.fromStringInvocations
+ ).containsExactly(null)
+ }
+
+ @Database(
+ version = 1,
+ entities = [
+ User::class,
+ ],
+ exportSchema = false
+ )
+ @TypeConverters(NonNullTypeConverters::class)
+ abstract class NullAwareConverterDatabase : RoomDatabase() {
+ abstract val userDao: UserDao
+ }
+
+ @Dao
+ abstract class UserDao {
+
+ @Insert
+ abstract fun insert(user: User): Long
+
+ @Query("UPDATE user SET nonNullCountry = :nonNullCountry WHERE id = :id")
+ abstract fun setNonNullCountry(id: Long, nonNullCountry: Country)
+
+ @Query("UPDATE user SET nullableCountry = :nullableCountry WHERE id = :id")
+ abstract fun setNullableCountry(id: Long, nullableCountry: Country?)
+
+ @Query("SELECT * FROM user WHERE id = :id")
+ abstract fun getById(id: Long): User?
+
+ @Query("UPDATE user SET nullableCountry = :nullableCountry WHERE id = :id")
+ @TypeConverters(NullableTypeConverters::class)
+ abstract fun setNullableCountryWithNullableTypeConverter(
+ id: Long,
+ nullableCountry: Country?
+ )
+
+ @Query("SELECT nonNullCountry FROM user WHERE id = :id")
+ abstract fun getNonNullCountry(id: Long): Country
+
+ @Query("SELECT nullableCountry FROM user WHERE id = :id")
+ abstract fun getNullableCountry(id: Long): Country?
+
+ @Query("SELECT nullableCountry FROM user WHERE id = :id")
+ @TypeConverters(NullableTypeConverters::class)
+ abstract fun getNullableCountryWithNullableTypeConverter(id: Long): Country?
+
+ @Query("SELECT nonNullCountry FROM user WHERE id = :id")
+ @TypeConverters(NullableTypeConverters::class)
+ abstract fun getNonNullCountryWithNullableTypeConverter(id: Long): Country
+
+ @Query("SELECT nonNullCountry FROM user WHERE id = :id")
+ @TypeConverters(NullableTypeConverters::class)
+ abstract fun getNonNullCountryAsNullableWithNullableTypeConverter(id: Long): Country?
+
+ @Query("SELECT * FROM User ORDER BY id")
+ protected abstract fun getUsers(): Cursor
+
+ /**
+ * Return raw data in the database so that we can assert what is in the database
+ * without room's converters
+ */
+ fun getRawData(): String {
+ return buildString {
+ getUsers().use {
+ if (it.moveToNext()) {
+ append(it.getInt(0))
+ append("-")
+ append(it.getString(1))
+ append("-")
+ append(it.getString(2))
+ }
+ }
+ }
+ }
+ }
+
+ @Entity(tableName = "user")
+ data class User(
+ @PrimaryKey
+ val id: Long,
+ val nonNullCountry: Country,
+ val nullableCountry: Country?,
+ )
+
+ enum class Country(val countryCode: String) {
+ UNITED_KINGDOM("UK"),
+ FRANCE("FR"),
+ }
+
+ object NonNullTypeConverters {
+ @TypeConverter
+ fun toString(country: Country): String {
+ return country.countryCode
+ }
+
+ @TypeConverter
+ fun toCountry(string: String): Country {
+ return Country.values().find { it.countryCode == string }
+ ?: throw IllegalArgumentException("Country code '$string' not found")
+ }
+ }
+
+ @ProvidedTypeConverter
+ class NullableTypeConverters {
+ val toStringInvocations = mutableListOf<Country?>()
+ val fromStringInvocations = mutableListOf<String?>()
+ @TypeConverter
+ fun toString(country: Country?): String? {
+ toStringInvocations.add(country)
+ return country?.countryCode
+ }
+
+ @TypeConverter
+ fun toCountry(string: String?): Country? {
+ fromStringInvocations.add(string)
+ if (string == null) {
+ return null
+ }
+ return Country.values().find { it.countryCode == string }
+ ?: throw IllegalArgumentException("Country code '$string' not found")
+ }
+ }
+}
diff --git a/room/integration-tests/testapp/build.gradle b/room/integration-tests/testapp/build.gradle
index 17cf821..358a6e5 100644
--- a/room/integration-tests/testapp/build.gradle
+++ b/room/integration-tests/testapp/build.gradle
@@ -68,6 +68,18 @@
}
}
}
+ withNullAwareTypeConverter {
+ dimension "processorConfiguration"
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments = [
+ "room.schemaLocation" : "$projectDir/schemas".toString(),
+ "room.expandProjection" : "false",
+ "room.useNullAwareTypeAnalysis": "true"
+ ]
+ }
+ }
+ }
}
}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/RoomKspProcessor.kt b/room/room-compiler/src/main/kotlin/androidx/room/RoomKspProcessor.kt
index 7437810..0733517 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/RoomKspProcessor.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/RoomKspProcessor.kt
@@ -17,9 +17,12 @@
package androidx.room
import androidx.room.compiler.processing.ksp.KspBasicAnnotationProcessor
+import androidx.room.processor.Context.BooleanProcessorOptions.USE_NULL_AWARE_CONVERTER
+import androidx.room.processor.ProcessorErrors
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
+import javax.tools.Diagnostic
/**
* Entry point for processing using KSP.
@@ -27,7 +30,22 @@
class RoomKspProcessor(
environment: SymbolProcessorEnvironment
) : KspBasicAnnotationProcessor(environment) {
-
+ init {
+ // print a warning if null aware converter is disabled because we'll remove that ability
+ // soon.
+ if (USE_NULL_AWARE_CONVERTER.getInputValue(xProcessingEnv) == false) {
+ xProcessingEnv.messager.printMessage(
+ kind = Diagnostic.Kind.WARNING,
+ msg = """
+ Disabling null-aware type analysis in KSP is a temporary flag that will be
+ removed in a future release.
+ If the null-aware type analysis is causing a bug in your application,
+ please file a bug at ${ProcessorErrors.ISSUE_TRACKER_LINK} with
+ a sample app that reproduces your problem.
+ """.trimIndent()
+ )
+ }
+ }
override fun processingSteps() = listOf(
DatabaseProcessingStep()
)
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
index 537ceff..791a90e 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/Context.kt
@@ -41,6 +41,15 @@
val checker: Checks = Checks(logger)
val COMMON_TYPES = CommonTypes(processingEnv)
+ /**
+ * Checks whether we should use the TypeConverter store that has a specific heuristic for
+ * nullability. Defaults to true in KSP, false in javac.
+ */
+ val useNullAwareConverter: Boolean by lazy {
+ BooleanProcessorOptions.USE_NULL_AWARE_CONVERTER.getInputValue(processingEnv)
+ ?: (processingEnv.backend == XProcessingEnv.Backend.KSP)
+ }
+
val typeAdapterStore by lazy {
if (inheritedAdapterStore != null) {
TypeAdapterStore.copy(this, inheritedAdapterStore)
@@ -207,19 +216,21 @@
enum class BooleanProcessorOptions(val argName: String, private val defaultValue: Boolean) {
INCREMENTAL("room.incremental", defaultValue = true),
- EXPAND_PROJECTION("room.expandProjection", defaultValue = false);
+ EXPAND_PROJECTION("room.expandProjection", defaultValue = false),
+ USE_NULL_AWARE_CONVERTER("room.useNullAwareTypeAnalysis", defaultValue = false);
/**
* Returns the value of this option passed through the [XProcessingEnv]. If the value
* is null or blank, it returns the default value instead.
*/
fun getValue(processingEnv: XProcessingEnv): Boolean {
- val value = processingEnv.options[argName]
- return if (value.isNullOrBlank()) {
- defaultValue
- } else {
- value.toBoolean()
- }
+ return getInputValue(processingEnv) ?: defaultValue
+ }
+
+ fun getInputValue(processingEnv: XProcessingEnv): Boolean? {
+ return processingEnv.options[argName]?.takeIf {
+ it.isNotBlank()
+ }?.toBoolean()
}
}
}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/NullAwareTypeConverterStore.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/NullAwareTypeConverterStore.kt
new file mode 100644
index 0000000..95e4a8f
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/NullAwareTypeConverterStore.kt
@@ -0,0 +1,429 @@
+/*
+ * 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
+
+import androidx.room.compiler.processing.XNullability
+import androidx.room.compiler.processing.XNullability.NONNULL
+import androidx.room.compiler.processing.XNullability.NULLABLE
+import androidx.room.compiler.processing.XNullability.UNKNOWN
+import androidx.room.compiler.processing.XProcessingEnv.Backend
+import androidx.room.compiler.processing.XType
+import androidx.room.processor.Context
+import androidx.room.solver.types.CompositeTypeConverter
+import androidx.room.solver.types.NoOpConverter
+import androidx.room.solver.types.NullSafeTypeConverter
+import androidx.room.solver.types.RequireNotNullTypeConverter
+import androidx.room.solver.types.TypeConverter
+import androidx.room.solver.types.UpCastTypeConverter
+import java.util.PriorityQueue
+
+/**
+ * A [TypeConverterStore] implementation that generates better code when we have the nullability
+ * information in types. It is enabled by default only in KSP backend but it can also be turned
+ * on via the [Context.BooleanProcessorOptions.USE_NULL_AWARE_CONVERTER] flag.
+ *
+ * This [TypeConverterStore] tries to maintain the nullability of the input/output type
+ * when writing into/reading from database. Even though nullability preservation is preferred, it is
+ * not strictly required such that it will fall back to the mismatched nullability.
+ */
+class NullAwareTypeConverterStore(
+ context: Context,
+ /**
+ * Available TypeConverters. Note that we might synthesize new type converters based on this
+ * list.
+ */
+ typeConverters: List<TypeConverter>,
+ /**
+ * List of types that can be saved into db/read from without a converter.
+ */
+ private val knownColumnTypes: List<XType>
+) : TypeConverterStore {
+ override val typeConverters = if (context.processingEnv.backend == Backend.KSP) {
+ val processedConverters = typeConverters.toMutableList()
+ // create copies for converters that receive non-null values
+ typeConverters.forEach { converter ->
+ if (converter.from.nullability == NONNULL) {
+ val candidate = NullSafeTypeConverter(delegate = converter)
+ // before we add this null safe converter, make sure there is no other converter
+ // that would already handle the same arguments.
+ val match = processedConverters.any { other ->
+ other.from.isAssignableFrom(candidate.from) &&
+ candidate.to.isAssignableFrom(other.to)
+ }
+ if (!match) {
+ processedConverters.add(candidate)
+ }
+ }
+ }
+ processedConverters
+ } else {
+ typeConverters
+ }
+
+ // cache for type converter lookups to avoid traversing all of the list every time we need to
+ // find possible converters for a type. Unlike JAVAC, KSP supports equality in its objects so
+ // this tends to work rather well.
+ private val typeConvertersByFromCache = mutableMapOf<XType, List<TypeConverter>>()
+ private val typeConvertersByToCache = mutableMapOf<XType, List<TypeConverter>>()
+
+ /**
+ * Known column types that are nullable.
+ * Used in [getColumnTypesInPreferenceBuckets] to avoid re-partitioning known type lists.
+ */
+ private val knownNullableColumnTypes by lazy {
+ knownColumnTypes.filter { it.nullability == NULLABLE }
+ }
+
+ /**
+ * Known column types that are non-null or have unknown nullability.
+ * Used in [getColumnTypesInPreferenceBuckets] to avoid re-partitioning known type lists.
+ */
+ private val knownNonNullableColumnTypes by lazy {
+ knownColumnTypes.filter { it.nullability != NULLABLE }
+ }
+
+ /**
+ * Returns a list of lists for the given type, ordered by preference buckets for
+ * the given nullability.
+ */
+ private fun getColumnTypesInPreferenceBuckets(
+ nullability: XNullability,
+ explicitColumnTypes: List<XType>?
+ ): List<List<XType>> {
+ return if (explicitColumnTypes == null) {
+ when (nullability) {
+ NULLABLE -> {
+ // prioritize nulls
+ listOf(
+ knownNullableColumnTypes,
+ knownNonNullableColumnTypes
+ )
+ }
+ NONNULL -> {
+ // prioritize non-null
+ listOf(
+ knownNonNullableColumnTypes,
+ knownNullableColumnTypes
+ )
+ }
+ else -> {
+ // we don't know, YOLO
+ listOf(knownColumnTypes)
+ }
+ }
+ } else {
+ when (nullability) {
+ UNKNOWN -> listOf(explicitColumnTypes)
+ else -> listOf(
+ explicitColumnTypes.filter { it.nullability == nullability },
+ explicitColumnTypes.filter { it.nullability != nullability }
+ )
+ }
+ }
+ }
+
+ override fun findConverterIntoStatement(
+ input: XType,
+ columnTypes: List<XType>?
+ ): TypeConverter? {
+ getColumnTypesInPreferenceBuckets(
+ nullability = input.nullability,
+ explicitColumnTypes = columnTypes
+ ).forEach { types ->
+ findConverterIntoStatementInternal(
+ input = input,
+ columnTypes = types
+ )?.getOrCreateConverter()?.let {
+ return it
+ }
+ }
+ return null
+ }
+
+ private fun findConverterIntoStatementInternal(
+ input: XType,
+ columnTypes: List<XType>
+ ): TypeConverterEntry? {
+ if (columnTypes.isEmpty()) return null
+ val queue = TypeConverterQueue(
+ sourceType = input,
+ // each converter is keyed on which type they will take us to
+ keyType = TypeConverter::to
+ )
+
+ while (true) {
+ val current = queue.next() ?: break
+ val match = columnTypes.any { columnType ->
+ columnType.isSameType(current.type)
+ }
+ if (match) {
+ return current
+ }
+ // check for assignable matches but only enqueue them as there might be another shorter
+ // path
+ columnTypes.forEach { columnType ->
+ if (columnType.isAssignableFrom(current.type)) {
+ queue.maybeEnqueue(
+ current.appendConverter(
+ UpCastTypeConverter(
+ upCastFrom = current.type,
+ upCastTo = columnType
+ )
+ )
+ )
+ }
+ }
+ getAllTypeConvertersFrom(current.type).forEach {
+ queue.maybeEnqueue(current.appendConverter(it))
+ }
+ }
+ return null
+ }
+
+ override fun findConverterFromCursor(
+ columnTypes: List<XType>?,
+ output: XType
+ ): TypeConverter? {
+ @Suppress("NAME_SHADOWING") // intentional
+ val columnTypes = columnTypes ?: knownColumnTypes
+ // prefer nullable when reading from database, regardless of the output type
+ getColumnTypesInPreferenceBuckets(
+ nullability = NULLABLE,
+ explicitColumnTypes = columnTypes
+ ).forEach { types ->
+ findConverterFromCursorInternal(
+ columnTypes = types,
+ output = output
+ )?.let {
+ return it.getOrCreateConverter()
+ }
+ }
+
+ // if type is non-null, try to find nullable and add null check
+ return if (output.nullability == NONNULL) {
+ findConverterFromCursorInternal(
+ columnTypes = columnTypes,
+ output = output.makeNullable()
+ )?.appendConverter(
+ RequireNotNullTypeConverter(
+ from = output.makeNullable()
+ )
+ )
+ } else {
+ null
+ }
+ }
+
+ private fun findConverterFromCursorInternal(
+ columnTypes: List<XType>,
+ output: XType
+ ): TypeConverterEntry? {
+ if (columnTypes.isEmpty()) return null
+ val queue = TypeConverterQueue(
+ sourceType = output,
+ // each converter is keyed on which type they receive as we are doing pathfinding
+ // reverse here
+ keyType = TypeConverter::from
+ )
+
+ while (true) {
+ val current = queue.next() ?: break
+ val match = columnTypes.any { columnType ->
+ columnType.isSameType(current.type)
+ }
+ if (match) {
+ return current
+ }
+ // check for assignable matches but only enqueue them as there might be another shorter
+ // path
+ columnTypes.forEach { columnType ->
+ if (current.type.isAssignableFrom(columnType)) {
+ queue.maybeEnqueue(
+ current.prependConverter(
+ UpCastTypeConverter(
+ upCastFrom = columnType,
+ upCastTo = current.type
+ )
+ )
+ )
+ }
+ }
+ getAllTypeConvertersTo(current.type).forEach {
+ queue.maybeEnqueue(current.prependConverter(it))
+ }
+ }
+ return null
+ }
+
+ override fun findTypeConverter(input: XType, output: XType): TypeConverter? {
+ return findConverterIntoStatementInternal(
+ input = input,
+ columnTypes = listOf(output)
+ )?.getOrCreateConverter()
+ }
+
+ /**
+ * Returns all type converters that can receive input type and return into another type.
+ */
+ private fun getAllTypeConvertersFrom(
+ input: XType
+ ): List<TypeConverter> {
+ // for input, check assignability because it defines whether we can use the method or not.
+ return typeConvertersByFromCache.getOrPut(input) {
+ // this cache avoids us many assignability checks.
+ typeConverters.mapNotNull { converter ->
+ when {
+ converter.from.isSameType(input) -> converter
+ converter.from.isAssignableFrom(input) -> CompositeTypeConverter(
+ conv1 = UpCastTypeConverter(
+ upCastFrom = input,
+ upCastTo = converter.from
+ ),
+ conv2 = converter
+ )
+ else -> null
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns all type converters that can return the output type.
+ */
+ private fun getAllTypeConvertersTo(
+ output: XType
+ ): List<TypeConverter> {
+ return typeConvertersByToCache.getOrPut(output) {
+ // this cache avoids us many assignability checks.
+ typeConverters.mapNotNull { converter ->
+ when {
+ converter.to.isSameType(output) -> converter
+ output.isAssignableFrom(converter.to) -> CompositeTypeConverter(
+ conv1 = converter,
+ conv2 = UpCastTypeConverter(
+ upCastFrom = converter.to,
+ upCastTo = output
+ )
+ )
+ else -> null
+ }
+ }
+ }
+ }
+
+ /**
+ * Priority queue for the type converter search.
+ */
+ private class TypeConverterQueue(
+ sourceType: XType,
+ val keyType: TypeConverter.() -> XType
+ ) {
+ // using insertion order as the tie breaker for reproducible builds.
+ private var insertionOrder = 0
+
+ // map of XType to the converter that includes the path from the source type to the XType.
+ private val cheapestEntry = mutableMapOf<XType, TypeConverterEntry>()
+ private val queue = PriorityQueue<TypeConverterEntry>()
+
+ init {
+ val typeConverterEntry = TypeConverterEntry(
+ tieBreakerPriority = insertionOrder++,
+ type = sourceType,
+ converter = null
+ )
+ cheapestEntry[sourceType] = typeConverterEntry
+ queue.add(typeConverterEntry)
+ }
+
+ fun next(): TypeConverterEntry? {
+ while (queue.isNotEmpty()) {
+ val entry = queue.remove()
+ // check if we processed this type as there is no reason to process it again
+ if (cheapestEntry[entry.type] !== entry) {
+ continue
+ }
+ return entry
+ }
+ return null
+ }
+
+ /**
+ * Enqueues the given [converter] if its target type (defined by [keyType]) is not visited
+ * or visited with a more expensive converter.
+ */
+ fun maybeEnqueue(
+ converter: TypeConverter
+ ): Boolean {
+ val keyType = converter.keyType()
+ val existing = cheapestEntry[keyType]
+ if (existing == null ||
+ (existing.converter != null && existing.converter.cost > converter.cost)
+ ) {
+ val entry = TypeConverterEntry(insertionOrder++, keyType, converter)
+ cheapestEntry[keyType] = entry
+ queue.add(entry)
+ return true
+ }
+ return false
+ }
+ }
+
+ private data class TypeConverterEntry(
+ // when costs are equal, tieBreakerPriority is used
+ val tieBreakerPriority: Int,
+ val type: XType,
+ val converter: TypeConverter?
+ ) : Comparable<TypeConverterEntry> {
+ override fun compareTo(other: TypeConverterEntry): Int {
+ if (converter == null) {
+ if (other.converter != null) {
+ return -1
+ }
+ } else if (other.converter == null) {
+ return 1
+ } else {
+ val costCmp = converter.cost.compareTo(other.converter.cost)
+ if (costCmp != 0) {
+ return costCmp
+ }
+ }
+ return tieBreakerPriority.compareTo(other.tieBreakerPriority)
+ }
+
+ fun getOrCreateConverter() = converter ?: NoOpConverter(type)
+
+ fun appendConverter(nextConverter: TypeConverter): TypeConverter {
+ if (converter == null) {
+ return nextConverter
+ }
+ return CompositeTypeConverter(
+ conv1 = converter,
+ conv2 = nextConverter
+ )
+ }
+
+ fun prependConverter(previous: TypeConverter): TypeConverter {
+ if (converter == null) {
+ return previous
+ }
+ return CompositeTypeConverter(
+ conv1 = previous,
+ conv2 = converter
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
index 937ec76..d67bc34 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
@@ -172,11 +172,14 @@
ByteArrayColumnTypeAdapter.create(context.processingEnv).forEach(::addColumnAdapter)
ByteBufferColumnTypeAdapter.create(context.processingEnv).forEach(::addColumnAdapter)
PrimitiveBooleanToIntConverter.create(context.processingEnv).forEach(::addTypeConverter)
+ // null aware converter is able to automatically null wrap converters so we don't
+ // need this as long as we are running in KSP
BoxedBooleanToBoxedIntConverter.create(context.processingEnv)
.forEach(::addTypeConverter)
return TypeAdapterStore(
context = context, columnTypeAdapters = adapters,
- typeConverterStore = TypeConverterStore(
+ typeConverterStore = TypeConverterStore.create(
+ context = context,
typeConverters = converters,
knownColumnTypes = adapters.map { it.out }
),
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStore.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStore.kt
index fbd8863..0b7d0af 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStore.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStore.kt
@@ -17,49 +17,34 @@
package androidx.room.solver
import androidx.room.compiler.processing.XType
+import androidx.room.processor.Context
import androidx.room.solver.types.CompositeTypeConverter
import androidx.room.solver.types.NoOpConverter
+import androidx.room.solver.types.RequireNotNullTypeConverter
import androidx.room.solver.types.TypeConverter
-import java.util.LinkedList
+import androidx.room.solver.types.UpCastTypeConverter
-/**
- * Common logic that handles conversion between types either using built in converters or user
- * provided type converters.
- */
-class TypeConverterStore(
- /**
- * Available TypeConverters
- */
- private val typeConverters: List<TypeConverter>,
- /**
- * List of types that can be saved into db/read from without a converter.
- */
- private val knownColumnTypes: List<XType>
-) {
+interface TypeConverterStore {
+ val typeConverters: List<TypeConverter>
+
/**
* Finds a [TypeConverter] (might be composite) that can convert the given [input] type into
* one of the given [columnTypes]. If [columnTypes] is not specified, targets all
- * [knownColumnTypes].
+ * `knownColumnTypes`.
*/
fun findConverterIntoStatement(
input: XType,
- columnTypes: List<XType>? = null
- ) = findTypeConverter(
- inputs = listOf(input),
- outputs = columnTypes ?: knownColumnTypes
- )
+ columnTypes: List<XType>?
+ ): TypeConverter?
/**
* Finds a [TypeConverter] (might be composite) that can convert the given [columnTypes] into
- * the [output] type. If [columnTypes] is not specified, uses all [knownColumnTypes].
+ * the [output] type. If [columnTypes] is not specified, uses all `knownColumnTypes`.
*/
fun findConverterFromCursor(
- columnTypes: List<XType>? = null,
+ columnTypes: List<XType>?,
output: XType
- ) = findTypeConverter(
- inputs = columnTypes ?: knownColumnTypes,
- outputs = listOf(output)
- )
+ ): TypeConverter?
/**
* Finds a [TypeConverter] from [input] to [output].
@@ -67,104 +52,8 @@
fun findTypeConverter(
input: XType,
output: XType
- ) = findTypeConverter(
- inputs = listOf(input),
- outputs = listOf(output)
- )
+ ): TypeConverter?
- /**
- * Finds a type converter that can convert one of the input values to one of the output values.
- *
- * When multiple conversion paths are possible, shortest path (least amount of conversion) is
- * preferred.
- */
- private fun findTypeConverter(
- inputs: List<XType>,
- outputs: List<XType>
- ): TypeConverter? {
- if (inputs.isEmpty()) {
- return null
- }
- inputs.forEach { input ->
- if (outputs.any { output -> input.isSameType(output) }) {
- return NoOpConverter(input)
- }
- }
-
- val excludes = arrayListOf<XType>()
-
- val queue = LinkedList<TypeConverter>()
- fun List<TypeConverter>.findMatchingConverter(): TypeConverter? {
- // We prioritize exact match over assignable. To do that, this variable keeps any
- // assignable match and if we cannot find exactly same type match, we'll return the
- // assignable match.
- var assignableMatchFallback: TypeConverter? = null
- this.forEach { converter ->
- outputs.forEach { output ->
- if (output.isSameType(converter.to)) {
- return converter
- } else if (assignableMatchFallback == null &&
- output.isAssignableFrom(converter.to)
- ) {
- // if we don't find exact match, we'll return this.
- assignableMatchFallback = converter
- }
- }
- }
- return assignableMatchFallback
- }
- inputs.forEach { input ->
- val candidates = getAllTypeConverters(input, excludes)
- val match = candidates.findMatchingConverter()
- if (match != null) {
- return match
- }
- candidates.forEach {
- excludes.add(it.to)
- queue.add(it)
- }
- }
- excludes.addAll(inputs)
- while (queue.isNotEmpty()) {
- val prev = queue.pop()
- val from = prev.to
- val candidates = getAllTypeConverters(from, excludes)
- val match = candidates.findMatchingConverter()
- if (match != null) {
- return CompositeTypeConverter(prev, match)
- }
- candidates.forEach {
- excludes.add(it.to)
- queue.add(CompositeTypeConverter(prev, it))
- }
- }
- return null
- }
-
- /**
- * Returns all type converters that can receive input type and return into another type.
- * The returned list is ordered by priority such that if we have an exact match, it is
- * prioritized.
- */
- private fun getAllTypeConverters(input: XType, excludes: List<XType>): List<TypeConverter> {
- // for input, check assignability because it defines whether we can use the method or not.
- // for excludes, use exact match
- return typeConverters.filter { converter ->
- converter.from.isAssignableFrom(input) &&
- !excludes.any { it.isSameType(converter.to) }
- }.sortedByDescending {
- // if it is the same, prioritize
- if (it.from.isSameType(input)) {
- 2
- } else {
- 1
- }
- }
- }
-
- /**
- * Tries to reverse the converter going through the same nodes, if possible.
- */
fun reverse(converter: TypeConverter): TypeConverter? {
return when (converter) {
is NoOpConverter -> converter
@@ -173,6 +62,11 @@
val r2 = reverse(converter.conv2) ?: return null
CompositeTypeConverter(r2, r1)
}
+ // reverse of require not null is upcast since not null can be converted into nullable
+ is RequireNotNullTypeConverter -> UpCastTypeConverter(
+ upCastFrom = converter.to,
+ upCastTo = converter.from
+ )
else -> {
typeConverters.firstOrNull {
it.from.isSameType(converter.to) &&
@@ -181,4 +75,30 @@
}
}
}
+
+ companion object {
+ /**
+ * @param context Processing context
+ * @param typeConverters Available TypeConverters, ordered by priority when they have the
+ * same cost.
+ * @param knownColumnTypes List of types that can be saved into db/read from without a
+ * converter.
+ */
+ fun create(
+ context: Context,
+ typeConverters: List<TypeConverter>,
+ knownColumnTypes: List<XType>
+ ) = if (context.useNullAwareConverter) {
+ NullAwareTypeConverterStore(
+ context = context,
+ typeConverters = typeConverters,
+ knownColumnTypes = knownColumnTypes
+ )
+ } else {
+ TypeConverterStoreImpl(
+ typeConverters = typeConverters,
+ knownColumnTypes = knownColumnTypes
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStoreImpl.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStoreImpl.kt
new file mode 100644
index 0000000..848addf
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeConverterStoreImpl.kt
@@ -0,0 +1,152 @@
+/*
+ * 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
+
+import androidx.room.compiler.processing.XType
+import androidx.room.solver.types.CompositeTypeConverter
+import androidx.room.solver.types.NoOpConverter
+import androidx.room.solver.types.TypeConverter
+import java.util.LinkedList
+
+/**
+ * Legacy [TypeConverterStore] implementation that does not assume we have type nullability
+ * information. It is kept around for backwards compatibility.
+ */
+class TypeConverterStoreImpl(
+ /**
+ * Available TypeConverters
+ */
+ override val typeConverters: List<TypeConverter>,
+ /**
+ * List of types that can be saved into db/read from without a converter.
+ */
+ private val knownColumnTypes: List<XType>
+) : TypeConverterStore {
+ override fun findConverterIntoStatement(
+ input: XType,
+ columnTypes: List<XType>?
+ ) = findTypeConverter(
+ inputs = listOf(input),
+ outputs = columnTypes ?: knownColumnTypes
+ )
+
+ override fun findConverterFromCursor(
+ columnTypes: List<XType>?,
+ output: XType
+ ) = findTypeConverter(
+ inputs = columnTypes ?: knownColumnTypes,
+ outputs = listOf(output)
+ )
+
+ override fun findTypeConverter(
+ input: XType,
+ output: XType
+ ) = findTypeConverter(
+ inputs = listOf(input),
+ outputs = listOf(output)
+ )
+
+ /**
+ * Finds a type converter that can convert one of the input values to one of the output values.
+ *
+ * When multiple conversion paths are possible, shortest path (least amount of conversion) is
+ * preferred.
+ */
+ private fun findTypeConverter(
+ inputs: List<XType>,
+ outputs: List<XType>
+ ): TypeConverter? {
+ if (inputs.isEmpty()) {
+ return null
+ }
+ inputs.forEach { input ->
+ if (outputs.any { output -> input.isSameType(output) }) {
+ return NoOpConverter(input)
+ }
+ }
+
+ val excludes = arrayListOf<XType>()
+
+ val queue = LinkedList<TypeConverter>()
+ fun List<TypeConverter>.findMatchingConverter(): TypeConverter? {
+ // We prioritize exact match over assignable. To do that, this variable keeps any
+ // assignable match and if we cannot find exactly same type match, we'll return the
+ // assignable match.
+ var assignableMatchFallback: TypeConverter? = null
+ this.forEach { converter ->
+ outputs.forEach { output ->
+ if (output.isSameType(converter.to)) {
+ return converter
+ } else if (assignableMatchFallback == null &&
+ output.isAssignableFrom(converter.to)
+ ) {
+ // if we don't find exact match, we'll return this.
+ assignableMatchFallback = converter
+ }
+ }
+ }
+ return assignableMatchFallback
+ }
+ inputs.forEach { input ->
+ val candidates = getAllTypeConverters(input, excludes)
+ val match = candidates.findMatchingConverter()
+ if (match != null) {
+ return match
+ }
+ candidates.forEach {
+ excludes.add(it.to)
+ queue.add(it)
+ }
+ }
+ excludes.addAll(inputs)
+ while (queue.isNotEmpty()) {
+ val prev = queue.pop()
+ val from = prev.to
+ val candidates = getAllTypeConverters(from, excludes)
+ val match = candidates.findMatchingConverter()
+ if (match != null) {
+ return CompositeTypeConverter(prev, match)
+ }
+ candidates.forEach {
+ excludes.add(it.to)
+ queue.add(CompositeTypeConverter(prev, it))
+ }
+ }
+ return null
+ }
+
+ /**
+ * Returns all type converters that can receive input type and return into another type.
+ * The returned list is ordered by priority such that if we have an exact match, it is
+ * prioritized.
+ */
+ private fun getAllTypeConverters(input: XType, excludes: List<XType>): List<TypeConverter> {
+ // for input, check assignability because it defines whether we can use the method or not.
+ // for excludes, use exact match
+ return typeConverters.filter { converter ->
+ converter.from.isAssignableFrom(input) &&
+ !excludes.any { it.isSameType(converter.to) }
+ }.sortedByDescending {
+ // if it is the same, prioritize
+ if (it.from.isSameType(input)) {
+ 2
+ } else {
+ 1
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/CompositeTypeConverter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/CompositeTypeConverter.kt
index b9c7cb9..2bb2c0c 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/CompositeTypeConverter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/CompositeTypeConverter.kt
@@ -22,7 +22,9 @@
* combines 2 type converters
*/
class CompositeTypeConverter(val conv1: TypeConverter, val conv2: TypeConverter) : TypeConverter(
- conv1.from, conv2.to
+ from = conv1.from,
+ to = conv2.to,
+ cost = conv1.cost + conv2.cost
) {
override fun doConvert(inputVarName: String, outputVarName: String, scope: CodeGenScope) {
scope.builder().apply {
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/NullAwareTypeConverters.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/NullAwareTypeConverters.kt
new file mode 100644
index 0000000..288899f
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/NullAwareTypeConverters.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.types
+
+import androidx.annotation.VisibleForTesting
+import androidx.room.compiler.processing.XNullability
+import androidx.room.compiler.processing.XType
+import androidx.room.ext.L
+import androidx.room.ext.S
+import androidx.room.ext.T
+import androidx.room.solver.CodeGenScope
+import java.lang.IllegalStateException
+
+/**
+ * A type converter that checks if the input is null and returns null instead of calling the
+ * [delegate].
+ */
+class NullSafeTypeConverter(
+ @VisibleForTesting
+ internal val delegate: TypeConverter
+) : TypeConverter(
+ from = delegate.from.makeNullable(),
+ to = delegate.to.makeNullable(),
+ cost = delegate.cost + Cost.NULL_SAFE
+) {
+ init {
+ check(delegate.from.nullability == XNullability.NONNULL) {
+ "NullableWrapper can ony be used if the input type is non-nullable"
+ }
+ }
+
+ override fun doConvert(inputVarName: String, outputVarName: String, scope: CodeGenScope) {
+ scope.builder().apply {
+ beginControlFlow("if($L == null)", inputVarName)
+ addStatement("$L = null", outputVarName)
+ nextControlFlow("else")
+ delegate.convert(inputVarName, outputVarName, scope)
+ endControlFlow()
+ }
+ }
+}
+
+/**
+ * A [TypeConverter] that checks the value is `non-null` and throws if it is null.
+ */
+class RequireNotNullTypeConverter(
+ from: XType,
+) : TypeConverter(
+ from = from,
+ to = from.makeNonNullable(),
+ cost = Cost.REQUIRE_NOT_NULL
+) {
+ init {
+ check(from.nullability != XNullability.NONNULL) {
+ "No reason to null check a non-null input"
+ }
+ }
+
+ override fun doConvert(inputVarName: String, outputVarName: String, scope: CodeGenScope) {
+ scope.builder().apply {
+ beginControlFlow("if($L == null)", inputVarName)
+ addStatement(
+ "throw new $T($S)", IllegalStateException::class.java,
+ "Expected non-null ${from.typeName}, but it was null."
+ )
+ nextControlFlow("else").apply {
+ addStatement("$L = $L", outputVarName, inputVarName)
+ }
+ endControlFlow()
+ }
+ }
+
+ override fun doConvert(inputVarName: String, scope: CodeGenScope): String {
+ scope.builder().apply {
+ beginControlFlow("if($L == null)", inputVarName)
+ addStatement(
+ "throw new $T($S)", IllegalStateException::class.java,
+ "Expected non-null ${from.typeName}, but it was null."
+ )
+ endControlFlow()
+ }
+ return inputVarName
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/TypeConverter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/TypeConverter.kt
index fab9eb1..2f8ae17 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/TypeConverter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/TypeConverter.kt
@@ -16,6 +16,7 @@
package androidx.room.solver.types
+import androidx.annotation.VisibleForTesting
import androidx.room.compiler.processing.XType
import androidx.room.ext.L
import androidx.room.ext.T
@@ -24,7 +25,11 @@
/**
* A code generator that can convert from 1 type to another
*/
-abstract class TypeConverter(val from: XType, val to: XType) {
+abstract class TypeConverter(
+ val from: XType,
+ val to: XType,
+ val cost: Cost = Cost.CONVERTER
+) {
/**
* Should generate the code that will covert [inputVarName] of type [from] to [outputVarName]
* of type [to]. This method *should not* declare the [outputVarName] as it is already
@@ -70,4 +75,123 @@
) {
doConvert(inputVarName, outputVarName, scope)
}
+
+ /**
+ * Represents the cost of a type converter.
+ *
+ * When calculating cost, we consider multiple types of conversions in ascending order, from
+ * cheapest to expensive:
+ * * `upcast`: Converts from a subtype to super type (e.g. Int to Number)
+ * * `nullSafeWrapper`: Adds a null check before calling the delegated converter or else
+ * returns null.
+ * * `converter`: Unit converter
+ * * `requireNotNull`: Adds a null check before returning the delegated converter's value
+ * or throws if the value is null.
+ *
+ * The comparison happens in buckets such that having 10 upcasts is still cheaper than having
+ * 1 nullSafeWrapper.
+ *
+ * Internally, this class uses an IntArray to keep its fields to optimize for readability in
+ * operators.
+ */
+ class Cost private constructor(
+ /**
+ * Values for each bucket, ordered from most expensive to least expensive.
+ */
+ private val values: IntArray
+ ) : Comparable<Cost> {
+ init {
+ require(values.size == Buckets.SIZE)
+ }
+
+ constructor(
+ converters: Int,
+ nullSafeWrapper: Int = 0,
+ upCasts: Int = 0,
+ requireNotNull: Int = 0
+ ) : this(
+ // NOTE: construction order here MUST match the [Buckets]
+ intArrayOf(
+ requireNotNull,
+ converters,
+ nullSafeWrapper,
+ upCasts
+ )
+ )
+
+ @VisibleForTesting
+ val upCasts: Int
+ get() = values[Buckets.UP_CAST]
+
+ @VisibleForTesting
+ val nullSafeWrapper: Int
+ get() = values[Buckets.NULL_SAFE]
+
+ @VisibleForTesting
+ val requireNotNull: Int
+ get() = values[Buckets.REQUIRE_NOT_NULL]
+
+ @VisibleForTesting
+ val converters: Int
+ get() = values[Buckets.CONVERTER]
+
+ operator fun plus(other: Cost) = Cost(
+ values = IntArray(Buckets.SIZE) { index ->
+ values[index] + other.values[index]
+ }
+ )
+
+ override operator fun compareTo(other: Cost): Int {
+ for (index in 0 until Buckets.SIZE) {
+ val cmp = values[index].compareTo(other.values[index])
+ if (cmp != 0) {
+ return cmp
+ }
+ }
+ return 0
+ }
+
+ override fun toString() = buildString {
+ append("Cost[")
+ append("upcast:")
+ append(upCasts)
+ append(",nullsafe:")
+ append(nullSafeWrapper)
+ append(",converters:")
+ append(converters)
+ append(",requireNotNull:")
+ append(requireNotNull)
+ append("]")
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is Cost) {
+ return false
+ }
+ return compareTo(other) == 0
+ }
+
+ override fun hashCode(): Int {
+ // we don't really use hash functions so this is good enough as a hash function.
+ return values[Buckets.CONVERTER]
+ }
+
+ companion object {
+ val UP_CAST = Cost(converters = 0, upCasts = 1)
+ val NULL_SAFE = Cost(converters = 0, nullSafeWrapper = 1)
+ val CONVERTER = Cost(converters = 1)
+ val REQUIRE_NOT_NULL = Cost(converters = 0, requireNotNull = 1)
+ }
+
+ /**
+ * Comparison buckets, ordered from the MOST expensive to LEAST expensive
+ */
+ private object Buckets {
+ const val REQUIRE_NOT_NULL = 0
+ const val CONVERTER = 1
+ const val NULL_SAFE = 2
+ const val UP_CAST = 3
+ const val SIZE = 4
+ }
+ }
}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/types/UpCastTypeConverter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/UpCastTypeConverter.kt
new file mode 100644
index 0000000..ad07028
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/types/UpCastTypeConverter.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.types
+
+import androidx.room.compiler.processing.XType
+import androidx.room.ext.L
+import androidx.room.solver.CodeGenScope
+
+/**
+ * We have a special class for upcasting types in type converters. (e.g. Int to Number)
+ * It is used in the pathfinding to be more expensive than exactly matching calls to prioritize
+ * exact matches.
+ */
+class UpCastTypeConverter(
+ upCastFrom: XType,
+ upCastTo: XType
+) : TypeConverter(
+ from = upCastFrom,
+ to = upCastTo,
+ cost = Cost.UP_CAST
+) {
+ override fun doConvert(inputVarName: String, outputVarName: String, scope: CodeGenScope) {
+ scope.builder().apply {
+ addStatement("$L = $L", outputVarName, inputVarName)
+ }
+ }
+
+ override fun doConvert(inputVarName: String, scope: CodeGenScope): String {
+ // normally, we don't need to generate any code here but if the upcast is converting from
+ // a primitive to boxed; we need to. Otherwise, output value won't become an object and
+ // that might break the rest of the code generation (e.g. checking nullable on primitive)
+ return if (to.typeName.isBoxedPrimitive && from.typeName.isPrimitive) {
+ super.doConvert(inputVarName, scope)
+ } else {
+ inputVarName
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/solver/NullabilityAwareTypeConverterStoreTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/solver/NullabilityAwareTypeConverterStoreTest.kt
new file mode 100644
index 0000000..99a0162
--- /dev/null
+++ b/room/room-compiler/src/test/kotlin/androidx/room/solver/NullabilityAwareTypeConverterStoreTest.kt
@@ -0,0 +1,743 @@
+/*
+ * 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
+
+import androidx.room.RoomKspProcessor
+import androidx.room.compiler.processing.XType
+import androidx.room.compiler.processing.util.Source
+import androidx.room.compiler.processing.util.XTestInvocation
+import androidx.room.compiler.processing.util.compiler.TestCompilationArguments
+import androidx.room.compiler.processing.util.compiler.compile
+import androidx.room.compiler.processing.util.runProcessorTest
+import androidx.room.processor.Context.BooleanProcessorOptions.USE_NULL_AWARE_CONVERTER
+import androidx.room.processor.CustomConverterProcessor
+import androidx.room.processor.DaoProcessor
+import androidx.room.solver.types.CustomTypeConverterWrapper
+import androidx.room.solver.types.TypeConverter
+import androidx.room.testing.context
+import androidx.room.vo.BuiltInConverterFlags
+import androidx.room.writer.DaoWriter
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import javax.tools.Diagnostic
+
+@RunWith(JUnit4::class)
+class NullabilityAwareTypeConverterStoreTest {
+ @get:Rule
+ val tmpFolder = TemporaryFolder()
+ val source = Source.kotlin(
+ "Foo.kt",
+ """
+ import androidx.room.*
+ class MyClass
+ class NonNullConverters {
+ @TypeConverter
+ fun myClassToString(myClass: MyClass): String {
+ TODO()
+ }
+ @TypeConverter
+ fun stringToMyClass(input: String): MyClass {
+ TODO()
+ }
+ }
+ class MyNullableReceivingConverters {
+ @TypeConverter
+ fun nullableMyClassToNonNullString(myClass: MyClass?): String {
+ TODO()
+ }
+ @TypeConverter
+ fun nullableStringToNonNullMyClass(input: String?): MyClass {
+ TODO()
+ }
+ }
+ class MyFullyNullableConverters {
+ @TypeConverter
+ fun nullableMyClassToNullableString(myClass: MyClass?): String? {
+ TODO()
+ }
+ @TypeConverter
+ fun nullableStringToNullableMyClass(input: String?): MyClass? {
+ TODO()
+ }
+ }
+ """.trimIndent()
+ )
+
+ private fun XTestInvocation.createStore(
+ vararg converters: String
+ ): TypeConverterStore {
+ val allConverters = converters.flatMap {
+ CustomConverterProcessor(
+ context = context,
+ element = processingEnv.requireTypeElement(it)
+ ).process()
+ }.map(::CustomTypeConverterWrapper)
+ return TypeAdapterStore.create(
+ context = context,
+ builtInConverterFlags = BuiltInConverterFlags.DEFAULT,
+ allConverters
+ ).typeConverterStore
+ }
+
+ @Test
+ fun withNonNullableConverters() {
+ val result = collectStringConversionResults("NonNullConverters")
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ String? to MyClass?: stringToMyClass
+ MyClass? to String?: myClassToString
+ String? to MyClass!: stringToMyClass
+ MyClass! to String?: myClassToString
+ String! to MyClass?: stringToMyClass
+ MyClass? to String!: myClassToString
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ KSP
+ String? to MyClass?: (String? == null ? null : stringToMyClass)
+ MyClass? to String?: (MyClass? == null ? null : myClassToString)
+ String? to MyClass!: null
+ MyClass! to String?: myClassToString / (String! as String?)
+ String! to MyClass?: stringToMyClass / (MyClass! as MyClass?)
+ MyClass? to String!: null
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun withNonNullableConverters_cursor() {
+ val result = collectCursorResults("NonNullConverters")
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ Cursor to MyClass?: stringToMyClass
+ MyClass? to Cursor: myClassToString
+ Cursor to MyClass!: stringToMyClass
+ MyClass! to Cursor: myClassToString
+ KSP
+ Cursor to MyClass?: (String? == null ? null : stringToMyClass)
+ MyClass? to Cursor: (MyClass? == null ? null : myClassToString)
+ // when reading from cursor, we can assume non-null cursor value when
+ // we don't have a converter that would convert it from String?
+ Cursor to MyClass!: stringToMyClass
+ MyClass! to Cursor: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun withNonNullAndNullableReceiving() {
+ val result = collectStringConversionResults(
+ "NonNullConverters",
+ "MyNullableReceivingConverters"
+ )
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ String? to MyClass?: stringToMyClass
+ MyClass? to String?: myClassToString
+ String? to MyClass!: stringToMyClass
+ MyClass! to String?: myClassToString
+ String! to MyClass?: stringToMyClass
+ MyClass? to String!: myClassToString
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ KSP
+ String? to MyClass?: nullableStringToNonNullMyClass / (MyClass! as MyClass?)
+ MyClass? to String?: nullableMyClassToNonNullString / (String! as String?)
+ String? to MyClass!: nullableStringToNonNullMyClass
+ MyClass! to String?: myClassToString / (String! as String?)
+ String! to MyClass?: stringToMyClass / (MyClass! as MyClass?)
+ MyClass? to String!: nullableMyClassToNonNullString
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun withNonNullAndNullableReceiving_cursor() {
+ val result = collectCursorResults(
+ "NonNullConverters",
+ "MyNullableReceivingConverters"
+ )
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ Cursor to MyClass?: stringToMyClass
+ MyClass? to Cursor: myClassToString
+ Cursor to MyClass!: stringToMyClass
+ MyClass! to Cursor: myClassToString
+ KSP
+ // we start from nullable string because cursor values are assumed nullable when reading
+ Cursor to MyClass?: nullableStringToNonNullMyClass / (MyClass! as MyClass?)
+ // there is an additional upcast for String! to String? because when the written value
+ // is nullable, we prioritize a nullable column
+ MyClass? to Cursor: nullableMyClassToNonNullString / (String! as String?)
+ Cursor to MyClass!: nullableStringToNonNullMyClass
+ MyClass! to Cursor: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun withFullyNullableConverters() {
+ val result = collectStringConversionResults(
+ "NonNullConverters",
+ "MyNullableReceivingConverters",
+ "MyFullyNullableConverters"
+ )
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ String? to MyClass?: stringToMyClass
+ MyClass? to String?: myClassToString
+ String? to MyClass!: stringToMyClass
+ MyClass! to String?: myClassToString
+ String! to MyClass?: stringToMyClass
+ MyClass? to String!: myClassToString
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ KSP
+ String? to MyClass?: nullableStringToNullableMyClass
+ MyClass? to String?: nullableMyClassToNullableString
+ String? to MyClass!: nullableStringToNonNullMyClass
+ // another alternative is to use nonNullMyClassToNullableString and then upcast
+ // both are equal weight
+ MyClass! to String?: (MyClass! as MyClass?) / nullableMyClassToNullableString
+ String! to MyClass?: (String! as String?) / nullableStringToNullableMyClass
+ MyClass? to String!: nullableMyClassToNonNullString
+ String! to MyClass!: stringToMyClass
+ MyClass! to String!: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun withFullyNullableConverters_cursor() {
+ val result = collectCursorResults(
+ "NonNullConverters",
+ "MyNullableReceivingConverters",
+ "MyFullyNullableConverters"
+ )
+ assertResult(
+ result.trim(),
+ """
+ JAVAC
+ Cursor to MyClass?: stringToMyClass
+ MyClass? to Cursor: myClassToString
+ Cursor to MyClass!: stringToMyClass
+ MyClass! to Cursor: myClassToString
+ KSP
+ Cursor to MyClass?: nullableStringToNullableMyClass
+ MyClass? to Cursor: nullableMyClassToNullableString
+ Cursor to MyClass!: nullableStringToNonNullMyClass
+ MyClass! to Cursor: myClassToString
+ """.trimIndent()
+ )
+ }
+
+ @Test
+ fun pojoProcess() {
+ // This is a repro case from trying to run TestApp with null aware converter.
+ // It reproduces the case where if we don't know nullability, we shouldn't try to
+ // prioritize nullable or non-null; instead YOLO and find whichever we can find first.
+ val user = Source.java(
+ "User", """
+ import androidx.room.*;
+ import java.util.*;
+ @TypeConverters({TestConverters.class})
+ @Entity
+ public class User {
+ @PrimaryKey
+ public int mId;
+ public Set<Day> mWorkDays = new HashSet<>();
+ }
+ """.trimIndent()
+ )
+ val converters = Source.java(
+ "TestConverters", """
+ import androidx.room.*;
+ import java.util.Date;
+ import java.util.HashSet;
+ import java.util.Set;
+ class TestConverters {
+ @TypeConverter
+ public static Set<Day> decomposeDays(int flags) {
+ Set<Day> result = new HashSet<>();
+ for (Day day : Day.values()) {
+ if ((flags & (1 << day.ordinal())) != 0) {
+ result.add(day);
+ }
+ }
+ return result;
+ }
+
+ @TypeConverter
+ public static int composeDays(Set<Day> days) {
+ int result = 0;
+ for (Day day : days) {
+ result |= 1 << day.ordinal();
+ }
+ return result;
+ }
+ }
+ """.trimIndent()
+ )
+ val day = Source.java(
+ "Day", """
+ public enum Day {
+ MONDAY,
+ TUESDAY,
+ WEDNESDAY,
+ THURSDAY,
+ FRIDAY,
+ SATURDAY,
+ SUNDAY
+ }
+ """.trimIndent()
+ )
+ val dao = Source.java(
+ "MyDao", """
+ import androidx.room.*;
+ @Dao
+ interface MyDao {
+ @Insert
+ void insert(User user);
+ }
+ """.trimIndent()
+ )
+ runProcessorTest(
+ sources = listOf(user, day, converters, dao),
+ options = mapOf(
+ USE_NULL_AWARE_CONVERTER.argName to "true"
+ )
+ ) { invocation ->
+ val daoProcessor = DaoProcessor(
+ baseContext = invocation.context,
+ element = invocation.processingEnv.requireTypeElement("MyDao"),
+ dbType = invocation.processingEnv.requireType("androidx.room.RoomDatabase"),
+ dbVerifier = null
+ )
+ DaoWriter(
+ dao = daoProcessor.process(),
+ dbElement = invocation.processingEnv
+ .requireTypeElement("androidx.room.RoomDatabase"),
+ processingEnv = invocation.processingEnv
+ ).write(invocation.processingEnv)
+ invocation.assertCompilationResult {
+ generatedSourceFileWithPath("MyDao_Impl.java").let {
+ // make sure it bounded w/o upcasting to Boolean
+ it.contains("final int _tmp = TestConverters.composeDays(value.mWorkDays);")
+ it.contains("stmt.bindLong(2, _tmp);")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun checkSyntheticConverters() {
+ class MockTypeConverter(
+ from: XType,
+ to: XType,
+ ) : TypeConverter(
+ from = from,
+ to = to
+ ) {
+ override fun doConvert(
+ inputVarName: String,
+ outputVarName: String,
+ scope: CodeGenScope
+ ) {
+ }
+ }
+ runProcessorTest { invocation ->
+ val string = invocation.processingEnv.requireType(String::class)
+ .makeNonNullable()
+ val int = invocation.processingEnv.requireType(Int::class)
+ .makeNonNullable()
+ val long = invocation.processingEnv.requireType(Long::class)
+ .makeNonNullable()
+ val number = invocation.processingEnv.requireType(Number::class)
+ .makeNonNullable()
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int.makeNullable()
+ )
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // nullable converter, don't duplicate anything
+ assertThat(
+ store.typeConverters
+ ).hasSize(1)
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = int
+ )
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ if (invocation.isKsp) {
+ // add a null wrapper version
+ assertThat(store.typeConverters).hasSize(2)
+ } else {
+ // do not duplicate unless we run in KSP
+ assertThat(store.typeConverters).hasSize(1)
+ }
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = int
+ ),
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int
+ )
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // don't duplicate, we already have a null receiving version
+ assertThat(store.typeConverters).hasSize(2)
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = int
+ ),
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int.makeNullable()
+ )
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // don't duplicate, we already have a null receiving version
+ assertThat(store.typeConverters).hasSize(2)
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = int
+ ),
+ MockTypeConverter(
+ from = string,
+ to = long
+ ),
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int.makeNullable()
+ )
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // don't duplicate, we already have a null receiving version
+ if (invocation.isKsp) {
+ // duplicate the long receiving one
+ assertThat(store.typeConverters).hasSize(4)
+ } else {
+ // don't duplicate in javac
+ assertThat(store.typeConverters).hasSize(3)
+ }
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = number
+ ),
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int
+ ),
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // don't duplicate string number converter since we have string? to int
+ assertThat(store.typeConverters).hasSize(2)
+ }
+ NullAwareTypeConverterStore(
+ context = invocation.context,
+ typeConverters = listOf(
+ MockTypeConverter(
+ from = string,
+ to = number.makeNullable()
+ ),
+ MockTypeConverter(
+ from = string.makeNullable(),
+ to = int
+ ),
+ ),
+ knownColumnTypes = emptyList()
+ ).let { store ->
+ // don't duplicate string number converter since we have string? to int
+ assertThat(store.typeConverters).hasSize(2)
+ }
+ }
+ }
+
+ @Test
+ fun warnIfTurnedOffInKsp() {
+ val sources = Source.kotlin("Foo.kt", "")
+ arrayOf("", "true", "false").forEach { value ->
+ val result = compile(
+ workingDir = tmpFolder.newFolder(),
+ arguments = TestCompilationArguments(
+ sources = listOf(sources),
+ symbolProcessorProviders = listOf(
+ RoomKspProcessor.Provider()
+ ),
+ processorOptions = mapOf(
+ USE_NULL_AWARE_CONVERTER.argName to value
+ )
+ )
+ )
+ val warnings = result.diagnostics[Diagnostic.Kind.WARNING]?.map {
+ it.msg
+ }?.filter {
+ it.contains("Disabling null-aware type analysis in KSP is a temporary flag")
+ } ?: emptyList()
+ val expected = if (value == "false") {
+ 1
+ } else {
+ 0
+ }
+ assertThat(
+ warnings
+ ).hasSize(expected)
+ }
+ }
+
+ /**
+ * Test converting a known column type into another type due to explicit affinity
+ */
+ @Test
+ fun knownColumnTypeToExplicitType() {
+ val source = Source.kotlin(
+ "Subject.kt", """
+ import androidx.room.*
+ object MyByteArrayConverter {
+ @TypeConverter
+ fun toByteArray(input:String): ByteArray { TODO() }
+ @TypeConverter
+ fun fromByteArray(input:ByteArray): String { TODO() }
+ }
+ class Subject(val arr:ByteArray)
+ """.trimIndent()
+ )
+ runProcessorTest(
+ sources = listOf(source),
+ options = mapOf(
+ USE_NULL_AWARE_CONVERTER.argName to "true"
+ )
+ ) { invocation ->
+ val byteArray = invocation.processingEnv.requireTypeElement("Subject")
+ .getDeclaredFields().first().type.makeNonNullable()
+ val string = invocation.processingEnv.requireType("java.lang.String")
+ invocation.createStore().let { storeWithoutConverter ->
+ val intoStatement = storeWithoutConverter.findConverterIntoStatement(
+ input = byteArray,
+ columnTypes = listOf(
+ string.makeNullable(),
+ string.makeNonNullable()
+ )
+ )
+ assertThat(intoStatement).isNull()
+ val fromCursor = storeWithoutConverter.findConverterFromCursor(
+ output = byteArray,
+ columnTypes = listOf(
+ string.makeNullable(),
+ string.makeNonNullable()
+ )
+ )
+ assertThat(fromCursor).isNull()
+ }
+
+ invocation.createStore(
+ "MyByteArrayConverter"
+ ).let { storeWithConverter ->
+ val intoStatement = storeWithConverter.findConverterIntoStatement(
+ input = byteArray,
+ columnTypes = listOf(
+ string.makeNullable(),
+ string.makeNonNullable()
+ )
+ )
+ assertThat(intoStatement?.toSignature()).isEqualTo("fromByteArray")
+ assertThat(intoStatement?.to).isEqualTo(string.makeNonNullable())
+ assertThat(intoStatement?.from).isEqualTo(byteArray.makeNonNullable())
+ val fromCursor = storeWithConverter.findConverterFromCursor(
+ output = byteArray,
+ columnTypes = listOf(
+ string.makeNullable(),
+ string.makeNonNullable()
+ )
+ )
+ assertThat(fromCursor?.toSignature()).isEqualTo("toByteArray")
+ assertThat(fromCursor?.to).isEqualTo(byteArray.makeNonNullable())
+ assertThat(fromCursor?.from).isEqualTo(string.makeNonNullable())
+ }
+ }
+ }
+
+ /**
+ * Collect results for conversion from String to our type
+ */
+ private fun collectStringConversionResults(
+ vararg selectedConverters: String
+ ): String {
+ val result = StringBuilder()
+ runProcessorTest(
+ sources = listOf(source),
+ options = mapOf(
+ USE_NULL_AWARE_CONVERTER.argName to "true"
+ )
+ ) { invocation ->
+ val store = invocation.createStore(*selectedConverters)
+ assertThat(store).isInstanceOf(NullAwareTypeConverterStore::class.java)
+ val myClassTypeElement = invocation.processingEnv.requireTypeElement(
+ "MyClass"
+ )
+ val stringTypeElement = invocation.processingEnv.requireTypeElement(
+ "java.lang.String"
+ )
+
+ result.appendLine(invocation.processingEnv.backend.name)
+ listOf(
+ stringTypeElement.type.makeNullable(),
+ stringTypeElement.type.makeNonNullable(),
+ ).forEach { stringType ->
+ listOf(
+ myClassTypeElement.type.makeNullable(),
+ myClassTypeElement.type.makeNonNullable()
+ ).forEach { myClassType ->
+ val fromString = store.findTypeConverter(
+ input = stringType,
+ output = myClassType
+ )
+ val toString = store.findTypeConverter(
+ input = myClassType,
+ output = stringType
+ )
+ result.apply {
+ append(stringType.toSignature())
+ append(" to ")
+ append(myClassType.toSignature())
+ append(": ")
+ appendLine(fromString?.toSignature() ?: "null")
+ }
+
+ result.apply {
+ append(myClassType.toSignature())
+ append(" to ")
+ append(stringType.toSignature())
+ append(": ")
+ appendLine(toString?.toSignature() ?: "null")
+ }
+ }
+ }
+ }
+ return result.toString()
+ }
+
+ /**
+ * Collect results for conversion from an unknown cursor type to our type
+ */
+ private fun collectCursorResults(
+ vararg selectedConverters: String
+ ): String {
+ val result = StringBuilder()
+ runProcessorTest(
+ sources = listOf(source),
+ options = mapOf(
+ USE_NULL_AWARE_CONVERTER.argName to "true"
+ )
+ ) { invocation ->
+ val store = invocation.createStore(*selectedConverters)
+ assertThat(store).isInstanceOf(NullAwareTypeConverterStore::class.java)
+ val myClassTypeElement = invocation.processingEnv.requireTypeElement(
+ "MyClass"
+ )
+
+ result.appendLine(invocation.processingEnv.backend.name)
+ listOf(
+ myClassTypeElement.type.makeNullable(),
+ myClassTypeElement.type.makeNonNullable()
+ ).forEach { myClassType ->
+ val toMyClass = store.findConverterFromCursor(
+ columnTypes = null,
+ output = myClassType
+ )
+ val fromMyClass = store.findConverterIntoStatement(
+ input = myClassType,
+ columnTypes = null
+ )
+
+ result.apply {
+ append("Cursor to ")
+ append(myClassType.toSignature())
+ append(": ")
+ appendLine(toMyClass?.toSignature() ?: "null")
+ }
+
+ result.apply {
+ append(myClassType.toSignature())
+ append(" to Cursor: ")
+ appendLine(fromMyClass?.toSignature() ?: "null")
+ }
+ }
+ }
+ return result.toString()
+ }
+
+ private fun assertResult(result: String, expected: String) {
+ // remove commented lines from expected as they are used to explain cases for test's
+ // readability
+ assertThat(result).isEqualTo(
+ expected
+ .lines()
+ .filterNot { it.trim().startsWith("//") }
+ .joinToString("\n")
+ )
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/solver/Signatures.kt b/room/room-compiler/src/test/kotlin/androidx/room/solver/Signatures.kt
new file mode 100644
index 0000000..4ddee69
--- /dev/null
+++ b/room/room-compiler/src/test/kotlin/androidx/room/solver/Signatures.kt
@@ -0,0 +1,51 @@
+/*
+ * 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
+
+import androidx.room.compiler.processing.XNullability
+import androidx.room.compiler.processing.XType
+import androidx.room.solver.types.CompositeTypeConverter
+import androidx.room.solver.types.CustomTypeConverterWrapper
+import androidx.room.solver.types.NullSafeTypeConverter
+import androidx.room.solver.types.RequireNotNullTypeConverter
+import androidx.room.solver.types.TypeConverter
+import androidx.room.solver.types.UpCastTypeConverter
+
+// Shared signatures for objects that make testing more readable
+private fun XNullability.toSignature() = when (this) {
+ XNullability.NONNULL -> "!"
+ XNullability.NULLABLE -> "?"
+ XNullability.UNKNOWN -> ""
+}
+
+fun XType.toSignature() =
+ "$typeName${nullability.toSignature()}".substringAfter("java.lang.")
+
+fun TypeConverter.toSignature(): String {
+ return when (this) {
+ is CompositeTypeConverter -> "${conv1.toSignature()} / ${conv2.toSignature()}"
+ is CustomTypeConverterWrapper -> this.custom.methodName
+ is NullSafeTypeConverter ->
+ "(${this.from.toSignature()} == null " +
+ "? null : ${this.delegate.toSignature()})"
+ is RequireNotNullTypeConverter ->
+ "checkNotNull(${from.toSignature()})"
+ is UpCastTypeConverter ->
+ "(${from.toSignature()} as ${to.toSignature()})"
+ else -> "${from.toSignature()} -> ${to.toSignature()}"
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterCostTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterCostTest.kt
new file mode 100644
index 0000000..03ab939
--- /dev/null
+++ b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterCostTest.kt
@@ -0,0 +1,100 @@
+/*
+ * 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
+
+import androidx.room.solver.types.TypeConverter.Cost
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class TypeConverterCostTest {
+ @Test
+ fun sections() {
+ val cost = Cost(
+ upCasts = 7,
+ nullSafeWrapper = 3,
+ converters = 1,
+ requireNotNull = 2
+ )
+ assertThat(
+ cost.toString()
+ ).isEqualTo(
+ "Cost[upcast:7,nullsafe:3,converters:1,requireNotNull:2]"
+ )
+ }
+
+ @Test
+ fun sum() {
+ assertThat(
+ Cost(converters = 3) + Cost(converters = 3, nullSafeWrapper = 4)
+
+ ).isEqualTo(Cost(converters = 6, nullSafeWrapper = 4))
+ }
+
+ @Test
+ fun sum2() {
+ val sum = Cost(
+ converters = 1,
+ upCasts = 60,
+ ) + Cost(
+ converters = 1,
+ nullSafeWrapper = 2,
+ upCasts = 20
+ )
+ assertThat(
+ sum
+ ).isEqualTo(
+ Cost(
+ converters = 2,
+ nullSafeWrapper = 2,
+ upCasts = 80
+ )
+ )
+ }
+
+ @Test
+ fun compare() {
+ assertThat(
+ Cost(requireNotNull = 1, converters = 2, nullSafeWrapper = 3, upCasts = 4)
+ ).isEquivalentAccordingToCompareTo(
+ Cost(requireNotNull = 1, converters = 2, nullSafeWrapper = 3, upCasts = 4)
+ )
+ // require not null is the most expensive
+ assertThat(
+ Cost(requireNotNull = 2, converters = 1, nullSafeWrapper = 1, upCasts = 1)
+ ).isGreaterThan(
+ Cost(requireNotNull = 1, converters = 2, nullSafeWrapper = 2, upCasts = 2)
+ )
+ // converters are the second most expensive
+ assertThat(
+ Cost(requireNotNull = 1, converters = 2, nullSafeWrapper = 1, upCasts = 1)
+ ).isGreaterThan(
+ Cost(requireNotNull = 1, converters = 1, nullSafeWrapper = 2, upCasts = 2)
+ )
+ // null safe wrapper is the third most expensive
+ assertThat(
+ Cost(requireNotNull = 1, converters = 1, nullSafeWrapper = 2, upCasts = 1)
+ ).isGreaterThan(
+ Cost(requireNotNull = 1, converters = 1, nullSafeWrapper = 1, upCasts = 2)
+ )
+ // upcast is the least expensive
+ assertThat(
+ Cost(requireNotNull = 1, converters = 1, nullSafeWrapper = 1, upCasts = 2)
+ ).isGreaterThan(
+ Cost(requireNotNull = 1, converters = 1, nullSafeWrapper = 1, upCasts = 1)
+ )
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterStoreTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterStoreTest.kt
index 18c67d2..a6c6499 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterStoreTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/solver/TypeConverterStoreTest.kt
@@ -98,6 +98,14 @@
findConverter("Type1_Super", "Type2_Super")
).isEqualTo(
"Type1_Super -> JumpType_2 : JumpType_2 -> JumpType_3 : JumpType_3 -> Type2_Sub"
+ .let { tillSub ->
+ if (invocation.context.useNullAwareConverter) {
+ // new type converter will have a step for upcasts too
+ "$tillSub : Type2_Sub -> Type2_Super"
+ } else {
+ tillSub
+ }
+ }
)
assertThat(
findConverter("Type1", "Type2_Sub")
@@ -107,7 +115,14 @@
assertThat(
findConverter("Type1_Sub", "Type2_Sub")
).isEqualTo(
- "Type1 -> JumpType_1 : JumpType_1 -> Type2_Sub"
+ "Type1 -> JumpType_1 : JumpType_1 -> Type2_Sub".let { end ->
+ if (invocation.context.useNullAwareConverter) {
+ // new type converter will have a step for upcasts too
+ "Type1_Sub -> Type1 : $end"
+ } else {
+ end
+ }
+ }
)
assertThat(
findConverter("Type2", "Type2_Sub")