ERP-node/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts

260 lines
7.6 KiB
TypeScript

/**
* pop-dashboard 데이터 페처
*
* @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정
* 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체.
*
* 보안:
* - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리
* - 멀티테넌시: autoFilter 자동 전달
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
*/
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<string, unknown>[];
error?: string;
}
export interface ColumnInfo {
name: string;
type: string;
udtName: string;
}
// ===== 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 생성 =====
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
function buildWhereClause(filters: DataSourceFilter[]): string {
if (!filters.length) return "";
const conditions = filters.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 = sanitizeIdentifier(config.aggregation.column);
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) {
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<AggregatedResult> {
try {
// 집계 또는 조인이 있으면 SQL 직접 실행
if (config.aggregation || (config.joins && config.joins.length > 0)) {
const sql = buildAggregationSQL(config);
const result = await dashboardApi.executeQuery(sql);
if (result.rows.length === 0) {
return { value: 0, rows: [] };
}
// 첫 번째 행의 value 컬럼 추출
const firstRow = result.rows[0];
const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0);
return {
value: Number.isFinite(numericValue) ? numericValue : 0,
rows: result.rows,
};
}
// 단순 조회
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<string, unknown>
),
});
// 단순 조회 시에는 행 수를 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<ColumnInfo[]> {
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<TableInfo[]> {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
return response.data;
}
return [];
} catch {
return [];
}
}