Skip to main content

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;