diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDsl.kt new file mode 100644 index 00000000..8a9ef583 --- /dev/null +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDsl.kt @@ -0,0 +1,147 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +import com.github.inflab.spring.data.mongodb.core.annotation.AggregationMarker + +/** + * A Kotlin DSL to configure queryString query option operator using idiomatic Kotlin code. + * + * @author minwoo + * @since 1.0 + * @see queryString options + */ +@AggregationMarker +class QueryStringQueryOptionDsl { + var query: Query? = null + + class Query(val value: String, val field: String?) { + override fun toString(): String { + return when (field) { + null -> value + else -> "$field:$value" + } + } + } + + /** + * Indicates 0 or more characters to match. + */ + val WILDCARD = "*" + + /** + * Indicates any single character to match. + */ + val QUESTION = "?" + + /** + * Creates a text query. + * + * @param value The value to search + * @param field Indexed field search + */ + fun text(value: String, field: String? = null): Query { + val escaped = value.replace("*", "\\*").replace("?", "\\?") + + return Query("\"$escaped\"", field) + } + + /** + * Creates a wildcard query. + * + * @param value The value to search + * @param field Indexed field to search + */ + fun wildcard(value: String, field: String? = null): Query { + return Query(value, field) + } + + /** + * Creates a regex query. + * + * @param pattern The pattern to search + * @param field Indexed field to search + */ + fun regex(pattern: String, field: String? = null): Query { + return Query("/$pattern/", field) + } + + /** + * Creates a range query. + * + * @param left The left value to search + * @param right The right value to search + * @param leftInclusion The left value is included in the range + * @param rightInclusion The right value is included in the range + * @param field Indexed field to search + */ + fun range(left: String, right: String, leftInclusion: Boolean = true, rightInclusion: Boolean = true, field: String? = null): Query { + val leftBracket = if (leftInclusion) "[" else "{" + val rightBracket = if (rightInclusion) "]" else "}" + + val leftExp = when (left) { + WILDCARD -> WILDCARD + QUESTION -> QUESTION + else -> "\"$left\"" + } + val rightExp = when (right) { + WILDCARD -> WILDCARD + QUESTION -> QUESTION + else -> "\"$right\"" + } + + return Query("$leftBracket$leftExp TO $rightExp$rightBracket", field) + } + + /** + * Creates a fuzzy query. + * + * @param value The value to search + * @param maxEdits Maximum number of single-character edits required to match the specified search term. + * @param field indexed field to search + */ + fun fuzzy(value: String, maxEdits: Int, field: String? = null): Query { + return Query("$value~$maxEdits", field) + } + + /** + * Creates a delimiters for subqueries. + * + * @param query The Query for subqueries. + */ + fun sub(query: Query, field: String? = null): Query { + return Query("${field?.let { "$it:" }.orEmpty()}(${query.value})", query.field) + } + + /** + * Creates an operator that indicates `NOT` boolean operator. + * Specified search value must be absent for a document to be included in the results. + * + * @param query The Query to apply. + */ + fun not(query: Query): Query { + return Query("NOT (${query.value})", query.field) + } + + /** + * Creates an operator that indicates `AND` boolean operator. + * All search values must be present for a document to be included in the results. + * + * @param query The Query to apply. + */ + infix fun Query.and(query: Query): Query { + return Query("$this AND $query", null) + } + + /** + * Creates an operator that indicates OR boolean operator. + * At least one of the search value must be present for a document to be included in the results. + * + * @param query The Query to apply. + */ + infix fun Query.or(query: Query): Query { + return Query("$this OR $query", null) + } + + internal fun build(): String { + return checkNotNull(query) { "query must not be null" }.toString() + } +} diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDsl.kt new file mode 100644 index 00000000..88b59cef --- /dev/null +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDsl.kt @@ -0,0 +1,76 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +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 kotlin.reflect.KProperty + +/** + * A Kotlin DSL to configure queryString search operator using idiomatic Kotlin code. + * + * @author minwoo + * @since 1.0 + * @see queryString + */ +@AggregationMarker +class QueryStringSearchOperatorDsl { + private val document = Document() + + /** + * The indexed field to search by default. + * Atlas Search only searches the field in `defaultPath` if you omit the field to search in the query. + * + * @param path The indexed field to search by default. + */ + fun defaultPath(path: String) { + document["defaultPath"] = path + } + + /** + * The indexed field to search by default. + * Atlas Search only searches the field in `defaultPath` if you omit the field to search in the query. + * + * @param path The indexed field to search by default. + */ + fun defaultPath(path: KProperty) { + document["defaultPath"] = path.toDotPath() + } + + /** + * The indexed field to search by default. + * Atlas Search only searches the field in `defaultPath` if you omit the field to search in the query. + * + * @param path The indexed field to search by default. + */ + @JvmName("defaultPathIterable") + fun defaultPath(path: KProperty?>) { + document["defaultPath"] = path.toDotPath() + } + + /** + * Specifies one or more indexed fields and values to search. + * Fields and values are colon-delimited. + * For example, to search the plot field for the string baseball, use `plot:baseball`. + * + * @param configuration The configuration block for the [QueryStringQueryOptionDsl]. + */ + fun query(configuration: QueryStringQueryOptionDsl.() -> Unit) { + document["query"] = QueryStringQueryOptionDsl().apply(configuration).build() + } + + /** + * The score assigned to matching search results. + * You can modify the default score using the following options: + * + * - boost: multiply the result score by the given number. + * - constant: replace the result score with the given number. + * - function: replace the result score using the given expression. + * + * @see Score the Documents in the Results + */ + fun score(scoreConfiguration: ScoreSearchOptionDsl.() -> Unit) { + document["score"] = ScoreSearchOptionDsl().apply(scoreConfiguration).get() + } + + internal fun build() = Document("queryString", document) +} diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperator.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperator.kt index e1f014a3..20f3ec8b 100644 --- a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperator.kt +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperator.kt @@ -116,4 +116,13 @@ interface SearchOperator { * @see wildcard */ fun wildcard(configuration: WildcardSearchOperatorDsl.() -> Unit) + + /** + * Supports querying a combination of indexed fields and values. + * You can perform text, wildcard, regular expression, fuzzy, and range searches on string fields using the queryString operator. + * + * @param configuration The configuration block for the [QueryStringSearchOperatorDsl]. + * @see queryString + */ + fun queryString(configuration: QueryStringSearchOperatorDsl.() -> Unit) } diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperatorDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperatorDsl.kt index 5bf6a2e2..d022600d 100644 --- a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperatorDsl.kt +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/SearchOperatorDsl.kt @@ -57,4 +57,8 @@ class SearchOperatorDsl : SearchOperator { override fun wildcard(configuration: WildcardSearchOperatorDsl.() -> Unit) { operators.add(WildcardSearchOperatorDsl().apply(configuration).build()) } + + override fun queryString(configuration: QueryStringSearchOperatorDsl.() -> Unit) { + operators.add(QueryStringSearchOperatorDsl().apply(configuration).build()) + } } diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDslTest.kt new file mode 100644 index 00000000..0dc11ac9 --- /dev/null +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringQueryOptionDslTest.kt @@ -0,0 +1,261 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe + +internal class QueryStringQueryOptionDslTest : FreeSpec({ + fun query(block: QueryStringQueryOptionDsl.() -> Unit) = + QueryStringQueryOptionDsl().apply(block) + + "text" - { + "should add a text" { + // given + val option = query { + query = text("search") + } + + // when + val result = option.build() + + // then + result shouldBe "\"search\"" + } + + "should add a text with field" { + // given + val option = query { + query = text("search", "field") + } + + // when + val result = option.build() + + // then + result shouldBe "field:\"search\"" + } + + "should escape special characters" { + // given + val option = query { + query = text("c?u*t") + } + + // when + val result = option.build() + + // then + result shouldBe "\"c\\?u\\*t\"" + } + } + + "wildcard" - { + "should add a wildcard" { + // given + val option = query { + query = wildcard("search$WILDCARD$QUESTION") + } + + // when + val result = option.build() + + // then + result shouldBe "search*?" + } + + "should add a wildcard with field" { + // given + val option = query { + query = wildcard("search", "field") + } + + // when + val result = option.build() + + // then + result shouldBe "field:search" + } + } + + "regex" - { + "should add a regex" { + // given + val option = query { + query = regex("search") + } + + // when + val result = option.build() + + // then + result shouldBe "/search/" + } + + "should add a regex with field" { + // given + val option = query { + query = regex("search", "field") + } + + // when + val result = option.build() + + // then + result shouldBe "field:/search/" + } + } + + "range" - { + "should add a inclusive range" { + // given + val option = query { + query = range("left", "right", leftInclusion = true, rightInclusion = true) + } + + // when + val result = option.build() + + // then + result shouldBe "[\"left\" TO \"right\"]" + } + + "should add a exclusive range" { + // given + val option = query { + query = range("left", "right", leftInclusion = false, rightInclusion = false) + } + + // when + val result = option.build() + + // then + result shouldBe "{\"left\" TO \"right\"}" + } + + "should add a half-open range" { + // given + val option = query { + query = range("left", "right", leftInclusion = true, rightInclusion = false) + } + + // when + val result = option.build() + + // then + result shouldBe "[\"left\" TO \"right\"}" + } + + "should not add double quote if wildcard is given" { + // given + val option = query { + query = range(WILDCARD, WILDCARD) + } + + // when + val result = option.build() + + // then + result shouldBe "[* TO *]" + } + } + + "fuzzy" - { + "should add a fuzzy" { + // given + val option = query { + query = fuzzy("search", 2) + } + + // when + val result = option.build() + + // then + result shouldBe "search~2" + } + + "should add a fuzzy with field" { + // given + val option = query { + query = fuzzy("search", 3, "field") + } + + // when + val result = option.build() + + // then + result shouldBe "field:search~3" + } + } + + "not" - { + "should add NOT block" { + // given + val option = query { + query = not(text("search")) + } + + // when + val result = option.build() + + // then + result shouldBe "NOT (\"search\")" + } + } + + "and" - { + "should add AND block" { + // given + val option = query { + query = text("search1") and text("search2") + } + + // when + val result = option.build() + + // then + result shouldBe "\"search1\" AND \"search2\"" + } + } + + "or" - { + "should add OR block" { + // given + val option = query { + query = text("search1") or text("search2") + } + + // when + val result = option.build() + + // then + result shouldBe "\"search1\" OR \"search2\"" + } + } + + "sub" - { + "should add subquery block" { + // given + val option = query { + query = sub(text("a") or text("b")) and text("c") + } + + // when + val result = option.build() + + // then + result shouldBe "(\"a\" OR \"b\") AND \"c\"" + } + + "should add subquery block with field" { + // given + val option = query { + query = sub(text("a") or text("b"), "c") + } + + // when + val result = option.build() + + // then + result shouldBe "c:(\"a\" OR \"b\")" + } + } +}) diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDslTest.kt new file mode 100644 index 00000000..5bc2b48e --- /dev/null +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/QueryStringSearchOperatorDslTest.kt @@ -0,0 +1,108 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +import com.github.inflab.spring.data.mongodb.core.util.shouldBeJson +import io.kotest.core.spec.style.FreeSpec + +internal class QueryStringSearchOperatorDslTest : FreeSpec({ + fun queryString(block: QueryStringSearchOperatorDsl.() -> Unit) = + QueryStringSearchOperatorDsl().apply(block) + + "defaultPath" - { + "should build a default path by string" { + // given + val operator = queryString { + defaultPath("path") + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "queryString": { + "defaultPath": "path" + } + } + """.trimIndent(), + ) + } + + "should build a default path by property" { + // given + data class Collection(val field: List?) + val operator = queryString { + defaultPath(Collection::field) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "queryString": { + "defaultPath": "field" + } + } + """.trimIndent(), + ) + } + } + + "query" - { + "should build a query" { + // given + val operator = queryString { + query { + query = text("search") + } + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "queryString": { + "query": "\"search\"" + } + } + """.trimIndent(), + ) + } + } + + "score" - { + "should build a score" { + // given + val operator = queryString { + score { + constant(1.0) + } + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "queryString": { + "score": { + "constant": { + "value": 1.0 + } + } + } + } + """.trimIndent(), + ) + } + } +}) diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepository.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepository.kt new file mode 100644 index 00000000..814c1109 --- /dev/null +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepository.kt @@ -0,0 +1,152 @@ +package com.github.inflab.example.spring.data.mongodb.repository.atlas + +import com.github.inflab.example.spring.data.mongodb.entity.mflix.Movies +import com.github.inflab.spring.data.mongodb.core.aggregation.aggregation +import com.mongodb.client.model.Projections.excludeId +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.aggregate +import org.springframework.data.mongodb.core.aggregation.AggregationResults +import org.springframework.stereotype.Repository + +@Repository +class QueryStringSearchRepository( + private val mongoTemplate: MongoTemplate, +) { + + data class PlotAndFullplotDto( + val title: String, + val plot: String, + val fullplot: String, + val score: Double, + ) + + /** + * @see Boolean Operator Queries (OR AND) + */ + fun findFullplotWithPlot(): AggregationResults { + val aggregation = aggregation { + search { + queryString { + defaultPath(Movies::fullplot) + query { + query = sub(text("captain") or text("kirk"), "plot") and text("enterprise") + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + +Movies::plot + +Movies::fullplot + searchScore() + } + } + + return mongoTemplate.aggregate(aggregation) + } + + data class TitleDto(val title: String) + + /** + * @see Range Queries (TO) + */ + fun findPoltWithTitleRange(): AggregationResults { + val aggregation = aggregation { + search { + queryString { + defaultPath(Movies::plot) + query { + query = range(left = "count", right = WILDCARD, leftInclusion = true, rightInclusion = true, field = "title") + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + } + } + + return mongoTemplate.aggregate(aggregation) + } + + /** + * @see Wildcard Queries (Fuzzy) + */ + fun findTitleByFuzzy(): AggregationResults { + val aggregation = aggregation { + search { + queryString { + defaultPath(Movies::title) + query { + query = fuzzy("catch", 2) + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + } + } + + return mongoTemplate.aggregate(aggregation) + } + + /** + * @see Wildcard Queries (Wildcard) + */ + fun findTitleByWildcard(): AggregationResults { + val aggregation = aggregation { + search { + queryString { + defaultPath(Movies::title) + query { + query = wildcard("cou*t?*") + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + } + } + + return mongoTemplate.aggregate(aggregation) + } + + /** + * @see Wildcard Queries (Regex) + */ + fun findTitleByRegex(): AggregationResults { + val aggregation = aggregation { + search { + queryString { + defaultPath(Movies::title) + query { + query = regex(".tal(y|ian)") + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + } + } + + return mongoTemplate.aggregate(aggregation) + } +} diff --git a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepositoryTest.kt b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepositoryTest.kt new file mode 100644 index 00000000..8d66f1d1 --- /dev/null +++ b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/QueryStringSearchRepositoryTest.kt @@ -0,0 +1,91 @@ +package com.github.inflab.example.spring.data.mongodb.repository.atlas + +import com.github.inflab.example.spring.data.mongodb.extension.AtlasTest +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.collections.shouldBeMonotonicallyDecreasing +import io.kotest.matchers.shouldBe + +@AtlasTest(database = "sample_mflix") +internal class QueryStringSearchRepositoryTest( + private val queryStringSearchRepository: QueryStringSearchRepository, +) : FreeSpec({ + + "findFullplotWithPlot" { + // when + val result = queryStringSearchRepository.findFullplotWithPlot() + + // then + result.mappedResults.take(3).map { it.title } shouldBe listOf( + "Star Trek: Generations", + "Star Trek V: The Final Frontier", + "Star Trek: The Motion Picture", + ) + result.mappedResults.take(3).map { it.score }.shouldBeMonotonicallyDecreasing() + } + + "findPoltWithTitleRange" { + // when + val result = queryStringSearchRepository.findPoltWithTitleRange() + + // then + result.mappedResults.take(10).map { it.title } shouldBe listOf( + "The Great Train Robbery", + "A Corner in Wheat", + "Winsor McCay, the Famous Cartoonist of the N.Y. Herald and His Moving Comics", + "Traffic in Souls", + "Gertie the Dinosaur", + "In the Land of the Head Hunters", + "The Perils of Pauline", + "The Italian", + "Regeneration", + "Where Are My Children?", + ) + } + + "findTitleByFuzzy" { + // when + val result = queryStringSearchRepository.findTitleByFuzzy() + + // then + result.mappedResults.take(10).map { it.title } shouldBe listOf( + "Catch-22", + "Catch That Girl", + "Catch That Kid", + "Catch a Fire", + "Catch Me Daddy", + "Death Watch", + "Patch Adams", + "Batch '81", + "Briar Patch", + "Night Watch", + ) + } + + "findTitleByWildcard" { + // when + val result = queryStringSearchRepository.findTitleByWildcard() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "Diary of a Country Priest", + "Cry, the Beloved Country", + "Raintree County", + "Ride the High Country", + "The Courtship of Eddie's Father", + ) + } + + "findTitleByRegex" { + // when + val result = queryStringSearchRepository.findTitleByRegex() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "The Italian", + "Marriage Italian Style", + "Jealousy, Italian Style", + "My Voyage to Italy", + "Italian for Beginners", + ) + } +})