@pgsql/transform 18.1.1 → 18.3.0

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 (113) hide show
  1. package/README.md +29 -73
  2. package/categorize.d.ts +52 -0
  3. package/categorize.js +56 -0
  4. package/esm/categorize.js +51 -0
  5. package/esm/facts.js +289 -0
  6. package/esm/fixture-closure.js +197 -0
  7. package/esm/index.js +7 -22
  8. package/esm/qualify.js +297 -0
  9. package/esm/round-trip-core.js +126 -0
  10. package/esm/round-trip.js +116 -0
  11. package/esm/transform.js +1169 -0
  12. package/facts.d.ts +69 -0
  13. package/facts.js +292 -0
  14. package/fixture-closure.d.ts +71 -0
  15. package/fixture-closure.js +200 -0
  16. package/index.d.ts +14 -28
  17. package/index.js +43 -76
  18. package/package.json +22 -14
  19. package/qualify.d.ts +125 -0
  20. package/qualify.js +303 -0
  21. package/round-trip-core.d.ts +60 -0
  22. package/round-trip-core.js +133 -0
  23. package/round-trip.d.ts +47 -0
  24. package/round-trip.js +123 -0
  25. package/transform.d.ts +259 -0
  26. package/transform.js +1190 -0
  27. package/13/enums.d.ts +0 -62
  28. package/13/enums.js +0 -2
  29. package/13/types.d.ts +0 -2103
  30. package/13/types.js +0 -2
  31. package/14/enums.d.ts +0 -62
  32. package/14/enums.js +0 -2
  33. package/14/types.d.ts +0 -2164
  34. package/14/types.js +0 -2
  35. package/15/enums.d.ts +0 -64
  36. package/15/enums.js +0 -2
  37. package/15/types.d.ts +0 -2222
  38. package/15/types.js +0 -2
  39. package/16/enums.d.ts +0 -69
  40. package/16/enums.js +0 -2
  41. package/16/types.d.ts +0 -2328
  42. package/16/types.js +0 -2
  43. package/17/enums.d.ts +0 -76
  44. package/17/enums.js +0 -2
  45. package/17/types.d.ts +0 -2488
  46. package/17/types.js +0 -2
  47. package/18/enums.d.ts +0 -78
  48. package/18/enums.js +0 -2
  49. package/18/types.d.ts +0 -2538
  50. package/18/types.js +0 -2
  51. package/esm/13/enums.js +0 -1
  52. package/esm/13/types.js +0 -1
  53. package/esm/14/enums.js +0 -1
  54. package/esm/14/types.js +0 -1
  55. package/esm/15/enums.js +0 -1
  56. package/esm/15/types.js +0 -1
  57. package/esm/16/enums.js +0 -1
  58. package/esm/16/types.js +0 -1
  59. package/esm/17/enums.js +0 -1
  60. package/esm/17/types.js +0 -1
  61. package/esm/18/enums.js +0 -1
  62. package/esm/18/types.js +0 -1
  63. package/esm/multi-version-transformer.js +0 -80
  64. package/esm/transformers/context.js +0 -1
  65. package/esm/transformers/v13-to-v14.js +0 -2767
  66. package/esm/transformers/v14-to-v15.js +0 -1218
  67. package/esm/transformers/v15-to-v16.js +0 -2935
  68. package/esm/transformers/v16-to-v17.js +0 -1526
  69. package/esm/transformers/v17-to-v18.js +0 -204
  70. package/esm/transformers-direct/index.js +0 -13
  71. package/esm/transformers-direct/v13-to-v17/index.js +0 -74
  72. package/esm/transformers-direct/v13-to-v18/index.js +0 -84
  73. package/esm/transformers-direct/v14-to-v17/index.js +0 -63
  74. package/esm/transformers-direct/v14-to-v18/index.js +0 -73
  75. package/esm/transformers-direct/v15-to-v17/index.js +0 -53
  76. package/esm/transformers-direct/v15-to-v18/index.js +0 -62
  77. package/esm/transformers-direct/v16-to-v17/index.js +0 -40
  78. package/esm/transformers-direct/v16-to-v18/index.js +0 -51
  79. package/esm/transformers-direct/v17-to-v18/index.js +0 -39
  80. package/multi-version-transformer.d.ts +0 -14
  81. package/multi-version-transformer.js +0 -84
  82. package/transformers/context.d.ts +0 -4
  83. package/transformers/context.js +0 -2
  84. package/transformers/v13-to-v14.d.ts +0 -255
  85. package/transformers/v13-to-v14.js +0 -2771
  86. package/transformers/v14-to-v15.d.ts +0 -615
  87. package/transformers/v14-to-v15.js +0 -1222
  88. package/transformers/v15-to-v16.d.ts +0 -632
  89. package/transformers/v15-to-v16.js +0 -2939
  90. package/transformers/v16-to-v17.d.ts +0 -640
  91. package/transformers/v16-to-v17.js +0 -1530
  92. package/transformers/v17-to-v18.d.ts +0 -54
  93. package/transformers/v17-to-v18.js +0 -208
  94. package/transformers-direct/index.d.ts +0 -13
  95. package/transformers-direct/index.js +0 -25
  96. package/transformers-direct/v13-to-v17/index.d.ts +0 -27
  97. package/transformers-direct/v13-to-v17/index.js +0 -78
  98. package/transformers-direct/v13-to-v18/index.d.ts +0 -28
  99. package/transformers-direct/v13-to-v18/index.js +0 -88
  100. package/transformers-direct/v14-to-v17/index.d.ts +0 -26
  101. package/transformers-direct/v14-to-v17/index.js +0 -67
  102. package/transformers-direct/v14-to-v18/index.d.ts +0 -27
  103. package/transformers-direct/v14-to-v18/index.js +0 -77
  104. package/transformers-direct/v15-to-v17/index.d.ts +0 -25
  105. package/transformers-direct/v15-to-v17/index.js +0 -57
  106. package/transformers-direct/v15-to-v18/index.d.ts +0 -26
  107. package/transformers-direct/v15-to-v18/index.js +0 -66
  108. package/transformers-direct/v16-to-v17/index.d.ts +0 -24
  109. package/transformers-direct/v16-to-v17/index.js +0 -44
  110. package/transformers-direct/v16-to-v18/index.d.ts +0 -25
  111. package/transformers-direct/v16-to-v18/index.js +0 -55
  112. package/transformers-direct/v17-to-v18/index.d.ts +0 -24
  113. package/transformers-direct/v17-to-v18/index.js +0 -43
