import { query, queryOne } from "../database/db"; import { logger } from "../utils/logger"; import { EntityJoinConfig, BatchLookupRequest, BatchLookupResponse, } from "../types/tableManagement"; import { referenceCacheService } from "./referenceCacheService"; /** * Entity 조인 기능을 제공하는 서비스 * ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템 */ export class EntityJoinService { /** * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 * @param tableName 테이블명 * @param screenEntityConfigs 화면별 엔티티 설정 (선택사항) */ async detectEntityJoins( tableName: string, screenEntityConfigs?: Record ): Promise { try { logger.info(`Entity 컬럼 감지 시작: ${tableName}`); // column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용) const entityColumns = await query<{ column_name: string; input_type: string; reference_table: string; reference_column: string; display_column: string | null; }>( `SELECT column_name, input_type, reference_table, reference_column, display_column FROM column_labels WHERE table_name = $1 AND input_type IN ('entity', 'category')`, [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); entityColumns.forEach((col, index) => { logger.info( ` ${index + 1}. ${col.column_name} -> ${col.reference_table}.${col.reference_column} (display: ${col.display_column})` ); }); const joinConfigs: EntityJoinConfig[] = []; // 🎯 writer 컬럼 자동 감지 및 조인 설정 추가 const tableColumns = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' AND column_name = 'writer'`, [tableName] ); if (tableColumns.length > 0) { const writerJoinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: "writer", referenceTable: "user_info", referenceColumn: "user_id", displayColumns: ["user_name"], displayColumn: "user_name", aliasColumn: "writer_name", separator: " - ", }; if (await this.validateJoinConfig(writerJoinConfig)) { joinConfigs.push(writerJoinConfig); } } for (const column of entityColumns) { // 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정 let referenceTable = column.reference_table; let referenceColumn = column.reference_column; let displayColumn = column.display_column; if (column.input_type === "category") { // 카테고리 타입: reference 정보가 비어있어도 자동 설정 referenceTable = referenceTable || "table_column_category_values"; referenceColumn = referenceColumn || "value_code"; displayColumn = displayColumn || "value_label"; logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, { referenceTable, referenceColumn, displayColumn, }); } logger.info(`🔍 Entity 컬럼 상세 정보:`, { column_name: column.column_name, input_type: column.input_type, reference_table: referenceTable, reference_column: referenceColumn, display_column: displayColumn, }); if (!column.column_name || !referenceTable || !referenceColumn) { logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`); continue; } // 화면별 엔티티 설정이 있으면 우선 사용, 없으면 기본값 사용 const screenConfig = screenEntityConfigs?.[column.column_name]; let displayColumns: string[] = []; let separator = " - "; logger.info(`🔍 조건 확인 - 컬럼: ${column.column_name}`, { hasScreenConfig: !!screenConfig, hasDisplayColumns: screenConfig?.displayColumns, displayColumn: column.display_column, }); if (screenConfig && screenConfig.displayColumns) { // 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원) displayColumns = screenConfig.displayColumns; separator = screenConfig.separator || " - "; console.log(`🎯 화면별 엔티티 설정 적용: ${column.column_name}`, { displayColumns, separator, screenConfig, }); } else if (displayColumn && displayColumn !== "none") { // 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만) displayColumns = [displayColumn]; logger.info( `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}` ); } else { // display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기 logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`); // 참조 테이블의 모든 컬럼 이름 가져오기 const tableColumnsResult = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position`, [referenceTable] ); if (tableColumnsResult.length > 0) { displayColumns = tableColumnsResult.map((col) => col.column_name); logger.info( `✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`, displayColumns.join(", ") ); } else { // 테이블 컬럼을 못 찾으면 기본값 사용 displayColumns = [referenceColumn]; logger.warn( `⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}` ); } } // 별칭 컬럼명 생성 (writer -> writer_name) const aliasColumn = `${column.column_name}_name`; const joinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: column.column_name, referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용 referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용 displayColumns: displayColumns, displayColumn: displayColumns[0], // 하위 호환성 aliasColumn: aliasColumn, separator: separator, }; logger.info(`🔧 기본 조인 설정 생성:`, { sourceTable: joinConfig.sourceTable, sourceColumn: joinConfig.sourceColumn, referenceTable: joinConfig.referenceTable, aliasColumn: joinConfig.aliasColumn, displayColumns: joinConfig.displayColumns, }); // 조인 설정 유효성 검증 logger.info( `🔍 조인 설정 검증 중: ${joinConfig.sourceColumn} -> ${joinConfig.referenceTable}` ); if (await this.validateJoinConfig(joinConfig)) { joinConfigs.push(joinConfig); logger.info(`✅ 조인 설정 추가됨: ${joinConfig.aliasColumn}`); } else { logger.warn(`❌ 조인 설정 검증 실패: ${joinConfig.sourceColumn}`); } } logger.info(`🎯 Entity 조인 설정 생성 완료: ${joinConfigs.length}개`); joinConfigs.forEach((config, index) => { logger.info( ` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable}.${config.referenceColumn} AS ${config.aliasColumn}` ); }); return joinConfigs; } catch (error) { logger.error(`Entity 조인 감지 실패: ${tableName}`, error); return []; } } /** * 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식 */ private formatDateColumn( tableAlias: string, columnName: string, dataType?: string ): string { // date, timestamp 타입이면 TO_CHAR로 변환 if ( dataType && (dataType.includes("date") || dataType.includes("timestamp")) ) { return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`; } // 기본은 TEXT 캐스팅 return `${tableAlias}.${columnName}::TEXT`; } /** * Entity 조인이 포함된 SQL 쿼리 생성 */ buildJoinQuery( tableName: string, joinConfigs: EntityJoinConfig[], selectColumns: string[], whereClause: string = "", orderBy: string = "", limit?: number, offset?: number, columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) // 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해 // jsonb_build_object를 사용하여 명시적으로 변환 let baseColumns: string; if (selectColumns.length === 1 && selectColumns[0] === "*") { // main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환 // PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지 baseColumns = `main.*`; logger.info( `⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요` ); } else { baseColumns = selectColumns .map((col) => { const dataType = columnTypes?.get(col); const formattedCol = this.formatDateColumn("main", col, dataType); return `${formattedCol} AS ${col}`; }) .join(", "); } // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) // 별칭 매핑 생성 (JOIN 절과 동일한 로직) const aliasMap = new Map(); const usedAliasesForColumns = new Set(); // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 // (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( (existingConfig) => existingConfig.referenceTable === config.referenceTable && existingConfig.sourceColumn === config.sourceColumn ) ) { acc.push(config); } return acc; }, [] as EntityJoinConfig[]); logger.info( `🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합` ); uniqueReferenceTableConfigs.forEach((config) => { let baseAlias = config.referenceTable.substring(0, 3); let alias = baseAlias; let counter = 1; while (usedAliasesForColumns.has(alias)) { alias = `${baseAlias}${counter}`; counter++; } usedAliasesForColumns.add(alias); // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); logger.info( `🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}` ); }); const joinColumns = joinConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); const displayColumns = config.displayColumns || [ config.displayColumn, ]; const separator = config.separator || " - "; // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; if (displayColumns.length === 0 || !displayColumns[0]) { // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 // 조인 테이블의 referenceColumn을 기본값으로 사용 resultColumns.push( `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` ); } else if (displayColumns.length === 1) { // 단일 컬럼인 경우 const col = displayColumns[0]; // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; if (isJoinTableColumn) { resultColumns.push( `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` ); // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) // sourceColumn_label 형식으로 추가 resultColumns.push( `COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` ); // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) // 예: customer_code, item_number 등 // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) resultColumns.push( `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` ); } else { resultColumns.push( `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` ); } } else { // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) displayColumns.forEach((col) => { const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; const individualAlias = `${config.sourceColumn}_${col}`; if (isJoinTableColumn) { // 조인 테이블 컬럼은 조인 별칭 사용 resultColumns.push( `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` ); } else { // 기본 테이블 컬럼은 main 별칭 사용 resultColumns.push( `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` ); } }); // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; if ( isJoinTableColumn && !displayColumns.includes(config.referenceColumn) ) { resultColumns.push( `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` ); } } // 모든 resultColumns를 반환 return resultColumns.join(", "); }) .join(", "); // SELECT 절 구성 const selectClause = joinColumns ? `${baseColumns}, ${joinColumns}` : baseColumns; // FROM 절 (메인 테이블) const fromClause = `FROM ${tableName} main`; // LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN) const joinClauses = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); // WHERE 절 const whereSQL = whereClause ? `WHERE ${whereClause}` : ""; // ORDER BY 절 const orderSQL = orderBy ? `ORDER BY ${orderBy}` : ""; // LIMIT 및 OFFSET let limitSQL = ""; if (limit !== undefined) { limitSQL = `LIMIT ${limit}`; if (offset !== undefined) { limitSQL += ` OFFSET ${offset}`; } } // 최종 쿼리 조합 const query = [ `SELECT ${selectClause}`, fromClause, joinClauses, whereSQL, orderSQL, limitSQL, ] .filter(Boolean) .join("\n"); logger.info(`🔍 생성된 Entity 조인 쿼리:`, query); return { query: query, aliasMap: aliasMap, }; } catch (error) { logger.error("Entity 조인 쿼리 생성 실패", error); throw error; } } /** * 조인 전략 결정 (테이블 크기 기반) */ async determineJoinStrategy( joinConfigs: EntityJoinConfig[] ): Promise<"full_join" | "cache_lookup" | "hybrid"> { try { const strategies = await Promise.all( joinConfigs.map(async (config) => { // 여러 컬럼을 조합하는 경우 캐시 전략 사용 불가 if (config.displayColumns && config.displayColumns.length > 1) { console.log( `🎯 여러 컬럼 조합으로 인해 조인 전략 사용: ${config.sourceColumn}`, config.displayColumns ); return "join"; } // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 if (config.referenceTable === "table_column_category_values") { logger.info( `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` ); return "join"; } // 참조 테이블의 캐시 가능성 확인 const displayCol = config.displayColumn || config.displayColumns?.[0] || config.referenceColumn; logger.info( `🔍 캐시 확인용 표시 컬럼: ${config.referenceTable} - ${displayCol}` ); const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, config.referenceColumn, displayCol ); return cachedData ? "cache" : "join"; }) ); // 모두 캐시 가능한 경우 if (strategies.every((s) => s === "cache")) { return "cache_lookup"; } // 혼합인 경우 if (strategies.includes("cache") && strategies.includes("join")) { return "hybrid"; } // 기본은 조인 return "full_join"; } catch (error) { logger.error("조인 전략 결정 실패", error); return "full_join"; // 안전한 기본값 } } /** * 조인 설정 유효성 검증 */ private async validateJoinConfig(config: EntityJoinConfig): Promise { try { logger.info("🔍 조인 설정 검증 상세:", { sourceColumn: config.sourceColumn, referenceTable: config.referenceTable, displayColumns: config.displayColumns, displayColumn: config.displayColumn, aliasColumn: config.aliasColumn, }); // 참조 테이블 존재 확인 const tableExists = await query<{ exists: number }>( `SELECT 1 as exists FROM information_schema.tables WHERE table_name = $1 LIMIT 1`, [config.referenceTable] ); if (tableExists.length === 0) { logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`); return false; } // 참조 컬럼 존재 확인 (displayColumns[0] 사용) const displayColumn = config.displayColumns?.[0] || config.displayColumn; logger.info( `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})` ); // 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용 if (displayColumn && displayColumn !== "none") { const columnExists = await query<{ exists: number }>( `SELECT 1 as exists FROM information_schema.columns WHERE table_name = $1 AND column_name = $2 LIMIT 1`, [config.referenceTable, displayColumn] ); if (columnExists.length === 0) { logger.warn( `표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}` ); return false; } logger.info( `✅ 표시 컬럼 확인 완료: ${config.referenceTable}.${displayColumn}` ); } else { logger.info( `🔧 표시 컬럼 검증 생략: display_column이 none이거나 설정되지 않음` ); } return true; } catch (error) { logger.error("조인 설정 검증 실패", error); return false; } } /** * 카운트 쿼리 생성 (페이징용) */ buildCountQuery( tableName: string, joinConfigs: EntityJoinConfig[], whereClause: string = "" ): string { try { // 별칭 매핑 생성 (buildJoinQuery와 동일한 로직) const aliasMap = new Map(); const usedAliases = new Set(); // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( (existingConfig) => existingConfig.referenceTable === config.referenceTable && existingConfig.sourceColumn === config.sourceColumn ) ) { acc.push(config); } return acc; }, [] as EntityJoinConfig[]); uniqueReferenceTableConfigs.forEach((config) => { let baseAlias = config.referenceTable.substring(0, 3); let alias = baseAlias; let counter = 1; while (usedAliases.has(alias)) { alias = `${baseAlias}${counter}`; counter++; } usedAliases.add(alias); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); }); // JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요) const joinClauses = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; }) .join("\n"); // WHERE 절 const whereSQL = whereClause ? `WHERE ${whereClause}` : ""; // COUNT 쿼리 조합 const query = [ `SELECT COUNT(*) as total`, `FROM ${tableName} main`, joinClauses, whereSQL, ] .filter(Boolean) .join("\n"); return query; } catch (error) { logger.error("COUNT 쿼리 생성 실패", error); throw error; } } /** * 참조 테이블의 컬럼 목록 조회 (UI용) */ async getReferenceTableColumns(tableName: string): Promise< Array<{ columnName: string; displayName: string; dataType: string; }> > { try { // 1. 테이블의 기본 컬럼 정보 조회 const columns = await query<{ column_name: string; data_type: string; }>( `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND data_type IN ('character varying', 'varchar', 'text', 'char') ORDER BY ordinal_position`, [tableName] ); // 2. column_labels 테이블에서 라벨 정보 조회 const columnLabels = await query<{ column_name: string; column_label: string | null; }>( `SELECT column_name, column_label FROM column_labels WHERE table_name = $1`, [tableName] ); // 3. 라벨 정보를 맵으로 변환 const labelMap = new Map(); columnLabels.forEach((label) => { if (label.column_name && label.column_label) { labelMap.set(label.column_name, label.column_label); } }); // 4. 컬럼 정보와 라벨 정보 결합 return columns.map((col) => ({ columnName: col.column_name, displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명 dataType: col.data_type, })); } catch (error) { logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error); return []; } } } export const entityJoinService = new EntityJoinService();