postgresai 0.17.0-dev.1 → 0.17.0-dev.11

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.
@@ -1,6 +1,7 @@
1
1
  -- Role creation / password update (template-filled by cli/lib/init.ts)
2
2
  --
3
- -- Always uses a race-safe pattern (create if missing, then always alter to set the password):
3
+ -- A race-safe pattern: create if missing, then alter to set the password.
4
+ -- `pgai connect` omits the alter, so an existing role keeps its password:
4
5
  -- do $$ begin
5
6
  -- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
6
7
  -- begin
@@ -3,6 +3,6 @@
3
3
  -- Enable pg_stat_statements for query performance monitoring
4
4
  -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
5
  -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
6
+ create extension if not exists pg_stat_statements with schema public;
7
7
 
8
8
 
@@ -1,6 +1,7 @@
1
1
  -- Optional permissions for RDS Postgres / Aurora (best effort)
2
2
 
3
- create extension if not exists rds_tools;
3
+ create schema if not exists rds_tools;
4
+ create extension if not exists rds_tools with schema rds_tools;
4
5
  grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
6
 
6
7
 
@@ -1,6 +1,4 @@
1
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
2
 
5
3
  /*
6
4
  * table_describe
@@ -15,18 +13,25 @@
15
13
  *
16
14
  * Usage:
17
15
  * select postgres_ai.table_describe('public.users');
18
- * select postgres_ai.table_describe('my_table'); -- uses search_path
16
+ * select postgres_ai.table_describe('my_table'); -- pg_catalog, then public
17
+ *
18
+ * SECURITY INVOKER: it reads only the world-readable catalog, so it needs no
19
+ * privilege the caller lacks. Names are resolved by catalog query, not regclass
20
+ * (which demands USAGE on the schema): unqualified in pg_catalog, then public.
21
+ * search_path is pg_catalog, pg_temp so objects in a writable schema cannot
22
+ * shadow a built-in the function calls.
19
23
  */
20
24
  create or replace function postgres_ai.table_describe(
21
25
  in table_name text,
22
26
  out result text
23
27
  )
24
28
  language plpgsql
25
- security definer
26
- set search_path = pg_catalog, public
29
+ security invoker
30
+ set search_path = pg_catalog, pg_temp
27
31
  as $$
28
32
  declare
29
33
  v_oid oid;
34
+ v_ident text[];
30
35
  v_schema text;
31
36
  v_table text;
32
37
  v_relkind char;
@@ -37,8 +42,24 @@ declare
37
42
  v_rec record;
38
43
  v_constraint_count int := 0;
39
44
  begin
