Ubuntu TechHive
inside-sqlx-building-compile-time-checked-sql-from-lego-brick.md
Inside SQLx — building compile-time checked SQL from Lego brick
article.细节

Inside SQLx — building compile-time checked SQL from Lego brick

reading.进展 27 分钟阅读数

目标是了解在 `Rust` 和 `SQL` 中,编译时查询保证究竟源自何处。

#+title: 深入 SQLx — 从乐高积木构建编译时检查的 SQL
#+author: ChiefKemist
#+date: <2026-07-25 Sat>

  • 介绍

我们正在构建一个玩具版的 SQLx,以了解其主要功能及实现方式。同时,我们将探索一些 Rust 生态系统的特性,例如 宏 (macros)Cargo 工作空间 (workspaces)。最终目标是理解在 RustSQL 的语境下,编译时查询保证究竟从何而来。

我们从 Rust 视为未检查字符串的 SQL 开始,最终实现如下效果:

#+begin_src rust :tangle no
let users = toy_sqlx::query!(
&connection,
"SELECT id, email FROM users WHERE active = ?1",
true,
)?;
#+end_src

通过一次宏调用,三个系统共同确保了正确性:

  • SQLite 证明 SQL 对于构建时的模式 (schema) 是有效的。
  • 将 SQLite 的证据转化为 Rust 代码。
  • rustc 检查生成的字段和应用程序结构体。

我们不会克隆 SQLx,而是展示最小的可行编译时流水线,以在 Rust 代码中强制执行正确的 SQL

  • 关于宏

#+begin_quote
“整个语言始终存在。读取时、编译时和运行时之间没有真正的区别。你可以在读取时编译或运行代码,在编译时读取或运行代码,并在运行时读取或编译代码。

在读取时运行代码允许用户重编程 Lisp 的语法;在编译时运行代码是宏的基础;在运行时编译是 Lisp 作为扩展语言在 Emacs 等程序中使用的基础;而在运行时读取则使程序能够使用 s-表达式进行通信,这是一个最近被重新发明为 XML 的概念。”
#+end_quote

— Paul Graham, /Revenge of the Nerds/, 载于 /Hackers & Painters/,
第 9 节 “What Made Lisp Different”。

