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

@@ -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;
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.SchemaMetadataColumnRow;
import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView;
import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable;
import com.cloudhandson.vpdbackoffice.mapper.SchemaMetadataMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Screen-level orchestration for schema metadata. Database reads and DDL are
* intentionally delegated only to {@link SchemaMetadataMapper}.
*/
@Service
public class SchemaMetadataService {
@@ -23,11 +28,14 @@ public class SchemaMetadataService {
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 final JdbcTemplate jdbcTemplate;
private final SchemaMetadataMapper mapper;
private final StructuredDataService structuredDataService;
public SchemaMetadataService(JdbcTemplate jdbcTemplate, StructuredDataService structuredDataService) {
this.jdbcTemplate = jdbcTemplate;
public SchemaMetadataService(
SchemaMetadataMapper mapper,
StructuredDataService structuredDataService
) {
this.mapper = mapper;
this.structuredDataService = structuredDataService;
}
@@ -41,13 +49,12 @@ public class SchemaMetadataService {
public SchemaMetadataView find(String tableKey) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = table.tableName();
String tableComment = tableComment(tableName);
String tableName = requireSimpleName(table.tableName(), "table name");
Map<String, List<SchemaAnnotation>> annotations = annotationsByTarget(tableName);
List<SchemaMetadataColumn> columns = columns(tableName, annotations);
return new SchemaMetadataView(
table,
nullToEmpty(tableComment),
nullToEmpty(mapper.findTableComment(OWNER, tableName)),
annotations.getOrDefault(tableTargetKey(), List.of()),
columns
);
@@ -56,24 +63,24 @@ public class SchemaMetadataService {
@Transactional
public void updateTableComment(String tableKey, String comment) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String tableName = requireSimpleName(table.tableName(), "table name");
String normalizedComment = normalizeText(comment, MAX_COMMENT_LENGTH, "테이블 comment");
jdbcTemplate.execute("COMMENT ON TABLE " + qualifiedTable(table.tableName())
+ " IS " + quoteLiteral(normalizedComment));
mapper.updateTableComment(OWNER, tableName, quoteLiteral(normalizedComment));
}
@Transactional
public void updateColumnComment(String tableKey, String columnName, String comment) {
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");
jdbcTemplate.execute("COMMENT ON COLUMN " + qualifiedTable(table.tableName()) + "."
+ quoteName(column) + " IS " + quoteLiteral(normalizedComment));
mapper.updateColumnComment(OWNER, tableName, column, quoteLiteral(normalizedComment));
}
@Transactional
public void updateTableAnnotation(String tableKey, String annotationName, String annotationValue) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
updateAnnotation(table.tableName(), null, annotationName, annotationValue);
updateAnnotation(requireSimpleName(table.tableName(), "table name"), null, annotationName, annotationValue);
}
@Transactional
@@ -84,8 +91,9 @@ public class SchemaMetadataService {
String annotationValue
) {
StructuredDataTable table = structuredDataService.requireTable(tableKey);
String column = requireColumn(table.tableName(), columnName);
updateAnnotation(table.tableName(), column, annotationName, annotationValue);
String tableName = requireSimpleName(table.tableName(), "table name");
String column = requireColumn(tableName, columnName);
updateAnnotation(tableName, column, annotationName, annotationValue);
}
private void updateAnnotation(
@@ -97,75 +105,55 @@ public class SchemaMetadataService {
String key = requireSimpleName(annotationName, "annotation name");
String value = normalizeText(annotationValue, MAX_ANNOTATION_VALUE_LENGTH, "annotation value");
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()) {
jdbcTemplate.execute(annotationSql(tableName, columnName,
"ADD " + quoteName(key) + " " + quoteLiteral(value)));
if (columnName == null) {
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(
String tableName,
Map<String, List<SchemaAnnotation>> annotations
) {
return jdbcTemplate.query("""
SELECT c.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 display_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 = ?
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);
return mapper.findColumns(OWNER, tableName).stream()
.map(row -> toColumn(row, annotations))
.toList();
}
private SchemaMetadataColumn toColumn(
SchemaMetadataColumnRow row,
Map<String, List<SchemaAnnotation>> annotations
) {
String columnName = requireSimpleName(row.columnName(), "column name");
return new SchemaMetadataColumn(
columnName,
row.dataType(),
"Y".equalsIgnoreCase(row.nullable()),
nullToEmpty(row.comment()),
annotations.getOrDefault(columnTargetKey(columnName), List.of())
);
}
private Map<String, List<SchemaAnnotation>> annotationsByTarget(String tableName) {
Map<String, LinkedHashMap<String, List<String>>> grouped = new LinkedHashMap<>();
jdbcTemplate.query("""
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()
: columnTargetKey(rs.getString("column_name"));
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(rs.getString("annotation_name"), ignored -> new ArrayList<>())
.add(nullToEmpty(rs.getString("annotation_value")));
}, tableName);
for (SchemaMetadataAnnotationRow row : mapper.findAnnotations(OWNER, tableName)) {
String target = row.columnName() == null
? tableTargetKey()
: columnTargetKey(row.columnName());
grouped
.computeIfAbsent(target, ignored -> new LinkedHashMap<>())
.computeIfAbsent(row.annotationName(), ignored -> new ArrayList<>())
.add(nullToEmpty(row.annotationValue()));
}
Map<String, List<SchemaAnnotation>> result = new LinkedHashMap<>();
grouped.forEach((target, valuesByName) -> {
@@ -180,44 +168,14 @@ public class SchemaMetadataService {
}
private boolean annotationExists(String tableName, String columnName, String annotationName) {
Integer count = columnName == null
? jdbcTemplate.queryForObject("""
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 + ")";
return columnName == null
? mapper.countTableAnnotation(OWNER, tableName, annotationName) > 0
: mapper.countColumnAnnotation(OWNER, tableName, columnName, annotationName) > 0;
}
private String requireColumn(String tableName, String columnName) {
String column = requireSimpleName(columnName, "column name");
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) {
if (mapper.countColumn(OWNER, tableName, column) == 0) {
throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다.");
}
return column;
@@ -242,14 +200,6 @@ public class SchemaMetadataService {
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) {
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>