@pgsql/transform 18.1.0 → 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.
- package/README.md +29 -73
- package/categorize.d.ts +52 -0
- package/categorize.js +56 -0
- package/esm/categorize.js +51 -0
- package/esm/facts.js +289 -0
- package/esm/fixture-closure.js +197 -0
- package/esm/index.js +7 -22
- package/esm/qualify.js +297 -0
- package/esm/round-trip-core.js +126 -0
- package/esm/round-trip.js +116 -0
- package/esm/transform.js +1169 -0
- package/facts.d.ts +69 -0
- package/facts.js +292 -0
- package/fixture-closure.d.ts +71 -0
- package/fixture-closure.js +200 -0
- package/index.d.ts +14 -28
- package/index.js +43 -76
- package/package.json +22 -14
- package/qualify.d.ts +125 -0
- package/qualify.js +303 -0
- package/round-trip-core.d.ts +60 -0
- package/round-trip-core.js +133 -0
- package/round-trip.d.ts +47 -0
- package/round-trip.js +123 -0
- package/transform.d.ts +259 -0
- package/transform.js +1190 -0
- package/13/enums.d.ts +0 -62
- package/13/enums.js +0 -2
- package/13/types.d.ts +0 -2103
- package/13/types.js +0 -2
- package/14/enums.d.ts +0 -62
- package/14/enums.js +0 -2
- package/14/types.d.ts +0 -2164
- package/14/types.js +0 -2
- package/15/enums.d.ts +0 -64
- package/15/enums.js +0 -2
- package/15/types.d.ts +0 -2222
- package/15/types.js +0 -2
- package/16/enums.d.ts +0 -69
- package/16/enums.js +0 -2
- package/16/types.d.ts +0 -2328
- package/16/types.js +0 -2
- package/17/enums.d.ts +0 -76
- package/17/enums.js +0 -2
- package/17/types.d.ts +0 -2488
- package/17/types.js +0 -2
- package/18/enums.d.ts +0 -78
- package/18/enums.js +0 -2
- package/18/types.d.ts +0 -2538
- package/18/types.js +0 -2
- package/esm/13/enums.js +0 -1
- package/esm/13/types.js +0 -1
- package/esm/14/enums.js +0 -1
- package/esm/14/types.js +0 -1
- package/esm/15/enums.js +0 -1
- package/esm/15/types.js +0 -1
- package/esm/16/enums.js +0 -1
- package/esm/16/types.js +0 -1
- package/esm/17/enums.js +0 -1
- package/esm/17/types.js +0 -1
- package/esm/18/enums.js +0 -1
- package/esm/18/types.js +0 -1
- package/esm/multi-version-transformer.js +0 -80
- package/esm/transformers/context.js +0 -1
- package/esm/transformers/v13-to-v14.js +0 -2767
- package/esm/transformers/v14-to-v15.js +0 -1218
- package/esm/transformers/v15-to-v16.js +0 -2935
- package/esm/transformers/v16-to-v17.js +0 -1526
- package/esm/transformers/v17-to-v18.js +0 -204
- package/esm/transformers-direct/index.js +0 -13
- package/esm/transformers-direct/v13-to-v17/index.js +0 -74
- package/esm/transformers-direct/v13-to-v18/index.js +0 -84
- package/esm/transformers-direct/v14-to-v17/index.js +0 -63
- package/esm/transformers-direct/v14-to-v18/index.js +0 -73
- package/esm/transformers-direct/v15-to-v17/index.js +0 -53
- package/esm/transformers-direct/v15-to-v18/index.js +0 -62
- package/esm/transformers-direct/v16-to-v17/index.js +0 -40
- package/esm/transformers-direct/v16-to-v18/index.js +0 -51
- package/esm/transformers-direct/v17-to-v18/index.js +0 -39
- package/multi-version-transformer.d.ts +0 -14
- package/multi-version-transformer.js +0 -84
- package/transformers/context.d.ts +0 -4
- package/transformers/context.js +0 -2
- package/transformers/v13-to-v14.d.ts +0 -255
- package/transformers/v13-to-v14.js +0 -2771
- package/transformers/v14-to-v15.d.ts +0 -615
- package/transformers/v14-to-v15.js +0 -1222
- package/transformers/v15-to-v16.d.ts +0 -632
- package/transformers/v15-to-v16.js +0 -2939
- package/transformers/v16-to-v17.d.ts +0 -640
- package/transformers/v16-to-v17.js +0 -1530
- package/transformers/v17-to-v18.d.ts +0 -54
- package/transformers/v17-to-v18.js +0 -208
- package/transformers-direct/index.d.ts +0 -13
- package/transformers-direct/index.js +0 -25
- package/transformers-direct/v13-to-v17/index.d.ts +0 -27
- package/transformers-direct/v13-to-v17/index.js +0 -78
- package/transformers-direct/v13-to-v18/index.d.ts +0 -28
- package/transformers-direct/v13-to-v18/index.js +0 -88
- package/transformers-direct/v14-to-v17/index.d.ts +0 -26
- package/transformers-direct/v14-to-v17/index.js +0 -67
- package/transformers-direct/v14-to-v18/index.d.ts +0 -27
- package/transformers-direct/v14-to-v18/index.js +0 -77
- package/transformers-direct/v15-to-v17/index.d.ts +0 -25
- package/transformers-direct/v15-to-v17/index.js +0 -57
- package/transformers-direct/v15-to-v18/index.d.ts +0 -26
- package/transformers-direct/v15-to-v18/index.js +0 -66
- package/transformers-direct/v16-to-v17/index.d.ts +0 -24
- package/transformers-direct/v16-to-v17/index.js +0 -44
- package/transformers-direct/v16-to-v18/index.d.ts +0 -25
- package/transformers-direct/v16-to-v18/index.js +0 -55
- package/transformers-direct/v17-to-v18/index.d.ts +0 -24
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
const result = transformer.transform13To17(pg13Ast);
|
|
58
|
-
```
|
|
27
|
+
## Features
|
|
59
28
|
|
|
60
|
-
###
|
|
29
|
+
### `transformSql` — schema-name rewriting
|
|
61
30
|
|
|
62
|
-
|
|
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 {
|
|
34
|
+
import { loadModule } from 'plpgsql-parser';
|
|
35
|
+
import { transformSql } from '@pgsql/transform';
|
|
66
36
|
|
|
67
|
-
|
|
68
|
-
const
|
|
37
|
+
await loadModule();
|
|
38
|
+
const mapping = new Map([['my-schema', 'my_schema']]);
|
|
39
|
+
const { sql } = transformSql(inputSql, mapping);
|
|
69
40
|
```
|
|
70
41
|
|
|
71
|
-
###
|
|
42
|
+
### `classifyStatements` — per-statement AST facts
|
|
72
43
|
|
|
73
44
|
```typescript
|
|
74
|
-
import {
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
- Step-by-step version upgrades
|
|
93
|
-
- Useful for debugging transformation issues
|
|
57
|
+
### `resolveFixtureClosure` — transitive dependency closure
|
|
94
58
|
|
|
95
|
-
|
|
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
|
-
|
|
61
|
+
### `categorizeChange` / `buildCategoryOf` — change categorization
|
|
100
62
|
|
|
101
|
-
|
|
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
|
-
|
|
65
|
+
### Round-trip validation
|
|
109
66
|
|
|
110
|
-
|
|
67
|
+
`normalizeTree` / `cleanTree` / `validateRoundTrip` — dependency-free AST normalization and mutation-aware parse→deparse→re-parse validation.
|
|
111
68
|
|
|
112
|
-
|
|
113
|
-
2. **Transform** ASTs to the latest version
|
|
114
|
-
3. **Deparse** using the unified, latest-version deparser
|
|
69
|
+
## Scripts
|
|
115
70
|
|
|
116
|
-
|
|
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
|
|
package/categorize.d.ts
ADDED
|
@@ -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
|
+
}
|