@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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * AST transform that applies a {@link RoleRouter}.
3
+ *
4
+ * Role identifiers surface in many statement forms. Most flow through a single
5
+ * `RoleSpec` node (grantees, ownership `... OWNER TO`, `CREATE POLICY ... TO`,
6
+ * `ALTER DEFAULT PRIVILEGES [FOR ROLE ...] ... TO`, `ALTER ROLE`, `DROP ROLE`,
7
+ * role-membership grantees, `GRANTED BY`, `REASSIGN OWNED BY`), which the
8
+ * traversal reaches automatically. Four positions carry a role name outside a
9
+ * `RoleSpec` and are handled explicitly:
10
+ *
11
+ * - `CREATE ROLE <name>` — a bare string on `CreateRoleStmt.role`.
12
+ * - `GRANT <role> TO ...` — the *granted* roles are `AccessPriv.priv_name`
13
+ * strings on `GrantRoleStmt.granted_roles` (not `RoleSpec`s).
14
+ * - `SET ROLE` / `SET SESSION AUTHORIZATION` — the role is a string constant
15
+ * in `VariableSetStmt.args`.
16
+ * - `ALTER ROLE <old> RENAME TO <new>` — `RenameStmt` with string
17
+ * `subname`/`newname`.
18
+ *
19
+ * Reserved role specifications (`PUBLIC`, `CURRENT_USER`, `SESSION_USER`, ...)
20
+ * carry no `rolename` and a non-`CSTRING` `roletype`, so they are never
21
+ * rewritten.
22
+ */
23
+ import { walk as walkSql } from '@pgsql/traverse';
24
+ import { transformSync } from 'plpgsql-parser';
25
+ import { RoleRouter } from './role-router';
26
+ export function createRoleResult() {
27
+ return { rolesRenamed: new Map() };
28
+ }
29
+ const ROLE_SET_PARAMS = new Set(['role', 'session_authorization']);
30
+ /**
31
+ * Create a SQL AST visitor that renames role identifiers against a router.
32
+ * Composable with the walkers used by the core transform.
33
+ */
34
+ export function createRoleVisitor(router, result) {
35
+ const record = (name) => {
36
+ result.rolesRenamed.set(name, (result.rolesRenamed.get(name) ?? 0) + 1);
37
+ };
38
+ return {
39
+ RoleSpec: (path) => {
40
+ const node = path.node;
41
+ // Only named roles (ROLESPEC_CSTRING) carry a rolename; PUBLIC /
42
+ // CURRENT_USER / SESSION_USER are encoded by roletype and left alone.
43
+ if (node.roletype !== 'ROLESPEC_CSTRING')
44
+ return;
45
+ const target = router.resolve(node.rolename);
46
+ if (target === undefined)
47
+ return;
48
+ node.rolename = target;
49
+ record(node.rolename);
50
+ },
51
+ CreateRoleStmt: (path) => {
52
+ const node = path.node;
53
+ const target = router.resolve(node.role);
54
+ if (target === undefined)
55
+ return;
56
+ node.role = target;
57
+ record(target);
58
+ },
59
+ GrantRoleStmt: (path) => {
60
+ // granted_roles are role names carried as AccessPriv.priv_name (the
61
+ // grantee_roles RoleSpecs are handled by the RoleSpec visitor).
62
+ const granted = path.node.granted_roles;
63
+ if (!Array.isArray(granted))
64
+ return;
65
+ for (const g of granted) {
66
+ const priv = g?.AccessPriv;
67
+ if (!priv || typeof priv.priv_name !== 'string')
68
+ continue;
69
+ const target = router.resolve(priv.priv_name);
70
+ if (target === undefined)
71
+ continue;
72
+ priv.priv_name = target;
73
+ record(target);
74
+ }
75
+ },
76
+ VariableSetStmt: (path) => {
77
+ const node = path.node;
78
+ if (!ROLE_SET_PARAMS.has(node.name) || !Array.isArray(node.args))
79
+ return;
80
+ for (const arg of node.args) {
81
+ const sval = arg?.A_Const?.sval?.sval ?? arg?.String?.sval;
82
+ if (typeof sval !== 'string')
83
+ continue;
84
+ const target = router.resolve(sval);
85
+ if (target === undefined)
86
+ continue;
87
+ if (arg.A_Const?.sval)
88
+ arg.A_Const.sval.sval = target;
89
+ else if (arg.String)
90
+ arg.String.sval = target;
91
+ record(target);
92
+ }
93
+ },
94
+ RenameStmt: (path) => {
95
+ const node = path.node;
96
+ if (node.renameType !== 'OBJECT_ROLE')
97
+ return;
98
+ const subTarget = router.resolve(node.subname);
99
+ if (subTarget !== undefined) {
100
+ node.subname = subTarget;
101
+ record(subTarget);
102
+ }
103
+ const newTarget = router.resolve(node.newname);
104
+ if (newTarget !== undefined) {
105
+ node.newname = newTarget;
106
+ record(newTarget);
107
+ }
108
+ }
109
+ };
110
+ }
111
+ /**
112
+ * Apply role renaming to a SQL string: parse -> walk -> deparse. Role names in
113
+ * PL/pgSQL / `LANGUAGE sql` bodies are string literals (e.g. inside dynamic
114
+ * `EXECUTE`) and are intentionally not rewritten — see the module docs on why
115
+ * arbitrary string contents are out of scope.
116
+ */
117
+ export function transformRoles(sql, router) {
118
+ const resolved = RoleRouter.from(router);
119
+ const result = createRoleResult();
120
+ const out = transformSync(sql, (ctx) => {
121
+ const stmts = ctx.sql?.stmts ?? [];
122
+ const visitor = createRoleVisitor(resolved, result);
123
+ for (const stmt of stmts) {
124
+ if (stmt?.stmt)
125
+ walkSql(stmt.stmt, visitor);
126
+ }
127
+ }, { hydrate: true, pretty: true });
128
+ return { sql: out, result };
129
+ }
package/esm/router.js CHANGED
@@ -20,6 +20,19 @@
20
20
  * `types` — matching `pg_class` / `pg_proc` / `pg_type`), mirroring the routing
