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