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,855 @@
1
+ const oracledb = require("oracledb");
2
+ const { jsonSafeParse } = require("../commons/function");
3
+ const sqlTranslator = require("./sql_translator");
4
+
5
+ let pool = null;
6
+ let dateStringsMode = false;
7
+ const WHERE_INVALID = "Invalid filter object";
8
+ const pkCache = {};
9
+ const ORACLE_RESERVED_WORDS = new Set([
10
+ "access",
11
+ "add",
12
+ "all",
13
+ "alter",
14
+ "and",
15
+ "any",
16
+ "as",
17
+ "asc",
18
+ "audit",
19
+ "between",
20
+ "by",
21
+ "char",
22
+ "check",
23
+ "cluster",
24
+ "column",
25
+ "comment",
26
+ "compress",
27
+ "connect",
28
+ "create",
29
+ "current",
30
+ "date",
31
+ "decimal",
32
+ "default",
33
+ "delete",
34
+ "desc",
35
+ "distinct",
36
+ "drop",
37
+ "else",
38
+ "exclusive",
39
+ "exists",
40
+ "file",
41
+ "float",
42
+ "for",
43
+ "from",
44
+ "grant",
45
+ "group",
46
+ "having",
47
+ "identified",
48
+ "immediate",
49
+ "in",
50
+ "increment",
51
+ "index",
52
+ "initial",
53
+ "insert",
54
+ "integer",
55
+ "intersect",
56
+ "into",
57
+ "is",
58
+ "level",
59
+ "like",
60
+ "lock",
61
+ "long",
62
+ "maxextents",
63
+ "minus",
64
+ "mlslabel",
65
+ "mode",
66
+ "modify",
67
+ "noaudit",
68
+ "nocompress",
69
+ "not",
70
+ "nowait",
71
+ "null",
72
+ "number",
73
+ "of",
74
+ "offline",
75
+ "on",
76
+ "online",
77
+ "option",
78
+ "or",
79
+ "order",
80
+ "pctfree",
81
+ "prior",
82
+ "public",
83
+ "raw",
84
+ "rename",
85
+ "resource",
86
+ "revoke",
87
+ "row",
88
+ "rowid",
89
+ "rownum",
90
+ "rows",
91
+ "select",
92
+ "session",
93
+ "set",
94
+ "share",
95
+ "size",
96
+ "smallint",
97
+ "start",
98
+ "successful",
99
+ "synonym",
100
+ "sysdate",
101
+ "table",
102
+ "then",
103
+ "to",
104
+ "trigger",
105
+ "type",
106
+ "uid",
107
+ "union",
108
+ "unique",
109
+ "update",
110
+ "user",
111
+ "validate",
112
+ "values",
113
+ "varchar",
114
+ "varchar2",
115
+ "view",
116
+ "whenever",
117
+ "where",
118
+ "with",
119
+ "private",
120
+ ]);
121
+ function quoteCol(col) {
122
+ return ORACLE_RESERVED_WORDS.has(col.toLowerCase())
123
+ ? '"' + col.toUpperCase() + '"'
124
+ : col;
125
+ }
126
+ function sanitizeValue(v) {
127
+ if (v instanceof Date) return v.toISOString().slice(0, 19).replace("T", " ");
128
+ if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) {
129
+ return v
130
+ .replace("T", " ")
131
+ .replace(/[+-]\d{2}:\d{2}$/, "")
132
+ .slice(0, 19);
133
+ }
134
+ return v;
135
+ }
136
+
137
+ // Retry-eligible ORA errors
138
+ const RETRYABLE_ERRORS = ["ORA-03113", "ORA-03114", "ORA-12541"];
139
+
140
+ // Oracle → MySQL error code mapping
141
+ const ERROR_MAP = {
142
+ "ORA-00001": "ER_DUP_ENTRY",
143
+ "ORA-02291": "ER_NO_REFERENCED_ROW",
144
+ "ORA-03113": "PROTOCOL_CONNECTION_LOST",
145
+ "ORA-03114": "PROTOCOL_CONNECTION_LOST",
146
+ "ORA-12541": "ECONNREFUSED",
147
+ };
148
+
149
+ function mapOracleError(err) {
150
+ const msg = err.message || "";
151
+ for (const [ora, mysql] of Object.entries(ERROR_MAP)) {
152
+ if (msg.includes(ora)) {
153
+ err.code = mysql;
154
+ err.sqlMessage = msg;
155
+ return err;
156
+ }
157
+ }
158
+ if (!err.sqlMessage) err.sqlMessage = msg;
159
+ return err;
160
+ }
161
+
162
+ function isRetryable(err) {
163
+ const msg = err.message || "";
164
+ return RETRYABLE_ERRORS.some((code) => msg.includes(code));
165
+ }
166
+
167
+ function initSession(conn, requestedTag, cb) {
168
+ conn
169
+ .execute(
170
+ "ALTER SESSION SET NLS_DATE_FORMAT='YYYY-MM-DD HH24:MI:SS' NLS_TIMESTAMP_FORMAT='YYYY-MM-DD HH24:MI:SS'",
171
+ )
172
+ .then(() => cb(null))
173
+ .catch(cb);
174
+ }
175
+
176
+ function connect(config) {
177
+ oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
178
+ oracledb.autoCommit = true;
179
+ oracledb.fetchAsString = [oracledb.CLOB];
180
+
181
+ dateStringsMode =
182
+ config.dateStrings === true || process.env.DB_DATE_STRINGS === "true";
183
+
184
+ const connectString =
185
+ process.env.DB_CONNECT_STRING ||
186
+ `${config.host || process.env.DB_HOST}:${config.port || process.env.DB_PORT || 1521}/${config.database || process.env.DB_NAME}`;
187
+
188
+ const poolConfig = {
189
+ user: config.user || process.env.DB_USER,
190
+ password: config.password || process.env.DB_PASS,
191
+ connectString,
192
+ poolMin: parseInt(process.env.DB_POOL_MIN) || 4,
193
+ poolMax:
194
+ parseInt(config.connectionLimit) ||
195
+ parseInt(process.env.DB_POOL_MAX) ||
196
+ 50,
197
+ poolIncrement: parseInt(process.env.DB_POOL_INCREMENT) || 2,
198
+ sessionCallback: initSession,
199
+ };
200
+
201
+ pool = oracledb.createPool(poolConfig);
202
+ return pool;
203
+ }
204
+
205
+ /**
206
+ * Resolve ?? (identifier) and ? (value) placeholders.
207
+ * Returns { sql, params } with Oracle :1,:2,... bind vars.
208
+ */
209
+ function translatePlaceholders(sql, params) {
210
+ if (!params || params.length === 0) return { sql, params: [] };
211
+
212
+ const paramsCopy = [...params];
213
+ let oracleParams = [];
214
+
215
+ // Step 0: Fix '?' (quoted placeholders) - remove quotes around ?
216
+ sql = sql.replace(/'(\?)'/g, "$1");
217
+
218
+ // Step 1: resolve ?? identifiers (consume from front of params)
219
+ while (sql.includes("??")) {
220
+ const val = paramsCopy.shift();
221
+ sql = sql.replace("??", String(val));
222
+ }
223
+
224
+ // Step 2: Check for bulk VALUES ? (array-of-arrays)
225
+ const bulkMatch = sql.match(/VALUES\s+\?/i);
226
+ if (
227
+ bulkMatch &&
228
+ paramsCopy.length === 1 &&
229
+ Array.isArray(paramsCopy[0]) &&
230
+ Array.isArray(paramsCopy[0][0])
231
+ ) {
232
+ // Bulk insert — return special marker
233
+ return { sql, params: paramsCopy[0], isBulk: true };
234
+ }
235
+
236
+ // Step 3: replace ? with :1, :2, ...
237
+ let bindIndex = 0;
238
+ sql = sql.replace(/\?/g, () => {
239
+ bindIndex++;
240
+ oracleParams.push(paramsCopy.shift());
241
+ return ":" + bindIndex;
242
+ });
243
+
244
+ // If no ? were found but SQL has :N placeholders, pass params through
245
+ if (bindIndex === 0 && paramsCopy.length > 0 && /:\d+/.test(sql)) {
246
+ oracleParams = paramsCopy;
247
+ }
248
+
249
+ return { sql, params: oracleParams };
250
+ }
251
+
252
+ function mapResults(result) {
253
+ if (result.rows) {
254
+ return result.rows.map((row) => {
255
+ const mapped = {};
256
+ for (const [key, val] of Object.entries(row)) {
257
+ const lk = key.toLowerCase();
258
+ const parsed =
259
+ typeof val === "string" && val.startsWith("{")
260
+ ? jsonSafeParse(val)
261
+ : val;
262
+ mapped[lk] =
263
+ dateStringsMode && val instanceof Date
264
+ ? val.toISOString().slice(0, 19).replace("T", " ")
265
+ : parsed;
266
+ }
267
+ return mapped;
268
+ });
269
+ }
270
+ return {
271
+ affectedRows: result.rowsAffected || 0,
272
+ insertId: result.outBinds?.id?.[0] || 0,
273
+ };
274
+ }
275
+
276
+ async function executeWithRetry(fn) {
277
+ try {
278
+ return await fn();
279
+ } catch (err) {
280
+ if (isRetryable(err)) {
281
+ return await fn();
282
+ }
283
+ throw mapOracleError(err);
284
+ }
285
+ }
286
+
287
+ async function query(sql, parameter = []) {
288
+ const resolvedPool = await pool;
289
+ const translated = sqlTranslator.translate(sql);
290
+ const {
291
+ sql: oracleSql,
292
+ params: oracleParams,
293
+ isBulk,
294
+ } = translatePlaceholders(translated, parameter);
295
+
296
+ return executeWithRetry(async () => {
297
+ const conn = await resolvedPool.getConnection();
298
+ try {
299
+ if (isBulk) {
300
+ // Bulk insert via executeMany
301
+ const cleanSql = oracleSql.replace(/VALUES\s+\?/i, () => {
302
+ const cols = oracleParams[0];
303
+ const placeholders = cols.map((_, i) => ":" + (i + 1)).join(",");
304
+ return `VALUES (${placeholders})`;
305
+ });
306
+ const result = await conn.executeMany(cleanSql, oracleParams, {
307
+ autoCommit: true,
308
+ });
309
+ return { affectedRows: result.rowsAffected || 0, insertId: 0 };
310
+ }
311
+
312
+ // Detect statement type (strip leading comments for detection)
313
+ const sqlNoComment = oracleSql.replace(/^\s*\/\*.*?\*\/\s*/g, "");
314
+ const isInsert = /^\s*INSERT\s/i.test(sqlNoComment);
315
+ let execSql = oracleSql;
316
+ let execParams = oracleParams;
317
+ let execOptions = {
318
+ outFormat: oracledb.OUT_FORMAT_OBJECT,
319
+ autoCommit: true,
320
+ };
321
+
322
+ if (isInsert && !oracleSql.match(/RETURNING/i)) {
323
+ // Try to add RETURNING id INTO :pk_out
324
+ execSql = oracleSql + " RETURNING id INTO :pk_out";
325
+ execParams = [
326
+ ...oracleParams,
327
+ { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
328
+ ];
329
+ // Convert positional to named binds for mixed mode
330
+ let idx = 0;
331
+ const namedParams = {};
332
+ execSql = execSql.replace(/:(\d+)/g, (_, num) => {
333
+ const name = "p" + num;
334
+ namedParams[name] = oracleParams[parseInt(num) - 1];
335
+ return ":" + name;
336
+ });
337
+ namedParams["pk_out"] = {
338
+ dir: oracledb.BIND_OUT,
339
+ type: oracledb.NUMBER,
340
+ };
341
+ execParams = namedParams;
342
+ }
343
+
344
+ let result;
345
+ try {
346
+ result = await conn.execute(execSql, execParams, execOptions);
347
+ } catch (insertErr) {
348
+ // If RETURNING failed (e.g. no 'id' column), retry without it
349
+ if (
350
+ isInsert &&
351
+ insertErr.message &&
352
+ insertErr.message.includes("ORA-")
353
+ ) {
354
+ result = await conn.execute(oracleSql, oracleParams, execOptions);
355
+ } else {
356
+ throw insertErr;
357
+ }
358
+ }
359
+
360
+ if (isInsert) {
361
+ const insertId =
362
+ result.outBinds?.pk_out?.[0] || result.outBinds?.pk_out || 0;
363
+ return {
364
+ affectedRows: result.rowsAffected || 0,
365
+ insertId,
366
+ };
367
+ }
368
+
369
+ const isUpdate = /^\s*(UPDATE|DELETE|MERGE)\s/i.test(sqlNoComment);
370
+ if (isUpdate) {
371
+ return {
372
+ affectedRows: result.rowsAffected || 0,
373
+ insertId: 0,
374
+ };
375
+ }
376
+
377
+ return mapResults(result);
378
+ } finally {
379
+ await conn.close();
380
+ }
381
+ });
382
+ }
383
+
384
+ // WHERE builder
385
+ function where(filter, safeDelete = null) {
386
+ if (filter !== null && filter !== "" && !Array.isArray(filter)) {
387
+ return null;
388
+ }
389
+ try {
390
+ if (
391
+ filter === null ||
392
+ filter === "" ||
393
+ filter.length === 0 ||
394
+ (Array.isArray(filter[0]) && filter[0].length === 0) ||
395
+ (Array.isArray(filter[0]) &&
396
+ Array.isArray(filter[0][0]) &&
397
+ filter[0][0].length === 0)
398
+ ) {
399
+ if (safeDelete === null) {
400
+ return { query: "", value: [] };
401
+ } else {
402
+ filter = [[]];
403
+ }
404
+ }
405
+ } catch (err) {
406
+ return null;
407
+ }
408
+
409
+ if (safeDelete !== null) {
410
+ for (const filterItem of filter) {
411
+ filterItem.push([safeDelete, "=", 0]);
412
+ }
413
+ }
414
+
415
+ const valid_conditionals = [
416
+ "=",
417
+ "like",
418
+ "not like",
419
+ "in",
420
+ "not in",
421
+ "<",
422
+ ">",
423
+ "<=",
424
+ ">=",
425
+ "!=",
426
+ ];
427
+ let conditionOr = [];
428
+ let value = [];
429
+ let bindIdx = 0;
430
+
431
+ for (const i of filter) {
432
+ let conditionAnd = [];
433
+ for (const j of i) {
434
+ if (!valid_conditionals.includes(j[1])) return null;
435
+ if ((j[1] === "in" || j[1] === "not in") && !Array.isArray(j[2]))
436
+ return null;
437
+
438
+ if (j[1] === "in" || j[1] === "not in") {
439
+ const placeholders = j[2]
440
+ .map(() => {
441
+ bindIdx++;
442
+ return ":" + bindIdx;
443
+ })
444
+ .join(",");
445
+ conditionAnd.push(`${j[0]} ${j[1]} (${placeholders})`);
446
+ // Convert booleans to numbers for Oracle
447
+ value.push(
448
+ ...j[2].map((v) => (typeof v === "boolean" ? (v ? 1 : 0) : v)),
449
+ );
450
+ } else if (j[1] === "like" || j[1] === "not like") {
451
+ bindIdx++;
452
+ conditionAnd.push(`${j[0]} ${j[1]} :${bindIdx}`);
453
+ value.push("%" + j[2] + "%");
454
+ } else {
455
+ bindIdx++;
456
+ conditionAnd.push(`${j[0]} ${j[1]} :${bindIdx}`);
457
+ // Convert boolean to number for Oracle; coerce empty string to null (Oracle treats '' as NULL anyway)
458
+ const val =
459
+ typeof j[2] === "boolean"
460
+ ? j[2]
461
+ ? 1
462
+ : 0
463
+ : j[2] === ""
464
+ ? null
465
+ : j[2];
466
+ value.push(val);
467
+ }
468
+ }
469
+ conditionOr.push(conditionAnd.join(" AND "));
470
+ }
471
+
472
+ return {
473
+ query: "WHERE ((" + conditionOr.join(") OR (") + "))",
474
+ value,
475
+ };
476
+ }
477
+
478
+ function sort_builder(sort) {
479
+ if (!sort || sort.length < 1) return { query: "", value: [] };
480
+ let items = [];
481
+ for (const item of sort) {
482
+ if (item[0] === "-") {
483
+ items.push(item.replace("-", "") + " DESC");
484
+ } else {
485
+ items.push(item + " ASC");
486
+ }
487
+ }
488
+ return { query: "ORDER BY " + items.join(","), value: [] };
489
+ }
490
+
491
+ async function get(table, filter = [], sort = [], safeDelete = null) {
492
+ const whereData = where(filter, safeDelete);
493
+ const sortData = sort_builder(sort);
494
+ if (whereData == null) throw new Error(WHERE_INVALID);
495
+
496
+ const sql = `SELECT * FROM ${table} ${whereData.query} ${sortData.query}`;
497
+ const rows = await query(`/* ORACLE_NATIVE */ ${sql}`, whereData.value);
498
+ const count = await qcount(table, filter, safeDelete);
499
+ return { data: jsonSafeParse(rows), count };
500
+ }
501
+
502
+ async function list(
503
+ table,
504
+ filter = [],
505
+ sort = [],
506
+ safeDelete = null,
507
+ page = 0,
508
+ limit = 30,
509
+ ) {
510
+ const whereData = where(filter, safeDelete);
511
+ const sortData = sort_builder(sort);
512
+ if (whereData == null) throw new Error(WHERE_INVALID);
513
+
514
+ const offset = page * limit;
515
+ const sql = `SELECT * FROM ${table} ${whereData.query} ${sortData.query} OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
516
+ const rows = await query(`/* ORACLE_NATIVE */ ${sql}`, whereData.value);
517
+ const count = await qcount(table, filter, safeDelete);
518
+ return { data: jsonSafeParse(rows), count };
519
+ }
520
+
521
+ async function qcount(table, filter, safeDelete = null) {
522
+ const whereData = where(filter, safeDelete);
523
+ if (whereData == null) return 0;
524
+ const sql = `SELECT count(*) AS "number" FROM ${table} ${whereData.query}`;
525
+ try {
526
+ const rows = await query(`/* ORACLE_NATIVE */ ${sql}`, whereData.value);
527
+ return rows[0]?.number || 0;
528
+ } catch {
529
+ return 0;
530
+ }
531
+ }
532
+
533
+ async function remove(table, filter, safeDelete = null) {
534
+ const whereData = where(filter);
535
+ if (whereData == null) throw new Error(WHERE_INVALID);
536
+ if (whereData.value.length < 1) {
537
+ throw new Error("unable to remove as there are no filter attributes");
538
+ }
539
+
540
+ let sql;
541
+ let params;
542
+ if (safeDelete != null) {
543
+ sql = `UPDATE ${table} SET ${safeDelete} = 1 ${whereData.query}`;
544
+ params = whereData.value;
545
+ } else {
546
+ sql = `DELETE FROM ${table} ${whereData.query}`;
547
+ params = whereData.value;
548
+ }
549
+
550
+ const result = await query(`/* ORACLE_NATIVE */ ${sql}`, params);
551
+ const rows = result.affectedRows || 0;
552
+ return { message: rows + " " + table + (rows > 1 ? "s" : "") + " removed" };
553
+ }
554
+
555
+ function namify(text) {
556
+ return text
557
+ .replace("_", " ")
558
+ .replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase());
559
+ }
560
+
561
+ async function getPkColumn(table) {
562
+ if (pkCache[table] !== undefined) return pkCache[table];
563
+ try {
564
+ const resolvedPool = await pool;
565
+ const conn = await resolvedPool.getConnection();
566
+ try {
567
+ const r = await conn.execute(
568
+ `SELECT cols.column_name FROM user_constraints cons JOIN user_cons_columns cols ON cons.constraint_name = cols.constraint_name WHERE cons.table_name = :1 AND cons.constraint_type = 'P' AND ROWNUM = 1`,
569
+ [table.toUpperCase()],
570
+ { outFormat: oracledb.OUT_FORMAT_OBJECT },
571
+ );
572
+ pkCache[table] =
573
+ r.rows.length > 0 ? r.rows[0].COLUMN_NAME.toLowerCase() : null;
574
+ } finally {
575
+ await conn.close();
576
+ }
577
+ } catch (e) {
578
+ pkCache[table] = null;
579
+ }
580
+ return pkCache[table];
581
+ }
582
+
583
+ async function insert(table, data, uniqueKeys = []) {
584
+ let array = Array.isArray(data) ? data : [data];
585
+ const total = array.length;
586
+ const columns = Object.keys(array[0]);
587
+ const qCols = columns.map(quoteCol);
588
+
589
+ // Only use MERGE when unique keys are actually present in the data
590
+ const effectiveUniqueKeys = uniqueKeys.filter((k) => columns.includes(k));
591
+ if (effectiveUniqueKeys.length > 0) {
592
+ return await _mergeInsertOnly(
593
+ table,
594
+ array,
595
+ columns,
596
+ effectiveUniqueKeys,
597
+ total,
598
+ );
599
+ }
600
+
601
+ if (total === 1) {
602
+ const pk = await getPkColumn(table);
603
+ const row = array[0];
604
+ const vals = columns.map((c) => sanitizeValue(row[c]));
605
+ const placeholders = columns.map((_, i) => ":" + (i + 1)).join(",");
606
+ let sql = `INSERT INTO ${table} (${qCols.join(",")}) VALUES (${placeholders})`;
607
+
608
+ if (pk) {
609
+ sql += ` RETURNING ${quoteCol(pk)} INTO :pk_out`;
610
+ const namedParams = {};
611
+ vals.forEach((v, i) => {
612
+ namedParams["p" + (i + 1)] = v;
613
+ });
614
+ namedParams["pk_out"] = { dir: oracledb.BIND_OUT, type: oracledb.NUMBER };
615
+ sql = sql.replace(/:(\d+)/g, (_, num) => ":p" + num);
616
+
617
+ const resolvedPool = await pool;
618
+ const conn = await resolvedPool.getConnection();
619
+ try {
620
+ const result = await conn.execute(sql, namedParams, {
621
+ autoCommit: true,
622
+ });
623
+ const insertId =
624
+ result.outBinds?.pk_out?.[0] || result.outBinds?.pk_out || 0;
625
+ return {
626
+ rows: 1,
627
+ message: `1 ${namify(table)} is saved`,
628
+ type: "success",
629
+ id: insertId,
630
+ };
631
+ } catch (e) {
632
+ throw mapOracleError(e);
633
+ } finally {
634
+ await conn.close();
635
+ }
636
+ }
637
+
638
+ const result = await query(`/* ORACLE_NATIVE */ ${sql}`, vals);
639
+ return {
640
+ rows: 1,
641
+ message: `1 ${namify(table)} is saved`,
642
+ type: "success",
643
+ id: result.insertId || 0,
644
+ };
645
+ }
646
+
647
+ // Bulk insert via executeMany in batches of 1000
648
+ const resolvedPool = await pool;
649
+ let insertId = 0;
650
+ const placeholders = columns.map((_, i) => ":" + (i + 1)).join(",");
651
+ const sql = `INSERT INTO ${table} (${qCols.join(",")}) VALUES (${placeholders})`;
652
+
653
+ for (let i = 0; i < array.length; i += 1000) {
654
+ const batch = array
655
+ .slice(i, i + 1000)
656
+ .map((row) => columns.map((c) => sanitizeValue(row[c])));
657
+ const conn = await resolvedPool.getConnection();
658
+ try {
659
+ await conn.executeMany(sql, batch, { autoCommit: true });
660
+ } finally {
661
+ await conn.close();
662
+ }
663
+ }
664
+
665
+ return {
666
+ rows: total,
667
+ message:
668
+ (total === 1
669
+ ? `1 ${namify(table)} is `
670
+ : `${total} ${namify(table)}s are `) + "saved",
671
+ type: "success",
672
+ id: insertId,
673
+ };
674
+ }
675
+
676
+ async function _mergeInsertOnly(table, array, columns, uniqueKeys, total) {
677
+ let lastId = 0;
678
+ const pk = await getPkColumn(table);
679
+
680
+ for (let i = 0; i < array.length; i += 1000) {
681
+ const batch = array.slice(i, i + 1000);
682
+ for (const row of batch) {
683
+ const vals = columns.map((c) => sanitizeValue(row[c]));
684
+ const usingCols = columns
685
+ .map((c, idx) => `:${idx + 1} AS ${quoteCol(c)}`)
686
+ .join(", ");
687
+ const onClause = uniqueKeys
688
+ .map((k) => `t.${quoteCol(k)} = s.${quoteCol(k)}`)
689
+ .join(" AND ");
690
+ // Exclude PK identity column from the INSERT portion of MERGE
691
+ const insertColumns = pk ? columns.filter((c) => c !== pk) : columns;
692
+ const insertCols = insertColumns.map(quoteCol).join(",");
693
+ const insertVals = insertColumns.map((c) => `s.${quoteCol(c)}`).join(",");
694
+
695
+ const sql = `MERGE INTO ${table} t USING (SELECT ${usingCols} FROM DUAL) s ON (${onClause}) WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals})`;
696
+ await query(`/* ORACLE_NATIVE */ ${sql}`, vals);
697
+
698
+ // Fetch the PK of the inserted/existing row
699
+ if (total === 1) {
700
+ const pk = await getPkColumn(table);
701
+ if (pk) {
702
+ const whereClause = uniqueKeys
703
+ .map((k, idx) => `${k} = :${idx + 1}`)
704
+ .join(" AND ");
705
+ const whereVals = uniqueKeys.map((k) => row[k]);
706
+ const fetched = await query(
707
+ `/* ORACLE_NATIVE */ SELECT ${pk} FROM ${table} WHERE ${whereClause}`,
708
+ whereVals,
709
+ );
710
+ if (fetched.length > 0) lastId = fetched[0][pk] || 0;
711
+ }
712
+ }
713
+ }
714
+ }
715
+
716
+ return {
717
+ rows: total,
718
+ message:
719
+ (total === 1
720
+ ? `1 ${namify(table)} is `
721
+ : `${total} ${namify(table)}s are `) + "saved",
722
+ type: "success",
723
+ id: lastId,
724
+ };
725
+ }
726
+
727
+ async function upsert(table, data, uniqueKeys = []) {
728
+ if (!uniqueKeys || !uniqueKeys.length) {
729
+ return insert(table, data);
730
+ }
731
+ let array = Array.isArray(data) ? data : [data];
732
+ const total = array.length;
733
+ const columns = Object.keys(array[0]);
734
+
735
+ // If unique keys aren't all in the data, fall back to UPDATE using PK or available unique key
736
+ const missingKeys = uniqueKeys.filter((k) => !columns.includes(k));
737
+ if (missingKeys.length > 0) {
738
+ const pk = await getPkColumn(table);
739
+ const keyCol =
740
+ pk && columns.includes(pk)
741
+ ? pk
742
+ : columns.find((c) => uniqueKeys.includes(c));
743
+ if (keyCol) {
744
+ let lastId = 0;
745
+ for (const row of array) {
746
+ const updateCols = columns.filter((c) => c !== keyCol);
747
+ if (updateCols.length === 0) continue;
748
+ const setClause = updateCols
749
+ .map((c, i) => `${quoteCol(c)} = :${i + 1}`)
750
+ .join(", ");
751
+ const vals = [
752
+ ...updateCols.map((c) => sanitizeValue(row[c])),
753
+ row[keyCol],
754
+ ];
755
+ const sql = `UPDATE ${table} SET ${setClause} WHERE ${quoteCol(keyCol)} = :${updateCols.length + 1}`;
756
+ await query(`/* ORACLE_NATIVE */ ${sql}`, vals);
757
+ if (row[keyCol]) lastId = row[keyCol];
758
+ }
759
+ const response = {
760
+ rows: total,
761
+ message:
762
+ (total === 1
763
+ ? `1 ${namify(table)} is `
764
+ : `${total} ${namify(table)}s are `) + "saved",
765
+ type: "success",
766
+ };
767
+ if (total === 1) response.id = lastId;
768
+ return response;
769
+ }
770
+ }
771
+
772
+ const nonUniqueColumns = columns.filter((c) => !uniqueKeys.includes(c));
773
+
774
+ let lastId = 0;
775
+ const pk = await getPkColumn(table);
776
+
777
+ for (let i = 0; i < array.length; i += 1000) {
778
+ const batch = array.slice(i, i + 1000);
779
+ for (const row of batch) {
780
+ const vals = columns.map((c) => sanitizeValue(row[c]));
781
+ const usingCols = columns
782
+ .map((c, idx) => `:${idx + 1} AS ${quoteCol(c)}`)
783
+ .join(", ");
784
+ const onClause = uniqueKeys
785
+ .map((k) => `t.${quoteCol(k)} = s.${quoteCol(k)}`)
786
+ .join(" AND ");
787
+ const updateSet = nonUniqueColumns
788
+ .map((c) => `t.${quoteCol(c)} = s.${quoteCol(c)}`)
789
+ .join(", ");
790
+ // Exclude PK identity column from the INSERT portion of MERGE
791
+ const insertColumns = pk ? columns.filter((c) => c !== pk) : columns;
792
+ const insertCols = insertColumns.map(quoteCol).join(",");
793
+ const insertVals = insertColumns.map((c) => `s.${quoteCol(c)}`).join(",");
794
+
795
+ let sql = `MERGE INTO ${table} t USING (SELECT ${usingCols} FROM DUAL) s ON (${onClause})`;
796
+ if (updateSet) {
797
+ sql += ` WHEN MATCHED THEN UPDATE SET ${updateSet}`;
798
+ }
799
+ sql += ` WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals})`;
800
+
801
+ const result = await query(`/* ORACLE_NATIVE */ ${sql}`, vals);
802
+
803
+ // MERGE doesn't support RETURNING, so fetch the PK via unique keys
804
+ if (total === 1) {
805
+ const pk = await getPkColumn(table);
806
+ if (pk) {
807
+ const whereClause = uniqueKeys
808
+ .map((k, idx) => `${quoteCol(k)} = :${idx + 1}`)
809
+ .join(" AND ");
810
+ const whereVals = uniqueKeys.map((k) => row[k]);
811
+ const fetched = await query(
812
+ `/* ORACLE_NATIVE */ SELECT ${quoteCol(pk)} FROM ${table} WHERE ${whereClause}`,
813
+ whereVals,
814
+ );
815
+ if (fetched.length > 0) lastId = fetched[0][pk] || 0;
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ const response = {
822
+ rows: total,
823
+ message:
824
+ (total === 1
825
+ ? `1 ${namify(table)} is `
826
+ : `${total} ${namify(table)}s are `) + "saved",
827
+ type: "success",
828
+ };
829
+ if (total === 1) response.id = lastId;
830
+ return response;
831
+ }
832
+
833
+ async function disconnect() {
834
+ if (pool) {
835
+ const resolvedPool = await pool;
836
+ await resolvedPool.close(0);
837
+ pool = null;
838
+ }
839
+ }
840
+
841
+ module.exports = {
842
+ connect,
843
+ get,
844
+ list,
845
+ where,
846
+ query,
847
+ qcount,
848
+ remove,
849
+ upsert,
850
+ change: upsert,
851
+ insert,
852
+ disconnect,
853
+ close: disconnect,
854
+ pool: null, // Exposed for compatibility, but use methods instead
855
+ };