格式化是已解决的问题

SQL 格式化不应该是手动活动。就像 gofmtprettierblack 一样,SQL 拥有成熟的自动化格式化器,能消除风格争论并在无人工干预的情况下强制执行一致性。

本指南的价值不是"如何手动缩进 SQL"——而是理解格式化规则背后的设计决策,以便你能正确配置自动化工具,并处理它们难以处理的模式。

主要风格争议

每个 SQL 风格指南都在这些有争议的选择上表明立场。没有哪个在客观上是正确的——重要的是在代码库内保持一致。

关键字大小写

风格 示例 使用者
大写关键字 SELECT id FROM users WHERE ... 大多数传统指南、Simon Holywell 指南
小写关键字 select id from users where ... GitLab、部分现代团队
混合(仅主子句) SELECT id from users WHERE ... 不常见,不推荐

大小写争论在很大程度上是审美问题。现代编辑器的语法高亮使关键字在视觉上可区分,无论大小写如何。选一种并用格式化器强制执行。

前导逗号 vs 尾随逗号

sql
-- 尾随逗号(传统)
SELECT
    id,
    name,
    email,
    created_at
FROM users;

-- 前导逗号(更干净的 diff,无尾随逗号错误)
SELECT
    id
  , name
  , email
  , created_at
FROM users;

前导逗号产生更干净的 git diff 输出(添加列是单行新增,而非修改+新增)。但大多数格式化器由于惯例默认使用尾随逗号。

右对齐关键字(River 风格)

sql
-- 左对齐(最常见)
SELECT
    id,
    name
FROM users
WHERE status = 'active'
ORDER BY created_at;

-- 右对齐 / "River" 风格
SELECT id,
       name
  FROM users
 WHERE status = 'active'
 ORDER BY created_at;

River 风格在关键字和内容之间创建一条垂直的空白"河流"。紧凑但难以手动维护,且大多数格式化器不支持。

缩进宽度

2 空格、4 空格或 Tab——与其他语言相同的争论。SQL 中 4 空格最常见,因为查询嵌套较深(子查询中的子查询)。

自动化格式化器:工具对比

工具 语言 方言 Linting 可修复 配置方式
sqlfluff Python ANSI, PostgreSQL, MySQL, BigQuery, Snowflake, T-SQL, Spark .sqlfluff 文件,规则丰富
sqlfmt Python PostgreSQL, 通用 ANSI 零配置(观点化)
pg_format Perl PostgreSQL CLI 参数和配置文件
sql-formatter JS/TS ANSI, PostgreSQL, MySQL, MariaDB, T-SQL, PL/SQL, BigQuery, Spark 编程 API
prettier-plugin-sql JS 通过 sql-formatter .prettierrc 集成
dbt sqlfluff Python dbt 专用 Jinja+SQL dbt 项目集成

sqlfluff:最全面的选择

sqlfluff 同时是格式化器Linter。它理解 SQL 语义,而不仅仅是语法:

bash
# 格式化文件
sqlfluff fix query.sql --dialect postgres

# 仅 lint,不修复
sqlfluff lint query.sql --dialect postgres

配置示例(.sqlfluff):

ini
[sqlfluff]
dialect = postgres
templater = raw
max_line_length = 120

[sqlfluff:indentation]
indent_unit = space
tab_space_size = 4

[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper

[sqlfluff:rules:layout.long_lines]
ignore_comment_lines = True

sqlfmt:零配置的选择

sqlfmt 采用 gofmt 哲学——一种风格,零配置,零争论:

bash
pip install shandy-sqlfmt
sqlfmt query.sql

它始终产生:

  • 小写关键字
  • 尾随逗号
  • 4 空格缩进
  • 每行一个子句

如果你的团队看重零配置一致性胜过风格偏好,sqlfmt 消除所有讨论。

复杂模式格式化

自动化工具能很好地处理简单的 SELECT...FROM...WHERE。挑战在于格式化显著影响可读性的复杂模式。

CTE 链(WITH 子句)

CTE 是现代 SQL 中最重要的格式化挑战。格式不良的 CTE 链不可读:

sql
-- 格式良好的 CTE 链
WITH monthly_revenue AS (
    SELECT
        DATE_TRUNC('month', order_date) AS month,
        SUM(amount) AS revenue
    FROM orders
    WHERE order_date >= '2025-01-01'
    GROUP BY DATE_TRUNC('month', order_date)
),

revenue_growth AS (
    SELECT
        month,
        revenue,
        LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
        (revenue - LAG(revenue) OVER (ORDER BY month))
            / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) AS growth_rate
    FROM monthly_revenue
)

SELECT
    month,
    revenue,
    prev_revenue,
    ROUND(growth_rate * 100, 1) AS growth_pct
FROM revenue_growth
ORDER BY month;

关键原则:

  • 每个 CTE 用空行分隔
  • CTE 主体在括号内缩进
  • 最终 SELECT 与 WITH 同级
  • CTE 名称描述中间结果代表什么

窗口函数

带有复杂帧规范的窗口函数需要精心换行:

sql
SELECT
    employee_id,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
        ORDER BY hire_date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS rolling_avg,
    RANK() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS salary_rank,
    salary - FIRST_VALUE(salary) OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS diff_from_max
FROM employees;

当 OVER 子句能放在一行时,保持内联:

sql
ROW_NUMBER() OVER (ORDER BY id) AS row_num

