Skip to content
Open
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
Expand Up @@ -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

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ class TableSerializationTest {
"unit": "MILLISECOND",
"timezone": "+09:00",
"format": "yyyy-MM-dd"
}
},
"type": null
}
],
"valueField": "-",
Expand Down Expand Up @@ -201,7 +202,8 @@ class TableSerializationTest {
"unit": "MILLISECOND",
"timezone": "+09:00",
"format": "yyyy-MM-dd"
}
},
"type": null
}
],
"comment": "group by day"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>(rawResult)

// 2. after type resolution (simulating resolveFieldTypes)
val resolved = field.copy(type = PrimitiveType.LONG)
val castedResult = resolved.bucketOrGet("1", ceil = false)
assertIs<Long>(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<Long>(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<String>(result)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -169,22 +170,24 @@ data class LabelEntity(
objectMapper.readValue<DeprecatedEdgeSchema>(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<List<Group>>(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(),
type =
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(),
Expand All @@ -193,28 +196,32 @@ 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<List<Group>>(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"),
type =
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
Expand All @@ -241,11 +248,40 @@ data class LabelEntity(
dirType,
storage,
indices,
groups,
groups.resolveFieldTypes(schema),
event,
readOnly,
mode,
)

private fun List<Group>.resolveFieldTypes(schema: EdgeSchema): List<Group> =
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.")
Copy link
Copy Markdown
Contributor Author

@zipdoki zipdoki Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@em3s
DECIMAL group fields are not yet supported — PrimitiveType has no DECIMAL variant. Tracked for a follow-up.

}
}

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)"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading