@pgsql/transform 18.1.0 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +29 -73
  2. package/categorize.d.ts +52 -0
  3. package/categorize.js +56 -0
  4. package/esm/categorize.js +51 -0
  5. package/esm/facts.js +289 -0
  6. package/esm/fixture-closure.js +197 -0
  7. package/esm/index.js +7 -22
  8. package/esm/qualify.js +297 -0
  9. package/esm/round-trip-core.js +126 -0
  10. package/esm/round-trip.js +116 -0
  11. package/esm/transform.js +1169 -0
  12. package/facts.d.ts +69 -0
  13. package/facts.js +292 -0
  14. package/fixture-closure.d.ts +71 -0
  15. package/fixture-closure.js +200 -0
  16. package/index.d.ts +14 -28
  17. package/index.js +43 -76
  18. package/package.json +22 -14
  19. package/qualify.d.ts +125 -0
  20. package/qualify.js +303 -0
  21. package/round-trip-core.d.ts +60 -0
  22. package/round-trip-core.js +133 -0
  23. package/round-trip.d.ts +47 -0
  24. package/round-trip.js +123 -0
  25. package/transform.d.ts +259 -0
  26. package/transform.js +1190 -0
  27. package/13/enums.d.ts +0 -62
  28. package/13/enums.js +0 -2
  29. package/13/types.d.ts +0 -2103
  30. package/13/types.js +0 -2
  31. package/14/enums.d.ts +0 -62
  32. package/14/enums.js +0 -2
  33. package/14/types.d.ts +0 -2164
  34. package/14/types.js +0 -2
  35. package/15/enums.d.ts +0 -64
  36. package/15/enums.js +0 -2
  37. package/15/types.d.ts +0 -2222
  38. package/15/types.js +0 -2
  39. package/16/enums.d.ts +0 -69
  40. package/16/enums.js +0 -2
  41. package/16/types.d.ts +0 -2328
  42. package/16/types.js +0 -2
  43. package/17/enums.d.ts +0 -76
  44. package/17/enums.js +0 -2
  45. package/17/types.d.ts +0 -2488
  46. package/17/types.js +0 -2
  47. package/18/enums.d.ts +0 -78
  48. package/18/enums.js +0 -2
  49. package/18/types.d.ts +0 -2538
  50. package/18/types.js +0 -2
  51. package/esm/13/enums.js +0 -1
  52. package/esm/13/types.js +0 -1
  53. package/esm/14/enums.js +0 -1
  54. package/esm/14/types.js +0 -1
  55. package/esm/15/enums.js +0 -1
  56. package/esm/15/types.js +0 -1
  57. package/esm/16/enums.js +0 -1
  58. package/esm/16/types.js +0 -1
  59. package/esm/17/enums.js +0 -1
  60. package/esm/17/types.js +0 -1
  61. package/esm/18/enums.js +0 -1
  62. package/esm/18/types.js +0 -1
  63. package/esm/multi-version-transformer.js +0 -80
  64. package/esm/transformers/context.js +0 -1
  65. package/esm/transformers/v13-to-v14.js +0 -2767
  66. package/esm/transformers/v14-to-v15.js +0 -1218
  67. package/esm/transformers/v15-to-v16.js +0 -2935
  68. package/esm/transformers/v16-to-v17.js +0 -1526
  69. package/esm/transformers/v17-to-v18.js +0 -204
  70. package/esm/transformers-direct/index.js +0 -13
  71. package/esm/transformers-direct/v13-to-v17/index.js +0 -74
  72. package/esm/transformers-direct/v13-to-v18/index.js +0 -84
  73. package/esm/transformers-direct/v14-to-v17/index.js +0 -63
  74. package/esm/transformers-direct/v14-to-v18/index.js +0 -73
  75. package/esm/transformers-direct/v15-to-v17/index.js +0 -53
  76. package/esm/transformers-direct/v15-to-v18/index.js +0 -62
  77. package/esm/transformers-direct/v16-to-v17/index.js +0 -40
  78. package/esm/transformers-direct/v16-to-v18/index.js +0 -51
  79. package/esm/transformers-direct/v17-to-v18/index.js +0 -39
  80. package/multi-version-transformer.d.ts +0 -14
  81. package/multi-version-transformer.js +0 -84
  82. package/transformers/context.d.ts +0 -4
  83. package/transformers/context.js +0 -2
  84. package/transformers/v13-to-v14.d.ts +0 -255
  85. package/transformers/v13-to-v14.js +0 -2771
  86. package/transformers/v14-to-v15.d.ts +0 -615
  87. package/transformers/v14-to-v15.js +0 -1222
  88. package/transformers/v15-to-v16.d.ts +0 -632
  89. package/transformers/v15-to-v16.js +0 -2939
  90. package/transformers/v16-to-v17.d.ts +0 -640
  91. package/transformers/v16-to-v17.js +0 -1530
  92. package/transformers/v17-to-v18.d.ts +0 -54
  93. package/transformers/v17-to-v18.js +0 -208
  94. package/transformers-direct/index.d.ts +0 -13
  95. package/transformers-direct/index.js +0 -25
  96. package/transformers-direct/v13-to-v17/index.d.ts +0 -27
  97. package/transformers-direct/v13-to-v17/index.js +0 -78
  98. package/transformers-direct/v13-to-v18/index.d.ts +0 -28
  99. package/transformers-direct/v13-to-v18/index.js +0 -88
  100. package/transformers-direct/v14-to-v17/index.d.ts +0 -26
  101. package/transformers-direct/v14-to-v17/index.js +0 -67
  102. package/transformers-direct/v14-to-v18/index.d.ts +0 -27
  103. package/transformers-direct/v14-to-v18/index.js +0 -77
  104. package/transformers-direct/v15-to-v17/index.d.ts +0 -25
  105. package/transformers-direct/v15-to-v17/index.js +0 -57
  106. package/transformers-direct/v15-to-v18/index.d.ts +0 -26
  107. package/transformers-direct/v15-to-v18/index.js +0 -66
  108. package/transformers-direct/v16-to-v17/index.d.ts +0 -24
  109. package/transformers-direct/v16-to-v17/index.js +0 -44
  110. package/transformers-direct/v16-to-v18/index.d.ts +0 -25
  111. package/transformers-direct/v16-to-v18/index.js +0 -55
  112. package/transformers-direct/v17-to-v18/index.d.ts +0 -24
  113. package/transformers-direct/v17-to-v18/index.js +0 -43