package/README.md CHANGED
@@ -12,25 +12,9 @@
12
12
  <a href="https://www.npmjs.com/package/@pgsql/transform"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgsql-parser?filename=packages%2Ftransform%2Fpackage.json"/></a>
13
13
  </p>
14
14
 
15
- `@pgsql/transform` is a TypeScript library for transforming PostgreSQL ASTs between different PostgreSQL versions. It serves as a crucial component for building a single source of truth deparser that can handle SQL from multiple PostgreSQL versions while maintaining backward compatibility.
15
+ AST-based SQL transformation, qualification, classification, and dependency-closure analysis for PostgreSQL. Works on plain SQL **and inside PL/pgSQL function bodies** (via `plpgsql-parser` hydration) — no regexes.
16
16
 
17
- ## Transforming ASTs Between PG Versions
18
-
19
- The transform package enables you to:
20
-
21
- - **Transform legacy ASTs**: Convert ASTs from PostgreSQL versions 13-16 to version 17
22
- - **Build unified deparsers**: Create a single deparser pipeline that works with multiple PostgreSQL versions
23
- - **Maintain backward compatibility**: Support legacy codebases while leveraging the latest PostgreSQL features
24
-
25
- ## Key Limitation
26
-
27
- This package only supports ASTs derived from SQL that is parseable by PostgreSQL 17. This means:
28
-
29
- - ✅ **Supported**: SQL from PG13-16 that remains valid in PG17
30
- - ❌ **Not supported**: Deprecated syntax from older versions that was removed
31
- - ❌ **Not supported**: SQL that cannot be parsed by the PG17 parser
32
-
33
- This design ensures all transformed ASTs can be reliably deparsed using the latest PostgreSQL grammar.
17
+ > Looking for the PG13→17/18 version-upgrade AST transformer previously published under this name? It now lives at [`@pgsql/transform-ast`](../transform-ast).
34
18
 
35
19
  ## Installation
36
20
 
@@ -38,82 +22,54 @@ This design ensures all transformed ASTs can be reliably deparsed using the late
38
22
  npm install @pgsql/transform
