refs #703: route schema metadata through MyBatis

This commit is contained in:
devmrko
2026-07-23 10:29:22 +09:00
parent 99798aa6bb
commit b106e40631
7 changed files with 384 additions and 114 deletions

View File

@@ -24,6 +24,7 @@
4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다. 4. Data Redaction 동기화는 `SGMP_POC`의 실제 게임 사용자·판매 데이터 컬럼만 관리 대상으로 삼는다.
5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다. 5. 내부 호환용 `CB_*` 뷰와 과거 SQL 이력은 실행 경로에서 제외한다. Smilegate 공개 화면·MCP 설정은 이력의 고객 데이터나 endpoint를 참조하지 않는다.
6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다. 6. Streamlit 외피는 Smilegate 프로필·게임 데이터 시나리오·`oracle.select_ai.smilegate_game_text2sql` MCP 하나만 노출한다. 이전 고객용 토큰 프리셋 및 감사·보안관리 탭은 기본 실행 경로에서 제외한다.
7. `/schema-metadata`의 테이블 comment·컬럼 comment·annotation 조회와 저장 DDL은 모두 `SchemaMetadataMapper`로 수행한다. 메타데이터 조회는 `SGMP_POC` owner와 허용된 테이블 목록으로 한정한다.
## 설계 결정 ## 설계 결정
@@ -51,6 +52,10 @@ MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의
마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다. 마스킹 동기화 대상 owner는 `SGMP_POC`다. 관리 정책은 실제 컬럼 존재 여부를 검증한 뒤 사용자 식별자와 거래 사용자 식별자에만 적용한다. 대상에 없는 규칙은 DBMS_REDACT 호출 전에 화면 설정 오류로 처리한다.
### 5. 스키마 메타데이터 접근은 MyBatis로 통일한다
`/schema-metadata`는 화면 카드 목록을 정적 허용 목록에서 만들고, 선택된 테이블의 comment·컬럼·annotation만 조회한다. 서비스 계층에는 JDBC 직접 실행을 두지 않는다. MyBatis mapper의 모든 사전 조회는 `owner = 'SGMP_POC'` 조건을 갖고, DDL에 쓰이는 테이블·컬럼·annotation 이름은 호출 전에 대문자 식별자 규칙과 허용 테이블 목록으로 검증한다.
## 변경 파일과 책임 ## 변경 파일과 책임
| 영역 | 파일 | 변경 | | 영역 | 파일 | 변경 |
@@ -61,6 +66,7 @@ MCP tool은 `SGMP_POC_HAIKU45` 프로파일을 기준으로 게임 데이터의
| MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 | | MCP 화면 | `templates/mcp-sse.html`, `McpSseService.java`, `SmilegateSelectAiService.java` | 게임 데이터 Select AI 도구, 토큰 검증 및 SHOWSQL 생성 |
| 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 | | 보안 스크립트 화면 | `SecuritySqlScriptService.java` | UI에 노출되는 KB 설명을 게임 데이터 설명으로 교체 |
| Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 | | Streamlit 외피 | `poc4_active_source_20260714/config/`, `apps/poc4/mcp_discovery_ui.py` | Smilegate 로그인/헤더/시나리오와 단일 게임 Text2SQL MCP 계약 적용 |
| 스키마 메타데이터 | `SchemaMetadataService.java`, `SchemaMetadataMapper.java`, `SchemaMetadataMapper.xml` | 직접 JDBC 제거, MyBatis 조회·DDL 통일, `SGMP_POC` owner 조건 강제 |
## 완료 기준 ## 완료 기준

View File

@@ -0,0 +1,11 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
/**
* Database row from ALL_ANNOTATIONS_USAGE for one table or column annotation.
*/
public record SchemaMetadataAnnotationRow(
String columnName,
String annotationName,
String annotationValue
) {
}

View File

@@ -0,0 +1,12 @@
package com.cloudhandson.vpdbackoffice.domain.schemametadata;
/**
* Database row used only while assembling the schema metadata screen.
*/
public record SchemaMetadataColumnRow(
String columnName,
String dataType,
String nullable,
String comment
) {
}

View File

