2025-09-01 11:00:38 +09:00
|
|
|
import { PrismaClient } from "@prisma/client";
|
2025-08-25 14:08:08 +09:00
|
|
|
import { logger } from "../utils/logger";
|
2025-09-08 14:20:01 +09:00
|
|
|
import { cache, CacheKeys } from "../utils/cache";
|
2025-08-25 14:08:08 +09:00
|
|
|
import {
|
|
|
|
|
TableInfo,
|
|
|
|
|
ColumnTypeInfo,
|
|
|
|
|
ColumnSettings,
|
|
|
|
|
TableLabels,
|
|
|
|
|
ColumnLabels,
|
2025-09-16 15:13:00 +09:00
|
|
|
EntityJoinResponse,
|
|
|
|
|
EntityJoinConfig,
|
2025-08-25 14:08:08 +09:00
|
|
|
} from "../types/tableManagement";
|
2025-09-16 15:13:00 +09:00
|
|
|
import { entityJoinService } from "./entityJoinService";
|
|
|
|
|
import { referenceCacheService } from "./referenceCacheService";
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
const prisma = new PrismaClient();
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
export class TableManagementService {
|
|
|
|
|
constructor() {}
|
2025-08-25 14:08:08 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
|
2025-09-01 11:00:38 +09:00
|
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getTableList(): Promise<TableInfo[]> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info("테이블 목록 조회 시작");
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
// 캐시에서 먼저 확인
|
|
|
|
|
const cachedTables = cache.get<TableInfo[]>(CacheKeys.TABLE_LIST);
|
|
|
|
|
if (cachedTables) {
|
|
|
|
|
logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}개`);
|
|
|
|
|
return cachedTables;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
// information_schema는 여전히 $queryRaw 사용
|
2025-09-01 15:37:49 +09:00
|
|
|
const rawTables = await prisma.$queryRaw<any[]>`
|
2025-08-25 14:08:08 +09:00
|
|
|
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
|
|
|
|
|
`;
|
|
|
|
|
|
2025-09-01 15:37:49 +09:00
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
|
|
|
const tables: TableInfo[] = rawTables.map((table) => ({
|
|
|
|
|
...table,
|
|
|
|
|
columnCount: Number(table.columnCount), // BigInt → Number 변환
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
// 캐시에 저장 (10분 TTL)
|
|
|
|
|
cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
logger.info(`테이블 목록 조회 완료: ${tables.length}개`);
|
|
|
|
|
return tables;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("테이블 목록 조회 중 오류 발생:", error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-08 14:20:01 +09:00
|
|
|
* 테이블 컬럼 정보 조회 (페이지네이션 지원)
|
2025-09-01 11:00:38 +09:00
|
|
|
* 메타데이터 조회는 Prisma로 변경 불가
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
2025-09-08 14:20:01 +09:00
|
|
|
async getColumnList(
|
|
|
|
|
tableName: string,
|
|
|
|
|
page: number = 1,
|
|
|
|
|
size: number = 50
|
|
|
|
|
): Promise<{
|
|
|
|
|
columns: ColumnTypeInfo[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}> {
|
2025-08-25 14:08:08 +09:00
|
|
|
try {
|
2025-09-08 14:20:01 +09:00
|
|
|
logger.info(
|
|
|
|
|
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size})`
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
// 캐시 키 생성
|
|
|
|
|
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size);
|
|
|
|
|
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}개`
|
|
|
|
|
);
|
|
|
|
|
return cachedResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 전체 컬럼 수 조회 (캐시 확인)
|
|
|
|
|
let total = cache.get<number>(countCacheKey);
|
|
|
|
|
if (!total) {
|
|
|
|
|
const totalResult = await prisma.$queryRaw<[{ count: bigint }]>`
|
|
|
|
|
SELECT COUNT(*) as count
|
|
|
|
|
FROM information_schema.columns c
|
|
|
|
|
WHERE c.table_name = ${tableName}
|
|
|
|
|
`;
|
|
|
|
|
total = Number(totalResult[0].count);
|
|
|
|
|
// 컬럼 수는 자주 변하지 않으므로 30분 캐시
|
|
|
|
|
cache.set(countCacheKey, total, 30 * 60 * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 페이지네이션 적용한 컬럼 조회
|
|
|
|
|
const offset = (page - 1) * size;
|
2025-09-01 15:37:49 +09:00
|
|
|
const rawColumns = await prisma.$queryRaw<any[]>`
|
2025-08-25 14:08:08 +09:00
|
|
|
SELECT
|
|
|
|
|
c.column_name as "columnName",
|
|
|
|
|
COALESCE(cl.column_label, c.column_name) as "displayName",
|
2025-09-05 18:00:18 +09:00
|
|
|
c.data_type as "dataType",
|
2025-08-25 14:08:08 +09:00
|
|
|
c.data_type as "dbType",
|
|
|
|
|
COALESCE(cl.web_type, 'text') as "webType",
|
2025-09-04 14:23:35 +09:00
|
|
|
COALESCE(cl.input_type, 'direct') as "inputType",
|
2025-08-25 14:08:08 +09:00
|
|
|
COALESCE(cl.detail_settings, '') as "detailSettings",
|
|
|
|
|
COALESCE(cl.description, '') as "description",
|
|
|
|
|
c.is_nullable as "isNullable",
|
2025-09-05 18:00:18 +09:00
|
|
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
2025-08-25 14:08:08 +09:00
|
|
|
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",
|
2025-09-16 15:13:00 +09:00
|
|
|
cl.display_column as "displayColumn",
|
2025-08-25 14:08:08 +09:00
|
|
|
cl.display_order as "displayOrder",
|
2025-09-17 10:35:36 +09:00
|
|
|
cl.is_visible as "isVisible",
|
|
|
|
|
-- Entity 조인 컬럼의 표시 컬럼 라벨 조회
|
|
|
|
|
dcl.column_label as "displayColumnLabel"
|
2025-08-25 14:08:08 +09:00
|
|
|
FROM information_schema.columns c
|
|
|
|
|
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
|
2025-09-17 10:35:36 +09:00
|
|
|
-- Entity 조인의 display_column에 대한 라벨 정보 조회
|
|
|
|
|
LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
|
2025-09-05 18:00:18 +09:00
|
|
|
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 = ${tableName}
|
|
|
|
|
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
|
2025-09-01 11:00:38 +09:00
|
|
|
WHERE c.table_name = ${tableName}
|
2025-08-25 14:08:08 +09:00
|
|
|
ORDER BY c.ordinal_position
|
2025-09-08 14:20:01 +09:00
|
|
|
LIMIT ${size} OFFSET ${offset}
|
2025-08-25 14:08:08 +09:00
|
|
|
`;
|
|
|
|
|
|
2025-09-01 15:37:49 +09:00
|
|
|
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
|
|
|
|
|
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
|
|
|
|
|
...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,
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
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;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블이 table_labels에 없으면 자동 추가
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async insertTableIfNotExists(tableName: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 자동 추가 시작: ${tableName}`);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
await prisma.table_labels.upsert({
|
|
|
|
|
where: { table_name: tableName },
|
|
|
|
|
update: {}, // 이미 존재하면 변경하지 않음
|
|
|
|
|
create: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
table_label: tableName,
|
|
|
|
|
description: "",
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-08-25 14:08:08 +09:00
|
|
|
|
|
|
|
|
logger.info(`테이블 라벨 자동 추가 완료: ${tableName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 라벨 자동 추가 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 자동 추가 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 14:20:01 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 라벨 업데이트
|
|
|
|
|
*/
|
|
|
|
|
async updateTableLabel(
|
|
|
|
|
tableName: string,
|
|
|
|
|
displayName: string,
|
|
|
|
|
description?: string
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
|
|
|
|
|
|
|
|
|
|
// table_labels 테이블에 UPSERT
|
|
|
|
|
await prisma.$executeRaw`
|
|
|
|
|
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
|
|
|
|
VALUES (${tableName}, ${displayName}, ${description || ""}, NOW(), NOW())
|
|
|
|
|
ON CONFLICT (table_name)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
table_label = EXCLUDED.table_label,
|
|
|
|
|
description = EXCLUDED.description,
|
|
|
|
|
updated_date = NOW()
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// 캐시 무효화
|
|
|
|
|
cache.delete(CacheKeys.TABLE_LIST);
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 라벨 업데이트 완료: ${tableName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("테이블 라벨 업데이트 중 오류 발생:", error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
/**
|
|
|
|
|
* 컬럼 설정 업데이트 (UPSERT 방식)
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async updateColumnSettings(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
settings: ColumnSettings
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}`);
|
|
|
|
|
|
|
|
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
|
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
// column_labels 업데이트 또는 생성
|
|
|
|
|
await prisma.column_labels.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
table_name_column_name: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
column_label: settings.columnLabel,
|
|
|
|
|
web_type: settings.webType,
|
|
|
|
|
detail_settings: settings.detailSettings,
|
|
|
|
|
code_category: settings.codeCategory,
|
|
|
|
|
code_value: settings.codeValue,
|
|
|
|
|
reference_table: settings.referenceTable,
|
|
|
|
|
reference_column: settings.referenceColumn,
|
2025-09-16 15:13:00 +09:00
|
|
|
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명
|
2025-09-01 11:00:38 +09:00
|
|
|
display_order: settings.displayOrder || 0,
|
|
|
|
|
is_visible:
|
|
|
|
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
column_label: settings.columnLabel,
|
|
|
|
|
web_type: settings.webType,
|
|
|
|
|
detail_settings: settings.detailSettings,
|
|
|
|
|
code_category: settings.codeCategory,
|
|
|
|
|
code_value: settings.codeValue,
|
|
|
|
|
reference_table: settings.referenceTable,
|
|
|
|
|
reference_column: settings.referenceColumn,
|
2025-09-16 15:13:00 +09:00
|
|
|
display_column: settings.displayColumn, // 🎯 Entity 조인에서 표시할 컬럼명
|
2025-09-01 11:00:38 +09:00
|
|
|
display_order: settings.displayOrder || 0,
|
|
|
|
|
is_visible:
|
|
|
|
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
|
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
|
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 설정 업데이트 중 오류 발생: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 설정 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 컬럼 설정 일괄 업데이트
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma 트랜잭션으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async updateAllColumnSettings(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnSettings: ColumnSettings[]
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
|
|
|
|
`전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, ${columnSettings.length}개`
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
// Prisma 트랜잭션 사용
|
|
|
|
|
await prisma.$transaction(async (tx) => {
|
2025-08-25 14:08:08 +09:00
|
|
|
// 테이블이 table_labels에 없으면 자동 추가
|
|
|
|
|
await this.insertTableIfNotExists(tableName);
|
|
|
|
|
|
|
|
|
|
// 각 컬럼 설정을 순차적으로 업데이트
|
|
|
|
|
for (const columnSetting of columnSettings) {
|
2025-09-01 11:48:12 +09:00
|
|
|
// columnName은 실제 DB 컬럼명을 유지해야 함
|
|
|
|
|
const columnName = columnSetting.columnName;
|
2025-08-25 14:08:08 +09:00
|
|
|
if (columnName) {
|
|
|
|
|
await this.updateColumnSettings(
|
|
|
|
|
tableName,
|
|
|
|
|
columnName,
|
|
|
|
|
columnSetting
|
|
|
|
|
);
|
2025-09-01 11:48:12 +09:00
|
|
|
} else {
|
|
|
|
|
logger.warn(
|
|
|
|
|
`컬럼명이 누락된 설정: ${JSON.stringify(columnSetting)}`
|
|
|
|
|
);
|
2025-08-25 14:08:08 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-09-01 11:00:38 +09:00
|
|
|
});
|
2025-08-25 14:08:08 +09:00
|
|
|
|
2025-09-15 15:38:48 +09:00
|
|
|
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
|
|
|
|
|
cache.deleteByPattern(`table_columns:${tableName}:`);
|
|
|
|
|
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`전체 컬럼 설정 일괄 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 라벨 정보 조회
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getTableLabels(tableName: string): Promise<TableLabels | null> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 라벨 정보 조회 시작: ${tableName}`);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
const tableLabel = await prisma.table_labels.findUnique({
|
|
|
|
|
where: { table_name: tableName },
|
|
|
|
|
select: {
|
|
|
|
|
table_name: true,
|
|
|
|
|
table_label: true,
|
|
|
|
|
description: true,
|
|
|
|
|
created_date: true,
|
|
|
|
|
updated_date: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!tableLabel) {
|
2025-08-25 14:08:08 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`);
|
2025-09-01 11:00:38 +09:00
|
|
|
return result;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 라벨 정보 조회 중 오류 발생: ${tableName}`, error);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`테이블 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼 라벨 정보 조회
|
2025-09-01 11:00:38 +09:00
|
|
|
* Prisma ORM으로 변경
|
2025-08-25 14:08:08 +09:00
|
|
|
*/
|
|
|
|
|
async getColumnLabels(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<ColumnLabels | null> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`컬럼 라벨 정보 조회 시작: ${tableName}.${columnName}`);
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
const columnLabel = await prisma.column_labels.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
table_name_column_name: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
table_name: true,
|
|
|
|
|
column_name: true,
|
|
|
|
|
column_label: true,
|
|
|
|
|
web_type: true,
|
|
|
|
|
detail_settings: true,
|
|
|
|
|
description: true,
|
|
|
|
|
display_order: true,
|
|
|
|
|
is_visible: true,
|
|
|
|
|
code_category: true,
|
|
|
|
|
code_value: true,
|
|
|
|
|
reference_table: true,
|
|
|
|
|
reference_column: true,
|
|
|
|
|
created_date: true,
|
|
|
|
|
updated_date: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!columnLabel) {
|
2025-08-25 14:08:08 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 11:00:38 +09:00
|
|
|
const result: ColumnLabels = {
|
|
|
|
|
id: columnLabel.id,
|
|
|
|
|
tableName: columnLabel.table_name || "",
|
|
|
|
|
columnName: columnLabel.column_name || "",
|
|
|
|
|
columnLabel: columnLabel.column_label || undefined,
|
|
|
|
|
webType: columnLabel.web_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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-25 14:08:08 +09:00
|
|
|
logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`);
|
2025-09-01 11:00:38 +09:00
|
|
|
return result;
|
2025-08-25 14:08:08 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 라벨 정보 조회 중 오류 발생: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 라벨 정보 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼 웹 타입 설정
|
|
|
|
|
*/
|
|
|
|
|
async updateColumnWebType(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string,
|
|
|
|
|
webType: string,
|
2025-09-04 14:23:35 +09:00
|
|
|
detailSettings?: Record<string, any>,
|
|
|
|
|
inputType?: string
|
2025-09-01 11:48:12 +09:00
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
|
|
|
|
`컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 웹 타입별 기본 상세 설정 생성
|
|
|
|
|
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
|
|
|
|
|
|
|
|
|
|
// 사용자 정의 설정과 기본 설정 병합
|
|
|
|
|
const finalDetailSettings = {
|
|
|
|
|
...defaultDetailSettings,
|
|
|
|
|
...detailSettings,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// column_labels 테이블에 해당 컬럼이 있는지 확인
|
2025-09-01 15:22:47 +09:00
|
|
|
const existingColumn = await prisma.column_labels.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-09-01 11:48:12 +09:00
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
if (existingColumn) {
|
2025-09-01 11:48:12 +09:00
|
|
|
// 기존 컬럼 라벨 업데이트
|
2025-09-04 14:23:35 +09:00
|
|
|
const updateData: any = {
|
|
|
|
|
web_type: webType,
|
|
|
|
|
detail_settings: JSON.stringify(finalDetailSettings),
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (inputType) {
|
|
|
|
|
updateData.input_type = inputType;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
await prisma.column_labels.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: existingColumn.id,
|
|
|
|
|
},
|
2025-09-04 14:23:35 +09:00
|
|
|
data: updateData,
|
2025-09-01 15:22:47 +09:00
|
|
|
});
|
2025-09-01 11:48:12 +09:00
|
|
|
logger.info(
|
|
|
|
|
`컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// 새로운 컬럼 라벨 생성
|
2025-09-04 14:23:35 +09:00
|
|
|
const createData: any = {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
web_type: webType,
|
|
|
|
|
detail_settings: JSON.stringify(finalDetailSettings),
|
|
|
|
|
created_date: new Date(),
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (inputType) {
|
|
|
|
|
createData.input_type = inputType;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-01 15:22:47 +09:00
|
|
|
await prisma.column_labels.create({
|
2025-09-04 14:23:35 +09:00
|
|
|
data: createData,
|
2025-09-01 15:22:47 +09:00
|
|
|
});
|
2025-09-01 11:48:12 +09:00
|
|
|
logger.info(
|
|
|
|
|
`컬럼 라벨 생성 및 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 웹 타입별 기본 상세 설정 생성
|
|
|
|
|
*/
|
|
|
|
|
private generateDefaultDetailSettings(webType: string): Record<string, any> {
|
|
|
|
|
switch (webType) {
|
|
|
|
|
case "text":
|
|
|
|
|
return {
|
|
|
|
|
maxLength: 255,
|
|
|
|
|
pattern: null,
|
|
|
|
|
placeholder: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "number":
|
|
|
|
|
return {
|
|
|
|
|
min: null,
|
|
|
|
|
max: null,
|
|
|
|
|
step: 1,
|
|
|
|
|
precision: 2,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "date":
|
|
|
|
|
return {
|
|
|
|
|
format: "YYYY-MM-DD",
|
|
|
|
|
minDate: null,
|
|
|
|
|
maxDate: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "code":
|
|
|
|
|
return {
|
|
|
|
|
codeCategory: null,
|
|
|
|
|
displayFormat: "label",
|
|
|
|
|
searchable: true,
|
|
|
|
|
multiple: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
return {
|
|
|
|
|
referenceTable: null,
|
|
|
|
|
referenceColumn: null,
|
|
|
|
|
searchable: true,
|
|
|
|
|
multiple: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "textarea":
|
|
|
|
|
return {
|
|
|
|
|
rows: 3,
|
|
|
|
|
maxLength: 1000,
|
|
|
|
|
placeholder: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return {
|
|
|
|
|
options: [],
|
|
|
|
|
multiple: false,
|
|
|
|
|
searchable: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return {
|
|
|
|
|
defaultChecked: false,
|
|
|
|
|
label: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "radio":
|
|
|
|
|
return {
|
|
|
|
|
options: [],
|
|
|
|
|
inline: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
case "file":
|
|
|
|
|
return {
|
|
|
|
|
accept: "*/*",
|
|
|
|
|
maxSize: 10485760, // 10MB
|
|
|
|
|
multiple: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-03 15:23:12 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
/**
|
|
|
|
|
* 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기)
|
|
|
|
|
*/
|
|
|
|
|
private async enrichFileData(
|
|
|
|
|
data: any[],
|
|
|
|
|
fileColumns: string[],
|
|
|
|
|
tableName: string
|
|
|
|
|
): Promise<any[]> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
|
|
|
|
`파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 각 행의 파일 정보를 보강
|
|
|
|
|
const enrichedData = await Promise.all(
|
|
|
|
|
data.map(async (row) => {
|
|
|
|
|
const enrichedRow = { ...row };
|
|
|
|
|
|
|
|
|
|
// 각 파일 컬럼에 대해 처리
|
|
|
|
|
for (const fileColumn of fileColumns) {
|
|
|
|
|
const filePath = row[fileColumn];
|
|
|
|
|
if (filePath && typeof filePath === "string") {
|
2025-09-08 10:02:30 +09:00
|
|
|
// 🎯 컴포넌트별 파일 정보 조회
|
|
|
|
|
// 파일 경로에서 컴포넌트 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) {
|
2025-09-05 21:52:19 +09:00
|
|
|
// 파일 정보를 JSON 형태로 저장
|
2025-09-08 10:02:30 +09:00
|
|
|
const totalSize = fileInfos.reduce(
|
|
|
|
|
(sum, file) => sum + (file.size || 0),
|
|
|
|
|
0
|
|
|
|
|
);
|
2025-09-05 21:52:19 +09:00
|
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
2025-09-08 10:02:30 +09:00
|
|
|
files: fileInfos,
|
|
|
|
|
totalCount: fileInfos.length,
|
|
|
|
|
totalSize: totalSize,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// 파일이 없으면 빈 상태로 설정
|
2025-09-05 21:52:19 +09:00
|
|
|
enrichedRow[fileColumn] = JSON.stringify({
|
2025-09-08 10:02:30 +09:00
|
|
|
files: [],
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
totalSize: 0,
|
2025-09-05 21:52:19 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return enrichedRow;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`);
|
|
|
|
|
return enrichedData;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("파일 데이터 보강 실패:", error);
|
|
|
|
|
return data; // 실패 시 원본 데이터 반환
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-09-08 10:02:30 +09:00
|
|
|
* 파일 경로에서 컴포넌트 ID 추출 (현재는 사용하지 않음)
|
|
|
|
|
*/
|
|
|
|
|
private extractComponentIdFromPath(filePath: string): string | null {
|
|
|
|
|
// 현재는 파일 경로에서 컴포넌트 ID를 추출할 수 없으므로 null 반환
|
|
|
|
|
// 추후 필요시 구현
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컬럼별 파일 정보 조회 (컬럼명과 target_objid로 구분)
|
|
|
|
|
*/
|
|
|
|
|
private async getFileInfoByColumnAndTarget(
|
|
|
|
|
columnName: string,
|
|
|
|
|
targetObjid: any,
|
|
|
|
|
tableName: string
|
|
|
|
|
): Promise<any[]> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(
|
|
|
|
|
`컬럼별 파일 정보 조회: ${tableName}.${columnName}, target: ${targetObjid}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 🎯 컬럼명을 doc_type으로 사용하여 파일 구분
|
|
|
|
|
const fileInfos = await prisma.attach_file_info.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
target_objid: String(targetObjid),
|
|
|
|
|
doc_type: columnName, // 컬럼명으로 파일 구분
|
|
|
|
|
status: "ACTIVE",
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
objid: true,
|
|
|
|
|
real_file_name: true,
|
|
|
|
|
file_size: true,
|
|
|
|
|
file_ext: true,
|
|
|
|
|
file_path: true,
|
|
|
|
|
doc_type: true,
|
|
|
|
|
doc_type_name: true,
|
|
|
|
|
regdate: true,
|
|
|
|
|
writer: true,
|
|
|
|
|
},
|
|
|
|
|
orderBy: {
|
|
|
|
|
regdate: "desc",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 파일 정보 포맷팅
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 파일 경로로 파일 정보 조회 (기존 메서드 - 호환성 유지)
|
2025-09-05 21:52:19 +09:00
|
|
|
*/
|
|
|
|
|
private async getFileInfoByPath(filePath: string): Promise<any | null> {
|
|
|
|
|
try {
|
|
|
|
|
const fileInfo = await prisma.attach_file_info.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
file_path: filePath,
|
|
|
|
|
status: "ACTIVE",
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
objid: true,
|
|
|
|
|
real_file_name: true,
|
|
|
|
|
file_size: true,
|
|
|
|
|
file_ext: true,
|
|
|
|
|
file_path: true,
|
|
|
|
|
doc_type: true,
|
|
|
|
|
doc_type_name: true,
|
|
|
|
|
regdate: true,
|
|
|
|
|
writer: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!fileInfo) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
name: fileInfo.real_file_name,
|
|
|
|
|
path: fileInfo.file_path,
|
|
|
|
|
size: Number(fileInfo.file_size) || 0,
|
|
|
|
|
type: fileInfo.file_ext,
|
|
|
|
|
objid: fileInfo.objid.toString(),
|
|
|
|
|
docType: fileInfo.doc_type,
|
|
|
|
|
docTypeName: fileInfo.doc_type_name,
|
|
|
|
|
regdate: fileInfo.regdate?.toISOString(),
|
|
|
|
|
writer: fileInfo.writer,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn(`파일 정보 조회 실패: ${filePath}`, error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 파일 타입 컬럼 조회
|
|
|
|
|
*/
|
|
|
|
|
private async getFileTypeColumns(tableName: string): Promise<string[]> {
|
|
|
|
|
try {
|
|
|
|
|
const fileColumns = await prisma.column_labels.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
web_type: "file",
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
column_name: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const columnNames = fileColumns.map((col: any) => col.column_name);
|
|
|
|
|
logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames);
|
|
|
|
|
return columnNames;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
/**
|
|
|
|
|
* 테이블 데이터 조회 (페이징 + 검색)
|
|
|
|
|
*/
|
|
|
|
|
async getTableData(
|
2025-09-03 16:38:10 +09:00
|
|
|
tableName: string,
|
2025-09-03 15:23:12 +09:00
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<{
|
|
|
|
|
data: any[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
2025-09-03 16:38:10 +09:00
|
|
|
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
2025-09-03 15:23:12 +09:00
|
|
|
const offset = (page - 1) * size;
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
|
|
|
|
|
2025-09-09 17:42:23 +09:00
|
|
|
// 🎯 파일 타입 컬럼 감지 (비활성화됨 - 자동 파일 컬럼 생성 방지)
|
|
|
|
|
// const fileColumns = await this.getFileTypeColumns(tableName);
|
|
|
|
|
const fileColumns: string[] = []; // 자동 파일 컬럼 생성 비활성화
|
2025-09-05 21:52:19 +09:00
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// WHERE 조건 구성
|
|
|
|
|
let whereConditions: string[] = [];
|
|
|
|
|
let searchValues: any[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (search && Object.keys(search).length > 0) {
|
|
|
|
|
for (const [column, value] of Object.entries(search)) {
|
2025-09-03 16:38:10 +09:00
|
|
|
if (value !== null && value !== undefined && value !== "") {
|
2025-09-17 13:49:00 +09:00
|
|
|
// 🎯 추가 조인 컬럼들은 실제 테이블 컬럼이 아니므로 제외
|
|
|
|
|
const additionalJoinColumns = [
|
|
|
|
|
"company_code_status",
|
|
|
|
|
"writer_dept_code",
|
|
|
|
|
];
|
|
|
|
|
if (additionalJoinColumns.includes(column)) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`🔍 추가 조인 컬럼 ${column} 검색 조건에서 제외 (실제 테이블 컬럼 아님)`
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
|
|
|
|
|
if (typeof value === "string") {
|
2025-09-03 15:23:12 +09:00
|
|
|
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
|
|
|
|
|
searchValues.push(`%${value}%`);
|
|
|
|
|
} else {
|
|
|
|
|
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
|
|
|
|
searchValues.push(value);
|
|
|
|
|
}
|
|
|
|
|
paramIndex++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
const whereClause =
|
|
|
|
|
whereConditions.length > 0
|
|
|
|
|
? `WHERE ${whereConditions.join(" AND ")}`
|
|
|
|
|
: "";
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
// ORDER BY 조건 구성
|
2025-09-03 16:38:10 +09:00
|
|
|
let orderClause = "";
|
2025-09-03 15:23:12 +09:00
|
|
|
if (sortBy) {
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
|
|
|
|
|
const safeSortOrder =
|
|
|
|
|
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
2025-09-03 15:23:12 +09:00
|
|
|
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 안전한 테이블명 검증
|
2025-09-03 16:38:10 +09:00
|
|
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
// 전체 개수 조회
|
|
|
|
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
2025-09-03 16:38:10 +09:00
|
|
|
const countResult = await prisma.$queryRawUnsafe<any[]>(
|
|
|
|
|
countQuery,
|
|
|
|
|
...searchValues
|
|
|
|
|
);
|
2025-09-03 15:23:12 +09:00
|
|
|
const total = parseInt(countResult[0].count);
|
|
|
|
|
|
|
|
|
|
// 데이터 조회
|
|
|
|
|
const dataQuery = `
|
|
|
|
|
SELECT * FROM ${safeTableName}
|
|
|
|
|
${whereClause}
|
|
|
|
|
${orderClause}
|
|
|
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
|
|
|
`;
|
2025-09-03 16:38:10 +09:00
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
let data = await prisma.$queryRawUnsafe<any[]>(
|
2025-09-03 16:38:10 +09:00
|
|
|
dataQuery,
|
|
|
|
|
...searchValues,
|
|
|
|
|
size,
|
2025-09-03 15:23:12 +09:00
|
|
|
offset
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-05 21:52:19 +09:00
|
|
|
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
|
|
|
|
if (fileColumns.length > 0) {
|
|
|
|
|
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 15:23:12 +09:00
|
|
|
const totalPages = Math.ceil(total / size);
|
|
|
|
|
|
2025-09-03 16:38:10 +09:00
|
|
|
logger.info(
|
|
|
|
|
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
|
|
|
|
);
|
2025-09-03 15:23:12 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
size,
|
2025-09-03 16:38:10 +09:00
|
|
|
totalPages,
|
2025-09-03 15:23:12 +09:00
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-03 16:38:10 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 현재 사용자 정보 조회 (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<string, any>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
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 prisma.$queryRawUnsafe(
|
|
|
|
|
columnInfoQuery,
|
|
|
|
|
tableName
|
|
|
|
|
)) as any[];
|
|
|
|
|
const columnTypeMap = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
columnInfoResult.forEach((col: any) => {
|
|
|
|
|
columnTypeMap.set(col.column_name, col.data_type);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
|
|
|
|
|
|
|
|
|
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
|
|
|
|
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 query = `
|
|
|
|
|
INSERT INTO "${tableName}" (${columnNames})
|
|
|
|
|
VALUES (${placeholders})
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
logger.info(`실행할 쿼리: ${query}`);
|
|
|
|
|
logger.info(`쿼리 파라미터:`, values);
|
|
|
|
|
|
|
|
|
|
await prisma.$queryRawUnsafe(query, ...values);
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 데이터 수정
|
|
|
|
|
*/
|
|
|
|
|
async editTableData(
|
|
|
|
|
tableName: string,
|
|
|
|
|
originalData: Record<string, any>,
|
|
|
|
|
updatedData: Record<string, any>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
|
|
|
|
|
logger.info(`원본 데이터:`, originalData);
|
|
|
|
|
logger.info(`수정할 데이터:`, updatedData);
|
|
|
|
|
|
|
|
|
|
// 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용)
|
|
|
|
|
const columnInfoQuery = `
|
|
|
|
|
SELECT c.column_name, c.data_type, c.is_nullable,
|
|
|
|
|
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key
|
|
|
|
|
FROM information_schema.columns c
|
|
|
|
|
LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name
|
|
|
|
|
LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name
|
|
|
|
|
WHERE c.table_name = $1
|
|
|
|
|
ORDER BY c.ordinal_position
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const columnInfoResult = (await prisma.$queryRawUnsafe(
|
|
|
|
|
columnInfoQuery,
|
|
|
|
|
tableName
|
|
|
|
|
)) as any[];
|
|
|
|
|
const columnTypeMap = new Map<string, string>();
|
|
|
|
|
const primaryKeys: string[] = [];
|
|
|
|
|
|
|
|
|
|
columnInfoResult.forEach((col: any) => {
|
|
|
|
|
columnTypeMap.set(col.column_name, col.data_type);
|
|
|
|
|
if (col.is_primary_key === "YES") {
|
|
|
|
|
primaryKeys.push(col.column_name);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
|
|
|
|
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
|
|
|
|
|
|
|
|
|
// 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 query = `
|
|
|
|
|
UPDATE "${tableName}"
|
|
|
|
|
SET ${setConditions.join(", ")}
|
|
|
|
|
WHERE ${whereConditions.join(" AND ")}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const allValues = [...setValues, ...whereValues];
|
|
|
|
|
|
|
|
|
|
logger.info(`실행할 UPDATE 쿼리: ${query}`);
|
|
|
|
|
logger.info(`쿼리 파라미터:`, allValues);
|
|
|
|
|
|
|
|
|
|
const result = await prisma.$queryRawUnsafe(query, ...allValues);
|
|
|
|
|
|
|
|
|
|
logger.info(`테이블 데이터 수정 완료: ${tableName}`, result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 수정 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PostgreSQL 타입명 반환
|
|
|
|
|
*/
|
|
|
|
|
private getPostgreSQLType(dataType: string): string {
|
|
|
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
lowerDataType.includes("timestamp") ||
|
|
|
|
|
lowerDataType.includes("datetime")
|
|
|
|
|
) {
|
|
|
|
|
return "timestamp";
|
|
|
|
|
} else if (lowerDataType.includes("date")) {
|
|
|
|
|
return "date";
|
|
|
|
|
} else if (lowerDataType.includes("time")) {
|
|
|
|
|
return "time";
|
|
|
|
|
} else if (
|
|
|
|
|
lowerDataType.includes("integer") ||
|
|
|
|
|
lowerDataType.includes("bigint") ||
|
|
|
|
|
lowerDataType.includes("serial")
|
|
|
|
|
) {
|
|
|
|
|
return "integer";
|
|
|
|
|
} else if (
|
|
|
|
|
lowerDataType.includes("numeric") ||
|
|
|
|
|
lowerDataType.includes("decimal")
|
|
|
|
|
) {
|
|
|
|
|
return "numeric";
|
|
|
|
|
} else if (lowerDataType.includes("boolean")) {
|
|
|
|
|
return "boolean";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "text"; // 기본값
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블에서 데이터 삭제
|
|
|
|
|
*/
|
|
|
|
|
async deleteTableData(
|
|
|
|
|
tableName: string,
|
|
|
|
|
dataToDelete: Record<string, any>[]
|
|
|
|
|
): Promise<number> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete);
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) {
|
|
|
|
|
throw new Error("삭제할 데이터가 없습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
|
|
|
|
// 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해)
|
|
|
|
|
const primaryKeyQuery = `
|
|
|
|
|
SELECT column_name
|
|
|
|
|
FROM information_schema.table_constraints tc
|
|
|
|
|
JOIN information_schema.key_column_usage kcu
|
|
|
|
|
ON tc.constraint_name = kcu.constraint_name
|
|
|
|
|
WHERE tc.table_name = $1
|
|
|
|
|
AND tc.constraint_type = 'PRIMARY KEY'
|
|
|
|
|
ORDER BY kcu.ordinal_position
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const primaryKeys = await prisma.$queryRawUnsafe<
|
|
|
|
|
{ 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 prisma.$queryRawUnsafe(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 prisma.$queryRawUnsafe(deleteQuery, ...values);
|
|
|
|
|
deletedCount += Number(result);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
|
|
|
|
);
|
|
|
|
|
return deletedCount;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 🎯 Entity 조인 기능
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Entity 조인이 포함된 데이터 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTableDataWithEntityJoins(
|
|
|
|
|
tableName: string,
|
|
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
enableEntityJoin?: boolean;
|
2025-09-16 18:02:19 +09:00
|
|
|
additionalJoinColumns?: Array<{
|
|
|
|
|
sourceTable: string;
|
|
|
|
|
sourceColumn: string;
|
|
|
|
|
joinAlias: string;
|
|
|
|
|
}>;
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`Entity 조인 데이터 조회 시작: ${tableName}`);
|
|
|
|
|
|
|
|
|
|
// Entity 조인이 비활성화된 경우 기본 데이터 조회
|
|
|
|
|
if (!options.enableEntityJoin) {
|
|
|
|
|
const basicResult = await this.getTableData(tableName, options);
|
|
|
|
|
return {
|
|
|
|
|
data: basicResult.data,
|
|
|
|
|
total: basicResult.total,
|
|
|
|
|
page: options.page,
|
|
|
|
|
size: options.size,
|
|
|
|
|
totalPages: Math.ceil(basicResult.total / options.size),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Entity 조인 설정 감지
|
2025-09-16 18:02:19 +09:00
|
|
|
let joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
|
|
|
|
|
|
|
|
|
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
|
|
|
|
|
if (
|
|
|
|
|
options.additionalJoinColumns &&
|
|
|
|
|
options.additionalJoinColumns.length > 0
|
|
|
|
|
) {
|
|
|
|
|
logger.info(
|
|
|
|
|
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}개`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const additionalColumn of options.additionalJoinColumns) {
|
|
|
|
|
// 기존 조인 설정에서 같은 참조 테이블을 사용하는 설정 찾기
|
|
|
|
|
const baseJoinConfig = joinConfigs.find(
|
|
|
|
|
(config) => config.referenceTable === additionalColumn.sourceTable
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (baseJoinConfig) {
|
|
|
|
|
// 추가 조인 컬럼 설정 생성
|
|
|
|
|
const additionalJoinConfig: EntityJoinConfig = {
|
|
|
|
|
sourceTable: tableName,
|
|
|
|
|
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (writer)
|
|
|
|
|
referenceTable: additionalColumn.sourceTable, // 참조 테이블 (user_info)
|
|
|
|
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (user_id)
|
|
|
|
|
displayColumn: additionalColumn.sourceColumn, // 표시할 컬럼 (email)
|
|
|
|
|
aliasColumn: additionalColumn.joinAlias, // 별칭 (writer_email)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
joinConfigs.push(additionalJoinConfig);
|
|
|
|
|
logger.info(
|
|
|
|
|
`추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
// 조인 전략 결정 (테이블 크기 기반)
|
2025-09-16 15:13:00 +09:00
|
|
|
const strategy =
|
|
|
|
|
await entityJoinService.determineJoinStrategy(joinConfigs);
|
2025-09-16 16:53:03 +09:00
|
|
|
console.log(
|
|
|
|
|
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 테이블 컬럼 정보 조회
|
|
|
|
|
const columns = await this.getTableColumns(tableName);
|
|
|
|
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
|
|
|
|
|
|
|
|
|
// WHERE 절 구성
|
|
|
|
|
const whereClause = this.buildWhereClause(options.search);
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
);
|
2025-09-16 16:53:03 +09:00
|
|
|
} else if (strategy === "cache_lookup") {
|
2025-09-16 15:13:00 +09:00
|
|
|
// 캐시 룩업 방식
|
|
|
|
|
return await this.executeCachedLookup(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
options,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
2025-09-16 16:53:03 +09:00
|
|
|
} else {
|
|
|
|
|
// 하이브리드 방식: 일부는 조인, 일부는 캐시
|
|
|
|
|
return await this.executeHybridJoin(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
selectColumns,
|
|
|
|
|
whereClause,
|
|
|
|
|
orderBy,
|
|
|
|
|
options.size,
|
|
|
|
|
offset,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Entity 조인 데이터 조회 실패: ${tableName}`, error);
|
2025-09-17 11:15:34 +09:00
|
|
|
throw error;
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SQL JOIN 방식으로 데이터 조회
|
|
|
|
|
*/
|
|
|
|
|
private async executeJoinQuery(
|
|
|
|
|
tableName: string,
|
|
|
|
|
joinConfigs: EntityJoinConfig[],
|
|
|
|
|
selectColumns: string[],
|
|
|
|
|
whereClause: string,
|
|
|
|
|
orderBy: string,
|
|
|
|
|
limit: number,
|
|
|
|
|
offset: number,
|
|
|
|
|
startTime: number
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
try {
|
|
|
|
|
// 데이터 조회 쿼리
|
|
|
|
|
const dataQuery = entityJoinService.buildJoinQuery(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
selectColumns,
|
|
|
|
|
whereClause,
|
|
|
|
|
orderBy,
|
|
|
|
|
limit,
|
|
|
|
|
offset
|
2025-09-17 11:15:34 +09:00
|
|
|
).query;
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 카운트 쿼리
|
|
|
|
|
const countQuery = entityJoinService.buildCountQuery(
|
|
|
|
|
tableName,
|
|
|
|
|
joinConfigs,
|
|
|
|
|
whereClause
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 병렬 실행
|
|
|
|
|
const [dataResult, countResult] = await Promise.all([
|
|
|
|
|
prisma.$queryRawUnsafe(dataQuery),
|
|
|
|
|
prisma.$queryRawUnsafe(countQuery),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const data = Array.isArray(dataResult) ? dataResult : [];
|
|
|
|
|
const total =
|
|
|
|
|
Array.isArray(countResult) && countResult.length > 0
|
|
|
|
|
? Number((countResult[0] as any).total)
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
const queryTime = Date.now() - startTime;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
data,
|
|
|
|
|
total,
|
|
|
|
|
page: Math.floor(offset / limit) + 1,
|
|
|
|
|
size: limit,
|
|
|
|
|
totalPages: Math.ceil(total / limit),
|
|
|
|
|
entityJoinInfo: {
|
|
|
|
|
joinConfigs,
|
|
|
|
|
strategy: "full_join",
|
|
|
|
|
performance: {
|
|
|
|
|
queryTime,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error("SQL JOIN 쿼리 실행 실패", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 캐시 룩업 방식으로 데이터 조회
|
|
|
|
|
*/
|
|
|
|
|
private async executeCachedLookup(
|
|
|
|
|
tableName: string,
|
|
|
|
|
joinConfigs: EntityJoinConfig[],
|
|
|
|
|
options: {
|
|
|
|
|
page: number;
|
|
|
|
|
size: number;
|
|
|
|
|
search?: Record<string, any>;
|
|
|
|
|
sortBy?: string;
|
|
|
|
|
sortOrder?: string;
|
|
|
|
|
},
|
|
|
|
|
startTime: number
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
try {
|
|
|
|
|
// 캐시 데이터 미리 로드
|
|
|
|
|
for (const config of joinConfigs) {
|
2025-09-16 16:53:03 +09:00
|
|
|
await referenceCacheService.getCachedReference(
|
2025-09-16 15:13:00 +09:00
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
|
|
|
|
config.displayColumn
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 11:15:34 +09:00
|
|
|
// 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<string, any> = {};
|
|
|
|
|
|
|
|
|
|
// Entity 조인 컬럼을 제외한 검색 조건만 유지
|
|
|
|
|
for (const [key, value] of Object.entries(options.search)) {
|
|
|
|
|
const isEntityColumn = joinConfigs.some(
|
|
|
|
|
(config) => config.aliasColumn === key
|
|
|
|
|
);
|
|
|
|
|
if (!isEntityColumn) {
|
|
|
|
|
filteredSearch[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fallbackOptions.search = filteredSearch;
|
|
|
|
|
logger.info(
|
|
|
|
|
`🔄 Entity 조인 에러 시 검색 조건 필터링: ${Object.keys(filteredSearch).join(", ")}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
basicResult = await this.getTableData(tableName, fallbackOptions);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
|
|
|
|
basicResult = await this.getTableData(tableName, options);
|
|
|
|
|
}
|
2025-09-16 15:13:00 +09:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
String(sourceValue)
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
|
|
|
enhancedRow[config.aliasColumn] = lookupValue || "";
|
|
|
|
|
} else {
|
|
|
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
|
|
|
enhancedRow[config.aliasColumn] = "";
|
2025-09-16 15:13:00 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 buildWhereClause(search?: Record<string, any>): string {
|
|
|
|
|
if (!search || Object.keys(search).length === 0) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const conditions: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(search)) {
|
|
|
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
conditions.push(`main.${key} ILIKE '%${value}%'`);
|
|
|
|
|
} else {
|
|
|
|
|
conditions.push(`main.${key} = '${value}'`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return conditions.length > 0 ? conditions.join(" AND ") : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블의 컬럼 정보 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTableColumns(tableName: string): Promise<{
|
|
|
|
|
data: Array<{ column_name: string; data_type: string }>;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
const columns = await prisma.$queryRaw<
|
|
|
|
|
Array<{
|
|
|
|
|
column_name: string;
|
|
|
|
|
data_type: string;
|
|
|
|
|
}>
|
|
|
|
|
>`
|
|
|
|
|
SELECT column_name, data_type
|
|
|
|
|
FROM information_schema.columns
|
|
|
|
|
WHERE table_name = ${tableName}
|
|
|
|
|
ORDER BY ordinal_position
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
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<ColumnLabels>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
|
|
|
|
|
|
|
|
|
|
await prisma.column_labels.upsert({
|
|
|
|
|
where: {
|
|
|
|
|
table_name_column_name: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
update: {
|
|
|
|
|
column_label: updates.columnLabel,
|
|
|
|
|
web_type: updates.webType,
|
|
|
|
|
detail_settings: updates.detailSettings,
|
|
|
|
|
description: updates.description,
|
|
|
|
|
display_order: updates.displayOrder,
|
|
|
|
|
is_visible: updates.isVisible,
|
|
|
|
|
code_category: updates.codeCategory,
|
|
|
|
|
code_value: updates.codeValue,
|
|
|
|
|
reference_table: updates.referenceTable,
|
|
|
|
|
reference_column: updates.referenceColumn,
|
|
|
|
|
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석)
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
},
|
|
|
|
|
create: {
|
|
|
|
|
table_name: tableName,
|
|
|
|
|
column_name: columnName,
|
|
|
|
|
column_label: updates.columnLabel || columnName,
|
|
|
|
|
web_type: updates.webType || "text",
|
|
|
|
|
detail_settings: updates.detailSettings,
|
|
|
|
|
description: updates.description,
|
|
|
|
|
display_order: updates.displayOrder || 0,
|
|
|
|
|
is_visible: updates.isVisible !== false,
|
|
|
|
|
code_category: updates.codeCategory,
|
|
|
|
|
code_value: updates.codeValue,
|
|
|
|
|
reference_table: updates.referenceTable,
|
|
|
|
|
reference_column: updates.referenceColumn,
|
|
|
|
|
// display_column: updates.displayColumn, // 🎯 새로 추가 (임시 주석)
|
|
|
|
|
created_date: new Date(),
|
|
|
|
|
updated_date: new Date(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.info(`컬럼 라벨 업데이트 완료: ${tableName}.${columnName}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
`컬럼 라벨 업데이트 실패: ${tableName}.${columnName}`,
|
|
|
|
|
error
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`컬럼 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-09-16 16:53:03 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 🎯 하이브리드 조인 전략 구현
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 하이브리드 조인 실행: 일부는 조인, 일부는 캐시 룩업
|
|
|
|
|
*/
|
|
|
|
|
private async executeHybridJoin(
|
|
|
|
|
tableName: string,
|
|
|
|
|
joinConfigs: EntityJoinConfig[],
|
|
|
|
|
selectColumns: string[],
|
|
|
|
|
whereClause: string,
|
|
|
|
|
orderBy: string,
|
|
|
|
|
limit: number,
|
|
|
|
|
offset: number,
|
|
|
|
|
startTime: number
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info(`🔀 하이브리드 조인 실행: ${tableName}`);
|
|
|
|
|
|
|
|
|
|
// 각 조인 설정을 캐시 가능 여부에 따라 분류
|
|
|
|
|
const { cacheableJoins, dbJoins } =
|
|
|
|
|
await this.categorizeJoins(joinConfigs);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`📋 캐시 조인: ${cacheableJoins.length}개, DB 조인: ${dbJoins.length}개`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// DB 조인이 있는 경우: 조인 쿼리 실행 후 캐시 룩업 적용
|
|
|
|
|
if (dbJoins.length > 0) {
|
|
|
|
|
return await this.executeJoinThenCache(
|
|
|
|
|
tableName,
|
|
|
|
|
dbJoins,
|
|
|
|
|
cacheableJoins,
|
|
|
|
|
selectColumns,
|
|
|
|
|
whereClause,
|
|
|
|
|
orderBy,
|
|
|
|
|
limit,
|
|
|
|
|
offset,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
|
|
|
|
else {
|
|
|
|
|
return await this.executeCachedLookup(
|
|
|
|
|
tableName,
|
|
|
|
|
cacheableJoins,
|
|
|
|
|
{ page: Math.floor(offset / limit) + 1, size: limit, search: {} },
|
|
|
|
|
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) {
|
|
|
|
|
// 캐시 가능성 확인
|
|
|
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
|
|
|
|
config.displayColumn
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cachedData && cachedData.size > 0) {
|
|
|
|
|
cacheableJoins.push(config);
|
|
|
|
|
console.log(
|
|
|
|
|
`📋 캐시 사용: ${config.referenceTable} (${cachedData.size}건)`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
dbJoins.push(config);
|
|
|
|
|
console.log(`🔗 DB 조인: ${config.referenceTable}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { cacheableJoins, dbJoins };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* DB 조인 실행 후 캐시 룩업 적용
|
|
|
|
|
*/
|
|
|
|
|
private async executeJoinThenCache(
|
|
|
|
|
tableName: string,
|
|
|
|
|
dbJoins: EntityJoinConfig[],
|
|
|
|
|
cacheableJoins: EntityJoinConfig[],
|
|
|
|
|
selectColumns: string[],
|
|
|
|
|
whereClause: string,
|
|
|
|
|
orderBy: string,
|
|
|
|
|
limit: number,
|
|
|
|
|
offset: number,
|
|
|
|
|
startTime: number
|
|
|
|
|
): Promise<EntityJoinResponse> {
|
|
|
|
|
// 1. DB 조인 먼저 실행
|
|
|
|
|
const joinResult = await this.executeJoinQuery(
|
|
|
|
|
tableName,
|
|
|
|
|
dbJoins,
|
|
|
|
|
selectColumns,
|
|
|
|
|
whereClause,
|
|
|
|
|
orderBy,
|
|
|
|
|
limit,
|
|
|
|
|
offset,
|
|
|
|
|
startTime
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 2. 캐시 가능한 조인들을 결과에 추가 적용
|
|
|
|
|
if (cacheableJoins.length > 0) {
|
|
|
|
|
const enhancedData = await this.applyCacheLookupToData(
|
|
|
|
|
joinResult.data,
|
|
|
|
|
cacheableJoins
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...joinResult,
|
|
|
|
|
data: enhancedData,
|
|
|
|
|
entityJoinInfo: {
|
|
|
|
|
...joinResult.entityJoinInfo!,
|
|
|
|
|
strategy: "hybrid",
|
|
|
|
|
performance: {
|
|
|
|
|
...joinResult.entityJoinInfo!.performance,
|
|
|
|
|
cacheHitRate: await this.calculateCacheHitRate(cacheableJoins),
|
|
|
|
|
hybridBreakdown: {
|
|
|
|
|
dbJoins: dbJoins.length,
|
|
|
|
|
cacheJoins: cacheableJoins.length,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return joinResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 데이터에 캐시 룩업 적용
|
|
|
|
|
*/
|
|
|
|
|
private async applyCacheLookupToData(
|
|
|
|
|
data: any[],
|
|
|
|
|
cacheableJoins: EntityJoinConfig[]
|
|
|
|
|
): Promise<any[]> {
|
|
|
|
|
const enhancedData = [...data];
|
|
|
|
|
|
|
|
|
|
for (const config of cacheableJoins) {
|
|
|
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
|
|
|
|
config.displayColumn
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cachedData) {
|
|
|
|
|
enhancedData.forEach((row) => {
|
|
|
|
|
const keyValue = row[config.sourceColumn];
|
|
|
|
|
if (keyValue) {
|
|
|
|
|
const lookupValue = cachedData.get(String(keyValue));
|
2025-09-16 18:02:19 +09:00
|
|
|
// null이나 undefined인 경우 빈 문자열로 설정
|
|
|
|
|
row[config.aliasColumn] = lookupValue || "";
|
|
|
|
|
} else {
|
|
|
|
|
// sourceValue가 없는 경우도 빈 문자열로 설정
|
|
|
|
|
row[config.aliasColumn] = "";
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
|
|
|
|
});
|
2025-09-16 18:02:19 +09:00
|
|
|
} else {
|
|
|
|
|
// 캐시가 없는 경우 모든 행에 빈 문자열 설정
|
|
|
|
|
enhancedData.forEach((row) => {
|
|
|
|
|
row[config.aliasColumn] = "";
|
|
|
|
|
});
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return enhancedData;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 캐시 적중률 계산
|
|
|
|
|
*/
|
|
|
|
|
private async calculateCacheHitRate(
|
|
|
|
|
cacheableJoins: EntityJoinConfig[]
|
|
|
|
|
): Promise<number> {
|
|
|
|
|
if (cacheableJoins.length === 0) return 0;
|
|
|
|
|
|
|
|
|
|
let totalHitRate = 0;
|
|
|
|
|
for (const config of cacheableJoins) {
|
|
|
|
|
const hitRate = referenceCacheService.getCacheHitRate(
|
|
|
|
|
config.referenceTable,
|
|
|
|
|
config.referenceColumn,
|
|
|
|
|
config.displayColumn
|
|
|
|
|
);
|
|
|
|
|
totalHitRate += hitRate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return totalHitRate / cacheableJoins.length;
|
|
|
|
|
}
|
2025-08-25 14:08:08 +09:00
|
|
|
}
|