[Developer] #424 generate object query ORDS handlers

This commit is contained in:
devmrko
2026-06-23 15:27:17 +09:00
parent 0b43750a32
commit 4fa130cb8f
5 changed files with 202 additions and 8 deletions

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.domain.ords;
public record OrdsObjectHandlerResult(
long objectId,
String ordsPath,
String moduleName,
String template
) {
}

View File

@@ -2,6 +2,9 @@ package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerUpdateCommand;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsHandlerView;
import com.cloudhandson.vpdbackoffice.domain.ords.OrdsObjectHandlerResult;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.mapper.OrdsMetadataMapper;
import java.util.List;
import java.util.Locale;
@@ -14,10 +17,16 @@ public class OrdsMetadataService {
private final OrdsMetadataMapper mapper;
private final JdbcTemplate jdbcTemplate;
private final ProtectedObjectService protectedObjectService;
public OrdsMetadataService(OrdsMetadataMapper mapper, JdbcTemplate jdbcTemplate) {
public OrdsMetadataService(
OrdsMetadataMapper mapper,
JdbcTemplate jdbcTemplate,
ProtectedObjectService protectedObjectService
) {
this.mapper = mapper;
this.jdbcTemplate = jdbcTemplate;
this.protectedObjectService = protectedObjectService;
}
public List<OrdsHandlerView> findHandlers() {
@@ -71,4 +80,141 @@ public class OrdsMetadataService {
END;
""", handler.moduleName(), handler.template(), handler.method(), command.source());
}
@Transactional
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
List<String> columns = protectedObjectService.findColumns(objectId).stream()
.map(ProtectedColumn::columnName)
.toList();
if (columns.isEmpty()) {
throw new AppException("보호 객체 컬럼이 없어 ORDS 조회 Handler를 만들 수 없습니다.");
}
requireIdentifier(object.owner(), "owner");
requireIdentifier(object.objectName(), "objectName");
columns.forEach(column -> requireIdentifier(column, "column"));
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
if (currentUser == null || !currentUser.equalsIgnoreCase("CB_ORDS")) {
throw new AppException("ORDS 조회 Handler 생성은 ORDS parsing schema(CB_ORDS)로 DB에 연결했을 때만 가능합니다. 현재 연결 사용자: "
+ currentUser);
}
String moduleName = "cb.object.query";
String basePath = "cb-object-query/";
String template = object.owner().toLowerCase(Locale.ROOT) + "/" + object.objectName().toLowerCase(Locale.ROOT);
String ordsPath = "cb-ords/" + basePath + template;
String source = objectQuerySource(object, columns);
jdbcTemplate.update("""
BEGIN
ORDS.DEFINE_MODULE(
p_module_name => ?,
p_base_path => ?,
p_items_per_page => 25,
p_status => 'PUBLISHED'
);
ORDS.DEFINE_TEMPLATE(
p_module_name => ?,
p_pattern => ?
);
ORDS.DEFINE_HANDLER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_source_type => ORDS.source_type_plsql,
p_source => ?,
p_items_per_page => 0
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'Authorization',
p_bind_variable_name => 'auth_header',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => ?,
p_pattern => ?,
p_method => 'POST',
p_name => 'limit',
p_bind_variable_name => 'row_limit',
p_source_type => 'QUERY',
p_param_type => 'INT',
p_access_method => 'IN'
);
COMMIT;
END;
""",
moduleName, basePath,
moduleName, template,
moduleName, template, source,
moduleName, template,
moduleName, template);
protectedObjectService.updateOrdsPath(object.objectId(), ordsPath);
return new OrdsObjectHandlerResult(object.objectId(), ordsPath, moduleName, template);
}
private String objectQuerySource(ProtectedObject object, List<String> columns) {
String jsonEntries = columns.stream()
.map(column -> "'" + column.toLowerCase(Locale.ROOT) + "' VALUE " + column)
.reduce((left, right) -> left + ",\n " + right)
.orElseThrow();
String selectColumns = String.join(", ", columns);
return """
DECLARE
v_auth VARCHAR2(4000);
v_bearer_key VARCHAR2(4000);
v_limit NUMBER := LEAST(GREATEST(NVL(:row_limit, 50), 1), 500);
v_rows_json CLOB;
BEGIN
v_auth := TRIM(:auth_header);
IF v_auth IS NULL OR LOWER(SUBSTR(v_auth, 1, 7)) != 'bearer ' THEN
RAISE_APPLICATION_ERROR(-20101, 'Authorization header must be Bearer <key>');
END IF;
v_bearer_key := TRIM(SUBSTR(v_auth, 8));
admin.cb_agent_ctx_pkg.clear_user;
admin.cb_agent_ctx_pkg.set_user_by_bearer(v_bearer_key);
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
%s
RETURNING CLOB
)
RETURNING CLOB
)
INTO v_rows_json
FROM (
SELECT %s
FROM %s.%s
WHERE ROWNUM <= v_limit
);
IF v_rows_json IS NULL THEN
v_rows_json := '[]';
END IF;
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', TRUE);
HTP.P(JSON_OBJECT('rows' VALUE v_rows_json FORMAT JSON RETURNING CLOB));
admin.cb_agent_ctx_pkg.clear_user;
EXCEPTION
WHEN OTHERS THEN
admin.cb_agent_ctx_pkg.clear_user;
:status_code := 403;
OWA_UTIL.MIME_HEADER('application/json', TRUE);
HTP.P(JSON_OBJECT('error' VALUE SQLERRM RETURNING CLOB));
END;
""".formatted(jsonEntries, selectColumns, object.owner(), object.objectName());
}
private void requireIdentifier(String value, String label) {
if (value == null || !value.matches("[A-Z][A-Z0-9_$#]*")) {
throw new AppException("ORDS Handler 생성에 사용할 수 없는 " + label + " 식별자입니다: " + value);
}
}
}

View File

@@ -2,7 +2,9 @@ package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObjectCreateCommand;
import com.cloudhandson.vpdbackoffice.service.AppException;
import com.cloudhandson.vpdbackoffice.service.OrdsMetadataService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,9 +16,14 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
public class ProtectedObjectController {
private final ProtectedObjectService protectedObjectService;
private final OrdsMetadataService ordsMetadataService;
public ProtectedObjectController(ProtectedObjectService protectedObjectService) {
public ProtectedObjectController(
ProtectedObjectService protectedObjectService,
OrdsMetadataService ordsMetadataService
) {
this.protectedObjectService = protectedObjectService;
this.ordsMetadataService = ordsMetadataService;
}
@GetMapping("/objects")
@@ -60,6 +67,20 @@ public class ProtectedObjectController {
return "redirect:/objects";
}
@PostMapping("/objects/ords-handler")
public String createOrdsHandler(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
try {
var result = ordsMetadataService.createObjectQueryHandler(objectId);
redirectAttributes.addFlashAttribute("message", "ORDS 조회 Handler를 생성했습니다: " + result.ordsPath());
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", error.message());
}
return "redirect:/objects";
}
@PostMapping("/objects/disable")
public String disable(@RequestParam long objectId, RedirectAttributes redirectAttributes) {
protectedObjectService.disableObject(objectId);

View File

@@ -143,6 +143,17 @@ body {
display: inline;
}
.action-stack {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: .4rem;
}
.action-stack .inline-form {
display: inline-flex;
}
.switch-field {
align-items: center;
display: flex;

View File

@@ -61,7 +61,7 @@
<th>Owner</th>
<th>Object</th>
<th>ORDS Path</th>
<th></th>
<th>Action</th>
</tr>
</thead>
<tbody>
@@ -78,11 +78,18 @@
</form>
</td>
<td>
<form method="post" action="/objects/disable" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="objectId" th:value="${object.objectId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">비활성화</button>
</form>
<div class="action-stack">
<form method="post" action="/objects/ords-handler" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="objectId" th:value="${object.objectId()}">
<button class="btn btn-sm btn-outline-primary" type="submit">조회 Handler 생성</button>
</form>
<form method="post" action="/objects/disable" class="inline-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="objectId" th:value="${object.objectId()}">
<button class="btn btn-sm btn-outline-danger" type="submit">비활성화</button>
</form>
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}">