From 3a704e4cf0d712a7caaad76abb7c498f4f6dcd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jun 2026 17:34:14 +0200 Subject: [PATCH 1/4] feat(dsl): Create the matchExpr helper stage --- .../kotlin/MongoAggregationPipeline.kt | 11 ++--- .../kotlin/MongoAggregationPipeline.kt | 10 +++-- .../kotlin/aggregation/stages/Match.kt | 42 +++++++++++++++++++ .../kotlin/aggregation/stages/Match.kt | 42 +++++++++++++++++++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/driver-coroutines/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-coroutines/src/commonMain/kotlin/MongoAggregationPipeline.kt index ed19c768..5ec71573 100644 --- a/driver-coroutines/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-coroutines/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,10 +21,7 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AbstractPipeline -import opensavvy.ktmongo.dsl.aggregation.AccumulationOperators -import opensavvy.ktmongo.dsl.aggregation.AggregationPipeline -import opensavvy.ktmongo.dsl.aggregation.PipelineChainLink +import opensavvy.ktmongo.dsl.aggregation.* import opensavvy.ktmongo.dsl.aggregation.stages.HasUnionWithCompatibility import opensavvy.ktmongo.dsl.aggregation.stages.ProjectStageOperators import opensavvy.ktmongo.dsl.aggregation.stages.SetStageOperators @@ -75,6 +72,10 @@ class MongoAggregationPipeline @OptIn(LowLevelApi::class) internal override fun match(filter: FilterQuery.() -> Unit): MongoAggregationPipeline = super.match(filter) as MongoAggregationPipeline + @KtMongoDsl + override fun matchExpr(filter: AggregationOperators.() -> Value): MongoAggregationPipeline = + super.matchExpr(filter) as MongoAggregationPipeline + @KtMongoDsl override fun sample(size: Int): MongoAggregationPipeline = super.sample(size) as MongoAggregationPipeline diff --git a/driver-sync/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-sync/src/commonMain/kotlin/MongoAggregationPipeline.kt index 201183e8..48736166 100644 --- a/driver-sync/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-sync/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,9 +21,7 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.aggregation.AbstractPipeline -import opensavvy.ktmongo.dsl.aggregation.AggregationPipeline -import opensavvy.ktmongo.dsl.aggregation.PipelineChainLink +import opensavvy.ktmongo.dsl.aggregation.* import opensavvy.ktmongo.dsl.aggregation.stages.HasUnionWithCompatibility import opensavvy.ktmongo.dsl.aggregation.stages.ProjectStageOperators import opensavvy.ktmongo.dsl.aggregation.stages.SetStageOperators @@ -74,6 +72,10 @@ class MongoAggregationPipeline @OptIn(LowLevelApi::class) internal override fun match(filter: FilterQuery.() -> Unit): MongoAggregationPipeline = super.match(filter) as MongoAggregationPipeline + @KtMongoDsl + override fun matchExpr(filter: AggregationOperators.() -> Value): MongoAggregationPipeline = + super.matchExpr(filter) as MongoAggregationPipeline + @KtMongoDsl override fun sample(size: Int): MongoAggregationPipeline = super.sample(size) as MongoAggregationPipeline diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Match.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Match.kt index c6aaffa6..23958437 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Match.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Match.kt @@ -21,7 +21,9 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.tree.AbstractBsonNode @@ -55,6 +57,46 @@ interface HasMatch : Pipeline { ): Pipeline = withStage(MatchStage(FilterQuery(context).apply(filter), context)) + /** + * Filters documents based on a specific [filter], written using [aggregation syntax][AggregationOperators]. + * + * Matched documents are passed to the next pipeline stage. + * + * This method is a helper to combine the [match] stage with the [expr][FilterQuery.expr] operator. + * + * ### Example + * + * Find all incoherent users: users modified before they were created. + * ```kotlin + * class User( + * val _id: ObjectId, + * val creationDate: Instant, + * val modificationDate: Instant, + * ) + * + * val incoherent = users.aggregate() + * .matchExpr { User::modificationDate lt User::creationDate } + * .toList() + * ``` + * + * The above query is syntax sugar for this identical query: + * ```kotlin + * val incoherent = users.aggregate() + * .match { + * expr { User::modificationDate lt User::creationDate } + * } + * .toList() + * ``` + * + * @see match The `$match` stage. + * @see FilterQuery.expr The `$expr` operator, allowing to use aggregation syntax in a filter. + */ + @KtMongoDsl + fun matchExpr( + filter: AggregationOperators.() -> Value, + ): Pipeline = + match { expr(filter) } + } private class MatchStage( diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt index 1bb31a82..b80f4180 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt @@ -24,7 +24,9 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.tree.AbstractBsonNode @@ -58,6 +60,46 @@ interface HasMatch : Pipeline { ): Pipeline = withStage(MatchStage(FilterQuery(context).apply(filter), context)) + /** + * Filters documents based on a specific [filter], written using [aggregation syntax][AggregationOperators]. + * + * Matched documents are passed to the next pipeline stage. + * + * This method is a helper to combine the [match] stage with the [expr][FilterQuery.expr] operator. + * + * ### Example + * + * Find all incoherent users: users modified before they were created. + * ```kotlin + * class User( + * val _id: ObjectId, + * val creationDate: Instant, + * val modificationDate: Instant, + * ) + * + * val incoherent = users.aggregate() + * .matchExpr { User::modificationDate lt User::creationDate } + * .toList() + * ``` + * + * The above query is syntax sugar for this identical query: + * ```kotlin + * val incoherent = users.aggregate() + * .match { + * expr { User::modificationDate lt User::creationDate } + * } + * .toList() + * ``` + * + * @see match The `$match` stage. + * @see FilterQuery.expr The `$expr` operator, allowing to use aggregation syntax in a filter. + */ + @KtMongoDsl + fun matchExpr( + filter: AggregationOperators.() -> Value, + ): Pipeline = + match { expr(filter) } + } private class MatchStage( -- 2.51.2 From e464c8d0e3f4e722106918a38abd13949067f94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 20 Jun 2026 18:27:52 +0200 Subject: [PATCH 2/4] perf(dsl): Avoid List copy when accessing the PipelineChainLink elements --- dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt | 5 +++-- dsl/src/commonMain/kotlin/aggregation/Pipeline.kt | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt index 4de25686..cdd7ee81 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -216,7 +216,7 @@ class PipelineChainLink internal constructor( */ @LowLevelApi fun toList(): List = - hierarchyReversed().toList().reversed() + hierarchyReversed().toList().asReversed() /** * Converts this chain in a list of BSON documents, each representing a stage. @@ -227,7 +227,8 @@ class PipelineChainLink internal constructor( fun toBsonList(): List = hierarchyReversed() .map { context.buildDocument { it.writeTo(this) } } - .toList().reversed() + .toList() + .asReversed() /** * Equivalent to [Pipeline.writeTo]. diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index 7862925f..f074fda2 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -219,7 +219,7 @@ class PipelineChainLink internal constructor( */ @LowLevelApi fun toList(): List = - hierarchyReversed().toList().reversed() + hierarchyReversed().toList().asReversed() /** * Converts this chain in a list of BSON documents, each representing a stage. @@ -230,7 +230,8 @@ class PipelineChainLink internal constructor( fun toBsonList(): List = hierarchyReversed() .map { context.buildDocument { it.writeTo(this) } } - .toList().reversed() + .toList() + .asReversed() /** * Equivalent to [Pipeline.writeTo]. -- 2.51.2 From 06279bd0f64982fb2dc04e3f8fc71cbda4d5e321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 20 Jun 2026 18:28:21 +0200 Subject: [PATCH 3/4] feat(dsl): Add PipelineChainLink.isEmpty --- .../src/commonMain/kotlin/aggregation/Pipeline.kt | 12 ++++++++++++ dsl/src/commonMain/kotlin/aggregation/Pipeline.kt | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt index cdd7ee81..4f159267 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -192,6 +192,18 @@ class PipelineChainLink internal constructor( return PipelineChainLink(context, this, simplified) } + /** + * Semantically equivalent to `chain.toList().isEmpty()`, but faster. + */ + fun isEmpty(): Boolean = + current == null + + /** + * Semantically equivalent to `chain.toList().isNotEmpty()`, but faster. + */ + fun isNotEmpty(): Boolean = + !isEmpty() + /** * Iterates through this chain. * diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index f074fda2..2a0e1d0a 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -195,6 +195,18 @@ class PipelineChainLink internal constructor( return PipelineChainLink(context, this, simplified) } + /** + * Semantically equivalent to `chain.toList().isEmpty()`, but faster. + */ + fun isEmpty(): Boolean = + current == null + + /** + * Semantically equivalent to `chain.toList().isNotEmpty()`, but faster. + */ + fun isNotEmpty(): Boolean = + !isEmpty() + /** * Iterates through this chain. * -- 2.51.2 From c29fc188e5d2a58c0f37044d40c6823be60bedf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 20 Jun 2026 19:16:52 +0200 Subject: [PATCH 4/4] feat(dsl): Allow direct nested field accessing in arrays in projections --- .../kotlin/aggregation/stages/Project.kt | 106 +++++++++++++++++- .../kotlin/aggregation/stages/Project.kt | 106 +++++++++++++++++- 2 files changed, 206 insertions(+), 6 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt index df4c16c2..df4b340d 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -24,13 +24,12 @@ import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Pipeline import opensavvy.ktmongo.dsl.aggregation.Value -import opensavvy.ktmongo.dsl.path.Field -import opensavvy.ktmongo.dsl.path.FieldDsl -import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.path.* import opensavvy.ktmongo.dsl.tree.AbstractBsonNode import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode import opensavvy.ktmongo.dsl.tree.BsonNode import opensavvy.ktmongo.dsl.tree.CompoundBsonNode +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 /** @@ -207,6 +206,107 @@ interface ProjectStageOperators : CompoundBsonNode, AggregationO include(field.field) } + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divFieldArrayIntoField") + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + operator fun Field>.div(child: Field): Field = + FieldImpl(this.path / child.path) + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divFieldArrayIntoProperty") + operator fun Field>.div(child: KProperty1): Field = + this / child.field + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divPropertyArrayIntoField") + operator fun KProperty1>.div(child: Field): Field = + this.field / child + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divPropertyArrayIntoProperty") + operator fun KProperty1>.div(child: KProperty1): Field = + this.field / child + } private class ProjectStageBsonNode( diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt index 19240cc6..9beff71c 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -27,13 +27,12 @@ import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Pipeline import opensavvy.ktmongo.dsl.aggregation.Value -import opensavvy.ktmongo.dsl.path.Field -import opensavvy.ktmongo.dsl.path.FieldDsl -import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.path.* import opensavvy.ktmongo.dsl.tree.AbstractBsonNode import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode import opensavvy.ktmongo.dsl.tree.BsonNode import opensavvy.ktmongo.dsl.tree.CompoundBsonNode +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 /** @@ -210,6 +209,107 @@ interface ProjectStageOperators : CompoundBsonNode, AggregationO include(field.field) } + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divFieldArrayIntoField") + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + operator fun Field>.div(child: Field): Field = + FieldImpl(this.path / child.path) + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divFieldArrayIntoProperty") + operator fun Field>.div(child: KProperty1): Field = + this / child.field + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divPropertyArrayIntoField") + operator fun KProperty1>.div(child: Field): Field = + this.field / child + + /** + * Refers to [child] as a nested field of the current collection field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profiles: List, + * ) + * + * class Profile( + * val name: String, + * ) + * + * // Refer to the name + * println(User::profiles / Profile::name) + * // → `profiles.name` + * ``` + */ + @Suppress("INAPPLICABLE_JVM_NAME") + @JvmName("divPropertyArrayIntoProperty") + operator fun KProperty1>.div(child: KProperty1): Field = + this.field / child + } private class ProjectStageBsonNode( -- 2.51.2