/** * pop-dashboard 데이터 페처 * * @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정 * 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체. * * 보안: * - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리 * - 멀티테넌시: autoFilter 자동 전달 * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 */ import { apiClient } from "@/lib/api/client"; import { dashboardApi } from "@/lib/api/dashboard"; import { dataApi } from "@/lib/api/data"; import { tableManagementApi } from "@/lib/api/tableManagement"; import type { TableInfo } from "@/lib/api/tableManagement"; import type { DataSourceConfig, DataSourceFilter } from "../../types"; // ===== 타입 re-export ===== export type { TableInfo }; // ===== 반환 타입 ===== export interface AggregatedResult { value: number; rows?: Record[]; error?: string; } export interface ColumnInfo { name: string; type: string; udtName: string; isPrimaryKey?: boolean; } // ===== SQL 값 이스케이프 ===== /** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ function escapeSQL(value: unknown): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "number") return String(value); if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; // 문자열: 작은따옴표 이스케이프 const str = String(value).replace(/'/g, "''"); return `'${str}'`; } // ===== 설정 완료 여부 검증 ===== /** * DataSourceConfig의 필수값이 모두 채워졌는지 검증 * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 * SQL을 생성하지 않도록 사전 차단 * * @returns null이면 유효, 문자열이면 미완료 사유 */ function validateDataSourceConfig(config: DataSourceConfig): string | null { // 테이블명 필수 if (!config.tableName || !config.tableName.trim()) { return "테이블이 선택되지 않았습니다"; } // 집계 함수가 설정되었으면 대상 컬럼도 필수 // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) if (config.aggregation) { const aggType = config.aggregation.type?.toLowerCase(); const aggCol = config.aggregation.column?.trim(); if (aggType !== "count" && !aggCol) { return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; } } // 조인이 있으면 조인 조건 필수 if (config.joins?.length) { for (const join of config.joins) { if (!join.targetTable?.trim()) { return "조인 대상 테이블이 선택되지 않았습니다"; } if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { return "조인 조건 컬럼이 설정되지 않았습니다"; } } } return null; } // ===== 필터 조건 SQL 생성 ===== /** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ function buildWhereClause(filters: DataSourceFilter[]): string { // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) const validFilters = filters.filter((f) => f.column?.trim()); if (!validFilters.length) return ""; const conditions = validFilters.map((f) => { const col = sanitizeIdentifier(f.column); switch (f.operator) { case "between": { const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; } case "in": { const arr = Array.isArray(f.value) ? f.value : [f.value]; const vals = arr.map(escapeSQL).join(", "); return `${col} IN (${vals})`; } case "like": return `${col} LIKE ${escapeSQL(f.value)}`; default: return `${col} ${f.operator} ${escapeSQL(f.value)}`; } }); return `WHERE ${conditions.join(" AND ")}`; } // ===== 식별자 검증 (테이블명, 컬럼명) ===== /** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ function sanitizeIdentifier(name: string): string { // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 return name.replace(/[^a-zA-Z0-9_.]/g, ""); } // ===== 집계 SQL 빌더 ===== /** * DataSourceConfig를 SELECT SQL로 변환 * * @param config - 데이터 소스 설정 * @returns SQL 문자열 */ export function buildAggregationSQL(config: DataSourceConfig): string { const tableName = sanitizeIdentifier(config.tableName); // SELECT 절 let selectClause: string; if (config.aggregation) { const aggType = config.aggregation.type.toUpperCase(); const aggCol = config.aggregation.column?.trim() ? sanitizeIdentifier(config.aggregation.column) : ""; // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 if (!aggCol) { selectClause = aggType === "COUNT" ? "COUNT(*) as value" : `${aggType}(${tableName}.*) as value`; } else { selectClause = `${aggType}(${aggCol}) as value`; } // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 if (config.aggregation.groupBy?.length) { const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); selectClause = `${groupCols}, ${selectClause}`; } } else { selectClause = "*"; } // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) let fromClause = tableName; if (config.joins?.length) { for (const join of config.joins) { // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { continue; } const joinTable = sanitizeIdentifier(join.targetTable); const joinType = join.joinType.toUpperCase(); const srcCol = sanitizeIdentifier(join.on.sourceColumn); const tgtCol = sanitizeIdentifier(join.on.targetColumn); fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; } } // WHERE 절 const whereClause = config.filters?.length ? buildWhereClause(config.filters) : ""; // GROUP BY 절 let groupByClause = ""; if (config.aggregation?.groupBy?.length) { groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; } // ORDER BY 절 let orderByClause = ""; if (config.sort?.length) { const sortCols = config.sort .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) .join(", "); orderByClause = `ORDER BY ${sortCols}`; } // LIMIT 절 const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; return [ `SELECT ${selectClause}`, `FROM ${fromClause}`, whereClause, groupByClause, orderByClause, limitClause, ] .filter(Boolean) .join(" "); } // ===== 메인 데이터 페처 ===== /** * DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환 * * API 선택 전략: * 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery() * 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원) * 3. 단순 조회 -> dataApi.getTableData() * * @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체 */ export async function fetchAggregatedData( config: DataSourceConfig ): Promise { try { // 설정 완료 여부 검증 (미완료 시 SQL 전송 차단) const validationError = validateDataSourceConfig(config); if (validationError) { return { value: 0, rows: [], error: validationError }; } // 집계 또는 조인이 있으면 SQL 직접 실행 if (config.aggregation || (config.joins && config.joins.length > 0)) { const sql = buildAggregationSQL(config); // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 let queryResult: { columns: string[]; rows: any[] }; try { // 1차: apiClient (axios 기반, 인증/세션 안정적) const response = await apiClient.post("/dashboards/execute-query", { query: sql }); if (response.data?.success && response.data?.data) { queryResult = response.data.data; } else { throw new Error(response.data?.message || "쿼리 실행 실패"); } } catch { // 2차: dashboardApi (fetch 기반, 폴백) queryResult = await dashboardApi.executeQuery(sql); } if (queryResult.rows.length === 0) { return { value: 0, rows: [] }; } // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 // Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리 const processedRows = queryResult.rows.map((row: Record) => { const converted: Record = { ...row }; for (const key of Object.keys(converted)) { const val = converted[key]; if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { converted[key] = Number(val); } } return converted; }); // 첫 번째 행의 value 컬럼 추출 const firstRow = processedRows[0]; const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)); return { value: Number.isFinite(numericValue) ? numericValue : 0, rows: processedRows, }; } // 단순 조회 const tableResult = await dataApi.getTableData(config.tableName, { page: 1, size: config.limit ?? 100, sortBy: config.sort?.[0]?.column, sortOrder: config.sort?.[0]?.direction, filters: config.filters?.reduce( (acc, f) => { acc[f.column] = f.value; return acc; }, {} as Record ), }); // 단순 조회 시에는 행 수를 value로 사용 return { value: tableResult.total ?? tableResult.data.length, rows: tableResult.data, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; return { value: 0, error: message }; } } // ===== 설정 패널용 헬퍼 ===== /** * 테이블 목록 조회 (설정 패널 드롭다운용) * dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되, * 테이블 목록은 별도로 필요하므로 간단히 반환 */ export async function fetchTableColumns( tableName: string ): Promise { // 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적) try { const response = await tableManagementApi.getTableSchema(tableName); if (response.success && response.data) { const cols = Array.isArray(response.data) ? response.data : []; if (cols.length > 0) { return cols.map((col: any) => ({ name: col.columnName || col.column_name || col.name, type: col.dataType || col.data_type || col.type || "unknown", udtName: col.dbType || col.udt_name || col.udtName || "unknown", isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true", })); } } } catch { // tableManagementApi 실패 시 dashboardApi로 폴백 } // 2차: dashboardApi (fetch 기반, 폴백) try { const schema = await dashboardApi.getTableSchema(tableName); return schema.columns.map((col) => ({ name: col.name, type: col.type, udtName: col.udtName, })); } catch { return []; } } /** * 테이블 목록 조회 (설정 패널 Combobox용) * tableManagementApi.getTableList() 래핑 * * @INFRA-EXTRACT: useDataSource 완성 후 교체 예정 */ export async function fetchTableList(): Promise { try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { return response.data; } return []; } catch { return []; } }