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,461 @@
1
+ const sql = require("mssql");
2
+ const { jsonSafeParse } = require("../commons/function");
3
+
4
+ let pool = null;
5
+ const WHERE_INVALID = "Invalid filter object";
6
+
7
+ const RETRYABLE_ERRORS = [
8
+ "ECONNREFUSED",
9
+ "ECONNRESET",
10
+ "EPIPE",
11
+ "ETIMEOUT",
12
+ "ESOCKET",
13
+ ];
14
+
15
+ const ERROR_MAP = {
16
+ 2627: "ER_DUP_ENTRY", // Violation of PRIMARY KEY / UNIQUE constraint
17
+ 2601: "ER_DUP_ENTRY", // Cannot insert duplicate key row
18
+ };
19
+
20
+ function mapMssqlError(err) {
21
+ const num = err.number || err.originalError?.number || 0;
22
+ if (ERROR_MAP[num]) {
23
+ err.code = ERROR_MAP[num];
24
+ err.sqlMessage = err.message;
25
+ return err;
26
+ }
27
+ if (!err.sqlMessage) err.sqlMessage = err.message;
28
+ return err;
29
+ }
30
+
31
+ function isRetryable(err) {
32
+ const code = err.code || "";
33
+ return RETRYABLE_ERRORS.includes(code);
34
+ }
35
+
36
+ async function connect(config) {
37
+ const mssqlConfig = {
38
+ server: config.server || config.host || process.env.DB_HOST || "localhost",
39
+ port: parseInt(config.port || process.env.DB_PORT || 1433),
40
+ database: config.database || process.env.DB_NAME,
41
+ user: config.user || process.env.DB_USER,
42
+ password: config.password || process.env.DB_PASS,
43
+ pool: {
44
+ max: parseInt(config.connectionLimit || process.env.DB_POOL_MAX || 50),
45
+ min: 0,
46
+ idleTimeoutMillis: 30000,
47
+ },
48
+ options: {
49
+ encrypt: config.options?.encrypt ?? false,
50
+ trustServerCertificate: config.options?.trustServerCertificate ?? true,
51
+ enableArithAbort: true,
52
+ },
53
+ };
54
+
55
+ pool = await sql.connect(mssqlConfig);
56
+ return pool;
57
+ }
58
+
59
+ async function executeWithRetry(fn) {
60
+ try {
61
+ return await fn();
62
+ } catch (err) {
63
+ if (isRetryable(err)) {
64
+ return await fn();
65
+ }
66
+ throw mapMssqlError(err);
67
+ }
68
+ }
69
+
70
+ async function query(sqlStr, parameter = []) {
71
+ return executeWithRetry(async () => {
72
+ const request = pool.request();
73
+ for (let i = 0; i < parameter.length; i++) {
74
+ request.input("param" + i, parameter[i]);
75
+ }
76
+ // Replace positional @paramN placeholders if not already present
77
+ const result = await request.query(sqlStr);
78
+ return result.recordset || { affectedRows: result.rowsAffected?.[0] || 0 };
79
+ });
80
+ }
81
+
82
+ function sort_builder(sort) {
83
+ if (!sort || sort.length < 1) {
84
+ return { query: "", value: [] };
85
+ }
86
+ let items = [];
87
+ for (const item of sort) {
88
+ if (item[0] === "-") {
89
+ items.push(escapeId(item.replace("-", "")) + " DESC");
90
+ } else {
91
+ items.push(escapeId(item) + " ASC");
92
+ }
93
+ }
94
+ return { query: "ORDER BY " + items.join(","), value: [] };
95
+ }
96
+
97
+ function where(filter, safeDelete = null) {
98
+ if (filter !== null && filter !== "" && !Array.isArray(filter)) {
99
+ return null;
100
+ }
101
+ try {
102
+ if (
103
+ filter === null ||
104
+ filter === "" ||
105
+ filter.length === 0 ||
106
+ (Array.isArray(filter[0]) && filter[0].length === 0) ||
107
+ (Array.isArray(filter[0]) &&
108
+ Array.isArray(filter[0][0]) &&
109
+ filter[0][0].length === 0)
110
+ ) {
111
+ if (safeDelete === null) {
112
+ return { query: "", value: [] };
113
+ } else {
114
+ filter = [[]];
115
+ }
116
+ }
117
+ } catch (err) {
118
+ return null;
119
+ }
120
+
121
+ if (safeDelete !== null) {
122
+ for (const filterItem of filter) {
123
+ filterItem.push([safeDelete, "=", 0]);
124
+ }
125
+ }
126
+
127
+ const valid_conditionals = [
128
+ "=",
129
+ "like",
130
+ "not like",
131
+ "in",
132
+ "not in",
133
+ "<",
134
+ ">",
135
+ "<=",
136
+ ">=",
137
+ "!=",
138
+ ];
139
+ let conditionOr = [];
140
+ let value = [];
141
+ let bindIdx = 0;
142
+
143
+ for (const i of filter) {
144
+ let conditionAnd = [];
145
+ for (const j of i) {
146
+ if (!valid_conditionals.includes(j[1])) return null;
147
+ if ((j[1] === "in" || j[1] === "not in") && !Array.isArray(j[2]))
148
+ return null;
149
+
150
+ if (j[1] === "in" || j[1] === "not in") {
151
+ const placeholders = j[2]
152
+ .map(() => {
153
+ const p = "@param" + bindIdx;
154
+ bindIdx++;
155
+ return p;
156
+ })
157
+ .join(",");
158
+ conditionAnd.push(`${escapeId(j[0])} ${j[1]} (${placeholders})`);
159
+ value.push(...j[2]);
160
+ } else if (j[1] === "like" || j[1] === "not like") {
161
+ const p = "@param" + bindIdx;
162
+ bindIdx++;
163
+ conditionAnd.push(`${escapeId(j[0])} ${j[1]} ${p}`);
164
+ value.push("%" + j[2] + "%");
165
+ } else {
166
+ const p = "@param" + bindIdx;
167
+ bindIdx++;
168
+ conditionAnd.push(`${escapeId(j[0])} ${j[1]} ${p}`);
169
+ value.push(j[2]);
170
+ }
171
+ }
172
+ conditionOr.push(conditionAnd.join(" AND "));
173
+ }
174
+
175
+ return {
176
+ query: "WHERE ((" + conditionOr.join(") OR (") + "))",
177
+ value,
178
+ };
179
+ }
180
+
181
+ async function get(table, filter = [], sort = [], safeDelete = null) {
182
+ const whereData = where(filter, safeDelete);
183
+ const sortData = sort_builder(sort);
184
+ if (whereData == null) throw new Error(WHERE_INVALID);
185
+
186
+ const sqlStr = `SELECT * FROM ${escapeId(table)} ${whereData.query} ${sortData.query}`;
187
+ const request = pool.request();
188
+ for (let i = 0; i < whereData.value.length; i++) {
189
+ request.input("param" + i, whereData.value[i]);
190
+ }
191
+ const result = await request.query(sqlStr);
192
+ const rows = jsonSafeParse(result.recordset || []);
193
+ const count = await qcount(table, filter, safeDelete);
194
+ return { data: rows, count };
195
+ }
196
+
197
+ async function list(
198
+ table,
199
+ filter = [],
200
+ sort = [],
201
+ safeDelete = null,
202
+ page = 0,
203
+ limit = 30,
204
+ ) {
205
+ const whereData = where(filter, safeDelete);
206
+ const sortData = sort_builder(sort);
207
+ if (whereData == null) throw new Error(WHERE_INVALID);
208
+
209
+ const offset = page * limit;
210
+ // MSSQL requires ORDER BY for OFFSET/FETCH. Use sort or default to (SELECT NULL)
211
+ const orderClause = sortData.query || "ORDER BY (SELECT NULL)";
212
+ const sqlStr = `SELECT * FROM ${escapeId(table)} ${whereData.query} ${orderClause} OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`;
213
+
214
+ const request = pool.request();
215
+ for (let i = 0; i < whereData.value.length; i++) {
216
+ request.input("param" + i, whereData.value[i]);
217
+ }
218
+ const result = await request.query(sqlStr);
219
+ const rows = jsonSafeParse(result.recordset || []);
220
+ const count = await qcount(table, filter, safeDelete);
221
+ return { data: rows, count };
222
+ }
223
+
224
+ async function qcount(table, filter, safeDelete = null) {
225
+ const whereData = where(filter, safeDelete);
226
+ if (whereData == null) return 0;
227
+ const sqlStr = `SELECT COUNT(*) AS number FROM ${escapeId(table)} ${whereData.query}`;
228
+ try {
229
+ const request = pool.request();
230
+ for (let i = 0; i < whereData.value.length; i++) {
231
+ request.input("param" + i, whereData.value[i]);
232
+ }
233
+ const result = await request.query(sqlStr);
234
+ return result.recordset?.[0]?.number || 0;
235
+ } catch {
236
+ return 0;
237
+ }
238
+ }
239
+
240
+ async function remove(table, filter, safeDelete = null) {
241
+ const whereData = where(filter);
242
+ if (whereData == null) throw new Error(WHERE_INVALID);
243
+ if (whereData.value.length < 1) {
244
+ throw new Error("unable to remove as there are no filter attributes");
245
+ }
246
+
247
+ let sqlStr;
248
+ if (safeDelete != null) {
249
+ sqlStr = `UPDATE ${escapeId(table)} SET ${escapeId(safeDelete)} = 1 ${whereData.query}`;
250
+ } else {
251
+ sqlStr = `DELETE FROM ${escapeId(table)} ${whereData.query}`;
252
+ }
253
+
254
+ const request = pool.request();
255
+ for (let i = 0; i < whereData.value.length; i++) {
256
+ request.input("param" + i, whereData.value[i]);
257
+ }
258
+ const result = await request.query(sqlStr);
259
+ const rows = result.rowsAffected?.[0] || 0;
260
+ return { message: rows + " " + table + (rows > 1 ? "s" : "") + " removed" };
261
+ }
262
+
263
+ function namify(text) {
264
+ return text
265
+ .replace("_", " ")
266
+ .replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase());
267
+ }
268
+
269
+ function escapeId(name) {
270
+ return "[" + name.replace(/\]/g, "]]") + "]";
271
+ }
272
+
273
+ async function upsert(table, data, uniqueKeys = []) {
274
+ if (!uniqueKeys || uniqueKeys.length === 0) {
275
+ return insert(table, data, uniqueKeys);
276
+ }
277
+
278
+ let array = Array.isArray(data) ? [...data] : [data];
279
+ const total = array.length;
280
+ const columns = Object.keys(array[0]);
281
+ const nonUniqueColumns = columns.filter((c) => !uniqueKeys.includes(c));
282
+ let lastId = 0;
283
+
284
+ for (const row of array) {
285
+ const request = pool.request();
286
+ let paramIdx = 0;
287
+
288
+ // Build source VALUES
289
+ const valuePlaceholders = columns.map((c) => {
290
+ const p = "@param" + paramIdx;
291
+ request.input("param" + paramIdx, row[c]);
292
+ paramIdx++;
293
+ return p;
294
+ });
295
+
296
+ const colList = columns.map(escapeId).join(",");
297
+ const onClause = uniqueKeys
298
+ .map((k) => `target.${escapeId(k)} = source.${escapeId(k)}`)
299
+ .join(" AND ");
300
+
301
+ let mergeSql = `MERGE INTO ${escapeId(table)} AS target`;
302
+ mergeSql += ` USING (VALUES (${valuePlaceholders.join(",")})) AS source (${colList})`;
303
+ mergeSql += ` ON ${onClause}`;
304
+
305
+ if (nonUniqueColumns.length > 0) {
306
+ const updateSet = nonUniqueColumns
307
+ .map((c) => `target.${escapeId(c)} = source.${escapeId(c)}`)
308
+ .join(",");
309
+ mergeSql += ` WHEN MATCHED THEN UPDATE SET ${updateSet}`;
310
+ }
311
+
312
+ const insertCols = nonUniqueColumns.map((c) => escapeId(c)).join(",");
313
+ const insertVals = nonUniqueColumns
314
+ .map((c) => `source.${escapeId(c)}`)
315
+ .join(",");
316
+ mergeSql += ` WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals})`;
317
+ mergeSql += ` OUTPUT INSERTED.*;`;
318
+
319
+ try {
320
+ const result = await request.query(mergeSql);
321
+ if (result.recordset && result.recordset.length > 0) {
322
+ const firstRow = result.recordset[0];
323
+ lastId = firstRow.id || firstRow[Object.keys(firstRow)[0]] || 0;
324
+ }
325
+ } catch (e) {
326
+ throw mapMssqlError(e);
327
+ }
328
+ }
329
+
330
+ const response = {
331
+ rows: total,
332
+ message:
333
+ (total === 1
334
+ ? `1 ${namify(table)} is `
335
+ : `${total} ${namify(table)}s are `) + "saved",
336
+ type: "success",
337
+ };
338
+ if (total === 1) response.id = lastId;
339
+ return response;
340
+ }
341
+
342
+ async function insert(table, data, uniqueKeys = []) {
343
+ let array = Array.isArray(data) ? [...data] : [data];
344
+ const total = array.length;
345
+ const columns = Object.keys(array[0]);
346
+
347
+ // Only use MERGE path if all unique key columns are present in the data
348
+ const hasAllUniqueKeys =
349
+ uniqueKeys &&
350
+ uniqueKeys.length > 0 &&
351
+ uniqueKeys.every((k) => columns.includes(k));
352
+
353
+ if (total === 1) {
354
+ const row = array[0];
355
+ const request = pool.request();
356
+ const colList = columns.map(escapeId).join(",");
357
+ const valuePlaceholders = columns.map((c, i) => {
358
+ request.input("param" + i, row[c]);
359
+ return "@param" + i;
360
+ });
361
+
362
+ let sqlStr;
363
+ if (hasAllUniqueKeys) {
364
+ // INSERT with conflict ignore: use TRY/CATCH or check existence
365
+ // For MSSQL, we use a MERGE with WHEN NOT MATCHED only
366
+ const onClause = uniqueKeys
367
+ .map((k) => `target.${escapeId(k)} = source.${escapeId(k)}`)
368
+ .join(" AND ");
369
+ const insertCols = columns.map(escapeId).join(",");
370
+ const insertVals = columns.map((c) => `source.${escapeId(c)}`).join(",");
371
+
372
+ sqlStr = `MERGE INTO ${escapeId(table)} AS target`;
373
+ sqlStr += ` USING (VALUES (${valuePlaceholders.join(",")})) AS source (${colList})`;
374
+ sqlStr += ` ON ${onClause}`;
375
+ sqlStr += ` WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals})`;
376
+ sqlStr += ` OUTPUT INSERTED.*;`;
377
+ } else {
378
+ sqlStr = `INSERT INTO ${escapeId(table)} (${colList}) OUTPUT INSERTED.* VALUES (${valuePlaceholders.join(",")})`;
379
+ }
380
+
381
+ try {
382
+ const result = await request.query(sqlStr);
383
+ const insertedRow = result.recordset?.[0];
384
+ const insertId = insertedRow
385
+ ? insertedRow.id || insertedRow[Object.keys(insertedRow)[0]] || 0
386
+ : 0;
387
+ return {
388
+ rows: 1,
389
+ message: `1 ${namify(table)} is saved`,
390
+ type: "success",
391
+ id: insertId,
392
+ };
393
+ } catch (e) {
394
+ throw mapMssqlError(e);
395
+ }
396
+ }
397
+
398
+ // Bulk insert
399
+ for (const row of array) {
400
+ const request = pool.request();
401
+ const colList = columns.map(escapeId).join(",");
402
+ const valuePlaceholders = columns.map((c, i) => {
403
+ request.input("param" + i, row[c]);
404
+ return "@param" + i;
405
+ });
406
+
407
+ let sqlStr;
408
+ if (hasAllUniqueKeys) {
409
+ const onClause = uniqueKeys
410
+ .map((k) => `target.${escapeId(k)} = source.${escapeId(k)}`)
411
+ .join(" AND ");
412
+ const insertCols = columns.map(escapeId).join(",");
413
+ const insertVals = columns.map((c) => `source.${escapeId(c)}`).join(",");
414
+
415
+ sqlStr = `MERGE INTO ${escapeId(table)} AS target`;
416
+ sqlStr += ` USING (VALUES (${valuePlaceholders.join(",")})) AS source (${colList})`;
417
+ sqlStr += ` ON ${onClause}`;
418
+ sqlStr += ` WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${insertVals});`;
419
+ } else {
420
+ sqlStr = `INSERT INTO ${escapeId(table)} (${colList}) VALUES (${valuePlaceholders.join(",")})`;
421
+ }
422
+
423
+ try {
424
+ await request.query(sqlStr);
425
+ } catch (e) {
426
+ throw mapMssqlError(e);
427
+ }
428
+ }
429
+
430
+ return {
431
+ rows: total,
432
+ message:
433
+ (total === 1
434
+ ? `1 ${namify(table)} is `
435
+ : `${total} ${namify(table)}s are `) + "saved",
436
+ type: "success",
437
+ id: 0,
438
+ };
439
+ }
440
+
441
+ async function disconnect() {
442
+ if (pool) {
443
+ await pool.close();
444
+ pool = null;
445
+ }
446
+ }
447
+
448
+ module.exports = {
449
+ connect,
450
+ get,
451
+ list,
452
+ where,
453
+ query,
454
+ qcount,
455
+ remove,
456
+ upsert,
457
+ change: upsert,
458
+ insert,
459
+ disconnect,
460
+ close: disconnect,
461
+ };