From 4baa8a41ec105f1a85bf605befa51a29f950a29a Mon Sep 17 00:00:00 2001 From: Jake Son Date: Sun, 1 Oct 2023 21:08:06 +0900 Subject: [PATCH 1/2] feat: implement unionWith stage dsl --- .../core/aggregation/LookupStageDsl.kt | 3 +- .../core/aggregation/UnionWithStageDsl.kt | 64 ++++++ .../core/aggregation/UnionWithStageDslTest.kt | 203 ++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDsl.kt create mode 100644 core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDslTest.kt diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/LookupStageDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/LookupStageDsl.kt index 64c997af..ef467d6e 100644 --- a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/LookupStageDsl.kt +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/LookupStageDsl.kt @@ -6,6 +6,7 @@ import org.springframework.data.mongodb.core.aggregation.VariableOperators.Let import org.springframework.data.mongodb.core.mapping.Document import kotlin.reflect.KClass import kotlin.reflect.KProperty +import kotlin.reflect.full.findAnnotation /** * A Kotlin DSL to configure $lookup stage using idiomatic Kotlin code. @@ -39,7 +40,7 @@ class LookupStageDsl { * @see Use a `$documents` Stage in a `$lookup` Stage */ fun from(from: KClass<*>) { - val annotation = from.annotations.firstNotNullOfOrNull { it as? Document } + val annotation = from.findAnnotation() operation.from( annotation?.collection?.ifEmpty { annotation.value.takeIf { it.isNotEmpty() } } ?: from.simpleName, diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDsl.kt new file mode 100644 index 00000000..3985aed8 --- /dev/null +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDsl.kt @@ -0,0 +1,64 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation + +import com.github.inflab.spring.data.mongodb.core.annotation.AggregationMarker +import org.springframework.data.mongodb.core.aggregation.AggregationPipeline +import org.springframework.data.mongodb.core.aggregation.UnionWithOperation +import org.springframework.data.mongodb.core.mapping.Document +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +/** + * A Kotlin DSL to configure $unionWith stage using idiomatic Kotlin code. + * + * @author Jake Son + * @since 1.0 + * @see $unionWith (aggregation) + */ +@AggregationMarker +class UnionWithStageDsl { + private var collection: String? = null + private var pipeline: AggregationPipeline? = null + + /** + * The collection or view whose `pipeline` results you wish to include in the result set. + * + * @param collection The collection name. + * @see coll + */ + fun coll(collection: String) { + this.collection = collection + } + + /** + * The collection or view whose `pipeline` results you wish to include in the result set. + * + * @param collection The collection class. + * @see coll + */ + fun coll(collection: KClass<*>) { + val annotation = collection.findAnnotation() + this.collection = annotation?.collection?.ifEmpty { annotation.value.takeIf { it.isNotEmpty() } } + ?: collection.simpleName + } + + /** + * An aggregation pipeline to apply to the specified [coll]. + * + * `[ , , ...]` + * + * The pipeline cannot include the `$out` and `$merge` stages. + * Starting in v6.0, the `pipeline` can contain the `Atlas Search $search` stage as the first stage inside the pipeline. + * + * @param configuration The configuration for [AggregationDsl]. + * @see pipeline + */ + fun pipeline(configuration: AggregationDsl.() -> Unit) { + pipeline = AggregationDsl().apply(configuration).build().pipeline + } + + internal fun build(): UnionWithOperation? { + val operation = collection?.let { UnionWithOperation.unionWith(it) } ?: return null + + return pipeline?.let { operation.pipeline(it) } ?: operation + } +} diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDslTest.kt new file mode 100644 index 00000000..f3efe0e0 --- /dev/null +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/UnionWithStageDslTest.kt @@ -0,0 +1,203 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation + +import com.github.inflab.spring.data.mongodb.core.util.shouldBeJson +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import org.springframework.data.mongodb.core.mapping.Document + +internal class UnionWithStageDslTest : FreeSpec({ + fun unionWith(block: UnionWithStageDsl.() -> Unit) = + UnionWithStageDsl().apply(block) + + "coll" - { + "should add a collection by string" { + // given + val stage = unionWith { + coll("collection") + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "collection" + } + } + """.trimIndent(), + ) + } + + "should add a collection by class" { + // given + data class Collection(val property: String) + val stage = unionWith { + coll(Collection::class) + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "Collection" + } + } + """.trimIndent(), + ) + } + + "should add a collection by class with @Document annotation" { + // given + @Document("collection") + data class Collection(val property: String) + val stage = unionWith { + coll(Collection::class) + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "collection" + } + } + """.trimIndent(), + ) + } + + "should add a collection by class with @Document annotation with value" { + // given + @Document(value = "collection") + data class Collection(val property: String) + val stage = unionWith { + coll(Collection::class) + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "collection" + } + } + """.trimIndent(), + ) + } + + "should add a collection by class with @Document annotation with collection" { + // given + @Document(collection = "collection") + data class Collection(val property: String) + val stage = unionWith { + coll(Collection::class) + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "collection" + } + } + """.trimIndent(), + ) + } + + "should use class name if @Document annotation's properties are empty" { + // given + @Document(collection = "", value = "") + data class Collection(val property: String) + + val stage = unionWith { + coll(Collection::class) + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "Collection" + } + } + """.trimIndent(), + ) + } + } + + "pipeline" - { + "should not build stage if collection is not given" { + // given + val stage = unionWith { + pipeline { + count("fieldName") + } + } + + // when + val result = stage.build() + + // then + result.shouldBeNull() + } + + "should build stage if collection is given" { + // given + val stage = unionWith { + coll("collection") + pipeline { + count("fieldName") + } + } + + // when + val result = stage.build() + + // then + result.shouldNotBeNull() + result.shouldBeJson( + """ + { + "${'$'}unionWith": { + "coll": "collection", + "pipeline": [ + { + "${'$'}count": "fieldName" + } + ] + } + } + """.trimIndent(), + ) + } + } +}) From 9ad0d45a3eecd1dcb3bbabcb4e3f92ca441deb93 Mon Sep 17 00:00:00 2001 From: Jake Son Date: Sun, 1 Oct 2023 21:50:10 +0900 Subject: [PATCH 2/2] test: add unionWith stage examples --- .../core/aggregation/AggregationDsl.kt | 14 ++ .../core/aggregation/AggregationDslTest.kt | 17 +++ .../mongodb/repository/LookupRepository.kt | 2 - .../mongodb/repository/UnionWithRepository.kt | 87 +++++++++++++ .../repository/UnionWithRepositoryTest.kt | 122 ++++++++++++++++++ 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepository.kt create mode 100644 example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepositoryTest.kt diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDsl.kt index e433a1e6..66725fd1 100644 --- a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDsl.kt +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDsl.kt @@ -242,6 +242,20 @@ class AggregationDsl { operations += UnsetOperation.unset(*PathSearchOptionDsl().apply(pathConfiguration).get().toTypedArray()) } + /** + * Configures a stage that performs a union of two collections. + * `$unionWith` combines pipeline results from two collections into a single result set. + * The stage outputs the combined result set (including duplicates) to the next stage. + * + * The order in which the combined result set documents are output is unspecified. + * + * @param unionWithConfiguration The configuration block for the [UnionWithStageDsl]. + * @see $unionWith (aggregation) + */ + fun unionWith(unionWithConfiguration: UnionWithStageDsl.() -> Unit) { + UnionWithStageDsl().apply(unionWithConfiguration).build()?.let { operations += it } + } + /** * Builds the [Aggregation] using the configured [AggregationOperation]s. * diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDslTest.kt index d5854c1f..8b46f592 100644 --- a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDslTest.kt +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/AggregationDslTest.kt @@ -9,6 +9,7 @@ import io.kotest.matchers.shouldBe import org.bson.Document import org.springframework.data.mongodb.core.aggregation.Aggregation import org.springframework.data.mongodb.core.aggregation.StringOperators +import org.springframework.data.mongodb.core.aggregation.UnionWithOperation import org.springframework.data.mongodb.core.aggregation.UnsetOperation import org.springframework.data.mongodb.core.query.Criteria import org.springframework.data.mongodb.core.query.TextCriteria @@ -268,4 +269,20 @@ internal class AggregationDslTest : FreeSpec({ ).toString() } } + + "unionWith" - { + "should create unionWith stage" { + // when + val aggregation = aggregation { + unionWith { + coll("collectionName") + } + } + + // then + aggregation.toString() shouldBe Aggregation.newAggregation( + UnionWithOperation.unionWith("collectionName"), + ).toString() + } + } }) diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/LookupRepository.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/LookupRepository.kt index 61c05f71..d3e962e9 100644 --- a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/LookupRepository.kt +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/LookupRepository.kt @@ -84,8 +84,6 @@ class LookupRepository( } } - print(aggregation.toString()) - return mongoTemplate.aggregate(aggregation, ORDERS, OrderDto::class.java) } diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepository.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepository.kt new file mode 100644 index 00000000..b2208488 --- /dev/null +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepository.kt @@ -0,0 +1,87 @@ +package com.github.inflab.example.spring.data.mongodb.repository + +import com.github.inflab.spring.data.mongodb.core.aggregation.aggregation +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.aggregation.Aggregation +import org.springframework.data.mongodb.core.aggregation.AggregationResults +import org.springframework.data.mongodb.core.aggregation.SetOperation +import org.springframework.stereotype.Repository + +@Repository +class UnionWithRepository( + private val mongoTemplate: MongoTemplate, +) { + + data class SalesDto( + val id: String, + val store: String, + val item: String, + val quantity: Int, + ) + + /** + * @see All Sales by Year and Stores and Items + */ + fun findSalesByYearAndStoresAndItems(): AggregationResults { + // TODO: apply $set dsl + val aggregation = aggregation { + stage(SetOperation.set("_id").toValue("2017")) + + unionWith { + coll(SALES_2018) + pipeline { + stage(SetOperation.set("_id").toValue("2018")) + } + } + unionWith { + coll(SALES_2019) + pipeline { + stage(SetOperation.set("_id").toValue("2019")) + } + } + unionWith { + coll(SALES_2020) + pipeline { + stage(SetOperation.set("_id").toValue("2020")) + } + } + + sort { + "_id" by Ascending + "store" by Ascending + "item" by Ascending + } + } + + return mongoTemplate.aggregate(aggregation, SALES_2017, SalesDto::class.java) + } + + data class SalesByItemsDto(val id: String, val total: Int) + + /** + * @see Aggregated Sales by Items + */ + fun findSalesByItems(): AggregationResults { + val aggregation = aggregation { + unionWith { coll(SALES_2018) } + unionWith { coll(SALES_2019) } + unionWith { coll(SALES_2020) } + + // TODO: apply $group dsl + stage(Aggregation.group("item").sum("quantity").`as`("total")) + + sort { + "total" by Descending + } + } + + return mongoTemplate.aggregate(aggregation, SALES_2017, SalesByItemsDto::class.java) + } + + companion object { + const val SALES_2017 = "sales_2017" + const val SALES_2018 = "sales_2018" + const val SALES_2019 = "sales_2019" + const val SALES_2020 = "sales_2020" + } +} diff --git a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepositoryTest.kt b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepositoryTest.kt new file mode 100644 index 00000000..843216e6 --- /dev/null +++ b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/UnionWithRepositoryTest.kt @@ -0,0 +1,122 @@ +package com.github.inflab.example.spring.data.mongodb.repository + +import com.github.inflab.example.spring.data.mongodb.extension.makeMongoTemplate +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import org.bson.Document + +internal class UnionWithRepositoryTest : FreeSpec({ + val mongoTemplate = makeMongoTemplate() + val unionWithRepository = UnionWithRepository(mongoTemplate) + + beforeSpec { + val sales2017 = listOf( + mapOf("store" to "General Store", "item" to "Chocolates", "quantity" to 150), + mapOf("store" to "ShopMart", "item" to "Chocolates", "quantity" to 50), + mapOf("store" to "General Store", "item" to "Cookies", "quantity" to 100), + mapOf("store" to "ShopMart", "item" to "Cookies", "quantity" to 120), + mapOf("store" to "General Store", "item" to "Pie", "quantity" to 10), + mapOf("store" to "ShopMart", "item" to "Pie", "quantity" to 5), + ).map(::Document) + mongoTemplate.insert(sales2017, UnionWithRepository.SALES_2017) + + val sales2018 = listOf( + mapOf("store" to "General Store", "item" to "Cheese", "quantity" to 30), + mapOf("store" to "ShopMart", "item" to "Cheese", "quantity" to 50), + mapOf("store" to "General Store", "item" to "Chocolates", "quantity" to 125), + mapOf("store" to "ShopMart", "item" to "Chocolates", "quantity" to 150), + mapOf("store" to "General Store", "item" to "Cookies", "quantity" to 200), + mapOf("store" to "ShopMart", "item" to "Cookies", "quantity" to 100), + mapOf("store" to "ShopMart", "item" to "Nuts", "quantity" to 100), + mapOf("store" to "General Store", "item" to "Pie", "quantity" to 30), + mapOf("store" to "ShopMart", "item" to "Pie", "quantity" to 25), + ).map(::Document) + mongoTemplate.insert(sales2018, UnionWithRepository.SALES_2018) + + val sales2019 = listOf( + mapOf("store" to "General Store", "item" to "Cheese", "quantity" to 50), + mapOf("store" to "ShopMart", "item" to "Cheese", "quantity" to 20), + mapOf("store" to "General Store", "item" to "Chocolates", "quantity" to 125), + mapOf("store" to "ShopMart", "item" to "Chocolates", "quantity" to 150), + mapOf("store" to "General Store", "item" to "Cookies", "quantity" to 200), + mapOf("store" to "ShopMart", "item" to "Cookies", "quantity" to 100), + mapOf("store" to "General Store", "item" to "Nuts", "quantity" to 80), + mapOf("store" to "ShopMart", "item" to "Nuts", "quantity" to 30), + mapOf("store" to "General Store", "item" to "Pie", "quantity" to 50), + mapOf("store" to "ShopMart", "item" to "Pie", "quantity" to 75), + ).map(::Document) + mongoTemplate.insert(sales2019, UnionWithRepository.SALES_2019) + + val sales2020 = listOf( + mapOf("store" to "General Store", "item" to "Cheese", "quantity" to 100), + mapOf("store" to "ShopMart", "item" to "Cheese", "quantity" to 100), + mapOf("store" to "General Store", "item" to "Chocolates", "quantity" to 200), + mapOf("store" to "ShopMart", "item" to "Chocolates", "quantity" to 300), + mapOf("store" to "General Store", "item" to "Cookies", "quantity" to 500), + mapOf("store" to "ShopMart", "item" to "Cookies", "quantity" to 400), + mapOf("store" to "General Store", "item" to "Nuts", "quantity" to 100), + mapOf("store" to "ShopMart", "item" to "Nuts", "quantity" to 200), + mapOf("store" to "General Store", "item" to "Pie", "quantity" to 100), + mapOf("store" to "ShopMart", "item" to "Pie", "quantity" to 100), + ).map(::Document) + mongoTemplate.insert(sales2020, UnionWithRepository.SALES_2020) + } + + "findSalesByYearAndStoresAndItems" { + // when + val result = unionWithRepository.findSalesByYearAndStoresAndItems() + + // then + result.mappedResults.map { it.id to it.quantity } shouldBe listOf( + "2017" to 150, + "2017" to 100, + "2017" to 10, + "2017" to 50, + "2017" to 120, + "2017" to 5, + "2018" to 30, + "2018" to 125, + "2018" to 200, + "2018" to 30, + "2018" to 50, + "2018" to 150, + "2018" to 100, + "2018" to 100, + "2018" to 25, + "2019" to 50, + "2019" to 125, + "2019" to 200, + "2019" to 80, + "2019" to 50, + "2019" to 20, + "2019" to 150, + "2019" to 100, + "2019" to 30, + "2019" to 75, + "2020" to 100, + "2020" to 200, + "2020" to 500, + "2020" to 100, + "2020" to 100, + "2020" to 100, + "2020" to 300, + "2020" to 400, + "2020" to 200, + "2020" to 100, + ) + } + + "findSalesBYItems" { + // when + val result = unionWithRepository.findSalesByItems() + + // then + result.mappedResults.map { it.id to it.total } shouldBe listOf( + "Cookies" to 1720, + "Chocolates" to 1250, + "Nuts" to 510, + "Pie" to 395, + "Cheese" to 350, + ) + } +})