46707bd116
기존엔 PostgreSQL 만 테스트 가능했고, V001 (SERIAL→VARCHAR) 마이그레이션 이후 회사 프로비저닝 시 시퀀스가 max(id) 보다 작은 상태로 남아있어서 새 외부 커넥션 등록 시 duplicate key 가 재발하던 문제 해결. backend - build.gradle: MariaDB/MySQL/MSSQL/SQLite JDBC 드라이버 4종 runtimeOnly 추가 - ExternalDbConnectionService.executeConnectionTest: PostgreSQL-only 가드 제거, dbType 별 JDBC URL/props 분기 구현 (postgresql/mysql/mariadb/mssql/sqlite). defaultPort helper 추가 - mapper/externalDbConnection.xml: INSERT/UPDATE 의 port/connection_timeout/query_timeout/max_connections 에 ::varchar 캐스팅 추가 (V001 으로 VARCHAR 가 됐는데 클라가 숫자로 보내서 character varying = bigint 비교 불가로 500 나던 것) - DataCopier.resetSequences: VARCHAR PK + 시퀀스 의존성이 남은 컬럼도 setval 대상에 포함. MAX(col::bigint) + col ~ '^[0-9]+$' 정규식으로 type mismatch 회피하면서 숫자형 VARCHAR PK 만 안전하게 reset frontend - ExternalDbConnectionModal: DialogContent 를 flex 컬럼으로, 본문에 자체 스크롤 + Footer shrink-0 → 길어진 폼에서도 취소/생성 버튼이 항상 보이도록 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
7.9 KiB
Java
173 lines
7.9 KiB
Java
package com.erp.provisioning;
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.sql.Connection;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.ResultSet;
|
|
import java.sql.ResultSetMetaData;
|
|
import java.sql.SQLException;
|
|
import java.sql.Statement;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* 선택된 테이블을 원본 DB → 신규 DB 로 복사.
|
|
*
|
|
* 정책:
|
|
* - company_code 컬럼이 있는 테이블: ('*','TEMPLATE') + NULL 만 복사 (공통 템플릿).
|
|
* - 없는 테이블: 전체 복사.
|
|
*
|
|
* ★ 실패 시: 테이블 하나라도 예외가 터지면 호출자에게 그대로 throw.
|
|
* CompanyProvisioningService 가 dst.rollback() + DROP DATABASE 로 원자성 보장.
|
|
*
|
|
* ★ 2026-04-24 추가: resetSequences() — 데이터 복사 후 모든 시퀀스의 current value 를
|
|
* max(column)+1 로 재설정. pg_dump --schema-only 는 시퀀스 값을 안 복제하므로 필수.
|
|
*/
|
|
@Service
|
|
@Slf4j
|
|
public class DataCopier {
|
|
|
|
private static final int BATCH = 500;
|
|
|
|
public int copyTable(Connection src, Connection dst, String table) throws SQLException {
|
|
boolean hasCompanyCode = columnExists(src, table, "company_code");
|
|
List<String> cols = listColumns(src, table);
|
|
if (cols.isEmpty()) {
|
|
log.warn("[Provisioning] {} has no columns in source — skip", table);
|
|
return 0;
|
|
}
|
|
|
|
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
|
|
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
|
|
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
|
|
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
|
|
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ")";
|
|
|
|
int rows = 0;
|
|
try (PreparedStatement selectStmt = src.prepareStatement(selectSql);
|
|
ResultSet rs = selectStmt.executeQuery();
|
|
PreparedStatement insertStmt = dst.prepareStatement(insertSql)) {
|
|
ResultSetMetaData meta = rs.getMetaData();
|
|
int colCount = meta.getColumnCount();
|
|
while (rs.next()) {
|
|
for (int i = 1; i <= colCount; i++) {
|
|
insertStmt.setObject(i, rs.getObject(i));
|
|
}
|
|
insertStmt.addBatch();
|
|
rows++;
|
|
if (rows % BATCH == 0) insertStmt.executeBatch();
|
|
}
|
|
insertStmt.executeBatch();
|
|
}
|
|
log.info("[Provisioning] COPY DATA {}: {} rows (filter={})", table, rows, hasCompanyCode);
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* 모든 시퀀스의 current value 를 연결된 컬럼의 MAX()+1 로 재설정.
|
|
* pg_depend 조회로 seq↔table↔column 관계를 얻으므로 휴리스틱 없음.
|
|
*
|
|
* 이 쿼리의 테이블/컬럼/시퀀스 이름은 pg_class 에서 직접 나온 값이라 신뢰 가능 → SQL injection 위험 없음.
|
|
* 빈 테이블은 setval(seq, 1) 로 안전하게 초기화.
|
|
*/
|
|
public int resetSequences(Connection dst) throws SQLException {
|
|
// format_type 으로 컬럼 실제 타입까지 조회 → integer 계열만 setval.
|
|
// 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음 (시퀀스 의존성만 남음).
|
|
// 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
|
|
String findSql = """
|
|
SELECT s.relname AS seq, t.relname AS tbl, a.attname AS col,
|
|
format_type(a.atttypid, a.atttypmod) AS coltype
|
|
FROM pg_class s
|
|
JOIN pg_depend d ON d.objid = s.oid
|
|
JOIN pg_class t ON t.oid = d.refobjid
|
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
|
|
WHERE s.relkind = 'S' AND d.deptype = 'a'
|
|
""";
|
|
List<String[]> rows = new ArrayList<>();
|
|
try (Statement s = dst.createStatement();
|
|
ResultSet rs = s.executeQuery(findSql)) {
|
|
while (rs.next()) {
|
|
rows.add(new String[]{
|
|
rs.getString("seq"), rs.getString("tbl"),
|
|
rs.getString("col"), rs.getString("coltype")});
|
|
}
|
|
}
|
|
|
|
int updated = 0, skippedType = 0, skippedErr = 0;
|
|
try (Statement us = dst.createStatement()) {
|
|
for (String[] r : rows) {
|
|
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
|
|
String sql;
|
|
if (isIntegerLike(coltype)) {
|
|
sql = String.format(
|
|
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
|
seq.replace("'", "''"), col, tbl);
|
|
} else if (isVarcharLike(coltype)) {
|
|
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
|
|
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
|
|
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
|
|
sql = String.format(
|
|
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
|
|
seq.replace("'", "''"), col, tbl, col);
|
|
} else {
|
|
skippedType++;
|
|
continue;
|
|
}
|
|
try {
|
|
us.execute(sql);
|
|
updated++;
|
|
} catch (SQLException e) {
|
|
skippedErr++;
|
|
log.warn("[Provisioning] setval failed seq={} tbl={} col={} type={}: {}",
|
|
seq, tbl, col, coltype, e.getMessage());
|
|
}
|
|
}
|
|
}
|
|
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
|
|
updated, skippedType, skippedErr, rows.size());
|
|
return updated;
|
|
}
|
|
|
|
private static boolean isIntegerLike(String coltype) {
|
|
if (coltype == null) return false;
|
|
String t = coltype.toLowerCase();
|
|
return t.startsWith("integer") || t.startsWith("bigint") || t.startsWith("smallint")
|
|
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
|
|
}
|
|
|
|
private static boolean isVarcharLike(String coltype) {
|
|
if (coltype == null) return false;
|
|
String t = coltype.toLowerCase();
|
|
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
|
|
}
|
|
|
|
private List<String> listColumns(Connection conn, String table) throws SQLException {
|
|
List<String> cols = new ArrayList<>();
|
|
try (PreparedStatement ps = conn.prepareStatement(
|
|
"SELECT column_name FROM information_schema.columns " +
|
|
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
|
|
"ORDER BY ordinal_position")) {
|
|
ps.setString(1, table);
|
|
try (ResultSet rs = ps.executeQuery()) {
|
|
while (rs.next()) cols.add(rs.getString(1));
|
|
}
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
|
|
try (PreparedStatement ps = conn.prepareStatement(
|
|
"SELECT 1 FROM information_schema.columns " +
|
|
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
|
|
ps.setString(1, table);
|
|
ps.setString(2, col);
|
|
try (ResultSet rs = ps.executeQuery()) {
|
|
return rs.next();
|
|
}
|
|
}
|
|
}
|
|
}
|