39
23
  ```
40
24
 
41
- ## 🚀 Quick Start
42
-
43
- ### Multi-Version Transformer
44
-
45
- ```typescript
46
- import { ASTTransformer } from '@pgsql/transform';
47
-
48
- const transformer = new ASTTransformer();
49
-
50
- // Transform any version to PG17
51
- const pg17Ast = transformer.transformToLatest(pg13Ast, 13);
52
-
53
- // Transform between specific versions
54
- const pg15Ast = transformer.transform(pg13Ast, 13, 15);
25
+ The parser runs on a WASM build of the real PostgreSQL parser; call `loadModule()` from `plpgsql-parser` once before using any synchronous API.
55
26
 
56
- // Convenience methods
57
- const result = transformer.transform13To17(pg13Ast);
58
- ```
27
+ ## Features
59
28
 
60
- ### Direct Transformers
29
+ ### `transformSql` — schema-name rewriting
61
30
 
62
- For better performance when you know source and target versions:
31
+ Rewrite schema names everywhere they can appear (DDL, DML, function bodies, trigger definitions, grants, policies, comments, type casts, string-embedded types inside PL/pgSQL, ...):
63
32
 
64
33
  ```typescript
65
- import { PG13ToPG17Transformer } from '@pgsql/transform';
34
+ import { loadModule } from 'plpgsql-parser';
35
+ import { transformSql } from '@pgsql/transform';
66
36
 
67
- const transformer = new PG13ToPG17Transformer();
68
- const pg17Ast = transformer.transform(pg13Ast);
37
+ await loadModule();
38
+ const mapping = new Map([['my-schema', 'my_schema']]);
39
+ const { sql } = transformSql(inputSql, mapping);
69
40
  ```
70
41
 
71
- ### Integration Example
42
+ ### `classifyStatements` — per-statement AST facts
72
43
 
73
44
  ```typescript
74
- import { parse } from '@pgsql/parser';
75
- import { deparse } from 'pgsql-deparser';
76
- import { PG13ToPG17Transformer } from '@pgsql/transform';
77
-
78
- // Parse with older version
79
- const pg13Ast = await parse('SELECT * FROM users', { version: 13 });
45
+ import { classifyStatements } from '@pgsql/transform';
80
46
 