40
- -- Resolve table name to OID (handles schema-qualified and search_path)
41
- v_oid := table_name::regclass::oid;
45
+ -- Resolve table name to OID: [current database.]schema.table (pg_temp is the
46
+ -- caller's temp schema), or an unqualified name in pg_catalog, then public
47
+ v_ident := parse_ident(table_name);
48
+ select c.oid into v_oid
49
+ from pg_class c
50
+ join pg_namespace n on n.oid = c.relnamespace
51
+ where (cardinality(v_ident) <= 2 or (cardinality(v_ident) = 3 and v_ident[1] = current_database()))
52
+ and c.relname = v_ident[cardinality(v_ident)]::name
53
+ and case
54
+ when cardinality(v_ident) = 1 then n.nspname in ('pg_catalog', 'public')
55
+ when v_ident[cardinality(v_ident) - 1] = 'pg_temp' then n.oid = pg_my_temp_schema()
56
+ else n.nspname = v_ident[cardinality(v_ident) - 1]::name
57
+ end
58
+ order by n.nspname = 'public'
59
+ limit 1;
60
+ if v_oid is null then
61
+ raise exception 'relation "%" does not exist', table_name using errcode = '42P01';
62
+ end if;
42
63
 
43
64
  -- Get basic table info
44
65
  select
@@ -207,7 +228,7 @@ begin
207
228
  when 'f' then v_line := v_line || 'FK: ';
208
229
  when 'u' then v_line := v_line || 'UNIQUE: ';
209
230
  when 'c' then v_line := v_line || 'CHECK: ';
210
- else v_line := v_line || v_rec.contype || ': ';
231
+ else v_line := v_line || v_rec.contype::text || ': ';
211
232
  end case;
212
233
  v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
213
234
  v_lines := array_append(v_lines, v_line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.17.0-dev.1",
3
+ "version": "0.17.0-dev.11",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -36,7 +36,7 @@
36
36
  "embed-checkup-dictionary": "bun run scripts/embed-checkup-dictionary.ts",
37
37
  "embed-all": "bun run embed-metrics && bun run embed-checkup-dictionary",
38
38
  "sync-schemas": "node scripts/sync-schemas.mjs",
39
- "build": "bun run sync-schemas && bun run embed-all && 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/sql && cp ../instances.demo.yml ./instances.demo.yml",
39
+ "build": "bun run sync-schemas && bun run embed-all && 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'))\" && rm -rf ./dist/sql && cp -r ./sql ./dist/sql && cp ../instances.demo.yml ./instances.demo.yml",
40
40
  "prepack": "npm run sync-schemas",
41
41
  "prepublishOnly": "npm run build",
42
42
  "start": "bun ./bin/postgres-ai.ts --help",
package/sql/01.role.sql CHANGED
@@ -1,6 +1,7 @@
1
1
  -- Role creation / password update (template-filled by cli/lib/init.ts)
2
2
  --
3
- -- Always uses a race-safe pattern (create if missing, then always alter to set the password):
3
+ -- A race-safe pattern: create if missing, then alter to set the password.
4
+ -- `pgai connect` omits the alter, so an existing role keeps its password:
4
5
  -- do $$ begin
5
6
  -- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
6
7
  -- begin
@@ -3,6 +3,6 @@
3
3
  -- Enable pg_stat_statements for query performance monitoring
4
4
  -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
5
  -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
6
+ create extension if not exists pg_stat_statements with schema public;
7
7
 
8
8
 
@@ -1,6 +1,7 @@
1
1
  -- Optional permissions for RDS Postgres / Aurora (best effort)
2
2
 
3
- create extension if not exists rds_tools;
3
+ create schema if not exists rds_tools;
4
+ create extension if not exists rds_tools with schema rds_tools;
4
5
  grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
6
 
6
7
 
@@ -1,6 +1,4 @@
1
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
2
 
5
3
  /*
6
4
  * table_describe
@@ -15,18 +13,25 @@
15
13
  *
16
14
  * Usage:
17
15
  * select postgres_ai.table_describe('public.users');
18
- * select postgres_ai.table_describe('my_table'); -- uses search_path
16
+ * select postgres_ai.table_describe('my_table'); -- pg_catalog, then public
17
+ *
18
+ * SECURITY INVOKER: it reads only the world-readable catalog, so it needs no
19
+ * privilege the caller lacks. Names are resolved by catalog query, not regclass
20
+ * (which demands USAGE on the schema): unqualified in pg_catalog, then public.
21
+ * search_path is pg_catalog, pg_temp so objects in a writable schema cannot
22
+ * shadow a built-in the function calls.
19
23
  */
20
24
  create or replace function postgres_ai.table_describe(
21
25
  in table_name text,
22
26
  out result text
23
27
  )
24
28
  language plpgsql
25
- security definer
26
- set search_path = pg_catalog, public
29
+ security invoker
30
+ set search_path = pg_catalog, pg_temp
27
31
  as $$
28
32
  declare
29
33
  v_oid oid;
34
+ v_ident text[];
30
35
  v_schema text;
31
36
  v_table text;
32
37
  v_relkind char;
@@ -37,8 +42,24 @@ declare
37
42
  v_rec record;
38
43
  v_constraint_count int := 0;
39
44
  begin
40
- -- Resolve table name to OID (handles schema-qualified and search_path)
41
- v_oid := table_name::regclass::oid;
45
+ -- Resolve table name to OID: [current database.]schema.table (pg_temp is the
46
+ -- caller's temp schema), or an unqualified name in pg_catalog, then public
47
+ v_ident := parse_ident(table_name);
48
+ select c.oid into v_oid
49
+ from pg_class c
50
+ join pg_namespace n on n.oid = c.relnamespace
51
+ where (cardinality(v_ident) <= 2 or (cardinality(v_ident) = 3 and v_ident[1] = current_database()))
52
+ and c.relname = v_ident[cardinality(v_ident)]::name
53
+ and case
54
+ when cardinality(v_ident) = 1 then n.nspname in ('pg_catalog', 'public')
55
+ when v_ident[cardinality(v_ident) - 1] = 'pg_temp' then n.oid = pg_my_temp_schema()
56
+ else n.nspname = v_ident[cardinality(v_ident) - 1]::name
57
+ end
58
+ order by n.nspname = 'public'
59
+ limit 1;
60
+ if v_oid is null then
61
+ raise exception 'relation "%" does not exist', table_name using errcode = '42P01';
62
+ end if;
42
63
 
43
64
  -- Get basic table info
44
65
  select
@@ -207,7 +228,7 @@ begin
207
228
  when 'f' then v_line := v_line || 'FK: ';
208
229
  when 'u' then v_line := v_line || 'UNIQUE: ';
209
230
  when 'c' then v_line := v_line || 'CHECK: ';
210
- else v_line := v_line || v_rec.contype || ': ';
231
+ else v_line := v_line || v_rec.contype::text || ': ';
211
232
  end case;
212
233
  v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
213
234
  v_lines := array_append(v_lines, v_line);
@@ -1,16 +0,0 @@
1
- -- Role creation / password update (template-filled by cli/lib/init.ts)
2
- --
3
- -- Always uses a race-safe pattern (create if missing, then always alter to set the password):
4
- -- do $$ begin
5
- -- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
6
- -- begin
7
- -- create user "..." with password '...';
8
- -- exception when duplicate_object then
9
- -- null;
10
- -- end;
11
- -- end if;
12
- -- alter user "..." with password '...';
13
- -- end $$;
14
- {{ROLE_STMT}}
15
-
16
-
@@ -1,8 +0,0 @@
1
- -- Extensions required for postgres_ai monitoring
2
-
3
- -- Enable pg_stat_statements for query performance monitoring
4
- -- Note: Uses IF NOT EXISTS because extension may already be installed.
5
- -- We do NOT drop this extension in unprepare-db since it may have been pre-existing.
6
- create extension if not exists pg_stat_statements;
7
-
8
-
@@ -1,86 +0,0 @@
1
- -- Required permissions for postgres_ai monitoring user (template-filled by cli/lib/init.ts)
2
-
3
- -- Allow connect
4
- grant connect on database {{DB_IDENT}} to {{ROLE_IDENT}};
5
-
6
- -- Standard monitoring privileges
7
- grant pg_monitor to {{ROLE_IDENT}};
8
- grant select on pg_catalog.pg_index to {{ROLE_IDENT}};
9
-
10
- -- Required for cluster-wide pg_stat_statements collection.
11
- -- pg_stat_statements is backed by shared memory and holds entries for every database
12
- -- in the cluster, but a role without pg_read_all_stats sees other users' rows with
13
- -- queryid and query text redacted to NULL -- which collapses the per-queryid grouping
14
- -- in the pg_stat_statements metric. pg_monitor already implies pg_read_all_stats;
15
- -- granting it explicitly documents the dependency and keeps the metric working if the
16
- -- pg_monitor grant above is ever narrowed. Idempotent.
17
- grant pg_read_all_stats to {{ROLE_IDENT}};
18
-
19
- -- Create postgres_ai schema for our objects
20
- -- Using IF NOT EXISTS for idempotency - prepare-db can be run multiple times
21
- create schema if not exists postgres_ai;
22
- grant usage on schema postgres_ai to {{ROLE_IDENT}};
23
-
24
- -- For bloat analysis: expose pg_statistic via a view
25
- create or replace view postgres_ai.pg_statistic as
26
- select
27
- n.nspname as schemaname,
28
- c.relname as tablename,
29
- a.attname,
30
- s.stanullfrac as null_frac,
31
- s.stawidth as avg_width,
32
- false as inherited
33
- from pg_catalog.pg_statistic s
34
- join pg_catalog.pg_class c on c.oid = s.starelid
35
- join pg_catalog.pg_namespace n on n.oid = c.relnamespace
36
- join pg_catalog.pg_attribute a on a.attrelid = s.starelid and a.attnum = s.staattnum
37
- where a.attnum > 0 and not a.attisdropped;
38
-
39
- grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
40
-
41
- -- Hardened clusters sometimes revoke PUBLIC on schema public
42
- grant usage on schema public to {{ROLE_IDENT}};
43
-
44
- -- Grant access to the schema where pg_stat_statements is installed.
45
- -- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
46
- -- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
47
- do $$
48
- declare
49
- ext_schema text;
50
- begin
51
- select n.nspname into ext_schema
52
- from pg_extension e
53
- join pg_namespace n on e.extnamespace = n.oid
54
- where e.extname = 'pg_stat_statements';
55
-
56
- -- Only grant if extension exists and is in a non-standard schema
57
- if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
58
- execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
59
- end if;
60
- end $$;
61
-
62
- -- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
63
- -- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
64
- do $$
65
- declare
66
- ext_schema text;
67
- sp text;
68
- begin
69
- -- Detect pg_stat_statements extension schema
70
- select n.nspname into ext_schema
71
- from pg_extension e
72
- join pg_namespace n on e.extnamespace = n.oid
73
- where e.extname = 'pg_stat_statements';
74
-
75
- -- Build search_path: include extension schema if in non-standard location
76
- if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
77
- sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
78
- else
79
- sp := 'postgres_ai, "$user", public, pg_catalog';
80
- end if;
81
-
82
- execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
83
- end $$;
84
- -- [SEARCH_PATH_BLOCK_END]
85
-
86
-
@@ -1,6 +0,0 @@
1
- -- Optional permissions for RDS Postgres / Aurora (best effort)
2
-
3
- create extension if not exists rds_tools;
4
- grant execute on function rds_tools.pg_ls_multixactdir() to {{ROLE_IDENT}};
5
-
6
-
@@ -1,8 +0,0 @@
1
- -- Optional permissions for self-managed Postgres (best effort)
2
-
3
- grant execute on function pg_catalog.pg_stat_file(text) to {{ROLE_IDENT}};
4
- grant execute on function pg_catalog.pg_stat_file(text, boolean) to {{ROLE_IDENT}};
5
- grant execute on function pg_catalog.pg_ls_dir(text) to {{ROLE_IDENT}};
6
- grant execute on function pg_catalog.pg_ls_dir(text, boolean, boolean) to {{ROLE_IDENT}};
7
-
8
-
@@ -1,317 +0,0 @@
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
- * table_describe
7
- *
8
- * Collects comprehensive information about a table for LLM analysis.
9
- * Returns a compact text format with:
10
- * - Table metadata (type, size estimates)
11
- * - Columns (name, type, nullable, default)
12
- * - Indexes
13
- * - Constraints (PK, FK, unique, check)
14
- * - Maintenance stats (vacuum/analyze times)
15
- *
16
- * Usage:
17
- * select postgres_ai.table_describe('public.users');
18
- * select postgres_ai.table_describe('my_table'); -- uses search_path
19
- */
20
- create or replace function postgres_ai.table_describe(
21
- in table_name text,
22
- out result text
23
- )
24
- language plpgsql
25
- security definer
26
- set search_path = pg_catalog, public
27
- as $$
28
- declare
29
- v_oid oid;
30
- v_schema text;
31
- v_table text;
32
- v_relkind char;
33
- v_relpages int;
34
- v_reltuples float;
35
- v_lines text[] := '{}';
36
- v_line text;
37
- v_rec record;
38
- v_constraint_count int := 0;
39
- begin
40
- -- Resolve table name to OID (handles schema-qualified and search_path)
41
- v_oid := table_name::regclass::oid;
42
-
43
- -- Get basic table info
44
- select
45
- n.nspname,
46
- c.relname,
47
- c.relkind,
48
- c.relpages,
49
- c.reltuples
50
- into v_schema, v_table, v_relkind, v_relpages, v_reltuples
51
- from pg_class c
52
- join pg_namespace n on n.oid = c.relnamespace
53
- where c.oid = v_oid;
54
-
55
- -- Validate object type - only tables, views, and materialized views are supported
56
- if v_relkind not in ('r', 'p', 'v', 'm', 'f') then
57
- raise exception 'table_describe does not support % (relkind=%)',
58
- case v_relkind
59
- when 'i' then 'indexes'
60
- when 'I' then 'partitioned indexes'
61
- when 'S' then 'sequences'
62
- when 't' then 'TOAST tables'
63
- when 'c' then 'composite types'
64
- else format('objects of type "%s"', v_relkind)
65
- end,
66
- v_relkind;
67
- end if;
68
-
69
- -- Header
70
- v_lines := array_append(v_lines, format('Table: %I.%I', v_schema, v_table));
71
- v_lines := array_append(v_lines, format('Type: %s | relpages: %s | reltuples: %s',
72
- case v_relkind
73
- when 'r' then 'table'
74
- when 'p' then 'partitioned table'
75
- when 'v' then 'view'
76
- when 'm' then 'materialized view'
77
- when 'f' then 'foreign table'
78
- end,
79
- v_relpages,
80
- case when v_reltuples < 0 then '-1' else v_reltuples::bigint::text end
81
- ));
82
-
83
- -- Vacuum/analyze stats (only for tables and materialized views, not views)
84
- if v_relkind in ('r', 'p', 'm', 'f') then
85
- select
86
- format('Vacuum: %s (auto: %s) | Analyze: %s (auto: %s)',
87
- coalesce(to_char(last_vacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
88
- coalesce(to_char(last_autovacuum at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
89
- coalesce(to_char(last_analyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never'),
90
- coalesce(to_char(last_autoanalyze at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS UTC'), 'never')
91
- )
92
- into v_line
93
- from pg_stat_all_tables
94
- where relid = v_oid;
95
-
96
- if v_line is not null then
97
- v_lines := array_append(v_lines, v_line);
98
- end if;
99
- end if;
100
-
101
- v_lines := array_append(v_lines, '');
102
-
103
- -- Columns
104
- v_lines := array_append(v_lines, 'Columns:');
105
- for v_rec in
106
- select
107
- a.attname,
108
- format_type(a.atttypid, a.atttypmod) as data_type,
109
- a.attnotnull,
110
- (select pg_get_expr(d.adbin, d.adrelid, true)
111
- from pg_attrdef d
112
- where d.adrelid = a.attrelid and d.adnum = a.attnum and a.atthasdef) as default_val,
113
- a.attidentity,
114
- a.attgenerated
115
- from pg_attribute a
116
- where a.attrelid = v_oid
117
- and a.attnum > 0
118
- and not a.attisdropped
119
- order by a.attnum
120
- loop
121
- v_line := format(' %s %s', v_rec.attname, v_rec.data_type);
122
-
123
- if v_rec.attnotnull then
124
- v_line := v_line || ' NOT NULL';
125
- end if;
126
-
127
- if v_rec.attidentity = 'a' then
128
- v_line := v_line || ' GENERATED ALWAYS AS IDENTITY';
129
- elsif v_rec.attidentity = 'd' then
130
- v_line := v_line || ' GENERATED BY DEFAULT AS IDENTITY';
131
- elsif v_rec.attgenerated = 's' then
132
- v_line := v_line || format(' GENERATED ALWAYS AS (%s) STORED', v_rec.default_val);
133
- elsif v_rec.default_val is not null then
134
- v_line := v_line || format(' DEFAULT %s', v_rec.default_val);
135
- end if;
136
-
137
- v_lines := array_append(v_lines, v_line);
138
- end loop;
139
-
140
- -- View definition (for views and materialized views)
141
- if v_relkind in ('v', 'm') then
142
- v_lines := array_append(v_lines, '');
143
- v_lines := array_append(v_lines, 'Definition:');
144
- v_line := pg_get_viewdef(v_oid, true);
145
- if v_line is not null then
146
- -- Indent the view definition
147
- v_line := ' ' || replace(v_line, e'\n', e'\n ');
148
- v_lines := array_append(v_lines, v_line);
149
- end if;
150
- end if;
151
-
152
- -- Indexes (tables, partitioned tables, and materialized views can have indexes)
153
- if v_relkind in ('r', 'p', 'm') then
154
- v_lines := array_append(v_lines, '');
155
- v_lines := array_append(v_lines, 'Indexes:');
156
- for v_rec in
157
- select
158
- i.relname as index_name,
159
- pg_get_indexdef(i.oid) as index_def,
160
- ix.indisprimary,
161
- ix.indisunique
162
- from pg_index ix
163
- join pg_class i on i.oid = ix.indexrelid
164
- where ix.indrelid = v_oid
165
- order by ix.indisprimary desc, ix.indisunique desc, i.relname
166
- loop
167
- v_line := ' ';
168
- if v_rec.indisprimary then
169
- v_line := v_line || 'PRIMARY KEY: ';
170
- elsif v_rec.indisunique then
171
- v_line := v_line || 'UNIQUE: ';
172
- else
173
- v_line := v_line || 'INDEX: ';
174
- end if;
175
- -- Extract just the column part from index definition
176
- v_line := v_line || v_rec.index_name || ' ' ||
177
- regexp_replace(v_rec.index_def, '^CREATE.*INDEX.*ON.*USING\s+\w+\s*', '');
178
- v_lines := array_append(v_lines, v_line);
179
- end loop;
180
-
181
- if not exists (select 1 from pg_index where indrelid = v_oid) then
182
- v_lines := array_append(v_lines, ' (none)');
183
- end if;
184
- end if;
185
-
186
- -- Constraints (only tables can have constraints)
187
- if v_relkind in ('r', 'p', 'f') then
188
- v_lines := array_append(v_lines, '');
189
- v_lines := array_append(v_lines, 'Constraints:');
190
- v_constraint_count := 0;
191
-
192
- for v_rec in
193
- select
194
- conname,
195
- contype,
196
- pg_get_constraintdef(oid, true) as condef
197
- from pg_constraint
198
- where conrelid = v_oid
199
- and contype != 'p' -- skip primary key (shown with indexes)
200
- order by
201
- case contype when 'f' then 1 when 'u' then 2 when 'c' then 3 else 4 end,
202
- conname
203
- loop
204
- v_constraint_count := v_constraint_count + 1;
205
- v_line := ' ';
206
- case v_rec.contype
207
- when 'f' then v_line := v_line || 'FK: ';
208
- when 'u' then v_line := v_line || 'UNIQUE: ';
209
- when 'c' then v_line := v_line || 'CHECK: ';
210
- else v_line := v_line || v_rec.contype || ': ';
211
- end case;
212
- v_line := v_line || v_rec.conname || ' ' || v_rec.condef;
213
- v_lines := array_append(v_lines, v_line);
214
- end loop;
215
-
216
- if v_constraint_count = 0 then
217
- v_lines := array_append(v_lines, ' (none)');
218
- end if;
219
-
220
- -- Foreign keys referencing this table
221
- v_lines := array_append(v_lines, '');
222
- v_lines := array_append(v_lines, 'Referenced by:');
223
- v_constraint_count := 0;
224
-
225
- for v_rec in
226
- select
227
- conname,
228
- conrelid::regclass::text as from_table,
229
- pg_get_constraintdef(oid, true) as condef
230
- from pg_constraint
231
- where confrelid = v_oid
232
- and contype = 'f'
233
- order by conrelid::regclass::text, conname
234
- loop
235
- v_constraint_count := v_constraint_count + 1;
236
- v_lines := array_append(v_lines, format(' %s.%s %s',
237
- v_rec.from_table, v_rec.conname, v_rec.condef));
238
- end loop;
239
-
240
- if v_constraint_count = 0 then
241
- v_lines := array_append(v_lines, ' (none)');
242
- end if;
243
- end if;
244
-
245
- -- Partition info (if partitioned table or partition)
246
- if v_relkind = 'p' then
247
- -- This is a partitioned table - show partition key and partitions
248
- v_lines := array_append(v_lines, '');
249
- v_lines := array_append(v_lines, 'Partitioning:');
250
-
251
- select format(' %s BY %s',
252
- case partstrat
253
- when 'r' then 'RANGE'
254
- when 'l' then 'LIST'
255
- when 'h' then 'HASH'
256
- else partstrat
257
- end,
258
- pg_get_partkeydef(v_oid)
259
- )
260
- into v_line
261
- from pg_partitioned_table
262
- where partrelid = v_oid;
263
-
264
- if v_line is not null then
265
- v_lines := array_append(v_lines, v_line);
266
- end if;
267
-
268
- -- List partitions
269
- v_constraint_count := 0;
270
- for v_rec in
271
- select
272
- c.oid::regclass::text as partition_name,
273
- pg_get_expr(c.relpartbound, c.oid) as partition_bound,
274
- c.relpages,
275
- c.reltuples
276
- from pg_inherits i
277
- join pg_class c on c.oid = i.inhrelid
278
- where i.inhparent = v_oid
279
- order by c.oid::regclass::text
280
- loop
281
- v_constraint_count := v_constraint_count + 1;
282
- v_lines := array_append(v_lines, format(' %s: %s (relpages: %s, reltuples: %s)',
283
- v_rec.partition_name, v_rec.partition_bound,
284
- v_rec.relpages,
285
- case when v_rec.reltuples < 0 then '-1' else v_rec.reltuples::bigint::text end
286
- ));
287
- end loop;
288
-
289
- v_lines := array_append(v_lines, format(' Total partitions: %s', v_constraint_count));
290
-
291
- elsif exists (select 1 from pg_inherits where inhrelid = v_oid) then
292
- -- This is a partition - show parent and bound
293
- v_lines := array_append(v_lines, '');
294
- v_lines := array_append(v_lines, 'Partition of:');
295
-
296
- select format(' %s FOR VALUES %s',
297
- i.inhparent::regclass::text,
298
- pg_get_expr(c.relpartbound, c.oid)
299
- )
300
- into v_line
301
- from pg_inherits i
302
- join pg_class c on c.oid = i.inhrelid
303
- where i.inhrelid = v_oid;
304
-
305
- if v_line is not null then
306
- v_lines := array_append(v_lines, v_line);
307
- end if;
308
- end if;
309
-
310
- result := array_to_string(v_lines, e'\n');
311
- end;
312
- $$;
313
-
314
- comment on function postgres_ai.table_describe(text) is
315
- 'Returns comprehensive table information in compact text format for LLM analysis';
316
-
317
- grant execute on function postgres_ai.table_describe(text) to {{ROLE_IDENT}};
@@ -1,5 +0,0 @@
1
- -- Drop helper functions created by prepare-db (template-filled by cli/lib/init.ts)
2
- -- Run before dropping the postgres_ai schema.
3
-
4
- drop function if exists postgres_ai.explain_generic(text, text, text);
5
- drop function if exists postgres_ai.table_describe(text);