db-model-router 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Oracle DDL → PostgreSQL DDL Translation
3
+ * Used by the migration runner to convert Oracle-specific DDL to PostgreSQL equivalents.
4
+ */
5
+
6
+ function translateDDL(sql) {
7
+ if (!sql || typeof sql !== 'string') return sql;
8
+
9
+ // NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY → SERIAL (or BIGSERIAL)
10
+ sql = sql.replace(/\bNUMBER\s+GENERATED\s+(?:BY\s+DEFAULT\s+ON\s+NULL\s+|ALWAYS\s+)AS\s+IDENTITY\b/gi, 'BIGSERIAL');
11
+
12
+ // NUMBER(1) → SMALLINT (used for booleans)
13
+ sql = sql.replace(/\bNUMBER\s*\(\s*1\s*\)/gi, 'SMALLINT');
14
+
15
+ // NUMBER(p,s) → NUMERIC(p,s)
16
+ sql = sql.replace(/\bNUMBER\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)/gi, 'NUMERIC($1,$2)');
17
+
18
+ // NUMBER(p) → NUMERIC(p)
19
+ sql = sql.replace(/\bNUMBER\s*\(\s*(\d+)\s*\)/gi, 'NUMERIC($1)');
20
+
21
+ // NUMBER → BIGINT (bare NUMBER without precision)
22
+ sql = sql.replace(/\bNUMBER\b(?!\s*\()/gi, 'BIGINT');
23
+
24
+ // VARCHAR2(n) → VARCHAR(n)
25
+ sql = sql.replace(/\bVARCHAR2\s*\(/gi, 'VARCHAR(');
26
+
27
+ // CLOB → TEXT
28
+ sql = sql.replace(/\bCLOB\b/gi, 'TEXT');
29
+
30
+ // BLOB → BYTEA
31
+ sql = sql.replace(/\bBLOB\b/gi, 'BYTEA');
32
+
33
+ // SYSTIMESTAMP → NOW()
34
+ sql = sql.replace(/\bSYSTIMESTAMP\b/gi, 'NOW()');
35
+
36
+ // SYSDATE → NOW()
37
+ sql = sql.replace(/\bSYSDATE\b/gi, 'NOW()');
38
+
39
+ // Oracle trigger syntax → PostgreSQL trigger function + trigger
40
+ if (/\bCREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\b/i.test(sql)) {
41
+ sql = translateTrigger(sql);
42
+ }
43
+
44
+ return sql;
45
+ }
46
+
47
+ function translateTrigger(sql) {
48
+ // Parse Oracle trigger: CREATE [OR REPLACE] TRIGGER name AFTER INSERT|UPDATE ON table FOR EACH ROW BEGIN ... END;
49
+ const match = sql.match(
50
+ /CREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\s+(\w+)\s+(BEFORE|AFTER)\s+(INSERT|UPDATE|DELETE)\s+ON\s+(\w+)\s+FOR\s+EACH\s+ROW\s+BEGIN\s+([\s\S]+?)\s*END;?/i
51
+ );
52
+ if (!match) return sql;
53
+
54
+ const [, triggerName, timing, event, tableName, body] = match;
55
+
56
+ // Convert :NEW.col → NEW.col and :OLD.col → OLD.col
57
+ let pgBody = body.replace(/:NEW\./gi, 'NEW.').replace(/:OLD\./gi, 'OLD.');
58
+
59
+ // Convert Oracle DDL types in the body
60
+ pgBody = pgBody.replace(/\bVARCHAR2\s*\(/gi, 'VARCHAR(');
61
+ pgBody = pgBody.replace(/\bNUMBER\b/gi, 'BIGINT');
62
+ pgBody = pgBody.replace(/\bCLOB\b/gi, 'TEXT');
63
+
64
+ const funcName = triggerName + '_func';
65
+
66
+ return `CREATE OR REPLACE FUNCTION ${funcName}() RETURNS TRIGGER AS $$\nBEGIN\n ${pgBody.trim()}\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER ${triggerName}\n${timing} ${event} ON ${tableName}\nFOR EACH ROW\nEXECUTE FUNCTION ${funcName}()`;
67
+ }
68
+
69
+ module.exports = { translateDDL };
@@ -0,0 +1,396 @@
1
+ /**
2
+ * MySQL → PostgreSQL SQL Translation Layer
3
+ * Preprocesses SQL strings before execution to convert common MySQL patterns to PostgreSQL equivalents.
4
+ */
5
+
6
+ const MYSQL_TO_PG_DATE_FORMATS = {
7
+ '%Y': 'YYYY', '%y': 'YY', '%m': 'MM', '%d': 'DD',
8
+ '%H': 'HH24', '%h': 'HH12', '%i': 'MI', '%s': 'SS',
9
+ '%p': 'AM', '%M': 'Month', '%b': 'Mon', '%W': 'Day', '%a': 'Dy',
10
+ '%j': 'DDD', '%T': 'HH24:MI:SS', '%r': 'HH12:MI:SS AM',
11
+ };
12
+
13
+ function convertDateFormat(mysqlFmt) {
14
+ let pgFmt = mysqlFmt;
15
+ const keys = Object.keys(MYSQL_TO_PG_DATE_FORMATS).sort((a, b) => b.length - a.length);
16
+ for (const k of keys) pgFmt = pgFmt.split(k).join(MYSQL_TO_PG_DATE_FORMATS[k]);
17
+ return pgFmt;
18
+ }
19
+
20
+ /** Parse balanced-paren args for FUNC_NAME(...) starting at the open paren. */
21
+ function parseFuncArgs(s, argsStart) {
22
+ let depth = 1, i = argsStart, args = [], argStart = argsStart;
23
+ while (i < s.length && depth > 0) {
24
+ if (s[i] === '(') depth++;
25
+ else if (s[i] === ')') { depth--; if (depth === 0) { args.push(s.slice(argStart, i).trim()); break; } }
26
+ else if (s[i] === ',' && depth === 1) { args.push(s.slice(argStart, i).trim()); argStart = i + 1; }
27
+ else if (s[i] === "'") { i++; while (i < s.length && s[i] !== "'") i++; }
28
+ i++;
29
+ }
30
+ return { args, end: i };
31
+ }
32
+
33
+ /** Replace a named function call using balanced-paren arg parsing. Calls replacer(args) → string|null. */
34
+ function replaceFuncCall(sql, funcName, replacer) {
35
+ const re = new RegExp('\\b' + funcName + '\\s*\\(', 'i');
36
+ let result = '';
37
+ while (true) {
38
+ const m = re.exec(sql);
39
+ if (!m) { result += sql; break; }
40
+ result += sql.slice(0, m.index);
41
+ const { args, end } = parseFuncArgs(sql, m.index + m[0].length);
42
+ // Recursively process args that may contain the same function
43
+ const processedArgs = args.map(a => replaceFuncCall(a, funcName, replacer));
44
+ const replacement = replacer(processedArgs);
45
+ if (replacement != null) {
46
+ result += replacement;
47
+ } else {
48
+ result += m[0] + processedArgs.join(', ') + ')';
49
+ }
50
+ sql = sql.slice(end + 1);
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function stripBackticks(sql) {
56
+ return sql.replace(/`([^`]+)`/g, (_, name) => '"' + name + '"');
57
+ }
58
+
59
+ function translateFunctions(sql) {
60
+ // NOW() stays as NOW() in PG
61
+ // IFNULL(a, b) → COALESCE(a, b)
62
+ sql = sql.replace(/\bIFNULL\s*\(/gi, 'COALESCE(');
63
+
64
+ // GROUP_CONCAT(col SEPARATOR ',') → STRING_AGG(col::text, ',')
65
+ sql = sql.replace(
66
+ /\bGROUP_CONCAT\s*\(\s*([^)]+?)\s+SEPARATOR\s+'([^']*)'\s*\)/gi,
67
+ (_, col, sep) => `STRING_AGG(${col.trim()}::text, '${sep}')`
68
+ );
69
+ // GROUP_CONCAT(col) → STRING_AGG(col::text, ',')
70
+ sql = sql.replace(
71
+ /\bGROUP_CONCAT\s*\(\s*([^)]+?)\s*\)/gi,
72
+ (_, col) => `STRING_AGG(${col.trim()}::text, ',')`
73
+ );
74
+
75
+ // UNIX_TIMESTAMP()*1000 → (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint
76
+ sql = sql.replace(
77
+ /\bUNIX_TIMESTAMP\s*\(\s*\)\s*\*\s*1000/gi,
78
+ "(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint"
79
+ );
80
+
81
+ // UNIX_TIMESTAMP() → EXTRACT(EPOCH FROM NOW())::bigint
82
+ sql = sql.replace(
83
+ /\bUNIX_TIMESTAMP\s*\(\s*\)/gi,
84
+ "EXTRACT(EPOCH FROM NOW())::bigint"
85
+ );
86
+
87
+ // FROM_UNIXTIME(expr, fmt) → TO_CHAR(TO_TIMESTAMP(expr), fmt)
88
+ // FROM_UNIXTIME(expr) → TO_TIMESTAMP(expr)
89
+ sql = replaceFuncCall(sql, 'FROM_UNIXTIME', (args) => {
90
+ if (args.length === 2) {
91
+ const fmt = args[1].match(/^'([^']*)'$/);
92
+ return fmt ? `TO_CHAR(TO_TIMESTAMP(${args[0]}), '${convertDateFormat(fmt[1])}')` : null;
93
+ }
94
+ if (args.length === 1) return `TO_TIMESTAMP(${args[0]})`;
95
+ return null;
96
+ });
97
+
98
+ // CAST(x AS UNSIGNED) → CAST(x AS BIGINT)
99
+ sql = sql.replace(/\bCAST\s*\((.+?)\s+AS\s+UNSIGNED\s*\)/gi, 'CAST($1 AS BIGINT)');
100
+ // CAST(x AS SIGNED) → CAST(x AS BIGINT)
101
+ sql = sql.replace(/\bCAST\s*\((.+?)\s+AS\s+SIGNED\s*\)/gi, 'CAST($1 AS BIGINT)');
102
+
103
+ // DATE_ADD(date, INTERVAL n UNIT) → (date + INTERVAL 'n UNIT')
104
+ // When n is a ? placeholder, use (date + ? * INTERVAL '1 UNIT') to preserve the bind param
105
+ sql = sql.replace(
106
+ /\bDATE_ADD\s*\(\s*(.+?)\s*,\s*INTERVAL\s+(.+?)\s+(SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|YEAR)\s*\)/gi,
107
+ (_, date, n, unit) => {
108
+ n = n.trim();
109
+ if (n === '?') return `(${date.trim()} + ? * INTERVAL '1 ${unit}')`;
110
+ return `(${date.trim()} + INTERVAL '${n} ${unit}')`;
111
+ }
112
+ );
113
+ // DATE_SUB(date, INTERVAL n UNIT) → (date - INTERVAL 'n UNIT')
114
+ sql = sql.replace(
115
+ /\bDATE_SUB\s*\(\s*(.+?)\s*,\s*INTERVAL\s+(.+?)\s+(SECOND|MINUTE|HOUR|DAY|WEEK|MONTH|YEAR)\s*\)/gi,
116
+ (_, date, n, unit) => {
117
+ n = n.trim();
118
+ if (n === '?') return `(${date.trim()} - ? * INTERVAL '1 ${unit}')`;
119
+ return `(${date.trim()} - INTERVAL '${n} ${unit}')`;
120
+ }
121
+ );
122
+
123
+ // SUBSTRING_INDEX(str, delim, count) → SPLIT_PART / REVERSE+SPLIT_PART
124
+ sql = replaceFuncCall(sql, 'SUBSTRING_INDEX', (args) => {
125
+ if (args.length !== 3) return null;
126
+ const delimMatch = args[1].match(/^'([^']*)'$/);
127
+ if (!delimMatch) return null;
128
+ const n = parseInt(args[2]);
129
+ if (n > 0) return `SPLIT_PART(${args[0]}, ${args[1]}, ${n})`;
130
+ if (n === -1) return `REVERSE(SPLIT_PART(REVERSE(${args[0]}), ${args[1]}, 1))`;
131
+ return `SPLIT_PART(${args[0]}, ${args[1]}, ${n})`;
132
+ });
133
+
134
+ // JSON_SET(col, '$.key', val, ...) → nested jsonb_set(jsonb_set(col, '{key}', to_jsonb(val)), ...)
135
+ // Handles multiple path-value pairs. Process innermost first, then outer.
136
+ function replaceJsonSet(s) {
137
+ const re = /\bJSON_SET\s*\(/i;
138
+ const m = re.exec(s);
139
+ if (!m) return s;
140
+ const start = m.index;
141
+ const argsStart = start + m[0].length;
142
+ let depth = 1, i = argsStart, args = [], argStart = argsStart;
143
+ while (i < s.length && depth > 0) {
144
+ if (s[i] === '(') depth++;
145
+ else if (s[i] === ')') { depth--; if (depth === 0) { args.push(s.slice(argStart, i).trim()); break; } }
146
+ else if (s[i] === ',' && depth === 1) { args.push(s.slice(argStart, i).trim()); argStart = i + 1; }
147
+ else if (s[i] === "'" ) { i++; while (i < s.length && s[i] !== "'") i++; }
148
+ else if (s[i] === '"' ) { i++; while (i < s.length && s[i] !== '"') i++; }
149
+ i++;
150
+ }
151
+ // Need odd number of args >= 3: col, path1, val1, [path2, val2, ...]
152
+ if (args.length < 3 || args.length % 2 === 0) return s;
153
+ let col = replaceJsonSet(args[0]);
154
+ for (let j = 1; j < args.length; j += 2) {
155
+ let pathArg = args[j], val = replaceJsonSet(args[j + 1]);
156
+ // Handle both single-quoted '$.path' and double-quoted "$.path"
157
+ const pathMatch = pathArg.match(/['"]?\$\.([^'"]+)['"]?/);
158
+ if (!pathMatch) return s;
159
+ const pgPath = '{' + pathMatch[1].split('.').join(',') + '}';
160
+ col = `jsonb_set(${col}, '${pgPath}', to_jsonb(${val}))`;
161
+ }
162
+ return s.slice(0, start) + col + replaceJsonSet(s.slice(i + 1));
163
+ }
164
+ sql = replaceJsonSet(sql);
165
+
166
+ // JSON_ARRAY_APPEND(arr, '$', val) → (arr || val) (jsonb array append)
167
+ sql = replaceFuncCall(sql, 'JSON_ARRAY_APPEND', (args) => {
168
+ if (args.length === 3) return `(${args[0]} || ${args[2]})`;
169
+ return null;
170
+ });
171
+
172
+ // JSON_ARRAY() → '[]'::jsonb
173
+ sql = sql.replace(/\bJSON_ARRAY\s*\(\s*\)/gi, "'[]'::jsonb");
174
+
175
+ // CAST(? AS JSON) → ?::jsonb
176
+ sql = sql.replace(/\bCAST\s*\(\s*\?\s+AS\s+JSON\s*\)/gi, '?::jsonb');
177
+
178
+ // LAST_INSERT_ID() → lastval()
179
+ sql = sql.replace(/\bLAST_INSERT_ID\s*\(\s*\)/gi, 'lastval()');
180
+
181
+ // JSON_OBJECTAGG(key, val) → json_object_agg(key, val)
182
+ sql = sql.replace(/\bJSON_OBJECTAGG\s*\(/gi, 'json_object_agg(');
183
+
184
+ // JSON_OBJECT( → json_build_object(
185
+ sql = sql.replace(/\bJSON_OBJECT\s*\(/gi, 'json_build_object(');
186
+
187
+ // col REGEXP 'pattern' → col ~ 'pattern'
188
+ sql = sql.replace(/\bREGEXP\s+'/gi, "~ '");
189
+
190
+ // CURDATE() → CURRENT_DATE
191
+ sql = sql.replace(/\bCURDATE\s*\(\s*\)/gi, 'CURRENT_DATE');
192
+
193
+ return sql;
194
+ }
195
+
196
+ function translateLimit(sql) {
197
+ // UPDATE table SET ... WHERE ... LIMIT n → UPDATE table SET ... WHERE ctid IN (SELECT ctid FROM table WHERE ... LIMIT n)
198
+ const updateLimitMatch = sql.match(
199
+ /^(\s*UPDATE\s+)(\w+|"[^"]+")(\s+SET\s+.+?\s+WHERE\s+)(.+?)\s+LIMIT\s+(\d+|\?)\s*;?\s*$/is
200
+ );
201
+ if (updateLimitMatch) {
202
+ const [, upd, table, set, where, limit] = updateLimitMatch;
203
+ return `${upd}${table}${set}ctid IN (SELECT ctid FROM ${table} WHERE ${where} LIMIT ${limit})`;
204
+ }
205
+
206
+ // MySQL LIMIT offset, count → LIMIT count OFFSET offset
207
+ sql = sql.replace(
208
+ /\bLIMIT\s+(\d+)\s*,\s*(\d+)/gi,
209
+ (_, offset, limit) => `LIMIT ${limit} OFFSET ${offset}`
210
+ );
211
+ // Placeholder version
212
+ sql = sql.replace(
213
+ /\bLIMIT\s+\?\s*,\s*\?/gi,
214
+ 'LIMIT ? OFFSET ?'
215
+ );
216
+ return sql;
217
+ }
218
+
219
+ function translateDateFunctions(sql) {
220
+ // DATE_FORMAT(col, '%Y-%m-%d') → TO_CHAR(col, 'YYYY-MM-DD')
221
+ sql = replaceFuncCall(sql, 'DATE_FORMAT', (args) => {
222
+ if (args.length !== 2) return null;
223
+ const fmt = args[1].match(/^'([^']*)'$/);
224
+ return fmt ? `TO_CHAR(${args[0]}, '${convertDateFormat(fmt[1])}')` : null;
225
+ });
226
+ // STR_TO_DATE(str, '%Y-%m-%d') → TO_DATE(str, 'YYYY-MM-DD')
227
+ sql = replaceFuncCall(sql, 'STR_TO_DATE', (args) => {
228
+ if (args.length !== 2) return null;
229
+ const fmt = args[1].match(/^'([^']*)'$/);
230
+ return fmt ? `TO_DATE(${args[0]}, '${convertDateFormat(fmt[1])}')` : null;
231
+ });
232
+ return sql;
233
+ }
234
+
235
+ function buildJsonPath(col, path) {
236
+ const parts = path.split('.');
237
+ if (parts.length === 1) return `${col}->>'${parts[0]}'`;
238
+ const last = parts.pop();
239
+ const chain = parts.map(p => `->'${p}'`).join('');
240
+ return `${col}${chain}->>'${last}'`;
241
+ }
242
+
243
+ function translateJsonFunctions(sql) {
244
+ // JSON_UNQUOTE(col->'$.path') → col->>'path' (must run before -> conversion)
245
+ sql = sql.replace(
246
+ /\bJSON_UNQUOTE\s*\(\s*((?:"\w+"|\w+)(?:\.(?:"\w+"|\w+))?)\s*->\s*'\$\.([^']+)'\s*\)/gi,
247
+ (_, col, path) => buildJsonPath(col, path)
248
+ );
249
+ // JSON_UNQUOTE(expr) → expr (PG ->> already returns unquoted text; strip leftover wrappers)
250
+ sql = replaceFuncCall(sql, 'JSON_UNQUOTE', (args) => args.length === 1 ? args[0] : null);
251
+ // JSON_EXTRACT(col, '$.key') → col->>'key'
252
+ sql = sql.replace(
253
+ /\bJSON_EXTRACT\s*\(\s*([^,]+?)\s*,\s*'\$\.([^']+)'\s*\)/gi,
254
+ (_, col, key) => `${col.trim()}->>'${key}'`
255
+ );
256
+ // JSON_CONTAINS(col, val, '$.path') → col->>'path' IS NOT NULL
257
+ sql = sql.replace(
258
+ /\bJSON_CONTAINS\s*\(\s*([^,]+?)\s*,\s*[^,]+?\s*,\s*'\$\.([^']+)'\s*\)/gi,
259
+ (_, col, path) => `${col.trim()}->>'${path}' IS NOT NULL`
260
+ );
261
+ // col->>'$.path.sub' or alias.col->>'$.path' → col->>'path' or col->'path'->>'sub'
262
+ // Handles both single-quoted '$.path' and double-quoted "$.path" (MySQL JSON shorthand)
263
+ sql = sql.replace(/((?:"\w+"|\w+)(?:\.(?:"\w+"|\w+))?)\s*->>\s*['"]?\$\.([^'"]+)['"]?/g, (_, col, path) => buildJsonPath(col, path));
264
+ // col->'$.path.sub' or alias.col->'$.path' → col->>'path' or col->'path'->>'sub'
265
+ sql = sql.replace(/((?:"\w+"|\w+)(?:\.(?:"\w+"|\w+))?)\s*->\s*['"]?\$\.([^'"]+)['"]?/g, (_, col, path) => buildJsonPath(col, path));
266
+ // WHERE 1 (MySQL truthy) → WHERE 1=1
267
+ sql = sql.replace(/\bWHERE\s+1\b(?!\s*=)/gi, 'WHERE 1=1');
268
+
269
+ // Fix COALESCE type mismatch: COALESCE(expr->>'key', 60) → COALESCE(expr->>'key', '60')
270
+ sql = sql.replace(
271
+ /\bCOALESCE\s*\(\s*((?:(?!COALESCE).)*?->>'[^']+')((?:\s*,\s*(?:'[^']*'|[^,)]+))*)\s*\)/gi,
272
+ (match, jsonExpr, rest) => {
273
+ const fixedRest = rest.replace(/,\s*(\d+(?:\.\d+)?)\b/g, ",'$1'");
274
+ return `COALESCE(${jsonExpr}${fixedRest})`;
275
+ }
276
+ );
277
+
278
+ // Fix ->> text result compared to _id columns (bigint): col_id = (SELECT x->>'key' ...) → col_id = (SELECT (x->>'key')::bigint ...)
279
+ // PG ->> returns text; MySQL ->> returns native type. Cast when compared to _id columns.
280
+ sql = sql.replace(
281
+ /"?(\w*_id\w*)"?\s*=\s*\(\s*SELECT\s+((?:"\w+"|\w+)(?:->'\w+')*->>'[^']+')/gi,
282
+ (m, col, jsonExpr) => `"${col}" = ( SELECT (${jsonExpr})::bigint`
283
+ );
284
+
285
+ // Fix ->> text result used in arithmetic: expr->>'key' + N → (expr->>'key')::numeric + N
286
+ // Also handles COALESCE(expr->>'key', '0') + N
287
+ sql = sql.replace(
288
+ /(\w+->>'[^']+')\s*(\+|-)\s*(\d+)/g,
289
+ (_, jsonExpr, op, num) => `(${jsonExpr})::numeric ${op} ${num}`
290
+ );
291
+ sql = sql.replace(
292
+ /(COALESCE\s*\([^)]*->>'[^']+[^)]*\))\s*(\+|-)\s*(\d+)/gi,
293
+ (_, coalesceExpr, op, num) => `(${coalesceExpr})::numeric ${op} ${num}`
294
+ );
295
+
296
+ return sql;
297
+ }
298
+
299
+ /**
300
+ * Translate INSERT ... ON DUPLICATE KEY UPDATE → INSERT ... ON CONFLICT ... DO UPDATE SET
301
+ */
302
+ function translateOnDuplicateKey(sql) {
303
+ const match = sql.match(
304
+ /INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s+(.+?)\s+(?:AS\s+(\w+)\s+)?ON\s+DUPLICATE\s+KEY\s+UPDATE\s+(.+)$/i
305
+ );
306
+ if (!match) return sql;
307
+
308
+ const [, table, colStr, valuesClause, , updateClause] = match;
309
+ const columns = colStr.split(',').map(c => c.trim());
310
+
311
+ // Parse update assignments
312
+ const updates = updateClause.split(',').map(a => {
313
+ const m = a.trim().match(/(\w+)\s*=\s*(?:\w+\.(\w+)|VALUES\s*\(\s*(\w+)\s*\))/i);
314
+ return m ? m[1].trim() : null;
315
+ }).filter(Boolean);
316
+
317
+ const updateSet = new Set(updates);
318
+ const onColumns = columns.filter(c => !updateSet.has(c));
319
+ const conflictCols = onColumns.join(', ');
320
+ const updateSetSql = updates.map(c => `${c} = EXCLUDED.${c}`).join(', ');
321
+
322
+ return `INSERT INTO ${table} (${colStr}) VALUES ${valuesClause} ON CONFLICT (${conflictCols}) DO UPDATE SET ${updateSetSql}`;
323
+ }
324
+
325
+ function translateInsertIgnore(sql) {
326
+ // INSERT IGNORE INTO table → INSERT INTO table ... ON CONFLICT DO NOTHING
327
+ // We add a marker that will be appended after VALUES
328
+ sql = sql.replace(
329
+ /\bINSERT\s+IGNORE\s+INTO\s+/gi,
330
+ 'INSERT INTO '
331
+ );
332
+ // If the original had INSERT IGNORE, append ON CONFLICT DO NOTHING at the end
333
+ // This is handled by checking a flag, but for simplicity we detect and append
334
+ if (/INSERT\s+INTO\s+/i.test(sql) && !sql.includes('ON CONFLICT')) {
335
+ // Only add if this was originally an INSERT IGNORE (we lost the marker above)
336
+ // We'll handle this via a flag approach instead
337
+ }
338
+ return sql;
339
+ }
340
+
341
+ /**
342
+ * MySQL: UPDATE t1 [AS a] INNER JOIN t2 [AS b] ON cond SET ... WHERE ...
343
+ * PG: UPDATE t1 AS a SET ... FROM t2 AS b WHERE cond AND ...
344
+ */
345
+ function translateUpdateJoin(sql) {
346
+ const m = sql.match(
347
+ /^(\s*UPDATE\s+)(\w+)(\s+AS\s+(\w+))?\s+INNER\s+JOIN\s+(\w+)(\s+AS\s+(\w+))?\s+ON\s+([\s\S]+?)\s+SET\s+([\s\S]+?)\s+WHERE\s+([\s\S]+)$/i
348
+ );
349
+ if (!m) return sql;
350
+ const [, prefix, t1, , a1, t2, , a2, onCond, setCols, whereCond] = m;
351
+ const alias1 = a1 || t1;
352
+ const alias2 = a2 || t2;
353
+ // Strip table alias prefix from SET columns (PG doesn't allow alias.col in SET)
354
+ const cleanSet = setCols.replace(new RegExp('\\b' + alias1 + '\\.', 'g'), '');
355
+ return `${prefix}${t1} AS ${alias1} SET ${cleanSet} FROM ${t2} AS ${alias2} WHERE ${onCond.trim()} AND ${whereCond.trim()}`;
356
+ }
357
+
358
+ function translate(sql) {
359
+ if (!sql || typeof sql !== 'string') return sql;
360
+ // Escape hatch: skip translation for native PostgreSQL SQL
361
+ if (sql.trimStart().startsWith('/* PG_NATIVE */')) return sql;
362
+
363
+ const wasInsertIgnore = /\bINSERT\s+IGNORE\s+INTO\s+/i.test(sql);
364
+
365
+ sql = stripBackticks(sql);
366
+ sql = translateUpdateJoin(sql);
367
+ sql = translateFunctions(sql);
368
+ sql = translateLimit(sql);
369
+ sql = translateDateFunctions(sql);
370
+ sql = translateJsonFunctions(sql);
371
+ sql = translateOnDuplicateKey(sql);
372
+ if (wasInsertIgnore) {
373
+ sql = sql.replace(/\bINSERT\s+IGNORE\s+INTO\s+/gi, 'INSERT INTO ');
374
+ if (!sql.includes('ON CONFLICT')) {
375
+ sql = sql.replace(/;\s*$/, '') + ' ON CONFLICT DO NOTHING';
376
+ }
377
+ }
378
+ // MySQL allows HAVING without GROUP BY to filter on aliases; PG does not.
379
+ // Wrap in subquery: SELECT * FROM (...) _t WHERE <having_cond>
380
+ if (/\bHAVING\b/i.test(sql) && !/\bGROUP\s+BY\b/i.test(sql) && /^\s*SELECT\b/i.test(sql)) {
381
+ const havingMatch = sql.match(/\bHAVING\b\s+([\s\S]+?)(?:\bORDER\s+BY\b|\bLIMIT\b|$)/i);
382
+ if (havingMatch) {
383
+ const havingCond = havingMatch[1].trim().replace(/;\s*$/, '');
384
+ const havingStart = sql.indexOf(havingMatch[0]);
385
+ const inner = sql.slice(0, havingStart).trim().replace(/;\s*$/, '');
386
+ const after = sql.slice(havingStart + havingMatch[0].length - (havingMatch[0].match(/(\bORDER\s+BY\b|\bLIMIT\b)[\s\S]*$/i) || [''])[0].length);
387
+ const rest = sql.slice(havingStart + ('HAVING '.length + havingCond.length)).trim();
388
+ sql = `SELECT * FROM (${inner}) _t WHERE ${havingCond} ${rest}`.replace(/\s+/g, ' ').trim();
389
+ }
390
+ }
391
+ // Remove trailing semicolons
392
+ sql = sql.replace(/;\s*$/, '');
393
+ return sql;
394
+ }
395
+
396
+ module.exports = { translate, stripBackticks, translateFunctions, translateLimit, translateDateFunctions, translateJsonFunctions, translateOnDuplicateKey, translateInsertIgnore, convertDateFormat };