diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt index 9b11a758bf..916dbb5a5c 100644 --- a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt @@ -15,9 +15,12 @@ import org.jetbrains.kotlinx.dataframe.columns.SingleColumn import org.jetbrains.kotlinx.dataframe.columns.asColumnSet import org.jetbrains.kotlinx.dataframe.columns.size import org.jetbrains.kotlinx.dataframe.columns.values +import org.jetbrains.kotlinx.dataframe.documentation.DocumentationUrls import org.jetbrains.kotlinx.dataframe.documentation.DslGrammarTemplateColumnsSelectionDsl.DslGrammarTemplate import org.jetbrains.kotlinx.dataframe.documentation.Indent import org.jetbrains.kotlinx.dataframe.documentation.LineBreak +import org.jetbrains.kotlinx.dataframe.documentation.RowFilterDescription +import org.jetbrains.kotlinx.dataframe.documentation.SelectingColumns import org.jetbrains.kotlinx.dataframe.impl.columns.TransformableColumnSet import org.jetbrains.kotlinx.dataframe.impl.columns.singleOrNullWithTransformerImpl import org.jetbrains.kotlinx.dataframe.impl.columns.transform @@ -27,28 +30,153 @@ import kotlin.reflect.KProperty // region DataColumn +/** + * Returns the last value in this [DataColumn]. + * + * See also [lastOrNull], [first], [take], [takeLast]. + * + * @return The last value in this [DataColumn]. + * + * @throws [IndexOutOfBoundsException] if the [DataColumn] is empty. + */ public fun DataColumn.last(): T = get(size - 1) +/** + * Returns the last value in this [DataColumn]. If the [DataColumn] is empty, returns `null`. + * + * See also [last], [first], [take], [takeLast]. + * + * @return The last value in this [DataColumn], or `null` if the [DataColumn] is empty. + */ public fun DataColumn.lastOrNull(): T? = if (size > 0) last() else null +/** + * Returns the last value in this [DataColumn] that matches the given [predicate]. + * + * ### Example + * ```kotlin + * // In a DataFrame of financial transactions sorted by time, + * // find the amount of the most recent financial transaction over 100 euros + * df.amount.last { it > 100 } + * ``` + * + * See also [lastOrNull], [first], [take], [takeLast]. + * + * @param predicate A lambda expression used to get the last value + * that satisfies a condition specified in this expression. + * This predicate takes a value from the [DataColumn] as an input + * and returns `true` if the value satisfies the condition or `false` otherwise. + * + * @return The last value in this [DataColumn] that matches the given [predicate]. + * + * @throws [NoSuchElementException] if the [DataColumn] contains no element matching the [predicate] + * (including the case when the [DataColumn] is empty). + */ public inline fun DataColumn.last(predicate: (T) -> Boolean): T = values.last(predicate) +/** + * Returns the last value in this [DataColumn] that matches the given [predicate]. + * Returns `null` if the [DataColumn] contains no elements matching the [predicate] + * (including the case when the [DataColumn] is empty). + * + * ### Example + * ```kotlin + * // In a DataFrame of financial transactions sorted by time, + * // obtain the amount of the most recent financial transaction over 100 euros, + * // or 'null' if there is no such transaction + * df.amount.lastOrNull { it > 100 } + * ``` + * + * See also [last], [first], [take], [takeLast]. + * + * @param predicate A lambda expression used to get the last value + * that satisfies a condition specified in this expression. + * This predicate takes a value from the [DataColumn] as an input + * and returns `true` if the value satisfies the condition or `false` otherwise. + * + * @return The last value in this [DataColumn] that matches the given [predicate], + * or `null` if the [DataColumn] contains no element matching the [predicate]. + */ public inline fun DataColumn.lastOrNull(predicate: (T) -> Boolean): T? = values.lastOrNull(predicate) // endregion // region DataFrame +/** + * Returns the last [row][DataRow] in this [DataFrame] that satisfies the given [predicate]. + * Returns `null` if the [DataFrame] contains no rows matching the [predicate] + * (including the case when the [DataFrame] is empty). + * + * {@include [RowFilterDescription]} + * + * @include [SelectingColumns.ColumnGroupsAndNestedColumnsMention] + * + * ### Example + * ```kotlin + * // In a DataFrame of financial transactions sorted by time, + * // obtain the most recent financial transaction with amount over 100 euros, + * // or 'null' if there is no such transaction + * df.lastOrNull { amount > 100 } + * ``` + * + * See also [last], [first], [take], [takeLast], [takeWhile]. + * + * @param predicate A [row filter][RowFilter] used to get the last value + * that satisfies a condition specified in this filter. + * + * @return A [DataRow] containing the last row that matches the given [predicate], + * or `null` if the [DataFrame] contains no rows matching the [predicate]. + */ public inline fun DataFrame.lastOrNull(predicate: RowFilter): DataRow? = rowsReversed().firstOrNull { predicate(it, it) } +/** + * Returns the last [row][DataRow] in this [DataFrame] that satisfies the given [predicate]. + * + * {@include [RowFilterDescription]} + * + * @include [SelectingColumns.ColumnGroupsAndNestedColumnsMention] + * + * ### Example + * ```kotlin + * // In a DataFrame of financial transactions sorted by time, + * // find the most recent financial transaction with amount over 100 euros + * df.last { amount > 100 } + * ``` + * + * See also [lastOrNull], [first], [take], [takeLast], [takeWhile]. + * + * @param predicate A [row filter][RowFilter] used to get the last value + * that satisfies a condition specified in this filter. + * + * @return A [DataRow] containing the last row that matches the given [predicate]. + * + * @throws [NoSuchElementException] if the [DataFrame] contains no rows matching the [predicate]. + */ public inline fun DataFrame.last(predicate: RowFilter): DataRow = rowsReversed().first { predicate(it, it) } +/** + * Returns the last [row][DataRow] in this [DataFrame]. If the [DataFrame] does not contain any rows, returns `null`. + * + * See also [last], [first], [take], [takeLast]. + * + * @return A [DataRow] containing the last row in this [DataFrame], or `null` if the [DataFrame] is empty. + */ public fun DataFrame.lastOrNull(): DataRow? = if (nrow > 0) get(nrow - 1) else null +/** + * Returns the last [row][DataRow] in this [DataFrame]. + * + * See also [lastOrNull], [first], [take], [takeLast]. + * + * @return A [DataRow] containing the last row in this [DataFrame]. + * + * @throws NoSuchElementException if the [DataFrame] contains no rows. + */ public fun DataFrame.last(): DataRow { if (nrow == 0) { throw NoSuchElementException("DataFrame has no rows. Use `lastOrNull`.") @@ -60,9 +188,58 @@ public fun DataFrame.last(): DataRow { // region GroupBy +/** + * Gets the last [row][DataRow] from each group of the given [GroupBy] + * and returns a [ReducedGroupBy] containing these rows + * (one row per group, each row is the last row in its group). + * + * If the group in [GroupBy] is empty, + * the corresponding row in [ReducedGroupBy] will contain `null` values for all columns in the group, + * except the column with the grouping key. + * + * See also [first]. + * + * ### Example + * ```kotlin + * // In a DataFrame of order status logs sorted by time, + * // find the most recent status for each order + * df.groupBy { orderId }.last() + * ``` + * + * @return A [ReducedGroupBy] containing the last [row][DataRow] + * (or a row with `null` values, except the grouping key) from each group. + */ @Interpretable("GroupByReducePredicate") public fun GroupBy.last(): ReducedGroupBy = reduce { lastOrNull() } +/** + * Gets from each group of the given [GroupBy] the last [row][DataRow] satisfying the given [predicate], + * and returns a [ReducedGroupBy] containing these rows (one row per group, + * each row is the last row in its group that satisfies the [predicate]). + * + * If the group in [GroupBy] contains no matching rows, + * the corresponding row in [ReducedGroupBy] will contain `null` values for all columns in the group, + * except the grouping key. + * + * {@include [RowFilterDescription]} + * + * @include [SelectingColumns.ColumnGroupsAndNestedColumnsMention] + * + * See also [first]. + * + * ### Example + * ```kotlin + * // In a DataFrame of order status logs sorted by time, + * // find the most recent status shown to the customer for each order + * df.groupBy { orderId }.last { !isInternal } + * ``` + * + * @param predicate A [row filter][RowFilter] used to get the last value + * that satisfies a condition specified in this filter. + * + * @return A [ReducedGroupBy] containing the last [row][DataRow] matching the [predicate] + * (or a row with `null` values, except the grouping key) from each group. + */ @Interpretable("GroupByReducePredicate") public fun GroupBy.last(predicate: RowFilter): ReducedGroupBy = reduce { lastOrNull(predicate) } @@ -70,16 +247,127 @@ public fun GroupBy.last(predicate: RowFilter): ReducedGroupBy Pivot.last(): ReducedPivot = reduce { lastOrNull() } +/** + * Reduces this [Pivot] by taking from each group the last [row][DataRow] satisfying the given [predicate], + * and returns a [ReducedPivot] that contains the last row, matching the [predicate], + * from the corresponding group in each column. + * + * See also: + * - [pivot]; + * - common [reduce][Pivot.reduce]; + * - [first]. + * + * For more information about [Pivot] with examples: {@include [DocumentationUrls.Pivot]} + * + * {@include [RowFilterDescription]} + * + * @include [SelectingColumns.ColumnGroupsAndNestedColumnsMention] + * + * ### Example + * ```kotlin + * // In a DataFrame of real estate listings sorted by date and time, + * // find the most recent listing for each type of property (house, apartment, etc.) + * // with the price less than 500,000 euros + * df.pivot { type }.last { price < 500_000 } + * ``` + * + * @param predicate A [row filter][RowFilter] used to get the last value + * that satisfies a condition specified in this filter. + * + * @return A [ReducedPivot] containing in each column the last [row][DataRow] that satisfies the [predicate], + * from the corresponding group (or a row with `null` values) + */ public fun Pivot.last(predicate: RowFilter): ReducedPivot = reduce { lastOrNull(predicate) } // endregion // region PivotGroupBy +/** + * Reduces this [PivotGroupBy] by taking the last [row][DataRow] from each combined [pivot] + [groupBy] group, + * and returns a [ReducedPivotGroupBy] that contains the last row from each corresponding group. + * If any combined [pivot] + [groupBy] group in [PivotGroupBy] is empty, in the resulting [ReducedPivotGroupBy] + * it will be represented by a row with `null` values (except the grouping key). + * + * See also: + * - [pivot], [Pivot.groupBy] and [GroupBy.pivot]; + * - common [reduce][PivotGroupBy.reduce]; + * - [first]. + * + * For more information about [Pivot] with examples: {@include [DocumentationUrls.Pivot]} + * + * ### Example + * ```kotlin + * // In a DataFrame of real estate listings sorted by date and time, + * // find the most recent listing for each combination of type of property (house, apartment, etc.) + * // and the city it is located in + * df.pivot { type }.groupBy { city }.last() + * ``` + * + * @return A [ReducedPivotGroupBy] containing in each combination of a [groupBy] key and a [pivot] key either + * the last [row][DataRow] of the corresponding DataFrame formed by this pivot–group pair, + * or a row with `null` values (except the grouping key) if this DataFrame is empty. + */ public fun PivotGroupBy.last(): ReducedPivotGroupBy = reduce { lastOrNull() } +/** + * Reduces this [PivotGroupBy] by taking from each combined [pivot] + [groupBy] group + * the last [row][DataRow] satisfying the given [predicate]. Returns a [ReducedPivotGroupBy] that contains the last row + * matching the [predicate] from each corresponding group. + * If any combined [pivot] + [groupBy] group in [PivotGroupBy] does not contain any rows matching the [predicate], + * in the resulting [ReducedPivotGroupBy] it will be represented by a row with `null` values (except the grouping key). + * + * See also: + * - [pivot], [Pivot.groupBy] and [GroupBy.pivot]; + * - common [reduce][PivotGroupBy.reduce]; + * - [first]. + * + * {@include [DocumentationUrls.PivotGroupBy]} + * + * {@include [DocumentationUrls.Pivot]} + * + * {@include [RowFilterDescription]} + * + * @include [SelectingColumns.ColumnGroupsAndNestedColumnsMention] + * + * ### Example + * ```kotlin + * // In a DataFrame of real estate listings sorted by date and time, + * // for each combination of type of property (house, apartment, etc.) + * // and the city it is located in, + * // find the most recent listing with the price less than 500,000 euros + * df.pivot { type }.groupBy { city }.last { price < 500_000 } + * ``` + * + * @param predicate A [row filter][RowFilter] used to get the last value + * that satisfies a condition specified in this filter. + * + * @return A [ReducedPivotGroupBy] containing in each combination of a [groupBy] key and a [pivot] key either + * the last matching the [predicate] [row][DataRow] of the corresponding DataFrame formed by this pivot–group pair, + * or a row with `null` values if this DataFrame does not contain any rows matching the [predicate]. + */ public fun PivotGroupBy.last(predicate: RowFilter): ReducedPivotGroupBy = reduce { lastOrNull(predicate) } // endregion diff --git a/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt index dece2355f1..63a72924ee 100644 --- a/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt +++ b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/last.kt @@ -1,12 +1,37 @@ package org.jetbrains.kotlinx.dataframe.api import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.jetbrains.kotlinx.dataframe.nrow +import org.jetbrains.kotlinx.dataframe.samples.api.age +import org.jetbrains.kotlinx.dataframe.samples.api.city import org.jetbrains.kotlinx.dataframe.samples.api.firstName import org.jetbrains.kotlinx.dataframe.samples.api.isHappy +import org.jetbrains.kotlinx.dataframe.samples.api.lastName import org.jetbrains.kotlinx.dataframe.samples.api.name +import org.jetbrains.kotlinx.dataframe.samples.api.weight import org.junit.Test +/** + * This class tests the behavior of the `last` (`lastCol`) and `lastOrNull` functions, including: + * - **ColumnsSelectionDsl**: selecting the last column or last column matching a condition, with invocations + * on illegal types, and in case when no column matches the condition. + * - **DataColumn**: getting the last value or the last value matching a predicate, + * verifying behavior on empty columns, columns with `null` values, and columns without values matching the predicate. + * - **DataFrame**: getting the last row or last matching row, + * verifying behavior on empty DataFrames, DataFrames with `null` values, and + * DataFrames without rows matching the predicate. + * - **GroupBy**: reducing each group to its last row or the last row matching a predicate, + * with handling groups that contain no matching rows. + * - **Pivot**: reducing each group in the pivot to its last row or the last row matching a predicate, + * with handling groups that contain no matching rows. + * - **PivotGroupBy**: reducing each combined [pivot] + [groupBy] group to its last row + * or the last row matching a predicate, with handling [pivot] + [groupBy] combinations that contain no matching rows. + */ class LastTests : ColumnsSelectionDslTests() { + + // region ColumnsSelectionDsl + @Test fun `ColumnsSelectionDsl last`() { shouldThrow { @@ -36,8 +61,290 @@ class LastTests : ColumnsSelectionDslTests() { df.select { "name".lastCol { col -> col.any { it == "Alice" } } }, df.select { Person::name.lastCol { col -> col.any { it == "Alice" } } }, df.select { NonDataSchemaPerson::name.lastCol { col -> col.any { it == "Alice" } } }, + df.remove { name.lastName }.select { pathOf("name").lastCol() }, df.select { pathOf("name").lastCol { col -> col.any { it == "Alice" } } }, df.select { it["name"].asColumnGroup().lastCol { col -> col.any { it == "Alice" } } }, ).shouldAllBeEqual() } + + // endregion + + // region DataColumn + + @Test + fun `last on DataColumn`() { + df.name.lastName.last() shouldBe "Byrd" + df.age.last { it > 30 } shouldBe 40 + shouldThrow { + df.drop(df.nrow).isHappy.last() + } + } + + @Test + fun `lastOrNull on DataColumn`() { + df.name.lastName.lastOrNull() shouldBe "Byrd" + df.take(4).weight.lastOrNull() shouldBe null + df.drop(df.nrow).age.lastOrNull() shouldBe null + df.age.lastOrNull { it > 30 } shouldBe 40 + df.age.lastOrNull { it > 50 } shouldBe null + } + + // endregion + + // region DataFrame + + @Test + fun `last on DataFrame`() { + df.last().name.lastName shouldBe "Byrd" + df.last { !isHappy }.name.lastName shouldBe "Wolf" + shouldThrow { + df.drop(df.nrow).last() + } + shouldThrow { + df.last { age > 50 } + } + shouldThrow { + df.drop(df.nrow).last { isHappy } + } + } + + @Test + fun `lastOrNull on DataFrame`() { + df.lastOrNull()?.name?.lastName shouldBe "Byrd" + df.take(6).lastOrNull()?.city shouldBe null + df.drop(df.nrow).lastOrNull() shouldBe null + df.lastOrNull { !isHappy }?.name?.lastName shouldBe "Wolf" + df.lastOrNull { age > 50 } shouldBe null + df.drop(df.nrow).lastOrNull { isHappy } shouldBe null + } + + // endregion + + // region GroupBy + + @Test + fun `last on GroupBy`() { + val grouped = df.groupBy { isHappy } + val reducedGrouped = grouped.last() + val lastHappy = reducedGrouped.values()[0] + val lastUnhappy = reducedGrouped.values()[1] + lastHappy shouldBe dataFrameOf( + "isHappy" to columnOf(true), + "name" to columnOf( + "firstName" to columnOf("Charlie"), + "lastName" to columnOf("Byrd"), + ), + "age" to columnOf(30), + "city" to columnOf("Moscow"), + "weight" to columnOf(90), + )[0] + lastUnhappy shouldBe dataFrameOf( + "isHappy" to columnOf(false), + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Wolf"), + ), + "age" to columnOf(20), + "city" to columnOf(null), + "weight" to columnOf(55), + )[0] + } + + @Test + fun `last on GroupBy with predicate`() { + val grouped = df.groupBy { isHappy } + val reducedGrouped = grouped.last { "age"() < 21 && it["city"] != "Moscow" } + val lastHappy = reducedGrouped.values()[0] + val lastUnhappy = reducedGrouped.values()[1] + lastHappy shouldBe dataFrameOf( + "isHappy" to columnOf(true), + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Cooper"), + ), + "age" to columnOf(15), + "city" to columnOf("London"), + "weight" to columnOf(54), + )[0] + lastUnhappy shouldBe dataFrameOf( + "isHappy" to columnOf(false), + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Wolf"), + ), + "age" to columnOf(20), + "city" to columnOf(null), + "weight" to columnOf(55), + )[0] + } + + @Test + fun `last on GroupBy with predicate without match`() { + val grouped = df.groupBy { isHappy } + val reducedGrouped = grouped.last { it["city"] == "London" } + val lastHappy = reducedGrouped.values()[0] + val lastUnhappy = reducedGrouped.values()[1] + lastHappy shouldBe dataFrameOf( + "isHappy" to columnOf(true), + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Cooper"), + ), + "age" to columnOf(15), + "city" to columnOf("London"), + "weight" to columnOf(54), + )[0] + lastUnhappy shouldBe dataFrameOf( + "isHappy" to columnOf(false), + "name" to columnOf( + "firstName" to columnOf(null), + "lastName" to columnOf(null), + ), + "age" to columnOf(null), + "city" to columnOf(null), + "weight" to columnOf(null), + )[0] + } + + // endregion + + // region Pivot + + @Test + fun `last on Pivot`() { + val pivot = df.pivot { isHappy } + val reducedPivot = pivot.last() + val lastHappy = reducedPivot.values()[0] + val lastUnhappy = reducedPivot.values()[1] + lastHappy shouldBe dataFrameOf( + "name" to columnOf( + "firstName" to columnOf("Charlie"), + "lastName" to columnOf("Byrd"), + ), + "age" to columnOf(30), + "city" to columnOf("Moscow"), + "weight" to columnOf(90), + )[0] + lastUnhappy shouldBe dataFrameOf( + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Wolf"), + ), + "age" to columnOf(20), + "city" to columnOf(null), + "weight" to columnOf(55), + )[0] + } + + @Test + fun `last on Pivot with predicate`() { + val pivot = df.pivot { isHappy } + val reducedPivot = pivot.last { "age"() < 21 && it["city"] != "Moscow" } + val lastHappy = reducedPivot.values()[0] + val lastUnhappy = reducedPivot.values()[1] + lastHappy shouldBe dataFrameOf( + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Cooper"), + ), + "age" to columnOf(15), + "city" to columnOf("London"), + "weight" to columnOf(54), + )[0] + lastUnhappy shouldBe dataFrameOf( + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Wolf"), + ), + "age" to columnOf(20), + "city" to columnOf(null), + "weight" to columnOf(55), + )[0] + } + + @Test + fun `last on Pivot with predicate without match`() { + val pivot = df.pivot { isHappy } + val reducedPivot = pivot.last { it["city"] == "London" } + val lastHappy = reducedPivot.values()[0] + val lastUnhappy = reducedPivot.values()[1] + lastHappy shouldBe dataFrameOf( + "name" to columnOf( + "firstName" to columnOf("Alice"), + "lastName" to columnOf("Cooper"), + ), + "age" to columnOf(15), + "city" to columnOf("London"), + "weight" to columnOf(54), + )[0] + lastUnhappy shouldBe dataFrameOf( + "name" to columnOf(null), + "age" to columnOf(null), + "city" to columnOf(null), + "weight" to columnOf(null), + )[0] + } + + // endregion + + // region PivotGroupBy + + @Test + fun `last on PivotGroupBy`() { + val students = dataFrameOf( + "name" to columnOf("Alice", "Alice", "Alice", "Alice", "Bob", "Bob", "Bob", "Bob"), + "age" to columnOf(15, 15, 20, 20, 15, 15, 20, 20), + "group" to columnOf(1, 2, 1, 2, 1, 2, 1, 2), + ) + val studentsPivotGrouped = students.pivot("age").groupBy("name") + val studentsPivotGroupedReduced = studentsPivotGrouped.last().values() + val expectedDf = dataFrameOf( + "name" to columnOf("Alice", "Bob"), + "age" to columnOf( + "15" to columnOf(2, 2), + "20" to columnOf(2, 2), + ), + ) + studentsPivotGroupedReduced shouldBe expectedDf + } + + @Test + fun `last on PivotGroupBy with predicate`() { + val students = dataFrameOf( + "name" to columnOf("Alice", "Alice", "Alice", "Alice", "Bob", "Bob", "Bob", "Bob"), + "age" to columnOf(15, 15, 20, 20, 15, 15, 20, 20), + "group" to columnOf(1, 2, 1, 2, 1, 2, 1, 2), + ) + val studentsPivotGrouped = students.pivot("age").groupBy("name") + val studentsPivotGroupedReduced = studentsPivotGrouped.last { it["group"] == 1 }.values() + val expected = dataFrameOf( + "name" to columnOf("Alice", "Bob"), + "age" to columnOf( + "15" to columnOf(1, 1), + "20" to columnOf(1, 1), + ), + ) + studentsPivotGroupedReduced shouldBe expected + } + + @Test + fun `last on PivotGroupBy with predicate without match`() { + val students = dataFrameOf( + "name" to columnOf("Alice", "Alice", "Alice", "Alice", "Bob", "Bob", "Bob", "Bob"), + "age" to columnOf(15, 15, 20, 20, 15, 15, 20, 20), + "group" to columnOf(1, 2, 1, 2, 1, 2, 1, 2), + ) + val studentsPivotGrouped = students.pivot("age").groupBy("name") + val studentsPivotGroupedReduced = studentsPivotGrouped.last { it["group"] == 3 }.values() + val expected = dataFrameOf( + "name" to columnOf("Alice", "Bob"), + "age" to columnOf( + "15" to columnOf(null, null), + "20" to columnOf(null, null), + ), + ) + studentsPivotGroupedReduced shouldBe expected + } + + // endregion }