@pgsql/transform 18.15.2 → 18.16.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/index.js CHANGED
@@ -10,4 +10,4 @@ export { createRoleResult, createRoleVisitor, transformRoles, } from './role-tra
10
10
  export { captureAstsFromSql, captureTransformAsts, firstDifference, normalizeParseTree, validateRoundTrip, } from './round-trip';
11
11
  export { CLEAN_TREE_VOLATILE_KEYS, cleanTree, normalizeTree, trimDefElemBody, } from './round-trip-core';
12
12
  export { SchemaRouter } from './router';
13
- export { createPlpgsqlVisitor, createSqlVisitor, escapeRegexp, extractPgpmHeader, shouldTransformSchema, transformComments, transformJsonStringValues, transformNameList, transformPlpgsqlTypeAst, transformPlpgsqlTypeString, transformRelation, transformSchemaName, transformSql, transformSqlStatement, transformVerifyCalls, validateNoUntransformedSchemas, walkPlpgsqlForSchemas, } from './transform';
13
+ export { createPlpgsqlVisitor, createSqlVisitor, escapeRegexp, extractPgpmHeader, identityCastNamespace, shouldTransformSchema, transformComments, transformIdentityCastLiteral, transformJsonStringValues, transformNameList, transformPlpgsqlTypeAst, transformPlpgsqlTypeString, transformRelation, transformSchemaName, transformSql, transformSqlStatement, transformVerifyCalls, validateNoUntransformedSchemas, walkPlpgsqlForSchemas, } from './transform';
package/esm/transform.js CHANGED
@@ -197,7 +197,9 @@ export function transformSchemaRefsInString(str, schemaMapping, result) {
197
197
  // References embedded in opaque strings carry no object identity, so only
198
198
  // whole-schema (schema-level default) routes can be applied here.
199
199
  for (const [oldSchema, newSchema] of schemaLevelMap(schemaMapping)) {
200
- const pattern = new RegExp(`(?<![\\w-])("?)${escapeRegexp(oldSchema)}\\1(?=\\.)`, 'g');
200
+ if (!out.includes(oldSchema))
201
+ continue;
202
+ const pattern = cachedRegExp(`(?<![\\w-])("?)${cachedEscapeRegexp(oldSchema)}\\1(?=\\.)`, 'g');
201
203
  const before = out;
202
204
  out = out.replace(pattern, `$1${newSchema}$1`);
203
205
  if (out !== before) {
@@ -207,6 +209,98 @@ export function transformSchemaRefsInString(str, schemaMapping, result) {
207
209
  }
208
210
  return out;
209
211
  }
212
+ /**
213
+ * Cast types whose string operand is an object reference rather than opaque
214
+ * text. `'sch.tbl'::regclass` is a relation the parser resolves, and the cast
215
+ * names the namespace it resolves in — so the reference can be routed from the
216
+ * cast, semantically, instead of pattern-matched. This reaches what the string
217
+ * passes cannot: a bare `'sch'::regnamespace` has no dot to match on.
218
+ */
219
+ const IDENTITY_CAST_NAMESPACES = {
220
+ regclass: 'relation',
221
+ regcollation: 'unknown',
222
+ regconfig: 'unknown',
223
+ regdictionary: 'unknown',
224
+ regnamespace: 'schema',
225
+ regoper: 'unknown',
226
+ regoperator: 'unknown',
227
+ regproc: 'function',
228
+ regprocedure: 'function',
229
+ regtype: 'type'
230
+ };
231
+ /**
232
+ * The namespace a cast resolves its operand in, or `undefined` when the target
233
+ * type is not an object-identity type.
234
+ */
235
+ export function identityCastNamespace(typeName) {
236
+ const names = typeName?.names;
237
+ if (!Array.isArray(names) || names.length === 0)
238
+ return undefined;
239
+ // Only `pg_catalog`-qualified (or unqualified) reg* types are the built-ins.
240
+ if (names.length > 1 && names[0]?.String?.sval !== 'pg_catalog')
241
+ return undefined;
242
+ const typeIdent = names[names.length - 1]?.String?.sval;
243
+ if (typeof typeIdent !== 'string')
244
+ return undefined;
245
+ return IDENTITY_CAST_NAMESPACES[typeIdent.toLowerCase()];
246
+ }
247
+ const IDENTITY_TOKEN = String.raw `(?:"(?:[^"]|"")*"|[^\s".,()[\]]+)`;
248
+ const BARE_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\s*$`);
249
+ const QUALIFIED_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\.([\\s\\S]+)$`);
250
+ function unquoteIdentifier(token) {
251
+ if (token.length > 1 && token.startsWith('"') && token.endsWith('"')) {
252
+ return token.slice(1, -1).replace(/""/g, '"');
253
+ }
254
+ return token;
255
+ }
256
+ /**
257
+ * Route the object reference carried in the string operand of an identity
258
+ * cast. `ns` comes from the cast's target type.
259
+ *
260
+ * Unqualified operands (`'tbl'::regclass`) name no schema — they resolve
261
+ * through `search_path` — so they are returned unchanged.
262
+ */
263
+ export function transformIdentityCastLiteral(sval, ns, schemaMapping, result) {
264
+ const router = asRouter(schemaMapping);
265
+ if (ns === 'schema') {
266
+ const bare = BARE_IDENTITY_RE.exec(sval);
267
+ if (!bare)
268
+ return sval;
269
+ const schemaName = unquoteIdentifier(bare[1]);
270
+ const newName = router.resolve(schemaName, undefined, 'schema');
271
+ if (!newName || newName === schemaName)
272
+ return sval;
273
+ result.schemasFound.add(schemaName);
274
+ result.schemasTransformed.set(schemaName, newName);
275
+ return QuoteUtils.quoteIdentifier(newName);
276
+ }
277
+ // `regprocedure` and `regoperator` carry an argument list after the name, and
278
+ // an argument type can itself be schema-qualified
279
+ // (`fn("my-schema".row_type)`), so the list is routed as ordinary schema refs.
280
+ const argsAt = sval.indexOf('(');
281
+ const head = argsAt === -1 ? sval : sval.slice(0, argsAt);
282
+ const args = argsAt === -1 ? '' : transformSchemaRefsInString(sval.slice(argsAt), router, result);
283
+ const qualified = QUALIFIED_IDENTITY_RE.exec(head);
284
+ if (!qualified)
285
+ return `${head}${args}`;
286
+ const schemaName = unquoteIdentifier(qualified[1]);
287
+ const objName = unquoteIdentifier(qualified[2].trim());
288
+ const target = router.resolveObject(schemaName, objName, ns);
289
+ if (!target)
290
+ return `${head}${args}`;
291
+ const rebound = target.name !== undefined && target.name !== objName;
292
+ const requalified = target.schema === null || (!!target.schema && target.schema !== schemaName);
293
+ if (!rebound && !requalified)
294
+ return `${head}${args}`;
295
+ result.schemasFound.add(schemaName);
296
+ const nameToken = QuoteUtils.quoteIdentifier(rebound ? target.name : objName);
297
+ if (target.schema === null)
298
+ return `${nameToken}${args}`;
299
+ if (target.schema && target.schema !== schemaName) {
300
+ result.schemasTransformed.set(schemaName, target.schema);
301
+ }
302
+ return `${QuoteUtils.quoteIdentifier(target.schema ?? schemaName)}.${nameToken}${args}`;
303
+ }
210
304
  /**
211
305
  * Create a SQL AST visitor that transforms schema names.
212
306
  *
@@ -459,9 +553,28 @@ export function createSqlVisitor(schemaMapping, result, visitorOptions) {
459
553
  A_Const: (path) => {
460
554
  const node = path.node;
461
555
  if (typeof node.sval?.sval === 'string' && node.sval.sval.includes('.')) {
556
+ if (!claimSite(result, node.sval, 'sval'))
557
+ return;
462
558
  node.sval.sval = transformSchemaRefsInString(node.sval.sval, schemaMapping, result);
463
559
  }
464
560
  },
561
+ // Object identities carried as the operand of an identity cast
562
+ // ('sch.tbl'::regclass, 'sch.fn(uuid)'::regprocedure, 'sch'::regnamespace).
563
+ // The cast names the namespace, so these route through the same object
564
+ // routes as a RangeVar rather than through a string pattern — and the bare
565
+ // schema form, invisible to every string pass, routes here.
566
+ TypeCast: (path) => {
567
+ const node = path.node;
568
+ const sval = node?.arg?.A_Const?.sval;
569
+ if (typeof sval?.sval !== 'string')
570
+ return;
571
+ const ns = identityCastNamespace(node.typeName);
572
+ if (!ns)
573
+ return;
574
+ if (!claimSite(result, sval, 'sval'))
575
+ return;
576
+ sval.sval = transformIdentityCastLiteral(sval.sval, ns, router, result);
577
+ },
465
578
  // Transform DefineStmt (CREATE TYPE schema.name, CREATE AGGREGATE schema.agg)
466
579
  DefineStmt: (path) => {
467
580
  const node = path.node;
@@ -618,14 +731,19 @@ export function validateNoUntransformedSchemas(content, schemaMapping) {
618
731
  if (moved.size === 0) {
619
732
  return;
620
733
  }
734
+ // Every pattern below requires a literal occurrence of the schema name, so a
735
+ // single case-insensitive substring scan rules out most schemas up front.
736
+ const lowerContent = content.toLowerCase();
621
737
  for (const [oldSchema, newSchema] of moved) {
622
- const escapedSchema = escapeRegexp(oldSchema);
738
+ if (!lowerContent.includes(oldSchema.toLowerCase()))
739
+ continue;
740
+ const escapedSchema = cachedEscapeRegexp(oldSchema);
623
741
  // Pattern 1: quoted or unquoted schema name followed by dot (schema-qualified)
624
- const dotPattern = new RegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)`, 'g');
742
+ const dotPattern = cachedRegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)`, 'g');
625
743
  // Pattern 2: standalone schema name in known SQL contexts
626
744
  // Note: We don't use trailing \b because it fails after closing quotes
627
745
  // (both '"' and whitespace are non-word characters, so no boundary exists).
628
- const standalonePattern = new RegExp(`(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
746
+ const standalonePattern = cachedRegExp(`(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
629
747
  `(?:"${escapedSchema}"|\\b${escapedSchema}\\b)`, 'gi');
630
748
  const dotMatches = content.match(dotPattern);
631
749
  const standaloneMatches = content.match(standalonePattern);
@@ -633,7 +751,7 @@ export function validateNoUntransformedSchemas(content, schemaMapping) {
633
751
  if (totalMatches > 0) {
634
752
  const lines = content.split('\n');
635
753
  const locations = [];
636
- const combinedPattern = new RegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)|` +
754
+ const combinedPattern = cachedRegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)|` +
637
755
  `(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
638
756
  `(?:"${escapedSchema}"|\\b${escapedSchema}\\b)`, 'gi');
639
757
  for (let i = 0; i < lines.length; i++) {
@@ -878,6 +996,34 @@ function transformSchemasInContext(ctx, schemaMapping, result, visitorOptions) {
878
996
  export function escapeRegexp(str) {
879
997
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
880
998
  }
999
+ /**
1000
+ * Compiling a pattern costs far more than running it, and the string-level
1001
+ * passes below rebuild the same handful of per-schema patterns for every file
1002
+ * they touch — on a large corpus that is hundreds of thousands of identical
1003
+ * compiles. Patterns are keyed by source + flags and reused; each returned
1004
+ * RegExp is stateful (`g`/`y` carry `lastIndex`), so callers must only use it
1005
+ * with `String#replace`/`String#match`, which reset it, or reset it
1006
+ * themselves, exactly as the throwaway instances required.
1007
+ */
1008
+ const regExpCache = new Map();
1009
+ function cachedRegExp(source, flags) {
1010
+ const key = `${flags}\u0000${source}`;
1011
+ let re = regExpCache.get(key);
1012
+ if (!re) {
1013
+ re = new RegExp(source, flags);
1014
+ regExpCache.set(key, re);
1015
+ }
1016
+ return re;
1017
+ }
1018
+ const escapeRegexpCache = new Map();
1019
+ function cachedEscapeRegexp(str) {
1020
+ let escaped = escapeRegexpCache.get(str);
1021
+ if (escaped === undefined) {
1022
+ escaped = escapeRegexp(str);
1023
+ escapeRegexpCache.set(str, escaped);
1024
+ }
1025
+ return escaped;
1026
+ }
881
1027
  /**
882
1028
  * Extract pgpm header comments from the beginning of SQL content.
883
1029
  */
@@ -936,7 +1082,7 @@ export function transformComments(content, schemaMapping, result) {
936
1082
  if (!newName)
937
1083
  continue;
938
1084
  result.schemasFound.add(schema);
939
- const pathPattern = new RegExp(`(schemas/)${escapeRegexp(schema)}(/|$)`, 'g');
1085
+ const pathPattern = cachedRegExp(`(schemas/)${cachedEscapeRegexp(schema)}(/|$)`, 'g');
940
1086
  const before = newPath;
941
1087
  newPath = newPath.replace(pathPattern, `$1${newName}$2`);
942
1088
  if (newPath !== before) {
@@ -953,15 +1099,20 @@ export function transformComments(content, schemaMapping, result) {
953
1099
  export function transformVerifyCalls(content, schemaMapping, result) {
954
1100
  const schemas = Array.from(schemaMapping.keys()).sort((a, b) => b.length - a.length);
955
1101
  let newContent = content;
1102
+ // The pattern needs the schema name spelled out, case-insensitively.
1103
+ let lowerContent = content.toLowerCase();
956
1104
  for (const schema of schemas) {
957
1105
  const newName = schemaMapping.get(schema);
958
1106
  if (!newName)
959
1107
  continue;
960
- const escapedSchema = escapeRegexp(schema);
961
- const verifyPattern = new RegExp(`(verify_(?:function|table|trigger|type|domain|view|index|constraint|schema|policy|table_grant|function_grant|sequence_grant|type_grant)\\s*\\(\\s*')${escapedSchema}(\\.|'\\s*\\))`, 'gi');
1108
+ if (!lowerContent.includes(schema.toLowerCase()))
1109
+ continue;
1110
+ const escapedSchema = cachedEscapeRegexp(schema);
1111
+ const verifyPattern = cachedRegExp(`(verify_(?:function|table|trigger|type|domain|view|index|constraint|schema|policy|table_grant|function_grant|sequence_grant|type_grant)\\s*\\(\\s*')${escapedSchema}(\\.|'\\s*\\))`, 'gi');
962
1112
  const before = newContent;
963
1113
  newContent = newContent.replace(verifyPattern, `$1${newName}$2`);
964
1114
  if (newContent !== before) {
1115
+ lowerContent = newContent.toLowerCase();
965
1116
  result.schemasFound.add(schema);
966
1117
  result.schemasTransformed.set(schema, newName);
967
1118
  }
@@ -978,8 +1129,10 @@ export function transformJsonStringValues(content, schemaMapping, result) {
978
1129
  const newName = schemaMapping.get(schema);
979
1130
  if (!newName)
980
1131
  continue;
981
- const escapedSchema = escapeRegexp(schema);
982
- const jsonValuePattern = new RegExp(`(:")${escapedSchema}(")`, 'g');
1132
+ if (!newContent.includes(schema))
1133
+ continue;
1134
+ const escapedSchema = cachedEscapeRegexp(schema);
1135
+ const jsonValuePattern = cachedRegExp(`(:")${escapedSchema}(")`, 'g');
983
1136
  const before = newContent;
984
1137
  newContent = newContent.replace(jsonValuePattern, `$1${newName}$2`);
985
1138
  if (newContent !== before) {
package/index.d.ts CHANGED
@@ -23,4 +23,4 @@ export { CLEAN_TREE_VOLATILE_KEYS, cleanTree, normalizeTree, trimDefElemBody, }
23
23
  export type { ObjectNamespace, ObjectRoute, ObjectRouteTarget, RouteNamespace, RouteSpec, SchemaRoute, } from './router';
24
24
  export { SchemaRouter } from './router';
25
25
  export type { SchemaMappingInput, SchemaTransformPass, SchemaTransformResult, TransformSqlOptions, } from './transform';
26
- export { createPlpgsqlVisitor, createSqlVisitor, escapeRegexp, extractPgpmHeader, shouldTransformSchema, transformComments, transformJsonStringValues, transformNameList, transformPlpgsqlTypeAst, transformPlpgsqlTypeString, transformRelation, transformSchemaName, transformSql, transformSqlStatement, transformVerifyCalls, validateNoUntransformedSchemas, walkPlpgsqlForSchemas, } from './transform';
26
+ export { createPlpgsqlVisitor, createSqlVisitor, escapeRegexp, extractPgpmHeader, identityCastNamespace, shouldTransformSchema, transformComments, transformIdentityCastLiteral, transformJsonStringValues, transformNameList, transformPlpgsqlTypeAst, transformPlpgsqlTypeString, transformRelation, transformSchemaName, transformSql, transformSqlStatement, transformVerifyCalls, validateNoUntransformedSchemas, walkPlpgsqlForSchemas, } from './transform';
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.walkPlpgsqlForSchemas = exports.validateNoUntransformedSchemas = exports.transformVerifyCalls = exports.transformSqlStatement = exports.transformSql = exports.transformSchemaName = exports.transformRelation = exports.transformPlpgsqlTypeString = exports.transformPlpgsqlTypeAst = exports.transformNameList = exports.transformJsonStringValues = exports.transformComments = exports.shouldTransformSchema = exports.extractPgpmHeader = exports.escapeRegexp = exports.createSqlVisitor = exports.createPlpgsqlVisitor = exports.SchemaRouter = exports.trimDefElemBody = exports.normalizeTree = exports.cleanTree = exports.CLEAN_TREE_VOLATILE_KEYS = exports.validateRoundTrip = exports.normalizeParseTree = exports.firstDifference = exports.captureTransformAsts = exports.captureAstsFromSql = exports.transformRoles = exports.createRoleVisitor = exports.createRoleResult = exports.RoleRouter = exports.restructureSql = exports.orderStatements = exports.qualifyUnqualified = exports.mergeInventories = exports.createQualifyVisitor = exports.collectCreatedObjects = exports.identityOf = exports.buildStatementGraph = exports.classifyStatements = exports.transformExtensions = exports.createExtensionVisitor = exports.createExtensionResult = exports.ExtensionRouter = exports.COMMON_EXTENSIONS = void 0;
3
+ exports.walkPlpgsqlForSchemas = exports.validateNoUntransformedSchemas = exports.transformVerifyCalls = exports.transformSqlStatement = exports.transformSql = exports.transformSchemaName = exports.transformRelation = exports.transformPlpgsqlTypeString = exports.transformPlpgsqlTypeAst = exports.transformNameList = exports.transformJsonStringValues = exports.transformIdentityCastLiteral = exports.transformComments = exports.shouldTransformSchema = exports.identityCastNamespace = exports.extractPgpmHeader = exports.escapeRegexp = exports.createSqlVisitor = exports.createPlpgsqlVisitor = exports.SchemaRouter = exports.trimDefElemBody = exports.normalizeTree = exports.cleanTree = exports.CLEAN_TREE_VOLATILE_KEYS = exports.validateRoundTrip = exports.normalizeParseTree = exports.firstDifference = exports.captureTransformAsts = exports.captureAstsFromSql = exports.transformRoles = exports.createRoleVisitor = exports.createRoleResult = exports.RoleRouter = exports.restructureSql = exports.orderStatements = exports.qualifyUnqualified = exports.mergeInventories = exports.createQualifyVisitor = exports.collectCreatedObjects = exports.identityOf = exports.buildStatementGraph = exports.classifyStatements = exports.transformExtensions = exports.createExtensionVisitor = exports.createExtensionResult = exports.ExtensionRouter = exports.COMMON_EXTENSIONS = void 0;
4
4
  var extension_router_1 = require("./extension-router");
5
5
  Object.defineProperty(exports, "COMMON_EXTENSIONS", { enumerable: true, get: function () { return extension_router_1.COMMON_EXTENSIONS; } });
6
6
  Object.defineProperty(exports, "ExtensionRouter", { enumerable: true, get: function () { return extension_router_1.ExtensionRouter; } });
@@ -46,8 +46,10 @@ Object.defineProperty(exports, "createPlpgsqlVisitor", { enumerable: true, get:
46
46
  Object.defineProperty(exports, "createSqlVisitor", { enumerable: true, get: function () { return transform_1.createSqlVisitor; } });
47
47
  Object.defineProperty(exports, "escapeRegexp", { enumerable: true, get: function () { return transform_1.escapeRegexp; } });
48
48
  Object.defineProperty(exports, "extractPgpmHeader", { enumerable: true, get: function () { return transform_1.extractPgpmHeader; } });
49
+ Object.defineProperty(exports, "identityCastNamespace", { enumerable: true, get: function () { return transform_1.identityCastNamespace; } });
49
50
  Object.defineProperty(exports, "shouldTransformSchema", { enumerable: true, get: function () { return transform_1.shouldTransformSchema; } });
50
51
  Object.defineProperty(exports, "transformComments", { enumerable: true, get: function () { return transform_1.transformComments; } });
52
+ Object.defineProperty(exports, "transformIdentityCastLiteral", { enumerable: true, get: function () { return transform_1.transformIdentityCastLiteral; } });
51
53
  Object.defineProperty(exports, "transformJsonStringValues", { enumerable: true, get: function () { return transform_1.transformJsonStringValues; } });
52
54
  Object.defineProperty(exports, "transformNameList", { enumerable: true, get: function () { return transform_1.transformNameList; } });
53
55
  Object.defineProperty(exports, "transformPlpgsqlTypeAst", { enumerable: true, get: function () { return transform_1.transformPlpgsqlTypeAst; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgsql/transform",
3
- "version": "18.15.2",
3
+ "version": "18.16.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "AST-based SQL transformation, qualification, classification and closure analysis for PostgreSQL",
6
6
  "main": "index.js",
@@ -50,5 +50,5 @@
50
50
  "classify",
51
51
  "plpgsql"
52
52
  ],
53
- "gitHead": "b6105874628cc7b8517c1ebbd99e9ad6fe786976"
53
+ "gitHead": "9ecd247f9f0ef696a95e1f582d7353eb6bb693d4"
54
54
  }
package/transform.d.ts CHANGED
@@ -123,6 +123,19 @@ export declare function transformRelation(relation: any, schemaMapping: SchemaMa
123
123
  * followed by a dot are rewritten.
124
124
  */
125
125
  export declare function transformSchemaRefsInString(str: string, schemaMapping: SchemaMappingInput, result: SchemaTransformResult): string;
126
+ /**
127
+ * The namespace a cast resolves its operand in, or `undefined` when the target
128
+ * type is not an object-identity type.
129
+ */
130
+ export declare function identityCastNamespace(typeName: any): RouteNamespace | undefined;
131
+ /**
132
+ * Route the object reference carried in the string operand of an identity
133
+ * cast. `ns` comes from the cast's target type.
134
+ *
135
+ * Unqualified operands (`'tbl'::regclass`) name no schema — they resolve
136
+ * through `search_path` — so they are returned unchanged.
137
+ */
138
+ export declare function transformIdentityCastLiteral(sval: string, ns: RouteNamespace, schemaMapping: SchemaMappingInput, result: SchemaTransformResult): string;
126
139
  /**
127
140
  * Create a SQL AST visitor that transforms schema names.
128
141
  *
@@ -148,6 +161,7 @@ export declare function createSqlVisitor(schemaMapping: SchemaMappingInput, resu
148
161
  CreateTrigStmt: (path: any) => void;
149
162
  CommentStmt: (path: any) => void;
150
163
  A_Const: (path: any) => void;
164
+ TypeCast: (path: any) => void;
151
165
  DefineStmt: (path: any) => void;
152
166
  CreateDomainStmt: (path: any) => void;
153
167
  CreateEnumStmt: (path: any) => void;
package/transform.js CHANGED
@@ -31,6 +31,8 @@ exports.transformNameList = transformNameList;
31
31
  exports.transformSchemaNameField = transformSchemaNameField;
32
32
  exports.transformRelation = transformRelation;
33
33
  exports.transformSchemaRefsInString = transformSchemaRefsInString;
34
+ exports.identityCastNamespace = identityCastNamespace;
35
+ exports.transformIdentityCastLiteral = transformIdentityCastLiteral;
34
36
  exports.createSqlVisitor = createSqlVisitor;
35
37
  exports.validateNoUntransformedSchemas = validateNoUntransformedSchemas;
36
38
  exports.createPlpgsqlVisitor = createPlpgsqlVisitor;
@@ -218,7 +220,9 @@ function transformSchemaRefsInString(str, schemaMapping, result) {
218
220
  // References embedded in opaque strings carry no object identity, so only
219
221
  // whole-schema (schema-level default) routes can be applied here.
220
222
  for (const [oldSchema, newSchema] of schemaLevelMap(schemaMapping)) {
221
- const pattern = new RegExp(`(?<![\\w-])("?)${escapeRegexp(oldSchema)}\\1(?=\\.)`, 'g');
223
+ if (!out.includes(oldSchema))
224
+ continue;
225
+ const pattern = cachedRegExp(`(?<![\\w-])("?)${cachedEscapeRegexp(oldSchema)}\\1(?=\\.)`, 'g');
222
226
  const before = out;
223
227
  out = out.replace(pattern, `$1${newSchema}$1`);
224
228
  if (out !== before) {
@@ -228,6 +232,98 @@ function transformSchemaRefsInString(str, schemaMapping, result) {
228
232
  }
229
233
  return out;
230
234
  }
235
+ /**
236
+ * Cast types whose string operand is an object reference rather than opaque
237
+ * text. `'sch.tbl'::regclass` is a relation the parser resolves, and the cast
238
+ * names the namespace it resolves in — so the reference can be routed from the
239
+ * cast, semantically, instead of pattern-matched. This reaches what the string
240
+ * passes cannot: a bare `'sch'::regnamespace` has no dot to match on.
241
+ */
242
+ const IDENTITY_CAST_NAMESPACES = {
243
+ regclass: 'relation',
244
+ regcollation: 'unknown',
245
+ regconfig: 'unknown',
246
+ regdictionary: 'unknown',
247
+ regnamespace: 'schema',
248
+ regoper: 'unknown',
249
+ regoperator: 'unknown',
250
+ regproc: 'function',
251
+ regprocedure: 'function',
252
+ regtype: 'type'
253
+ };
254
+ /**
255
+ * The namespace a cast resolves its operand in, or `undefined` when the target
256
+ * type is not an object-identity type.
257
+ */
258
+ function identityCastNamespace(typeName) {
259
+ const names = typeName?.names;
260
+ if (!Array.isArray(names) || names.length === 0)
261
+ return undefined;
262
+ // Only `pg_catalog`-qualified (or unqualified) reg* types are the built-ins.
263
+ if (names.length > 1 && names[0]?.String?.sval !== 'pg_catalog')
264
+ return undefined;
265
+ const typeIdent = names[names.length - 1]?.String?.sval;
266
+ if (typeof typeIdent !== 'string')
267
+ return undefined;
268
+ return IDENTITY_CAST_NAMESPACES[typeIdent.toLowerCase()];
269
+ }
270
+ const IDENTITY_TOKEN = String.raw `(?:"(?:[^"]|"")*"|[^\s".,()[\]]+)`;
271
+ const BARE_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\s*$`);
272
+ const QUALIFIED_IDENTITY_RE = new RegExp(`^\\s*(${IDENTITY_TOKEN})\\.([\\s\\S]+)$`);
273
+ function unquoteIdentifier(token) {
274
+ if (token.length > 1 && token.startsWith('"') && token.endsWith('"')) {
275
+ return token.slice(1, -1).replace(/""/g, '"');
276
+ }
277
+ return token;
278
+ }
279
+ /**
280
+ * Route the object reference carried in the string operand of an identity
281
+ * cast. `ns` comes from the cast's target type.
282
+ *
283
+ * Unqualified operands (`'tbl'::regclass`) name no schema — they resolve
284
+ * through `search_path` — so they are returned unchanged.
285
+ */
286
+ function transformIdentityCastLiteral(sval, ns, schemaMapping, result) {
287
+ const router = asRouter(schemaMapping);
288
+ if (ns === 'schema') {
289
+ const bare = BARE_IDENTITY_RE.exec(sval);
290
+ if (!bare)
291
+ return sval;
292
+ const schemaName = unquoteIdentifier(bare[1]);
293
+ const newName = router.resolve(schemaName, undefined, 'schema');
294
+ if (!newName || newName === schemaName)
295
+ return sval;
296
+ result.schemasFound.add(schemaName);
297
+ result.schemasTransformed.set(schemaName, newName);
298
+ return quotes_1.QuoteUtils.quoteIdentifier(newName);
299
+ }
300
+ // `regprocedure` and `regoperator` carry an argument list after the name, and
301
+ // an argument type can itself be schema-qualified
302
+ // (`fn("my-schema".row_type)`), so the list is routed as ordinary schema refs.
303
+ const argsAt = sval.indexOf('(');
304
+ const head = argsAt === -1 ? sval : sval.slice(0, argsAt);
305
+ const args = argsAt === -1 ? '' : transformSchemaRefsInString(sval.slice(argsAt), router, result);
306
+ const qualified = QUALIFIED_IDENTITY_RE.exec(head);
307
+ if (!qualified)
308
+ return `${head}${args}`;
309
+ const schemaName = unquoteIdentifier(qualified[1]);
310
+ const objName = unquoteIdentifier(qualified[2].trim());
311
+ const target = router.resolveObject(schemaName, objName, ns);
312
+ if (!target)
313
+ return `${head}${args}`;
314
+ const rebound = target.name !== undefined && target.name !== objName;
315
+ const requalified = target.schema === null || (!!target.schema && target.schema !== schemaName);
316
+ if (!rebound && !requalified)
317
+ return `${head}${args}`;
318
+ result.schemasFound.add(schemaName);
319
+ const nameToken = quotes_1.QuoteUtils.quoteIdentifier(rebound ? target.name : objName);
320
+ if (target.schema === null)
321
+ return `${nameToken}${args}`;
322
+ if (target.schema && target.schema !== schemaName) {
323
+ result.schemasTransformed.set(schemaName, target.schema);
324
+ }
325
+ return `${quotes_1.QuoteUtils.quoteIdentifier(target.schema ?? schemaName)}.${nameToken}${args}`;
326
+ }
231
327
  /**
232
328
  * Create a SQL AST visitor that transforms schema names.
233
329
  *
@@ -480,9 +576,28 @@ function createSqlVisitor(schemaMapping, result, visitorOptions) {
480
576
  A_Const: (path) => {
481
577
  const node = path.node;
482
578
  if (typeof node.sval?.sval === 'string' && node.sval.sval.includes('.')) {
579
+ if (!claimSite(result, node.sval, 'sval'))
580
+ return;
483
581
  node.sval.sval = transformSchemaRefsInString(node.sval.sval, schemaMapping, result);
484
582
  }
485
583
  },
584
+ // Object identities carried as the operand of an identity cast
585
+ // ('sch.tbl'::regclass, 'sch.fn(uuid)'::regprocedure, 'sch'::regnamespace).
586
+ // The cast names the namespace, so these route through the same object
587
+ // routes as a RangeVar rather than through a string pattern — and the bare
588
+ // schema form, invisible to every string pass, routes here.
589
+ TypeCast: (path) => {
590
+ const node = path.node;
591
+ const sval = node?.arg?.A_Const?.sval;
592
+ if (typeof sval?.sval !== 'string')
593
+ return;
594
+ const ns = identityCastNamespace(node.typeName);
595
+ if (!ns)
596
+ return;
597
+ if (!claimSite(result, sval, 'sval'))
598
+ return;
599
+ sval.sval = transformIdentityCastLiteral(sval.sval, ns, router, result);
600
+ },
486
601
  // Transform DefineStmt (CREATE TYPE schema.name, CREATE AGGREGATE schema.agg)
487
602
  DefineStmt: (path) => {
488
603
  const node = path.node;
@@ -639,14 +754,19 @@ function validateNoUntransformedSchemas(content, schemaMapping) {
639
754
  if (moved.size === 0) {
640
755
  return;
641
756
  }
757
+ // Every pattern below requires a literal occurrence of the schema name, so a
758
+ // single case-insensitive substring scan rules out most schemas up front.
759
+ const lowerContent = content.toLowerCase();
642
760
  for (const [oldSchema, newSchema] of moved) {
643
- const escapedSchema = escapeRegexp(oldSchema);
761
+ if (!lowerContent.includes(oldSchema.toLowerCase()))
762
+ continue;
763
+ const escapedSchema = cachedEscapeRegexp(oldSchema);
644
764
  // Pattern 1: quoted or unquoted schema name followed by dot (schema-qualified)
645
- const dotPattern = new RegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)`, 'g');
765
+ const dotPattern = cachedRegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)`, 'g');
646
766
  // Pattern 2: standalone schema name in known SQL contexts
647
767
  // Note: We don't use trailing \b because it fails after closing quotes
648
768
  // (both '"' and whitespace are non-word characters, so no boundary exists).
649
- const standalonePattern = new RegExp(`(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
769
+ const standalonePattern = cachedRegExp(`(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
650
770
  `(?:"${escapedSchema}"|\\b${escapedSchema}\\b)`, 'gi');
651
771
  const dotMatches = content.match(dotPattern);
652
772
  const standaloneMatches = content.match(standalonePattern);
@@ -654,7 +774,7 @@ function validateNoUntransformedSchemas(content, schemaMapping) {
654
774
  if (totalMatches > 0) {
655
775
  const lines = content.split('\n');
656
776
  const locations = [];
657
- const combinedPattern = new RegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)|` +
777
+ const combinedPattern = cachedRegExp(`(?:"${escapedSchema}"|\\b${escapedSchema})(?=\\.)|` +
658
778
  `(?:ON\\s+SCHEMA\\s+|IN\\s+SCHEMA\\s+|CREATE\\s+SCHEMA\\s+|DROP\\s+SCHEMA\\s+(?:IF\\s+EXISTS\\s+)?|SET\\s+SCHEMA\\s+)` +
659
779
  `(?:"${escapedSchema}"|\\b${escapedSchema}\\b)`, 'gi');
660
780
  for (let i = 0; i < lines.length; i++) {
@@ -899,6 +1019,34 @@ function transformSchemasInContext(ctx, schemaMapping, result, visitorOptions) {
899
1019
  function escapeRegexp(str) {
900
1020
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
901
1021
  }
1022
+ /**
1023
+ * Compiling a pattern costs far more than running it, and the string-level
1024
+ * passes below rebuild the same handful of per-schema patterns for every file
1025
+ * they touch — on a large corpus that is hundreds of thousands of identical
1026
+ * compiles. Patterns are keyed by source + flags and reused; each returned
1027
+ * RegExp is stateful (`g`/`y` carry `lastIndex`), so callers must only use it
1028
+ * with `String#replace`/`String#match`, which reset it, or reset it
1029
+ * themselves, exactly as the throwaway instances required.
1030
+ */
1031
+ const regExpCache = new Map();
1032
+ function cachedRegExp(source, flags) {
1033
+ const key = `${flags}\u0000${source}`;
1034
+ let re = regExpCache.get(key);
1035
+ if (!re) {
1036
+ re = new RegExp(source, flags);
1037
+ regExpCache.set(key, re);
1038
+ }
1039
+ return re;
1040
+ }
1041
+ const escapeRegexpCache = new Map();
1042
+ function cachedEscapeRegexp(str) {
1043
+ let escaped = escapeRegexpCache.get(str);
1044
+ if (escaped === undefined) {
1045
+ escaped = escapeRegexp(str);
1046
+ escapeRegexpCache.set(str, escaped);
1047
+ }
1048
+ return escaped;
1049
+ }
902
1050
  /**
903
1051
  * Extract pgpm header comments from the beginning of SQL content.
904
1052
  */
@@ -957,7 +1105,7 @@ function transformComments(content, schemaMapping, result) {
957
1105
  if (!newName)
958
1106
  continue;
959
1107
  result.schemasFound.add(schema);
960
- const pathPattern = new RegExp(`(schemas/)${escapeRegexp(schema)}(/|$)`, 'g');
1108
+ const pathPattern = cachedRegExp(`(schemas/)${cachedEscapeRegexp(schema)}(/|$)`, 'g');
961
1109
  const before = newPath;
962
1110
  newPath = newPath.replace(pathPattern, `$1${newName}$2`);
963
1111
  if (newPath !== before) {
@@ -974,15 +1122,20 @@ function transformComments(content, schemaMapping, result) {
974
1122
  function transformVerifyCalls(content, schemaMapping, result) {
975
1123
  const schemas = Array.from(schemaMapping.keys()).sort((a, b) => b.length - a.length);
976
1124
  let newContent = content;
1125
+ // The pattern needs the schema name spelled out, case-insensitively.
1126
+ let lowerContent = content.toLowerCase();
977
1127
  for (const schema of schemas) {
978
1128
  const newName = schemaMapping.get(schema);
979
1129
  if (!newName)
980
1130
  continue;
981
- const escapedSchema = escapeRegexp(schema);
982
- const verifyPattern = new RegExp(`(verify_(?:function|table|trigger|type|domain|view|index|constraint|schema|policy|table_grant|function_grant|sequence_grant|type_grant)\\s*\\(\\s*')${escapedSchema}(\\.|'\\s*\\))`, 'gi');
1131
+ if (!lowerContent.includes(schema.toLowerCase()))
1132
+ continue;
1133
+ const escapedSchema = cachedEscapeRegexp(schema);
1134
+ const verifyPattern = cachedRegExp(`(verify_(?:function|table|trigger|type|domain|view|index|constraint|schema|policy|table_grant|function_grant|sequence_grant|type_grant)\\s*\\(\\s*')${escapedSchema}(\\.|'\\s*\\))`, 'gi');
983
1135
  const before = newContent;
984
1136
  newContent = newContent.replace(verifyPattern, `$1${newName}$2`);
985
1137
  if (newContent !== before) {
1138
+ lowerContent = newContent.toLowerCase();
986
1139
  result.schemasFound.add(schema);
987
1140
  result.schemasTransformed.set(schema, newName);
988
1141
  }
@@ -999,8 +1152,10 @@ function transformJsonStringValues(content, schemaMapping, result) {
999
1152
  const newName = schemaMapping.get(schema);
1000
1153
  if (!newName)
1001
1154
  continue;
1002
- const escapedSchema = escapeRegexp(schema);
1003
- const jsonValuePattern = new RegExp(`(:")${escapedSchema}(")`, 'g');
1155
+ if (!newContent.includes(schema))
1156
+ continue;
1157
+ const escapedSchema = cachedEscapeRegexp(schema);
1158
+ const jsonValuePattern = cachedRegExp(`(:")${escapedSchema}(")`, 'g');
1004
1159
  const before = newContent;
1005
1160
  newContent = newContent.replace(jsonValuePattern, `$1${newName}$2`);
1006
1161
  if (newContent !== before) {