@@ -0,0 +1,197 @@
1
+ import { TIER_PROFILE } from './categorize';
2
+ import { classifyStatements } from './facts';
3
+ function objectKey(schema, name) {
4
+ return `${schema ?? ''}\u0000${name}`;
5
+ }
6
+ function analyze(change, profile) {
7
+ const facts = classifyStatements(change.sql);
8
+ const category = profile.categorize(facts, change.name);
9
+ const creates = [];
10
+ const createsSchemas = [];
11
+ const createsRoles = [];
12
+ const needsObjects = [];
13
+ const needsSchemas = new Set();
14
+ const needsRoles = new Set();
15
+ for (const f of facts) {
16
+ for (const c of f.creates) {
17
+ if (f.kind === 'schema')
18
+ createsSchemas.push(c.name);
19
+ else {
20
+ creates.push(c);
21
+ if (c.schema)
22
+ needsSchemas.add(c.schema);
23
+ }
24
+ }
25
+ if (f.nodeTag === 'CreateRoleStmt')
26
+ createsRoles.push(...f.roles);
27
+ for (const r of [...f.references, ...f.fkTargets]) {
28
+ if (r.schema)
29
+ needsObjects.push(r);
30
+ }
31
+ for (const s of f.referencedSchemas)
32
+ needsSchemas.add(s);
33
+ }
34
+ const created = new Set(createsRoles);
35
+ for (const f of facts) {
36
+ if (f.nodeTag === 'CreateRoleStmt')
37
+ continue;
38
+ for (const role of f.roles)
39
+ if (!created.has(role))
40
+ needsRoles.add(role);
41
+ }
42
+ for (const s of createsSchemas)
43
+ needsSchemas.delete(s);
44
+ return {
45
+ name: change.name,
46
+ category,
47
+ facts,
48
+ dependencies: change.dependencies ?? [],
49
+ creates,
50
+ createsSchemas,
51
+ createsRoles,
52
+ needsObjects,
53
+ needsSchemas: [...needsSchemas],
54
+ needsRoles: [...needsRoles],
55
+ isFixture: category === 'security' || category === 'fixtures'
56
+ };
57
+ }
58
+ /**
59
+ * Resolve the fixture closure of a schema slice.
60
+ *
61
+ * Pure and deterministic — no I/O — and runs to a fixpoint in both directions:
62
+ * - forward: a selected change pulls in the producers of the objects/schemas/
63
+ * roles it references, plus its declared plan dependencies (prerequisites);
64
+ * - reverse: any fixture (security/fixtures-tier change) that attaches to an
65
+ * object already in the closure is pulled in (attached fixtures), and then
66
+ * resolved for its own prerequisites.
67
+ *
68
+ * Output order follows the input (plan) order, so it stays deploy-safe.
69
+ */
70
+ export function resolveFixtureClosure(allChanges, selection, options = {}) {
71
+ const profile = options.profile ?? TIER_PROFILE;
72
+ const includePrerequisites = options.includePrerequisites ?? true;
73
+ const analyses = allChanges.map(c => analyze(c, profile));
74
+ const byName = new Map(analyses.map(a => [a.name, a]));
75
+ const producerByObject = new Map();
76
+ const producerBySchema = new Map();
77
+ const producerByRole = new Map();
78
+ for (const a of analyses) {
79
+ for (const c of a.creates) {
80
+ const key = objectKey(c.schema, c.name);
81
+ if (!producerByObject.has(key))
82
+ producerByObject.set(key, a.name);
83
+ }
84
+ for (const s of a.createsSchemas) {
85
+ if (!producerBySchema.has(s))
86
+ producerBySchema.set(s, a.name);
87
+ }
88
+ for (const r of a.createsRoles) {
89
+ if (!producerByRole.has(r))
90
+ producerByRole.set(r, a.name);
91
+ }
92
+ }
93
+ const reason = new Map();
94
+ for (const name of selection) {
95
+ if (byName.has(name))
96
+ reason.set(name, 'selected');
97
+ }
98
+ const producedObjectKeys = new Set();
99
+ const producedSchemas = new Set();
100
+ const refreshProduced = () => {
101
+ producedObjectKeys.clear();
102
+ producedSchemas.clear();
103
+ for (const name of reason.keys()) {
104
+ const a = byName.get(name);
105
+ for (const c of a.creates)
106
+ producedObjectKeys.add(objectKey(c.schema, c.name));
107
+ for (const s of a.createsSchemas)
108
+ producedSchemas.add(s);
109
+ }
110
+ };
111
+ let changed = true;
112
+ while (changed) {
113
+ changed = false;
114
+ // forward: pull producers of what current members need
115
+ for (const name of [...reason.keys()]) {
116
+ const a = byName.get(name);
117
+ const pull = (producer, r) => {
118
+ if (!producer || reason.has(producer))
119
+ return;
120
+ const pa = byName.get(producer);
121
+ reason.set(producer, pa.isFixture ? 'fixture' : r);
122
+ changed = true;
123
+ };
124
+ for (const dep of a.dependencies) {
125
+ pull(byName.has(dep) ? dep : undefined, 'prerequisite');
126
+ }
127
+ if (!includePrerequisites)
128
+ continue;
129
+ for (const o of a.needsObjects)
130
+ pull(producerByObject.get(objectKey(o.schema, o.name)), 'prerequisite');
131
+ for (const s of a.needsSchemas)
132
+ pull(producerBySchema.get(s), 'prerequisite');
133
+ for (const role of a.needsRoles)
134
+ pull(producerByRole.get(role), 'prerequisite');
135
+ }
136
+ // reverse: pull attached fixtures that target current members
137
+ refreshProduced();
138
+ for (const a of analyses) {
139
+ if (reason.has(a.name) || !a.isFixture)
140
+ continue;
141
+ const objectAttaches = a.needsObjects.some(o => producedObjectKeys.has(objectKey(o.schema, o.name))) ||
142
+ a.creates.some(c => producedObjectKeys.has(objectKey(c.schema, c.name)));
143
+ // Schema-scoped fixtures (e.g. ALTER DEFAULT PRIVILEGES IN SCHEMA x) name no
144
+ // specific object; attach them by schema. Object-scoped fixtures attach only
145
+ // via their object refs, so a shared schema does not drag in every fixture.
146
+ const schemaScoped = a.needsObjects.length === 0 && a.creates.length === 0;
147
+ const schemaAttaches = schemaScoped &&
148
+ (a.needsSchemas.some(s => producedSchemas.has(s)) ||
149
+ a.createsSchemas.some(s => producedSchemas.has(s)));
150
+ if (objectAttaches || schemaAttaches) {
151
+ reason.set(a.name, 'fixture');
152
+ changed = true;
153
+ }
154
+ }
155
+ }
156
+ const order = analyses.filter(a => reason.has(a.name)).map(a => a.name);
157
+ const changes = order.map(name => {
158
+ const a = byName.get(name);
159
+ return { name, category: a.category, reason: reason.get(name) };
160
+ });
161
+ const roles = new Set();
162
+ const unresolvedObjects = [];
163
+ const unresolvedObjectKeys = new Set();
164
+ const unresolvedSchemas = new Set();
165
+ const unresolvedRoles = new Set();
166
+ for (const name of order) {
167
+ const a = byName.get(name);
168
+ for (const role of a.needsRoles) {
169
+ roles.add(role);
170
+ if (!producerByRole.has(role))
171
+ unresolvedRoles.add(role);
172
+ }
173
+ for (const o of a.needsObjects) {
174
+ const key = objectKey(o.schema, o.name);
175
+ if (!producerByObject.has(key) && !unresolvedObjectKeys.has(key)) {
176
+ unresolvedObjectKeys.add(key);
177
+ unresolvedObjects.push(o);
178
+ }
179
+ }
180
+ for (const s of a.needsSchemas) {
181
+ if (!producerBySchema.has(s))
182
+ unresolvedSchemas.add(s);
183
+ }
184
+ }
185
+ return {
186
+ order,
187
+ changes,
188
+ fixtures: changes.filter(c => c.reason === 'fixture').map(c => c.name),
189
+ prerequisites: changes.filter(c => c.reason === 'prerequisite').map(c => c.name),
190
+ roles: [...roles],
191
+ unresolved: {
192
+ objects: unresolvedObjects,
193
+ schemas: [...unresolvedSchemas],
194
+ roles: [...unresolvedRoles]
195
+ }
196
+ };
197
+ }
package/esm/index.js CHANGED
@@ -1,22 +1,7 @@
1
- import * as PG13Types from './13/types';
2
- import * as PG14Types from './14/types';
3
- import * as PG15Types from './15/types';
4
- import * as PG16Types from './16/types';
5
- import * as PG17Types from './17/types';
6
- import * as PG18Types from './18/types';
7
- export { ASTTransformer } from './multi-version-transformer';
8
- export { V13ToV14Transformer } from './transformers/v13-to-v14';
9
- export { V14ToV15Transformer } from './transformers/v14-to-v15';
10
- export { V15ToV16Transformer } from './transformers/v15-to-v16';
11
- export { V16ToV17Transformer } from './transformers/v16-to-v17';
12
- export { V17ToV18Transformer } from './transformers/v17-to-v18';
13
- export { PG13ToPG17Transformer } from './transformers-direct/v13-to-v17';
14
- export { PG14ToPG17Transformer } from './transformers-direct/v14-to-v17';
15
- export { PG15ToPG17Transformer } from './transformers-direct/v15-to-v17';
16
- export { PG16ToPG17Transformer } from './transformers-direct/v16-to-v17';
17
- export { PG13ToPG18Transformer } from './transformers-direct/v13-to-v18';
18
- export { PG14ToPG18Transformer } from './transformers-direct/v14-to-v18';
19
- export { PG15ToPG18Transformer } from './transformers-direct/v15-to-v18';
20
- export { PG16ToPG18Transformer } from './transformers-direct/v16-to-v18';
21
- export { PG17ToPG18Transformer } from './transformers-direct/v17-to-v18';
22
- export { PG13Types, PG14Types, PG15Types, PG16Types, PG17Types, PG18Types };
1
+ export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
2
+ export { classifyStatements } from './facts';
3
+ export { resolveFixtureClosure } from './fixture-closure';
4
+ export { collectCreatedObjects, createQualifyVisitor, mergeInventories, qualifyUnqualified, } from './qualify';
5
+ export { captureAstsFromSql, captureTransformAsts, firstDifference, normalizeParseTree, validateRoundTrip, } from './round-trip';
6
+ export { CLEAN_TREE_VOLATILE_KEYS, cleanTree, normalizeTree, trimDefElemBody, } from './round-trip-core';
7
+ export { createPlpgsqlVisitor, createSqlVisitor, escapeRegexp, extractPgpmHeader, shouldTransformSchema, transformComments, transformJsonStringValues, transformNameList, transformPlpgsqlTypeAst, transformPlpgsqlTypeString, transformRelation, transformSchemaName, transformSql, transformSqlStatement, transformVerifyCalls, validateNoUntransformedSchemas, walkPlpgsqlForSchemas, } from './transform';
package/esm/qualify.js ADDED
@@ -0,0 +1,297 @@
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
+ import { walk as walkSql } from '@pgsql/traverse';
18
+ import { Deparser, parseSql, transformSync, walk as walkPlpgsql } from 'plpgsql-parser';
19
+ import { classifyStatements } from './facts';
20
+ const RELATION_TAGS = new Set([
21
+ 'CreateStmt',
22
+ 'ViewStmt',
23
+ 'CreateSeqStmt',
24
+ 'CreateTableAsStmt'
25
+ ]);
26
+ const TYPE_TAGS = new Set([
27
+ 'CreateEnumStmt',
28
+ 'CreateDomainStmt',
29
+ 'CreateRangeStmt',
30
+ 'CompositeTypeStmt'
31
+ ]);
32
+ /**
33
+ * Collect the objects a SQL script creates, bucketed for qualification.
34
+ * Only unqualified (schema-less) creations are collected — an explicitly
35
+ * qualified creation already declares where it lives.
36
+ */
37
+ export function collectCreatedObjects(sql) {
38
+ const inventory = {
39
+ relations: new Set(),
40
+ functions: new Set(),
41
+ types: new Set()
42
+ };
43
+ for (const facts of classifyStatements(sql)) {
44
+ for (const created of facts.creates) {
45
+ if (created.schema)
46
+ continue;
47
+ if (RELATION_TAGS.has(facts.nodeTag)) {
48
+ inventory.relations.add(created.name);
49
+ }
50
+ else if (facts.nodeTag === 'CreateFunctionStmt') {
51
+ inventory.functions.add(created.name);
52
+ }
53
+ else if (TYPE_TAGS.has(facts.nodeTag)) {
54
+ inventory.types.add(created.name);
55
+ }
56
+ }
57
+ }
58
+ return inventory;
59
+ }
60
+ /** Merge inventories collected from multiple scripts. */
61
+ export function mergeInventories(inventories) {
62
+ const merged = {
63
+ relations: new Set(),
64
+ functions: new Set(),
65
+ types: new Set()
66
+ };
67
+ for (const inv of inventories) {
68
+ for (const name of inv.relations)
69
+ merged.relations.add(name);
70
+ for (const name of inv.functions)
71
+ merged.functions.add(name);
72
+ for (const name of inv.types)
73
+ merged.types.add(name);
74
+ }
75
+ return merged;
76
+ }
77
+ /** Resolve the routing table from the option forms. */
78
+ function resolveRoutes(sql, options) {
79
+ if (options.targets && options.schema) {
80
+ throw new Error('qualifyUnqualified: pass either `schema` or `targets`, not both');
81
+ }
82
+ const routes = {
83
+ relations: new Map(),
84
+ functions: new Map(),
85
+ types: new Map()
86
+ };
87
+ const route = (bucket, namespace, name, schema) => {
88
+ const existing = bucket.get(name);
89
+ if (existing && existing !== schema) {
90
+ throw new Error(`qualifyUnqualified: conflicting targets for ${namespace} "${name}": ${existing} vs ${schema}`);
91
+ }
92
+ bucket.set(name, schema);
93
+ };
94
+ if (options.targets) {
95
+ if (options.inventory) {
96
+ throw new Error('qualifyUnqualified: `inventory` only applies to the `schema` form');
97
+ }
98
+ for (const [schema, selector] of Object.entries(options.targets)) {
99
+ for (const name of selector.relations ?? [])
100
+ route(routes.relations, 'relation', name, schema);
101
+ for (const name of selector.functions ?? [])
102
+ route(routes.functions, 'function', name, schema);
103
+ for (const name of selector.types ?? [])
104
+ route(routes.types, 'type', name, schema);
105
+ }
106
+ return routes;
107
+ }
108
+ if (!options.schema) {
109
+ throw new Error('qualifyUnqualified: one of `schema` or `targets` is required');
110
+ }
111
+ const inventory = options.inventory ?? collectCreatedObjects(sql);
112
+ for (const name of inventory.relations)
113
+ routes.relations.set(name, options.schema);
114
+ for (const name of inventory.functions)
115
+ routes.functions.set(name, options.schema);
116
+ for (const name of inventory.types)
117
+ routes.types.set(name, options.schema);
118
+ return routes;
119
+ }
120
+ function collectCteNames(stmt) {
121
+ const names = new Set();
122
+ walkSql(stmt, {
123
+ CommonTableExpr: (path) => {
124
+ if (path.node?.ctename)
125
+ names.add(path.node.ctename);
126
+ }
127
+ });
128
+ return names;
129
+ }
130
+ /**
131
+ * Qualify unqualified references inside a LANGUAGE sql function body string.
132
+ * (`AS $$...$$` is a String under the 'as' DefElem; only PL/pgSQL bodies are
133
+ * exposed as a hydrated AST, so sql-language bodies need their own parse.)
134
+ */
135
+ function qualifySqlBodyString(body, routes, result) {
136
+ const parseResult = parseSql(body);
137
+ const stmts = parseResult?.stmts ?? [];
138
+ if (stmts.length === 0)
139
+ return body;
140
+ const pieces = [];
141
+ for (const stmt of stmts) {
142
+ if (!stmt?.stmt)
143
+ continue;
144
+ const visitor = createQualifyVisitor({ routes, cteNames: collectCteNames(stmt.stmt) }, result);
145
+ walkSql(stmt.stmt, visitor);
146
+ pieces.push(Deparser.deparse(stmt.stmt));
147
+ }
148
+ return pieces.join(';\n');
149
+ }
150
+ function schemaStringNode(schema) {
151
+ return { String: { sval: schema } };
152
+ }
153
+ /**
154
+ * Create a SQL AST visitor that qualifies unqualified references against a
155
+ * routing table. Composable with the walkers used by the core transform.
156
+ */
157
+ export function createQualifyVisitor(options, result) {
158
+ const { routes } = options;
159
+ const cteNames = options.cteNames ?? new Set();
160
+ const typeSchemaOf = (name) => routes.types.get(name) ?? routes.relations.get(name);
161
+ const record = (name, schema) => {
162
+ result.qualified.set(name, (result.qualified.get(name) ?? 0) + 1);
163
+ result.routed.set(name, schema);
164
+ };
165
+ const qualifyNameList = (names, schemaOf) => {
166
+ if (!names || names.length !== 1)
167
+ return;
168
+ const name = names[0]?.String?.sval;
169
+ if (!name)
170
+ return;
171
+ const schema = schemaOf(name);
172
+ if (schema) {
173
+ names.unshift(schemaStringNode(schema));
174
+ record(name, schema);
175
+ }
176
+ };
177
+ const functionSchemaOf = (name) => routes.functions.get(name);
178
+ return {
179
+ RangeVar: (path) => {
180
+ const node = path.node;
181
+ if (!node.schemaname && node.relname && !cteNames.has(node.relname)) {
182
+ const schema = routes.relations.get(node.relname);
183
+ if (schema) {
184
+ node.schemaname = schema;
185
+ record(node.relname, schema);
186
+ }
187
+ }
188
+ },
189
+ FuncCall: (path) => {
190
+ qualifyNameList(path.node.funcname, functionSchemaOf);
191
+ },
192
+ CallStmt: (path) => {
193
+ // The walker does not auto-recurse into CallStmt.funccall.
194
+ if (path.node.funccall) {
195
+ qualifyNameList(path.node.funccall.funcname, functionSchemaOf);
196
+ }
197
+ },
198
+ TypeName: (path) => {
199
+ qualifyNameList(path.node.names, typeSchemaOf);
200
+ },
201
+ CreateFunctionStmt: (path) => {
202
+ const node = path.node;
203
+ qualifyNameList(node.funcname, functionSchemaOf);
204
+ // LANGUAGE sql bodies are opaque strings to the walker; parse and
205
+ // qualify them separately (plpgsql bodies ride the hydrated AST walk).
206
+ const isPlpgsql = (node.options ?? []).some((opt) => opt?.DefElem?.defname === 'language' &&
207
+ opt.DefElem.arg?.String?.sval === 'plpgsql');
208
+ if (!isPlpgsql && Array.isArray(node.options)) {
209
+ for (const opt of node.options) {
210
+ if (opt?.DefElem?.defname === 'as' && opt.DefElem.arg?.List?.items) {
211
+ for (const item of opt.DefElem.arg.List.items) {
212
+ if (typeof item?.String?.sval === 'string') {
213
+ try {
214
+ item.String.sval = qualifySqlBodyString(item.String.sval, routes, result);
215
+ }
216
+ catch {
217
+ // Not parseable standalone (e.g. C symbol names) — leave as-is.
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ },
225
+ CreateTrigStmt: (path) => {
226
+ qualifyNameList(path.node.funcname, functionSchemaOf);
227
+ },
228
+ ObjectWithArgs: (path) => {
229
+ qualifyNameList(path.node.objname, functionSchemaOf);
230
+ },
231
+ CreateEnumStmt: (path) => {
232
+ qualifyNameList(path.node.typeName, name => routes.types.get(name));
233
+ },
234
+ CreateRangeStmt: (path) => {
235
+ qualifyNameList(path.node.typeName, name => routes.types.get(name));
236
+ },
237
+ CreateDomainStmt: (path) => {
238
+ qualifyNameList(path.node.domainname, name => routes.types.get(name));
239
+ }
240
+ };
241
+ }
242
+ /**
243
+ * Qualify unqualified references in a SQL string.
244
+ *
245
+ * Two forms:
246
+ * - `{ schema, inventory? }` — pin every inventoried name to one schema.
247
+ * - `{ targets }` — route specific objects to specific schemas in one pass.
248
+ *
249
+ * Runs as a standalone AST pass (parse → qualify → deparse). To combine with
250
+ * a schema rename, run this first (typically with `schema: 'public'`) and
251
+ * then map via the core transform — or use the `qualifyUnqualified` option on
252
+ * `transformSql`, which does exactly that.
253
+ */
254
+ export function qualifyUnqualified(sql, options) {
255
+ const result = { qualified: new Map(), routed: new Map() };
256
+ const routes = resolveRoutes(sql, options);
257
+ let qualified = transformSync(sql, (ctx) => {
258
+ const stmts = ctx.sql?.stmts ?? [];
259
+ for (const stmt of stmts) {
260
+ if (stmt?.stmt) {
261
+ const visitor = createQualifyVisitor({ routes, cteNames: collectCteNames(stmt.stmt) }, result);
262
+ walkSql(stmt.stmt, visitor);
263
+ }
264
+ }
265
+ const bodyVisitor = createQualifyVisitor({ routes }, result);
266
+ for (const fn of ctx.functions) {
267
+ if (fn.plpgsql?.hydrated) {
268
+ walkPlpgsql(fn.plpgsql.hydrated, {}, {
269
+ walkSqlExpressions: true,
270
+ sqlVisitor: bodyVisitor
271
+ });
272
+ }
273
+ }
274
+ }, { hydrate: true, pretty: true });
275
+ if (options.injectCreateSchema) {
276
+ const targetSchemas = new Set([
277
+ ...routes.relations.values(),
278
+ ...routes.functions.values(),
279
+ ...routes.types.values()
280
+ ]);
281
+ const created = new Set();
282
+ for (const facts of classifyStatements(qualified)) {
283
+ if (facts.nodeTag === 'CreateSchemaStmt') {
284
+ for (const createdSchema of facts.creates)
285
+ created.add(createdSchema.name);
286
+ }
287
+ }
288
+ const missing = [...targetSchemas].filter(schema => !created.has(schema)).sort();
289
+ if (missing.length > 0) {
290
+ const prelude = missing
291
+ .map(schema => `CREATE SCHEMA IF NOT EXISTS ${schema};`)
292
+ .join('\n');
293
+ qualified = `${prelude}\n\n${qualified}`;
294
+ }
295
+ }
296
+ return { sql: qualified, result };
297
+ }
@@ -0,0 +1,126 @@
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
+ /**
20
+ * Deep-clone an AST while dropping volatile keys, optionally sorting keys,
21
+ * filtering array items, and applying per-key transform hooks. Primitives are
22
+ * returned as-is; Dates are cloned (parity with the original upstream
23
+ * `transform` helper).
24
+ */
25
+ export function normalizeTree(node, options = {}) {
26
+ const { volatileKeys, sortKeys, dropArrayItem, keyHandlers } = options;
27
+ const recurse = (n) => normalizeTree(n, options);
28
+ if (Array.isArray(node)) {
29
+ const items = dropArrayItem ? node.filter((i) => !dropArrayItem(i)) : node;
30
+ return items.map(recurse);
31
+ }
32
+ if (node instanceof Date) {
33
+ return new Date(node.getTime());
34
+ }
35
+ if (node && typeof node === 'object') {
36
+ const out = {};
37
+ const keys = sortKeys ? Object.keys(node).sort() : Object.keys(node);
38
+ for (const key of keys) {
39
+ if (volatileKeys?.has(key))
40
+ continue;
41
+ const handler = keyHandlers?.[key];
42
+ out[key] = handler ? handler(node[key], recurse) : recurse(node[key]);
43
+ }
44
+ return out;
45
+ }
46
+ return node;
47
+ }
48
+ /**
49
+ * Return the JSON path of the first structural difference between two trees,
50
+ * or null if they are deeply equal. Useful for actionable mismatch messages.
51
+ */
52
+ export function firstDifference(a, b, path = '$') {
53
+ if (a === b)
54
+ return null;
55
+ if (Array.isArray(a) && Array.isArray(b)) {
56
+ if (a.length !== b.length) {
57
+ return `${path} (array length ${a.length} vs ${b.length})`;
58
+ }
59
+ for (let i = 0; i < a.length; i++) {
60
+ const diff = firstDifference(a[i], b[i], `${path}[${i}]`);
61
+ if (diff)
62
+ return diff;
63
+ }
64
+ return null;
65
+ }
66
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
67
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
68
+ for (const key of keys) {
69
+ const diff = firstDifference(a[key], b[key], `${path}.${key}`);
70
+ if (diff)
71
+ return diff;
72
+ }
73
+ return null;
74
+ }
75
+ return `${path} (${JSON.stringify(a)} vs ${JSON.stringify(b)})`;
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Upstream-compatible cleanTree preset
79
+ // ---------------------------------------------------------------------------
80
+ /** Position/length fields stripped by the upstream `cleanTree`. */
81
+ export const CLEAN_TREE_VOLATILE_KEYS = new Set([
82
+ 'stmt_len',
83
+ 'stmt_location',
84
+ 'location',
85
+ 'rexpr_list_start',
86
+ 'rexpr_list_end',
87
+ 'list_start',
88
+ 'list_end',
89
+ ]);
90
+ /**
91
+ * Trim the function/DO body string carried by a `DefElem` with defname "as".
92
+ * Mirrors the in-place trimming in pgsql-parser's `cleanTree` so the body
93
+ * text (whose surrounding whitespace legitimately shifts on deparse) does not
94
+ * cause spurious AST mismatches. Mutates the passed DefElem in place.
95
+ */
96
+ export function trimDefElemBody(defElem) {
97
+ if (!defElem || defElem.defname !== 'as')
98
+ return;
99
+ const arg = defElem.arg;
100
+ if (Array.isArray(arg) && arg.length && arg[0]?.String) {
101
+ arg[0].String.sval = arg[0].String.sval.trim();
102
+ }
103
+ else if (arg?.List?.items?.length && arg.List.items[0]?.String) {
104
+ arg.List.items[0].String.sval = arg.List.items[0].String.sval.trim();
105
+ }
106
+ else if (arg?.String) {
107
+ arg.String.sval = arg.String.sval.trim();
108
+ }
109
+ }
110
+ /**
111
+ * Upstream-compatible normalization: strips `stmt_len` / `stmt_location` /
112
+ * `location` and trims `DefElem` "as" body strings. Equivalent to
113
+ * pgsql-parser's deparser `cleanTree`, provided here so upstream can adopt the
114
+ * shared core without behavior change.
115
+ */
116
+ export function cleanTree(tree) {
117
+ return normalizeTree(tree, {
118
+ volatileKeys: CLEAN_TREE_VOLATILE_KEYS,
119
+ keyHandlers: {
120
+ DefElem: (defElem, recurse) => {
121
+ trimDefElemBody(defElem);
122
+ return recurse(defElem);
123
+ },
124
+ },
125
+ });
126
+ }