1pub const SLOW_QUERIES_MAX: u32 = 50;
6pub const SLOW_QUERIES_DEFAULT: u32 = 10;
7
8pub fn parse_ref(ref_: &str) -> Result<(String, String), String> {
10 let trimmed = ref_.trim();
11 if trimmed.is_empty() {
12 return Err("Ref parameter is required".into());
13 }
14 let parts: Vec<&str> = trimmed.split('.').collect();
15 let (schema, name) = match parts.as_slice() {
16 [name] => ("public", *name),
17 [schema, name] => (*schema, *name),
18 _ => {
19 return Err(format!(
20 "Invalid object reference \"{ref_}\". Expected format \"schema.name\" with plain identifiers."
21 ));
22 }
23 };
24 if !is_safe_ident(schema) || !is_safe_ident(name) {
25 return Err(format!(
26 "Invalid object reference \"{ref_}\". Expected format \"schema.name\" with plain identifiers."
27 ));
28 }
29 Ok((schema.to_owned(), name.to_owned()))
30}
31
32pub fn is_safe_ident(s: &str) -> bool {
34 let mut chars = s.chars();
35 match chars.next() {
36 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
37 _ => return false,
38 }
39 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
40}
41
42pub fn quote_ident(ident: &str) -> String {
44 format!("\"{}\"", ident.replace('"', "\"\""))
45}
46
47pub fn quote_ref(schema: &str, name: &str) -> String {
48 format!("{}.{}", quote_ident(schema), quote_ident(name))
49}
50
51pub fn regclass_literal(schema: &str, name: &str) -> String {
53 format!(
54 "'{}'::regclass",
55 quote_ref(schema, name).replace('\'', "''")
56 )
57}
58
59pub fn table_stats(schema: &str, table: &str) -> String {
60 format!(
61 r#"
62SELECT
63 schemaname,
64 relname AS table_name,
65 n_live_tup AS approximate_row_count,
66 pg_size_pretty(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS total_size,
67 pg_size_pretty(pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS table_size,
68 pg_size_pretty(pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS indexes_size,
69 pg_size_pretty(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) -
70 pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) -
71 pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname))) AS toast_size
72FROM pg_stat_user_tables
73WHERE schemaname = '{schema}' AND relname = '{table}'
74"#
75 )
76 .trim()
77 .to_owned()
78}
79
80pub fn column_stats(schema: &str, table: &str) -> String {
81 format!(
82 r#"
83SELECT
84 attname AS column_name,
85 null_frac AS null_fraction,
86 n_distinct AS distinct_values,
87 avg_width AS avg_bytes,
88 correlation,
89 most_common_vals::text AS most_common_values,
90 most_common_freqs::text AS frequencies
91FROM pg_stats
92WHERE schemaname = '{schema}' AND tablename = '{table}'
93ORDER BY attname
94"#
95 )
96 .trim()
97 .to_owned()
98}
99
100pub fn column_details(schema: &str, table: &str) -> String {
101 let reg = regclass_literal(schema, table);
102 format!(
103 r#"
104SELECT
105 a.attname AS column_name,
106 pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
107 a.attnotnull AS not_null,
108 COALESCE(pg_get_expr(ad.adbin, ad.adrelid), '') AS default_value,
109 CASE
110 WHEN a.attnum = ANY(pk.conkey) THEN 'PK'
111 WHEN a.attnum = ANY(uk.conkey) THEN 'UNIQUE'
112 ELSE ''
113 END AS key_type
114FROM pg_catalog.pg_attribute a
115LEFT JOIN pg_catalog.pg_attrdef ad ON (a.attrelid = ad.adrelid AND a.attnum = ad.adnum)
116LEFT JOIN pg_catalog.pg_constraint pk ON (pk.conrelid = a.attrelid AND pk.contype = 'p')
117LEFT JOIN pg_catalog.pg_constraint uk ON (uk.conrelid = a.attrelid AND uk.contype = 'u' AND a.attnum = ANY(uk.conkey))
118WHERE a.attrelid = {reg}
119 AND a.attnum > 0
120 AND NOT a.attisdropped
121ORDER BY a.attnum
122"#
123 )
124 .trim()
125 .to_owned()
126}
127
128pub fn table_activity(schema: &str, table: &str) -> String {
129 format!(
130 r#"
131SELECT
132 seq_scan AS sequential_scans,
133 seq_tup_read AS rows_seq_read,
134 idx_scan AS index_scans,
135 idx_tup_fetch AS rows_idx_fetched,
136 n_tup_ins AS rows_inserted,
137 n_tup_upd AS rows_updated,
138 n_tup_del AS rows_deleted,
139 n_tup_hot_upd AS hot_updates,
140 n_live_tup AS live_rows,
141 n_dead_tup AS dead_rows,
142 last_vacuum,
143 last_autovacuum,
144 last_analyze,
145 last_autoanalyze,
146 vacuum_count,
147 autovacuum_count,
148 analyze_count,
149 autoanalyze_count
150FROM pg_stat_user_tables
151WHERE schemaname = '{schema}' AND relname = '{table}'
152"#
153 )
154 .trim()
155 .to_owned()
156}
157
158pub fn index_usage(schema: &str, table: &str) -> String {
159 format!(
160 r#"
161SELECT
162 s.indexrelname AS index_name,
163 pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
164 s.idx_scan AS number_of_scans,
165 s.idx_tup_read AS tuples_read,
166 s.idx_tup_fetch AS tuples_fetched,
167 pg_get_indexdef(s.indexrelid) AS index_definition,
168 CASE
169 WHEN i.indisunique THEN 'UNIQUE'
170 WHEN i.indisprimary THEN 'PRIMARY KEY'
171 ELSE 'INDEX'
172 END AS index_type
173FROM pg_stat_user_indexes s
174JOIN pg_index i ON s.indexrelid = i.indexrelid
175WHERE s.schemaname = '{schema}' AND s.relname = '{table}'
176ORDER BY s.idx_scan DESC
177"#
178 )
179 .trim()
180 .to_owned()
181}
182
183pub fn running_queries() -> &'static str {
184 r#"
185SELECT pid,
186 usename AS user,
187 datname AS database,
188 state,
189 wait_event_type,
190 wait_event,
191 (now() - query_start)::text AS duration,
192 query_start,
193 LEFT(query, 500) AS query
194FROM pg_stat_activity
195WHERE pid != pg_backend_pid()
196 AND state IS DISTINCT FROM 'idle'
197 AND datname = current_database()
198ORDER BY query_start ASC
199LIMIT 100
200"#
201 .trim()
202}
203
204pub fn blocking_locks() -> &'static str {
205 r#"
206SELECT
207 blocked_locks.pid AS blocked_pid,
208 blocked_activity.usename AS blocked_user,
209 blocking_locks.pid AS blocking_pid,
210 blocking_activity.usename AS blocking_user,
211 blocked_activity.query AS blocked_query,
212 blocking_activity.query AS blocking_query,
213 blocked_locks.mode AS lock_mode,
214 COALESCE(c.relname, 'null') AS locked_object
215FROM pg_catalog.pg_locks blocked_locks
216JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
217JOIN pg_catalog.pg_locks blocking_locks
218 ON blocking_locks.locktype = blocked_locks.locktype
219 AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
220 AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
221 AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
222 AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
223 AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
224 AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
225 AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
226 AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
227 AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
228 AND blocking_locks.pid != blocked_locks.pid
229JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
230LEFT JOIN pg_catalog.pg_class c ON c.oid = blocked_locks.relation
231WHERE NOT blocked_locks.granted
232AND blocked_activity.datname = current_database()
233AND blocking_activity.datname = current_database()
234"#
235 .trim()
236}
237
238pub fn connection_states() -> &'static str {
239 r#"
240SELECT state, wait_event_type IS NOT NULL as waiting, count(*) as count
241FROM pg_stat_activity
242WHERE datname = current_database()
243GROUP BY state, waiting
244"#
245 .trim()
246}
247
248pub fn cache_hit_ratio() -> &'static str {
249 r#"
250SELECT
251 blks_hit,
252 blks_read,
253 CASE WHEN blks_hit + blks_read = 0 THEN NULL
254 ELSE ROUND(blks_hit::numeric / (blks_hit + blks_read), 4)
255 END AS cache_hit_ratio,
256 xact_commit,
257 xact_rollback,
258 deadlocks,
259 temp_files,
260 temp_bytes
261FROM pg_stat_database
262WHERE datname = current_database()
263"#
264 .trim()
265}
266
267pub fn slow_queries(limit: u32) -> String {
268 let capped = limit.clamp(1, SLOW_QUERIES_MAX);
269 format!(
270 r#"
271SELECT
272 queryid::text,
273 LEFT(query, 500) AS query,
274 calls,
275 ROUND(mean_exec_time::numeric, 2) AS mean_ms,
276 ROUND(total_exec_time::numeric, 2) AS total_ms,
277 ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
278 rows
279FROM pg_stat_statements
280WHERE query NOT LIKE '%pg_stat_statements%'
281 AND query NOT LIKE 'BEGIN%'
282 AND query NOT LIKE 'COMMIT%'
283 AND query NOT LIKE 'ROLLBACK%'
284 AND calls >= 5
285ORDER BY mean_exec_time DESC
286LIMIT {capped}
287"#
288 )
289 .trim()
290 .to_owned()
291}
292
293pub fn database_stats() -> &'static str {
294 r#"
295SELECT
296 d.datname as "Database",
297 pg_size_pretty(pg_database_size(d.datname)) as "Size",
298 u.usename as "Owner",
299 (SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()) as "Active Connections",
300 (SELECT count(*) FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema')) as "Schemas",
301 (SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) as "Tables",
302 (SELECT count(*) FROM pg_roles) as "Roles"
303FROM pg_database d
304JOIN pg_user u ON d.datdba = u.usesysid
305WHERE d.datname = current_database()
306"#
307 .trim()
308}
309
310pub fn database_maintenance_stats() -> &'static str {
311 r#"
312SELECT
313 schemaname || '.' || relname as "Table",
314 n_dead_tup as "Dead Tuples",
315 n_live_tup as "Live Tuples",
316 last_vacuum as "Last Vacuum",
317 last_autovacuum as "Last Auto Vacuum",
318 pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) as "Total Size"
319FROM pg_stat_user_tables
320WHERE n_dead_tup > 0
321ORDER BY n_dead_tup DESC
322LIMIT 20
323"#
324 .trim()
325}
326
327pub fn list_extensions() -> &'static str {
328 r#"
329SELECT e.extname AS name,
330 e.extversion AS version,
331 n.nspname AS schema
332FROM pg_extension e
333JOIN pg_namespace n ON n.oid = e.extnamespace
334ORDER BY e.extname
335"#
336 .trim()
337}
338
339pub fn list_roles() -> &'static str {
341 r#"
342SELECT
343 r.rolname AS role,
344 r.rolsuper AS superuser,
345 r.rolcreatedb AS create_db,
346 r.rolcreaterole AS create_role,
347 r.rolcanlogin AS can_login,
348 r.rolreplication AS replication,
349 r.rolbypassrls AS bypass_rls,
350 r.rolinherit AS inherit,
351 r.rolconnlimit AS connection_limit,
352 r.rolvaliduntil AS valid_until
353FROM pg_roles r
354ORDER BY r.rolname
355"#
356 .trim()
357}
358
359pub fn role_details() -> &'static str {
361 r#"
362SELECT
363 r.rolname AS role,
364 r.rolsuper AS superuser,
365 r.rolcreatedb AS create_db,
366 r.rolcreaterole AS create_role,
367 r.rolcanlogin AS can_login,
368 r.rolreplication AS replication,
369 r.rolbypassrls AS bypass_rls,
370 r.rolinherit AS inherit,
371 r.rolconnlimit AS connection_limit,
372 r.rolvaliduntil AS valid_until,
373 pg_catalog.shobj_description(r.oid, 'pg_authid') AS description
374FROM pg_roles r
375WHERE r.rolname = $1
376"#
377 .trim()
378}
379
380pub fn role_member_of() -> &'static str {
382 r#"
383SELECT
384 m.rolname AS member_of,
385 g.rolname AS granted_by,
386 am.admin_option AS admin_option
387FROM pg_auth_members am
388JOIN pg_roles r ON r.oid = am.member
389JOIN pg_roles m ON m.oid = am.roleid
390JOIN pg_roles g ON g.oid = am.grantor
391WHERE r.rolname = $1
392ORDER BY m.rolname
393"#
394 .trim()
395}
396
397pub fn role_has_members() -> &'static str {
399 r#"
400SELECT
401 m.rolname AS has_member,
402 g.rolname AS granted_by,
403 am.admin_option AS admin_option
404FROM pg_auth_members am
405JOIN pg_roles r ON r.oid = am.roleid
406JOIN pg_roles m ON m.oid = am.member
407JOIN pg_roles g ON g.oid = am.grantor
408WHERE r.rolname = $1
409ORDER BY m.rolname
410"#
411 .trim()
412}
413
414pub fn role_table_privileges() -> &'static str {
416 r#"
417SELECT
418 table_schema AS schema,
419 table_name AS table_name,
420 privilege_type AS privilege,
421 is_grantable AS grantable
422FROM information_schema.table_privileges
423WHERE grantee = $1
424ORDER BY table_schema, table_name, privilege_type
425LIMIT 500
426"#
427 .trim()
428}
429
430pub fn dashboard_db_info() -> &'static str {
432 r#"
433SELECT
434 current_database() AS db_name,
435 pg_catalog.pg_get_userbyid(d.datdba) AS owner,
436 pg_size_pretty(pg_database_size(d.datname)) AS size,
437 pg_database_size(d.datname) AS size_bytes
438FROM pg_database d
439WHERE d.datname = current_database()
440"#
441 .trim()
442}
443
444pub fn dashboard_top_tables() -> &'static str {
446 r#"
447SELECT schemaname || '.' || tablename AS name,
448 pg_size_pretty(pg_total_relation_size(
449 (quote_ident(schemaname) || '.' || quote_ident(tablename))::regclass
450 )) AS size,
451 pg_total_relation_size(
452 (quote_ident(schemaname) || '.' || quote_ident(tablename))::regclass
453 ) AS raw_size
454FROM pg_tables
455WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
456ORDER BY raw_size DESC
457LIMIT 10
458"#
459 .trim()
460}
461
462pub fn dashboard_object_counts() -> &'static str {
464 r#"
465SELECT
466 (SELECT count(*) FROM pg_namespace
467 WHERE nspname NOT IN ('pg_catalog', 'information_schema')
468 AND nspname NOT LIKE 'pg_%') AS schemas,
469 (SELECT count(*) FROM pg_tables
470 WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) AS tables,
471 (SELECT count(*) FROM pg_views
472 WHERE schemaname NOT IN ('pg_catalog', 'information_schema')) AS views,
473 (SELECT count(*) FROM pg_proc p
474 JOIN pg_namespace n ON p.pronamespace = n.oid
475 WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')) AS functions,
476 (SELECT count(*) FROM pg_class c
477 JOIN pg_namespace n ON c.relnamespace = n.oid
478 WHERE c.relkind = 'S'
479 AND n.nspname NOT IN ('pg_catalog', 'information_schema')) AS sequences
480"#
481 .trim()
482}
483
484pub fn dashboard_active_queries() -> &'static str {
486 r#"
487SELECT pid, usename, datname, state,
488 wait_event_type, wait_event,
489 xact_start,
490 (now() - query_start)::text AS duration,
491 query_start,
492 LEFT(query, 500) AS query
493FROM pg_stat_activity
494WHERE pid != pg_backend_pid()
495 AND datname = current_database()
496ORDER BY state = 'active' DESC, query_start ASC
497LIMIT 50
498"#
499 .trim()
500}
501
502pub fn dashboard_max_connections() -> &'static str {
503 r#"SHOW max_connections"#
504}
505
506pub fn dashboard_extension_count() -> &'static str {
507 r#"
508SELECT count(*)::int AS count
509FROM pg_extension
510"#
511 .trim()
512}
513
514pub fn server_settings() -> &'static str {
515 r#"
516SELECT name, setting, unit, category, short_desc
517FROM pg_settings
518WHERE name IN (
519 'max_connections', 'shared_buffers', 'work_mem', 'maintenance_work_mem',
520 'effective_cache_size', 'random_page_cost', 'seq_page_cost',
521 'statement_timeout', 'idle_in_transaction_session_timeout',
522 'max_wal_size', 'checkpoint_completion_target', 'wal_buffers',
523 'default_statistics_target', 'autovacuum', 'server_version',
524 'shared_preload_libraries', 'default_transaction_read_only'
525)
526ORDER BY name
527"#
528 .trim()
529}
530
531pub const REPORT_LIMIT_MAX: u32 = 50;
533pub const REPORT_LIMIT_DEFAULT: u32 = 20;
534
535fn clamp_report_limit(limit: u32) -> u32 {
536 limit.clamp(1, REPORT_LIMIT_MAX)
537}
538
539pub fn high_seq_scan_tables(limit: u32) -> String {
542 let capped = clamp_report_limit(limit);
543 format!(
544 r#"
545SELECT schemaname || '.' || relname AS table_name,
546 seq_scan,
547 COALESCE(idx_scan, 0) AS idx_scan,
548 CASE WHEN seq_scan + COALESCE(idx_scan, 0) > 0
549 THEN ROUND(100.0 * seq_scan / (seq_scan + COALESCE(idx_scan, 0)), 1)
550 ELSE 0 END AS seq_scan_pct,
551 n_live_tup AS row_count,
552 'High sequential scan ratio — consider indexes on frequently filtered/joined columns' AS rationale
553FROM pg_stat_user_tables
554WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
555 AND seq_scan + COALESCE(idx_scan, 0) > 100
556ORDER BY seq_scan_pct DESC, seq_scan DESC
557LIMIT {capped}
558"#
559 )
560 .trim()
561 .to_owned()
562}
563
564pub fn unindexed_fk_columns(limit: u32) -> String {
566 let capped = clamp_report_limit(limit);
567 format!(
568 r#"
569SELECT
570 n.nspname || '.' || c.relname AS table_name,
571 a.attname AS column_name,
572 confrelid::regclass::text AS references_table,
573 'FK column without supporting index — CREATE INDEX ON ' ||
574 quote_ident(n.nspname) || '.' || quote_ident(c.relname) ||
575 ' (' || quote_ident(a.attname) || ')' AS suggestion
576FROM pg_constraint con
577JOIN pg_class c ON c.oid = con.conrelid
578JOIN pg_namespace n ON n.oid = c.relnamespace
579JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
580JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ck.attnum
581WHERE con.contype = 'f'
582 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
583 AND NOT EXISTS (
584 SELECT 1
585 FROM pg_index i
586 WHERE i.indrelid = c.oid
587 AND i.indkey[0] = a.attnum
588 )
589ORDER BY n.nspname, c.relname, a.attname
590LIMIT {capped}
591"#
592 )
593 .trim()
594 .to_owned()
595}
596
597pub fn find_unused_indexes(limit: u32) -> String {
600 let capped = clamp_report_limit(limit);
601 format!(
602 r#"
603SELECT s.schemaname || '.' || s.indexrelname AS index_name,
604 s.schemaname || '.' || s.relname AS table_name,
605 pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
606 pg_relation_size(s.indexrelid) AS raw_size,
607 pg_get_indexdef(s.indexrelid) AS index_definition
608FROM pg_stat_user_indexes s
609JOIN pg_index i
610 ON i.indexrelid = s.indexrelid
611LEFT JOIN pg_constraint c
612 ON c.conindid = s.indexrelid
613WHERE s.idx_scan = 0
614 AND s.schemaname NOT IN ('pg_catalog', 'information_schema')
615 AND c.oid IS NULL
616 AND NOT i.indisprimary
617 AND NOT i.indisunique
618ORDER BY raw_size DESC
619LIMIT {capped}
620"#
621 )
622 .trim()
623 .to_owned()
624}
625
626pub fn bloat_report(limit: u32) -> String {
629 let capped = clamp_report_limit(limit);
630 format!(
631 r#"
632SELECT schemaname || '.' || relname AS table_name,
633 n_live_tup AS live_tuples,
634 n_dead_tup AS dead_tuples,
635 CASE WHEN n_live_tup + n_dead_tup > 0
636 THEN ROUND(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1)
637 ELSE 0 END AS bloat_pct,
638 pg_size_pretty(pg_relation_size(relid)) AS table_size,
639 last_autovacuum,
640 last_vacuum
641FROM pg_stat_user_tables
642WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
643 AND n_dead_tup > 1000
644ORDER BY bloat_pct DESC, n_dead_tup DESC
645LIMIT {capped}
646"#
647 )
648 .trim()
649 .to_owned()
650}
651
652pub fn find_missing_fks_catalog(limit: u32) -> String {
654 let capped = clamp_report_limit(limit);
655 format!(
656 r#"
657WITH pk_tables AS (
658 SELECT
659 n.nspname AS schema_name,
660 c.relname AS table_name,
661 a.attname AS pk_column,
662 lower(c.relname) AS table_lower
663 FROM pg_constraint con
664 JOIN pg_class c ON c.oid = con.conrelid
665 JOIN pg_namespace n ON n.oid = c.relnamespace
666 JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
667 JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ck.attnum
668 WHERE con.contype = 'p'
669 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
670 AND array_length(con.conkey, 1) = 1
671),
672candidates AS (
673 SELECT
674 n.nspname AS schema_name,
675 c.relname AS table_name,
676 a.attname AS column_name,
677 left(a.attname, length(a.attname) - 3) AS name_prefix
678 FROM pg_attribute a
679 JOIN pg_class c ON c.oid = a.attrelid AND c.relkind = 'r'
680 JOIN pg_namespace n ON n.oid = c.relnamespace
681 WHERE a.attnum > 0
682 AND NOT a.attisdropped
683 AND n.nspname NOT IN ('pg_catalog', 'information_schema')
684 AND a.attname ~* '_id$'
685 AND a.attname <> 'id'
686 AND NOT EXISTS (
687 SELECT 1
688 FROM pg_constraint con
689 JOIN LATERAL unnest(con.conkey) AS ck(attnum) ON true
690 WHERE con.conrelid = c.oid
691 AND con.contype = 'f'
692 AND ck.attnum = a.attnum
693 )
694)
695SELECT
696 cand.schema_name || '.' || cand.table_name AS from_table,
697 cand.column_name,
698 pk.schema_name || '.' || pk.table_name AS suggested_ref_table,
699 pk.pk_column AS suggested_ref_column,
700 'naming_convention' AS detection
701FROM candidates cand
702JOIN pk_tables pk
703 ON pk.schema_name = cand.schema_name
704 AND (
705 pk.table_lower = lower(cand.name_prefix)
706 OR pk.table_lower = lower(cand.name_prefix) || 's'
707 OR pk.table_lower = lower(cand.name_prefix) || 'es'
708 OR (right(lower(cand.name_prefix), 1) = 'y'
709 AND pk.table_lower = left(lower(cand.name_prefix), -1) || 'ies')
710 )
711WHERE cand.schema_name || '.' || cand.table_name
712 <> pk.schema_name || '.' || pk.table_name
713ORDER BY from_table, column_name
714LIMIT {capped}
715"#
716 )
717 .trim()
718 .to_owned()
719}
720
721pub fn map_stat_statements_error(e: impl std::fmt::Display) -> Option<String> {
723 let message = e.to_string();
724 if message.to_ascii_lowercase().contains("pg_stat_statements") {
725 Some(
726 "pg_stat_statements is not available — CREATE EXTENSION pg_stat_statements; \
727 (and GRANT) or ignore slow query suggestions."
728 .into(),
729 )
730 } else {
731 None
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[test]
740 fn map_stat_statements_error_matches_extension_errors() {
741 let msg = map_stat_statements_error("relation \"pg_stat_statements\" does not exist");
742 assert!(msg.is_some());
743 assert!(msg.unwrap().contains("CREATE EXTENSION pg_stat_statements"));
744 }
745
746 #[test]
747 fn map_stat_statements_error_ignores_unrelated() {
748 assert!(map_stat_statements_error("connection refused").is_none());
749 }
750
751 #[test]
752 fn parse_ref_accepts_schema_dot_name() {
753 assert_eq!(
754 parse_ref("public.users").unwrap(),
755 ("public".into(), "users".into())
756 );
757 }
758
759 #[test]
760 fn parse_ref_defaults_schema() {
761 assert_eq!(
762 parse_ref("orders").unwrap(),
763 ("public".into(), "orders".into())
764 );
765 }
766
767 #[test]
768 fn parse_ref_rejects_injection() {
769 let err = parse_ref("public.users; DROP").unwrap_err();
770 assert!(err.contains("Invalid object reference"), "{err}");
771 }
772
773 #[test]
774 fn parse_ref_rejects_empty() {
775 assert!(parse_ref("").is_err());
776 assert!(parse_ref(" ").is_err());
777 }
778}