Files
pipeline/backend-node/src/services/tableManagementService.ts
T
kjs 0e8c68a9ff feat: Add multi-table Excel upload functionality
- Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains.
- The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table.
- The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process.
- Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler.

This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently.
2026-03-05 19:17:35 +09:00

5347 lines
176 KiB
TypeScript

import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
import {
TableInfo,
ColumnTypeInfo,
ColumnSettings,
TableLabels,
ColumnLabels,
EntityJoinResponse,
EntityJoinConfig,
} from "../types/tableManagement";
import { WebType } from "../types/v2-web-types";
import { entityJoinService } from "./entityJoinService";
import { referenceCacheService } from "./referenceCacheService";
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
export class TableManagementService {
constructor() {}
/**
* 컬럼이 코드 타입인지 확인하고 코드 카테고리 반환
*/
private async getCodeTypeInfo(
tableName: string,
columnName: string
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
try {
// table_type_columns 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
const result = await query(
`SELECT input_type, code_category
FROM table_type_columns
WHERE table_name = $1
AND column_name = $2
AND input_type = 'code'
AND company_code = '*'`,
[tableName, columnName]
);
if (Array.isArray(result) && result.length > 0) {
const row = result[0] as any;
return {
isCodeType: true,
codeCategory: row.code_category,
};
}
return { isCodeType: false };
} catch (error) {
logger.warn(
`코드 타입 컬럼 확인 중 오류: ${tableName}.${columnName}`,
error
);
return { isCodeType: false };
}
}
/**
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
* 메타데이터 조회는 Prisma로 변경 불가
*/
async getTableList(): Promise<TableInfo[]> {
try {
logger.info("테이블 목록 조회 시작");
// 캐시에서 먼저 확인
const cachedTables = cache.get<TableInfo[]>(CacheKeys.TABLE_LIST);
if (cachedTables) {
logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}`);
return cachedTables;
}
// information_schema는 여전히 $queryRaw 사용
const rawTables = await query<any>(
`SELECT
t.table_name as "tableName",
COALESCE(tl.table_label, t.table_name) as "displayName",
COALESCE(tl.description, '') as "description",
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = t.table_name AND table_schema = 'public') as "columnCount"
FROM information_schema.tables t
LEFT JOIN table_labels tl ON t.table_name = tl.table_name
WHERE t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND t.table_name NOT LIKE 'pg_%'
AND t.table_name NOT LIKE 'sql_%'
ORDER BY t.table_name`
);
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const tables: TableInfo[] = rawTables.map((table) => ({
...table,
columnCount: Number(table.columnCount), // BigInt → Number 변환
}));
// 캐시에 저장 (10분 TTL)
cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000);
logger.info(`테이블 목록 조회 완료: ${tables.length}`);
return tables;
} catch (error) {
logger.error("테이블 목록 조회 중 오류 발생:", error);
throw new Error(
`테이블 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 테이블 컬럼 정보 조회 (페이지네이션 지원)
* 메타데이터 조회는 Prisma로 변경 불가
*/
async getColumnList(
tableName: string,
page: number = 1,
size: number = 50,
companyCode?: string, // 🔥 회사 코드 추가
bustCache: boolean = false // 🔥 캐시 버스팅 옵션
): Promise<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
logger.info(
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}`
);
// 캐시 키 생성 (companyCode 포함)
const cacheKey =
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 🔥 캐시 버스팅: bustCache가 true면 캐시 무시
if (!bustCache) {
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
// 디버깅: 캐시된 currency_code 확인
const cachedCurrency = cachedResult.columns.find(
(col: any) => col.columnName === "currency_code"
);
if (cachedCurrency) {
console.log(`💾 [캐시] currency_code:`, {
columnName: cachedCurrency.columnName,
inputType: cachedCurrency.inputType,
webType: cachedCurrency.webType,
});
}
return cachedResult;
}
} else {
logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`);
}
// 전체 컬럼 수 조회 (캐시 확인)
let total = cache.get<number>(countCacheKey);
if (!total) {
const totalResult = await query<{ count: bigint }>(
`SELECT COUNT(*) as count
FROM information_schema.columns c
WHERE c.table_name = $1`,
[tableName]
);
total = Number(totalResult[0].count);
// 컬럼 수는 자주 변하지 않으므로 30분 캐시
cache.set(countCacheKey, total, 30 * 60 * 1000);
}
// 페이지네이션 적용한 컬럼 조회
const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
// cl: 공통 설정 (company_code = '*'), ttc: 회사별 설정
const rawColumns = companyCode
? await query<any>(
`SELECT
c.column_name as "columnName",
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
ttc.input_type as "ttc_input_type",
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",
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 COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
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",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
COALESCE(ttc.code_category, cl.code_category) as "codeCategory",
COALESCE(ttc.code_value, cl.code_value) as "codeValue",
COALESCE(ttc.reference_table, cl.reference_table) as "referenceTable",
COALESCE(ttc.reference_column, cl.reference_column) as "referenceColumn",
COALESCE(ttc.display_column, cl.display_column) as "displayColumn",
COALESCE(ttc.display_order, cl.display_order) as "displayOrder",
COALESCE(ttc.is_visible, cl.is_visible) as "isVisible",
dcl.column_label as "displayColumnLabel"
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 = '*'
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4
LEFT JOIN table_type_columns dcl ON COALESCE(ttc.reference_table, cl.reference_table) = dcl.table_name AND COALESCE(ttc.display_column, cl.display_column) = dcl.column_name AND dcl.company_code = '*'
LEFT JOIN (
SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_name = $1
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
WHERE c.table_name = $1
ORDER BY c.ordinal_position
LIMIT $2 OFFSET $3`,
[tableName, size, offset, companyCode]
)
: await query<any>(
`SELECT
c.column_name as "columnName",
COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
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 cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
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",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
cl.code_category as "codeCategory",
cl.code_value as "codeValue",
cl.reference_table as "referenceTable",
cl.reference_column as "referenceColumn",
cl.display_column as "displayColumn",
cl.display_order as "displayOrder",
cl.is_visible as "isVisible",
dcl.column_label as "displayColumnLabel"
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 = '*'
LEFT JOIN table_type_columns dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name AND dcl.company_code = '*'
LEFT JOIN (
SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_name = $1
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
WHERE c.table_name = $1
ORDER BY c.ordinal_position
LIMIT $2 OFFSET $3`,
[tableName, size, offset]
);
// 🆕 category_column_mapping 조회
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
tableName,
companyCode,
});
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
if (columnCheck.length > 0) {
// menu_objid 컬럼이 있는 경우
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
logger.info(
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
);
}
} catch (mappingError: any) {
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
}
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
const baseColumn = {
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale
? Number(column.numericScale)
: null,
displayOrder: column.displayOrder
? Number(column.displayOrder)
: null,
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 table_type_columns에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (
column.inputType === "category" &&
categoryMappings.has(column.columnName)
) {
const menus = categoryMappings.get(column.columnName);
logger.info(
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
{ menus }
);
return {
...baseColumn,
categoryMenus: menus,
};
}
return baseColumn;
});
const totalPages = Math.ceil(total / size);
const result = {
columns,
total,
page,
size,
totalPages,
};
// 캐시에 저장 (5분 TTL)
cache.set(cacheKey, result, 5 * 60 * 1000);
logger.info(
`컬럼 정보 조회 완료: ${tableName}, ${columns.length}/${total}개 (${page}/${totalPages} 페이지)`
);
return result;
} catch (error) {
logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error);
throw new Error(
`컬럼 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 테이블이 table_labels에 없으면 자동 추가
* Prisma ORM으로 변경
*/
async insertTableIfNotExists(tableName: string): Promise<void> {
try {
logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`);
await query(
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (table_name) DO NOTHING`,
[tableName, tableName, ""]
);
logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`);
} catch (error) {
logger.error(`테이블 라벨 자동 추가 중 오류 발생: ${tableName}`, error);
throw new Error(
`테이블 라벨 자동 추가 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 테이블 라벨 업데이트
*/
async updateTableLabel(
tableName: string,
displayName: string,
description?: string
): Promise<void> {
try {
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
// table_labels 테이블에 UPSERT
await query(
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = NOW()`,
[tableName, displayName, description || ""]
);
// 캐시 무효화
cache.delete(CacheKeys.TABLE_LIST);
logger.info(`테이블 라벨 업데이트 완료: ${tableName}`);
} catch (error) {
logger.error("테이블 라벨 업데이트 중 오류 발생:", error);
throw new Error(
`테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 컬럼 설정 업데이트 (UPSERT 방식)
* Prisma ORM으로 변경
*/
async updateColumnSettings(
tableName: string,
columnName: string,
settings: ColumnSettings,
companyCode: string // 🔥 회사 코드 추가
): Promise<void> {
try {
logger.info(
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
);
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (settings.inputType === "direct" || settings.inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${settings.inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
settings.inputType = "text";
}
// 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName);
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
let detailSettingsStr = settings.detailSettings;
if (
typeof settings.detailSettings === "object" &&
settings.detailSettings !== null
) {
detailSettingsStr = JSON.stringify(settings.detailSettings);
}
// 입력타입에 해당하지 않는 설정값은 NULL로 강제 초기화
const inputType = settings.inputType;
const referenceTable = inputType === "entity" ? (settings.referenceTable || null) : null;
const referenceColumn = inputType === "entity" ? (settings.referenceColumn || null) : null;
const displayColumn = inputType === "entity" ? (settings.displayColumn || null) : null;
const codeCategory = inputType === "code" ? (settings.codeCategory || null) : null;
const codeValue = inputType === "code" ? (settings.codeValue || null) : null;
const categoryRef = inputType === "category" ? (settings.categoryRef || null) : null;
await query(
`INSERT INTO table_type_columns (
table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable,
company_code, category_ref, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
code_category = EXCLUDED.code_category,
code_value = EXCLUDED.code_value,
reference_table = EXCLUDED.reference_table,
reference_column = EXCLUDED.reference_column,
display_column = EXCLUDED.display_column,
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref,
updated_date = NOW()`,
[
tableName,
columnName,
settings.columnLabel,
inputType,
detailSettingsStr,
codeCategory,
codeValue,
referenceTable,
referenceColumn,
displayColumn,
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true,
companyCode,
categoryRef,
]
);
// 🔥 화면 레이아웃 동기화 (입력 타입 변경 시)
if (settings.inputType) {
await this.syncScreenLayoutsInputType(
tableName,
columnName,
settings.inputType as string,
companyCode
);
}
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) {
logger.error(
`컬럼 설정 업데이트 중 오류 발생: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 설정 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 전체 컬럼 설정 일괄 업데이트
* Prisma 트랜잭션으로 변경
*/
async updateAllColumnSettings(
tableName: string,
columnSettings: ColumnSettings[],
companyCode: string // 🔥 회사 코드 추가
): Promise<void> {
try {
logger.info(
`전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
);
// Raw Query 트랜잭션 사용
await transaction(async (client) => {
// 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName);
// 각 컬럼 설정을 순차적으로 업데이트
for (const columnSetting of columnSettings) {
// columnName은 실제 DB 컬럼명을 유지해야 함
const columnName = columnSetting.columnName;
if (columnName) {
await this.updateColumnSettings(
tableName,
columnName,
columnSetting,
companyCode // 🔥 회사 코드 전달
);
} else {
logger.warn(
`컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}`
);
}
}
});
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
} catch (error) {
logger.error(
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
error
);
throw new Error(
`전체 컬럼 설정 일괄 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 테이블 라벨 정보 조회
* Prisma ORM으로 변경
*/
async getTableLabels(tableName: string): Promise<TableLabels | null> {
try {
logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`);
const tableLabel = await queryOne<{
table_name: string;
table_label: string | null;
description: string | null;
created_date: Date | null;
updated_date: Date | null;
}>(
`SELECT table_name, table_label, description, created_date, updated_date
FROM table_labels
WHERE table_name = $1`,
[tableName]
);
if (!tableLabel) {
return null;
}
const result: TableLabels = {
tableName: tableLabel.table_name,
tableLabel: tableLabel.table_label || undefined,
description: tableLabel.description || undefined,
createdDate: tableLabel.created_date || undefined,
updatedDate: tableLabel.updated_date || undefined,
};
logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`);
return result;
} catch (error) {
logger.error(`테이블 라벨 정보 조회 중 오류 발생: ${tableName}`, error);
throw new Error(
`테이블 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 컬럼 라벨 정보 조회
* Prisma ORM으로 변경
*/
async getColumnLabels(
tableName: string,
columnName: string
): Promise<ColumnLabels | null> {
try {
logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`);
const columnLabel = await queryOne<{
id: number;
table_name: string;
column_name: string;
column_label: string | null;
input_type: string | null;
detail_settings: any;
description: string | null;
display_order: number | null;
is_visible: boolean | null;
code_category: string | null;
code_value: string | null;
reference_table: string | null;
reference_column: string | null;
created_date: Date | null;
updated_date: Date | null;
}>(
`SELECT id, table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, created_date, updated_date
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'`,
[tableName, columnName]
);
if (!columnLabel) {
return null;
}
const result: ColumnLabels = {
id: columnLabel.id,
tableName: columnLabel.table_name || "",
columnName: columnLabel.column_name || "",
columnLabel: columnLabel.column_label || undefined,
webType: columnLabel.input_type || undefined,
detailSettings: columnLabel.detail_settings || undefined,
description: columnLabel.description || undefined,
displayOrder: columnLabel.display_order || undefined,
isVisible: columnLabel.is_visible || undefined,
codeCategory: columnLabel.code_category || undefined,
codeValue: columnLabel.code_value || undefined,
referenceTable: columnLabel.reference_table || undefined,
referenceColumn: columnLabel.reference_column || undefined,
createdDate: columnLabel.created_date || undefined,
updatedDate: columnLabel.updated_date || undefined,
};
logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`);
return result;
} catch (error) {
logger.error(
`컬럼 라벨 정보 조회 중 오류 발생: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 컬럼 입력 타입 설정 (web_type → input_type 통합)
*/
async updateColumnWebType(
tableName: string,
columnName: string,
webType: string,
detailSettings?: Record<string, any>,
inputType?: string
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalWebType = webType;
if (webType === "direct" || webType === "auto") {
logger.warn(
`잘못된 webType 값 감지: ${webType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalWebType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalWebType}`
);
// 웹 타입별 기본 상세 설정 생성
const defaultDetailSettings =
this.generateDefaultDetailSettings(finalWebType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
...defaultDetailSettings,
...detailSettings,
};
// table_type_columns UPSERT로 업데이트 또는 생성 (company_code = '*' 공통 설정)
await query(
`INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings, is_nullable,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, 'Y', '*', NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
updated_date = NOW()`,
[
tableName,
columnName,
finalWebType,
JSON.stringify(finalDetailSettings),
]
);
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
);
} catch (error) {
logger.error(
`컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 컬럼 입력 타입 설정 (새로운 시스템)
* @param companyCode - 회사 코드 (멀티테넌시)
*/
async updateColumnInputType(
tableName: string,
columnName: string,
inputType: string,
companyCode: string,
detailSettings?: Record<string, any>
): Promise<void> {
try {
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
let finalInputType = inputType;
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 input_type 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
finalInputType = "text";
}
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalInputType}, company: ${companyCode}`
);
// 입력 타입별 기본 상세 설정 생성
const defaultDetailSettings =
this.generateDefaultInputTypeSettings(finalInputType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
...defaultDetailSettings,
...detailSettings,
};
// 입력타입 변경 시 이전 타입의 설정값 초기화
const clearEntity = finalInputType !== "entity";
const clearCode = finalInputType !== "code";
const clearCategory = finalInputType !== "category";
await query(
`INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings,
reference_table = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_table END,
reference_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_column END,
display_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.display_column END,
code_category = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_category END,
code_value = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_value END,
category_ref = CASE WHEN $8 THEN NULL ELSE table_type_columns.category_ref END,
updated_date = now()`,
[
tableName,
columnName,
finalInputType,
JSON.stringify(finalDetailSettings),
companyCode,
clearEntity,
clearCode,
clearCategory,
]
);
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
await this.syncScreenLayoutsInputType(
tableName,
columnName,
finalInputType,
companyCode
);
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
cache.delete(cacheKeyPattern);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} (캐시 무효화 완료)`
);
} catch (error) {
logger.error(
`컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 입력 타입에 해당하는 컴포넌트 ID 반환
* (프론트엔드 webTypeMapping.ts와 동일한 매핑)
*/
private getComponentIdFromInputType(inputType: string): string {
const mapping: Record<string, string> = {
// 텍스트 입력
text: "text-input",
email: "text-input",
password: "text-input",
tel: "text-input",
// 숫자 입력
number: "number-input",
decimal: "number-input",
// 날짜/시간
date: "date-input",
datetime: "date-input",
time: "date-input",
// 텍스트 영역
textarea: "textarea-basic",
// 선택
select: "select-basic",
dropdown: "select-basic",
// 체크박스/라디오
checkbox: "checkbox-basic",
radio: "radio-basic",
boolean: "toggle-switch",
// 파일
file: "file-upload",
// 이미지
image: "image-widget",
img: "image-widget",
picture: "image-widget",
photo: "image-widget",
// 버튼
button: "button-primary",
// 기타
label: "text-display",
code: "select-basic",
entity: "select-basic",
category: "select-basic",
};
return mapping[inputType] || "text-input";
}
/**
* 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화
* @param tableName - 테이블명
* @param columnName - 컬럼명
* @param inputType - 새로운 입력 타입
* @param companyCode - 회사 코드
*/
private async syncScreenLayoutsInputType(
tableName: string,
columnName: string,
inputType: string,
companyCode: string
): Promise<void> {
try {
// 해당 컬럼을 사용하는 화면 레이아웃 조회
const affectedLayouts = await query<{
layout_id: number;
screen_id: number;
component_id: string;
component_type: string;
properties: any;
}>(
`SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties
FROM screen_layouts sl
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
WHERE sl.properties->>'tableName' = $1
AND sl.properties->>'columnName' = $2
AND (sd.company_code = $3 OR $3 = '*')`,
[tableName, columnName, companyCode]
);
if (affectedLayouts.length === 0) {
logger.info(
`화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음`
);
return;
}
logger.info(
`화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견`
);
// 새로운 componentType 계산
const newComponentType = this.getComponentIdFromInputType(inputType);
// 각 레이아웃의 widgetType, componentType 업데이트
for (const layout of affectedLayouts) {
const updatedProperties = {
...layout.properties,
widgetType: inputType,
inputType: inputType,
// componentConfig 내부의 type, inputType, webType 모두 업데이트
componentConfig: {
...layout.properties?.componentConfig,
type: newComponentType,
inputType: inputType,
webType: inputType, // 프론트엔드 SelectBasicComponent에서 카테고리 로딩 여부 판단에 사용
},
};
await query(
`UPDATE screen_layouts
SET properties = $1, component_type = $2
WHERE layout_id = $3`,
[
JSON.stringify(updatedProperties),
newComponentType,
layout.layout_id,
]
);
logger.info(
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, webType=${inputType}, componentType=${newComponentType}`
);
}
logger.info(
`화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨`
);
} catch (error) {
// 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행
logger.warn(
`화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`,
error
);
}
}
/**
* 입력 타입별 기본 상세 설정 생성
*/
private generateDefaultInputTypeSettings(
inputType: string
): Record<string, any> {
switch (inputType) {
case "text":
return {
maxLength: 500,
placeholder: "텍스트를 입력하세요",
};
case "number":
return {
min: 0,
step: 1,
placeholder: "숫자를 입력하세요",
};
case "date":
return {
format: "YYYY-MM-DD",
placeholder: "날짜를 선택하세요",
};
case "code":
return {
placeholder: "코드를 선택하세요",
searchable: true,
};
case "entity":
return {
placeholder: "항목을 선택하세요",
searchable: true,
};
case "select":
return {
placeholder: "선택하세요",
searchable: false,
};
case "checkbox":
return {
defaultChecked: false,
trueValue: "Y",
falseValue: "N",
};
case "radio":
return {
inline: false,
};
default:
return {};
}
}
/**
* 웹 타입별 기본 상세 설정 생성 (레거시 지원)
* @deprecated generateDefaultInputTypeSettings 사용 권장
*/
private generateDefaultDetailSettings(webType: string): Record<string, any> {
switch (webType) {
case "text":
return {
maxLength: 255,
pattern: null,
placeholder: null,
};
case "number":
return {
min: null,
max: null,
step: 1,
precision: 2,
};
case "date":
return {
format: "YYYY-MM-DD",
minDate: null,
maxDate: null,
};
case "code":
return {
codeCategory: null,
displayFormat: "label",
searchable: true,
multiple: false,
};
case "entity":
return {
referenceTable: null,
referenceColumn: null,
searchable: true,
multiple: false,
};
case "textarea":
return {
rows: 3,
maxLength: 1000,
placeholder: null,
};
case "select":
return {
options: [],
multiple: false,
searchable: false,
};
case "checkbox":
return {
defaultChecked: false,
label: null,
};
case "radio":
return {
options: [],
inline: false,
};
case "file":
return {
accept: "*/*",
maxSize: 10485760, // 10MB
multiple: false,
};
default:
return {};
}
}
/**
* 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기)
*/
private async enrichFileData(
data: any[],
fileColumns: string[],
tableName: string
): Promise<any[]> {
try {
logger.info(
`파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}`
);
// 각 행의 파일 정보를 보강
const enrichedData = await Promise.all(
data.map(async (row) => {
const enrichedRow = { ...row };
// 각 파일 컬럼에 대해 처리
for (const fileColumn of fileColumns) {
const filePath = row[fileColumn];
if (filePath && typeof filePath === "string") {
// 🎯 컴포넌트별 파일 정보 조회
// 파일 경로에서 컴포넌트 ID 추출하거나 컬럼명 사용
const componentId =
this.extractComponentIdFromPath(filePath) || fileColumn;
const fileInfos = await this.getFileInfoByColumnAndTarget(
componentId,
row.id || row.objid || row.seq, // 기본키 값
tableName
);
if (fileInfos && fileInfos.length > 0) {
// 파일 정보를 JSON 형태로 저장
const totalSize = fileInfos.reduce(
(sum, file) => sum + (file.size || 0),
0
);
enrichedRow[fileColumn] = JSON.stringify({
files: fileInfos,
totalCount: fileInfos.length,
totalSize: totalSize,
});
} else {
// 파일이 없으면 빈 상태로 설정
enrichedRow[fileColumn] = JSON.stringify({
files: [],
totalCount: 0,
totalSize: 0,
});
}
}
}
return enrichedRow;
})
);
logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`);
return enrichedData;
} catch (error) {
logger.error("파일 데이터 보강 실패:", error);
return data; // 실패 시 원본 데이터 반환
}
}
/**
* 파일 경로에서 컴포넌트 ID 추출 (현재는 사용하지 않음)
*/
private extractComponentIdFromPath(filePath: string): string | null {
// 현재는 파일 경로에서 컴포넌트 ID를 추출할 수 없으므로 null 반환
// 추후 필요시 구현
return null;
}
/**
* 컬럼별 파일 정보 조회 (컬럼명과 target_objid로 구분)
*/
private async getFileInfoByColumnAndTarget(
columnName: string,
targetObjid: any,
tableName: string
): Promise<any[]> {
try {
logger.info(
`컬럼별 파일 정보 조회: ${tableName}.${columnName}, target: ${targetObjid}`
);
// 🎯 컬럼명을 doc_type으로 사용하여 파일 구분
const fileInfos = await query<{
objid: string;
real_file_name: string;
file_size: number;
file_ext: string;
file_path: string;
doc_type: string;
doc_type_name: string;
regdate: Date;
writer: string;
}>(
`SELECT objid, real_file_name, file_size, file_ext, file_path,
doc_type, doc_type_name, regdate, writer
FROM attach_file_info
WHERE target_objid = $1 AND doc_type = $2 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[String(targetObjid), columnName]
);
// 파일 정보 포맷팅
return fileInfos.map((fileInfo) => ({
name: fileInfo.real_file_name,
size: Number(fileInfo.file_size) || 0,
path: fileInfo.file_path,
ext: fileInfo.file_ext,
objid: String(fileInfo.objid),
docType: fileInfo.doc_type,
docTypeName: fileInfo.doc_type_name,
regdate: fileInfo.regdate?.toISOString(),
writer: fileInfo.writer,
}));
} catch (error) {
logger.warn(`컬럼별 파일 정보 조회 실패: ${columnName}`, error);
return [];
}
}
/**
* 파일 경로로 파일 정보 조회 (기존 메서드 - 호환성 유지)
*/
private async getFileInfoByPath(filePath: string): Promise<any | null> {
try {
const fileInfo = await queryOne<{
objid: string;
real_file_name: string;
file_size: number;
file_ext: string;
file_path: string;
doc_type: string;
doc_type_name: string;
regdate: Date;
writer: string;
}>(
`SELECT objid, real_file_name, file_size, file_ext, file_path,
doc_type, doc_type_name, regdate, writer
FROM attach_file_info
WHERE file_path = $1 AND status = 'ACTIVE'
LIMIT 1`,
[filePath]
);
if (!fileInfo) {
return null;
}
return {
name: fileInfo.real_file_name,
path: fileInfo.file_path,
size: Number(fileInfo.file_size) || 0,
type: fileInfo.file_ext,
objid: fileInfo.objid.toString(),
docType: fileInfo.doc_type,
docTypeName: fileInfo.doc_type_name,
regdate: fileInfo.regdate?.toISOString(),
writer: fileInfo.writer,
};
} catch (error) {
logger.warn(`파일 정보 조회 실패: ${filePath}`, error);
return null;
}
}
/**
* 파일 타입 컬럼 조회
*/
private async getFileTypeColumns(tableName: string): Promise<string[]> {
try {
const fileColumns = await query<{ column_name: string }>(
`SELECT column_name
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'file' AND company_code = '*'`,
[tableName]
);
const columnNames = fileColumns.map((col) => col.column_name);
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
return columnNames;
} catch (error) {
logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error);
return [];
}
}
/**
* 고급 검색 조건 구성
*/
private async buildAdvancedSearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
} | null> {
try {
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
if (Array.isArray(value) && value.length > 0) {
// 배열의 각 값에 대해 OR 조건으로 검색
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
// - 정확히 "2"
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + idx * 4;
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(
safeValue,
`${safeValue},%`,
`%,${safeValue}`,
`%,${safeValue},%`
);
});
logger.info(
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
paramCount: values.length,
};
}
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
// 날짜 타입이면 날짜 범위로 처리
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 🔧 날짜 범위 객체 {from, to} 체크
if (
typeof value === "object" &&
value !== null &&
("from" in value || "to" in value)
) {
// 날짜 범위 객체는 그대로 전달
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
}
// 🔧 {value, operator} 형태의 필터 객체 처리
let actualValue = value;
let operator = "contains"; // 기본값
if (typeof value === "object" && value !== null && "value" in value) {
actualValue = value.value;
operator = value.operator || "contains";
logger.info("🔍 필터 객체 처리:", {
columnName,
originalValue: value,
actualValue,
operator,
});
}
// 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리)
if (typeof actualValue === "string" && actualValue.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
// 날짜 타입이면 날짜 범위로 처리
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
actualValue === "__ALL__" ||
actualValue === "" ||
actualValue === null ||
actualValue === undefined
) {
return null;
}
// 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
logger.info(
`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
`webType=${columnInfo?.webType || "NULL"}`,
`inputType=${columnInfo?.inputType || "NULL"}`,
`actualValue=${JSON.stringify(actualValue)}`,
`operator=${operator}`
);
if (!columnInfo) {
// 컬럼 정보가 없으면 operator에 따른 기본 검색
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [actualValue],
paramCount: 1,
};
case "contains":
default:
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
paramCount: 1,
};
}
}
const webType = columnInfo.webType;
// 🔧 다중선택 처리: actualValue가 파이프(|)를 포함하고 날짜 타입이 아닌 경우
if (
typeof actualValue === "string" &&
actualValue.includes("|") &&
webType !== "date" &&
webType !== "datetime"
) {
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 웹타입별 검색 조건 구성
switch (webType) {
case "date":
case "datetime":
return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
case "number":
case "decimal":
return this.buildNumberRangeCondition(
columnName,
actualValue,
paramIndex
);
case "code":
return await this.buildCodeSearchCondition(
tableName,
columnName,
actualValue,
paramIndex,
operator
);
case "entity":
return await this.buildEntitySearchCondition(
tableName,
columnName,
actualValue,
paramIndex,
operator // operator 전달 (equals면 직접 매칭)
);
default:
// operator에 따라 정확 일치 또는 부분 일치 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(actualValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
let fallbackOperator = "contains";
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
fallbackOperator = value.operator || "contains";
}
if (fallbackOperator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(fallbackValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`],
paramCount: 1,
};
}
}
/**
* 날짜 범위 검색 조건 구성
*/
private buildDateRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
if (typeof value === "string" && value.includes("|")) {
const [fromStr, toStr] = value.split("|");
if (fromStr && fromStr.trim() !== "") {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
values.push(fromStr.trim());
paramCount++;
}
if (toStr && toStr.trim() !== "") {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
values.push(toStr.trim());
paramCount++;
}
}
// 객체 형식의 날짜 범위 ({from, to})
else if (typeof value === "object" && value !== null) {
if (value.from) {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
values.push(value.from);
paramCount++;
}
if (value.to) {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
values.push(value.to);
paramCount++;
}
}
// 단일 날짜 검색
else if (typeof value === "string" && value.trim() !== "") {
conditions.push(`${columnName}::date = $${paramIndex}::date`);
values.push(value);
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
* 숫자 범위 검색 조건 구성
*/
private buildNumberRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
if (typeof value === "object" && value !== null) {
if (value.min !== undefined && value.min !== null && value.min !== "") {
conditions.push(
`${columnName}::numeric >= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.min));
paramCount++;
}
if (value.max !== undefined && value.max !== null && value.max !== "") {
conditions.push(
`${columnName}::numeric <= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.max));
paramCount++;
}
} else if (typeof value === "string" || typeof value === "number") {
// 정확한 값 검색
conditions.push(`${columnName}::numeric = $${paramIndex}`);
values.push(parseFloat(value.toString()));
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
* 코드 검색 조건 구성
*/
private async buildCodeSearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number,
operator: string = "contains"
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 operator에 따라 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
whereClause: `(
${columnName}::text = $${paramIndex} OR
EXISTS (
SELECT 1 FROM code_info ci
WHERE ci.code_category = $${paramIndex + 1}
AND ci.code_value = ${columnName}
AND ci.code_name ILIKE $${paramIndex + 2}
)
)`,
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
paramCount: 3,
};
} else {
// 정확한 코드값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
* 엔티티 검색 조건 구성
*/
private async buildEntitySearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number,
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const entityTypeInfo = await this.getEntityTypeInfo(
tableName,
columnName
);
// 배열 처리: IN 절 사용
if (Array.isArray(value)) {
if (value.length === 0) {
// 빈 배열이면 항상 false 조건
return {
whereClause: `1 = 0`,
values: [],
paramCount: 0,
};
}
// IN 절로 여러 값 검색
const placeholders = value
.map((_, idx) => `$${paramIndex + idx}`)
.join(", ");
return {
whereClause: `${columnName} IN (${placeholders})`,
values: value,
paramCount: value.length,
};
}
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
// 엔티티 타입이 아니면 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
if (operator === "equals") {
logger.info(
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
);
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
const referenceColumn = entityTypeInfo.referenceColumn || "id";
const referenceTable = entityTypeInfo.referenceTable;
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn;
if (
!displayColumn ||
displayColumn === "none" ||
displayColumn === ""
) {
displayColumn = await this.findDisplayColumnForTable(
referenceTable,
referenceColumn
);
logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
);
}
// 참조 테이블의 표시 컬럼으로 검색
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
return {
whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = main.${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
values: [`%${value}%`],
paramCount: 1,
};
} else {
// 정확한 참조값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
* 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위)
* 우선순위: *_name > name > label/*_label > title > referenceColumn
*/
private async findDisplayColumnForTable(
tableName: string,
referenceColumn?: string
): Promise<string> {
try {
const result = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
const allColumns = result.map((r) => r.column_name);
// entityJoinService와 동일한 우선순위
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
if (nameColumn) {
return nameColumn;
}
// 2. name 컬럼
if (allColumns.includes("name")) {
return "name";
}
// 3. label 또는 *_label 컬럼
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
if (labelColumn) {
return labelColumn;
}
// 4. title 컬럼
if (allColumns.includes("title")) {
return "title";
}
// 5. 참조 컬럼 (referenceColumn)
if (referenceColumn && allColumns.includes(referenceColumn)) {
return referenceColumn;
}
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
return allColumns.find((col) => col !== "id") || "id";
} catch (error) {
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
return referenceColumn || "id"; // 오류 시 기본값
}
}
/**
* 불린 검색 조건 구성
*/
private buildBooleanCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
if (value === "true" || value === true) {
return {
whereClause: `${columnName} = true`,
values: [],
paramCount: 0,
};
} else if (value === "false" || value === false) {
return {
whereClause: `${columnName} = false`,
values: [],
paramCount: 0,
};
} else {
// 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
* 컬럼 웹타입 정보 조회
*/
private async getColumnWebTypeInfo(
tableName: string,
columnName: string
): Promise<{
webType: string;
inputType?: string;
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
} | null> {
try {
const result = await queryOne<{
input_type: string | null;
code_category: string | null;
reference_table: string | null;
reference_column: string | null;
display_column: string | null;
}>(
`SELECT input_type, code_category, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'
LIMIT 1`,
[tableName, columnName]
);
logger.info(
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
{
found: !!result,
input_type: result?.input_type,
}
);
if (!result) {
logger.warn(
`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`
);
return null;
}
const columnInfo = {
webType: result.input_type || "",
inputType: result.input_type || "",
codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined,
referenceColumn: result.reference_column || undefined,
displayColumn: result.display_column || undefined,
};
logger.info(
`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`
);
return columnInfo;
} catch (error) {
logger.error(
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
error
);
return null;
}
}
/**
* 엔티티 타입 정보 조회
*/
private async getEntityTypeInfo(
tableName: string,
columnName: string
): Promise<{
isEntityType: boolean;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}> {
try {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (!columnInfo || columnInfo.webType !== "entity") {
return { isEntityType: false };
}
return {
isEntityType: true,
referenceTable: columnInfo.referenceTable,
referenceColumn: columnInfo.referenceColumn,
displayColumn: columnInfo.displayColumn,
};
} catch (error) {
logger.error(
`엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`,
error
);
return { isEntityType: false };
}
}
/**
* 테이블 데이터 조회 (페이징 + 검색)
*/
async getTableData(
tableName: string,
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
dataFilter?: any; // 🆕 DataFilterConfig
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const {
page,
size,
search = {},
sortBy,
sortOrder = "asc",
companyCode,
dataFilter,
} = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
// 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
// const fileColumns = await this.getFileTypeColumns(tableName);
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
// WHERE 조건 구성
let whereConditions: string[] = [];
let searchValues: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 추가 (company_code)
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode);
paramIndex++;
logger.info(
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
);
}
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
// 🎯 추가 조인 컬럼들은 실제 테이블 컬럼이 아니므로 제외
const additionalJoinColumns = [
"company_code_status",
"writer_dept_code",
];
if (additionalJoinColumns.includes(column)) {
logger.info(
`🔍 추가 조인 컬럼 ${column} 검색 조건에서 제외 (실제 테이블 컬럼 아님)`
);
continue;
}
// 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외
// Entity 조인 조회에서만 처리됨
if (column.includes(".")) {
logger.info(
`🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)`
);
continue;
}
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
// 🎯 고급 필터 처리
const condition = await this.buildAdvancedSearchCondition(
tableName,
safeColumn,
value,
paramIndex
);
if (condition) {
whereConditions.push(condition.whereClause);
searchValues.push(...condition.values);
paramIndex += condition.paramCount;
}
}
}
}
// 🆕 데이터 필터 적용
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const {
buildDataFilterWhereClause,
} = require("../utils/dataFilterUtil");
const { whereClause: filterWhere, params: filterParams } =
buildDataFilterWhereClause(dataFilter, paramIndex);
if (filterWhere) {
whereConditions.push(filterWhere);
searchValues.push(...filterParams);
paramIndex += filterParams.length;
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
logger.info(`🔍 필터 파라미터:`, filterParams);
}
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// ORDER BY 조건 구성
let orderClause = "";
if (sortBy) {
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
const safeSortOrder =
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
} else {
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
const hasCreatedDate = await query<any>(
`SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`,
[safeTableName]
);
if (hasCreatedDate.length > 0) {
orderClause = `ORDER BY main.created_date DESC`;
}
}
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
const countResult = await query<any>(countQuery, searchValues);
const total = parseInt(countResult[0].count);
// 데이터 조회 (main 별칭 추가)
const dataQuery = `
SELECT main.* FROM ${safeTableName} main
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
logger.info(
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
);
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
// 🎯 파일 컬럼이 있으면 파일 정보 보강
if (fileColumns.length > 0) {
data = await this.enrichFileData(data, fileColumns, safeTableName);
}
const totalPages = Math.ceil(total / size);
logger.info(
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
);
return {
data,
total,
page,
size,
totalPages,
};
} catch (error) {
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
throw error;
}
}
/**
* 현재 사용자 정보 조회 (JWT 토큰에서)
*/
private getCurrentUserFromRequest(req?: any): {
userId: string;
userName: string;
} {
// 실제 프로젝트에서는 req 객체에서 JWT 토큰을 파싱하여 사용자 정보를 가져올 수 있습니다
// 현재는 기본값을 반환
return {
userId: "system",
userName: "시스템 사용자",
};
}
/**
* 값을 PostgreSQL 타입에 맞게 변환
*/
private convertValueForPostgreSQL(value: any, dataType: string): any {
if (value === null || value === undefined || value === "") {
return null;
}
const lowerDataType = dataType.toLowerCase();
// 날짜/시간 타입 처리
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
// YYYY-MM-DDTHH:mm:ss 형식을 PostgreSQL timestamp로 변환
if (typeof value === "string") {
try {
const date = new Date(value);
return date.toISOString();
} catch {
return null;
}
}
return value;
}
// 날짜 타입 처리
if (lowerDataType.includes("date")) {
if (typeof value === "string") {
try {
// YYYY-MM-DD 형식 유지
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const date = new Date(value);
return date.toISOString().split("T")[0];
} catch {
return null;
}
}
return value;
}
// 시간 타입 처리
if (lowerDataType.includes("time")) {
if (typeof value === "string") {
// HH:mm:ss 형식 유지
if (/^\d{2}:\d{2}:\d{2}$/.test(value)) {
return value;
}
}
return value;
}
// 숫자 타입 처리
if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return parseInt(value) || null;
}
if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal") ||
lowerDataType.includes("real") ||
lowerDataType.includes("double")
) {
return parseFloat(value) || null;
}
// 불린 타입 처리
if (lowerDataType.includes("boolean")) {
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1";
}
return Boolean(value);
}
// 기본적으로 문자열로 처리
return value;
}
/**
* 회사별 NOT NULL 소프트 제약조건 검증
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
*/
async validateNotNullConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string
): Promise<string[]> {
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 [];
}
}
/**
* 회사별 UNIQUE 소프트 제약조건 검증
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
* @param excludeId 수정 시 자기 자신은 제외
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = 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_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = 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_unique = 'Y'
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]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* 테이블에 데이터 추가
* @returns 무시된 컬럼 정보 (디버깅용)
*/
async addTableData(
tableName: string,
data: Record<string, any>
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
// 테이블의 컬럼 정보 조회
const columnInfoQuery = `
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`;
const columnInfoResult = (await query(columnInfoQuery, [
tableName,
])) as any[];
const columnTypeMap = new Map<string, string>();
columnInfoResult.forEach((col: any) => {
columnTypeMap.set(col.column_name, col.data_type);
});
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
const hasCreatedDate = columnTypeMap.has("created_date");
if (hasCreatedDate && !data.created_date) {
data.created_date = new Date().toISOString();
logger.info(`created_date 자동 추가: ${data.created_date}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {
const exists = columnTypeMap.has(col);
if (!exists) {
skippedColumns.push(col);
}
return exists;
});
// 무시된 컬럼이 있으면 경고 로그 출력
if (skippedColumns.length > 0) {
logger.warn(
`⚠️ [${tableName}] 테이블에 존재하지 않는 컬럼 ${skippedColumns.length}개 무시됨: ${skippedColumns.join(", ")}`
);
logger.warn(
`⚠️ [${tableName}] 무시된 컬럼 상세:`,
skippedColumns.map((col) => ({ column: col, value: data[col] }))
);
}
if (existingColumns.length === 0) {
throw new Error(
`저장할 유효한 컬럼이 없습니다. 테이블: ${tableName}, 전달된 컬럼: ${Object.keys(data).join(", ")}`
);
}
logger.info(
`✅ [${tableName}] 저장될 컬럼 ${existingColumns.length}개: ${existingColumns.join(", ")}`
);
// 컬럼명과 값을 분리하고 타입에 맞게 변환 (존재하는 컬럼만)
const columns = existingColumns;
const values = columns.map((columnName) => {
const value = data[columnName];
const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info(
`컬럼 "${columnName}" (${dataType}): "${value}" → "${convertedValue}"`
);
return convertedValue;
});
// 동적 INSERT 쿼리 생성 (타입 캐스팅 포함)
const placeholders = columns
.map((col, index) => {
const dataType = columnTypeMap.get(col) || "text";
const lowerDataType = dataType.toLowerCase();
// PostgreSQL에서 직접 타입 캐스팅
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
return `$${index + 1}::timestamp`;
} else if (lowerDataType.includes("date")) {
return `$${index + 1}::date`;
} else if (lowerDataType.includes("time")) {
return `$${index + 1}::time`;
} else if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return `$${index + 1}::integer`;
} else if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal")
) {
return `$${index + 1}::numeric`;
} else if (lowerDataType.includes("boolean")) {
return `$${index + 1}::boolean`;
}
return `$${index + 1}`;
})
.join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING id
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
return {
skippedColumns,
savedColumns: existingColumns,
insertedId,
};
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error;
}
}
/**
* 테이블 데이터 수정
*/
async editTableData(
tableName: string,
originalData: Record<string, any>,
updatedData: Record<string, any>
): Promise<void> {
try {
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
logger.info(`원본 데이터:`, originalData);
logger.info(`수정할 데이터:`, updatedData);
// 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용)
const columnInfoQuery = `
SELECT c.column_name, c.data_type, c.is_nullable,
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key
FROM information_schema.columns c
LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name
LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name
WHERE c.table_name = $1
ORDER BY c.ordinal_position
`;
const columnInfoResult = (await query(columnInfoQuery, [
tableName,
])) as any[];
const columnTypeMap = new Map<string, string>();
const primaryKeys: string[] = [];
columnInfoResult.forEach((col: any) => {
columnTypeMap.set(col.column_name, col.data_type);
if (col.is_primary_key === "YES") {
primaryKeys.push(col.column_name);
}
});
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
const hasUpdatedDate = columnTypeMap.has("updated_date");
if (hasUpdatedDate && !updatedData.updated_date) {
updatedData.updated_date = new Date().toISOString();
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];
const setValues: any[] = [];
let paramIndex = 1;
const skippedColumns: string[] = [];
Object.keys(updatedData).forEach((column) => {
// 테이블에 존재하지 않는 컬럼은 스킵
if (!columnTypeMap.has(column)) {
skippedColumns.push(column);
return;
}
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
setValues.push(
this.convertValueForPostgreSQL(updatedData[column], dataType)
);
paramIndex++;
});
if (skippedColumns.length > 0) {
logger.info(
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = [];
let whereValues: any[] = [];
if (primaryKeys.length > 0) {
// PRIMARY KEY로 WHERE 조건 생성
primaryKeys.forEach((pkColumn) => {
if (originalData[pkColumn] !== undefined) {
const dataType = columnTypeMap.get(pkColumn) || "text";
whereConditions.push(
`"${pkColumn}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
whereValues.push(
this.convertValueForPostgreSQL(originalData[pkColumn], dataType)
);
paramIndex++;
}
});
} else {
// PRIMARY KEY가 없으면 모든 원본 데이터로 WHERE 조건 생성
Object.keys(originalData).forEach((column) => {
const dataType = columnTypeMap.get(column) || "text";
whereConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
whereValues.push(
this.convertValueForPostgreSQL(originalData[column], dataType)
);
paramIndex++;
});
}
// UPDATE 쿼리 생성
const updateQuery = `
UPDATE "${tableName}"
SET ${setConditions.join(", ")}
WHERE ${whereConditions.join(" AND ")}
`;
const allValues = [...setValues, ...whereValues];
logger.info(`실행할 UPDATE 쿼리: ${updateQuery}`);
logger.info(`쿼리 파라미터:`, allValues);
const result = await query(updateQuery, allValues);
logger.info(`테이블 데이터 수정 완료: ${tableName}`, result);
} catch (error) {
logger.error(`테이블 데이터 수정 오류: ${tableName}`, error);
throw error;
}
}
/**
* PostgreSQL 타입명 반환
*/
private getPostgreSQLType(dataType: string): string {
const lowerDataType = dataType.toLowerCase();
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
return "timestamp";
} else if (lowerDataType.includes("date")) {
return "date";
} else if (lowerDataType.includes("time")) {
return "time";
} else if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return "integer";
} else if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal")
) {
return "numeric";
} else if (lowerDataType.includes("boolean")) {
return "boolean";
}
return "text"; // 기본값
}
/**
* 테이블에서 데이터 삭제
*/
async deleteTableData(
tableName: string,
dataToDelete: Record<string, any>[]
): Promise<number> {
try {
logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete);
if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) {
throw new Error("삭제할 데이터가 없습니다.");
}
let deletedCount = 0;
// 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해)
const primaryKeyQuery = `
SELECT column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = $1
AND tc.constraint_type = 'PRIMARY KEY'
ORDER BY kcu.ordinal_position
`;
const primaryKeys = await query<{ column_name: string }>(
primaryKeyQuery,
[tableName]
);
if (primaryKeys.length === 0) {
// 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성
logger.warn(
`테이블 ${tableName}에 기본 키가 없습니다. 모든 컬럼으로 삭제 조건을 생성합니다.`
);
for (const rowData of dataToDelete) {
const conditions = Object.keys(rowData)
.map((key, index) => `"${key}" = $${index + 1}`)
.join(" AND ");
const values = Object.values(rowData);
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await query(deleteQuery, values);
deletedCount += Number(result);
}
} else {
// 기본 키를 사용한 삭제
const primaryKeyNames = primaryKeys.map((pk) => pk.column_name);
for (const rowData of dataToDelete) {
const conditions = primaryKeyNames
.map((key, index) => `"${key}" = $${index + 1}`)
.join(" AND ");
const values = primaryKeyNames.map((key) => rowData[key]);
// null 값이 있는 경우 스킵
if (values.some((val) => val === null || val === undefined)) {
logger.warn(`기본 키 값이 null인 행을 스킵합니다:`, rowData);
continue;
}
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await query(deleteQuery, values);
deletedCount += Number(result);
}
}
logger.info(
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
);
return deletedCount;
} catch (error) {
logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error);
throw error;
}
}
// ========================================
// 🎯 Entity 조인 기능
// ========================================
/**
* Entity 조인이 포함된 데이터 조회
*/
async getTableDataWithEntityJoins(
tableName: string,
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
enableEntityJoin?: boolean;
companyCode?: string; // 멀티테넌시 필터용
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
excludeFilter?: {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
}
): Promise<EntityJoinResponse> {
const startTime = Date.now();
try {
logger.info(`Entity 조인 데이터 조회 시작: ${tableName}`);
// Entity 조인이 비활성화된 경우 기본 데이터 조회
if (!options.enableEntityJoin) {
const basicResult = await this.getTableData(tableName, options);
return {
data: basicResult.data,
total: basicResult.total,
page: options.page,
size: options.size,
totalPages: Math.ceil(basicResult.total / options.size),
};
}
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
let joinConfigs = await entityJoinService.detectEntityJoins(
tableName,
options.screenEntityConfigs,
options.companyCode
);
logger.info(
`🔍 detectEntityJoins 결과: ${joinConfigs.length}개 조인 설정`
);
if (joinConfigs.length > 0) {
joinConfigs.forEach((config, index) => {
logger.info(
` 조인 ${index + 1}: ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`
);
});
}
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
if (
options.additionalJoinColumns &&
options.additionalJoinColumns.length > 0
) {
logger.info(
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}`
);
logger.info(
"📋 전달받은 additionalJoinColumns:",
options.additionalJoinColumns
);
for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
let baseJoinConfig = joinConfigs.find(
(config) => config.sourceColumn === additionalColumn.sourceColumn
);
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) =>
config.referenceTable ===
(additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`
);
}
}
if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
let actualColumnName: string;
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${frontendSourceColumn}_`,
""
);
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${sourceColumn}_`,
""
);
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn,
frontendSourceColumn,
originalJoinAlias,
correctedJoinAlias,
actualColumnName,
referenceTable: (additionalColumn as any).referenceTable,
});
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin =
correctedJoinAlias === `${sourceColumn}_name`;
if (isBasicEntityJoin) {
logger.info(
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
);
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
// 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합
const existingConfig = joinConfigs.find(
(config) =>
config.sourceColumn === sourceColumn &&
config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable)
);
if (existingConfig) {
// 기존 config에 display column 추가 (중복 방지)
if (!existingConfig.displayColumns?.includes(actualColumnName)) {
existingConfig.displayColumns = existingConfig.displayColumns || [];
existingConfig.displayColumns.push(actualColumnName);
logger.info(
`🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn}${actualColumnName} (총 ${existingConfig.displayColumns.length}개)`
);
}
} else {
// 새 조인 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};
joinConfigs.push(additionalJoinConfig);
logger.info(
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
);
logger.info(`🔍 추가된 조인 설정 상세:`, {
sourceTable: additionalJoinConfig.sourceTable,
sourceColumn: additionalJoinConfig.sourceColumn,
referenceTable: additionalJoinConfig.referenceTable,
displayColumns: additionalJoinConfig.displayColumns,
aliasColumn: additionalJoinConfig.aliasColumn,
});
}
}
}
}
// 최종 조인 설정 배열 로깅
logger.info(`🎯 최종 joinConfigs 배열 (${joinConfigs.length}개):`);
joinConfigs.forEach((config, index) => {
logger.info(
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`,
{
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
}
);
});
if (joinConfigs.length === 0) {
logger.info(`Entity 조인 설정이 없음: ${tableName}`);
const basicResult = await this.getTableData(tableName, options);
return {
data: basicResult.data,
total: basicResult.total,
page: options.page,
size: options.size,
totalPages: Math.ceil(basicResult.total / options.size),
};
}
// 조인 전략 결정 (테이블 크기 기반)
// 🚨 additionalJoinColumns가 있는 경우 강제로 full_join 사용 (캐시 안정성 보장)
let strategy: "full_join" | "cache_lookup" | "hybrid";
if (
options.additionalJoinColumns &&
options.additionalJoinColumns.length > 0
) {
strategy = "full_join";
console.log(
`🔧 additionalJoinColumns 감지: 강제로 full_join 전략 사용 (${options.additionalJoinColumns.length}개 추가 조인)`
);
} else {
strategy = await entityJoinService.determineJoinStrategy(joinConfigs);
}
console.log(
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
);
// 테이블 컬럼 정보 조회
const columns = await this.getTableColumns(tableName);
const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성
let whereClause = await this.buildWhereClause(tableName, options.search);
// 멀티테넌시 필터 추가 (company_code)
if (options.companyCode) {
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
whereClause = whereClause
? `${whereClause} AND ${companyFilter}`
: companyFilter;
logger.info(
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
);
}
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
if (
options.dataFilter &&
options.dataFilter.enabled &&
options.dataFilter.filters &&
options.dataFilter.filters.length > 0
) {
const filterConditions: string[] = [];
for (const filter of options.dataFilter.filters) {
const { columnName, operator, value } = filter;
if (!columnName || value === undefined || value === null) {
continue;
}
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
);
break;
case "not_equals":
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
);
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} IN (${values})`);
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} NOT IN (${values})`);
}
break;
case "contains":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "starts_with":
filterConditions.push(
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
);
break;
case "ends_with":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
);
break;
case "is_null":
filterConditions.push(`${safeColumn} IS NULL`);
break;
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
}
}
if (filterConditions.length > 0) {
const logicalOperator =
options.dataFilter.matchType === "any" ? " OR " : " AND ";
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
whereClause = whereClause
? `${whereClause} AND ${filterWhere}`
: filterWhere;
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
} = options.excludeFilter;
if (referenceTable && referenceColumn && sourceColumn) {
// 서브쿼리로 이미 존재하는 데이터 제외
let excludeSubquery = `main."${sourceColumn}" NOT IN (
SELECT "${referenceColumn}" FROM "${referenceTable}"
WHERE "${referenceColumn}" IS NOT NULL`;
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
if (
filterColumn &&
filterValue !== undefined &&
filterValue !== null
) {
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
}
excludeSubquery += ")";
whereClause = whereClause
? `${whereClause} AND ${excludeSubquery}`
: excludeSubquery;
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
excludeSubquery,
});
}
}
// ORDER BY 절 구성
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
const hasCreatedDateColumn = selectColumns.includes("created_date");
const orderBy = options.sortBy
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateColumn
? `main."created_date" DESC`
: "";
// 페이징 계산
const offset = (options.page - 1) * options.size;
if (strategy === "full_join") {
// SQL JOIN 방식
return await this.executeJoinQuery(
tableName,
joinConfigs,
selectColumns,
whereClause,
orderBy,
options.size,
offset,
startTime
);
} else if (strategy === "cache_lookup") {
// 캐시 룩업 방식
return await this.executeCachedLookup(
tableName,
joinConfigs,
options,
startTime
);
} else {
// 하이브리드 방식: 일부는 조인, 일부는 캐시
return await this.executeHybridJoin(
tableName,
joinConfigs,
selectColumns,
whereClause,
orderBy,
options.size,
offset,
startTime
);
}
} catch (error) {
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* SQL JOIN 방식으로 데이터 조회
*/
private async executeJoinQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
selectColumns: string[],
whereClause: string,
orderBy: string,
limit: number,
offset: number,
startTime: number
): Promise<EntityJoinResponse> {
try {
// 🆕 참조 테이블별 전체 컬럼 목록 미리 조회
const referenceTableColumns = new Map<string, string[]>();
const uniqueRefTables = new Set(
joinConfigs
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
);
for (const key of uniqueRefTables) {
const refTable = key.split(":")[0];
if (!referenceTableColumns.has(key)) {
const cols = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
ORDER BY ordinal_position`,
[refTable]
);
referenceTableColumns.set(key, cols.map((c) => c.column_name));
logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable}${cols.length}`);
}
}
// 데이터 조회 쿼리
const dataQuery = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
selectColumns,
whereClause,
orderBy,
limit,
offset,
undefined,
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
).query;
// 카운트 쿼리
const countQuery = entityJoinService.buildCountQuery(
tableName,
joinConfigs,
whereClause
);
// ⚠️ SQL 쿼리 로깅 (디버깅용)
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
// 병렬 실행
const [dataResult, countResult] = await Promise.all([
query(dataQuery),
query(countQuery),
]);
logger.info(
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : [];
const total =
Array.isArray(countResult) && countResult.length > 0
? Number((countResult[0] as any).total)
: 0;
const queryTime = Date.now() - startTime;
return {
data,
total,
page: Math.floor(offset / limit) + 1,
size: limit,
totalPages: Math.ceil(total / limit),
entityJoinInfo: {
joinConfigs,
strategy: "full_join",
performance: {
queryTime,
},
},
};
} catch (error) {
logger.error("SQL JOIN 쿼리 실행 실패", error);
throw error;
}
}
/**
* 캐시 룩업 방식으로 데이터 조회
*/
private async executeCachedLookup(
tableName: string,
joinConfigs: EntityJoinConfig[],
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
},
startTime: number
): Promise<EntityJoinResponse> {
try {
// 캐시 데이터 미리 로드
for (const config of joinConfigs) {
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 로드 - ${config.referenceTable}: keyCol=${config.referenceColumn}, displayCol=${displayCol}`
);
await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
displayCol
);
}
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
const allEntityColumns = [
...joinConfigs.map((config) => config.aliasColumn),
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
...joinConfigs.flatMap((config) => {
const additionalColumns = [];
// writer -> writer_dept_code 패턴
if (config.sourceColumn === "writer") {
additionalColumns.push("writer_dept_code");
}
// company_code -> company_code_status 패턴
if (config.sourceColumn === "company_code") {
additionalColumns.push("company_code_status");
}
return additionalColumns;
}),
];
// 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식
const hasJoinTableSearch =
options.search &&
Object.keys(options.search).some((key) => key.includes("."));
const hasEntitySearch =
options.search &&
(Object.keys(options.search).some((key) =>
allEntityColumns.includes(key)
) ||
hasJoinTableSearch);
if (hasEntitySearch) {
const entitySearchKeys = options.search
? Object.keys(options.search).filter(
(key) => allEntityColumns.includes(key) || key.includes(".")
)
: [];
logger.info(
`🔍 Entity 조인 컬럼 검색 감지: ${entitySearchKeys.join(", ")}`
);
}
let basicResult;
if (hasEntitySearch) {
// Entity 조인 컬럼으로 검색하는 경우 SQL JOIN 방식 사용
logger.info("🔍 Entity 조인 컬럼 검색 감지, SQL JOIN 방식으로 전환");
try {
// 테이블 컬럼 정보 조회
const columns = await this.getTableColumns(tableName);
const selectColumns = columns.data.map((col: any) => col.column_name);
// Entity 조인 컬럼 검색을 위한 WHERE 절 구성
const whereConditions: string[] = [];
const entitySearchColumns: string[] = [];
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
const hasCreatedDateForSearch = selectColumns.includes("created_date");
const joinQueryResult = entityJoinService.buildJoinQuery(
tableName,
joinConfigs,
selectColumns,
"", // WHERE 절은 나중에 추가
options.sortBy
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
: hasCreatedDateForSearch
? `main."created_date" DESC`
: undefined,
options.size,
(options.page - 1) * options.size
);
const aliasMap = joinQueryResult.aliasMap;
logger.info(
`🔧 [검색] 별칭 매핑 사용: ${Array.from(aliasMap.entries())
.map(([table, alias]) => `${table}${alias}`)
.join(", ")}`
);
if (options.search) {
for (const [key, value] of Object.entries(options.search)) {
// 검색값 및 operator 추출 (객체 형태일 수 있음)
let searchValue = value;
let operator = "contains"; // 기본값: 부분 일치
if (
typeof value === "object" &&
value !== null &&
"value" in value
) {
searchValue = value.value;
operator = (value as any).operator || "contains";
}
// 빈 값이면 스킵
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null ||
searchValue === undefined
) {
continue;
}
const safeValue = String(searchValue).replace(/'/g, "''");
// 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name)
if (key.includes(".")) {
const [refTable, refColumn] = key.split(".");
// aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식)
let foundAlias: string | undefined;
for (const [aliasKey, alias] of aliasMap.entries()) {
if (aliasKey.startsWith(`${refTable}:`)) {
foundAlias = alias;
break;
}
}
if (foundAlias) {
whereConditions.push(
`${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (${refTable}.${refColumn})`);
logger.info(
`🎯 조인 테이블 검색: ${key}${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})`
);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음`
);
}
continue;
}
const joinConfig = joinConfigs.find(
(config) => config.aliasColumn === key
);
if (joinConfig) {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// 🔧 파이프로 구분된 다중 선택값 처리
if (safeValue.includes("|")) {
const multiValues = safeValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const inClause = multiValues
.map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text IN (${inClause})`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 다중선택 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
whereConditions.push(
`main.${joinConfig.sourceColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (main.${joinConfig.sourceColumn})`
);
logger.info(
`🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
);
}
} else if (key === "writer_dept_code") {
// writer_dept_code: user_info.dept_code에서 검색
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("user_info:")
);
const userAlias = userAliasKey
? aliasMap.get(userAliasKey)
: undefined;
if (userAlias) {
whereConditions.push(
`${userAlias}.dept_code ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (user_info.dept_code)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})`
);
}
} else if (key === "company_code_status") {
// company_code_status: company_info.status에서 검색
const companyAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("company_info:")
);
const companyAlias = companyAliasKey
? aliasMap.get(companyAliasKey)
: undefined;
if (companyAlias) {
whereConditions.push(
`${companyAlias}.status ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (company_info.status)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})`
);
}
} else {
// 일반 컬럼인 경우: 메인 테이블에서 검색
// 🔧 파이프로 구분된 다중 선택값 처리
if (safeValue.includes("|")) {
const multiValues = safeValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const inClause = multiValues
.map((v: string) => `'${v}'`)
.join(", ");
whereConditions.push(`main.${key}::text IN (${inClause})`);
logger.info(
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
whereConditions.push(`main.${key}::text = '${safeValue}'`);
logger.info(
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
);
} else {
// 기본: 부분 일치 (ILIKE)
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
);
}
}
}
}
const whereClause = whereConditions.join(" AND ");
const hasCreatedDateForOrder = selectColumns.includes("created_date");
const orderBy = options.sortBy
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateForOrder
? `main."created_date" DESC`
: "";
// 페이징 계산
const offset = (options.page - 1) * options.size;
// SQL JOIN 쿼리 실행
const joinResult = await this.executeJoinQuery(
tableName,
joinConfigs,
selectColumns,
whereClause,
orderBy,
options.size,
offset,
startTime
);
return joinResult;
} catch (joinError) {
logger.error(
`Entity 조인 검색 실패, 캐시 방식으로 폴백: ${tableName}`,
joinError
);
// Entity 조인 검색 실패 시 Entity 조인 컬럼을 제외한 검색 조건으로 캐시 방식 사용
const fallbackOptions = { ...options };
if (options.search) {
const filteredSearch: Record<string, any> = {};
// Entity 조인 컬럼을 제외한 검색 조건만 유지
for (const [key, value] of Object.entries(options.search)) {
const isEntityColumn = joinConfigs.some(
(config) => config.aliasColumn === key
);
if (!isEntityColumn) {
filteredSearch[key] = value;
}
}
fallbackOptions.search = filteredSearch;
logger.info(
`🔄 Entity 조인 에러 시 검색 조건 필터링: ${Object.keys(filteredSearch).join(", ")}`
);
}
basicResult = await this.getTableData(tableName, {
...fallbackOptions,
companyCode: options.companyCode,
});
}
} else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, {
...options,
companyCode: options.companyCode,
});
}
// Entity 값들을 캐시에서 룩업하여 변환
const enhancedData = basicResult.data.map((row: any) => {
const enhancedRow = { ...row };
for (const config of joinConfigs) {
const sourceValue = row[config.sourceColumn];
if (sourceValue) {
const lookupValue = referenceCacheService.getLookupValue(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0],
String(sourceValue)
);
// null이나 undefined인 경우 빈 문자열로 설정
enhancedRow[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
enhancedRow[config.aliasColumn] = "";
}
}
return enhancedRow;
});
const queryTime = Date.now() - startTime;
const cacheHitRate = referenceCacheService.getOverallCacheHitRate();
return {
data: enhancedData,
total: basicResult.total,
page: options.page,
size: options.size,
totalPages: Math.ceil(basicResult.total / options.size),
entityJoinInfo: {
joinConfigs,
strategy: "cache_lookup",
performance: {
queryTime,
cacheHitRate,
},
},
};
} catch (error) {
logger.error("캐시 룩업 실행 실패", error);
throw error;
}
}
/**
* WHERE 절 구성 (고급 검색 지원)
*/
private async buildWhereClause(
tableName: string,
search?: Record<string, any>
): Promise<string> {
if (!search || Object.keys(search).length === 0) {
return "";
}
const conditions: string[] = [];
for (const [columnName, value] of Object.entries(search)) {
if (
value === undefined ||
value === null ||
value === "" ||
value === "__ALL__"
) {
continue;
}
try {
// 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name)
if (columnName.includes(".")) {
const [refTable, refColumn] = columnName.split(".");
// 검색값 추출
let searchValue = value;
if (typeof value === "object" && value !== null && "value" in value) {
searchValue = value.value;
}
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null
) {
continue;
}
// 🔍 table_type_columns에서 해당 엔티티 설정 찾기
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
const entityColumnResult = await query<{
column_name: string;
reference_table: string;
reference_column: string;
}>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
[tableName, refTable]
);
if (entityColumnResult.length > 0) {
// 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자)
const joinAlias = refTable.substring(0, 3);
// 조인 테이블 컬럼으로 검색 조건 생성
const safeValue = String(searchValue).replace(/'/g, "''");
const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`;
logger.info(`🔍 조인 테이블 검색 조건: ${condition}`);
conditions.push(condition);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음`
);
}
continue;
}
// 고급 검색 조건 구성
const searchCondition = await this.buildAdvancedSearchCondition(
tableName,
columnName,
value,
1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입)
);
if (searchCondition) {
// SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용
let condition = searchCondition.whereClause;
// 파라미터를 실제 값으로 치환 (안전한 방식)
searchCondition.values.forEach((val, index) => {
const paramPlaceholder = `$${index + 1}`;
if (typeof val === "string") {
condition = condition.replace(
paramPlaceholder,
`'${val.replace(/'/g, "''")}'`
);
} else if (typeof val === "number") {
condition = condition.replace(paramPlaceholder, val.toString());
} else {
condition = condition.replace(
paramPlaceholder,
`'${String(val).replace(/'/g, "''")}'`
);
}
});
// main. 접두사 추가 (조인 쿼리용)
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
condition = condition.replace(
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
`main.${columnName}`
);
conditions.push(condition);
}
} catch (error) {
logger.warn(`검색 조건 구성 실패: ${columnName}`, error);
// 폴백: 기본 문자열 검색
if (typeof value === "string") {
conditions.push(
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
);
} else {
conditions.push(
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
);
}
}
}
return conditions.length > 0 ? conditions.join(" AND ") : "";
}
/**
* 테이블의 컬럼 정보 조회
*/
async getTableColumns(tableName: string): Promise<{
data: Array<{ column_name: string; data_type: string }>;
}> {
try {
const columns = await query<{
column_name: string;
data_type: string;
}>(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position`,
[tableName]
);
return { data: columns };
} catch (error) {
logger.error(`테이블 컬럼 조회 실패: ${tableName}`, error);
throw new Error(
`테이블 컬럼 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 참조 테이블의 표시 컬럼 목록 조회
*/
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
Array<{
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}>
> {
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
}
/**
* 컬럼 라벨 정보 업데이트 (display_column 추가)
*/
async updateColumnLabel(
tableName: string,
columnName: string,
updates: Partial<ColumnLabels>
): Promise<void> {
try {
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
await query(
`INSERT INTO table_type_columns (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, is_nullable, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
description = COALESCE(EXCLUDED.description, table_type_columns.description),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value),
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
updated_date = NOW()`,
[
tableName,
columnName,
updates.columnLabel || columnName,
updates.webType || "text",
updates.detailSettings,
updates.description,
updates.displayOrder || 0,
updates.isVisible !== false,
updates.codeCategory,
updates.codeValue,
updates.referenceTable,
updates.referenceColumn,
]
);
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) {
logger.error(
`컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
// ========================================
// 🎯 하이브리드 조인 전략 구현
// ========================================
/**
* 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업
*/
private async executeHybridJoin(
tableName: string,
joinConfigs: EntityJoinConfig[],
selectColumns: string[],
whereClause: string,
orderBy: string,
limit: number,
offset: number,
startTime: number
): Promise<EntityJoinResponse> {
try {
logger.info(`🔀 하이브리드 조인 실행: ${tableName}`);
// 각 조인 설정을 캐시 가능 여부에 따라 분류
const { cacheableJoins, dbJoins } =
await this.categorizeJoins(joinConfigs);
console.log(
`📋 캐시 조인: ${cacheableJoins.length}개, DB 조인: ${dbJoins.length}`
);
// DB 조인이 있는 경우: 조인 쿼리 실행 후 캐시 룩업 적용
if (dbJoins.length > 0) {
return await this.executeJoinThenCache(
tableName,
dbJoins,
cacheableJoins,
selectColumns,
whereClause,
orderBy,
limit,
offset,
startTime
);
}
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else {
// whereClause에서 company_code 추출 (멀티테넌시 필터)
const companyCodeMatch = whereClause.match(
/main\.company_code\s*=\s*'([^']+)'/
);
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
return await this.executeCachedLookup(
tableName,
cacheableJoins,
{
page: Math.floor(offset / limit) + 1,
size: limit,
search: {},
companyCode,
},
startTime
);
}
} catch (error) {
logger.error("하이브리드 조인 실행 실패", error);
throw error;
}
}
/**
* 조인 설정을 캐시 가능 여부에 따라 분류
*/
private async categorizeJoins(joinConfigs: EntityJoinConfig[]): Promise<{
cacheableJoins: EntityJoinConfig[];
dbJoins: EntityJoinConfig[];
}> {
const cacheableJoins: EntityJoinConfig[] = [];
const dbJoins: EntityJoinConfig[] = [];
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [
"supplier_mng",
"customer_mng",
"item_info",
"dept_info",
"sales_order_mng", // 🔧 수주관리 테이블 추가
"sales_order_detail", // 🔧 수주상세 테이블 추가
"partner_info", // 🔧 거래처 테이블 추가
// 필요시 추가
];
for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue;
}
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
if (companySpecificTables.includes(config.referenceTable)) {
dbJoins.push(config);
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
);
if (cachedData && cachedData.size > 0) {
cacheableJoins.push(config);
console.log(
`📋 캐시 사용: ${config.referenceTable} (${cachedData.size}건)`
);
} else {
dbJoins.push(config);
console.log(`🔗 DB 조인: ${config.referenceTable}`);
}
}
return { cacheableJoins, dbJoins };
}
/**
* DB 조인 실행 후 캐시 룩업 적용
*/
private async executeJoinThenCache(
tableName: string,
dbJoins: EntityJoinConfig[],
cacheableJoins: EntityJoinConfig[],
selectColumns: string[],
whereClause: string,
orderBy: string,
limit: number,
offset: number,
startTime: number
): Promise<EntityJoinResponse> {
// 1. DB 조인 먼저 실행
const joinResult = await this.executeJoinQuery(
tableName,
dbJoins,
selectColumns,
whereClause,
orderBy,
limit,
offset,
startTime
);
// 2. 캐시 가능한 조인들을 결과에 추가 적용
if (cacheableJoins.length > 0) {
const enhancedData = await this.applyCacheLookupToData(
joinResult.data,
cacheableJoins
);
return {
...joinResult,
data: enhancedData,
entityJoinInfo: {
...joinResult.entityJoinInfo!,
strategy: "hybrid",
performance: {
...joinResult.entityJoinInfo!.performance,
cacheHitRate: await this.calculateCacheHitRate(cacheableJoins),
hybridBreakdown: {
dbJoins: dbJoins.length,
cacheJoins: cacheableJoins.length,
},
},
},
};
}
return joinResult;
}
/**
* 데이터에 캐시 룩업 적용
*/
private async applyCacheLookupToData(
data: any[],
cacheableJoins: EntityJoinConfig[]
): Promise<any[]> {
const enhancedData = [...data];
for (const config of cacheableJoins) {
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
);
if (cachedData) {
enhancedData.forEach((row) => {
const keyValue = row[config.sourceColumn];
if (keyValue) {
const lookupValue = cachedData.get(String(keyValue));
// null이나 undefined인 경우 빈 문자열로 설정
row[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
row[config.aliasColumn] = "";
}
});
} else {
// 캐시가 없는 경우 모든 행에 빈 문자열 설정
enhancedData.forEach((row) => {
row[config.aliasColumn] = "";
});
}
}
return enhancedData;
}
/**
* 캐시 적중률 계산
*/
private async calculateCacheHitRate(
cacheableJoins: EntityJoinConfig[]
): Promise<number> {
if (cacheableJoins.length === 0) return 0;
let totalHitRate = 0;
for (const config of cacheableJoins) {
const hitRate = referenceCacheService.getCacheHitRate(
config.referenceTable,
config.referenceColumn,
config.displayColumn || config.displayColumns[0]
);
totalHitRate += hitRate;
}
return totalHitRate / cacheableJoins.length;
}
/**
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
*/
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
numericPrecision: col.numericPrecision
? Number(col.numericPrecision)
: undefined,
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
);
return columns;
} catch (error) {
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 테이블 존재 여부 확인
*/
async checkTableExists(tableName: string): Promise<boolean> {
try {
logger.info(`테이블 존재 여부 확인: ${tableName}`);
const result = await query<any>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1
AND table_schema = 'public'
AND table_type = 'BASE TABLE'
) as "exists"`,
[tableName]
);
const exists = result[0]?.exists || false;
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
return exists;
} catch (error) {
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
throw error;
}
}
/**
* 컬럼 입력타입 정보 조회 (화면관리 연동용)
* @param companyCode - 회사 코드 (멀티테넌시)
*/
async getColumnInputTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
try {
logger.info(
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
);
// table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
// detail_settings 컬럼에 유효하지 않은 JSON이 있을 수 있으므로 안전하게 처리
const rawInputTypes = await query<any>(
`SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(ttc.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
CASE
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
WHEN ttc.detail_settings ~ '^\\s*\\{.*\\}\\s*$' THEN ttc.detail_settings::jsonb
ELSE '{}'::jsonb
END as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode",
ttc.category_ref as "categoryRef"
FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = $1
AND ttc.company_code IN ($2, '*')
ORDER BY ttc.column_name,
CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
ttc.display_order`,
[tableName, companyCode]
);
// category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
try {
// menu_objid 컬럼이 있는지 먼저 확인
const columnCheck = await query<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
);
if (columnCheck.length > 0) {
const mappings = await query<any>(
`SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
} else {
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
}
} catch (mappingError: any) {
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
error: mappingError.message,
});
}
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
} else {
logger.warn("category_column_mapping 테이블이 존재하지 않음");
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo: any = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "",
isNullable: col.isNullable === "Y" ? "Y" : "N",
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
if (col.categoryRef) {
baseInfo.categoryRef = col.categoryRef;
}
// 카테고리 타입인 경우 categoryMenus 추가
if (
col.inputType === "category" &&
categoryMappings.has(col.columnName)
) {
const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
menus,
});
return {
...baseInfo,
categoryMenus: menus,
};
}
if (col.inputType === "category") {
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
}
return baseInfo;
});
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
);
return inputTypes;
} catch (error) {
logger.error(
`컬럼 입력타입 정보 조회 실패: ${tableName}, company: ${companyCode}`,
error
);
throw error;
}
}
/**
* 레거시 지원: 컬럼 웹타입 정보 조회
* @deprecated getColumnInputTypes 사용 권장
*/
async getColumnWebTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
logger.warn(
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
);
return this.getColumnInputTypes(tableName, companyCode); // 🔥 FIX: companyCode 파라미터 추가
}
/**
* 데이터베이스 연결 상태 확인
*/
async checkDatabaseConnection(): Promise<{
connected: boolean;
message: string;
}> {
try {
logger.info("데이터베이스 연결 상태 확인");
// 간단한 쿼리로 연결 테스트
const result = await query<any>(`SELECT 1 as "test"`);
if (result && result.length > 0) {
logger.info("데이터베이스 연결 성공");
return {
connected: true,
message: "데이터베이스에 성공적으로 연결되었습니다.",
};
} else {
logger.warn("데이터베이스 연결 응답 없음");
return {
connected: false,
message: "데이터베이스 연결 응답이 없습니다.",
};
}
} catch (error) {
logger.error("데이터베이스 연결 확인 실패:", error);
return {
connected: false,
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
};
}
}
/**
* 데이터 타입으로부터 웹타입 추론
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/v2-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================
// 🎯 테이블 로그 시스템
// ========================================
/**
* 로그 테이블 생성
*/
async createLogTable(
tableName: string,
pkColumn: { columnName: string; dataType: string },
userId?: string
): Promise<void> {
try {
const logTableName = `${tableName}_log`;
const triggerFuncName = `${tableName}_log_trigger_func`;
const triggerName = `${tableName}_audit_trigger`;
logger.info(`로그 테이블 생성 시작: ${logTableName}`);
// 로그 테이블 DDL 생성
const logTableDDL = this.generateLogTableDDL(
logTableName,
tableName,
pkColumn.columnName,
pkColumn.dataType
);
// 트리거 함수 DDL 생성
const triggerFuncDDL = this.generateTriggerFunctionDDL(
triggerFuncName,
logTableName,
tableName,
pkColumn.columnName
);
// 트리거 DDL 생성
const triggerDDL = this.generateTriggerDDL(
triggerName,
tableName,
triggerFuncName
);
// 트랜잭션으로 실행
await transaction(async (client) => {
// 1. 로그 테이블 생성
await client.query(logTableDDL);
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
// 2. 트리거 함수 생성
await client.query(triggerFuncDDL);
logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
// 3. 트리거 생성
await client.query(triggerDDL);
logger.info(`트리거 생성 완료: ${triggerName}`);
// 4. 로그 설정 저장
await client.query(
`INSERT INTO table_log_config (
original_table_name, log_table_name, trigger_name,
trigger_function_name, created_by
) VALUES ($1, $2, $3, $4, $5)`,
[tableName, logTableName, triggerName, triggerFuncName, userId]
);
logger.info(`로그 설정 저장 완료: ${tableName}`);
});
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
} catch (error) {
logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
throw new Error(
`로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 로그 테이블 DDL 생성
*/
private generateLogTableDDL(
logTableName: string,
originalTableName: string,
pkColumnName: string,
pkDataType: string
): string {
return `
CREATE TABLE ${logTableName} (
log_id SERIAL PRIMARY KEY,
operation_type VARCHAR(10) NOT NULL,
original_id VARCHAR(100),
changed_column VARCHAR(100),
old_value TEXT,
new_value TEXT,
changed_by VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(50),
user_agent TEXT,
full_row_before JSONB,
full_row_after JSONB
);
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
`;
}
/**
* 트리거 함수 DDL 생성
*/
private generateTriggerFunctionDDL(
funcName: string,
logTableName: string,
originalTableName: string,
pkColumnName: string
): string {
return `
CREATE OR REPLACE FUNCTION ${funcName}()
RETURNS TRIGGER AS $$
DECLARE
v_column_name TEXT;
v_old_value TEXT;
v_new_value TEXT;
v_user_id VARCHAR(50);
v_ip_address VARCHAR(50);
BEGIN
v_user_id := current_setting('app.user_id', TRUE);
v_ip_address := current_setting('app.ip_address', TRUE);
IF (TG_OP = 'INSERT') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
RETURN NEW;
ELSIF (TG_OP = 'UPDATE') THEN
FOR v_column_name IN
SELECT column_name
FROM information_schema.columns
WHERE table_name = '${originalTableName}'
AND table_schema = 'public'
LOOP
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
'${pkColumnName}'
)
USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
END IF;
END LOOP;
RETURN NEW;
ELSIF (TG_OP = 'DELETE') THEN
EXECUTE format(
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
VALUES ($1, ($2).%I, $3, $4, $5)',
'${pkColumnName}'
)
USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
`;
}
/**
* 트리거 DDL 생성
*/
private generateTriggerDDL(
triggerName: string,
tableName: string,
funcName: string
): string {
return `
CREATE TRIGGER ${triggerName}
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
`;
}
/**
* 로그 설정 조회
*/
async getLogConfig(tableName: string): Promise<{
originalTableName: string;
logTableName: string;
triggerName: string;
triggerFunctionName: string;
isActive: string;
createdAt: Date;
createdBy: string;
} | null> {
try {
logger.info(`로그 설정 조회: ${tableName}`);
const result = await queryOne<{
original_table_name: string;
log_table_name: string;
trigger_name: string;
trigger_function_name: string;
is_active: string;
created_at: Date;
created_by: string;
}>(
`SELECT
original_table_name, log_table_name, trigger_name,
trigger_function_name, is_active, created_at, created_by
FROM table_log_config
WHERE original_table_name = $1`,
[tableName]
);
if (!result) {
return null;
}
return {
originalTableName: result.original_table_name,
logTableName: result.log_table_name,
triggerName: result.trigger_name,
triggerFunctionName: result.trigger_function_name,
isActive: result.is_active,
createdAt: result.created_at,
createdBy: result.created_by,
};
} catch (error) {
logger.error(`로그 설정 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 로그 데이터 조회
*/
async getLogData(
tableName: string,
options: {
page: number;
size: number;
operationType?: string;
startDate?: string;
endDate?: string;
changedBy?: string;
originalId?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const logTableName = `${tableName}_log`;
const offset = (options.page - 1) * options.size;
logger.info(`로그 데이터 조회: ${logTableName}`, options);
// WHERE 조건 구성
const whereConditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (options.operationType) {
whereConditions.push(`operation_type = $${paramIndex}`);
values.push(options.operationType);
paramIndex++;
}
if (options.startDate) {
whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
values.push(options.startDate);
paramIndex++;
}
if (options.endDate) {
whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
values.push(options.endDate);
paramIndex++;
}
if (options.changedBy) {
whereConditions.push(`changed_by = $${paramIndex}`);
values.push(options.changedBy);
paramIndex++;
}
if (options.originalId) {
whereConditions.push(`original_id::text = $${paramIndex}`);
values.push(options.originalId);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
const countResult = await query<any>(countQuery, values);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${logTableName}
${whereClause}
ORDER BY changed_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await query<any>(dataQuery, [
...values,
options.size,
offset,
]);
const totalPages = Math.ceil(total / options.size);
logger.info(
`로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
);
return {
data,
total,
page: options.page,
size: options.size,
totalPages,
};
} catch (error) {
logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 로그 테이블 활성화/비활성화
*/
async toggleLogTable(tableName: string, isActive: boolean): Promise<void> {
try {
const logConfig = await this.getLogConfig(tableName);
if (!logConfig) {
throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
}
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
);
await transaction(async (client) => {
// 트리거 활성화/비활성화
if (isActive) {
await client.query(
`ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
);
} else {
await client.query(
`ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
);
}
// 설정 업데이트
await client.query(
`UPDATE table_log_config
SET is_active = $1, updated_at = NOW()
WHERE original_table_name = $2`,
[isActive ? "Y" : "N", tableName]
);
});
logger.info(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
);
} catch (error) {
logger.error(
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
error
);
throw error;
}
}
/**
* 테이블에 특정 컬럼이 존재하는지 확인
*/
async hasColumn(tableName: string, columnName: string): Promise<boolean> {
try {
const result = await query<any>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName]
);
return result.length > 0;
} catch (error) {
logger.error(
`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`,
error
);
return false;
}
}
/**
* 두 테이블 간의 엔티티 관계 자동 감지
* table_type_columns에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
*
* @param leftTable 좌측 테이블명
* @param rightTable 우측 테이블명
* @returns 감지된 엔티티 관계 배열
*/
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
> {
try {
logger.info(
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
);
const relations: Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}> = [];
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
const rightToLeftRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[rightTable, leftTable]
);
for (const rel of rightToLeftRels) {
relations.push({
leftColumn: rel.reference_column,
rightColumn: rel.column_name,
direction: "right_to_left",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
// 예: left_table의 item_id -> right_table(item_info)의 item_number
const leftToRightRels = await query<{
column_name: string;
reference_column: string;
input_type: string;
display_column: string | null;
}>(
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[leftTable, rightTable]
);
for (const rel of leftToRightRels) {
relations.push({
leftColumn: rel.column_name,
rightColumn: rel.reference_column,
direction: "left_to_right",
inputType: rel.input_type,
displayColumn: rel.display_column || undefined,
});
}
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
);
});
return relations;
} catch (error) {
logger.error(
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
error
);
return [];
}
}
}