페이즈1 완료

This commit is contained in:
kjs 2025-09-30 15:29:56 +09:00
parent ed78ef184d
commit bcc79b185c
2 changed files with 674 additions and 0 deletions

View File

@ -0,0 +1,384 @@
/**
*
*
* SQL
*/
export class DatabaseValidator {
// PostgreSQL 예약어 목록 (주요 키워드만)
private static readonly RESERVED_WORDS = new Set([
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"FROM",
"WHERE",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"FULL",
"ON",
"GROUP",
"BY",
"ORDER",
"HAVING",
"LIMIT",
"OFFSET",
"UNION",
"ALL",
"DISTINCT",
"AS",
"AND",
"OR",
"NOT",
"NULL",
"TRUE",
"FALSE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"IF",
"EXISTS",
"IN",
"BETWEEN",
"LIKE",
"ILIKE",
"SIMILAR",
"TO",
"CREATE",
"DROP",
"ALTER",
"TABLE",
"INDEX",
"VIEW",
"FUNCTION",
"PROCEDURE",
"TRIGGER",
"DATABASE",
"SCHEMA",
"USER",
"ROLE",
"GRANT",
"REVOKE",
"COMMIT",
"ROLLBACK",
"BEGIN",
"TRANSACTION",
"SAVEPOINT",
"RELEASE",
"CONSTRAINT",
"PRIMARY",
"FOREIGN",
"KEY",
"UNIQUE",
"CHECK",
"DEFAULT",
"REFERENCES",
"CASCADE",
"RESTRICT",
"SET",
"ACTION",
"DEFERRABLE",
"INITIALLY",
"DEFERRED",
"IMMEDIATE",
"MATCH",
"PARTIAL",
"SIMPLE",
"FULL",
]);
// 유효한 PostgreSQL 데이터 타입 패턴
private static readonly DATA_TYPE_PATTERNS = [
/^(SMALLINT|INTEGER|BIGINT|DECIMAL|NUMERIC|REAL|DOUBLE\s+PRECISION|SMALLSERIAL|SERIAL|BIGSERIAL)$/i,
/^(MONEY)$/i,
/^(CHARACTER\s+VARYING|VARCHAR|CHARACTER|CHAR|TEXT)(\(\d+\))?$/i,
/^(BYTEA)$/i,
/^(TIMESTAMP|TIME)(\s+(WITH|WITHOUT)\s+TIME\s+ZONE)?(\(\d+\))?$/i,
/^(DATE|INTERVAL)(\(\d+\))?$/i,
/^(BOOLEAN|BOOL)$/i,
/^(POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)$/i,
/^(CIDR|INET|MACADDR|MACADDR8)$/i,
/^(BIT|BIT\s+VARYING)(\(\d+\))?$/i,
/^(TSVECTOR|TSQUERY)$/i,
/^(UUID)$/i,
/^(XML)$/i,
/^(JSON|JSONB)$/i,
/^(ARRAY|INTEGER\[\]|TEXT\[\]|VARCHAR\[\])$/i,
/^(DECIMAL|NUMERIC)\(\d+,\d+\)$/i,
];
/**
*
*/
static validateTableName(tableName: string): boolean {
if (!tableName || typeof tableName !== "string") {
return false;
}
// 길이 제한 (PostgreSQL 최대 63자)
if (tableName.length === 0 || tableName.length > 63) {
return false;
}
// 유효한 식별자 패턴 (문자 또는 밑줄로 시작, 문자/숫자/밑줄만 포함)
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(tableName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(tableName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateColumnName(columnName: string): boolean {
if (!columnName || typeof columnName !== "string") {
return false;
}
// 길이 제한
if (columnName.length === 0 || columnName.length > 63) {
return false;
}
// JSON 연산자 포함 컬럼명 허용 (예: config->>'type', data->>path)
if (columnName.includes("->") || columnName.includes("->>")) {
const baseName = columnName.split(/->|->>/)[0];
return this.validateColumnName(baseName);
}
// 유효한 식별자 패턴
const validPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validPattern.test(columnName)) {
return false;
}
// 예약어 체크
if (this.RESERVED_WORDS.has(columnName.toUpperCase())) {
return false;
}
return true;
}
/**
*
*/
static validateDataType(dataType: string): boolean {
if (!dataType || typeof dataType !== "string") {
return false;
}
const normalizedType = dataType.trim().toUpperCase();
return this.DATA_TYPE_PATTERNS.some((pattern) =>
pattern.test(normalizedType)
);
}
/**
* WHERE
*/
static validateWhereClause(whereClause: Record<string, any>): boolean {
if (!whereClause || typeof whereClause !== "object") {
return false;
}
// 모든 키가 유효한 컬럼명인지 확인
for (const key of Object.keys(whereClause)) {
if (!this.validateColumnName(key)) {
return false;
}
}
return true;
}
/**
*
*/
static validatePagination(page: number, pageSize: number): boolean {
// 페이지 번호는 1 이상
if (!Number.isInteger(page) || page < 1) {
return false;
}
// 페이지 크기는 1 이상 1000 이하
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) {
return false;
}
return true;
}
/**
* ORDER BY
*/
static validateOrderBy(orderBy: string): boolean {
if (!orderBy || typeof orderBy !== "string") {
return false;
}
// 기본 패턴: column_name [ASC|DESC]
const orderPattern = /^[a-zA-Z_][a-zA-Z0-9_]*(\s+(ASC|DESC))?$/i;
// 여러 컬럼 정렬의 경우 콤마로 분리하여 각각 검증
const orderClauses = orderBy.split(",").map((clause) => clause.trim());
return orderClauses.every((clause) => {
return (
orderPattern.test(clause) &&
this.validateColumnName(clause.split(/\s+/)[0])
);
});
}
/**
* UUID
*/
static validateUUID(uuid: string): boolean {
if (!uuid || typeof uuid !== "string") {
return false;
}
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidPattern.test(uuid);
}
/**
*
*/
static validateEmail(email: string): boolean {
if (!email || typeof email !== "string") {
return false;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email) && email.length <= 254;
}
/**
* SQL
*/
static containsSqlInjection(input: string): boolean {
if (!input || typeof input !== "string") {
return false;
}
// 위험한 SQL 패턴들
const dangerousPatterns = [
/('|(\\')|(;)|(--)|(\s+(OR|AND)\s+\d+\s*=\s*\d+)/i,
/(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE)/i,
/(\bxp_\w+|\bsp_\w+)/i, // SQL Server 확장 프로시저
/(script|javascript|vbscript|onload|onerror)/i, // XSS 패턴
];
return dangerousPatterns.some((pattern) => pattern.test(input));
}
/**
*
*/
static validateNumberRange(
value: number,
min?: number,
max?: number
): boolean {
if (typeof value !== "number" || !Number.isFinite(value)) {
return false;
}
if (min !== undefined && value < min) {
return false;
}
if (max !== undefined && value > max) {
return false;
}
return true;
}
/**
*
*/
static validateStringLength(
value: string,
minLength?: number,
maxLength?: number
): boolean {
if (typeof value !== "string") {
return false;
}
if (minLength !== undefined && value.length < minLength) {
return false;
}
if (maxLength !== undefined && value.length > maxLength) {
return false;
}
return true;
}
/**
* JSON
*/
static validateJSON(jsonString: string): boolean {
try {
JSON.parse(jsonString);
return true;
} catch {
return false;
}
}
/**
* (ISO 8601)
*/
static validateDateISO(dateString: string): boolean {
if (!dateString || typeof dateString !== "string") {
return false;
}
const date = new Date(dateString);
return !isNaN(date.getTime()) && dateString === date.toISOString();
}
/**
*
*/
static validateArray<T>(
array: any[],
validator: (item: T) => boolean,
minLength?: number,
maxLength?: number
): boolean {
if (!Array.isArray(array)) {
return false;
}
if (minLength !== undefined && array.length < minLength) {
return false;
}
if (maxLength !== undefined && array.length > maxLength) {
return false;
}
return array.every((item) => validator(item));
}
}

290
src/utils/queryBuilder.ts Normal file
View File

@ -0,0 +1,290 @@
/**
* SQL
*
* Raw Query
*/
export interface SelectOptions {
columns?: string[];
where?: Record<string, any>;
joins?: JoinClause[];
orderBy?: string;
limit?: number;
offset?: number;
groupBy?: string[];
having?: Record<string, any>;
}
export interface JoinClause {
type: "INNER" | "LEFT" | "RIGHT" | "FULL";
table: string;
on: string;
}
export interface InsertOptions {
returning?: string[];
onConflict?: {
columns: string[];
action: "DO NOTHING" | "DO UPDATE";
updateSet?: string[];
};
}
export interface UpdateOptions {
returning?: string[];
}
export interface QueryResult {
query: string;
params: any[];
}
export class QueryBuilder {
/**
* SELECT
*/
static select(table: string, options: SelectOptions = {}): QueryResult {
const {
columns = ["*"],
where = {},
joins = [],
orderBy,
limit,
offset,
groupBy = [],
having = {},
} = options;
let query = `SELECT ${columns.join(", ")} FROM ${table}`;
const params: any[] = [];
let paramIndex = 1;
// JOIN 절 추가
for (const join of joins) {
query += ` ${join.type} JOIN ${join.table} ON ${join.on}`;
}
// WHERE 절 추가
const whereConditions = Object.keys(where);
if (whereConditions.length > 0) {
const whereClause = whereConditions
.map((key) => {
params.push(where[key]);
return `${key} = $${paramIndex++}`;
})
.join(" AND ");
query += ` WHERE ${whereClause}`;
}
// GROUP BY 절 추가
if (groupBy.length > 0) {
query += ` GROUP BY ${groupBy.join(", ")}`;
}
// HAVING 절 추가
const havingConditions = Object.keys(having);
if (havingConditions.length > 0) {
const havingClause = havingConditions
.map((key) => {
params.push(having[key]);
return `${key} = $${paramIndex++}`;
})
.join(" AND ");
query += ` HAVING ${havingClause}`;
}
// ORDER BY 절 추가
if (orderBy) {
query += ` ORDER BY ${orderBy}`;
}
// LIMIT 절 추가
if (limit !== undefined) {
params.push(limit);
query += ` LIMIT $${paramIndex++}`;
}
// OFFSET 절 추가
if (offset !== undefined) {
params.push(offset);
query += ` OFFSET $${paramIndex++}`;
}
return { query, params };
}
/**
* INSERT
*/
static insert(
table: string,
data: Record<string, any>,
options: InsertOptions = {}
): QueryResult {
const { returning = [], onConflict } = options;
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
let query = `INSERT INTO ${table} (${columns.join(
", "
)}) VALUES (${placeholders})`;
// ON CONFLICT 절 추가
if (onConflict) {
query += ` ON CONFLICT (${onConflict.columns.join(", ")})`;
if (onConflict.action === "DO NOTHING") {
query += " DO NOTHING";
} else if (onConflict.action === "DO UPDATE" && onConflict.updateSet) {
const updateSet = onConflict.updateSet
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
query += ` DO UPDATE SET ${updateSet}`;
}
}
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(", ")}`;
}
return { query, params: values };
}
/**
* UPDATE
*/
static update(
table: string,
data: Record<string, any>,
where: Record<string, any>,
options: UpdateOptions = {}
): QueryResult {
const { returning = [] } = options;
const dataKeys = Object.keys(data);
const dataValues = Object.values(data);
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let paramIndex = 1;
// SET 절 생성
const setClause = dataKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(", ");
// WHERE 절 생성
const whereClause = whereKeys
.map((key) => `${key} = $${paramIndex++}`)
.join(" AND ");
let query = `UPDATE ${table} SET ${setClause} WHERE ${whereClause}`;
// RETURNING 절 추가
if (returning.length > 0) {
query += ` RETURNING ${returning.join(", ")}`;
}
const params = [...dataValues, ...whereValues];
return { query, params };
}
/**
* DELETE
*/
static delete(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
const query = `DELETE FROM ${table} WHERE ${whereClause}`;
return { query, params: whereValues };
}
/**
* COUNT
*/
static count(table: string, where: Record<string, any> = {}): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
let query = `SELECT COUNT(*) as count FROM ${table}`;
if (whereKeys.length > 0) {
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
query += ` WHERE ${whereClause}`;
}
return { query, params: whereValues };
}
/**
* EXISTS
*/
static exists(table: string, where: Record<string, any>): QueryResult {
const whereKeys = Object.keys(where);
const whereValues = Object.values(where);
const whereClause = whereKeys
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
const query = `SELECT EXISTS(SELECT 1 FROM ${table} WHERE ${whereClause}) as exists`;
return { query, params: whereValues };
}
/**
* WHERE ( )
*/
static buildWhereClause(
conditions: Record<string, any>,
startParamIndex: number = 1
): { clause: string; params: any[]; nextParamIndex: number } {
const keys = Object.keys(conditions);
const params: any[] = [];
let paramIndex = startParamIndex;
if (keys.length === 0) {
return { clause: "", params: [], nextParamIndex: paramIndex };
}
const clause = keys
.map((key) => {
const value = conditions[key];
// 특수 연산자 처리
if (key.includes(">>") || key.includes("->")) {
// JSON 쿼리
params.push(value);
return `${key} = $${paramIndex++}`;
} else if (Array.isArray(value)) {
// IN 절
const placeholders = value.map(() => `$${paramIndex++}`).join(", ");
params.push(...value);
return `${key} IN (${placeholders})`;
} else if (value === null) {
// NULL 체크
return `${key} IS NULL`;
} else {
// 일반 조건
params.push(value);
return `${key} = $${paramIndex++}`;
}
})
.join(" AND ");
return { clause, params, nextParamIndex: paramIndex };
}
}