610 lines
20 KiB
TypeScript
610 lines
20 KiB
TypeScript
import { query, queryOne } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
import {
|
|
EntityJoinConfig,
|
|
BatchLookupRequest,
|
|
BatchLookupResponse,
|
|
} from "../types/tableManagement";
|
|
import { referenceCacheService } from "./referenceCacheService";
|
|
|
|
/**
|
|
* Entity 조인 기능을 제공하는 서비스
|
|
* ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템
|
|
*/
|
|
export class EntityJoinService {
|
|
/**
|
|
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
|
* @param tableName 테이블명
|
|
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
|
*/
|
|
async detectEntityJoins(
|
|
tableName: string,
|
|
screenEntityConfigs?: Record<string, any>
|
|
): Promise<EntityJoinConfig[]> {
|
|
try {
|
|
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
|
|
|
// column_labels에서 entity 타입인 컬럼들 조회
|
|
const entityColumns = await query<{
|
|
column_name: string;
|
|
reference_table: string;
|
|
reference_column: string;
|
|
display_column: string | null;
|
|
}>(
|
|
`SELECT column_name, reference_table, reference_column, display_column
|
|
FROM column_labels
|
|
WHERE table_name = $1
|
|
AND web_type = $2
|
|
AND reference_table IS NOT NULL
|
|
AND reference_column IS NOT NULL`,
|
|
[tableName, "entity"]
|
|
);
|
|
|
|
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
|
entityColumns.forEach((col, index) => {
|
|
logger.info(
|
|
` ${index + 1}. ${col.column_name} -> ${col.reference_table}.${col.reference_column} (display: ${col.display_column})`
|
|
);
|
|
});
|
|
|
|
const joinConfigs: EntityJoinConfig[] = [];
|
|
|
|
// 🎯 writer 컬럼 자동 감지 및 조인 설정 추가
|
|
const tableColumns = await query<{ column_name: string }>(
|
|
`SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
AND table_schema = 'public'
|
|
AND column_name = 'writer'`,
|
|
[tableName]
|
|
);
|
|
|
|
if (tableColumns.length > 0) {
|
|
const writerJoinConfig: EntityJoinConfig = {
|
|
sourceTable: tableName,
|
|
sourceColumn: "writer",
|
|
referenceTable: "user_info",
|
|
referenceColumn: "user_id",
|
|
displayColumns: ["user_name"],
|
|
displayColumn: "user_name",
|
|
aliasColumn: "writer_name",
|
|
separator: " - ",
|
|
};
|
|
|
|
if (await this.validateJoinConfig(writerJoinConfig)) {
|
|
joinConfigs.push(writerJoinConfig);
|
|
}
|
|
}
|
|
|
|
for (const column of entityColumns) {
|
|
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
|
column_name: column.column_name,
|
|
reference_table: column.reference_table,
|
|
reference_column: column.reference_column,
|
|
display_column: column.display_column,
|
|
});
|
|
|
|
if (
|
|
!column.column_name ||
|
|
!column.reference_table ||
|
|
!column.reference_column
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// 화면별 엔티티 설정이 있으면 우선 사용, 없으면 기본값 사용
|
|
const screenConfig = screenEntityConfigs?.[column.column_name];
|
|
let displayColumns: string[] = [];
|
|
let separator = " - ";
|
|
|
|
logger.info(`🔍 조건 확인 - 컬럼: ${column.column_name}`, {
|
|
hasScreenConfig: !!screenConfig,
|
|
hasDisplayColumns: screenConfig?.displayColumns,
|
|
displayColumn: column.display_column,
|
|
});
|
|
|
|
if (screenConfig && screenConfig.displayColumns) {
|
|
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
|
|
displayColumns = screenConfig.displayColumns;
|
|
separator = screenConfig.separator || " - ";
|
|
console.log(`🎯 화면별 엔티티 설정 적용: ${column.column_name}`, {
|
|
displayColumns,
|
|
separator,
|
|
screenConfig,
|
|
});
|
|
} else if (column.display_column && column.display_column !== "none") {
|
|
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
|
|
displayColumns = [column.display_column];
|
|
logger.info(
|
|
`🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}`
|
|
);
|
|
} else {
|
|
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
|
// 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
|
|
let defaultDisplayColumn = column.reference_column;
|
|
if (column.reference_table === "dept_info") {
|
|
defaultDisplayColumn = "dept_name";
|
|
} else if (column.reference_table === "company_info") {
|
|
defaultDisplayColumn = "company_name";
|
|
} else if (column.reference_table === "user_info") {
|
|
defaultDisplayColumn = "user_name";
|
|
}
|
|
|
|
displayColumns = [defaultDisplayColumn];
|
|
logger.info(
|
|
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})`
|
|
);
|
|
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
|
}
|
|
|
|
// 별칭 컬럼명 생성 (writer -> writer_name)
|
|
const aliasColumn = `${column.column_name}_name`;
|
|
|
|
const joinConfig: EntityJoinConfig = {
|
|
sourceTable: tableName,
|
|
sourceColumn: column.column_name,
|
|
referenceTable: column.reference_table,
|
|
referenceColumn: column.reference_column,
|
|
displayColumns: displayColumns,
|
|
displayColumn: displayColumns[0], // 하위 호환성
|
|
aliasColumn: aliasColumn,
|
|
separator: separator,
|
|
};
|
|
|
|
logger.info(`🔧 기본 조인 설정 생성:`, {
|
|
sourceTable: joinConfig.sourceTable,
|
|
sourceColumn: joinConfig.sourceColumn,
|
|
referenceTable: joinConfig.referenceTable,
|
|
aliasColumn: joinConfig.aliasColumn,
|
|
displayColumns: joinConfig.displayColumns,
|
|
});
|
|
|
|
// 조인 설정 유효성 검증
|
|
logger.info(
|
|
`🔍 조인 설정 검증 중: ${joinConfig.sourceColumn} -> ${joinConfig.referenceTable}`
|
|
);
|
|
if (await this.validateJoinConfig(joinConfig)) {
|
|
joinConfigs.push(joinConfig);
|
|
logger.info(`✅ 조인 설정 추가됨: ${joinConfig.aliasColumn}`);
|
|
} else {
|
|
logger.warn(`❌ 조인 설정 검증 실패: ${joinConfig.sourceColumn}`);
|
|
}
|
|
}
|
|
|
|
logger.info(`🎯 Entity 조인 설정 생성 완료: ${joinConfigs.length}개`);
|
|
joinConfigs.forEach((config, index) => {
|
|
logger.info(
|
|
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable}.${config.referenceColumn} AS ${config.aliasColumn}`
|
|
);
|
|
});
|
|
return joinConfigs;
|
|
} catch (error) {
|
|
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entity 조인이 포함된 SQL 쿼리 생성
|
|
*/
|
|
buildJoinQuery(
|
|
tableName: string,
|
|
joinConfigs: EntityJoinConfig[],
|
|
selectColumns: string[],
|
|
whereClause: string = "",
|
|
orderBy: string = "",
|
|
limit?: number,
|
|
offset?: number
|
|
): { query: string; aliasMap: Map<string, string> } {
|
|
try {
|
|
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
|
|
const baseColumns = selectColumns
|
|
.map((col) => `main.${col}::TEXT AS ${col}`)
|
|
.join(", ");
|
|
|
|
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
|
|
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
|
|
const aliasMap = new Map<string, string>();
|
|
const usedAliasesForColumns = new Set<string>();
|
|
|
|
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
|
|
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
|
if (
|
|
!acc.some(
|
|
(existingConfig) =>
|
|
existingConfig.referenceTable === config.referenceTable
|
|
)
|
|
) {
|
|
acc.push(config);
|
|
}
|
|
return acc;
|
|
}, [] as EntityJoinConfig[]);
|
|
|
|
logger.info(
|
|
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블`
|
|
);
|
|
|
|
uniqueReferenceTableConfigs.forEach((config) => {
|
|
let baseAlias = config.referenceTable.substring(0, 3);
|
|
let alias = baseAlias;
|
|
let counter = 1;
|
|
|
|
while (usedAliasesForColumns.has(alias)) {
|
|
alias = `${baseAlias}${counter}`;
|
|
counter++;
|
|
}
|
|
usedAliasesForColumns.add(alias);
|
|
aliasMap.set(config.referenceTable, alias);
|
|
logger.info(`🔧 별칭 생성: ${config.referenceTable} → ${alias}`);
|
|
});
|
|
|
|
const joinColumns = joinConfigs
|
|
.map((config) => {
|
|
const alias = aliasMap.get(config.referenceTable);
|
|
const displayColumns = config.displayColumns || [
|
|
config.displayColumn,
|
|
];
|
|
const separator = config.separator || " - ";
|
|
|
|
if (displayColumns.length === 0 || !displayColumns[0]) {
|
|
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
|
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
|
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
|
|
} else if (displayColumns.length === 1) {
|
|
// 단일 컬럼인 경우
|
|
const col = displayColumns[0];
|
|
const isJoinTableColumn = [
|
|
"dept_name",
|
|
"dept_code",
|
|
"master_user_id",
|
|
"location_name",
|
|
"parent_dept_code",
|
|
"master_sabun",
|
|
"location",
|
|
"data_type",
|
|
"company_name",
|
|
"sales_yn",
|
|
"status",
|
|
].includes(col);
|
|
|
|
if (isJoinTableColumn) {
|
|
return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
|
|
} else {
|
|
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
|
|
}
|
|
} else {
|
|
// 여러 컬럼인 경우 CONCAT으로 연결
|
|
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
|
|
const concatParts = displayColumns
|
|
.map((col) => {
|
|
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
|
|
// 현재는 dept_info 테이블의 컬럼들을 확인
|
|
const isJoinTableColumn = [
|
|
"dept_name",
|
|
"dept_code",
|
|
"master_user_id",
|
|
"location_name",
|
|
"parent_dept_code",
|
|
"master_sabun",
|
|
"location",
|
|
"data_type",
|
|
"company_name",
|
|
"sales_yn",
|
|
"status",
|
|
].includes(col);
|
|
|
|
if (isJoinTableColumn) {
|
|
// 조인 테이블 컬럼은 조인 별칭 사용
|
|
return `COALESCE(${alias}.${col}::TEXT, '')`;
|
|
} else {
|
|
// 기본 테이블 컬럼은 main 별칭 사용
|
|
return `COALESCE(main.${col}::TEXT, '')`;
|
|
}
|
|
})
|
|
.join(` || '${separator}' || `);
|
|
|
|
return `(${concatParts}) AS ${config.aliasColumn}`;
|
|
}
|
|
})
|
|
.join(", ");
|
|
|
|
// SELECT 절 구성
|
|
const selectClause = joinColumns
|
|
? `${baseColumns}, ${joinColumns}`
|
|
: baseColumns;
|
|
|
|
// FROM 절 (메인 테이블)
|
|
const fromClause = `FROM ${tableName} main`;
|
|
|
|
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거)
|
|
const joinClauses = uniqueReferenceTableConfigs
|
|
.map((config) => {
|
|
const alias = aliasMap.get(config.referenceTable);
|
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
|
})
|
|
.join("\n");
|
|
|
|
// WHERE 절
|
|
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
|
|
|
// ORDER BY 절
|
|
const orderSQL = orderBy ? `ORDER BY ${orderBy}` : "";
|
|
|
|
// LIMIT 및 OFFSET
|
|
let limitSQL = "";
|
|
if (limit !== undefined) {
|
|
limitSQL = `LIMIT ${limit}`;
|
|
if (offset !== undefined) {
|
|
limitSQL += ` OFFSET ${offset}`;
|
|
}
|
|
}
|
|
|
|
// 최종 쿼리 조합
|
|
const query = [
|
|
`SELECT ${selectClause}`,
|
|
fromClause,
|
|
joinClauses,
|
|
whereSQL,
|
|
orderSQL,
|
|
limitSQL,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
logger.info(`🔍 생성된 Entity 조인 쿼리:`, query);
|
|
return {
|
|
query: query,
|
|
aliasMap: aliasMap,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Entity 조인 쿼리 생성 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 조인 전략 결정 (테이블 크기 기반)
|
|
*/
|
|
async determineJoinStrategy(
|
|
joinConfigs: EntityJoinConfig[]
|
|
): Promise<"full_join" | "cache_lookup" | "hybrid"> {
|
|
try {
|
|
const strategies = await Promise.all(
|
|
joinConfigs.map(async (config) => {
|
|
// 여러 컬럼을 조합하는 경우 캐시 전략 사용 불가
|
|
if (config.displayColumns && config.displayColumns.length > 1) {
|
|
console.log(
|
|
`🎯 여러 컬럼 조합으로 인해 조인 전략 사용: ${config.sourceColumn}`,
|
|
config.displayColumns
|
|
);
|
|
return "join";
|
|
}
|
|
|
|
// 참조 테이블의 캐시 가능성 확인
|
|
const displayCol =
|
|
config.displayColumn ||
|
|
config.displayColumns?.[0] ||
|
|
config.referenceColumn;
|
|
logger.info(
|
|
`🔍 캐시 확인용 표시 컬럼: ${config.referenceTable} - ${displayCol}`
|
|
);
|
|
|
|
const cachedData = await referenceCacheService.getCachedReference(
|
|
config.referenceTable,
|
|
config.referenceColumn,
|
|
displayCol
|
|
);
|
|
|
|
return cachedData ? "cache" : "join";
|
|
})
|
|
);
|
|
|
|
// 모두 캐시 가능한 경우
|
|
if (strategies.every((s) => s === "cache")) {
|
|
return "cache_lookup";
|
|
}
|
|
|
|
// 혼합인 경우
|
|
if (strategies.includes("cache") && strategies.includes("join")) {
|
|
return "hybrid";
|
|
}
|
|
|
|
// 기본은 조인
|
|
return "full_join";
|
|
} catch (error) {
|
|
logger.error("조인 전략 결정 실패", error);
|
|
return "full_join"; // 안전한 기본값
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 조인 설정 유효성 검증
|
|
*/
|
|
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
|
|
try {
|
|
logger.info("🔍 조인 설정 검증 상세:", {
|
|
sourceColumn: config.sourceColumn,
|
|
referenceTable: config.referenceTable,
|
|
displayColumns: config.displayColumns,
|
|
displayColumn: config.displayColumn,
|
|
aliasColumn: config.aliasColumn,
|
|
});
|
|
|
|
// 참조 테이블 존재 확인
|
|
const tableExists = await query<{ exists: number }>(
|
|
`SELECT 1 as exists FROM information_schema.tables
|
|
WHERE table_name = $1
|
|
LIMIT 1`,
|
|
[config.referenceTable]
|
|
);
|
|
|
|
if (tableExists.length === 0) {
|
|
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
|
|
return false;
|
|
}
|
|
|
|
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
|
|
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
|
|
logger.info(
|
|
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
|
|
);
|
|
|
|
// 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
|
|
if (displayColumn && displayColumn !== "none") {
|
|
const columnExists = await query<{ exists: number }>(
|
|
`SELECT 1 as exists FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
AND column_name = $2
|
|
LIMIT 1`,
|
|
[config.referenceTable, displayColumn]
|
|
);
|
|
|
|
if (columnExists.length === 0) {
|
|
logger.warn(
|
|
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
|
|
);
|
|
return false;
|
|
}
|
|
logger.info(
|
|
`✅ 표시 컬럼 확인 완료: ${config.referenceTable}.${displayColumn}`
|
|
);
|
|
} else {
|
|
logger.info(
|
|
`🔧 표시 컬럼 검증 생략: display_column이 none이거나 설정되지 않음`
|
|
);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error("조인 설정 검증 실패", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카운트 쿼리 생성 (페이징용)
|
|
*/
|
|
buildCountQuery(
|
|
tableName: string,
|
|
joinConfigs: EntityJoinConfig[],
|
|
whereClause: string = ""
|
|
): string {
|
|
try {
|
|
// 별칭 매핑 생성 (buildJoinQuery와 동일한 로직)
|
|
const aliasMap = new Map<string, string>();
|
|
const usedAliases = new Set<string>();
|
|
|
|
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성
|
|
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
|
|
if (
|
|
!acc.some(
|
|
(existingConfig) =>
|
|
existingConfig.referenceTable === config.referenceTable
|
|
)
|
|
) {
|
|
acc.push(config);
|
|
}
|
|
return acc;
|
|
}, [] as EntityJoinConfig[]);
|
|
|
|
uniqueReferenceTableConfigs.forEach((config) => {
|
|
let baseAlias = config.referenceTable.substring(0, 3);
|
|
let alias = baseAlias;
|
|
let counter = 1;
|
|
|
|
while (usedAliases.has(alias)) {
|
|
alias = `${baseAlias}${counter}`;
|
|
counter++;
|
|
}
|
|
usedAliases.add(alias);
|
|
aliasMap.set(config.referenceTable, alias);
|
|
});
|
|
|
|
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
|
|
const joinClauses = uniqueReferenceTableConfigs
|
|
.map((config) => {
|
|
const alias = aliasMap.get(config.referenceTable);
|
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
|
})
|
|
.join("\n");
|
|
|
|
// WHERE 절
|
|
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
|
|
|
|
// COUNT 쿼리 조합
|
|
const query = [
|
|
`SELECT COUNT(*) as total`,
|
|
`FROM ${tableName} main`,
|
|
joinClauses,
|
|
whereSQL,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return query;
|
|
} catch (error) {
|
|
logger.error("COUNT 쿼리 생성 실패", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
|
*/
|
|
async getReferenceTableColumns(tableName: string): Promise<
|
|
Array<{
|
|
columnName: string;
|
|
displayName: string;
|
|
dataType: string;
|
|
}>
|
|
> {
|
|
try {
|
|
// 1. 테이블의 기본 컬럼 정보 조회
|
|
const columns = await query<{
|
|
column_name: string;
|
|
data_type: string;
|
|
}>(
|
|
`SELECT
|
|
column_name,
|
|
data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
|
ORDER BY ordinal_position`,
|
|
[tableName]
|
|
);
|
|
|
|
// 2. column_labels 테이블에서 라벨 정보 조회
|
|
const columnLabels = await query<{
|
|
column_name: string;
|
|
column_label: string | null;
|
|
}>(
|
|
`SELECT column_name, column_label
|
|
FROM column_labels
|
|
WHERE table_name = $1`,
|
|
[tableName]
|
|
);
|
|
|
|
// 3. 라벨 정보를 맵으로 변환
|
|
const labelMap = new Map<string, string>();
|
|
columnLabels.forEach((label) => {
|
|
if (label.column_name && label.column_label) {
|
|
labelMap.set(label.column_name, label.column_label);
|
|
}
|
|
});
|
|
|
|
// 4. 컬럼 정보와 라벨 정보 결합
|
|
return columns.map((col) => ({
|
|
columnName: col.column_name,
|
|
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
|
|
dataType: col.data_type,
|
|
}));
|
|
} catch (error) {
|
|
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
export const entityJoinService = new EntityJoinService();
|