refs #739: align Smilegate with application repository layout
This commit is contained in:
@@ -7,16 +7,9 @@ after each review.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
from src.smilegate_demo.ui.shell import render_blank_shell
|
from ai_web_agent_console.smilegate_demo.ui.shell import render_blank_shell
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -2,13 +2,16 @@ package com.cloudhandson.vpdbackoffice.config;
|
|||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
/** Product-neutral labels and endpoint details for the MCP Select AI tool. */
|
/** Product-neutral MCP endpoint labels and tool catalogue configuration. */
|
||||||
@ConfigurationProperties(prefix = "backoffice.mcp")
|
@ConfigurationProperties(prefix = "backoffice.mcp")
|
||||||
public record McpProperties(
|
public record McpProperties(
|
||||||
|
String publicUrl,
|
||||||
|
String serverName,
|
||||||
String toolName,
|
String toolName,
|
||||||
String toolLabel,
|
String toolLabel,
|
||||||
String toolDescription,
|
String toolDescription,
|
||||||
String promptDescription,
|
String promptDescription,
|
||||||
|
String tools,
|
||||||
String showpromptToolName,
|
String showpromptToolName,
|
||||||
String showpromptToolLabel,
|
String showpromptToolLabel,
|
||||||
String showpromptToolDescription,
|
String showpromptToolDescription,
|
||||||
@@ -26,45 +29,29 @@ public record McpProperties(
|
|||||||
String gameScopeToolDescription
|
String gameScopeToolDescription
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private static final String DEFAULT_PUBLIC_URL = "/mcp";
|
||||||
|
private static final String DEFAULT_SERVER_NAME = "data-ai-backoffice";
|
||||||
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
|
||||||
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
private static final String DEFAULT_TOOL_LABEL = "업무 데이터 Text2SQL";
|
||||||
private static final String DEFAULT_TOOL_DESCRIPTION =
|
private static final String DEFAULT_TOOL_DESCRIPTION =
|
||||||
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. "
|
"승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 "
|
||||||
+ "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.";
|
+ "트랜잭션에서 실행합니다.";
|
||||||
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
private static final String DEFAULT_PROMPT_DESCRIPTION =
|
||||||
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
"업무 데이터에서 조회할 내용을 자연어로 입력합니다.";
|
||||||
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME =
|
|
||||||
"oracle.select_ai.data_showprompt";
|
private static final String DEFAULT_SHOWPROMPT_TOOL_NAME = "oracle.select_ai.data_showprompt";
|
||||||
private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL =
|
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME = "oracle.select_ai.qa_vector_search";
|
||||||
"업무 데이터 SHOWPROMPT";
|
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME = "oracle.select_ai.qa_vector_store";
|
||||||
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
|
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME = "oracle.select_ai.smilegate_fewshot_nl2sql";
|
||||||
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
|
private static final String DEFAULT_GAME_SCOPE_TOOL_NAME = "oracle.select_ai.game_scope_resolve";
|
||||||
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
|
|
||||||
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME =
|
public String resolvedPublicUrl() {
|
||||||
"oracle.select_ai.qa_vector_search";
|
return requiredOrDefault(publicUrl, DEFAULT_PUBLIC_URL);
|
||||||
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회";
|
}
|
||||||
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
|
|
||||||
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. "
|
public String resolvedServerName() {
|
||||||
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.";
|
return requiredOrDefault(serverName, DEFAULT_SERVER_NAME);
|
||||||
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME =
|
}
|
||||||
"oracle.select_ai.qa_vector_store";
|
|
||||||
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
|
|
||||||
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
|
|
||||||
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
|
|
||||||
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
|
|
||||||
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME =
|
|
||||||
"oracle.select_ai.smilegate_fewshot_nl2sql";
|
|
||||||
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL =
|
|
||||||
"Few-shot NL2SQL 실행";
|
|
||||||
private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION =
|
|
||||||
"벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. "
|
|
||||||
+ "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.";
|
|
||||||
private static final String DEFAULT_GAME_SCOPE_TOOL_NAME =
|
|
||||||
"oracle.select_ai.game_scope_resolve";
|
|
||||||
private static final String DEFAULT_GAME_SCOPE_TOOL_LABEL = "게임 조회 범위 확인";
|
|
||||||
private static final String DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION =
|
|
||||||
"질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, "
|
|
||||||
+ "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.";
|
|
||||||
|
|
||||||
public String resolvedToolName() {
|
public String resolvedToolName() {
|
||||||
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
|
||||||
@@ -82,65 +69,21 @@ public record McpProperties(
|
|||||||
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
|
return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolvedShowpromptToolName() {
|
public String resolvedShowpromptToolName() { return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME); }
|
||||||
return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME);
|
public String resolvedShowpromptToolLabel() { return requiredOrDefault(showpromptToolLabel, "업무 데이터 SHOWPROMPT"); }
|
||||||
}
|
public String resolvedShowpromptToolDescription() { return requiredOrDefault(showpromptToolDescription, "Select AI SQL 생성 prompt를 조회하는 읽기 전용 진단 도구입니다."); }
|
||||||
|
public String resolvedQaVectorSearchToolName() { return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME); }
|
||||||
public String resolvedShowpromptToolLabel() {
|
public String resolvedQaVectorSearchToolLabel() { return requiredOrDefault(qaVectorSearchToolLabel, "Select AI 예제 SQL 조회"); }
|
||||||
return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL);
|
public String resolvedQaVectorSearchToolDescription() { return requiredOrDefault(qaVectorSearchToolDescription, "현재 질문에 사용할 유사 예제 SQL을 조회합니다."); }
|
||||||
}
|
public String resolvedQaVectorStoreToolName() { return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME); }
|
||||||
|
public String resolvedQaVectorStoreToolLabel() { return requiredOrDefault(qaVectorStoreToolLabel, "Select AI 예제 SQL 저장"); }
|
||||||
public String resolvedShowpromptToolDescription() {
|
public String resolvedQaVectorStoreToolDescription() { return requiredOrDefault(qaVectorStoreToolDescription, "검토된 읽기 전용 답 SQL을 예제로 저장합니다."); }
|
||||||
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
|
public String resolvedFewShotNl2SqlToolName() { return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME); }
|
||||||
}
|
public String resolvedFewShotNl2SqlToolLabel() { return requiredOrDefault(fewShotNl2SqlToolLabel, "Few-shot NL2SQL 실행"); }
|
||||||
|
public String resolvedFewShotNl2SqlToolDescription() { return requiredOrDefault(fewShotNl2SqlToolDescription, "검증된 Few-shot 예제를 참고해 읽기 전용 SQL을 생성·실행합니다."); }
|
||||||
public String resolvedQaVectorSearchToolName() {
|
public String resolvedGameScopeToolName() { return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME); }
|
||||||
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME);
|
public String resolvedGameScopeToolLabel() { return requiredOrDefault(gameScopeToolLabel, "게임 조회 범위 확인"); }
|
||||||
}
|
public String resolvedGameScopeToolDescription() { return requiredOrDefault(gameScopeToolDescription, "질문의 게임 별칭과 데이터 조회 범위를 확인합니다."); }
|
||||||
|
|
||||||
public String resolvedQaVectorSearchToolLabel() {
|
|
||||||
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedQaVectorSearchToolDescription() {
|
|
||||||
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedQaVectorStoreToolName() {
|
|
||||||
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedQaVectorStoreToolLabel() {
|
|
||||||
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedQaVectorStoreToolDescription() {
|
|
||||||
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedFewShotNl2SqlToolName() {
|
|
||||||
return requiredOrDefault(fewShotNl2SqlToolName, DEFAULT_FEW_SHOT_NL2SQL_TOOL_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedFewShotNl2SqlToolLabel() {
|
|
||||||
return requiredOrDefault(fewShotNl2SqlToolLabel, DEFAULT_FEW_SHOT_NL2SQL_TOOL_LABEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedFewShotNl2SqlToolDescription() {
|
|
||||||
return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedGameScopeToolName() {
|
|
||||||
return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedGameScopeToolLabel() {
|
|
||||||
return requiredOrDefault(gameScopeToolLabel, DEFAULT_GAME_SCOPE_TOOL_LABEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String resolvedGameScopeToolDescription() {
|
|
||||||
return requiredOrDefault(gameScopeToolDescription, DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String requiredOrDefault(String value, String fallback) {
|
private String requiredOrDefault(String value, String fallback) {
|
||||||
return value == null || value.isBlank() ? fallback : value.trim();
|
return value == null || value.isBlank() ? fallback : value.trim();
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.domain.structured;
|
package com.cloudhandson.vpdbackoffice.domain.structured;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public record StructuredDataTable(
|
public record StructuredDataTable(
|
||||||
String key,
|
String key,
|
||||||
String tableName,
|
String tableName,
|
||||||
String objectType,
|
String objectType,
|
||||||
String businessName,
|
String businessName,
|
||||||
String description
|
String description,
|
||||||
|
List<String> previewColumns
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
public StructuredDataTable(String key, String tableName, String businessName, String description) {
|
||||||
this(key, tableName, "TABLE", businessName, description);
|
this(key, tableName, "TABLE", businessName, description, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTable() {
|
||||||
|
return "TABLE".equals(objectType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
|
||||||
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
|
||||||
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
/**
|
|
||||||
* Screen-level orchestration for schema metadata. Database reads and DDL are
|
|
||||||
* intentionally delegated only to {@link SchemaMetadataMapper}.
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
public class SchemaMetadataService {
|
public class SchemaMetadataService {
|
||||||
|
|
||||||
@@ -27,17 +22,12 @@ public class SchemaMetadataService {
|
|||||||
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000;
|
||||||
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
||||||
|
|
||||||
private final SchemaMetadataMapper mapper;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final StructuredDataService structuredDataService;
|
private final StructuredDataService structuredDataService;
|
||||||
private final DataCatalog catalog;
|
|
||||||
|
|
||||||
public SchemaMetadataService(
|
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
|
||||||
SchemaMetadataMapper mapper,
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
StructuredDataService structuredDataService, DataCatalog catalog
|
|
||||||
) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.structuredDataService = structuredDataService;
|
this.structuredDataService = structuredDataService;
|
||||||
this.catalog = catalog;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
@@ -54,12 +44,14 @@ public class SchemaMetadataService {
|
|||||||
|
|
||||||
public SchemaMetadataView find(String tableKey) {
|
public SchemaMetadataView find(String tableKey) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
String tableName = table.tableName();
|
||||||
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
|
String tableComment = tableComment(tableName);
|
||||||
|
Map<String, List<SchemaAnnotation>> annotations =
|
||||||
|
table.isTable() ? annotationsByTarget(tableName) : Map.of();
|
||||||
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
|
||||||
return new SchemaMetadataView(
|
return new SchemaMetadataView(
|
||||||
table,
|
table,
|
||||||
nullToEmpty(mapper.findTableComment(catalog.owner(), tableName)),
|
nullToEmpty(tableComment),
|
||||||
annotations.getOrDefault(tableTargetKey(), List.of()),
|
annotations.getOrDefault(tableTargetKey(), List.of()),
|
||||||
columns
|
columns
|
||||||
);
|
);
|
||||||
@@ -68,24 +60,25 @@ public class SchemaMetadataService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableComment(String tableKey, String comment) {
|
public void updateTableComment(String tableKey, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
|
||||||
mapper.updateTableComment(catalog.owner(), tableName, quoteLiteral(normalizedComment));
|
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
|
||||||
|
+ " IS " + quoteLiteral(normalizedComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
public void updateColumnComment(String tableKey, String columnName, String comment) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
String column = requireColumn(table.tableName(), columnName);
|
||||||
String column = requireColumn(tableName, columnName);
|
|
||||||
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
|
||||||
mapper.updateColumnComment(catalog.owner(), tableName, column, quoteLiteral(normalizedComment));
|
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
|
||||||
|
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
|
requireAnnotationTable(table);
|
||||||
|
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -96,9 +89,9 @@ public class SchemaMetadataService {
|
|||||||
String annotationValue
|
String annotationValue
|
||||||
) {
|
) {
|
||||||
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
StructuredDataTable table = structuredDataService.requireTable(tableKey);
|
||||||
String tableName = requireSimpleName(table.tableName(), "table name");
|
requireAnnotationTable(table);
|
||||||
String column = requireColumn(tableName, columnName);
|
String column = requireColumn(table.tableName(), columnName);
|
||||||
updateAnnotation(tableName, column, annotationName, annotationValue);
|
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateAnnotation(
|
private void updateAnnotation(
|
||||||
@@ -110,55 +103,75 @@ public class SchemaMetadataService {
|
|||||||
String key = requireSimpleName(annotationName, "annotation name");
|
String key = requireSimpleName(annotationName, "annotation name");
|
||||||
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
|
||||||
if (annotationExists(tableName, columnName, key)) {
|
if (annotationExists(tableName, columnName, key)) {
|
||||||
if (columnName == null) {
|
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key)));
|
||||||
mapper.dropTableAnnotation(catalog.owner(), tableName, key);
|
|
||||||
} else {
|
|
||||||
mapper.dropColumnAnnotation(catalog.owner(), tableName, columnName, key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!value.isBlank()) {
|
if (!value.isBlank()) {
|
||||||
if (columnName == null) {
|
jdbcTemplate.execute(annotationSql(tableName, columnName,
|
||||||
mapper.addTableAnnotation(catalog.owner(), tableName, key, quoteLiteral(value));
|
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
|
||||||
} else {
|
|
||||||
mapper.addColumnAnnotation(catalog.owner(), tableName, columnName, key, quoteLiteral(value));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String tableComment(String tableName) {
|
||||||
|
List<String> values = jdbcTemplate.query("""
|
||||||
|
SELECT comments
|
||||||
|
FROM all_tab_comments
|
||||||
|
WHERE owner = ?
|
||||||
|
AND table_name = ?
|
||||||
|
""", (rs, rowNum) -> rs.getString(1), owner(), tableName);
|
||||||
|
return values.isEmpty() ? "" : values.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SchemaMetadataColumn> columns(
|
private List<SchemaMetadataColumn> columns(
|
||||||
String tableName,
|
String tableName,
|
||||||
Map<String, List<SchemaAnnotation>> annotations
|
Map<String, List<SchemaAnnotation>> annotations
|
||||||
) {
|
) {
|
||||||
return mapper.findColumns(catalog.owner(), tableName).stream()
|
return jdbcTemplate.query("""
|
||||||
.map(row -> toColumn(row, annotations))
|
SELECT c.column_name,
|
||||||
.toList();
|
CASE
|
||||||
}
|
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
|
||||||
|
THEN c.data_type || '(' || c.char_length || ')'
|
||||||
private SchemaMetadataColumn toColumn(
|
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
|
||||||
SchemaMetadataColumnRow row,
|
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
|
||||||
Map<String, List<SchemaAnnotation>> annotations
|
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
|
||||||
) {
|
THEN c.data_type || '(' || c.data_precision || ')'
|
||||||
String columnName = requireSimpleName(row.columnName(), "column name");
|
ELSE c.data_type
|
||||||
return new SchemaMetadataColumn(
|
END AS display_type,
|
||||||
columnName,
|
c.nullable,
|
||||||
row.dataType(),
|
cc.comments
|
||||||
"Y".equalsIgnoreCase(row.nullable()),
|
FROM all_tab_columns c
|
||||||
nullToEmpty(row.comment()),
|
LEFT JOIN all_col_comments cc
|
||||||
annotations.getOrDefault(columnTargetKey(columnName), List.of())
|
ON cc.owner = c.owner
|
||||||
);
|
AND cc.table_name = c.table_name
|
||||||
|
AND cc.column_name = c.column_name
|
||||||
|
WHERE c.owner = ?
|
||||||
|
AND c.table_name = ?
|
||||||
|
ORDER BY c.column_id
|
||||||
|
""", (rs, rowNum) -> new SchemaMetadataColumn(
|
||||||
|
rs.getString("column_name"),
|
||||||
|
rs.getString("display_type"),
|
||||||
|
"Y".equalsIgnoreCase(rs.getString("nullable")),
|
||||||
|
nullToEmpty(rs.getString("comments")),
|
||||||
|
annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of())
|
||||||
|
), owner(), tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
|
||||||
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
|
||||||
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(tableName)) {
|
jdbcTemplate.query("""
|
||||||
String target = row.columnName() == null
|
SELECT column_name, annotation_name, annotation_value
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = ?
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
|
||||||
|
""", rs -> {
|
||||||
|
String target = rs.getString("column_name") == null
|
||||||
? tableTargetKey()
|
? tableTargetKey()
|
||||||
: columnTargetKey(row.columnName());
|
: columnTargetKey(rs.getString("column_name"));
|
||||||
grouped
|
grouped
|
||||||
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
|
||||||
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
|
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
|
||||||
.add(nullToEmpty(row.annotationValue()));
|
.add(nullToEmpty(rs.getString("annotation_value")));
|
||||||
}
|
}, tableName);
|
||||||
|
|
||||||
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
|
||||||
grouped.forEach((target, valuesByName) -> {
|
grouped.forEach((target, valuesByName) -> {
|
||||||
@@ -173,14 +186,44 @@ public class SchemaMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
private boolean annotationExists(String tableName, String columnName, String annotationName) {
|
||||||
return columnName == null
|
Integer count = columnName == null
|
||||||
? mapper.countTableAnnotation(tableName, annotationName) > 0
|
? jdbcTemplate.queryForObject("""
|
||||||
: mapper.countColumnAnnotation(tableName, columnName, annotationName) > 0;
|
SELECT COUNT(*)
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = ?
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
AND annotation_name = ?
|
||||||
|
AND column_name IS NULL
|
||||||
|
""", Integer.class, tableName, annotationName)
|
||||||
|
: jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM all_annotations_usage
|
||||||
|
WHERE object_name = ?
|
||||||
|
AND object_type = 'TABLE'
|
||||||
|
AND annotation_name = ?
|
||||||
|
AND column_name = ?
|
||||||
|
""", Integer.class, tableName, annotationName, columnName);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String annotationSql(String tableName, String columnName, String operation) {
|
||||||
|
if (columnName == null) {
|
||||||
|
return "ALTER TABLE " + qualifiedTable(tableName) + " ANNOTATIONS (" + operation + ")";
|
||||||
|
}
|
||||||
|
return "ALTER TABLE " + qualifiedTable(tableName) + " MODIFY " + quoteName(columnName)
|
||||||
|
+ " ANNOTATIONS (" + operation + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String requireColumn(String tableName, String columnName) {
|
private String requireColumn(String tableName, String columnName) {
|
||||||
String column = requireSimpleName(columnName, "column name");
|
String column = requireSimpleName(columnName, "column name");
|
||||||
if (mapper.countColumn(catalog.owner(), tableName, column) == 0) {
|
Integer count = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM all_tab_columns
|
||||||
|
WHERE owner = ?
|
||||||
|
AND table_name = ?
|
||||||
|
AND column_name = ?
|
||||||
|
""", Integer.class, owner(), tableName, column);
|
||||||
|
if (count == null || count == 0) {
|
||||||
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
|
||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
@@ -211,6 +254,18 @@ public class SchemaMetadataService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String qualifiedTable(String tableName) {
|
||||||
|
return quoteName(owner()) + "." + quoteName(requireSimpleName(tableName, "table name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String owner() {
|
||||||
|
return structuredDataService.owner();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quoteName(String value) {
|
||||||
|
return "\"" + value.replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
private String quoteLiteral(String value) {
|
private String quoteLiteral(String value) {
|
||||||
return "'" + value.replace("'", "''") + "'";
|
return "'" + value.replace("'", "''") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
package com.cloudhandson.vpdbackoffice.service;
|
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
|
||||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.ResultSetMetaData;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates and executes bounded read-only SQL through the configured schema-owned Select AI profile.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class SelectAiService {
|
|
||||||
|
|
||||||
private static final int MAX_PROMPT_LENGTH = 4_000;
|
|
||||||
private static final int MAX_RESULT_ROWS = 100;
|
|
||||||
private static final int QUERY_TIMEOUT_SECONDS = 30;
|
|
||||||
private static final Pattern UNSAFE_SQL = Pattern.compile(
|
|
||||||
"(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|"
|
|
||||||
+ "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|"
|
|
||||||
+ "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b"
|
|
||||||
);
|
|
||||||
|
|
||||||
private final BackofficeProperties properties;
|
|
||||||
private final BearerTokenService bearerTokenService;
|
|
||||||
private final Clock clock;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
public SelectAiService(
|
|
||||||
BackofficeProperties properties,
|
|
||||||
BearerTokenService bearerTokenService,
|
|
||||||
Clock clock,
|
|
||||||
ObjectMapper objectMapper
|
|
||||||
) {
|
|
||||||
this.properties = properties;
|
|
||||||
this.bearerTokenService = bearerTokenService;
|
|
||||||
this.clock = clock;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
|
||||||
requireActiveToken(bearerToken);
|
|
||||||
String normalizedPrompt = requiredPrompt(prompt);
|
|
||||||
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
|
|
||||||
if (selectAi == null || !selectAi.configured()) {
|
|
||||||
throw new AppException("Select AI 연결 설정이 필요합니다. "
|
|
||||||
+ "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
|
||||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요.");
|
|
||||||
}
|
|
||||||
|
|
||||||
String generatedSql = generate(selectAi, normalizedPrompt);
|
|
||||||
String normalizedSql = validateReadOnlySql(generatedSql);
|
|
||||||
QueryExecution execution = executeReadOnly(selectAi, normalizedSql);
|
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
|
||||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
|
||||||
response.put("profile", selectAi.profile());
|
|
||||||
response.put("generatedSql", normalizedSql);
|
|
||||||
response.put("execution", "READ_ONLY_EXECUTED");
|
|
||||||
response.put("rowCount", execution.items().size());
|
|
||||||
response.put("truncated", execution.truncated());
|
|
||||||
response.set("items", execution.items());
|
|
||||||
response.put("nextStep", execution.truncated()
|
|
||||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다."
|
|
||||||
: "생성 SQL을 읽기 전용으로 실행한 결과입니다.");
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireActiveToken(String bearerToken) {
|
|
||||||
if (bearerToken == null || bearerToken.isBlank()) {
|
|
||||||
throw new VpdTokenAccessDeniedException();
|
|
||||||
}
|
|
||||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
|
|
||||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
|
||||||
if (token == null || !token.active(now)) {
|
|
||||||
throw new VpdTokenAccessDeniedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String requiredPrompt(String prompt) {
|
|
||||||
String normalized = prompt == null ? "" : prompt.trim();
|
|
||||||
if (normalized.isEmpty()) {
|
|
||||||
throw new AppException("prompt는 필수입니다.");
|
|
||||||
}
|
|
||||||
if (normalized.length() > MAX_PROMPT_LENGTH) {
|
|
||||||
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generate(BackofficeProperties.SelectAi selectAi, String prompt) {
|
|
||||||
String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual";
|
|
||||||
try (Connection connection = DriverManager.getConnection(
|
|
||||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
|
||||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
|
||||||
statement.setString(1, prompt);
|
|
||||||
statement.setString(2, selectAi.profile());
|
|
||||||
try (ResultSet resultSet = statement.executeQuery()) {
|
|
||||||
if (!resultSet.next() || resultSet.getString(1) == null) {
|
|
||||||
throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다.");
|
|
||||||
}
|
|
||||||
return resultSet.getString(1);
|
|
||||||
}
|
|
||||||
} catch (AppException exception) {
|
|
||||||
throw exception;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) {
|
|
||||||
ArrayNode items = objectMapper.createArrayNode();
|
|
||||||
boolean truncated = false;
|
|
||||||
try (Connection connection = DriverManager.getConnection(
|
|
||||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
|
|
||||||
Statement transaction = connection.createStatement()) {
|
|
||||||
connection.setAutoCommit(false);
|
|
||||||
connection.setReadOnly(true);
|
|
||||||
transaction.execute("SET TRANSACTION READ ONLY");
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement(generatedSql)) {
|
|
||||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
|
||||||
statement.setFetchSize(MAX_RESULT_ROWS + 1);
|
|
||||||
statement.setMaxRows(MAX_RESULT_ROWS + 1);
|
|
||||||
try (ResultSet resultSet = statement.executeQuery()) {
|
|
||||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
|
||||||
while (resultSet.next()) {
|
|
||||||
if (items.size() >= MAX_RESULT_ROWS) {
|
|
||||||
truncated = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
ObjectNode row = items.addObject();
|
|
||||||
for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) {
|
|
||||||
String column = metadata.getColumnLabel(columnIndex);
|
|
||||||
if (column == null || column.isBlank()) {
|
|
||||||
column = metadata.getColumnName(columnIndex);
|
|
||||||
}
|
|
||||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
connection.rollback();
|
|
||||||
}
|
|
||||||
} catch (Exception exception) {
|
|
||||||
throw new AppException("Select AI 생성 SQL 실행 실패: " + exception.getMessage());
|
|
||||||
}
|
|
||||||
return new QueryExecution(items, truncated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void putResultValue(ObjectNode row, String column, Object value) {
|
|
||||||
if (value == null) {
|
|
||||||
row.putNull(column);
|
|
||||||
} else if (value instanceof BigDecimal number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof BigInteger number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Integer number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Long number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Short number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Float number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Double number) {
|
|
||||||
row.put(column, number);
|
|
||||||
} else if (value instanceof Boolean bool) {
|
|
||||||
row.put(column, bool);
|
|
||||||
} else {
|
|
||||||
row.put(column, String.valueOf(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String validateReadOnlySql(String generatedSql) {
|
|
||||||
String normalized = generatedSql == null ? "" : generatedSql.trim();
|
|
||||||
if (normalized.startsWith("```")) {
|
|
||||||
int firstLineEnd = normalized.indexOf('\n');
|
|
||||||
int closingFence = normalized.lastIndexOf("```");
|
|
||||||
if (firstLineEnd >= 0 && closingFence > firstLineEnd) {
|
|
||||||
normalized = normalized.substring(firstLineEnd + 1, closingFence).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized = normalized.replaceFirst(";\\s*$", "").trim();
|
|
||||||
if (!normalized.matches("(?is)^(select|with)\\b.*")) {
|
|
||||||
throw new AppException("Select AI가 읽기 전용 SELECT/WITH SQL을 반환하지 않았습니다.");
|
|
||||||
}
|
|
||||||
if (normalized.contains(";")) {
|
|
||||||
throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다.");
|
|
||||||
}
|
|
||||||
if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|
|
||||||
|| UNSAFE_SQL.matcher(normalized).find()) {
|
|
||||||
throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다.");
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private record QueryExecution(ArrayNode items, boolean truncated) {}
|
|
||||||
}
|
|
||||||
@@ -12,14 +12,26 @@ import org.springframework.stereotype.Service;
|
|||||||
public class StructuredDataService {
|
public class StructuredDataService {
|
||||||
|
|
||||||
private static final int ROW_LIMIT = 50;
|
private static final int ROW_LIMIT = 50;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final DataCatalog catalog;
|
private final DataCatalog catalog;
|
||||||
|
|
||||||
public StructuredDataService(JdbcTemplate jdbcTemplate, DataCatalog catalog) {
|
public StructuredDataService(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
DataCatalog catalog
|
||||||
|
) {
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
this.catalog = catalog;
|
this.catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DataCatalog catalog() {
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String owner() {
|
||||||
|
return catalog.owner();
|
||||||
|
}
|
||||||
|
|
||||||
public List<StructuredDataTable> tables() {
|
public List<StructuredDataTable> tables() {
|
||||||
return catalog.objects();
|
return catalog.objects();
|
||||||
}
|
}
|
||||||
@@ -47,16 +59,43 @@ public class StructuredDataService {
|
|||||||
if (columns.isEmpty()) {
|
if (columns.isEmpty()) {
|
||||||
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
List<String> previewColumns =
|
||||||
|
table.previewColumns().isEmpty() ? columns : table.previewColumns();
|
||||||
|
if (!columns.containsAll(previewColumns)) {
|
||||||
|
throw new AppException("환경 카탈로그의 미리보기 컬럼이 실제 객체와 일치하지 않습니다.");
|
||||||
|
}
|
||||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
previewSql(table), ROW_LIMIT);
|
previewSql(table, previewColumns), ROW_LIMIT);
|
||||||
return new StructuredDataPreview(table, columns, rows, ROW_LIMIT);
|
return new StructuredDataPreview(table, previewColumns, rows, ROW_LIMIT);
|
||||||
} catch (DataAccessException exception) {
|
} catch (DataAccessException exception) {
|
||||||
throw new AppException("카탈로그 데이터를 조회할 수 없습니다. DB 권한과 대상 객체 상태를 확인하세요.");
|
throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.owner()
|
||||||
|
+ " 조회 권한과 대상 객체 상태를 확인하세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String previewSql(StructuredDataTable table) {
|
/**
|
||||||
return "SELECT * FROM \"" + catalog.owner() + "\".\"" + table.tableName() + "\" WHERE ROWNUM <= ?";
|
* The table is selected from a closed application whitelist, so the query
|
||||||
|
* text remains fixed and no request value can become a SQL identifier.
|
||||||
|
*/
|
||||||
|
String previewSql(StructuredDataTable table) {
|
||||||
|
return previewSql(table, table.previewColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String previewSql(
|
||||||
|
StructuredDataTable table,
|
||||||
|
List<String> previewColumns
|
||||||
|
) {
|
||||||
|
StructuredDataTable approved = requireTable(table.key());
|
||||||
|
if (!approved.tableName().equals(table.tableName())) {
|
||||||
|
throw new AppException("선택할 수 없는 카탈로그 객체입니다.");
|
||||||
|
}
|
||||||
|
String projection = previewColumns == null || previewColumns.isEmpty()
|
||||||
|
? "*"
|
||||||
|
: previewColumns.stream()
|
||||||
|
.map(column -> "\"" + column + "\"")
|
||||||
|
.reduce((left, right) -> left + ", " + right)
|
||||||
|
.orElseThrow();
|
||||||
|
return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName()
|
||||||
|
+ "\" WHERE ROWNUM <= ?";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user