diff --git a/bson/src/commonTest/kotlin/BsonWriterTest.kt b/bson/src/commonTest/kotlin/BsonWriterTest.kt index 352532b3..22ecbfa9 100644 --- a/bson/src/commonTest/kotlin/BsonWriterTest.kt +++ b/bson/src/commonTest/kotlin/BsonWriterTest.kt @@ -19,7 +19,6 @@ package opensavvy.ktmongo.bson import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.prepared.suite.SuiteDsl -import kotlin.test.assertEquals @OptIn(LowLevelApi::class) @Suppress("DEPRECATION") @@ -29,7 +28,7 @@ fun SuiteDsl.writerTests() = suite("BsonPrimitiveWriter") { val result = buildBsonDocument { writeInt32("foo", 42) } - assertEquals("""{"foo": 42}""", result.toString()) + check(result.toString() == """{"foo": 42}""") } test("More complex example") { @@ -50,6 +49,11 @@ fun SuiteDsl.writerTests() = suite("BsonPrimitiveWriter") { val ref = "\$ref" val id = "\$id" val oid = "\$oid" - assertEquals("""{"user": {"$ref": "myproject.users", "$id": {"$oid": "507f1f77bcf86cd799439011"}}, "age": 18, "isAlive": true, "children": [{"name": "Paul"}, {"name": "Alice"}]}""", result.toString()) + check(result.toString() == """{"user": {"$ref": "myproject.users", "$id": {"$oid": "507f1f77bcf86cd799439011"}}, "age": 18, "isAlive": true, "children": [{"name": "Paul"}, {"name": "Alice"}]}""") + } + + test("An empty document") { + val result = buildBsonDocument {} + check(result.toString() == """{}""") } } -- 2.51.2 From 9d0cbea3e3c564132a0edf62a887de0b890d03a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 29 Nov 2024 23:55:25 +0100 Subject: [PATCH 02/15] fix(bson): Fix BsonArray.toString() returning invalid JSON --- bson/src/commonTest/kotlin/BsonWriterTest.kt | 18 ++++++++++++++++++ bson/src/jvmMain/kotlin/Bson.jvm.kt | 16 +++++++++++++++- bson/src/jvmMain/kotlin/BsonWriter.jvm.kt | 10 ++++++---- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/bson/src/commonTest/kotlin/BsonWriterTest.kt b/bson/src/commonTest/kotlin/BsonWriterTest.kt index 22ecbfa9..fa01bd13 100644 --- a/bson/src/commonTest/kotlin/BsonWriterTest.kt +++ b/bson/src/commonTest/kotlin/BsonWriterTest.kt @@ -56,4 +56,22 @@ fun SuiteDsl.writerTests() = suite("BsonPrimitiveWriter") { val result = buildBsonDocument {} check(result.toString() == """{}""") } + + test("An empty array") { + val result = buildBsonArray {} + check(result.toString() == """[]""") + } + + test("An array with multiple elements") { + val result = buildBsonArray { + writeInt32(123) + writeBoolean(false) + writeDocument { + writeString("name", "Paul") + writeInt32("age", 18) + } + } + + check(result.toString() == """[123, false, {"name": "Paul", "age": 18}]""") + } } diff --git a/bson/src/jvmMain/kotlin/Bson.jvm.kt b/bson/src/jvmMain/kotlin/Bson.jvm.kt index 6227ee4b..93658036 100644 --- a/bson/src/jvmMain/kotlin/Bson.jvm.kt +++ b/bson/src/jvmMain/kotlin/Bson.jvm.kt @@ -21,4 +21,18 @@ import org.bson.BsonDocument actual typealias Bson = BsonDocument -actual typealias BsonArray = BsonArray +actual class BsonArray(val raw: BsonArray) { + + actual override fun toString(): String { + // Yes, this is very ugly, and probably inefficient. + // The Java library doesn't provide a way to serialize arrays to JSON. + // https://www.mongodb.com/community/forums/t/how-to-convert-a-single-bsonvalue-such-as-bsonarray-to-json-in-the-java-bson-library + + val document = BsonDocument("a", raw).toJson() + + return document.substring( + document.indexOf('['), + document.lastIndexOf(']') + 1 + ).trim() + } +} diff --git a/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt b/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt index 5261562f..440a8ad7 100644 --- a/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt +++ b/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt @@ -20,6 +20,7 @@ import opensavvy.ktmongo.bson.types.Decimal128 import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.* +import org.bson.BsonArray import org.bson.codecs.Encoder import org.bson.codecs.EncoderContext @@ -332,7 +333,7 @@ private class JavaRootArrayWriter( @LowLevelApi override fun writeArray(block: BsonValueWriter.() -> Unit) { - array.add(buildBsonArray(block)) + array.add(buildBsonArray(block).raw) } @LowLevelApi @@ -349,10 +350,11 @@ private class JavaRootArrayWriter( } @LowLevelApi -actual fun buildBsonArray(block: BsonValueWriter.() -> Unit): BsonArray { - val array = BsonArray() +actual fun buildBsonArray(block: BsonValueWriter.() -> Unit): opensavvy.ktmongo.bson.BsonArray { + val nativeArray = BsonArray() + val array = opensavvy.ktmongo.bson.BsonArray(nativeArray) - JavaRootArrayWriter(array).block() + JavaRootArrayWriter(nativeArray).block() return array } -- 2.51.2 From 44ea7bf33066da5d0cb750566f1bea1611cc9b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 Nov 2024 01:30:02 +0100 Subject: [PATCH 03/15] feat(dsl): Create Pipeline and the $match stage --- .../commonMain/kotlin/aggregation/Pipeline.kt | 190 ++++++++++++++++++ .../kotlin/aggregation/PipelineType.kt | 80 ++++++++ .../kotlin/aggregation/stages/Match.kt | 69 +++++++ .../aggregation/AggregationStageTest.kt | 64 ++++++ 4 files changed, 403 insertions(+) create mode 100644 dsl/src/commonMain/kotlin/aggregation/Pipeline.kt create mode 100644 dsl/src/commonMain/kotlin/aggregation/PipelineType.kt create mode 100644 dsl/src/commonMain/kotlin/aggregation/stages/Match.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt new file mode 100644 index 00000000..676e8a25 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.bson.buildBsonArray +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.stages.match +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression +import opensavvy.ktmongo.dsl.expr.common.CompoundExpression +import opensavvy.ktmongo.dsl.expr.common.Expression + +/** + * A multi-stage pipeline that performs complex operations on MongoDB. + * + * Similar to [Sequence] and [Flow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/), + * but executed by MongoDB itself. + * + * MongoDB has different types of pipelines with different available operators. + * Using this library, all pipeline types are modelled as instances of this class, but with a different [Type] + * type parameter. + * + * Instances of this class are immutable. + * + * ### Stages + * + * A pipeline is composed of _stages_, each of which transforms the data in some way. + * For example, some stages filter information out, some stages add more information, some stages combine documents, + * some stages extract information from elsewhere, etc. + * + * Each stage is defined as an extension function on this class. + * Note that as mentioned, not all stages are available for all pipeline types. + * The following stages are available: + * - [`$match`][match] + * + * If you can't find a stage you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/7). + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/aggregation/) + * + * @param Type The type of this pipeline, which determines what stages are available. See [PipelineType] for more information. + * @param Output The type of document that this pipeline results in. The main way to change this type is to use projection stages. + */ +class Pipeline private constructor( + + /** + * The context used to generate this pipeline. + * + * Can be used when creating child expressions. + */ + @property:LowLevelApi val context: BsonContext, + + private val type: Type, + + private val previous: Pipeline<*, *>?, + + private val current: Expression?, +) { + + /** + * Constructs a new, empty pipeline, of the given [type]. + * + * This method should usually be called by the driver itself and not by end-users. + */ + @LowLevelApi + constructor( + context: BsonContext, + type: Type, + ) : this(context, type, null, null) + + /** + * Creates a new pipeline that expands on the current one by adding [stage]. + * + * This method is analogous to [CompoundExpression.accept], with the main difference that the latter mutates the + * current expression, whereas this method returns a new pipeline on which the stage is applied + * (because pipelines are immutable). + * + * **End-users should not need to call this function.** + * All implemented stages provide an extension function on the [Pipeline] type. + * This function is provided for cases in which you need a stage that is not yet provided by the library. + * If that is your situation, start by reading [AbstractExpression] and [AbstractCompoundExpression]. + * If you want to proceed and implement your own stage, consider getting in touch with the maintainers of the + * library so it can be shared to all users. + * + * The provided [stage] must validate the entire contract of [Expression]. Additionally, it should always emit + * the name of the stage first. For example, this is a valid stage: + * ```json + * "$match": { + * "name": "Bob" + * } + * ``` + * but this isn't: + * ```json + * "name": "Bob" + * ``` + * because it doesn't start a stage name. + * + * Similarly, this isn't a valid stage, because it declares two different stage names: + * ```json + * "$match": { + * "name": "Bob" + * }, + * "$set": { + * "foo": "bar" + * } + * ``` + * + * @see reinterpret Change the output type of this pipeline. + */ + @DangerousMongoApi + @LowLevelApi + fun withStage(stage: Expression): Pipeline { + val simplified = stage.simplify() ?: return this + + simplified.freeze() + + return Pipeline(context, type, this, simplified) + } + + /** + * Changes the type of the returned document, with no type-safety. + * + * **End-users should not need to call this function.** + * This function is provided to allow stages to change the return document. + * No type verifications are made, it is solely the responsibility of the caller to ensure that the declared return + * type corresponds to the reality. + * + * @see withStage Add a new stage to this pipeline. + */ + @Suppress("UNCHECKED_CAST") + @DangerousMongoApi + @LowLevelApi + fun reinterpret(): Pipeline = this as Pipeline + + private fun hierarchyReversed(): Sequence = sequence { + var cursor: Pipeline<*, *>? = this@Pipeline + + while (cursor != null) { + if (cursor.current != null) yield(cursor.current) + + cursor = cursor.previous + } + } + + /** + * Writes the entire pipeline into [writer]. + * + * This function is similar to [Expression.writeTo], with the difference that expressions generate documents, + * and pipelines generate arrays. + * + * Using this method will thus write an array containing the different stages. + */ + @LowLevelApi + fun writeTo(writer: BsonValueWriter) = with(writer) { + val stages = hierarchyReversed().toList().reversed() + + for (stage in stages) { + writeDocument { + stage.writeTo(this) + } + } + } + + /** + * JSON representation of this pipeline. + */ + @OptIn(LowLevelApi::class) + override fun toString(): String = buildBsonArray { + writeTo(this) + }.toString() + +} diff --git a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt new file mode 100644 index 00000000..2e22a10d --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.aggregation.stages.HasMatch + +/** + * The super-type for all [pipeline][Pipeline] features. + * + * A pipeline feature is a marker interface placed on [pipeline types][PipelineType] to declare the availability + * of one or more stages for that type. + * + * Therefore, all inheritors of this interface are the various kinds of stages. + * Each of their inheritors is a type of pipeline in which that feature can be used. + */ +@DangerousMongoApi +interface PipelineFeature + +/** + * The different [pipeline][Pipeline] types. + * + * MongoDB pipelines are represented by the [Pipeline] class. + * However, not all stages are available in all pipelines. + * + * Instances of this interface describe a pipeline type, meaning a given usage of MongoDB pipelines. + * Instances describe which [features][PipelineFeature] are available in their context, by inheriting from them. + */ +// Not sealed, because we want to allow users to create their own pipeline types +// However, users cannot edit existing pipeline types, to avoid compatibility issues +interface PipelineType { + + /** + * Marker type for pipeline features that are available in aggregation pipelines. + */ + object Aggregate : PipelineType, + HasMatch + + /** + * Marker type for pipeline features that are available in update operations using a pipeline. + */ + object Update : PipelineType +} + +/** + * An aggregation pipeline. + * + * Aggregation pipelines read data from one or more collections and transform it in a manner of ways. + * Finally, the data can be sent to the server, or written to another collection. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/core/aggregation-pipeline/) + */ +typealias AggregationPipeline = Pipeline + +/** + * An update pipeline. + * + * Update pipelines allow more complex updates without resorting to an entire [AggregationPipeline]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/#update-with-aggregation-pipeline) + */ +typealias UpdatePipeline = Pipeline diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt new file mode 100644 index 00000000..ed22b9d6 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Match.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.PipelineFeature +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.expr.FilterExpression +import opensavvy.ktmongo.dsl.expr.FilterOperators +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression + +/** + * Marks that a pipeline is able to use [match]. + */ +@OptIn(DangerousMongoApi::class) +interface HasMatch : PipelineFeature + +/** + * Filters documents based on a specified [filter]. + * + * Matched documents are passed to the next pipeline stage. + * + * ### Pipeline optimization + * + * Place the `match` call as early in the pipeline as possible. + * Because `match` limits the total number of elements being processed, earlier `match` operations + * minimize the amount of processing down the pipe. + * + * If you place a `match` at the very beginning of a pipeline, the query can take advantage of indexes. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/match/) + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.match( + filter: FilterOperators.() -> Unit +): Pipeline where Type : PipelineType, Type : HasMatch = + withStage(MatchStage(FilterExpression(context).apply(filter), context)) + +private class MatchStage( + val expression: FilterExpression<*>, + context: BsonContext, +) : AbstractExpression(context) { + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument("\$match") { + expression.writeTo(this) + } + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt new file mode 100644 index 00000000..47431daa --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.stages.match +import opensavvy.ktmongo.dsl.expr.filter.eq +import opensavvy.ktmongo.dsl.expr.shouldBeBson +import opensavvy.ktmongo.dsl.expr.testContext +import opensavvy.prepared.runner.kotest.PreparedSpec + +val match = "\$match" +val set = "\$set" + +@OptIn(LowLevelApi::class) +class AggregationStageTest : PreparedSpec({ + + class Target( + val foo: String, + val bar: Int, + ) + + fun aggregate(type: Type) = + Pipeline(testContext(), type) + + infix fun Pipeline<*, *>.shouldBeBson(expected: String) { + this.toString() shouldBeBson expected + } + + test("Empty pipeline") { + aggregate(PipelineType.Aggregate) shouldBeBson "[]" + } + + test("Single-stage pipeline") { + aggregate(PipelineType.Aggregate) + .match { Target::foo eq "Bob" } + .shouldBeBson(""" + [ + { + "$match": { + "foo": { + "$eq": "Bob" + } + } + } + ] + """.trimIndent()) + } + +}) -- 2.51.2 From fc34f2b5fb4118b43b1ac18ad6af1df6620f6124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 3 Dec 2024 23:33:07 +0100 Subject: [PATCH 04/15] feat(dsl): Implement the $sample stage --- .../kotlin/aggregation/PipelineType.kt | 4 +- .../kotlin/aggregation/stages/Sample.kt | 66 +++++++++++++++++++ .../aggregation/AggregationStageTest.kt | 16 +++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt index 2e22a10d..148fffeb 100644 --- a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt +++ b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.aggregation.stages.HasMatch +import opensavvy.ktmongo.dsl.aggregation.stages.HasSample /** * The super-type for all [pipeline][Pipeline] features. @@ -48,7 +49,8 @@ interface PipelineType { * Marker type for pipeline features that are available in aggregation pipelines. */ object Aggregate : PipelineType, - HasMatch + HasMatch, + HasSample /** * Marker type for pipeline features that are available in update operations using a pipeline. diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt new file mode 100644 index 00000000..13dc242e --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.PipelineFeature +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression + +/** + * Marks that a pipeline is able to [sample]. + */ +@OptIn(DangerousMongoApi::class) +interface HasSample : PipelineFeature + +/** + * Randomly selects [size] documents. + * + * ### Pipeline optimizations + * + * MongoDB is able to perform sampling more efficiently if it is the first stage of the pipeline and [size] is less + * than 5% of the collection size. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/) + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.sample(size: Int): Pipeline + where Type : PipelineType, Type : HasSample = + withStage(SampleStage(size, context)) + +private class SampleStage( + val size: Int, + context: BsonContext, +) : AbstractExpression(context) { + + init { + require(size >= 1) { "The sample size should be at least 1. Found: $size" } + } + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument("\$sample") { + writeInt32("size", size) + } + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt index 47431daa..ee285d60 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt @@ -18,12 +18,14 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.stages.match +import opensavvy.ktmongo.dsl.aggregation.stages.sample import opensavvy.ktmongo.dsl.expr.filter.eq import opensavvy.ktmongo.dsl.expr.shouldBeBson import opensavvy.ktmongo.dsl.expr.testContext import opensavvy.prepared.runner.kotest.PreparedSpec val match = "\$match" +val sample = "\$sample" val set = "\$set" @OptIn(LowLevelApi::class) @@ -61,4 +63,18 @@ class AggregationStageTest : PreparedSpec({ """.trimIndent()) } + test(sample) { + aggregate(PipelineType.Aggregate) + .sample(5) + .shouldBeBson(""" + [ + { + "$sample": { + "size": 5 + } + } + ] + """.trimIndent()) + } + }) -- 2.51.2 From 6ae6c1c96af18dcf6e48b3f47d5ad184fe616447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 3 Dec 2024 23:48:55 +0100 Subject: [PATCH 05/15] test(dsl): Split the aggregation tests into a file per stage --- .../aggregation/AggregationStageTest.kt | 32 +------------ .../aggregation/AggregationTestUtils.kt | 33 +++++++++++++ .../kotlin/aggregation/stages/MatchTest.kt | 48 +++++++++++++++++++ .../kotlin/aggregation/stages/SampleTest.kt | 41 ++++++++++++++++ 4 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/stages/MatchTest.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt index ee285d60..9f29daac 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationStageTest.kt @@ -18,16 +18,9 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.stages.match -import opensavvy.ktmongo.dsl.aggregation.stages.sample import opensavvy.ktmongo.dsl.expr.filter.eq -import opensavvy.ktmongo.dsl.expr.shouldBeBson -import opensavvy.ktmongo.dsl.expr.testContext import opensavvy.prepared.runner.kotest.PreparedSpec -val match = "\$match" -val sample = "\$sample" -val set = "\$set" - @OptIn(LowLevelApi::class) class AggregationStageTest : PreparedSpec({ @@ -36,19 +29,12 @@ class AggregationStageTest : PreparedSpec({ val bar: Int, ) - fun aggregate(type: Type) = - Pipeline(testContext(), type) - - infix fun Pipeline<*, *>.shouldBeBson(expected: String) { - this.toString() shouldBeBson expected - } - test("Empty pipeline") { - aggregate(PipelineType.Aggregate) shouldBeBson "[]" + aggregate<_, Target>(PipelineType.Aggregate) shouldBeBson "[]" } test("Single-stage pipeline") { - aggregate(PipelineType.Aggregate) + aggregate<_, Target>(PipelineType.Aggregate) .match { Target::foo eq "Bob" } .shouldBeBson(""" [ @@ -63,18 +49,4 @@ class AggregationStageTest : PreparedSpec({ """.trimIndent()) } - test(sample) { - aggregate(PipelineType.Aggregate) - .sample(5) - .shouldBeBson(""" - [ - { - "$sample": { - "size": 5 - } - } - ] - """.trimIndent()) - } - }) diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt new file mode 100644 index 00000000..04433bb7 --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.shouldBeBson +import opensavvy.ktmongo.dsl.expr.testContext + +val match = "\$match" +val sample = "\$sample" +val set = "\$set" + +@OptIn(LowLevelApi::class) +fun aggregate(type: Type) = + Pipeline(testContext(), type) + +infix fun Pipeline<*, *>.shouldBeBson(expected: String) { + this.toString() shouldBeBson expected +} diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/MatchTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/MatchTest.kt new file mode 100644 index 00000000..2ac95ac6 --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/stages/MatchTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.aggregation.aggregate +import opensavvy.ktmongo.dsl.aggregation.match +import opensavvy.ktmongo.dsl.aggregation.shouldBeBson +import opensavvy.ktmongo.dsl.expr.filter.eq +import opensavvy.prepared.runner.kotest.PreparedSpec + +class MatchTest : PreparedSpec({ + + class Target( + val foo: String, + ) + + test("Simple $match") { + aggregate<_, Target>(PipelineType.Aggregate) + .match { Target::foo eq "Bob" } + .shouldBeBson(""" + [ + { + "$match": { + "foo": { + "$eq": "Bob" + } + } + } + ] + """.trimIndent()) + } + +}) diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt new file mode 100644 index 00000000..6aecffab --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.aggregation.aggregate +import opensavvy.ktmongo.dsl.aggregation.sample +import opensavvy.ktmongo.dsl.aggregation.shouldBeBson +import opensavvy.prepared.runner.kotest.PreparedSpec + +class SampleTest : PreparedSpec({ + + test(sample) { + aggregate<_, Nothing>(PipelineType.Aggregate) + .sample(5) + .shouldBeBson(""" + [ + { + "$sample": { + "size": 5 + } + } + ] + """.trimIndent()) + } + +}) -- 2.51.2 From 308e542f37a0db3398911ec086c18baf9e05d0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 3 Dec 2024 23:53:52 +0100 Subject: [PATCH 06/15] test(dsl): Test $sample with illegal input --- .../kotlin/aggregation/stages/SampleTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt index 6aecffab..01a99ade 100644 --- a/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/stages/SampleTest.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.dsl.aggregation.stages +import io.kotest.assertions.throwables.shouldThrow import opensavvy.ktmongo.dsl.aggregation.PipelineType import opensavvy.ktmongo.dsl.aggregation.aggregate import opensavvy.ktmongo.dsl.aggregation.sample @@ -38,4 +39,18 @@ class SampleTest : PreparedSpec({ """.trimIndent()) } + test("Sample of 0 elements is forbidden") { + shouldThrow { + aggregate<_, Nothing>(PipelineType.Aggregate) + .sample(0) + } + } + + test("Sample of less than 0 elements is forbidden") { + shouldThrow { + aggregate<_, Nothing>(PipelineType.Aggregate) + .sample(-1) + } + } + }) -- 2.51.2 From 3239a479f8da41c7a8973948999afe51339d0f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 3 Dec 2024 23:54:40 +0100 Subject: [PATCH 07/15] feat(dsl): Implement the $skip stage --- .../kotlin/aggregation/PipelineType.kt | 4 +- .../kotlin/aggregation/stages/Skip.kt | 94 +++++++++++++++++++ .../aggregation/AggregationTestUtils.kt | 1 + .../kotlin/aggregation/stages/SkipTest.kt | 56 +++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/stages/SkipTest.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt index 148fffeb..d6dbeb0c 100644 --- a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt +++ b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt @@ -19,6 +19,7 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.aggregation.stages.HasMatch import opensavvy.ktmongo.dsl.aggregation.stages.HasSample +import opensavvy.ktmongo.dsl.aggregation.stages.HasSkip /** * The super-type for all [pipeline][Pipeline] features. @@ -50,7 +51,8 @@ interface PipelineType { */ object Aggregate : PipelineType, HasMatch, - HasSample + HasSample, + HasSkip /** * Marker type for pipeline features that are available in update operations using a pipeline. diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt new file mode 100644 index 00000000..917b0d13 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.PipelineFeature +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression + +/** + * Marks that a pipeline is able to [skip]. + */ +@OptIn(DangerousMongoApi::class) +interface HasSkip : PipelineFeature + +/** + * Skips over the specified [amount] of documents that pass into the stage, + * and passes the remaining documents to the next stage. + * + * ### Using skip with sorted results + * + * Sort results aren't stable with `skip`: if multiple documents are identical, their relative order is undefined + * and may change from one execution to the next. + * + * To avoid surprises, include a unique field in your sort, for example `_id`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/skip/) + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.skip(amount: Long): Pipeline + where Type : PipelineType, Type : HasSkip = + withStage(SkipStage(amount, context)) + +/** + * Skips over the specified [amount] of documents that pass into the stage, + * and passes the remaining documents to the next stage. + * + * ### Using skip with sorted results + * + * Sort results aren't stable with `skip`: if multiple documents are identical, their relative order is undefined + * and may change from one execution to the next. + * + * To avoid surprises, include a unique field in your sort, for example `_id`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/skip/) + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.skip(amount: Int): Pipeline + where Type : PipelineType, Type : HasSkip = + skip(amount.toLong()) + +private class SkipStage( + val amount: Long, + context: BsonContext, +) : AbstractExpression(context) { + + init { + require(amount >= 0) { "At least 0 elements should be skipped. Found: $amount" } + } + + @LowLevelApi + override fun simplify(): AbstractExpression? = + when { + amount == 0L -> null + else -> this + } + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeInt64("\$skip", amount) + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt index 04433bb7..86683ed4 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt @@ -22,6 +22,7 @@ import opensavvy.ktmongo.dsl.expr.testContext val match = "\$match" val sample = "\$sample" +val skip = "\$skip" val set = "\$set" @OptIn(LowLevelApi::class) diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/SkipTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/SkipTest.kt new file mode 100644 index 00000000..272eeb4a --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/stages/SkipTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import io.kotest.assertions.throwables.shouldThrow +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.aggregation.aggregate +import opensavvy.ktmongo.dsl.aggregation.shouldBeBson +import opensavvy.ktmongo.dsl.aggregation.skip +import opensavvy.prepared.runner.kotest.PreparedSpec + +class SkipTest : PreparedSpec({ + + test("Skip 5 elements") { + aggregate<_, Nothing>(PipelineType.Aggregate) + .skip(5) + .shouldBeBson(""" + [ + { + "$skip": 5 + } + ] + """.trimIndent()) + } + + test("Skip of 0 elements should no-op") { + aggregate<_, Nothing>(PipelineType.Aggregate) + .skip(0) + .shouldBeBson(""" + [ + ] + """.trimIndent()) + } + + test("Skip of less than 0 elements is forbidden") { + shouldThrow { + aggregate<_, Nothing>(PipelineType.Aggregate) + .skip(-1) + } + } + +}) -- 2.51.2 From 4cc8472279e696917cc24ff07750c1bf3a32a850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Dec 2024 00:09:39 +0100 Subject: [PATCH 08/15] feat(dsl): Implement the $limit stage --- .../kotlin/aggregation/PipelineType.kt | 2 + .../kotlin/aggregation/stages/Limit.kt | 91 +++++++++++++++++++ .../kotlin/aggregation/stages/Sample.kt | 2 + .../kotlin/aggregation/stages/Skip.kt | 4 + .../aggregation/AggregationTestUtils.kt | 1 + .../kotlin/aggregation/stages/LimitTest.kt | 56 ++++++++++++ 6 files changed, 156 insertions(+) create mode 100644 dsl/src/commonMain/kotlin/aggregation/stages/Limit.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/stages/LimitTest.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt index d6dbeb0c..42e1e245 100644 --- a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt +++ b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt @@ -17,6 +17,7 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.aggregation.stages.HasLimit import opensavvy.ktmongo.dsl.aggregation.stages.HasMatch import opensavvy.ktmongo.dsl.aggregation.stages.HasSample import opensavvy.ktmongo.dsl.aggregation.stages.HasSkip @@ -50,6 +51,7 @@ interface PipelineType { * Marker type for pipeline features that are available in aggregation pipelines. */ object Aggregate : PipelineType, + HasLimit, HasMatch, HasSample, HasSkip diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Limit.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Limit.kt new file mode 100644 index 00000000..af8459d9 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Limit.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Pipeline +import opensavvy.ktmongo.dsl.aggregation.PipelineFeature +import opensavvy.ktmongo.dsl.aggregation.PipelineType +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression + +/** + * Marks that a pipeline is able to [limit]. + */ +@OptIn(DangerousMongoApi::class) +interface HasLimit : PipelineFeature + +/** + * Limits the number of elements passed to the next stage to [amount]. + * + * ### Using limit with sorted results + * + * Sort results aren't stable with `limit`: if multiple documents are identical, their relative order is undefined + * and may change from one execution to the next. + * + * To avoid surprises, include a unique field in your sort, for example `_id`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/limit/) + * + * @see skip Skip over an amount of elements. + * @see sample Randomly limit the number of elements. + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.limit(amount: Long): Pipeline + where Type : PipelineType, Type : HasLimit = + withStage(LimitStage(amount, context)) + +/** + * Limits the number of elements passed to the next stage to [amount]. + * + * ### Using limit with sorted results + * + * Sort results aren't stable with `limit`: if multiple documents are identical, their relative order is undefined + * and may change from one execution to the next. + * + * To avoid surprises, include a unique field in your sort, for example `_id`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/limit/) + * + * @see skip Skip over an amount of elements. + * @see sample Randomly limit the number of elements. + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.limit(amount: Int): Pipeline + where Type : PipelineType, Type : HasLimit = + limit(amount.toLong()) + +private class LimitStage( + val amount: Long, + context: BsonContext, +) : AbstractExpression(context) { + + init { + require(amount >= 0) { "Negative limits are not allowed. Found: $amount" } + } + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeInt64("\$limit", amount) + } +} diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt index 13dc242e..7e49f784 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Sample.kt @@ -42,6 +42,8 @@ interface HasSample : PipelineFeature * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/) + * + * @see limit Selects the first elements found. */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) fun Pipeline.sample(size: Int): Pipeline diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt index 917b0d13..3b15b3fe 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Skip.kt @@ -45,6 +45,8 @@ interface HasSkip : PipelineFeature * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/skip/) + * + * @see limit Limit the number of elements. */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) fun Pipeline.skip(amount: Long): Pipeline @@ -65,6 +67,8 @@ fun Pipeline.skip(amount: Long): Pipeline * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/skip/) + * + * @see limit Limit the number of elements. */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) fun Pipeline.skip(amount: Int): Pipeline diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt index 86683ed4..cef93df1 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt @@ -20,6 +20,7 @@ import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.expr.shouldBeBson import opensavvy.ktmongo.dsl.expr.testContext +val limit = "\$limit" val match = "\$match" val sample = "\$sample" val skip = "\$skip" diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/LimitTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/LimitTest.kt new file mode 100644 index 00000000..0508ed33 --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/stages/LimitTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import io.kotest.assertions.throwables.shouldThrow +import opensavvy.ktmongo.dsl.aggregation.* +import opensavvy.prepared.runner.kotest.PreparedSpec + +class LimitTest : PreparedSpec({ + + test("Nominal $limit") { + aggregate<_, Nothing>(PipelineType.Aggregate) + .limit(5) + .shouldBeBson(""" + [ + { + "$limit": 5 + } + ] + """.trimIndent()) + } + + test("Limit of 0 is kept") { + aggregate<_, Nothing>(PipelineType.Aggregate) + .limit(0) + .shouldBeBson(""" + [ + { + "$limit": 0 + } + ] + """.trimIndent()) + } + + test("Limit less than 0 is forbidden") { + shouldThrow { + aggregate<_, Nothing>(PipelineType.Aggregate) + .limit(-1) + } + } + +}) -- 2.51.2 From bec086c1b0ba56598ac3615969ff6ed48c558ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 10 Dec 2024 21:45:24 +0100 Subject: [PATCH 09/15] feat(dsl): Create the aggregation Value --- .../commonMain/kotlin/aggregation/Value.kt | 144 ++++++++++++++++++ .../commonMain/kotlin/aggregation/ValueDsl.kt | 54 +++++++ .../kotlin/aggregation/ValueTest.kt | 67 ++++++++ 3 files changed, 265 insertions(+) create mode 100644 dsl/src/commonMain/kotlin/aggregation/Value.kt create mode 100644 dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/ValueTest.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/Value.kt b/dsl/src/commonMain/kotlin/aggregation/Value.kt new file mode 100644 index 00000000..efc8c145 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/Value.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.bson.buildBsonArray +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.common.Expression +import opensavvy.ktmongo.dsl.tree.Node +import opensavvy.ktmongo.dsl.tree.NodeImpl + +/** + * An intermediary value in an aggregation expression. + * + * Each implementation of this interface is a logical BSON node in our own intermediate representation. + * Each node knows how to [writeTo] itself into a BSON document. + * + * ### Difference with Expression + * + * This interface and its hierarchy mimic [Expression]. + * The main difference is the expected context: [Expression] represents an operator, which is stored as a BSON document + * and doesn't participate in any type hierarchy. + * Instead, [Value] is stored as a BSON value and its return type can be further embedded into more values. + * + * ### Security + * + * Implementing this interface allows injecting arbitrary BSON into a request. + * Be very careful not to make injections possible. + * + * ### Implementation notes + * + * Prefer implementing [AbstractValue] instead of implementing this interface directly. + * + * ### Debugging notes + * + * Use [toString] to view the JSON representation of this expression. + */ +interface Value : Node { + + /** + * The context used to generate this value. + */ + @LowLevelApi + val context: BsonContext + + /** + * Makes this value immutable. + * + * After this method has been called, the value can never be modified again. + * This ensures that values cannot change after they have been used within other values. + */ + @LowLevelApi + override fun freeze() + + /** + * Returns a simplified (but equivalent) value to the current value. + */ + @LowLevelApi + fun simplify(): Value + + /** + * Writes the result of [simplifying][simplify] this value into [writer]. + */ + @LowLevelApi + fun writeTo(writer: BsonValueWriter) + + /** + * JSON representation of this expression. + * + * Note that since this class represents a BSON _value_, a BSON libraries often only support _documents_, + * the actual value may be surrounded by some boilerplate (like an array or a useless value). + */ + override fun toString(): String +} + +abstract class AbstractValue private constructor( + @property:LowLevelApi override val context: BsonContext, + private val node: NodeImpl, +) : Node by node, Value { + + constructor(context: BsonContext) : this(context, NodeImpl()) + + /** + * `true` if [freeze] has been called. Can never become `false` again. + * + * If this value is `true`, this value should reject any attempt to mutate it. + * It is the responsibility of the implementor to satisfy this invariant. + */ + protected val frozen: Boolean + get() = node.frozen + + /** + * Called when the value should be written to a [writer]. + * + * Note that this function is only called on instances that have already passed through [simplify], + * so it is guaranteed that this value is fully simplified already. + */ + @LowLevelApi + protected abstract fun write(writer: BsonValueWriter) + + @LowLevelApi + override fun simplify(): AbstractValue = this + + @LowLevelApi + final override fun writeTo(writer: BsonValueWriter) { + this.simplify().write(writer) + } + + /** + * JSON representation of this expression. + * + * By default, simplifications are enabled. Set [simplified] to `false` to disable simplifications. + */ + @OptIn(LowLevelApi::class) + fun toString(simplified: Boolean): String { + val document = buildBsonArray { + if (simplified) + writeTo(this) + else + write(this) + } + + return document.toString() + } + + final override fun toString(): String = + toString(simplified = true) + +} diff --git a/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt new file mode 100644 index 00000000..1a6a8e64 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.FieldDsl +import kotlin.reflect.KProperty1 + +/** + * DSL to instantiate [aggregation values][Value], usually automatically added into scope by aggregation stages. + */ +interface ValueDsl : FieldDsl { + + @LowLevelApi + val context: BsonContext + + // TODO: document once we have a few more operators + @OptIn(LowLevelApi::class) + fun of(field: Field): Value = + FieldValue(field, context) + + // TODO: document once we have a few more operators + fun of(field: KProperty1): Value = + of(field.field) + +} + +private class FieldValue( + val field: Field, + context: BsonContext, +) : AbstractValue(context) { + + @LowLevelApi + override fun write(writer: BsonValueWriter) { + writer.writeString("$$field") + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt new file mode 100644 index 00000000..488ecbda --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.shouldBeBson +import opensavvy.ktmongo.dsl.expr.testContext +import opensavvy.prepared.runner.kotest.PreparedSpec + +@LowLevelApi +class ValueTest : PreparedSpec({ + + val dollar = "$" + + class ValueDslImpl : ValueDsl { + override val context: BsonContext = testContext() + } + + fun value(block: ValueDsl.() -> Value<*, *>) = + ValueDslImpl().block().toString() + + class Profile( + val age: Int, + ) + + class User( + val name: String, + val profile: Profile, + ) + + suite("Referring to fields with of") { + test("Referring to a top-level field") { + value { + of(User::name) + } shouldBeBson """ + [ + "${dollar}name" + ] + """.trimIndent() + } + + test("Referring to a second-level field") { + value { + of(User::profile / Profile::age) + } shouldBeBson """ + [ + "${dollar}profile.age" + ] + """.trimIndent() + } + } +}) -- 2.51.2 From 49e1d56455f7b3ab62005dac760da0565081cfc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 10 Dec 2024 21:58:33 +0100 Subject: [PATCH 10/15] feat(dsl): Implement $literal --- .../commonMain/kotlin/aggregation/ValueDsl.kt | 18 +++++++++++++ .../kotlin/aggregation/ValueTest.kt | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt index 1a6a8e64..5783c67d 100644 --- a/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt +++ b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt @@ -40,6 +40,11 @@ interface ValueDsl : FieldDsl { fun of(field: KProperty1): Value = of(field.field) + // TODO: document once we have a few more operators + @OptIn(LowLevelApi::class) + fun of(value: Result): Value = + LiteralValue(value, context) + } private class FieldValue( @@ -52,3 +57,16 @@ private class FieldValue( writer.writeString("$$field") } } + +private class LiteralValue( + val value: Any?, + context: BsonContext, +) : AbstractValue(context) { + + @LowLevelApi + override fun write(writer: BsonValueWriter) { + writer.writeDocument { + writeObjectSafe("\$literal", value, context) + } + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt index 488ecbda..9f6c20bc 100644 --- a/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt @@ -26,6 +26,7 @@ import opensavvy.prepared.runner.kotest.PreparedSpec class ValueTest : PreparedSpec({ val dollar = "$" + val literal = "\$literal" class ValueDslImpl : ValueDsl { override val context: BsonContext = testContext() @@ -64,4 +65,30 @@ class ValueTest : PreparedSpec({ """.trimIndent() } } + + suite("Embedding Kotlin values with of") { + test("Embedding a primitive integer") { + value { + of(5) + } shouldBeBson """ + [ + { + "$literal": 5 + } + ] + """.trimIndent() + } + + test("Embedding null") { + value { + of(null) + } shouldBeBson """ + [ + { + "$literal": null + } + ] + """.trimIndent() + } + } }) -- 2.51.2 From 22b9d97e371bbe4b40e279f4753c6efc67e8f34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 19 Jan 2025 20:08:48 +0100 Subject: [PATCH 11/15] feat(dsl): Implement $expr and aggregation binary comparison operators ($eq, $ne, $gt, $lt, $gte, $lte) --- .../commonMain/kotlin/aggregation/Value.kt | 22 ++- .../commonMain/kotlin/aggregation/ValueDsl.kt | 143 ++++++++++++++++-- .../operators/ComparisonValueOperators.kt | 143 ++++++++++++++++++ .../aggregation/operators/ValueOperators.kt | 34 +++++ .../kotlin/expr/FilterExpression.kt | 31 +++- .../commonMain/kotlin/expr/FilterOperators.kt | 35 +++++ .../kotlin/aggregation/ValueTest.kt | 32 +++- .../kotlin/expr/filter/ExprFilterTest.kt | 105 +++++++++++++ .../kotlin/expr/filter/FilterUtils.kt | 4 +- .../src/commonTest/kotlin/AggregationTests.kt | 45 ++++++ 10 files changed, 570 insertions(+), 24 deletions(-) create mode 100644 dsl/src/commonMain/kotlin/aggregation/operators/ComparisonValueOperators.kt create mode 100644 dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt create mode 100644 dsl/src/commonTest/kotlin/expr/filter/ExprFilterTest.kt create mode 100644 test/src/commonTest/kotlin/AggregationTests.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/Value.kt b/dsl/src/commonMain/kotlin/aggregation/Value.kt index efc8c145..376b67bd 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Value.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Value.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -17,9 +17,12 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.buildBsonArray import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.FilterOperators +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression import opensavvy.ktmongo.dsl.expr.common.Expression import opensavvy.ktmongo.dsl.tree.Node import opensavvy.ktmongo.dsl.tree.NodeImpl @@ -30,6 +33,10 @@ import opensavvy.ktmongo.dsl.tree.NodeImpl * Each implementation of this interface is a logical BSON node in our own intermediate representation. * Each node knows how to [writeTo] itself into a BSON document. * + * Instances of this interface are obtained by the end-user through the [ValueDsl] builder. + * Functions from KtMongo which expect aggregation values provide an instance of [ValueDsl] into scope automatically. + * For example, see [FilterOperators.expr]. + * * ### Difference with Expression * * This interface and its hierarchy mimic [Expression]. @@ -49,8 +56,10 @@ import opensavvy.ktmongo.dsl.tree.NodeImpl * ### Debugging notes * * Use [toString] to view the JSON representation of this expression. + * + * @see ValueDsl Builder for aggregation values. */ -interface Value : Node { +interface Value : Node { /** * The context used to generate this value. @@ -88,6 +97,15 @@ interface Value : Node { override fun toString(): String } +/** + * Utility implementation of [Value], which handles the [context], [toString] representation and [freezing][freeze]. + * + * ### Implementing a new operator + * + * Implementing class is identical in concept to implementing [AbstractExpression]. + * The main difference is the writer is a [BsonValueWriter] instead of a [BsonFieldWriter]. + */ +@LowLevelApi abstract class AbstractValue private constructor( @property:LowLevelApi override val context: BsonContext, private val node: NodeImpl, diff --git a/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt index 5783c67d..0cc3043f 100644 --- a/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt +++ b/dsl/src/commonMain/kotlin/aggregation/ValueDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -18,35 +18,151 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.bson.BsonContext import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.operators.ComparisonValueOperators +import opensavvy.ktmongo.dsl.aggregation.operators.ValueOperators +import opensavvy.ktmongo.dsl.expr.FilterOperators import opensavvy.ktmongo.dsl.path.Field -import opensavvy.ktmongo.dsl.path.FieldDsl import kotlin.reflect.KProperty1 /** - * DSL to instantiate [aggregation values][Value], usually automatically added into scope by aggregation stages. + * DSL to instantiate aggregation values, usually automatically added into scope by aggregation stages. + * + * ### What are aggregation values? + * + * In MongoDB, operators targeting regular queries and aggregation pipelines often have the same name but a different + * syntax. Using KtMongo, operators keep the same name __and syntax__ for both usages, but the way they are used in + * practice is still quite different. For example, compare [`$eq` (query)][opensavvy.ktmongo.dsl.expr.FilterOperators.eq] + * and [`$eq` (aggregation)][ComparisonValueOperators.eq]. + * + * In regular queries, operators do not have a return type, and invoking them immediately adds them to the current query. + * If multiple operators are called within the operation lambda, each of them is added to the query: operators "bind" + * themselves on invoking. Operators always take a [field][Field] as first operand, and value as second operand. As an example: + * ```kotlin + * users.find { + * User::age gt 18 + * User::scores[0] eq 10 + * } + * ``` + * which generates: + * ```json + * { "$and": [{ "$eq": { "age": 18 } }, { "$eq": { "scores.0": 10 } }] } + * ``` + * + * In aggregations, operators have a return type and **only the last value of a block is taken into account**, just like + * in regular Kotlin code. We can use regular variables to store parts of a more complex expression. + * Operators accept multiple aggregation values which must conform to some type requirements. As an example: + * ```kotlin + * users.find { + * expr { + * val maxScore = of(User::scores).max() + * maxScore lt of(15) + * } + * } + * ``` + * which generates: + * ```json + * { "expr": { "lt": [{ "$max": "$scores" }, { "$literal": 15 }] } } + * ``` + * + * As you can see, we use the [of] method to convert from Kotlin values or from field names to aggregation values. + * Because each side of an operator accepts an aggregation value, we can thus compare multiple fields from the same document, + * use conditionals or other complex requests. + * + * In this example, we used the [`$expr`][FilterOperators.expr] query predicate to write an aggregation value within + * a regular query. `$expr` requires returning a boolean, so the last operator of the value needed to be a boolean-returning + * operator, which `$lt` is one of. In other contexts, aggregation values can be typed with any other document type. + * + * When writing your first aggregation pipelines, keep in mind that **only the last value of each lambda is used**, + * just like when calling Kotlin functions that return a value that is unused. + * + * ### Operators + * + * Access values: + * - [`$literal`][of] + * + * Compare values: + * - [`$eq`][eq] + * - [`$ne`][ne] + * - [`$gt`][gt] + * - [`$lt`][lt] + * - [`$gte`][gte] + * - [`$lte`][lte] + * + * @see Value Representation of an aggregation value. */ -interface ValueDsl : FieldDsl { +@KtMongoDsl +interface ValueDsl : ValueOperators, + ComparisonValueOperators { - @LowLevelApi - val context: BsonContext - - // TODO: document once we have a few more operators + /** + * Refers to a [field] within an [aggregation value][ValueDsl]. + * + * ### Example + * + * ```kotlin + * class Product( + * val acceptanceDate: Instant, + * val publishingDate: Instant, + * ) + * + * val publishedBeforeAcceptance = products.find { + * expr { + * of(Product::publishingDate) lt of(Product::acceptanceDate) + * } + * } + * ``` + */ @OptIn(LowLevelApi::class) fun of(field: Field): Value = FieldValue(field, context) - // TODO: document once we have a few more operators + /** + * Refers to a [field] within an [aggregation value][ValueDsl]. + * + * ### Example + * + * ```kotlin + * class Product( + * val acceptanceDate: Instant, + * val publishingDate: Instant, + * ) + * + * val publishedBeforeAcceptance = products.find { + * expr { + * of(Product::publishingDate) lt of(Product::acceptanceDate) + * } + * } + * ``` + */ fun of(field: KProperty1): Value = of(field.field) - // TODO: document once we have a few more operators + /** + * Refers to a Kotlin [value] within an [aggregation value][ValueDsl]. + * + * ### Example + * + * ```kotlin + * class Product( + * val age: Int, + * ) + * + * val publishedBeforeAcceptance = products.find { + * expr { + * of(Product::age) lt of(15) + * } + * } + * ``` + */ @OptIn(LowLevelApi::class) - fun of(value: Result): Value = + fun of(value: Result): Value = LiteralValue(value, context) } +@OptIn(LowLevelApi::class) private class FieldValue( val field: Field, context: BsonContext, @@ -58,10 +174,11 @@ private class FieldValue( } } -private class LiteralValue( +@OptIn(LowLevelApi::class) +private class LiteralValue( val value: Any?, context: BsonContext, -) : AbstractValue(context) { +) : AbstractValue(context) { @LowLevelApi override fun write(writer: BsonValueWriter) { diff --git a/dsl/src/commonMain/kotlin/aggregation/operators/ComparisonValueOperators.kt b/dsl/src/commonMain/kotlin/aggregation/operators/ComparisonValueOperators.kt new file mode 100644 index 00000000..669694b0 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/operators/ComparisonValueOperators.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024-2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.operators + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.KtMongoDsl +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.AbstractValue +import opensavvy.ktmongo.dsl.aggregation.Value +import opensavvy.ktmongo.dsl.aggregation.ValueDsl +import opensavvy.ktmongo.dsl.expr.FilterOperators + +/** + * Operators to compare two values. + * + * To learn more about aggregation operators, view [ValueDsl]. + */ +interface ComparisonValueOperators : ValueOperators { + + /** + * Compares two aggregation values and returns `true` if they are equivalent. + * + * ### Example + * + * ```kotlin + * class Product( + * val name: String, + * val creationDate: Instant, + * val releaseDate: Instant, + * ) + * + * val releasedOnCreation = collection.aggregate() + * .match { + * expr { + * of(Product::creationDate) eq of(Product::releaseDate) + * } + * } + * .toList() + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/eq/) + * - [Comparison algorithm](https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#std-label-bson-types-comparison-order) + * + * @see ne Negation of this operator. + * @see FilterOperators.eq Equivalent operator in regular queries. + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.eq(other: Value): Value = + ComparisonValueOperator(context, this, other, "eq") + + /** + * Compares two aggregation values and returns `true` if they are not equivalent. + * + * ### Example + * + * ```kotlin + * class Product( + * val name: String, + * val creationDate: Instant, + * val releaseDate: Instant, + * ) + * + * val notReleasedOnCreation = collection.aggregate() + * .match { + * expr { + * of(Product::creationDate) eq of(Product::releaseDate) + * } + * } + * .toList() + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/ne/) + * - [Comparison algorithm](https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#std-label-bson-types-comparison-order) + * + * @see eq Negation of this operator. + * @see FilterOperators.ne Equivalent operator in regular queries. + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.ne(other: Value): Value = + ComparisonValueOperator(context, this, other, "ne") + + // TODO: document the other operators once 'project' is implemented, since the official examples use 'project' to demonstrate them + + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.gt(other: Value): Value = + ComparisonValueOperator(context, this, other, "gt") + + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.gte(other: Value): Value = + ComparisonValueOperator(context, this, other, "gte") + + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.lt(other: Value): Value = + ComparisonValueOperator(context, this, other, "lt") + + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun Value.lte(other: Value): Value = + ComparisonValueOperator(context, this, other, "lte") + + @OptIn(LowLevelApi::class) + private class ComparisonValueOperator( + context: BsonContext, + private val operandA: Value, + private val operandB: Value, + private val operator: String, + ) : AbstractValue(context) { + + @LowLevelApi + override fun write(writer: BsonValueWriter) = with(writer) { + writeDocument { + writeArray("$$operator") { + operandA.writeTo(this) + operandB.writeTo(this) + } + } + } + } +} diff --git a/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt new file mode 100644 index 00000000..fe753360 --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024-2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.operators + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.ValueDsl +import opensavvy.ktmongo.dsl.path.FieldDsl + +/** + * Supertype for all interface operators describing operators on aggregation values. + * + * Most of the time, end-users will be using the subtype [ValueDsl] instead of this interface. + */ +interface ValueOperators : FieldDsl { + + @LowLevelApi + val context: BsonContext + +} diff --git a/dsl/src/commonMain/kotlin/expr/FilterExpression.kt b/dsl/src/commonMain/kotlin/expr/FilterExpression.kt index efc6000f..f90e103c 100644 --- a/dsl/src/commonMain/kotlin/expr/FilterExpression.kt +++ b/dsl/src/commonMain/kotlin/expr/FilterExpression.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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,6 +21,8 @@ import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Value +import opensavvy.ktmongo.dsl.aggregation.ValueDsl import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression import opensavvy.ktmongo.dsl.expr.common.AbstractExpression import opensavvy.ktmongo.dsl.expr.common.Expression @@ -228,5 +230,32 @@ class FilterExpression( } // endregion + // region $expr + + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + @KtMongoDsl + override fun expr(block: ValueDsl.() -> Value) { + val value = ExprEvaluator(context).block() + accept(ExprExpressionNode(value, context)) + } + + @LowLevelApi + private class ExprEvaluator(override val context: BsonContext) : ValueDsl + + @OptIn(LowLevelApi::class) + private class ExprExpressionNode( + val value: Value<*, T>, + context: BsonContext, + ) : FilterExpressionNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + write("\$expr") { + value.writeTo(this) + } + } + } + + // endregion } diff --git a/dsl/src/commonMain/kotlin/expr/FilterOperators.kt b/dsl/src/commonMain/kotlin/expr/FilterOperators.kt index 2cd7d736..dc7287c5 100644 --- a/dsl/src/commonMain/kotlin/expr/FilterOperators.kt +++ b/dsl/src/commonMain/kotlin/expr/FilterOperators.kt @@ -22,6 +22,8 @@ import opensavvy.ktmongo.bson.types.BsonType import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.Value +import opensavvy.ktmongo.dsl.aggregation.ValueDsl import opensavvy.ktmongo.dsl.expr.common.CompoundExpression import opensavvy.ktmongo.dsl.path.* import kotlin.jvm.JvmName @@ -2300,5 +2302,38 @@ interface FilterOperators : CompoundExpression, FieldDsl { this.field.containsAll(values) } + // endregion + // region $expr + + /** + * Enables the usage of [aggregation values][ValueDsl] within a regular query. + * + * Aggregation values are much more powerful than regular query operators (for example, it is possible to compare two + * fields of the same document). However, the way they are written is quite different, and the way they are + * evaluated by MongoDB is quite different again. Before using aggregation values, be sure to read [ValueDsl]. + * + * ### Example + * + * ```kotlin + * class Product( + * val name: String, + * val creationDate: Instant, + * val releaseDate: Instant, + * ) + * + * val anomalies = products.find { + * expr { + * of(Product::creationDate) gt of(Product::releaseDate) + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/expr/) + */ + @KtMongoDsl + fun expr(block: ValueDsl.() -> Value) + // endregion } diff --git a/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt index 9f6c20bc..2611645e 100644 --- a/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt +++ b/dsl/src/commonTest/kotlin/aggregation/ValueTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -22,19 +22,17 @@ import opensavvy.ktmongo.dsl.expr.shouldBeBson import opensavvy.ktmongo.dsl.expr.testContext import opensavvy.prepared.runner.kotest.PreparedSpec +const val literal = "\$literal" + @LowLevelApi class ValueTest : PreparedSpec({ val dollar = "$" - val literal = "\$literal" class ValueDslImpl : ValueDsl { override val context: BsonContext = testContext() } - fun value(block: ValueDsl.() -> Value<*, *>) = - ValueDslImpl().block().toString() - class Profile( val age: Int, ) @@ -44,6 +42,9 @@ class ValueTest : PreparedSpec({ val profile: Profile, ) + fun value(block: ValueDsl.() -> Value) = + ValueDslImpl().block().toString() + suite("Referring to fields with of") { test("Referring to a top-level field") { value { @@ -69,7 +70,7 @@ class ValueTest : PreparedSpec({ suite("Embedding Kotlin values with of") { test("Embedding a primitive integer") { value { - of(5) + of(5) } shouldBeBson """ [ { @@ -81,7 +82,7 @@ class ValueTest : PreparedSpec({ test("Embedding null") { value { - of(null) + of(null) } shouldBeBson """ [ { @@ -91,4 +92,21 @@ class ValueTest : PreparedSpec({ """.trimIndent() } } + + test("Foo") { + value { + of(5) eq of(User::profile / Profile::age) + } shouldBeBson """ + [ + { + "${opensavvy.ktmongo.dsl.expr.filter.eq}": [ + { + "$literal": 5 + }, + "${dollar}profile.age" + ] + } + ] + """.trimIndent() + } }) diff --git a/dsl/src/commonTest/kotlin/expr/filter/ExprFilterTest.kt b/dsl/src/commonTest/kotlin/expr/filter/ExprFilterTest.kt new file mode 100644 index 00000000..03b6d38c --- /dev/null +++ b/dsl/src/commonTest/kotlin/expr/filter/ExprFilterTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.expr.filter + +import opensavvy.ktmongo.dsl.aggregation.literal +import opensavvy.ktmongo.dsl.expr.shouldBeBson +import opensavvy.prepared.runner.kotest.PreparedSpec +import kotlin.text.Typography.dollar + +class ExprFilterTest : PreparedSpec({ + test("Reading a field") { + filter { + expr { + of(User::isAlive) + } + } shouldBeBson """ + { + "$expr": "${dollar}isAlive" + } + """.trimIndent() + } + + test("Hardcoded value") { + filter { + expr { + of(true) + } + } shouldBeBson """ + { + "$expr": { + "$literal": true + } + } + """.trimIndent() + } + + test("Comparing two fields") { + filter { + expr { + of(User::grades[0]) ne of(User::grades[1]) + } + } shouldBeBson """ + { + "$expr": { + "$ne": [ + "${dollar}grades.0", + "${dollar}grades.1" + ] + } + } + """.trimIndent() + } + + test("Comparing a field and a hardcoded value") { + filter { + expr { + of(User::grades[0]) ne of(12) + } + } shouldBeBson """ + { + "$expr": { + "$ne": [ + "${dollar}grades.0", + { + "$literal": 12 + } + ] + } + } + """.trimIndent() + } + + test("Comparing a hardcoded value and a field") { + filter { + expr { + of(12) ne of(User::grades[0]) + } + } shouldBeBson """ + { + "$expr": { + "$ne": [ + { + "$literal": 12 + }, + "${dollar}grades.0" + ] + } + } + """.trimIndent() + } +}) diff --git a/dsl/src/commonTest/kotlin/expr/filter/FilterUtils.kt b/dsl/src/commonTest/kotlin/expr/filter/FilterUtils.kt index 138333a0..5105da1a 100644 --- a/dsl/src/commonTest/kotlin/expr/filter/FilterUtils.kt +++ b/dsl/src/commonTest/kotlin/expr/filter/FilterUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -37,6 +37,7 @@ val lte = "\$lte" val all = "\$all" val oid = "\$oid" val elemMatch = "\$elemMatch" +val expr = "\$expr" class Pet( val name: String, @@ -49,6 +50,7 @@ class User( val age: Int?, val grades: List, val pets: List, + val isAlive: Boolean = true, ) @KtMongoDsl diff --git a/test/src/commonTest/kotlin/AggregationTests.kt b/test/src/commonTest/kotlin/AggregationTests.kt new file mode 100644 index 00000000..a29e421d --- /dev/null +++ b/test/src/commonTest/kotlin/AggregationTests.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import kotlinx.serialization.Serializable +import opensavvy.ktmongo.test.testCollection +import opensavvy.prepared.runner.kotest.PreparedSpec + +class AggregationTests : PreparedSpec({ + @Serializable + data class Song( + val creationDate: Int, + val editionDate: Int, + ) + + val songs by testCollection("aggregation-songs") + + test("Use \$expr to compare two fields in a document") { + songs().insertOne(Song(creationDate = 0, editionDate = 1)) + songs().insertOne(Song(creationDate = 1, editionDate = 1)) + songs().insertOne(Song(creationDate = 2, editionDate = 1)) + + val anomalies = songs().find { + expr { + of(Song::creationDate) gt of(Song::editionDate) + } + } + + check(anomalies.toList() == listOf(Song(creationDate = 2, editionDate = 1))) + } +}) -- 2.51.2 From e9249ab2babb4d6f5007ad9ad9f2258f53c14aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 20 Jan 2025 11:17:01 +0100 Subject: [PATCH 12/15] docs(dsl): Add the $limit, $sample and $skip stages to the Pipeline documentation --- dsl/src/commonMain/kotlin/aggregation/Pipeline.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index 676e8a25..e04c02ca 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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,7 +21,10 @@ import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.buildBsonArray import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.stages.limit import opensavvy.ktmongo.dsl.aggregation.stages.match +import opensavvy.ktmongo.dsl.aggregation.stages.sample +import opensavvy.ktmongo.dsl.aggregation.stages.skip import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression import opensavvy.ktmongo.dsl.expr.common.AbstractExpression import opensavvy.ktmongo.dsl.expr.common.CompoundExpression @@ -48,7 +51,10 @@ import opensavvy.ktmongo.dsl.expr.common.Expression * Each stage is defined as an extension function on this class. * Note that as mentioned, not all stages are available for all pipeline types. * The following stages are available: + * - [`$limit`][limit] * - [`$match`][match] + * - [`$sample`][sample] + * - [`$skip`][skip] * * If you can't find a stage you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/7). * -- 2.51.2 From 81b7f8b425a482ed67d75b59a1c9555b340e977a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 20 Jan 2025 11:39:15 +0100 Subject: [PATCH 13/15] feat(dsl): Implement $set --- .../commonMain/kotlin/aggregation/Pipeline.kt | 1 + .../kotlin/aggregation/PipelineType.kt | 11 +- .../kotlin/aggregation/stages/Set.kt | 144 ++++++++++++++++++ .../kotlin/aggregation/stages/SetTest.kt | 59 +++++++ 4 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 dsl/src/commonMain/kotlin/aggregation/stages/Set.kt create mode 100644 dsl/src/commonTest/kotlin/aggregation/stages/SetTest.kt diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index e04c02ca..062589d3 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -54,6 +54,7 @@ import opensavvy.ktmongo.dsl.expr.common.Expression * - [`$limit`][limit] * - [`$match`][match] * - [`$sample`][sample] + * - [`$set`][set] * - [`$skip`][skip] * * If you can't find a stage you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/7). diff --git a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt index 42e1e245..ecec86d6 100644 --- a/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt +++ b/dsl/src/commonMain/kotlin/aggregation/PipelineType.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -17,10 +17,7 @@ package opensavvy.ktmongo.dsl.aggregation import opensavvy.ktmongo.dsl.DangerousMongoApi -import opensavvy.ktmongo.dsl.aggregation.stages.HasLimit -import opensavvy.ktmongo.dsl.aggregation.stages.HasMatch -import opensavvy.ktmongo.dsl.aggregation.stages.HasSample -import opensavvy.ktmongo.dsl.aggregation.stages.HasSkip +import opensavvy.ktmongo.dsl.aggregation.stages.* /** * The super-type for all [pipeline][Pipeline] features. @@ -54,12 +51,14 @@ interface PipelineType { HasLimit, HasMatch, HasSample, + HasSet, HasSkip /** * Marker type for pipeline features that are available in update operations using a pipeline. */ - object Update : PipelineType + object Update : PipelineType, + HasSet } /** diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt new file mode 100644 index 00000000..4697695e --- /dev/null +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2024-2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.KtMongoDsl +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.* +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractExpression +import opensavvy.ktmongo.dsl.expr.common.CompoundExpression +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.FieldDsl +import opensavvy.ktmongo.dsl.path.Path +import kotlin.reflect.KProperty1 + +/** + * Marks that a pipeline is able to use [set]. + */ +@OptIn(DangerousMongoApi::class) +interface HasSet : PipelineFeature + +/** + * Adds new fields to documents, or overwrites existing fields. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/set/) + */ +@OptIn(LowLevelApi::class, DangerousMongoApi::class) +fun Pipeline.set( + block: SetOperators.() -> Unit, +): Pipeline where Type : PipelineType, Type : HasSet = + withStage(SetStage(SetExpression(context).apply(block), context)) + +@OptIn(LowLevelApi::class) +private class SetStage( + val expression: SetOperators<*>, + context: BsonContext, +) : AbstractExpression(context) { + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument("\$set") { + expression.writeTo(this) + } + } +} + +/** + * The operators allowed in a [set] stage. + */ +@KtMongoDsl +interface SetOperators : CompoundExpression, ValueDsl, FieldDsl { + + /** + * Replaces the value of a field with the specified [value]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + */ + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes V> Field.set(value: Value) + + /** + * Replaces the value of a field with the specified [value]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + */ + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes V> KProperty1.set(value: Value) { + this.field.set(value) + } + + /** + * Replaces the value of a field with the specified [value]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + */ + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes V> Field.set(value: V) { + this.set(of(value)) + } + + /** + * Replaces the value of a field with the specified [value]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + */ + @Suppress("INVISIBLE_REFERENCE") + @KtMongoDsl + infix fun <@kotlin.internal.OnlyInputTypes V> KProperty1.set(value: V) { + this.field.set(value) + } + +} + +private class SetExpression( + context: BsonContext, +) : AbstractCompoundExpression(context), SetOperators { + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + override fun Field.set(value: Value) { + accept(SetExpression(this.path, value, context)) + } + + @LowLevelApi + private class SetExpression( + val path: Path, + val value: Value<*, *>, + context: BsonContext, + ) : AbstractExpression(context) { + + override fun write(writer: BsonFieldWriter) = with(writer) { + write(path.toString()) { + value.writeTo(this) + } + } + } +} diff --git a/dsl/src/commonTest/kotlin/aggregation/stages/SetTest.kt b/dsl/src/commonTest/kotlin/aggregation/stages/SetTest.kt new file mode 100644 index 00000000..53919bb5 --- /dev/null +++ b/dsl/src/commonTest/kotlin/aggregation/stages/SetTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.aggregation.stages + +import opensavvy.ktmongo.dsl.aggregation.* +import opensavvy.ktmongo.dsl.expr.filter.gt +import opensavvy.prepared.runner.kotest.PreparedSpec +import kotlin.text.Typography.dollar + +class SetTest : PreparedSpec({ + + class Target( + val foo: String, + val deathDate: Int, + val isAlive: Boolean, + ) + + test("Simple $set") { + aggregate<_, Target>(PipelineType.Update) + .set { + Target::foo set "bar" + Target::isAlive set (of(Target::deathDate) gt of(18)) + } + .shouldBeBson(""" + [ + { + "$set": { + "foo": { + "$literal": "bar" + }, + "isAlive": { + "$gt": [ + "${dollar}deathDate", + { + "$literal": 18 + } + ] + } + } + } + ] + """.trimIndent()) + } + +}) -- 2.51.2 From a6527f075fa8a3546072cbb2a5ee2eff59e27261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 20 Jan 2025 13:38:40 +0100 Subject: [PATCH 14/15] build(docker): Document how to test older versions --- docker/docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index abbc08a8..6cc17cb8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,6 +2,9 @@ version: "3.6" services: mongo: + # When downgrading the version, run + # docker compose -p ktmongo rm -v + # (all data will be lost) image: "mongo:8.0.1" ports: - "27017:27017" -- 2.51.2 From 356fa73918decae57ad0163ce934b0947dd0d0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 29 Jan 2025 12:10:36 +0100 Subject: [PATCH 15/15] ci(gitlab): Automatically retry failed tests, because MongoDB sometimes times out --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e5d5bdc4..d09940bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -72,6 +72,11 @@ workflow: - name: $mongodb alias: mongo + retry: + max: 1 + exit_codes: + - 2 # Sometimes, the job fails due to timeouts? + check[jvm]: extends: [ .kotlin-jvm, .with-mongo ] stage: test