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/unified-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 { // column_labels ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น ์ปฌ๋Ÿผ์˜ input_type์ด 'code'์ธ์ง€ ํ™•์ธ const result = await query( `SELECT input_type, code_category FROM column_labels WHERE table_name = $1 AND column_name = $2 AND input_type = '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 { try { logger.info("ํ…Œ์ด๋ธ” ๋ชฉ๋ก ์กฐํšŒ ์‹œ์ž‘"); // ์บ์‹œ์—์„œ ๋จผ์ € ํ™•์ธ const cachedTables = cache.get(CacheKeys.TABLE_LIST); if (cachedTables) { logger.info(`ํ…Œ์ด๋ธ” ๋ชฉ๋ก ์บ์‹œ์—์„œ ์กฐํšŒ: ${cachedTables.length}๊ฐœ`); return cachedTables; } // information_schema๋Š” ์—ฌ์ „ํžˆ $queryRaw ์‚ฌ์šฉ const rawTables = await query( `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 // ๐Ÿ”ฅ ํšŒ์‚ฌ ์ฝ”๋“œ ์ถ”๊ฐ€ ): Promise<{ columns: ColumnTypeInfo[]; total: number; page: number; size: number; totalPages: number; }> { try { logger.info( `์ปฌ๋Ÿผ ์ •๋ณด ์กฐํšŒ ์‹œ์ž‘: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}` ); // ์บ์‹œ ํ‚ค ์ƒ์„ฑ (companyCode ํฌํ•จ) const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); // ์บ์‹œ์—์„œ ๋จผ์ € ํ™•์ธ 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; } // ์ „์ฒด ์ปฌ๋Ÿผ ์ˆ˜ ์กฐํšŒ (์บ์‹œ ํ™•์ธ) let total = cache.get(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 ๊ฐ€์ ธ์˜ค๊ธฐ const rawColumns = companyCode ? await query( `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(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, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", 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 column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name 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 column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name 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( `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, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", 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 column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name 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( `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 = new Map(); if (mappingTableExists && companyCode) { logger.info("๐Ÿ“ฅ getColumnList: ์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘ ์กฐํšŒ ์‹œ์ž‘", { tableName, companyCode, }); const mappings = await query( `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: mappings, }); mappings.forEach((m: any) => { if (!categoryMappings.has(m.columnName)) { categoryMappings.set(m.columnName, []); } categoryMappings.get(m.columnName)!.push(Number(m.menuObjid)); }); 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์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์„ค์ •ํ•œ ๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ // (์ž๋™ ์ถ”๋ก ์€ column_labels์— ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ 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 { 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 { 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 { try { logger.info( `์ปฌ๋Ÿผ ์„ค์ • ์—…๋ฐ์ดํŠธ ์‹œ์ž‘: ${tableName}.${columnName}, company: ${companyCode}` ); // ํ…Œ์ด๋ธ”์ด table_labels์— ์—†์œผ๋ฉด ์ž๋™ ์ถ”๊ฐ€ await this.insertTableIfNotExists(tableName); // column_labels ์—…๋ฐ์ดํŠธ ๋˜๋Š” ์ƒ์„ฑ await query( `INSERT INTO column_labels ( table_name, column_name, column_label, input_type, detail_settings, code_category, code_value, reference_table, reference_column, display_column, display_order, is_visible, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW()) ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.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 = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = NOW()`, [ tableName, columnName, settings.columnLabel, settings.inputType, settings.detailSettings, settings.codeCategory, settings.codeValue, settings.referenceTable, settings.referenceColumn, settings.displayColumn, settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, ] ); // ๐Ÿ”ฅ table_type_columns๋„ ์—…๋ฐ์ดํŠธ (๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ์ง€์›) if (settings.inputType) { // detailSettings๊ฐ€ ๋ฌธ์ž์—ด์ด๋ฉด ํŒŒ์‹ฑ, ๊ฐ์ฒด๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ let parsedDetailSettings: Record | undefined = undefined; if (settings.detailSettings) { if (typeof settings.detailSettings === "string") { try { parsedDetailSettings = JSON.parse(settings.detailSettings); } catch (e) { logger.warn( `detailSettings ํŒŒ์‹ฑ ์‹คํŒจ, ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ: ${settings.detailSettings}` ); } } else if (typeof settings.detailSettings === "object") { parsedDetailSettings = settings.detailSettings as Record< string, any >; } } await this.updateColumnInputType( tableName, columnName, settings.inputType as string, companyCode, parsedDetailSettings ); } // ์บ์‹œ ๋ฌดํšจํ™” - ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ปฌ๋Ÿผ ์บ์‹œ ์‚ญ์ œ 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 { 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 { 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 { 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 column_labels WHERE table_name = $1 AND column_name = $2`, [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, inputType?: string ): Promise { try { logger.info( `์ปฌ๋Ÿผ ์ž…๋ ฅ ํƒ€์ž… ์„ค์ • ์‹œ์ž‘: ${tableName}.${columnName} = ${webType}` ); // ์›น ํƒ€์ž…๋ณ„ ๊ธฐ๋ณธ ์ƒ์„ธ ์„ค์ • ์ƒ์„ฑ const defaultDetailSettings = this.generateDefaultDetailSettings(webType); // ์‚ฌ์šฉ์ž ์ •์˜ ์„ค์ •๊ณผ ๊ธฐ๋ณธ ์„ค์ • ๋ณ‘ํ•ฉ const finalDetailSettings = { ...defaultDetailSettings, ...detailSettings, }; // column_labels UPSERT๋กœ ์—…๋ฐ์ดํŠธ ๋˜๋Š” ์ƒ์„ฑ (input_type๋งŒ ์‚ฌ์šฉ) await query( `INSERT INTO column_labels ( table_name, column_name, input_type, detail_settings, created_date, updated_date ) VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (table_name, column_name) DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, updated_date = NOW()`, [tableName, columnName, webType, JSON.stringify(finalDetailSettings)] ); logger.info( `์ปฌ๋Ÿผ ์ž…๋ ฅ ํƒ€์ž… ์„ค์ • ์™„๋ฃŒ: ${tableName}.${columnName} = ${webType}` ); } 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 ): Promise { try { logger.info( `์ปฌ๋Ÿผ ์ž…๋ ฅ ํƒ€์ž… ์„ค์ • ์‹œ์ž‘: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` ); // ์ž…๋ ฅ ํƒ€์ž…๋ณ„ ๊ธฐ๋ณธ ์ƒ์„ธ ์„ค์ • ์ƒ์„ฑ const defaultDetailSettings = this.generateDefaultInputTypeSettings(inputType); // ์‚ฌ์šฉ์ž ์ •์˜ ์„ค์ •๊ณผ ๊ธฐ๋ณธ ์„ค์ • ๋ณ‘ํ•ฉ const finalDetailSettings = { ...defaultDetailSettings, ...detailSettings, }; // table_type_columns ํ…Œ์ด๋ธ”์—์„œ ์—…๋ฐ์ดํŠธ (company_code ์ถ”๊ฐ€) 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, updated_date = now()`, [ tableName, columnName, inputType, JSON.stringify(finalDetailSettings), 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"}` ); } } /** * ์ž…๋ ฅ ํƒ€์ž…๋ณ„ ๊ธฐ๋ณธ ์ƒ์„ธ ์„ค์ • ์ƒ์„ฑ */ private generateDefaultInputTypeSettings( inputType: string ): Record { 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 { 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 { 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 { 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 { 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 { try { const fileColumns = await query<{ column_name: string }>( `SELECT column_name FROM column_labels WHERE table_name = $1 AND web_type = 'file'`, [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 { // ๐Ÿ”ง ํŒŒ์ดํ”„๋กœ ๊ตฌ๋ถ„๋œ ๋ฌธ์ž์—ด ์ฒ˜๋ฆฌ (๋‹ค์ค‘์„ ํƒ ๋˜๋Š” ๋‚ ์งœ ๋ฒ”์œ„) 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, }); } // "__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; // ์›นํƒ€์ž…๋ณ„ ๊ฒ€์ƒ‰ ์กฐ๊ฑด ๊ตฌ์„ฑ 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 ); case "entity": return await this.buildEntitySearchCondition( tableName, columnName, actualValue, paramIndex ); default: // ๊ธฐ๋ณธ ๋ฌธ์ž์—ด ๊ฒ€์ƒ‰ (actualValue ์‚ฌ์šฉ) return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], paramCount: 1, }; } } catch (error) { logger.error( `๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ ์กฐ๊ฑด ๊ตฌ์„ฑ ์‹คํŒจ: ${tableName}.${columnName}`, error ); // ์˜ค๋ฅ˜ ์‹œ ๊ธฐ๋ณธ ๊ฒ€์ƒ‰์œผ๋กœ ํด๋ฐฑ let fallbackValue = value; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; } 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 ): Promise<{ whereClause: string; values: any[]; paramCount: number; }> { try { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { // ์ฝ”๋“œ ํƒ€์ž…์ด ์•„๋‹ˆ๋ฉด ๊ธฐ๋ณธ ๊ฒ€์ƒ‰ return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${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 ): Promise<{ whereClause: string; values: any[]; paramCount: number; }> { try { const entityTypeInfo = await this.getEntityTypeInfo( tableName, columnName ); if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // ์—”ํ‹ฐํ‹ฐ ํƒ€์ž…์ด ์•„๋‹ˆ๋ฉด ๊ธฐ๋ณธ ๊ฒ€์ƒ‰ return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${value}%`], paramCount: 1, }; } if (typeof value === "string" && value.trim() !== "") { const displayColumn = entityTypeInfo.displayColumn || "name"; const referenceColumn = entityTypeInfo.referenceColumn || "id"; // ์ฐธ์กฐ ํ…Œ์ด๋ธ”์˜ ํ‘œ์‹œ ์ปฌ๋Ÿผ์œผ๋กœ ๊ฒ€์ƒ‰ return { whereClause: `EXISTS ( SELECT 1 FROM ${entityTypeInfo.referenceTable} ref WHERE ref.${referenceColumn} = ${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, }; } } /** * ๋ถˆ๋ฆฐ ๊ฒ€์ƒ‰ ์กฐ๊ฑด ๊ตฌ์„ฑ */ 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<{ web_type: string | null; input_type: string | null; code_category: string | null; reference_table: string | null; reference_column: string | null; display_column: string | null; }>( `SELECT web_type, input_type, code_category, reference_table, reference_column, display_column FROM column_labels WHERE table_name = $1 AND column_name = $2 LIMIT 1`, [tableName, columnName] ); logger.info(`๐Ÿ” [getColumnWebTypeInfo] ${tableName}.${columnName} ์กฐํšŒ ๊ฒฐ๊ณผ:`, { found: !!result, web_type: result?.web_type, input_type: result?.input_type, }); if (!result) { logger.warn(`โš ๏ธ [getColumnWebTypeInfo] ์ปฌ๋Ÿผ ์ •๋ณด ์—†์Œ: ${tableName}.${columnName}`); return null; } // web_type์ด ์—†์œผ๋ฉด input_type์„ ์‚ฌ์šฉ (๋ ˆ๊ฑฐ์‹œ ํ˜ธํ™˜) const webType = result.web_type || result.input_type || ""; const columnInfo = { webType: webType, 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; 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; } // ์•ˆ์ „ํ•œ ์ปฌ๋Ÿผ๋ช… ๊ฒ€์ฆ (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 ")}` : ""; // 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}`; } // ์•ˆ์ „ํ•œ ํ…Œ์ด๋ธ”๋ช… ๊ฒ€์ฆ const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; const countResult = await query(countQuery, searchValues); const total = parseInt(countResult[0].count); // ๋ฐ์ดํ„ฐ ์กฐํšŒ const dataQuery = ` SELECT * FROM ${safeTableName} ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; logger.info(`๐Ÿ” ์‹คํ–‰ํ•  SQL: ${dataQuery}`); logger.info( `๐Ÿ” ํŒŒ๋ผ๋ฏธํ„ฐ: ${JSON.stringify([...searchValues, size, offset])}` ); let data = await query(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; } /** * ํ…Œ์ด๋ธ”์— ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ */ async addTableData( tableName: string, data: Record ): Promise { 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(); columnInfoResult.forEach((col: any) => { columnTypeMap.set(col.column_name, col.data_type); }); logger.info(`์ปฌ๋Ÿผ ํƒ€์ž… ์ •๋ณด:`, Object.fromEntries(columnTypeMap)); // ์ปฌ๋Ÿผ๋ช…๊ณผ ๊ฐ’์„ ๋ถ„๋ฆฌํ•˜๊ณ  ํƒ€์ž…์— ๋งž๊ฒŒ ๋ณ€ํ™˜ const columns = Object.keys(data); const values = Object.values(data).map((value, index) => { const columnName = columns[index]; 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}) `; logger.info(`์‹คํ–‰ํ•  ์ฟผ๋ฆฌ: ${insertQuery}`); logger.info(`์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ:`, values); await query(insertQuery, values); logger.info(`ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ ์™„๋ฃŒ: ${tableName}`); } catch (error) { logger.error(`ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ ์˜ค๋ฅ˜: ${tableName}`, error); throw error; } } /** * ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ์ˆ˜์ • */ async editTableData( tableName: string, originalData: Record, updatedData: Record ): Promise { 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(); 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); // SET ์ ˆ ์ƒ์„ฑ (์ˆ˜์ •ํ•  ๋ฐ์ดํ„ฐ) - ๋จผ์ € ์ƒ์„ฑ const setConditions: string[] = []; const setValues: any[] = []; let paramIndex = 1; Object.keys(updatedData).forEach((column) => { const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` ); setValues.push( this.convertValueForPostgreSQL(updatedData[column], dataType) ); paramIndex++; }); // 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[] ): Promise { 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; sortBy?: string; sortOrder?: string; enableEntityJoin?: boolean; companyCode?: string; // ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ํ•„ํ„ฐ์šฉ additionalJoinColumns?: Array<{ sourceTable: string; sourceColumn: string; joinAlias: string; }>; screenEntityConfigs?: Record; // ํ™”๋ฉด๋ณ„ ์—”ํ‹ฐํ‹ฐ ์„ค์ • dataFilter?: any; // ๐Ÿ†• ๋ฐ์ดํ„ฐ ํ•„ํ„ฐ } ): Promise { 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 ); 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) { // ๐Ÿ” sourceColumn์„ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ์กด ์กฐ์ธ ์„ค์ • ์ฐพ๊ธฐ (dept_code๋กœ ์ฐพ๊ธฐ) const baseJoinConfig = joinConfigs.find( (config) => config.sourceColumn === additionalColumn.sourceColumn ); if (baseJoinConfig) { // joinAlias์—์„œ ์‹ค์ œ ์ปฌ๋Ÿผ๋ช… ์ถ”์ถœ (์˜ˆ: dept_code_location_name -> location_name) // sourceColumn์„ ์ œ๊ฑฐํ•œ ๋‚˜๋จธ์ง€ ๋ถ€๋ถ„์ด ์‹ค์ œ ์ปฌ๋Ÿผ๋ช… const sourceColumn = baseJoinConfig.sourceColumn; // dept_code const joinAlias = additionalColumn.joinAlias; // dept_code_company_name const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name logger.info(`๐Ÿ” ์กฐ์ธ ์ปฌ๋Ÿผ ์ƒ์„ธ ๋ถ„์„:`, { sourceColumn, joinAlias, actualColumnName, referenceTable: additionalColumn.sourceTable, }); // ๐Ÿšจ ๊ธฐ๋ณธ Entity ์กฐ์ธ๊ณผ ์ค‘๋ณต๋˜์ง€ ์•Š๋„๋ก ์ฒดํฌ const isBasicEntityJoin = additionalColumn.joinAlias === `${baseJoinConfig.sourceColumn}_name`; if (isBasicEntityJoin) { logger.info( `โš ๏ธ ๊ธฐ๋ณธ Entity ์กฐ์ธ๊ณผ ์ค‘๋ณต: ${additionalColumn.joinAlias} - ๊ฑด๋„ˆ๋œ€` ); continue; // ๊ธฐ๋ณธ Entity ์กฐ์ธ๊ณผ ์ค‘๋ณต๋˜๋ฉด ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Œ } // ์ถ”๊ฐ€ ์กฐ์ธ ์ปฌ๋Ÿผ ์„ค์ • ์ƒ์„ฑ const additionalJoinConfig: EntityJoinConfig = { sourceTable: tableName, sourceColumn: baseJoinConfig.sourceColumn, // ์›๋ณธ ์ปฌ๋Ÿผ (dept_code) referenceTable: (additionalColumn as any).referenceTable || baseJoinConfig.referenceTable, // ์ฐธ์กฐ ํ…Œ์ด๋ธ” (dept_info) referenceColumn: baseJoinConfig.referenceColumn, // ์ฐธ์กฐ ํ‚ค (dept_code) displayColumns: [actualColumnName], // ํ‘œ์‹œํ•  ์ปฌ๋Ÿผ๋“ค (company_name) displayColumn: actualColumnName, // ํ•˜์œ„ ํ˜ธํ™˜์„ฑ aliasColumn: additionalColumn.joinAlias, // ๋ณ„์นญ (dept_code_company_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}`); } } // ORDER BY ์ ˆ ๊ตฌ์„ฑ const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : ""; // ํŽ˜์ด์ง• ๊ณ„์‚ฐ 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 { try { // ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ฟผ๋ฆฌ const dataQuery = entityJoinService.buildJoinQuery( tableName, joinConfigs, selectColumns, whereClause, orderBy, limit, offset ).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; sortBy?: string; sortOrder?: string; companyCode?: string; }, startTime: number ): Promise { 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 ์กฐ์ธ ์ปฌ๋Ÿผ ๊ฒ€์ƒ‰์ด ์žˆ๋Š”์ง€ ํ™•์ธ (๊ธฐ๋ณธ ์กฐ์ธ + ์ถ”๊ฐ€ ์กฐ์ธ ์ปฌ๋Ÿผ ๋ชจ๋‘ ํฌํ•จ) const allEntityColumns = [ ...joinConfigs.map((config) => config.aliasColumn), // ์ถ”๊ฐ€ ์กฐ์ธ ์ปฌ๋Ÿผ๋“ค๋„ ํฌํ•จ (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; }), ]; const hasEntitySearch = options.search && Object.keys(options.search).some((key) => allEntityColumns.includes(key) ); if (hasEntitySearch) { const entitySearchKeys = options.search ? Object.keys(options.search).filter((key) => allEntityColumns.includes(key) ) : []; 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 joinQueryResult = entityJoinService.buildJoinQuery( tableName, joinConfigs, selectColumns, "", // WHERE ์ ˆ์€ ๋‚˜์ค‘์— ์ถ”๊ฐ€ options.sortBy ? `main.${options.sortBy} ${options.sortOrder || "ASC"}` : 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)) { const joinConfig = joinConfigs.find( (config) => config.aliasColumn === key ); if (joinConfig) { // ๊ธฐ๋ณธ Entity ์กฐ์ธ ์ปฌ๋Ÿผ์ธ ๊ฒฝ์šฐ: ์กฐ์ธ๋œ ํ…Œ์ด๋ธ”์˜ ํ‘œ์‹œ ์ปฌ๋Ÿผ์—์„œ ๊ฒ€์ƒ‰ const alias = aliasMap.get(joinConfig.referenceTable); whereConditions.push( `${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'` ); entitySearchColumns.push( `${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})` ); logger.info( `๐ŸŽฏ Entity ์กฐ์ธ ๊ฒ€์ƒ‰: ${key} โ†’ ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (๋ณ„์นญ: ${alias})` ); } else if (key === "writer_dept_code") { // writer_dept_code: user_info.dept_code์—์„œ ๊ฒ€์ƒ‰ const userAlias = aliasMap.get("user_info"); whereConditions.push( `${userAlias}.dept_code ILIKE '%${value}%'` ); entitySearchColumns.push(`${key} (user_info.dept_code)`); logger.info( `๐ŸŽฏ ์ถ”๊ฐ€ Entity ์กฐ์ธ ๊ฒ€์ƒ‰: ${key} โ†’ user_info.dept_code LIKE '%${value}%' (๋ณ„์นญ: ${userAlias})` ); } else if (key === "company_code_status") { // company_code_status: company_info.status์—์„œ ๊ฒ€์ƒ‰ const companyAlias = aliasMap.get("company_info"); whereConditions.push( `${companyAlias}.status ILIKE '%${value}%'` ); entitySearchColumns.push(`${key} (company_info.status)`); logger.info( `๐ŸŽฏ ์ถ”๊ฐ€ Entity ์กฐ์ธ ๊ฒ€์ƒ‰: ${key} โ†’ company_info.status LIKE '%${value}%' (๋ณ„์นญ: ${companyAlias})` ); } else { // ์ผ๋ฐ˜ ์ปฌ๋Ÿผ์ธ ๊ฒฝ์šฐ: ๋ฉ”์ธ ํ…Œ์ด๋ธ”์—์„œ ๊ฒ€์ƒ‰ whereConditions.push(`main.${key} ILIKE '%${value}%'`); logger.info( `๐Ÿ” ์ผ๋ฐ˜ ์ปฌ๋Ÿผ ๊ฒ€์ƒ‰: ${key} โ†’ main.${key} LIKE '%${value}%'` ); } } } const whereClause = whereConditions.join(" AND "); const orderBy = options.sortBy ? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : ""; // ํŽ˜์ด์ง• ๊ณ„์‚ฐ 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 = {}; // 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 ): Promise { 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 { // ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ ์กฐ๊ฑด ๊ตฌ์„ฑ 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. ์ ‘๋‘์‚ฌ ์ถ”๊ฐ€ (์กฐ์ธ ์ฟผ๋ฆฌ์šฉ) 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): Promise< Array<{ columnName: string; displayName: string; dataType: string; }> > { return await entityJoinService.getReferenceTableColumns(tableName); } /** * ์ปฌ๋Ÿผ ๋ผ๋ฒจ ์ •๋ณด ์—…๋ฐ์ดํŠธ (display_column ์ถ”๊ฐ€) */ async updateColumnLabel( tableName: string, columnName: string, updates: Partial ): Promise { try { logger.info(`์ปฌ๋Ÿผ ๋ผ๋ฒจ ์—…๋ฐ์ดํŠธ: ${tableName}.${columnName}`); await query( `INSERT INTO column_labels ( table_name, column_name, column_label, web_type, detail_settings, description, display_order, is_visible, code_category, code_value, reference_table, reference_column, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW()) ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = EXCLUDED.column_label, web_type = EXCLUDED.web_type, detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, code_category = EXCLUDED.code_category, code_value = EXCLUDED.code_value, reference_table = EXCLUDED.reference_table, reference_column = EXCLUDED.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 { 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[] = []; 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; } // ์บ์‹œ ๊ฐ€๋Šฅ์„ฑ ํ™•์ธ 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 { // 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 { 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 { 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 { try { logger.info(`ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ์ •๋ณด ์กฐํšŒ: ${tableName}`); const rawColumns = await query( `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 { try { logger.info(`ํ…Œ์ด๋ธ” ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ: ${tableName}`); const result = await query( `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 { try { logger.info( `์ปฌ๋Ÿผ ์ž…๋ ฅํƒ€์ž… ์ •๋ณด ์กฐํšŒ: ${tableName}, company: ${companyCode}` ); // table_type_columns์—์„œ ์ž…๋ ฅํƒ€์ž… ์ •๋ณด ์กฐํšŒ (company_code ํ•„ํ„ฐ๋ง) const rawInputTypes = await query( `SELECT ttc.column_name as "columnName", COALESCE(cl.column_label, ttc.column_name) as "displayName", ttc.input_type as "inputType", COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings", ttc.is_nullable as "isNullable", ic.data_type as "dataType", ttc.company_code as "companyCode" FROM table_type_columns ttc LEFT JOIN column_labels cl ON ttc.table_name = cl.table_name AND ttc.column_name = cl.column_name 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 = $2 ORDER BY ttc.display_order, ttc.column_name`, [tableName, companyCode] ); // category_column_mapping ํ…Œ์ด๋ธ” ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ const tableExistsResult = await query( `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 = new Map(); if (mappingTableExists) { logger.info("์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘ ์กฐํšŒ ์‹œ์ž‘", { tableName, companyCode }); const mappings = await query( `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("์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘ ์กฐํšŒ ์™„๋ฃŒ", { tableName, companyCode, mappingCount: mappings.length, mappings: mappings, }); mappings.forEach((m: any) => { if (!categoryMappings.has(m.columnName)) { categoryMappings.set(m.columnName, []); } categoryMappings.get(m.columnName)!.push(Number(m.menuObjid)); }); 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 = { 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", // ๐Ÿ”ฅ FIX: string ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ isPrimaryKey: false, displayOrder: 0, isVisible: true, }; // ์นดํ…Œ๊ณ ๋ฆฌ ํƒ€์ž…์ธ ๊ฒฝ์šฐ 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 { 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(`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/unified-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 { 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(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(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 { 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; } } }