ERP-node/backend-node/src/services/tableManagementService.ts

2196 lines
68 KiB
TypeScript
Raw Normal View History

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",
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",
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
-- 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,
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
// 기존 컬럼 라벨 업데이트
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,
},
data: updateData,
2025-09-01 15:22:47 +09:00
});
2025-09-01 11:48:12 +09:00
logger.info(
`컬럼 웹 타입 업데이트 완료: ${tableName}.${columnName} = ${webType}`
);
} else {
// 새로운 컬럼 라벨 생성
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({
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-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;
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 조인 설정 감지
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)
);
// 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));
// null이나 undefined인 경우 빈 문자열로 설정
row[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
row[config.aliasColumn] = "";
2025-09-16 16:53:03 +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
}