pg-migration-engine 0.1.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/LICENSE +35 -0
- package/README.md +304 -0
- package/package.json +52 -0
- package/src/behavioral/behavioral-applier.js +374 -0
- package/src/behavioral/behavioral-extractor.js +266 -0
- package/src/behavioral/behavioral-puller.js +35 -0
- package/src/behavioral/index.js +4 -0
- package/src/behavioral/phase-sorter.js +24 -0
- package/src/constants.js +97 -0
- package/src/ddl-generator/alter-generator.js +1512 -0
- package/src/ddl-generator/comment-generator.js +95 -0
- package/src/ddl-generator/create-generator.js +1426 -0
- package/src/ddl-generator/drop-generator.js +194 -0
- package/src/ddl-generator/generator.js +78 -0
- package/src/ddl-generator/grant-generator.js +31 -0
- package/src/ddl-generator/index.js +3 -0
- package/src/ddl-generator/pg-version.js +15 -0
- package/src/ddl-generator/rename-generator.js +88 -0
- package/src/ddl-generator/safe-patterns.js +241 -0
- package/src/differ/change-classifier.js +192 -0
- package/src/differ/dependency-resolver.js +523 -0
- package/src/differ/index.js +12 -0
- package/src/differ/object-matcher.js +510 -0
- package/src/differ/property-differ.js +1134 -0
- package/src/differ/risk-tagger.js +561 -0
- package/src/differ/schema-differ.js +309 -0
- package/src/differ/utils/levenshtein.js +210 -0
- package/src/differ/utils/path-builder.js +198 -0
- package/src/differ/utils/type-compatibility.js +472 -0
- package/src/errors.js +147 -0
- package/src/executor/drift-detector.js +221 -0
- package/src/executor/index.js +7 -0
- package/src/executor/lock-manager.js +308 -0
- package/src/executor/migration-executor.js +1249 -0
- package/src/executor/progress-tracker.js +81 -0
- package/src/executor/recovery-manager.js +47 -0
- package/src/executor/snapshot-manager.js +22 -0
- package/src/executor/sql-splitter.js +120 -0
- package/src/executor/transaction-manager.js +288 -0
- package/src/index.js +483 -0
- package/src/introspection/index.js +4 -0
- package/src/introspection/introspector.js +250 -0
- package/src/introspection/queries/access-methods.js +29 -0
- package/src/introspection/queries/casts.js +38 -0
- package/src/introspection/queries/collations.js +51 -0
- package/src/introspection/queries/comments.js +66 -0
- package/src/introspection/queries/constraints.js +65 -0
- package/src/introspection/queries/conversions.js +39 -0
- package/src/introspection/queries/databases.js +43 -0
- package/src/introspection/queries/default-privileges.js +37 -0
- package/src/introspection/queries/event-triggers.js +43 -0
- package/src/introspection/queries/extensions.js +21 -0
- package/src/introspection/queries/foreign-data.js +136 -0
- package/src/introspection/queries/functions.js +75 -0
- package/src/introspection/queries/grants.js +38 -0
- package/src/introspection/queries/index.js +33 -0
- package/src/introspection/queries/indexes.js +99 -0
- package/src/introspection/queries/inheritance.js +24 -0
- package/src/introspection/queries/multiranges.js +37 -0
- package/src/introspection/queries/operators.js +180 -0
- package/src/introspection/queries/partitions.js +42 -0
- package/src/introspection/queries/pg18-19.js +43 -0
- package/src/introspection/queries/policies.js +35 -0
- package/src/introspection/queries/procedural-languages.js +36 -0
- package/src/introspection/queries/publications.js +114 -0
- package/src/introspection/queries/roles.js +66 -0
- package/src/introspection/queries/rules.js +49 -0
- package/src/introspection/queries/sequences.js +37 -0
- package/src/introspection/queries/statistics.js +74 -0
- package/src/introspection/queries/subscriptions.js +99 -0
- package/src/introspection/queries/tables.js +151 -0
- package/src/introspection/queries/tablespaces.js +36 -0
- package/src/introspection/queries/text-search.js +146 -0
- package/src/introspection/queries/triggers.js +61 -0
- package/src/introspection/queries/types.js +106 -0
- package/src/introspection/queries/views.js +146 -0
- package/src/introspection/translator.js +1283 -0
- package/src/introspection/version-detector.js +25 -0
- package/src/planner/backfill-planner.js +36 -0
- package/src/planner/dry-run.js +21 -0
- package/src/planner/index.js +6 -0
- package/src/planner/migration-planner.js +356 -0
- package/src/planner/smart-migrator.js +82 -0
- package/src/planner/step-sequencer.js +61 -0
- package/src/planner/type-registry.js +47 -0
- package/src/risk/compatibility-checker.js +29 -0
- package/src/risk/data-loss-checker.js +32 -0
- package/src/risk/destructive-checker.js +38 -0
- package/src/risk/index.js +6 -0
- package/src/risk/lock-analyzer.js +39 -0
- package/src/risk/recommendations.js +44 -0
- package/src/risk/risk-engine.js +25 -0
- package/src/storage/index.js +5 -0
- package/src/storage/memory-storage.js +87 -0
- package/src/storage/migration-table.js +423 -0
- package/src/storage/rollback-generator.js +344 -0
- package/src/types/changes.js +136 -0
- package/src/types/connection.js +17 -0
- package/src/types/execution.js +107 -0
- package/src/types/index.js +1 -0
- package/src/types/migration.js +67 -0
- package/src/types/risk.js +32 -0
- package/src/types/schema.d.ts +1004 -0
- package/src/types/schema.js +561 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { ObjectMatcher } from './object-matcher.js';
|
|
3
|
+
import { PropertyDiffer } from './property-differ.js';
|
|
4
|
+
import { DependencyResolver } from './dependency-resolver.js';
|
|
5
|
+
import { ChangeClassifier } from './change-classifier.js';
|
|
6
|
+
import { RiskTagger } from './risk-tagger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Schema Differ - Main orchestrator for comparing two schema snapshots.
|
|
10
|
+
* Produces a complete SchemaDiff with all changes, properly ordered and classified.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class SchemaDiffer {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.pgVersion = options.pgVersion || 150000;
|
|
16
|
+
this.objectMatcher = new ObjectMatcher();
|
|
17
|
+
this.propertyDiffer = new PropertyDiffer();
|
|
18
|
+
this.dependencyResolver = new DependencyResolver();
|
|
19
|
+
this.changeClassifier = new ChangeClassifier();
|
|
20
|
+
this.riskTagger = new RiskTagger(this.pgVersion);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Diff two schema snapshots (desired vs current).
|
|
25
|
+
* @param {Object} desired - The desired/target schema
|
|
26
|
+
* @param {Object} current - The current/live schema
|
|
27
|
+
* @returns {Object} SchemaDiff
|
|
28
|
+
*/
|
|
29
|
+
diff(desired, current) {
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
const allChanges = [];
|
|
32
|
+
|
|
33
|
+
// Step 1: Match objects between snapshots
|
|
34
|
+
const matched = this.objectMatcher.match(desired, current);
|
|
35
|
+
|
|
36
|
+
// Step 2: Handle creates (objects only in desired)
|
|
37
|
+
for (const create of matched.creates) {
|
|
38
|
+
allChanges.push(this.createChangeObject('CREATE', create.objectType, create.key, null, create.object, create));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step 3: Handle drops (objects only in current)
|
|
42
|
+
for (const drop of matched.drops) {
|
|
43
|
+
allChanges.push(this.createChangeObject('DROP', drop.objectType, drop.key, drop.object, null, drop));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 4: Handle detected renames
|
|
47
|
+
for (const rename of matched.renames) {
|
|
48
|
+
allChanges.push(this.createChangeObject('RENAME', rename.objectType, rename.key, rename, rename, rename, rename));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step 5: Property-level diff for matched objects
|
|
52
|
+
const propertyResults = this.propertyDiffer.diff(matched.matches, desired, current);
|
|
53
|
+
allChanges.push(...propertyResults.changes.map(c => {
|
|
54
|
+
let pluralKey = c.objectType + 's';
|
|
55
|
+
if (c.objectType === 'index') pluralKey = 'indexes';
|
|
56
|
+
else if (c.objectType === 'materializedView') pluralKey = 'materializedViews';
|
|
57
|
+
else if (c.objectType === 'operatorClass') pluralKey = 'operatorClasses';
|
|
58
|
+
else if (c.objectType === 'operatorFamily') pluralKey = 'operatorFamilies';
|
|
59
|
+
else if (c.objectType === 'defaultPrivileges') pluralKey = 'defaultPrivileges';
|
|
60
|
+
else if (c.objectType === 'foreignTable') pluralKey = 'tables';
|
|
61
|
+
else if (c.objectType === 'function' && desired.procedures?.[c.path]) pluralKey = 'procedures';
|
|
62
|
+
else if (c.objectType === 'function' && desired.aggregates?.[c.path]) pluralKey = 'aggregates';
|
|
63
|
+
else if (c.objectType === 'textSearchConfig') pluralKey = 'textSearchConfigs';
|
|
64
|
+
else if (c.objectType === 'textSearchDict') pluralKey = 'textSearchDictionaries';
|
|
65
|
+
else if (c.objectType === 'textSearchParser') pluralKey = 'textSearchParsers';
|
|
66
|
+
else if (c.objectType === 'textSearchTemplate') pluralKey = 'textSearchTemplates';
|
|
67
|
+
|
|
68
|
+
const beforeObj = current[pluralKey]?.[c.path] || null;
|
|
69
|
+
const afterObj = desired[pluralKey]?.[c.path] || null;
|
|
70
|
+
|
|
71
|
+
const changeObj = this.createChangeObject(
|
|
72
|
+
'ALTER',
|
|
73
|
+
c.objectType,
|
|
74
|
+
c.path,
|
|
75
|
+
beforeObj,
|
|
76
|
+
afterObj,
|
|
77
|
+
c,
|
|
78
|
+
c.property
|
|
79
|
+
);
|
|
80
|
+
changeObj.currentValue = c.currentValue;
|
|
81
|
+
changeObj.desiredValue = c.desiredValue;
|
|
82
|
+
return changeObj;
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// Step 6: Process warnings from property differ
|
|
86
|
+
|
|
87
|
+
// Step 7: Classify changes (track 1 vs track 2)
|
|
88
|
+
this.changeClassifier.classify(allChanges);
|
|
89
|
+
|
|
90
|
+
// Step 8: Resolve dependency order
|
|
91
|
+
const orderedChanges = this.dependencyResolver.resolve(allChanges, desired, current);
|
|
92
|
+
|
|
93
|
+
// Step 9: Tag risks
|
|
94
|
+
this.riskTagger.tag(orderedChanges);
|
|
95
|
+
|
|
96
|
+
// Step 10: Build output
|
|
97
|
+
return {
|
|
98
|
+
summary: this.buildSummary(orderedChanges),
|
|
99
|
+
changes: orderedChanges,
|
|
100
|
+
warnings: [...matched.renames.filter(r => !r.confirmed).map(r => ({
|
|
101
|
+
code: 'RENAME_UNCONFIRMED',
|
|
102
|
+
message: `Possible rename detected: ${r.oldName} → ${r.newName}`,
|
|
103
|
+
changeKey: r.key,
|
|
104
|
+
action: 'Confirm rename or treat as drop+add',
|
|
105
|
+
})), ...propertyResults.warnings],
|
|
106
|
+
dependencyGraph: this.dependencyResolver.getGraph(),
|
|
107
|
+
metadata: {
|
|
108
|
+
diffDuration: Date.now() - startTime,
|
|
109
|
+
pgVersion: this.pgVersion,
|
|
110
|
+
desiredChecksum: desired.checksum,
|
|
111
|
+
currentChecksum: current.checksum,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a standardized change object.
|
|
118
|
+
*/
|
|
119
|
+
createChangeObject(changeType, objectType, objectKey, before, after, extra = {}, property = null) {
|
|
120
|
+
const id = `change_${crypto.randomUUID().slice(0, 8)}`;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
changeType,
|
|
125
|
+
objectType,
|
|
126
|
+
objectKey,
|
|
127
|
+
schema: extra?.schema,
|
|
128
|
+
name: extra?.name || objectKey?.split('.').pop(),
|
|
129
|
+
property,
|
|
130
|
+
before: before || null,
|
|
131
|
+
after: after || null,
|
|
132
|
+
track: extra?.track || 1,
|
|
133
|
+
phase: extra?.phase || 10,
|
|
134
|
+
ddlStrategy: extra?.ddlStrategy || 'ALTER',
|
|
135
|
+
dependencies: extra?.dependencies || [],
|
|
136
|
+
dependents: [],
|
|
137
|
+
risk: extra?.risk || { level: 'none', categories: [], warnings: [] },
|
|
138
|
+
requiresRecreation: extra?.requiresRecreation || false,
|
|
139
|
+
safePatternAvailable: extra?.safePatternAvailable || false,
|
|
140
|
+
isNonTransactional: extra?.isTransactional === false,
|
|
141
|
+
pgVersionMinimum: extra?.pgVersionMinimum,
|
|
142
|
+
dataLossRisk: extra?.dataLossRisk,
|
|
143
|
+
...extra,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build summary statistics.
|
|
149
|
+
*/
|
|
150
|
+
buildSummary(changes) {
|
|
151
|
+
const summary = {
|
|
152
|
+
totalChanges: changes.length,
|
|
153
|
+
creates: 0,
|
|
154
|
+
drops: 0,
|
|
155
|
+
alters: 0,
|
|
156
|
+
renames: 0,
|
|
157
|
+
recreates: 0,
|
|
158
|
+
replaces: 0,
|
|
159
|
+
|
|
160
|
+
byTrack: {
|
|
161
|
+
track1: { count: 0, phases: {} },
|
|
162
|
+
track2: { count: 0, phases: {} },
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
byPhase: {},
|
|
166
|
+
|
|
167
|
+
byObjectType: {},
|
|
168
|
+
|
|
169
|
+
riskSummary: {
|
|
170
|
+
critical: 0,
|
|
171
|
+
high: 0,
|
|
172
|
+
medium: 0,
|
|
173
|
+
low: 0,
|
|
174
|
+
none: 0,
|
|
175
|
+
categories: {},
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
requiresDowntime: false,
|
|
179
|
+
estimatedDuration: this.estimateDuration(changes),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const change of changes) {
|
|
183
|
+
// Count by change type
|
|
184
|
+
if (change.changeType === 'CREATE') summary.creates++;
|
|
185
|
+
else if (change.changeType === 'DROP') summary.drops++;
|
|
186
|
+
else if (change.changeType === 'ALTER') summary.alters++;
|
|
187
|
+
else if (change.changeType === 'RENAME') summary.renames++;
|
|
188
|
+
else if (change.changeType?.includes('RECREATE')) summary.recreates++;
|
|
189
|
+
else if (change.changeType?.includes('REPLACE')) summary.replaces++;
|
|
190
|
+
|
|
191
|
+
// Count by track
|
|
192
|
+
const track = change.track === 2 ? 'track2' : 'track1';
|
|
193
|
+
summary.byTrack[track].count++;
|
|
194
|
+
const phase = change.phase;
|
|
195
|
+
if (!summary.byTrack[track].phases[phase]) {
|
|
196
|
+
summary.byTrack[track].phases[phase] = 0;
|
|
197
|
+
}
|
|
198
|
+
summary.byTrack[track].phases[phase]++;
|
|
199
|
+
|
|
200
|
+
// Count by phase
|
|
201
|
+
if (!summary.byPhase[phase]) summary.byPhase[phase] = { count: 0, name: this.getPhaseName(phase) };
|
|
202
|
+
summary.byPhase[phase].count++;
|
|
203
|
+
|
|
204
|
+
// Count by object type
|
|
205
|
+
const objType = change.objectType;
|
|
206
|
+
if (!summary.byObjectType[objType]) summary.byObjectType[objType] = 0;
|
|
207
|
+
summary.byObjectType[objType]++;
|
|
208
|
+
|
|
209
|
+
// Risk counts
|
|
210
|
+
const level = change.risk?.level || 'none';
|
|
211
|
+
summary.riskSummary[level]++;
|
|
212
|
+
|
|
213
|
+
for (const cat of (change.risk?.categories || [])) {
|
|
214
|
+
if (!summary.riskSummary.categories[cat]) {
|
|
215
|
+
summary.riskSummary.categories[cat] = 0;
|
|
216
|
+
}
|
|
217
|
+
summary.riskSummary.categories[cat]++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check for downtime
|
|
221
|
+
if (change.risk?.requiresDowntime) {
|
|
222
|
+
summary.requiresDowntime = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return summary;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get human-readable phase name.
|
|
231
|
+
*/
|
|
232
|
+
getPhaseName(phase) {
|
|
233
|
+
const phases = {
|
|
234
|
+
1: 'pre_check',
|
|
235
|
+
2: 'advisory_lock',
|
|
236
|
+
3: 'extensions',
|
|
237
|
+
4: 'types',
|
|
238
|
+
5: 'schemas',
|
|
239
|
+
6: 'tables_create',
|
|
240
|
+
7: 'columns_add',
|
|
241
|
+
8: 'sequences',
|
|
242
|
+
9: 'indexes_create',
|
|
243
|
+
10: 'constraints_non_fk',
|
|
244
|
+
11: 'data_migration',
|
|
245
|
+
12: 'constraints_fk',
|
|
246
|
+
13: 'validate_constraints',
|
|
247
|
+
14: 'views',
|
|
248
|
+
15: 'materialized_views',
|
|
249
|
+
16: 'functions',
|
|
250
|
+
17: 'triggers',
|
|
251
|
+
18: 'policies',
|
|
252
|
+
19: 'rules',
|
|
253
|
+
20: 'behavioral_other',
|
|
254
|
+
21: 'grants',
|
|
255
|
+
22: 'comments',
|
|
256
|
+
23: 'indexes_concurrent',
|
|
257
|
+
24: 'cleanup',
|
|
258
|
+
25: 'post_check',
|
|
259
|
+
};
|
|
260
|
+
return phases[phase] || `phase_${phase}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Estimate migration duration.
|
|
265
|
+
*/
|
|
266
|
+
estimateDuration(changes) {
|
|
267
|
+
let seconds = 0;
|
|
268
|
+
|
|
269
|
+
for (const change of changes) {
|
|
270
|
+
switch (change.objectType) {
|
|
271
|
+
case 'index':
|
|
272
|
+
seconds += change.isConcurrent ? 30 : 2;
|
|
273
|
+
break;
|
|
274
|
+
case 'constraint':
|
|
275
|
+
seconds += change.constraintType === 'FOREIGN_KEY' ? 5 : 2;
|
|
276
|
+
break;
|
|
277
|
+
case 'table':
|
|
278
|
+
seconds += change.changeType === 'CREATE' ? 1 : 2;
|
|
279
|
+
break;
|
|
280
|
+
case 'column':
|
|
281
|
+
seconds += change.property === 'dataType' ? 10 : 1;
|
|
282
|
+
break;
|
|
283
|
+
case 'type':
|
|
284
|
+
seconds += 2;
|
|
285
|
+
break;
|
|
286
|
+
case 'view':
|
|
287
|
+
case 'materializedView':
|
|
288
|
+
seconds += 3;
|
|
289
|
+
break;
|
|
290
|
+
case 'function':
|
|
291
|
+
seconds += 2;
|
|
292
|
+
break;
|
|
293
|
+
default:
|
|
294
|
+
seconds += 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (seconds < 60) return `${seconds} seconds`;
|
|
299
|
+
if (seconds < 3600) return `${Math.ceil(seconds / 60)} minutes`;
|
|
300
|
+
return `${Math.ceil(seconds / 3600)} hours`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Export all sub-modules
|
|
305
|
+
export { ObjectMatcher } from './object-matcher.js';
|
|
306
|
+
export { PropertyDiffer } from './property-differ.js';
|
|
307
|
+
export { DependencyResolver } from './dependency-resolver.js';
|
|
308
|
+
export { ChangeClassifier } from './change-classifier.js';
|
|
309
|
+
export { RiskTagger } from './risk-tagger.js';
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Levenshtein distance implementation for rename detection.
|
|
3
|
+
* Pure JS, zero dependencies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calculate the Levenshtein distance between two strings.
|
|
8
|
+
* @param {string} a - First string
|
|
9
|
+
* @param {string} b - Second string
|
|
10
|
+
* @returns {number} - Minimum number of edits to transform a into b
|
|
11
|
+
*/
|
|
12
|
+
export function levenshtein(a, b) {
|
|
13
|
+
if (!a) return b ? b.length : 0;
|
|
14
|
+
if (!b) return a.length;
|
|
15
|
+
|
|
16
|
+
const aLen = a.length;
|
|
17
|
+
const bLen = b.length;
|
|
18
|
+
|
|
19
|
+
// Use the shorter string for the outer loop to minimize memory
|
|
20
|
+
if (aLen > bLen) {
|
|
21
|
+
return levenshtein(b, a);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Initialize row
|
|
25
|
+
let prevRow = Array.from({ length: aLen + 1 }, (_, i) => i);
|
|
26
|
+
|
|
27
|
+
for (let j = 1; j <= bLen; j++) {
|
|
28
|
+
const currRow = [j];
|
|
29
|
+
const bChar = b.charCodeAt(j - 1);
|
|
30
|
+
|
|
31
|
+
for (let i = 1; i <= aLen; i++) {
|
|
32
|
+
const aChar = a.charCodeAt(i - 1);
|
|
33
|
+
const cost = aChar === bChar ? 0 : 1;
|
|
34
|
+
|
|
35
|
+
currRow[i] = Math.min(
|
|
36
|
+
prevRow[i] + 1, // deletion
|
|
37
|
+
currRow[i - 1] + 1, // insertion
|
|
38
|
+
prevRow[i - 1] + cost // substitution
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
prevRow = currRow;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return prevRow[aLen];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calculate similarity score between two strings (0-1).
|
|
50
|
+
* Higher values mean more similar.
|
|
51
|
+
* @param {string} a - First string
|
|
52
|
+
* @param {string} b - Second string
|
|
53
|
+
* @returns {number} - Similarity score between 0 and 1
|
|
54
|
+
*/
|
|
55
|
+
export function similarity(a, b) {
|
|
56
|
+
if (!a && !b) return 1;
|
|
57
|
+
if (!a || !b) return 0;
|
|
58
|
+
|
|
59
|
+
const distance = levenshtein(a.toLowerCase(), b.toLowerCase());
|
|
60
|
+
const maxLen = Math.max(a.length, b.length);
|
|
61
|
+
|
|
62
|
+
return maxLen === 0 ? 1 : 1 - (distance / maxLen);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if two strings are similar enough to be rename candidates.
|
|
67
|
+
* @param {string} a - First string
|
|
68
|
+
* @param {string} b - Second string
|
|
69
|
+
* @param {number} threshold - Minimum similarity threshold (default 0.4)
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isSimilarEnough(a, b, threshold = 0.4) {
|
|
73
|
+
return similarity(a, b) >= threshold;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Calculate Damerau-Levenshtein distance (includes transpositions).
|
|
78
|
+
* More accurate for detecting typos like "teh" vs "the".
|
|
79
|
+
* @param {string} a - First string
|
|
80
|
+
* @param {string} b - Second string
|
|
81
|
+
* @returns {number}
|
|
82
|
+
*/
|
|
83
|
+
export function damerauLevenshtein(a, b) {
|
|
84
|
+
if (!a) return b ? b.length : 0;
|
|
85
|
+
if (!b) return a.length;
|
|
86
|
+
|
|
87
|
+
const aLen = a.length;
|
|
88
|
+
const bLen = b.length;
|
|
89
|
+
|
|
90
|
+
const matrix = Array.from({ length: aLen + 1 }, () =>
|
|
91
|
+
Array.from({ length: bLen + 1 }, (_, j) => j)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i <= aLen; i++) {
|
|
95
|
+
matrix[i][0] = i;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let j = 0; j <= bLen; j++) {
|
|
99
|
+
matrix[0][j] = j;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (let i = 1; i <= aLen; i++) {
|
|
103
|
+
for (let j = 1; j <= bLen; j++) {
|
|
104
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
105
|
+
|
|
106
|
+
matrix[i][j] = Math.min(
|
|
107
|
+
matrix[i - 1][j] + 1, // deletion
|
|
108
|
+
matrix[i][j - 1] + 1, // insertion
|
|
109
|
+
matrix[i - 1][j - 1] + cost // substitution
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Transposition
|
|
113
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
114
|
+
matrix[i][j] = Math.min(matrix[i][j], matrix[i - 2][j - 2] + cost);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return matrix[aLen][bLen];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find the best matching string from a list.
|
|
124
|
+
* @param {string} target - String to match
|
|
125
|
+
* @param {string[]} candidates - List of candidate strings
|
|
126
|
+
* @param {number} threshold - Minimum similarity (default 0.4)
|
|
127
|
+
* @returns {{match: string|null, similarity: number}}
|
|
128
|
+
*/
|
|
129
|
+
export function findBestMatch(target, candidates, threshold = 0.4) {
|
|
130
|
+
let bestMatch = null;
|
|
131
|
+
let bestScore = threshold - 1; // Start below threshold
|
|
132
|
+
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
const score = similarity(target, candidate);
|
|
135
|
+
if (score > bestScore) {
|
|
136
|
+
bestScore = score;
|
|
137
|
+
bestMatch = candidate;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { match: bestMatch, similarity: bestMatch };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Calculate Jaro-Winkler similarity (better for name matching).
|
|
146
|
+
* @param {string} a - First string
|
|
147
|
+
* @param {string} b - Second string
|
|
148
|
+
* @returns {number} - Similarity score between 0 and 1
|
|
149
|
+
*/
|
|
150
|
+
export function jaroWinkler(a, b) {
|
|
151
|
+
if (!a && !b) return 1;
|
|
152
|
+
if (!a || !b) return 0;
|
|
153
|
+
if (a === b) return 1;
|
|
154
|
+
|
|
155
|
+
const aLower = a.toLowerCase();
|
|
156
|
+
const bLower = b.toLowerCase();
|
|
157
|
+
|
|
158
|
+
const aLen = aLower.length;
|
|
159
|
+
const bLen = bLower.length;
|
|
160
|
+
|
|
161
|
+
const matchDistance = Math.floor(Math.max(aLen, bLen) / 2) - 1;
|
|
162
|
+
if (matchDistance < 0) return 0;
|
|
163
|
+
|
|
164
|
+
const aMatches = new Array(aLen).fill(false);
|
|
165
|
+
const bMatches = new Array(bLen).fill(false);
|
|
166
|
+
|
|
167
|
+
let matches = 0;
|
|
168
|
+
let transpositions = 0;
|
|
169
|
+
|
|
170
|
+
// Find matches
|
|
171
|
+
for (let i = 0; i < aLen; i++) {
|
|
172
|
+
const start = Math.max(0, i - matchDistance);
|
|
173
|
+
const end = Math.min(i + matchDistance + 1, bLen);
|
|
174
|
+
|
|
175
|
+
for (let j = start; j < end; j++) {
|
|
176
|
+
if (bMatches[j] || aLower[i] !== bLower[j]) continue;
|
|
177
|
+
aMatches[i] = true;
|
|
178
|
+
bMatches[j] = true;
|
|
179
|
+
matches++;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (matches === 0) return 0;
|
|
185
|
+
|
|
186
|
+
// Count transpositions
|
|
187
|
+
let k = 0;
|
|
188
|
+
for (let i = 0; i < aLen; i++) {
|
|
189
|
+
if (!aMatches[i]) continue;
|
|
190
|
+
while (!bMatches[k]) k++;
|
|
191
|
+
if (aLower[i] !== bLower[k]) transpositions++;
|
|
192
|
+
k++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Jaro similarity
|
|
196
|
+
const jaro = (
|
|
197
|
+
matches / aLen +
|
|
198
|
+
matches / bLen +
|
|
199
|
+
(matches - transpositions / 2) / matches
|
|
200
|
+
) / 3;
|
|
201
|
+
|
|
202
|
+
// Winkler modification (boost for common prefix)
|
|
203
|
+
let prefix = 0;
|
|
204
|
+
const maxPrefix = Math.min(4, aLen, bLen);
|
|
205
|
+
for (let i = 0; i < maxPrefix && aLower[i] === bLower[i]; i++) {
|
|
206
|
+
prefix++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
210
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility for building dotted paths like "public.users.email"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a path for an object.
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {string} [options.schema]
|
|
9
|
+
* @param {string} [options.table]
|
|
10
|
+
* @param {string} [options.name]
|
|
11
|
+
* @param {string} [options.column]
|
|
12
|
+
* @param {string} [options.objectType]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function buildPath(options) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
|
|
18
|
+
if (options.schema) {
|
|
19
|
+
parts.push(options.schema);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (options.table) {
|
|
23
|
+
parts.push(options.table);
|
|
24
|
+
} else if (options.parent) {
|
|
25
|
+
parts.push(options.parent);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (options.column) {
|
|
29
|
+
parts.push(options.column);
|
|
30
|
+
} else if (options.name) {
|
|
31
|
+
parts.push(options.name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return parts.join('.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a path for a table.
|
|
39
|
+
* @param {string} schema
|
|
40
|
+
* @param {string} tableName
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function tablePath(schema, tableName) {
|
|
44
|
+
return `${schema}.${tableName}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a path for a column.
|
|
49
|
+
* @param {string} schema
|
|
50
|
+
* @param {string} tableName
|
|
51
|
+
* @param {string} columnName
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function columnPath(schema, tableName, columnName) {
|
|
55
|
+
return `${schema}.${tableName}.${columnName}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a path for an index.
|
|
60
|
+
* @param {string} schema
|
|
61
|
+
* @param {string} indexName
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function indexPath(schema, indexName) {
|
|
65
|
+
return `${schema}.${indexName}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a path for a constraint.
|
|
70
|
+
* @param {string} schema
|
|
71
|
+
* @param {string} constraintName
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function constraintPath(schema, constraintName) {
|
|
75
|
+
return `${schema}.${constraintName}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a path for a function.
|
|
80
|
+
* @param {string} schema
|
|
81
|
+
* @param {string} functionName
|
|
82
|
+
* @param {string} [args]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function functionPath(schema, functionName, args) {
|
|
86
|
+
if (args) {
|
|
87
|
+
return `${schema}.${functionName}(${args})`;
|
|
88
|
+
}
|
|
89
|
+
return `${schema}.${functionName}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a path for a trigger.
|
|
94
|
+
* @param {string} schema
|
|
95
|
+
* @param {string} tableName
|
|
96
|
+
* @param {string} triggerName
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function triggerPath(schema, tableName, triggerName) {
|
|
100
|
+
return `${schema}.${tableName}.${triggerName}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a path for a policy.
|
|
105
|
+
* @param {string} schema
|
|
106
|
+
* @param {string} tableName
|
|
107
|
+
* @param {string} policyName
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function policyPath(schema, tableName, policyName) {
|
|
111
|
+
return `${schema}.${tableName}.${policyName}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a path for a view.
|
|
116
|
+
* @param {string} schema
|
|
117
|
+
* @param {string} viewName
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function viewPath(schema, viewName) {
|
|
121
|
+
return `${schema}.${viewName}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a path for a type.
|
|
126
|
+
* @param {string} schema
|
|
127
|
+
* @param {string} typeName
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function typePath(schema, typeName) {
|
|
131
|
+
return `${schema}.${typeName}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a path for a sequence.
|
|
136
|
+
* @param {string} schema
|
|
137
|
+
* @param {string} sequenceName
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
export function sequencePath(schema, sequenceName) {
|
|
141
|
+
return `${schema}.${sequenceName}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a path into its components.
|
|
146
|
+
* @param {string} path
|
|
147
|
+
* @returns {{schema: string, name: string, parent: string|null}}
|
|
148
|
+
*/
|
|
149
|
+
export function parsePath(path) {
|
|
150
|
+
const parts = path.split('.');
|
|
151
|
+
|
|
152
|
+
if (parts.length === 1) {
|
|
153
|
+
return { schema: null, name: parts[0], parent: null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (parts.length === 2) {
|
|
157
|
+
return { schema: parts[0], name: parts[1], parent: null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (parts.length >= 3) {
|
|
161
|
+
return { schema: parts[0], parent: `${parts[0]}.${parts[1]}`, name: parts[parts.length - 1] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { schema: null, name: path, parent: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the parent path (table for column, schema for table, etc.)
|
|
169
|
+
* @param {string} path
|
|
170
|
+
* @returns {string|null}
|
|
171
|
+
*/
|
|
172
|
+
export function getParentPath(path) {
|
|
173
|
+
const parts = path.split('.');
|
|
174
|
+
|
|
175
|
+
if (parts.length <= 1) return null;
|
|
176
|
+
|
|
177
|
+
return parts.slice(0, -1).join('.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get the leaf name from a path.
|
|
182
|
+
* @param {string} path
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function getLeafName(path) {
|
|
186
|
+
const parts = path.split('.');
|
|
187
|
+
return parts[parts.length - 1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the schema from a path.
|
|
192
|
+
* @param {string} path
|
|
193
|
+
* @returns {string|null}
|
|
194
|
+
*/
|
|
195
|
+
export function getSchema(path) {
|
|
196
|
+
const parts = path.split('.');
|
|
197
|
+
return parts.length >= 1 ? parts[0] : null;
|
|
198
|
+
}
|