Skip to content

Commit 8de0d68

Browse files
authored
Add an Explainable trait to EXPLAIN Diesel statements (#598)
For any runnable diesel statement, rather than executing it using the typical RunQueryDsl methods, it may be "explained" instead, producing a string of the raw EXPLAIN output. The intent of this statement is to assist debugging, but it could also potentially be used for automated testing (e.g., checking for / disallowing `spans: FULL SCAN`)
1 parent f2e00cf commit 8de0d68

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed

nexus/src/db/explain.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! Utility allowing Diesel to EXPLAIN queries.
6+
7+
use super::pool::DbConnection;
8+
use async_bb8_diesel::{AsyncRunQueryDsl, ConnectionManager, PoolError};
9+
use async_trait::async_trait;
10+
use diesel::pg::Pg;
11+
use diesel::prelude::*;
12+
use diesel::query_builder::*;
13+
14+
/// A wrapper around a runnable Diesel query, which EXPLAINs what it is doing.
15+
///
16+
/// Q: The Query we're explaining.
17+
///
18+
/// EXPLAIN: https://www.cockroachlabs.com/docs/stable/explain.html
19+
pub trait Explainable<Q> {
20+
/// Syncronously issues an explain statement.
21+
fn explain(
22+
self,
23+
conn: &mut DbConnection,
24+
) -> Result<String, diesel::result::Error>;
25+
}
26+
27+
impl<Q> Explainable<Q> for Q
28+
where
29+
Q: QueryFragment<Pg>
30+
+ QueryId
31+
+ RunQueryDsl<DbConnection>
32+
+ Sized
33+
+ 'static,
34+
{
35+
fn explain(
36+
self,
37+
conn: &mut DbConnection,
38+
) -> Result<String, diesel::result::Error> {
39+
Ok(ExplainStatement { query: self }
40+
.get_results::<String>(conn)?
41+
.join("\n"))
42+
}
43+
}
44+
45+
/// An async variant of [`Explainable`].
46+
#[async_trait]
47+
pub trait ExplainableAsync<Q> {
48+
/// Asynchronously issues an explain statement.
49+
async fn explain_async(
50+
self,
51+
pool: &bb8::Pool<ConnectionManager<DbConnection>>,
52+
) -> Result<String, PoolError>;
53+
}
54+
55+
#[async_trait]
56+
impl<Q> ExplainableAsync<Q> for Q
57+
where
58+
Q: QueryFragment<Pg>
59+
+ QueryId
60+
+ RunQueryDsl<DbConnection>
61+
+ Sized
62+
+ Send
63+
+ 'static,
64+
{
65+
async fn explain_async(
66+
self,
67+
pool: &bb8::Pool<ConnectionManager<DbConnection>>,
68+
) -> Result<String, PoolError> {
69+
Ok(ExplainStatement { query: self }
70+
.get_results_async::<String>(pool)
71+
.await?
72+
.join("\n"))
73+
}
74+
}
75+
76+
// An EXPLAIN statement, wrapping an underlying query.
77+
//
78+
// This isn't `pub` because it's kinda weird to access "part" of the EXPLAIN
79+
// output, which would be possible by calling "get_result" instead of
80+
// "get_results". We'd like to be able to constrain callers such that they get
81+
// all of the output or none of it.
82+
//
83+
// See the [`Explainable`] trait for why this exists.
84+
struct ExplainStatement<Q> {
85+
query: Q,
86+
}
87+
88+
impl<Q> QueryId for ExplainStatement<Q>
89+
where
90+
Q: QueryId + 'static,
91+
{
92+
type QueryId = ExplainStatement<Q>;
93+
const HAS_STATIC_QUERY_ID: bool = Q::HAS_STATIC_QUERY_ID;
94+
}
95+
96+
impl<Q> Query for ExplainStatement<Q> {
97+
type SqlType = diesel::sql_types::Text;
98+
}
99+
100+
impl<Q> RunQueryDsl<DbConnection> for ExplainStatement<Q> {}
101+
102+
impl<Q> QueryFragment<Pg> for ExplainStatement<Q>
103+
where
104+
Q: QueryFragment<Pg>,
105+
{
106+
fn walk_ast(&self, mut out: AstPass<Pg>) -> QueryResult<()> {
107+
out.push_sql("EXPLAIN (");
108+
self.query.walk_ast(out.reborrow())?;
109+
out.push_sql(")");
110+
Ok(())
111+
}
112+
}
113+
114+
#[cfg(test)]
115+
mod test {
116+
use super::*;
117+
118+
use crate::db;
119+
use async_bb8_diesel::{AsyncConnection, AsyncSimpleConnection};
120+
use diesel::SelectableHelper;
121+
use expectorate::assert_contents;
122+
use nexus_test_utils::db::test_setup_database;
123+
use omicron_test_utils::dev;
124+
use uuid::Uuid;
125+
126+
mod schema {
127+
use diesel::prelude::*;
128+
129+
table! {
130+
test_users {
131+
id -> Uuid,
132+
age -> Int8,
133+
height -> Int8,
134+
}
135+
}
136+
}
137+
138+
use schema::test_users;
139+
140+
#[derive(Clone, Debug, Queryable, Insertable, PartialEq, Selectable)]
141+
#[table_name = "test_users"]
142+
struct User {
143+
id: Uuid,
144+
age: i64,
145+
height: i64,
146+
}
147+
148+
async fn create_schema(pool: &db::Pool) {
149+
pool.pool()
150+
.get()
151+
.await
152+
.unwrap()
153+
.batch_execute_async(
154+
"CREATE TABLE test_users (
155+
id UUID PRIMARY KEY,
156+
age INT NOT NULL,
157+
height INT NOT NULL
158+
)",
159+
)
160+
.await
161+
.unwrap();
162+
}
163+
164+
// Tests the ".explain()" method in a synchronous context.
165+
//
166+
// This is often done when calling from transactions, which we demonstrate.
167+
#[tokio::test]
168+
async fn test_explain() {
169+
let logctx = dev::test_setup_log("test_explain");
170+
let db = test_setup_database(&logctx.log).await;
171+
let cfg = db::Config { url: db.pg_config().clone() };
172+
let pool = db::Pool::new(&cfg);
173+
174+
create_schema(&pool).await;
175+
176+
use schema::test_users::dsl;
177+
pool.pool()
178+
.transaction(
179+
move |conn| -> Result<(), db::error::TransactionError<()>> {
180+
let explanation = dsl::test_users
181+
.filter(dsl::id.eq(Uuid::nil()))
182+
.select(User::as_select())
183+
.explain(conn)
184+
.unwrap();
185+
assert_contents(
186+
"tests/output/test-explain-output",
187+
&explanation,
188+
);
189+
Ok(())
190+
},
191+
)
192+
.await
193+
.unwrap();
194+
}
195+
196+
// Tests the ".explain_async()" method in an asynchronous context.
197+
#[tokio::test]
198+
async fn test_explain_async() {
199+
let logctx = dev::test_setup_log("test_explain_async");
200+
let db = test_setup_database(&logctx.log).await;
201+
let cfg = db::Config { url: db.pg_config().clone() };
202+
let pool = db::Pool::new(&cfg);
203+
204+
create_schema(&pool).await;
205+
206+
use schema::test_users::dsl;
207+
let explanation = dsl::test_users
208+
.filter(dsl::id.eq(Uuid::nil()))
209+
.select(User::as_select())
210+
.explain_async(pool.pool())
211+
.await
212+
.unwrap();
213+
214+
assert_contents("tests/output/test-explain-output", &explanation);
215+
}
216+
217+
// Tests that ".explain()" can tell us when we're doing full table scans.
218+
#[tokio::test]
219+
async fn test_explain_full_table_scan() {
220+
let logctx = dev::test_setup_log("test_explain_full_table_scan");
221+
let db = test_setup_database(&logctx.log).await;
222+
let cfg = db::Config { url: db.pg_config().clone() };
223+
let pool = db::Pool::new(&cfg);
224+
225+
create_schema(&pool).await;
226+
227+
use schema::test_users::dsl;
228+
let explanation = dsl::test_users
229+
.filter(dsl::age.eq(2))
230+
.select(User::as_select())
231+
.explain_async(pool.pool())
232+
.await
233+
.unwrap();
234+
235+
assert!(
236+
explanation.contains("FULL SCAN"),
237+
"Expected [{}] to contain 'FULL SCAN'",
238+
explanation
239+
);
240+
}
241+
}

nexus/src/db/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod config;
1414
// This is marked public for use by the integration tests
1515
pub mod datastore;
1616
mod error;
17+
mod explain;
1718
pub mod fixed_data;
1819
mod pagination;
1920
mod pool;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distribution: local
2+
vectorized: true
3+
4+
• scan
5+
missing stats
6+
table: test_users@primary
7+
spans: [/'00000000-0000-0000-0000-000000000000' - /'00000000-0000-0000-0000-000000000000']

0 commit comments

Comments
 (0)