postgresai 0.14.0-dev.43 → 0.14.0-dev.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postgres-ai.ts +649 -310
- package/bun.lock +258 -0
- package/dist/bin/postgres-ai.js +29491 -1910
- package/dist/sql/01.role.sql +16 -0
- package/dist/sql/02.permissions.sql +37 -0
- package/dist/sql/03.optional_rds.sql +6 -0
- package/dist/sql/04.optional_self_managed.sql +8 -0
- package/dist/sql/05.helpers.sql +415 -0
- package/lib/auth-server.ts +58 -97
- package/lib/checkup-api.ts +175 -0
- package/lib/checkup.ts +833 -0
- package/lib/config.ts +3 -0
- package/lib/init.ts +106 -74
- package/lib/issues.ts +121 -194
- package/lib/mcp-server.ts +6 -17
- package/lib/metrics-loader.ts +156 -0
- package/package.json +13 -9
- package/sql/02.permissions.sql +9 -5
- package/sql/05.helpers.sql +415 -0
- package/test/checkup.test.ts +953 -0
- package/test/init.integration.test.ts +396 -0
- package/test/init.test.ts +345 -0
- package/test/schema-validation.test.ts +188 -0
- package/tsconfig.json +12 -20
- package/dist/bin/postgres-ai.d.ts +0 -3
- package/dist/bin/postgres-ai.d.ts.map +0 -1
- package/dist/bin/postgres-ai.js.map +0 -1
- package/dist/lib/auth-server.d.ts +0 -31
- package/dist/lib/auth-server.d.ts.map +0 -1
- package/dist/lib/auth-server.js +0 -263
- package/dist/lib/auth-server.js.map +0 -1
- package/dist/lib/config.d.ts +0 -45
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -181
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/init.d.ts +0 -85
- package/dist/lib/init.d.ts.map +0 -1
- package/dist/lib/init.js +0 -644
- package/dist/lib/init.js.map +0 -1
- package/dist/lib/issues.d.ts +0 -75
- package/dist/lib/issues.d.ts.map +0 -1
- package/dist/lib/issues.js +0 -336
- package/dist/lib/issues.js.map +0 -1
- package/dist/lib/mcp-server.d.ts +0 -9
- package/dist/lib/mcp-server.d.ts.map +0 -1
- package/dist/lib/mcp-server.js +0 -168
- package/dist/lib/mcp-server.js.map +0 -1
- package/dist/lib/pkce.d.ts +0 -32
- package/dist/lib/pkce.d.ts.map +0 -1
- package/dist/lib/pkce.js +0 -101
- package/dist/lib/pkce.js.map +0 -1
- package/dist/lib/util.d.ts +0 -27
- package/dist/lib/util.d.ts.map +0 -1
- package/dist/lib/util.js +0 -46
- package/dist/lib/util.js.map +0 -1
- package/dist/package.json +0 -46
- package/test/init.integration.test.cjs +0 -382
- package/test/init.test.cjs +0 -392
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load SQL queries from metrics.yml
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: This module loads SQL queries directly from config/pgwatch-prometheus/metrics.yml
|
|
5
|
+
* to avoid code duplication. The metrics.yml is the single source of truth for metric extraction logic.
|
|
6
|
+
*
|
|
7
|
+
* DO NOT copy-paste SQL queries into TypeScript code. Always load them from metrics.yml.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { resolve, dirname } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import * as yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
// Get the path to metrics.yml relative to this file
|
|
16
|
+
function getMetricsYmlPath(): string {
|
|
17
|
+
// When running from source: cli/lib/metrics-loader.ts -> config/pgwatch-prometheus/metrics.yml
|
|
18
|
+
// When running from dist: cli/dist/lib/metrics-loader.js -> config/pgwatch-prometheus/metrics.yml
|
|
19
|
+
const currentDir = typeof __dirname !== "undefined"
|
|
20
|
+
? __dirname
|
|
21
|
+
: dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
// Try multiple possible locations
|
|
24
|
+
const possiblePaths = [
|
|
25
|
+
resolve(currentDir, "../../config/pgwatch-prometheus/metrics.yml"), // from cli/lib
|
|
26
|
+
resolve(currentDir, "../../../config/pgwatch-prometheus/metrics.yml"), // from cli/dist/lib
|
|
27
|
+
resolve(currentDir, "../../../../config/pgwatch-prometheus/metrics.yml"), // deeper nesting
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const path of possiblePaths) {
|
|
31
|
+
try {
|
|
32
|
+
readFileSync(path);
|
|
33
|
+
return path;
|
|
34
|
+
} catch {
|
|
35
|
+
// Try next path
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(`Cannot find metrics.yml. Tried: ${possiblePaths.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MetricDefinition {
|
|
43
|
+
description?: string;
|
|
44
|
+
sqls?: {
|
|
45
|
+
[pgVersion: string]: string;
|
|
46
|
+
};
|
|
47
|
+
gauges?: string[];
|
|
48
|
+
statement_timeout_seconds?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MetricsYmlRoot {
|
|
52
|
+
metrics: {
|
|
53
|
+
[metricName: string]: MetricDefinition;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let cachedMetrics: MetricsYmlRoot | null = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load and parse metrics.yml (cached after first load)
|
|
61
|
+
*/
|
|
62
|
+
export function loadMetricsYml(): MetricsYmlRoot {
|
|
63
|
+
if (cachedMetrics) {
|
|
64
|
+
return cachedMetrics;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const metricsPath = getMetricsYmlPath();
|
|
68
|
+
const content = readFileSync(metricsPath, "utf8");
|
|
69
|
+
cachedMetrics = yaml.load(content) as MetricsYmlRoot;
|
|
70
|
+
return cachedMetrics;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get SQL query for a specific metric and PostgreSQL version.
|
|
75
|
+
* Falls back to lower versions if exact version not found.
|
|
76
|
+
*
|
|
77
|
+
* @param metricName - Name of the metric in metrics.yml (e.g., "pg_invalid_indexes")
|
|
78
|
+
* @param pgMajorVersion - PostgreSQL major version (e.g., 16)
|
|
79
|
+
* @returns SQL query string
|
|
80
|
+
*/
|
|
81
|
+
export function getMetricSql(metricName: string, pgMajorVersion: number = 16): string {
|
|
82
|
+
const root = loadMetricsYml();
|
|
83
|
+
const metric = root.metrics[metricName];
|
|
84
|
+
|
|
85
|
+
if (!metric) {
|
|
86
|
+
throw new Error(`Metric "${metricName}" not found in metrics.yml`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!metric.sqls) {
|
|
90
|
+
throw new Error(`Metric "${metricName}" has no SQL queries defined`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Try exact version first, then fall back to lower versions
|
|
94
|
+
const versions = Object.keys(metric.sqls)
|
|
95
|
+
.map(Number)
|
|
96
|
+
.filter(v => !isNaN(v))
|
|
97
|
+
.sort((a, b) => b - a); // Sort descending
|
|
98
|
+
|
|
99
|
+
for (const version of versions) {
|
|
100
|
+
if (version <= pgMajorVersion) {
|
|
101
|
+
return metric.sqls[version.toString()];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If no matching version, use the lowest available
|
|
106
|
+
const lowestVersion = versions[versions.length - 1];
|
|
107
|
+
if (lowestVersion !== undefined) {
|
|
108
|
+
return metric.sqls[lowestVersion.toString()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw new Error(`No SQL query found for metric "${metricName}"`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Metric names in metrics.yml that correspond to express report checks.
|
|
116
|
+
* These map check IDs to metric names in config/pgwatch-prometheus/metrics.yml.
|
|
117
|
+
*/
|
|
118
|
+
export const METRIC_NAMES = {
|
|
119
|
+
// Index health checks
|
|
120
|
+
H001: "pg_invalid_indexes",
|
|
121
|
+
H002: "unused_indexes",
|
|
122
|
+
H004: "redundant_indexes",
|
|
123
|
+
// Express report metrics
|
|
124
|
+
version: "express_version",
|
|
125
|
+
settings: "express_settings",
|
|
126
|
+
alteredSettings: "express_altered_settings",
|
|
127
|
+
databaseSizes: "express_database_sizes",
|
|
128
|
+
clusterStats: "express_cluster_stats",
|
|
129
|
+
connectionStates: "express_connection_states",
|
|
130
|
+
uptimeInfo: "express_uptime",
|
|
131
|
+
statsReset: "express_stats_reset",
|
|
132
|
+
currentDatabase: "express_current_database",
|
|
133
|
+
} as const;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Transform a row from metrics.yml query output to JSON report format.
|
|
137
|
+
* Metrics.yml uses `tag_` prefix for dimensions; we strip it for JSON reports.
|
|
138
|
+
* Also removes Prometheus-specific fields like epoch_ns, num.
|
|
139
|
+
*/
|
|
140
|
+
export function transformMetricRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
141
|
+
const result: Record<string, unknown> = {};
|
|
142
|
+
|
|
143
|
+
for (const [key, value] of Object.entries(row)) {
|
|
144
|
+
// Skip Prometheus-specific fields
|
|
145
|
+
if (key === "epoch_ns" || key === "num" || key === "tag_datname") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Strip tag_ prefix
|
|
150
|
+
const newKey = key.startsWith("tag_") ? key.slice(4) : key;
|
|
151
|
+
result[newKey] = value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "postgresai",
|
|
3
|
-
"version": "0.14.0-dev.
|
|
4
|
-
"description": "postgres_ai CLI
|
|
3
|
+
"version": "0.14.0-dev.44",
|
|
4
|
+
"description": "postgres_ai CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
7
7
|
"repository": {
|
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
"postgresai": "./dist/bin/postgres-ai.js",
|
|
18
18
|
"pgai": "./dist/bin/postgres-ai.js"
|
|
19
19
|
},
|
|
20
|
-
"type": "
|
|
20
|
+
"type": "module",
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=18"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "
|
|
26
|
-
"
|
|
27
|
-
"start": "
|
|
28
|
-
"
|
|
29
|
-
"
|
|
25
|
+
"build": "bun build ./bin/postgres-ai.ts --outdir ./dist/bin --target node && node -e \"const fs=require('fs');const f='./dist/bin/postgres-ai.js';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\" && cp -r sql dist/",
|
|
26
|
+
"prepublishOnly": "npm run build",
|
|
27
|
+
"start": "bun ./bin/postgres-ai.ts --help",
|
|
28
|
+
"start:node": "node ./dist/bin/postgres-ai.js --help",
|
|
29
|
+
"dev": "bun --watch ./bin/postgres-ai.ts",
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"typecheck": "bunx tsc --noEmit"
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|
|
32
34
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
@@ -35,9 +37,11 @@
|
|
|
35
37
|
"pg": "^8.16.3"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@types/bun": "^1.1.14",
|
|
38
41
|
"@types/js-yaml": "^4.0.9",
|
|
39
|
-
"@types/node": "^18.19.0",
|
|
40
42
|
"@types/pg": "^8.15.6",
|
|
43
|
+
"ajv": "^8.17.1",
|
|
44
|
+
"ajv-formats": "^3.0.1",
|
|
41
45
|
"typescript": "^5.3.3"
|
|
42
46
|
},
|
|
43
47
|
"publishConfig": {
|
package/sql/02.permissions.sql
CHANGED
|
@@ -7,8 +7,12 @@ grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
|
|
|
7
7
|
grant pg_monitor to {{ROLE_IDENT}};
|
|
8
8
|
grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
|
|
9
9
|
|
|
10
|
-
--
|
|
11
|
-
create
|
|
10
|
+
-- Create postgres_ai schema for our objects
|
|
11
|
+
create schema if not exists postgres_ai;
|
|
12
|
+
grant usage on schema postgres_ai to {{ROLE_IDENT}};
|
|
13
|
+
|
|
14
|
+
-- For bloat analysis: expose pg_statistic via a view
|
|
15
|
+
create or replace view postgres_ai.pg_statistic as
|
|
12
16
|
select
|
|
13
17
|
n.nspname as schemaname,
|
|
14
18
|
c.relname as tablename,
|
|
@@ -22,12 +26,12 @@ join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
|
22
26
|
join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
|
|
23
27
|
where a.attnum > 0 and not a.attisdropped;
|
|
24
28
|
|
|
25
|
-
grant select on
|
|
29
|
+
grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
26
30
|
|
|
27
31
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
28
32
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
29
33
|
|
|
30
|
-
-- Keep search_path predictable
|
|
31
|
-
alter user {{ROLE_IDENT}} set search_path = "$user", public, pg_catalog;
|
|
34
|
+
-- Keep search_path predictable; postgres_ai first so our objects are found
|
|
35
|
+
alter user {{ROLE_IDENT}} set search_path = postgres_ai, "$user", public, pg_catalog;
|
|
32
36
|
|
|
33
37
|
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
-- Helper functions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
|
|
2
|
+
-- These functions use SECURITY DEFINER to allow the monitoring user to perform
|
|
3
|
+
-- operations they don't have direct permissions for.
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* pgai_explain_generic
|
|
7
|
+
*
|
|
8
|
+
* Function to get generic explain plans with optional HypoPG index testing.
|
|
9
|
+
* Requires: PostgreSQL 16+ (for generic_plan option), HypoPG extension (optional).
|
|
10
|
+
*
|
|
11
|
+
* Usage examples:
|
|
12
|
+
* -- Basic generic plan
|
|
13
|
+
* select postgres_ai.explain_generic('select * from users where id = $1');
|
|
14
|
+
*
|
|
15
|
+
* -- JSON format
|
|
16
|
+
* select postgres_ai.explain_generic('select * from users where id = $1', 'json');
|
|
17
|
+
*
|
|
18
|
+
* -- Test a hypothetical index
|
|
19
|
+
* select postgres_ai.explain_generic(
|
|
20
|
+
* 'select * from users where email = $1',
|
|
21
|
+
* 'text',
|
|
22
|
+
* 'create index on users (email)'
|
|
23
|
+
* );
|
|
24
|
+
*/
|
|
25
|
+
create or replace function postgres_ai.explain_generic(
|
|
26
|
+
in query text,
|
|
27
|
+
in format text default 'text',
|
|
28
|
+
in hypopg_index text default null,
|
|
29
|
+
out result text
|
|
30
|
+
)
|
|
31
|
+
language plpgsql
|
|
32
|
+
security definer
|
|
33
|
+
set search_path = pg_catalog, public
|
|
34
|
+
as $$
|
|
35
|
+
declare
|
|
36
|
+
v_line record;
|
|
37
|
+
v_lines text[] := '{}';
|
|
38
|
+
v_explain_query text;
|
|
39
|
+
v_hypo_result record;
|
|
40
|
+
v_version int;
|
|
41
|
+
v_hypopg_available boolean;
|
|
42
|
+
begin
|
|
43
|
+
-- Check PostgreSQL version (generic_plan requires 16+)
|
|
44
|
+
select current_setting('server_version_num')::int into v_version;
|
|
45
|
+
|
|
46
|
+
if v_version < 160000 then
|
|
47
|
+
raise exception 'generic_plan requires PostgreSQL 16+, current version: %',
|
|
48
|
+
current_setting('server_version');
|
|
49
|
+
end if;
|
|
50
|
+
|
|
51
|
+
-- Check if HypoPG extension is available
|
|
52
|
+
if hypopg_index is not null then
|
|
53
|
+
select exists(
|
|
54
|
+
select 1 from pg_extension where extname = 'hypopg'
|
|
55
|
+
) into v_hypopg_available;
|
|
56
|
+
|
|
57
|
+
if not v_hypopg_available then
|
|
58
|
+
raise exception 'HypoPG extension is required for hypothetical index testing but is not installed';
|
|
59
|
+
end if;
|
|
60
|
+
|
|
61
|
+
-- Create hypothetical index
|
|
62
|
+
select * into v_hypo_result from hypopg_create_index(hypopg_index);
|
|
63
|
+
raise notice 'Created hypothetical index: % (oid: %)',
|
|
64
|
+
v_hypo_result.indexname, v_hypo_result.indexrelid;
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- Build and execute explain query based on format
|
|
68
|
+
-- Output is preserved exactly as EXPLAIN returns it
|
|
69
|
+
begin
|
|
70
|
+
if lower(format) = 'json' then
|
|
71
|
+
v_explain_query := 'explain (verbose, settings, generic_plan, format json) ' || query;
|
|
72
|
+
execute v_explain_query into result;
|
|
73
|
+
else
|
|
74
|
+
v_explain_query := 'explain (verbose, settings, generic_plan) ' || query;
|
|
75
|
+
for v_line in execute v_explain_query loop
|
|
76
|
+
v_lines := array_append(v_lines, v_line."QUERY PLAN");
|
|
77
|
+
end loop;
|
|
78
|
+
result := array_to_string(v_lines, e'\n');
|
|
79
|
+
end if;
|
|
80
|
+
exception when others then
|
|
81
|
+
-- Clean up hypothetical index before re-raising
|
|
82
|
+
if hypopg_index is not null then
|
|
83
|
+
perform hypopg_reset();
|
|
84
|
+
end if;
|
|
85
|
+
raise;
|
|
86
|
+
end;
|
|
87
|
+
|
|
88
|
+
-- Clean up hypothetical index
|
|
89
|
+
if hypopg_index is not null then
|
|
90
|
+
perform hypopg_reset();
|
|
91
|
+
end if;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
comment on function postgres_ai.explain_generic(text, text, text) is
|
|
96
|
+
'Returns generic EXPLAIN plan with optional HypoPG index testing (requires PG16+)';
|
|
97
|
+
|
|
98
|
+
-- Grant execute to the monitoring user
|
|
99
|
+
grant execute on function postgres_ai.explain_generic(text, text, text) to {{ROLE_IDENT}};
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* table_describe
|
|
103
|
+
*
|
|
104
|
+
* Collects comprehensive information about a table for LLM analysis.
|
|
105
|
+
* Returns a compact text format with:
|
|
106
|
+
* - Table metadata (type, size estimates)
|
|
107
|
+
* - Columns (name, type, nullable, default)
|
|
108
|
+
* - Indexes
|
|
109
|
+
* - Constraints (PK, FK, unique, check)
|
|
110
|
+
* - Maintenance stats (vacuum/analyze times)
|
|
111
|
+
*
|
|
112
|
+
* Usage:
|
|
113
|
+
* select postgres_ai.table_describe('public.users');
|
|
114
|
+
* select postgres_ai.table_describe('my_table'); -- uses search_path
|
|
115
|
+
*/
|
|
116
|
+
create or replace function postgres_ai.table_describe(
|
|
117
|
+
in table_name text,
|
|
118
|
+
out result text
|
|
119
|
+
)
|
|
120
|
+
language plpgsql
|
|
121
|
+
security definer
|
|
122
|
+
set search_path = pg_catalog, public
|
|
123
|
+
as $$
|
|
124
|
+
declare
|
|
125
|
+
v_oid oid;
|
|
126
|
+
v_schema text;
|
|
127
|
+
v_table text;
|
|
128
|
+
v_relkind char;
|
|
129
|
+
v_relpages int;
|
|
130
|
+
v_reltuples float;
|
|
131
|
+
v_lines text[] := '{}';
|
|
132
|
+
v_line text;
|
|
133
|
+
v_rec record;
|
|
134
|
+
v_constraint_count int := 0;
|
|
135
|
+
begin
|
|
136
|
+
-- Resolve table name to OID (handles schema-qualified and search_path)
|
|
137
|
+
v_oid := table_name::regclass::oid;
|
|
138
|
+
|
|
139
|
+
-- Get basic table info
|
|
140
|
+
select
|
|
141
|
+
n.nspname,
|
|
142
|
+
c.relname,
|
|
143
|
+
c.relkind,
|
|
144
|
+
c.relpages,
|
|
145
|
+
c.reltuples
|
|
146
|
+
into v_schema, v_table, v_relkind, v_relpages, v_reltuples
|
|
147
|
+
from pg_class c
|
|
148
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
149
|
+
where c.oid = v_oid;
|
|
150
|
+
|
|
151
|
+
-- Validate object type - only tables, views, and materialized views are supported
|
|
152
|
+
if v_relkind not in ('r', 'p', 'v', 'm', 'f') then
|
|
153
|
+
raise exception 'table_describe does not support % (relkind=%)',
|
|
154
|
+
case v_relkind
|
|
155
|
+
when 'i' then 'indexes'
|
|
156
|
+
when 'I' then 'partitioned indexes'
|
|
157
|
+
when 'S' then 'sequences'
|
|
158
|
+
when 't' then 'TOAST tables'
|
|
159
|
+
when 'c' then 'composite types'
|
|
160
|
+
else format('objects of type "%s"', v_relkind)
|
|
161
|
+
end,
|
|
162
|
+
v_relkind;
|
|
163
|
+
end if;
|
|
164
|
+
|
|
165
|
+
-- Header
|
|
166
|
+
v_lines := array_append(v_lines, format('Table: %I.%I', v_schema, v_table));
|
|
167
|
+
v_lines := array_append(v_lines, format('Type: %s | relpages: %s | reltuples: %s',
|
|
168
|
+
case v_relkind
|
|
169
|
+
when 'r' then 'table'
|
|
170
|
+
when 'p' then 'partitioned table'
|
|
171
|
+
when 'v' then 'view'
|
|
172
|
+
when 'm' then 'materialized view'
|
|
173
|
+
when 'f' then 'foreign table'
|
|
174
|
+
end,
|
|
175
|
+
v_relpages,
|
|
176
|
+
case when v_reltuples < 0 then '-1' else v_reltuples::bigint::text end
|
|
177
|
+
));
|
|
178
|
+
|
|
179
|
+
-- Vacuum/analyze stats (only for tables and materialized views, not views)
|
|
180
|
+
if v_relkind in ('r', 'p', 'm', 'f') then
|
|
181
|
+
select
|
|
182
|
+
format('Vacuum: %s (auto: %s) | Analyze: %s (auto: %s)',
|
|
183
|
+
coalesce(to_char(last_vacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
184
|
+
coalesce(to_char(last_autovacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
185
|
+
coalesce(to_char(last_analyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
|
|
186
|
+
coalesce(to_char(last_autoanalyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never')
|
|
187
|
+
)
|
|
188
|
+
into v_line
|
|
189
|
+
from pg_stat_all_tables
|
|
190
|
+
where relid = v_oid;
|
|
191
|
+
|
|
192
|
+
if v_line is not null then
|
|
193
|
+
v_lines := array_append(v_lines, v_line);
|
|
194
|
+
end if;
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
v_lines := array_append(v_lines, '');
|
|
198
|
+
|
|
199
|
+
-- Columns
|
|
200
|
+
v_lines := array_append(v_lines, 'Columns:');
|
|
201
|
+
for v_rec in
|
|
202
|
+
select
|
|
203
|
+
a.attname,
|
|
204
|
+
format_type(a.atttypid, a.atttypmod) as data_type,
|
|
205
|
+
a.attnotnull,
|
|
206
|
+
(select pg_get_expr(d.adbin, d.adrelid, true)
|
|
207
|
+
from pg_attrdef d
|
|
208
|
+
where d.adrelid = a.attrelid and d.adnum = a.attnum and a.atthasdef) as default_val,
|
|
209
|
+
a.attidentity,
|
|
210
|
+
a.attgenerated
|
|
211
|
+
from pg_attribute a
|
|
212
|
+
where a.attrelid = v_oid
|
|
213
|
+
and a.attnum > 0
|
|
214
|
+
and not a.attisdropped
|
|
215
|
+
order by a.attnum
|
|
216
|
+
loop
|
|
217
|
+
v_line := format(' %s %s', v_rec.attname, v_rec.data_type);
|
|
218
|
+
|
|
219
|
+
if v_rec.attnotnull then
|
|
220
|
+
v_line := v_line || ' NOT NULL';
|
|
221
|
+
end if;
|
|
222
|
+
|
|
223
|
+
if v_rec.attidentity = 'a' then
|
|
224
|
+
v_line := v_line || ' GENERATED ALWAYS AS IDENTITY';
|
|
225
|
+
elsif v_rec.attidentity = 'd' then
|
|
226
|
+
v_line := v_line || ' GENERATED BY DEFAULT AS IDENTITY';
|
|
227
|
+
elsif v_rec.attgenerated = 's' then
|
|
228
|
+
v_line := v_line || format(' GENERATED ALWAYS AS (%s) STORED', v_rec.default_val);
|
|
229
|
+
elsif v_rec.default_val is not null then
|
|
230
|
+
v_line := v_line || format(' DEFAULT %s', v_rec.default_val);
|
|
231
|
+
end if;
|
|
232
|
+
|
|
233
|
+
v_lines := array_append(v_lines, v_line);
|
|
234
|
+
end loop;
|
|
235
|
+
|
|
236
|
+
-- View definition (for views and materialized views)
|
|
237
|
+
if v_relkind in ('v', 'm') then
|
|
238
|
+
v_lines := array_append(v_lines, '');
|
|
239
|
+
v_lines := array_append(v_lines, 'Definition:');
|
|
240
|
+
v_line := pg_get_viewdef(v_oid, true);
|
|
241
|
+
if v_line is not null then
|
|
242
|
+
-- Indent the view definition
|
|
243
|
+
v_line := ' ' || replace(v_line, e'\n', e'\n ');
|
|
244
|
+
v_lines := array_append(v_lines, v_line);
|
|
245
|
+
end if;
|
|
246
|
+
end if;
|
|
247
|
+
|
|
248
|
+
-- Indexes (tables, partitioned tables, and materialized views can have indexes)
|
|
249
|
+
if v_relkind in ('r', 'p', 'm') then
|
|
250
|
+
v_lines := array_append(v_lines, '');
|
|
251
|
+
v_lines := array_append(v_lines, 'Indexes:');
|
|
252
|
+
for v_rec in
|
|
253
|
+
select
|
|
254
|
+
i.relname as index_name,
|
|
255
|
+
pg_get_indexdef(i.oid) as index_def,
|
|
256
|
+
ix.indisprimary,
|
|
257
|
+
ix.indisunique
|
|
258
|
+
from pg_index ix
|
|
259
|
+
join pg_class i on i.oid = ix.indexrelid
|
|
260
|
+
where ix.indrelid = v_oid
|
|
261
|
+
order by ix.indisprimary desc, ix.indisunique desc, i.relname
|
|
262
|
+
loop
|
|
263
|
+
v_line := ' ';
|
|
264
|
+
if v_rec.indisprimary then
|
|
265
|
+
v_line := v_line || 'PRIMARY KEY: ';
|
|
266
|
+
elsif v_rec.indisunique then
|
|
267
|
+
v_line := v_line || 'UNIQUE: ';
|
|
268
|
+
else
|
|
269
|
+
v_line := v_line || 'INDEX: ';
|
|
270
|
+
end if;
|
|
271
|
+
-- Extract just the column part from index definition
|
|
272
|
+
v_line := v_line || v_rec.index_name || ' ' ||
|
|
273
|
+
regexp_replace(v_rec.index_def, '^CREATE.*INDEX.*ON.*USING\s+\w+\s*', '');
|
|
274
|
+
v_lines := array_append(v_lines, v_line);
|
|
275
|
+
end loop;
|
|
276
|
+
|
|
277
|
+
if not exists (select 1 from pg_index where indrelid = v_oid) then
|
|
278
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
279
|
+
end if;
|
|
280
|
+
end if;
|
|
281
|
+
|
|
282
|
+
-- Constraints (only tables can have constraints)
|
|
283
|
+
if v_relkind in ('r', 'p', 'f') then
|
|
284
|
+
v_lines := array_append(v_lines, '');
|
|
285
|
+
v_lines := array_append(v_lines, 'Constraints:');
|
|
286
|
+
v_constraint_count := 0;
|
|
287
|
+
|
|
288
|
+
for v_rec in
|
|
289
|
+
select
|
|
290
|
+
conname,
|
|
291
|
+
contype,
|
|
292
|
+
pg_get_constraintdef(oid, true) as condef
|
|
293
|
+
from pg_constraint
|
|
294
|
+
where conrelid = v_oid
|
|
295
|
+
and contype != 'p' -- skip primary key (shown with indexes)
|
|
296
|
+
order by
|
|
297
|
+
case contype when 'f' then 1 when 'u' then 2 when 'c' then 3 else 4 end,
|
|
298
|
+
conname
|
|
299
|
+
loop
|
|
300
|
+
v_constraint_count := v_constraint_count + 1;
|
|
301
|
+
v_line := ' ';
|
|
302
|
+
case v_rec.contype
|
|
303
|
+
when 'f' then v_line := v_line || 'FK: ';
|
|
304
|
+
when 'u' then v_line := v_line || 'UNIQUE: ';
|
|
305
|
+
when 'c' then v_line := v_line || 'CHECK: ';
|
|
306
|
+
else v_line := v_line || v_rec.contype || ': ';
|
|
307
|
+
end case;
|
|
308
|
+
v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
|
|
309
|
+
v_lines := array_append(v_lines, v_line);
|
|
310
|
+
end loop;
|
|
311
|
+
|
|
312
|
+
if v_constraint_count = 0 then
|
|
313
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
314
|
+
end if;
|
|
315
|
+
|
|
316
|
+
-- Foreign keys referencing this table
|
|
317
|
+
v_lines := array_append(v_lines, '');
|
|
318
|
+
v_lines := array_append(v_lines, 'Referenced by:');
|
|
319
|
+
v_constraint_count := 0;
|
|
320
|
+
|
|
321
|
+
for v_rec in
|
|
322
|
+
select
|
|
323
|
+
conname,
|
|
324
|
+
conrelid::regclass::text as from_table,
|
|
325
|
+
pg_get_constraintdef(oid, true) as condef
|
|
326
|
+
from pg_constraint
|
|
327
|
+
where confrelid = v_oid
|
|
328
|
+
and contype = 'f'
|
|
329
|
+
order by conrelid::regclass::text, conname
|
|
330
|
+
loop
|
|
331
|
+
v_constraint_count := v_constraint_count + 1;
|
|
332
|
+
v_lines := array_append(v_lines, format(' %s.%s %s',
|
|
333
|
+
v_rec.from_table, v_rec.conname, v_rec.condef));
|
|
334
|
+
end loop;
|
|
335
|
+
|
|
336
|
+
if v_constraint_count = 0 then
|
|
337
|
+
v_lines := array_append(v_lines, ' (none)');
|
|
338
|
+
end if;
|
|
339
|
+
end if;
|
|
340
|
+
|
|
341
|
+
-- Partition info (if partitioned table or partition)
|
|
342
|
+
if v_relkind = 'p' then
|
|
343
|
+
-- This is a partitioned table - show partition key and partitions
|
|
344
|
+
v_lines := array_append(v_lines, '');
|
|
345
|
+
v_lines := array_append(v_lines, 'Partitioning:');
|
|
346
|
+
|
|
347
|
+
select format(' %s BY %s',
|
|
348
|
+
case partstrat
|
|
349
|
+
when 'r' then 'RANGE'
|
|
350
|
+
when 'l' then 'LIST'
|
|
351
|
+
when 'h' then 'HASH'
|
|
352
|
+
else partstrat
|
|
353
|
+
end,
|
|
354
|
+
pg_get_partkeydef(v_oid)
|
|
355
|
+
)
|
|
356
|
+
into v_line
|
|
357
|
+
from pg_partitioned_table
|
|
358
|
+
where partrelid = v_oid;
|
|
359
|
+
|
|
360
|
+
if v_line is not null then
|
|
361
|
+
v_lines := array_append(v_lines, v_line);
|
|
362
|
+
end if;
|
|
363
|
+
|
|
364
|
+
-- List partitions
|
|
365
|
+
v_constraint_count := 0;
|
|
366
|
+
for v_rec in
|
|
367
|
+
select
|
|
368
|
+
c.oid::regclass::text as partition_name,
|
|
369
|
+
pg_get_expr(c.relpartbound, c.oid) as partition_bound,
|
|
370
|
+
c.relpages,
|
|
371
|
+
c.reltuples
|
|
372
|
+
from pg_inherits i
|
|
373
|
+
join pg_class c on c.oid = i.inhrelid
|
|
374
|
+
where i.inhparent = v_oid
|
|
375
|
+
order by c.oid::regclass::text
|
|
376
|
+
loop
|
|
377
|
+
v_constraint_count := v_constraint_count + 1;
|
|
378
|
+
v_lines := array_append(v_lines, format(' %s: %s (relpages: %s, reltuples: %s)',
|
|
379
|
+
v_rec.partition_name, v_rec.partition_bound,
|
|
380
|
+
v_rec.relpages,
|
|
381
|
+
case when v_rec.reltuples < 0 then '-1' else v_rec.reltuples::bigint::text end
|
|
382
|
+
));
|
|
383
|
+
end loop;
|
|
384
|
+
|
|
385
|
+
v_lines := array_append(v_lines, format(' Total partitions: %s', v_constraint_count));
|
|
386
|
+
|
|
387
|
+
elsif exists (select 1 from pg_inherits where inhrelid = v_oid) then
|
|
388
|
+
-- This is a partition - show parent and bound
|
|
389
|
+
v_lines := array_append(v_lines, '');
|
|
390
|
+
v_lines := array_append(v_lines, 'Partition of:');
|
|
391
|
+
|
|
392
|
+
select format(' %s FOR VALUES %s',
|
|
393
|
+
i.inhparent::regclass::text,
|
|
394
|
+
pg_get_expr(c.relpartbound, c.oid)
|
|
395
|
+
)
|
|
396
|
+
into v_line
|
|
397
|
+
from pg_inherits i
|
|
398
|
+
join pg_class c on c.oid = i.inhrelid
|
|
399
|
+
where i.inhrelid = v_oid;
|
|
400
|
+
|
|
401
|
+
if v_line is not null then
|
|
402
|
+
v_lines := array_append(v_lines, v_line);
|
|
403
|
+
end if;
|
|
404
|
+
end if;
|
|
405
|
+
|
|
406
|
+
result := array_to_string(v_lines, e'\n');
|
|
407
|
+
end;
|
|
408
|
+
$$;
|
|
409
|
+
|
|
410
|
+
comment on function postgres_ai.table_describe(text) is
|
|
411
|
+
'Returns comprehensive table information in compact text format for LLM analysis';
|
|
412
|
+
|
|
413
|
+
grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
|
|
414
|
+
|
|
415
|
+
|