@pgsql/transform 18.5.0 → 18.7.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/esm/extension-router.js +236 -0
- package/esm/extension-transform.js +202 -0
- package/esm/facts.js +54 -0
- package/esm/index.js +4 -0
- package/esm/role-router.js +62 -0
- package/esm/role-transform.js +129 -0
- package/esm/router.js +69 -5
- package/esm/transform.js +32 -9
- package/extension-router.d.ts +161 -0
- package/extension-router.js +240 -0
- package/extension-transform.d.ts +48 -0
- package/extension-transform.js +207 -0
- package/facts.d.ts +37 -1
- package/facts.js +54 -0
- package/index.d.ts +10 -2
- package/index.js +14 -1
- package/package.json +2 -2
- package/role-router.d.ts +42 -0
- package/role-router.js +66 -0
- package/role-transform.d.ts +50 -0
- package/role-transform.js +134 -0
- package/router.d.ts +63 -6
- package/router.js +69 -5
- package/transform.js +32 -9
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension routing for the core transform.
|
|
3
|
+
*
|
|
4
|
+
* A PostgreSQL extension is installed into exactly one schema, and the objects
|
|
5
|
+
* it provides (functions, types, operators) live in that schema. Two databases
|
|
6
|
+
* can install the *same* extension into *different* schemas — one leaves it in
|
|
7
|
+
* `public`, another isolates it in a dedicated schema — so portable SQL must be
|
|
8
|
+
* able to move an extension's install site **and** rewrite every reference to
|
|
9
|
+
* the symbols it provides.
|
|
10
|
+
*
|
|
11
|
+
* This is a distinct problem from {@link SchemaRouter}, which routes objects the
|
|
12
|
+
* SQL itself creates. Here the objects are owned by an extension and are not
|
|
13
|
+
* declared in the script; the only way to know that a bare `crypt(...)` call
|
|
14
|
+
* belongs to `pgcrypto` (and must move when `pgcrypto` moves) is an
|
|
15
|
+
* **inventory** of which symbols each extension provides. That inventory is
|
|
16
|
+
* also **version-aware**: a symbol can graduate into the server core and cease
|
|
17
|
+
* to be extension-owned (the canonical case is `gen_random_uuid()`, provided by
|
|
18
|
+
* `pgcrypto` before PostgreSQL 13 and part of core `pg_catalog` from 13 on — it
|
|
19
|
+
* must never be routed on 13+).
|
|
20
|
+
*
|
|
21
|
+
* An {@link ExtensionRouter} answers two questions:
|
|
22
|
+
*
|
|
23
|
+
* - **install**: where should `CREATE EXTENSION <e>` (or `ALTER EXTENSION <e>
|
|
24
|
+
* SET SCHEMA`) place the extension?
|
|
25
|
+
* - **reference**: given a reference to a symbol `(schema, name)` in namespace
|
|
26
|
+
* `ns`, what schema should it live in now — including stripping the qualifier
|
|
27
|
+
* entirely (relying on `search_path`) when the target is "unqualified".
|
|
28
|
+
*
|
|
29
|
+
* A schema of `null` denotes the **unqualified** site: a bare reference
|
|
30
|
+
* (`crypt(...)`) or installing an extension with no explicit `SCHEMA` clause.
|
|
31
|
+
* This makes the router fully bidirectional: `null -> 'extensions'` qualifies
|
|
32
|
+
* bare references, and `'extensions' -> null` strips them back to bare.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_SERVER_VERSION = 9999;
|
|
35
|
+
/**
|
|
36
|
+
* A curated, platform-agnostic inventory of common contrib/extension symbols.
|
|
37
|
+
* Intentionally minimal and conservative — only well-known, stable symbols are
|
|
38
|
+
* listed so routing never touches something it cannot prove belongs to the
|
|
39
|
+
* extension. Callers extend this via {@link ExtensionRouterOptions.inventory}.
|
|
40
|
+
*/
|
|
41
|
+
export const COMMON_EXTENSIONS = [
|
|
42
|
+
{
|
|
43
|
+
name: 'pgcrypto',
|
|
44
|
+
relocatable: true,
|
|
45
|
+
symbols: [
|
|
46
|
+
{ name: 'crypt' },
|
|
47
|
+
{ name: 'gen_salt' },
|
|
48
|
+
{ name: 'digest' },
|
|
49
|
+
{ name: 'hmac' },
|
|
50
|
+
{ name: 'encrypt' },
|
|
51
|
+
{ name: 'decrypt' },
|
|
52
|
+
{ name: 'encrypt_iv' },
|
|
53
|
+
{ name: 'decrypt_iv' },
|
|
54
|
+
{ name: 'gen_random_bytes' },
|
|
55
|
+
// Provided by pgcrypto before PG13; part of core pg_catalog from 13 on.
|
|
56
|
+
{ name: 'gen_random_uuid', coreSince: 13 },
|
|
57
|
+
{ name: 'pgp_sym_encrypt' },
|
|
58
|
+
{ name: 'pgp_sym_decrypt' },
|
|
59
|
+
{ name: 'pgp_pub_encrypt' },
|
|
60
|
+
{ name: 'pgp_pub_decrypt' }
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'uuid-ossp',
|
|
65
|
+
relocatable: true,
|
|
66
|
+
symbols: [
|
|
67
|
+
{ name: 'uuid_generate_v1' },
|
|
68
|
+
{ name: 'uuid_generate_v1mc' },
|
|
69
|
+
{ name: 'uuid_generate_v3' },
|
|
70
|
+
{ name: 'uuid_generate_v4' },
|
|
71
|
+
{ name: 'uuid_generate_v5' },
|
|
72
|
+
{ name: 'uuid_nil' }
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'citext',
|
|
77
|
+
relocatable: true,
|
|
78
|
+
symbols: [
|
|
79
|
+
{ name: 'citext', namespace: 'type' },
|
|
80
|
+
{ name: 'citextin', namespace: 'function' },
|
|
81
|
+
{ name: 'citext_hash', namespace: 'function' }
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'pg_trgm',
|
|
86
|
+
relocatable: true,
|
|
87
|
+
symbols: [
|
|
88
|
+
{ name: 'similarity' },
|
|
89
|
+
{ name: 'show_trgm' },
|
|
90
|
+
{ name: 'word_similarity' },
|
|
91
|
+
{ name: 'strict_word_similarity' }
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'ltree',
|
|
96
|
+
relocatable: true,
|
|
97
|
+
symbols: [
|
|
98
|
+
{ name: 'ltree', namespace: 'type' },
|
|
99
|
+
{ name: 'lquery', namespace: 'type' },
|
|
100
|
+
{ name: 'ltxtquery', namespace: 'type' },
|
|
101
|
+
{ name: 'subltree' },
|
|
102
|
+
{ name: 'subpath' },
|
|
103
|
+
{ name: 'nlevel' },
|
|
104
|
+
{ name: 'lca' }
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'hstore',
|
|
109
|
+
relocatable: true,
|
|
110
|
+
symbols: [{ name: 'hstore', namespace: 'type' }]
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'unaccent',
|
|
114
|
+
relocatable: true,
|
|
115
|
+
symbols: [{ name: 'unaccent' }]
|
|
116
|
+
}
|
|
117
|
+
];
|
|
118
|
+
/**
|
|
119
|
+
* Resolves install-schema and symbol-reference routing for extensions, backed
|
|
120
|
+
* by a version-aware symbol inventory.
|
|
121
|
+
*/
|
|
122
|
+
export class ExtensionRouter {
|
|
123
|
+
routes;
|
|
124
|
+
serverVersion;
|
|
125
|
+
definitions;
|
|
126
|
+
/** `${namespace}:${name}` -> providing extension (+ version predicate). */
|
|
127
|
+
symbolIndex;
|
|
128
|
+
constructor(routes = {}, options = {}) {
|
|
129
|
+
this.routes = routes instanceof Map ? new Map(routes) : new Map(Object.entries(routes));
|
|
130
|
+
this.serverVersion = options.serverVersion ?? DEFAULT_SERVER_VERSION;
|
|
131
|
+
const inventory = options.inventory ?? COMMON_EXTENSIONS;
|
|
132
|
+
this.definitions = new Map(inventory.map(def => [def.name, def]));
|
|
133
|
+
this.symbolIndex = new Map();
|
|
134
|
+
for (const def of inventory) {
|
|
135
|
+
for (const sym of def.symbols) {
|
|
136
|
+
const key = symbolKey(sym.namespace ?? 'function', sym.name);
|
|
137
|
+
// First definition wins; inventories should not double-claim a symbol.
|
|
138
|
+
if (!this.symbolIndex.has(key)) {
|
|
139
|
+
this.symbolIndex.set(key, { extension: def.name, coreSince: sym.coreSince });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Build a router that moves the given extensions (default: every inventoried
|
|
146
|
+
* one) to a single `targetSchema`, rewriting their references from `public`
|
|
147
|
+
* and from bare (unqualified) sites. Passing `null` as `targetSchema` strips
|
|
148
|
+
* qualification instead (the "repollute public / rely on search_path"
|
|
149
|
+
* direction).
|
|
150
|
+
*/
|
|
151
|
+
static toSchema(targetSchema, options = {}) {
|
|
152
|
+
const inventory = options.inventory ?? COMMON_EXTENSIONS;
|
|
153
|
+
const names = options.extensions ?? inventory.map(d => d.name);
|
|
154
|
+
const from = options.from ?? ['public', null];
|
|
155
|
+
const spec = {};
|
|
156
|
+
for (const name of names)
|
|
157
|
+
spec[name] = { to: targetSchema, from };
|
|
158
|
+
return new ExtensionRouter(spec, options);
|
|
159
|
+
}
|
|
160
|
+
/** Coerce a spec, map, or existing router into a router. */
|
|
161
|
+
static from(source, options) {
|
|
162
|
+
if (source instanceof ExtensionRouter)
|
|
163
|
+
return source;
|
|
164
|
+
return new ExtensionRouter(source, options);
|
|
165
|
+
}
|
|
166
|
+
/** True when no extension routes are configured. */
|
|
167
|
+
get size() {
|
|
168
|
+
return this.routes.size;
|
|
169
|
+
}
|
|
170
|
+
/** Extension names this router may rewrite. */
|
|
171
|
+
routedExtensions() {
|
|
172
|
+
return [...this.routes.keys()];
|
|
173
|
+
}
|
|
174
|
+
/** True when any configured extension has inventoried symbols to rewrite. */
|
|
175
|
+
hasSymbolRoutes() {
|
|
176
|
+
for (const name of this.routes.keys()) {
|
|
177
|
+
const def = this.definitions.get(name);
|
|
178
|
+
if (def && !def.fixedSchema && def.symbols.length > 0)
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the install schema for `CREATE EXTENSION <extname>` /
|
|
185
|
+
* `ALTER EXTENSION <extname> SET SCHEMA`. Returns:
|
|
186
|
+
* - a string to place/keep the extension in that schema,
|
|
187
|
+
* - `null` to install with no explicit `SCHEMA` clause (server default),
|
|
188
|
+
* - `undefined` to leave the statement unchanged (no route, or the extension
|
|
189
|
+
* is pinned to a fixed schema).
|
|
190
|
+
*/
|
|
191
|
+
resolveInstall(extname) {
|
|
192
|
+
if (!extname)
|
|
193
|
+
return undefined;
|
|
194
|
+
const route = this.routes.get(extname);
|
|
195
|
+
if (!route)
|
|
196
|
+
return undefined;
|
|
197
|
+
if (this.definitions.get(extname)?.fixedSchema !== undefined)
|
|
198
|
+
return undefined;
|
|
199
|
+
return route.to;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a rewrite for a reference to `(schema, name)` in namespace `ns`,
|
|
203
|
+
* where `schema` is `null` for a bare reference. Returns the target
|
|
204
|
+
* ({@link SymbolRewrite}) or `undefined` to leave the reference unchanged.
|
|
205
|
+
*
|
|
206
|
+
* A reference is rewritten only when the inventory proves the symbol belongs
|
|
207
|
+
* to a routed extension, the symbol has not graduated to core at the target
|
|
208
|
+
* server version, the current schema is one the route rewrites `from`, and
|
|
209
|
+
* the destination actually differs from where it already is.
|
|
210
|
+
*/
|
|
211
|
+
resolveSymbol(schema, name, ns) {
|
|
212
|
+
if (!name)
|
|
213
|
+
return undefined;
|
|
214
|
+
const indexed = this.symbolIndex.get(symbolKey(ns, name));
|
|
215
|
+
if (!indexed)
|
|
216
|
+
return undefined;
|
|
217
|
+
// Graduated into core at/after the target server version — resolves from
|
|
218
|
+
// pg_catalog and must not be tied to the extension's schema.
|
|
219
|
+
if (indexed.coreSince !== undefined && this.serverVersion >= indexed.coreSince)
|
|
220
|
+
return undefined;
|
|
221
|
+
const route = this.routes.get(indexed.extension);
|
|
222
|
+
if (!route)
|
|
223
|
+
return undefined;
|
|
224
|
+
if (this.definitions.get(indexed.extension)?.fixedSchema !== undefined)
|
|
225
|
+
return undefined;
|
|
226
|
+
const current = schema ?? null;
|
|
227
|
+
if (route.from !== undefined && !route.from.includes(current))
|
|
228
|
+
return undefined;
|
|
229
|
+
if (current === route.to)
|
|
230
|
+
return undefined;
|
|
231
|
+
return { to: route.to };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function symbolKey(ns, name) {
|
|
235
|
+
return `${ns}:${name}`;
|
|
236
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST transform that applies an {@link ExtensionRouter}.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the schema transform — which only rewrites string *fields* already
|
|
5
|
+
* present on a node — moving an extension changes the **shape** of the AST:
|
|
6
|
+
*
|
|
7
|
+
* - `CREATE EXTENSION <e>` with no `SCHEMA` clause must gain one, which means
|
|
8
|
+
* constructing a new `DefElem` node and attaching it to a (possibly absent)
|
|
9
|
+
* `options` array; the reverse strips the `DefElem` (and empties `options`).
|
|
10
|
+
* - a bare `crypt(...)` reference gains a schema qualifier (a `String` node is
|
|
11
|
+
* prepended to `funcname`), and the reverse removes it.
|
|
12
|
+
*
|
|
13
|
+
* All rewrites are AST-precise and driven by the router's version-aware symbol
|
|
14
|
+
* inventory, so built-ins that merely share a name with an extension symbol
|
|
15
|
+
* (and symbols that have graduated into core) are left untouched.
|
|
16
|
+
*/
|
|
17
|
+
import { walk as walkSql } from '@pgsql/traverse';
|
|
18
|
+
import { Deparser, parseSql, transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
|
|
19
|
+
import { ExtensionRouter } from './extension-router';
|
|
20
|
+
/** Create a fresh result tracker. */
|
|
21
|
+
export function createExtensionResult() {
|
|
22
|
+
return { installsMoved: new Map(), symbolsRewritten: new Map() };
|
|
23
|
+
}
|
|
24
|
+
function schemaStringNode(schema) {
|
|
25
|
+
return { String: { sval: schema } };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Rewrite a (possibly schema-qualified) name list in place against the router.
|
|
29
|
+
* `names` is the node's own array (`funcname`, `TypeName.names`, ...); it is
|
|
30
|
+
* mutated in place so the enclosing node keeps referring to it.
|
|
31
|
+
*/
|
|
32
|
+
function rewriteNameList(names, router, ns, result) {
|
|
33
|
+
if (!Array.isArray(names) || names.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const parts = names.map((n) => n?.String?.sval);
|
|
36
|
+
if (parts.some(p => typeof p !== 'string'))
|
|
37
|
+
return; // non-identifier element (e.g. %type)
|
|
38
|
+
const name = parts[parts.length - 1];
|
|
39
|
+
const schema = parts.length >= 2 ? parts[parts.length - 2] : null;
|
|
40
|
+
const rewrite = router.resolveSymbol(schema, name, ns);
|
|
41
|
+
if (!rewrite)
|
|
42
|
+
return;
|
|
43
|
+
if (rewrite.to === null) {
|
|
44
|
+
// Strip qualification: keep only the bare name node.
|
|
45
|
+
names.splice(0, names.length - 1);
|
|
46
|
+
}
|
|
47
|
+
else if (schema === null) {
|
|
48
|
+
// Qualify a bare reference: prepend the target schema.
|
|
49
|
+
names.unshift(schemaStringNode(rewrite.to));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Requalify: rewrite the existing schema element.
|
|
53
|
+
names[names.length - 2].String.sval = rewrite.to;
|
|
54
|
+
}
|
|
55
|
+
result.symbolsRewritten.set(name, (result.symbolsRewritten.get(name) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
/** Read/replace the `schema` DefElem inside a `CreateExtensionStmt.options`. */
|
|
58
|
+
function setExtensionSchemaOption(node, target) {
|
|
59
|
+
const options = Array.isArray(node.options) ? node.options : [];
|
|
60
|
+
const idx = options.findIndex((opt) => opt?.DefElem?.defname === 'schema');
|
|
61
|
+
if (target === null) {
|
|
62
|
+
// Install with no explicit SCHEMA clause: remove any existing option.
|
|
63
|
+
if (idx === -1)
|
|
64
|
+
return false;
|
|
65
|
+
options.splice(idx, 1);
|
|
66
|
+
if (options.length === 0)
|
|
67
|
+
delete node.options;
|
|
68
|
+
else
|
|
69
|
+
node.options = options;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const defElem = {
|
|
73
|
+
DefElem: {
|
|
74
|
+
defname: 'schema',
|
|
75
|
+
arg: { String: { sval: target } },
|
|
76
|
+
defaction: 'DEFELEM_UNSPEC'
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
if (idx === -1) {
|
|
80
|
+
options.push(defElem);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const existing = options[idx].DefElem;
|
|
84
|
+
if (existing.arg?.String?.sval === target)
|
|
85
|
+
return false;
|
|
86
|
+
existing.arg = { String: { sval: target } };
|
|
87
|
+
}
|
|
88
|
+
node.options = options;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a SQL AST visitor that applies extension routing. Composable with the
|
|
93
|
+
* walkers used by the core transform and by PL/pgSQL body traversal.
|
|
94
|
+
*/
|
|
95
|
+
export function createExtensionVisitor(router, result) {
|
|
96
|
+
const rewriteBody = router.hasSymbolRoutes();
|
|
97
|
+
return {
|
|
98
|
+
CreateExtensionStmt: (path) => {
|
|
99
|
+
const node = path.node;
|
|
100
|
+
const target = router.resolveInstall(node.extname);
|
|
101
|
+
if (target === undefined)
|
|
102
|
+
return;
|
|
103
|
+
if (setExtensionSchemaOption(node, target)) {
|
|
104
|
+
result.installsMoved.set(node.extname, target);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
// ALTER EXTENSION <e> SET SCHEMA <newschema>. The walker does not recurse
|
|
108
|
+
// into AlterObjectSchemaStmt.object; a null target has no SET SCHEMA form
|
|
109
|
+
// (a schema must be named), so it is left unchanged.
|
|
110
|
+
AlterObjectSchemaStmt: (path) => {
|
|
111
|
+
const node = path.node;
|
|
112
|
+
if (node.objectType !== 'OBJECT_EXTENSION')
|
|
113
|
+
return;
|
|
114
|
+
const extname = node.object?.String?.sval;
|
|
115
|
+
const target = router.resolveInstall(extname);
|
|
116
|
+
if (typeof target !== 'string' || target === node.newschema)
|
|
117
|
+
return;
|
|
118
|
+
node.newschema = target;
|
|
119
|
+
result.installsMoved.set(extname, target);
|
|
120
|
+
},
|
|
121
|
+
FuncCall: (path) => {
|
|
122
|
+
rewriteNameList(path.node.funcname, router, 'function', result);
|
|
123
|
+
},
|
|
124
|
+
// The walker does not auto-recurse into CallStmt.funccall.
|
|
125
|
+
CallStmt: (path) => {
|
|
126
|
+
if (path.node.funccall) {
|
|
127
|
+
rewriteNameList(path.node.funccall.funcname, router, 'function', result);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
TypeName: (path) => {
|
|
131
|
+
rewriteNameList(path.node.names, router, 'type', result);
|
|
132
|
+
},
|
|
133
|
+
CreateFunctionStmt: (path) => {
|
|
134
|
+
const node = path.node;
|
|
135
|
+
if (!rewriteBody)
|
|
136
|
+
return;
|
|
137
|
+
// LANGUAGE sql bodies are opaque strings to the walker; parse and rewrite
|
|
138
|
+
// them separately (PL/pgSQL bodies ride the hydrated walk in transformExtensions).
|
|
139
|
+
const isPlpgsql = (node.options ?? []).some((opt) => opt?.DefElem?.defname === 'language' &&
|
|
140
|
+
opt.DefElem.arg?.String?.sval === 'plpgsql');
|
|
141
|
+
if (isPlpgsql || !Array.isArray(node.options))
|
|
142
|
+
return;
|
|
143
|
+
for (const opt of node.options) {
|
|
144
|
+
if (opt?.DefElem?.defname === 'as' && opt.DefElem.arg?.List?.items) {
|
|
145
|
+
for (const item of opt.DefElem.arg.List.items) {
|
|
146
|
+
if (typeof item?.String?.sval === 'string') {
|
|
147
|
+
try {
|
|
148
|
+
item.String.sval = rewriteSqlBodyString(item.String.sval, router, result);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Not parseable standalone (e.g. C symbol names) — leave as-is.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Rewrite extension references inside a `LANGUAGE sql` body string. */
|
|
161
|
+
function rewriteSqlBodyString(body, router, result) {
|
|
162
|
+
const stmts = parseSql(body)?.stmts ?? [];
|
|
163
|
+
if (stmts.length === 0)
|
|
164
|
+
return body;
|
|
165
|
+
const visitor = createExtensionVisitor(router, result);
|
|
166
|
+
const pieces = [];
|
|
167
|
+
for (const stmt of stmts) {
|
|
168
|
+
if (!stmt?.stmt)
|
|
169
|
+
continue;
|
|
170
|
+
walkSql(stmt.stmt, visitor);
|
|
171
|
+
pieces.push(Deparser.deparse(stmt.stmt));
|
|
172
|
+
}
|
|
173
|
+
return pieces.join(';\n');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Apply extension routing to a SQL string: parse -> walk -> deparse, including
|
|
177
|
+
* PL/pgSQL and `LANGUAGE sql` bodies. Returns the rewritten SQL and a summary
|
|
178
|
+
* of what changed.
|
|
179
|
+
*/
|
|
180
|
+
export function transformExtensions(sql, router, options = {}) {
|
|
181
|
+
const resolved = ExtensionRouter.from(router, options);
|
|
182
|
+
const result = createExtensionResult();
|
|
183
|
+
const out = transformSync(sql, (ctx) => {
|
|
184
|
+
const stmts = ctx.sql?.stmts ?? [];
|
|
185
|
+
const visitor = createExtensionVisitor(resolved, result);
|
|
186
|
+
for (const stmt of stmts) {
|
|
187
|
+
if (stmt?.stmt)
|
|
188
|
+
walkSql(stmt.stmt, visitor);
|
|
189
|
+
}
|
|
190
|
+
if (resolved.hasSymbolRoutes()) {
|
|
191
|
+
for (const fn of ctx.functions ?? []) {
|
|
192
|
+
if (fn.plpgsql?.hydrated) {
|
|
193
|
+
walkPlpgsql(fn.plpgsql.hydrated, {}, {
|
|
194
|
+
walkSqlExpressions: true,
|
|
195
|
+
sqlVisitor: visitor
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}, { hydrate: true, pretty: true });
|
|
201
|
+
return { sql: out, result };
|
|
202
|
+
}
|
package/esm/facts.js
CHANGED
|
@@ -13,6 +13,7 @@ const SECURITY_TAGS = new Set([
|
|
|
13
13
|
]);
|
|
14
14
|
const KIND_BY_TAG = {
|
|
15
15
|
CreateSchemaStmt: 'schema',
|
|
16
|
+
CreateExtensionStmt: 'extension',
|
|
16
17
|
CreateStmt: 'table',
|
|
17
18
|
ViewStmt: 'view',
|
|
18
19
|
IndexStmt: 'index',
|
|
@@ -37,6 +38,23 @@ const KIND_BY_TAG = {
|
|
|
37
38
|
function qn(schema, name) {
|
|
38
39
|
return { schema: schema ?? null, name };
|
|
39
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
|
+
}
|
|
40
58
|
function nameListToQualified(names) {
|
|
41
59
|
if (!Array.isArray(names) || names.length === 0)
|
|
42
60
|
return null;
|
|
@@ -142,6 +160,14 @@ function classifyOne(nodeTag, node) {
|
|
|
142
160
|
case 'CreateSchemaStmt':
|
|
143
161
|
facts.creates.push(qn(null, node.schemaname));
|
|
144
162
|
break;
|
|
163
|
+
case 'CreateExtensionStmt':
|
|
164
|
+
facts.extension = {
|
|
165
|
+
name: node.extname,
|
|
166
|
+
schema: extensionSchemaOption(node.options),
|
|
167
|
+
action: 'create',
|
|
168
|
+
ifNotExists: node.if_not_exists === true
|
|
169
|
+
};
|
|
170
|
+
break;
|
|
145
171
|
case 'CreateStmt':
|
|
146
172
|
case 'ViewStmt': {
|
|
147
173
|
const rel = nodeTag === 'ViewStmt' ? node.view : node.relation;
|
|
@@ -203,6 +229,34 @@ function classifyOne(nodeTag, node) {
|
|
|
203
229
|
facts.creates.push(name);
|
|
204
230
|
break;
|
|
205
231
|
}
|
|
232
|
+
case 'AlterObjectSchemaStmt':
|
|
233
|
+
// ALTER EXTENSION <name> SET SCHEMA <newschema>. Other object types
|
|
234
|
+
// (TABLE/TYPE/FUNCTION ... SET SCHEMA) keep their default classification.
|
|
235
|
+
if (node.objectType === 'OBJECT_EXTENSION') {
|
|
236
|
+
const name = node.object?.String?.sval;
|
|
237
|
+
if (typeof name === 'string') {
|
|
238
|
+
facts.kind = 'extension';
|
|
239
|
+
facts.extension = {
|
|
240
|
+
name,
|
|
241
|
+
schema: typeof node.newschema === 'string' ? node.newschema : null,
|
|
242
|
+
action: 'set_schema'
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
case 'DropStmt':
|
|
248
|
+
// DROP EXTENSION <name> [, ...]. Extension names are bare String nodes.
|
|
249
|
+
// Other DROP object types keep their default classification. When
|
|
250
|
+
// several extensions are dropped in one statement the first names the
|
|
251
|
+
// fact; the full list stays available via the raw node.
|
|
252
|
+
if (node.removeType === 'OBJECT_EXTENSION' && Array.isArray(node.objects)) {
|
|
253
|
+
const name = node.objects[0]?.String?.sval;
|
|
254
|
+
if (typeof name === 'string') {
|
|
255
|
+
facts.kind = 'extension';
|
|
256
|
+
facts.extension = { name, schema: null, action: 'drop' };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
206
260
|
case 'AlterTableStmt': {
|
|
207
261
|
if (node.relation) {
|
|
208
262
|
facts.creates.push(qn(node.relation.schemaname ?? null, node.relation.relname));
|
package/esm/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { classifyStatements } from './facts';
|
|
2
|
+
export { COMMON_EXTENSIONS, ExtensionRouter } from './extension-router';
|
|
3
|
+
export { createExtensionResult, createExtensionVisitor, transformExtensions, } from './extension-transform';
|
|
4
|
+
export { RoleRouter } from './role-router';
|
|
5
|
+
export { createRoleResult, createRoleVisitor, transformRoles, } from './role-transform';
|
|
2
6
|
export { collectCreatedObjects, createQualifyVisitor, mergeInventories, qualifyUnqualified, } from './qualify';
|
|
3
7
|
export { SchemaRouter } from './router';
|
|
4
8
|
export { captureAstsFromSql, captureTransformAsts, firstDifference, normalizeParseTree, validateRoundTrip, } from './round-trip';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-name routing for the core transform.
|
|
3
|
+
*
|
|
4
|
+
* Two databases can express the *same* access model with *different* role
|
|
5
|
+
* names — one calls the unauthenticated role `anonymous`, another `anon`; one
|
|
6
|
+
* calls the privileged role `administrator`, another something else. Portable
|
|
7
|
+
* SQL must be able to translate role identifiers between these conventions.
|
|
8
|
+
*
|
|
9
|
+
* Scope, deliberately narrow: a {@link RoleRouter} only renames role
|
|
10
|
+
* *identifiers* in the AST positions where a role name legitimately appears
|
|
11
|
+
* (grants, ownership, policies, default privileges, role membership, role
|
|
12
|
+
* settings). It does **not** alter role *attributes* (`BYPASSRLS`, `LOGIN`,
|
|
13
|
+
* ...): two roles that share a name across conventions do not necessarily
|
|
14
|
+
* share privilege scope, and inventing attribute changes would silently
|
|
15
|
+
* misrepresent the security model. Privilege-semantics reconciliation is a
|
|
16
|
+
* downstream, policy-level concern; the router restricts itself to the
|
|
17
|
+
* deterministic, reversible part — the name.
|
|
18
|
+
*
|
|
19
|
+
* Renaming is a pure identifier substitution, so a router is trivially
|
|
20
|
+
* invertible ({@link RoleRouter.invert}) for bidirectional translation.
|
|
21
|
+
*/
|
|
22
|
+
/** Resolves a target role name for a source role name. */
|
|
23
|
+
export class RoleRouter {
|
|
24
|
+
map;
|
|
25
|
+
constructor(routes = {}) {
|
|
26
|
+
this.map = routes instanceof Map ? new Map(routes) : new Map(Object.entries(routes));
|
|
27
|
+
}
|
|
28
|
+
/** Coerce a spec, map, or existing router into a router. */
|
|
29
|
+
static from(source) {
|
|
30
|
+
return source instanceof RoleRouter ? source : new RoleRouter(source);
|
|
31
|
+
}
|
|
32
|
+
/** Number of configured renames. */
|
|
33
|
+
get size() {
|
|
34
|
+
return this.map.size;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the target name for `role`, or `undefined` when it is not routed
|
|
38
|
+
* or already at its destination.
|
|
39
|
+
*/
|
|
40
|
+
resolve(role) {
|
|
41
|
+
if (!role)
|
|
42
|
+
return undefined;
|
|
43
|
+
const target = this.map.get(role);
|
|
44
|
+
if (target === undefined || target === role)
|
|
45
|
+
return undefined;
|
|
46
|
+
return target;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The inverse router (target -> source), for translating in the opposite
|
|
50
|
+
* direction. Throws if the mapping is not one-to-one.
|
|
51
|
+
*/
|
|
52
|
+
invert() {
|
|
53
|
+
const inverted = new Map();
|
|
54
|
+
for (const [from, to] of this.map) {
|
|
55
|
+
if (inverted.has(to)) {
|
|
56
|
+
throw new Error(`RoleRouter.invert: mapping is not one-to-one (two sources map to "${to}")`);
|
|
57
|
+
}
|
|
58
|
+
inverted.set(to, from);
|
|
59
|
+
}
|
|
60
|
+
return new RoleRouter(inverted);
|
|
61
|
+
}
|
|
62
|
+
}
|