参考:[[https://paulgraham.com/icad.html][Paul Graham — Revenge of the Nerds]]

  • 脚手架:三个 crate

Cargo 工作空间 crate:toy-sqlx, toy-macro, toy-demo

#+name: toy-sqlx/Cargo.toml
#+begin_src toml :tangle toy-sqlx/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-sqlx"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

[dependencies]
toy-sqlx-macros = { path = "../toy-sqlx-macros" }
rusqlite = { version = "0.39", features = ["bundled"] }
#+end_src

#+name: toy-macro/Cargo.toml
#+begin_src toml :tangle toy-sqlx-macros/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-sqlx-macros"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1"
quote = "1"
rusqlite = { version = "0.39", features = ["bundled", "column_metadata"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
syn = { version = "2", features = ["full"] }
#+end_src

#+name: toy-demo/Cargo.toml
#+begin_src toml :tangle toy-demo/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-demo"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true

[features]
fail-literal = []
fail-invalid-sql = []
fail-arity = []
fail-query-as-name = []
fail-query-as-type = []
fail-expression-metadata = []
fail-unsupported-shape = []
fail-view-source = []
fail-subquery-source = []

[dependencies]
toy-sqlx = { path = "../toy-sqlx" }
#+end_src

  • 第 0 步:暴露运行时失败
    :PROPERTIES:
    :MINUTES: 5
    :END:

#+begin_quote
问题: Rust 将 SQL 视为普通文本,因此无法拒绝错误的列。

为何关注: 第一个有用的反馈只有在程序构建、部署并运行查询路径后才会出现。

本步骤: 创建一个微型数据库,并故意在运行时准备一个无效的查询。这是后续每一步都必须改进的基准。
#+end_quote

该夹具 (fixture) 仅包含我们稍后需要的模式证据:声明的列类型和一个可为空的列。设置程序不使用任何已检查的宏。

#+name: fixture-schema
#+begin_src sql :tangle toy-demo/schema.sql :mkdirp yes :comments no
PRAGMA foreign_keys = ON;

DROP VIEW IF EXISTS hidden_join;
DROP TABLE IF EXISTS users;

CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE CHECK (active IN (FALSE, TRUE))
);

INSERT INTO users (email, display_name, active) VALUES
('ada@example.test', 'Ada', TRUE),
('grace@example.test', NULL, TRUE),
('alan@example.test', 'Alan', FALSE);
#+end_src

#+name: fixture-setup
#+begin_src rust :tangle toy-demo/src/bin/setup.rs :mkdirp yes :comments no
fn main() -> Result<(), Box> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
connection.execute_batch(include_str!("../../schema.sql"))?;
println!("initialized {}", toy_sqlx::database_path(&url).display());
Ok(())
}
#+end_src

#+name: runtime-failure
#+begin_src rust :tangle toy-demo/src/bin/00_runtime_sql.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let result = connection.prepare("SELECT id, definitely_missing FROM users");
match result {
Ok(_) => println!("unexpected success"),
Err(error) => println!("runtime SQLite error: {error}"),
}
Ok(())
}
#+end_src

  • 运行步骤:

#+begin_src bash :results output

./scripts/toy-demo.sh 0

#+end_src

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.27s
: Running target/debug/00_runtime_sql
: runtime SQLite error: no such column: definitely_missing in SELECT id, definitely_missing FROM users at offset 11

  • 观察: Rust 编译通过;SQLite 在运行时报告 =definitely_missing=。
  • 收获: 一个具体的失败,以便将其提前。
  • 仍存在的问题: 编译器从未将 SQL 作为可检查的输入接收。第 1 步将 SQL 交给过程宏。
  • 第 1 步:使 SQL 在编译时可见
    :PROPERTIES:
    :MINUTES: 6
    :END:

#+begin_quote
问题: 过程宏接收的是 Rust 标记 (tokens),而不是隐藏在运行时变量中的值。

为何关注: 除非 SQL 本身在宏展开期间可用,否则编译时检查是不可能的。

本步骤: 要求使用字符串字面量。第一个宏只解析并返回该字面量,这样我们就可以在不混入数据库逻辑的情况下看到这个边界。
#+end_quote

下面的运行时 crate 是支撑性的管道:连接到 SQLite 并映射行。它不执行任何编译时分析。

#+name: toy-runtime
#+begin_src rust :tangle toy-sqlx/src/lib.rs :mkdirp yes :comments no
use std::path::Path;

pub use rusqlite;
pub use toy_sqlx_macros::{checked_query, checked_sql, query, query_as, sql_literal};

pub fn database_path(url: &str) -> &Path {
let path = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(url);
Path::new(path)
}

pub fn connect(url: &str) -> rusqlite::Resultrusqlite::Connection {
let connection = rusqlite::Connection::open(database_path(url))?;
connection.pragma_update(None, "foreign_keys", "ON")?;
Ok(connection)
}

#[doc(hidden)]
pub fn map_rows<T, F>(
connection: &rusqlite::Connection,
sql: &str,
parameters: &[&dyn rusqlite::ToSql],
mut map: F,
) -> rusqlite::Result<Vec>
where
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result,
{
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(parameters, |row| map(row))?;
rows.collect()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_urls_and_paths() {
    assert_eq!(database_path("sqlite://db/toy.db"), Path::new("db/toy.db"));
    assert_eq!(database_path("sqlite::memory:"), Path::new(":memory:"));
    assert_eq!(database_path("db/toy.db"), Path::new("db/toy.db"));
}

#[test]
fn enables_foreign_keys() {
    let connection = connect("sqlite::memory:").unwrap();
    let enabled: bool = connection
        .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
        .unwrap();
    assert!(enabled);
}

}
#+end_src

#+name: macro-foundation
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
//! 一个刻意设计的、仅限 SQLite 的 SQLx 编译时查询宏模型。
//!
//! # 核心思想
//!
//! 过程宏在调用它的 crate 被编译时运行。它接收 Rust 标记,询问 SQLite SQL 字符串的含义,并发出新的 Rust 标记。发出的 Rust 代码随后通过正常的 Rust 编译器。
//!
//! 流水线如下:
//!
//! 1. [syn] 将宏输入解析为 Rust 数据结构。
//! 2. [rusqlite] 准备 SQL 并返回参数/列元数据。
//! 3. 此 crate 验证刻意设计的教学子集。
//! 4. [quote!] 从该元数据构建普通的 Rust 代码。
//! 5. rustc 检查生成的字段、类型和结构体字面量。
//!
//! 这是教学代码,不是通用的 SQL 分析器。类型化查询有意只接受来自一个真实 SQLite 表的直接列。

// 用于名称、环境变量、缓存文件和路径的标准库工具。
use std::{collections::HashSet, env, fs, path::PathBuf};

// proc_macro::TokenStream 是面向编译器的输入和输出类型。
use proc_macro::TokenStream;
// proc_macro2 提供了更容易构建和测试的标记类型。
use proc_macro2::{Ident, Span, TokenStream as Tokens};
// quote! 将类 Rust 语法转换为标记;format_ident! 创建标识符。
use quote::{format_ident, quote};
// SQLite 既是运行时数据库,也是我们编译时的 SQL 权威。
use rusqlite::{Connection, OpenFlags};
// 描述被序列化,以便相同的证据可以在离线时使用。
use serde::{Deserialize, Serialize};
// syn 解析字面量、表达式、类型、逗号和自定义宏语法。
#[rustfmt::skip]
use syn::{parse::{Parse, ParseStream}, parse_macro_input, punctuated::Punctuated, Expr, LitStr, Token, Type};
#+end_src

#+name: macro-entry-points
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 要求宏输入为字符串字面量,然后原样发出该字面量。
///
/// 这个第一个教学宏不理解 SQL。它的唯一工作是展示编译时检查需要编译时输入:sql_literal!(variable) 会失败,因为 variable 不是 [LitStr]。
#[proc_macro]
pub fn sql_literal(input: TokenStream) -> TokenStream {
// parse_macro_input! 停止展开并在失败时发出编译器诊断信息。
let sql = parse_macro_input!(input as LitStr);
// #sql 将解析后的字面量插入到 quote! 构建的标记流中。
quote!(#sql).into()
}

/// 将我们内部的 syn::Result 转换为 rustc 所需的标记流。
///
/// 过程宏不能返回 Result。因此,syn::Error 变为放置在最有用的源跨度 (span) 上的 compile_error! 调用。
fn result(result: syn::Result) -> TokenStream {
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#+end_src

#+name: literal-demo
#+begin_src rust :tangle toy-demo/src/bin/01_literal_macro.rs :mkdirp yes :comments no
fn main() {
#[cfg(feature = "fail-literal")]
{
let sql = "SELECT 40 + 2";
let _ = toy_sqlx::sql_literal!(sql);
}
#[cfg(not(feature = "fail-literal"))]
println!("{}", toy_sqlx::sql_literal!("SELECT 40 + 2 AS answer"));
}
#+end_src

使用 =cargo expand= 来证明宏原样发出了字面量。第一个命令确认已安装 =cargo-expand=。如果缺少,请运行 =cargo install cargo-expand --locked=。

#+begin_src bash :results output

set -euo pipefail
cargo expand --version
cargo expand --color never -p toy-demo --bin 01_literal_macro 2>/dev/null |
grep -C 2 'SELECT 40 + 2 AS answer'

#+end_src

#+RESULTS:
: cargo-expand-expand 1.0.59 + prettyplease 0.2.10
: fn main() {
: {
: ::std::io::_print(format_args!("{0}\n", "SELECT 40 + 2 AS answer"));
: };
: }

有用的展开是有意枯燥的:

#+begin_src rust :tangle no
"SELECT 40 + 2 AS answer"
#+end_src

  • 运行步骤及其预期的失败:

#+begin_src bash :results output

./scripts/toy-demo.sh 1
./scripts/toy-demo.sh fail-literal

#+end_src

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.20s
: Running target/debug/01_literal_macro
: SELECT 40 + 2 AS answer
: fail-literal: failed as intended

  • 观察: 字面量编译通过;运行时 =String= 被宏解析器拒绝。
  • 收获: 宏在编译期间拥有 SQL 文本。
  • 仍存在的问题: 拥有文本并不能证明 SQL 有效。第 2 步询问真正理解 SQLite 的系统。
  • 第 2 步:让 SQLite 在编译期间拒绝错误的 SQL
    :PROPERTIES:
    :MINUTES: 12
    :END:

#+begin_quote
问题: 宏现在可以看到 SQL,但仍然无法判断表或列是否存在。

为何关注: 在宏内部编写第二个 SQLite 解析器将是庞大、不完整且不如 SQLite 本身可信的。

本步骤: 在宏展开期间,以只读方式打开构建数据库并要求 SQLite 准备查询。将 SQLite 的答案转换为后续步骤可以重用的微小 =Description=。
#+end_quote

只有三个移动部件:描述、宏入口点和在线加载器。在此步骤中,观察对 =connection.prepare= 的调用。其他描述字段在第 3、4 和 6 步中变得有用。

#+name: description-ir
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// 每当缓存元数据的含义或形状发生变化时,请增加此值。旧的缓存条目随后会清晰地失败,而不是在新规则下被解释。
const CACHE_VERSION: u8 = 3;

/// 在线和离线检查共享的小型中间表示。
///
/// 将 Description 视为关于一个 SQL 字符串的事实表:
///
/// - parameter_count 表示 SQLite 期望多少个值;
/// - columns 描述结果集;
/// - source_is_table 记录我们狭窄的类型化查询规则是否被证明;
/// - versiondatabasesql 保护离线缓存加载。
///
/// 保持一种表示很重要:代码生成不需要知道这些事实是来自实时 SQLite 还是缓存文件。
#[rustfmt::skip]
#[derive(Debug, Serialize, Deserialize)]
struct Description { version: u8, database: String, sql: String, parameter_count: usize, source_is_table: bool, columns: Vec }

/// 生成一个 Rust 输出字段所需的 SQLite 证据。
///
/// declared_type 是在 CREATE TABLE 中编写的类型;它不是 SQLite 动态存储的每个值的类型。nullable 决定了生成的 Rust 中是使用 T 还是 Option<T>
#[rustfmt::skip]
#[derive(Debug, Serialize, Deserialize)]
struct Column { name: String, declared_type: Option, nullable: bool }
#+end_src

#+name: checked-sql-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 要求 SQLite 在宏展开期间验证 SQL 字面量。
///
/// 发出的值仍然只是原始字符串字面量。改进之处在于时机:无效的 SQL 变为编译器错误,而不是运行时错误。
#[proc_macro]
pub fn checked_sql(input: TokenStream) -> TokenStream {
let sql = parse_macro_input!(input as LitStr);
// 加载描述执行验证;此宏丢弃这些事实。
result(load_description(&sql).map(|_| quote!(#sql)))
}
#+end_src

#+name: online-description
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 从单一来源获取查询证据。
///
/// 离线模式读取先前保存的描述。在线模式直接询问 SQLite,并可选择保存答案以供以后的离线构建使用。
fn load_description(sql: &LitStr) -> syn::Result {
if flag("TOY_SQLX_OFFLINE") {
return load_cache(&sql.value(), sql.span());
}
let description = describe_online(&sql.value(), sql.span())?;
if flag("TOY_SQLX_PREPARE") {
save_cache(&description, sql.span())?;
}
Ok(description)
}

/// 识别类型化查询支持的刻意设计的微小 SQL 形状。
///
/// 这是一个 作用域守卫,而不是 SQL 解析器。它仅针对来自一个简单表的直接列投影返回表名。任何不确定性都会返回 None,这使得类型化宏拒绝查询,而不是编造事实。
#[rustfmt::skip]
fn direct_source(sql: &str) -> Option<&str> {
// 注释可能会隐藏额外的 FROM 子句,因此这个玩具直接拒绝它们。
if ["--", "/", "/"].iter().any(|marker| sql.contains(marker)) { return None; }
// 空白标记化就足够了,因为每个接受的形状都很简单。
let words = sql.split_whitespace().collect::<Vec<>>();
// 恰好一个 FROM 排除了普通的 SELECT 子查询和模糊来源。
let from = words.iter().enumerate().filter(|(
, word)| word.eq_ignore_ascii_case("FROM")).map(|(index, )| index).collect::<Vec<>>();
let [from] = from.as_slice() else { return None };
// FROM 后的第一个单词必须是未加引号、未限定的表标识符。
let table = *words.get(from + 1)?;
// 当普通过滤/排序开始时,停止源子句。
let tail = words[from + 2..].iter().take_while(|word| !matches!(word.to_ascii_uppercase().as_str(), "WHERE" | "GROUP" | "ORDER" | "LIMIT")).copied().collect::<Vec<>>();
let ident = |word: &str| word.chars().all(|c| c.is_ascii_alphanumeric() || c == '
');
// 每个选定的字段必须是 columntable.column,或者带有 AS name 的任一形式。
let fields = words[1..*from].join(" ");
let direct_field = |field: &str| { let parts = field.split_whitespace().collect::<Vec<>>(); let path = parts.first()?.split('.').collect::<Vec<>>(); Some((path.len() == 1 || path.len() == 2) && path.iter().all(|part| ident(part)) && (parts.len() == 1 || (parts.len() == 3 && parts[1].eq_ignore_ascii_case("AS") && ident(parts[2])))) };
// 在表之后,不允许别名、aliasAS alias——绝不允许第二个来源。
let alias = tail.is_empty() || (tail.len() == 1 && ident(tail[0])) || (tail.len() == 2 && tail[0].eq_ignore_ascii_case("AS") && ident(tail[1]));
(ident(table) && alias && fields.split(',').all(|field| direct_field(field) == Some(true))).then_some(table)
}

/// 在编译期间要求实时 SQLite 数据库描述查询。
///
/// 准备工作验证语法、表名和列名,而不执行查询。span 将诊断信息指回调用者代码中的 SQL 字面量。
#[rustfmt::skip]
fn describe_online(sql: &str, span: Span) -> syn::Result {
// Cargo 在过程宏进程中运行此函数,而不是在最终程序中。
let url = env::var("TOY_DATABASE_URL")
.map_err(|| syn::Error::new(span, "TOY_DATABASE_URL is required for online checking"))?;
// 接受与运行时 crate 相同的 SQLite URL 形式。
let path = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(&url);
// 只读模式防止编译修改教学数据库。
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|error| syn::Error::new(span, format!("cannot open SQLite: {error}")))?;
// 这是关键的编译时检查:让 SQLite 验证 SQLite SQL。
let statement = connection.prepare(sql).map_err(|error| {
syn::Error::new(
span,
format!("SQLite rejected this query during compilation: {error}"),
)
})?;
// 仅复制后续 Rust 代码生成所需的输出事实。
let columns = (0..statement.column_count())
.map(|index| {
let name = statement.column_name(index)?.to_owned();
let metadata = statement.column_metadata(index)?;
// rusqlite 暴露了几个来源字段;这个玩具需要声明和 NOT NULL。
let (declared_type, nullable) = match metadata {
Some((
, _, _, declared, _, not_null, _, )) => (
declared.map(|value| value.to_string_lossy().into_owned()),
!not_null,
),
// 缺失的来源证据绝不能被视为非空。
None => (None, true),
};
Ok(Column { name, declared_type, nullable })
})
.collect::<rusqlite::Result<Vec<
>>>()
.map_err(|error| syn::Error::new(span, format!("cannot describe output: {error}")))?;
// 词法识别是不够的:sqlite_schema 必须确认是一个真实的表,而不是视图。
#[rustfmt::skip]
let source_is_table = direct_source(sql).is_some_and(|table| connection.query_row(
"SELECT type = 'table' FROM sqlite_schema WHERE name = ?1", [table], |row| row.get(0),
).unwrap_or(false));
// 准备好的语句本身提供了 SQLite 的权威占位符计数。
Ok(Description {
version: CACHE_VERSION,
database: "SQLite".into(),
sql: sql.into(),
parameter_count: statement.parameter_count(),
source_is_table,
columns,
})
}
#+end_src

#+name: prepare-demo
#+begin_src rust :tangle toy-demo/src/bin/02_compile_time_prepare.rs :mkdirp yes :comments no
fn main() {
#[cfg(feature = "fail-invalid-sql")]
let sql = toy_sqlx::checked_sql!("SELECT id, definitely_missing FROM users");
#[cfg(not(feature = "fail-invalid-sql"))]
let sql = toy_sqlx::checked_sql!("SELECT id, email FROM users");
println!("SQLite accepted during compilation: {sql}");
}
#+end_src

  • 运行步骤及其预期的失败:

#+begin_bash :results output

./scripts/toy-demo.sh 2
./scripts/toy-demo.sh fail-sql

#+end_bash

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.57s
: Running target/debug/02_compile_time_prepare
: SQLite accepted during compilation: SELECT id, email FROM users
: fail-sql: failed as intended

  • 观察: =definitely_missing= 现在是一个编译器错误。
  • 收获: SQLite 接受了针对构建数据库的查询。
  • 边界: 运行时数据库仍然可以具有不同的模式。
  • 仍存在的问题: 有效的查询可能接收到错误数量的 Rust 值。第 3 步检查占位符与参数的匹配情况。
  • 第 3 步:拒绝错误数量的参数
    :PROPERTIES:
    :MINUTES: 8
    :END:

#+begin_quote
问题: 查询可能是有效的,但其 =?1=, =?2= 占位符与 Rust 提供的值的数量不匹配。

为何关注: 这种不匹配否则会变成另一个运行时准备/绑定失败。

本步骤: 比较 SQLite 的参数计数与宏参数的数量。保持 Rust 表达式对 rustc 可见,而不评估它们。
#+end_quote

这是刻意设计的 仅限元数 (arity-only)。SQLite 不提供静态绑定类型。

#+name: checked-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// checked_query!("SQL", arg1, arg2, ...) 的解析输入。
///
/// Punctuated 是 syn 对以逗号分隔的零个或多个表达式的表示。保持表达式为语法形式让我们可以在不运行它们的情况下计算它们。
#[rustfmt::skip]
struct CheckedInput { sql: LitStr, args: Punctuated<Expr, Token![,]> }

/// 教 syn checked_query! 接受的小语法。
impl Parse for CheckedInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
// 第一个标记必须是 SQL 字符串字面量。
let sql = input.parse()?;
let args = if input.is_empty() {
// 没有占位符的查询不需要逗号和参数。
Punctuated::new()
} else {
// 否则消耗 SQL 后的逗号,然后是所有以逗号分隔的表达式。
input.parse::<Token![,]>()?;
Punctuated::parse_terminated(input)?
};
Ok(Self { sql, args })
}
}
#+end_src

#+name: checked-query-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 验证 SQL 加上参数元数,但不执行查询。
///
/// 这个过渡宏隔离了一个教训:SQLite 可以告诉我们它期望多少个参数,但不能告诉我们静态 SQLite 参数类型。
#[proc_macro]
pub fn checked_query(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as CheckedInput);
result(expand_checked(input))
}
#+end_src

#+name: checked-query-expansion
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 在解析其标记后实现 checked_query!
fn expand_checked(input: CheckedInput) -> syn::Result {
let description = load_description(&input.sql)?;
validate_arity(input.args.len(), &description, input.sql.span())?;
let CheckedInput { sql, args } = input;
let args = args.into_iter().collect::<Vec<_>>();
Ok(quote! {{
// 此分支永远不会运行,但 rustc 仍然解析并类型检查每个表达式。
// #(...)* 是 quote 的重复语法:每个参数发出一次主体。
if false {
#(let _ = &(#args);)*
}
// 宏的运行时值仍然是原始 SQL 字符串。
#sql
}})
}
#+end_src

展开演示以查看保留在 =if false= 内部的未评估参数:

#+begin_bash :results output

set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 03_parameter_arity 2>/dev/null |
grep -A 4 'if false'

#+end_bash

#+RESULTS:
: if false {
: let _ = &(deliberately_wrong_type);
: }
: "SELECT id FROM users WHERE active = ?1"
: };

#+name: arity-demo
#+begin_src rust :tangle toy-demo/src/bin/03_parameter_arity.rs :mkdirp yes :comments no
fn main() {
let deliberately_wrong_type = "SQLite accepts dynamic values";
#[cfg(feature = "fail-arity")]
let sql = toy_sqlx::checked_query!(
"SELECT id FROM users WHERE active = ?1 AND id >= ?2",
deliberately_wrong_type,
);
#[cfg(not(feature = "fail-arity"))]
let sql = toy_sqlx::checked_query!(
"SELECT id FROM users WHERE active = ?1",
deliberately_wrong_type,
);
println!("arity checked, parameter type deliberately unchecked: {sql}");
}
#+end_src

  • 运行步骤及其预期的失败:

#+begin_bash :results output

./scripts/toy-demo.sh 3
./scripts/toy-demo.sh fail-arity

#+end_bash

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.19s
: Running target/debug/03_parameter_arity
: arity checked, parameter type deliberately unchecked: SELECT id FROM users WHERE active = ?1
: fail-arity: failed as intended

  • 观察: 两个占位符对应一个值会失败;一个占位符对应一个值会通过——即使该值刻意是一个不适当的字符串。
  • 收获: 绑定计数匹配。
  • 边界: 没有做出静态 SQLite 绑定类型声明。
  • 仍存在的问题: 成功的查询仍然返回无类型的行。第 4 步生成 Rust 输出形状和解码器。
  • 第 4 步:生成类型化记录和解码器
    :PROPERTIES:
    :MINUTES: 17
    :END:

#+begin_quote
问题: 即使是已检查的 SQL 仍然需要重复的 =row.get(0)=, =row.get(1)= 调用和手写的 Rust 类型。

为何关注: 位置解码是脆弱的,且 SQL 结果形状在 Rust 中被手动重复。

本步骤: 将 =Description= 中的列名和声明类型转换为本地 =Record=,然后生成构造它的位置解码器。
#+end_quote

声明类型映射是刻意可见且微小的:

| SQLite 声明包含 | 生成的 Rust 基础类型 |
|---+---|
| =BOOL= 或 =BOOLEAN= | =bool= |
| =INT= | =i64= |
| =REAL=, =FLOA=, 或 =DOUB= | =f64= |
| =CHAR=, =CLOB=, 或 =TEXT= | =String= |
| =BLOB= | =Vec= |
| 其他任何内容 | 编译时错误 |

第 6 步解释了基础类型何时保持 =T= 以及何时可空性将其包装在 =Option= 中。

#+name: query-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// query!(&connection, "SQL", arg1, arg2, ...) 的解析输入。
///
/// 连接和每个参数都是完整的 Rust 表达式。它们保持为语法形式,直到展开发出评估每个表达式恰好一次的代码。
#[rustfmt::skip]
struct QueryInput { connection: Expr, sql: LitStr, args: Punctuated<Expr, Token![,]> }

/// 先解析连接,然后重用 CheckedInput 处理 SQL 和参数。
#[rustfmt::skip]
impl Parse for QueryInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
let connection = input.parse()?; input.parse::<Token![,]>()?;
let checked = input.parse::()?;
Ok(Self { connection, sql: checked.sql, args: checked.args })
}
}
#+end_src

