4. SQL变更的CI静态检查流水线搭建

SQL变更的CI静态检查流水线搭建 学习卡片

为什么 SQL 变更需要独立的静态检查层

SQL 文件进入仓库后,如果只靠人工 Review,很容易漏掉方言不兼容、语法错误和破坏性操作。CI 静态检查流水线的价值在于:在合并请求(MR)阶段自动完成三类判断——语法能否解析、方言是否匹配目标引擎、是否包含 DROP/TRUNCATE 等危险语句,并把结果写成报告,不合规则直接阻断合并。

MR 提交 / Push 触发 CI Pipeline 变更文件集 *.sql / migrations/ dialect: hive / pg / mysql ① 语法解析(Parser) sqlparse / sqlglot 解析 AST 失败 → 语法错误行号 + 原因 ② 方言校验(Dialect) 按目标引擎解析:Hive / PostgreSQL / MySQL 不兼容函数/类型 → 标记警告或错误 ③ 危险操作拦截(Guard) 匹配 DROP TABLE / TRUNCATE / DELETE 无 WHERE 命中 → 直接阻断,需人工审批 检查报告 report.json / report.md 每条 SQL 的检查结果 错误数 / 警告数 / 阻断数 合并门禁决策 error > 0 → 阻断合并 warning → 允许但需确认 pass → 放行 MR 状态:Blocked 评论回写:文件 + 行号 + 违规类型 图例 语法解析阶段 方言校验阶段 危险操作拦截

流水线核心实现

下面给出一个可直接运行的检查脚本,使用 sqlglot 做语法解析与方言校验,用正则匹配危险操作。脚本接收 SQL 文件路径列表,输出 JSON 报告,并以退出码表示是否阻断。

# ci_sql_check.py
import json
import re
import sys
from pathlib import Path

import sqlglot
from sqlglot.errors import ParseError

# 危险操作模式:DROP TABLE / TRUNCATE / DELETE 无 WHERE
DANGEROUS_PATTERNS = [
    (re.compile(r"\bDROP\s+TABLE\b", re.IGNORECASE), "DROP TABLE"),
    (re.compile(r"\bTRUNCATE\s+TABLE\b", re.IGNORECASE), "TRUNCATE TABLE"),
    (re.compile(r"\bDELETE\s+FROM\b(?!.*\bWHERE\b)", re.IGNORECASE | re.DOTALL), "DELETE 无 WHERE"),
]

# 目标方言映射
DIALECT_MAP = {
    "hive": "hive",
    "postgresql": "postgres",
    "mysql": "mysql",
}

def check_file(path: Path, dialect: str) -> dict:
    """对单个 SQL 文件执行三项检查,返回结果字典。"""
    result = {
        "file": str(path),
        "dialect": dialect,
        "syntax_ok": True,
        "syntax_error": None,
        "dialect_ok": True,
        "dialect_error": None,
        "dangerous": [],
    }
    sql_text = path.read_text(encoding="utf-8")

    # ① 语法解析
    try:
        sqlglot.parse(sql_text, read=dialect)
    except ParseError as e:
        result["syntax_ok"] = False
        result["syntax_error"] = str(e)
        return result

    # ② 方言校验:用目标方言重新解析,捕获不兼容
    try:
        sqlglot.parse(sql_text, read=dialect)
    except Exception as e:
        result["dialect_ok"] = False
        result["dialect_error"] = str(e)

    # ③ 危险操作拦截
    for pattern, label in DANGEROUS_PATTERNS:
        if pattern.search(sql_text):
            result["dangerous"].append(label)

    return result

