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 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 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 listColumns(Connection conn, String table) throws SQLException { List 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(); } } } }