refactor: Enhance unique constraint validation across data operations

- Integrated `TableManagementService` to validate unique constraints before insert, update, and upsert actions in various controllers, including `dataflowExecutionController`, `dynamicFormController`, and `tableManagementController`.
- Improved error handling in `errorHandler` to provide detailed messages indicating which field has a unique constraint violation.
- Updated the `formatPgError` utility to extract and display specific column labels for unique constraint violations, enhancing user feedback.
- Adjusted the table schema retrieval to include company-specific nullable and unique constraints, ensuring accurate representation of database rules.

These changes improve data integrity by preventing duplicate entries and enhance user experience through clearer error messages related to unique constraints.
This commit is contained in:
kjs 2026-03-10 16:15:20 +09:00
parent d56e46b17c
commit 3982aabc24
10 changed files with 225 additions and 47 deletions

View File

@ -3563,19 +3563,21 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
// 회사별 라벨 우선, 없으면 공통(*) 라벨 사용
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const schemaQuery = `
SELECT
ic.column_name,
ic.data_type,
ic.is_nullable,
ic.is_nullable AS db_is_nullable,
ic.column_default,
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc_common
ON ttc_common.table_name = ic.table_name
@ -3600,17 +3602,28 @@ export async function getTableSchema(
return;
}
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({
name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type,
nullable: col.is_nullable === "YES",
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
}));
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
const columnList = columns.map((col: any) => {
// DB level nullable + 회사별 table_type_columns 제약조건 통합
// table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
const dbNullable = col.db_is_nullable === "YES";
const ttcNotNull = col.ttc_is_nullable === "N";
const effectiveNullable = ttcNotNull ? false : dbNullable;
const ttcUnique = col.ttc_is_unique === "Y";
return {
name: col.column_name,
label: col.column_label || col.column_name,
type: col.data_type,
nullable: effectiveNullable,
unique: ttcUnique,
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
};
});
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);

View File

@ -8,6 +8,7 @@ import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
import logger from "../utils/logger";
import { TableManagementService } from "../services/tableManagementService";
/**
*
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
company_code: companyCode,
};
// UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전)
if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) {
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
dataWithCompany,
companyCode
);
if (uniqueViolations.length > 0) {
throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`);
}
}
switch (actionType.toLowerCase()) {
case "insert":
return await executeInsert(tableName, dataWithCompany);

View File

@ -1,6 +1,7 @@
import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { TableManagementService } from "../services/tableManagementService";
import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
@ -48,6 +49,21 @@ export const saveFormData = async (
formDataWithMeta.company_code = companyCode;
}
// UNIQUE 제약조건 검증 (INSERT 전)
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 클라이언트 IP 주소 추출
const ipAddress =
req.ip ||
@ -112,6 +128,21 @@ export const saveFormDataEnhanced = async (
formDataWithMeta.company_code = companyCode;
}
// UNIQUE 제약조건 검증 (INSERT 전)
const tmsEnhanced = new TableManagementService();
const uniqueViolations = await tmsEnhanced.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 개선된 서비스 사용
const result = await enhancedDynamicFormService.saveFormData(
screenId,
@ -153,12 +184,28 @@ export const updateFormData = async (
const formDataWithMeta = {
...data,
updated_by: userId,
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
writer: data.writer || userId,
updated_at: new Date(),
};
// UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormData(
id, // parseInt 제거 - 문자열 ID 지원
id,
tableName,
formDataWithMeta
);
@ -209,11 +256,27 @@ export const updateFormDataPartial = async (
const newDataWithMeta = {
...newData,
updated_by: userId,
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정
writer: newData.writer || userId,
};
// UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외)
const tmsPartial = new TableManagementService();
const uniqueViolations = await tmsPartial.validateUniqueConstraints(
tableName,
newDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormDataPartial(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
id,
tableName,
originalData,
newDataWithMeta

View File

@ -2087,6 +2087,23 @@ export async function multiTableSave(
return;
}
// UNIQUE 제약조건 검증 (트랜잭션 전에)
const tmsMulti = new TableManagementService();
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
mainTable.tableName,
mainData,
companyCode,
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
);
if (uniqueViolations.length > 0) {
client.release();
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
await client.query("BEGIN");
// 1. 메인 테이블 저장

View File

@ -41,7 +41,18 @@ export const errorHandler = (
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") {
// unique_violation
error = new AppError("중복된 데이터가 존재합니다.", 400);
const constraint = pgError.constraint || "";
const tbl = pgError.table || "";
let col = "";
if (constraint && tbl) {
const prefix = `${tbl}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
col = constraint.slice(prefix.length, -suffix.length);
}
}
const detail = col ? ` [${col}]` : "";
error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400);
} else if (pgError.code === "23503") {
// foreign_key_violation
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);

View File

@ -5,6 +5,8 @@ import { multiTableExcelService, TableChainConfig } from "../services/multiTable
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
const router = express.Router();
@ -950,6 +952,20 @@ router.post(
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
}
// UNIQUE 제약조건 검증
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
enrichedData,
req.user?.companyCode || "*"
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 생성
const result = await dataService.createRecord(tableName, enrichedData);
@ -1019,6 +1035,21 @@ router.put(
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
// UNIQUE 제약조건 검증 (자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
data,
req.user?.companyCode || "*",
String(id)
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 수정
const result = await dataService.updateRecord(tableName, id, data);

View File

@ -970,10 +970,11 @@ class MultiTableExcelService {
const result = await pool.query(
`SELECT
c.column_name,
c.is_nullable,
c.is_nullable AS db_is_nullable,
c.column_default,
COALESCE(ttc.column_label, cl.column_label) AS column_label,
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
FROM information_schema.columns c
LEFT JOIN table_type_columns cl
ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
@ -991,13 +992,13 @@ class MultiTableExcelService {
// 시스템 컬럼 제외
if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue;
// FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조)
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
// _id로 끝나면서 reference_table이 있는 경우만 제외
// FK 컬럼 제외
if (row.reference_table && colName.endsWith("_id")) continue;
const hasDefault = row.column_default !== null;
const isNullable = row.is_nullable === "YES";
const dbNullable = row.db_is_nullable === "YES";
const ttcNotNull = row.ttc_is_nullable === "N";
const isNullable = ttcNotNull ? false : dbNullable;
const isRequired = !isNullable && !hasDefault;
columns.push({

View File

@ -37,8 +37,38 @@ export async function formatPgError(
const detail = colName ? ` [${colName}]` : "";
return `필수 입력값이 누락되었습니다.${detail}`;
}
case "23505":
return "중복된 데이터가 존재합니다.";
case "23505": {
// unique_violation
const constraint = error.constraint || "";
const tblName = error.table || "";
// constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code)
let colName = "";
if (constraint && tblName) {
const prefix = `${tblName}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
colName = constraint.slice(prefix.length, -suffix.length);
}
}
if (colName && tblName && companyCode) {
try {
const rows = await query(
`SELECT column_label FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
LIMIT 1`,
[tblName, colName, companyCode]
);
const label = rows[0]?.column_label;
if (label) {
return `중복된 데이터가 존재합니다: ${label}`;
}
} catch {
// 폴백
}
}
const detail = colName ? ` [${colName}]` : "";
return `중복된 데이터가 존재합니다.${detail}`;
}
case "23503":
return "참조 무결성 제약 조건 위반입니다.";
default:

View File

@ -1061,19 +1061,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
// 카테고리/엔티티/코드 타입이 아닌 경우에만 DISTINCT API 사용
// (카테고리 등은 DISTINCT로 조회하면 코드만 반환되어 label이 없음)
if (!["category", "entity", "code"].includes(inputType)) {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
}
} catch (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
} catch (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출

View File

@ -467,14 +467,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
if (hasNewOptions) {
setSelectOptions((prev) => {
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
const merged = { ...prev };
for (const [key, value] of Object.entries(loadedOptions)) {
if (!merged[key] || merged[key].length === 0) {
merged[key] = value;
}
}
return merged;
// 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로)
// 로드 실패한 컬럼의 기존 옵션은 유지
return { ...prev, ...loadedOptions };
});
}
};