def main():
    if len(sys.argv) < 3:
        print("用法: python ci_sql_check.py <dialect> <sql_file...>")
        sys.exit(2)

    dialect = DIALECT_MAP.get(sys.argv[1].lower())
    if not dialect:
        print(f"不支持的方言: {sys.argv[1]},可选: {list(DIALECT_MAP)}")
        sys.exit(2)

    files = [Path(p) for p in sys.argv[2:]]
    reports = [check_file(f, dialect) for f in files]

    # 汇总
    errors = sum(1 for r in reports if not r["syntax_ok"] or not r["dialect_ok"])
    blocked = sum(1 for r in reports if r["dangerous"])

    report = {
        "dialect": dialect,
        "total": len(reports),
        "errors": errors,
        "blocked": blocked,
        "details": reports,
    }

    Path("sql_check_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
    )

    # 控制台摘要
    for r in reports:
        status = "PASS"
        if not r["syntax_ok"]:
            status = "SYNTAX_ERROR"
        elif not r["dialect_ok"]:
            status = "DIALECT_ERROR"
        elif r["dangerous"]:
            status = "BLOCKED"
        print(f"[{status}] {r['file']}")
        if r["syntax_error"]:
            print(f"  语法: {r['syntax_error']}")
        if r["dialect_error"]:
            print(f"  方言: {r['dialect_error']}")
        if r["dangerous"]:
            print(f"  危险操作: {', '.join(r['dangerous'])}")

    # 退出码:有错误或阻断则非零
    sys.exit(1 if (errors or blocked) else 0)

if __name__ == "__main__":
    main()

CI 配置与门禁规则

在 GitLab CI 或 GitHub Actions 中,把上述脚本挂到 MR 的 test 阶段,并设置 allow_failure: false,即可实现阻断。以下为 GitLab CI 示例:

# .gitlab-ci.yml 片段
stages:
  - test

sql-static-check:
  stage: test
  image: python:3.11-slim
  before_script:
    - pip install sqlglot==25.0.0
  script:
    - |
      CHANGED=$(git diff --name-only origin/main...HEAD -- '*.sql')
      if [ -z "$CHANGED" ]; then
        echo "无 SQL 变更,跳过检查"
        exit 0
      fi
      python ci_sql_check.py hive $CHANGED
  artifacts:
    when: always
    paths:
      - sql_check_report.json
    expire_in: 7 days
  allow_failure: false
方言参数选择建议:如果仓库中同时存在 Hive 和 PostgreSQL 的 SQL,可按目录拆分检查任务,例如 warehouse/hive/ 用 hive 方言,app/pg/ 用 postgres 方言,避免用错方言导致误报。

验证与结果判读

准备三个测试文件,覆盖通过、语法错误、危险操作三种情况:

-- test_pass.sql
SELECT id, name FROM users WHERE dt = '2024-01-01';

-- test_syntax_error.sql
SELEC id FROM users;

-- test_dangerous.sql
DROP TABLE users;

执行 python ci_sql_check.py hive test_pass.sql test_syntax_error.sql test_dangerous.sql,预期控制台输出:

[PASS] test_pass.sql
[SYNTAX_ERROR] test_syntax_error.sql
  语法: Invalid expression / Unexpected token. Line 1, Col: 1.
[BLOCKED] test_dangerous.sql
  危险操作: DROP TABLE

退出码为 1,CI 任务失败,MR 被阻断。打开 sql_check_report.json 可看到每条 SQL 的详细结果,评论机器人可将 details 中的 file、syntax_error、dangerous 回写到 MR 评论,方便开发者定位。

注意:正则匹配危险操作时,DELETE FROM 的负向断言 (?!.*\bWHERE\b) 在跨多行 SQL 中可能误判。生产环境建议改用 AST 遍历:解析后检查 Delete 节点是否带 where 子句,比正则更可靠。

可直接使用的检查清单

检查项 工具/方法 失败处理
SQL 语法可解析 sqlglot.parse() 标记 SYNTAX_ERROR,阻断
方言兼容 按目标 dialect 重新解析 标记 DIALECT_ERROR,阻断
DROP TABLE 正则 / AST 节点匹配 标记 BLOCKED,需人工审批
TRUNCATE TABLE 正则 / AST 节点匹配 标记 BLOCKED,需人工审批
DELETE 无 WHERE AST 检查 Delete.where 为空 标记 BLOCKED,需人工审批
报告产出 sql_check_report.json 作为 CI artifact 保留 7 天