Skip to main content

sqlmodel_console/renderables/
query_timing.rs

1//! Query timing display for execution performance visualization.
2//!
3//! Provides visual breakdown of query execution timing.
4//!
5//! # Example
6//!
7//! ```rust
8//! use sqlmodel_console::renderables::QueryTiming;
9//! use std::time::Duration;
10//!
11//! let timing = QueryTiming::new()
12//!     .total(Duration::from_millis(12))
13//!     .parse(Duration::from_micros(1200))
14//!     .plan(Duration::from_micros(3400))
15//!     .execute(Duration::from_micros(7700))
16//!     .rows(3);
17//!
18//! println!("{}", timing.render_plain());
19//! println!("{}", timing.render_styled());
20//! ```
21
22use crate::theme::Theme;
23use std::time::Duration;
24
25/// A timing phase with label and duration.
26#[derive(Debug, Clone)]
27pub struct TimingPhase {
28    /// Phase name (e.g., "Parse", "Plan", "Execute")
29    pub name: String,
30    /// Duration of this phase
31    pub duration: Duration,
32}
33
34impl TimingPhase {
35    /// Create a new timing phase.
36    #[must_use]
37    pub fn new(name: impl Into<String>, duration: Duration) -> Self {
38        Self {
39            name: name.into(),
40            duration,
41        }
42    }
43}
44
45/// Query timing display for execution performance visualization.
46///
47/// Shows a breakdown of query execution time with optional phase details.
48#[derive(Debug, Clone)]
49pub struct QueryTiming {
50    /// Total execution time
51    total_time: Option<Duration>,
52    /// Number of rows affected/returned
53    row_count: Option<u64>,
54    /// Individual timing phases
55    phases: Vec<TimingPhase>,
56    /// Theme for styled output
57    theme: Option<Theme>,
58    /// Width of timing bars
59    bar_width: usize,
60}
61
62impl QueryTiming {
63    /// Create a new query timing display.
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            total_time: None,
68            row_count: None,
69            phases: Vec::new(),
70            theme: None,
71            bar_width: 20,
72        }
73    }
74
75    /// Set the total execution time.
76    #[must_use]
77    pub fn total(mut self, duration: Duration) -> Self {
78        self.total_time = Some(duration);
79        self
80    }
81
82    /// Set the total execution time in milliseconds.
83    #[must_use]
84    pub fn total_ms(mut self, ms: f64) -> Self {
85        self.total_time = Some(Duration::from_secs_f64(ms / 1000.0));
86        self
87    }
88
89    /// Set the row count.
90    #[must_use]
91    pub fn rows(mut self, count: u64) -> Self {
92        self.row_count = Some(count);
93        self
94    }
95
96    /// Add a timing phase.
97    #[must_use]
98    pub fn phase(mut self, name: impl Into<String>, duration: Duration) -> Self {
99        self.phases.push(TimingPhase::new(name, duration));
100        self
101    }
102
103    /// Add parse phase timing.
104    #[must_use]
105    pub fn parse(self, duration: Duration) -> Self {
106        self.phase("Parse", duration)
107    }
108
109    /// Add plan phase timing.
110    #[must_use]
111    pub fn plan(self, duration: Duration) -> Self {
112        self.phase("Plan", duration)
113    }
114
115    /// Add execute phase timing.
116    #[must_use]
117    pub fn execute(self, duration: Duration) -> Self {
118        self.phase("Execute", duration)
119    }
120
121    /// Add fetch phase timing.
122    #[must_use]
123    pub fn fetch(self, duration: Duration) -> Self {
124        self.phase("Fetch", duration)
125    }
126
127    /// Set the theme for styled output.
128    #[must_use]
129    pub fn theme(mut self, theme: Theme) -> Self {
130        self.theme = Some(theme);
131        self
132    }
133
134    /// Set the width of timing bars.
135    #[must_use]
136    pub fn bar_width(mut self, width: usize) -> Self {
137        self.bar_width = width;
138        self
139    }
140
141    /// Format a duration for display.
142    fn format_duration(duration: Duration) -> String {
143        let micros = duration.as_micros();
144        if micros < 1000 {
145            format!("{}µs", micros)
146        } else if micros < 1_000_000 {
147            format!("{:.2}ms", micros as f64 / 1000.0)
148        } else {
149            format!("{:.2}s", duration.as_secs_f64())
150        }
151    }
152
153    /// Calculate the total from phases if not set.
154    fn effective_total(&self) -> Duration {
155        self.total_time
156            .unwrap_or_else(|| self.phases.iter().map(|p| p.duration).sum())
157    }
158
159    /// Render as plain text.
160    #[must_use]
161    #[allow(clippy::cast_possible_truncation)]
162    pub fn render_plain(&self) -> String {
163        let mut lines = Vec::new();
164        let total = self.effective_total();
165
166        // Header line
167        let row_info = self
168            .row_count
169            .map_or(String::new(), |r| format!(" ({} rows)", r));
170        lines.push(format!(
171            "Query completed in {}{}",
172            Self::format_duration(total),
173            row_info
174        ));
175
176        // Phase breakdown
177        if !self.phases.is_empty() {
178            for phase in &self.phases {
179                let pct = if total.as_nanos() > 0 {
180                    (phase.duration.as_nanos() as f64 / total.as_nanos() as f64 * 100.0) as u32
181                } else {
182                    0
183                };
184                lines.push(format!(
185                    "  {}: {} ({}%)",
186                    phase.name,
187                    Self::format_duration(phase.duration),
188                    pct
189                ));
190            }
191        }
192
193        lines.join("\n")
194    }
195
196    /// Render as styled text with ANSI colors and bar charts.
197    #[must_use]
198    #[allow(clippy::cast_possible_truncation)]
199    pub fn render_styled(&self) -> String {
200        let theme = self.theme.clone().unwrap_or_default();
201        let total = self.effective_total();
202        let reset = "\x1b[0m";
203        let success_color = theme.success.color_code();
204        let dim = theme.dim.color_code();
205        let info_color = theme.info.color_code();
206
207        let mut lines = Vec::new();
208
209        // Header line
210        let row_info = self
211            .row_count
212            .map_or(String::new(), |r| format!(" ({} rows)", r));
213        lines.push(format!(
214            "{success_color}Query completed in {}{row_info}{reset}",
215            Self::format_duration(total),
216        ));
217
218        // Phase breakdown with bars
219        if !self.phases.is_empty() {
220            let max_name_len = self.phases.iter().map(|p| p.name.len()).max().unwrap_or(0);
221            let max_time_len = self
222                .phases
223                .iter()
224                .map(|p| Self::format_duration(p.duration).len())
225                .max()
226                .unwrap_or(0);
227
228            for phase in &self.phases {
229                let pct = if total.as_nanos() > 0 {
230                    phase.duration.as_nanos() as f64 / total.as_nanos() as f64
231                } else {
232                    0.0
233                };
234                let filled = (pct * self.bar_width as f64).round() as usize;
235                let empty = self.bar_width.saturating_sub(filled);
236
237                let bar = format!(
238                    "{info_color}{}{dim}{}{reset}",
239                    "█".repeat(filled),
240                    "░".repeat(empty)
241                );
242
243                lines.push(format!(
244                    "  {:width$}  {} {:>time_width$}",
245                    phase.name,
246                    bar,
247                    Self::format_duration(phase.duration),
248                    width = max_name_len,
249                    time_width = max_time_len
250                ));
251            }
252        }
253
254        lines.join("\n")
255    }
256
257    /// Render as JSON-serializable structure.
258    #[must_use]
259    pub fn to_json(&self) -> serde_json::Value {
260        let total = self.effective_total();
261
262        let phases: Vec<serde_json::Value> = self
263            .phases
264            .iter()
265            .map(|p| {
266                serde_json::json!({
267                    "name": p.name,
268                    "duration_us": p.duration.as_micros(),
269                    "duration_ms": p.duration.as_secs_f64() * 1000.0,
270                })
271            })
272            .collect();
273
274        serde_json::json!({
275            "total_us": total.as_micros(),
276            "total_ms": total.as_secs_f64() * 1000.0,
277            "row_count": self.row_count,
278            "phases": phases,
279        })
280    }
281}
282
283impl Default for QueryTiming {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289/// Compact timing display for inline use.
290///
291/// Shows timing in a single line format suitable for headers or footers.
292#[derive(Debug, Clone)]
293pub struct CompactTiming {
294    /// Execution time
295    duration: Duration,
296    /// Row count
297    rows: Option<u64>,
298}
299
300impl CompactTiming {
301    /// Create a new compact timing display.
302    #[must_use]
303    pub fn new(duration: Duration) -> Self {
304        Self {
305            duration,
306            rows: None,
307        }
308    }
309
310    /// Create from milliseconds.
311    #[must_use]
312    pub fn from_ms(ms: f64) -> Self {
313        Self::new(Duration::from_secs_f64(ms / 1000.0))
314    }
315
316    /// Set the row count.
317    #[must_use]
318    pub fn rows(mut self, count: u64) -> Self {
319        self.rows = Some(count);
320        self
321    }
322
323    /// Render as plain text.
324    #[must_use]
325    pub fn render(&self) -> String {
326        let time_str = QueryTiming::format_duration(self.duration);
327        match self.rows {
328            Some(r) => format!("{} rows in {}", r, time_str),
329            None => time_str,
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_query_timing_new() {
340        let timing = QueryTiming::new();
341        assert!(timing.total_time.is_none());
342        assert!(timing.row_count.is_none());
343    }
344
345    #[test]
346    fn test_query_timing_total() {
347        let timing = QueryTiming::new().total(Duration::from_millis(100));
348        assert_eq!(timing.total_time, Some(Duration::from_millis(100)));
349    }
350
351    #[test]
352    fn test_query_timing_total_ms() {
353        let timing = QueryTiming::new().total_ms(50.0);
354        let expected = Duration::from_secs_f64(0.05);
355        assert!((timing.total_time.unwrap().as_secs_f64() - expected.as_secs_f64()).abs() < 0.001);
356    }
357
358    #[test]
359    fn test_query_timing_rows() {
360        let timing = QueryTiming::new().rows(42);
361        assert_eq!(timing.row_count, Some(42));
362    }
363
364    #[test]
365    fn test_query_timing_phases() {
366        let timing = QueryTiming::new()
367            .parse(Duration::from_micros(100))
368            .plan(Duration::from_micros(200))
369            .execute(Duration::from_micros(300));
370
371        assert_eq!(timing.phases.len(), 3);
372        assert_eq!(timing.phases[0].name, "Parse");
373        assert_eq!(timing.phases[1].name, "Plan");
374        assert_eq!(timing.phases[2].name, "Execute");
375    }
376
377    #[test]
378    fn test_format_duration_micros() {
379        let s = QueryTiming::format_duration(Duration::from_micros(500));
380        assert!(s.contains("µs"));
381    }
382
383    #[test]
384    fn test_format_duration_millis() {
385        let s = QueryTiming::format_duration(Duration::from_millis(50));
386        assert!(s.contains("ms"));
387    }
388
389    #[test]
390    fn test_format_duration_seconds() {
391        let s = QueryTiming::format_duration(Duration::from_secs(2));
392        assert!(s.contains('s'));
393    }
394
395    #[test]
396    fn test_render_plain_basic() {
397        let timing = QueryTiming::new().total(Duration::from_millis(12)).rows(3);
398
399        let output = timing.render_plain();
400        assert!(output.contains("Query completed"));
401        assert!(output.contains("3 rows"));
402    }
403
404    #[test]
405    fn test_render_plain_with_phases() {
406        let timing = QueryTiming::new()
407            .total(Duration::from_millis(10))
408            .parse(Duration::from_millis(1))
409            .plan(Duration::from_millis(2))
410            .execute(Duration::from_millis(7));
411
412        let output = timing.render_plain();
413        assert!(output.contains("Parse"));
414        assert!(output.contains("Plan"));
415        assert!(output.contains("Execute"));
416    }
417
418    #[test]
419    fn test_render_styled_contains_ansi() {
420        let timing = QueryTiming::new()
421            .total(Duration::from_millis(10))
422            .parse(Duration::from_millis(5))
423            .execute(Duration::from_millis(5));
424
425        let styled = timing.render_styled();
426        assert!(styled.contains('\x1b'));
427    }
428
429    #[test]
430    fn test_render_styled_contains_bars() {
431        let timing = QueryTiming::new()
432            .total(Duration::from_millis(10))
433            .parse(Duration::from_millis(5))
434            .execute(Duration::from_millis(5));
435
436        let styled = timing.render_styled();
437        assert!(styled.contains('█') || styled.contains('░'));
438    }
439
440    #[test]
441    fn test_to_json() {
442        let timing = QueryTiming::new()
443            .total(Duration::from_millis(10))
444            .rows(5)
445            .parse(Duration::from_millis(3));
446
447        let json = timing.to_json();
448        assert_eq!(json["row_count"], 5);
449        assert!(json["total_us"].as_u64().unwrap() > 0);
450        assert!(json["phases"].is_array());
451    }
452
453    #[test]
454    fn test_effective_total_from_phases() {
455        let timing = QueryTiming::new()
456            .parse(Duration::from_millis(1))
457            .execute(Duration::from_millis(2));
458
459        // No explicit total set, should sum phases
460        let total = timing.effective_total();
461        assert_eq!(total, Duration::from_millis(3));
462    }
463
464    #[test]
465    fn test_timing_phase_new() {
466        let phase = TimingPhase::new("Test", Duration::from_millis(5));
467        assert_eq!(phase.name, "Test");
468        assert_eq!(phase.duration, Duration::from_millis(5));
469    }
470
471    #[test]
472    fn test_compact_timing_new() {
473        let compact = CompactTiming::new(Duration::from_millis(10));
474        assert_eq!(compact.duration, Duration::from_millis(10));
475    }
476
477    #[test]
478    fn test_compact_timing_from_ms() {
479        let compact = CompactTiming::from_ms(25.0);
480        let rendered = compact.render();
481        assert!(rendered.contains("ms"));
482    }
483
484    #[test]
485    fn test_compact_timing_with_rows() {
486        let compact = CompactTiming::new(Duration::from_millis(10)).rows(42);
487        let rendered = compact.render();
488        assert!(rendered.contains("42 rows"));
489    }
490
491    #[test]
492    fn test_default() {
493        let timing = QueryTiming::default();
494        assert!(timing.total_time.is_none());
495    }
496
497    #[test]
498    fn test_bar_width() {
499        let timing = QueryTiming::new()
500            .bar_width(30)
501            .parse(Duration::from_micros(500))
502            .execute(Duration::from_micros(500));
503
504        assert_eq!(timing.bar_width, 30);
505    }
506
507    #[test]
508    fn test_fetch_phase() {
509        let timing = QueryTiming::new().fetch(Duration::from_millis(1));
510
511        assert_eq!(timing.phases.len(), 1);
512        assert_eq!(timing.phases[0].name, "Fetch");
513    }
514
515    #[test]
516    fn test_custom_phase() {
517        let timing = QueryTiming::new().phase("Custom", Duration::from_millis(1));
518
519        assert_eq!(timing.phases.len(), 1);
520        assert_eq!(timing.phases[0].name, "Custom");
521    }
522}