格式化是已解决的问题
SQL 格式化不应该是手动活动。就像 gofmt、prettier 和 black 一样,SQL 拥有成熟的自动化格式化器,能消除风格争论并在无人工干预的情况下强制执行一致性。
本指南的价值不是"如何手动缩进 SQL"——而是理解格式化规则背后的设计决策,以便你能正确配置自动化工具,并处理它们难以处理的模式。
主要风格争议
每个 SQL 风格指南都在这些有争议的选择上表明立场。没有哪个在客观上是正确的——重要的是在代码库内保持一致。
关键字大小写
| 风格 | 示例 | 使用者 |
|---|---|---|
| 大写关键字 | SELECT id FROM users WHERE ... |
大多数传统指南、Simon Holywell 指南 |
| 小写关键字 | select id from users where ... |
GitLab、部分现代团队 |
| 混合(仅主子句) | SELECT id from users WHERE ... |
不常见,不推荐 |
大小写争论在很大程度上是审美问题。现代编辑器的语法高亮使关键字在视觉上可区分,无论大小写如何。选一种并用格式化器强制执行。
前导逗号 vs 尾随逗号
-- 尾随逗号(传统)
SELECT
id,
name,
email,
created_at
FROM users;
-- 前导逗号(更干净的 diff,无尾随逗号错误)
SELECT
id
, name
, email
, created_at
FROM users;
前导逗号产生更干净的 git diff 输出(添加列是单行新增,而非修改+新增)。但大多数格式化器由于惯例默认使用尾随逗号。
右对齐关键字(River 风格)
-- 左对齐(最常见)
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 语义,而不仅仅是语法:
# 格式化文件
sqlfluff fix query.sql --dialect postgres
# 仅 lint,不修复
sqlfluff lint query.sql --dialect postgres
配置示例(.sqlfluff):
[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 哲学——一种风格,零配置,零争论:
pip install shandy-sqlfmt
sqlfmt query.sql
它始终产生:
- 小写关键字
- 尾随逗号
- 4 空格缩进
- 每行一个子句
如果你的团队看重零配置一致性胜过风格偏好,sqlfmt 消除所有讨论。
复杂模式格式化
自动化工具能很好地处理简单的 SELECT...FROM...WHERE。挑战在于格式化显著影响可读性的复杂模式。
CTE 链(WITH 子句)
CTE 是现代 SQL 中最重要的格式化挑战。格式不良的 CTE 链不可读:
-- 格式良好的 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 名称描述中间结果代表什么
窗口函数
带有复杂帧规范的窗口函数需要精心换行:
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 子句能放在一行时,保持内联:
ROW_NUMBER() OVER (ORDER BY id) AS row_num
放不下时,在 OVER 后换行并缩进窗口规范。
CASE 表达式
-- 简单 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;
关联子查询
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 条件
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(语义) |
|---|---|---|
| 关键字大小写 | select → SELECT |
— |
| 缩进 | 修复间距 | — |
| 未使用别名 | — | 检测未引用的别名 |
| 隐式 JOIN | — | 对逗号连接语法发出警告 |
| SELECT * | — | 对通配符 SELECT 发出警告 |
| 未限定列名 | — | 在 JOIN 中要求表前缀 |
| UPDATE/DELETE 无 WHERE | — | 对无守卫的变更操作发出警告 |
sqlfluff 同时处理两者。其他大多数工具(sqlfmt、pg_format、sql-formatter)只处理格式化。
防止 Bug 的 Linting 规则
# 能捕获真实 bug 的 sqlfluff 规则
[sqlfluff:rules:ambiguous.column_references]
# 在多表查询中要求限定列名
[sqlfluff:rules:convention.select_trailing_comma]
# 防止导致语法错误的尾随逗号
[sqlfluff:rules:structure.subquery]
# 要求子查询有别名
CI/CD 强制执行
格式化强制执行属于 CI,不属于代码审查。人类不应在空白问题上花费审查时间。
Pre-commit Hook
# .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
# .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 引号函数
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 和数组
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 格式化在工具层面是已解决的问题。剩余的人类决策是:
- 选择格式化器(sqlfluff 用于全面 Linting + 格式化,sqlfmt 用于零配置)
- 配置有争议的风格选择(关键字大小写、逗号位置、缩进)
- 在 CI 中强制执行(pre-commit hook 或 GitHub Actions)
- 学习格式化器处理不完美的复杂模式(深层嵌套 CTE、多窗口函数查询)
手动格式化 SQL 花的时间是浪费的。配置自动化强制执行花的时间是在每次未来提交上都能回报的投资。