`unfold` is not consistent with `toDataFrame` and with the compiler plugin

Aperta
#2,114 1 commento 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@koperagen ci sta già lavorando.

Dal 29/9/2026.

Valutazione

Questa issue non è ancora stata valutata.

Descrizione

unfold is meant to be toDataFrame() for a column inside a dataframe (#677). While documenting it (#1991),
we found five cases where it is not: two about roots, two about the kind of column that gets unfolded,
and one where the compiler plugin and the runtime give different schemas.

Every case below has a test in the PR for #1991. Each test asserts the expected behaviour and is marked
@Ignore with TODO(#ISSUE). Enable the test when you fix its case.

The fixture the tests use (from core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/unfold.kt, class UnfoldTests):

data class Name(val firstName: String, val lastName: String)

data class Score(val subject: String, val value: Int)

data class Student(val name: Name, val age: Int, val scores: List<Score>)

private val students = dataFrameOf(
    "id" to columnOf(1, 2),
    "student" to columnOf(
        Student(Name("Alice", "Cooper"), 15, listOf(Score("math", 4), Score("biology", 3))),
        Student(Name("Bob", "Marley"), 20, listOf(Score("music", 5))),
    ),
    "year" to columnOf(2021, 2022),
)
1. roots are ignored on a column of simple values
@Ignore("unfold ignores roots on a column of simple values")
@Test
fun `unfold applies roots to a column of simple values`() {
    // replaces `unfold ignores roots on a column of simple values` once fixed
    val df = dataFrameOf("word" to columnOf("abc", "de"))
    val res = df.unfold(String::length) { col("word") }
    // the same as `listOf("abc", "de").toDataFrame(String::length)`
    res["word"].asColumnGroup().columnNames() shouldBe listOf("length")
    res[pathOf("word", "length")].values() shouldBe listOf(3, 2)
}

Actual: word stays a String column, and the test fails with a ClassCastException on asColumnGroup().
listOf("abc", "de").toDataFrame(String::length) gives length: Int with the values 3, 2.

Cause: unfoldImpl returns the column unchanged when !typeClass.canBeUnfolded, before it looks at the roots.
convertToDataFrame makes the same check only when the roots are empty (impl/api/toDataFrame.kt,
if (roots.isEmpty() && !clazz.canBeUnfolded)).

Suggested fix: pass the roots to unfoldImpl and skip the check when they are not empty.
unfoldImpl is @PublishedApi and is called from the inline DataColumn.unfold, so add a new overload and
keep the old one for code that is already compiled.
This needs the compiler plugin to support roots first (issue 2); otherwise the plugin says the column stays
as it is, while at runtime it becomes a column group.

The PR for #1991 documents the current behaviour ("roots only apply to a column that can be unfolded") and
pins it with the test unfold ignores roots on a column of simple values. The fix removes that sentence
and replaces that test with the one above.

2. Roots that are not properties of the column type are stored as exceptions
@Ignore("unfold stores the exceptions as values when roots are not properties of the column type")
@Test
fun `unfold rejects roots that are not properties of the column type`() {
    shouldThrow<IllegalArgumentException> {
        students.unfold(Score::value) { col("student") }
    }
}

Actual: nothing is thrown. The result is student: { value: java.lang.IllegalArgumentException }:
every cell of the new column holds the exception thrown by the reflective call.

Expected: an IllegalArgumentException at the call, like the one properties() already throws for a root
that is not a property or a getter-like function.

Suggested fix: in CreateDataFrameDslImpl.properties, next to the isGetterLike() check, require that
the instance parameter of every root accepts the element type
(root.instanceParameter?.type?.jvmErasure is a superclass of the traversed class).
toDataFrame(vararg props) probably has the same problem; we did not check it.

3. A column of lists becomes a column group with the size property
@Ignore("unfold turns a column of lists into a column group with the `size` property")
@Test
fun `unfold turns a column of lists of objects into a frame column`() {
    val df = dataFrameOf(
        "scores" to columnOf(listOf(Score("math", 4), Score("biology", 3)), listOf(Score("music", 5))),
    )
    val res = df.unfold("scores")
    res["scores"].kind() shouldBe ColumnKind.Frame
    res["scores"][0] shouldBe dataFrameOf("subject", "value")("math", 4, "biology", 3)
    res["scores"][1] shouldBe dataFrameOf("subject", "value")("music", 5)
}

Actual: expected:<FrameColumn> but was:<ColumnGroup>. The group has one column, size: Int.
The compiler plugin gives the same schema, scores: { size: Int }.
toDataFrame does the same: listOf(listOf(Score("a", 1)), listOf<Score>()).toDataFrame() gives size: Int
with the values 1, 0.

Cause: List has a public size property, so canBeUnfolded is true for it.

Suggested fix: in unfoldImpl, turn a column of Iterable<E>, where E can be unfolded, into a
FrameColumn: one convertToDataFrame per cell, as convertToDataFrame already does for a property of an
Iterable type. A column of lists of simple values stays as it is.
Open questions: does maxDepth count the list as a level, and should toDataFrame on a list of lists change
the same way? The compiler plugin has to change together with the library (issue 2).

4. A column of maps is unfolded
@Ignore("unfold turns a column of maps into a column group with the properties of Map")
@Test
fun `unfold leaves a column of maps as it is`() {
    val df = dataFrameOf("scores" to columnOf(mapOf("math" to 4), mapOf("music" to 5)))
    df.unfold("scores") shouldBe df
}

Actual: the type of the column is java.util.AbstractMap<String, Int>, and unfold turns it into
scores: { entries, keys, size, values }. The compiler plugin sees Map<String, Int> and keeps the column
as it is, so the schemas differ (test below).

Cause: KClass.isValueType checks this in valueTypes, an exact match. Map::class and MutableMap::class
are in the set, but AbstractMap, LinkedHashMap and other implementations are not.

Suggested fix: check this.isSubclassOf(Map::class) in isValueType. It is shared with toDataFrame, so
a property of type LinkedHashMap stops being unfolded as well, which is the intended behaviour.
The plugin's isValueType should be aligned (issue 2), as the comment on isValueType asks.

5. With roots, the compiler plugin gives an empty schema

Reported for the compiler plugin as issue 2. The tests are in
samples/src/test/kotlin/org/jetbrains/kotlinx/dataframe/plugin/UnfoldCompileTimeSchemaTests.kt:

class UnfoldCompileTimeSchemaTests {
    data class Name(val firstName: String, val lastName: String)

    data class Score(val subject: String, val value: Int)

    data class Student(val name: Name, val age: Int, val scores: List<Score>)

    private fun students() =
        dataFrameOf(
            "id" to listOf(1, 2),
            "student" to listOf(
                Student(Name("Alice", "Cooper"), 15, listOf(Score("math", 4), Score("biology", 3))),
                Student(Name("Bob", "Marley"), 20, listOf(Score("music", 5))),
            ),
            "year" to listOf(2021, 2022),
        )

    @Test
    fun `unfold with maxDepth has the same schema at compile time and at runtime`() {
        val res = students().unfold(maxDepth = 1) { student }
        res.compileTimeSchema() shouldBe res.schema()
    }

    // TODO(#ISSUE): enable this test once the issue is fixed
    @Ignore("with roots, the compiler plugin derives an empty schema for unfold")
    @Test
    fun `unfold with roots has the same schema at compile time and at runtime`() {
        val res = students().unfold(Student::name, Student::age) { student }
        res.compileTimeSchema() shouldBe res.schema()
    }

    // TODO(#ISSUE): enable this test once the issue is fixed
    @Ignore("at runtime, unfold turns a column of maps into a column group; the compiler plugin keeps it as it is")
    @Test
    fun `unfold of a column of maps has the same schema at compile time and at runtime`() {
        val res = dataFrameOf("scores" to listOf(mapOf("math" to 4), mapOf("music" to 5))).unfold { scores }
        res.compileTimeSchema() shouldBe res.schema()
    }
}

The first test passes and shows that the comparison works. The second one fails:

expected:<id: Int
student:
    name: plugin.UnfoldCompileTimeSchemaTests.Name
    age: Int
year: Int> but was:<>

The third one fails:

expected:<scores:
    entries: Set<Map.Entry<Any, Any>>
    keys: Set<Any>
    size: Int
    values: Collection<Any>
> but was:<scores: Map<String, Int>>
Lingua principale
Kotlin
Stelle
1.1k
Fork
87
Merge medio
2g 21h
PR unite (30g)
40

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di Kotlin/dataframe

Tutte le issue di Kotlin/dataframe

Issue simili

Altre issue su Kotlin

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.