Skip to main content

nexql_tools/
sql.rs

1//! SQL builders for Phase 4 monitoring / DDL tools.
2//! Ported from `pro/.../ToolExecutor.ts` + `core/commands/sql/{profile,monitoring}.ts`.
3
4/// Max rows for `slow_queries` (matches TS `MonitoringSQL.slowQueries`).
5pub const SLOW_QUERIES_MAX: u32 = 50;
6pub const SLOW_QUERIES_DEFAULT: u32 = 10;
7
8/// Validate `schema.name` (or bare name → `public`) as plain SQL identifiers.
9pub 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
32/// Validate a SQL identifier (table/column name).
33pub 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
42/// Double-quote an identifier (idents already validated via [`parse_ref`]).
43pub 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
51/// String literal for `'\"schema\".\"name\"'::regclass`.
52pub 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
339/// All roles (ported from core `QueryBuilder.databaseRoles`).
340pub 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
359/// Single-role attributes (`$1` = role name).
360pub 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
380/// Roles this role is a member of (`$1` = role name).
381pub 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
397/// Roles that are members of this role (`$1` = role name).
398pub 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
414/// Table privileges granted to a role (`$1` = role name). Cap via LIMIT in caller if needed.
415pub 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
430/// Dashboard: database owner + pretty size for `current_database()`.
431pub 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
444/// Dashboard: top tables by total relation size.
445pub 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
462/// Dashboard: object counts (non-system).
463pub 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
484/// Dashboard: backends for current DB (incl. idle), capped.
485pub 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
531/// Max rows for advisory reports (`suggest_indexes`, unused indexes, bloat, missing FKs).
532pub 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
539/// Tables with high sequential-scan ratio — primary `suggest_indexes` heuristic
540/// (ported from Pro dashboard `highSeqScanTables`).
541pub 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
564/// Foreign-key columns lacking a covering btree index — classic missing-index heuristic.
565pub 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
597/// Unused indexes: `idx_scan = 0`, excluding PK / UNIQUE / constraint-backed
598/// (matches Pro `DashboardData` unusedIndexes query).
599pub 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
626/// Approximate table bloat via dead-tuple ratio (not physical page bloat).
627/// Documented simplified estimate — avoids heavy pgstattuple / check_postgres SQL.
628pub 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
652/// Catalog fallback: `*_id` columns with no FK that name-match another table's PK.
653pub 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
721/// Map tokio-postgres errors from `pg_stat_statements` queries to actionable guidance.
722pub 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}