#+name: query-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 验证并执行输出记录由宏生成的查询。
///
/// 本地生成的类型为每个选定的 SQL 列拥有一个 Rust 字段。
#[proc_macro]
pub fn query(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as QueryInput);
result(expand_query(input, Output::Generated))
}
#+end_src

#+name: typed-query-expansion
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 选择行解码器构造哪种 Rust 值。
enum Output {
/// query! 要求我们定义一个本地 Record 类型。
Generated,
/// query_as! 给我们一个应用程序拥有的类型来构造。
Given(Box),
}

/// 转换为 Rust 字段名和类型标记的已检查 SQL 输出列。
#[rustfmt::skip]
struct RustColumn { ident: Ident, ty: Tokens }

/// 构建由 query!query_as! 发出的 Rust 程序。
///
/// 最终 quote! 之前的一切都在编译期间运行。该 quote! 内部的代码是调用者的程序将在运行时执行的内容。
#[rustfmt::skip]
fn expand_query(input: QueryInput, output: Output) -> syn::Result {
// 编译时阶段:收集证据并拒绝不支持的输入。
let description = load_description(&input.sql)?;
validate_arity(input.args.len(), &description, input.sql.span())?;
let columns = typed_columns(&description, input.sql.span())?;

// 将每个已检查的列转换为 `field_name: RustType` 标记。
let fields = columns.iter().map(|column| {
    let ident = &column.ident;
    let ty = &column.ty;
    quote!(#ident: #ty)
});
// 将每个已检查的列转换为 `field_name: row.get::<_, RustType>(index)?`。
let values: Vec<_> = columns
    .iter()
    .enumerate()
    .map(|(index, column)| {
        let ident = &column.ident;
        let ty = &column.ty;
        quote!(#ident: __toy_row.get::<usize, #ty>(#index)?)
    })
    .collect();

// 两个公共宏共享一个解码器;只有它们的最终构造函数不同。
let (definition, expression) = match output {
    Output::Generated => (
        quote!(#[derive(Debug)] struct Record { #(#fields),* }),
        quote!(Record { #(#values),* }),
    ),
    Output::Given(output) => (Tokens::new(), quote!(#output { #(#values),* })),
};

let QueryInput { connection, sql, args } = input;
// 生成的本地名称让我们评估每个调用者表达式恰好一次。
let names: Vec<_> = (0..args.len())
    .map(|index| format_ident!("__toy_arg_{index}"))
    .collect();
let args = args.into_iter().collect::<Vec<_>>();

// 运行时阶段:整个块被插入到宏调用点。
Ok(quote! {{
    #definition
    // 元组右侧在绑定生成的名称之前进行评估。
    let (__toy_connection, #(#names,)*) = (#connection, #(&(#args),)*);
    // rusqlite 接受实现 `ToSql` 的值的引用切片。
    let __toy_parameters: &[&dyn ::toy_sqlx::rusqlite::ToSql] = &[#(#names),*];
    ::toy_sqlx::map_rows(
        __toy_connection,
        #sql,
        __toy_parameters,
        // 将一个 SQLite 行解码为生成的或调用者提供的结构体。
        |__toy_row| ::core::result::Result::Ok(#expression),
    )
}})

}

/// 比较 Rust 参数的数量与 SQLite 的占位符计数。
///
/// 这刻意只检查存在 多少 个值。SQLite 不给这个玩具稳定的静态参数类型,所以声称更多会产生误导。
fn validate_arity(got: usize, description: &Description, span: Span) -> syn::Result<()> {
if got == description.parameter_count {
Ok(())
} else {
let expected = description.parameter_count;
Err(syn::Error::new(
span,
format!("SQLite expects {expected} parameter(s), but the macro received {got}"),
))
}
}

/// 将 SQLite 列转换为安全的 Rust 字段名和类型。
///
/// 该函数首先强制执行证据边界,然后检查名称、拒绝重复字段、映射 SQLite 声明并应用可空性。
fn typed_columns(description: &Description, span: Span) -> syn::Result<Vec> {
ensure_typed_shape(&description.sql, span)?;
if !description.source_is_table {
return Err(syn::Error::new(
span,
"typed query must select direct columns from one table; expressions, views, and subqueries are out of scope",
));
}
if description.columns.is_empty() {
return Err(syn::Error::new(span, "typed query must return columns"));
}
let mut names = HashSet::new();
description
.columns
.iter()
.map(|column| {
// SQL 输出名称必须可在生成的 Rust 结构体字面量中使用。
let ident = rust_ident(&column.name, span)?;
if !names.insert(ident.to_string()) {
return Err(syn::Error::new(span, "duplicate output field name"));
}
// 表达式通常没有表声明;类型化输出拒绝它们。
let declared = column.declared_type.as_deref().ok_or_else(|| {
let name = &column.name;
syn::Error::new(span, format!("SQLite has no declared type for {name:?}; typed queries only support direct table columns"))
})?;
let base = rust_type(declared, span)?;
// Rust 将可能为 NULL 的 SQL 值表示为 Option<T>
let ty = if column.nullable {
quote!(::core::option::Option<#base>)
} else {
base
};
Ok(RustColumn { ident, ty })
})
.collect()
}

/// 在生成类型化 Rust 之前拒绝广泛的 SQL 形状。
///
/// 连接和复合查询需要此研讨会未实现的可空性分析。保守的拒绝使小保证保持诚实。
fn ensure_typed_shape(sql: &str, span: Span) -> syn::Result<()> {
let sql = sql.trim_start().to_ascii_uppercase();
let words =
|| sql.split(|character: char| !character.is_ascii_alphanumeric() && character != '_');
let unsupported = !sql.starts_with("SELECT ")
|| words().filter(|word| *word == "SELECT").count() != 1
|| words().any(|word| matches!(word, "JOIN" | "UNION" | "INTERSECT" | "EXCEPT"));
if unsupported {
Err(syn::Error::new(
span,
"typed queries support one direct-table SELECT; joins and compound queries are out of scope",
))
} else {
Ok(())
}
}

/// 将 SQLite 输出名称转换为 Rust 字段标识符。
///
/// 原始形式(例如 r#type)允许在合法时使用 Rust 关键字。回退处理在具有不同原始标识符解析行为的编译器版本上的普通标识符。
fn rust_ident(name: &str, span: Span) -> syn::Result {
syn::parse_str(&format!("r#{name}"))
.or_else(|| syn::parse_str(name))
.map_err(|
| syn::Error::new(span, format!("{name:?} is not a Rust field name")))
}

/// 将一小部分 SQLite 声明类型映射到 Rust 类型标记。
///
/// SQLite 使用类型亲和性并允许动态存储值。此表是一个教学子集,而不是声称每个存储的值都必须具有此 Rust 类型。
///
/// | 声明包含 | 生成的基础类型 |
/// | --- | --- |
/// | BOOLBOOLEAN | bool |
/// | INT | i64 |
/// | REAL, FLOA, 或 DOUB | f64 |
/// | CHAR, CLOB, 或 TEXT | String |
/// | BLOB | Vec<u8> |
/// | 其他任何内容 | 编译器错误 |
fn rust_type(declared: &str, span: Span) -> syn::Result {
let name = declared.trim().to_ascii_uppercase();
if matches!(name.as_str(), "BOOL" | "BOOLEAN") {
Ok(quote!(bool))
} else if name.contains("INT") {
Ok(quote!(i64))
} else if name.contains("REAL") || name.contains("FLOA") || name.contains("DOUB") {
Ok(quote!(f64))
} else if name.contains("CHAR") || name.contains("CLOB") || name.contains("TEXT") {
Ok(quote!(::std::string::String))
} else if name.contains("BLOB") {
Ok(quote!(::std::vec::Vec))
} else {
Err(syn::Error::new(
span,
format!("no toy Rust mapping for SQLite declaration {declared:?}"),
))
}
}
#+end_src

#+name: typed-record-demo
#+begin_src rust :tangle toy-demo/src/bin/04_typed_record.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let users = toy_sqlx::query!(
&connection,
"SELECT id, email, display_name FROM users WHERE active = ?1 ORDER BY id",
true,
)?;
for user in users {
println!("{} {} {:?}", user.id, user.email, user.display_name);
}
Ok(())
}
#+end_src

展开演示并选择生成的 =Record= 加上运行时行映射调用:

#+begin_bash :results output

set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 04_typed_record 2>/dev/null |
grep -E -A 12 'struct Record|::toy_sqlx::map_rows'

#+end_bash

#+RESULTS:
#+begin_example
struct Record {
id: i64,
email: ::std::string::String,
display_name: ::core::option::Option<::std::string::String>,
}
#[automatically_derived]
impl ::core::fmt::Debug for Record {
#[inline]
fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
::core::fmt::Formatter::debug_struct_field3_finish(
f,
"Record",
"id",

    ::toy_sqlx::map_rows(
        __toy_connection,
        "SELECT id, email, display_name FROM users WHERE active = ?1 ORDER BY id",
        __toy_parameters,
        |__toy_row| ::core::result::Result::Ok(Record {
            id: __toy_row.get::<usize, i64>(0usize)?,
            email: __toy_row.get::<usize, ::std::string::String>(1usize)?,
            display_name: __toy_row
                .get::<usize, ::core::option::Option<::std::string::String>>(2usize)?,
        }),
    )
}?;
for user in users {

#+end_example

重要的生成形状是:

#+begin_src rust :tangle no
struct Record {
id: i64,
email: String,
display_name: Option,
}
map_rows(connection, sql, parameters, |row| Ok(Record {
id: row.get::<usize, i64>(0)?,
email: row.get::<usize, String>(1)?,
display_name: row.get::<usize, Option>(2)?,
}))
#+end_src

  • 运行步骤:

#+begin_bash :results output

./scripts/toy-demo.sh 4

#+end_bash

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.34s
: Running target/debug/04_typed_record
: 1 ada@example.test Some("Ada")
: 2 grace@example.test None

  • 观察: 循环使用 =user.id= 和 =user.email=;应用程序代码中不再保留手写的 =row.get= 调用。
  • 收获: 输出名称和支持的声明成为 Rust 字段。
  • 为第 6 步保留的问题: 什么证据证明 =email= 使用 =String= 而 =display_name= 使用 =Option=?
  • 下一步仍存在的问题: =Record= 对宏展开是局部的。第 5 步将相同的证据映射到应用程序拥有的结构体中。
  • 第 5 步:填充应用程序拥有的结构体
    :PROPERTIES:
    :MINUTES: 10
    :END:

#+begin_quote
问题: 生成的本地 =Record= 在一个表达式内很方便,但应用程序已经拥有在函数和模块之间使用的命名领域类型。

为何关注: 查询结果应该适合这些类型,而无需添加第二个手动解码器。

本步骤: 生成调用者结构体的普通字面量。让 rustc(而不是宏)验证字段名和 Rust 类型。
#+end_quote

=query_as!= 重用相同的展开并仅更改最终构造函数。它不检查或反射结构体定义。

#+name: query-as-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// query_as!(OutputType, &connection, "SQL", args...) 的解析输入。
#[rustfmt::skip]
struct QueryAsInput { output: Type, query: QueryInput }

/// 解析调用者的 Rust 类型,然后重用完整的 query! 语法。
#[rustfmt::skip]
impl Parse for QueryAsInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
let output = input.parse()?; input.parse::<Token![,]>()?;
Ok(Self { output, query: input.parse()? })
}
}
#+end_src

#+name: query-as-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 验证并执行查询到调用者提供的 Rust 结构体中。
///
/// 宏发出一个普通的结构体字面量。因此,rustc 会报告缺失、额外或类型错误的字段,而无需自定义反射机制。
#[proc_macro]
#[rustfmt::skip]
pub fn query_as(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as QueryAsInput);
result(expand_query(input.query, Output::Given(Box::new(input.output))))
}
#+end_src

#+name: query-as-demo
#+begin_src rust :tangle toy-demo/src/bin/05_query_as.rs :mkdirp yes :comments no
struct UserSummary {
id: i64,
email: String,
}

#[cfg(feature = "fail-query-as-name")]
struct WrongName {
id: i64,
address: String,
}

#[cfg(feature = "fail-query-as-type")]
struct WrongType {
id: i32,
email: String,
}

fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
#[cfg(feature = "fail-query-as-name")]
let _ = toy_sqlx::query_as!(
WrongName,
&connection,
"SELECT id, email FROM users ORDER BY id"
)?;
#[cfg(feature = "fail-query-as-type")]
let _ = toy_sqlx::query_as!(
WrongType,
&connection,
"SELECT id, email FROM users ORDER BY id"
)?;
#[cfg(not(any(feature = "fail-query-as-name", feature = "fail-query-as-type")))]
for user in toy_sqlx::query_as!(
UserSummary,
&connection,
"SELECT id, email FROM users ORDER BY id"
)? {
println!("{} {}", user.id, user.email);
}
Ok(())
}
#+end_src

展开 =query_as!= 以查看它重用了 =map_rows= 但构造了 =UserSummary= 而不是定义 =Record=:

#+begin_bash :results output

set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 05_query_as 2>/dev/null |
grep -A 12 '::toy_sqlx::map_rows'

#+end_bash

#+RESULTS:
#+begin_example
::toy_sqlx::map_rows(
__toy_connection,
"SELECT id, email FROM users ORDER BY id",
__toy_parameters,
|__toy_row| ::core::result::Result::Ok(UserSummary {
id: __toy_row.get::<usize, i64>(0usize)?,
email: __toy_row.get::<usize, ::std::string::String>(1usize)?,
}),
)
}? {
{
::std::io::_print(format_args!("{0} {1}\n", user.id, user.email));
};
#+end_example

生成的表达式是一个普通的结构体字面量:

#+begin_src rust :tangle no
UserSummary {
id: row.get::<usize, i64>(0)?,
email: row.get::<usize, String>(1)?,
}
#+end_src

  • 运行步骤及其两个预期的失败:

#+begin_bash :results output

./scripts/toy-demo.sh 5
./scripts/toy-demo.sh fail-query-as-name
./scripts/toy-demo.sh fail-query-as-type

#+end_bash

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.31s
: Running target/debug/05_query_as
: 1 ada@example.test
: 2 grace@example.test
: 3 alan@example.test
: fail-query-as-name: failed as intended
: fail-query-as-type: failed as intended

  • 观察: 正确的 =UserSummary= 编译通过;错误的字段或字段类型会产生普通的 rustc 错误。
  • 收获: 已检查的行可以进入应用程序拥有的类型,而无需第二个解码系统。
  • 仍存在的问题: 生成的 Rust 只有在 SQL 元数据证明其合理时才可信。第 6 步定义了诚实的证据边界。
  • 第 6 步:使类型声明诚实
    :PROPERTIES:
    :MINUTES: 8
    :END:

#+begin_quote
问题: 为实际上可能是 =NULL= 的值生成 =String= 会使编译时特性变得自信地错误。

为何关注: 连接、视图、子查询和表达式即使在底层表列声明为 =NOT NULL= 时也可能改变可空性。

本步骤: 仅接受来自一个真实表的直接列的类型化输出。读取该表的模式:=NOT NULL= 变为 =T=;所有可为空的内容变为 =Option=。拒绝我们无法证明其证据的形状。
#+end_quote

这不是一个假装理解每个查询的小型 SQL 解析器。拒绝是正确性机制。

#+name: nullability-demo
#+begin_src rust :tangle toy-demo/src/bin/06_nullability.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
#[cfg(feature = "fail-expression-metadata")]
let _ = toy_sqlx::query!(&connection, "SELECT COUNT(*) AS user_count FROM users")?;
#[cfg(feature = "fail-unsupported-shape")]
let _ = toy_sqlx::query!(
&connection,
"SELECT a.id FROM users AS a JOIN users AS b ON a.id = b.id",
)?;
#[cfg(feature = "fail-view-source")]
let _ = toy_sqlx::query!(&connection, "SELECT bid FROM hidden_join")?;
#[cfg(feature = "fail-subquery-source")]
let _ = toy_sqlx::query!(
&connection,
"SELECT (VALUES(NULL),(email)) AS email FROM users"
)?;
#[cfg(not(any(
feature = "fail-expression-metadata",
feature = "fail-unsupported-shape",
feature = "fail-view-source",
feature = "fail-subquery-source"
)))]
for user in toy_sqlx::query!(
&connection,
"SELECT email, display_name FROM users ORDER BY id"
)? {
println!("{} {:?}", user.email, user.display_name);
}
Ok(())
}
#+end_src

  • 运行步骤及其证据边界失败:

#+begin_bash :results output

./scripts/toy-demo.sh 6
./scripts/toy-demo.sh fail-expression
./scripts/toy-demo.sh fail-shape
./scripts/toy-demo.sh fail-view
./scripts/toy-demo.sh fail-subquery

#+end_bash

#+RESULTS:
#+begin_example
Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
Finished dev profile [unoptimized + debuginfo] target(s) in 0.76s
Running target/debug/06_nullability
ada@example.test Some("Ada")
grace@example.test None
alan@example.test Some("Alan")
fail-expression: failed as intended
fail-shape: failed as intended
fail-view: failed as intended
fail-subquery: failed as intended
#+end_example

  • 观察: 直接 =email= 是 =String=;可为空的 =display_name= 是 =Option=;不支持的形状在编译期间失败。
  • 收获: 由直接模式证据支持的狭窄类型化查询声明。
  • 仍存在的问题: 在线展开每次都需要数据库。第 7 步使证据可移植。
  • 第 7 步:在没有构建数据库的情况下编译
    :PROPERTIES:
    :MINUTES: 7
    :END:

#+begin_quote
问题: 编译时检查现在依赖于打开构建数据库。

为何关注: 开发人员和 CI 可能需要无法使用该数据库的可重现构建。

本步骤: 将已验证的 =Description= 保存为 JSON,然后在离线模式下加载相同的形状。保持加载器之后的所有验证和代码生成步骤不变。
#+end_quote

缓存是刻意可见的:一个小哈希选择文件名,而在信任缓存证据之前会检查版本、后端和确切的 SQL。

#+name: offline-cache
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// 读取研讨会的布尔环境变量标志。
fn flag(name: &str) -> bool {
env::var(name)
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
}

/// 从确切的 SQL 文本生成简短、确定性的缓存文件名。
///
/// 使用 FNV-1a 是因为它的循环很容易教学。生产缓存通常会使用抗碰撞哈希和更强的并发保证。
fn hash(sql: &str) -> u64 {
sql.as_bytes()
.iter()
.fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}

/// 在调用宏的 crate 内部定位此查询的缓存文件。
///
/// CARGO_MANIFEST_DIR 属于调用者,因此每个 crate 都有自己的 .toy-sqlx 目录,而不是共享过程宏 crate 的目录。
fn cache_path(sql: &str, span: Span) -> syn::Result {
let manifest = env::var_os("CARGO_MANIFEST_DIR")
.ok_or_else(|| syn::Error::new(span, "Cargo did not provide CARGO_MANIFEST_DIR"))?;
Ok(PathBuf::from(manifest)
.join(".toy-sqlx")
.join(format!("query-{:016x}.json", hash(sql))))
}

/// 将实时 SQLite 描述序列化为可读的 JSON 以供离线编译。
fn save_cache(description: &Description, span: Span) -> syn::Result<()> {
let path = cache_path(&description.sql, span)?;
fs::create_dir_all(path.parent().unwrap())
.and_then(|_| fs::write(&path, serde_json::to_vec_pretty(description).unwrap()))
.map_err(|error| syn::Error::new(span, format!("cannot write {}: {error}", path.display())))
}

/// 加载缓存的证据并拒绝任何与此查询不匹配的内容。
///
/// 检查版本、后端和确切的 SQL 可以防止在不同假设下创建的缓存条目静默地驱动 Rust 代码生成。
fn load_cache(sql: &str, span: Span) -> syn::Result {
let path = cache_path(sql, span)?;
let bytes = fs::read(&path).map_err(|error| {
syn::Error::new(
span,
format!("offline metadata missing at {}: {error}", path.display()),
)
})?;
let description: Description = serde_json::from_slice(&bytes)
.map_err(|error| syn::Error::new(span, format!("invalid offline metadata: {error}")))?;
// 所有三个检查都是缓存信任边界的一部分。
if description.version != CACHE_VERSION
|| description.database != "SQLite"
|| description.sql != sql
{
Err(syn::Error::new(
span,
"offline metadata does not match this SQLite query",
))
} else {
Ok(description)
}
}
#+end_src

  • 运行在线演示,然后证明离线构建:

#+begin_bash :results output

./scripts/toy-demo.sh 7
./scripts/toy-demo.sh offline

#+end_bash

#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.85s
: Running target/debug/07_offline_metadata
: decoded 3 users using a compile-time description
: offline: success plus missing, malformed, version, backend, and SQL mismatch checks passed

  • 观察: 在线准备元数据,删除数据库,强制展开,并成功从缓存中编译。
  • 收获: 代码生成消耗一个 =Description=,无论其证据是来自实时 SQLite 还是已检查的缓存。
  • 边界: 缓存新鲜度、抗碰撞性、原子写入和并发写入是生产问题。该玩具验证了缺失、格式错误、版本错误、后端错误和 SQL 错误的元数据。

** 纠缠的验证和演示运行器 :noexport:

核心故事到此结束。剩余的块自动化测试、演示、预期的失败和大小检查;它们不是额外的教学步骤。

#+name: focused-macro-tests
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// 这些测试针对在编辑研讨会时最容易破坏的小策略:类型映射、查询形状拒绝、字段名和缓存哈希。
#[cfg(test)]
mod tests {
use super::*;

#[test]
#[rustfmt::skip]
fn maps_the_teaching_types() {
    // 比较发出的标记作为文本,因为这些辅助程序生成代码,而不是值。
    assert_eq!(rust_type("INTEGER", Span::call_site()).unwrap().to_string(), "i64");
    assert_eq!(
        rust_type("TEXT", Span::call_site()).unwrap().to_string(),
        ":: std :: string :: String"
    );
    assert!(rust_type("NUMERIC", Span::call_site()).is_err());
}

#[test]
#[rustfmt::skip]
fn rejects_unsupported_sources_and_shapes() {
    // 每个不确定的形状必须失败关闭,而不是生成不健全的类型。
    assert!(ensure_typed_shape("SELECT * FROM a LEFT JOIN b", Span::call_site()).is_err());
    assert!(ensure_typed_shape("DELETE FROM a RETURNING id", Span::call_site()).is_err());
    assert!(ensure_typed_shape("SELECT (SELECT id FROM a) FROM b", Span::call_site()).is_err());
    assert_eq!(direct_source("SELECT id FROM users"), Some("users"));
    assert_eq!(direct_source("SELECT id FROM users AS u WHERE u.id > 0"), Some("users"));
    assert_eq!(direct_source("SELECT bid /* FROM users */ FROM hidden_join"), None);
    assert_eq!(direct_source("SELECT u.id FROM users u , hidden_join v"), None);
    assert_eq!(direct_source("SELECT (VALUES(NULL),(email)) AS email FROM users"), None);
}

#[test]
#[rustfmt::skip]
fn rejects_bad_and_duplicate_fields() {
    // 无效或重复的 SQL 名称不能形成有效的 Rust 结构体。
    assert!(rust_ident("bad name", Span::call_site()).is_err());
    let column = || Column { name: "id".into(), declared_type: Some("INTEGER".into()), nullable: false };
    let description = Description { version: CACHE_VERSION, database: "SQLite".into(), sql: "SELECT id, id FROM users".into(), parameter_count: 0, source_is_table: true, columns: vec![column(), column()] };
    assert!(typed_columns(&description, Span::call_site()).is_err());
}

#[test]
fn hash_is_stable() {
    // 更改此值将移动每个缓存文件,并需要明确的决定。
    assert_eq!(hash("SELECT 1"), 0x199e7bca63ea84f2);
}

}
#+end_src

#+name: offline-demo
#+begin_src rust :tangle toy-demo/src/bin/07_offline_metadata.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let users = toy_sqlx::query!(&connection, "SELECT id, email FROM users ORDER BY id")?;
println!(
"decoded {} users using a compile-time description",
users.len()
);
Ok(())
}
#+end_src

#+name: presentation-runner
#+begin_src bash :tangle scripts/toy-demo.sh :mkdirp yes :comments no :tangle-mode (identity #o755)
#!/usr/bin/env bash
set -euo pipefail

root="(cd -- "(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
export TOY_DATABASE_URL="{TOY_DATABASE_URL:-sqlite://root/toy-demo/toy.db}"

database_path() {
case "$1" in
sqlite://) printf '%s\n' "${1#sqlite://}" ;;
sqlite:
) printf '%s\n' "${1#sqlite:}" ;;
*) printf '%s\n' "$1" ;;
esac
}