81
- // Transform to latest
82
- const transformer = new PG13ToPG17Transformer();
83
- const pg17Ast = transformer.transform(pg13Ast);
84
-
85
- // Deparse with latest grammar
86
- const sql = await deparse(pg17Ast);
47
+ const facts = classifyStatements(sql);
48
+ // per statement: kind (schema|table|view|index|type|function|trigger|policy|grant|...),
49
+ // creates, references (incl. inside PL/pgSQL bodies), referencedSchemas, roles,
50
+ // fkTargets, securityRelevant, securityDefiner, dynamicSql
87
51
  ```
88
52
 
89
- ## 🔄 Transformation Chain
53
+ ### `qualifyUnqualified` — add schema qualification
54
+
55
+ Qualify unqualified object references against an inventory of known objects, with multi-schema routing support.
90
56
 
91
- **Incremental**: PG13 → PG14 → PG15 → PG16 → PG17
92
- - Step-by-step version upgrades
93
- - Useful for debugging transformation issues
57
+ ### `resolveFixtureClosure` — transitive dependency closure
94
58
 
95
- **Direct**: PG13 → PG17, PG14 → PG17, etc.
96
- - Single-step transformations
97
- - Optimized for performance
59
+ Given a set of changes (name + SQL + optional declared dependencies), compute the transitive closure of a selection: forward producers of every referenced object/schema/role, plus attached fixtures (policies/grants/RLS targeting closure members), with explicit unresolved-reference reporting.
98
60
 
99
- ## 📋 Supported Transformations
61
+ ### `categorizeChange` / `buildCategoryOf` — change categorization
100
62
 
101
- | From | To | Transformer |
102
- |------|----|-----------|
103
- | PG13 | PG14, PG15, PG16, PG17 | `V13ToV14Transformer`, `PG13ToPG17Transformer` |
104
- | PG14 | PG15, PG16, PG17 | `V14ToV15Transformer`, `PG14ToPG17Transformer` |
105
- | PG15 | PG16, PG17 | `V15ToV16Transformer`, `PG15ToPG17Transformer` |
106
- | PG16 | PG17 | `V16ToV17Transformer`, `PG16ToPG17Transformer` |
63
+ Profile-driven categorization of migration changes (e.g. schema / functionality / security / fixtures) from their AST facts.
107
64
 
108
- ## 🏗️ Architecture
65
+ ### Round-trip validation
109
66
 
110
- The transform package fits into the broader pgsql-parser ecosystem:
67
+ `normalizeTree` / `cleanTree` / `validateRoundTrip` — dependency-free AST normalization and mutation-aware parse→deparse→re-parse validation.
111
68
 
112
- 1. **Parse** legacy SQL with version-specific parsers
113
- 2. **Transform** ASTs to the latest version
114
- 3. **Deparse** using the unified, latest-version deparser
69
+ ## Scripts
115
70
 
116
- This enables a single source of truth for SQL generation while supporting legacy codebases.
71
+ - `npm run fixtures` — regenerate `__fixtures__/output/` golden files from `__fixtures__/input/`
72
+ - `npm run scan-corpus <dir> [dir...]` — audit node-type coverage over a SQL corpus
117
73
 
118
74
  ---
119
75
 
@@ -0,0 +1,52 @@
1
+ import { StatementFacts } from './facts';
2
+ /**
3
+ * A category is a chunk-seam key a change is assigned to. Free-form string; the
4
+ * built-in {@link TIER_PROFILE} uses `'security' | 'functionality' | 'fixtures'
5
+ * | 'schema'`.
6
+ *
7
+ * This module is the classifier-driven counterpart to `@pgpmjs/core`'s
8
+ * `boundary: 'category'` rebundle mode: core stays classifier-agnostic and only
9
+ * consumes a `categoryOf(changeName)` seam function, while the rules that decide
10
+ * *what* each change is (the profile) live here, next to the AST facts.
11
+ */
12
+ export type ChangeCategory = string;
13
+ /**
14
+ * Decides a change's category from the AST facts of its (deploy) statements.
15
+ * A profile is a pure function of the facts — swap it to carve a monolith along
16
+ * different seams (e.g. per-role admin/users modules) without touching core.
17
+ */
18
+ export interface CategoryProfile {
19
+ /** Identifier for the profile (for logging / provenance). */
20
+ name: string;
21
+ /** Map a change's statement facts to its category. */
22
+ categorize: (facts: StatementFacts[], changeName: string) => ChangeCategory;
23
+ }
24
+ /**
25
+ * Default tier profile: isolates the security surface and procedural code out
26
+ * of the schema base. Precedence, highest first — a change is placed in the
27
+ * most specialized tier any of its statements reaches:
28
+ *
29
+ * security any securityRelevant statement (policy/grant/RLS/role/owner)
30
+ * functionality functions and triggers
31
+ * fixtures seed DML (INSERT/UPDATE/DELETE)
32
+ * schema schemas, tables, views, indexes, types, constraints (base)
33
+ *
34
+ * Ordering/dependencies across the resulting chunks are still resolved by
35
+ * core's rebundle graph, so this only decides *naming/grouping*, never deploy
36
+ * order.
37
+ */
38
+ export declare const TIER_PROFILE: CategoryProfile;
39
+ /**
40
+ * Classify a single change's deploy SQL into its category under `profile`
41
+ * (defaults to {@link TIER_PROFILE}).
42
+ */
43
+ export declare function categorizeChange(sql: string, changeName: string, profile?: CategoryProfile): ChangeCategory;
44
+ /**
45
+ * Build a `categoryOf(changeName)` seam function from a map of change name to
46
+ * its deploy SQL — ready to pass straight to `@pgpmjs/core`'s rebundle
47
+ * `boundary: 'category'` / `categoryOf` options.
48
+ *
49
+ * Changes absent from `changeSql` return `undefined`, letting core fall back to
50
+ * its folder key for anything the classifier did not see.
51
+ */
52
+ export declare function buildCategoryOf(changeSql: Record<string, string>, profile?: CategoryProfile): (changeName: string) => ChangeCategory | undefined;
package/categorize.js ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TIER_PROFILE = void 0;
4
+ exports.categorizeChange = categorizeChange;
5
+ exports.buildCategoryOf = buildCategoryOf;
6
+ const facts_1 = require("./facts");
7
+ /**
8
+ * Default tier profile: isolates the security surface and procedural code out
9
+ * of the schema base. Precedence, highest first — a change is placed in the
10
+ * most specialized tier any of its statements reaches:
11
+ *
12
+ * security any securityRelevant statement (policy/grant/RLS/role/owner)
13
+ * functionality functions and triggers
14
+ * fixtures seed DML (INSERT/UPDATE/DELETE)
15
+ * schema schemas, tables, views, indexes, types, constraints (base)
16
+ *
17
+ * Ordering/dependencies across the resulting chunks are still resolved by
18
+ * core's rebundle graph, so this only decides *naming/grouping*, never deploy
19
+ * order.
20
+ */
21
+ exports.TIER_PROFILE = {
22
+ name: 'tier',
23
+ categorize(facts) {
24
+ if (facts.length === 0)
25
+ return 'schema';
26
+ if (facts.some(f => f.securityRelevant))
27
+ return 'security';
28
+ if (facts.some(f => f.kind === 'function' || f.kind === 'trigger'))
29
+ return 'functionality';
30
+ if (facts.some(f => f.kind === 'seed_dml'))
31
+ return 'fixtures';
32
+ return 'schema';
33
+ },
34
+ };
35
+ /**
36
+ * Classify a single change's deploy SQL into its category under `profile`
37
+ * (defaults to {@link TIER_PROFILE}).
38
+ */
39
+ function categorizeChange(sql, changeName, profile = exports.TIER_PROFILE) {
40
+ return profile.categorize((0, facts_1.classifyStatements)(sql), changeName);
41
+ }
42
+ /**
43
+ * Build a `categoryOf(changeName)` seam function from a map of change name to
44
+ * its deploy SQL — ready to pass straight to `@pgpmjs/core`'s rebundle
45
+ * `boundary: 'category'` / `categoryOf` options.
46
+ *
47
+ * Changes absent from `changeSql` return `undefined`, letting core fall back to
48
+ * its folder key for anything the classifier did not see.
49
+ */
50
+ function buildCategoryOf(changeSql, profile = exports.TIER_PROFILE) {
51
+ const categories = new Map();
52
+ for (const [name, sql] of Object.entries(changeSql)) {
53
+ categories.set(name, categorizeChange(sql, name, profile));
54
+ }
55
+ return (changeName) => categories.get(changeName);
56
+ }
@@ -0,0 +1,51 @@
1
+ import { classifyStatements } from './facts';
2
+ /**
3
+ * Default tier profile: isolates the security surface and procedural code out
4
+ * of the schema base. Precedence, highest first — a change is placed in the
5
+ * most specialized tier any of its statements reaches:
6
+ *
7
+ * security any securityRelevant statement (policy/grant/RLS/role/owner)
8
+ * functionality functions and triggers
9
+ * fixtures seed DML (INSERT/UPDATE/DELETE)
10
+ * schema schemas, tables, views, indexes, types, constraints (base)
11
+ *
12
+ * Ordering/dependencies across the resulting chunks are still resolved by
13
+ * core's rebundle graph, so this only decides *naming/grouping*, never deploy
14
+ * order.
15
+ */
16
+ export const TIER_PROFILE = {
17
+ name: 'tier',
18
+ categorize(facts) {
19
+ if (facts.length === 0)
20
+ return 'schema';
21
+ if (facts.some(f => f.securityRelevant))
22
+ return 'security';
23
+ if (facts.some(f => f.kind === 'function' || f.kind === 'trigger'))
24
+ return 'functionality';
25
+ if (facts.some(f => f.kind === 'seed_dml'))
26
+ return 'fixtures';
27
+ return 'schema';
28
+ },
29
+ };
30
+ /**
31
+ * Classify a single change's deploy SQL into its category under `profile`
32
+ * (defaults to {@link TIER_PROFILE}).
33
+ */
34
+ export function categorizeChange(sql, changeName, profile = TIER_PROFILE) {
35
+ return profile.categorize(classifyStatements(sql), changeName);
36
+ }
37
+ /**
38
+ * Build a `categoryOf(changeName)` seam function from a map of change name to
39
+ * its deploy SQL — ready to pass straight to `@pgpmjs/core`'s rebundle
40
+ * `boundary: 'category'` / `categoryOf` options.
41
+ *
42
+ * Changes absent from `changeSql` return `undefined`, letting core fall back to
43
+ * its folder key for anything the classifier did not see.
44
+ */
45
+ export function buildCategoryOf(changeSql, profile = TIER_PROFILE) {
46
+ const categories = new Map();
47
+ for (const [name, sql] of Object.entries(changeSql)) {
48
+ categories.set(name, categorizeChange(sql, name, profile));
49
+ }
50
+ return (changeName) => categories.get(changeName);
51
+ }
package/esm/facts.js ADDED
@@ -0,0 +1,289 @@
1
+ import { walk as walkSql } from '@pgsql/traverse';
2
+ import { transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
3
+ const SECURITY_TAGS = new Set([
4
+ 'CreatePolicyStmt',
5
+ 'AlterPolicyStmt',
6
+ 'GrantStmt',
7
+ 'GrantRoleStmt',
8
+ 'AlterDefaultPrivilegesStmt',
9
+ 'SecLabelStmt',
10
+ 'AlterOwnerStmt',
11
+ 'CreateRoleStmt',
12
+ 'AlterRoleStmt'
13
+ ]);
14
+ const KIND_BY_TAG = {
15
+ CreateSchemaStmt: 'schema',
16
+ CreateStmt: 'table',
17
+ ViewStmt: 'view',
18
+ IndexStmt: 'index',
19
+ CompositeTypeStmt: 'type',
20
+ CreateEnumStmt: 'type',
21
+ CreateDomainStmt: 'type',
22
+ CreateRangeStmt: 'type',
23
+ DefineStmt: 'type',
24
+ CreateFunctionStmt: 'function',
25
+ CreateTrigStmt: 'trigger',
26
+ CreateEventTrigStmt: 'trigger',
27
+ CreatePolicyStmt: 'policy',
28
+ AlterPolicyStmt: 'policy',
29
+ GrantStmt: 'grant',
30
+ GrantRoleStmt: 'grant',
31
+ AlterDefaultPrivilegesStmt: 'grant',
32
+ CommentStmt: 'comment',
33
+ InsertStmt: 'seed_dml',
34
+ UpdateStmt: 'seed_dml',
35
+ DeleteStmt: 'seed_dml'
36
+ };
37
+ function qn(schema, name) {
38
+ return { schema: schema ?? null, name };
39
+ }
40
+ function nameListToQualified(names) {
41
+ if (!Array.isArray(names) || names.length === 0)
42
+ return null;
43
+ const parts = names
44
+ .map((n) => n?.String?.sval)
45
+ .filter((s) => typeof s === 'string');
46
+ if (parts.length === 0)
47
+ return null;
48
+ if (parts.length === 1)
49
+ return qn(null, parts[0]);
50
+ return qn(parts[parts.length - 2], parts[parts.length - 1]);
51
+ }
52
+ function isCatalogSchema(schema) {
53
+ return schema === 'pg_catalog' || schema === 'information_schema';
54
+ }
55
+ function pushRef(refs, ref) {
56
+ if (!ref || !ref.schema || isCatalogSchema(ref.schema))
57
+ return;
58
+ if (refs.some(r => r.schema === ref.schema && r.name === ref.name))
59
+ return;
60
+ refs.push(ref);
61
+ }
62
+ function collectRoles(node, roles) {
63
+ const push = (role) => {
64
+ if (typeof role === 'string' && role.length > 0 && !roles.includes(role)) {
65
+ roles.push(role);
66
+ }
67
+ };
68
+ if (Array.isArray(node?.grantees)) {
69
+ for (const g of node.grantees)
70
+ push(g?.RoleSpec?.rolename);
71
+ }
72
+ if (Array.isArray(node?.roles)) {
73
+ for (const r of node.roles)
74
+ push(r?.RoleSpec?.rolename);
75
+ }
76
+ push(node?.newowner?.rolename);
77
+ push(node?.role?.rolename);
78
+ }
79
+ /**
80
+ * Create a read-only visitor that accumulates references, roles and FK
81
+ * targets into the provided facts object.
82
+ */
83
+ function createFactsVisitor(facts, bodyRefs) {
84
+ const push = (ref) => {
85
+ pushRef(facts.references, ref);
86
+ if (bodyRefs)
87
+ pushRef(bodyRefs, ref);
88
+ };
89
+ return {
90
+ RangeVar: (path) => {
91
+ const node = path.node;
92
+ if (node.schemaname) {
93
+ push(qn(node.schemaname, node.relname));
94
+ }
95
+ },
96
+ FuncCall: (path) => {
97
+ push(nameListToQualified(path.node.funcname));
98
+ },
99
+ TypeName: (path) => {
100
+ push(nameListToQualified(path.node.names));
101
+ },
102
+ ColumnRef: (path) => {
103
+ // schema.table.column references carry cross-schema information
104
+ const fields = path.node.fields;
105
+ if (Array.isArray(fields) && fields.length >= 3) {
106
+ const parts = fields
107
+ .map((f) => f?.String?.sval)
108
+ .filter((s) => typeof s === 'string');
109
+ if (parts.length >= 3) {
110
+ push(qn(parts[0], parts[1]));
111
+ }
112
+ }
113
+ },
114
+ Constraint: (path) => {
115
+ const node = path.node;
116
+ if (node.contype === 'CONSTR_FOREIGN' && node.pktable) {
117
+ const target = qn(node.pktable.schemaname ?? null, node.pktable.relname);
118
+ if (!facts.fkTargets.some(t => t.schema === target.schema && t.name === target.name)) {
119
+ facts.fkTargets.push(target);
120
+ }
121
+ if (target.schema)
122
+ pushRef(facts.references, target);
123
+ }
124
+ }
125
+ };
126
+ }
127
+ function classifyOne(nodeTag, node) {
128
+ const facts = {
129
+ kind: KIND_BY_TAG[nodeTag] ?? 'other',
130
+ nodeTag,
131
+ creates: [],
132
+ references: [],
133
+ referencedSchemas: [],
134
+ roles: [],
135
+ fkTargets: [],
136
+ bodyReferences: [],
137
+ securityRelevant: SECURITY_TAGS.has(nodeTag),
138
+ securityDefiner: false,
139
+ dynamicSql: false
140
+ };
141
+ switch (nodeTag) {
142
+ case 'CreateSchemaStmt':
143
+ facts.creates.push(qn(null, node.schemaname));
144
+ break;
145
+ case 'CreateStmt':
146
+ case 'ViewStmt': {
147
+ const rel = nodeTag === 'ViewStmt' ? node.view : node.relation;
148
+ if (rel)
149
+ facts.creates.push(qn(rel.schemaname ?? null, rel.relname));
150
+ break;
151
+ }
152
+ case 'IndexStmt':
153
+ if (node.relation) {
154
+ facts.creates.push(qn(node.relation.schemaname ?? null, node.idxname ?? node.relation.relname));
155
+ }
156
+ break;
157
+ case 'CreateFunctionStmt': {
158
+ const name = nameListToQualified(node.funcname);
159
+ if (name)
160
+ facts.creates.push(name);
161
+ for (const opt of node.options ?? []) {
162
+ const def = opt?.DefElem;
163
+ if (def?.defname === 'security' && def?.arg?.Boolean?.boolval === true) {
164
+ facts.securityDefiner = true;
165
+ }
166
+ }
167
+ break;
168
+ }
169
+ case 'CreateTrigStmt':
170
+ if (node.relation) {
171
+ // Trigger names are only unique per table; qualify with the table.
172
+ facts.creates.push(qn(node.relation.schemaname ?? null, `${node.relation.relname}.${node.trigname}`));
173
+ }
174
+ pushRef(facts.references, nameListToQualified(node.funcname));
175
+ break;
176
+ case 'CreatePolicyStmt':
177
+ case 'AlterPolicyStmt':
178
+ if (node.table) {
179
+ // Policy names are only unique per table; qualify with the table.
180
+ facts.creates.push(qn(node.table.schemaname ?? null, `${node.table.relname}.${node.policy_name}`));
181
+ // The guarded table lives on `node.table`; the generic RangeVar walker
182
+ // does not descend into it, so capture the reference explicitly.
183
+ if (node.table.schemaname) {
184
+ pushRef(facts.references, qn(node.table.schemaname, node.table.relname));
185
+ }
186
+ }
187
+ break;
188
+ case 'CreateSeqStmt':
189
+ if (node.sequence) {
190
+ facts.creates.push(qn(node.sequence.schemaname ?? null, node.sequence.relname));
191
+ }
192
+ break;
193
+ case 'CompositeTypeStmt':
194
+ if (node.typevar) {
195
+ facts.creates.push(qn(node.typevar.schemaname ?? null, node.typevar.relname));
196
+ }
197
+ break;
198
+ case 'CreateEnumStmt':
199
+ case 'CreateDomainStmt':
200
+ case 'CreateRangeStmt': {
201
+ const name = nameListToQualified(node.typeName ?? node.domainname);
202
+ if (name)
203
+ facts.creates.push(name);
204
+ break;
205
+ }
206
+ case 'AlterTableStmt': {
207
+ if (node.relation) {
208
+ facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
209
+ }
210
+ const cmds = node.cmds ?? [];
211
+ for (const cmd of cmds) {
212
+ const subtype = cmd?.AlterTableCmd?.subtype;
213
+ if (subtype === 'AT_EnableRowSecurity' || subtype === 'AT_ForceRowSecurity' ||
214
+ subtype === 'AT_DisableRowSecurity' || subtype === 'AT_NoForceRowSecurity') {
215
+ facts.kind = 'rls_enable';
216
+ facts.securityRelevant = true;
217
+ }
218
+ else if (subtype === 'AT_AddConstraint') {
219
+ const contype = cmd?.AlterTableCmd?.def?.Constraint?.contype;
220
+ facts.kind = contype === 'CONSTR_FOREIGN' ? 'fk_constraint' : 'constraint';
221
+ }
222
+ else if (subtype === 'AT_ChangeOwner') {
223
+ facts.securityRelevant = true;
224
+ const owner = cmd?.AlterTableCmd?.newowner?.rolename;
225
+ if (owner && !facts.roles.includes(owner))
226
+ facts.roles.push(owner);
227
+ }
228
+ }
229
+ break;
230
+ }
231
+ case 'InsertStmt':
232
+ case 'UpdateStmt':
233
+ case 'DeleteStmt':
234
+ if (node.relation) {
235
+ facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
236
+ }
237
+ break;
238
+ default:
239
+ break;
240
+ }
241
+ collectRoles(node, facts.roles);
242
+ return facts;
243
+ }
244
+ /**
245
+ * Classify each top-level statement in a SQL script into {@link StatementFacts}.
246
+ *
247
+ * Uses the same parser stack as the schema transformer (pgsql AST walk plus
248
+ * hydrated PL/pgSQL body walk), so references inside function bodies are
249
+ * included. The input SQL is never modified.
250
+ */
251
+ export function classifyStatements(sql) {
252
+ const allFacts = [];
253
+ transformSync(sql, (ctx) => {
254
+ const stmts = ctx.sql?.stmts ?? [];
255
+ for (const stmt of stmts) {
256
+ const stmtNode = stmt?.stmt;
257
+ const nodeTag = stmtNode ? Object.keys(stmtNode)[0] : 'other';
258
+ const node = stmtNode?.[nodeTag] ?? {};
259
+ const facts = classifyOne(nodeTag, node);
260
+ if (stmtNode) {
261
+ walkSql(stmtNode, createFactsVisitor(facts));
262
+ }
263
+ allFacts.push(facts);
264
+ }
265
+ for (const fn of ctx.functions ?? []) {
266
+ const facts = allFacts[fn.stmtIndex];
267
+ if (!facts || !fn.plpgsql?.hydrated)
268
+ continue;
269
+ // Refs already found outside the body (signature types, defaults)
270
+ // constrain deploy order and are not body-only.
271
+ const outer = new Set(facts.references.map(r => `${r.schema ?? '?'}.${r.name}`));
272
+ walkPlpgsql(fn.plpgsql.hydrated, {
273
+ PLpgSQL_stmt_dynexecute: () => { facts.dynamicSql = true; },
274
+ PLpgSQL_stmt_dynfors: () => { facts.dynamicSql = true; }
275
+ }, {
276
+ walkSqlExpressions: true,
277
+ sqlVisitor: createFactsVisitor(facts, facts.bodyReferences)
278
+ });
279
+ facts.bodyReferences = facts.bodyReferences.filter(r => !outer.has(`${r.schema ?? '?'}.${r.name}`));
280
+ }
281
+ }, { hydrate: true });
282
+ for (const facts of allFacts) {
283
+ const selfRef = (r) => facts.creates.some(c => c.schema === r.schema && c.name === r.name);
284
+ facts.references = facts.references.filter(r => !selfRef(r));
285
+ facts.bodyReferences = facts.bodyReferences.filter(r => !selfRef(r));
286
+ facts.referencedSchemas = [...new Set(facts.references.map(r => r.schema).filter(Boolean))];
287
+ }
288
+ return allFacts;
289
+ }