@pgsql/semantics 18.1.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/LICENSE +21 -0
- package/README.md +78 -0
- package/esm/facts.js +393 -0
- package/esm/index.js +1 -0
- package/facts.d.ts +128 -0
- package/facts.js +396 -0
- package/index.d.ts +2 -0
- package/index.js +5 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Dan Lynch <pyramation@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @pgsql/semantics
|
|
2
|
+
|
|
3
|
+
<p align="center" width="100%">
|
|
4
|
+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center" width="100%">
|
|
8
|
+
<a href="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml">
|
|
9
|
+
<img height="20" src="https://github.com/constructive-io/pgsql-parser/actions/workflows/run-tests.yaml/badge.svg" />
|
|
10
|
+
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/pgsql-parser/blob/main/LICENSE-MIT"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/@pgsql/semantics"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/pgsql-parser?filename=packages%2Fsemantics%2Fpackage.json"/></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
AST-derived **semantic facts** for PostgreSQL statements: given a SQL script,
|
|
16
|
+
extract what each statement *is*, what it *creates*, and what it *references* —
|
|
17
|
+
including references reached inside PL/pgSQL function bodies (via `plpgsql-parser`
|
|
18
|
+
hydration). Read-only: the input is never modified.
|
|
19
|
+
|
|
20
|
+
This is the fact-extraction layer that grew up inside [`@pgsql/transform`](../transform).
|
|
21
|
+
It extracts *semantics* (which relations/functions/types a statement reads and
|
|
22
|
+
creates, which namespaces it touches), independent of any transformation.
|
|
23
|
+
`@pgsql/transform` re-exports the same symbols, so existing consumers are
|
|
24
|
+
unaffected.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @pgsql/semantics
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The parser runs on a WASM build of the real PostgreSQL parser; call `loadModule()`
|
|
33
|
+
from `plpgsql-parser` once before using any synchronous API.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { loadModule } from 'plpgsql-parser';
|
|
39
|
+
import { classifyStatements } from '@pgsql/semantics';
|
|
40
|
+
|
|
41
|
+
await loadModule();
|
|
42
|
+
|
|
43
|
+
const facts = classifyStatements(sql);
|
|
44
|
+
// per statement: kind (schema|table|view|index|type|function|trigger|policy|grant|...),
|
|
45
|
+
// creates, references (incl. inside PL/pgSQL bodies), bodyReferences,
|
|
46
|
+
// referencedSchemas, roles, fkTargets, extension, securityRelevant,
|
|
47
|
+
// securityDefiner, dynamicSql, span, stmt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Exports
|
|
51
|
+
|
|
52
|
+
- `classifyStatements(sql)` — classify each top-level statement into `StatementFacts[]`.
|
|
53
|
+
- Types: `StatementFacts`, `StatementKind`, `QualifiedName`, `ExtensionFact`, `ExtensionAction`, `StatementSpan`.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
58
|
+
|
|
59
|
+
## Related
|
|
60
|
+
|
|
61
|
+
* [pgpm](https://pgpm.dev): A Postgres Package Manager that brings modular development to PostgreSQL with reusable packages, deterministic migrations, recursive dependency resolution, and tag-aware versioning.
|
|
62
|
+
* [pgsql-test](https://www.npmjs.com/package/pgsql-test): Instant, isolated PostgreSQL databases for each test with automatic transaction rollbacks, context switching, and clean seeding for fast, reliable database testing.
|
|
63
|
+
* [pgsql-seed](https://www.npmjs.com/package/pgsql-seed): PostgreSQL seeding utilities for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
64
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): The real PostgreSQL parser for Node.js, providing symmetric parsing and deparsing of SQL statements with actual PostgreSQL parser integration.
|
|
65
|
+
* [pgsql-deparser](https://www.npmjs.com/package/pgsql-deparser): A streamlined tool designed for converting PostgreSQL ASTs back into SQL queries, focusing solely on deparser functionality to complement `pgsql-parser`.
|
|
66
|
+
* [@pgsql/parser](https://www.npmjs.com/package/@pgsql/parser): Multi-version PostgreSQL parser with dynamic version selection at runtime, supporting PostgreSQL 15, 16, and 17 in a single package.
|
|
67
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): Offers TypeScript type definitions for PostgreSQL AST nodes, facilitating type-safe construction, analysis, and manipulation of ASTs.
|
|
68
|
+
* [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): Provides TypeScript enum definitions for PostgreSQL constants, enabling type-safe usage of PostgreSQL enums and constants in your applications.
|
|
69
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): A comprehensive utility library for PostgreSQL, offering type-safe AST node creation and enum value conversions, simplifying the construction and manipulation of PostgreSQL ASTs.
|
|
70
|
+
* [@pgsql/traverse](https://www.npmjs.com/package/@pgsql/traverse): PostgreSQL AST traversal utilities for pgsql-parser, providing a visitor pattern for traversing PostgreSQL Abstract Syntax Tree nodes, similar to Babel's traverse functionality but specifically designed for PostgreSQL AST structures.
|
|
71
|
+
* [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): A TypeScript tool that parses PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
72
|
+
* [libpg-query](https://github.com/constructive-io/libpg-query-node): The real PostgreSQL parser exposed for Node.js, used primarily in `pgsql-parser` for parsing and deparsing SQL queries.
|
|
73
|
+
|
|
74
|
+
## Disclaimer
|
|
75
|
+
|
|
76
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
77
|
+
|
|
78
|
+
No developer or entity involved in creating Software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the Software code or Software CLI, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
package/esm/facts.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { walkSqlAst } from '@pgsql/traverse';
|
|
2
|
+
import { parseSql, transformSync, walkPlpgsqlAst } 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
|
+
CreateExtensionStmt: 'extension',
|
|
17
|
+
CreateStmt: 'table',
|
|
18
|
+
ViewStmt: 'view',
|
|
19
|
+
IndexStmt: 'index',
|
|
20
|
+
CompositeTypeStmt: 'type',
|
|
21
|
+
CreateEnumStmt: 'type',
|
|
22
|
+
CreateDomainStmt: 'type',
|
|
23
|
+
CreateRangeStmt: 'type',
|
|
24
|
+
DefineStmt: 'type',
|
|
25
|
+
CreateFunctionStmt: 'function',
|
|
26
|
+
CreateTrigStmt: 'trigger',
|
|
27
|
+
CreateEventTrigStmt: 'trigger',
|
|
28
|
+
CreatePolicyStmt: 'policy',
|
|
29
|
+
AlterPolicyStmt: 'policy',
|
|
30
|
+
GrantStmt: 'grant',
|
|
31
|
+
GrantRoleStmt: 'grant',
|
|
32
|
+
AlterDefaultPrivilegesStmt: 'grant',
|
|
33
|
+
CommentStmt: 'comment',
|
|
34
|
+
InsertStmt: 'seed_dml',
|
|
35
|
+
UpdateStmt: 'seed_dml',
|
|
36
|
+
DeleteStmt: 'seed_dml'
|
|
37
|
+
};
|
|
38
|
+
function qn(schema, name) {
|
|
39
|
+
return { schema: schema ?? null, name };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the `SCHEMA <name>` clause of a `CreateExtensionStmt` from its options
|
|
43
|
+
* (`{ DefElem: { defname: 'schema', arg: { String: { sval } } } }`), or `null`
|
|
44
|
+
* when the statement specifies no schema.
|
|
45
|
+
*/
|
|
46
|
+
function extensionSchemaOption(options) {
|
|
47
|
+
if (!Array.isArray(options))
|
|
48
|
+
return null;
|
|
49
|
+
for (const opt of options) {
|
|
50
|
+
const def = opt?.DefElem;
|
|
51
|
+
if (def?.defname === 'schema') {
|
|
52
|
+
const sval = def.arg?.String?.sval;
|
|
53
|
+
return typeof sval === 'string' ? sval : null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function nameListToQualified(names) {
|
|
59
|
+
if (!Array.isArray(names) || names.length === 0)
|
|
60
|
+
return null;
|
|
61
|
+
const parts = names
|
|
62
|
+
.map((n) => n?.String?.sval)
|
|
63
|
+
.filter((s) => typeof s === 'string');
|
|
64
|
+
if (parts.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
if (parts.length === 1)
|
|
67
|
+
return qn(null, parts[0]);
|
|
68
|
+
return qn(parts[parts.length - 2], parts[parts.length - 1]);
|
|
69
|
+
}
|
|
70
|
+
function isCatalogSchema(schema) {
|
|
71
|
+
return schema === 'pg_catalog' || schema === 'information_schema';
|
|
72
|
+
}
|
|
73
|
+
function pushRef(refs, ref) {
|
|
74
|
+
if (!ref || !ref.schema || isCatalogSchema(ref.schema))
|
|
75
|
+
return;
|
|
76
|
+
if (refs.some(r => r.schema === ref.schema && r.name === ref.name))
|
|
77
|
+
return;
|
|
78
|
+
refs.push(ref);
|
|
79
|
+
}
|
|
80
|
+
function collectRoles(node, roles) {
|
|
81
|
+
const push = (role) => {
|
|
82
|
+
if (typeof role === 'string' && role.length > 0 && !roles.includes(role)) {
|
|
83
|
+
roles.push(role);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
if (Array.isArray(node?.grantees)) {
|
|
87
|
+
for (const g of node.grantees)
|
|
88
|
+
push(g?.RoleSpec?.rolename);
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(node?.roles)) {
|
|
91
|
+
for (const r of node.roles)
|
|
92
|
+
push(r?.RoleSpec?.rolename);
|
|
93
|
+
}
|
|
94
|
+
push(node?.newowner?.rolename);
|
|
95
|
+
push(node?.role?.rolename);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Create a read-only visitor that accumulates references, roles and FK
|
|
99
|
+
* targets into the provided facts object.
|
|
100
|
+
*/
|
|
101
|
+
function createFactsVisitor(facts, bodyRefs) {
|
|
102
|
+
const push = (ref) => {
|
|
103
|
+
pushRef(facts.references, ref);
|
|
104
|
+
if (bodyRefs)
|
|
105
|
+
pushRef(bodyRefs, ref);
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
RangeVar: (path) => {
|
|
109
|
+
const node = path.node;
|
|
110
|
+
if (node.schemaname) {
|
|
111
|
+
push(qn(node.schemaname, node.relname));
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
FuncCall: (path) => {
|
|
115
|
+
push(nameListToQualified(path.node.funcname));
|
|
116
|
+
},
|
|
117
|
+
TypeName: (path) => {
|
|
118
|
+
push(nameListToQualified(path.node.names));
|
|
119
|
+
},
|
|
120
|
+
ColumnRef: (path) => {
|
|
121
|
+
// schema.table.column references carry cross-schema information
|
|
122
|
+
const fields = path.node.fields;
|
|
123
|
+
if (Array.isArray(fields) && fields.length >= 3) {
|
|
124
|
+
const parts = fields
|
|
125
|
+
.map((f) => f?.String?.sval)
|
|
126
|
+
.filter((s) => typeof s === 'string');
|
|
127
|
+
if (parts.length >= 3) {
|
|
128
|
+
push(qn(parts[0], parts[1]));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
Constraint: (path) => {
|
|
133
|
+
const node = path.node;
|
|
134
|
+
if (node.contype === 'CONSTR_FOREIGN' && node.pktable) {
|
|
135
|
+
const target = qn(node.pktable.schemaname ?? null, node.pktable.relname);
|
|
136
|
+
if (!facts.fkTargets.some(t => t.schema === target.schema && t.name === target.name)) {
|
|
137
|
+
facts.fkTargets.push(target);
|
|
138
|
+
}
|
|
139
|
+
if (target.schema)
|
|
140
|
+
pushRef(facts.references, target);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function classifyOne(nodeTag, node) {
|
|
146
|
+
const facts = {
|
|
147
|
+
kind: KIND_BY_TAG[nodeTag] ?? 'other',
|
|
148
|
+
nodeTag,
|
|
149
|
+
creates: [],
|
|
150
|
+
references: [],
|
|
151
|
+
referencedSchemas: [],
|
|
152
|
+
roles: [],
|
|
153
|
+
fkTargets: [],
|
|
154
|
+
bodyReferences: [],
|
|
155
|
+
securityRelevant: SECURITY_TAGS.has(nodeTag),
|
|
156
|
+
securityDefiner: false,
|
|
157
|
+
dynamicSql: false,
|
|
158
|
+
span: { start: 0, len: 0 }
|
|
159
|
+
};
|
|
160
|
+
switch (nodeTag) {
|
|
161
|
+
case 'CreateSchemaStmt':
|
|
162
|
+
facts.creates.push(qn(null, node.schemaname));
|
|
163
|
+
break;
|
|
164
|
+
case 'CreateExtensionStmt':
|
|
165
|
+
facts.extension = {
|
|
166
|
+
name: node.extname,
|
|
167
|
+
schema: extensionSchemaOption(node.options),
|
|
168
|
+
action: 'create',
|
|
169
|
+
ifNotExists: node.if_not_exists === true
|
|
170
|
+
};
|
|
171
|
+
break;
|
|
172
|
+
case 'CreateStmt':
|
|
173
|
+
case 'ViewStmt': {
|
|
174
|
+
const rel = nodeTag === 'ViewStmt' ? node.view : node.relation;
|
|
175
|
+
if (rel)
|
|
176
|
+
facts.creates.push(qn(rel.schemaname ?? null, rel.relname));
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'IndexStmt':
|
|
180
|
+
if (node.relation) {
|
|
181
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.idxname ?? node.relation.relname));
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
case 'CreateFunctionStmt': {
|
|
185
|
+
const name = nameListToQualified(node.funcname);
|
|
186
|
+
if (name)
|
|
187
|
+
facts.creates.push(name);
|
|
188
|
+
for (const opt of node.options ?? []) {
|
|
189
|
+
const def = opt?.DefElem;
|
|
190
|
+
if (def?.defname === 'security' && def?.arg?.Boolean?.boolval === true) {
|
|
191
|
+
facts.securityDefiner = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case 'CreateTrigStmt':
|
|
197
|
+
if (node.relation) {
|
|
198
|
+
// Trigger names are only unique per table; qualify with the table.
|
|
199
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, `${node.relation.relname}.${node.trigname}`));
|
|
200
|
+
}
|
|
201
|
+
pushRef(facts.references, nameListToQualified(node.funcname));
|
|
202
|
+
break;
|
|
203
|
+
case 'CreatePolicyStmt':
|
|
204
|
+
case 'AlterPolicyStmt':
|
|
205
|
+
if (node.table) {
|
|
206
|
+
// Policy names are only unique per table; qualify with the table.
|
|
207
|
+
facts.creates.push(qn(node.table.schemaname ?? null, `${node.table.relname}.${node.policy_name}`));
|
|
208
|
+
// The guarded table lives on `node.table`; the generic RangeVar walker
|
|
209
|
+
// does not descend into it, so capture the reference explicitly.
|
|
210
|
+
if (node.table.schemaname) {
|
|
211
|
+
pushRef(facts.references, qn(node.table.schemaname, node.table.relname));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case 'CreateSeqStmt':
|
|
216
|
+
if (node.sequence) {
|
|
217
|
+
facts.creates.push(qn(node.sequence.schemaname ?? null, node.sequence.relname));
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case 'CompositeTypeStmt':
|
|
221
|
+
if (node.typevar) {
|
|
222
|
+
facts.creates.push(qn(node.typevar.schemaname ?? null, node.typevar.relname));
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case 'CreateEnumStmt':
|
|
226
|
+
case 'CreateRangeStmt': {
|
|
227
|
+
const name = nameListToQualified(node.typeName);
|
|
228
|
+
if (name)
|
|
229
|
+
facts.creates.push(name);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'CreateDomainStmt': {
|
|
233
|
+
// `typeName` on a domain is the base type; the name is `domainname`.
|
|
234
|
+
const name = nameListToQualified(node.domainname);
|
|
235
|
+
if (name)
|
|
236
|
+
facts.creates.push(name);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'AlterObjectSchemaStmt':
|
|
240
|
+
// ALTER EXTENSION <name> SET SCHEMA <newschema>. Other object types
|
|
241
|
+
// (TABLE/TYPE/FUNCTION ... SET SCHEMA) keep their default classification.
|
|
242
|
+
if (node.objectType === 'OBJECT_EXTENSION') {
|
|
243
|
+
const name = node.object?.String?.sval;
|
|
244
|
+
if (typeof name === 'string') {
|
|
245
|
+
facts.kind = 'extension';
|
|
246
|
+
facts.extension = {
|
|
247
|
+
name,
|
|
248
|
+
schema: typeof node.newschema === 'string' ? node.newschema : null,
|
|
249
|
+
action: 'set_schema'
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
case 'DropStmt':
|
|
255
|
+
// DROP EXTENSION <name> [, ...]. Extension names are bare String nodes.
|
|
256
|
+
// Other DROP object types keep their default classification. When
|
|
257
|
+
// several extensions are dropped in one statement the first names the
|
|
258
|
+
// fact; the full list stays available via the raw node.
|
|
259
|
+
if (node.removeType === 'OBJECT_EXTENSION' && Array.isArray(node.objects)) {
|
|
260
|
+
const name = node.objects[0]?.String?.sval;
|
|
261
|
+
if (typeof name === 'string') {
|
|
262
|
+
facts.kind = 'extension';
|
|
263
|
+
facts.extension = { name, schema: null, action: 'drop' };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
case 'AlterTableStmt': {
|
|
268
|
+
if (node.relation) {
|
|
269
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
|
|
270
|
+
}
|
|
271
|
+
const cmds = node.cmds ?? [];
|
|
272
|
+
for (const cmd of cmds) {
|
|
273
|
+
const subtype = cmd?.AlterTableCmd?.subtype;
|
|
274
|
+
if (subtype === 'AT_EnableRowSecurity' || subtype === 'AT_ForceRowSecurity' ||
|
|
275
|
+
subtype === 'AT_DisableRowSecurity' || subtype === 'AT_NoForceRowSecurity') {
|
|
276
|
+
facts.kind = 'rls_enable';
|
|
277
|
+
facts.securityRelevant = true;
|
|
278
|
+
}
|
|
279
|
+
else if (subtype === 'AT_AddConstraint') {
|
|
280
|
+
const contype = cmd?.AlterTableCmd?.def?.Constraint?.contype;
|
|
281
|
+
facts.kind = contype === 'CONSTR_FOREIGN' ? 'fk_constraint' : 'constraint';
|
|
282
|
+
}
|
|
283
|
+
else if (subtype === 'AT_ChangeOwner') {
|
|
284
|
+
facts.securityRelevant = true;
|
|
285
|
+
const owner = cmd?.AlterTableCmd?.newowner?.rolename;
|
|
286
|
+
if (owner && !facts.roles.includes(owner))
|
|
287
|
+
facts.roles.push(owner);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case 'InsertStmt':
|
|
293
|
+
case 'UpdateStmt':
|
|
294
|
+
case 'DeleteStmt':
|
|
295
|
+
if (node.relation) {
|
|
296
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
default:
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
collectRoles(node, facts.roles);
|
|
303
|
+
return facts;
|
|
304
|
+
}
|
|
305
|
+
/** Read a `CreateFunctionStmt` DefElem option's scalar/list value. */
|
|
306
|
+
function functionOption(node, defname) {
|
|
307
|
+
for (const opt of node.options ?? []) {
|
|
308
|
+
if (opt?.DefElem?.defname === defname)
|
|
309
|
+
return opt.DefElem.arg;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Collect references from a `LANGUAGE sql` function body supplied as a string
|
|
315
|
+
* literal (`AS $$ ... $$`). That body is an opaque String node the AST walker
|
|
316
|
+
* never parses, so — mirroring the schema transformer's body rewrite — parse
|
|
317
|
+
* it standalone and walk each statement with the facts visitor. The standard
|
|
318
|
+
* `BEGIN ATOMIC` / `RETURN` `sql_body` form is already part of the AST and is
|
|
319
|
+
* covered by the outer walk, so only the string form needs this.
|
|
320
|
+
*/
|
|
321
|
+
function collectSqlBodyReferences(node, facts) {
|
|
322
|
+
const language = functionOption(node, 'language')?.String?.sval;
|
|
323
|
+
if (typeof language !== 'string' || language.toLowerCase() !== 'sql')
|
|
324
|
+
return;
|
|
325
|
+
const asArg = functionOption(node, 'as');
|
|
326
|
+
const items = asArg?.List?.items ?? [];
|
|
327
|
+
const body = items[0]?.String?.sval;
|
|
328
|
+
if (typeof body !== 'string')
|
|
329
|
+
return;
|
|
330
|
+
try {
|
|
331
|
+
const stmts = parseSql(body)?.stmts ?? [];
|
|
332
|
+
const visitor = createFactsVisitor(facts, facts.bodyReferences);
|
|
333
|
+
for (const stmt of stmts) {
|
|
334
|
+
if (stmt?.stmt)
|
|
335
|
+
walkSqlAst(stmt.stmt, visitor);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// A non-parseable body (C symbol name, etc.) contributes no references.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Classify each top-level statement in a SQL script into {@link StatementFacts}.
|
|
344
|
+
*
|
|
345
|
+
* Uses the same parser stack as the schema transformer (pgsql AST walk plus
|
|
346
|
+
* hydrated PL/pgSQL body walk), so references inside function bodies are
|
|
347
|
+
* included. The input SQL is never modified.
|
|
348
|
+
*/
|
|
349
|
+
export function classifyStatements(sql) {
|
|
350
|
+
const allFacts = [];
|
|
351
|
+
transformSync(sql, (ctx) => {
|
|
352
|
+
const stmts = ctx.sql?.stmts ?? [];
|
|
353
|
+
for (const stmt of stmts) {
|
|
354
|
+
const stmtNode = stmt?.stmt;
|
|
355
|
+
const nodeTag = stmtNode ? Object.keys(stmtNode)[0] : 'other';
|
|
356
|
+
const node = stmtNode?.[nodeTag] ?? {};
|
|
357
|
+
const facts = classifyOne(nodeTag, node);
|
|
358
|
+
const start = stmt?.stmt_location ?? 0;
|
|
359
|
+
facts.span = { start, len: stmt?.stmt_len ?? Math.max(0, sql.length - start) };
|
|
360
|
+
if (stmtNode) {
|
|
361
|
+
facts.stmt = stmtNode;
|
|
362
|
+
walkSqlAst(stmtNode, createFactsVisitor(facts));
|
|
363
|
+
}
|
|
364
|
+
if (nodeTag === 'CreateFunctionStmt') {
|
|
365
|
+
collectSqlBodyReferences(node, facts);
|
|
366
|
+
}
|
|
367
|
+
allFacts.push(facts);
|
|
368
|
+
}
|
|
369
|
+
for (const fn of ctx.functions ?? []) {
|
|
370
|
+
const facts = allFacts[fn.stmtIndex];
|
|
371
|
+
if (!facts || !fn.plpgsql?.hydrated)
|
|
372
|
+
continue;
|
|
373
|
+
// Refs already found outside the body (signature types, defaults)
|
|
374
|
+
// constrain deploy order and are not body-only.
|
|
375
|
+
const outer = new Set(facts.references.map(r => `${r.schema ?? '?'}.${r.name}`));
|
|
376
|
+
walkPlpgsqlAst(fn.plpgsql.hydrated, {
|
|
377
|
+
PLpgSQL_stmt_dynexecute: () => { facts.dynamicSql = true; },
|
|
378
|
+
PLpgSQL_stmt_dynfors: () => { facts.dynamicSql = true; }
|
|
379
|
+
}, {
|
|
380
|
+
walkSqlExpressions: true,
|
|
381
|
+
sqlVisitor: createFactsVisitor(facts, facts.bodyReferences)
|
|
382
|
+
});
|
|
383
|
+
facts.bodyReferences = facts.bodyReferences.filter(r => !outer.has(`${r.schema ?? '?'}.${r.name}`));
|
|
384
|
+
}
|
|
385
|
+
}, { hydrate: true });
|
|
386
|
+
for (const facts of allFacts) {
|
|
387
|
+
const selfRef = (r) => facts.creates.some(c => c.schema === r.schema && c.name === r.name);
|
|
388
|
+
facts.references = facts.references.filter(r => !selfRef(r));
|
|
389
|
+
facts.bodyReferences = facts.bodyReferences.filter(r => !selfRef(r));
|
|
390
|
+
facts.referencedSchemas = [...new Set(facts.references.map(r => r.schema).filter(Boolean))];
|
|
391
|
+
}
|
|
392
|
+
return allFacts;
|
|
393
|
+
}
|
package/esm/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { classifyStatements } from './facts';
|
package/facts.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A (possibly schema-qualified) object name extracted from a statement.
|
|
3
|
+
*/
|
|
4
|
+
export interface QualifiedName {
|
|
5
|
+
schema: string | null;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Coarse statement classification used for tier/package sorting.
|
|
10
|
+
*/
|
|
11
|
+
export type StatementKind = 'schema' | 'extension' | 'table' | 'view' | 'index' | 'type' | 'function' | 'trigger' | 'policy' | 'grant' | 'rls_enable' | 'fk_constraint' | 'constraint' | 'comment' | 'seed_dml' | 'other';
|
|
12
|
+
/**
|
|
13
|
+
* The action a statement performs on a PostgreSQL extension.
|
|
14
|
+
*
|
|
15
|
+
* - `create` — `CREATE EXTENSION` (`CreateExtensionStmt`).
|
|
16
|
+
* - `set_schema` — `ALTER EXTENSION ... SET SCHEMA` (`AlterObjectSchemaStmt`
|
|
17
|
+
* with `objectType: OBJECT_EXTENSION`); only succeeds for relocatable
|
|
18
|
+
* extensions, so it is surfaced as its own action.
|
|
19
|
+
* - `drop` — `DROP EXTENSION` (`DropStmt` with `removeType: OBJECT_EXTENSION`).
|
|
20
|
+
*/
|
|
21
|
+
export type ExtensionAction = 'create' | 'set_schema' | 'drop';
|
|
22
|
+
/**
|
|
23
|
+
* Facts about an extension-level statement. Unlike ordinary objects, an
|
|
24
|
+
* extension is installed into exactly one schema and its member objects are
|
|
25
|
+
* renamed with it, so the relevant fact is the extension name plus the schema
|
|
26
|
+
* it is (being) placed in.
|
|
27
|
+
*/
|
|
28
|
+
export interface ExtensionFact {
|
|
29
|
+
/** The extension name (`CREATE EXTENSION <name>`). */
|
|
30
|
+
name: string;
|
|
31
|
+
/**
|
|
32
|
+
* The schema the statement places the extension in, or `null` when none is
|
|
33
|
+
* specified (`CREATE EXTENSION <name>` with no `SCHEMA` clause installs into
|
|
34
|
+
* the current default — typically `public` or the extension's fixed schema).
|
|
35
|
+
* `DROP EXTENSION` carries no schema.
|
|
36
|
+
*/
|
|
37
|
+
schema: string | null;
|
|
38
|
+
/** Which extension operation this statement performs. */
|
|
39
|
+
action: ExtensionAction;
|
|
40
|
+
/** `CREATE EXTENSION IF NOT EXISTS`. */
|
|
41
|
+
ifNotExists?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* AST-derived facts about a single top-level SQL statement.
|
|
45
|
+
*
|
|
46
|
+
* Read-only: classification never modifies the statement. Facts are the
|
|
47
|
+
* substrate for classifier-driven slicing (schema / functionality / security
|
|
48
|
+
* tiers) — replacing path/name-glob decisions with computed properties like
|
|
49
|
+
* "this trigger function references billing".
|
|
50
|
+
*/
|
|
51
|
+
export interface StatementFacts {
|
|
52
|
+
/** Coarse category of the statement. */
|
|
53
|
+
kind: StatementKind;
|
|
54
|
+
/** The raw parser node tag (e.g. `CreateStmt`, `CreatePolicyStmt`). */
|
|
55
|
+
nodeTag: string;
|
|
56
|
+
/** Objects this statement creates or directly targets. */
|
|
57
|
+
creates: QualifiedName[];
|
|
58
|
+
/**
|
|
59
|
+
* Schema-qualified objects this statement references — tables, functions
|
|
60
|
+
* and types reached anywhere in the statement, including PL/pgSQL bodies.
|
|
61
|
+
* Unqualified references are omitted (they resolve via search_path and
|
|
62
|
+
* carry no cross-schema information).
|
|
63
|
+
*/
|
|
64
|
+
references: QualifiedName[];
|
|
65
|
+
/**
|
|
66
|
+
* The subset of `references` reached only inside a PL/pgSQL body. These
|
|
67
|
+
* are late-binding: Postgres resolves them at call time, not at CREATE
|
|
68
|
+
* time, so they do not constrain deploy order (and legitimately form
|
|
69
|
+
* recursion cycles between functions).
|
|
70
|
+
*/
|
|
71
|
+
bodyReferences: QualifiedName[];
|
|
72
|
+
/** Distinct schemas reached by `references`. */
|
|
73
|
+
referencedSchemas: string[];
|
|
74
|
+
/** Role names granted to, owning, or bound by this statement. */
|
|
75
|
+
roles: string[];
|
|
76
|
+
/**
|
|
77
|
+
* For extension-level statements (`kind: 'extension'`): the extension being
|
|
78
|
+
* created, relocated, or dropped. Absent for every other statement.
|
|
79
|
+
*/
|
|
80
|
+
extension?: ExtensionFact;
|
|
81
|
+
/** Foreign-key target tables (from column/table FK constraints). */
|
|
82
|
+
fkTargets: QualifiedName[];
|
|
83
|
+
/**
|
|
84
|
+
* Whether the statement is part of the security surface: policies, grants,
|
|
85
|
+
* RLS enable/force, security labels, ownership.
|
|
86
|
+
*/
|
|
87
|
+
securityRelevant: boolean;
|
|
88
|
+
/** For functions: declared with SECURITY DEFINER. */
|
|
89
|
+
securityDefiner: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* For functions: the body executes dynamic SQL (EXECUTE / EXECUTE ... USING
|
|
92
|
+
* / FOR ... IN EXECUTE). Analogous to `eval` — references inside the
|
|
93
|
+
* dynamic string are invisible to the AST, so edges from this statement
|
|
94
|
+
* are incomplete and slicing should treat it conservatively.
|
|
95
|
+
*/
|
|
96
|
+
dynamicSql: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The statement's source span in the classified script, as reported by the
|
|
99
|
+
* parser: `start` is the byte offset of the statement's first token, `len`
|
|
100
|
+
* runs to the end of the statement (the parser excludes the trailing `;`;
|
|
101
|
+
* for the final statement the span extends to the end of the script).
|
|
102
|
+
* `sql.slice(span.start, span.start + span.len)` is the statement's
|
|
103
|
+
* verbatim source, so consumers can carry original text alongside the
|
|
104
|
+
* facts without a second parse.
|
|
105
|
+
*/
|
|
106
|
+
span: StatementSpan;
|
|
107
|
+
/**
|
|
108
|
+
* The raw parsed statement node (`{ CreateStmt: {...} }` etc.), exactly as
|
|
109
|
+
* the parser produced it. Facts are still read-only — the node is carried
|
|
110
|
+
* so consumers like `revertFor`/`verifyFor` can derive inverse or
|
|
111
|
+
* existence-check statements without a second parse. Absent only for
|
|
112
|
+
* facts constructed by hand.
|
|
113
|
+
*/
|
|
114
|
+
stmt?: Record<string, any>;
|
|
115
|
+
}
|
|
116
|
+
/** A statement's location in the source script (byte offsets). */
|
|
117
|
+
export interface StatementSpan {
|
|
118
|
+
start: number;
|
|
119
|
+
len: number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Classify each top-level statement in a SQL script into {@link StatementFacts}.
|
|
123
|
+
*
|
|
124
|
+
* Uses the same parser stack as the schema transformer (pgsql AST walk plus
|
|
125
|
+
* hydrated PL/pgSQL body walk), so references inside function bodies are
|
|
126
|
+
* included. The input SQL is never modified.
|
|
127
|
+
*/
|
|
128
|
+
export declare function classifyStatements(sql: string): StatementFacts[];
|
package/facts.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyStatements = classifyStatements;
|
|
4
|
+
const traverse_1 = require("@pgsql/traverse");
|
|
5
|
+
const plpgsql_parser_1 = require("plpgsql-parser");
|
|
6
|
+
const SECURITY_TAGS = new Set([
|
|
7
|
+
'CreatePolicyStmt',
|
|
8
|
+
'AlterPolicyStmt',
|
|
9
|
+
'GrantStmt',
|
|
10
|
+
'GrantRoleStmt',
|
|
11
|
+
'AlterDefaultPrivilegesStmt',
|
|
12
|
+
'SecLabelStmt',
|
|
13
|
+
'AlterOwnerStmt',
|
|
14
|
+
'CreateRoleStmt',
|
|
15
|
+
'AlterRoleStmt'
|
|
16
|
+
]);
|
|
17
|
+
const KIND_BY_TAG = {
|
|
18
|
+
CreateSchemaStmt: 'schema',
|
|
19
|
+
CreateExtensionStmt: 'extension',
|
|
20
|
+
CreateStmt: 'table',
|
|
21
|
+
ViewStmt: 'view',
|
|
22
|
+
IndexStmt: 'index',
|
|
23
|
+
CompositeTypeStmt: 'type',
|
|
24
|
+
CreateEnumStmt: 'type',
|
|
25
|
+
CreateDomainStmt: 'type',
|
|
26
|
+
CreateRangeStmt: 'type',
|
|
27
|
+
DefineStmt: 'type',
|
|
28
|
+
CreateFunctionStmt: 'function',
|
|
29
|
+
CreateTrigStmt: 'trigger',
|
|
30
|
+
CreateEventTrigStmt: 'trigger',
|
|
31
|
+
CreatePolicyStmt: 'policy',
|
|
32
|
+
AlterPolicyStmt: 'policy',
|
|
33
|
+
GrantStmt: 'grant',
|
|
34
|
+
GrantRoleStmt: 'grant',
|
|
35
|
+
AlterDefaultPrivilegesStmt: 'grant',
|
|
36
|
+
CommentStmt: 'comment',
|
|
37
|
+
InsertStmt: 'seed_dml',
|
|
38
|
+
UpdateStmt: 'seed_dml',
|
|
39
|
+
DeleteStmt: 'seed_dml'
|
|
40
|
+
};
|
|
41
|
+
function qn(schema, name) {
|
|
42
|
+
return { schema: schema ?? null, name };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the `SCHEMA <name>` clause of a `CreateExtensionStmt` from its options
|
|
46
|
+
* (`{ DefElem: { defname: 'schema', arg: { String: { sval } } } }`), or `null`
|
|
47
|
+
* when the statement specifies no schema.
|
|
48
|
+
*/
|
|
49
|
+
function extensionSchemaOption(options) {
|
|
50
|
+
if (!Array.isArray(options))
|
|
51
|
+
return null;
|
|
52
|
+
for (const opt of options) {
|
|
53
|
+
const def = opt?.DefElem;
|
|
54
|
+
if (def?.defname === 'schema') {
|
|
55
|
+
const sval = def.arg?.String?.sval;
|
|
56
|
+
return typeof sval === 'string' ? sval : null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function nameListToQualified(names) {
|
|
62
|
+
if (!Array.isArray(names) || names.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
const parts = names
|
|
65
|
+
.map((n) => n?.String?.sval)
|
|
66
|
+
.filter((s) => typeof s === 'string');
|
|
67
|
+
if (parts.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
if (parts.length === 1)
|
|
70
|
+
return qn(null, parts[0]);
|
|
71
|
+
return qn(parts[parts.length - 2], parts[parts.length - 1]);
|
|
72
|
+
}
|
|
73
|
+
function isCatalogSchema(schema) {
|
|
74
|
+
return schema === 'pg_catalog' || schema === 'information_schema';
|
|
75
|
+
}
|
|
76
|
+
function pushRef(refs, ref) {
|
|
77
|
+
if (!ref || !ref.schema || isCatalogSchema(ref.schema))
|
|
78
|
+
return;
|
|
79
|
+
if (refs.some(r => r.schema === ref.schema && r.name === ref.name))
|
|
80
|
+
return;
|
|
81
|
+
refs.push(ref);
|
|
82
|
+
}
|
|
83
|
+
function collectRoles(node, roles) {
|
|
84
|
+
const push = (role) => {
|
|
85
|
+
if (typeof role === 'string' && role.length > 0 && !roles.includes(role)) {
|
|
86
|
+
roles.push(role);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (Array.isArray(node?.grantees)) {
|
|
90
|
+
for (const g of node.grantees)
|
|
91
|
+
push(g?.RoleSpec?.rolename);
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(node?.roles)) {
|
|
94
|
+
for (const r of node.roles)
|
|
95
|
+
push(r?.RoleSpec?.rolename);
|
|
96
|
+
}
|
|
97
|
+
push(node?.newowner?.rolename);
|
|
98
|
+
push(node?.role?.rolename);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Create a read-only visitor that accumulates references, roles and FK
|
|
102
|
+
* targets into the provided facts object.
|
|
103
|
+
*/
|
|
104
|
+
function createFactsVisitor(facts, bodyRefs) {
|
|
105
|
+
const push = (ref) => {
|
|
106
|
+
pushRef(facts.references, ref);
|
|
107
|
+
if (bodyRefs)
|
|
108
|
+
pushRef(bodyRefs, ref);
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
RangeVar: (path) => {
|
|
112
|
+
const node = path.node;
|
|
113
|
+
if (node.schemaname) {
|
|
114
|
+
push(qn(node.schemaname, node.relname));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
FuncCall: (path) => {
|
|
118
|
+
push(nameListToQualified(path.node.funcname));
|
|
119
|
+
},
|
|
120
|
+
TypeName: (path) => {
|
|
121
|
+
push(nameListToQualified(path.node.names));
|
|
122
|
+
},
|
|
123
|
+
ColumnRef: (path) => {
|
|
124
|
+
// schema.table.column references carry cross-schema information
|
|
125
|
+
const fields = path.node.fields;
|
|
126
|
+
if (Array.isArray(fields) && fields.length >= 3) {
|
|
127
|
+
const parts = fields
|
|
128
|
+
.map((f) => f?.String?.sval)
|
|
129
|
+
.filter((s) => typeof s === 'string');
|
|
130
|
+
if (parts.length >= 3) {
|
|
131
|
+
push(qn(parts[0], parts[1]));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
Constraint: (path) => {
|
|
136
|
+
const node = path.node;
|
|
137
|
+
if (node.contype === 'CONSTR_FOREIGN' && node.pktable) {
|
|
138
|
+
const target = qn(node.pktable.schemaname ?? null, node.pktable.relname);
|
|
139
|
+
if (!facts.fkTargets.some(t => t.schema === target.schema && t.name === target.name)) {
|
|
140
|
+
facts.fkTargets.push(target);
|
|
141
|
+
}
|
|
142
|
+
if (target.schema)
|
|
143
|
+
pushRef(facts.references, target);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function classifyOne(nodeTag, node) {
|
|
149
|
+
const facts = {
|
|
150
|
+
kind: KIND_BY_TAG[nodeTag] ?? 'other',
|
|
151
|
+
nodeTag,
|
|
152
|
+
creates: [],
|
|
153
|
+
references: [],
|
|
154
|
+
referencedSchemas: [],
|
|
155
|
+
roles: [],
|
|
156
|
+
fkTargets: [],
|
|
157
|
+
bodyReferences: [],
|
|
158
|
+
securityRelevant: SECURITY_TAGS.has(nodeTag),
|
|
159
|
+
securityDefiner: false,
|
|
160
|
+
dynamicSql: false,
|
|
161
|
+
span: { start: 0, len: 0 }
|
|
162
|
+
};
|
|
163
|
+
switch (nodeTag) {
|
|
164
|
+
case 'CreateSchemaStmt':
|
|
165
|
+
facts.creates.push(qn(null, node.schemaname));
|
|
166
|
+
break;
|
|
167
|
+
case 'CreateExtensionStmt':
|
|
168
|
+
facts.extension = {
|
|
169
|
+
name: node.extname,
|
|
170
|
+
schema: extensionSchemaOption(node.options),
|
|
171
|
+
action: 'create',
|
|
172
|
+
ifNotExists: node.if_not_exists === true
|
|
173
|
+
};
|
|
174
|
+
break;
|
|
175
|
+
case 'CreateStmt':
|
|
176
|
+
case 'ViewStmt': {
|
|
177
|
+
const rel = nodeTag === 'ViewStmt' ? node.view : node.relation;
|
|
178
|
+
if (rel)
|
|
179
|
+
facts.creates.push(qn(rel.schemaname ?? null, rel.relname));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'IndexStmt':
|
|
183
|
+
if (node.relation) {
|
|
184
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.idxname ?? node.relation.relname));
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
case 'CreateFunctionStmt': {
|
|
188
|
+
const name = nameListToQualified(node.funcname);
|
|
189
|
+
if (name)
|
|
190
|
+
facts.creates.push(name);
|
|
191
|
+
for (const opt of node.options ?? []) {
|
|
192
|
+
const def = opt?.DefElem;
|
|
193
|
+
if (def?.defname === 'security' && def?.arg?.Boolean?.boolval === true) {
|
|
194
|
+
facts.securityDefiner = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'CreateTrigStmt':
|
|
200
|
+
if (node.relation) {
|
|
201
|
+
// Trigger names are only unique per table; qualify with the table.
|
|
202
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, `${node.relation.relname}.${node.trigname}`));
|
|
203
|
+
}
|
|
204
|
+
pushRef(facts.references, nameListToQualified(node.funcname));
|
|
205
|
+
break;
|
|
206
|
+
case 'CreatePolicyStmt':
|
|
207
|
+
case 'AlterPolicyStmt':
|
|
208
|
+
if (node.table) {
|
|
209
|
+
// Policy names are only unique per table; qualify with the table.
|
|
210
|
+
facts.creates.push(qn(node.table.schemaname ?? null, `${node.table.relname}.${node.policy_name}`));
|
|
211
|
+
// The guarded table lives on `node.table`; the generic RangeVar walker
|
|
212
|
+
// does not descend into it, so capture the reference explicitly.
|
|
213
|
+
if (node.table.schemaname) {
|
|
214
|
+
pushRef(facts.references, qn(node.table.schemaname, node.table.relname));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
case 'CreateSeqStmt':
|
|
219
|
+
if (node.sequence) {
|
|
220
|
+
facts.creates.push(qn(node.sequence.schemaname ?? null, node.sequence.relname));
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
case 'CompositeTypeStmt':
|
|
224
|
+
if (node.typevar) {
|
|
225
|
+
facts.creates.push(qn(node.typevar.schemaname ?? null, node.typevar.relname));
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'CreateEnumStmt':
|
|
229
|
+
case 'CreateRangeStmt': {
|
|
230
|
+
const name = nameListToQualified(node.typeName);
|
|
231
|
+
if (name)
|
|
232
|
+
facts.creates.push(name);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'CreateDomainStmt': {
|
|
236
|
+
// `typeName` on a domain is the base type; the name is `domainname`.
|
|
237
|
+
const name = nameListToQualified(node.domainname);
|
|
238
|
+
if (name)
|
|
239
|
+
facts.creates.push(name);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'AlterObjectSchemaStmt':
|
|
243
|
+
// ALTER EXTENSION <name> SET SCHEMA <newschema>. Other object types
|
|
244
|
+
// (TABLE/TYPE/FUNCTION ... SET SCHEMA) keep their default classification.
|
|
245
|
+
if (node.objectType === 'OBJECT_EXTENSION') {
|
|
246
|
+
const name = node.object?.String?.sval;
|
|
247
|
+
if (typeof name === 'string') {
|
|
248
|
+
facts.kind = 'extension';
|
|
249
|
+
facts.extension = {
|
|
250
|
+
name,
|
|
251
|
+
schema: typeof node.newschema === 'string' ? node.newschema : null,
|
|
252
|
+
action: 'set_schema'
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
case 'DropStmt':
|
|
258
|
+
// DROP EXTENSION <name> [, ...]. Extension names are bare String nodes.
|
|
259
|
+
// Other DROP object types keep their default classification. When
|
|
260
|
+
// several extensions are dropped in one statement the first names the
|
|
261
|
+
// fact; the full list stays available via the raw node.
|
|
262
|
+
if (node.removeType === 'OBJECT_EXTENSION' && Array.isArray(node.objects)) {
|
|
263
|
+
const name = node.objects[0]?.String?.sval;
|
|
264
|
+
if (typeof name === 'string') {
|
|
265
|
+
facts.kind = 'extension';
|
|
266
|
+
facts.extension = { name, schema: null, action: 'drop' };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
case 'AlterTableStmt': {
|
|
271
|
+
if (node.relation) {
|
|
272
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
|
|
273
|
+
}
|
|
274
|
+
const cmds = node.cmds ?? [];
|
|
275
|
+
for (const cmd of cmds) {
|
|
276
|
+
const subtype = cmd?.AlterTableCmd?.subtype;
|
|
277
|
+
if (subtype === 'AT_EnableRowSecurity' || subtype === 'AT_ForceRowSecurity' ||
|
|
278
|
+
subtype === 'AT_DisableRowSecurity' || subtype === 'AT_NoForceRowSecurity') {
|
|
279
|
+
facts.kind = 'rls_enable';
|
|
280
|
+
facts.securityRelevant = true;
|
|
281
|
+
}
|
|
282
|
+
else if (subtype === 'AT_AddConstraint') {
|
|
283
|
+
const contype = cmd?.AlterTableCmd?.def?.Constraint?.contype;
|
|
284
|
+
facts.kind = contype === 'CONSTR_FOREIGN' ? 'fk_constraint' : 'constraint';
|
|
285
|
+
}
|
|
286
|
+
else if (subtype === 'AT_ChangeOwner') {
|
|
287
|
+
facts.securityRelevant = true;
|
|
288
|
+
const owner = cmd?.AlterTableCmd?.newowner?.rolename;
|
|
289
|
+
if (owner && !facts.roles.includes(owner))
|
|
290
|
+
facts.roles.push(owner);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'InsertStmt':
|
|
296
|
+
case 'UpdateStmt':
|
|
297
|
+
case 'DeleteStmt':
|
|
298
|
+
if (node.relation) {
|
|
299
|
+
facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
default:
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
collectRoles(node, facts.roles);
|
|
306
|
+
return facts;
|
|
307
|
+
}
|
|
308
|
+
/** Read a `CreateFunctionStmt` DefElem option's scalar/list value. */
|
|
309
|
+
function functionOption(node, defname) {
|
|
310
|
+
for (const opt of node.options ?? []) {
|
|
311
|
+
if (opt?.DefElem?.defname === defname)
|
|
312
|
+
return opt.DefElem.arg;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Collect references from a `LANGUAGE sql` function body supplied as a string
|
|
318
|
+
* literal (`AS $$ ... $$`). That body is an opaque String node the AST walker
|
|
319
|
+
* never parses, so — mirroring the schema transformer's body rewrite — parse
|
|
320
|
+
* it standalone and walk each statement with the facts visitor. The standard
|
|
321
|
+
* `BEGIN ATOMIC` / `RETURN` `sql_body` form is already part of the AST and is
|
|
322
|
+
* covered by the outer walk, so only the string form needs this.
|
|
323
|
+
*/
|
|
324
|
+
function collectSqlBodyReferences(node, facts) {
|
|
325
|
+
const language = functionOption(node, 'language')?.String?.sval;
|
|
326
|
+
if (typeof language !== 'string' || language.toLowerCase() !== 'sql')
|
|
327
|
+
return;
|
|
328
|
+
const asArg = functionOption(node, 'as');
|
|
329
|
+
const items = asArg?.List?.items ?? [];
|
|
330
|
+
const body = items[0]?.String?.sval;
|
|
331
|
+
if (typeof body !== 'string')
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
const stmts = (0, plpgsql_parser_1.parseSql)(body)?.stmts ?? [];
|
|
335
|
+
const visitor = createFactsVisitor(facts, facts.bodyReferences);
|
|
336
|
+
for (const stmt of stmts) {
|
|
337
|
+
if (stmt?.stmt)
|
|
338
|
+
(0, traverse_1.walkSqlAst)(stmt.stmt, visitor);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// A non-parseable body (C symbol name, etc.) contributes no references.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Classify each top-level statement in a SQL script into {@link StatementFacts}.
|
|
347
|
+
*
|
|
348
|
+
* Uses the same parser stack as the schema transformer (pgsql AST walk plus
|
|
349
|
+
* hydrated PL/pgSQL body walk), so references inside function bodies are
|
|
350
|
+
* included. The input SQL is never modified.
|
|
351
|
+
*/
|
|
352
|
+
function classifyStatements(sql) {
|
|
353
|
+
const allFacts = [];
|
|
354
|
+
(0, plpgsql_parser_1.transformSync)(sql, (ctx) => {
|
|
355
|
+
const stmts = ctx.sql?.stmts ?? [];
|
|
356
|
+
for (const stmt of stmts) {
|
|
357
|
+
const stmtNode = stmt?.stmt;
|
|
358
|
+
const nodeTag = stmtNode ? Object.keys(stmtNode)[0] : 'other';
|
|
359
|
+
const node = stmtNode?.[nodeTag] ?? {};
|
|
360
|
+
const facts = classifyOne(nodeTag, node);
|
|
361
|
+
const start = stmt?.stmt_location ?? 0;
|
|
362
|
+
facts.span = { start, len: stmt?.stmt_len ?? Math.max(0, sql.length - start) };
|
|
363
|
+
if (stmtNode) {
|
|
364
|
+
facts.stmt = stmtNode;
|
|
365
|
+
(0, traverse_1.walkSqlAst)(stmtNode, createFactsVisitor(facts));
|
|
366
|
+
}
|
|
367
|
+
if (nodeTag === 'CreateFunctionStmt') {
|
|
368
|
+
collectSqlBodyReferences(node, facts);
|
|
369
|
+
}
|
|
370
|
+
allFacts.push(facts);
|
|
371
|
+
}
|
|
372
|
+
for (const fn of ctx.functions ?? []) {
|
|
373
|
+
const facts = allFacts[fn.stmtIndex];
|
|
374
|
+
if (!facts || !fn.plpgsql?.hydrated)
|
|
375
|
+
continue;
|
|
376
|
+
// Refs already found outside the body (signature types, defaults)
|
|
377
|
+
// constrain deploy order and are not body-only.
|
|
378
|
+
const outer = new Set(facts.references.map(r => `${r.schema ?? '?'}.${r.name}`));
|
|
379
|
+
(0, plpgsql_parser_1.walkPlpgsqlAst)(fn.plpgsql.hydrated, {
|
|
380
|
+
PLpgSQL_stmt_dynexecute: () => { facts.dynamicSql = true; },
|
|
381
|
+
PLpgSQL_stmt_dynfors: () => { facts.dynamicSql = true; }
|
|
382
|
+
}, {
|
|
383
|
+
walkSqlExpressions: true,
|
|
384
|
+
sqlVisitor: createFactsVisitor(facts, facts.bodyReferences)
|
|
385
|
+
});
|
|
386
|
+
facts.bodyReferences = facts.bodyReferences.filter(r => !outer.has(`${r.schema ?? '?'}.${r.name}`));
|
|
387
|
+
}
|
|
388
|
+
}, { hydrate: true });
|
|
389
|
+
for (const facts of allFacts) {
|
|
390
|
+
const selfRef = (r) => facts.creates.some(c => c.schema === r.schema && c.name === r.name);
|
|
391
|
+
facts.references = facts.references.filter(r => !selfRef(r));
|
|
392
|
+
facts.bodyReferences = facts.bodyReferences.filter(r => !selfRef(r));
|
|
393
|
+
facts.referencedSchemas = [...new Set(facts.references.map(r => r.schema).filter(Boolean))];
|
|
394
|
+
}
|
|
395
|
+
return allFacts;
|
|
396
|
+
}
|
package/index.d.ts
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyStatements = void 0;
|
|
4
|
+
var facts_1 = require("./facts");
|
|
5
|
+
Object.defineProperty(exports, "classifyStatements", { enumerable: true, get: function () { return facts_1.classifyStatements; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pgsql/semantics",
|
|
3
|
+
"version": "18.1.0",
|
|
4
|
+
"author": "Constructive <developers@constructive.io>",
|
|
5
|
+
"description": "AST-derived semantic facts for PostgreSQL statements: what each statement creates, references, and touches",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/constructive-io/pgsql-parser",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/constructive-io/pgsql-parser"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/constructive-io/pgsql-parser/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"copy": "makage assets",
|
|
24
|
+
"clean": "makage clean dist",
|
|
25
|
+
"prepublishOnly": "npm run build",
|
|
26
|
+
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy",
|
|
27
|
+
"build:dev": "npm run clean && tsc --declarationMap && tsc -p tsconfig.esm.json && npm run copy",
|
|
28
|
+
"lint": "eslint . --fix",
|
|
29
|
+
"test": "jest",
|
|
30
|
+
"test:watch": "jest --watch"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@pgsql/traverse": "18.7.1",
|
|
34
|
+
"plpgsql-parser": "18.5.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"makage": "^0.1.8"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"sql",
|
|
41
|
+
"postgres",
|
|
42
|
+
"postgresql",
|
|
43
|
+
"pg",
|
|
44
|
+
"ast",
|
|
45
|
+
"semantics",
|
|
46
|
+
"facts",
|
|
47
|
+
"classify",
|
|
48
|
+
"plpgsql"
|
|
49
|
+
],
|
|
50
|
+
"gitHead": "0fba1b6faf8067bfd5f1591656c2a90f77c280b0"
|
|
51
|
+
}
|