allure_diesel/lib.rs
1//! Diesel integration that records executed queries as Allure steps.
2//!
3//! This crate plugs into Diesel's [`Instrumentation`] hook, so it is wired up once in test or
4//! setup code and never touches production query call sites:
5//!
6//! ```no_run
7//! use diesel::prelude::*;
8//! use diesel::sqlite::SqliteConnection;
9//!
10//! # fn demo() -> ConnectionResult<()> {
11//! let mut conn = SqliteConnection::establish(":memory:")?;
12//! conn.set_instrumentation(allure_diesel::AllureInstrumentation::new());
13//! // From here on, every query executed on `conn` is recorded as an Allure step.
14//! # Ok(())
15//! # }
16//! ```
17//!
18//! Alternatively, [`install_default`] registers the instrumentation for *every* newly
19//! established connection process-wide.
20//!
21//! ## What is captured
22//!
23//! Each executed statement becomes a step named with its SQL, and (by default) carries the full
24//! SQL as a `query.sql` attachment. Transactions become `transaction` steps that nest their
25//! inner queries; a query that returns an error marks its step failed with the Diesel error.
26//!
27//! ## What is not captured
28//!
29//! Diesel's instrumentation exposes the SQL of each statement but **not the returned rows** —
30//! no non-invasive Diesel hook does. This crate therefore records queries and transactions, not
31//! result sets. The SQL text may contain sensitive bind values; keep that in mind before
32//! publishing reports.
33
34#![deny(missing_docs)]
35
36use allure_rust_commons::{current_allure, StepGuard};
37use diesel::connection::{set_default_instrumentation, Instrumentation, InstrumentationEvent};
38
39const DEFAULT_MAX_SQL_PREVIEW: usize = 1024;
40
41/// Controls what [`AllureInstrumentation`] records.
42#[derive(Debug, Clone)]
43pub struct CaptureOptions {
44 capture_transactions: bool,
45 capture_connection_events: bool,
46 attach_sql: bool,
47 max_sql_preview: usize,
48}
49
50impl CaptureOptions {
51 /// Disables `transaction` steps for `BEGIN`/`COMMIT`/`ROLLBACK` boundaries.
52 pub fn without_transactions(mut self) -> Self {
53 self.capture_transactions = false;
54 self
55 }
56
57 /// Enables steps for connection-establishment events (off by default).
58 pub fn with_connection_events(mut self) -> Self {
59 self.capture_connection_events = true;
60 self
61 }
62
63 /// Disables the full-SQL `query.sql` attachment (the step name still shows the SQL).
64 pub fn without_sql_attachment(mut self) -> Self {
65 self.attach_sql = false;
66 self
67 }
68
69 /// Sets the maximum number of characters used for the SQL preview in a step name.
70 pub fn with_max_sql_preview(mut self, max_sql_preview: usize) -> Self {
71 self.max_sql_preview = max_sql_preview;
72 self
73 }
74}
75
76impl Default for CaptureOptions {
77 fn default() -> Self {
78 Self {
79 capture_transactions: true,
80 capture_connection_events: false,
81 attach_sql: true,
82 max_sql_preview: DEFAULT_MAX_SQL_PREVIEW,
83 }
84 }
85}
86
87/// Diesel [`Instrumentation`] that records executed queries and transactions as Allure steps.
88///
89/// Attach it to a connection with `conn.set_instrumentation(AllureInstrumentation::new())`, or
90/// register it globally with [`install_default`]. Steps are recorded against whichever Allure
91/// facade is bound to the current thread when the query runs; outside a test context, events are
92/// no-ops.
93pub struct AllureInstrumentation {
94 options: CaptureOptions,
95 stack: Vec<Option<StepGuard>>,
96 /// Set after `BeginTransaction` until the opening `BEGIN`/`SAVEPOINT` SQL finishes. If that
97 /// boundary fails, the transaction never started and no commit/rollback will follow, so the
98 /// transaction step is closed with the failure instead of leaking open.
99 opening_transaction: bool,
100 /// Set when a `CommitTransaction`/`RollbackTransaction` event has been seen but the boundary
101 /// `COMMIT`/`ROLLBACK` SQL has not finished yet, so the transaction step stays open until it
102 /// does (and inherits any boundary failure).
103 closing_transaction: bool,
104}
105
106impl AllureInstrumentation {
107 /// Creates instrumentation with the default [`CaptureOptions`].
108 pub fn new() -> Self {
109 Self::with_options(CaptureOptions::default())
110 }
111
112 /// Creates instrumentation with explicit [`CaptureOptions`].
113 pub fn with_options(options: CaptureOptions) -> Self {
114 Self {
115 options,
116 stack: Vec::new(),
117 opening_transaction: false,
118 closing_transaction: false,
119 }
120 }
121
122 /// Opens a step against the current thread-bound facade, tracking its guard on the stack.
123 ///
124 /// When no facade is bound, a `None` placeholder is pushed so start/finish events stay
125 /// balanced.
126 fn push_step(&mut self, name: String, sql: Option<&str>) {
127 let guard = current_allure().map(|allure| {
128 let guard = allure.enter_step(name);
129 if self.options.attach_sql {
130 if let Some(sql) = sql {
131 // Nests a `query.sql` step holding the full SQL under the query step.
132 allure.attachment("query.sql", "text/sql", sql.as_bytes());
133 }
134 }
135 guard
136 });
137 self.stack.push(guard);
138 }
139
140 /// Closes the most recently opened step, marking it failed when an error message is present.
141 ///
142 /// A `None` guard means the step was opened without a bound facade, and an empty stack means
143 /// an unbalanced finish event; both are safe to ignore.
144 fn pop_step(&mut self, error: Option<String>) {
145 if let Some(Some(mut guard)) = self.stack.pop() {
146 if let Some(message) = error {
147 guard.fail(message);
148 }
149 }
150 }
151}
152
153impl Default for AllureInstrumentation {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159impl Instrumentation for AllureInstrumentation {
160 fn on_connection_event(&mut self, event: InstrumentationEvent<'_>) {
161 match event {
162 InstrumentationEvent::StartQuery { query, .. } => {
163 let sql = query.to_string();
164 let name = sql_preview(&sql, self.options.max_sql_preview);
165 self.push_step(name, Some(&sql));
166 }
167 InstrumentationEvent::FinishQuery { error, .. } => {
168 let error = error.map(ToString::to_string);
169 self.pop_step(error.clone());
170 if self.opening_transaction {
171 // The just-finished query was the BEGIN/SAVEPOINT opening boundary. On success
172 // the transaction step stays open for the inner queries; on failure the
173 // transaction never started and no COMMIT/ROLLBACK will follow, so close it now
174 // with the boundary error rather than leaking it open.
175 self.opening_transaction = false;
176 if error.is_some() {
177 self.pop_step(error);
178 }
179 } else if self.closing_transaction {
180 // The just-finished query was the COMMIT/ROLLBACK boundary: close the
181 // transaction step that was held open for it, propagating a boundary failure so
182 // a failed COMMIT or ROLLBACK is not reported as a passed transaction.
183 self.closing_transaction = false;
184 self.pop_step(error);
185 }
186 }
187 InstrumentationEvent::BeginTransaction { depth, .. }
188 if self.options.capture_transactions =>
189 {
190 self.push_step(format!("transaction (depth {depth})"), None);
191 // Diesel runs the BEGIN/SAVEPOINT SQL next; its FinishQuery decides whether the
192 // transaction step stays open (success) or closes as failed (see FinishQuery).
193 self.opening_transaction = true;
194 }
195 // Diesel emits these BEFORE running the actual COMMIT/ROLLBACK SQL. Keep the
196 // transaction step open so the boundary query nests inside it and its outcome (via the
197 // next FinishQuery) closes the step and sets its status.
198 InstrumentationEvent::CommitTransaction { .. }
199 | InstrumentationEvent::RollbackTransaction { .. }
200 if self.options.capture_transactions =>
201 {
202 self.closing_transaction = true;
203 }
204 InstrumentationEvent::StartEstablishConnection { .. }
205 if self.options.capture_connection_events =>
206 {
207 self.push_step("connect".to_string(), None);
208 }
209 InstrumentationEvent::FinishEstablishConnection { error, .. }
210 if self.options.capture_connection_events =>
211 {
212 self.pop_step(error.map(ToString::to_string));
213 }
214 // `CacheQuery`, option-disabled events, and future non-exhaustive variants need no step.
215 _ => {}
216 }
217 }
218}
219
220/// Registers [`AllureInstrumentation`] as the default instrumentation for every newly
221/// established Diesel connection in this process.
222///
223/// Call this once during test setup so connections created by the code under test are
224/// instrumented without any call-site changes.
225pub fn install_default() -> diesel::QueryResult<()> {
226 set_default_instrumentation(build_default_instrumentation)
227}
228
229fn build_default_instrumentation() -> Option<Box<dyn Instrumentation>> {
230 Some(Box::new(AllureInstrumentation::new()))
231}
232
233fn sql_preview(sql: &str, max_sql_preview: usize) -> String {
234 if sql.chars().count() <= max_sql_preview {
235 return sql.to_string();
236 }
237 let truncated: String = sql.chars().take(max_sql_preview).collect();
238 format!("{truncated}…")
239}
240
241#[cfg(test)]
242#[path = "tests.rs"]
243mod tests;