sql-analyzer.js 7.14 KB
// SQL预执行分析器
import { executeExplain } from './db.js'

/**
 * 对SQL进行预执行检查
 * 1. 语法检查(通过EXPLAIN)
 * 2. 执行计划分析(全表扫描、索引使用等)
 * 3. 锁风险分析
 * 4. 预估影响行数
 */
export async function analyzeSql(connId, sql) {
  const result = {
    syntaxOk: false,
    syntaxError: null,
    warnings: [],
    risks: [],
    explainRows: [],
    estimatedRows: 0,
    sqlType: detectSqlType(sql),
  }

  // 检测SQL类型,非SELECT给出风险提示
  if (result.sqlType !== 'SELECT') {
    result.risks.push({
      level: 'high',
      type: 'DML_OPERATION',
      message: `当前SQL为 ${result.sqlType} 语句,可能造成数据变更和锁表风险`,
    })
  }

  // 语法检查:通过EXPLAIN验证
  try {
    const explainRows = await executeExplain(connId, sql)
    if (explainRows) {
      result.syntaxOk = true
      result.explainRows = explainRows

      // 分析执行计划
      for (const row of explainRows) {
        const type = row.type || row.access_type
        const key = row.key || row.key_name
        const extra = row.Extra || row.extra || ''
        const rows = row.rows || row.rows_examined || 0
        const table = row.table_name || row.table || ''

        // 累计预估行数
        result.estimatedRows += Number(rows) || 0

        // 检测全表扫描
        if (type === 'ALL') {
          result.warnings.push({
            level: 'warn',
            type: 'FULL_TABLE_SCAN',
            message: `表 ${table} 将进行全表扫描,预估扫描 ${rows} 行`,
            table,
          })
        }

        // 检测未使用索引
        if (!key || key === 'NULL' || key === null) {
          result.warnings.push({
            level: 'warn',
            type: 'NO_INDEX',
            message: `表 ${table} 未使用索引`,
            table,
          })
        }

        // 检测filesort
        if (extra.includes('Using filesort')) {
          result.warnings.push({
            level: 'warn',
            type: 'FILESORT',
            message: `表 ${table} 使用了文件排序(filesort),大数据量下可能较慢`,
            table,
          })
        }

        // 检测临时表
        if (extra.includes('Using temporary')) {
          result.risks.push({
            level: 'medium',
            type: 'TEMPORARY_TABLE',
            message: `表 ${table} 使用了临时表,可能影响性能`,
            table,
          })
        }
      }

      // 大数据量风险
      if (result.estimatedRows > 100000) {
        result.risks.push({
          level: 'high',
          type: 'LARGE_RESULT',
          message: `预估扫描行数 ${result.estimatedRows.toLocaleString()} 行,可能消耗大量资源`,
        })
      } else if (result.estimatedRows > 10000) {
        result.warnings.push({
          level: 'warn',
          type: 'MEDIUM_RESULT',
          message: `预估扫描行数 ${result.estimatedRows.toLocaleString()} 行,请关注执行时间`,
        })
      }
    } else {
      // EXPLAIN无法执行,但SQL可能是正确的(如某些DDL)
      result.syntaxOk = true
      result.warnings.push({
        level: 'info',
        type: 'NO_EXPLAIN',
        message: '该SQL类型无法通过EXPLAIN分析,请自行评估风险',
      })
    }
  } catch (e) {
    result.syntaxOk = false
    result.syntaxError = e.message
  }

  return result
}

/**
 * 检测SQL类型
 */
