From 3ca511924e05e55e4ff7a5d57207a006748c913a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 18:40:36 +0900 Subject: [PATCH] feat: Implement company-specific NOT NULL constraint validation for table data - Added validation for NOT NULL constraints in the add and edit table data functions, ensuring that required fields are not empty based on company-specific settings. - Enhanced the toggleColumnNullable function to check for existing NULL values before changing the NOT NULL status, providing appropriate error messages. - Introduced a new service method to validate NOT NULL constraints against company-specific configurations, improving data integrity in a multi-tenancy environment. --- .../controllers/tableManagementController.ts | 106 +++++++++++++++--- .../src/services/tableManagementService.ts | 73 +++++++++++- 2 files changed, 160 insertions(+), 19 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..5657010f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -921,6 +921,24 @@ export async function addTableData( } } + // 회사별 NOT NULL 소프트 제약조건 검증 + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + data, + companyCode || "*" + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); @@ -1003,6 +1021,25 @@ export async function editTableData( } const tableManagementService = new TableManagementService(); + const companyCode = req.user?.companyCode || "*"; + + // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + updatedData, + companyCode + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } // 데이터 수정 await tableManagementService.editTableData( @@ -2652,8 +2689,11 @@ export async function toggleTableIndex( } /** - * NOT NULL 토글 + * NOT NULL 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + * + * DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다. + * 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다. */ export async function toggleColumnNullable( req: AuthenticatedRequest, @@ -2662,6 +2702,7 @@ export async function toggleColumnNullable( try { const { tableName, columnName } = req.params; const { nullable } = req.body; + const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ @@ -2671,18 +2712,54 @@ export async function toggleColumnNullable( return; } - if (nullable) { - // NOT NULL 해제 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; - logger.info(`NOT NULL 해제: ${sql}`); - await query(sql); - } else { - // NOT NULL 설정 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; - logger.info(`NOT NULL 설정: ${sql}`); - await query(sql); + // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL + const isNullableValue = nullable ? "Y" : "N"; + + if (!nullable) { + // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const nullCheckQuery = companyCode === "*" + ? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL` + : `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`; + const nullCheckParams = companyCode === "*" ? [] : [companyCode]; + + const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams); + const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10); + + if (nullCount > 0) { + logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, { + companyCode, + nullCount, + }); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`, + }); + return; + } + } } + // table_type_columns에 회사별 is_nullable 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_nullable = $3, updated_date = NOW()`, + [tableName, columnName, isNullableValue, companyCode] + ); + + logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, { + companyCode, + }); + res.status(200).json({ success: true, message: nullable @@ -2692,14 +2769,9 @@ export async function toggleColumnNullable( } catch (error: any) { logger.error("NOT NULL 토글 오류:", error); - // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 - const errorMsg = error.message?.includes("contains null values") - ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." - : "NOT NULL 설정 중 오류가 발생했습니다."; - res.status(500).json({ success: false, - message: errorMsg, + message: "NOT NULL 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..c19c2631 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -199,7 +199,11 @@ export class TableManagementService { cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.description, cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL + THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -241,7 +245,11 @@ export class TableManagementService { COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN cl.is_nullable IS NOT NULL + THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -2431,6 +2439,67 @@ export class TableManagementService { return value; } + /** + * 회사별 NOT NULL 소프트 제약조건 검증 + * table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다. + */ + async validateNotNullConstraints( + tableName: string, + data: Record, + companyCode: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + const notNullColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (notNullColumns.length === 0 && companyCode !== "*") { + const globalNotNull = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + notNullColumns.push(...globalNotNull); + } + + if (notNullColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of notNullColumns) { + const value = data[col.column_name]; + // NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리 + if (value === null || value === undefined || value === "") { + violations.push(col.column_label); + } + } + + return violations; + } catch (error) { + logger.error(`NOT NULL 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용)