f73e468f66
- DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일)
- 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호
- 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록
- DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용)
- ddlApi.dropColumn 헬퍼
- ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템
- page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
642 lines
36 KiB
Java
642 lines
36 KiB
Java
package com.erp.service;
|
|
|
|
import com.erp.common.BaseService;
|
|
import com.erp.constants.InputTypeConstants;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.PlatformTransactionManager;
|
|
import org.springframework.transaction.support.TransactionTemplate;
|
|
|
|
import java.time.LocalDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.*;
|
|
|
|
@Service
|
|
@Slf4j
|
|
public class DdlService extends BaseService {
|
|
|
|
private static final String NS = "ddl.";
|
|
|
|
private final JdbcTemplate jdbcTemplate;
|
|
private final TransactionTemplate transactionTemplate;
|
|
|
|
private static final Set<String> SYSTEM_TABLES = Set.of(
|
|
"user_info", "company_mng", "menu_info", "auth_group",
|
|
"table_labels", "column_labels", "screen_definitions", "screen_layouts",
|
|
"common_code", "multi_lang_key_master", "multi_lang_text",
|
|
"button_action_standards", "ddl_execution_log"
|
|
);
|
|
|
|
private static final Set<String> RESERVED_WORDS = Set.of(
|
|
"user", "order", "group", "table", "column", "index", "select", "insert",
|
|
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
|
|
"null", "true", "false", "create", "alter", "drop", "primary", "key",
|
|
"foreign", "references", "constraint", "default", "unique", "check",
|
|
"view", "procedure", "function"
|
|
);
|
|
|
|
private static final Set<String> RESERVED_COLUMNS = Set.of(
|
|
"id", "created_date", "updated_date", "company_code"
|
|
);
|
|
|
|
public DdlService(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
|
|
this.jdbcTemplate = jdbcTemplate;
|
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// CREATE TABLE
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> createTable(String tableName, List<Map<String, Object>> columns,
|
|
String companyCode, String userId, String description) {
|
|
// 1. 검증
|
|
Map<String, Object> validation = validateTableCreation(tableName, columns);
|
|
if (!(Boolean) validation.get("is_valid")) {
|
|
@SuppressWarnings("unchecked")
|
|
List<String> errors = (List<String>) validation.get("errors");
|
|
String errorMsg = "테이블 생성 검증 실패: " + String.join(", ", errors);
|
|
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, "VALIDATION_FAILED", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "VALIDATION_FAILED");
|
|
}
|
|
|
|
// 2. 테이블 존재 여부 확인
|
|
if (tableExists(tableName)) {
|
|
String errorMsg = "테이블 '" + tableName + "'이 이미 존재합니다.";
|
|
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, "TABLE_EXISTS", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_EXISTS");
|
|
}
|
|
|
|
// 3. DDL 쿼리 생성
|
|
String ddlQuery = generateCreateTableQuery(tableName, columns);
|
|
|
|
// 4. 트랜잭션으로 DDL 실행 + 메타데이터 저장
|
|
try {
|
|
final String finalTableName = tableName;
|
|
final List<Map<String, Object>> finalColumns = columns;
|
|
final String finalCompanyCode = companyCode;
|
|
final String finalDescription = description;
|
|
transactionTemplate.execute(status -> {
|
|
jdbcTemplate.execute(ddlQuery);
|
|
saveTableMetadata(finalTableName, finalDescription);
|
|
saveColumnMetadata(finalTableName, finalColumns, finalCompanyCode);
|
|
return null;
|
|
});
|
|
|
|
// 5. 성공 로그 (트랜잭션 밖)
|
|
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName, ddlQuery, true, null);
|
|
log.info("테이블 생성 성공: {}, 사용자: {}, 컬럼수: {}", tableName, userId, columns.size());
|
|
|
|
return Map.of(
|
|
"success", true,
|
|
"message", "테이블 '" + tableName + "'이 성공적으로 생성되었습니다.",
|
|
"table_name", tableName,
|
|
"column_count", columns.size(),
|
|
"executed_query", ddlQuery
|
|
);
|
|
} catch (Exception e) {
|
|
String errorMsg = "테이블 생성 실패: " + e.getMessage();
|
|
logDdlOperation(userId, companyCode, "CREATE_TABLE", tableName,
|
|
"FAILED: " + e.getMessage(), false, errorMsg);
|
|
log.error("테이블 생성 실패: {}, 사용자: {}, 오류: {}", tableName, userId, e.getMessage(), e);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// ADD COLUMN
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> addColumn(String tableName, Map<String, Object> column,
|
|
String companyCode, String userId) {
|
|
// 1. 검증
|
|
List<String> errors = validateColumnForAddition(tableName, column);
|
|
if (!errors.isEmpty()) {
|
|
String errorMsg = "컬럼 추가 검증 실패: " + String.join(", ", errors);
|
|
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "VALIDATION_FAILED", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "VALIDATION_FAILED");
|
|
}
|
|
|
|
// 2. 테이블 존재 여부 확인
|
|
if (!tableExists(tableName)) {
|
|
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
|
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "TABLE_NOT_EXISTS", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_EXISTS");
|
|
}
|
|
|
|
// 3. 컬럼 존재 여부 확인
|
|
String colName = (String) column.get("name");
|
|
if (columnExists(tableName, colName)) {
|
|
String errorMsg = "컬럼 '" + colName + "'이 이미 존재합니다.";
|
|
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, "COLUMN_EXISTS", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_EXISTS");
|
|
}
|
|
|
|
// 4. DDL 쿼리 생성
|
|
String ddlQuery = generateAddColumnQuery(tableName, column);
|
|
|
|
// 5. 트랜잭션으로 DDL 실행 + 메타데이터 저장
|
|
try {
|
|
transactionTemplate.execute(status -> {
|
|
jdbcTemplate.execute(ddlQuery);
|
|
String inputType = convertToInputType(column);
|
|
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
|
throw new IllegalArgumentException(
|
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
|
+ " (받은 값: " + inputType + ")"
|
|
);
|
|
}
|
|
String detailSettings = column.containsKey("detail_settings")
|
|
? column.get("detail_settings").toString() : "{}";
|
|
Integer maxOrder = jdbcTemplate.queryForObject(
|
|
"SELECT COALESCE(MAX(display_order), 0) FROM table_type_columns " +
|
|
"WHERE table_name = ? AND company_code = ?",
|
|
Integer.class, tableName, companyCode);
|
|
saveColumnMeta(tableName, colName, companyCode, inputType,
|
|
detailSettings, (maxOrder != null ? maxOrder : 0) + 1);
|
|
return null;
|
|
});
|
|
|
|
// 6. 성공 로그 (트랜잭션 밖)
|
|
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName, ddlQuery, true, null);
|
|
log.info("컬럼 추가 성공: {}.{}, 사용자: {}", tableName, colName, userId);
|
|
|
|
return Map.of(
|
|
"success", true,
|
|
"message", "컬럼 '" + colName + "'이 성공적으로 추가되었습니다.",
|
|
"table_name", tableName,
|
|
"column_name", colName,
|
|
"executed_query", ddlQuery
|
|
);
|
|
} catch (Exception e) {
|
|
String errorMsg = "컬럼 추가 실패: " + e.getMessage();
|
|
logDdlOperation(userId, companyCode, "ADD_COLUMN", tableName,
|
|
"FAILED: " + e.getMessage(), false, errorMsg);
|
|
log.error("컬럼 추가 실패: {}.{}, 사용자: {}, 오류: {}", tableName, colName, userId, e.getMessage(), e);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// DROP TABLE
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> dropTable(String tableName, String companyCode, String userId) {
|
|
// 1. 시스템 테이블 보호
|
|
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
|
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 삭제할 수 없습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName,
|
|
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
|
|
}
|
|
|
|
// 2. 테이블 존재 여부 확인
|
|
if (!tableExists(tableName)) {
|
|
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName, "TABLE_NOT_FOUND", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
|
|
}
|
|
|
|
String ddlQuery = "DROP TABLE IF EXISTS \"" + sanitize(tableName) + "\" CASCADE";
|
|
|
|
try {
|
|
transactionTemplate.execute(status -> {
|
|
jdbcTemplate.execute(ddlQuery);
|
|
jdbcTemplate.update("DELETE FROM table_type_columns WHERE table_name = ?", tableName);
|
|
jdbcTemplate.update("DELETE FROM table_labels WHERE table_name = ?", tableName);
|
|
return null;
|
|
});
|
|
|
|
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName, ddlQuery, true, null);
|
|
log.info("테이블 삭제 성공: {}, 사용자: {}", tableName, userId);
|
|
|
|
return Map.of(
|
|
"success", true,
|
|
"message", "테이블 '" + tableName + "'이 성공적으로 삭제되었습니다.",
|
|
"table_name", tableName,
|
|
"executed_query", ddlQuery
|
|
);
|
|
} catch (Exception e) {
|
|
String errorMsg = "테이블 삭제 실패: " + e.getMessage();
|
|
logDdlOperation(userId, companyCode, "DROP_TABLE", tableName,
|
|
"FAILED: " + e.getMessage(), false, errorMsg);
|
|
log.error("테이블 삭제 실패: {}, 사용자: {}, 오류: {}", tableName, userId, e.getMessage(), e);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> dropColumn(String tableName, String columnName,
|
|
String companyCode, String userId) {
|
|
// 1. 시스템 테이블 보호
|
|
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
|
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
|
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
|
|
}
|
|
|
|
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
|
|
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
|
|
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
|
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
|
|
}
|
|
|
|
// 3. 테이블/컬럼 존재 여부
|
|
if (!tableExists(tableName)) {
|
|
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
|
|
}
|
|
if (!columnExists(tableName, columnName)) {
|
|
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
|
|
}
|
|
|
|
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
|
|
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
|
|
|
|
try {
|
|
transactionTemplate.execute(status -> {
|
|
jdbcTemplate.execute(ddlQuery);
|
|
// 컬럼 메타 청소
|
|
jdbcTemplate.update(
|
|
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
|
|
tableName, columnName);
|
|
jdbcTemplate.update(
|
|
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
|
|
tableName, columnName);
|
|
return null;
|
|
});
|
|
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
|
|
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
|
|
|
|
return Map.of(
|
|
"success", true,
|
|
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
|
|
"table_name", tableName,
|
|
"column_name", columnName,
|
|
"executed_query", ddlQuery
|
|
);
|
|
} catch (Exception e) {
|
|
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
|
|
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
|
|
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
|
|
: "";
|
|
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
|
|
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
|
"FAILED: " + rawMsg, false, errorMsg);
|
|
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
|
|
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// VALIDATE
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public Map<String, Object> validateTableCreation(String tableName, List<Map<String, Object>> columns) {
|
|
List<String> errors = new ArrayList<>();
|
|
List<String> warnings = new ArrayList<>();
|
|
|
|
errors.addAll(validateTableName(tableName));
|
|
|
|
if (columns == null || columns.isEmpty()) {
|
|
errors.add("최소 1개의 컬럼이 필요합니다.");
|
|
} else {
|
|
Set<String> seen = new LinkedHashSet<>();
|
|
Set<String> duplicates = new LinkedHashSet<>();
|
|
for (int i = 0; i < columns.size(); i++) {
|
|
Map<String, Object> col = columns.get(i);
|
|
errors.addAll(validateSingleColumn(col, i + 1));
|
|
String colName = col.get("name") != null ? col.get("name").toString().toLowerCase() : "";
|
|
if (!colName.isEmpty() && !seen.add(colName)) {
|
|
duplicates.add(colName);
|
|
}
|
|
}
|
|
if (!duplicates.isEmpty()) {
|
|
errors.add("중복된 컬럼명이 있습니다: " + String.join(", ", duplicates));
|
|
}
|
|
}
|
|
|
|
int colCount = columns != null ? columns.size() : 0;
|
|
return Map.of(
|
|
"is_valid", errors.isEmpty(),
|
|
"errors", errors,
|
|
"warnings", warnings,
|
|
"summary", buildValidationSummary(tableName, colCount, errors, warnings)
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// LOG QUERIES
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public List<Map<String, Object>> getDdlLogs(int limit, String userId, String ddlType) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("limit", Math.min(limit, 200));
|
|
params.put("user_id", userId);
|
|
params.put("ddl_type", ddlType);
|
|
return sqlSession.selectList(NS + "selectDdlLogs", params);
|
|
}
|
|
|
|
public Map<String, Object> getDdlStatistics(String fromDate, String toDate) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("from_date", fromDate);
|
|
params.put("to_date", toDate);
|
|
|
|
Map<String, Object> totalStats = sqlSession.selectOne(NS + "selectDdlTotalStats", params);
|
|
List<Map<String, Object>> byType = sqlSession.selectList(NS + "selectDdlStatsByType", params);
|
|
List<Map<String, Object>> byUser = sqlSession.selectList(NS + "selectDdlStatsByUser", params);
|
|
List<Map<String, Object>> recentFailures = sqlSession.selectList(NS + "selectRecentFailures", params);
|
|
|
|
Map<String, Long> byDdlType = new LinkedHashMap<>();
|
|
for (Map<String, Object> row : byType) {
|
|
byDdlType.put(String.valueOf(row.get("ddl_type")), toLong(row.get("count")));
|
|
}
|
|
Map<String, Long> byUserMap = new LinkedHashMap<>();
|
|
for (Map<String, Object> row : byUser) {
|
|
byUserMap.put(String.valueOf(row.get("user_id")), toLong(row.get("count")));
|
|
}
|
|
|
|
return Map.of(
|
|
"total_executions", toLong(totalStats != null ? totalStats.get("total_executions") : null),
|
|
"successful_executions", toLong(totalStats != null ? totalStats.get("successful_executions") : null),
|
|
"failed_executions", toLong(totalStats != null ? totalStats.get("failed_executions") : null),
|
|
"by_ddl_type", byDdlType,
|
|
"by_user", byUserMap,
|
|
"recent_failures", recentFailures
|
|
);
|
|
}
|
|
|
|
public List<Map<String, Object>> getTableDdlHistory(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
return sqlSession.selectList(NS + "selectTableDdlHistory", params);
|
|
}
|
|
|
|
public Map<String, Object> getTableInfo(String tableName) {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("table_name", tableName);
|
|
Map<String, Object> tableInfo = sqlSession.selectOne(NS + "selectTableInfo", params);
|
|
if (tableInfo == null) return null;
|
|
List<Map<String, Object>> columns = sqlSession.selectList(NS + "selectTableColumns", params);
|
|
return Map.of("table_info", tableInfo, "columns", columns);
|
|
}
|
|
|
|
public int cleanupOldLogs(int retentionDays) {
|
|
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("cutoff_date", cutoff.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
|
int deleted = sqlSession.delete(NS + "deleteOldDdlLogs", params);
|
|
log.info("DDL 로그 정리 완료: {}개 삭제, 보존 기간: {}일", deleted, retentionDays);
|
|
return deleted;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// DDL QUERY GENERATION
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private String generateCreateTableQuery(String tableName, List<Map<String, Object>> columns) {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("CREATE TABLE \"").append(sanitize(tableName)).append("\" (\n");
|
|
sb.append(" \"id\" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,\n");
|
|
sb.append(" \"created_date\" timestamp DEFAULT now(),\n");
|
|
sb.append(" \"updated_date\" timestamp DEFAULT now(),\n");
|
|
sb.append(" \"writer\" varchar(500) DEFAULT NULL,\n");
|
|
sb.append(" \"company_code\" varchar(500)");
|
|
|
|
for (Map<String, Object> col : columns) {
|
|
sb.append(",\n \"").append(sanitize((String) col.get("name"))).append("\" varchar(500)");
|
|
Boolean nullable = col.containsKey("nullable") ? (Boolean) col.get("nullable") : true;
|
|
if (Boolean.FALSE.equals(nullable)) sb.append(" NOT NULL");
|
|
if (col.get("default_value") != null && !col.get("default_value").toString().isBlank()) {
|
|
sb.append(" DEFAULT '").append(col.get("default_value").toString().replace("'", "''")).append("'");
|
|
}
|
|
}
|
|
sb.append("\n);");
|
|
return sb.toString();
|
|
}
|
|
|
|
private String generateAddColumnQuery(String tableName, Map<String, Object> column) {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("ALTER TABLE \"").append(sanitize(tableName)).append("\" ADD COLUMN ");
|
|
sb.append("\"").append(sanitize((String) column.get("name"))).append("\" varchar(500)");
|
|
Boolean nullable = column.containsKey("nullable") ? (Boolean) column.get("nullable") : true;
|
|
if (Boolean.FALSE.equals(nullable)) sb.append(" NOT NULL");
|
|
if (column.get("default_value") != null && !column.get("default_value").toString().isBlank()) {
|
|
sb.append(" DEFAULT '").append(column.get("default_value").toString().replace("'", "''")).append("'");
|
|
}
|
|
sb.append(";");
|
|
return sb.toString();
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// TABLE / COLUMN EXISTS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private boolean tableExists(String tableName) {
|
|
Integer cnt = jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM information_schema.tables " +
|
|
"WHERE table_schema = 'public' AND table_name = ?",
|
|
Integer.class, tableName);
|
|
return cnt != null && cnt > 0;
|
|
}
|
|
|
|
private boolean columnExists(String tableName, String columnName) {
|
|
Integer cnt = jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM information_schema.columns " +
|
|
"WHERE table_schema = 'public' AND table_name = ? AND column_name = ?",
|
|
Integer.class, tableName, columnName);
|
|
return cnt != null && cnt > 0;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// METADATA SAVE
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private void saveTableMetadata(String tableName, String description) {
|
|
String desc = description != null && !description.isBlank()
|
|
? description : "사용자 생성 테이블: " + tableName;
|
|
jdbcTemplate.update(
|
|
"INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) " +
|
|
"VALUES (?, ?, ?, now(), now()) " +
|
|
"ON CONFLICT (table_name) DO UPDATE SET table_label = ?, description = ?, updated_date = now()",
|
|
tableName, tableName, desc, tableName, desc);
|
|
}
|
|
|
|
private void saveColumnMetadata(String tableName, List<Map<String, Object>> columns, String companyCode) {
|
|
// 기본 시스템 컬럼
|
|
List<Object[]> defaultCols = List.of(
|
|
new Object[]{"id", "text", -5},
|
|
new Object[]{"created_date", "date", -4},
|
|
new Object[]{"updated_date", "date", -3},
|
|
new Object[]{"writer", "text", -2},
|
|
new Object[]{"company_code", "text", -1}
|
|
);
|
|
for (Object[] dc : defaultCols) {
|
|
saveColumnMeta(tableName, (String) dc[0], companyCode, (String) dc[1], "{}", (int) dc[2]);
|
|
}
|
|
// 사용자 정의 컬럼
|
|
for (int i = 0; i < columns.size(); i++) {
|
|
Map<String, Object> col = columns.get(i);
|
|
String inputType = convertToInputType(col);
|
|
if (!InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(inputType)) {
|
|
throw new IllegalArgumentException(
|
|
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
|
|
+ " (받은 값: " + inputType + ")"
|
|
);
|
|
}
|
|
String detailSettings = col.containsKey("detail_settings")
|
|
? col.get("detail_settings").toString() : "{}";
|
|
saveColumnMeta(tableName, (String) col.get("name"), companyCode,
|
|
inputType, detailSettings, i);
|
|
}
|
|
}
|
|
|
|
private void saveColumnMeta(String tableName, String colName, String companyCode,
|
|
String inputType, String detailSettings, int order) {
|
|
jdbcTemplate.update(
|
|
"INSERT INTO table_type_columns " +
|
|
" (table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date) " +
|
|
"VALUES (?, ?, ?, ?, ?::jsonb, 'Y', ?, now(), now()) " +
|
|
"ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET " +
|
|
" input_type = ?, detail_settings = ?::jsonb, display_order = ?, updated_date = now()",
|
|
tableName, colName, companyCode, inputType, detailSettings, order,
|
|
inputType, detailSettings, order);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// VALIDATION HELPERS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private List<String> validateTableName(String tableName) {
|
|
List<String> errors = new ArrayList<>();
|
|
if (tableName == null || tableName.isBlank()) {
|
|
errors.add("테이블명은 필수입니다.");
|
|
return errors;
|
|
}
|
|
if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
|
|
errors.add("유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.");
|
|
}
|
|
if (tableName.length() > 63) errors.add("테이블명은 63자를 초과할 수 없습니다.");
|
|
if (tableName.length() < 2) errors.add("테이블명은 최소 2자 이상이어야 합니다.");
|
|
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
|
errors.add("'" + tableName + "'은 시스템 테이블명으로 사용할 수 없습니다.");
|
|
}
|
|
if (RESERVED_WORDS.contains(tableName.toLowerCase())) {
|
|
errors.add("'" + tableName + "'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.");
|
|
}
|
|
if (tableName.startsWith("_") || tableName.endsWith("_")) {
|
|
errors.add("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.");
|
|
}
|
|
if (tableName.contains("__")) {
|
|
errors.add("테이블명에 연속된 언더스코어는 사용할 수 없습니다.");
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
private List<String> validateSingleColumn(Map<String, Object> column, int position) {
|
|
List<String> errors = new ArrayList<>();
|
|
String name = column.get("name") != null ? column.get("name").toString().trim() : "";
|
|
String prefix = "컬럼 " + position + "(" + name + "): ";
|
|
|
|
if (name.isBlank()) {
|
|
errors.add(prefix + "컬럼명은 필수입니다.");
|
|
return errors;
|
|
}
|
|
if (!name.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
|
|
errors.add(prefix + "유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.");
|
|
}
|
|
if (name.length() > 63) errors.add(prefix + "컬럼명은 63자를 초과할 수 없습니다.");
|
|
if (name.length() < 2) errors.add(prefix + "컬럼명은 최소 2자 이상이어야 합니다.");
|
|
if (RESERVED_COLUMNS.contains(name.toLowerCase())) {
|
|
errors.add(prefix + "'" + name + "'은 예약된 컬럼명입니다 (id, created_date, updated_date, company_code).");
|
|
}
|
|
if (RESERVED_WORDS.contains(name.toLowerCase())) {
|
|
errors.add(prefix + "'" + name + "'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.");
|
|
}
|
|
if (name.contains("__")) {
|
|
errors.add(prefix + "컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
private List<String> validateColumnForAddition(String tableName, Map<String, Object> column) {
|
|
List<String> errors = new ArrayList<>();
|
|
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
|
errors.add("'" + tableName + "'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.");
|
|
}
|
|
errors.addAll(validateSingleColumn(column, 1));
|
|
return errors;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// UTILITIES
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private String sanitize(String name) {
|
|
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
|
}
|
|
|
|
private String convertToInputType(Map<String, Object> col) {
|
|
Object inputType = col.get("input_type");
|
|
Object webType = col.get("web_type");
|
|
String type = inputType != null ? inputType.toString()
|
|
: (webType != null ? webType.toString() : "text");
|
|
return switch (type) {
|
|
case "number", "decimal" -> "number";
|
|
case "date", "datetime" -> "date";
|
|
case "select", "dropdown" -> "select";
|
|
case "checkbox", "boolean" -> "checkbox";
|
|
case "radio" -> "radio";
|
|
case "code" -> "code";
|
|
case "entity" -> "entity";
|
|
case "file" -> "file";
|
|
case "image" -> "image";
|
|
case "numbering" -> "numbering";
|
|
default -> "text";
|
|
};
|
|
}
|
|
|
|
private void logDdlOperation(String userId, String companyCode, String ddlType,
|
|
String tableName, String ddlQuery, boolean success, String errorMessage) {
|
|
try {
|
|
Map<String, Object> params = new HashMap<>();
|
|
params.put("user_id", userId);
|
|
params.put("company_code", companyCode);
|
|
params.put("ddl_type", ddlType);
|
|
params.put("table_name", tableName);
|
|
params.put("ddl_query", ddlQuery);
|
|
params.put("success", success);
|
|
params.put("error_message", errorMessage);
|
|
sqlSession.insert(NS + "insertDdlLog", params);
|
|
} catch (Exception e) {
|
|
log.error("DDL 로그 기록 실패: userId={}, ddlType={}, tableName={}", userId, ddlType, tableName, e);
|
|
}
|
|
}
|
|
|
|
private long toLong(Object value) {
|
|
if (value == null) return 0L;
|
|
if (value instanceof Number) return ((Number) value).longValue();
|
|
try { return Long.parseLong(value.toString()); } catch (NumberFormatException ex) { return 0L; }
|
|
}
|
|
|
|
private String buildValidationSummary(String tableName, int columnCount,
|
|
List<String> errors, List<String> warnings) {
|
|
StringBuilder sb = new StringBuilder("테이블 '").append(tableName).append("' 검증 완료. ");
|
|
sb.append("컬럼 ").append(columnCount).append("개 중 ");
|
|
sb.append(errors.isEmpty() ? "모든 검증 통과." : errors.size() + "개 오류 발견.");
|
|
if (!warnings.isEmpty()) sb.append(" ").append(warnings.size()).append("개 경고 있음.");
|
|
return sb.toString();
|
|
}
|
|
}
|