pythia-postgres 0.1.0__py3-none-any.whl
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.
- pythia_postgres/__init__.py +5 -0
- pythia_postgres/__main__.py +5 -0
- pythia_postgres/catalog.py +59 -0
- pythia_postgres/cli.py +8 -0
- pythia_postgres/engine.py +213 -0
- pythia_postgres/guard.py +54 -0
- pythia_postgres/queries/code-objects.sql +28 -0
- pythia_postgres/queries/columns.sql +15 -0
- pythia_postgres/queries/comment-current.sql +17 -0
- pythia_postgres/queries/current-schema.sql +5 -0
- pythia_postgres/queries/dependencies-catalog.sql +66 -0
- pythia_postgres/queries/dependents-catalog.sql +66 -0
- pythia_postgres/queries/dependents-text.sql +22 -0
- pythia_postgres/queries/function-body.sql +8 -0
- pythia_postgres/queries/grep.sql +30 -0
- pythia_postgres/queries/name-candidates.sql +32 -0
- pythia_postgres/queries/object-names.sql +56 -0
- pythia_postgres/queries/plpgsql-check-installed.sql +7 -0
- pythia_postgres/queries/plpgsql-check.sql +27 -0
- pythia_postgres/queries/plpgsql-unchecked.sql +16 -0
- pythia_postgres/queries/rehearsal-limits.sql +7 -0
- pythia_postgres/queries/resolve.sql +34 -0
- pythia_postgres/queries/row-set-count.sql +11 -0
- pythia_postgres/queries/row-set-sample.sql +8 -0
- pythia_postgres/queries/search-path-catalog.sql +7 -0
- pythia_postgres/queries/search-path-first.sql +13 -0
- pythia_postgres/queries/sequence-ddl.sql +16 -0
- pythia_postgres/queries/server-version.sql +6 -0
- pythia_postgres/queries/session-power.sql +13 -0
- pythia_postgres/queries/source.sql +28 -0
- pythia_postgres/queries/table-columns.sql +16 -0
- pythia_postgres/queries/table-constraints.sql +13 -0
- pythia_postgres/queries/table-extras.sql +43 -0
- pythia_postgres/queries/table-head.sql +18 -0
- pythia_postgres/read.py +299 -0
- pythia_postgres/rules.py +135 -0
- pythia_postgres/skills/pythia-pg-apply/SKILL.md +97 -0
- pythia_postgres/skills/pythia-pg-apply/reference/refusals.md +26 -0
- pythia_postgres/skills/pythia-pg-autopilot/SKILL.md +63 -0
- pythia_postgres/skills/pythia-pg-deploy/SKILL.md +73 -0
- pythia_postgres/skills/pythia-pg-explore/SKILL.md +63 -0
- pythia_postgres/skills/pythia-pg-impact/SKILL.md +57 -0
- pythia_postgres/skills/pythia-pg-verify/SKILL.md +56 -0
- pythia_postgres/skills/using-pythia-pg/SKILL.md +65 -0
- pythia_postgres/write.py +583 -0
- pythia_postgres-0.1.0.dist-info/METADATA +360 -0
- pythia_postgres-0.1.0.dist-info/RECORD +51 -0
- pythia_postgres-0.1.0.dist-info/WHEEL +5 -0
- pythia_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- pythia_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- pythia_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- Purpose: objects in a schema whose name matches a LIKE pattern in any case,
|
|
2
|
+
-- for `ls`: relations, functions and procedures, triggers, and
|
|
3
|
+
-- stand-alone types
|
|
4
|
+
-- Binds: %(s)s schema, %(p)s LIKE pattern, %(n)s row cap
|
|
5
|
+
-- Returns: NAME, TYPE, DETAIL (comment, signature, or the trigger's table)
|
|
6
|
+
select name, type, detail
|
|
7
|
+
from (select c.relname as name,
|
|
8
|
+
case c.relkind when 'r' then 'TABLE'
|
|
9
|
+
when 'p' then 'PARTITIONED TABLE'
|
|
10
|
+
when 'v' then 'VIEW'
|
|
11
|
+
when 'm' then 'MATERIALIZED VIEW'
|
|
12
|
+
when 'S' then 'SEQUENCE'
|
|
13
|
+
when 'f' then 'FOREIGN TABLE' end as type,
|
|
14
|
+
coalesce(obj_description(c.oid, 'pg_class'), '') as detail
|
|
15
|
+
from pg_class c
|
|
16
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
17
|
+
where n.nspname = %(s)s
|
|
18
|
+
and c.relkind in ('r', 'p', 'v', 'm', 'S', 'f')
|
|
19
|
+
and c.relname ilike %(p)s
|
|
20
|
+
union all
|
|
21
|
+
select p.proname,
|
|
22
|
+
case p.prokind when 'p' then 'PROCEDURE'
|
|
23
|
+
when 'a' then 'AGGREGATE'
|
|
24
|
+
when 'w' then 'WINDOW FUNCTION'
|
|
25
|
+
else 'FUNCTION' end,
|
|
26
|
+
'(' || pg_get_function_identity_arguments(p.oid) || ')'
|
|
27
|
+
from pg_proc p
|
|
28
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
29
|
+
where n.nspname = %(s)s
|
|
30
|
+
and p.proname ilike %(p)s
|
|
31
|
+
union all
|
|
32
|
+
select t.tgname, 'TRIGGER', 'on ' || c.relname
|
|
33
|
+
from pg_trigger t
|
|
34
|
+
join pg_class c on c.oid = t.tgrelid
|
|
35
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
36
|
+
where n.nspname = %(s)s
|
|
37
|
+
and not t.tgisinternal
|
|
38
|
+
and t.tgname ilike %(p)s
|
|
39
|
+
union all
|
|
40
|
+
select ty.typname,
|
|
41
|
+
case ty.typtype when 'e' then 'ENUM'
|
|
42
|
+
when 'd' then 'DOMAIN'
|
|
43
|
+
when 'r' then 'RANGE'
|
|
44
|
+
else 'TYPE' end,
|
|
45
|
+
''
|
|
46
|
+
from pg_type ty
|
|
47
|
+
join pg_namespace n on n.oid = ty.typnamespace
|
|
48
|
+
where n.nspname = %(s)s
|
|
49
|
+
and ty.typname ilike %(p)s
|
|
50
|
+
and (ty.typtype in ('e', 'd', 'r')
|
|
51
|
+
or (ty.typtype = 'c'
|
|
52
|
+
and exists (select 1 from pg_class rc
|
|
53
|
+
where rc.oid = ty.typrelid
|
|
54
|
+
and rc.relkind = 'c')))) o
|
|
55
|
+
order by type, name
|
|
56
|
+
limit %(n)s
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Purpose: whether the plpgsql_check extension is installed in this
|
|
2
|
+
-- database — without it, PostgreSQL checks a PL/pgSQL body's syntax
|
|
3
|
+
-- at CREATE time and nothing else
|
|
4
|
+
-- Binds: none
|
|
5
|
+
-- Returns: INSTALLED (one row, true or false)
|
|
6
|
+
select exists (select 1 from pg_extension where extname = 'plpgsql_check')
|
|
7
|
+
as installed
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Purpose: what plpgsql_check reports for the PL/pgSQL functions of a schema,
|
|
2
|
+
-- or those holding one name. A trigger function is checked against
|
|
3
|
+
-- the first table whose trigger uses it; one no trigger uses cannot
|
|
4
|
+
-- be checked (plpgsql-unchecked.sql names those).
|
|
5
|
+
-- Binds: %(s)s schema, %(n)s object name or NULL for the whole schema
|
|
6
|
+
-- Returns: NAME, TYPE, LINE, POSITION, SEVERITY, MESSAGE
|
|
7
|
+
select p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')'
|
|
8
|
+
as name,
|
|
9
|
+
case p.prokind when 'p' then 'PROCEDURE' else 'FUNCTION' end as type,
|
|
10
|
+
c.lineno as line,
|
|
11
|
+
c.position as position,
|
|
12
|
+
c.level as severity,
|
|
13
|
+
c.message || coalesce(' (' || c.detail || ')', '') as message
|
|
14
|
+
from pg_proc p
|
|
15
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
16
|
+
join pg_language l on l.oid = p.prolang and l.lanname = 'plpgsql'
|
|
17
|
+
cross join lateral plpgsql_check_function_tb(
|
|
18
|
+
p.oid,
|
|
19
|
+
coalesce((select t.tgrelid from pg_trigger t
|
|
20
|
+
where t.tgfoid = p.oid
|
|
21
|
+
order by t.oid limit 1), 0)) c
|
|
22
|
+
where n.nspname = %(s)s
|
|
23
|
+
and (%(n)s::text is null or p.proname = %(n)s)
|
|
24
|
+
and p.prorettype <> 'event_trigger'::regtype
|
|
25
|
+
and (p.prorettype <> 'trigger'::regtype
|
|
26
|
+
or exists (select 1 from pg_trigger t where t.tgfoid = p.oid))
|
|
27
|
+
order by 1, 3
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Purpose: the PL/pgSQL functions plpgsql-check.sql cannot check — trigger
|
|
2
|
+
-- functions no trigger uses, and event trigger functions — so the
|
|
3
|
+
-- gap is named instead of read as clean
|
|
4
|
+
-- Binds: %(s)s schema, %(n)s object name or NULL for the whole schema
|
|
5
|
+
-- Returns: NAME
|
|
6
|
+
select p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')'
|
|
7
|
+
as name
|
|
8
|
+
from pg_proc p
|
|
9
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
10
|
+
join pg_language l on l.oid = p.prolang and l.lanname = 'plpgsql'
|
|
11
|
+
where n.nspname = %(s)s
|
|
12
|
+
and (%(n)s::text is null or p.proname = %(n)s)
|
|
13
|
+
and (p.prorettype = 'event_trigger'::regtype
|
|
14
|
+
or (p.prorettype = 'trigger'::regtype
|
|
15
|
+
and not exists (select 1 from pg_trigger t where t.tgfoid = p.oid)))
|
|
16
|
+
order by 1
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Purpose: bound a rehearsal, for this transaction only: a preview must not
|
|
2
|
+
-- hold locks or rewrite a large table for long (the confirm run is
|
|
3
|
+
-- the same rehearsal, so the write gets the same bounds)
|
|
4
|
+
-- Binds: %(st)s statement_timeout, %(lt)s lock_timeout (e.g. '30s', '5s')
|
|
5
|
+
-- Returns: STATEMENT_TIMEOUT, LOCK_TIMEOUT
|
|
6
|
+
select set_config('statement_timeout', %(st)s, true) as statement_timeout,
|
|
7
|
+
set_config('lock_timeout', %(lt)s, true) as lock_timeout
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Purpose: every object in a schema holding a name — relations, and each
|
|
2
|
+
-- overload of a function — as the catalog identifies it, so the
|
|
3
|
+
-- dependency walks can start from oids instead of names
|
|
4
|
+
-- Binds: %(s)s schema, %(n)s object name (already folded)
|
|
5
|
+
-- Returns: CLASSID, OBJID, TYPE, LABEL, BARE_NAME
|
|
6
|
+
select 'pg_class'::regclass::oid as classid,
|
|
7
|
+
c.oid as objid,
|
|
8
|
+
case c.relkind when 'r' then 'TABLE'
|
|
9
|
+
when 'p' then 'PARTITIONED TABLE'
|
|
10
|
+
when 'v' then 'VIEW'
|
|
11
|
+
when 'm' then 'MATERIALIZED VIEW'
|
|
12
|
+
when 'S' then 'SEQUENCE'
|
|
13
|
+
when 'f' then 'FOREIGN TABLE' end as type,
|
|
14
|
+
c.relname as label,
|
|
15
|
+
c.relname as bare_name
|
|
16
|
+
from pg_class c
|
|
17
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
18
|
+
where n.nspname = %(s)s
|
|
19
|
+
and c.relname = %(n)s
|
|
20
|
+
and c.relkind in ('r', 'p', 'v', 'm', 'S', 'f')
|
|
21
|
+
union all
|
|
22
|
+
select 'pg_proc'::regclass::oid,
|
|
23
|
+
p.oid,
|
|
24
|
+
case p.prokind when 'p' then 'PROCEDURE'
|
|
25
|
+
when 'a' then 'AGGREGATE'
|
|
26
|
+
when 'w' then 'WINDOW FUNCTION'
|
|
27
|
+
else 'FUNCTION' end,
|
|
28
|
+
p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')',
|
|
29
|
+
p.proname
|
|
30
|
+
from pg_proc p
|
|
31
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
32
|
+
where n.nspname = %(s)s
|
|
33
|
+
and p.proname = %(n)s
|
|
34
|
+
order by 3, 4
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Purpose: how many rows an UPDATE or DELETE will touch, and a fingerprint of
|
|
2
|
+
-- which (their ctids: an update in between gives a row a new one)
|
|
3
|
+
-- Binds: none — the engine puts the statement's own table and predicate
|
|
4
|
+
-- where the TARGET and WHERE markers stand, and runs the text with
|
|
5
|
+
-- no parameters, so a percent sign in the predicate is read as
|
|
6
|
+
-- written
|
|
7
|
+
-- Returns: N, FINGERPRINT
|
|
8
|
+
select count(*) as n,
|
|
9
|
+
md5(coalesce(string_agg(ctid::text, ',' order by ctid), '')) as fingerprint
|
|
10
|
+
from /*TARGET*/
|
|
11
|
+
/*WHERE*/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Purpose: the first rows an UPDATE or DELETE will touch, for the person
|
|
2
|
+
-- approving it to read (one beyond the sample, so a cut is announced)
|
|
3
|
+
-- Binds: none — filled and run like row-set-count.sql
|
|
4
|
+
-- Returns: every column of the target
|
|
5
|
+
select *
|
|
6
|
+
from /*TARGET*/
|
|
7
|
+
/*WHERE*/
|
|
8
|
+
limit 11
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Purpose: leave only pg_catalog on search_path for the rest of this
|
|
2
|
+
-- transaction, as pg_dump does, so every name the catalog prints
|
|
3
|
+
-- back (defaults, indexes, constraints, views) comes out qualified
|
|
4
|
+
-- and the DDL runs the same from any session
|
|
5
|
+
-- Binds: none
|
|
6
|
+
-- Returns: SEARCH_PATH
|
|
7
|
+
select set_config('search_path', 'pg_catalog', true) as search_path
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Purpose: put the configured schema first on search_path for the rest of
|
|
2
|
+
-- this transaction only, so a checker resolves unqualified names as
|
|
3
|
+
-- the application whose schema it is would; allowed in a read-only
|
|
4
|
+
-- transaction, and gone when it ends. Also says whether this account
|
|
5
|
+
-- may use the schema at all: without USAGE, PostgreSQL leaves it off
|
|
6
|
+
-- the path silently and every name in it looks missing.
|
|
7
|
+
-- Binds: %(s)s schema
|
|
8
|
+
-- Returns: SEARCH_PATH (the path now in force), SCHEMA_USAGE, ACCOUNT
|
|
9
|
+
select set_config('search_path',
|
|
10
|
+
quote_ident(%(s)s) || ', ' || current_setting('search_path'),
|
|
11
|
+
true) as search_path,
|
|
12
|
+
has_schema_privilege(%(s)s, 'USAGE') as schema_usage,
|
|
13
|
+
current_user as account
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Purpose: the CREATE SEQUENCE statement for one sequence
|
|
2
|
+
-- Binds: %(objid)s the sequence's oid
|
|
3
|
+
-- Returns: STATEMENT
|
|
4
|
+
select 'CREATE SEQUENCE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname)
|
|
5
|
+
|| ' AS ' || format_type(s.seqtypid, null)
|
|
6
|
+
|| ' INCREMENT BY ' || s.seqincrement
|
|
7
|
+
|| ' MINVALUE ' || s.seqmin
|
|
8
|
+
|| ' MAXVALUE ' || s.seqmax
|
|
9
|
+
|| ' START WITH ' || s.seqstart
|
|
10
|
+
|| ' CACHE ' || s.seqcache
|
|
11
|
+
|| case when s.seqcycle then ' CYCLE' else ' NO CYCLE' end || ';'
|
|
12
|
+
as statement
|
|
13
|
+
from pg_sequence s
|
|
14
|
+
join pg_class c on c.oid = s.seqrelid
|
|
15
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
16
|
+
where s.seqrelid = %(objid)s::int8::oid
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Purpose: whether this session can do far more than changing one schema
|
|
2
|
+
-- needs: a superuser, a role that creates roles, or the schema's
|
|
3
|
+
-- owner
|
|
4
|
+
-- Binds: %(s)s schema
|
|
5
|
+
-- Returns: SUPERUSER, CREATEROLE, OWNS_SCHEMA, ACCOUNT
|
|
6
|
+
select r.rolsuper as superuser,
|
|
7
|
+
r.rolcreaterole as createrole,
|
|
8
|
+
coalesce((select ns.nspowner = r.oid
|
|
9
|
+
from pg_namespace ns
|
|
10
|
+
where ns.nspname = %(s)s), false) as owns_schema,
|
|
11
|
+
r.rolname as account
|
|
12
|
+
from pg_roles r
|
|
13
|
+
where r.rolname = current_user
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Purpose: the source of every function, procedure or view holding a name,
|
|
2
|
+
-- as PostgreSQL itself prints it back — one unit per overload
|
|
3
|
+
-- Binds: %(s)s schema, %(n)s object name (already folded)
|
|
4
|
+
-- Returns: UNIT, DEFINITION
|
|
5
|
+
select unit, definition
|
|
6
|
+
from (select p.proname || '(' || pg_get_function_identity_arguments(p.oid)
|
|
7
|
+
|| ')' as unit,
|
|
8
|
+
pg_get_functiondef(p.oid) as definition,
|
|
9
|
+
1 as k
|
|
10
|
+
from pg_proc p
|
|
11
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
12
|
+
where n.nspname = %(s)s
|
|
13
|
+
and p.proname = %(n)s
|
|
14
|
+
and p.prokind in ('f', 'p', 'w')
|
|
15
|
+
union all
|
|
16
|
+
select c.relname,
|
|
17
|
+
case c.relkind
|
|
18
|
+
when 'v' then 'CREATE OR REPLACE VIEW '
|
|
19
|
+
else 'CREATE MATERIALIZED VIEW ' end
|
|
20
|
+
|| quote_ident(n.nspname) || '.' || quote_ident(c.relname)
|
|
21
|
+
|| E' AS\n' || pg_get_viewdef(c.oid, true),
|
|
22
|
+
2
|
|
23
|
+
from pg_class c
|
|
24
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
25
|
+
where n.nspname = %(s)s
|
|
26
|
+
and c.relname = %(n)s
|
|
27
|
+
and c.relkind in ('v', 'm')) s
|
|
28
|
+
order by k, unit
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Purpose: column definitions as a CREATE TABLE writes them, defaults,
|
|
2
|
+
-- identity and generated expressions included
|
|
3
|
+
-- Binds: %(objid)s the table's oid
|
|
4
|
+
-- Returns: NAME, DATA_TYPE, NOT_NULL, DEFAULT_EXPR, IDENTITY, GENERATED
|
|
5
|
+
select quote_ident(a.attname) as name,
|
|
6
|
+
format_type(a.atttypid, a.atttypmod) as data_type,
|
|
7
|
+
a.attnotnull as not_null,
|
|
8
|
+
pg_get_expr(d.adbin, d.adrelid) as default_expr,
|
|
9
|
+
a.attidentity as identity,
|
|
10
|
+
a.attgenerated as generated
|
|
11
|
+
from pg_attribute a
|
|
12
|
+
left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
|
|
13
|
+
where a.attrelid = %(objid)s::int8::oid
|
|
14
|
+
and a.attnum > 0
|
|
15
|
+
and not a.attisdropped
|
|
16
|
+
order by a.attnum
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Purpose: a table's primary key, unique, check, exclusion and foreign key
|
|
2
|
+
-- constraints as PostgreSQL prints them back (NOT NULL is on the
|
|
3
|
+
-- columns, so contype 'n' of PostgreSQL 18 is left out)
|
|
4
|
+
-- Binds: %(objid)s the table's oid
|
|
5
|
+
-- Returns: NAME, DEFINITION
|
|
6
|
+
select quote_ident(k.conname) as name,
|
|
7
|
+
pg_get_constraintdef(k.oid, true) as definition
|
|
8
|
+
from pg_constraint k
|
|
9
|
+
where k.conrelid = %(objid)s::int8::oid
|
|
10
|
+
and k.contype in ('p', 'u', 'c', 'x', 'f')
|
|
11
|
+
order by case k.contype when 'p' then 1 when 'u' then 2 when 'c' then 3
|
|
12
|
+
when 'x' then 4 else 5 end,
|
|
13
|
+
k.conname
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Purpose: the statements that follow a CREATE TABLE: indexes no constraint
|
|
2
|
+
-- owns, triggers, and comments on the table and its columns
|
|
3
|
+
-- Binds: %(objid)s the table's oid
|
|
4
|
+
-- Returns: STATEMENT
|
|
5
|
+
select stmt as statement
|
|
6
|
+
from (select 1 as k, ic.relname as sort_key,
|
|
7
|
+
pg_get_indexdef(i.indexrelid) || ';' as stmt
|
|
8
|
+
from pg_index i
|
|
9
|
+
join pg_class ic on ic.oid = i.indexrelid
|
|
10
|
+
where i.indrelid = %(objid)s::int8::oid
|
|
11
|
+
and not exists (select 1 from pg_constraint k
|
|
12
|
+
where k.conindid = i.indexrelid
|
|
13
|
+
and k.contype in ('p', 'u', 'x'))
|
|
14
|
+
union all
|
|
15
|
+
select 2, t.tgname, pg_get_triggerdef(t.oid, true) || ';'
|
|
16
|
+
from pg_trigger t
|
|
17
|
+
where t.tgrelid = %(objid)s::int8::oid
|
|
18
|
+
and not t.tgisinternal
|
|
19
|
+
union all
|
|
20
|
+
select 3, '',
|
|
21
|
+
'COMMENT ON TABLE ' || quote_ident(n.nspname) || '.'
|
|
22
|
+
|| quote_ident(c.relname) || ' IS ' || quote_literal(d.description)
|
|
23
|
+
|| ';'
|
|
24
|
+
from pg_class c
|
|
25
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
26
|
+
join pg_description d on d.objoid = c.oid
|
|
27
|
+
and d.classoid = 'pg_class'::regclass
|
|
28
|
+
and d.objsubid = 0
|
|
29
|
+
where c.oid = %(objid)s::int8::oid
|
|
30
|
+
union all
|
|
31
|
+
select 4, lpad(a.attnum::text, 6, '0'),
|
|
32
|
+
'COMMENT ON COLUMN ' || quote_ident(n.nspname) || '.'
|
|
33
|
+
|| quote_ident(c.relname) || '.' || quote_ident(a.attname)
|
|
34
|
+
|| ' IS ' || quote_literal(d.description) || ';'
|
|
35
|
+
from pg_class c
|
|
36
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
37
|
+
join pg_attribute a on a.attrelid = c.oid
|
|
38
|
+
join pg_description d on d.objoid = c.oid
|
|
39
|
+
and d.classoid = 'pg_class'::regclass
|
|
40
|
+
and d.objsubid = a.attnum
|
|
41
|
+
where c.oid = %(objid)s::int8::oid
|
|
42
|
+
and a.attnum > 0) x
|
|
43
|
+
order by k, sort_key
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Purpose: what the first line of a table's DDL needs — its qualified name,
|
|
2
|
+
-- persistence, partition key, and the parent it is a partition of
|
|
3
|
+
-- Binds: %(objid)s the table's oid
|
|
4
|
+
-- Returns: QNAME, UNLOGGED, PARTITION_KEY, PARTITION_OF, PARTITION_BOUND
|
|
5
|
+
select quote_ident(n.nspname) || '.' || quote_ident(c.relname) as qname,
|
|
6
|
+
c.relpersistence = 'u' as unlogged,
|
|
7
|
+
case when c.relkind = 'p' then pg_get_partkeydef(c.oid) end as partition_key,
|
|
8
|
+
(select quote_ident(pn.nspname) || '.' || quote_ident(pc.relname)
|
|
9
|
+
from pg_inherits i
|
|
10
|
+
join pg_class pc on pc.oid = i.inhparent
|
|
11
|
+
join pg_namespace pn on pn.oid = pc.relnamespace
|
|
12
|
+
where c.relispartition
|
|
13
|
+
and i.inhrelid = c.oid) as partition_of,
|
|
14
|
+
case when c.relispartition then pg_get_expr(c.relpartbound, c.oid) end
|
|
15
|
+
as partition_bound
|
|
16
|
+
from pg_class c
|
|
17
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
18
|
+
where c.oid = %(objid)s::int8::oid
|
pythia_postgres/read.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""The read tier for PostgreSQL: pythia's Learn movement, asked of the live
|
|
2
|
+
catalog.
|
|
3
|
+
|
|
4
|
+
PostgreSQL's catalog records less than Oracle's in one place that matters:
|
|
5
|
+
pg_depend does not see into a PL/pgSQL body, or a SQL function whose body is
|
|
6
|
+
a string. The dependency walks therefore ask twice — the catalog, and then a
|
|
7
|
+
whole-word search of function source — and mark what only the search vouches
|
|
8
|
+
for (how = "text"), naming that blind spot every time instead of returning a
|
|
9
|
+
clean-looking answer it cannot stand behind.
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from pythia_core.output import TEXT_HOW
|
|
14
|
+
|
|
15
|
+
from .catalog import fold, q
|
|
16
|
+
|
|
17
|
+
CATALOG_HOW = "catalog"
|
|
18
|
+
FUNCTION_TYPES = ("FUNCTION", "PROCEDURE", "WINDOW FUNCTION", "AGGREGATE")
|
|
19
|
+
SOURCE_TYPES = ("FUNCTION", "PROCEDURE", "WINDOW FUNCTION", "VIEW",
|
|
20
|
+
"MATERIALIZED VIEW")
|
|
21
|
+
COLUMN_TYPES = ("TABLE", "PARTITIONED TABLE", "VIEW", "MATERIALIZED VIEW",
|
|
22
|
+
"FOREIGN TABLE")
|
|
23
|
+
TABLE_TYPES = ("TABLE", "PARTITIONED TABLE")
|
|
24
|
+
|
|
25
|
+
DDL_NOTE = ("-- rebuilt from the catalog by pythia-pg; pg_dump --schema-only "
|
|
26
|
+
"is the reference")
|
|
27
|
+
|
|
28
|
+
BLIND_TEXT_IN = (
|
|
29
|
+
"PL/pgSQL bodies and SQL functions with a string body are not in "
|
|
30
|
+
"pg_depend, so functions are matched by name as text ([text]): a column "
|
|
31
|
+
"or variable with the same name gives a false match, and a name built at "
|
|
32
|
+
"run time (EXECUTE format(...)) is not seen at all")
|
|
33
|
+
BLIND_TEXT_OUT = (
|
|
34
|
+
"function bodies are read as text against the names of existing tables, "
|
|
35
|
+
"views, sequences and functions ([text]): a variable or column sharing a "
|
|
36
|
+
"name gives a false match, a name built at run time (EXECUTE) is not "
|
|
37
|
+
"seen, and a name that exists in several schemas is listed for each — "
|
|
38
|
+
"search_path decides which one runs")
|
|
39
|
+
BLIND_ELSEWHERE = (
|
|
40
|
+
"other databases on this server, and other servers (dblink, "
|
|
41
|
+
"postgres_fdw), are never seen")
|
|
42
|
+
NOT_INSTALLED = (
|
|
43
|
+
"plpgsql_check is not installed in this database, so the only check was "
|
|
44
|
+
"PostgreSQL's own, of syntax, when each function was created: a missing "
|
|
45
|
+
"table or column in a PL/pgSQL body shows up at run time. A superuser "
|
|
46
|
+
"can add it where the server has the package: CREATE EXTENSION "
|
|
47
|
+
"plpgsql_check;")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve(conn, schema, name):
|
|
51
|
+
"""(classid, objid, type, label, bare_name) of everything holding a name."""
|
|
52
|
+
return q(conn, "resolve.sql", s=schema, n=fold(name))[1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def object_names(conn, schema, pattern, fetch):
|
|
56
|
+
return q(conn, "object-names.sql", s=schema, p=pattern, n=fetch)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_definition(definition):
|
|
60
|
+
return str(definition).rstrip("\n").split("\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def source(conn, schema, name):
|
|
64
|
+
rows = []
|
|
65
|
+
for unit, definition in q(conn, "source.sql", s=schema, n=fold(name))[1]:
|
|
66
|
+
rows.extend((unit, i, line)
|
|
67
|
+
for i, line in enumerate(split_definition(definition), 1))
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def columns(conn, schema, name):
|
|
72
|
+
rel = [r for r in resolve(conn, schema, name) if r[2] in COLUMN_TYPES]
|
|
73
|
+
if not rel:
|
|
74
|
+
return None
|
|
75
|
+
return q(conn, "columns.sql", objid=rel[0][1])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def table_ddl(conn, objid):
|
|
79
|
+
qname, unlogged, partkey, partof, bound = q(conn, "table-head.sql",
|
|
80
|
+
objid=objid)[1][0]
|
|
81
|
+
lines = []
|
|
82
|
+
if partof:
|
|
83
|
+
tail = f" PARTITION BY {partkey}" if partkey else ""
|
|
84
|
+
lines.append(f"CREATE TABLE {qname} PARTITION OF {partof} {bound}{tail};")
|
|
85
|
+
else:
|
|
86
|
+
body = []
|
|
87
|
+
for name, dtype, not_null, default, identity, generated in q(
|
|
88
|
+
conn, "table-columns.sql", objid=objid)[1]:
|
|
89
|
+
col = f" {name} {dtype}"
|
|
90
|
+
if generated:
|
|
91
|
+
col += f" GENERATED ALWAYS AS ({default}) STORED"
|
|
92
|
+
elif identity:
|
|
93
|
+
col += (" GENERATED " + ("ALWAYS" if identity == "a" else
|
|
94
|
+
"BY DEFAULT") + " AS IDENTITY")
|
|
95
|
+
elif default is not None:
|
|
96
|
+
col += f" DEFAULT {default}"
|
|
97
|
+
if not_null:
|
|
98
|
+
col += " NOT NULL"
|
|
99
|
+
body.append(col)
|
|
100
|
+
body.extend(f" CONSTRAINT {cname} {cdef}" for cname, cdef in
|
|
101
|
+
q(conn, "table-constraints.sql", objid=objid)[1])
|
|
102
|
+
lines.append(f"CREATE {'UNLOGGED ' if unlogged else ''}TABLE {qname} (")
|
|
103
|
+
lines.append(",\n".join(body))
|
|
104
|
+
lines.append(")" + (f" PARTITION BY {partkey}" if partkey else "") + ";")
|
|
105
|
+
extras = [r[0] for r in q(conn, "table-extras.sql", objid=objid)[1]]
|
|
106
|
+
if extras:
|
|
107
|
+
lines.append("")
|
|
108
|
+
lines.extend(extras)
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ddl(conn, schema, name):
|
|
113
|
+
# names printed back by the catalog follow search_path: fix it first, so
|
|
114
|
+
# the DDL means the same in whatever session runs it
|
|
115
|
+
q(conn, "search-path-catalog.sql")
|
|
116
|
+
objs = resolve(conn, schema, name)
|
|
117
|
+
if not objs:
|
|
118
|
+
return ""
|
|
119
|
+
parts = []
|
|
120
|
+
for _classid, objid, otype, label, _bare in objs:
|
|
121
|
+
if otype in TABLE_TYPES:
|
|
122
|
+
parts.append(table_ddl(conn, objid))
|
|
123
|
+
elif otype == "SEQUENCE":
|
|
124
|
+
parts.append(q(conn, "sequence-ddl.sql", objid=objid)[1][0][0])
|
|
125
|
+
elif otype not in SOURCE_TYPES:
|
|
126
|
+
parts.append(f"-- {label}: pythia-pg does not rebuild the DDL of "
|
|
127
|
+
f"a {otype} yet; pg_dump --schema-only is the "
|
|
128
|
+
"reference")
|
|
129
|
+
if any(r[2] in SOURCE_TYPES for r in objs):
|
|
130
|
+
for _unit, definition in q(conn, "source.sql", s=schema, n=fold(name))[1]:
|
|
131
|
+
text = str(definition).rstrip()
|
|
132
|
+
parts.append(text if text.endswith(";") else text + ";")
|
|
133
|
+
if any(r[2] in TABLE_TYPES for r in objs):
|
|
134
|
+
parts.insert(0, DDL_NOTE)
|
|
135
|
+
return "\n\n".join(parts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
GREP_COLS = ("NAME", "TYPE", "LINE", "TEXT")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def grep(conn, schema, text, fetch):
|
|
142
|
+
needle = str(text).lower()
|
|
143
|
+
out = []
|
|
144
|
+
for unit, otype, definition in q(conn, "grep.sql", s=schema, t=text)[1]:
|
|
145
|
+
for i, line in enumerate(split_definition(definition), 1):
|
|
146
|
+
if needle in line.lower():
|
|
147
|
+
out.append((unit, otype, i, line.strip()))
|
|
148
|
+
if len(out) >= fetch:
|
|
149
|
+
return GREP_COLS, out
|
|
150
|
+
return GREP_COLS, out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- dependency walks ---------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def word_pattern(name):
|
|
156
|
+
"""A PostgreSQL regular expression matching name as a whole word in any
|
|
157
|
+
case (the query uses ~*): every character that is not a letter, digit or
|
|
158
|
+
underscore is escaped."""
|
|
159
|
+
esc = "".join(ch if ch.isalnum() or ch == "_" else "\\" + ch for ch in name)
|
|
160
|
+
return f"\\m{esc}\\M"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_COMMENTS = re.compile(r"--[^\n]*|/\*.*?\*/", re.S)
|
|
164
|
+
_QUOTED = re.compile(r'"((?:[^"]|"")+)"')
|
|
165
|
+
_WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def body_names(body):
|
|
169
|
+
"""The names a function body could be referring to: unquoted words
|
|
170
|
+
folded to lower case, quoted identifiers exactly. Comments are dropped;
|
|
171
|
+
string literals are kept, because dynamic SQL and nextval('seq') name
|
|
172
|
+
real objects inside them."""
|
|
173
|
+
text = _COMMENTS.sub(" ", str(body or ""))
|
|
174
|
+
names = {m.group(1).replace('""', '"') for m in _QUOTED.finditer(text)}
|
|
175
|
+
names |= {w.lower() for w in _WORD.findall(_QUOTED.sub(" ", text))}
|
|
176
|
+
return names
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def bare(name, otype):
|
|
180
|
+
return name.split("(", 1)[0] if otype in FUNCTION_TYPES else name
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def walk(roots, depth, children):
|
|
184
|
+
"""Depth-first from each root, in the order a tree is read. An object
|
|
185
|
+
reached twice is listed twice (that is the graph) but walked once."""
|
|
186
|
+
rows = []
|
|
187
|
+
seen = {(r[0], r[1]) for r in roots}
|
|
188
|
+
|
|
189
|
+
def visit(node, level):
|
|
190
|
+
if level > depth:
|
|
191
|
+
return
|
|
192
|
+
for schema, name, otype, nclass, nobj, how in children(node):
|
|
193
|
+
rows.append((level, schema, name, otype, how))
|
|
194
|
+
if nobj is not None and (nclass, nobj) not in seen:
|
|
195
|
+
seen.add((nclass, nobj))
|
|
196
|
+
visit((nclass, nobj, otype, bare(name, otype)), level + 1)
|
|
197
|
+
|
|
198
|
+
for classid, objid, otype, _label, bare_name in roots:
|
|
199
|
+
visit((classid, objid, otype, bare_name), 1)
|
|
200
|
+
return rows
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _overload_note(roots, name):
|
|
204
|
+
if len(roots) > 1:
|
|
205
|
+
labels = ", ".join(r[3] for r in roots)
|
|
206
|
+
return f"{name} names {len(roots)} objects ({labels}); the walk starts from each"
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def dependents(conn, schema, name, depth):
|
|
211
|
+
roots = resolve(conn, schema, name)
|
|
212
|
+
if not roots:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def children(node):
|
|
216
|
+
classid, objid, otype, bare_name = node
|
|
217
|
+
out = [(s, n, t, c, o, CATALOG_HOW) for s, n, t, c, o in
|
|
218
|
+
q(conn, "dependents-catalog.sql", classid=classid, objid=objid)[1]]
|
|
219
|
+
seen = {o for *_, o, _ in out if o is not None}
|
|
220
|
+
for s, n, t, c, o in q(conn, "dependents-text.sql",
|
|
221
|
+
pattern=word_pattern(bare_name), objid=objid)[1]:
|
|
222
|
+
if o not in seen:
|
|
223
|
+
out.append((s, n, t, c, o, TEXT_HOW))
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
rows = walk(roots, depth, children)
|
|
227
|
+
notes = [n for n in (_overload_note(roots, name), BLIND_TEXT_IN,
|
|
228
|
+
BLIND_ELSEWHERE) if n]
|
|
229
|
+
return rows, notes
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def dependencies(conn, schema, name, depth):
|
|
233
|
+
roots = resolve(conn, schema, name)
|
|
234
|
+
if not roots:
|
|
235
|
+
return None
|
|
236
|
+
cache = {}
|
|
237
|
+
|
|
238
|
+
def candidates():
|
|
239
|
+
if "all" not in cache:
|
|
240
|
+
by_name = {}
|
|
241
|
+
for bare_name, *rest in q(conn, "name-candidates.sql")[1]:
|
|
242
|
+
by_name.setdefault(bare_name, []).append(tuple(rest))
|
|
243
|
+
cache["all"] = by_name
|
|
244
|
+
return cache["all"]
|
|
245
|
+
|
|
246
|
+
def children(node):
|
|
247
|
+
classid, objid, otype, _bare = node
|
|
248
|
+
out = [(s, n, t, c, o, CATALOG_HOW) for s, n, t, c, o in
|
|
249
|
+
q(conn, "dependencies-catalog.sql", classid=classid, objid=objid)[1]]
|
|
250
|
+
if otype not in FUNCTION_TYPES:
|
|
251
|
+
return out
|
|
252
|
+
found = q(conn, "function-body.sql", objid=objid)[1]
|
|
253
|
+
if not found or found[0][0] not in ("plpgsql", "sql"):
|
|
254
|
+
return out
|
|
255
|
+
seen = {o for *_, o, _ in out if o is not None} | {objid}
|
|
256
|
+
words = body_names(found[0][1])
|
|
257
|
+
for word in sorted(words):
|
|
258
|
+
for s, n, t, c, o in candidates().get(word, ()):
|
|
259
|
+
if o not in seen:
|
|
260
|
+
seen.add(o)
|
|
261
|
+
out.append((s, n, t, c, o, TEXT_HOW))
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
rows = walk(roots, depth, children)
|
|
265
|
+
notes = [n for n in (_overload_note(roots, name), BLIND_TEXT_OUT,
|
|
266
|
+
BLIND_ELSEWHERE) if n]
|
|
267
|
+
return rows, notes
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- errors -----------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def compile_errors(conn, schema, name):
|
|
273
|
+
if name is not None:
|
|
274
|
+
objs = resolve(conn, schema, name)
|
|
275
|
+
if not objs:
|
|
276
|
+
return None
|
|
277
|
+
if not any(r[2] in FUNCTION_TYPES for r in objs):
|
|
278
|
+
return [], (f"{name} is a {objs[0][2]}, not a function: there is no "
|
|
279
|
+
"PL/pgSQL body for plpgsql_check to read")
|
|
280
|
+
if not q(conn, "plpgsql-check-installed.sql")[1][0][0]:
|
|
281
|
+
return [], NOT_INSTALLED
|
|
282
|
+
n = fold(name) if name is not None else None
|
|
283
|
+
# unqualified names in a body resolve by search_path at run time; check
|
|
284
|
+
# them the way the schema's own application resolves them
|
|
285
|
+
_path, usage, account = q(conn, "search-path-first.sql", s=schema)[1][0]
|
|
286
|
+
if not usage:
|
|
287
|
+
return [], (f"this account has no USAGE on schema {schema}, so "
|
|
288
|
+
"PostgreSQL leaves it off search_path and every name in "
|
|
289
|
+
"it would look missing to plpgsql_check — no list is "
|
|
290
|
+
"better than a false one. A DBA grants it: GRANT USAGE "
|
|
291
|
+
f"ON SCHEMA {schema} TO {account};")
|
|
292
|
+
rows = q(conn, "plpgsql-check.sql", s=schema, n=n)[1]
|
|
293
|
+
unchecked = [r[0] for r in q(conn, "plpgsql-unchecked.sql", s=schema, n=n)[1]]
|
|
294
|
+
blind = None
|
|
295
|
+
if unchecked:
|
|
296
|
+
blind = (f"not checked: {', '.join(unchecked)} — plpgsql_check reads a "
|
|
297
|
+
"trigger function through a table whose trigger uses it, and "
|
|
298
|
+
"these have none (or are event trigger functions)")
|
|
299
|
+
return rows, blind
|