@openfn/language-motherduck 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/LICENSE +674 -0
- package/LICENSE.LESSER +165 -0
- package/README.md +96 -0
- package/ast.json +1111 -0
- package/configuration-schema.json +27 -0
- package/dist/index.cjs +537 -0
- package/dist/index.js +512 -0
- package/package.json +49 -0
- package/types/Adaptor.d.ts +61 -0
- package/types/index.d.ts +4 -0
- package/types/mock.d.ts +17 -0
- package/types/util.d.ts +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/Adaptor.js
|
|
8
|
+
var Adaptor_exports = {};
|
|
9
|
+
__export(Adaptor_exports, {
|
|
10
|
+
alterState: () => alterState,
|
|
11
|
+
arrayToString: () => arrayToString,
|
|
12
|
+
as: () => as,
|
|
13
|
+
combine: () => combine,
|
|
14
|
+
cursor: () => cursor,
|
|
15
|
+
dataPath: () => dataPath,
|
|
16
|
+
dataValue: () => dataValue,
|
|
17
|
+
dateFns: () => dateFns,
|
|
18
|
+
each: () => each,
|
|
19
|
+
execute: () => execute,
|
|
20
|
+
field: () => field,
|
|
21
|
+
fields: () => fields,
|
|
22
|
+
fn: () => fn,
|
|
23
|
+
fnIf: () => fnIf,
|
|
24
|
+
group: () => group,
|
|
25
|
+
insert: () => insert,
|
|
26
|
+
lastReferenceValue: () => lastReferenceValue,
|
|
27
|
+
map: () => map,
|
|
28
|
+
merge: () => merge,
|
|
29
|
+
query: () => query,
|
|
30
|
+
sourceValue: () => sourceValue,
|
|
31
|
+
util: () => util_exports
|
|
32
|
+
});
|
|
33
|
+
import {
|
|
34
|
+
execute as commonExecute,
|
|
35
|
+
composeNextState
|
|
36
|
+
} from "@openfn/language-common";
|
|
37
|
+
import { expandReferences } from "@openfn/language-common/util";
|
|
38
|
+
import { DuckDBInstance } from "@duckdb/node-api";
|
|
39
|
+
import _ from "lodash";
|
|
40
|
+
|
|
41
|
+
// src/util.js
|
|
42
|
+
var util_exports = {};
|
|
43
|
+
__export(util_exports, {
|
|
44
|
+
convertBigIntToNumber: () => convertBigIntToNumber,
|
|
45
|
+
escapeSqlString: () => escapeSqlString,
|
|
46
|
+
formatSqlValue: () => formatSqlValue,
|
|
47
|
+
queryHandler: () => queryHandler,
|
|
48
|
+
validateSqlIdentifier: () => validateSqlIdentifier
|
|
49
|
+
});
|
|
50
|
+
function validateSqlIdentifier(identifier) {
|
|
51
|
+
if (typeof identifier !== "string") {
|
|
52
|
+
throw new Error("SQL identifier must be a string");
|
|
53
|
+
}
|
|
54
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(identifier)) {
|
|
55
|
+
throw new Error(`Invalid SQL identifier: ${identifier}. Only alphanumeric characters, underscores, and dots are allowed.`);
|
|
56
|
+
}
|
|
57
|
+
const upperIdentifier = identifier.toUpperCase();
|
|
58
|
+
const dangerousPatterns = [
|
|
59
|
+
"DROP",
|
|
60
|
+
"DELETE",
|
|
61
|
+
"INSERT",
|
|
62
|
+
"UPDATE",
|
|
63
|
+
"ALTER",
|
|
64
|
+
"CREATE",
|
|
65
|
+
"EXEC",
|
|
66
|
+
"UNION",
|
|
67
|
+
"SELECT",
|
|
68
|
+
"--",
|
|
69
|
+
"/*",
|
|
70
|
+
"*/",
|
|
71
|
+
";"
|
|
72
|
+
];
|
|
73
|
+
for (const pattern of dangerousPatterns) {
|
|
74
|
+
if (upperIdentifier.includes(pattern)) {
|
|
75
|
+
throw new Error(`SQL identifier contains forbidden pattern: ${pattern}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return identifier;
|
|
79
|
+
}
|
|
80
|
+
function escapeSqlString(value) {
|
|
81
|
+
if (typeof value !== "string") {
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
return value.replace(/'/g, "''");
|
|
85
|
+
}
|
|
86
|
+
function formatSqlValue(value) {
|
|
87
|
+
if (value === null || value === void 0) {
|
|
88
|
+
return "NULL";
|
|
89
|
+
}
|
|
90
|
+
if (typeof value === "string") {
|
|
91
|
+
return `'${escapeSqlString(value)}'`;
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === "boolean") {
|
|
94
|
+
return value ? "TRUE" : "FALSE";
|
|
95
|
+
}
|
|
96
|
+
return value.toString();
|
|
97
|
+
}
|
|
98
|
+
function convertBigIntToNumber(obj) {
|
|
99
|
+
if (typeof obj === "bigint") {
|
|
100
|
+
if (obj <= Number.MAX_SAFE_INTEGER && obj >= Number.MIN_SAFE_INTEGER) {
|
|
101
|
+
return Number(obj);
|
|
102
|
+
} else {
|
|
103
|
+
return obj.toString();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(obj)) {
|
|
107
|
+
return obj.map(convertBigIntToNumber);
|
|
108
|
+
}
|
|
109
|
+
if (obj !== null && typeof obj === "object") {
|
|
110
|
+
if (obj.width !== void 0 && obj.scale !== void 0 && obj.value !== void 0) {
|
|
111
|
+
const scale = Number(obj.scale);
|
|
112
|
+
const value = typeof obj.value === "bigint" ? Number(obj.value) : obj.value;
|
|
113
|
+
return value / Math.pow(10, scale);
|
|
114
|
+
}
|
|
115
|
+
const converted = {};
|
|
116
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
117
|
+
converted[key] = convertBigIntToNumber(value);
|
|
118
|
+
}
|
|
119
|
+
return converted;
|
|
120
|
+
}
|
|
121
|
+
return obj;
|
|
122
|
+
}
|
|
123
|
+
async function queryHandler(connection2, state, sqlQuery, options, composeNextState2) {
|
|
124
|
+
if (!connection2) {
|
|
125
|
+
throw new Error("No active MotherDuck connection found. Ensure you are running within an execute() block.");
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const result = await connection2.runAndReadAll(sqlQuery);
|
|
129
|
+
const rawRows = result.getRowObjects();
|
|
130
|
+
const rows = convertBigIntToNumber(rawRows);
|
|
131
|
+
const nextState = {
|
|
132
|
+
...composeNextState2(state, rows),
|
|
133
|
+
response: {
|
|
134
|
+
rows,
|
|
135
|
+
rowCount: rows.length,
|
|
136
|
+
command: sqlQuery.trim().split(" ")[0].toUpperCase(),
|
|
137
|
+
query: options.writeSql ? sqlQuery : "[query hidden]"
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return nextState;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const errorMessage = `MotherDuck query failed: ${error.message}`;
|
|
143
|
+
console.error(errorMessage);
|
|
144
|
+
console.error("Failed query:", sqlQuery.substring(0, 200) + (sqlQuery.length > 200 ? "..." : ""));
|
|
145
|
+
const enhancedError = new Error(errorMessage);
|
|
146
|
+
enhancedError.originalError = error;
|
|
147
|
+
enhancedError.query = sqlQuery;
|
|
148
|
+
throw enhancedError;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/mock.js
|
|
153
|
+
var mockTables = {};
|
|
154
|
+
function parseSelectLiterals(sql) {
|
|
155
|
+
const result = {};
|
|
156
|
+
const selectMatch = sql.match(/SELECT\s+(.+?)(?:\s+FROM|\s*$)/is);
|
|
157
|
+
if (!selectMatch)
|
|
158
|
+
return result;
|
|
159
|
+
const selectClause = selectMatch[1].trim();
|
|
160
|
+
const columns = [];
|
|
161
|
+
let current = "";
|
|
162
|
+
let inQuotes = false;
|
|
163
|
+
let parenDepth = 0;
|
|
164
|
+
for (let i = 0; i < selectClause.length; i++) {
|
|
165
|
+
const char = selectClause[i];
|
|
166
|
+
if (char === "'" && (i === 0 || selectClause[i - 1] !== "\\")) {
|
|
167
|
+
inQuotes = !inQuotes;
|
|
168
|
+
}
|
|
169
|
+
if (!inQuotes) {
|
|
170
|
+
if (char === "(")
|
|
171
|
+
parenDepth++;
|
|
172
|
+
if (char === ")")
|
|
173
|
+
parenDepth--;
|
|
174
|
+
if (char === "," && parenDepth === 0) {
|
|
175
|
+
columns.push(current.trim());
|
|
176
|
+
current = "";
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
current += char;
|
|
181
|
+
}
|
|
182
|
+
if (current.trim())
|
|
183
|
+
columns.push(current.trim());
|
|
184
|
+
columns.forEach((col) => {
|
|
185
|
+
var _a;
|
|
186
|
+
const stringMatch = col.match(/'([^']*)'\s+(?:as\s+)?(\w+)/i);
|
|
187
|
+
const numberMatch = col.match(/^(\d+)\s+(?:as\s+)?(\w+)/i);
|
|
188
|
+
const funcMatch = col.match(/^(\w+)\s*\([^)]*\)\s+(?:as\s+)?(\w+)/i);
|
|
189
|
+
if (stringMatch) {
|
|
190
|
+
result[stringMatch[2]] = stringMatch[1];
|
|
191
|
+
} else if (numberMatch) {
|
|
192
|
+
result[numberMatch[2]] = parseInt(numberMatch[1]);
|
|
193
|
+
} else if (funcMatch) {
|
|
194
|
+
const funcName = funcMatch[1].toLowerCase();
|
|
195
|
+
const alias = funcMatch[2];
|
|
196
|
+
if (funcName === "count") {
|
|
197
|
+
result[alias] = ((_a = mockTables[Object.keys(mockTables)[0]]) == null ? void 0 : _a.length) || 0;
|
|
198
|
+
} else if (funcName === "current_database") {
|
|
199
|
+
result[alias] = "my_db";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
function createMockConnection() {
|
|
206
|
+
const mockConnection = {
|
|
207
|
+
runAndReadAll: async (sql) => {
|
|
208
|
+
if (/SELECT\s+FROM/i.test(sql) || /WHERE\s+INVALID/i.test(sql)) {
|
|
209
|
+
throw new Error('Parser Error: syntax error at or near "FROM"');
|
|
210
|
+
}
|
|
211
|
+
let mockData = [];
|
|
212
|
+
if (/CREATE TABLE/i.test(sql)) {
|
|
213
|
+
const tableMatch = sql.match(/CREATE TABLE\s+(\w+)/i);
|
|
214
|
+
if (tableMatch) {
|
|
215
|
+
const tableName = tableMatch[1];
|
|
216
|
+
mockTables[tableName] = [];
|
|
217
|
+
}
|
|
218
|
+
mockData = [{ success: true }];
|
|
219
|
+
} else if (/INSERT INTO/i.test(sql)) {
|
|
220
|
+
const tableMatch = sql.match(/INSERT INTO\s+(\w+)/i);
|
|
221
|
+
const valuesMatch = sql.match(/VALUES\s+(.+)/is);
|
|
222
|
+
if (tableMatch && valuesMatch) {
|
|
223
|
+
const tableName = tableMatch[1];
|
|
224
|
+
if (!mockTables[tableName])
|
|
225
|
+
mockTables[tableName] = [];
|
|
226
|
+
const columnsMatch = sql.match(/\(([^)]+)\)\s+VALUES/i);
|
|
227
|
+
const columns = columnsMatch ? columnsMatch[1].split(",").map((c) => c.trim()) : [];
|
|
228
|
+
const valuesSets = valuesMatch[1].match(/\([^)]+\)/g) || [];
|
|
229
|
+
valuesSets.forEach((valueSet) => {
|
|
230
|
+
const values = valueSet.slice(1, -1).split(",").map((v) => {
|
|
231
|
+
v = v.trim();
|
|
232
|
+
if (v.startsWith("'") && v.endsWith("'")) {
|
|
233
|
+
return v.slice(1, -1).replace(/''/g, "'");
|
|
234
|
+
}
|
|
235
|
+
if (v === "NULL")
|
|
236
|
+
return null;
|
|
237
|
+
if (v === "TRUE")
|
|
238
|
+
return true;
|
|
239
|
+
if (v === "FALSE")
|
|
240
|
+
return false;
|
|
241
|
+
if (/^\d+$/.test(v))
|
|
242
|
+
return parseInt(v);
|
|
243
|
+
return v;
|
|
244
|
+
});
|
|
245
|
+
const row = {};
|
|
246
|
+
columns.forEach((col, idx) => {
|
|
247
|
+
row[col] = values[idx];
|
|
248
|
+
});
|
|
249
|
+
mockTables[tableName].push(row);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
mockData = [{ success: true }];
|
|
253
|
+
} else if (/SELECT/i.test(sql)) {
|
|
254
|
+
const fromMatch = sql.match(/FROM\s+(\w+)/i);
|
|
255
|
+
if (fromMatch) {
|
|
256
|
+
const tableName = fromMatch[1];
|
|
257
|
+
const tableData = mockTables[tableName] || [];
|
|
258
|
+
if (/COUNT\s*\(\s*\*\s*\)/i.test(sql)) {
|
|
259
|
+
const aliasMatch = sql.match(/COUNT\s*\(\s*\*\s*\)\s+(?:as\s+)?(\w+)/i);
|
|
260
|
+
const alias = aliasMatch ? aliasMatch[1] : "count";
|
|
261
|
+
mockData = [{ [alias]: tableData.length }];
|
|
262
|
+
} else if (/SELECT\s+\*/i.test(sql)) {
|
|
263
|
+
mockData = [...tableData];
|
|
264
|
+
} else {
|
|
265
|
+
const selectMatch = sql.match(/SELECT\s+(.+?)\s+FROM/is);
|
|
266
|
+
if (selectMatch) {
|
|
267
|
+
const columns = selectMatch[1].split(",").map((c) => c.trim());
|
|
268
|
+
mockData = tableData.map((row) => {
|
|
269
|
+
const newRow = {};
|
|
270
|
+
columns.forEach((col) => {
|
|
271
|
+
if (row[col] !== void 0) {
|
|
272
|
+
newRow[col] = row[col];
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return newRow;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
const literals = parseSelectLiterals(sql);
|
|
281
|
+
if (Object.keys(literals).length === 0) {
|
|
282
|
+
mockData = [];
|
|
283
|
+
} else {
|
|
284
|
+
mockData = [literals];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else if (/UPDATE/i.test(sql)) {
|
|
288
|
+
mockData = [{ success: true }];
|
|
289
|
+
} else if (/DELETE/i.test(sql)) {
|
|
290
|
+
mockData = [{ success: true }];
|
|
291
|
+
} else if (/DROP TABLE/i.test(sql)) {
|
|
292
|
+
const tableMatch = sql.match(/DROP TABLE\s+(?:IF EXISTS\s+)?(\w+)/i);
|
|
293
|
+
if (tableMatch) {
|
|
294
|
+
delete mockTables[tableMatch[1]];
|
|
295
|
+
}
|
|
296
|
+
mockData = [{ success: true }];
|
|
297
|
+
} else {
|
|
298
|
+
mockData = [{ result: "mock_data" }];
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
getRowObjects: () => mockData
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
close: () => {
|
|
305
|
+
Object.keys(mockTables).forEach((key) => delete mockTables[key]);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const mockInstance = {
|
|
309
|
+
connect: async () => mockConnection,
|
|
310
|
+
close: () => {
|
|
311
|
+
Object.keys(mockTables).forEach((key) => delete mockTables[key]);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
return { mockInstance, mockConnection };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/Adaptor.js
|
|
318
|
+
import {
|
|
319
|
+
alterState,
|
|
320
|
+
arrayToString,
|
|
321
|
+
combine,
|
|
322
|
+
cursor,
|
|
323
|
+
dataPath,
|
|
324
|
+
dataValue,
|
|
325
|
+
dateFns,
|
|
326
|
+
each,
|
|
327
|
+
field,
|
|
328
|
+
fields,
|
|
329
|
+
fn,
|
|
330
|
+
fnIf,
|
|
331
|
+
group,
|
|
332
|
+
lastReferenceValue,
|
|
333
|
+
map,
|
|
334
|
+
merge,
|
|
335
|
+
sourceValue,
|
|
336
|
+
as
|
|
337
|
+
} from "@openfn/language-common";
|
|
338
|
+
var instance = null;
|
|
339
|
+
var connection = null;
|
|
340
|
+
function execute(...operations) {
|
|
341
|
+
const initialState = {
|
|
342
|
+
references: [],
|
|
343
|
+
data: null
|
|
344
|
+
};
|
|
345
|
+
return async (state) => {
|
|
346
|
+
try {
|
|
347
|
+
return await commonExecute(
|
|
348
|
+
createConnection,
|
|
349
|
+
...operations,
|
|
350
|
+
disconnect
|
|
351
|
+
)({ ...initialState, ...state });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
disconnect(state);
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
async function createConnection(state) {
|
|
359
|
+
const {
|
|
360
|
+
token,
|
|
361
|
+
database,
|
|
362
|
+
sessionHint,
|
|
363
|
+
testMode = false
|
|
364
|
+
} = state.configuration;
|
|
365
|
+
if (testMode || process.env.NODE_ENV === "test") {
|
|
366
|
+
const { mockInstance, mockConnection } = createMockConnection();
|
|
367
|
+
instance = mockInstance;
|
|
368
|
+
connection = mockConnection;
|
|
369
|
+
return state;
|
|
370
|
+
}
|
|
371
|
+
if (!token) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
"MotherDuck token is required. Please provide a token in the configuration."
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
let databasePath = `md:${database}`;
|
|
377
|
+
if (sessionHint) {
|
|
378
|
+
databasePath += `?session_hint=${sessionHint}`;
|
|
379
|
+
}
|
|
380
|
+
const config = {
|
|
381
|
+
motherduck_token: token
|
|
382
|
+
};
|
|
383
|
+
console.log(`Connecting to MotherDuck cloud database: ${database}`);
|
|
384
|
+
instance = await DuckDBInstance.create(databasePath, config);
|
|
385
|
+
connection = await instance.connect();
|
|
386
|
+
console.log("Connected successfully to MotherDuck");
|
|
387
|
+
return state;
|
|
388
|
+
}
|
|
389
|
+
function disconnect(state) {
|
|
390
|
+
if (connection && typeof connection.close === "function") {
|
|
391
|
+
connection.close();
|
|
392
|
+
}
|
|
393
|
+
if (instance && typeof instance.close === "function") {
|
|
394
|
+
instance.close();
|
|
395
|
+
}
|
|
396
|
+
connection = null;
|
|
397
|
+
instance = null;
|
|
398
|
+
return state;
|
|
399
|
+
}
|
|
400
|
+
function query(sqlQuery, options = {}) {
|
|
401
|
+
return (state) => {
|
|
402
|
+
const [resolvedQuery, resolvedOptions] = expandReferences(
|
|
403
|
+
state,
|
|
404
|
+
sqlQuery,
|
|
405
|
+
options
|
|
406
|
+
);
|
|
407
|
+
return queryHandler(
|
|
408
|
+
connection,
|
|
409
|
+
state,
|
|
410
|
+
resolvedQuery,
|
|
411
|
+
resolvedOptions,
|
|
412
|
+
composeNextState
|
|
413
|
+
);
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function insert(table, records, options = {}) {
|
|
417
|
+
return async (state) => {
|
|
418
|
+
const [resolvedTable, resolvedRecords, resolvedOptions] = expandReferences(
|
|
419
|
+
state,
|
|
420
|
+
table,
|
|
421
|
+
records,
|
|
422
|
+
options
|
|
423
|
+
);
|
|
424
|
+
const batchSize = resolvedOptions.batchSize || 1e3;
|
|
425
|
+
const recordsArray = Array.isArray(resolvedRecords) ? resolvedRecords : [resolvedRecords];
|
|
426
|
+
if (!recordsArray || recordsArray.length === 0) {
|
|
427
|
+
console.log("No records provided; skipping insert.");
|
|
428
|
+
return {
|
|
429
|
+
...state,
|
|
430
|
+
data: { recordsInserted: 0, batches: 0 }
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
validateSqlIdentifier(resolvedTable);
|
|
434
|
+
const totalRecords = recordsArray.length;
|
|
435
|
+
console.log(
|
|
436
|
+
`Preparing to insert ${totalRecords} record${totalRecords !== 1 ? "s" : ""} into:`,
|
|
437
|
+
resolvedTable
|
|
438
|
+
);
|
|
439
|
+
const chunks = _.chunk(recordsArray, batchSize);
|
|
440
|
+
if (chunks.length > 1) {
|
|
441
|
+
console.log(
|
|
442
|
+
`Large dataset detected. Splitting into ${chunks.length} batches of up to ${batchSize} records.`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
let currentState = state;
|
|
446
|
+
let totalInserted = 0;
|
|
447
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
448
|
+
const chunk = chunks[i];
|
|
449
|
+
const batchNumber = i + 1;
|
|
450
|
+
if (chunks.length > 1) {
|
|
451
|
+
console.log(
|
|
452
|
+
`Processing batch ${batchNumber}/${chunks.length}: ${chunk.length} records`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const columns = Object.keys(chunk[0]);
|
|
456
|
+
const columnsList = columns.join(", ");
|
|
457
|
+
columns.forEach((col) => validateSqlIdentifier(col));
|
|
458
|
+
const valuesStrings = chunk.map((record) => {
|
|
459
|
+
const values = columns.map((key) => formatSqlValue(record[key]));
|
|
460
|
+
return `(${values.join(", ")})`;
|
|
461
|
+
});
|
|
462
|
+
const sqlQuery = `INSERT INTO ${resolvedTable} (${columnsList}) VALUES ${valuesStrings.join(
|
|
463
|
+
", "
|
|
464
|
+
)}`;
|
|
465
|
+
currentState = await queryHandler(
|
|
466
|
+
connection,
|
|
467
|
+
currentState,
|
|
468
|
+
sqlQuery,
|
|
469
|
+
resolvedOptions,
|
|
470
|
+
composeNextState
|
|
471
|
+
);
|
|
472
|
+
totalInserted += chunk.length;
|
|
473
|
+
}
|
|
474
|
+
if (chunks.length > 1) {
|
|
475
|
+
console.log(
|
|
476
|
+
`Successfully inserted ${totalInserted} records in ${chunks.length} batches.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
...currentState,
|
|
481
|
+
data: { recordsInserted: totalInserted, batches: chunks.length }
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/index.js
|
|
487
|
+
var src_default = Adaptor_exports;
|
|
488
|
+
export {
|
|
489
|
+
alterState,
|
|
490
|
+
arrayToString,
|
|
491
|
+
as,
|
|
492
|
+
combine,
|
|
493
|
+
cursor,
|
|
494
|
+
dataPath,
|
|
495
|
+
dataValue,
|
|
496
|
+
dateFns,
|
|
497
|
+
src_default as default,
|
|
498
|
+
each,
|
|
499
|
+
execute,
|
|
500
|
+
field,
|
|
501
|
+
fields,
|
|
502
|
+
fn,
|
|
503
|
+
fnIf,
|
|
504
|
+
group,
|
|
505
|
+
insert,
|
|
506
|
+
lastReferenceValue,
|
|
507
|
+
map,
|
|
508
|
+
merge,
|
|
509
|
+
query,
|
|
510
|
+
sourceValue,
|
|
511
|
+
util_exports as util
|
|
512
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openfn/language-motherduck",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenFn MotherDuck cloud database adaptor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"require": "./dist/index.cjs"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"author": "Aseidas Blauvelt <ablauvelt@verasolutions.org> (Vera Solutions)",
|
|
15
|
+
"license": "LGPLv3",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/",
|
|
18
|
+
"types/",
|
|
19
|
+
"ast.json",
|
|
20
|
+
"configuration-schema.json"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@duckdb/node-api": "^1.3.2-alpha.25",
|
|
24
|
+
"lodash": "^4.17.21",
|
|
25
|
+
"@openfn/language-common": "3.1.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"assertion-error": "2.0.0",
|
|
29
|
+
"chai": "4.3.6",
|
|
30
|
+
"deep-eql": "4.1.1",
|
|
31
|
+
"mocha": "^10.7.3",
|
|
32
|
+
"rimraf": "3.0.2",
|
|
33
|
+
"undici": "^5.22.1"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/openfn/adaptors.git"
|
|
38
|
+
},
|
|
39
|
+
"types": "types/index.d.ts",
|
|
40
|
+
"main": "dist/index.cjs",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "pnpm clean && build-adaptor motherduck",
|
|
43
|
+
"test": "mocha --experimental-specifier-resolution=node --no-warnings",
|
|
44
|
+
"test:watch": "mocha -w --experimental-specifier-resolution=node --no-warnings",
|
|
45
|
+
"clean": "rimraf dist types docs",
|
|
46
|
+
"pack": "pnpm pack --pack-destination ../../dist",
|
|
47
|
+
"lint": "eslint src"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a sequence of operations against MotherDuck cloud database.
|
|
3
|
+
* Wraps `@openfn/language-common/execute`, and prepends initial state for MotherDuck.
|
|
4
|
+
* @example
|
|
5
|
+
* execute(
|
|
6
|
+
* query('SELECT * FROM my_table'),
|
|
7
|
+
* insert('users', { name: 'John', age: 30 })
|
|
8
|
+
* )(state)
|
|
9
|
+
* @public
|
|
10
|
+
* @function
|
|
11
|
+
* @param {...Operation} operations - Operations to be performed.
|
|
12
|
+
* @returns {Operation}
|
|
13
|
+
* @state {Array} references - Array of previous operation results
|
|
14
|
+
* @state {*} data - Data from the last operation
|
|
15
|
+
*/
|
|
16
|
+
export function execute(...operations: Operation[]): Operation;
|
|
17
|
+
/**
|
|
18
|
+
* Execute a SQL query against the MotherDuck database.
|
|
19
|
+
* @public
|
|
20
|
+
* @example <caption>Simple query</caption>
|
|
21
|
+
* query('SELECT * FROM users WHERE age > 18')
|
|
22
|
+
* @example <caption>Query with SQL logging</caption>
|
|
23
|
+
* query('SELECT * FROM orders', { writeSql: true })
|
|
24
|
+
* @function
|
|
25
|
+
* @param {string} sqlQuery - SQL query string
|
|
26
|
+
* @param {object} [options] - Query execution options
|
|
27
|
+
* @param {boolean} [options.writeSql=false] - Include full SQL in response.query (default: false, hides query for security)
|
|
28
|
+
* @returns {Operation}
|
|
29
|
+
* @state {Array} data - Query results as array of row objects
|
|
30
|
+
* @state {object} response - Metadata including rowCount, command, and query
|
|
31
|
+
*/
|
|
32
|
+
export function query(sqlQuery: string, options?: {
|
|
33
|
+
writeSql?: boolean;
|
|
34
|
+
}): Operation;
|
|
35
|
+
/**
|
|
36
|
+
* Insert one or more records into a MotherDuck table with automatic batching.
|
|
37
|
+
* Large datasets are automatically split into batches for optimal performance.
|
|
38
|
+
* @public
|
|
39
|
+
* @example <caption>Insert a single record</caption>
|
|
40
|
+
* insert('users', { name: 'John', age: 30, email: 'john@example.com' })
|
|
41
|
+
* @example <caption>Insert multiple records</caption>
|
|
42
|
+
* insert('users', [
|
|
43
|
+
* { name: 'John', age: 30 },
|
|
44
|
+
* { name: 'Jane', age: 25 }
|
|
45
|
+
* ])
|
|
46
|
+
* @example <caption>Insert with custom batch size</caption>
|
|
47
|
+
* insert('users', records, { batchSize: 500 })
|
|
48
|
+
* @function
|
|
49
|
+
* @param {string} table - Target table name
|
|
50
|
+
* @param {object|Array} records - A single record object or array of records
|
|
51
|
+
* @param {object} [options] - Insert options
|
|
52
|
+
* @param {number} [options.batchSize=1000] - Number of records per batch
|
|
53
|
+
* @returns {Operation}
|
|
54
|
+
* @state {object} data - Metadata including recordsInserted and batches
|
|
55
|
+
*/
|
|
56
|
+
export function insert(table: string, records: object | any[], options?: {
|
|
57
|
+
batchSize?: number;
|
|
58
|
+
}): Operation;
|
|
59
|
+
export { util };
|
|
60
|
+
import * as util from "./util.js";
|
|
61
|
+
export { alterState, arrayToString, combine, cursor, dataPath, dataValue, dateFns, each, field, fields, fn, fnIf, group, lastReferenceValue, map, merge, sourceValue, as } from "@openfn/language-common";
|
package/types/index.d.ts
ADDED
package/types/mock.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function createMockConnection(): {
|
|
2
|
+
mockInstance: {
|
|
3
|
+
connect: () => Promise<{
|
|
4
|
+
runAndReadAll: (sql: any) => Promise<{
|
|
5
|
+
getRowObjects: () => any[];
|
|
6
|
+
}>;
|
|
7
|
+
close: () => void;
|
|
8
|
+
}>;
|
|
9
|
+
close: () => void;
|
|
10
|
+
};
|
|
11
|
+
mockConnection: {
|
|
12
|
+
runAndReadAll: (sql: any) => Promise<{
|
|
13
|
+
getRowObjects: () => any[];
|
|
14
|
+
}>;
|
|
15
|
+
close: () => void;
|
|
16
|
+
};
|
|
17
|
+
};
|
package/types/util.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate SQL identifier (table names, column names, etc.)
|
|
3
|
+
* @public
|
|
4
|
+
* @function
|
|
5
|
+
* @param {string} identifier - SQL identifier to validate
|
|
6
|
+
* @returns {string} - Validated identifier
|
|
7
|
+
* @throws {Error} - If identifier contains dangerous characters
|
|
8
|
+
*/
|
|
9
|
+
export function validateSqlIdentifier(identifier: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Escape SQL string values to prevent SQL injection
|
|
12
|
+
* @public
|
|
13
|
+
* @function
|
|
14
|
+
* @param {string} value - String value to escape
|
|
15
|
+
* @returns {string} - Escaped string value
|
|
16
|
+
*/
|
|
17
|
+
export function escapeSqlString(value: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Format a value for SQL insertion
|
|
20
|
+
* @public
|
|
21
|
+
* @function
|
|
22
|
+
* @param {any} value - Value to format
|
|
23
|
+
* @returns {string} - Formatted SQL value
|
|
24
|
+
*/
|
|
25
|
+
export function formatSqlValue(value: any): string;
|
|
26
|
+
/**
|
|
27
|
+
* Convert BigInt and DECIMAL values to regular numbers for JSON serialization
|
|
28
|
+
* @private
|
|
29
|
+
* @function
|
|
30
|
+
* @param {any} obj - Object to convert
|
|
31
|
+
* @returns {any} - Object with BigInt and DECIMAL values converted to numbers
|
|
32
|
+
*/
|
|
33
|
+
export function convertBigIntToNumber(obj: any): any;
|
|
34
|
+
/**
|
|
35
|
+
* Helper function to handle query execution
|
|
36
|
+
* @private
|
|
37
|
+
* @function
|
|
38
|
+
* @param {object} connection - Active database connection
|
|
39
|
+
* @param {object} state - Runtime state
|
|
40
|
+
* @param {string} sqlQuery - SQL query to execute
|
|
41
|
+
* @param {object} options - Query options
|
|
42
|
+
* @param {Function} composeNextState - State composition function
|
|
43
|
+
* @returns {Promise}
|
|
44
|
+
*/
|
|
45
|
+
export function queryHandler(connection: object, state: object, sqlQuery: string, options: object, composeNextState: Function): Promise<any>;
|