diff --git a/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDsl.kt b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDsl.kt new file mode 100644 index 00000000..d9d99f66 --- /dev/null +++ b/core/src/main/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDsl.kt @@ -0,0 +1,141 @@ +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.firstOrAll +import com.github.inflab.spring.data.mongodb.core.extension.toDotPath +import org.bson.Document +import org.springframework.data.mongodb.core.geo.GeoJsonPoint +import java.time.temporal.Temporal +import kotlin.reflect.KProperty + +/** + * A Kotlin DSL to configure near search operator using idiomatic Kotlin code. + * + * @author username1103 + * @since 1.0 + * @see near + */ +@AggregationMarker +class NearSearchOperatorDsl { + private val document = Document() + + /** + * Indexed field or fields to search. + * See Path Construction. + * + * @param path The indexed field or fields to search. + * @see Path Construction + */ + fun path(vararg path: String) { + document["path"] = path.toList().firstOrAll() + } + + /** + * Indexed number type field or fields to search. + * See Path Construction. + * + * @param path The indexed field or fields to search. + * @see Path Construction + */ + @JvmName("pathNumber") + fun path(vararg path: KProperty) { + document["path"] = path.map { it.toDotPath() }.firstOrAll() + } + + /** + * Indexed date type field or fields to search. + * See Path Construction. + * + * @param path The indexed field or fields to search. + * @see Path Construction + */ + @JvmName("pathDate") + fun path(vararg path: KProperty) { + document["path"] = path.map { it.toDotPath() }.firstOrAll() + } + + /** + * Indexed geo type field or fields to search. + * See Path Construction for more information. + * + * @param path The indexed field or fields to search. + * @see Path Construction + */ + @JvmName("pathPoint") + fun path(vararg path: KProperty) { + document["path"] = path.map { it.toDotPath() }.firstOrAll() + } + + /** + * Origin to query for Number field. + * This is the origin from which the proximity of the results is measured. + * + * For number fields, the value must be of BSON int32, int64, or double data types. + */ + fun origin(origin: Number) { + document["origin"] = origin + } + + /** + * Origin to query for Date field. + * This is the origin from which the proximity of the results is measured. + * + * For date fields, the value must be an ISODate formatted date. + * @see ISODate + */ + fun origin(origin: Temporal) { + document["origin"] = origin + } + + /** + * Origin to query for Geo field. + * This is the origin from which the proximity of the results is measured. + * + * For geo fields. the value must be a GeoJSON point. + * @see GeoJson Point + */ + fun origin(origin: GeoJsonPoint) { + document["origin"] = Document("type", "Point").append("coordinates", origin.coordinates) + } + + /** + * Value to use to calculate scores of Atlas Search result documents. Score is calculated using the following formula: + * + * ``` + * pivot + * score = ------------------ + * pivot + distance + * ``` + * + * where distance is the difference between origin and the indexed field value. + * + * Results have a score equal to 1/2 (or 0.5) when their indexed field value is pivot units away from origin. + * The value of pivot must be greater than (i.e. >) 0. + * + * If origin is a: + * - Number, pivot can be specified as an integer or floating point number. + * - Date, pivot must be specified in milliseconds and can be specified as a 32 or 64 bit integer. + * - GeoJSON point, pivot is measured in meters and must be specified as an integer or floating point number. + * + * @param pivot The value to use to calculate scores of Atlas Search result documents. + */ + fun pivot(pivot: Number) { + document["pivot"] = pivot + } + + /** + * The score assigned to matching search term results. Use one of the following options to modify the score: + * + * - 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. + * + * @param scoreConfiguration The configuration block for [ScoreSearchOptionDsl] + * @see Modify the Score + */ + fun score(scoreConfiguration: ScoreSearchOptionDsl.() -> Unit) { + document["score"] = ScoreSearchOptionDsl().apply(scoreConfiguration).get() + } + + internal fun build() = Document("near", 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 20f3ec8b..7232be08 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 @@ -125,4 +125,14 @@ interface SearchOperator { * @see queryString */ fun queryString(configuration: QueryStringSearchOperatorDsl.() -> Unit) + + /** + * Supports querying and scoring numeric, date, and GeoJSON point values. + * You can use the near operator to find results that are near a number or a date. + * The near operator scores the Atlas Search results by proximity to the number or date. + * + * @param configuration The Configuration block for the [NearSearchOperatorDsl]. + * @see near + */ + fun near(configuration: NearSearchOperatorDsl.() -> 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 d022600d..99d7ec69 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 @@ -61,4 +61,8 @@ class SearchOperatorDsl : SearchOperator { override fun queryString(configuration: QueryStringSearchOperatorDsl.() -> Unit) { operators.add(QueryStringSearchOperatorDsl().apply(configuration).build()) } + + override fun near(configuration: NearSearchOperatorDsl.() -> Unit) { + operators.add(NearSearchOperatorDsl().apply(configuration).build()) + } } diff --git a/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDslTest.kt b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDslTest.kt new file mode 100644 index 00000000..b1195db6 --- /dev/null +++ b/core/src/test/kotlin/com/github/inflab/spring/data/mongodb/core/aggregation/search/NearSearchOperatorDslTest.kt @@ -0,0 +1,237 @@ +package com.github.inflab.spring.data.mongodb.core.aggregation.search + +import com.github.inflab.spring.data.mongodb.core.mapping.rangeTo +import com.github.inflab.spring.data.mongodb.core.util.shouldBeJson +import io.kotest.core.spec.style.FreeSpec +import org.springframework.data.mongodb.core.geo.GeoJsonPoint +import java.time.LocalDate +import java.time.LocalDateTime + +internal class NearSearchOperatorDslTest : FreeSpec({ + fun near(block: NearSearchOperatorDsl.() -> Unit): NearSearchOperatorDsl = + NearSearchOperatorDsl().apply(block) + + "path" - { + "should build a path by strings" { + // given + val operator = near { + path("path1", "path2") + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "path": [ + "path1", + "path2" + ] + } + } + """.trimIndent(), + ) + } + + "should build a path by multiple properties" { + // given + data class TestCollection(val path1: Number, val path2: Number) + + val operator = near { + path(TestCollection::path1, TestCollection::path2) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "path": [ + "path1", + "path2" + ] + } + } + """.trimIndent(), + ) + } + + "should build a path by nested property" { + // given + data class Child(val path: Number) + data class Parent(val child: Child) + + val operator = near { + path(Parent::child..Child::path) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "path": "child.path" + } + } + """.trimIndent(), + ) + } + } + + "origin" - { + "should build a origin by number" { + // given + val operator = near { + origin(123) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "origin": 123 + } + } + """.trimIndent(), + ) + } + + "should build a origin by LocalDateTime" { + // given + val operator = near { + origin(LocalDateTime.of(2023, 9, 18, 4, 10, 50, 1)) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "origin": { + "${'$'}date": "2023-09-18T04:10:50Z" + } + } + } + """.trimIndent(), + ) + } + + "should build a origin by LocalDate" { + // given + val operator = near { + origin(LocalDate.of(2023, 9, 18)) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "origin": { + "${'$'}date": "2023-09-18T00:00:00Z" + } + } + } + """.trimIndent(), + ) + } + + "should build a origin by GeoJson Point" { + // given + val operator = near { + origin(GeoJsonPoint(1.0, 2.0)) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "origin": { + "type": "Point", + "coordinates": [ + 1.0, + 2.0 + ] + } + } + } + """.trimIndent(), + ) + } + } + + "pivot" - { + "should build a pivot by Number" { + // given + val operator = near { + pivot(123) + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "pivot": 123 + } + } + """.trimIndent(), + ) + } + } + + "score" - { + "should build a score" { + // given + val operator = near { + score { + boost(5.0) + } + } + + // when + val result = operator.build() + + // then + result.shouldBeJson( + """ + { + "near": { + "score": { + "boost": { + "value": 5.0 + } + } + } + } + """.trimIndent(), + ) + } + } +}) diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/annotation/Database.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/annotation/Database.kt new file mode 100644 index 00000000..9bc70556 --- /dev/null +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/annotation/Database.kt @@ -0,0 +1,4 @@ +package com.github.inflab.example.spring.data.mongodb.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class Database(val value: String) diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/airbnb/ListingsAndReviews.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/airbnb/ListingsAndReviews.kt index 9e5d3947..2eca7316 100644 --- a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/airbnb/ListingsAndReviews.kt +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/entity/airbnb/ListingsAndReviews.kt @@ -2,11 +2,14 @@ package com.github.inflab.example.spring.data.mongodb.entity.airbnb import org.springframework.data.annotation.Id import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.core.mapping.Field @Document("listingsAndReviews") data class ListingsAndReviews( @Id val id: String, val name: String, + @Field("property_type") + val propertyType: String, val address: ListingsAndReviewsAddress, ) diff --git a/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepository.kt b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepository.kt new file mode 100644 index 00000000..47e17c21 --- /dev/null +++ b/example/spring-data-mongodb/src/main/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepository.kt @@ -0,0 +1,163 @@ +package com.github.inflab.example.spring.data.mongodb.repository.atlas + +import com.github.inflab.example.spring.data.mongodb.annotation.Database +import com.github.inflab.example.spring.data.mongodb.entity.airbnb.ListingsAndReviews +import com.github.inflab.example.spring.data.mongodb.entity.airbnb.ListingsAndReviewsAddress +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.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.aggregate +import org.springframework.data.mongodb.core.aggregation.AggregationResults +import org.springframework.data.mongodb.core.geo.GeoJsonPoint +import org.springframework.data.mongodb.core.mapping.Field +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class NearSearchRepository( + @Database("sample_mflix") private val mflixMongoTemplate: MongoTemplate, + @Database("sample_airbnb") private val airbnbMongoTemplate: MongoTemplate, +) { + + data class RuntimeDto( + val title: String, + val runtime: Int, + val score: Double, + ) + + data class ReleasedDto( + val title: String, + val released: LocalDateTime, + val score: Double, + ) + + data class GeoDto( + val name: String, + val address: ListingsAndReviewsAddress, + val score: Double, + ) + + data class GeoPropertyTypeDto( + @Field("property_type") + val propertyType: String, + val address: ListingsAndReviewsAddress, + val score: Double, + ) + + /** + * @see Number Example + */ + fun findByRuntime(): AggregationResults { + val aggregation = aggregation { + search { + index = "runtimes" + near { + path(Movies::runtime) + origin(279) + pivot(2) + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + +Movies::runtime + searchScore() + } + } + + return mflixMongoTemplate.aggregate(aggregation) + } + + /** + * @see Date Example + */ + fun findByDate(): AggregationResults { + val aggregation = aggregation { + search { + index = "releaseddate" + near { + path(Movies::released) + origin(LocalDateTime.of(1915, 9, 13, 0, 0, 0)) + pivot(7776000000) + } + } + + // TODO: add $limit stage + + project { + excludeId() + +Movies::title + +Movies::released + searchScore() + } + } + + return mflixMongoTemplate.aggregate(aggregation) + } + + /** + * @see GeoJSON Point Basic Example + */ + fun findByGeo(): AggregationResults { + val aggregation = aggregation { + search { + near { + path(ListingsAndReviews::address..ListingsAndReviewsAddress::location) + origin(GeoJsonPoint(-8.61308, 41.1413)) + pivot(1000) + } + } + + // TODO: add $limit stage + + project { + excludeId() + +ListingsAndReviews::name + +ListingsAndReviews::address + searchScore() + } + } + + return airbnbMongoTemplate.aggregate(aggregation) + } + + /** + * @see GeoJSON Point Compound Example + */ + fun findByGeoWithCompound(): AggregationResults { + val aggregation = aggregation { + search { + compound { + must { + text { + query("Apartment") + path(ListingsAndReviews::propertyType) + } + } + should { + near { + origin(GeoJsonPoint(114.15027, 22.28158)) + pivot(1000) + path(ListingsAndReviews::address..ListingsAndReviewsAddress::location) + } + } + } + } + + // TODO: add $limit stage + + project { + excludeId() + +ListingsAndReviews::propertyType + +ListingsAndReviews::address + searchScore() + } + } + + return airbnbMongoTemplate.aggregate(aggregation) + } +} diff --git a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/extension/AtlasTestConstructorExtension.kt b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/extension/AtlasTestConstructorExtension.kt index da030b9c..9af89755 100644 --- a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/extension/AtlasTestConstructorExtension.kt +++ b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/extension/AtlasTestConstructorExtension.kt @@ -1,5 +1,6 @@ package com.github.inflab.example.spring.data.mongodb.extension +import com.github.inflab.example.spring.data.mongodb.annotation.Database import io.kotest.core.annotation.AutoScan import io.kotest.core.extensions.ConstructorExtension import io.kotest.core.spec.Spec @@ -7,36 +8,43 @@ import org.springframework.boot.env.YamlPropertySourceLoader import org.springframework.core.io.ClassPathResource import org.springframework.data.mongodb.core.MongoTemplate import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory +import org.springframework.stereotype.Repository import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.primaryConstructor @AutoScan internal object AtlasTestConstructorExtension : ConstructorExtension { private val ATLAS_DOMAIN by lazy { val property = YamlPropertySourceLoader().load("env", ClassPathResource("application.yml")).first() - val username = property.getProperty("spring.data.mongodb.username") ?: throw IllegalStateException("spring.data.mongodb.username is not set") - val password = property.getProperty("spring.data.mongodb.password") ?: throw IllegalStateException("spring.data.mongodb.password is not set") - val host = property.getProperty("spring.data.mongodb.host") ?: throw IllegalStateException("spring.data.mongodb.host is not set") + val username = property.getProperty("spring.data.mongodb.username") + ?: throw IllegalStateException("spring.data.mongodb.username is not set") + val password = property.getProperty("spring.data.mongodb.password") + ?: throw IllegalStateException("spring.data.mongodb.password is not set") + val host = property.getProperty("spring.data.mongodb.host") + ?: throw IllegalStateException("spring.data.mongodb.host is not set") "$username:$password@$host" } override fun instantiate(clazz: KClass): Spec? { - val atlasTest = clazz.annotations.find { it is AtlasTest } as AtlasTest? ?: return null + val atlasTest = clazz.findAnnotation() ?: return null val testConstructor = clazz.primaryConstructor if (testConstructor == null || testConstructor.parameters.isEmpty()) { return null } - val connectionString = "mongodb+srv://$ATLAS_DOMAIN/${atlasTest.database}?retryWrites=true&w=majority" - val mongoTemplate = MongoTemplate(SimpleMongoClientDatabaseFactory(connectionString)) + val connectionString = getConnectionString(atlasTest.database) + val mongoTemplate = getMongoTemplate(connectionString) val parameters = testConstructor.parameters.associateWith { parameter -> val parameterClass = parameter.type.classifier as KClass<*> - check(parameterClass.annotations.any { it is org.springframework.stereotype.Repository }) { + + checkNotNull(parameterClass.findAnnotation()) { "The parameter type of constructor must be annotated with @Repository but ${parameterClass.qualifiedName}" } + val repositoryConstructor = checkNotNull(parameterClass.primaryConstructor) { "The parameter type of constructor must have primary constructor but ${parameterClass.qualifiedName}" } @@ -47,7 +55,9 @@ internal object AtlasTestConstructorExtension : ConstructorExtension { "The parameter type of repository constructor must be MongoTemplate but ${repositoryParameterClass.qualifiedName} from ${parameterClass.qualifiedName}" } - mongoTemplate + repositoryParameter.findAnnotation()?.let { + getMongoTemplate(getConnectionString(it.value)) + } ?: mongoTemplate } repositoryConstructor.callBy(repositoryParameters) @@ -55,4 +65,12 @@ internal object AtlasTestConstructorExtension : ConstructorExtension { return testConstructor.callBy(parameters) } + + private fun getConnectionString(databaseName: String): String { + return "mongodb+srv://$ATLAS_DOMAIN/$databaseName?retryWrites=true&w=majority" + } + + private fun getMongoTemplate(connectionString: String): MongoTemplate { + return MongoTemplate(SimpleMongoClientDatabaseFactory(connectionString)) + } } diff --git a/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepositoryTest.kt b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepositoryTest.kt new file mode 100644 index 00000000..793b9004 --- /dev/null +++ b/example/spring-data-mongodb/src/test/kotlin/com/github/inflab/example/spring/data/mongodb/repository/atlas/NearSearchRepositoryTest.kt @@ -0,0 +1,76 @@ +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.shouldBe +import org.springframework.data.mongodb.core.geo.GeoJsonPoint + +@AtlasTest(database = "sample_mflix") +class NearSearchRepositoryTest( + private val nearSearchRepository: NearSearchRepository, +) : FreeSpec({ + + "findByRuntime" { + // when + val result = nearSearchRepository.findByRuntime() + + // then + result.mappedResults.take(5).map { it.title } shouldBe listOf( + "The Kingdom", + "The Jinx: The Life and Deaths of Robert Durst", + "Shoah", + "Les Misèrables", + "Tokyo Trial", + ) + + result.mappedResults.take(5).map { it.runtime } shouldBe listOf( + 279, + 279, + 280, + 281, + 277, + ) + } + + "findByDate" { + // when + val result = nearSearchRepository.findByDate() + + // then + result.mappedResults.take(3).map { it.title } shouldBe listOf( + "Regeneration", + "The Cheat", + "Hell's Hinges", + ) + } + + "findByGeo" { + // when + val result = nearSearchRepository.findByGeo() + + // then + result.mappedResults.take(3).map { it.name } shouldBe listOf( + "Ribeira Charming Duplex", + "DB RIBEIRA - Grey Apartment", + "Ribeira 24 (4)", + ) + } + + "findByGeoWithCompound" { + // when + val result = nearSearchRepository.findByGeoWithCompound() + + // then + result.mappedResults.take(3).map { it.propertyType } shouldBe listOf( + "Apartment", + "Apartment", + "Apartment", + ) + + result.mappedResults.take(3).map { it.address.location } shouldBe listOf( + GeoJsonPoint(114.15027, 22.28158), + GeoJsonPoint(114.15082, 22.28161), + GeoJsonPoint(114.15007, 22.28215), + ) + } +})