setup() {
cargo run --quiet -p toy-demo --bin setup
}

run_stage() {
local bin="0{1}_2" pattern="$3" output
setup >/dev/null
output="(cargo run -p toy-demo --bin "bin" 2>&1)"
printf '%s\n' "$output"
grep -Eq "pattern" <<<output
}

expect_failure() {
local label="1" bin="2" feature="3" pattern="4" setup_first="${5:-yes}" log
[[ "$setup_first" == yes ]] && setup >/dev/null
log="$(mktemp)"
if cargo check -p toy-demo --bin "bin" --features "feature" >"$log" 2>&1; then
cat "$log"
rm -f "$log"
echo "$label unexpectedly compiled" >&2
return 1
fi
if ! grep -Eq "pattern" "log"; then
cat "$log"
rm -f "$log"
echo "$label failed without the expected diagnostic" >&2
return 1
fi
rm -f "$log"
echo "$label: failed as intended"
}

create_hidden_view() {
local database
database="(database_path "TOY_DATABASE_URL")"
python3 - "$database" <<'PY'
import sqlite3, sys
connection = sqlite3.connect(sys.argv[1])
connection.execute("CREATE VIEW hidden_join AS SELECT b.id AS bid FROM users a LEFT JOIN users b ON b.id = -1")
connection.commit()
PY
}

