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 moreLikeThis operator #50

Merged
merged 2 commits into from
Sep 28, 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
@@ -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 <a href="https://www.mongodb.com/docs/atlas/atlas-search/morelikethis">moreLikeThis</a>
*/
@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 <a href="https://www.mongodb.com/docs/atlas/atlas-search/scoring/#std-label-scoring-ref">Score the Documents in the Results</a>
*/
fun score(scoreConfiguration: ScoreSearchOptionDsl.() -> Unit) {
document["score"] = ScoreSearchOptionDsl().apply(scoreConfiguration).get()
}

internal fun build() = Document("moreLikeThis", document)
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,13 @@ interface SearchOperator {
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/geoWithin">geoWithin</a>
*/
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 <a href="https://www.mongodb.com/docs/atlas/atlas-search/morelikethis">moreLikeThis</a>
*/
fun moreLikeThis(configuration: MoreLikeThisSearchOperatorDsl.() -> Unit)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ data class Movies(
@Id
val id: String,
val plot: String,
val genres: List<String>,
val genres: List<String>?,
val runtime: Int,
val cast: List<String>,
val poster: String,
val title: String,
val fullplot: String,
val languages: List<String>,
val released: LocalDateTime,
val released: LocalDateTime?,
val directors: List<String>,
val rated: String,
val lastupdated: LocalDateTime,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>?)

/**
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/morelikethis/#example-1--single-document-with-multiple-fields">Single Document with Multiple Fields</a>
*/
fun findTitleAndGenres(): AggregationResults<TitleAndReleasedAndGenres> {
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<Movies, TitleAndReleasedAndGenres>(aggregation)
}

/**
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/morelikethis/#example-2--input-document-excluded-in-results">Input Document Excluded in Results</a>
*/
fun findByMovie(): AggregationResults<TitleAndReleasedAndGenres> {
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<Movies, TitleAndReleasedAndGenres>(aggregation)
}

data class IdAndTitleAndGenres(val id: String, val title: String, val genres: List<String>?)

/**
* @see <a href="https://www.mongodb.com/docs/atlas/atlas-search/morelikethis/#example-3--multiple-analyzers">Multiple Analyzers</a>
*/
fun findByMovies(): AggregationResults<IdAndTitleAndGenres> {
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<Movies, IdAndTitleAndGenres>(aggregation)
}
}
Original file line number Diff line number Diff line change
@@ -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"),
)
}
})