/** * POP 공통 SQL 빌더 * * DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티. * 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일). * * 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고, * 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다. * 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정. * * 보안: * - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프) * - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거 */ import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; // ===== 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}'`; } // ===== 식별자 검증 (테이블명, 컬럼명) ===== /** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ function sanitizeIdentifier(name: string): string { // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 return name.replace(/[^a-zA-Z0-9_.]/g, ""); } // ===== 설정 완료 여부 검증 ===== /** * DataSourceConfig의 필수값이 모두 채워졌는지 검증 * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 * SQL을 생성하지 않도록 사전 차단 * * @returns null이면 유효, 문자열이면 미완료 사유 */ export 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 빌더 ===== /** * 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(" "); }