offline_failure() {
local pattern="$1" log
log="$(mktemp)"
cargo clean -p toy-demo >/dev/null
if TOY_SQLX_OFFLINE=1 cargo check -p toy-demo --bin 07_offline_metadata >"$log" 2>&1; then
cat "$log"
rm -f "$log"
echo "invalid offline metadata unexpectedly compiled" >&2
return 1
fi
grep -Eq "pattern" "log" || { cat "log"; rm -f "log"; return 1; }
rm -f "$log"
}

offline() {
local database backup cache saved
setup >/dev/null
rm -rf toy-demo/.toy-sqlx
cargo clean -p toy-demo >/dev/null
TOY_SQLX_PREPARE=1 cargo check -p toy-demo --bin 07_offline_metadata
database="(database_path "TOY_DATABASE_URL")"
backup="${database}.offline-backup"
cache="$(find toy-demo/.toy-sqlx -name 'query-*.json' -print -quit)"
saved="${cache}.saved"
cp "cache" "saved"
cleanup() {
[[ -e "backup" ]] && mv "backup" "$database"
[[ -e "saved" ]] && mv -f "saved" "$cache"
}
trap cleanup EXIT
rm -f "$backup"
mv "database" "backup"
cargo clean -p toy-demo >/dev/null
TOY_SQLX_OFFLINE=1 cargo check -p toy-demo --bin 07_offline_metadata

rm "$cache"
offline_failure 'offline metadata missing'
cp "saved" "cache"
printf '{' >"$cache"
offline_failure 'invalid offline metadata'
for field in version database sql; do
cp "saved" "cache"
python3 - "cache" "field" <<'PY'
import json, sys
path, field = sys.argv[1:]
data = json.load(open(path))
if field == "version": data[field] += 1
else: data[field] += "-wrong"
open(path, "w").write(json.dumps(data))
PY
offline_failure 'offline metadata does not match'
done

cleanup
trap - EXIT
echo "offline: success plus missing, malformed, version, backend, and SQL mismatch checks passed"
}

