From 109be7afef8be11d4a62a78779eb0d1d5f48b477 Mon Sep 17 00:00:00 2001 From: Jake Son Date: Thu, 28 Sep 2023 19:26:50 +0900 Subject: [PATCH 1/2] feat: implement moreLikeThis search operator --- .../search/MoreLikeThisSearchOperatorDsl.kt | 44 ++++++++++++ .../core/aggregation/search/SearchOperator.kt | 9 +++ .../aggregation/search/SearchOperatorDsl.kt | 4 ++ .../MoreLikeThisSearchOperatorDslTest.kt | 72 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDsl.kt create mode 100644 core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDslTest.kt diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDsl.kt new file mode 100644 index 00000000..626e81e7 --- /dev/null +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDsl.kt @@ -0,0 +1,44 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +import com.github.inflab.spring.data.mongodb.core.annotation.AggregationMarker +import org.bson.Document + +/** + * A Kotlin DSL to configure text moreLikeThis operator using idiomatic Kotlin code. + * + * @author Jake Son + * @since 1.0 + * @see moreLikeThis + */ +@AggregationMarker +class MoreLikeThisSearchOperatorDsl { + private val document = Document() + + /** + * One or more BSON documents that Atlas Search uses to extract representative terms to query for. + * + * @param bson One BSON document or an array of documents. + */ + fun like(vararg bson: Document) { + document["like"] = bson.toList() + } + + /** + * Configures the Score to assign 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. + * + * When you query values in arrays, Atlas Search doesn't alter the score of the matching results based on the number of values inside the array that matched the query. + * The score would be the same as a single match regardless of the number of matches inside an array. + * + * @see Score the Documents in the Results + */ + fun score(scoreConfiguration: ScoreSearchOptionDsl.() -> Unit) { + document["score"] = ScoreSearchOptionDsl().apply(scoreConfiguration).get() + } + + internal fun build() = Document("moreLikeThis", 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 13599d5a..d2605927 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 @@ -99,4 +99,13 @@ interface SearchOperator { * @see geoWithin */ fun geoWithin(configuration: GeoWithinSearchOperatorDsl.() -> Unit) + + /** + * Returns documents similar to input documents. + * The moreLikeThis operator allows you to build features for your applications that display similar or alternative results based on one or more given documents. + * + * @param configuration The configuration block for the [MoreLikeThisSearchOperatorDsl]. + * @see moreLikeThis + */ + fun moreLikeThis(configuration: MoreLikeThisSearchOperatorDsl.() -> 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 5a37c434..23d01167 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 @@ -49,4 +49,8 @@ class SearchOperatorDsl : SearchOperator { override fun geoWithin(configuration: GeoWithinSearchOperatorDsl.() -> Unit) { operators.add(GeoWithinSearchOperatorDsl().apply(configuration).build()) } + + override fun moreLikeThis(configuration: MoreLikeThisSearchOperatorDsl.() -> Unit) { + operators.add(MoreLikeThisSearchOperatorDsl().apply(configuration).build()) + } } diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDslTest.kt new file mode 100644 index 00000000..b9c7c739 --- /dev/null +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/MoreLikeThisSearchOperatorDslTest.kt @@ -0,0 +1,72 @@ +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 +import org.bson.Document + +internal class MoreLikeThisSearchOperatorDslTest : FreeSpec({ + fun moreLikeThis(block: MoreLikeThisSearchOperatorDsl.() -> Unit) = + MoreLikeThisSearchOperatorDsl().apply(block) + + "like" - { + "should build a like option" { + // given + val operator = moreLikeThis { + like( + Document("foo", "bar"), + Document("baz", "qux"), + ) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "moreLikeThis": { + "like": [ + { + "foo": "bar" + }, + { + "baz": "qux" + } + ] + } + } + """.trimIndent(), + ) + } + } + + "score" - { + "should build a score option" { + // given + val operator = moreLikeThis { + score { + constant(2.0) + } + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "moreLikeThis": { + "score": { + "constant": { + "value": 2.0 + } + } + } + } + """.trimIndent(), + ) + } + } +}) From 021939ac3bf450a18a5f88881f8025ffdceb84f6 Mon Sep 17 00:00:00 2001 From: Jake Son Date: Thu, 28 Sep 2023 19:59:12 +0900 Subject: [PATCH 2/2] test: add moreLikeThis operator examples --- .../data/mongodb/entity/mflix/Movies.kt | 4 +- .../MoreLikeThisSearchRepository.kt | 137 ++++++++++++++++++ .../MoreLikeThisSearchRepositoryTest.kt | 78 ++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepository.kt create mode 100644 example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepositoryTest.kt diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/mflix/Movies.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/mflix/Movies.kt index 14a1b2b0..0a468dbc 100644 --- a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/mflix/Movies.kt +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/mflix/Movies.kt @@ -9,14 +9,14 @@ data class Movies( @Id val id: String, val plot: String, - val genres: List, + val genres: List?, val runtime: Int, val cast: List, val poster: String, val title: String, val fullplot: String, val languages: List, - val released: LocalDateTime, + val released: LocalDateTime?, val directors: List, val rated: String, val lastupdated: LocalDateTime, diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepository.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepository.kt new file mode 100644 index 00000000..706d5ca6 --- /dev/null +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepository.kt @@ -0,0 +1,137 @@ +package com.github.inflab.example.spring.data.mongodb.repository + +import com.github.inflab.example.spring.data.mongodb.entity.mflix.Movies +import com.github.inflab.spring.data.mongodb.core.aggregation.aggregation +import com.github.inflab.spring.data.mongodb.core.mapping.rangeTo +import org.bson.Document +import org.bson.types.ObjectId +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 +import java.time.LocalDateTime + +@Repository +class MoreLikeThisSearchRepository( + private val mongoTemplate: MongoTemplate, +) { + + data class TitleAndReleasedAndGenres(val title: String, val released: LocalDateTime?, val genres: List?) + + /** + * @see Single Document with Multiple Fields + */ + fun findTitleAndGenres(): AggregationResults { + val aggregation = aggregation { + search { + moreLikeThis { + like( + Document("title", "The Godfather").append("genres", "action"), + ) + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + +Movies::released + +Movies::genres + } + } + + return mongoTemplate.aggregate(aggregation) + } + + /** + * @see Input Document Excluded in Results + */ + fun findByMovie(): AggregationResults { + val movie = Document("_id", ObjectId("573a1396f29313caabce4a9a")) + .append("genres", listOf("Crime", "Drama")) + .append("title", "The Godfather") + val aggregation = aggregation { + search { + compound { + must { + moreLikeThis { + like(movie) + } + } + + mustNot { + equal { + path("_id") + value(ObjectId("573a1396f29313caabce4a9a")) + } + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + +Movies::released + +Movies::genres + } + } + + return mongoTemplate.aggregate(aggregation) + } + + data class IdAndTitleAndGenres(val id: String, val title: String, val genres: List?) + + /** + * @see Multiple Analyzers + */ + fun findByMovies(): AggregationResults { + val movies = listOf( + Document("_id", ObjectId("573a1394f29313caabcde9ef")) + .append("plot", "Alice stumbles into the world of Wonderland. Will she get home? Not if the Queen of Hearts has her way.") + .append("title", "Alice in Wonderland"), + Document("_id", ObjectId("573a1398f29313caabce963d")) + .append("plot", "Alice is in Looking Glass land, where she meets many Looking Glass creatures and attempts to avoid the Jabberwocky, a monster that appears due to her being afraid.") + .append("title", "Alice in Wonderland"), + Document("_id", ObjectId("573a1398f29313caabce9644")) + .append("plot", "Alice is in Looking Glass land, where she meets many Looking Glass creatures and attempts to avoid the Jabberwocky, a monster that appears due to her being afraid.") + .append("title", "Alice in Wonderland"), + Document("_id", ObjectId("573a139df29313caabcfb504")) + .append("plot", "The wizards behind The Odyssey (1997) and Merlin (1998) combine Lewis Carroll's \"Alice in Wonderland\" and \"Through the Looking Glass\" into a two-hour special that just gets curiouser and curiouser.") + .append("title", "Alice in Wonderland"), + Document("_id", ObjectId("573a13bdf29313caabd5933b")) + .append("plot", "Nineteen-year-old Alice returns to the magical world from her childhood adventure, where she reunites with her old friends and learns of her true destiny: to end the Red Queen's reign of terror.") + .append("title", "Alice in Wonderland"), + ) + val aggregation = aggregation { + search { + compound { + must { + moreLikeThis { + // change list of movie to vararg + like(*movies.toTypedArray()) + } + } + + mustNot { + equal { + path("_id") + value(ObjectId("573a1394f29313caabcde9ef")) + } + } + } + } + + // TODO: add $limit stage + + project { + +Movies::title + +Movies::genres + } + } + + return mongoTemplate.aggregate(aggregation) + } +} diff --git a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepositoryTest.kt b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepositoryTest.kt new file mode 100644 index 00000000..c5a5acab --- /dev/null +++ b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/MoreLikeThisSearchRepositoryTest.kt @@ -0,0 +1,78 @@ +package com.github.inflab.example.spring.data.mongodb.repository + +import com.github.inflab.example.spring.data.mongodb.extension.AtlasTest +import io.kotest.core.annotation.Ignored +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe + +@Ignored +@AtlasTest(database = "sample_mflix") +internal class MoreLikeThisSearchRepositoryTest( + private val moreLikeThisSearchRepository: MoreLikeThisSearchRepository, +) : FreeSpec({ + + "findTitleAndGenres" { + // when + val result = moreLikeThisSearchRepository.findTitleAndGenres() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "Godfather", + "The Godfather", + "The Godfather: Part II", + "The Godfather: Part III", + "The Defender", + ) + result.mappedResults.take(5).map { it.genres } shouldBe listOf( + listOf("Comedy", "Drama", "Romance"), + listOf("Crime", "Drama"), + listOf("Crime", "Drama"), + listOf("Crime", "Drama"), + listOf("Action"), + ) + } + + "findByMovie" { + // when + val result = moreLikeThisSearchRepository.findByMovie() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "Godfather", + "The Godfather: Part II", + "The Godfather: Part III", + "The Testimony", + "The Bandit", + + ) + result.mappedResults.take(5).map { it.genres } shouldBe listOf( + listOf("Comedy", "Drama", "Romance"), + listOf("Crime", "Drama"), + listOf("Crime", "Drama"), + listOf("Crime", "Drama"), + listOf("Crime", "Drama"), + ) + } + + "findByMovies" { + // when + val result = moreLikeThisSearchRepository.findByMovies() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "Alice in Wonderland", + "Alice in Wonderland", + "Alice in Wonderland", + "Alice in Wonderland", + "Alex in Wonderland", + + ) + result.mappedResults.take(5).map { it.genres } shouldBe listOf( + listOf("Adventure", "Family", "Fantasy"), + listOf("Adventure", "Family", "Fantasy"), + listOf("Adventure", "Comedy", "Family"), + listOf("Adventure", "Family", "Fantasy"), + listOf("Comedy", "Drama"), + ) + } +})