21
21
  * model already used by {@link qualifyUnqualified}. Resolution is
22
22
  * object-route-first, then the schema-level default, then "leave unchanged".
23
+ *
24
+ * An object route may also *rebind* — change the object's **name**, not just
25
+ * the schema it lives in. Routing preserves identity (the same function, a new
26
+ * address); rebinding repoints a reference at a *different* object, which is
27
+ * what lets one implementation of a contract be substituted for another:
28
+ *
29
+ * ```ts
30
+ * { auth: { functions: { uid: { schema: null, name: 'current_user_id' } } } }
31
+ * // auth.uid() -> current_user_id()
32
+ * ```
33
+ *
34
+ * A `null` target schema de-qualifies the reference (relying on `search_path`),
35
+ * matching the convention used by the extension router.
23
36
  */
24
37
  const NS_BUCKET = {
25
38
  relation: 'relations',
@@ -71,6 +84,36 @@ export class SchemaRouter {
71
84
  }
72
85
  return false;
73
86
  }
87
+ /**
88
+ * True when any object route changes a name or de-qualifies (rather than
89
+ * only moving between schemas). Such rewrites cannot be expressed by the
90
+ * string-level passes at all, so callers use this to require the AST path.
91
+ */
92
+ hasNameRebinds() {
93
+ return this.nameRebinds().length > 0;
94
+ }
95
+ /**
96
+ * Every object route that rebinds a name or de-qualifies, keyed by source
97
+ * schema and namespace. Callers use this to report or verify substitutions.
98
+ */
99
+ nameRebinds() {
100
+ const out = [];
101
+ for (const [schema, route] of this.routes) {
102
+ for (const ns of ['relation', 'function', 'type']) {
103
+ const bucket = route[NS_BUCKET[ns]];
104
+ if (!bucket)
105
+ continue;
106
+ for (const [from, target] of Object.entries(bucket)) {
107
+ if (typeof target === 'string')
108
+ continue;
109
+ if (target.name === undefined && target.schema !== null)
110
+ continue;
111
+ out.push({ schema, ns, from, to: target });
112
+ }
113
+ }
114
+ }
115
+ return out;
116
+ }
74
117
  /** Every source schema this router may touch. */
