diff --git a/core/src/main/kotlin/com/kakao/actionbase/core/metadata/common/Group.kt b/core/src/main/kotlin/com/kakao/actionbase/core/metadata/common/Group.kt index 8a3b090b..0ba123dd 100644 --- a/core/src/main/kotlin/com/kakao/actionbase/core/metadata/common/Group.kt +++ b/core/src/main/kotlin/com/kakao/actionbase/core/metadata/common/Group.kt @@ -2,6 +2,7 @@ package com.kakao.actionbase.core.metadata.common import com.kakao.actionbase.core.Constants import com.kakao.actionbase.core.codec.XXHash32Wrapper +import com.kakao.actionbase.core.types.PrimitiveType import com.fasterxml.jackson.annotation.JsonIgnore @@ -20,10 +21,14 @@ data class Group( data class Field( val name: String, val bucket: Bucket? = null, + val type: PrimitiveType? = null, ) { fun bucketOrGet( value: Any, ceil: Boolean, - ): Any = bucket?.handleQueryValue(value, ceil)?.toString() ?: value + ): Any = + bucket?.handleQueryValue(value, ceil)?.toString() + ?: type?.cast(value) + ?: value } } diff --git a/core/src/test/kotlin/com/kakao/actionbase/core/metadata/TableSerializationTest.kt b/core/src/test/kotlin/com/kakao/actionbase/core/metadata/TableSerializationTest.kt index 7a20b0ec..3f451bed 100644 --- a/core/src/test/kotlin/com/kakao/actionbase/core/metadata/TableSerializationTest.kt +++ b/core/src/test/kotlin/com/kakao/actionbase/core/metadata/TableSerializationTest.kt @@ -127,7 +127,8 @@ class TableSerializationTest { "unit": "MILLISECOND", "timezone": "+09:00", "format": "yyyy-MM-dd" - } + }, + "type": null } ], "valueField": "-", @@ -201,7 +202,8 @@ class TableSerializationTest { "unit": "MILLISECOND", "timezone": "+09:00", "format": "yyyy-MM-dd" - } + }, + "type": null } ], "comment": "group by day" diff --git a/core/src/test/kotlin/com/kakao/actionbase/core/metadata/common/GroupFieldTest.kt b/core/src/test/kotlin/com/kakao/actionbase/core/metadata/common/GroupFieldTest.kt new file mode 100644 index 00000000..e8413c81 --- /dev/null +++ b/core/src/test/kotlin/com/kakao/actionbase/core/metadata/common/GroupFieldTest.kt @@ -0,0 +1,54 @@ +package com.kakao.actionbase.core.metadata.common + +import com.kakao.actionbase.core.types.PrimitiveType + +import kotlin.test.assertEquals +import kotlin.test.assertIs + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class GroupFieldTest { + @Nested + inner class BackwardCompatibility { + @Test + fun `legacy field without type deserializes and casts correctly after type resolution`() { + // 1. before type resolution — returns raw String (the bug) + val field = Group.Field(name = "someLongField") + val rawResult = field.bucketOrGet("1", ceil = false) + assertIs(rawResult) + + // 2. after type resolution (simulating resolveFieldTypes) + val resolved = field.copy(type = PrimitiveType.LONG) + val castedResult = resolved.bucketOrGet("1", ceil = false) + assertIs(castedResult) + assertEquals(1L, castedResult) + } + } + + @Nested + inner class BucketOrGet { + @Test + fun `returns raw value when no bucket and no type`() { + val field = Group.Field(name = "myField") + val result = field.bucketOrGet("hello", ceil = false) + assertEquals("hello", result) + } + + @Test + fun `casts String to Long when type is LONG`() { + val field = Group.Field(name = "myField", type = PrimitiveType.LONG) + val result = field.bucketOrGet("42", ceil = false) + assertIs(result) + assertEquals(42L, result) + } + + @Test + fun `passes value to bucket when bucket is present, ignoring type`() { + val bucket = Bucket.Date("date_id", Bucket.ValueUnit.MILLISECOND, "+09:00", "yyyy-MM-dd") + val field = Group.Field(name = "ts", bucket = bucket, type = PrimitiveType.LONG) + val result = field.bucketOrGet(1700000000000L, ceil = false) + assertIs(result) + } + } +} diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/entity/LabelEntity.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/entity/LabelEntity.kt index 4fa10b50..36f5217c 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/entity/LabelEntity.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/entity/LabelEntity.kt @@ -1,6 +1,7 @@ package com.kakao.actionbase.v2.engine.entity import com.kakao.actionbase.core.metadata.common.Group +import com.kakao.actionbase.core.types.PrimitiveType import com.kakao.actionbase.v2.core.code.Index import com.kakao.actionbase.v2.core.code.hbase.ValueUtils import com.kakao.actionbase.v2.core.edge.TraceEdge @@ -169,8 +170,10 @@ data class LabelEntity( objectMapper.readValue(schemaString).toEdgeSchema() } - override fun toEntity(edge: HashEdge): LabelEntity = - LabelEntity( + override fun toEntity(edge: HashEdge): LabelEntity { + val schema = tryParseSchema(edge.props["schema"].toString()) + val groups = objectMapper.readValue>(edge.props["groups"]?.toString() ?: "[]") + return LabelEntity( active = (edge.props.getOrDefault("props_active", null) ?: true).toString().toBoolean(), name = EntityName.withPhase(edge.src.toString(), edge.tgt.toString()), desc = edge.props["desc"].toString(), @@ -178,13 +181,13 @@ data class LabelEntity( LabelType.of(edge.props["type"].toString()) ?: LabelType.NIL.also { logger.warn("Unknown label type: {}", edge.props["type"]) }, - schema = tryParseSchema(edge.props["schema"].toString()), + schema = schema, dirType = DirectionType.of(edge.props["dirType"].toString()) ?: DirectionType.BOTH.also { logger.warn("Unknown direction type: {}", edge.props["dirType"]) }, storage = edge.props["storage"].toString(), - groups = objectMapper.readValue(edge.props["groups"]?.toString() ?: "[]"), + groups = groups.resolveFieldTypes(schema), indices = objectMapper.readValue(edge.props["indices"].toString()), event = edge.props["event"].toString().toBoolean(), readOnly = edge.props["readOnly"].toString().toBoolean(), @@ -193,9 +196,12 @@ data class LabelEntity( edge.props.getOrDefault("mode", MutationMode.SYNC.name).toString(), ), ) + } - override fun toEntity(row: RowWithSchema): LabelEntity = - LabelEntity( + override fun toEntity(row: RowWithSchema): LabelEntity { + val schema = tryParseSchema(row.getString("schema")) + val groups = objectMapper.readValue>(row.getOrNull("groups")?.toString() ?: "[]") + return LabelEntity( active = (row.getOrNull("props_active") ?: true).toString().toBoolean(), name = EntityName.withPhase(row.getString("src"), row.getString("tgt")), desc = row.getString("desc"), @@ -203,18 +209,19 @@ data class LabelEntity( LabelType.of(row.getString("type")) ?: LabelType.NIL.also { logger.warn("Unknown label type: {}", row.getString("type")) }, - schema = tryParseSchema(row.getString("schema")), + schema = schema, dirType = DirectionType.of(row.getString("dirType")) ?: DirectionType.BOTH.also { logger.warn("Unknown direction type: {}", row.getString("dirType")) }, storage = row.getString("storage"), - groups = objectMapper.readValue(row.getOrNull("groups")?.toString() ?: "[]"), + groups = groups.resolveFieldTypes(schema), indices = objectMapper.readValue(row.getString("indices")), event = DataType.BOOLEAN.cast(row.getOrNull("event"))?.let { it as Boolean } ?: false, readOnly = row.getBoolean("readOnly"), mode = MutationMode.valueOf((row.getOrNull("mode") ?: MutationMode.SYNC.name).toString()), ) + } @JvmStatic @JsonCreator @@ -241,11 +248,40 @@ data class LabelEntity( dirType, storage, indices, - groups, + groups.resolveFieldTypes(schema), event, readOnly, mode, ) + + private fun List.resolveFieldTypes(schema: EdgeSchema): List = + map { group -> + group.copy( + fields = + group.fields.map { field -> + if (field.bucket != null) { + field + } else { + val primitiveType = schema.getField(field.name)?.type?.toPrimitiveType() + field.copy(type = primitiveType ?: field.type) + } + }, + ) + } + + private fun DataType.toPrimitiveType(): PrimitiveType = + when (this) { + DataType.BYTE -> PrimitiveType.BYTE + DataType.SHORT -> PrimitiveType.SHORT + DataType.INT -> PrimitiveType.INT + DataType.LONG -> PrimitiveType.LONG + DataType.BOOLEAN -> PrimitiveType.BOOLEAN + DataType.FLOAT -> PrimitiveType.FLOAT + DataType.DOUBLE -> PrimitiveType.DOUBLE + DataType.STRING -> PrimitiveType.STRING + DataType.JSON -> PrimitiveType.OBJECT + DataType.DECIMAL -> error("DECIMAL is not supported as a group field type. Use a supported type instead.") + } } override fun toString(): String = "LabelEntity(name=$name, desc='$desc', type=$type, schema=$schema, dirType=$dirType, storage='$storage', indices=$indices, event=$event, readOnly=$readOnly, mode=$mode)" diff --git a/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/EdgeAggQueryE2ETest.kt b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/EdgeAggQueryE2ETest.kt new file mode 100644 index 00000000..93d7bc2e --- /dev/null +++ b/server/src/test/kotlin/com/kakao/actionbase/server/api/graph/v3/EdgeAggQueryE2ETest.kt @@ -0,0 +1,182 @@ +package com.kakao.actionbase.server.api.graph.v3 + +import com.kakao.actionbase.server.test.E2ETestBase + +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.http.MediaType + +/** + * E2E test for AGG query. + * + * Verifies that AGG queries correctly return aggregated counts + * for group fields with various field type and bucket combinations. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EdgeAggQueryE2ETest : E2ETestBase() { + private val db = "agg-test-db" + private val table = "agg-test-table" + + private val group1 = "count_by_day" + private val group2 = "count_by_permission_day" + private val group3 = "count_by_category_day" + + // 1704067200000 = 2024-01-01 00:00:00 UTC + private val ts = 1704067200000L + + @BeforeAll + fun setup() { + client + .post() + .uri("/graph/v3/databases") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("""{"database": "$db", "comment": "test db"}""") + .exchange() + .expectStatus() + .isOk + + val dateBucket = """{"type": "date", "name": "day", "unit": "MILLISECOND", "timezone": "+00:00", "format": "yyyy-MM-dd"}""" + + client + .post() + .uri("/graph/v3/databases/$db/tables") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "table": "$table", + "schema": { + "type": "EDGE", + "source": {"type": "long", "comment": "source"}, + "target": {"type": "long", "comment": "target"}, + "properties": [ + {"name": "permission", "type": "string", "comment": "perm", "nullable": false}, + {"name": "categoryId", "type": "long", "comment": "category", "nullable": false}, + {"name": "createdAt", "type": "long", "comment": "ts", "nullable": false} + ], + "direction": "BOTH", + "indexes": [{"index": "created_at_desc", "fields": [{"field": "createdAt", "order": "DESC"}]}], + "groups": [ + {"group": "$group1", "type": "COUNT", "fields": [{"name": "createdAt", "bucket": $dateBucket}]}, + {"group": "$group2", "type": "COUNT", "fields": [{"name": "permission"}, {"name": "createdAt", "bucket": $dateBucket}]}, + {"group": "$group3", "type": "COUNT", "fields": [{"name": "categoryId"}, {"name": "createdAt", "bucket": $dateBucket}]} + ] + }, + "storage": "datastore://test_namespace/agg_test_hbase_table", + "mode": "SYNC", + "comment": "test table" + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + + client + .post() + .uri("/graph/v3/databases/$db/tables/$table/edges") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue( + """ + { + "mutations": [ + {"type": "INSERT", "edge": {"version": 1, "source": 100, "target": 1000, "properties": {"permission": "read", "categoryId": 1, "createdAt": $ts}}}, + {"type": "INSERT", "edge": {"version": 2, "source": 100, "target": 1001, "properties": {"permission": "read", "categoryId": 1, "createdAt": $ts}}}, + {"type": "INSERT", "edge": {"version": 3, "source": 100, "target": 1002, "properties": {"permission": "write", "categoryId": 2, "createdAt": $ts}}} + ] + } + """.trimIndent(), + ).exchange() + .expectStatus() + .isOk + } + + private fun aggQuery( + group: String, + ranges: String, + ) = client + .get() + .uri { builder -> + builder + .path("/graph/v3/databases/$db/tables/$table/edges/agg/$group") + .queryParam("start", "100") + .queryParam("direction", "OUT") + .queryParam("ranges", ranges) + .build() + }.exchange() + .expectStatus() + .isOk + .expectBody() + + /** + * | rowKey (source, direction, group) | qualifier (day) | value | + * |-----------------------------------|-----------------|-------| + * | 100, OUT, count_by_day | "2024-01-01" | 3 | + */ + @Nested + inner class BucketFieldOnly { + @Test + fun `returns aggregated count`() { + aggQuery(group1, "day:eq:2024-01-01") + .jsonPath("$.count") + .isEqualTo(1) + .jsonPath("$.groups[0].value") + .isEqualTo(3) + } + } + + /** + * | rowKey (source, direction, group) | qualifier (permission, day) | value | + * |-----------------------------------|-----------------------------|-------| + * | 100, OUT, count_by_permission_day | "read", "2024-01-01" | 2 | + * | 100, OUT, count_by_permission_day | "write", "2024-01-01" | 1 | + */ + @Nested + inner class StringFieldAndBucket { + @Test + fun `returns aggregated count for matching value`() { + aggQuery(group2, "permission:eq:read;day:eq:2024-01-01") + .jsonPath("$.count") + .isEqualTo(1) + .jsonPath("$.groups[0].value") + .isEqualTo(2) + } + + @Test + fun `returns correct count for different value`() { + aggQuery(group2, "permission:eq:write;day:eq:2024-01-01") + .jsonPath("$.count") + .isEqualTo(1) + .jsonPath("$.groups[0].value") + .isEqualTo(1) + } + } + + /** + * | rowKey (source, direction, group) | qualifier (categoryId, day) | value | + * |-----------------------------------|-----------------------------|-------| + * | 100, OUT, count_by_category_day | 1L, "2024-01-01" | 2 | + * | 100, OUT, count_by_category_day | 2L, "2024-01-01" | 1 | + */ + @Nested + inner class LongFieldAndBucket { + @Test + fun `returns aggregated count for matching value`() { + aggQuery(group3, "categoryId:eq:1;day:eq:2024-01-01") + .jsonPath("$.count") + .isEqualTo(1) + .jsonPath("$.groups[0].value") + .isEqualTo(2) + } + + @Test + fun `returns correct count for different value`() { + aggQuery(group3, "categoryId:eq:2;day:eq:2024-01-01") + .jsonPath("$.count") + .isEqualTo(1) + .jsonPath("$.groups[0].value") + .isEqualTo(1) + } + } +}