diff --git a/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsTopkResponse.kt b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsTopkResponse.kt new file mode 100644 index 000000000..0cc3c19db --- /dev/null +++ b/core/src/main/kotlin/com/kakao/actionbase/core/edge/payload/AggregationsTopkResponse.kt @@ -0,0 +1,36 @@ +package com.kakao.actionbase.core.edge.payload + +import com.kakao.actionbase.core.metadata.common.AggregationConstants + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue + +data class AggregationsTopkResponse( + val topks: List, + val count: Int, +) { + data class TopkItem( + val value: String, + val metric: Long, + val properties: Map, + ) + + companion object { + private val MAPPER = jacksonObjectMapper() + + fun from(payload: DataFrameEdgePayload): AggregationsTopkResponse { + val topkItems = + payload.edges.map { edge -> + TopkItem( + value = edge.target.toString(), + metric = (edge.properties[AggregationConstants.Topk.METRIC] as? Number)?.toLong() ?: 0L, + properties = + (edge.properties[AggregationConstants.Topk.ADDITIONAL_PROPERTIES] as? String) + ?.let { MAPPER.readValue>(it) } + ?: emptyMap(), + ) + } + return AggregationsTopkResponse(topks = topkItems, count = payload.count) + } + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationFunctions.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationFunctions.kt new file mode 100644 index 000000000..f7a383bda --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationFunctions.kt @@ -0,0 +1,9 @@ +package com.kakao.actionbase.engine.service + +internal fun parseFqn(fqn: String): Pair { + val dot = fqn.indexOf('.') + require(dot > 0 && dot < fqn.lastIndex) { + "table must be a fully-qualified `database.table`, got: $fqn" + } + return fqn.substring(0, dot) to fqn.substring(dot + 1) +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationQueryService.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationQueryService.kt new file mode 100644 index 000000000..2028333a4 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/AggregationQueryService.kt @@ -0,0 +1,43 @@ +package com.kakao.actionbase.engine.service + +import com.kakao.actionbase.core.edge.payload.DataFrameEdgePayload +import com.kakao.actionbase.engine.AggregationEngine +import com.kakao.actionbase.v2.engine.sql.ScanFilter + +import reactor.core.publisher.Mono + +class AggregationQueryService( + private val queryService: QueryService, + private val engine: AggregationEngine, +) { + fun topk( + database: String, + table: String, + topk: String, + entity: String? = null, + dimensionValues: Map = emptyMap(), + limit: Int = ScanFilter.defaultLimit, + offset: String? = null, + ): Mono { + val tb = engine.getTableBinding(database = database, alias = table) + val rank = + RankScan.from( + schema = tb.schema, + database = database, + table = tb.table, + topk = topk, + entity = entity, + dimensionValues = dimensionValues, + ) + + return queryService.scan( + database = rank.database, + table = rank.table, + index = rank.index, + start = rank.start, + direction = rank.direction, + limit = limit, + offset = offset, + ) + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/RankScan.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/RankScan.kt new file mode 100644 index 000000000..bd2b518e7 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/RankScan.kt @@ -0,0 +1,47 @@ +package com.kakao.actionbase.engine.service + +import com.kakao.actionbase.core.metadata.common.AggregationConstants +import com.kakao.actionbase.core.metadata.common.ModelSchema +import com.kakao.actionbase.v2.core.metadata.Direction + +internal data class RankScan( + val database: String, + val table: String, + val index: String, + val start: String, + val direction: Direction, +) { + companion object { + fun from( + schema: ModelSchema, + database: String, + table: String, + topk: String, + entity: String?, + dimensionValues: Map, + ): RankScan { + val config = + schema.topkByName[topk] + ?: throw IllegalArgumentException("Unknown topk `$topk` for $database.$table.") + val group = + schema.groupByTopkName[topk] + ?: throw IllegalArgumentException("Topk `$topk` of $database.$table is not declared on any group.") + val (rankDatabase, rankTable) = parseFqn(config.rank) + + return RankScan( + database = rankDatabase, + table = rankTable, + index = AggregationConstants.Topk.RANK_INDEX, + start = + AggregationConstants.Topk.rankSource( + database = database, + table = table, + topk = topk, + entity = if (config.entity == AggregationConstants.Topk.GLOBAL_ENTITY) AggregationConstants.Topk.GLOBAL_ENTITY else entity.orEmpty(), + dimensionValues = group.dimensionFields(config).map { dimensionValues[it.name].orEmpty() }, + ), + direction = Direction.OUT, + ) + } + } +} diff --git a/engine/src/test/kotlin/com/kakao/actionbase/engine/service/RankScanTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/RankScanTest.kt new file mode 100644 index 000000000..e22a9de62 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/engine/service/RankScanTest.kt @@ -0,0 +1,119 @@ +package com.kakao.actionbase.engine.service + +import com.kakao.actionbase.core.metadata.common.Aggregations +import com.kakao.actionbase.core.metadata.common.DirectionType +import com.kakao.actionbase.core.metadata.common.Field +import com.kakao.actionbase.core.metadata.common.Group +import com.kakao.actionbase.core.metadata.common.GroupType +import com.kakao.actionbase.core.metadata.common.ModelSchema +import com.kakao.actionbase.core.metadata.common.Topk +import com.kakao.actionbase.core.types.PrimitiveType +import com.kakao.actionbase.v2.core.metadata.Direction + +import org.junit.jupiter.api.Test + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe + +class RankScanTest { + @Test + fun `reads the rank table declared by the topk, in rank order`() { + val rank = rankScan() + + rank.database shouldBe "commerce" + rank.table shouldBe "orders_table__topk" + rank.index shouldBe "metric_desc" + rank.direction shouldBe Direction.OUT + } + + @Test + fun `keys the scan by the source table and the entity`() { + rankScan().start shouldBe "commerce|orders_table|top_purchased|user1" + } + + @Test + fun `orders the named values the way the group declares them, not the way they were passed`() { + val rank = + rankScan( + schema = schema(dimensionFields = listOf("category", "region")), + dimensionValues = mapOf("region" to "seoul", "category" to "fruit"), + ) + + rank.start shouldBe "commerce|orders_table|top_purchased|user1|fruit|seoul" + } + + @Test + fun `a value left out reads as empty`() { + val rank = + rankScan( + schema = schema(dimensionFields = listOf("category", "region")), + dimensionValues = mapOf("region" to "seoul"), + ) + + rank.start shouldBe "commerce|orders_table|top_purchased|user1||seoul" + } + + @Test + fun `a value the group does not declare is ignored`() { + val rank = + rankScan( + schema = schema(dimensionFields = listOf("category")), + dimensionValues = mapOf("category" to "fruit", "shoeSize" to "270"), + ) + + rank.start shouldBe "commerce|orders_table|top_purchased|user1|fruit" + } + + @Test + fun `a global topk keys by the sentinel and ignores the entity`() { + val rank = rankScan(schema = schema(entity = "__GLOBAL__"), entity = "user1") + + rank.start shouldBe "commerce|orders_table|top_purchased|__GLOBAL__" + } + + @Test + fun `rejects a topk the table does not declare`() { + shouldThrow { rankScan(topk = "top_viewed") } + } +} + +// region test fixtures + +private fun rankScan( + schema: ModelSchema = schema(), + topk: String = "top_purchased", + entity: String? = "user1", + dimensionValues: Map = emptyMap(), +): RankScan = + RankScan.from( + schema = schema, + database = "commerce", + table = "orders_table", + topk = topk, + entity = entity, + dimensionValues = dimensionValues, + ) + +private fun schema( + entity: String = "source", + dimensionFields: List = emptyList(), +): ModelSchema = + ModelSchema.Edge( + source = Field(type = PrimitiveType.STRING, comment = "user"), + target = Field(type = PrimitiveType.STRING, comment = "item"), + direction = DirectionType.OUT, + groups = + listOf( + Group( + group = "purchased_count", + type = GroupType.COUNT, + fields = listOf(Group.Field(name = "_target")) + dimensionFields.map { Group.Field(name = it) }, + aggregations = + Aggregations( + topk = listOf(Topk(topk = "top_purchased", entity = entity, dimension = "target", rank = "commerce.orders_table__topk")), + ), + ), + ), + ) + +// endregion diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryController.kt b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryController.kt new file mode 100644 index 000000000..4caf7aeb3 --- /dev/null +++ b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryController.kt @@ -0,0 +1,36 @@ +package com.kakao.actionbase.server.api.graph.v3.metadata + +import com.kakao.actionbase.core.edge.payload.AggregationsTopkResponse +import com.kakao.actionbase.engine.service.AggregationQueryService +import com.kakao.actionbase.server.payload.AggregationsTopkRequest +import com.kakao.actionbase.server.util.mapToResponseEntity +import com.kakao.actionbase.v2.engine.sql.ScanFilter + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +import reactor.core.publisher.Mono + +@RestController +class MetadataAggQueryController( + private val aggregationQueryService: AggregationQueryService, +) { + /** + * Reads a ranking over the body. A ranking is picked by naming values, and a query string cannot + * carry those names onto an immutable request, so the read is not offered over `GET`. + */ + @PostMapping("/aggregations/v1/databases/{database}/tables/{table}/topks/{topk}") + fun topk( + @PathVariable database: String, + @PathVariable table: String, + @PathVariable topk: String, + @RequestBody request: AggregationsTopkRequest, + ): Mono> = + aggregationQueryService + .topk(database, table, topk, request.entity, request.dimensionValues, request.limit ?: ScanFilter.defaultLimit, request.offset) + .map(AggregationsTopkResponse::from) + .mapToResponseEntity() +} diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/configuration/GraphConfiguration.kt b/server/src/main/kotlin/com/kakao/actionbase/server/configuration/GraphConfiguration.kt index 23fb8afdf..bc2c19ce5 100644 --- a/server/src/main/kotlin/com/kakao/actionbase/server/configuration/GraphConfiguration.kt +++ b/server/src/main/kotlin/com/kakao/actionbase/server/configuration/GraphConfiguration.kt @@ -2,6 +2,7 @@ package com.kakao.actionbase.server.configuration import com.kakao.actionbase.core.metadata.features.FeatureFlags import com.kakao.actionbase.engine.queue.QueueService +import com.kakao.actionbase.engine.service.AggregationQueryService import com.kakao.actionbase.engine.service.AggregationService import com.kakao.actionbase.engine.service.MutationService import com.kakao.actionbase.engine.service.QueryService @@ -160,6 +161,12 @@ class GraphConfiguration { handlers: List, ): AggregationService = AggregationService(engine, handlers) + @Bean + fun provideAggregationQueryService( + queryService: QueryService, + engine: V2BackedEngine, + ): AggregationQueryService = AggregationQueryService(queryService, engine) + @Bean fun provideMutationService( engine: V2BackedEngine, diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilter.kt b/server/src/main/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilter.kt index 3303c0c41..4afa72519 100644 --- a/server/src/main/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilter.kt +++ b/server/src/main/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilter.kt @@ -11,7 +11,8 @@ import org.springframework.web.server.WebFilterChain import reactor.core.publisher.Mono // Rejects non-GET methods on graph path prefixes with 403. -// Exceptions: read-only POST endpoints matched by readSuffixes. +// Exceptions: read-only POST endpoints, matched by readSuffixes, or by readSegments when the path ends in +// a variable and so has no fixed suffix to match on. class ReadOnlyRequestFilter : WebFilter { private val log = LoggerFactory.getLogger(ReadOnlyRequestFilter::class.java) @@ -23,6 +24,7 @@ class ReadOnlyRequestFilter : WebFilter { "/multi-edges/ids", "/query", ) + private val readSegments = setOf("/topks/") init { log.info("ReadOnlyRequestFilter is active. Write operations on {} will be rejected.", paths) @@ -50,5 +52,5 @@ class ReadOnlyRequestFilter : WebFilter { return exchange.response.writeWith(Mono.just(messageBuffer)) } - private fun isRead(path: String): Boolean = readSuffixes.any { path.endsWith(it) } + private fun isRead(path: String): Boolean = readSuffixes.any { path.endsWith(it) } || readSegments.any { path.contains(it) } } diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/payload/AggregationsTopkRequest.kt b/server/src/main/kotlin/com/kakao/actionbase/server/payload/AggregationsTopkRequest.kt new file mode 100644 index 000000000..bf0ac70b8 --- /dev/null +++ b/server/src/main/kotlin/com/kakao/actionbase/server/payload/AggregationsTopkRequest.kt @@ -0,0 +1,8 @@ +package com.kakao.actionbase.server.payload + +data class AggregationsTopkRequest( + val entity: String? = null, + val dimensionValues: Map = emptyMap(), + val limit: Int? = null, + val offset: String? = null, +) diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryControllerE2ETest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryControllerE2ETest.kt new file mode 100644 index 000000000..1a1fb00fe --- /dev/null +++ b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/metadata/MetadataAggQueryControllerE2ETest.kt @@ -0,0 +1,300 @@ +package com.kakao.actionbase.server.api.graph.v3.metadata + +import com.kakao.actionbase.core.edge.payload.AggregationsItemResponse +import com.kakao.actionbase.core.edge.payload.AggregationsTopkResponse +import com.kakao.actionbase.server.test.E2ETestBase + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.http.MediaType +import org.springframework.test.web.reactive.server.expectBody + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MetadataAggQueryControllerE2ETest : E2ETestBase() { + private val db = "commerce" + private val table = "orders_table" + private val alias = "orders" + private val rank = "${table}__topk" + private val rankFqn = "$db.$rank" + private val topkName = "top_purchased" + + @BeforeAll + fun setup() { + client + .post() + .uri("/graph/v3/databases") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"database": "$db", "comment": "test"}""") + .exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/graph/v3/databases/$db/tables") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "table": "$rank", + "schema": { + "type": "EDGE", + "source": {"type": "string", "comment": "database|table|topk|entity|dimensionValues"}, + "target": {"type": "string", "comment": "topkDimensionValue"}, + "properties": [ + {"name": "metric", "type": "long", "comment": "aggregated metric", "nullable": false}, + {"name": "additionalProperties", "type": "string", "comment": "carried properties as JSON", "nullable": true} + ], + "direction": "OUT", + "indexes": [ + {"index": "metric_desc", "fields": [{"field": "metric", "order": "DESC"}]} + ], + "groups": [], + "caches": [] + }, + "storage": "datastore://test_namespace/$rank", + "mode": "SYNC", + "comment": "topk rank rows" + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/graph/v3/databases/$db/tables") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "table": "$table", + "schema": { + "type": "MULTI_EDGE", + "id": {"type": "long", "comment": "order id"}, + "source": {"type": "string", "comment": "user"}, + "target": {"type": "string", "comment": "item"}, + "properties": [ + {"name": "category", "type": "string", "comment": "item category", "nullable": false}, + {"name": "purchasedAt", "type": "long", "comment": "purchase time ms", "nullable": false} + ], + "direction": "OUT", + "indexes": [], + "groups": [{ + "group": "purchased_count", + "type": "COUNT", + "fields": [ + {"name": "_target"}, + {"name": "category"}, + {"name": "purchasedAt", "bucket": {"type": "date", "name": "day", "unit": "MILLISECOND", "timezone": "UTC", "format": "yyyy-MM-dd"}} + ], + "directionType": "OUT", + "aggregations": { + "topk": [{ + "topk": "$topkName", + "entity": "source", + "ranges": "_target:eq:{_target};category:eq:{category};day:bt:2023-11-14,2024-11-13", + "dimension": "target", + "rank": "$rankFqn", + "additionalProperties": ["category"] + }] + } + }], + "caches": [] + }, + "storage": "datastore://test_namespace/$table", + "mode": "SYNC", + "comment": "user-to-item purchase edges" + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + // An alias pointing at the source table; queries go through it. + client + .post() + .uri("/graph/v3/databases/$db/aliases") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"alias": "$alias", "table": "$table", "comment": "query alias"}""") + .exchange() + .expectStatus() + .isOk + } + + /** + * The group declares a bucketed `purchasedAt` (day) alongside `category`: `ranges` constrains + * the metric to a day window and the bucket field stays out of the rank key, while `category` + * splits the ranking and so takes a place in it. + * + * 1. Record three edges for `user1`, each carrying `category = grocery` and a `purchasedAt` + * inside the window: two to `item1`, one to `item2`. + * + * commerce.orders_table (source) + * | source | target | category | purchasedAt | + * |--------|--------|----------|---------------| + * | user1 | item1 | grocery | 1710000000000 | + * | user1 | item1 | grocery | 1710000000000 | + * | user1 | item2 | grocery | 1710000000000 | + * + * 2. Aggregate both targets by the real table name (as a streaming writer would), materializing + * two rank rows (metric = edge count) with the carried property: + * + * commerce.orders_table__topk (rank) + * | row key (source) | target | metric | category | + * |---------------------------------------------------|--------|--------|----------| + * | commerce|orders_table|top_purchased|user1|grocery | item1 | 2 | grocery | + * | commerce|orders_table|top_purchased|user1|grocery | item2 | 1 | grocery | + * + * 3. Query the top-K for `user1` *through the alias* -> rows come back ordered by metric + * (`item1` then `item2`), each carrying `category`. + */ + @Test + fun `querying a topk through an alias returns the ranked targets with their carried properties`() { + client + .post() + .uri("/graph/v3/databases/$db/tables/$table/multi-edges") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "id": 1, "source": "user1", "target": "item1", "properties": {"category": "grocery", "purchasedAt": 1710000000000}}}, + {"type": "INSERT", "edge": {"version": 1, "id": 2, "source": "user1", "target": "item1", "properties": {"category": "grocery", "purchasedAt": 1710000000000}}}, + {"type": "INSERT", "edge": {"version": 1, "id": 3, "source": "user1", "target": "item2", "properties": {"category": "grocery", "purchasedAt": 1710000000000}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/aggregations/v1/aggregate") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "items": [ + {"database": "$db", "table": "$table", + "edge": {"version": 1, "source": "user1", "target": "item1", "properties": {"category": "grocery", "purchasedAt": 1710000000000}, "context": {}}}, + {"database": "$db", "table": "$table", + "edge": {"version": 1, "source": "user1", "target": "item2", "properties": {"category": "grocery", "purchasedAt": 1710000000000}, "context": {}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + + client + .post() + .uri("/aggregations/v1/databases/$db/tables/$alias/topks/$topkName") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"entity": "user1", "dimensionValues": {"category": "grocery"}}""") + .exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + .responseBody!! + .let { response -> + assertEquals(2, response.count) + assertEquals(listOf("item1", "item2"), response.topks.map { it.value }) + assertEquals(listOf(2L, 1L), response.topks.map { it.metric }) + assertEquals(listOf("grocery", "grocery"), response.topks.map { it.properties["category"] }) + } + } + + /** + * `user2` buys across two categories, so the split leaves them with one ranking per category rather + * than one overall: + * + * commerce.orders_table__topk (rank) + * | row key (source) | target | metric | + * |-------------------------------------------------------|--------|--------| + * | commerce\|orders_table\|top_purchased\|user2\|grocery | itemA | 2 | + * | commerce\|orders_table\|top_purchased\|user2\|dairy | itemB | 1 | + * + * Naming `category` is what picks one of them, and where that value lands in the key comes from + * the group. So asking for `grocery` returns `itemA` alone, and leaving the name out addresses the + * unsplit key, which no row was ever written to. + */ + @Test + fun `naming a dimension value picks the ranking split on that value`() { + client + .post() + .uri("/graph/v3/databases/$db/tables/$table/multi-edges") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "id": 11, "source": "user2", "target": "itemA", "properties": {"category": "grocery", "purchasedAt": 1710000000000}}}, + {"type": "INSERT", "edge": {"version": 1, "id": 12, "source": "user2", "target": "itemA", "properties": {"category": "grocery", "purchasedAt": 1710000000000}}}, + {"type": "INSERT", "edge": {"version": 1, "id": 13, "source": "user2", "target": "itemB", "properties": {"category": "dairy", "purchasedAt": 1710000000000}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/aggregations/v1/aggregate") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "items": [ + {"database": "$db", "table": "$table", + "edge": {"version": 1, "source": "user2", "target": "itemA", "properties": {"category": "grocery", "purchasedAt": 1710000000000}, "context": {}}}, + {"database": "$db", "table": "$table", + "edge": {"version": 1, "source": "user2", "target": "itemB", "properties": {"category": "dairy", "purchasedAt": 1710000000000}, "context": {}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + + client + .post() + .uri("/aggregations/v1/databases/$db/tables/$alias/topks/$topkName") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"entity": "user2", "dimensionValues": {"category": "grocery"}}""") + .exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + .responseBody!! + .let { response -> + assertEquals(1, response.count) + assertEquals(listOf("itemA"), response.topks.map { it.value }) + assertEquals(listOf(2L), response.topks.map { it.metric }) + } + + // Naming the value is what picks the ranking: leaving it out reads the unsplit key, which holds nothing. + client + .post() + .uri("/aggregations/v1/databases/$db/tables/$alias/topks/$topkName") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"entity": "user2"}""") + .exchange() + .expectStatus() + .isOk + .expectBody() + .returnResult() + .responseBody!! + .let { response -> assertEquals(0, response.count) } + } +} diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt index c2798620c..fbb662a11 100644 --- a/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt +++ b/server/src/test/kotlin/com/kakao/actionbase/server/filter/ReadOnlyRequestFilterTest.kt @@ -185,6 +185,7 @@ class ReadOnlyRequestFilterTest { "GET /graph/v3/datastore/hbase/tables/{tableName}/references", // read-only POST "POST /graph/v3/query", + "POST /aggregations/v1/databases/{database}/tables/{table}/topks/{topk}", "POST /graph/v3/databases/{database}/tables/{table}/edges/get", "POST /graph/v3/databases/{database}/tables/{table}/multi-edges/ids", // queue/v1 GET @@ -323,6 +324,7 @@ class ReadOnlyRequestFilterTest { "name" to "n", "namespace" to "n", "partition" to "0", + "topk" to "tk", "queue" to "q", "service" to "s", "storage" to "st",