@@ -0,0 +1,87 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* All database access for the schema metadata screen. Identifier parameters
* are validated against the screen's closed table list before mapper calls.
*/
@Mapper
public interface SchemaMetadataMapper {
String findTableComment(@Param("owner") String owner, @Param("tableName") String tableName);
List<SchemaMetadataColumnRow> findColumns(
@Param("owner") String owner,
@Param("tableName") String tableName
);
List<SchemaMetadataAnnotationRow> findAnnotations(
@Param("owner") String owner,
@Param("tableName") String tableName
);
int countColumn(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName
);
int countTableAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("annotationName") String annotationName
);
int countColumnAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName
);
void updateTableComment(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("commentLiteral") String commentLiteral
);
void updateColumnComment(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("commentLiteral") String commentLiteral
);
void dropTableAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("annotationName") String annotationName
);
void addTableAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("annotationName") String annotationName,
@Param("annotationValueLiteral") String annotationValueLiteral
);
void dropColumnAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName
);
void addColumnAnnotation(
@Param("owner") String owner,
@Param("tableName") String tableName,
@Param("columnName") String columnName,
@Param("annotationName") String annotationName,
@Param("annotationValueLiteral") String annotationValueLiteral
);
}

View File

@@ -1,20 +1,25 @@
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 {
@@ -23,11 +28,14 @@ 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 JdbcTemplate jdbcTemplate; private final SchemaMetadataMapper mapper;
private final StructuredDataService structuredDataService; private final StructuredDataService structuredDataService;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) { public SchemaMetadataService(
this.jdbcTemplate = jdbcTemplate; SchemaMetadataMapper mapper,
StructuredDataService structuredDataService
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService; this.structuredDataService = structuredDataService;
} }
@@ -41,13 +49,12 @@ 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 = table.tableName(); String tableName = requireSimpleName(table.tableName(), "table name");
String tableComment = tableComment(tableName);
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName); Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations); List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView( return new SchemaMetadataView(
table, table,
nullToEmpty(tableComment), nullToEmpty(mapper.findTableComment(OWNER, tableName)),
annotations.getOrDefault(tableTargetKey(), List.of()), annotations.getOrDefault(tableTargetKey(), List.of()),
columns columns
); );
@@ -56,24 +63,24 @@ 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");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName()) mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
+ " 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 column = requireColumn(table.tableName(), columnName); String tableName = requireSimpleName(table.tableName(), "table name");
String column = requireColumn(tableName, columnName);
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment"); String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "컬럼 comment");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "." mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
+ 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(table.tableName(), null, annotationName, annotationValue); updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
} }
@Transactional @Transactional
@@ -84,8 +91,9 @@ public class SchemaMetadataService {
String annotationValue String annotationValue
) { ) {
StructuredDataTable table = structuredDataService.requireTable(tableKey); StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName); String tableName = requireSimpleName(table.tableName(), "table name");
updateAnnotation(table.tableName(), column, annotationName, annotationValue); String column = requireColumn(tableName, columnName);
updateAnnotation(tableName, column, annotationName, annotationValue);
} }
private void updateAnnotation( private void updateAnnotation(
@@ -97,75 +105,55 @@ 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)) {
jdbcTemplate.execute(annotationSql(tableName, columnName, "DROP " + quoteName(key))); if (columnName == null) {
mapper.dropTableAnnotation(OWNER, tableName, key);
} else {
mapper.dropColumnAnnotation(OWNER, tableName, columnName, key);
}
} }
if (!value.isBlank()) { if (!value.isBlank()) {
jdbcTemplate.execute(annotationSql(tableName, columnName, if (columnName == null) {
"ADD " + quoteName(key) + " " + quoteLiteral(value))); mapper.addTableAnnotation(OWNER, tableName, key, quoteLiteral(value));
} else {
mapper.addColumnAnnotation(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 jdbcTemplate.query(""" return mapper.findColumns(OWNER, tableName).stream()
SELECT c.column_name, .map(row -> toColumn(row, annotations))
CASE .toList();
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR') }
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL private SchemaMetadataColumn toColumn(
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')' SchemaMetadataColumnRow row,
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL Map<String, List<SchemaAnnotation>> annotations
THEN c.data_type || '(' || c.data_precision || ')' ) {
ELSE c.data_type String columnName = requireSimpleName(row.columnName(), "column name");
END AS display_type, return new SchemaMetadataColumn(
c.nullable, columnName,
cc.comments row.dataType(),
FROM all_tab_columns c "Y".equalsIgnoreCase(row.nullable()),
LEFT JOIN all_col_comments cc nullToEmpty(row.comment()),
ON cc.owner = c.owner annotations.getOrDefault(columnTargetKey(columnName), List.of())
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<>();
jdbcTemplate.query(""" for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(OWNER, tableName)) {
SELECT column_name, annotation_name, annotation_value String target = row.columnName() == null
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(rs.getString("column_name")); : columnTargetKey(row.columnName());
grouped grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>()) .computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>()) .computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value"))); .add(nullToEmpty(row.annotationValue()));
}, tableName); }
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>(); Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> { grouped.forEach((target, valuesByName) -> {
@@ -180,44 +168,14 @@ public class SchemaMetadataService {
} }
private boolean annotationExists(String tableName, String columnName, String annotationName) { private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null return columnName == null
? jdbcTemplate.queryForObject(""" ? mapper.countTableAnnotation(OWNER, tableName, annotationName) > 0
SELECT COUNT(*) : mapper.countColumnAnnotation(OWNER, tableName, columnName, annotationName) > 0;
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");
Integer count = jdbcTemplate.queryForObject(""" if (mapper.countColumn(OWNER, tableName, column) == 0) {
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;
@@ -242,14 +200,6 @@ public class SchemaMetadataService {
return normalized; return normalized;
} }
private String qualifiedTable(String tableName) {
return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name"));
}
private String quoteName(String value) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
private String quoteLiteral(String value) { private String quoteLiteral(String value) {
return "'" + value.replace("'", "''") + "'"; return "'" + value.replace("'", "''") + "'";
} }

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper">
<select id="findTableComment" resultType="string">
SELECT comments
FROM all_tab_comments
WHERE owner = #{owner,jdbcType=VARCHAR}
AND table_name = #{tableName,jdbcType=VARCHAR}
</select>
<select id="findColumns"
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow">
SELECT c.column_name AS column_name,
CASE
WHEN c.data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2', 'NCHAR')
THEN c.data_type || '(' || c.char_length || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL AND c.data_scale IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ',' || c.data_scale || ')'
WHEN c.data_type = 'NUMBER' AND c.data_precision IS NOT NULL
THEN c.data_type || '(' || c.data_precision || ')'
ELSE c.data_type
END AS data_type,
c.nullable,
cc.comments
FROM all_tab_columns c
LEFT JOIN all_col_comments cc
ON cc.owner = c.owner
AND cc.table_name = c.table_name
AND cc.column_name = c.column_name
WHERE c.owner = #{owner,jdbcType=VARCHAR}
AND c.table_name = #{tableName,jdbcType=VARCHAR}
ORDER BY c.column_id
</select>
<select id="findAnnotations"
resultType="com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow">
SELECT column_name,
annotation_name,
annotation_value
FROM all_annotations_usage
WHERE object_owner = #{owner,jdbcType=VARCHAR}
AND object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
ORDER BY column_name NULLS FIRST, annotation_name, annotation_value
</select>
<select id="countColumn" resultType="int">
SELECT COUNT(*)
FROM all_tab_columns
WHERE owner = #{owner,jdbcType=VARCHAR}
AND table_name = #{tableName,jdbcType=VARCHAR}
AND column_name = #{columnName,jdbcType=VARCHAR}
</select>
<select id="countTableAnnotation" resultType="int">
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_owner = #{owner,jdbcType=VARCHAR}
AND object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
AND column_name IS NULL
</select>
<select id="countColumnAnnotation" resultType="int">
SELECT COUNT(*)
FROM all_annotations_usage
WHERE object_owner = #{owner,jdbcType=VARCHAR}
AND object_name = #{tableName,jdbcType=VARCHAR}
AND object_type = 'TABLE'
AND annotation_name = #{annotationName,jdbcType=VARCHAR}
AND column_name = #{columnName,jdbcType=VARCHAR}
</select>
<!-- Identifiers are server-side validated names. Oracle DDL comments require a literal. -->
<update id="updateTableComment">
COMMENT ON TABLE "${owner}"."${tableName}" IS ${commentLiteral}
</update>
<update id="updateColumnComment">
COMMENT ON COLUMN "${owner}"."${tableName}"."${columnName}" IS ${commentLiteral}
</update>
<update id="dropTableAnnotation">
ALTER TABLE "${owner}"."${tableName}" ANNOTATIONS (DROP "${annotationName}")
</update>
<update id="addTableAnnotation">
ALTER TABLE "${owner}"."${tableName}"
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
</update>
<update id="dropColumnAnnotation">
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
ANNOTATIONS (DROP "${annotationName}")
</update>
<update id="addColumnAnnotation">
ALTER TABLE "${owner}"."${tableName}" MODIFY "${columnName}"
ANNOTATIONS (ADD "${annotationName}" ${annotationValueLiteral})
</update>
</mapper>

View File

@@ -0,0 +1,101 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataAnnotationRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SchemaMetadataServiceTest {
private static final StructuredDataTable GAME_USERS = new StructuredDataTable(
"game-users", "CZN_COMN_USER_MST", "게임 사용자", "카제나 게임 사용자 마스터");
private SchemaMetadataMapper mapper;
private SchemaMetadataService service;
@BeforeEach
void setUp() {
mapper = mock(SchemaMetadataMapper.class);
StructuredDataService structuredDataService = mock(StructuredDataService.class);
when(structuredDataService.requireTable("game-users")).thenReturn(GAME_USERS);
when(structuredDataService.tables()).thenReturn(List.of(GAME_USERS));
when(structuredDataService.defaultKey()).thenReturn("game-users");
service = new SchemaMetadataService(mapper, structuredDataService);
}
@Test
void readsOnlyTheSelectedSmilegateTableThroughMapper() {
when(mapper.findTableComment("SGMP_POC", "CZN_COMN_USER_MST"))
.thenReturn("게임별 사용자 기준 정보");
when(mapper.findAnnotations("SGMP_POC", "CZN_COMN_USER_MST")).thenReturn(List.of(
new SchemaMetadataAnnotationRow(null, "BUSINESS_TERM", "게임 사용자"),
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "사용자 고유 식별자"),
new SchemaMetadataAnnotationRow("GUID", "BUSINESS_TERM", "고객 계정 식별자")
));
when(mapper.findColumns("SGMP_POC", "CZN_COMN_USER_MST")).thenReturn(List.of(
new SchemaMetadataColumnRow("GUID", "VARCHAR2(64)", "N", "사용자 GUID")
));
SchemaMetadataView view = service.find("game-users");
assertThat(view.tableComment()).isEqualTo("게임별 사용자 기준 정보");
assertThat(view.tableAnnotations()).singleElement()
.extracting(annotation -> annotation.name(), annotation -> annotation.value())
.containsExactly("BUSINESS_TERM", "게임 사용자");
assertThat(view.columns()).singleElement()
.satisfies(column -> {
assertThat(column.columnName()).isEqualTo("GUID");
assertThat(column.nullable()).isFalse();
assertThat(column.annotations()).singleElement()
.extracting(annotation -> annotation.value())
.isEqualTo("사용자 고유 식별자\n--- duplicate annotation value ---\n고객 계정 식별자");
});
verify(mapper).findAnnotations("SGMP_POC", "CZN_COMN_USER_MST");
verify(mapper).findColumns("SGMP_POC", "CZN_COMN_USER_MST");
}
@Test
void replacesAnExistingColumnAnnotationUsingMapperDdl() {
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "GUID")).thenReturn(1);
when(mapper.countColumnAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM"))
.thenReturn(1);
service.updateColumnAnnotation("game-users", "guid", "business_term", "사용자 고유 식별자");
verify(mapper).dropColumnAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM");
verify(mapper).addColumnAnnotation(
"SGMP_POC", "CZN_COMN_USER_MST", "GUID", "BUSINESS_TERM", "'사용자 고유 식별자'");
}
@Test
void clearsAnExistingTableAnnotationWithoutAddingAnEmptyValue() {
when(mapper.countTableAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "BUSINESS_TERM"))
.thenReturn(1);
service.updateTableAnnotation("game-users", "business_term", " ");
verify(mapper).dropTableAnnotation("SGMP_POC", "CZN_COMN_USER_MST", "BUSINESS_TERM");
verify(mapper, never()).addTableAnnotation(anyString(), anyString(), anyString(), anyString());
}
@Test
void rejectsAColumnThatIsNotPresentInTheSelectedTable() {
when(mapper.countColumn("SGMP_POC", "CZN_COMN_USER_MST", "UNKNOWN_COL")).thenReturn(0);
assertThatThrownBy(() -> service.updateColumnComment("game-users", "unknown_col", "설명"))
.isInstanceOf(AppException.class)
.hasMessage("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
}