import { PrismaClient } from "@prisma/client"; import { logger } from "../utils/logger"; import { EntityJoinConfig, BatchLookupRequest, BatchLookupResponse, } from "../types/tableManagement"; import { referenceCacheService } from "./referenceCacheService"; const prisma = new PrismaClient(); /** * Entity 조인 기능을 제공하는 서비스 * ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템 */ export class EntityJoinService { /** * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 */ async detectEntityJoins(tableName: string): Promise { try { logger.info(`Entity 컬럼 감지 시작: ${tableName}`); // column_labels에서 entity 타입인 컬럼들 조회 const entityColumns = await prisma.column_labels.findMany({ where: { table_name: tableName, web_type: "entity", reference_table: { not: null }, reference_column: { not: null }, }, select: { column_name: true, reference_table: true, reference_column: true, display_column: true, }, }); const joinConfigs: EntityJoinConfig[] = []; for (const column of entityColumns) { if ( !column.column_name || !column.reference_table || !column.reference_column ) { continue; } // display_column이 없으면 reference_column 사용 const displayColumn = column.display_column || column.reference_column; // 별칭 컬럼명 생성 (writer -> writer_name) const aliasColumn = `${column.column_name}_name`; const joinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: column.column_name, referenceTable: column.reference_table, referenceColumn: column.reference_column, displayColumn: displayColumn, aliasColumn: aliasColumn, }; // 조인 설정 유효성 검증 if (await this.validateJoinConfig(joinConfig)) { joinConfigs.push(joinConfig); } } logger.info(`Entity 조인 설정 생성 완료: ${joinConfigs.length}개`); return joinConfigs; } catch (error) { logger.error(`Entity 조인 감지 실패: ${tableName}`, error); return []; } } /** * Entity 조인이 포함된 SQL 쿼리 생성 */ buildJoinQuery( tableName: string, joinConfigs: EntityJoinConfig[], selectColumns: string[], whereClause: string = "", orderBy: string = "", limit?: number, offset?: number ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 const baseColumns = selectColumns.map((col) => `main.${col}`).join(", "); // Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리) // 별칭 매핑 생성 (JOIN 절과 동일한 로직) const aliasMap = new Map(); const usedAliasesForColumns = new Set(); // joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성 const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( (existingConfig) => existingConfig.referenceTable === config.referenceTable ) ) { 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); aliasMap.set(config.referenceTable, alias); logger.info(`🔧 별칭 생성: ${config.referenceTable} → ${alias}`); }); const joinColumns = joinConfigs .map( (config) => `COALESCE(${aliasMap.get(config.referenceTable)}.${config.displayColumn}, '') AS ${config.aliasColumn}` ) .join(", "); // SELECT 절 구성 const selectClause = joinColumns ? `${baseColumns}, ${joinColumns}` : baseColumns; // FROM 절 (메인 테이블) const fromClause = `FROM ${tableName} main`; // LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거) const joinClauses = uniqueReferenceTableConfigs .map((config) => { const alias = aliasMap.get(config.referenceTable); 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.debug(`생성된 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) => { // 참조 테이블의 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, config.referenceColumn, config.displayColumn ); 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 { // 참조 테이블 존재 확인 const tableExists = await prisma.$queryRaw` SELECT 1 FROM information_schema.tables WHERE table_name = ${config.referenceTable} LIMIT 1 `; if (!Array.isArray(tableExists) || tableExists.length === 0) { logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`); return false; } // 참조 컬럼 존재 확인 const columnExists = await prisma.$queryRaw` SELECT 1 FROM information_schema.columns WHERE table_name = ${config.referenceTable} AND column_name = ${config.displayColumn} LIMIT 1 `; if (!Array.isArray(columnExists) || columnExists.length === 0) { logger.warn( `표시 컬럼이 존재하지 않음: ${config.referenceTable}.${config.displayColumn}` ); return false; } 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 ) ) { 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); aliasMap.set(config.referenceTable, alias); }); // JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요) const joinClauses = uniqueReferenceTableConfigs .map((config) => { const alias = aliasMap.get(config.referenceTable); 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 prisma.$queryRaw` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ${tableName} AND data_type IN ('character varying', 'varchar', 'text', 'char') ORDER BY ordinal_position `) as Array<{ column_name: string; data_type: string; }>; // 2. column_labels 테이블에서 라벨 정보 조회 const columnLabels = await prisma.column_labels.findMany({ where: { table_name: tableName }, select: { column_name: true, column_label: true, }, }); // 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();