From cb9931e16d5e921f4565c502e8b219928b1f70a8 Mon Sep 17 00:00:00 2001 From: Dokyung Lee <112409928+zipdoki@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:21:52 +0900 Subject: [PATCH] Read a materialized top-K as a step in a multi-hop query --- .../actionbase/engine/binding/TableBinding.kt | 9 + .../engine/query/ActionbaseQuery.kt | 18 +- .../engine/query/ActionbaseQueryExecutor.kt | 81 +++- .../v2/engine/v3/V2BackedTableBinding.kt | 2 + .../v2/engine/v3/MultiHopQuerySpec.kt | 4 +- .../actionbase/v2/engine/v3/TopkQuerySpec.kt | 397 ++++++++++++++++++ .../v3/query/ActionbaseQueryParserSpec.kt | 58 ++- .../v2/engine/v3/query/ActionbaseQuerySpec.kt | 10 +- .../api/graph/v3/ActionbaseQueryE2ETest.kt | 8 +- 9 files changed, 557 insertions(+), 30 deletions(-) create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/TopkQuerySpec.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/binding/TableBinding.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/binding/TableBinding.kt index 07a238aa7..32fddfe66 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/binding/TableBinding.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/binding/TableBinding.kt @@ -16,6 +16,15 @@ interface TableBinding { val schema: ModelSchema val mutationMode: MutationMode + /** + * An empty result that still carries this table's columns. + * + * A read that found nothing has no rows, but the table it read from still has the columns it always + * had. Dropping them turns "no rows" into "no such column" for anything that resolves names against + * the frame — a SQL transform planned against a schema-less frame cannot resolve a single one. + */ + val emptyFrame: DataFrame get() = DataFrame.empty + // -- mutation fun withLock( diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQuery.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQuery.kt index 156f71183..b7a34693d 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQuery.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQuery.kt @@ -13,7 +13,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue data class ActionbaseQuery( - val query: List, + val fetch: List, val stats: Set = emptySet(), ) { @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @@ -23,6 +23,7 @@ data class ActionbaseQuery( JsonSubTypes.Type(value = Item.Count::class, name = "COUNT"), JsonSubTypes.Type(value = Item.Scan::class, name = "SCAN"), JsonSubTypes.Type(value = Item.Seek::class, name = "CACHE"), + JsonSubTypes.Type(value = Item.Topk::class, name = "TOPK"), ) sealed class Item { abstract val name: String @@ -96,6 +97,21 @@ data class ActionbaseQuery( override val post: List = emptyList(), override val aggregators: List = emptyList(), ) : Item() + + data class Topk( + override val name: String = UUID.randomUUID().toString(), + val database: String, + val table: String, + val topk: String, + val entity: Vertex? = null, + val dimensionValues: Map = emptyMap(), + val limit: Int = DEFAULT_SCAN_LIMIT, + val offset: String? = null, + override val include: Boolean = false, + override val memoize: Boolean = false, + override val post: List = emptyList(), + override val aggregators: List = emptyList(), + ) : Item() } @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQueryExecutor.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQueryExecutor.kt index 04a7d834d..49134d77b 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQueryExecutor.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/query/ActionbaseQueryExecutor.kt @@ -5,6 +5,8 @@ import com.kakao.actionbase.core.metadata.common.StructField import com.kakao.actionbase.core.metadata.common.StructType import com.kakao.actionbase.core.types.PrimitiveType import com.kakao.actionbase.engine.QueryEngine +import com.kakao.actionbase.engine.binding.TableBinding +import com.kakao.actionbase.engine.service.RankScan import com.kakao.actionbase.engine.sql.DataFrame import com.kakao.actionbase.engine.sql.Row @@ -51,16 +53,15 @@ class ActionbaseQueryExecutor( fun query(actionBaseQuery: ActionbaseQuery): Mono> { val computed: Mono> = - actionBaseQuery.query.fold(Mono.just(emptyMap())) { acc, queryItem -> + actionBaseQuery.fetch.fold(Mono.just(emptyMap())) { acc, queryItem -> acc.flatMap { context -> processQuery(queryItem, context, actionBaseQuery) .map { context + (queryItem.name to it) } } } - val returnNames = actionBaseQuery.query.filter { it.include }.map { it.name } - return computed.map { context -> - returnNames.associateWith { context.getValue(it) } - } + val returnNames = actionBaseQuery.fetch.filter { it.include }.map { it.name } + + return computed.map { context -> returnNames.associateWith { context.getValue(it) } } } private fun processQuery( @@ -84,6 +85,7 @@ class ActionbaseQueryExecutor( is ActionbaseQuery.Item.Count -> processCount(queryItem, context, actionBaseQuery) is ActionbaseQuery.Item.Scan -> processScan(queryItem, context, actionBaseQuery) is ActionbaseQuery.Item.Seek -> processSeek(queryItem, context, actionBaseQuery) + is ActionbaseQuery.Item.Topk -> processTopk(queryItem, context, actionBaseQuery) } private fun applyPostProcessors( @@ -168,15 +170,16 @@ class ActionbaseQueryExecutor( actionBaseQuery: ActionbaseQuery, ): Mono = resolveVertex(queryItem.source, context, actionBaseQuery).flatMap { source -> + val binding = engine.getTableBinding(database = queryItem.database, alias = queryItem.table) + Flux .fromIterable(source) .flatMap { start -> - engine - .getTableBinding(database = queryItem.database, alias = queryItem.table) + binding .scan(queryItem.index, start, queryItem.direction, queryItem.limit, queryItem.offset, ranges = queryItem.ranges, filters = null, features = emptyList()) }.reduce { a, b -> DataFrame(rows = a.rows + b.rows, schema = a.schema, total = a.total + b.total) - }.switchIfEmpty(Mono.just(DataFrame.empty)) + }.switchIfEmpty(Mono.just(binding.emptyFrame)) } private fun processSeek( @@ -185,12 +188,68 @@ class ActionbaseQueryExecutor( actionBaseQuery: ActionbaseQuery, ): Mono = resolveVertex(queryItem.source, context, actionBaseQuery).flatMap { source -> - engine - .getTableBinding(database = queryItem.database, alias = queryItem.table) + val binding = engine.getTableBinding(database = queryItem.database, alias = queryItem.table) + + binding .seek(queryItem.cache, source.toList(), queryItem.direction, queryItem.limit, offset = null, ranges = queryItem.ranges, filters = null, features = emptyList()) - .switchIfEmpty(Mono.just(DataFrame.empty)) + .switchIfEmpty(Mono.just(binding.emptyFrame)) } + private fun processTopk( + queryItem: ActionbaseQuery.Item.Topk, + context: Map, + actionBaseQuery: ActionbaseQuery, + ): Mono { + val binding = engine.getTableBinding(database = queryItem.database, alias = queryItem.table) + val entity = queryItem.entity ?: return scanRanking(queryItem, binding, entity = null) + + return resolveVertex(entity, context, actionBaseQuery).flatMap { entities -> + Flux + .fromIterable(entities) + .flatMap { scanRanking(queryItem, binding, it.toString()) } + .reduce { a, b -> DataFrame(rows = a.rows + b.rows, schema = a.schema, total = a.total + b.total) } + .switchIfEmpty(Mono.just(rankBindingOf(queryItem, binding).emptyFrame)) + } + } + + private fun scanRanking( + queryItem: ActionbaseQuery.Item.Topk, + binding: TableBinding, + entity: String?, + ): Mono { + val rank = rankScanOf(queryItem, binding, entity) + + return engine + .getTableBinding(database = rank.database, alias = rank.table) + .scan(rank.index, rank.start, rank.direction, queryItem.limit, queryItem.offset, ranges = null, filters = null, features = emptyList()) + } + + /** + * The step names the source table, but the rows come from the ranking table it points at, so an empty + * ranking has to answer with the ranking table's columns rather than the source table's. + */ + private fun rankBindingOf( + queryItem: ActionbaseQuery.Item.Topk, + binding: TableBinding, + ): TableBinding = + rankScanOf(queryItem, binding, entity = null).let { rank -> + engine.getTableBinding(database = rank.database, alias = rank.table) + } + + private fun rankScanOf( + queryItem: ActionbaseQuery.Item.Topk, + binding: TableBinding, + entity: String?, + ): RankScan = + RankScan.from( + schema = binding.schema, + database = queryItem.database, + table = binding.table, + topk = queryItem.topk, + entity = entity, + dimensionValues = queryItem.dimensionValues, + ) + // region Aggregators internal fun aggregateCount( diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/v3/V2BackedTableBinding.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/v3/V2BackedTableBinding.kt index a5040bb05..854635d54 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/v3/V2BackedTableBinding.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/v3/V2BackedTableBinding.kt @@ -57,6 +57,8 @@ class V2BackedTableBinding( override val schema: ModelSchema = descriptor.schema override val mutationMode: MutationMode = MutationMode.valueOf(label.entity.mode.name) + override val emptyFrame: DataFrame by lazy { V2DataFrame.empty(label.entity.schema.allStructType).toV3(total = 0L) } + private val groupRecordMapper = mapper.group private val cacheRecordMapper = mapper.cache diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/MultiHopQuerySpec.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/MultiHopQuerySpec.kt index 4067b1e32..2717f60e6 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/MultiHopQuerySpec.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/MultiHopQuerySpec.kt @@ -148,7 +148,7 @@ class MultiHopQuerySpec : // 2-hop query: user1's friends' wishlists val query = ActionbaseQuery( - query = + fetch = listOf( ActionbaseQuery.Item.Scan( name = "hop1", @@ -336,7 +336,7 @@ class MultiHopQuerySpec : // 3-hop query: user1's friends' wishlists' reviews val query = ActionbaseQuery( - query = + fetch = listOf( ActionbaseQuery.Item.Scan( name = "hop1", diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/TopkQuerySpec.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/TopkQuerySpec.kt new file mode 100644 index 000000000..b518481cb --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/TopkQuerySpec.kt @@ -0,0 +1,397 @@ +package com.kakao.actionbase.v2.engine.v3 + +import com.kakao.actionbase.core.metadata.common.DirectionType as V3DirectionType + +import com.kakao.actionbase.core.edge.payload.EdgeBulkMutationRequest +import com.kakao.actionbase.core.metadata.common.Aggregations +import com.kakao.actionbase.core.metadata.common.Bucket +import com.kakao.actionbase.core.metadata.common.Group +import com.kakao.actionbase.core.metadata.common.GroupType +import com.kakao.actionbase.core.metadata.common.Topk +import com.kakao.actionbase.engine.query.ActionbaseQuery +import com.kakao.actionbase.engine.query.ActionbaseQueryExecutor +import com.kakao.actionbase.engine.service.MutationService +import com.kakao.actionbase.v2.core.code.Index +import com.kakao.actionbase.v2.core.code.hbase.Order +import com.kakao.actionbase.v2.core.metadata.Direction +import com.kakao.actionbase.v2.core.metadata.DirectionType +import com.kakao.actionbase.v2.core.metadata.LabelType +import com.kakao.actionbase.v2.core.types.DataType +import com.kakao.actionbase.v2.core.types.EdgeSchema +import com.kakao.actionbase.v2.core.types.Field +import com.kakao.actionbase.v2.core.types.VertexField +import com.kakao.actionbase.v2.core.types.VertexType +import com.kakao.actionbase.v2.engine.Graph +import com.kakao.actionbase.v2.engine.entity.EntityName +import com.kakao.actionbase.v2.engine.service.ddl.LabelCreateRequest +import com.kakao.actionbase.v2.engine.test.GraphFixtures + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import reactor.kotlin.test.test + +class TopkQuerySpec : + StringSpec( + { + val database = GraphFixtures.serviceName + val sourceTable = "orders" + val rankTable = "orders__topk" + val topkName = "top_purchased" + val globalTopkName = "top_purchased_global" + val categoryTopkName = "top_purchased_by_category" + val mapper = jacksonObjectMapper() + + lateinit var graph: Graph + lateinit var mutationService: MutationService + lateinit var queryExecutor: ActionbaseQueryExecutor + + beforeTest { + graph = GraphFixtures.create() + val engine = V2BackedEngine(graph) + mutationService = MutationService(engine) + queryExecutor = ActionbaseQueryExecutor(engine) + } + + afterTest { + graph.close() + } + + // One row per ranked value, indexed by `metric` DESC — that index *is* the top-K read order. + fun createRankTable() { + graph.labelDdl + .create( + EntityName(database, rankTable), + LabelCreateRequest( + desc = "rank rows", + type = LabelType.INDEXED, + schema = + EdgeSchema( + VertexField(VertexType.STRING), + VertexField(VertexType.STRING), + listOf(Field("metric", DataType.LONG, false)), + ), + dirType = DirectionType.OUT, + storage = GraphFixtures.datastoreStorage, + indices = listOf(Index("metric_desc", listOf(Index.Field("metric", Order.DESC)))), + ), + ).test() + .assertNext { it.status.name shouldBe "CREATED" } + .verifyComplete() + } + + fun createSourceTable() { + graph.labelDdl + .create( + EntityName(database, sourceTable), + LabelCreateRequest( + desc = "purchases", + type = LabelType.INDEXED, + schema = + EdgeSchema( + VertexField(VertexType.STRING), + VertexField(VertexType.STRING), + listOf(Field("purchasedAt", DataType.LONG, false), Field("category", DataType.STRING, false)), + ), + dirType = DirectionType.BOTH, + storage = GraphFixtures.datastoreStorage, + groups = + listOf( + Group( + group = "purchased_count", + type = GroupType.COUNT, + fields = listOf(Group.Field("purchasedAt", bucket = Bucket.Date(name = "day", unit = Bucket.ValueUnit.MILLISECOND, timezone = "UTC", format = "yyyy-MM-dd"))), + directionType = V3DirectionType.OUT, + aggregations = + Aggregations( + topk = + listOf( + Topk(topk = topkName, entity = "source", dimension = "target", rank = "$database.$rankTable"), + Topk(topk = globalTopkName, entity = "__GLOBAL__", dimension = "target", rank = "$database.$rankTable"), + ), + ), + ), + Group( + group = "purchased_count_by_category", + type = GroupType.COUNT, + fields = listOf(Group.Field("category"), Group.Field("purchasedAt", bucket = Bucket.Date(name = "day", unit = Bucket.ValueUnit.MILLISECOND, timezone = "UTC", format = "yyyy-MM-dd"))), + directionType = V3DirectionType.OUT, + aggregations = + Aggregations( + topk = listOf(Topk(topk = categoryTopkName, entity = "source", dimension = "target", rank = "$database.$rankTable")), + ), + ), + ), + ), + ).test() + .assertNext { it.status.name shouldBe "CREATED" } + .verifyComplete() + } + + fun seedRankRows(mutations: String) { + mutationService + .mutate(database, rankTable, mapper.readValue(mutations).mutations) + .test() + .assertNext { } + .verifyComplete() + } + + /** + * test.orders__topk (rank) + * | row key (source) | target | metric | + * |------------------------------------|--------|--------| + * | test\|orders\|top_purchased\|user1 | item1 | 3 | + * | test\|orders\|top_purchased\|user1 | item2 | 1 | + * + * The step names the *source* table (`orders`) and recomposes that key itself, so the caller + * never addresses `orders__topk`. + */ + "TOPK step returns a materialized ranking in metric order" { + createRankTable() + createSourceTable() + seedRankRows( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased|user1", "target": "item1", "properties": {"metric": 3}}}, + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased|user1", "target": "item2", "properties": {"metric": 1}}} + ] + } + """.trimIndent(), + ) + + val query = + ActionbaseQuery( + fetch = + listOf( + ActionbaseQuery.Item.Topk( + name = "ranked", + database = database, + table = sourceTable, + topk = topkName, + entity = ActionbaseQuery.Vertex.Value(listOf("user1")), + limit = 10, + include = true, + ), + ), + ) + + queryExecutor + .query(query) + .test() + .assertNext { result -> + val ranked = result.getValue("ranked") + ranked.getColumn("target").filterNotNull() shouldBe listOf("item1", "item2") + ranked.getColumn("metric").filterNotNull() shouldBe listOf(3L, 1L) + }.verifyComplete() + } + + /** + * Per-entity rankings are the point of `entity` being a vertex: hop1 scans who `user1` follows, + * and the step then reads one ranking per followed user. + * + * test.follows (hop1: scan by created_at_desc) + * | source | target | createdAt | + * |--------|--------|-----------| + * | user1 | user2 | 100 | + * | user1 | user3 | 200 | + * + * test.orders__topk (rank) + * | row key (source) | target | metric | + * |------------------------------------|--------|--------| + * | test\|orders\|top_purchased\|user2 | itemA | 5 | + * | test\|orders\|top_purchased\|user3 | itemB | 2 | + */ + "TOPK step reads one ranking per entity produced by the previous hop" { + val followsTable = "follows" + + createRankTable() + createSourceTable() + + graph.labelDdl + .create( + EntityName(database, followsTable), + LabelCreateRequest( + desc = "follows", + type = LabelType.INDEXED, + schema = + EdgeSchema( + VertexField(VertexType.STRING), + VertexField(VertexType.STRING), + listOf(Field("createdAt", DataType.LONG, false)), + ), + dirType = DirectionType.BOTH, + storage = GraphFixtures.datastoreStorage, + indices = listOf(Index("created_at_desc", listOf(Index.Field("createdAt", Order.DESC)))), + ), + ).test() + .assertNext { it.status.name shouldBe "CREATED" } + .verifyComplete() + + mutationService + .mutate( + database, + followsTable, + mapper + .readValue( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": "user1", "target": "user2", "properties": {"createdAt": 100}}}, + {"type": "INSERT", "edge": {"version": 1, "source": "user1", "target": "user3", "properties": {"createdAt": 200}}} + ] + } + """.trimIndent(), + ).mutations, + ).test() + .assertNext { } + .verifyComplete() + + seedRankRows( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased|user2", "target": "itemA", "properties": {"metric": 5}}}, + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased|user3", "target": "itemB", "properties": {"metric": 2}}} + ] + } + """.trimIndent(), + ) + + val query = + ActionbaseQuery( + fetch = + listOf( + ActionbaseQuery.Item.Scan( + name = "hop1", + database = database, + table = followsTable, + source = ActionbaseQuery.Vertex.Value(listOf("user1")), + direction = Direction.OUT, + index = "created_at_desc", + limit = 100, + ), + ActionbaseQuery.Item.Topk( + name = "ranked", + database = database, + table = sourceTable, + topk = topkName, + entity = ActionbaseQuery.Vertex.Ref(ref = "hop1", field = "target"), + limit = 10, + include = true, + ), + ), + ) + + queryExecutor + .query(query) + .test() + .assertNext { result -> + val ranked = result.getValue("ranked") + ranked.getColumn("target").filterNotNull().toSet() shouldBe setOf("itemA", "itemB") + }.verifyComplete() + } + + /** + * `top_purchased_global` declares the sentinel as its entity, so it holds one ranking for everyone: + * + * test.orders__topk (rank) + * | row key (source) | target | metric | + * |-------------------------------------------------|--------|--------| + * | test\|orders\|top_purchased_global\|__GLOBAL__ | item1 | 3 | + * | test\|orders\|top_purchased_global\|__GLOBAL__ | item2 | 1 | + * + * The caller has no entity to name, and the step fills the sentinel from the config on its own. + */ + "TOPK step reads a global ranking without being given an entity" { + createRankTable() + createSourceTable() + seedRankRows( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased_global|__GLOBAL__", "target": "item1", "properties": {"metric": 3}}}, + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased_global|__GLOBAL__", "target": "item2", "properties": {"metric": 1}}} + ] + } + """.trimIndent(), + ) + + val query = + ActionbaseQuery( + fetch = + listOf( + ActionbaseQuery.Item.Topk( + name = "ranked", + database = database, + table = sourceTable, + topk = globalTopkName, + limit = 10, + include = true, + ), + ), + ) + + queryExecutor + .query(query) + .test() + .assertNext { result -> + result.getValue("ranked").getColumn("target") shouldBe listOf("item1", "item2") + }.verifyComplete() + } + + /** + * `top_purchased_by_category` is declared on a group that carries an unbucketed `category`, so + * `user1` has one ranking per category: + * + * test.orders__topk (rank) + * | row key (source) | target | metric | + * |---------------------------------------------------------|--------|--------| + * | test\|orders\|top_purchased_by_category\|user1\|fruit | itemA | 5 | + * | test\|orders\|top_purchased_by_category\|user1\|meat | itemB | 2 | + * + * The caller names `category` and the step puts that value where the group says it goes, so asking + * for `fruit` leaves the meat ranking out. + */ + "TOPK step picks the ranking named by its dimension values" { + createRankTable() + createSourceTable() + seedRankRows( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased_by_category|user1|fruit", "target": "itemA", "properties": {"metric": 5}}}, + {"type": "INSERT", "edge": {"version": 1, "source": "test|orders|top_purchased_by_category|user1|meat", "target": "itemB", "properties": {"metric": 2}}} + ] + } + """.trimIndent(), + ) + + val query = + ActionbaseQuery( + fetch = + listOf( + ActionbaseQuery.Item.Topk( + name = "ranked", + database = database, + table = sourceTable, + topk = categoryTopkName, + entity = ActionbaseQuery.Vertex.Value(listOf("user1")), + dimensionValues = mapOf("category" to "fruit"), + limit = 10, + include = true, + ), + ), + ) + + queryExecutor + .query(query) + .test() + .assertNext { result -> + result.getValue("ranked").getColumn("target") shouldBe listOf("itemA") + }.verifyComplete() + } + }, + ) diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQueryParserSpec.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQueryParserSpec.kt index 1f3e45dbd..537eb080a 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQueryParserSpec.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQueryParserSpec.kt @@ -14,7 +14,7 @@ class ActionbaseQueryParserSpec : val json = """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "a", @@ -80,6 +80,30 @@ class ActionbaseQueryParserSpec : "cache": "recent_wishlist", "limit": 10, "include": true + }, + { + "type": "TOPK", + "name": "g", + "database": "{database}", + "table": "{table}", + "topk": "top_product_groups_1y", + "entity": { + "type": "REF", + "ref": "a", + "field": "tgt" + }, + "limit": 10, + "include": true + }, + { + "type": "TOPK", + "name": "h", + "database": "{database}", + "table": "{table}", + "topk": "top_product_groups_global", + "dimensionValues": {"category": "fruit"}, + "limit": 10, + "include": true } ] } @@ -87,8 +111,8 @@ class ActionbaseQueryParserSpec : val actionBaseQuery = ActionbaseQuery.from(json) - actionBaseQuery.query.size shouldBe 5 - actionBaseQuery.query[0] shouldBe + actionBaseQuery.fetch.size shouldBe 7 + actionBaseQuery.fetch[0] shouldBe ActionbaseQuery.Item.Scan( name = "a", database = "{database}", @@ -100,7 +124,7 @@ class ActionbaseQueryParserSpec : ranges = "name:eq:Alice", include = false, ) - actionBaseQuery.query[1] shouldBe + actionBaseQuery.fetch[1] shouldBe ActionbaseQuery.Item.Get( name = "b", database = "{database}", @@ -109,7 +133,7 @@ class ActionbaseQueryParserSpec : target = ActionbaseQuery.Vertex.Value(listOf(1)), include = false, ) - actionBaseQuery.query[2] shouldBe + actionBaseQuery.fetch[2] shouldBe ActionbaseQuery.Item.Count( name = "d", database = "{database}", @@ -118,7 +142,7 @@ class ActionbaseQueryParserSpec : direction = Direction.OUT, include = true, ) - actionBaseQuery.query[3] shouldBe + actionBaseQuery.fetch[3] shouldBe ActionbaseQuery.Item.Self( name = "e", database = "{database}", @@ -126,7 +150,7 @@ class ActionbaseQueryParserSpec : source = ActionbaseQuery.Vertex.Value(listOf(1, 2, 3)), include = false, ) - actionBaseQuery.query[4] shouldBe + actionBaseQuery.fetch[4] shouldBe ActionbaseQuery.Item.Seek( name = "f", database = "{database}", @@ -137,6 +161,26 @@ class ActionbaseQueryParserSpec : limit = 10, include = true, ) + actionBaseQuery.fetch[5] shouldBe + ActionbaseQuery.Item.Topk( + name = "g", + database = "{database}", + table = "{table}", + topk = "top_product_groups_1y", + entity = ActionbaseQuery.Vertex.Ref("a", "tgt"), + limit = 10, + include = true, + ) + actionBaseQuery.fetch[6] shouldBe + ActionbaseQuery.Item.Topk( + name = "h", + database = "{database}", + table = "{table}", + topk = "top_product_groups_global", + dimensionValues = mapOf("category" to "fruit"), + limit = 10, + include = true, + ) // serialize and deserialize diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQuerySpec.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQuerySpec.kt index fcba1c981..af890b2a2 100644 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQuerySpec.kt +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/v3/query/ActionbaseQuerySpec.kt @@ -36,7 +36,7 @@ class ActionbaseQuerySpec : val actionBaseQueryString = """ { - "query": [ + "fetch": [ { "type": "SELF", "name": "$queryName", @@ -74,7 +74,7 @@ class ActionbaseQuerySpec : val actionBaseQueryString = """ { - "query": [ + "fetch": [ { "type": "GET", "name": "$queryName", @@ -115,7 +115,7 @@ class ActionbaseQuerySpec : val actionBaseQueryString = """ { - "query": [ + "fetch": [ { "type": "COUNT", "name": "$queryName", @@ -153,7 +153,7 @@ class ActionbaseQuerySpec : val actionBaseQueryString = """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "$queryName", @@ -202,7 +202,7 @@ class ActionbaseQuerySpec : val actionBaseQueryString = """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "step1", diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/ActionbaseQueryE2ETest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/ActionbaseQueryE2ETest.kt index 523c5fb88..7c39e1c89 100644 --- a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/ActionbaseQueryE2ETest.kt +++ b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/ActionbaseQueryE2ETest.kt @@ -179,7 +179,7 @@ class ActionbaseQueryE2ETest : E2ETestBase() { .bodyValue( """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "hop1", @@ -233,7 +233,7 @@ class ActionbaseQueryE2ETest : E2ETestBase() { .bodyValue( """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "hop1", @@ -283,7 +283,7 @@ class ActionbaseQueryE2ETest : E2ETestBase() { .bodyValue( """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "hop1", @@ -331,7 +331,7 @@ class ActionbaseQueryE2ETest : E2ETestBase() { .bodyValue( """ { - "query": [ + "fetch": [ { "type": "SCAN", "name": "follows_scan",