diff --git a/driver-api/src/commonMain/kotlin/MongoCollection.kt b/driver-api/src/commonMain/kotlin/MongoCollection.kt index b143df74..bb8a379a 100644 --- a/driver-api/src/commonMain/kotlin/MongoCollection.kt +++ b/driver-api/src/commonMain/kotlin/MongoCollection.kt @@ -16,10 +16,10 @@ package opensavvy.ktmongo.api +import opensavvy.ktmongo.api.operations.CountOperations import opensavvy.ktmongo.bson.BsonFactory import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.ObjectIdGenerator -import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.path.PropertyNameStrategy import kotlin.reflect.KType @@ -51,7 +51,8 @@ import kotlin.reflect.KType * - [Official documentation](https://www.mongodb.com/docs/manual/core/databases-and-collections/) * - [Size limits](https://www.mongodb.com/docs/manual/reference/limits/#bson-documents) */ -interface MongoCollection : ObjectIdGenerator { +interface MongoCollection : ObjectIdGenerator, + CountOperations { /** * THe name of this collection. @@ -102,13 +103,6 @@ interface MongoCollection : ObjectIdGenerator { */ val objectIdGenerator: ObjectIdGenerator - /** - * The full BSON configuration, used by the DSL to generate queries. - * - * For more information, see [BsonContext]. - */ - val context: BsonContext - override fun newId(): ObjectId = objectIdGenerator.newId() diff --git a/driver-api/src/commonMain/kotlin/operations/BaseOperations.kt b/driver-api/src/commonMain/kotlin/operations/BaseOperations.kt new file mode 100644 index 00000000..e5dbb43a --- /dev/null +++ b/driver-api/src/commonMain/kotlin/operations/BaseOperations.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.api.operations + +import opensavvy.ktmongo.dsl.BsonContext +import opensavvy.ktmongo.dsl.LowLevelApi + +/** + * The common interface to all operations interfaces. + * + * This interface provides no useful value to end-users. + */ +interface BaseOperations { + + /** + * The full BSON configuration, used by the DSL to generate queries. + * + * For more information, see [BsonContext]. + */ + @LowLevelApi + val context: BsonContext +} diff --git a/driver-api/src/commonMain/kotlin/operations/CountOperations.kt b/driver-api/src/commonMain/kotlin/operations/CountOperations.kt new file mode 100644 index 00000000..ebae4ce4 --- /dev/null +++ b/driver-api/src/commonMain/kotlin/operations/CountOperations.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.api.operations + +import opensavvy.ktmongo.dsl.command.CountOptions +import opensavvy.ktmongo.dsl.query.FilterQuery + +/** + * The different MongoDB operations related to counting documents. + */ +interface CountOperations : BaseOperations { + + /** + * Counts how many documents exist in the collection. + * + * ### Implementation + * + * This method, just like in `mongosh`, in syntax sugar for an aggregation pipeline using + * the [countTo][opensavvy.ktmongo.dsl.aggregation.stages.HasCount.countTo] stage. + * + * ### External resources + * + * - [`mongosh` documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) + * + * @see countEstimated Faster alternative when the result doesn't need to be exact. + */ + suspend fun count(): Long + + /** + * Counts how many documents match [predicate] in the collection. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.count { + * User::name eq "foo" + * User::age eq 10 + * } + * ``` + * + * ### Implementation + * + * This method, just like in `mongosh`, in syntax sugar for an aggregation pipeline using + * the [match][opensavvy.ktmongo.dsl.aggregation.stages.HasMatch.match] and + * the [countTo][opensavvy.ktmongo.dsl.aggregation.stages.HasCount.countTo] stages. + * + * ### External resources + * + * - [`mongosh` documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) + */ + suspend fun count( + options: CountOptions.() -> Unit = {}, + predicate: FilterQuery.() -> Unit, + ): Long + + /** + * Tests if there exists a document that matches [predicate] in the collection. + * + * This method is a convenience function for calling [count] with a [limit][CountOptions.limit] of 1. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.exists { + * User::name eq "foo" + * User::age eq 10 + * } + * ``` + */ + suspend fun exists( + options: CountOptions.() -> Unit = {}, + predicate: FilterQuery.() -> Unit, + ): Boolean = count( + options = { + options() + limit(1) + }, + predicate = predicate, + ) == 1L + + /** + * Counts all documents in the collection. + * + * ### Implementation + * + * This function reads collection metadata instead of actually counting through all documents. + * This makes it much more performant (almost no CPU nor RAM usage), but the count may be slightly out of date. + * + * In particular, it may become inaccurate when: + * - there are orphaned documents in a shared cluster, + * - an unclean shutdown happened. + * + * Views do not possess the required metadata. + * When this function is called on a view (either a MongoDB view or a [filter] logical view), a regular [count] is executed instead. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.countEstimated() + * ``` + * + * ### External resources + * + * - [`mongosh` documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.estimatedDocumentCount/) + * + * @see count Perform the count for real. + */ + suspend fun countEstimated(): Long + +} diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt index accdcde1..1cf05d1f 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt @@ -25,7 +25,11 @@ import opensavvy.ktmongo.bson.official.types.Jvm import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.command.Count +import opensavvy.ktmongo.dsl.command.CountOptions import opensavvy.ktmongo.dsl.path.PropertyNameStrategy +import opensavvy.ktmongo.dsl.query.FilterQuery +import opensavvy.ktmongo.official.options.toJava import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -52,8 +56,35 @@ private class CoroutineMongoCollectionImpl( ObjectIdGenerator by objectIdGenerator, PropertyNameStrategy by propertyNameStrategy + @LowLevelApi override val context: BsonContext = CoroutineBsonContext() + // region Count + + override suspend fun count(): Long = + inner.countDocuments() + + @OptIn(LowLevelApi::class) + override suspend fun count( + options: CountOptions.() -> Unit, + predicate: FilterQuery.() -> Unit, + ): Long { + val model = Count(context) + + model.options.options() + model.filter.predicate() + + return inner.countDocuments( + factory.buildDocument(model.filter).raw, + model.options.toJava() + ) + } + + override suspend fun countEstimated(): Long = + inner.estimatedDocumentCount() + + // endregion + override fun toString(): String = "CoroutineMongoCollection($fullyQualifiedName)" } diff --git a/test/src/commonMain/kotlin/Entrypoint.kt b/test/src/commonMain/kotlin/Entrypoint.kt index 643784ee..94b7c619 100644 --- a/test/src/commonMain/kotlin/Entrypoint.kt +++ b/test/src/commonMain/kotlin/Entrypoint.kt @@ -17,6 +17,7 @@ package opensavvy.ktmongo.tests.api import opensavvy.ktmongo.api.MongoClient +import opensavvy.ktmongo.tests.api.operations.verifyCountOperations import opensavvy.prepared.suite.* import kotlin.coroutines.CoroutineContext @@ -46,4 +47,6 @@ fun SuiteDsl.verifyClient( verifyClient(client) verifyDatabase(client) verifyCollection(client) + + verifyCountOperations(client) } diff --git a/test/src/commonMain/kotlin/Utils.kt b/test/src/commonMain/kotlin/Utils.kt new file mode 100644 index 00000000..8fcdfcd3 --- /dev/null +++ b/test/src/commonMain/kotlin/Utils.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.tests.api + +import opensavvy.ktmongo.api.MongoClient +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.prepared +import opensavvy.prepared.suite.random.randomInt + +val testId by randomInt(0, Int.MAX_VALUE) + +inline fun Prepared.collection( + prefix: String, +) = prepared { + this@collection() + .database("ktmongo-integration-tests") + .collection("$prefix-${testId()}") +} diff --git a/test/src/commonMain/kotlin/operations/CountOperations.test.kt b/test/src/commonMain/kotlin/operations/CountOperations.test.kt new file mode 100644 index 00000000..58eb0db2 --- /dev/null +++ b/test/src/commonMain/kotlin/operations/CountOperations.test.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.tests.api.operations + +import kotlinx.serialization.Serializable +import opensavvy.ktmongo.api.MongoClient +import opensavvy.ktmongo.bson.types.ObjectId +import opensavvy.ktmongo.tests.api.collection +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.SuiteDsl + +@Serializable +data class CountOperationsUser( + val _id: ObjectId, + val name: String, +) + +fun SuiteDsl.verifyCountOperations( + client: Prepared, +) = suite("Count operations") { + val collection by client.collection("operation-count-users") + + suite("Empty collection") { + test("Count should be 0") { + check(collection().count() == 0L) + } + + test("Count with a predicate should be 0") { + check(collection().count { CountOperationsUser::name eq "Bob" } == 0L) + } + + test("Does an element exist, in an empty collection") { + check(!collection().exists {}) + } + + test("Count (estimated) of an empty collection") { + check(collection().countEstimated() == 0L) + } + } +}