size() {
local macro_prod macro_tests runtime demos total
read -r macro_prod macro_tests <<(awk '
BEGIN { test = 0; production = 0; tests = 0 }
/^#[cfg(test)]/ { test = 1 }
/^[[:space:]]$/ || /^[[:space:]]/// { next }
{ if (test) tests++; else production++ }
END { print production, tests }
' toy-sqlx-macros/src/lib.rs)
runtime="(awk '!/^[[:space:]]*/ && !/^[[:space:]]///' toy-sqlx/src/lib.rs | wc -l | tr -d ' ')"
demos="(awk '!/^[[:space:]]*/ && !/^[[:space:]]
///' toy-demo/src/bin/*.rs | wc -l | tr -d ' ')"
total=$((macro_prod + macro_tests + runtime + demos))
printf 'macro production=%s tests=%s runtime=%s demos=%s total=%s\n'
"macro_prod" "macro_tests" "runtime" "demos" "$total"
((macro_prod <= 340 && macro_tests <= 50 && runtime <= 80 && demos <= 180 && total <= 650))
}

workspace() {
setup >/dev/null
scripts/setup-db.sh >/dev/null
cargo test --workspace
cargo clippy -p toy-sqlx -p toy-sqlx-macros -p toy-demo --all-targets -- -D warnings
cargo +1.85.0 check -p toy-sqlx -p toy-sqlx-macros -p toy-demo
cargo fmt --all -- --check
}

all() {
"$0" 0
"$0" 1
"$0" 2
"$0" 3
"$0" 4
"$0" 5
"$0" 6
"$0" 7
"$0" fail-literal
"$0" fail-sql
"$0" fail-arity
"$0" fail-query-as-name
"$0" fail-query-as-type
"$0" fail-expression
"$0" fail-shape
"$0" fail-view
"$0" fail-subquery
"$0" offline
"$0" size
}

case "${1:-}" in
setup) setup ;;
0) run_stage 0 runtime_sql 'runtime SQLite error' ;;

  1. run_stage 1 literal_macro 'SELECT 40 + 2 AS answer' ;;
  2. run_stage 2 compile_time_prepare 'SQLite accepted during compilation' ;;
  3. run_stage 3 parameter_arity 'parameter type deliberately unchecked' ;;
  4. run_stage 4 typed_record 'grace@example.test None' ;;
  5. run_stage 5 query_as 'alan@example.test' ;;
  6. run_stage 6 nullability 'grace@example.test None' ;;
  7. run_stage 7 offline_metadata 'decoded 3 users using a compile-time description' ;;
    fail-literal) expect_failure "$1" 01_literal_macro fail-literal 'expected string literal' ;;
    fail-sql) expect_failure "$1" 02_compile_time_prepare fail-invalid-sql 'SQLite rejected this query' ;;
    fail-arity) expect_failure "$1" 03_parameter_arity fail-arity 'SQLite expects 2 parameter.*received 1' ;;
    fail-query-as-name) expect_failure "$1" 05_query_as fail-query-as-name 'has no field named.*email' ;;
    fail-query-as-type) expect_failure "$1" 05_query_as fail-query-as-type 'incompatible types|mismatched types' ;;
    fail-expression) expect_failure "$1" 06_nullability fail-expression-metadata 'direct columns from one table' ;;
    fail-shape) expect_failure "$1" 06_nullability fail-unsupported-shape 'joins and compound queries are out of scope' ;;
    fail-view)
    setup >/dev/null
    create_hidden_view
    expect_failure "$1" 06_nullability fail-view-source 'direct columns from one table' no
    ;;
    fail-subquery) expect_failure "$1" 06_nullability fail-subquery-source 'direct columns from one table' ;;
    offline) offline ;;
    size) size ;;
    workspace) workspace ;;
    all) all ;;
    )
    echo "usage: scripts/toy-demo.sh {setup|0|1|2|3|4|5|6|7|offline|fail-
    |all|workspace|size}" >&2
    exit 2
    ;;
    esac
    #+end_src
  • 回顾:组装流水线

