1const USER_SCHEMA: &str = "public";
22
23use async_trait::async_trait;
24use sbol_db_core::DomainError;
25use sbol_db_storage::DbStats;
26use sqlx::Row;
27
28pub use sbol_db_storage::{
29 Activity, BlockingLock, DatabaseSize, IncomingForeignKey, IndexStats, OutgoingForeignKey,
30 RelationalColumn, RelationalSchema, RelationalTable, SlowQuery, TableColumn, TableSchema,
31 TableStats,
32};
33
34use crate::repo::db_err;
35use crate::PgPool;
36
37#[derive(Clone)]
38pub struct PgStatsRepository {
39 pool: PgPool,
40}
41
42impl PgStatsRepository {
43 pub fn new(pool: PgPool) -> Self {
44 Self { pool }
45 }
46
47 pub async fn schema_overview(&self) -> Result<RelationalSchema, DomainError> {
51 let rows = sqlx::query(
52 r#"
53 SELECT table_name, column_name, data_type, udt_name, is_nullable, ordinal_position
54 FROM information_schema.columns
55 WHERE table_schema = $1
56 AND table_name <> '_sqlx_migrations'
57 ORDER BY table_name, ordinal_position
58 "#,
59 )
60 .bind(USER_SCHEMA)
61 .fetch_all(&self.pool)
62 .await
63 .map_err(db_err)?;
64
65 let mut current: Option<RelationalTable> = None;
66 let mut tables: Vec<RelationalTable> = Vec::new();
67 for row in rows {
68 let table_name: String = row.try_get("table_name").map_err(db_err)?;
69 let column_name: String = row.try_get("column_name").map_err(db_err)?;
70 let data_type: String = row.try_get("data_type").map_err(db_err)?;
71 let udt_name: String = row.try_get("udt_name").map_err(db_err)?;
72 let is_nullable: String = row.try_get("is_nullable").map_err(db_err)?;
73
74 let column_type = if data_type == "USER-DEFINED" || data_type == "ARRAY" {
77 udt_name
78 } else {
79 data_type
80 };
81 let column = RelationalColumn {
82 name: column_name,
83 column_type,
84 nullable: is_nullable == "YES",
85 };
86
87 match &mut current {
88 Some(t) if t.name == table_name => t.columns.push(column),
89 _ => {
90 if let Some(prev) = current.take() {
91 tables.push(prev);
92 }
93 current = Some(RelationalTable {
94 name: table_name,
95 columns: vec![column],
96 });
97 }
98 }
99 }
100 if let Some(last) = current.take() {
101 tables.push(last);
102 }
103
104 Ok(RelationalSchema { tables })
105 }
106
107 pub async fn database_size(&self) -> Result<DatabaseSize, DomainError> {
108 let row = sqlx::query(
109 r#"
110 SELECT current_database()::text AS db,
111 pg_database_size(current_database())::bigint AS bytes
112 "#,
113 )
114 .fetch_one(&self.pool)
115 .await
116 .map_err(db_err)?;
117 Ok(DatabaseSize {
118 database: row.try_get("db").map_err(db_err)?,
119 total_bytes: row.try_get("bytes").map_err(db_err)?,
120 })
121 }
122
123 pub async fn table_stats(
124 &self,
125 limit: i64,
126 offset: i64,
127 ) -> Result<Vec<TableStats>, DomainError> {
128 let rows = sqlx::query(
129 r#"
130 SELECT
131 s.relname::text AS name,
132 COALESCE(c.reltuples, 0)::bigint AS rows_estimate,
133 pg_total_relation_size(c.oid)::bigint AS total_bytes,
134 pg_indexes_size(c.oid)::bigint AS index_bytes,
135 s.n_live_tup::bigint AS n_live_tup,
136 s.n_dead_tup::bigint AS n_dead_tup,
137 s.last_vacuum,
138 s.last_autovacuum,
139 s.last_analyze
140 FROM pg_stat_user_tables s
141 JOIN pg_class c ON c.oid = s.relid
142 WHERE s.schemaname = $1
143 ORDER BY pg_total_relation_size(c.oid) DESC
144 LIMIT $2 OFFSET $3
145 "#,
146 )
147 .bind(USER_SCHEMA)
148 .bind(limit)
149 .bind(offset)
150 .fetch_all(&self.pool)
151 .await
152 .map_err(db_err)?;
153
154 rows.into_iter()
155 .map(|r| {
156 Ok::<_, DomainError>(TableStats {
157 name: r.try_get("name").map_err(db_err)?,
158 rows_estimate: r.try_get("rows_estimate").map_err(db_err)?,
159 total_bytes: r.try_get("total_bytes").map_err(db_err)?,
160 index_bytes: r.try_get("index_bytes").map_err(db_err)?,
161 n_live_tup: r.try_get("n_live_tup").map_err(db_err)?,
162 n_dead_tup: r.try_get("n_dead_tup").map_err(db_err)?,
163 last_vacuum: r.try_get("last_vacuum").map_err(db_err)?,
164 last_autovacuum: r.try_get("last_autovacuum").map_err(db_err)?,
165 last_analyze: r.try_get("last_analyze").map_err(db_err)?,
166 })
167 })
168 .collect()
169 }
170
171 pub async fn index_stats(&self, limit: i64) -> Result<Vec<IndexStats>, DomainError> {
172 let rows = sqlx::query(
173 r#"
174 SELECT
175 s.relname::text AS table_name,
176 s.indexrelname::text AS index_name,
177 COALESCE(s.idx_scan, 0)::bigint AS idx_scan,
178 pg_relation_size(s.indexrelid)::bigint AS bytes
179 FROM pg_stat_user_indexes s
180 WHERE s.schemaname = $1
181 ORDER BY pg_relation_size(s.indexrelid) DESC
182 LIMIT $2
183 "#,
184 )
185 .bind(USER_SCHEMA)
186 .bind(limit)
187 .fetch_all(&self.pool)
188 .await
189 .map_err(db_err)?;
190
191 rows.into_iter()
192 .map(|r| {
193 Ok::<_, DomainError>(IndexStats {
194 table: r.try_get("table_name").map_err(db_err)?,
195 index: r.try_get("index_name").map_err(db_err)?,
196 idx_scan: r.try_get("idx_scan").map_err(db_err)?,
197 bytes: r.try_get("bytes").map_err(db_err)?,
198 })
199 })
200 .collect()
201 }
202
203 pub async fn activity(&self, limit: i64) -> Result<Vec<Activity>, DomainError> {
204 let rows = sqlx::query(
205 r#"
206 SELECT
207 pid,
208 application_name,
209 state,
210 wait_event_type,
211 wait_event,
212 query_start,
213 EXTRACT(EPOCH FROM (now() - query_start))::float8 AS duration_secs,
214 LEFT(query, 500) AS query,
215 client_addr::text AS client_addr
216 FROM pg_stat_activity
217 WHERE pid <> pg_backend_pid()
218 AND state IS NOT NULL
219 AND query IS NOT NULL
220 AND query != ''
221 AND backend_type = 'client backend'
222 ORDER BY query_start ASC NULLS LAST
223 LIMIT $1
224 "#,
225 )
226 .bind(limit)
227 .fetch_all(&self.pool)
228 .await
229 .map_err(db_err)?;
230
231 rows.into_iter()
232 .map(|r| {
233 Ok::<_, DomainError>(Activity {
234 pid: r.try_get("pid").map_err(db_err)?,
235 application_name: r.try_get("application_name").map_err(db_err)?,
236 state: r.try_get("state").map_err(db_err)?,
237 wait_event_type: r.try_get("wait_event_type").map_err(db_err)?,
238 wait_event: r.try_get("wait_event").map_err(db_err)?,
239 query: r.try_get("query").map_err(db_err)?,
240 query_start: r.try_get("query_start").map_err(db_err)?,
241 duration_secs: r.try_get("duration_secs").map_err(db_err)?,
242 client_addr: r.try_get("client_addr").map_err(db_err)?,
243 })
244 })
245 .collect()
246 }
247
248 pub async fn blocking_locks(&self) -> Result<Vec<BlockingLock>, DomainError> {
249 let rows = sqlx::query(
250 r#"
251 SELECT
252 kl.pid AS blocker_pid,
253 LEFT(ka.query, 500) AS blocker_query,
254 bl.pid AS blocked_pid,
255 LEFT(ba.query, 500) AS blocked_query,
256 bl.mode,
257 bl.locktype
258 FROM pg_locks bl
259 JOIN pg_stat_activity ba ON ba.pid = bl.pid
260 JOIN pg_locks kl
261 ON kl.locktype = bl.locktype
262 AND kl.granted
263 AND ((bl.relation = kl.relation AND bl.relation IS NOT NULL)
264 OR (bl.objid = kl.objid AND bl.objid IS NOT NULL)
265 OR (bl.transactionid = kl.transactionid AND bl.transactionid IS NOT NULL))
266 AND bl.pid <> kl.pid
267 JOIN pg_stat_activity ka ON ka.pid = kl.pid
268 WHERE NOT bl.granted
269 ORDER BY blocked_pid
270 LIMIT 50
271 "#,
272 )
273 .fetch_all(&self.pool)
274 .await
275 .map_err(db_err)?;
276
277 rows.into_iter()
278 .map(|r| {
279 Ok::<_, DomainError>(BlockingLock {
280 blocker_pid: r.try_get("blocker_pid").map_err(db_err)?,
281 blocker_query: r.try_get("blocker_query").map_err(db_err)?,
282 blocked_pid: r.try_get("blocked_pid").map_err(db_err)?,
283 blocked_query: r.try_get("blocked_query").map_err(db_err)?,
284 mode: r.try_get("mode").map_err(db_err)?,
285 locktype: r.try_get("locktype").map_err(db_err)?,
286 })
287 })
288 .collect()
289 }
290
291 pub async fn has_pg_stat_statements(&self) -> Result<bool, DomainError> {
295 let row = sqlx::query(
296 r#"
297 SELECT EXISTS(
298 SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
299 ) AS present
300 "#,
301 )
302 .fetch_one(&self.pool)
303 .await
304 .map_err(db_err)?;
305 row.try_get::<bool, _>("present").map_err(db_err)
306 }
307
308 pub async fn table_schema(&self, name: &str) -> Result<Option<TableSchema>, DomainError> {
313 let exists_row = sqlx::query(
314 r#"
315 SELECT obj_description(c.oid, 'pg_class') AS comment
316 FROM pg_class c
317 JOIN pg_namespace n ON n.oid = c.relnamespace
318 WHERE n.nspname = $1
319 AND c.relname = $2
320 AND c.relkind IN ('r','m','p')
321 "#,
322 )
323 .bind(USER_SCHEMA)
324 .bind(name)
325 .fetch_optional(&self.pool)
326 .await
327 .map_err(db_err)?;
328 let Some(exists_row) = exists_row else {
329 return Ok(None);
330 };
331 let comment: Option<String> = exists_row.try_get("comment").map_err(db_err)?;
332
333 let column_rows = sqlx::query(
334 r#"
335 WITH tbl AS (
336 SELECT c.oid
337 FROM pg_class c
338 JOIN pg_namespace n ON n.oid = c.relnamespace
339 WHERE n.nspname = $1 AND c.relname = $2
340 ),
341 pk AS (
342 SELECT unnest(con.conkey) AS attnum
343 FROM pg_constraint con
344 JOIN tbl ON con.conrelid = tbl.oid
345 WHERE con.contype = 'p'
346 )
347 SELECT
348 a.attname::text AS name,
349 pg_catalog.format_type(a.atttypid, a.atttypmod)::text AS column_type,
350 (NOT a.attnotnull) AS nullable,
351 pg_get_expr(d.adbin, d.adrelid) AS default_expr,
352 a.attnum::int AS ordinal,
353 pg_catalog.col_description(a.attrelid, a.attnum) AS comment,
354 EXISTS (SELECT 1 FROM pk WHERE pk.attnum = a.attnum) AS is_primary_key
355 FROM tbl
356 JOIN pg_attribute a ON a.attrelid = tbl.oid
357 LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
358 WHERE a.attnum > 0
359 AND NOT a.attisdropped
360 ORDER BY a.attnum
361 "#,
362 )
363 .bind(USER_SCHEMA)
364 .bind(name)
365 .fetch_all(&self.pool)
366 .await
367 .map_err(db_err)?;
368
369 let columns: Vec<TableColumn> = column_rows
370 .into_iter()
371 .map(|r| {
372 Ok::<_, DomainError>(TableColumn {
373 name: r.try_get("name").map_err(db_err)?,
374 column_type: r.try_get("column_type").map_err(db_err)?,
375 nullable: r.try_get("nullable").map_err(db_err)?,
376 default_expr: r.try_get("default_expr").map_err(db_err)?,
377 ordinal: r.try_get("ordinal").map_err(db_err)?,
378 comment: r.try_get("comment").map_err(db_err)?,
379 is_primary_key: r.try_get("is_primary_key").map_err(db_err)?,
380 })
381 })
382 .collect::<Result<_, _>>()?;
383
384 let fk_out_rows = sqlx::query(
385 r#"
386 SELECT
387 con.conname::text AS name,
388 (
389 SELECT array_agg(a.attname::text ORDER BY array_position(con.conkey, a.attnum))
390 FROM pg_attribute a
391 WHERE a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
392 ) AS columns,
393 tcl.relname::text AS target_table,
394 (
395 SELECT array_agg(a.attname::text ORDER BY array_position(con.confkey, a.attnum))
396 FROM pg_attribute a
397 WHERE a.attrelid = con.confrelid AND a.attnum = ANY(con.confkey)
398 ) AS target_columns
399 FROM pg_constraint con
400 JOIN pg_class scl ON scl.oid = con.conrelid
401 JOIN pg_namespace sns ON sns.oid = scl.relnamespace
402 JOIN pg_class tcl ON tcl.oid = con.confrelid
403 JOIN pg_namespace tns ON tns.oid = tcl.relnamespace
404 WHERE con.contype = 'f'
405 AND sns.nspname = $1
406 AND scl.relname = $2
407 AND tns.nspname = $1
408 ORDER BY con.conname
409 "#,
410 )
411 .bind(USER_SCHEMA)
412 .bind(name)
413 .fetch_all(&self.pool)
414 .await
415 .map_err(db_err)?;
416
417 let foreign_keys_out: Vec<OutgoingForeignKey> = fk_out_rows
418 .into_iter()
419 .map(|r| {
420 Ok::<_, DomainError>(OutgoingForeignKey {
421 name: r.try_get("name").map_err(db_err)?,
422 columns: r
423 .try_get::<Option<Vec<String>>, _>("columns")
424 .map_err(db_err)?
425 .unwrap_or_default(),
426 target_table: r.try_get("target_table").map_err(db_err)?,
427 target_columns: r
428 .try_get::<Option<Vec<String>>, _>("target_columns")
429 .map_err(db_err)?
430 .unwrap_or_default(),
431 })
432 })
433 .collect::<Result<_, _>>()?;
434
435 let fk_in_rows = sqlx::query(
436 r#"
437 SELECT
438 con.conname::text AS name,
439 scl.relname::text AS source_table,
440 (
441 SELECT array_agg(a.attname::text ORDER BY array_position(con.conkey, a.attnum))
442 FROM pg_attribute a
443 WHERE a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
444 ) AS source_columns,
445 (
446 SELECT array_agg(a.attname::text ORDER BY array_position(con.confkey, a.attnum))
447 FROM pg_attribute a
448 WHERE a.attrelid = con.confrelid AND a.attnum = ANY(con.confkey)
449 ) AS target_columns
450 FROM pg_constraint con
451 JOIN pg_class scl ON scl.oid = con.conrelid
452 JOIN pg_namespace sns ON sns.oid = scl.relnamespace
453 JOIN pg_class tcl ON tcl.oid = con.confrelid
454 JOIN pg_namespace tns ON tns.oid = tcl.relnamespace
455 WHERE con.contype = 'f'
456 AND tns.nspname = $1
457 AND tcl.relname = $2
458 AND sns.nspname = $1
459 ORDER BY scl.relname, con.conname
460 "#,
461 )
462 .bind(USER_SCHEMA)
463 .bind(name)
464 .fetch_all(&self.pool)
465 .await
466 .map_err(db_err)?;
467
468 let foreign_keys_in: Vec<IncomingForeignKey> = fk_in_rows
469 .into_iter()
470 .map(|r| {
471 Ok::<_, DomainError>(IncomingForeignKey {
472 name: r.try_get("name").map_err(db_err)?,
473 source_table: r.try_get("source_table").map_err(db_err)?,
474 source_columns: r
475 .try_get::<Option<Vec<String>>, _>("source_columns")
476 .map_err(db_err)?
477 .unwrap_or_default(),
478 target_columns: r
479 .try_get::<Option<Vec<String>>, _>("target_columns")
480 .map_err(db_err)?
481 .unwrap_or_default(),
482 })
483 })
484 .collect::<Result<_, _>>()?;
485
486 Ok(Some(TableSchema {
487 name: name.to_owned(),
488 comment,
489 columns,
490 foreign_keys_out,
491 foreign_keys_in,
492 }))
493 }
494
495 pub async fn slow_queries(&self, limit: i64) -> Result<Vec<SlowQuery>, DomainError> {
496 let rows = sqlx::query(
497 r#"
498 SELECT
499 queryid::text AS queryid,
500 LEFT(query, 500) AS query,
501 calls::bigint AS calls,
502 total_exec_time::float8 AS total_exec_ms,
503 mean_exec_time::float8 AS mean_exec_ms,
504 rows::bigint AS rows
505 FROM pg_stat_statements
506 ORDER BY total_exec_time DESC
507 LIMIT $1
508 "#,
509 )
510 .bind(limit)
511 .fetch_all(&self.pool)
512 .await
513 .map_err(db_err)?;
514
515 rows.into_iter()
516 .map(|r| {
517 Ok::<_, DomainError>(SlowQuery {
518 queryid: r.try_get("queryid").map_err(db_err)?,
519 query: r.try_get("query").map_err(db_err)?,
520 calls: r.try_get("calls").map_err(db_err)?,
521 total_exec_ms: r.try_get("total_exec_ms").map_err(db_err)?,
522 mean_exec_ms: r.try_get("mean_exec_ms").map_err(db_err)?,
523 rows: r.try_get("rows").map_err(db_err)?,
524 })
525 })
526 .collect()
527 }
528}
529
530#[async_trait]
534impl DbStats for PgStatsRepository {
535 async fn schema_overview(&self) -> Result<RelationalSchema, DomainError> {
536 PgStatsRepository::schema_overview(self).await
537 }
538 async fn database_size(&self) -> Result<DatabaseSize, DomainError> {
539 PgStatsRepository::database_size(self).await
540 }
541 async fn table_stats(&self, limit: i64, offset: i64) -> Result<Vec<TableStats>, DomainError> {
542 PgStatsRepository::table_stats(self, limit, offset).await
543 }
544 async fn index_stats(&self, limit: i64) -> Result<Vec<IndexStats>, DomainError> {
545 PgStatsRepository::index_stats(self, limit).await
546 }
547 async fn activity(&self, limit: i64) -> Result<Vec<Activity>, DomainError> {
548 PgStatsRepository::activity(self, limit).await
549 }
550 async fn blocking_locks(&self) -> Result<Vec<BlockingLock>, DomainError> {
551 PgStatsRepository::blocking_locks(self).await
552 }
553 async fn table_schema(&self, name: &str) -> Result<Option<TableSchema>, DomainError> {
554 PgStatsRepository::table_schema(self, name).await
555 }
556 async fn has_slow_query_stats(&self) -> Result<bool, DomainError> {
557 PgStatsRepository::has_pg_stat_statements(self).await
558 }
559 async fn slow_queries(&self, limit: i64) -> Result<Vec<SlowQuery>, DomainError> {
560 PgStatsRepository::slow_queries(self, limit).await
561 }
562 async fn optimize(&self) -> Result<(), DomainError> {
563 sqlx::query("ANALYZE")
566 .execute(&self.pool)
567 .await
568 .map_err(db_err)?;
569 Ok(())
570 }
571}