From 38bac2f88f15d98ab3f56c2a012887a591d19bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 19 Aug 2026 18:58:32 +0200 Subject: [PATCH] feat(dsl): Introduce $group's _id parameter --- .../kotlin/MongoAggregationPipeline.kt | 8 +- .../CoroutineMongoAggregationPipeline.kt | 3 +- .../CoroutineMongoAggregationPipelineImpl.kt | 2 +- .../BlockingMongoAggregationPipeline.kt | 8 +- .../kotlin/MongoAggregationPipeline.kt | 8 +- .../kotlin/SyncMongoAggregationPipeline.kt | 3 +- .../SyncMongoAggregationPipelineImpl.kt | 2 +- .../kotlin/aggregation/stages/Group.kt | 242 ++++- .../kotlin/aggregation/stages/Group.kt | 934 +++++++++++++++++- .../kotlin/aggregation/stages/GroupTest.kt | 215 +++- 10 files changed, 1382 insertions(+), 43 deletions(-) diff --git a/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt index c1e007a5..a6703f5a 100644 --- a/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -18,12 +18,8 @@ package opensavvy.ktmongo.api import kotlinx.coroutines.flow.Flow import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators import opensavvy.ktmongo.dsl.aggregation.AggregationPipeline -import opensavvy.ktmongo.dsl.aggregation.stages.HasUnionWithCompatibility -import opensavvy.ktmongo.dsl.aggregation.stages.ProjectStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.SetStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.UnsetStageOperators +import opensavvy.ktmongo.dsl.aggregation.stages.* import opensavvy.ktmongo.dsl.options.SortOptionDsl import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.dsl.query.FilterQuery @@ -96,7 +92,7 @@ interface MongoAggregationPipeline : AggregationPipeline): MongoAggregationPipeline - override fun group(block: AccumulationOperators.() -> Unit): MongoAggregationPipeline + override fun group(block: GroupStageOperators.() -> Unit): MongoAggregationPipeline override fun countTo(field: Field): MongoAggregationPipeline diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt index 367f14c4..b4f7edf4 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt @@ -22,7 +22,6 @@ package opensavvy.ktmongo.coroutines import opensavvy.ktmongo.api.MongoAggregationPipeline import opensavvy.ktmongo.api.MongoIterable import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.aggregation.stages.* @@ -74,7 +73,7 @@ interface CoroutineMongoAggregationPipeline : MongoAggregationPi override fun lookup(block: LookupStageOperators.() -> Unit): CoroutineMongoAggregationPipeline - override fun group(block: AccumulationOperators.() -> Unit): CoroutineMongoAggregationPipeline + override fun group(block: GroupStageOperators.() -> Unit): CoroutineMongoAggregationPipeline override fun countTo(field: Field): CoroutineMongoAggregationPipeline diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt index f13a651f..3e2d06dd 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt @@ -123,7 +123,7 @@ private class CoroutineMongoAggregationPipelineImpl @OptIn(LowLe super.lookup(block) as CoroutineMongoAggregationPipelineImpl @KtMongoDsl - override fun group(block: AccumulationOperators.() -> Unit): CoroutineMongoAggregationPipelineImpl = + override fun group(block: GroupStageOperators.() -> Unit): CoroutineMongoAggregationPipelineImpl = super.group(block) as CoroutineMongoAggregationPipelineImpl @KtMongoDsl diff --git a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt index cdce5afb..fdf91da9 100644 --- a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt +++ b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt @@ -22,11 +22,7 @@ import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators -import opensavvy.ktmongo.dsl.aggregation.stages.HasUnionWithCompatibility -import opensavvy.ktmongo.dsl.aggregation.stages.ProjectStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.SetStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.UnsetStageOperators +import opensavvy.ktmongo.dsl.aggregation.stages.* import opensavvy.ktmongo.dsl.options.SortOptionDsl import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.dsl.query.FilterQuery @@ -75,7 +71,7 @@ class BlockingMongoAggregationPipeline( override fun unionWith(other: HasUnionWithCompatibility): BlockingMongoAggregationPipeline = BlockingMongoAggregationPipeline(inner.unionWith(other)) - override fun group(block: AccumulationOperators.() -> Unit): BlockingMongoAggregationPipeline = + override fun group(block: GroupStageOperators.() -> Unit): BlockingMongoAggregationPipeline = BlockingMongoAggregationPipeline(inner.group(block)) override fun countTo(field: Field): BlockingMongoAggregationPipeline = diff --git a/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt index 683698c3..02503a5a 100644 --- a/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -17,12 +17,8 @@ package opensavvy.ktmongo.sync.api import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators import opensavvy.ktmongo.dsl.aggregation.AggregationPipeline -import opensavvy.ktmongo.dsl.aggregation.stages.HasUnionWithCompatibility -import opensavvy.ktmongo.dsl.aggregation.stages.ProjectStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.SetStageOperators -import opensavvy.ktmongo.dsl.aggregation.stages.UnsetStageOperators +import opensavvy.ktmongo.dsl.aggregation.stages.* import opensavvy.ktmongo.dsl.options.SortOptionDsl import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.dsl.query.FilterQuery @@ -95,7 +91,7 @@ interface MongoAggregationPipeline : AggregationPipeline): MongoAggregationPipeline - override fun group(block: AccumulationOperators.() -> Unit): MongoAggregationPipeline + override fun group(block: GroupStageOperators.() -> Unit): MongoAggregationPipeline override fun countTo(field: Field): MongoAggregationPipeline diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt index d042bd57..d097258b 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt @@ -20,7 +20,6 @@ package opensavvy.ktmongo.sync import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.aggregation.stages.* @@ -74,7 +73,7 @@ interface SyncMongoAggregationPipeline : MongoAggregationPipelin override fun lookup(block: LookupStageOperators.() -> Unit): SyncMongoAggregationPipeline - override fun group(block: AccumulationOperators.() -> Unit): SyncMongoAggregationPipeline + override fun group(block: GroupStageOperators.() -> Unit): SyncMongoAggregationPipeline override fun countTo(field: Field): SyncMongoAggregationPipeline diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt index 344548a9..9725c0d8 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt @@ -122,7 +122,7 @@ private class SyncMongoAggregationPipelineImpl @OptIn(LowLevelAp super.lookup(block) as SyncMongoAggregationPipelineImpl @KtMongoDsl - override fun group(block: AccumulationOperators.() -> Unit): SyncMongoAggregationPipelineImpl = + override fun group(block: GroupStageOperators.() -> Unit): SyncMongoAggregationPipelineImpl = super.group(block) as SyncMongoAggregationPipelineImpl @KtMongoDsl diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt index 5bf518ad..6c2cb72e 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt @@ -22,9 +22,14 @@ import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperatorsImpl import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.Value +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.path.PathSegment import opensavvy.ktmongo.dsl.tree.AbstractBsonNode +import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode +import opensavvy.ktmongo.dsl.tree.BsonNode /** * Pipeline implementing the `$group` stage. @@ -33,12 +38,18 @@ import opensavvy.ktmongo.dsl.tree.AbstractBsonNode interface HasGroup : Pipeline { /** - * Combines multiple documents into a single document. + * Combines many documents into few documents. * * The resulting documents contain fields generated by accumulating all input documents. * To learn more about accumulation operators, see [AccumulationOperators]. * - * ### Example + * `$group` can be used in two different ways: + * - Without specifying a key: this stage behaves like Kotlin's `fold()` function, + * returning a single document that accumulates the data from all results. + * - With a key: this stage behaves like Kotlin's `groupBy()` function, + * returning multiple documents which each acculumate the data of their matching results. + * + * ### Example with a single result * * If we have users with an account balance, we can find out the total account balance of all users. * @@ -54,10 +65,59 @@ interface HasGroup : Pipeline { * * users.aggregate() * .group { - * Result::totalBalance sum of(User::balance) + * Result::totalBalance sum User::balance * } * ``` * + * This is similar to the following Kotlin code: + * ```kotlin + * users.toList() + * .fold(0) { acc, it -> acc + it.balance } + * ``` + * Or even just: + * ```kotlin + * users.toList() + * .sumOf { it.balance } + * ``` + * + * To see the list of available accumulation operators, see [AccumulationOperators]. + * + * ### Example with multiple results + * + * If we have users with an account balance across different cities, we may be interested + * in the average balance of users in each city. + * + * ```kotlin + * class User( + * val name: String, + * val balance: Int, + * val city: String, + * ) + * + * class Result( + * val _id: String, + * val averageBalance: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * Result::_id set User::city + * + * // Each each group, compute the average balance + * Result::averageBalance average User::balance + * } + * ``` + * + * This is similar to the following Kotlin code: + * ```kotlin + * users.toList() + * .groupBy { it.city } + * .mapValues { (_, users) -> users.map { it.balance }.average() } + * ``` + * + * To learn more about creating multiple groups, see [GroupStageOperators.set]. + * * To see the list of available accumulation operators, see [AccumulationOperators]. * * ### Performance @@ -74,23 +134,189 @@ interface HasGroup : Pipeline { @OptIn(DangerousMongoApi::class, LowLevelApi::class) @KtMongoDsl fun group( - block: AccumulationOperators.() -> Unit, + block: GroupStageOperators.() -> Unit, ): Pipeline = - withStage(GroupStage(AccumulationOperatorsImpl(context).apply(block), context)) + withStage(GroupStage(GroupStageOperatorsImpl(context).apply(block), context)) .reinterpret() } +/** + * Operators available with the `$group` stage. + * + * For more information, see [HasGroup.group]. + */ +@KtMongoDsl +interface GroupStageOperators : AccumulationOperators { + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @Suppress("INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> Field.set(value: Value) +} + +@OptIn(LowLevelApi::class) +private class GroupStageOperatorsImpl( + context: BsonContext, +) : AbstractCompoundBsonNode(context), GroupStageOperators { + + override fun write(writer: BsonFieldWriter, children: List) = with(writer) { + val idMappings = children.filterIsInstance() + val others = children.filter { it !is IdMappingNode } + + when { + idMappings.isEmpty() -> writeNull("_id") + + idMappings.size == 1 && idMappings.first().path.parent == null -> + write("_id") { idMappings.first().value.writeTo(this) } + + else -> writeDocument("_id") { + writeIdChildren(idMappings.map { it.path.asSequence().drop(1).toList() to it.value }) + } + } + + super.write(this, others) + } + + private fun BsonFieldWriter.writeIdChildren(entries: List, Value<*, *>>>) { + val bySegment = LinkedHashMap, Value<*, *>>>>() + for ((segments, value) in entries) { + bySegment.getOrPut(segments.first()) { mutableListOf() } += segments.drop(1) to value + } + + for ((segment, rest) in bySegment) { + val single = rest.singleOrNull() + if (single != null && single.first.isEmpty()) { + write(segment.toString()) { single.second.writeTo(this) } + } else { + writeDocument(segment.toString()) { writeIdChildren(rest) } + } + } + } + + @OptIn(DangerousMongoApi::class) + override fun Field.set(value: Value) { + val path = this.path + require((path.asSequence().firstOrNull() as? PathSegment.Field)?.name == "_id") { $$"Inside a $group stage, the only field that can be set is _id (and its children), but this stage attempted to set the field $$path" } + accept(IdMappingNode(path, value, context)) + } +} + private class GroupStage( - val operators: AccumulationOperators<*, *>, + val operators: GroupStageOperatorsImpl<*, *>, context: BsonContext, ) : AbstractBsonNode(context) { @LowLevelApi override fun write(writer: BsonFieldWriter) = with(writer) { writeDocument("\$group") { - writeNull("_id") operators.writeTo(this) } } } + +@LowLevelApi +private class IdMappingNode( + val path: Path, + val value: Value<*, *>, + context: BsonContext, +) : AbstractBsonNode(context) { + + override fun write(writer: BsonFieldWriter) = with(writer) { + write(path.toString()) { + value.writeTo(this) + } + } +} diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt index 2e9d9356..9ef66105 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt @@ -25,9 +25,14 @@ import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperatorsImpl import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.Value +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.path.PathSegment import opensavvy.ktmongo.dsl.tree.AbstractBsonNode +import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode +import opensavvy.ktmongo.dsl.tree.BsonNode /** * Pipeline implementing the `$group` stage. @@ -36,12 +41,18 @@ import opensavvy.ktmongo.dsl.tree.AbstractBsonNode interface HasGroup : Pipeline { /** - * Combines multiple documents into a single document. + * Combines many documents into few documents. * * The resulting documents contain fields generated by accumulating all input documents. * To learn more about accumulation operators, see [AccumulationOperators]. * - * ### Example + * `$group` can be used in two different ways: + * - Without specifying a key: this stage behaves like Kotlin's `fold()` function, + * returning a single document that accumulates the data from all results. + * - With a key: this stage behaves like Kotlin's `groupBy()` function, + * returning multiple documents which each acculumate the data of their matching results. + * + * ### Example with a single result * * If we have users with an account balance, we can find out the total account balance of all users. * @@ -57,10 +68,59 @@ interface HasGroup : Pipeline { * * users.aggregate() * .group { - * Result::totalBalance sum of(User::balance) + * Result::totalBalance sum User::balance + * } + * ``` + * + * This is similar to the following Kotlin code: + * ```kotlin + * users.toList() + * .fold(0) { acc, it -> acc + it.balance } + * ``` + * Or even just: + * ```kotlin + * users.toList() + * .sumOf { it.balance } + * ``` + * + * To see the list of available accumulation operators, see [AccumulationOperators]. + * + * ### Example with multiple results + * + * If we have users with an account balance across different cities, we may be interested + * in the average balance of users in each city. + * + * ```kotlin + * class User( + * val name: String, + * val balance: Int, + * val city: String, + * ) + * + * class Result( + * val _id: String, + * val averageBalance: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * Result::_id set User::city + * + * // Each each group, compute the average balance + * Result::averageBalance average User::balance * } * ``` * + * This is similar to the following Kotlin code: + * ```kotlin + * users.toList() + * .groupBy { it.city } + * .mapValues { (_, users) -> users.map { it.balance }.average() } + * ``` + * + * To learn more about creating multiple groups, see [GroupStageOperators.set]. + * * To see the list of available accumulation operators, see [AccumulationOperators]. * * ### Performance @@ -77,23 +137,881 @@ interface HasGroup : Pipeline { @OptIn(DangerousMongoApi::class, LowLevelApi::class) @KtMongoDsl fun group( - block: AccumulationOperators.() -> Unit, + block: GroupStageOperators.() -> Unit, ): Pipeline = - withStage(GroupStage(AccumulationOperatorsImpl(context).apply(block), context)) + withStage(GroupStage(GroupStageOperatorsImpl(context).apply(block), context)) .reinterpret() } +/** + * Operators available with the `$group` stage. + * + * For more information, see [HasGroup.group]. + */ +@KtMongoDsl +interface GroupStageOperators : AccumulationOperators { + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @Suppress("INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> Field.set(value: Value) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @kotlin.jvm.JvmName("setByField") + @Suppress("INAPPLICABLE_JVM_NAME", "INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> Field.set(value: opensavvy.ktmongo.dsl.path.Field) = + set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @kotlin.jvm.JvmName("setPropertyReceiverByField") + @Suppress("INAPPLICABLE_JVM_NAME", "INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> kotlin.reflect.KProperty1.set(value: opensavvy.ktmongo.dsl.path.Field) = + this.field.set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @kotlin.jvm.JvmName("setByProperty") + @Suppress("INAPPLICABLE_JVM_NAME", "INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> Field.set(value: kotlin.reflect.KProperty1) = + set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @kotlin.jvm.JvmName("setPropertyReceiverByProperty") + @Suppress("INAPPLICABLE_JVM_NAME", "INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> kotlin.reflect.KProperty1.set(value: kotlin.reflect.KProperty1) = + this.field.set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @kotlin.internal.LowPriorityInOverloadResolution + @Suppress("INVISIBLE_REFERENCE", "WRONG_MODIFIER_CONTAINING_DECLARATION") + infix final inline fun <@kotlin.internal.Exact reified V> Field.set(value: V) = + set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @Suppress("INVISIBLE_REFERENCE", "WRONG_MODIFIER_CONTAINING_DECLARATION") + infix final inline fun <@kotlin.internal.Exact reified V> kotlin.reflect.KProperty1.set(value: V) = + this.field.set(of(value)) + + /** + * Sets the criteria to group by. + * + * If this function is not called, the [group][HasGroup.group] stage results in a single document that folds over the entire pipeline results. + * + * If this function is called, documents are grouped by equality of the [value], each group results in a document that folds over the documents in the group. + * + * **This function can only be called on the `_id` field or one of its subfields.** + * + * ### Simple example + * + * We can compute the average age of users in the different cities we have users in: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * class AgePerCity( + * val _id: String, + * val averageAge: Double, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCity::_id set User::city + * + * // Calculate average age within each city + * AgePerCity::averageAge average User::age + * } + * ``` + * + * Each city in the dataset returns a document with `_id` set to the city name, and `averageAge` set to the average age of users in that city. + * + * ### Compound example + * + * We can also compute more complex groups, where the `_id` is composed of multiple fields. + * To do so, they must all be nested within the `_id` field itself. + * + * For example, if we want to create different groups for different age ranges: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * val city: String, + * ) + * + * enum class AgeRange { + * Child, + * Adult, + * Elder, + * } + * + * class AgePerCityAndRangeId( + * val city: String, + * val ageRange: AgeRange, + * ) + * + * class AgePerCityAndRange( + * val _id: AgePerCityAndRangeId, + * val averageAge: Double, + * val medianAge: Int, + * ) + * + * users.aggregate() + * .group { + * // Group by city name + * AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + * + * // Also group by age range + * AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + * User::age lt 18 then AgeRange.Child, + * User::age gte 65 then AgeRange.Elder, + * default = AgeRange.Adult, + * ) + * + * // In each group, compute the average age + * AgePerCityAndRange::averageAge average User::age + * + * // In each group, count the number of users + * AgePerCityAndRange::medianAge median User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/) + */ + @Suppress("INVISIBLE_REFERENCE") + infix fun <@kotlin.internal.Exact V> kotlin.reflect.KProperty1.set(value: Value) { + return this.field.set(value) + } +} + +@OptIn(LowLevelApi::class) +private class GroupStageOperatorsImpl( + context: BsonContext, +) : AbstractCompoundBsonNode(context), GroupStageOperators { + + override fun write(writer: BsonFieldWriter, children: List) = with(writer) { + val idMappings = children.filterIsInstance() + val others = children.filter { it !is IdMappingNode } + + when { + idMappings.isEmpty() -> writeNull("_id") + + idMappings.size == 1 && idMappings.first().path.parent == null -> + write("_id") { idMappings.first().value.writeTo(this) } + + else -> writeDocument("_id") { + writeIdChildren(idMappings.map { it.path.asSequence().drop(1).toList() to it.value }) + } + } + + super.write(this, others) + } + + private fun BsonFieldWriter.writeIdChildren(entries: List, Value<*, *>>>) { + val bySegment = LinkedHashMap, Value<*, *>>>>() + for ((segments, value) in entries) { + bySegment.getOrPut(segments.first()) { mutableListOf() } += segments.drop(1) to value + } + + for ((segment, rest) in bySegment) { + val single = rest.singleOrNull() + if (single != null && single.first.isEmpty()) { + write(segment.toString()) { single.second.writeTo(this) } + } else { + writeDocument(segment.toString()) { writeIdChildren(rest) } + } + } + } + + @OptIn(DangerousMongoApi::class) + override fun Field.set(value: Value) { + val path = this.path + require((path.asSequence().firstOrNull() as? PathSegment.Field)?.name == "_id") { $$"Inside a $group stage, the only field that can be set is _id (and its children), but this stage attempted to set the field $$path" } + accept(IdMappingNode(path, value, context)) + } +} + private class GroupStage( - val operators: AccumulationOperators<*, *>, + val operators: GroupStageOperatorsImpl<*, *>, context: BsonContext, ) : AbstractBsonNode(context) { @LowLevelApi override fun write(writer: BsonFieldWriter) = with(writer) { writeDocument("\$group") { - writeNull("_id") operators.writeTo(this) } } } + +@LowLevelApi +private class IdMappingNode( + val path: Path, + val value: Value<*, *>, + context: BsonContext, +) : AbstractBsonNode(context) { + + override fun write(writer: BsonFieldWriter) = with(writer) { + write(path.toString()) { + value.writeTo(this) + } + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt index e1734161..4e2c7057 100644 --- a/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt @@ -14,23 +14,33 @@ * limitations under the License. */ -@file:OptIn(LowLevelApi::class) - package opensavvy.ktmongo.dsl.aggregation.stages -import opensavvy.ktmongo.dsl.LowLevelApi +import kotlinx.serialization.Serializable +import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.dsl.aggregation.Pipeline import opensavvy.ktmongo.dsl.aggregation.TestPipeline import opensavvy.ktmongo.dsl.aggregation.shouldBeBson import opensavvy.ktmongo.dsl.multiContextSuite +import opensavvy.prepared.suite.assertions.checkThrows + +@Serializable +enum class AgeRange { + Child, + Adult, + Elder, +} val GroupTest by multiContextSuite { class Score( + val topic: String, val score: Int, ) class Results( + val _id: String, + val topic: String, val average: Int, val max: Int, val total: Int, @@ -139,4 +149,203 @@ val GroupTest by multiContextSuite { """.trimIndent()) } + test($$"Simple $group with _id") { + TestPipeline() + .group { + Results::_id set Score::topic + Results::total sum of(Score::score) + } + .also { + @Suppress("unused") + val foo: Pipeline = it // Won't compile if 'group' stops changing the type automatically to Results + } + .shouldBeBson($$""" + [ + { + "$group": { + "_id": "$topic", + "total": { + "$sum": "$score" + } + } + } + ] + """.trimIndent()) + } + + test("Cannot set a non-_id field") { + checkThrows { + TestPipeline() + .group { + Results::total set Score::topic + } + } + } + + class NestedGroup( + val _id: Results, + ) + + test($$"$group with a nested _id field") { + TestPipeline() + .group { + NestedGroup::_id / Results::topic set Score::topic + } + .also { + @Suppress("unused") + val foo: Pipeline = it // Won't compile if 'group' stops changing the type automatically + } + .shouldBeBson($$""" + [ + { + "$group": { + "_id": { + "topic": "$topic" + } + } + } + ] + """.trimIndent()) + } + + class CompoundGroupId( + val topic: String, + val score: Int, + ) + + class CompoundGroup( + val _id: CompoundGroupId, + val average: Double, + ) + + test($$"$group with a nested _id field") { + TestPipeline() + .group { + CompoundGroup::_id / CompoundGroupId::topic set Score::topic + CompoundGroup::_id / CompoundGroupId::score set ((of(Score::score) / 10).toInt()) * 10 + CompoundGroup::average average Score::score + } + .also { + @Suppress("unused") + val foo: Pipeline = it // Won't compile if 'group' stops changing the type automatically + } + .shouldBeBson($$""" + [ + { + "$group": { + "_id": { + "topic": "$topic", + "score": { + "$multiply": [ + { + "$toInt": { + "$divide": [ + "$score", + { + "$literal": 10 + } + ] + } + }, + { + "$literal": 10 + } + ] + } + }, + "average": { + "$avg": "$score" + } + } + } + ] + """.trimIndent()) + } + + class User( + val _id: ObjectId, + val name: String, + val age: Int, + val city: String, + ) + + class AgePerCityAndRangeId( + val city: String, + val ageRange: AgeRange, + ) + + class AgePerCityAndRange( + val _id: AgePerCityAndRangeId, + val averageAge: Double, + val medianAge: Int, + ) + + test($$"$group by city and age range with switch, average and median") { + TestPipeline() + .group { + AgePerCityAndRange::_id / AgePerCityAndRangeId::city set User::city + AgePerCityAndRange::_id / AgePerCityAndRangeId::ageRange set switch( + User::age lt 18 then AgeRange.Child, + User::age gte 65 then AgeRange.Elder, + default = AgeRange.Adult, + ) + AgePerCityAndRange::averageAge average User::age + AgePerCityAndRange::medianAge median User::age + } + .shouldBeBson($$""" + [ + { + "$group": { + "_id": { + "city": "$city", + "ageRange": { + "$switch": { + "branches": [ + { + "case": { + "$lt": [ + "$age", + { + "$literal": 18 + } + ] + }, + "then": { + "$literal": "Child" + } + }, + { + "case": { + "$gte": [ + "$age", + { + "$literal": 65 + } + ] + }, + "then": { + "$literal": "Elder" + } + } + ], + "default": { + "$literal": "Adult" + } + } + } + }, + "averageAge": { + "$avg": "$age" + }, + "medianAge": { + "$median": { + "input": "$age", + "method": "approximate" + } + } + } + } + ] + """.trimIndent()) + } } -- 2.51.2