function detectSqlType(sql) {
  const normalized = normalizeSqlForDetection(sql)
  if (/^\s*SELECT\b/i.test(normalized)) return 'SELECT'
  if (/^\s*WITH\b/i.test(normalized)) return 'SELECT' // CTE + SELECT
  if (/^\s*INSERT\b/i.test(normalized)) return 'INSERT'
  if (/^\s*UPDATE\b/i.test(normalized)) return 'UPDATE'
  if (/^\s*DELETE\b/i.test(normalized)) return 'DELETE'
  if (/^\s*REPLACE\b/i.test(normalized)) return 'REPLACE'
  if (/^\s*CREATE\b/i.test(normalized)) return 'CREATE'
  if (/^\s*ALTER\b/i.test(normalized)) return 'ALTER'
  if (/^\s*DROP\b/i.test(normalized)) return 'DROP'
  if (/^\s*TRUNCATE\b/i.test(normalized)) return 'TRUNCATE'
  if (/^\s*LOAD\s+DATA\b/i.test(normalized)) return 'LOAD_DATA'
  if (/^\s*CALL\b/i.test(normalized)) return 'CALL'
  if (/^\s*GRANT\b/i.test(normalized)) return 'GRANT'
  if (/^\s*REVOKE\b/i.test(normalized)) return 'REVOKE'
  return 'OTHER'
}

/**
 * 去除SQL前缀注释和空白,用于类型检测
 */
function normalizeSqlForDetection(sql) {
  // 去除单行注释 (-- 和 #)
  let normalized = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ')
  // 去除多行注释
  normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, ' ')
  return normalized.trim()
}

/**
 * 校验SQL是否为只读SELECT语句
 * 支持检测多语句场景(分号分隔)
 * @param {string} sql
 * @returns {{ allowed: boolean, reason: string|null, detectedType: string }}
 */
export function validateSelectOnly(sql) {
  if (!sql || !sql.trim()) {
    return { allowed: false, reason: 'SQL内容不能为空', detectedType: 'EMPTY' }
  }

  // 分号分隔的多语句逐条检测
  const statements = splitSqlStatements(sql)

  for (let i = 0; i < statements.length; i++) {
    const stmt = statements[i]
    if (!stmt.trim()) continue

    const type = detectSqlType(stmt)

    if (type === 'SELECT') continue // SELECT允许

    // 非SELECT语句,拒绝
    const DANGEROUS_LABELS = {
      'INSERT': 'INSERT(插入数据)',
      'UPDATE': 'UPDATE(更新数据)',
      'DELETE': 'DELETE(删除数据)',
      'REPLACE': 'REPLACE(替换数据)',
      'CREATE': 'CREATE(创建对象)',
      'ALTER': 'ALTER(修改结构)',
      'DROP': 'DROP(删除对象)',
      'TRUNCATE': 'TRUNCATE(清空表)',
      'LOAD_DATA': 'LOAD DATA(导入数据)',
      'CALL': 'CALL(调用存储过程)',
      'GRANT': 'GRANT(授权)',
      'REVOKE': 'REVOKE(回收权限)',
      'OTHER': '未知类型',
    }
    const label = DANGEROUS_LABELS[type] || type
    return {
      allowed: false,
      reason: `脚本仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${label},不允许执行`,
      detectedType: type,
    }
  }

  return { allowed: true, reason: null, detectedType: 'SELECT' }
}

/**
 * 按分号分割SQL语句(忽略引号内的分号)
 */
function splitSqlStatements(sql) {
  const statements = []
  let current = ''
  let inSingleQuote = false
  let inDoubleQuote = false

  for (let i = 0; i < sql.length; i++) {
    const ch = sql[i]
    const next = sql[i + 1]

    if (ch === "'" && !inDoubleQuote) {
      if (inSingleQuote && next === "'") {
        // 转义单引号 ''
        current += ch + next
        i++
        continue
      }
      inSingleQuote = !inSingleQuote
      current += ch
      continue
    }

    if (ch === '"' && !inSingleQuote) {
      if (inDoubleQuote && next === '"') {
        current += ch + next
        i++
        continue
      }
      inDoubleQuote = !inDoubleQuote
      current += ch
      continue
    }

    if (ch === ';' && !inSingleQuote && !inDoubleQuote) {
      statements.push(current)
      current = ''
      continue
    }

    current += ch
  }

  if (current.trim()) {
    statements.push(current)
  }

  return statements
}