放不下时,在 OVER 后换行并缩进窗口规范。

CASE 表达式

sql
-- 简单 CASE:如果短可以内联
SELECT status, CASE status WHEN 'A' THEN 'Active' WHEN 'I' THEN 'Inactive' END AS label
FROM users;

-- 复杂 CASE:每个 WHEN 一行
SELECT
    order_id,
    CASE
        WHEN total > 1000 AND customer_type = 'enterprise'
            THEN 'high_value_enterprise'
        WHEN total > 1000
            THEN 'high_value'
        WHEN total > 100
            THEN 'medium_value'
        ELSE 'standard'
    END AS order_tier
FROM orders;

关联子查询

sql
SELECT
    d.name AS department,
    d.budget,
    (
        SELECT COUNT(*)
        FROM employees e
        WHERE e.department_id = d.id
            AND e.status = 'active'
    ) AS headcount,
    (
        SELECT AVG(e.salary)
        FROM employees e
        WHERE e.department_id = d.id
    ) AS avg_salary
FROM departments d
WHERE d.budget > 100000;

SELECT 列表中的子查询应在独立行上用括号包裹,主体缩进。

复杂 JOIN 条件

sql
SELECT o.id, o.total
FROM orders o
INNER JOIN customers c
    ON c.id = o.customer_id
    AND c.region = o.shipping_region
LEFT JOIN promotions p
    ON p.id = o.promo_id
    AND p.valid_from <= o.order_date
    AND p.valid_until >= o.order_date
WHERE o.status = 'completed';

当 JOIN 有多个条件时,ON 与 JOIN 同行,额外条件用 AND 缩进。

格式化 vs Linting:本质区别

格式化是外观性的:空白、缩进、换行、关键字大小写。它改变代码的外观,永不改变行为。

Linting 检测影响行为或可维护性的正确性和风格问题:

类别 格式化(外观) Linting(语义)
关键字大小写 selectSELECT
缩进 修复间距
未使用别名 检测未引用的别名
隐式 JOIN 对逗号连接语法发出警告
SELECT * 对通配符 SELECT 发出警告
未限定列名 在 JOIN 中要求表前缀
UPDATE/DELETE 无 WHERE 对无守卫的变更操作发出警告

sqlfluff 同时处理两者。其他大多数工具(sqlfmt、pg_format、sql-formatter)只处理格式化。

防止 Bug 的 Linting 规则

ini
# 能捕获真实 bug 的 sqlfluff 规则
[sqlfluff:rules:ambiguous.column_references]
# 在多表查询中要求限定列名

[sqlfluff:rules:convention.select_trailing_comma]
# 防止导致语法错误的尾随逗号

[sqlfluff:rules:structure.subquery]
# 要求子查询有别名

CI/CD 强制执行

格式化强制执行属于 CI,不属于代码审查。人类不应在空白问题上花费审查时间。

Pre-commit Hook

yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/sqlfluff/sqlfluff
    rev: 3.0.0
    hooks:
      - id: sqlfluff-lint
        args: [--dialect, postgres]
      - id: sqlfluff-fix
        args: [--dialect, postgres]

GitHub Actions

yaml
# .github/workflows/sql-lint.yml
name: SQL Lint
on: [pull_request]
jobs:
  sqlfluff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install sqlfluff
      - run: sqlfluff lint --dialect postgres sql/

编辑器集成

  • VS Code:SQLFluff 扩展(实时 Linting + 保存时格式化)
  • JetBrains:内置 SQL 格式化器(按方言可配置)
  • Vim/Neovim:通过 ALE 或 null-ls 配合 sqlfluff

方言特定的格式化考虑

PostgreSQL:Dollar 引号函数

sql
CREATE OR REPLACE FUNCTION calculate_discount(
    p_customer_id INTEGER,
    p_order_total NUMERIC
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
    v_discount NUMERIC := 0;
    v_tier TEXT;
BEGIN
    SELECT loyalty_tier INTO v_tier
    FROM customers
    WHERE id = p_customer_id;

    v_discount := CASE v_tier
        WHEN 'gold' THEN p_order_total * 0.15
        WHEN 'silver' THEN p_order_total * 0.10
        ELSE 0
    END;

    RETURN v_discount;
END;
$$;

BigQuery:嵌套 Struct 和数组

sql
SELECT
    user_id,
    STRUCT(
        first_name,
        last_name,
        STRUCT(
            street,
            city,
            state
        ) AS address
    ) AS user_info,
    ARRAY_AGG(
        STRUCT(order_id, amount, order_date)
        ORDER BY order_date DESC
        LIMIT 10
    ) AS recent_orders
FROM users
LEFT JOIN orders USING (user_id)
GROUP BY user_id, first_name, last_name, street, city, state;

总结

SQL 格式化在工具层面是已解决的问题。剩余的人类决策是:

  1. 选择格式化器(sqlfluff 用于全面 Linting + 格式化,sqlfmt 用于零配置)
  2. 配置有争议的风格选择(关键字大小写、逗号位置、缩进)
  3. 在 CI 中强制执行(pre-commit hook 或 GitHub Actions)
  4. 学习格式化器处理不完美的复杂模式(深层嵌套 CTE、多窗口函数查询)

手动格式化 SQL 花的时间是浪费的。配置自动化强制执行花的时间是在每次未来提交上都能回报的投资。