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.
Files changed (51) hide show
  1. pythia_postgres/__init__.py +5 -0
  2. pythia_postgres/__main__.py +5 -0
  3. pythia_postgres/catalog.py +59 -0
  4. pythia_postgres/cli.py +8 -0
  5. pythia_postgres/engine.py +213 -0
  6. pythia_postgres/guard.py +54 -0
  7. pythia_postgres/queries/code-objects.sql +28 -0
  8. pythia_postgres/queries/columns.sql +15 -0
  9. pythia_postgres/queries/comment-current.sql +17 -0
  10. pythia_postgres/queries/current-schema.sql +5 -0
  11. pythia_postgres/queries/dependencies-catalog.sql +66 -0
  12. pythia_postgres/queries/dependents-catalog.sql +66 -0
  13. pythia_postgres/queries/dependents-text.sql +22 -0
  14. pythia_postgres/queries/function-body.sql +8 -0
  15. pythia_postgres/queries/grep.sql +30 -0
  16. pythia_postgres/queries/name-candidates.sql +32 -0
  17. pythia_postgres/queries/object-names.sql +56 -0
  18. pythia_postgres/queries/plpgsql-check-installed.sql +7 -0
  19. pythia_postgres/queries/plpgsql-check.sql +27 -0
  20. pythia_postgres/queries/plpgsql-unchecked.sql +16 -0
  21. pythia_postgres/queries/rehearsal-limits.sql +7 -0
  22. pythia_postgres/queries/resolve.sql +34 -0
  23. pythia_postgres/queries/row-set-count.sql +11 -0
  24. pythia_postgres/queries/row-set-sample.sql +8 -0
  25. pythia_postgres/queries/search-path-catalog.sql +7 -0
  26. pythia_postgres/queries/search-path-first.sql +13 -0
  27. pythia_postgres/queries/sequence-ddl.sql +16 -0
  28. pythia_postgres/queries/server-version.sql +6 -0
  29. pythia_postgres/queries/session-power.sql +13 -0
  30. pythia_postgres/queries/source.sql +28 -0
  31. pythia_postgres/queries/table-columns.sql +16 -0
  32. pythia_postgres/queries/table-constraints.sql +13 -0
  33. pythia_postgres/queries/table-extras.sql +43 -0
  34. pythia_postgres/queries/table-head.sql +18 -0
  35. pythia_postgres/read.py +299 -0
  36. pythia_postgres/rules.py +135 -0
  37. pythia_postgres/skills/pythia-pg-apply/SKILL.md +97 -0
  38. pythia_postgres/skills/pythia-pg-apply/reference/refusals.md +26 -0
  39. pythia_postgres/skills/pythia-pg-autopilot/SKILL.md +63 -0
  40. pythia_postgres/skills/pythia-pg-deploy/SKILL.md +73 -0
  41. pythia_postgres/skills/pythia-pg-explore/SKILL.md +63 -0
  42. pythia_postgres/skills/pythia-pg-impact/SKILL.md +57 -0
  43. pythia_postgres/skills/pythia-pg-verify/SKILL.md +56 -0
  44. pythia_postgres/skills/using-pythia-pg/SKILL.md +65 -0
  45. pythia_postgres/write.py +583 -0
  46. pythia_postgres-0.1.0.dist-info/METADATA +360 -0
  47. pythia_postgres-0.1.0.dist-info/RECORD +51 -0
  48. pythia_postgres-0.1.0.dist-info/WHEEL +5 -0
  49. pythia_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  50. pythia_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. 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,6 @@
1
+ -- Purpose: what `check` reports as the server — its version, and the
2
+ -- database this session is connected to
3
+ -- Binds: none
4
+ -- Returns: VERSION, DATABASE (one row)
5
+ select current_setting('server_version') as version,
6
+ current_database() as database
@@ -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
@@ -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