75
118
  sourceSchemas() {
76
119
  return [...this.routes.keys()];
@@ -82,18 +125,39 @@ export class SchemaRouter {
82
125
  * to any object without a specific route.
83
126
  */
84
127
  resolve(sourceSchema, name, ns = 'unknown') {
128
+ // A `null` target de-qualifies the reference; the schema-only API cannot
129
+ // express that, so it reads as "unchanged" here.
130
+ return this.resolveObject(sourceSchema, name, ns)?.schema ?? undefined;
131
+ }
132
+ /**
133
+ * Resolve the full target for `(sourceSchema, name)` in namespace `ns` — both
134
+ * the schema the reference should live in and, when the route rebinds, the
135
+ * name it should be reached by. Returns `undefined` to leave it unchanged.
136
+ *
137
+ * In the result, `schema` is `null` when the reference should become
138
+ * unqualified and `undefined` when only the name changes; `name` is
139
+ * `undefined` when only the schema changes.
140
+ */
141
+ resolveObject(sourceSchema, name, ns = 'unknown') {
85
142
  if (!sourceSchema)
86
143
  return undefined;
87
144
  const route = this.routes.get(sourceSchema);
88
145
  if (!route)
89
146
  return undefined;
90
147
  if (name && (ns === 'relation' || ns === 'function' || ns === 'type')) {
91
- const bucket = route[NS_BUCKET[ns]];
92
- const mapped = bucket?.[name];
93
- if (mapped !== undefined)
94
- return mapped;
148
+ const target = route[NS_BUCKET[ns]]?.[name];
149
+ if (target !== undefined) {
150
+ if (typeof target === 'string')
151
+ return { schema: target };
152
+ // An object route naming no schema inherits the schema-level default,
153
+ // so a pure rebind leaves placement alone.
154
+ return {
155
+ schema: target.schema !== undefined ? target.schema : route.schema,
156
+ name: target.name
157
+ };
158
+ }
95
159
  }
96
- return route.schema;
160
+ return route.schema !== undefined ? { schema: route.schema } : undefined;
97
161
  }
98
162
  /**
99
163
  * Source schemas whose *every* object is guaranteed to move — i.e. those
package/esm/transform.js CHANGED
@@ -80,12 +80,24 @@ export function transformNameList(names, schemaMapping, result, ns = 'unknown')
80
80
  const first = names[0];
81
81
  if (first?.String?.sval) {
82
82
  const schemaName = first.String.sval;
83
- const objName = names[names.length - 1]?.String?.sval;
84
- const newName = router.resolve(schemaName, objName, ns);
85
- if (newName && newName !== schemaName) {
83
+ const last = names[names.length - 1];
84
+ const objName = last?.String?.sval;
85
+ const target = router.resolveObject(schemaName, objName, ns);
86
+ if (!target)
87
+ return;
88
+ if (target.name !== undefined && last?.String?.sval) {
86
89
  result.schemasFound.add(schemaName);
87
- first.String.sval = newName;
88
- result.schemasTransformed.set(schemaName, newName);
90
+ last.String.sval = target.name;
91
+ }
92
+ if (target.schema === null) {
93
+ // De-qualify: drop the schema element and rely on search_path.
94
+ result.schemasFound.add(schemaName);
95
+ names.splice(0, 1);
96
+ }
97
+ else if (target.schema && target.schema !== schemaName) {
98
+ result.schemasFound.add(schemaName);
99
+ first.String.sval = target.schema;
100
+ result.schemasTransformed.set(schemaName, target.schema);
89
101
  }
90
102
  }
91
103
  }
@@ -117,11 +129,22 @@ export function transformRelation(relation, schemaMapping, result) {
117
129
  const oldName = relation.schemaname;
118
130
  // A RangeVar names a relation (table/view/sequence/matview); route by the
119
131
  // relation name so object-level routes can send it to its own schema.
120
- const newName = asRouter(schemaMapping).resolve(oldName, relation.relname, 'relation');
121
- if (newName && newName !== oldName) {
132
+ const target = asRouter(schemaMapping).resolveObject(oldName, relation.relname, 'relation');
133
+ if (!target)
134
+ return;
135
+ if (target.name !== undefined && relation.relname) {
136
+ result.schemasFound.add(oldName);
137
+ relation.relname = target.name;
138
+ }
139
+ if (target.schema === null) {
140
+ // De-qualify: drop the schema qualifier and rely on search_path.
141
+ result.schemasFound.add(oldName);
142
+ delete relation.schemaname;
143
+ }
144
+ else if (target.schema && target.schema !== oldName) {
122
145
  result.schemasFound.add(oldName);
123
- relation.schemaname = newName;
124
- result.schemasTransformed.set(oldName, newName);
146
+ relation.schemaname = target.schema;
147
+ result.schemasTransformed.set(oldName, target.schema);
125
148
  }
126
149
  }
127
150
  /**
@@ -0,0 +1,161 @@
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
+ /** PostgreSQL namespaces an extension symbol can occupy. */
35
+ export type ExtensionSymbolNamespace = 'function' | 'type' | 'operator';
36
+ /** A single symbol provided by an extension. */
37
+ export interface ExtensionSymbol {
38
+ /** Bare symbol name (e.g. `crypt`, `citext`, `gen_random_uuid`). */
39
+ name: string;
40
+ /** Which namespace the symbol occupies. Defaults to `function`. */
41
+ namespace?: ExtensionSymbolNamespace;
42
+ /**
43
+ * Major PostgreSQL version at/after which this symbol is part of the server
44
+ * core (`pg_catalog`) rather than the extension. At or above this version
45
+ * the symbol is **never routed** — it resolves from `pg_catalog` regardless
46
+ * of where the extension lives. The classic case is `gen_random_uuid`
47
+ * (`coreSince: 13`).
48
+ */
49
+ coreSince?: number;
50
+ }
51
+ /** The set of symbols an extension provides. */
52
+ export interface ExtensionDefinition {
53
+ /** Extension name as used in `CREATE EXTENSION <name>`. */
54
+ name: string;
55
+ /**
56
+ * Whether the extension is relocatable (`ALTER EXTENSION ... SET SCHEMA`
57
+ * succeeds). Informational for callers deciding *how* to move an existing
58
+ * install; the router never emits `SET SCHEMA` itself.
59
+ */
60
+ relocatable?: boolean;
61
+ /**
62
+ * A schema the extension is pinned to and cannot be moved out of (fixed by
63
+ * its control file, e.g. a geocoder that always creates its own schema).
64
+ * When set, the router refuses to route the extension's install or symbols.
65
+ */
66
+ fixedSchema?: string;
67
+ /** Symbols the extension provides. */
68
+ symbols: ExtensionSymbol[];
69
+ }
70
+ /**
71
+ * A routing rule for one extension. `to` is the destination install/reference
72
+ * schema (or `null` to strip qualification / install without a `SCHEMA`
73
+ * clause). `from` lists the schemas currently holding the extension that
74
+ * should be rewritten; a `null` entry additionally routes bare references.
75
+ * When `from` is omitted, references in **any** schema (and bare ones) are
76
+ * routed — useful when the source layout is unknown.
77
+ */
78
+ export interface ExtensionRoute {
79
+ to: string | null;
80
+ from?: (string | null)[];
81
+ }
82
+ /** Routing specification keyed by extension name. */
83
+ export type ExtensionRouteSpec = Record<string, ExtensionRoute>;
84
+ /** A concrete rewrite instruction: from a schema (or bare) to a schema (or bare). */
85
+ export interface SymbolRewrite {
86
+ /** Target schema, or `null` to make the reference unqualified. */
87
+ to: string | null;
88
+ }
89
+ export interface ExtensionRouterOptions {
90
+ /**
91
+ * The symbol inventory. Defaults to {@link COMMON_EXTENSIONS}. Callers can
92
+ * pass an augmented or replacement inventory (e.g. extended from live-catalog
93
+ * introspection).
94
+ */
95
+ inventory?: ExtensionDefinition[];
96
+ /**
97
+ * Target PostgreSQL major version, used to apply {@link ExtensionSymbol.coreSince}.
98
+ * Defaults to a high value so version-graduated symbols (e.g. `gen_random_uuid`)
99
+ * are treated as core and never routed unless a caller opts into an older
100
+ * version.
101
+ */
102
+ serverVersion?: number;
103
+ }
104
+ /**
105
+ * A curated, platform-agnostic inventory of common contrib/extension symbols.
106
+ * Intentionally minimal and conservative — only well-known, stable symbols are
107
+ * listed so routing never touches something it cannot prove belongs to the
108
+ * extension. Callers extend this via {@link ExtensionRouterOptions.inventory}.
109
+ */
110
+ export declare const COMMON_EXTENSIONS: ExtensionDefinition[];
111
+ /**
112
+ * Resolves install-schema and symbol-reference routing for extensions, backed
113
+ * by a version-aware symbol inventory.
114
+ */
115
+ export declare class ExtensionRouter {
116
+ private readonly routes;
117
+ private readonly serverVersion;
118
+ private readonly definitions;
119
+ /** `${namespace}:${name}` -> providing extension (+ version predicate). */
120
+ private readonly symbolIndex;
121
+ constructor(routes?: ExtensionRouteSpec | Map<string, ExtensionRoute>, options?: ExtensionRouterOptions);
122
+ /**
123
+ * Build a router that moves the given extensions (default: every inventoried
124
+ * one) to a single `targetSchema`, rewriting their references from `public`
125
+ * and from bare (unqualified) sites. Passing `null` as `targetSchema` strips
126
+ * qualification instead (the "repollute public / rely on search_path"
127
+ * direction).
128
+ */
129
+ static toSchema(targetSchema: string | null, options?: ExtensionRouterOptions & {
130
+ extensions?: string[];
131
+ from?: (string | null)[];
132
+ }): ExtensionRouter;
133
+ /** Coerce a spec, map, or existing router into a router. */
134
+ static from(source: ExtensionRouter | ExtensionRouteSpec | Map<string, ExtensionRoute>, options?: ExtensionRouterOptions): ExtensionRouter;
135
+ /** True when no extension routes are configured. */
136
+ get size(): number;
137
+ /** Extension names this router may rewrite. */
138
+ routedExtensions(): string[];
139
+ /** True when any configured extension has inventoried symbols to rewrite. */
140
+ hasSymbolRoutes(): boolean;
141
+ /**
142
+ * Resolve the install schema for `CREATE EXTENSION <extname>` /
143
+ * `ALTER EXTENSION <extname> SET SCHEMA`. Returns:
144
+ * - a string to place/keep the extension in that schema,
145
+ * - `null` to install with no explicit `SCHEMA` clause (server default),
146
+ * - `undefined` to leave the statement unchanged (no route, or the extension
147
+ * is pinned to a fixed schema).
148
+ */
149
+ resolveInstall(extname: string | undefined | null): string | null | undefined;
150
+ /**
151
+ * Resolve a rewrite for a reference to `(schema, name)` in namespace `ns`,
152
+ * where `schema` is `null` for a bare reference. Returns the target
153
+ * ({@link SymbolRewrite}) or `undefined` to leave the reference unchanged.
154
+ *
155
+ * A reference is rewritten only when the inventory proves the symbol belongs
156
+ * to a routed extension, the symbol has not graduated to core at the target
157
+ * server version, the current schema is one the route rewrites `from`, and
158
+ * the destination actually differs from where it already is.
159
+ */
160
+ resolveSymbol(schema: string | null | undefined, name: string | undefined, ns: ExtensionSymbolNamespace): SymbolRewrite | undefined;
161
+ }