Skip to main content

nexql_tools/
sql.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! SQL builders for Phase 4 monitoring / DDL tools.
5//! Ported from `pro/.../ToolExecutor.ts` + `core/commands/sql/{profile,monitoring}.ts`.
6
7/// Max rows for `slow_queries` (matches TS `MonitoringSQL.slowQueries`).
8pub const SLOW_QUERIES_MAX: u32 = 50;
9pub const SLOW_QUERIES_DEFAULT: u32 = 10;
10
11/// Validate `schema.name` (or bare name → `public`) as plain SQL identifiers.
12pub 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
35/// Validate a SQL identifier (table/column name).
36pub 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
45/// Double-quote an identifier (idents already validated via [`parse_ref`]).
46pub 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
54/// String literal for `'\"schema\".\"name\"'::regclass`.
55pub 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
342/// All roles (ported from core `QueryBuilder.databaseRoles`).
343pub 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
362/// Single-role attributes (`$1` = role name).
363pub 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
383/// Roles this role is a member of (`$1` = role name).
384pub 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
400/// Roles that are members of this role (`$1` = role name).
401pub 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
417/// Table privileges granted to a role (`$1` = role name). Cap via LIMIT in caller if needed.
418pub 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
433/// Dashboard: database owner + pretty size for `current_database()`.
434pub 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
447/// Dashboard: top tables by total relation size.
448pub 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
465/// Dashboard: object counts (non-system).
466pub 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
487/// Dashboard: backends for current DB (incl. idle), capped.
488pub 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
534/// Max rows for advisory reports (`suggest_indexes`, unused indexes, bloat, missing FKs).
535pub 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
542/// Tables with high sequential-scan ratio — primary `suggest_indexes` heuristic
543/// (ported from Pro dashboard `highSeqScanTables`).
544pub 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
567/// Foreign-key columns lacking a covering btree index — classic missing-index heuristic.
568pub 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
600/// Unused indexes: `idx_scan = 0`, excluding PK / UNIQUE / constraint-backed
601/// (matches Pro `DashboardData` unusedIndexes query).
602pub 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
629/// Approximate table bloat via dead-tuple ratio (not physical page bloat).
630/// Documented simplified estimate — avoids heavy pgstattuple / check_postgres SQL.
631pub 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
655/// Catalog fallback: `*_id` columns with no FK that name-match another table's PK.
656pub 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
724/// Enrich a Postgres error with schema-index hints (qualified ref, did-you-mean).
725pub 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
838/// Map tokio-postgres errors from `pg_stat_statements` queries to actionable guidance.
839pub 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}