google / google/ksp

Bug Report: Room `@Embedded` Silently Drops All Columns for `@Serializable` Types from External Compiled Modules (KSP2 + Kotlin 2.2.0)

Open
#2,823 9 comments 0 reactions 0 assignees View on GitHub
bug P3
Dominant language
Kotlin
Stars
3.5k
Forks
415
Avg merge
1d 2h
Merged PRs (30d)
53

Description

### Summary

When using **Room 2.7.2 + KSP 2.2.0-2.0.2 + Kotlin 2.2.0**, Room's `@Embedded` annotation **silently generates zero SQL columns** for any `@Serializable` (kotlinx-serialization) data class that is defined in an **external compiled module** (AAR/JAR dependency). No error is emitted. No KSP warning is produced. The database schema is generated as if the `@Embedded` field does not exist. This is a **silent data-loss regression** — it produces a narrower schema than expected, which causes runtime crashes on migration from any previously-working schema version.

---

### Environment

| Component | Version |
|---|---|
| Kotlin | `2.2.0` |
| KSP | `2.2.0-2.0.2` |
| Room | `2.7.2` |
| `kotlinx-serialization` plugin | `2.2.0` |
| `kotlinx-serialization-json` | `1.9.0` |
| AGP | `8.11.1` |

---

### Minimal Reproduction

**External library module** (compiled to AAR, Kotlin 2.2.0):

```kotlin
// In :my-library module
@Serializable
data class Address(
val street: String = "",
val city: String = "",
val country: String = "",
val postCode: String? = null,
)
```

**Consumer app module** (Room entity, Kotlin 2.2.0 + KSP 2.2.0-2.0.2):

```kotlin
@Entity(tableName = "persons")
data class PersonEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,

val name: String = "",

@Embedded(prefix = "address_")
val address: Address? = null, // ← all 4 columns silently missing
)
```

**Expected** generated schema:
```sql
CREATE TABLE persons (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
address_street TEXT,
address_city TEXT,
address_country TEXT,
address_postCode TEXT
)
```

**Actual** generated schema — all `@Embedded` columns are missing, zero KSP warnings:
```sql
CREATE TABLE persons (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL
)
```

---

### What Does NOT Reproduce (Critical Contrast)

If `Address` is defined in the **same app module** (not an external dependency), `@Embedded` works correctly — even when `Address` is still annotated with `@Serializable`:

```kotlin
// Defined in the same :app module → @Embedded works fine
@Serializable
data class Address(
val street: String = "",
val city: String = "",
val country: String = "",
val postCode: String? = null,
)
```

**This confirms the bug is not about `@Serializable` per se — it is strictly about `@Serializable` types consumed from external compiled modules.**

---

### Root Cause (Proven by Bytecode Analysis)

The failure is a three-component interaction between the kotlinx-serialization K2 plugin, KSP2's metadata reader, and Room's field filter.

#### Step 1 — `kotlinx-serialization` K2 plugin writes empty JVM field signatures

The `kotlin.Metadata` annotation (`@Metadata`) on a compiled Kotlin class includes a `JvmPropertySignature` protobuf extension for each property. Under K1, this was populated with the actual JVM field name and descriptor, e.g.:

```
JvmPropertySignature {
field { name: "street", desc: "Ljava/lang/String;" }
getter { name: "getStreet", desc: "()Ljava/lang/String;" }
}
```

Under the K2 serialization compiler plugin (Kotlin 2.2.0), the `field` entry is written as a **zero-length message** — present in the proto, but with no `name` or `desc` fields set (`0x0a 0x00` in the encoded bytes). The getter signature remains correctly populated. The JVM backing fields themselves **are** present in the bytecode (`private final String street;` etc.) — only the metadata description of the field is empty.

This was verified directly by extracting and decoding the `kotlin.Metadata` `d1` protobuf from the compiled class. All 4 properties of a `@Serializable` data class compiled from an external module had empty field signatures. Zero had a properly populated field signature.

#### Step 2 — KSP2 reads empty field signature as `hasBackingField = false`

Room's KSP processor calls `KSPropertyDeclaration.hasBackingField` to determine whether a property represents a real database column. In KSP2 (K2 mode), `hasBackingField` is resolved by reading the JVM field signature from the deserialized `kotlin.Metadata`. Since Step 1 left the field signature empty/unpopulated, KSP2 reports `hasBackingField = false` for every property of the external `@Serializable` class.

When KSP2 processes a **source-module** class (same compilation unit), it reads from the live FIR AST rather than deserializing metadata, so the empty field signature issue never surfaces — this is why the same `@Serializable` type works when defined in the app module.

#### Step 3 — Room silently drops all fields

Room's `KspTypeElement.getDeclaredFields()` is implemented as:

```kotlin
declaration.getDeclaredProperties()
.filter { it.hasBackingField } // ← filters out ALL properties
.map { KspFieldElement.create(env, it, this) }
```

With `hasBackingField = false` for every property, the filter returns an empty list. Room's `@Embedded` processor receives zero fields for the type, generates zero columns, and emits **no warning or error**.

#### The failure chain

```
kotlinx-serialization K2 plugin
└── compiles @Serializable data class into AAR
└── writes JvmPropertySignature.field = empty message (0x0a 0x00)
↓ KSP2 deserializes kotlin.Metadata from external AAR
└── KSPropertyDeclaration.hasBackingField = false (reads empty field sig)
↓ Room KspTypeElement.getDeclaredFields()
└── .filter { it.hasBackingField } → []
↓ Room @Embedded processor
└── Zero columns generated — silently
```

---

### Regression Point

This worked correctly with Kotlin < 2.0 / KSP1. The K1 KSP implementation (`DescriptorResolver`) derived `hasBackingField` from the Kotlin compiler descriptor's `backingField` property directly, bypassing the JVM field signature in the metadata entirely. KSP2 switched to reading from the serialized JVM metadata extension, which exposed the empty-field-signature written by the K2 serialization plugin.

---

### Severity

**High / Data Loss.** The failure is completely silent — no build error, no warning from KSP, no Room annotation processing error. The only observable symptom is a narrower-than-expected Room schema, which crashes at runtime with `IllegalStateException: Migration didn't properly handle` on any device with an existing database. Any project that uses `@Embedded` with `@Serializable` types from a library dependency is silently affected after upgrading to Kotlin 2.2.0.

---

### Suggested Fix Owners

- **`kotlinx-serialization` team (JetBrains)**: The K2 compiler plugin should write a properly populated `FieldSignature` (with `name` and `desc`) for data class primary constructor properties, consistent with the K1 plugin behavior.
- **KSP team (Google)**: `KSPropertyDeclaration.hasBackingField` in K2 mode should not return `false` when the JVM backing field provably exists in the bytecode but the metadata field signature is empty. A fallback to bytecode inspection or to the K2 FIR property's `hasBackingField` flag would prevent downstream tools from being broken.
- **Room team (Google/AndroidX)**: Room's `@Embedded` processor emitting no diagnostic when a type resolves to zero fields is a usability gap regardless of the above. A warning such as `"@Embedded type X resolved to zero fields; check that the type is visible to KSP"` would have surfaced this immediately.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.