@openfn/language-mssql 6.0.6 → 7.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/ast.json +22 -4
- package/configuration-schema.json +3 -2
- package/dist/index.cjs +135 -37
- package/dist/index.js +135 -37
- package/package.json +2 -2
- package/types/Adaptor.d.ts +12 -4
- package/types/util.d.ts +44 -3
package/ast.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"params"
|
|
7
7
|
],
|
|
8
8
|
"docs": {
|
|
9
|
-
"description": "Execute
|
|
9
|
+
"description": "Execute a raw SQL statement",
|
|
10
10
|
"tags": [
|
|
11
11
|
{
|
|
12
12
|
"title": "public",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"title": "example",
|
|
18
|
-
"description": "sql({
|
|
18
|
+
"description": "sql({\n query: 'SELECT * FROM users WHERE id = @id',\n options\n})"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"title": "function",
|
|
@@ -24,13 +24,31 @@
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"title": "param",
|
|
27
|
-
"description": "Payload data for the
|
|
27
|
+
"description": "Payload data for the SQL query",
|
|
28
28
|
"type": {
|
|
29
29
|
"type": "NameExpression",
|
|
30
30
|
"name": "object"
|
|
31
31
|
},
|
|
32
32
|
"name": "params"
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
"title": "param",
|
|
36
|
+
"description": "The SQL query to execute",
|
|
37
|
+
"type": {
|
|
38
|
+
"type": "NameExpression",
|
|
39
|
+
"name": "string"
|
|
40
|
+
},
|
|
41
|
+
"name": "params.query"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"title": "param",
|
|
45
|
+
"description": "Optional query options",
|
|
46
|
+
"type": {
|
|
47
|
+
"type": "NameExpression",
|
|
48
|
+
"name": "object"
|
|
49
|
+
},
|
|
50
|
+
"name": "params.options"
|
|
51
|
+
},
|
|
34
52
|
{
|
|
35
53
|
"title": "returns",
|
|
36
54
|
"description": null,
|
|
@@ -41,7 +59,7 @@
|
|
|
41
59
|
}
|
|
42
60
|
]
|
|
43
61
|
},
|
|
44
|
-
"valid":
|
|
62
|
+
"valid": false
|
|
45
63
|
},
|
|
46
64
|
{
|
|
47
65
|
"name": "findValue",
|
package/dist/index.cjs
CHANGED
|
@@ -86,15 +86,73 @@ var import_util = require("@openfn/language-common/util");
|
|
|
86
86
|
// src/util.js
|
|
87
87
|
var util_exports = {};
|
|
88
88
|
__export(util_exports, {
|
|
89
|
-
escape: () => escape
|
|
89
|
+
escape: () => escape,
|
|
90
|
+
escapeIdentifier: () => escapeIdentifier,
|
|
91
|
+
escapeLike: () => escapeLike,
|
|
92
|
+
validateOperator: () => validateOperator
|
|
90
93
|
});
|
|
91
94
|
function escape(stringExp) {
|
|
92
95
|
if (typeof stringExp === "object" && stringExp !== null) {
|
|
96
|
+
if (Array.isArray(stringExp)) {
|
|
97
|
+
return stringExp.map((x) => escape(x));
|
|
98
|
+
}
|
|
93
99
|
return Object.values(stringExp).map((x) => escape(x));
|
|
94
|
-
}
|
|
100
|
+
}
|
|
101
|
+
if (stringExp === null || stringExp === void 0) {
|
|
95
102
|
return stringExp;
|
|
96
103
|
}
|
|
97
|
-
|
|
104
|
+
if (typeof stringExp === "number" || typeof stringExp === "boolean") {
|
|
105
|
+
return String(stringExp);
|
|
106
|
+
}
|
|
107
|
+
if (typeof stringExp !== "string") {
|
|
108
|
+
throw new Error(`Unexpected type for SQL value: ${typeof stringExp}`);
|
|
109
|
+
}
|
|
110
|
+
return stringExp.replace(/'/g, "''");
|
|
111
|
+
}
|
|
112
|
+
function escapeLike(pattern) {
|
|
113
|
+
let escaped = escape(pattern);
|
|
114
|
+
escaped = escaped.replace(/\[/g, "[[[]");
|
|
115
|
+
escaped = escaped.replace(/%/g, "[%]");
|
|
116
|
+
escaped = escaped.replace(/_/g, "[_]");
|
|
117
|
+
return escaped;
|
|
118
|
+
}
|
|
119
|
+
function escapeIdentifier(identifier) {
|
|
120
|
+
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
121
|
+
throw new Error("Identifier must be a non-empty string");
|
|
122
|
+
}
|
|
123
|
+
return `[${identifier.replace(/\]/g, "]]")}]`;
|
|
124
|
+
}
|
|
125
|
+
var ALLOWED_OPERATORS = [
|
|
126
|
+
"=",
|
|
127
|
+
"!=",
|
|
128
|
+
"<>",
|
|
129
|
+
"<",
|
|
130
|
+
">",
|
|
131
|
+
"<=",
|
|
132
|
+
">=",
|
|
133
|
+
"LIKE",
|
|
134
|
+
"NOT LIKE",
|
|
135
|
+
"IN",
|
|
136
|
+
"NOT IN",
|
|
137
|
+
"IS NULL",
|
|
138
|
+
"IS NOT NULL"
|
|
139
|
+
];
|
|
140
|
+
function validateOperator(operator) {
|
|
141
|
+
if (typeof operator !== "string") {
|
|
142
|
+
throw new Error("Operator must be a non-empty string");
|
|
143
|
+
}
|
|
144
|
+
const normalized = operator.trim().toUpperCase();
|
|
145
|
+
if (normalized.length === 0) {
|
|
146
|
+
throw new Error("Operator must be a non-empty string");
|
|
147
|
+
}
|
|
148
|
+
if (!ALLOWED_OPERATORS.includes(normalized)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Invalid SQL operator: "${operator}". Allowed operators: ${ALLOWED_OPERATORS.join(
|
|
151
|
+
", "
|
|
152
|
+
)}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return normalized;
|
|
98
156
|
}
|
|
99
157
|
|
|
100
158
|
// src/Adaptor.js
|
|
@@ -108,7 +166,7 @@ function createConnection(state) {
|
|
|
108
166
|
database,
|
|
109
167
|
port = 1433,
|
|
110
168
|
encrypt = true,
|
|
111
|
-
trustServerCertificate =
|
|
169
|
+
trustServerCertificate = false
|
|
112
170
|
} = state.configuration;
|
|
113
171
|
if (!server) {
|
|
114
172
|
throw new Error("server missing from configuration.");
|
|
@@ -160,9 +218,10 @@ function cleanupState(state) {
|
|
|
160
218
|
state == null ? true : delete state.connection;
|
|
161
219
|
return state;
|
|
162
220
|
}
|
|
163
|
-
function addRowsToRefs(state, rows) {
|
|
221
|
+
function addRowsToRefs(state, rows, rowCount) {
|
|
164
222
|
return {
|
|
165
223
|
...state,
|
|
224
|
+
rowCount,
|
|
166
225
|
references: [rows, ...state.references]
|
|
167
226
|
};
|
|
168
227
|
}
|
|
@@ -209,7 +268,7 @@ function queryHandler(state, query, callback, options) {
|
|
|
209
268
|
return;
|
|
210
269
|
} else {
|
|
211
270
|
console.log(`Finished: ${rowCount} row(s).`);
|
|
212
|
-
resolve(callback(state, rows));
|
|
271
|
+
resolve(callback(state, rows, rowCount));
|
|
213
272
|
}
|
|
214
273
|
});
|
|
215
274
|
connection.execSql(request);
|
|
@@ -221,6 +280,20 @@ function sql(params) {
|
|
|
221
280
|
try {
|
|
222
281
|
const [resolvedParams] = (0, import_util.expandReferences)(state, params);
|
|
223
282
|
const { query, options } = resolvedParams;
|
|
283
|
+
const dangerousPatterns = [
|
|
284
|
+
/;\s*DROP\s+TABLE/i,
|
|
285
|
+
/;\s*DELETE\s+FROM/i,
|
|
286
|
+
/;\s*TRUNCATE/i,
|
|
287
|
+
/--\s*$/m,
|
|
288
|
+
/\/\*.*\*\//
|
|
289
|
+
];
|
|
290
|
+
dangerousPatterns.forEach((pattern) => {
|
|
291
|
+
if (pattern.test(query)) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
"SQL query contains potentially dangerous pattern. Ensure this is intentional and from a trusted source."
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
224
297
|
console.log(`Preparing to execute sql statement: ${query}`);
|
|
225
298
|
return queryHandler(state, query, composeNextState, options);
|
|
226
299
|
} catch (e) {
|
|
@@ -259,16 +332,21 @@ function findValue(filter) {
|
|
|
259
332
|
const { connection } = state;
|
|
260
333
|
const { uuid, relation, where, operator } = filter;
|
|
261
334
|
const [whereData, operatorData] = (0, import_util.expandReferences)(state, where, operator);
|
|
335
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
336
|
+
const safeRelation = escapeIdentifier(relation);
|
|
262
337
|
let conditionsArray = [];
|
|
263
338
|
for (let key in whereData) {
|
|
264
|
-
const
|
|
339
|
+
const safeColumnName = escapeIdentifier(key);
|
|
340
|
+
const validatedOperator = operatorData ? validateOperator(operatorData[key]) : "=";
|
|
341
|
+
const isLikeOperator = validatedOperator === "LIKE" || validatedOperator === "NOT LIKE";
|
|
342
|
+
const escapedValue = isLikeOperator ? escapeLike(whereData[key]) : escape(whereData[key]);
|
|
265
343
|
conditionsArray.push(
|
|
266
|
-
`${
|
|
344
|
+
`${safeColumnName} ${validatedOperator} '${escapedValue}'`
|
|
267
345
|
);
|
|
268
346
|
}
|
|
269
347
|
const condition = conditionsArray.length > 0 ? `where ${conditionsArray.join(" and ")}` : "";
|
|
270
348
|
try {
|
|
271
|
-
const body = `select ${
|
|
349
|
+
const body = `select ${safeUuid} from ${safeRelation} ${condition}`;
|
|
272
350
|
console.log("Preparing to execute sql statement");
|
|
273
351
|
let returnValue = null;
|
|
274
352
|
return new Promise((resolve, reject) => {
|
|
@@ -301,13 +379,17 @@ function insert(table, record, options) {
|
|
|
301
379
|
const { connection } = state;
|
|
302
380
|
try {
|
|
303
381
|
const [recordData] = (0, import_util.expandReferences)(state, record);
|
|
382
|
+
const safeTable = escapeIdentifier(table);
|
|
304
383
|
const columns = Object.keys(recordData).sort();
|
|
384
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
305
385
|
const values = columns.map((key) => escape(recordData[key])).join("', '");
|
|
306
386
|
const query = handleValues(
|
|
307
|
-
`INSERT INTO ${
|
|
387
|
+
`INSERT INTO ${safeTable} (${safeColumns.join(
|
|
388
|
+
", "
|
|
389
|
+
)}) VALUES ('${values}');`,
|
|
308
390
|
handleOptions(options)
|
|
309
391
|
);
|
|
310
|
-
const safeQuery = `INSERT INTO ${
|
|
392
|
+
const safeQuery = `INSERT INTO ${safeTable} (${safeColumns.join(
|
|
311
393
|
", "
|
|
312
394
|
)}) VALUES [--REDACTED--]];`;
|
|
313
395
|
return new Promise((resolve) => {
|
|
@@ -326,17 +408,19 @@ function insertMany(table, records, options) {
|
|
|
326
408
|
const { connection } = state;
|
|
327
409
|
try {
|
|
328
410
|
const [recordData] = (0, import_util.expandReferences)(state, records);
|
|
411
|
+
const safeTable = escapeIdentifier(table);
|
|
329
412
|
const columns = Object.keys(recordData[0]);
|
|
413
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
330
414
|
const valueSets = recordData.map(
|
|
331
415
|
(x) => `('${escape(Object.values(x)).join("', '")}')`
|
|
332
416
|
);
|
|
333
417
|
const query = handleValues(
|
|
334
|
-
`INSERT INTO ${
|
|
418
|
+
`INSERT INTO ${safeTable} (${safeColumns.join(
|
|
335
419
|
", "
|
|
336
|
-
)};`,
|
|
420
|
+
)}) VALUES ${valueSets.join(", ")};`,
|
|
337
421
|
handleOptions(options)
|
|
338
422
|
);
|
|
339
|
-
const safeQuery = `INSERT INTO ${
|
|
423
|
+
const safeQuery = `INSERT INTO ${safeTable} (${safeColumns.join(
|
|
340
424
|
", "
|
|
341
425
|
)}) VALUES [--REDACTED--]];`;
|
|
342
426
|
return new Promise((resolve) => {
|
|
@@ -355,21 +439,27 @@ function upsert(table, uuid, record, options) {
|
|
|
355
439
|
const { connection } = state;
|
|
356
440
|
try {
|
|
357
441
|
const [recordData] = (0, import_util.expandReferences)(state, record);
|
|
442
|
+
const safeTable = escapeIdentifier(table);
|
|
358
443
|
const columns = Object.keys(recordData).sort();
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
444
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
445
|
+
const selectValues = columns.map((key, i) => `'${escape(recordData[key])}' AS ${safeColumns[i]}`).join(", ");
|
|
446
|
+
const updateValues = columns.map(
|
|
447
|
+
(key, i) => `[Target].${safeColumns[i]}='${escape(recordData[key])}'`
|
|
448
|
+
).join(", ");
|
|
449
|
+
const insertColumns = safeColumns.join(", ");
|
|
450
|
+
const insertValues = safeColumns.map((col) => `[Source].${col}`).join(", ");
|
|
363
451
|
const constraint = [];
|
|
364
452
|
if (Array.isArray(uuid)) {
|
|
365
453
|
uuid.forEach((key) => {
|
|
366
|
-
|
|
454
|
+
const safeKey = escapeIdentifier(key);
|
|
455
|
+
constraint.push(`[Target].${safeKey} = [Source].${safeKey}`);
|
|
367
456
|
});
|
|
368
457
|
} else {
|
|
369
|
-
|
|
458
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
459
|
+
constraint.push(`[Target].${safeUuid} = [Source].${safeUuid}`);
|
|
370
460
|
}
|
|
371
461
|
const query = handleValues(
|
|
372
|
-
`MERGE ${
|
|
462
|
+
`MERGE ${safeTable} AS [Target]
|
|
373
463
|
USING (SELECT ${selectValues}) AS [Source]
|
|
374
464
|
ON ${constraint.join(" AND ")}
|
|
375
465
|
WHEN MATCHED THEN
|
|
@@ -378,7 +468,7 @@ function upsert(table, uuid, record, options) {
|
|
|
378
468
|
INSERT (${insertColumns}) VALUES (${insertValues});`,
|
|
379
469
|
handleOptions(options)
|
|
380
470
|
);
|
|
381
|
-
const safeQuery = `MERGE ${
|
|
471
|
+
const safeQuery = `MERGE ${safeTable} AS [Target]
|
|
382
472
|
USING (SELECT [--REDACTED--])
|
|
383
473
|
ON [Target].[--VALUE--] = [Source].[--VALUE--]
|
|
384
474
|
WHEN MATCHED THEN
|
|
@@ -458,23 +548,27 @@ function upsertMany(table, uuid, records, options) {
|
|
|
458
548
|
console.log("No records provided; skipping upsert.");
|
|
459
549
|
resolve(state);
|
|
460
550
|
}
|
|
551
|
+
const safeTable = escapeIdentifier(table);
|
|
461
552
|
const columns = Object.keys(recordData[0]);
|
|
553
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
462
554
|
const valueSets = recordData.map(
|
|
463
555
|
(x) => `('${escape(Object.values(x)).join("', '")}')`
|
|
464
556
|
);
|
|
465
|
-
const insertColumns =
|
|
466
|
-
const insertValues =
|
|
467
|
-
const updateValues =
|
|
557
|
+
const insertColumns = safeColumns.join(", ");
|
|
558
|
+
const insertValues = safeColumns.map((col) => `[Source].${col}`).join(", ");
|
|
559
|
+
const updateValues = safeColumns.map((col) => `[Target].${col}=[Source].${col}`).join(", ");
|
|
468
560
|
const constraint = [];
|
|
469
561
|
if (Array.isArray(uuid)) {
|
|
470
562
|
uuid.forEach((key) => {
|
|
471
|
-
|
|
563
|
+
const safeKey = escapeIdentifier(key);
|
|
564
|
+
constraint.push(`[Target].${safeKey} = [Source].${safeKey}`);
|
|
472
565
|
});
|
|
473
566
|
} else {
|
|
474
|
-
|
|
567
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
568
|
+
constraint.push(`[Target].${safeUuid} = [Source].${safeUuid}`);
|
|
475
569
|
}
|
|
476
570
|
const query = handleValues(
|
|
477
|
-
`MERGE ${
|
|
571
|
+
`MERGE ${safeTable} AS [Target]
|
|
478
572
|
USING (VALUES ${valueSets.join(", ")}) AS [Source] (${insertColumns})
|
|
479
573
|
ON ${constraint.join(" AND ")}
|
|
480
574
|
WHEN MATCHED THEN
|
|
@@ -483,7 +577,7 @@ function upsertMany(table, uuid, records, options) {
|
|
|
483
577
|
INSERT (${insertColumns}) VALUES (${insertValues});`,
|
|
484
578
|
handleOptions(options)
|
|
485
579
|
);
|
|
486
|
-
const safeQuery = `MERGE ${
|
|
580
|
+
const safeQuery = `MERGE ${safeTable} AS [Target]
|
|
487
581
|
USING (VALUES [--REDACTED--]) AS [SOURCE] (${insertColumns})
|
|
488
582
|
ON [Target].[--VALUE--] = [Source].[--VALUE--]
|
|
489
583
|
WHEN MATCHED THEN
|
|
@@ -528,10 +622,12 @@ function insertTable(tableName, columns, options) {
|
|
|
528
622
|
resolve(state);
|
|
529
623
|
return state;
|
|
530
624
|
}
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
625
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
626
|
+
const structureData = data.map((x) => {
|
|
627
|
+
const safeColName = escapeIdentifier(x.name);
|
|
628
|
+
return `${safeColName} ${x.type} ${x.hasOwnProperty("default") ? x.type.includes("varchar") || x.type.includes("text") ? `DEFAULT '${escape(x.default)}'` : `DEFAULT ${x.default}` : ""} ${x.unique ? "UNIQUE" : ""} ${x.identity ? "PRIMARY KEY IDENTITY (1,1)" : ""} ${x.required ? "NOT NULL" : ""}`;
|
|
629
|
+
}).join(", ");
|
|
630
|
+
const query = `CREATE TABLE ${safeTableName} (
|
|
535
631
|
${structureData}
|
|
536
632
|
);`;
|
|
537
633
|
console.log("Preparing to create table via:", query);
|
|
@@ -554,10 +650,12 @@ function modifyTable(tableName, columns, options) {
|
|
|
554
650
|
resolve(state);
|
|
555
651
|
return state;
|
|
556
652
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
653
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
654
|
+
const structureData = data.map((x) => {
|
|
655
|
+
const safeColName = escapeIdentifier(x.name);
|
|
656
|
+
return `${safeColName} ${x.type} ${x.hasOwnProperty("default") ? x.type.includes("varchar") || x.type.includes("text") ? `DEFAULT '${escape(x.default)}'` : `DEFAULT ${x.default}` : ""} ${x.unique ? "UNIQUE" : ""} ${x.identity ? "IDENTITY (1,1)" : ""} ${x.required ? "NOT NULL" : ""}`;
|
|
657
|
+
}).join(", ");
|
|
658
|
+
const query = `ALTER TABLE ${safeTableName} ADD ${structureData};`;
|
|
561
659
|
console.log("Preparing to modify table via:", query);
|
|
562
660
|
resolve(queryHandler(state, query, flattenRows, options));
|
|
563
661
|
});
|
package/dist/index.js
CHANGED
|
@@ -40,15 +40,73 @@ import { expandReferences } from "@openfn/language-common/util";
|
|
|
40
40
|
// src/util.js
|
|
41
41
|
var util_exports = {};
|
|
42
42
|
__export(util_exports, {
|
|
43
|
-
escape: () => escape
|
|
43
|
+
escape: () => escape,
|
|
44
|
+
escapeIdentifier: () => escapeIdentifier,
|
|
45
|
+
escapeLike: () => escapeLike,
|
|
46
|
+
validateOperator: () => validateOperator
|
|
44
47
|
});
|
|
45
48
|
function escape(stringExp) {
|
|
46
49
|
if (typeof stringExp === "object" && stringExp !== null) {
|
|
50
|
+
if (Array.isArray(stringExp)) {
|
|
51
|
+
return stringExp.map((x) => escape(x));
|
|
52
|
+
}
|
|
47
53
|
return Object.values(stringExp).map((x) => escape(x));
|
|
48
|
-
}
|
|
54
|
+
}
|
|
55
|
+
if (stringExp === null || stringExp === void 0) {
|
|
49
56
|
return stringExp;
|
|
50
57
|
}
|
|
51
|
-
|
|
58
|
+
if (typeof stringExp === "number" || typeof stringExp === "boolean") {
|
|
59
|
+
return String(stringExp);
|
|
60
|
+
}
|
|
61
|
+
if (typeof stringExp !== "string") {
|
|
62
|
+
throw new Error(`Unexpected type for SQL value: ${typeof stringExp}`);
|
|
63
|
+
}
|
|
64
|
+
return stringExp.replace(/'/g, "''");
|
|
65
|
+
}
|
|
66
|
+
function escapeLike(pattern) {
|
|
67
|
+
let escaped = escape(pattern);
|
|
68
|
+
escaped = escaped.replace(/\[/g, "[[[]");
|
|
69
|
+
escaped = escaped.replace(/%/g, "[%]");
|
|
70
|
+
escaped = escaped.replace(/_/g, "[_]");
|
|
71
|
+
return escaped;
|
|
72
|
+
}
|
|
73
|
+
function escapeIdentifier(identifier) {
|
|
74
|
+
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
75
|
+
throw new Error("Identifier must be a non-empty string");
|
|
76
|
+
}
|
|
77
|
+
return `[${identifier.replace(/\]/g, "]]")}]`;
|
|
78
|
+
}
|
|
79
|
+
var ALLOWED_OPERATORS = [
|
|
80
|
+
"=",
|
|
81
|
+
"!=",
|
|
82
|
+
"<>",
|
|
83
|
+
"<",
|
|
84
|
+
">",
|
|
85
|
+
"<=",
|
|
86
|
+
">=",
|
|
87
|
+
"LIKE",
|
|
88
|
+
"NOT LIKE",
|
|
89
|
+
"IN",
|
|
90
|
+
"NOT IN",
|
|
91
|
+
"IS NULL",
|
|
92
|
+
"IS NOT NULL"
|
|
93
|
+
];
|
|
94
|
+
function validateOperator(operator) {
|
|
95
|
+
if (typeof operator !== "string") {
|
|
96
|
+
throw new Error("Operator must be a non-empty string");
|
|
97
|
+
}
|
|
98
|
+
const normalized = operator.trim().toUpperCase();
|
|
99
|
+
if (normalized.length === 0) {
|
|
100
|
+
throw new Error("Operator must be a non-empty string");
|
|
101
|
+
}
|
|
102
|
+
if (!ALLOWED_OPERATORS.includes(normalized)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Invalid SQL operator: "${operator}". Allowed operators: ${ALLOWED_OPERATORS.join(
|
|
105
|
+
", "
|
|
106
|
+
)}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return normalized;
|
|
52
110
|
}
|
|
53
111
|
|
|
54
112
|
// src/Adaptor.js
|
|
@@ -78,7 +136,7 @@ function createConnection(state) {
|
|
|
78
136
|
database,
|
|
79
137
|
port = 1433,
|
|
80
138
|
encrypt = true,
|
|
81
|
-
trustServerCertificate =
|
|
139
|
+
trustServerCertificate = false
|
|
82
140
|
} = state.configuration;
|
|
83
141
|
if (!server) {
|
|
84
142
|
throw new Error("server missing from configuration.");
|
|
@@ -130,9 +188,10 @@ function cleanupState(state) {
|
|
|
130
188
|
state == null ? true : delete state.connection;
|
|
131
189
|
return state;
|
|
132
190
|
}
|
|
133
|
-
function addRowsToRefs(state, rows) {
|
|
191
|
+
function addRowsToRefs(state, rows, rowCount) {
|
|
134
192
|
return {
|
|
135
193
|
...state,
|
|
194
|
+
rowCount,
|
|
136
195
|
references: [rows, ...state.references]
|
|
137
196
|
};
|
|
138
197
|
}
|
|
@@ -179,7 +238,7 @@ function queryHandler(state, query, callback, options) {
|
|
|
179
238
|
return;
|
|
180
239
|
} else {
|
|
181
240
|
console.log(`Finished: ${rowCount} row(s).`);
|
|
182
|
-
resolve(callback(state, rows));
|
|
241
|
+
resolve(callback(state, rows, rowCount));
|
|
183
242
|
}
|
|
184
243
|
});
|
|
185
244
|
connection.execSql(request);
|
|
@@ -191,6 +250,20 @@ function sql(params) {
|
|
|
191
250
|
try {
|
|
192
251
|
const [resolvedParams] = expandReferences(state, params);
|
|
193
252
|
const { query, options } = resolvedParams;
|
|
253
|
+
const dangerousPatterns = [
|
|
254
|
+
/;\s*DROP\s+TABLE/i,
|
|
255
|
+
/;\s*DELETE\s+FROM/i,
|
|
256
|
+
/;\s*TRUNCATE/i,
|
|
257
|
+
/--\s*$/m,
|
|
258
|
+
/\/\*.*\*\//
|
|
259
|
+
];
|
|
260
|
+
dangerousPatterns.forEach((pattern) => {
|
|
261
|
+
if (pattern.test(query)) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"SQL query contains potentially dangerous pattern. Ensure this is intentional and from a trusted source."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
194
267
|
console.log(`Preparing to execute sql statement: ${query}`);
|
|
195
268
|
return queryHandler(state, query, composeNextState, options);
|
|
196
269
|
} catch (e) {
|
|
@@ -229,16 +302,21 @@ function findValue(filter) {
|
|
|
229
302
|
const { connection } = state;
|
|
230
303
|
const { uuid, relation, where, operator } = filter;
|
|
231
304
|
const [whereData, operatorData] = expandReferences(state, where, operator);
|
|
305
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
306
|
+
const safeRelation = escapeIdentifier(relation);
|
|
232
307
|
let conditionsArray = [];
|
|
233
308
|
for (let key in whereData) {
|
|
234
|
-
const
|
|
309
|
+
const safeColumnName = escapeIdentifier(key);
|
|
310
|
+
const validatedOperator = operatorData ? validateOperator(operatorData[key]) : "=";
|
|
311
|
+
const isLikeOperator = validatedOperator === "LIKE" || validatedOperator === "NOT LIKE";
|
|
312
|
+
const escapedValue = isLikeOperator ? escapeLike(whereData[key]) : escape(whereData[key]);
|
|
235
313
|
conditionsArray.push(
|
|
236
|
-
`${
|
|
314
|
+
`${safeColumnName} ${validatedOperator} '${escapedValue}'`
|
|
237
315
|
);
|
|
238
316
|
}
|
|
239
317
|
const condition = conditionsArray.length > 0 ? `where ${conditionsArray.join(" and ")}` : "";
|
|
240
318
|
try {
|
|
241
|
-
const body = `select ${
|
|
319
|
+
const body = `select ${safeUuid} from ${safeRelation} ${condition}`;
|
|
242
320
|
console.log("Preparing to execute sql statement");
|
|
243
321
|
let returnValue = null;
|
|
244
322
|
return new Promise((resolve, reject) => {
|
|
@@ -271,13 +349,17 @@ function insert(table, record, options) {
|
|
|
271
349
|
const { connection } = state;
|
|
272
350
|
try {
|
|
273
351
|
const [recordData] = expandReferences(state, record);
|
|
352
|
+
const safeTable = escapeIdentifier(table);
|
|
274
353
|
const columns = Object.keys(recordData).sort();
|
|
354
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
275
355
|
const values = columns.map((key) => escape(recordData[key])).join("', '");
|
|
276
356
|
const query = handleValues(
|
|
277
|
-
`INSERT INTO ${
|
|
357
|
+
`INSERT INTO ${safeTable} (${safeColumns.join(
|
|
358
|
+
", "
|
|
359
|
+
)}) VALUES ('${values}');`,
|
|
278
360
|
handleOptions(options)
|
|
279
361
|
);
|
|
280
|
-
const safeQuery = `INSERT INTO ${
|
|
362
|
+
const safeQuery = `INSERT INTO ${safeTable} (${safeColumns.join(
|
|
281
363
|
", "
|
|
282
364
|
)}) VALUES [--REDACTED--]];`;
|
|
283
365
|
return new Promise((resolve) => {
|
|
@@ -296,17 +378,19 @@ function insertMany(table, records, options) {
|
|
|
296
378
|
const { connection } = state;
|
|
297
379
|
try {
|
|
298
380
|
const [recordData] = expandReferences(state, records);
|
|
381
|
+
const safeTable = escapeIdentifier(table);
|
|
299
382
|
const columns = Object.keys(recordData[0]);
|
|
383
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
300
384
|
const valueSets = recordData.map(
|
|
301
385
|
(x) => `('${escape(Object.values(x)).join("', '")}')`
|
|
302
386
|
);
|
|
303
387
|
const query = handleValues(
|
|
304
|
-
`INSERT INTO ${
|
|
388
|
+
`INSERT INTO ${safeTable} (${safeColumns.join(
|
|
305
389
|
", "
|
|
306
|
-
)};`,
|
|
390
|
+
)}) VALUES ${valueSets.join(", ")};`,
|
|
307
391
|
handleOptions(options)
|
|
308
392
|
);
|
|
309
|
-
const safeQuery = `INSERT INTO ${
|
|
393
|
+
const safeQuery = `INSERT INTO ${safeTable} (${safeColumns.join(
|
|
310
394
|
", "
|
|
311
395
|
)}) VALUES [--REDACTED--]];`;
|
|
312
396
|
return new Promise((resolve) => {
|
|
@@ -325,21 +409,27 @@ function upsert(table, uuid, record, options) {
|
|
|
325
409
|
const { connection } = state;
|
|
326
410
|
try {
|
|
327
411
|
const [recordData] = expandReferences(state, record);
|
|
412
|
+
const safeTable = escapeIdentifier(table);
|
|
328
413
|
const columns = Object.keys(recordData).sort();
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
414
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
415
|
+
const selectValues = columns.map((key, i) => `'${escape(recordData[key])}' AS ${safeColumns[i]}`).join(", ");
|
|
416
|
+
const updateValues = columns.map(
|
|
417
|
+
(key, i) => `[Target].${safeColumns[i]}='${escape(recordData[key])}'`
|
|
418
|
+
).join(", ");
|
|
419
|
+
const insertColumns = safeColumns.join(", ");
|
|
420
|
+
const insertValues = safeColumns.map((col) => `[Source].${col}`).join(", ");
|
|
333
421
|
const constraint = [];
|
|
334
422
|
if (Array.isArray(uuid)) {
|
|
335
423
|
uuid.forEach((key) => {
|
|
336
|
-
|
|
424
|
+
const safeKey = escapeIdentifier(key);
|
|
425
|
+
constraint.push(`[Target].${safeKey} = [Source].${safeKey}`);
|
|
337
426
|
});
|
|
338
427
|
} else {
|
|
339
|
-
|
|
428
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
429
|
+
constraint.push(`[Target].${safeUuid} = [Source].${safeUuid}`);
|
|
340
430
|
}
|
|
341
431
|
const query = handleValues(
|
|
342
|
-
`MERGE ${
|
|
432
|
+
`MERGE ${safeTable} AS [Target]
|
|
343
433
|
USING (SELECT ${selectValues}) AS [Source]
|
|
344
434
|
ON ${constraint.join(" AND ")}
|
|
345
435
|
WHEN MATCHED THEN
|
|
@@ -348,7 +438,7 @@ function upsert(table, uuid, record, options) {
|
|
|
348
438
|
INSERT (${insertColumns}) VALUES (${insertValues});`,
|
|
349
439
|
handleOptions(options)
|
|
350
440
|
);
|
|
351
|
-
const safeQuery = `MERGE ${
|
|
441
|
+
const safeQuery = `MERGE ${safeTable} AS [Target]
|
|
352
442
|
USING (SELECT [--REDACTED--])
|
|
353
443
|
ON [Target].[--VALUE--] = [Source].[--VALUE--]
|
|
354
444
|
WHEN MATCHED THEN
|
|
@@ -428,23 +518,27 @@ function upsertMany(table, uuid, records, options) {
|
|
|
428
518
|
console.log("No records provided; skipping upsert.");
|
|
429
519
|
resolve(state);
|
|
430
520
|
}
|
|
521
|
+
const safeTable = escapeIdentifier(table);
|
|
431
522
|
const columns = Object.keys(recordData[0]);
|
|
523
|
+
const safeColumns = columns.map((col) => escapeIdentifier(col));
|
|
432
524
|
const valueSets = recordData.map(
|
|
433
525
|
(x) => `('${escape(Object.values(x)).join("', '")}')`
|
|
434
526
|
);
|
|
435
|
-
const insertColumns =
|
|
436
|
-
const insertValues =
|
|
437
|
-
const updateValues =
|
|
527
|
+
const insertColumns = safeColumns.join(", ");
|
|
528
|
+
const insertValues = safeColumns.map((col) => `[Source].${col}`).join(", ");
|
|
529
|
+
const updateValues = safeColumns.map((col) => `[Target].${col}=[Source].${col}`).join(", ");
|
|
438
530
|
const constraint = [];
|
|
439
531
|
if (Array.isArray(uuid)) {
|
|
440
532
|
uuid.forEach((key) => {
|
|
441
|
-
|
|
533
|
+
const safeKey = escapeIdentifier(key);
|
|
534
|
+
constraint.push(`[Target].${safeKey} = [Source].${safeKey}`);
|
|
442
535
|
});
|
|
443
536
|
} else {
|
|
444
|
-
|
|
537
|
+
const safeUuid = escapeIdentifier(uuid);
|
|
538
|
+
constraint.push(`[Target].${safeUuid} = [Source].${safeUuid}`);
|
|
445
539
|
}
|
|
446
540
|
const query = handleValues(
|
|
447
|
-
`MERGE ${
|
|
541
|
+
`MERGE ${safeTable} AS [Target]
|
|
448
542
|
USING (VALUES ${valueSets.join(", ")}) AS [Source] (${insertColumns})
|
|
449
543
|
ON ${constraint.join(" AND ")}
|
|
450
544
|
WHEN MATCHED THEN
|
|
@@ -453,7 +547,7 @@ function upsertMany(table, uuid, records, options) {
|
|
|
453
547
|
INSERT (${insertColumns}) VALUES (${insertValues});`,
|
|
454
548
|
handleOptions(options)
|
|
455
549
|
);
|
|
456
|
-
const safeQuery = `MERGE ${
|
|
550
|
+
const safeQuery = `MERGE ${safeTable} AS [Target]
|
|
457
551
|
USING (VALUES [--REDACTED--]) AS [SOURCE] (${insertColumns})
|
|
458
552
|
ON [Target].[--VALUE--] = [Source].[--VALUE--]
|
|
459
553
|
WHEN MATCHED THEN
|
|
@@ -498,10 +592,12 @@ function insertTable(tableName, columns, options) {
|
|
|
498
592
|
resolve(state);
|
|
499
593
|
return state;
|
|
500
594
|
}
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
595
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
596
|
+
const structureData = data.map((x) => {
|
|
597
|
+
const safeColName = escapeIdentifier(x.name);
|
|
598
|
+
return `${safeColName} ${x.type} ${x.hasOwnProperty("default") ? x.type.includes("varchar") || x.type.includes("text") ? `DEFAULT '${escape(x.default)}'` : `DEFAULT ${x.default}` : ""} ${x.unique ? "UNIQUE" : ""} ${x.identity ? "PRIMARY KEY IDENTITY (1,1)" : ""} ${x.required ? "NOT NULL" : ""}`;
|
|
599
|
+
}).join(", ");
|
|
600
|
+
const query = `CREATE TABLE ${safeTableName} (
|
|
505
601
|
${structureData}
|
|
506
602
|
);`;
|
|
507
603
|
console.log("Preparing to create table via:", query);
|
|
@@ -524,10 +620,12 @@ function modifyTable(tableName, columns, options) {
|
|
|
524
620
|
resolve(state);
|
|
525
621
|
return state;
|
|
526
622
|
}
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
623
|
+
const safeTableName = escapeIdentifier(tableName);
|
|
624
|
+
const structureData = data.map((x) => {
|
|
625
|
+
const safeColName = escapeIdentifier(x.name);
|
|
626
|
+
return `${safeColName} ${x.type} ${x.hasOwnProperty("default") ? x.type.includes("varchar") || x.type.includes("text") ? `DEFAULT '${escape(x.default)}'` : `DEFAULT ${x.default}` : ""} ${x.unique ? "UNIQUE" : ""} ${x.identity ? "IDENTITY (1,1)" : ""} ${x.required ? "NOT NULL" : ""}`;
|
|
627
|
+
}).join(", ");
|
|
628
|
+
const query = `ALTER TABLE ${safeTableName} ADD ${structureData};`;
|
|
531
629
|
console.log("Preparing to modify table via:", query);
|
|
532
630
|
resolve(queryHandler(state, query, flattenRows, options));
|
|
533
631
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openfn/language-mssql",
|
|
3
3
|
"label": "MSSQL",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "7.0.0",
|
|
5
5
|
"description": "A Microsoft SQL language pack for OpenFn",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"tedious": "18.6.1",
|
|
29
|
-
"@openfn/language-common": "3.2.
|
|
29
|
+
"@openfn/language-common": "3.2.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"assertion-error": "2.0.0",
|
package/types/Adaptor.d.ts
CHANGED
|
@@ -12,15 +12,23 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export function execute(...operations: Operations): Operation;
|
|
14
14
|
/**
|
|
15
|
-
* Execute
|
|
15
|
+
* Execute a raw SQL statement
|
|
16
16
|
* @public
|
|
17
17
|
* @example
|
|
18
|
-
* sql({
|
|
18
|
+
* sql({
|
|
19
|
+
* query: 'SELECT * FROM users WHERE id = @id',
|
|
20
|
+
* options
|
|
21
|
+
* })
|
|
19
22
|
* @function
|
|
20
|
-
* @param {object} params - Payload data for the
|
|
23
|
+
* @param {object} params - Payload data for the SQL query
|
|
24
|
+
* @param {string} params.query - The SQL query to execute
|
|
25
|
+
* @param {object} params.options - Optional query options
|
|
21
26
|
* @returns {Operation}
|
|
22
27
|
*/
|
|
23
|
-
export function sql(params:
|
|
28
|
+
export function sql(params: {
|
|
29
|
+
query: string;
|
|
30
|
+
options: object;
|
|
31
|
+
}): Operation;
|
|
24
32
|
/**
|
|
25
33
|
* Fetch a uuid key given a condition
|
|
26
34
|
* @public
|
package/types/util.d.ts
CHANGED
|
@@ -6,8 +6,49 @@
|
|
|
6
6
|
* util.escape('string with "quotes"') // 'string with "quotes"'
|
|
7
7
|
* util.escape('') // ''
|
|
8
8
|
* util.escape(null) // null
|
|
9
|
+
* util.escape(123) // '123'
|
|
10
|
+
* util.escape(true) // 'true'
|
|
9
11
|
* util.escape("'; DROP TABLE users; --") // "''; DROP TABLE users; --"
|
|
10
|
-
* @param {string} stringExp - The
|
|
11
|
-
* @returns {string} The escaped string.
|
|
12
|
+
* @param {string|number|boolean|null|undefined} stringExp - The value to escape.
|
|
13
|
+
* @returns {string|null|undefined} The escaped string, or null/undefined if input was null/undefined.
|
|
14
|
+
* @throws {Error} If the value is an unexpected type (object, array, function, etc.).
|
|
12
15
|
*/
|
|
13
|
-
export function escape(stringExp: string): string;
|
|
16
|
+
export function escape(stringExp: string | number | boolean | null | undefined): string | null | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Escapes a string for use in SQL LIKE patterns, preventing wildcard injection.
|
|
19
|
+
* This should be used when the operator is LIKE or NOT LIKE.
|
|
20
|
+
* @example <caption>Examples</caption>
|
|
21
|
+
* util.escapeLike('test') // 'test'
|
|
22
|
+
* util.escapeLike('100%') // '100[%]'
|
|
23
|
+
* util.escapeLike('test_value') // 'test[_]value'
|
|
24
|
+
* util.escapeLike('[admin]') // '[[[]admin]'
|
|
25
|
+
* util.escapeLike("O'Connor%") // "O''Connor[%]"
|
|
26
|
+
* @param {string} pattern - The pattern to escape.
|
|
27
|
+
* @returns {string} The escaped pattern safe for use in LIKE clauses.
|
|
28
|
+
*/
|
|
29
|
+
export function escapeLike(pattern: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Escapes an SQL identifier (table or column name) to prevent SQL injection.
|
|
32
|
+
* Uses bracket notation for SQL Server which is safer than validating patterns.
|
|
33
|
+
* @example <caption>Examples</caption>
|
|
34
|
+
* util.escapeIdentifier('users') // '[users]'
|
|
35
|
+
* util.escapeIdentifier('first_name') // '[first_name]'
|
|
36
|
+
* util.escapeIdentifier('user]table') // '[user]]table]'
|
|
37
|
+
* util.escapeIdentifier('my.table') // '[my.table]'
|
|
38
|
+
* @param {string} identifier - The identifier to escape.
|
|
39
|
+
* @returns {string} The escaped identifier wrapped in brackets.
|
|
40
|
+
* @throws {Error} If the identifier is not a string or is empty.
|
|
41
|
+
*/
|
|
42
|
+
export function escapeIdentifier(identifier: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Validates and normalizes SQL operators to prevent SQL injection.
|
|
45
|
+
* @example <caption>Examples</caption>
|
|
46
|
+
* util.validateOperator('=') // '='
|
|
47
|
+
* util.validateOperator('like') // 'LIKE'
|
|
48
|
+
* util.validateOperator('>=') // '>='
|
|
49
|
+
* util.validateOperator("' OR '1'='1") // throws Error
|
|
50
|
+
* @param {string} operator - The operator to validate.
|
|
51
|
+
* @returns {string} The validated and normalized operator.
|
|
52
|
+
* @throws {Error} If the operator is not in the allowed list.
|
|
53
|
+
*/
|
|
54
|
+
export function validateOperator(operator: string): string;
|