diff --git a/engine/src/main/kotlin/com/kakao/actionbase/engine/service/PreparedQueryService.kt b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/PreparedQueryService.kt new file mode 100644 index 000000000..d17e95077 --- /dev/null +++ b/engine/src/main/kotlin/com/kakao/actionbase/engine/service/PreparedQueryService.kt @@ -0,0 +1,103 @@ +package com.kakao.actionbase.engine.service + +import com.kakao.actionbase.core.metadata.common.StructField +import com.kakao.actionbase.engine.sql.DataFrame +import com.kakao.actionbase.v2.engine.Graph +import com.kakao.actionbase.v2.engine.sql.StatKey + +import com.fasterxml.jackson.databind.JsonNode +import reactor.core.publisher.Mono + +/** + * A query is stored under an id the server assigns, and a name is kept separately and points at that id, + * so that a name can be moved onto another query in one write and put back the same way. + * + * Running one goes through [QueryService] because that executor owns the transform cache. + */ +class PreparedQueryService( + private val graph: Graph, + private val queries: QueryService, +) { + fun register( + desc: String, + arguments: List, + fetch: JsonNode, + transform: JsonNode, + stats: Set = emptySet(), + ): Mono = TODO("Not yet implemented") + + fun amend( + id: String, + desc: String? = null, + arguments: List? = null, + fetch: JsonNode? = null, + transform: JsonNode? = null, + stats: Set? = null, + ): Mono = TODO("Not yet implemented") + + fun get(id: String): Mono = TODO("Not yet implemented") + + fun list(status: MetadataStatus = MetadataStatus.ACTIVE): Mono> = TODO("Not yet implemented") + + fun delete(id: String): Mono = TODO("Not yet implemented") + + fun createAlias( + alias: String, + desc: String, + target: String, + ): Mono = TODO("Not yet implemented") + + fun updateAlias( + alias: String, + desc: String? = null, + target: String? = null, + active: Boolean? = null, + ): Mono = TODO("Not yet implemented") + + fun alias(alias: String): Mono = TODO("Not yet implemented") + + fun aliases(status: MetadataStatus = MetadataStatus.ACTIVE): Mono> = TODO("Not yet implemented") + + fun deleteAlias(alias: String): Mono = TODO("Not yet implemented") + + fun query( + id: String, + arguments: Map, + ): Mono> = TODO("Not yet implemented") +} + +/** Named separately so that a 404 does not follow from every missing map key in the engine. */ +class NoSuchPreparedQueryException( + id: String, +) : NoSuchElementException("No prepared query `$id`.") + +enum class MetadataStatus { + ACTIVE, + INACTIVE, + ALL, + ; + + fun matches(active: Boolean): Boolean = + when (this) { + ACTIVE -> active + INACTIVE -> !active + ALL -> true + } +} + +data class PreparedQueryDescriptor( + val id: String, + val arguments: List, + val fetch: JsonNode, + val transform: JsonNode, + val stats: Set, + val active: Boolean, + val desc: String, +) + +data class PreparedQueryAliasDescriptor( + val alias: String, + val target: String, + val active: Boolean, + val desc: String, +) diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/QueryController.kt b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/QueryController.kt index 84bba1c12..e0e0aeb7e 100644 --- a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/QueryController.kt +++ b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/QueryController.kt @@ -1,12 +1,15 @@ package com.kakao.actionbase.server.api.graph.v3 import com.kakao.actionbase.engine.query.ActionbaseQuery +import com.kakao.actionbase.engine.service.PreparedQueryService import com.kakao.actionbase.engine.service.QueryService import com.kakao.actionbase.engine.sql.DataFrame +import com.kakao.actionbase.server.api.graph.v3.query.PreparedQueryExecuteRequest import com.kakao.actionbase.server.util.mapToResponseEntity import com.kakao.actionbase.v2.engine.sql.QueryResult 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 @@ -16,6 +19,7 @@ import reactor.core.publisher.Mono @RestController class QueryController( private val queryService: QueryService, + private val preparedQueryService: PreparedQueryService, ) { @PostMapping("/graph/v3/query") fun query( @@ -28,6 +32,16 @@ class QueryController( NamedQueryResult(items) }.mapToResponseEntity() + @PostMapping("/graph/v3/query/{id}") + fun queryById( + @PathVariable id: String, + @RequestBody request: PreparedQueryExecuteRequest, + ): Mono> = + preparedQueryService + .query(id, request.arguments) + .map { NamedQueryResult(it.map { entry -> entry.value.toNamedJsonFormat(entry.key) }) } + .mapToResponseEntity() + private fun DataFrame.toNamedJsonFormat(name: String): QueryResult.NamedJsonFormat { val meta = schema.fields.map { QueryResult.Meta(it.name, it.type.name) } val data = rows.map { it.data } diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryController.kt b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryController.kt new file mode 100644 index 000000000..bcdf21742 --- /dev/null +++ b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryController.kt @@ -0,0 +1,148 @@ +package com.kakao.actionbase.server.api.graph.v3.query + +import com.kakao.actionbase.engine.service.MetadataStatus +import com.kakao.actionbase.engine.service.PreparedQueryAliasDescriptor +import com.kakao.actionbase.engine.service.PreparedQueryDescriptor +import com.kakao.actionbase.engine.service.PreparedQueryService +import com.kakao.actionbase.v2.engine.entity.EntityName + +import jakarta.validation.Valid + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +import reactor.core.publisher.Mono + +@RestController +class PreparedQueryController( + private val preparedQueryService: PreparedQueryService, +) { + private val tenant: String + get() = EntityName.tenant + + @GetMapping("/graph/v3/prepared-queries") + fun listQueries( + @RequestParam(required = false, defaultValue = "ACTIVE") status: MetadataStatus, + ): Mono>> = + preparedQueryService + .list(status) + .map { queries -> ResponseEntity.ok(queries.map { it.toResponse() }) } + + /** [id] takes either form: the id a registration was given, or a name pointing at one. */ + @GetMapping("/graph/v3/prepared-queries/{id}") + fun getQuery( + @PathVariable id: String, + ): Mono> = + preparedQueryService + .get(id) + .map { ResponseEntity.ok(it.toResponse()) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + + @PostMapping("/graph/v3/prepared-queries") + fun createQuery( + @Valid @RequestBody request: PreparedQueryCreateRequest, + ): Mono> = + preparedQueryService + .register( + desc = request.comment, + arguments = request.arguments.map { it.toStructField() }, + fetch = request.fetch, + transform = request.transform, + stats = request.stats, + ).map { ResponseEntity.ok(it.toResponse()) } + + @PutMapping("/graph/v3/prepared-queries/{id}") + fun updateQuery( + @PathVariable id: String, + @Valid @RequestBody request: PreparedQueryUpdateRequest, + ): Mono> = + preparedQueryService + .amend( + id = id, + desc = request.comment, + arguments = request.arguments?.map { it.toStructField() }, + fetch = request.fetch, + transform = request.transform, + stats = request.stats, + ).map { ResponseEntity.ok(it.toResponse()) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + + @DeleteMapping("/graph/v3/prepared-queries/{id}") + fun deleteQuery( + @PathVariable id: String, + ): Mono> = + preparedQueryService + .delete(id) + .then(Mono.just(ResponseEntity.noContent().build())) + + @GetMapping("/graph/v3/prepared-queries/aliases") + fun listAliases( + @RequestParam(required = false, defaultValue = "ACTIVE") status: MetadataStatus, + ): Mono>> = + preparedQueryService + .aliases(status) + .map { aliases -> ResponseEntity.ok(aliases.map { it.toResponse() }) } + + @GetMapping("/graph/v3/prepared-queries/aliases/{alias}") + fun getAlias( + @PathVariable alias: String, + ): Mono> = + preparedQueryService + .alias(alias) + .map { ResponseEntity.ok(it.toResponse()) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + + @PostMapping("/graph/v3/prepared-queries/aliases") + fun createAlias( + @Valid @RequestBody request: PreparedQueryAliasCreateRequest, + ): Mono> = + preparedQueryService + .createAlias(request.alias, request.comment, request.target) + .map { ResponseEntity.ok(it.toResponse()) } + + @PutMapping("/graph/v3/prepared-queries/aliases/{alias}") + fun updateAlias( + @PathVariable alias: String, + @Valid @RequestBody request: PreparedQueryAliasUpdateRequest, + ): Mono> = + preparedQueryService + .updateAlias(alias, request.comment, request.target, request.active) + .map { ResponseEntity.ok(it.toResponse()) } + .defaultIfEmpty(ResponseEntity.notFound().build()) + + @DeleteMapping("/graph/v3/prepared-queries/aliases/{alias}") + fun deleteAlias( + @PathVariable alias: String, + ): Mono> = + preparedQueryService + .deleteAlias(alias) + .then(Mono.just(ResponseEntity.noContent().build())) + + private fun PreparedQueryDescriptor.toResponse(): PreparedQueryResponse = + PreparedQueryResponse( + tenant = tenant, + id = id, + arguments = arguments, + fetch = fetch, + transform = transform, + stats = stats, + active = active, + comment = desc, + ) + + private fun PreparedQueryAliasDescriptor.toResponse(): PreparedQueryAliasResponse = + PreparedQueryAliasResponse( + tenant = tenant, + alias = alias, + target = target, + active = active, + comment = desc, + ) +} diff --git a/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryPayload.kt b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryPayload.kt new file mode 100644 index 000000000..255339ad0 --- /dev/null +++ b/server/src/main/kotlin/com/kakao/actionbase/server/api/graph/v3/query/PreparedQueryPayload.kt @@ -0,0 +1,92 @@ +package com.kakao.actionbase.server.api.graph.v3.query + +import com.kakao.actionbase.core.Constants +import com.kakao.actionbase.core.metadata.common.StructField +import com.kakao.actionbase.core.types.PrimitiveType +import com.kakao.actionbase.v2.engine.sql.StatKey + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.JsonNodeFactory + +data class QueryArgumentRequest( + @field:NotBlank(message = "argument name is required") + val name: String, + val type: PrimitiveType, + @field:Size(max = Constants.Name.COMMENT_MAX_LENGTH, message = Constants.Name.COMMENT_SIZE_MESSAGE) + val comment: String = "", +) { + fun toStructField(): StructField = StructField(name = name, type = type, comment = comment, nullable = false) +} + +data class PreparedQueryCreateRequest( + @field:Size(max = Constants.Name.COMMENT_MAX_LENGTH, message = Constants.Name.COMMENT_SIZE_MESSAGE) + val comment: String = "", + val arguments: List = emptyList(), + val fetch: JsonNode = JsonNodeFactory.instance.arrayNode(), + val transform: JsonNode = JsonNodeFactory.instance.arrayNode(), + val stats: Set = emptySet(), +) + +data class PreparedQueryUpdateRequest( + val active: Boolean? = null, + @field:Size(max = Constants.Name.COMMENT_MAX_LENGTH, message = Constants.Name.COMMENT_SIZE_MESSAGE) + val comment: String? = null, + val arguments: List? = null, + val fetch: JsonNode? = null, + val transform: JsonNode? = null, + val stats: Set? = null, +) + +data class PreparedQueryAliasCreateRequest( + @field:NotBlank(message = "alias is required") + @field:Pattern(regexp = Constants.Name.PATTERN, message = Constants.Name.MESSAGE) + val alias: String, + @field:NotBlank(message = "target is required") + val target: String, + @field:Size(max = Constants.Name.COMMENT_MAX_LENGTH, message = Constants.Name.COMMENT_SIZE_MESSAGE) + val comment: String = "", +) + +data class PreparedQueryAliasUpdateRequest( + val active: Boolean? = null, + @field:Size(max = Constants.Name.COMMENT_MAX_LENGTH, message = Constants.Name.COMMENT_SIZE_MESSAGE) + val comment: String? = null, + val target: String? = null, +) + +data class PreparedQueryExecuteRequest( + val arguments: Map = emptyMap(), +) + +data class PreparedQueryResponse( + val tenant: String, + val id: String, + val arguments: List, + val fetch: JsonNode, + val transform: JsonNode, + val stats: Set, + val active: Boolean = true, + val comment: String = Constants.DEFAULT_COMMENT, + val revision: Long = Constants.DEFAULT_REVISION, + val createdAt: Long = Constants.DEFAULT_CREATED_AT, + val createdBy: String = Constants.DEFAULT_CREATED_BY, + val updatedAt: Long = Constants.DEFAULT_UPDATED_AT, + val updatedBy: String = Constants.DEFAULT_UPDATED_BY, +) + +data class PreparedQueryAliasResponse( + val tenant: String, + val alias: String, + val target: String, + val active: Boolean = true, + val comment: String = Constants.DEFAULT_COMMENT, + val revision: Long = Constants.DEFAULT_REVISION, + val createdAt: Long = Constants.DEFAULT_CREATED_AT, + val createdBy: String = Constants.DEFAULT_CREATED_BY, + val updatedAt: Long = Constants.DEFAULT_UPDATED_AT, + val updatedBy: String = Constants.DEFAULT_UPDATED_BY, +) 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 872fb4635..69aaf0a36 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 @@ -5,6 +5,7 @@ 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.PreparedQueryService import com.kakao.actionbase.engine.service.QueryService import com.kakao.actionbase.engine.service.aggregation.AggregationHandler import com.kakao.actionbase.engine.service.aggregation.TopkAggregationHandler @@ -147,6 +148,12 @@ class GraphConfiguration { @Bean fun provideQueryService(engine: V2BackedEngine): QueryService = QueryService(engine) + @Bean + fun providePreparedQueryService( + graph: Graph, + queryService: QueryService, + ): PreparedQueryService = PreparedQueryService(graph, queryService) + @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 4afa72519..c55fff7de 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 @@ -24,7 +24,8 @@ class ReadOnlyRequestFilter : WebFilter { "/multi-edges/ids", "/query", ) - private val readSegments = setOf("/topks/") + + private val readSegments = setOf("/topks/", "/query/") init { log.info("ReadOnlyRequestFilter is active. Write operations on {} will be rejected.", paths) 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 f2d48cc63..2319bf81f 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 @@ -176,6 +176,10 @@ class ReadOnlyRequestFilterTest { "GET /graph/v3/databases/{database}/tables/{table}/edges/scan/{index}", "GET /graph/v3/databases/{database}/tables/{table}/multi-edges/ids", "GET /graph/v3/databases/{database}/tables/{table}/vertices/get", + "GET /graph/v3/prepared-queries", + "GET /graph/v3/prepared-queries/{id}", + "GET /graph/v3/prepared-queries/aliases", + "GET /graph/v3/prepared-queries/aliases/{alias}", "GET /graph/v3/datastore", "GET /graph/v3/datastore/hbase/namespaces", "GET /graph/v3/datastore/hbase/references", @@ -185,6 +189,7 @@ class ReadOnlyRequestFilterTest { "GET /graph/v3/datastore/hbase/tables/{tableName}/references", // read-only POST "POST /graph/v3/query", + "POST /graph/v3/query/{id}", "POST /graph/v3/databases/{database}/tables/{table}/edges/get", "POST /graph/v3/databases/{database}/tables/{table}/multi-edges/ids", "POST /aggregations/v1/databases/{database}/tables/{table}/topks/{topk}", @@ -225,6 +230,12 @@ class ReadOnlyRequestFilterTest { "PUT /graph/v2/service/{service}/label/{label}/edge/sync", "PUT /graph/v2/storage/{storage}", // v3 mutation + "POST /graph/v3/prepared-queries", + "PUT /graph/v3/prepared-queries/{id}", + "DELETE /graph/v3/prepared-queries/{id}", + "POST /graph/v3/prepared-queries/aliases", + "PUT /graph/v3/prepared-queries/aliases/{alias}", + "DELETE /graph/v3/prepared-queries/aliases/{alias}", "DELETE /graph/v3/databases/{database}", "DELETE /graph/v3/databases/{database}/aliases/{alias}", "DELETE /graph/v3/databases/{database}/tables/{table}", @@ -330,6 +341,7 @@ class ReadOnlyRequestFilterTest { "table" to "t", "tableFullName" to "t", "tableName" to "t", + "id" to "q", "tenant" to "alpha", "topk" to "tk", )