From 679ebb2cd0c29a3790a8c17e9144f7d38d6edd00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 13 Sep 2025 19:16:46 +0200 Subject: [PATCH 1/3] feat(dsl): Support $avg (accumulator) --- .../aggregation/AccumulationOperators.kt | 1 + .../ArithmeticValueAccumulators.kt | 72 +++++++++++++++++++ .../kotlin/aggregation/stages/GroupTest.kt | 25 ++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/dsl/src/commonMain/kotlin/aggregation/AccumulationOperators.kt b/dsl/src/commonMain/kotlin/aggregation/AccumulationOperators.kt index ea53486b..3965cd1d 100644 --- a/dsl/src/commonMain/kotlin/aggregation/AccumulationOperators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/AccumulationOperators.kt @@ -30,6 +30,7 @@ import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode * ### Operators * * Arithmetic operators: + * - [`$avg`][ArithmeticValueAccumulators.average] * - [`$sum`][ArithmeticValueAccumulators.sum] * * @see Value Representation of an aggregation value. diff --git a/dsl/src/commonMain/kotlin/aggregation/accumulators/ArithmeticValueAccumulators.kt b/dsl/src/commonMain/kotlin/aggregation/accumulators/ArithmeticValueAccumulators.kt index c468fdb0..c30eed9e 100644 --- a/dsl/src/commonMain/kotlin/aggregation/accumulators/ArithmeticValueAccumulators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/accumulators/ArithmeticValueAccumulators.kt @@ -103,6 +103,78 @@ interface ArithmeticValueAccumulators : ValueAccumulator this.field.sum(value) } + // endregion + // region $sum + + /** + * Calculates and returns the collective average of numeric values. + * Non-numeric values are ignored. + * + * If all elements are non-numeric, `null` is returned. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val balance: Int, + * ) + * + * class Result( + * val totalBalance: Int, + * ) + * + * users.aggregate() + * .group { + * Result::totalBalance average of(User::balance) + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg) + */ + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes T : Number> Field.average(value: Value) { + accept(ArithmeticValueAccumulator("\$avg", value, this.path, context)) + } + + /** + * Calculates and returns the collective average of numeric values. + * Non-numeric values are ignored. + * + * If all elements are non-numeric, `null` is returned. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val balance: Int, + * ) + * + * class Result( + * val totalBalance: Int, + * ) + * + * users.aggregate() + * .group { + * Result::totalBalance average of(User::balance) + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg) + */ + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes T : Number> KProperty1.average(value: Value) { + this.field.average(value) + } + // endregion @LowLevelApi diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt index afc875a1..d532d467 100644 --- a/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/stages/GroupTest.kt @@ -42,7 +42,7 @@ val GroupTest by preparedSuite { Results::total sum of(Score::score) } .also { - @Suppress("UnusedVariable", "unused") + @Suppress("unused") val foo: Pipeline = it // Won't compile if 'group' stops changing the type automatically to Results } .shouldBeBson($$""" @@ -59,4 +59,27 @@ val GroupTest by preparedSuite { """.trimIndent()) } + test($$"Simple $group with $avg") { + TestPipeline() + .group { + Results::total average 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": null, + "total": { + "$avg": "$score" + } + } + } + ] + """.trimIndent()) + } + } -- 2.51.2 From ebdc71de2e0484f952e537172928761a12c09690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 13 Sep 2025 20:02:09 +0200 Subject: [PATCH 2/3] feat(dsl): Support $avg (aggregation) --- .../aggregation/AggregationOperators.kt | 1 + .../operators/ArrayValueOperators.kt | 195 ++++++++++++++++++ .../operators/ArrayValueOperatorsTest.kt | 79 ++++++- 3 files changed, 274 insertions(+), 1 deletion(-) diff --git a/dsl/src/commonMain/kotlin/aggregation/AggregationOperators.kt b/dsl/src/commonMain/kotlin/aggregation/AggregationOperators.kt index 9e8aeaa1..b5c1472d 100644 --- a/dsl/src/commonMain/kotlin/aggregation/AggregationOperators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/AggregationOperators.kt @@ -99,6 +99,7 @@ import opensavvy.ktmongo.dsl.query.FilterQuery * - [`$subtract`][ArithmeticValueOperators.minus] * * Array operators: + * - [`$avg`][ArrayValueOperators.average] * - [`$filter`][ArrayValueOperators.filter] * - [`$firstN`][ArrayValueOperators.take] * - [`$lastN`][ArrayValueOperators.takeLast] diff --git a/dsl/src/commonMain/kotlin/aggregation/operators/ArrayValueOperators.kt b/dsl/src/commonMain/kotlin/aggregation/operators/ArrayValueOperators.kt index a92d1587..ade60538 100644 --- a/dsl/src/commonMain/kotlin/aggregation/operators/ArrayValueOperators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/operators/ArrayValueOperators.kt @@ -39,6 +39,201 @@ import kotlin.reflect.KProperty1 */ interface ArrayValueOperators : ValueOperators { + // region $avg + + /** + * Returns the average of the elements in the array. + * + * ### Example + * + * ```kotlin + * class Player( + * val _id: ObjectId, + * val scores: List, + * val averageScore: Double, + * ) + * + * players.updateManyWithPipeline { + * set { + * Player::averageScore set Player::scores.average() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun Value>.average(): Value = + AverageArrayValueOperator( + input = this, + context = context, + ) + + /** + * Returns the average of the elements in the array. + * + * ### Example + * + * ```kotlin + * class Player( + * val _id: ObjectId, + * val scores: List, + * val averageScore: Double, + * ) + * + * players.updateManyWithPipeline { + * set { + * Player::averageScore set Player::scores.average() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun Field>.average(): Value = + AverageArrayValueOperator( + input = of(this), + context = context, + ) + + /** + * Returns the average of the elements in the array. + * + * ### Example + * + * ```kotlin + * class Player( + * val _id: ObjectId, + * val scores: List, + * val averageScore: Double, + * ) + * + * players.updateManyWithPipeline { + * set { + * Player::averageScore set Player::scores.average() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun KProperty1>.average(): Value = + AverageArrayValueOperator( + input = of(this), + context = context, + ) + + @LowLevelApi + private class AverageArrayValueOperator( + private val input: Value>, + context: BsonContext, + ) : AbstractValue(context) { + + override fun write(writer: BsonValueWriter) = with(writer) { + writeDocument { + write("\$avg") { + input.writeTo(this) + } + } + } + } + + /** + * Returns the average of the elements in the array. + * + * ### Example + * + * ```kotlin + * class Player( + * val _id: ObjectId, + * val scores: List, + * val averageScore: Double, + * ) + * + * players.updateManyWithPipeline { + * set { + * Player::averageScore set Player::scores.average() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun Iterable>.average(): Value = + AverageOfValueOperator( + input = this.toList(), + context = context, + ) + + /** + * Returns the average of the elements in the array. + * + * ### Example + * + * ```kotlin + * class Player( + * val _id: ObjectId, + * val scores: List, + * val averageScore: Double, + * ) + * + * players.updateManyWithPipeline { + * set { + * Player::averageScore set Player::scores.average() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/avg/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun average(vararg input: Value): Value = + AverageOfValueOperator( + input = input.asList(), + context = context, + ) + + @Deprecated("Computing the average of 0 elements makes no sense, you should specify the elements to average as the receiver or as arguments.", level = DeprecationLevel.ERROR) + @KtMongoDsl + fun average(): Value = + error("Computing the average of 0 elements makes no sense, did you forget to specify arguments?") + + @LowLevelApi + private class AverageOfValueOperator( + private val input: List>, + context: BsonContext, + ) : AbstractValue(context) { + + override fun write(writer: BsonValueWriter) = with(writer) { + writeDocument { + writeArray("\$avg") { + for (document in input) { + document.writeTo(this) + } + } + } + } + } + + // endregion // region $filter /** diff --git a/dsl/src/commonTest/kotlin/aggregation/operators/ArrayValueOperatorsTest.kt b/dsl/src/commonTest/kotlin/aggregation/operators/ArrayValueOperatorsTest.kt index 409ba3d9..536956fa 100644 --- a/dsl/src/commonTest/kotlin/aggregation/operators/ArrayValueOperatorsTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/operators/ArrayValueOperatorsTest.kt @@ -34,7 +34,8 @@ val ArrayValueOperatorsTest by preparedSuite { class Target( val numbers: List, val users: List, - val results: List + val results: List, + val result: Double, ) suite($$"$filter") { @@ -260,6 +261,82 @@ val ArrayValueOperatorsTest by preparedSuite { } } + suite($$"$avg") { + test("Usage with a list of integers") { + TestPipeline() + .set { + Target::result set Target::numbers.average() + } + .shouldBeBson($$""" + [ + { + "$set": { + "result": { + "$avg": "$numbers" + } + } + } + ] + """.trimIndent()) + } + + test("Usage with a Kotlin list") { + TestPipeline() + .set { + Target::result set listOf(of(2), of(3.54), of(Target::result)).average() + } + .shouldBeBson($$""" + [ + { + "$set": { + "result": { + "$avg": [ + { + "$literal": 2 + }, + { + "$literal": 3.54 + }, + "$result" + ] + } + } + } + ] + """.trimIndent()) + } + + test("Usage with a vararg") { + TestPipeline() + .set { + Target::result set average( + of(2), + of(3.54), + of(Target::result) + ) + } + .shouldBeBson($$""" + [ + { + "$set": { + "result": { + "$avg": [ + { + "$literal": 2 + }, + { + "$literal": 3.54 + }, + "$result" + ] + } + } + } + ] + """.trimIndent()) + } + } + suite($$"$firstN") { test("Usage with a list of integers") { TestPipeline() -- 2.51.2 From 4dd2388aef4405449e40810890645f6f96bcf916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 13 Sep 2025 20:03:19 +0200 Subject: [PATCH 3/3] test(dsl): Improve the error message when the generated BSON differs --- dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt b/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt index cf0d23a5..c08d3712 100644 --- a/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt +++ b/dsl/src/commonTest/kotlin/query/ExpressionTestUtils.kt @@ -29,7 +29,7 @@ infix fun String.shouldBeBson(@Language("MongoDB-JSON") expected: String) { .replace(",", ", ") .replace(" ", " ") - check(this == expected) + check(this == expected) { "Expected: $expected\nActual: $this" } } infix fun BsonNode.shouldBeBson(@Language("MongoDB-JSON") expected: String) { -- 2.51.2