@pgsql/transform 18.1.1 → 18.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -76
- package/esm/facts.js +289 -0
- package/esm/index.js +5 -22
- package/esm/qualify.js +297 -0
- package/esm/round-trip-core.js +126 -0
- package/esm/round-trip.js +116 -0
- package/esm/transform.js +1169 -0
- package/facts.d.ts +69 -0
- package/facts.js +292 -0
- package/index.d.ts +10 -28
- package/index.js +37 -76
- package/package.json +22 -14
- package/qualify.d.ts +125 -0
- package/qualify.js +303 -0
- package/round-trip-core.d.ts +60 -0
- package/round-trip-core.js +133 -0
- package/round-trip.d.ts +47 -0
- package/round-trip.js +123 -0
- package/transform.d.ts +259 -0
- package/transform.js +1190 -0
- package/13/enums.d.ts +0 -62
- package/13/enums.js +0 -2
- package/13/types.d.ts +0 -2103
- package/13/types.js +0 -2
- package/14/enums.d.ts +0 -62
- package/14/enums.js +0 -2
- package/14/types.d.ts +0 -2164
- package/14/types.js +0 -2
- package/15/enums.d.ts +0 -64
- package/15/enums.js +0 -2
- package/15/types.d.ts +0 -2222
- package/15/types.js +0 -2
- package/16/enums.d.ts +0 -69
- package/16/enums.js +0 -2
- package/16/types.d.ts +0 -2328
- package/16/types.js +0 -2
- package/17/enums.d.ts +0 -76
- package/17/enums.js +0 -2
- package/17/types.d.ts +0 -2488
- package/17/types.js +0 -2
- package/18/enums.d.ts +0 -78
- package/18/enums.js +0 -2
- package/18/types.d.ts +0 -2538
- package/18/types.js +0 -2
- package/esm/13/enums.js +0 -1
- package/esm/13/types.js +0 -1
- package/esm/14/enums.js +0 -1
- package/esm/14/types.js +0 -1
- package/esm/15/enums.js +0 -1
- package/esm/15/types.js +0 -1
- package/esm/16/enums.js +0 -1
- package/esm/16/types.js +0 -1
- package/esm/17/enums.js +0 -1
- package/esm/17/types.js +0 -1
- package/esm/18/enums.js +0 -1
- package/esm/18/types.js +0 -1
- package/esm/multi-version-transformer.js +0 -80
- package/esm/transformers/context.js +0 -1
- package/esm/transformers/v13-to-v14.js +0 -2767
- package/esm/transformers/v14-to-v15.js +0 -1218
- package/esm/transformers/v15-to-v16.js +0 -2935
- package/esm/transformers/v16-to-v17.js +0 -1526
- package/esm/transformers/v17-to-v18.js +0 -204
- package/esm/transformers-direct/index.js +0 -13
- package/esm/transformers-direct/v13-to-v17/index.js +0 -74
- package/esm/transformers-direct/v13-to-v18/index.js +0 -84
- package/esm/transformers-direct/v14-to-v17/index.js +0 -63
- package/esm/transformers-direct/v14-to-v18/index.js +0 -73
- package/esm/transformers-direct/v15-to-v17/index.js +0 -53
- package/esm/transformers-direct/v15-to-v18/index.js +0 -62
- package/esm/transformers-direct/v16-to-v17/index.js +0 -40
- package/esm/transformers-direct/v16-to-v18/index.js +0 -51
- package/esm/transformers-direct/v17-to-v18/index.js +0 -39
- package/multi-version-transformer.d.ts +0 -14
- package/multi-version-transformer.js +0 -84
- package/transformers/context.d.ts +0 -4
- package/transformers/context.js +0 -2
- package/transformers/v13-to-v14.d.ts +0 -255
- package/transformers/v13-to-v14.js +0 -2771
- package/transformers/v14-to-v15.d.ts +0 -615
- package/transformers/v14-to-v15.js +0 -1222
- package/transformers/v15-to-v16.d.ts +0 -632
- package/transformers/v15-to-v16.js +0 -2939
- package/transformers/v16-to-v17.d.ts +0 -640
- package/transformers/v16-to-v17.js +0 -1530
- package/transformers/v17-to-v18.d.ts +0 -54
- package/transformers/v17-to-v18.js +0 -208
- package/transformers-direct/index.d.ts +0 -13
- package/transformers-direct/index.js +0 -25
- package/transformers-direct/v13-to-v17/index.d.ts +0 -27
- package/transformers-direct/v13-to-v17/index.js +0 -78
- package/transformers-direct/v13-to-v18/index.d.ts +0 -28
- package/transformers-direct/v13-to-v18/index.js +0 -88
- package/transformers-direct/v14-to-v17/index.d.ts +0 -26
- package/transformers-direct/v14-to-v17/index.js +0 -67
- package/transformers-direct/v14-to-v18/index.d.ts +0 -27
- package/transformers-direct/v14-to-v18/index.js +0 -77
- package/transformers-direct/v15-to-v17/index.d.ts +0 -25
- package/transformers-direct/v15-to-v17/index.js +0 -57
- package/transformers-direct/v15-to-v18/index.d.ts +0 -26
- package/transformers-direct/v15-to-v18/index.js +0 -66
- package/transformers-direct/v16-to-v17/index.d.ts +0 -24
- package/transformers-direct/v16-to-v17/index.js +0 -44
- package/transformers-direct/v16-to-v18/index.d.ts +0 -25
- package/transformers-direct/v16-to-v18/index.js +0 -55
- package/transformers-direct/v17-to-v18/index.d.ts +0 -24
- package/transformers-direct/v17-to-v18/index.js +0 -43
package/qualify.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in qualification of unqualified object references.
|
|
3
|
+
*
|
|
4
|
+
* Handwritten SQL is typically unqualified — objects implicitly land in (and
|
|
5
|
+
* resolve against) the `public` schema. The core schema transform only
|
|
6
|
+
* rewrites *explicitly* qualified references, so ingesting such code into
|
|
7
|
+
* named schemas needs a prior pass that pins every unqualified reference to a
|
|
8
|
+
* schema first. This module provides that pass.
|
|
9
|
+
*
|
|
10
|
+
* Safety model: only routed names are qualified — either everything in an
|
|
11
|
+
* {@link ObjectInventory} to a single schema, or per-object routes via
|
|
12
|
+
* `targets`. Builtin functions (`now()`, `count(...)`), builtin types, and
|
|
13
|
+
* column references are never touched because they are not routed. Names
|
|
14
|
+
* defined by a CTE in the same statement are excluded even when they collide
|
|
15
|
+
* with a routed relation.
|
|
16
|
+
*/
|
|
17
|
+
/** Named objects eligible for qualification, bucketed by namespace. */
|
|
18
|
+
export interface ObjectInventory {
|
|
19
|
+
/** Tables, views, sequences — anything a RangeVar can reference. */
|
|
20
|
+
relations: Set<string>;
|
|
21
|
+
/** Function and procedure names. */
|
|
22
|
+
functions: Set<string>;
|
|
23
|
+
/** Enum/domain/range/composite type names. */
|
|
24
|
+
types: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
/** The objects a target schema should receive, by namespace. */
|
|
27
|
+
export interface QualifyTargetSelector {
|
|
28
|
+
relations?: Iterable<string>;
|
|
29
|
+
functions?: Iterable<string>;
|
|
30
|
+
types?: Iterable<string>;
|
|
31
|
+
}
|
|
32
|
+
export interface QualifyUnqualifiedOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Single-target form: schema to pin unqualified references to (e.g.
|
|
35
|
+
* `'public'`). Mutually exclusive with `targets`.
|
|
36
|
+
*/
|
|
37
|
+
schema?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Names eligible for qualification (single-target form only). Defaults to
|
|
40
|
+
* the objects created by the content itself ({@link collectCreatedObjects}).
|
|
41
|
+
* When qualifying scripts that reference objects created elsewhere (e.g.
|
|
42
|
+
* the scripts of a whole module), collect the inventory across all scripts
|
|
43
|
+
* and pass it here.
|
|
44
|
+
*/
|
|
45
|
+
inventory?: ObjectInventory;
|
|
46
|
+
/**
|
|
47
|
+
* Multi-target form: route specific objects to specific schemas in one
|
|
48
|
+
* pass. Mutually exclusive with `schema`/`inventory`.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* qualifyUnqualified(sql, {
|
|
52
|
+
* targets: {
|
|
53
|
+
* auth: { relations: ['users'] },
|
|
54
|
+
* shop: { relations: ['products'], functions: ['get_products'] },
|
|
55
|
+
* shared: { types: ['widget'] }
|
|
56
|
+
* }
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* Routing a name to two schemas in the same namespace is a conflict and
|
|
61
|
+
* throws.
|
|
62
|
+
*/
|
|
63
|
+
targets?: Record<string, QualifyTargetSelector>;
|
|
64
|
+
/**
|
|
65
|
+
* Prepend `CREATE SCHEMA IF NOT EXISTS <schema>;` for every target schema
|
|
66
|
+
* the content does not already create. Useful when ingesting standalone
|
|
67
|
+
* handwritten SQL whose target schemas may not exist.
|
|
68
|
+
*/
|
|
69
|
+
injectCreateSchema?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface QualifyResult {
|
|
72
|
+
/** Count of references qualified, keyed by object name. */
|
|
73
|
+
qualified: Map<string, number>;
|
|
74
|
+
/** Schema each qualified object was routed to. */
|
|
75
|
+
routed: Map<string, string>;
|
|
76
|
+
}
|
|
77
|
+
/** Resolved name → schema routing, by namespace. */
|
|
78
|
+
export interface QualifyRoutes {
|
|
79
|
+
relations: Map<string, string>;
|
|
80
|
+
functions: Map<string, string>;
|
|
81
|
+
types: Map<string, string>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Collect the objects a SQL script creates, bucketed for qualification.
|
|
85
|
+
* Only unqualified (schema-less) creations are collected — an explicitly
|
|
86
|
+
* qualified creation already declares where it lives.
|
|
87
|
+
*/
|
|
88
|
+
export declare function collectCreatedObjects(sql: string): ObjectInventory;
|
|
89
|
+
/** Merge inventories collected from multiple scripts. */
|
|
90
|
+
export declare function mergeInventories(inventories: ObjectInventory[]): ObjectInventory;
|
|
91
|
+
/**
|
|
92
|
+
* Create a SQL AST visitor that qualifies unqualified references against a
|
|
93
|
+
* routing table. Composable with the walkers used by the core transform.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createQualifyVisitor(options: {
|
|
96
|
+
routes: QualifyRoutes;
|
|
97
|
+
cteNames?: Set<string>;
|
|
98
|
+
}, result: QualifyResult): {
|
|
99
|
+
RangeVar: (path: any) => void;
|
|
100
|
+
FuncCall: (path: any) => void;
|
|
101
|
+
CallStmt: (path: any) => void;
|
|
102
|
+
TypeName: (path: any) => void;
|
|
103
|
+
CreateFunctionStmt: (path: any) => void;
|
|
104
|
+
CreateTrigStmt: (path: any) => void;
|
|
105
|
+
ObjectWithArgs: (path: any) => void;
|
|
106
|
+
CreateEnumStmt: (path: any) => void;
|
|
107
|
+
CreateRangeStmt: (path: any) => void;
|
|
108
|
+
CreateDomainStmt: (path: any) => void;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Qualify unqualified references in a SQL string.
|
|
112
|
+
*
|
|
113
|
+
* Two forms:
|
|
114
|
+
* - `{ schema, inventory? }` — pin every inventoried name to one schema.
|
|
115
|
+
* - `{ targets }` — route specific objects to specific schemas in one pass.
|
|
116
|
+
*
|
|
117
|
+
* Runs as a standalone AST pass (parse → qualify → deparse). To combine with
|
|
118
|
+
* a schema rename, run this first (typically with `schema: 'public'`) and
|
|
119
|
+
* then map via the core transform — or use the `qualifyUnqualified` option on
|
|
120
|
+
* `transformSql`, which does exactly that.
|
|
121
|
+
*/
|
|
122
|
+
export declare function qualifyUnqualified(sql: string, options: QualifyUnqualifiedOptions): {
|
|
123
|
+
sql: string;
|
|
124
|
+
result: QualifyResult;
|
|
125
|
+
};
|
package/qualify.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in qualification of unqualified object references.
|
|
4
|
+
*
|
|
5
|
+
* Handwritten SQL is typically unqualified — objects implicitly land in (and
|
|
6
|
+
* resolve against) the `public` schema. The core schema transform only
|
|
7
|
+
* rewrites *explicitly* qualified references, so ingesting such code into
|
|
8
|
+
* named schemas needs a prior pass that pins every unqualified reference to a
|
|
9
|
+
* schema first. This module provides that pass.
|
|
10
|
+
*
|
|
11
|
+
* Safety model: only routed names are qualified — either everything in an
|
|
12
|
+
* {@link ObjectInventory} to a single schema, or per-object routes via
|
|
13
|
+
* `targets`. Builtin functions (`now()`, `count(...)`), builtin types, and
|
|
14
|
+
* column references are never touched because they are not routed. Names
|
|
15
|
+
* defined by a CTE in the same statement are excluded even when they collide
|
|
16
|
+
* with a routed relation.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.collectCreatedObjects = collectCreatedObjects;
|
|
20
|
+
exports.mergeInventories = mergeInventories;
|
|
21
|
+
exports.createQualifyVisitor = createQualifyVisitor;
|
|
22
|
+
exports.qualifyUnqualified = qualifyUnqualified;
|
|
23
|
+
const traverse_1 = require("@pgsql/traverse");
|
|
24
|
+
const plpgsql_parser_1 = require("plpgsql-parser");
|
|
25
|
+
const facts_1 = require("./facts");
|
|
26
|
+
const RELATION_TAGS = new Set([
|
|
27
|
+
'CreateStmt',
|
|
28
|
+
'ViewStmt',
|
|
29
|
+
'CreateSeqStmt',
|
|
30
|
+
'CreateTableAsStmt'
|
|
31
|
+
]);
|
|
32
|
+
const TYPE_TAGS = new Set([
|
|
33
|
+
'CreateEnumStmt',
|
|
34
|
+
'CreateDomainStmt',
|
|
35
|
+
'CreateRangeStmt',
|
|
36
|
+
'CompositeTypeStmt'
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Collect the objects a SQL script creates, bucketed for qualification.
|
|
40
|
+
* Only unqualified (schema-less) creations are collected — an explicitly
|
|
41
|
+
* qualified creation already declares where it lives.
|
|
42
|
+
*/
|
|
43
|
+
function collectCreatedObjects(sql) {
|
|
44
|
+
const inventory = {
|
|
45
|
+
relations: new Set(),
|
|
46
|
+
functions: new Set(),
|
|
47
|
+
types: new Set()
|
|
48
|
+
};
|
|
49
|
+
for (const facts of (0, facts_1.classifyStatements)(sql)) {
|
|
50
|
+
for (const created of facts.creates) {
|
|
51
|
+
if (created.schema)
|
|
52
|
+
continue;
|
|
53
|
+
if (RELATION_TAGS.has(facts.nodeTag)) {
|
|
54
|
+
inventory.relations.add(created.name);
|
|
55
|
+
}
|
|
56
|
+
else if (facts.nodeTag === 'CreateFunctionStmt') {
|
|
57
|
+
inventory.functions.add(created.name);
|
|
58
|
+
}
|
|
59
|
+
else if (TYPE_TAGS.has(facts.nodeTag)) {
|
|
60
|
+
inventory.types.add(created.name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return inventory;
|
|
65
|
+
}
|
|
66
|
+
/** Merge inventories collected from multiple scripts. */
|
|
67
|
+
function mergeInventories(inventories) {
|
|
68
|
+
const merged = {
|
|
69
|
+
relations: new Set(),
|
|
70
|
+
functions: new Set(),
|
|
71
|
+
types: new Set()
|
|
72
|
+
};
|
|
73
|
+
for (const inv of inventories) {
|
|
74
|
+
for (const name of inv.relations)
|
|
75
|
+
merged.relations.add(name);
|
|
76
|
+
for (const name of inv.functions)
|
|
77
|
+
merged.functions.add(name);
|
|
78
|
+
for (const name of inv.types)
|
|
79
|
+
merged.types.add(name);
|
|
80
|
+
}
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
83
|
+
/** Resolve the routing table from the option forms. */
|
|
84
|
+
function resolveRoutes(sql, options) {
|
|
85
|
+
if (options.targets && options.schema) {
|
|
86
|
+
throw new Error('qualifyUnqualified: pass either `schema` or `targets`, not both');
|
|
87
|
+
}
|
|
88
|
+
const routes = {
|
|
89
|
+
relations: new Map(),
|
|
90
|
+
functions: new Map(),
|
|
91
|
+
types: new Map()
|
|
92
|
+
};
|
|
93
|
+
const route = (bucket, namespace, name, schema) => {
|
|
94
|
+
const existing = bucket.get(name);
|
|
95
|
+
if (existing && existing !== schema) {
|
|
96
|
+
throw new Error(`qualifyUnqualified: conflicting targets for ${namespace} "${name}": ${existing} vs ${schema}`);
|
|
97
|
+
}
|
|
98
|
+
bucket.set(name, schema);
|
|
99
|
+
};
|
|
100
|
+
if (options.targets) {
|
|
101
|
+
if (options.inventory) {
|
|
102
|
+
throw new Error('qualifyUnqualified: `inventory` only applies to the `schema` form');
|
|
103
|
+
}
|
|
104
|
+
for (const [schema, selector] of Object.entries(options.targets)) {
|
|
105
|
+
for (const name of selector.relations ?? [])
|
|
106
|
+
route(routes.relations, 'relation', name, schema);
|
|
107
|
+
for (const name of selector.functions ?? [])
|
|
108
|
+
route(routes.functions, 'function', name, schema);
|
|
109
|
+
for (const name of selector.types ?? [])
|
|
110
|
+
route(routes.types, 'type', name, schema);
|
|
111
|
+
}
|
|
112
|
+
return routes;
|
|
113
|
+
}
|
|
114
|
+
if (!options.schema) {
|
|
115
|
+
throw new Error('qualifyUnqualified: one of `schema` or `targets` is required');
|
|
116
|
+
}
|
|
117
|
+
const inventory = options.inventory ?? collectCreatedObjects(sql);
|
|
118
|
+
for (const name of inventory.relations)
|
|
119
|
+
routes.relations.set(name, options.schema);
|
|
120
|
+
for (const name of inventory.functions)
|
|
121
|
+
routes.functions.set(name, options.schema);
|
|
122
|
+
for (const name of inventory.types)
|
|
123
|
+
routes.types.set(name, options.schema);
|
|
124
|
+
return routes;
|
|
125
|
+
}
|
|
126
|
+
function collectCteNames(stmt) {
|
|
127
|
+
const names = new Set();
|
|
128
|
+
(0, traverse_1.walk)(stmt, {
|
|
129
|
+
CommonTableExpr: (path) => {
|
|
130
|
+
if (path.node?.ctename)
|
|
131
|
+
names.add(path.node.ctename);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return names;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Qualify unqualified references inside a LANGUAGE sql function body string.
|
|
138
|
+
* (`AS $$...$$` is a String under the 'as' DefElem; only PL/pgSQL bodies are
|
|
139
|
+
* exposed as a hydrated AST, so sql-language bodies need their own parse.)
|
|
140
|
+
*/
|
|
141
|
+
function qualifySqlBodyString(body, routes, result) {
|
|
142
|
+
const parseResult = (0, plpgsql_parser_1.parseSql)(body);
|
|
143
|
+
const stmts = parseResult?.stmts ?? [];
|
|
144
|
+
if (stmts.length === 0)
|
|
145
|
+
return body;
|
|
146
|
+
const pieces = [];
|
|
147
|
+
for (const stmt of stmts) {
|
|
148
|
+
if (!stmt?.stmt)
|
|
149
|
+
continue;
|
|
150
|
+
const visitor = createQualifyVisitor({ routes, cteNames: collectCteNames(stmt.stmt) }, result);
|
|
151
|
+
(0, traverse_1.walk)(stmt.stmt, visitor);
|
|
152
|
+
pieces.push(plpgsql_parser_1.Deparser.deparse(stmt.stmt));
|
|
153
|
+
}
|
|
154
|
+
return pieces.join(';\n');
|
|
155
|
+
}
|
|
156
|
+
function schemaStringNode(schema) {
|
|
157
|
+
return { String: { sval: schema } };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Create a SQL AST visitor that qualifies unqualified references against a
|
|
161
|
+
* routing table. Composable with the walkers used by the core transform.
|
|
162
|
+
*/
|
|
163
|
+
function createQualifyVisitor(options, result) {
|
|
164
|
+
const { routes } = options;
|
|
165
|
+
const cteNames = options.cteNames ?? new Set();
|
|
166
|
+
const typeSchemaOf = (name) => routes.types.get(name) ?? routes.relations.get(name);
|
|
167
|
+
const record = (name, schema) => {
|
|
168
|
+
result.qualified.set(name, (result.qualified.get(name) ?? 0) + 1);
|
|
169
|
+
result.routed.set(name, schema);
|
|
170
|
+
};
|
|
171
|
+
const qualifyNameList = (names, schemaOf) => {
|
|
172
|
+
if (!names || names.length !== 1)
|
|
173
|
+
return;
|
|
174
|
+
const name = names[0]?.String?.sval;
|
|
175
|
+
if (!name)
|
|
176
|
+
return;
|
|
177
|
+
const schema = schemaOf(name);
|
|
178
|
+
if (schema) {
|
|
179
|
+
names.unshift(schemaStringNode(schema));
|
|
180
|
+
record(name, schema);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const functionSchemaOf = (name) => routes.functions.get(name);
|
|
184
|
+
return {
|
|
185
|
+
RangeVar: (path) => {
|
|
186
|
+
const node = path.node;
|
|
187
|
+
if (!node.schemaname && node.relname && !cteNames.has(node.relname)) {
|
|
188
|
+
const schema = routes.relations.get(node.relname);
|
|
189
|
+
if (schema) {
|
|
190
|
+
node.schemaname = schema;
|
|
191
|
+
record(node.relname, schema);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
FuncCall: (path) => {
|
|
196
|
+
qualifyNameList(path.node.funcname, functionSchemaOf);
|
|
197
|
+
},
|
|
198
|
+
CallStmt: (path) => {
|
|
199
|
+
// The walker does not auto-recurse into CallStmt.funccall.
|
|
200
|
+
if (path.node.funccall) {
|
|
201
|
+
qualifyNameList(path.node.funccall.funcname, functionSchemaOf);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
TypeName: (path) => {
|
|
205
|
+
qualifyNameList(path.node.names, typeSchemaOf);
|
|
206
|
+
},
|
|
207
|
+
CreateFunctionStmt: (path) => {
|
|
208
|
+
const node = path.node;
|
|
209
|
+
qualifyNameList(node.funcname, functionSchemaOf);
|
|
210
|
+
// LANGUAGE sql bodies are opaque strings to the walker; parse and
|
|
211
|
+
// qualify them separately (plpgsql bodies ride the hydrated AST walk).
|
|
212
|
+
const isPlpgsql = (node.options ?? []).some((opt) => opt?.DefElem?.defname === 'language' &&
|
|
213
|
+
opt.DefElem.arg?.String?.sval === 'plpgsql');
|
|
214
|
+
if (!isPlpgsql && Array.isArray(node.options)) {
|
|
215
|
+
for (const opt of node.options) {
|
|
216
|
+
if (opt?.DefElem?.defname === 'as' && opt.DefElem.arg?.List?.items) {
|
|
217
|
+
for (const item of opt.DefElem.arg.List.items) {
|
|
218
|
+
if (typeof item?.String?.sval === 'string') {
|
|
219
|
+
try {
|
|
220
|
+
item.String.sval = qualifySqlBodyString(item.String.sval, routes, result);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Not parseable standalone (e.g. C symbol names) — leave as-is.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
CreateTrigStmt: (path) => {
|
|
232
|
+
qualifyNameList(path.node.funcname, functionSchemaOf);
|
|
233
|
+
},
|
|
234
|
+
ObjectWithArgs: (path) => {
|
|
235
|
+
qualifyNameList(path.node.objname, functionSchemaOf);
|
|
236
|
+
},
|
|
237
|
+
CreateEnumStmt: (path) => {
|
|
238
|
+
qualifyNameList(path.node.typeName, name => routes.types.get(name));
|
|
239
|
+
},
|
|
240
|
+
CreateRangeStmt: (path) => {
|
|
241
|
+
qualifyNameList(path.node.typeName, name => routes.types.get(name));
|
|
242
|
+
},
|
|
243
|
+
CreateDomainStmt: (path) => {
|
|
244
|
+
qualifyNameList(path.node.domainname, name => routes.types.get(name));
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Qualify unqualified references in a SQL string.
|
|
250
|
+
*
|
|
251
|
+
* Two forms:
|
|
252
|
+
* - `{ schema, inventory? }` — pin every inventoried name to one schema.
|
|
253
|
+
* - `{ targets }` — route specific objects to specific schemas in one pass.
|
|
254
|
+
*
|
|
255
|
+
* Runs as a standalone AST pass (parse → qualify → deparse). To combine with
|
|
256
|
+
* a schema rename, run this first (typically with `schema: 'public'`) and
|
|
257
|
+
* then map via the core transform — or use the `qualifyUnqualified` option on
|
|
258
|
+
* `transformSql`, which does exactly that.
|
|
259
|
+
*/
|
|
260
|
+
function qualifyUnqualified(sql, options) {
|
|
261
|
+
const result = { qualified: new Map(), routed: new Map() };
|
|
262
|
+
const routes = resolveRoutes(sql, options);
|
|
263
|
+
let qualified = (0, plpgsql_parser_1.transformSync)(sql, (ctx) => {
|
|
264
|
+
const stmts = ctx.sql?.stmts ?? [];
|
|
265
|
+
for (const stmt of stmts) {
|
|
266
|
+
if (stmt?.stmt) {
|
|
267
|
+
const visitor = createQualifyVisitor({ routes, cteNames: collectCteNames(stmt.stmt) }, result);
|
|
268
|
+
(0, traverse_1.walk)(stmt.stmt, visitor);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const bodyVisitor = createQualifyVisitor({ routes }, result);
|
|
272
|
+
for (const fn of ctx.functions) {
|
|
273
|
+
if (fn.plpgsql?.hydrated) {
|
|
274
|
+
(0, plpgsql_parser_1.walk)(fn.plpgsql.hydrated, {}, {
|
|
275
|
+
walkSqlExpressions: true,
|
|
276
|
+
sqlVisitor: bodyVisitor
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}, { hydrate: true, pretty: true });
|
|
281
|
+
if (options.injectCreateSchema) {
|
|
282
|
+
const targetSchemas = new Set([
|
|
283
|
+
...routes.relations.values(),
|
|
284
|
+
...routes.functions.values(),
|
|
285
|
+
...routes.types.values()
|
|
286
|
+
]);
|
|
287
|
+
const created = new Set();
|
|
288
|
+
for (const facts of (0, facts_1.classifyStatements)(qualified)) {
|
|
289
|
+
if (facts.nodeTag === 'CreateSchemaStmt') {
|
|
290
|
+
for (const createdSchema of facts.creates)
|
|
291
|
+
created.add(createdSchema.name);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const missing = [...targetSchemas].filter(schema => !created.has(schema)).sort();
|
|
295
|
+
if (missing.length > 0) {
|
|
296
|
+
const prelude = missing
|
|
297
|
+
.map(schema => `CREATE SCHEMA IF NOT EXISTS ${schema};`)
|
|
298
|
+
.join('\n');
|
|
299
|
+
qualified = `${prelude}\n\n${qualified}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { sql: qualified, result };
|
|
303
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, dependency-free core for round-trip AST validation.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally self-contained — it imports no parser or
|
|
5
|
+
* deparser — so it can be lifted verbatim into an upstream package (e.g.
|
|
6
|
+
* `@pgsql/round-trip`) and shared between:
|
|
7
|
+
*
|
|
8
|
+
* - pgsql-parser's test fixtures (`cleanTree` / `expectParseDeparse`), which
|
|
9
|
+
* parse → deparse → re-parse and assert the normalized ASTs are equal, and
|
|
10
|
+
* - downstream mutation-aware validators (this package's
|
|
11
|
+
* round-trip check), which compare a *mutated* AST against the AST obtained
|
|
12
|
+
* by re-parsing the deparsed output.
|
|
13
|
+
*
|
|
14
|
+
* The upstream `cleanTree` is expressed here as a preset over the generic
|
|
15
|
+
* `normalizeTree` engine; the downstream fortified normalizer layers extra
|
|
16
|
+
* volatile keys, lazy-datum filtering, and body-sentinel handling on top of
|
|
17
|
+
* the same engine (see round-trip.ts).
|
|
18
|
+
*/
|
|
19
|
+
export interface NormalizeTreeOptions {
|
|
20
|
+
/** Object keys dropped entirely (volatile / non-semantic fields). */
|
|
21
|
+
volatileKeys?: ReadonlySet<string>;
|
|
22
|
+
/** Sort object keys so comparison is insensitive to key order. Default false. */
|
|
23
|
+
sortKeys?: boolean;
|
|
24
|
+
/** Array items for which this returns true are removed before recursing. */
|
|
25
|
+
dropArrayItem?: (item: unknown) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Per-key transform hooks keyed by object-property name. The hook receives
|
|
28
|
+
* the value at that key plus a `recurse` callback (which re-enters
|
|
29
|
+
* `normalizeTree` with the same options) and returns the replacement value.
|
|
30
|
+
*/
|
|
31
|
+
keyHandlers?: Record<string, (value: any, recurse: (node: any) => any) => any>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deep-clone an AST while dropping volatile keys, optionally sorting keys,
|
|
35
|
+
* filtering array items, and applying per-key transform hooks. Primitives are
|
|
36
|
+
* returned as-is; Dates are cloned (parity with the original upstream
|
|
37
|
+
* `transform` helper).
|
|
38
|
+
*/
|
|
39
|
+
export declare function normalizeTree(node: any, options?: NormalizeTreeOptions): any;
|
|
40
|
+
/**
|
|
41
|
+
* Return the JSON path of the first structural difference between two trees,
|
|
42
|
+
* or null if they are deeply equal. Useful for actionable mismatch messages.
|
|
43
|
+
*/
|
|
44
|
+
export declare function firstDifference(a: any, b: any, path?: string): string | null;
|
|
45
|
+
/** Position/length fields stripped by the upstream `cleanTree`. */
|
|
46
|
+
export declare const CLEAN_TREE_VOLATILE_KEYS: ReadonlySet<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Trim the function/DO body string carried by a `DefElem` with defname "as".
|
|
49
|
+
* Mirrors the in-place trimming in pgsql-parser's `cleanTree` so the body
|
|
50
|
+
* text (whose surrounding whitespace legitimately shifts on deparse) does not
|
|
51
|
+
* cause spurious AST mismatches. Mutates the passed DefElem in place.
|
|
52
|
+
*/
|
|
53
|
+
export declare function trimDefElemBody(defElem: any): void;
|
|
54
|
+
/**
|
|
55
|
+
* Upstream-compatible normalization: strips `stmt_len` / `stmt_location` /
|
|
56
|
+
* `location` and trims `DefElem` "as" body strings. Equivalent to
|
|
57
|
+
* pgsql-parser's deparser `cleanTree`, provided here so upstream can adopt the
|
|
58
|
+
* shared core without behavior change.
|
|
59
|
+
*/
|
|
60
|
+
export declare function cleanTree(tree: any): any;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared, dependency-free core for round-trip AST validation.
|
|
4
|
+
*
|
|
5
|
+
* This module is intentionally self-contained — it imports no parser or
|
|
6
|
+
* deparser — so it can be lifted verbatim into an upstream package (e.g.
|
|
7
|
+
* `@pgsql/round-trip`) and shared between:
|
|
8
|
+
*
|
|
9
|
+
* - pgsql-parser's test fixtures (`cleanTree` / `expectParseDeparse`), which
|
|
10
|
+
* parse → deparse → re-parse and assert the normalized ASTs are equal, and
|
|
11
|
+
* - downstream mutation-aware validators (this package's
|
|
12
|
+
* round-trip check), which compare a *mutated* AST against the AST obtained
|
|
13
|
+
* by re-parsing the deparsed output.
|
|
14
|
+
*
|
|
15
|
+
* The upstream `cleanTree` is expressed here as a preset over the generic
|
|
16
|
+
* `normalizeTree` engine; the downstream fortified normalizer layers extra
|
|
17
|
+
* volatile keys, lazy-datum filtering, and body-sentinel handling on top of
|
|
18
|
+
* the same engine (see round-trip.ts).
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.CLEAN_TREE_VOLATILE_KEYS = void 0;
|
|
22
|
+
exports.normalizeTree = normalizeTree;
|
|
23
|
+
exports.firstDifference = firstDifference;
|
|
24
|
+
exports.trimDefElemBody = trimDefElemBody;
|
|
25
|
+
exports.cleanTree = cleanTree;
|
|
26
|
+
/**
|
|
27
|
+
* Deep-clone an AST while dropping volatile keys, optionally sorting keys,
|
|
28
|
+
* filtering array items, and applying per-key transform hooks. Primitives are
|
|
29
|
+
* returned as-is; Dates are cloned (parity with the original upstream
|
|
30
|
+
* `transform` helper).
|
|
31
|
+
*/
|
|
32
|
+
function normalizeTree(node, options = {}) {
|
|
33
|
+
const { volatileKeys, sortKeys, dropArrayItem, keyHandlers } = options;
|
|
34
|
+
const recurse = (n) => normalizeTree(n, options);
|
|
35
|
+
if (Array.isArray(node)) {
|
|
36
|
+
const items = dropArrayItem ? node.filter((i) => !dropArrayItem(i)) : node;
|
|
37
|
+
return items.map(recurse);
|
|
38
|
+
}
|
|
39
|
+
if (node instanceof Date) {
|
|
40
|
+
return new Date(node.getTime());
|
|
41
|
+
}
|
|
42
|
+
if (node && typeof node === 'object') {
|
|
43
|
+
const out = {};
|
|
44
|
+
const keys = sortKeys ? Object.keys(node).sort() : Object.keys(node);
|
|
45
|
+
for (const key of keys) {
|
|
46
|
+
if (volatileKeys?.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
const handler = keyHandlers?.[key];
|
|
49
|
+
out[key] = handler ? handler(node[key], recurse) : recurse(node[key]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
return node;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Return the JSON path of the first structural difference between two trees,
|
|
57
|
+
* or null if they are deeply equal. Useful for actionable mismatch messages.
|
|
58
|
+
*/
|
|
59
|
+
function firstDifference(a, b, path = '$') {
|
|
60
|
+
if (a === b)
|
|
61
|
+
return null;
|
|
62
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
63
|
+
if (a.length !== b.length) {
|
|
64
|
+
return `${path} (array length ${a.length} vs ${b.length})`;
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < a.length; i++) {
|
|
67
|
+
const diff = firstDifference(a[i], b[i], `${path}[${i}]`);
|
|
68
|
+
if (diff)
|
|
69
|
+
return diff;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
74
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
const diff = firstDifference(a[key], b[key], `${path}.${key}`);
|
|
77
|
+
if (diff)
|
|
78
|
+
return diff;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return `${path} (${JSON.stringify(a)} vs ${JSON.stringify(b)})`;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Upstream-compatible cleanTree preset
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
/** Position/length fields stripped by the upstream `cleanTree`. */
|
|
88
|
+
exports.CLEAN_TREE_VOLATILE_KEYS = new Set([
|
|
89
|
+
'stmt_len',
|
|
90
|
+
'stmt_location',
|
|
91
|
+
'location',
|
|
92
|
+
'rexpr_list_start',
|
|
93
|
+
'rexpr_list_end',
|
|
94
|
+
'list_start',
|
|
95
|
+
'list_end',
|
|
96
|
+
]);
|
|
97
|
+
/**
|
|
98
|
+
* Trim the function/DO body string carried by a `DefElem` with defname "as".
|
|
99
|
+
* Mirrors the in-place trimming in pgsql-parser's `cleanTree` so the body
|
|
100
|
+
* text (whose surrounding whitespace legitimately shifts on deparse) does not
|
|
101
|
+
* cause spurious AST mismatches. Mutates the passed DefElem in place.
|
|
102
|
+
*/
|
|
103
|
+
function trimDefElemBody(defElem) {
|
|
104
|
+
if (!defElem || defElem.defname !== 'as')
|
|
105
|
+
return;
|
|
106
|
+
const arg = defElem.arg;
|
|
107
|
+
if (Array.isArray(arg) && arg.length && arg[0]?.String) {
|
|
108
|
+
arg[0].String.sval = arg[0].String.sval.trim();
|
|
109
|
+
}
|
|
110
|
+
else if (arg?.List?.items?.length && arg.List.items[0]?.String) {
|
|
111
|
+
arg.List.items[0].String.sval = arg.List.items[0].String.sval.trim();
|
|
112
|
+
}
|
|
113
|
+
else if (arg?.String) {
|
|
114
|
+
arg.String.sval = arg.String.sval.trim();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Upstream-compatible normalization: strips `stmt_len` / `stmt_location` /
|
|
119
|
+
* `location` and trims `DefElem` "as" body strings. Equivalent to
|
|
120
|
+
* pgsql-parser's deparser `cleanTree`, provided here so upstream can adopt the
|
|
121
|
+
* shared core without behavior change.
|
|
122
|
+
*/
|
|
123
|
+
function cleanTree(tree) {
|
|
124
|
+
return normalizeTree(tree, {
|
|
125
|
+
volatileKeys: exports.CLEAN_TREE_VOLATILE_KEYS,
|
|
126
|
+
keyHandlers: {
|
|
127
|
+
DefElem: (defElem, recurse) => {
|
|
128
|
+
trimDefElemBody(defElem);
|
|
129
|
+
return recurse(defElem);
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
package/round-trip.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip validation for the schema transform.
|
|
3
|
+
*
|
|
4
|
+
* After the transform deparses its mutated AST to SQL text, the emitted text
|
|
5
|
+
* must parse back to an AST that is structurally identical to the AST we
|
|
6
|
+
* deparsed (AST1 === AST2 after normalization). Any mismatch means the
|
|
7
|
+
* deparser dropped or mangled something (e.g. array bounds on a DECLAREd
|
|
8
|
+
* type), even though the output may still be syntactically valid SQL.
|
|
9
|
+
*
|
|
10
|
+
* Two comparisons are performed per file:
|
|
11
|
+
* - the SQL-level parse tree (with volatile fields and function body strings
|
|
12
|
+
* normalized — PL/pgSQL bodies are compared separately)
|
|
13
|
+
* - each PL/pgSQL function body, compared in dehydrated form so both sides
|
|
14
|
+
* go through the same hydrate -> dehydrate rendering
|
|
15
|
+
*/
|
|
16
|
+
import { firstDifference } from './round-trip-core';
|
|
17
|
+
export { firstDifference };
|
|
18
|
+
/**
|
|
19
|
+
* Fortified, mutation-aware normalizer built on the shared round-trip core.
|
|
20
|
+
* Beyond the upstream `cleanTree` (position stripping + body trimming), it
|
|
21
|
+
* additionally sorts keys, drops lazily-created recfield datums, and replaces
|
|
22
|
+
* DefElem "as" bodies with a sentinel (PL/pgSQL body fidelity is checked
|
|
23
|
+
* separately via the dehydrated PL/pgSQL ASTs).
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeParseTree(node: any): any;
|
|
26
|
+
export interface CapturedAsts {
|
|
27
|
+
sqlAst: any;
|
|
28
|
+
plpgsqlAsts: any[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Capture the normalized SQL parse tree and dehydrated PL/pgSQL function
|
|
32
|
+
* ASTs from a transform context (as produced by plpgsql-parser's
|
|
33
|
+
* transformSync callback). Call this AFTER mutating the context.
|
|
34
|
+
*/
|
|
35
|
+
export declare function captureTransformAsts(ctx: any): CapturedAsts;
|
|
36
|
+
/**
|
|
37
|
+
* Parse already-transformed SQL text and capture its ASTs the same way
|
|
38
|
+
* capture_transform_asts does for the pre-deparse context, so the two
|
|
39
|
+
* sides are directly comparable.
|
|
40
|
+
*/
|
|
41
|
+
export declare function captureAstsFromSql(sql: string): CapturedAsts;
|
|
42
|
+
/**
|
|
43
|
+
* Assert that the transformed AST (captured before deparse) matches the AST
|
|
44
|
+
* obtained by re-parsing the emitted SQL. Throws with the first differing
|
|
45
|
+
* path on mismatch.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateRoundTrip(before: CapturedAsts, transformedSql: string): void;
|