sqlmodel_console/renderables/
query_timing.rs1use crate::theme::Theme;
23use std::time::Duration;
24
25#[derive(Debug, Clone)]
27pub struct TimingPhase {
28 pub name: String,
30 pub duration: Duration,
32}
33
34impl TimingPhase {
35 #[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#[derive(Debug, Clone)]
49pub struct QueryTiming {
50 total_time: Option<Duration>,
52 row_count: Option<u64>,
54 phases: Vec<TimingPhase>,
56 theme: Option<Theme>,
58 bar_width: usize,
60}
61
62impl QueryTiming {
63 #[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 #[must_use]
77 pub fn total(mut self, duration: Duration) -> Self {
78 self.total_time = Some(duration);
79 self
80 }
81
82 #[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 #[must_use]
91 pub fn rows(mut self, count: u64) -> Self {
92 self.row_count = Some(count);
93 self
94 }
95
96 #[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 #[must_use]
105 pub fn parse(self, duration: Duration) -> Self {
106 self.phase("Parse", duration)
107 }
108
109 #[must_use]
111 pub fn plan(self, duration: Duration) -> Self {
112 self.phase("Plan", duration)
113 }
114
115 #[must_use]
117 pub fn execute(self, duration: Duration) -> Self {
118 self.phase("Execute", duration)
119 }
120
121 #[must_use]
123 pub fn fetch(self, duration: Duration) -> Self {
124 self.phase("Fetch", duration)
125 }
126
127 #[must_use]
129 pub fn theme(mut self, theme: Theme) -> Self {
130 self.theme = Some(theme);
131 self
132 }
133
134 #[must_use]
136 pub fn bar_width(mut self, width: usize) -> Self {
137 self.bar_width = width;
138 self
139 }
140
141 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 fn effective_total(&self) -> Duration {
155 self.total_time
156 .unwrap_or_else(|| self.phases.iter().map(|p| p.duration).sum())
157 }
158
159 #[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 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 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 #[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 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 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 #[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#[derive(Debug, Clone)]
293pub struct CompactTiming {
294 duration: Duration,
296 rows: Option<u64>,
298}
299
300impl CompactTiming {
301 #[must_use]
303 pub fn new(duration: Duration) -> Self {
304 Self {
305 duration,
306 rows: None,
307 }
308 }
309
310 #[must_use]
312 pub fn from_ms(ms: f64) -> Self {
313 Self::new(Duration::from_secs_f64(ms / 1000.0))
314 }
315
316 #[must_use]
318 pub fn rows(mut self, count: u64) -> Self {
319 self.rows = Some(count);
320 self
321 }
322
323 #[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 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}