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.
- package/.env +7 -0
- package/LICENSE +201 -0
- package/README.md +505 -0
- package/docker-compose.yml +141 -0
- package/docs/README.md +208 -0
- package/docs/SKILL.md +202 -0
- package/docs/adapters/cockroachdb.md +49 -0
- package/docs/adapters/dynamodb.md +53 -0
- package/docs/adapters/mongodb.md +56 -0
- package/docs/adapters/mssql.md +55 -0
- package/docs/adapters/oracle.md +52 -0
- package/docs/adapters/postgres.md +50 -0
- package/docs/adapters/redis.md +53 -0
- package/docs/adapters/sqlite3.md +43 -0
- package/package.json +109 -0
- package/src/cli/generate-app.js +359 -0
- package/src/cli/generate-model.js +760 -0
- package/src/cli/generate-openapi.js +237 -0
- package/src/cli/generate-route.js +346 -0
- package/src/cockroachdb/db.js +563 -0
- package/src/commons/function.js +165 -0
- package/src/commons/model.js +444 -0
- package/src/commons/route.js +214 -0
- package/src/commons/validator.js +172 -0
- package/src/dynamodb/db.js +552 -0
- package/src/index.js +57 -0
- package/src/mongodb/db.js +381 -0
- package/src/mssql/db.js +461 -0
- package/src/mysql/db.js +527 -0
- package/src/oracle/db.js +855 -0
- package/src/oracle/sql_translator.js +406 -0
- package/src/postgres/db.js +666 -0
- package/src/postgres/ddl_translator.js +69 -0
- package/src/postgres/sql_translator.js +396 -0
- package/src/redis/db.js +448 -0
- package/src/serve.js +90 -0
- package/src/sqlite3/db.js +346 -0
|
@@ -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 };
|