1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
//! Integration tests for #1262: automatic capture of a request's SQL query
//! list and the `TestResponse` query-count / N+1 assertions built on it.
//!
//! Capture requires **zero** manual interceptor wiring: driving a request with
//! `TestClient` against a real Postgres pool (`with_db`) is enough for
//! `TestResponse::query_count` / `assert_max_queries` / `assert_no_n_plus_one`
//! to see every statement the handler issued.
//!
//! Requires Docker (testcontainers), so both tests are `#[ignore]`d and run in
//! CI with:
//!
//! cargo test -p autumn-web --test integration_tests -- --include-ignored query_count
#[cfg(all(feature = "db", feature = "test-support"))]
mod query_count_tests {
use autumn_web::prelude::*;
use autumn_web::test::{TestApp, TestDb};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
diesel::table! {
qc_posts (id) {
id -> Int8,
title -> Text,
}
}
#[derive(Debug, Queryable, Selectable)]
#[diesel(table_name = qc_posts)]
struct Post {
pub id: i64,
pub title: String,
}
/// Issues exactly one application query: a single `SELECT` over all posts.
#[get("/posts-flat")]
async fn posts_flat(mut db: Db) -> AutumnResult<Json<usize>> {
let posts = qc_posts::table
.select(Post::as_select())
.load(&mut *db)
.await?;
Ok(Json(posts.len()))
}
/// Classic N+1: one `SELECT` for the list, then one `SELECT` per row. The
/// per-row statement normalizes to a single template repeated once per row.
#[get("/posts-n-plus-one")]
async fn posts_n_plus_one(mut db: Db) -> AutumnResult<Json<usize>> {
let posts = qc_posts::table
.select(Post::as_select())
.load(&mut *db)
.await?;
let mut total = 0usize;
for p in &posts {
let one: Post = qc_posts::table
.filter(qc_posts::id.eq(p.id))
.select(Post::as_select())
.first(&mut *db)
.await?;
total += one.title.len();
}
Ok(Json(total))
}
static SETUP: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
async fn setup(db: &TestDb) {
SETUP
.get_or_init(|| async {
db.execute_sql(
"CREATE TABLE IF NOT EXISTS qc_posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL
)",
)
.await;
db.execute_sql("DELETE FROM qc_posts").await;
db.execute_sql(
"INSERT INTO qc_posts (title) VALUES ('a'),('bb'),('ccc'),('dddd'),('eeeee')",
)
.await;
})
.await;
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn flat_handler_query_count_and_max_queries() {
let db = TestDb::shared().await;
setup(db).await;
let client = TestApp::new()
.routes(routes![posts_flat])
.with_db(db.pool())
.build();
let resp = client.get("/posts-flat").send().await;
resp.assert_ok();
// The housekeeping `SET statement_timeout` at checkout is excluded, so
// the flat handler's single SELECT is the only counted query.
assert_eq!(
resp.query_count(),
1,
"flat handler issues exactly one application query, got: {:?}",
resp.queries()
);
resp.assert_max_queries(1).assert_no_n_plus_one();
}
/// Regression: with `observability.server_timing` enabled the router
/// installs `ServerTimingLayer`, whose inner `REQUEST_DB_TIMINGS` timing
/// scope is independent of the harness's `REQUEST_QUERY_CAPTURE` lane. Query
/// capture rides that separate task-local, so it is unaffected by however
/// the Server-Timing layer scopes (and nests) its per-scope DB metric — the
/// flat handler's single SELECT is both counted for the `Server-Timing`
/// header AND captured (exactly once) for the assertions.
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn server_timing_enabled_still_captures_queries() {
use autumn_web::config::AutumnConfig;
let db = TestDb::shared().await;
setup(db).await;
let mut config = AutumnConfig {
profile: Some("test".into()),
..AutumnConfig::default()
};
config.security.csrf.enabled = false;
// Force the Server-Timing layer on (off by default in the test profile).
config.observability.server_timing = Some(true);
let client = TestApp::new()
.config(config)
.routes(routes![posts_flat])
.with_db(db.pool())
.build();
let resp = client.get("/posts-flat").send().await;
resp.assert_ok();
// The Server-Timing layer is active: it must have stamped the header,
// including the `db` metric for the one counted query.
let server_timing = resp
.header("server-timing")
.expect("server_timing enabled must emit a Server-Timing header")
.to_owned();
assert!(
server_timing.contains("db;dur="),
"the db metric should surface the counted query: {server_timing:?}"
);
// Crucially, query CAPTURE must still work despite the layer's inner
// scope — this is the regression the reuse fix guards.
assert_eq!(
resp.query_count(),
1,
"server_timing must not zero out captured queries, got: {:?}",
resp.queries()
);
resp.assert_max_queries(1).assert_no_n_plus_one();
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn n_plus_one_handler_trips_assertion() {
let db = TestDb::shared().await;
setup(db).await;
let client = TestApp::new()
.routes(routes![posts_n_plus_one])
.with_db(db.pool())
.build();
let resp = client.get("/posts-n-plus-one").send().await;
resp.assert_ok();
// 1 list query + 5 per-row queries = 6 counted statements.
assert_eq!(
resp.query_count(),
6,
"captured queries: {:?}",
resp.queries()
);
// The default dev threshold is 5; the per-row template repeats 5 times,
// so `assert_no_n_plus_one` must fire, naming the request.
let err = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resp.assert_no_n_plus_one();
}))
.expect_err("the N+1 handler must trip assert_no_n_plus_one");
let msg = err
.downcast_ref::<String>()
.cloned()
.or_else(|| err.downcast_ref::<&str>().map(|s| (*s).to_owned()))
.unwrap_or_default();
assert!(
msg.contains("GET /posts-n-plus-one"),
"failure names the request: {msg}"
);
assert!(msg.contains("5 times"), "failure reports the count: {msg}");
}
}