Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement unwind stage #56

Merged
merged 4 commits into from
Sep 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.github.inflab.spring.data.mongodb.core.aggregation
import com.github.inflab.spring.data.mongodb.core.aggregation.search.SearchMetaStageDsl
import com.github.inflab.spring.data.mongodb.core.aggregation.search.SearchStageDsl
import com.github.inflab.spring.data.mongodb.core.annotation.AggregationMarker
import com.github.inflab.spring.data.mongodb.core.extension.toDotPath
import org.bson.Document
import org.springframework.data.mongodb.core.aggregation.Aggregation
import org.springframework.data.mongodb.core.aggregation.AggregationExpression
Expand All @@ -12,6 +13,7 @@ import org.springframework.data.mongodb.core.aggregation.AggregationOptions.Doma
import org.springframework.data.mongodb.core.query.Collation
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.CriteriaDefinition
import kotlin.reflect.KProperty
import kotlin.time.Duration
import kotlin.time.toJavaDuration

Expand Down Expand Up @@ -96,7 +98,7 @@ class AggregationDsl {
/**
* Configures a stage that performs a full-text search on the specified field or fields which must be covered by an Atlas Search index.
*
* @param searchConfiguration custom configurations for the search stage.
* @param searchConfiguration The configuration block for the [SearchStageDsl].
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/search">$search (aggregation)</a>
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/query-syntax/">Return Atlas Search Results or Metadata</a>
*/
Expand All @@ -107,7 +109,7 @@ class AggregationDsl {
/**
* Configures a stage that returns different types of metadata result documents.
*
* @param searchMetaConfiguration custom configurations for the searchMeta stage.
* @param searchMetaConfiguration The configuration block for the [SearchMetaStageDsl].
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/query-syntax/#-searchmeta">$searchMeta</a>
*/
fun searchMeta(searchMetaConfiguration: SearchMetaStageDsl.() -> Unit) {
Expand All @@ -118,7 +120,7 @@ class AggregationDsl {
* Passes along the documents with the requested fields to the next stage in the pipeline.
* The specified fields can be existing fields from the input documents or newly computed fields.
*
* @param projectConfiguration custom configurations for the project stage.
* @param projectConfiguration The configuration block for the [ProjectStageDsl].
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/project">$project (aggregation)</a>
*/
fun project(projectConfiguration: ProjectStageDsl.() -> Unit) {
Expand All @@ -128,13 +130,39 @@ class AggregationDsl {
/**
* Configures a stage that sorts all input documents and returns them to the pipeline in sorted order.
*
* @param sortConfiguration custom configurations for the sort stage.
* @param sortConfiguration The configuration block for the [SortStageDsl].
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/sort">$sort (aggregation)</a>
*/
fun sort(sortConfiguration: SortStageDsl.() -> Unit) {
operations += SortStageDsl().apply(sortConfiguration).get()
}

/**
* Configures a stage that groups incoming documents based on the value of a specified expression, then computes the count of documents in each distinct group.
* Each output document contains two fields: an `_id` field containing the distinct grouping value, and a `count` field containing the number of documents belonging to that grouping or category.
* The documents are sorted by `count` in descending order.
*
* @param field The field path.
* @see <a href="https://www.mongodb.com/docs/v7.0/meta/aggregation-quick-reference/#std-label-agg-quick-ref-field-paths">Field Paths</a>
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/sortByCount">$sortByCount (aggregation)</a>
*/
fun sortByCount(field: String) {
operations += Aggregation.sortByCount(field)
}

/**
* Configures a stage that groups incoming documents based on the value of a specified expression, then computes the count of documents in each distinct group.
* Each output document contains two fields: an `_id` field containing the distinct grouping value, and a `count` field containing the number of documents belonging to that grouping or category.
* The documents are sorted by `count` in descending order.
*
* @param field The field path.
* @see <a href="https://www.mongodb.com/docs/v7.0/meta/aggregation-quick-reference/#std-label-agg-quick-ref-field-paths">Field Paths</a>
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/sortByCount">$sortByCount (aggregation)</a>
*/
fun sortByCount(field: KProperty<*>) {
sortByCount(field.toDotPath())
}

/**
* Configures a stage that groups incoming documents based on the value of a specified expression, then computes the count of documents in each distinct group.
* Each output document contains two fields: an `_id` field containing the distinct grouping value, and a `count` field containing the number of documents belonging to that grouping or category.
Expand All @@ -152,6 +180,7 @@ class AggregationDsl {
* Creates a new $match stage using the given [Criteria].
*
* @param criteria The [Criteria] to match documents against.
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/match/">$match (aggregation)</a>
*/
fun match(criteria: Criteria) {
operations += Aggregation.match(criteria)
Expand All @@ -161,6 +190,7 @@ class AggregationDsl {
* Creates a new $match stage using the given [CriteriaDefinition].
*
* @param criteria The [CriteriaDefinition] to match documents against.
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/match/">$match (aggregation)</a>
*/
fun match(criteria: CriteriaDefinition) {
operations += Aggregation.match(criteria)
Expand All @@ -170,11 +200,23 @@ class AggregationDsl {
* Creates a new $match stage using the given [AggregationExpression].
*
* @param expression The [AggregationExpression] to match documents against.
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/match/">$match (aggregation)</a>
*/
fun match(expression: AggregationExpression) {
operations += Aggregation.match(expression)
}

/**
* Configures a stage that deconstructs an array field from the input documents to output a document for each element.
* Each output document is the input document with the value of the array field replaced by the element.
*
* @param unwindConfiguration The configuration block for the [UnwindStageDsl].
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/unwind">$unwind (aggregation)</a>
*/
fun unwind(unwindConfiguration: UnwindStageDsl.() -> Unit) {
UnwindStageDsl().apply(unwindConfiguration).build()?.let { operations += it }
}

/**
* Builds the [Aggregation] using the configured [AggregationOperation]s.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.github.inflab.spring.data.mongodb.core.aggregation

import com.github.inflab.spring.data.mongodb.core.annotation.AggregationMarker
import com.github.inflab.spring.data.mongodb.core.extension.toDotPath
import org.springframework.data.mongodb.core.aggregation.UnwindOperation
import kotlin.reflect.KProperty

/**
* A Kotlin DSL to configure $unwind stage using idiomatic Kotlin code.
*
* @author Jake Son
* @since 1.0
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/unwind">$unwind (aggregation)</a>
*/
@AggregationMarker
class UnwindStageDsl {
private var path: String? = null

/**
* The name of a new field to hold the array index of the element.
* The name cannot start with a dollar sign $.
*/
var includeArrayIndex: String = ""

/**
* Optional.
*
* - If true, if the path is null, missing, or an empty array, `$unwind` outputs the document.
* - If false, if path is null, missing, or an empty array, `$unwind` does not output a document.
*
* The default value is `false`.
*/
var preserveNullAndEmptyArrays: Boolean = false

/**
* Field path to an array field.
*
* @param path The field path to an array field.
*/
fun path(path: String) {
this.path = path
}

/**
* Field path to an array field.
*
* @param path The field path to an array field.
*/
fun path(path: KProperty<*>) {
this.path = path.toDotPath()
}

internal fun build(): UnwindOperation? {
val builder = path?.let { UnwindOperation.newUnwind().path(it) } ?: return null

val indexBuilder = when (includeArrayIndex.isBlank()) {
true -> builder.noArrayIndex()
false -> builder.arrayIndex(includeArrayIndex)
}

return when (preserveNullAndEmptyArrays) {
true -> indexBuilder.preserveNullAndEmptyArrays()
false -> indexBuilder.skipNullAndEmptyArrays()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ internal class AggregationDslTest : FreeSpec({
}

"sortByCount" - {
"should create sortByCount stage with string" {
// when
val aggregation = aggregation {
sortByCount("fieldName")
}

// then
aggregation.toString() shouldBe Aggregation.newAggregation(
Aggregation.sortByCount("fieldName"),
).toString()
}

"should create sortByCount stage with property" {
// when
data class Test(val fieldName: String)
val aggregation = aggregation {
sortByCount(Test::fieldName)
}

// then
aggregation.toString() shouldBe Aggregation.newAggregation(
Aggregation.sortByCount("fieldName"),
).toString()
}

"should create sortByCount stage with expression" {
// when
val expression = StringOperators.valueOf("fieldName").ltrim()
Expand Down Expand Up @@ -179,4 +204,22 @@ internal class AggregationDslTest : FreeSpec({
).toString()
}
}

"unwind" - {
"should create unwind stage" {
// when
val aggregation = aggregation {
unwind {
path("fieldName")
includeArrayIndex = "index"
preserveNullAndEmptyArrays = true
}
}

// then
aggregation.toString() shouldBe Aggregation.newAggregation(
Aggregation.unwind("fieldName", "index", true),
).toString()
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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.shouldNotBeNull

internal class UnwindStageDslTest : FreeSpec({
fun unwind(block: UnwindStageDsl.() -> Unit) =
UnwindStageDsl().apply(block)

"path" - {
"should add a field by string" {
// given
val stage = unwind {
path("field")
}

// when
val result = stage.build()

// then
result.shouldNotBeNull()
result.shouldBeJson(
"""
{
"${'$'}unwind": "${'$'}field"
}
""".trimIndent(),
)
}

"should add a field by property" {
// given
data class Collection(val property: String)
val stage = unwind {
path(Collection::property)
}

// when
val result = stage.build()

// then
result.shouldNotBeNull()
result.shouldBeJson(
"""
{
"${'$'}unwind": "${'$'}property"
}
""".trimIndent(),
)
}
}

"includeArrayIndex" - {
"should build an array index option" {
// given
val stage = unwind {
path("field")
includeArrayIndex = "index"
}

// when
val result = stage.build()

// then
result.shouldNotBeNull()
result.shouldBeJson(
"""
{
"${'$'}unwind": {
"path": "${'$'}field",
"includeArrayIndex": "index",
"preserveNullAndEmptyArrays": false
}
}
""".trimIndent(),
)
}
}

"preserveNullAndEmptyArrays" - {
"should build a preserve null and empty arrays option" {
// given
val stage = unwind {
path("field")
preserveNullAndEmptyArrays = true
}

// when
val result = stage.build()

// then
result.shouldNotBeNull()
result.shouldBeJson(
"""
{
"${'$'}unwind": {
"path": "${'$'}field",
"preserveNullAndEmptyArrays": true
}
}
""".trimIndent(),
)
}
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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.REMOVE
import org.springframework.data.mongodb.core.aggregation.AggregationResults
import org.springframework.data.mongodb.core.aggregation.ComparisonOperators
import org.springframework.data.mongodb.core.aggregation.ConditionalOperators
import org.springframework.stereotype.Repository

@Repository
class ProjectRepository(
private val mongoTemplate: MongoTemplate,
) {

data class Author(val first: String, val last: String, val middle: String?)
data class TitleAndAuthorDto(
val id: String,
val title: String,
val author: Author,
)

/**
* @see <a href="https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/#conditionally-exclude-fields">Conditionally Exclude Fields</a>
*/
fun excludeConditionally(): AggregationResults<TitleAndAuthorDto> {
val aggregation = aggregation {
project {
+"title"
"author.first" alias "author.first"
"author.last" alias "author.last"
"author.middle" expression ConditionalOperators.`when`(
ComparisonOperators.valueOf("author.middle").equalToValue(""),
).thenValueOf(REMOVE).otherwiseValueOf("author.middle")
}
}

return mongoTemplate.aggregate(aggregation, BOOKS, TitleAndAuthorDto::class.java)
}

companion object {
const val BOOKS = "books"
}
}
Loading