演进是一系列早期反馈的链条:

#+begin_example
runtime SQL error
-> compile-time literal
-> SQLite validation
-> arity validation
-> generated record
-> application struct
-> evidence-backed nullability
-> offline compilation
#+end_example

这里没有任何内容要求宏成为数据库。宏只是将证据从 SQLite 传输到 rustc 已经知道如何检查的 Rust 代码中。

| 最终声明 | 使用的证据 | 诚实边界 |
|---+---+---|
| SQL 有效 | SQLite 在展开期间准备了它 | 仅构建模式 |
| 绑定计数匹配 | SQLite 参数计数 | 无 SQLite 绑定类型 |
| 输出字段存在 | 语句列元数据 | 仅直接列 |
| Rust 输出形状匹配 | 生成的记录/结构体字面量 | 动态值可能解码错误 |
| 可为空的直接列 | 模式 =NOT NULL= 元数据 | 连接/表达式被拒绝 |
| 无 DB 构建 | 缓存的 =Description= | 缓存新鲜度是外部纪律 |

可重用的工程模型是:询问数据库,保留其证据,生成普通 Rust,并让 rustc 完成检查

  • 问答

核心内容在第 80 分钟结束。接下来的部分不是主要故事所必需的。

  • 附录

** 后端依赖的参数类型
类似 PostgreSQL 的后端可以返回参数类型元数据并生成死代码 Rust 类型断言。SQLite 仅返回元数。在核心中添加一个编造的 SQLite 解析器会掩盖这个边界。

** 表达式覆盖
更大的玩具可以解析别名,例如 ="count!: INTEGER"=。核心拒绝表达式,因此每个推断类型都有直接元数据。

** 连接感知的可空性
真实的系统可能会检查计划或 SQLite 字节码。高级遗留 =mini-sqlx-macros= 展示了为什么这会迅速扩展实现。

** 更强的缓存
讨论加密哈希、模式标识、原子写入、锁和 CI 缓存新鲜度,而不将它们纠缠到教学实现中。

** 异步运行时和池
它们改变执行,而不是编译时证据/代码生成流水线。

** 迁移和 CI
宏检查构建期间存在的模式。CI 应该创建一个隔离的 DB,应用迁移,编译/准备查询,并验证缓存的证据。

** 将玩具映射到 SQLx
玩具 =Description= 对应于后端描述结果;=load_description= 对应于在线/离线查询数据选择;=expand_query= 对应于参数/输出代码生成。名称是概念性的,而不是源代码保真度声明。

** Github

[[https://github.com/chiefkemist/toy-sqlx][toy-sqlx -- Not a clone of SQLx]]