ERP-node/frontend/hooks/pop/popSqlBuilder.ts

196 lines
6.4 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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(" ");
}