@xeplr/actions 1.1.22 → 1.1.24
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/README.md +23 -2
- package/index.js +2 -0
- package/lib/builtins/file/upload.js +4 -0
- package/lib/drivers/db/duckdb.js +9 -2
- package/lib/drivers/db/internalTables.js +31 -0
- package/lib/drivers/db/mssql.js +34 -2
- package/lib/drivers/db/mysql.js +31 -2
- package/lib/drivers/db/postgres.js +31 -2
- package/lib/drivers/db/staging.js +70 -0
- package/lib/formats/index.js +22 -0
- package/lib/uploader/index.js +96 -1
- package/lib/uploader/meta-store-knex.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
117
117
|
| `dbListViews` | `db-list-views` | `{ views }`. |
|
|
118
118
|
| `dbListProcedures` | `db-list-procedures` | `{ procedures }` (functions excluded). |
|
|
119
119
|
| `dbListColumns` | `db-list-columns` | `{ columns }` — `name`, `dataType`, `udtName` and `maxLength` (characters; `-1` is SQL Server `(max)`, `null` when it has none) for one table. |
|
|
120
|
-
| `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
|
|
120
|
+
| `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`) as a New table, Append or Replace (`mode`, see [Import modes](#import-modes)). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
|
|
121
121
|
| `sendEmail` | `email-send` | Send one message. See [Email](#email). |
|
|
122
122
|
| `emailRead` | `email-read` | List messages, newest first, with `unread_only`, `limit` (1–1000, default 50), `since`, `from_equals`, `from_contains`, `subject_contains`, `body: 'full' \| 'text' \| 'none'`, optional NDJSON spooling. |
|
|
123
123
|
| `emailMove` | `email-move` | Move a message (`messageId`, `folder`) to `toFolder`. |
|
|
@@ -331,6 +331,27 @@ With `staged: true` a load **never writes into its target while it runs**. Batch
|
|
|
331
331
|
|
|
332
332
|
A driver opts in by implementing `prepareStaging`, `mergeStaged`, `dropStaging` and `committedMovement`; `upload` refuses `staged` for one that does not. A driver that sets `stagesUpserts: true` (DuckDB) has every upsert staged, asked or not.
|
|
333
333
|
|
|
334
|
+
### Import modes
|
|
335
|
+
|
|
336
|
+
`mode` says what the target must be, checked before a row is read:
|
|
337
|
+
|
|
338
|
+
| mode | the table | then |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| `'new'` | must not exist | created from the file. One that exists is refused ("already exists. Choose Append … or Replace …"), nothing written |
|
|
341
|
+
| `'append'` | must exist | the rows are added. A name that matches no table is refused: a typo is not a new table |
|
|
342
|
+
| `'replace'` | must exist | **staged**: the rows go to `<table>__xeplr_staging` while the import runs; when all are in, one transaction empties the table and moves them in. Stopped, failed or killed before that, the table is as it was. `merged` is `{ inserted, replaced }`, `replaced` being the rows it held before. No `primaryKeys` (that is an upsert), no `replaceFrom` |
|
|
343
|
+
| omitted | | created if missing, else appended (as before modes existed) |
|
|
344
|
+
|
|
345
|
+
A refused target throws with `code: 'TARGET_REFUSED'`. The mode is recorded on the import (`import_meta.meta.mode`), and **a Replace cannot be rolled back**: deleting its rows would leave the table empty, not as it was, so `uploader.rollback` refuses it.
|
|
346
|
+
|
|
347
|
+
On Postgres, MySQL and SQL Server the staging table is shaped like the target (`lib/drivers/db/staging.js`; each driver gives only how to create it and how to run a transaction), so a row the target refuses is refused while staged and recorded in the error table like any other. Postgres copies NOT NULL, defaults and CHECKs (`LIKE … INCLUDING DEFAULTS INCLUDING CONSTRAINTS`); MySQL copies everything (`CREATE TABLE … LIKE`); SQL Server copies columns, NULL-ness and identity but not CHECKs (`SELECT TOP 0 * INTO`), so there a row only a CHECK refuses fails the final move, which leaves the table as it was. These three stage only a whole-table Replace (`stagedReplaceOnly`); DuckDB stages upserts and range replaces too.
|
|
348
|
+
|
|
349
|
+
**Before loading: the file against the table.** `uploader.compareToTable({ driver, connection, targetTable, columns })` → `{ exists, matched, extra, missing: [{ name, required }] }`, matched by exact name, the framework's columns left out. `extra` (in the file, not the table) means the import will be refused; `missing` columns are left empty, and `required` ones (no empty value, no default) mean every row will be refused. `formats.columnsOf(format, filePath, { sheet? })` gives a file's columns from its first row. Every driver's `getTableSchema` now says `nullable` and `hasDefault` (an identity or auto-increment counts as a default).
|
|
350
|
+
|
|
351
|
+
### The uploader's own tables
|
|
352
|
+
|
|
353
|
+
Beside the tables it loads, the uploader makes three of its own: `<table>_import_errors` (refused rows), `<table>__xeplr_staging` (a staged load in progress) and, in a DuckDB file, `__xeplr_loads` (its ledger). `isInternalTable(name)` (also accepts a listing row `{ name }`) says whether a table is one of these, so an app listing a database's tables for people to pick from can leave them out. They still exist and are read where they belong.
|
|
354
|
+
|
|
334
355
|
### A written DuckDB file keeps no index
|
|
335
356
|
|
|
336
357
|
DuckDB 1.5.4–1.5.6 has a bug ([duckdb/duckdb#26106](https://github.com/duckdb/duckdb/issues/26106), open upstream): when a process dies with rows still only in the WAL, the next open replays them into the table and the next checkpoint drops them from every index on it. The first `DELETE` that reaches one fails with *"Failed to delete all rows from index"*, which is **FATAL**: the whole file is unusable until the process restarts. Any index, unique or not. A table with no index is unaffected.
|
|
@@ -456,7 +477,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
456
477
|
|
|
457
478
|
| needs nothing | needs a database |
|
|
458
479
|
|---|---|
|
|
459
|
-
| `test/formats/pasted-rows.test.js` (its last case loads into Postgres when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, else skipped), `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, which needs `@xeplr/db`: a peer dependency, installed for development) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js` (also honours `PG_HOST`/`PG_PORT`/`PG_USER`), `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
480
|
+
| `test/import-modes.test.js` (DuckDB in memory; its Postgres cases run when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, its MySQL cases when `MYSQL_HOST`/`MYSQL_PORT`/`MYSQL_USER`/`MYSQL_PASSWORD` are), `test/formats/pasted-rows.test.js` (its last case loads into Postgres when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, else skipped), `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, which needs `@xeplr/db`: a peer dependency, installed for development) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js` (also honours `PG_HOST`/`PG_PORT`/`PG_USER`), `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
460
481
|
| `test/list-columns-length.test.js` (`XEPLR_TEST_PG_URL`, `MYSQL_TEST_URL`, `MSSQL_TEST_URL`; each skipped when unset), `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/duckdb-index-guard.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
|
|
461
482
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
462
483
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
package/index.js
CHANGED
|
@@ -37,6 +37,8 @@ module.exports = {
|
|
|
37
37
|
// their own — e.g. a UI wizard showing inferred columns from a CSV sample
|
|
38
38
|
// before actually committing to an import).
|
|
39
39
|
uploader: uploader,
|
|
40
|
+
// The uploader's own tables (_import_errors, __xeplr_staging, __xeplr_loads), to leave out of table pickers.
|
|
41
|
+
isInternalTable: require('./lib/drivers/db/internalTables').isInternalTable,
|
|
40
42
|
inferColumns: uploader.inferColumns,
|
|
41
43
|
streaming: streaming,
|
|
42
44
|
drivers: { db: dbDrivers, file: fileDrivers },
|
|
@@ -43,6 +43,9 @@ module.exports = {
|
|
|
43
43
|
{ name: 'dbConnection', type: 'object', required: true, order: 11 },
|
|
44
44
|
{ name: 'targetTable', type: 'string', required: true, order: 12 },
|
|
45
45
|
{ name: 'primaryKeys', type: 'array', order: 13, group: 'Advanced' },
|
|
46
|
+
{ name: 'mode', type: 'string', order: 14, group: 'Advanced',
|
|
47
|
+
description: "What the target must be: 'new' (refused if it exists), 'append' (it must exist) or 'replace' " +
|
|
48
|
+
"(it must exist; its rows are replaced in one transaction once every row is in). Omitted: created if missing, else appended." },
|
|
46
49
|
|
|
47
50
|
// CONTROL
|
|
48
51
|
{ name: 'movementId', type: 'string', order: 20, group: 'Advanced' },
|
|
@@ -111,6 +114,7 @@ module.exports = {
|
|
|
111
114
|
connection: pool,
|
|
112
115
|
targetTable: input.targetTable,
|
|
113
116
|
primaryKeys: (input.primaryKeys && input.primaryKeys.length) ? input.primaryKeys : null,
|
|
117
|
+
mode: input.mode || null,
|
|
114
118
|
movementId: movementId,
|
|
115
119
|
queue: queue,
|
|
116
120
|
connectionName: input.targetTable,
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -611,11 +611,12 @@ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
|
|
|
611
611
|
// 'public' here returns an empty column list for a table that plainly exists.
|
|
612
612
|
async function getTableSchema(pool, tableName, schema) {
|
|
613
613
|
var r = await query(pool,
|
|
614
|
-
'SELECT column_name, data_type FROM information_schema.columns ' +
|
|
614
|
+
'SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns ' +
|
|
615
615
|
'WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position',
|
|
616
616
|
[schema || 'main', tableName]);
|
|
617
617
|
return r.rows.map(function(row) {
|
|
618
|
-
return { name: row.column_name, dataType: row.data_type, udtName: row.data_type
|
|
618
|
+
return { name: row.column_name, dataType: row.data_type, udtName: row.data_type,
|
|
619
|
+
nullable: row.is_nullable === 'YES', hasDefault: row.column_default != null };
|
|
619
620
|
});
|
|
620
621
|
}
|
|
621
622
|
|
|
@@ -913,6 +914,12 @@ async function mergeStaged(pool, opts) {
|
|
|
913
914
|
// away from it (a DATE read as UTC midnight here, compared at the
|
|
914
915
|
// source's local midnight there) — and a day deleted but never read
|
|
915
916
|
// back is a day lost. Nothing staged: MIN is NULL, nothing is deleted.
|
|
917
|
+
// THE WHOLE TABLE REPLACED: an import in Replace mode. Its rows go and
|
|
918
|
+
// the staged ones take their place, in this same transaction.
|
|
919
|
+
if (opts.replaceAll) {
|
|
920
|
+
var all = await conn.run('DELETE FROM ' + target);
|
|
921
|
+
replaced = Number(all.rowsChanged) || 0;
|
|
922
|
+
}
|
|
916
923
|
if (opts.replaceFrom) {
|
|
917
924
|
var col = quoteIdent(opts.replaceFrom.column);
|
|
918
925
|
var cut = await conn.run('DELETE FROM ' + target + ' WHERE ' + col + ' >= (SELECT MIN(' + col + ') FROM ' + staging + ')');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// THE TABLES THE UPLOADER MAKES FOR ITSELF, beside the tables it loads:
|
|
2
|
+
//
|
|
3
|
+
// <table>_import_errors rows the database refused (errorRows.js)
|
|
4
|
+
// <table>__xeplr_staging a staged load's rows until they move in (staging.js, duckdb.js)
|
|
5
|
+
// __xeplr_loads a DuckDB file's ledger of staged loads (duckdb.js)
|
|
6
|
+
//
|
|
7
|
+
// They are the uploader's bookkeeping, not data anybody models: an app that
|
|
8
|
+
// lists a database's tables for people to pick from (a dataset builder, a
|
|
9
|
+
// cube's source picker) leaves them out with isInternalTable(). They still
|
|
10
|
+
// exist and are read where they belong (an import's dropped rows).
|
|
11
|
+
|
|
12
|
+
var ERRORS_SUFFIX = '_import_errors';
|
|
13
|
+
var STAGING_SUFFIX = require('./staging').SUFFIX;
|
|
14
|
+
var LOADS_LEDGER = '__xeplr_loads';
|
|
15
|
+
|
|
16
|
+
function endsWith(s, suffix) {
|
|
17
|
+
return s.length > suffix.length && s.slice(-suffix.length) === suffix;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether a table is one the uploader made for itself. Case as the database reports it. */
|
|
21
|
+
function isInternalTable(name) {
|
|
22
|
+
var s = String(name == null ? '' : (typeof name === 'object' ? name.name || name.table || '' : name));
|
|
23
|
+
return endsWith(s, ERRORS_SUFFIX) || endsWith(s, STAGING_SUFFIX) || s === LOADS_LEDGER;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
isInternalTable: isInternalTable,
|
|
28
|
+
ERRORS_SUFFIX: ERRORS_SUFFIX,
|
|
29
|
+
STAGING_SUFFIX: STAGING_SUFFIX,
|
|
30
|
+
LOADS_LEDGER: LOADS_LEDGER
|
|
31
|
+
};
|
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -303,7 +303,8 @@ function coerceValue(v, type) {
|
|
|
303
303
|
|
|
304
304
|
async function getTableSchema(pool, tableName, schema) {
|
|
305
305
|
var r = await query(pool,
|
|
306
|
-
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
|
|
306
|
+
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, CHARACTER_MAXIMUM_LENGTH AS maxLength, IS_NULLABLE AS isNullable, ' +
|
|
307
|
+
'COLUMN_DEFAULT AS columnDefault, COLUMNPROPERTY(OBJECT_ID(QUOTENAME(TABLE_SCHEMA) + \'.\' + QUOTENAME(TABLE_NAME)), COLUMN_NAME, \'IsIdentity\') AS isIdentity ' +
|
|
307
308
|
'FROM INFORMATION_SCHEMA.COLUMNS ' +
|
|
308
309
|
'WHERE TABLE_NAME = @p0 AND (@p1 IS NULL OR TABLE_SCHEMA = @p1) ' +
|
|
309
310
|
'ORDER BY ORDINAL_POSITION',
|
|
@@ -311,7 +312,8 @@ async function getTableSchema(pool, tableName, schema) {
|
|
|
311
312
|
);
|
|
312
313
|
return r.rows.map(function(row) {
|
|
313
314
|
// maxLength -1 is (max): nvarchar(max), varchar(max), varbinary(max).
|
|
314
|
-
return { name: row.name, dataType: row.dataType, udtName: row.dataType, maxLength: row.maxLength == null ? null : Number(row.maxLength)
|
|
315
|
+
return { name: row.name, dataType: row.dataType, udtName: row.dataType, maxLength: row.maxLength == null ? null : Number(row.maxLength),
|
|
316
|
+
nullable: row.isNullable === 'YES', hasDefault: row.columnDefault != null || Number(row.isIdentity) === 1 };
|
|
315
317
|
});
|
|
316
318
|
}
|
|
317
319
|
|
|
@@ -450,11 +452,41 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
450
452
|
return result;
|
|
451
453
|
}
|
|
452
454
|
|
|
455
|
+
// STAGED REPLACE — see staging.js. SELECT … INTO copies the columns, their
|
|
456
|
+
// NULL-ness and the identity, but not CHECK or DEFAULT constraints: a row only
|
|
457
|
+
// a CHECK refuses fails the final move, which then leaves the target as it was.
|
|
458
|
+
var staged = require('./staging').stagingFor({
|
|
459
|
+
name: 'mssql', quoteIdent: quoteIdent, query: query,
|
|
460
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
461
|
+
createStagingSql: function(t, s) { return ['SELECT TOP 0 * INTO ' + s + ' FROM ' + t]; },
|
|
462
|
+
transaction: async function(pool, fn) {
|
|
463
|
+
var sql = require('mssql');
|
|
464
|
+
var tx = new sql.Transaction(pool);
|
|
465
|
+
await tx.begin();
|
|
466
|
+
try {
|
|
467
|
+
var out = await fn(async function(text) {
|
|
468
|
+
var r = await new sql.Request(tx).query(text);
|
|
469
|
+
return (r.rowsAffected || []).reduce(function(a, b) { return a + b; }, 0);
|
|
470
|
+
});
|
|
471
|
+
await tx.commit();
|
|
472
|
+
return out;
|
|
473
|
+
} catch (err) {
|
|
474
|
+
await tx.rollback().catch(function() {});
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
453
480
|
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
454
481
|
var errorRows = require('./errorRows');
|
|
455
482
|
var placeholders = require('./procedure').placeholders;
|
|
456
483
|
|
|
457
484
|
module.exports = {
|
|
485
|
+
prepareStaging: staged.prepareStaging,
|
|
486
|
+
dropStaging: staged.dropStaging,
|
|
487
|
+
mergeStaged: staged.mergeStaged,
|
|
488
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
489
|
+
stagedReplaceOnly: true,
|
|
458
490
|
requires: ['mssql'],
|
|
459
491
|
maxParams: MAX_PARAMS,
|
|
460
492
|
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -302,14 +302,16 @@ function coerceValue(v, type) {
|
|
|
302
302
|
|
|
303
303
|
async function getTableSchema(pool, tableName, schema) {
|
|
304
304
|
var r = await query(pool,
|
|
305
|
-
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
|
|
305
|
+
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName, CHARACTER_MAXIMUM_LENGTH AS maxLength, ' +
|
|
306
|
+
'IS_NULLABLE AS isNullable, COLUMN_DEFAULT AS columnDefault, EXTRA AS extra ' +
|
|
306
307
|
'FROM information_schema.COLUMNS ' +
|
|
307
308
|
'WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? ' +
|
|
308
309
|
'ORDER BY ORDINAL_POSITION',
|
|
309
310
|
[schema || null, tableName]
|
|
310
311
|
);
|
|
311
312
|
return r.rows.map(function(row) {
|
|
312
|
-
return { name: row.name, dataType: row.dataType, udtName: row.udtName, maxLength: row.maxLength == null ? null : Number(row.maxLength)
|
|
313
|
+
return { name: row.name, dataType: row.dataType, udtName: row.udtName, maxLength: row.maxLength == null ? null : Number(row.maxLength),
|
|
314
|
+
nullable: row.isNullable === 'YES', hasDefault: row.columnDefault != null || /auto_increment|DEFAULT_GENERATED/i.test(row.extra || '') };
|
|
313
315
|
});
|
|
314
316
|
}
|
|
315
317
|
|
|
@@ -451,11 +453,38 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
451
453
|
return result;
|
|
452
454
|
}
|
|
453
455
|
|
|
456
|
+
// STAGED REPLACE — see staging.js. CREATE TABLE … LIKE copies the columns,
|
|
457
|
+
// NOT NULL, defaults, indexes and (8.0.16+) CHECK constraints.
|
|
458
|
+
var staged = require('./staging').stagingFor({
|
|
459
|
+
name: 'mysql', quoteIdent: quoteIdent, query: query,
|
|
460
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
461
|
+
createStagingSql: function(t, s) { return ['CREATE TABLE ' + s + ' LIKE ' + t]; },
|
|
462
|
+
transaction: async function(pool, fn) {
|
|
463
|
+
var conn = await pool.getConnection();
|
|
464
|
+
try {
|
|
465
|
+
await conn.beginTransaction();
|
|
466
|
+
var out = await fn(async function(sql) { var r = await conn.query(sql); return (r[0] && r[0].affectedRows) || 0; });
|
|
467
|
+
await conn.commit();
|
|
468
|
+
return out;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
await conn.rollback().catch(function() {});
|
|
471
|
+
throw err;
|
|
472
|
+
} finally {
|
|
473
|
+
conn.release();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
454
478
|
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
455
479
|
var errorRows = require('./errorRows');
|
|
456
480
|
var placeholders = require('./procedure').placeholders;
|
|
457
481
|
|
|
458
482
|
module.exports = {
|
|
483
|
+
prepareStaging: staged.prepareStaging,
|
|
484
|
+
dropStaging: staged.dropStaging,
|
|
485
|
+
mergeStaged: staged.mergeStaged,
|
|
486
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
487
|
+
stagedReplaceOnly: true,
|
|
459
488
|
requires: ['mysql2'],
|
|
460
489
|
maxParams: MAX_PARAMS,
|
|
461
490
|
|
|
@@ -289,7 +289,7 @@ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
|
|
|
289
289
|
// in ordinal-position order. Empty array if the table doesn't exist.
|
|
290
290
|
async function getTableSchema(pool, tableName, schema) {
|
|
291
291
|
var r = await pool.query(
|
|
292
|
-
"SELECT column_name, data_type, udt_name, character_maximum_length " +
|
|
292
|
+
"SELECT column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default, is_identity " +
|
|
293
293
|
"FROM information_schema.columns " +
|
|
294
294
|
"WHERE table_schema = $2 AND table_name = $1 " +
|
|
295
295
|
"ORDER BY ordinal_position",
|
|
@@ -297,7 +297,8 @@ async function getTableSchema(pool, tableName, schema) {
|
|
|
297
297
|
);
|
|
298
298
|
return r.rows.map(function(row) {
|
|
299
299
|
return { name: row.column_name, dataType: row.data_type, udtName: row.udt_name,
|
|
300
|
-
maxLength: row.character_maximum_length == null ? null : Number(row.character_maximum_length)
|
|
300
|
+
maxLength: row.character_maximum_length == null ? null : Number(row.character_maximum_length),
|
|
301
|
+
nullable: row.is_nullable === 'YES', hasDefault: row.column_default != null || row.is_identity === 'YES' };
|
|
301
302
|
});
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -403,11 +404,39 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
|
403
404
|
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
// STAGED REPLACE — see staging.js. LIKE brings NOT NULL, defaults and CHECKs,
|
|
408
|
+
// so a row the target would refuse is refused while it is staged; indexes stay
|
|
409
|
+
// behind (a whole-table replace matches no keys).
|
|
410
|
+
var staged = require('./staging').stagingFor({
|
|
411
|
+
name: 'postgres', quoteIdent: quoteIdent, query: query,
|
|
412
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
413
|
+
createStagingSql: function(t, s) { return ['CREATE TABLE ' + s + ' (LIKE ' + t + ' INCLUDING DEFAULTS INCLUDING CONSTRAINTS)']; },
|
|
414
|
+
transaction: async function(pool, fn) {
|
|
415
|
+
var client = await pool.connect();
|
|
416
|
+
try {
|
|
417
|
+
await client.query('BEGIN');
|
|
418
|
+
var out = await fn(async function(sql) { return (await client.query(sql)).rowCount || 0; });
|
|
419
|
+
await client.query('COMMIT');
|
|
420
|
+
return out;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
await client.query('ROLLBACK').catch(function() {});
|
|
423
|
+
throw err;
|
|
424
|
+
} finally {
|
|
425
|
+
client.release();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
406
430
|
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
407
431
|
var errorRows = require('./errorRows');
|
|
408
432
|
var placeholders = require('./procedure').placeholders;
|
|
409
433
|
|
|
410
434
|
module.exports = {
|
|
435
|
+
prepareStaging: staged.prepareStaging,
|
|
436
|
+
dropStaging: staged.dropStaging,
|
|
437
|
+
mergeStaged: staged.mergeStaged,
|
|
438
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
439
|
+
stagedReplaceOnly: true,
|
|
411
440
|
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
412
441
|
requires: ['pg'],
|
|
413
442
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A STAGED REPLACE on the server databases (Postgres, MySQL, SQL Server).
|
|
2
|
+
//
|
|
3
|
+
// An import that replaces a table must never leave it empty because the
|
|
4
|
+
// import failed. So the rows are written to `<target>__xeplr_staging` while
|
|
5
|
+
// the import runs, and only when every one is in does ONE transaction empty
|
|
6
|
+
// the target and move them across. Stopped, failed or killed before that:
|
|
7
|
+
// the target is exactly as it was, and the staging table is all there is to
|
|
8
|
+
// drop (the next load drops a leftover one anyway).
|
|
9
|
+
//
|
|
10
|
+
// The staging table is shaped like the target, with its NOT NULL and (where
|
|
11
|
+
// the dialect copies them) CHECK constraints — so a row the target would
|
|
12
|
+
// refuse is refused while it is staged, one row at a time, and recorded in
|
|
13
|
+
// `<target>_import_errors` like any other, rather than failing the move.
|
|
14
|
+
//
|
|
15
|
+
// Each driver supplies only what differs: how to make the staging table and
|
|
16
|
+
// how to run statements in one transaction. DuckDB keeps its own (it also
|
|
17
|
+
// upserts and replaces ranges there — see its "staged loads" section).
|
|
18
|
+
|
|
19
|
+
var SUFFIX = '__xeplr_staging';
|
|
20
|
+
var MOVEMENT_COL = '__xeplr_movement_id__';
|
|
21
|
+
|
|
22
|
+
function stagingTableFor(tableName) { return tableName + SUFFIX; }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} dialect - { name, quoteIdent, query(pool, sql), createStagingSql(target, staging) → string[],
|
|
26
|
+
* transaction(pool, fn(run)) where run(sql) → rows affected }
|
|
27
|
+
*/
|
|
28
|
+
function stagingFor(dialect) {
|
|
29
|
+
var q = dialect.quoteIdent;
|
|
30
|
+
|
|
31
|
+
async function prepareStaging(pool, tableName) {
|
|
32
|
+
var staging = stagingTableFor(tableName);
|
|
33
|
+
await dropStaging(pool, tableName);
|
|
34
|
+
var sqls = dialect.createStagingSql(q(tableName), q(staging));
|
|
35
|
+
for (var i = 0; i < sqls.length; i++) await dialect.query(pool, sqls[i]);
|
|
36
|
+
return staging;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function dropStaging(pool, tableName) {
|
|
40
|
+
await dialect.query(pool, dialect.dropTableSql(q(stagingTableFor(tableName))));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replace the target's rows with the staged ones, in one transaction, then
|
|
45
|
+
* drop the staging table. Only a whole-table replace: upserts and range
|
|
46
|
+
* replaces are staged on DuckDB alone.
|
|
47
|
+
*
|
|
48
|
+
* @returns {{ inserted, replaced }} replaced: the rows the target held before
|
|
49
|
+
*/
|
|
50
|
+
async function mergeStaged(pool, opts) {
|
|
51
|
+
if (!opts.replaceAll || (opts.primaryKeys && opts.primaryKeys.length) || opts.replaceFrom) {
|
|
52
|
+
throw new Error(dialect.name + ': a staged load replaces the whole table here — upserts and range replaces are not staged on this database');
|
|
53
|
+
}
|
|
54
|
+
var target = q(opts.targetTable);
|
|
55
|
+
var staging = q(stagingTableFor(opts.targetTable));
|
|
56
|
+
var cols = [MOVEMENT_COL].concat(opts.columns.map(function(c) { return c.name; })).map(q).join(', ');
|
|
57
|
+
var counts = await dialect.transaction(pool, async function(run) {
|
|
58
|
+
var replaced = await run('DELETE FROM ' + target);
|
|
59
|
+
var inserted = await run('INSERT INTO ' + target + ' (' + cols + ') SELECT ' + cols + ' FROM ' + staging);
|
|
60
|
+
return { inserted: inserted, replaced: replaced };
|
|
61
|
+
});
|
|
62
|
+
// After the commit, and allowed to fail: the rows have landed.
|
|
63
|
+
await dropStaging(pool, opts.targetTable).catch(function() {});
|
|
64
|
+
return counts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { prepareStaging: prepareStaging, dropStaging: dropStaging, mergeStaged: mergeStaged };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { stagingFor: stagingFor, stagingTableFor: stagingTableFor, SUFFIX: SUFFIX };
|
package/lib/formats/index.js
CHANGED
|
@@ -47,6 +47,27 @@ async function scanPastedRows(type, filePath, opts) {
|
|
|
47
47
|
return pastedRows.scanForPastedRows(rows, { examples: config.examples });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A file's column names — its header (CSV), one sheet's (Excel), or the first
|
|
52
|
+
* record's keys (JSON) — read from the first row, the rest left unread. What
|
|
53
|
+
* an import compares with an existing table before loading anything.
|
|
54
|
+
* opts: the format's own options (`sheet` for excel).
|
|
55
|
+
*/
|
|
56
|
+
async function columnsOf(type, filePath, opts) {
|
|
57
|
+
var format = getFormat(type);
|
|
58
|
+
var config = opts || {};
|
|
59
|
+
var rows = format.inputMode === 'path'
|
|
60
|
+
? format.parsePath(filePath, config)
|
|
61
|
+
: format.parseStream(require('fs').createReadStream(filePath), config);
|
|
62
|
+
var iterator = rows[Symbol.asyncIterator]();
|
|
63
|
+
try {
|
|
64
|
+
var first = await iterator.next();
|
|
65
|
+
return first.done || !first.value ? [] : Object.keys(first.value);
|
|
66
|
+
} finally {
|
|
67
|
+
if (typeof iterator.return === 'function') await iterator.return();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
var pastedRows = require('./pastedRows');
|
|
51
72
|
|
|
52
73
|
module.exports = {
|
|
@@ -54,6 +75,7 @@ module.exports = {
|
|
|
54
75
|
checkFormatRequires: checkFormatRequires,
|
|
55
76
|
SUPPORTED: Object.keys(FORMATS),
|
|
56
77
|
scanPastedRows: scanPastedRows,
|
|
78
|
+
columnsOf: columnsOf,
|
|
57
79
|
// decodeOoxml, splitPastedRows, expandPastedRows, scanForPastedRows
|
|
58
80
|
pastedRows: pastedRows
|
|
59
81
|
};
|
package/lib/uploader/index.js
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
// keepBatchFiles, // default false — clean up NDJSON files after success
|
|
31
31
|
// replaceFrom, // optional, staged only: { column } — the move deletes target
|
|
32
32
|
// // rows from the earliest staged value on first (see mergeStaged)
|
|
33
|
+
// mode, // optional: 'new' | 'append' | 'replace' — what the target
|
|
34
|
+
// // must be (see MODES). Omitted: create it if missing,
|
|
35
|
+
// // else append, as before modes existed.
|
|
33
36
|
// staged, // default false. true: batches land in <targetTable>__xeplr_staging
|
|
34
37
|
// // and move into the target in ONE transaction at the end, so a
|
|
35
38
|
// // load that stops or dies midway never touched the target.
|
|
@@ -108,6 +111,11 @@ async function upload(opts) {
|
|
|
108
111
|
var rowNumbers = refused.rowNumbers;
|
|
109
112
|
|
|
110
113
|
try {
|
|
114
|
+
// WHAT THE TARGET MUST BE, checked before a row is read: a New table must
|
|
115
|
+
// not exist, and Append and Replace need one that does — a typo in the
|
|
116
|
+
// name would otherwise make a fresh table and "append" to nothing.
|
|
117
|
+
if (opts.mode) await checkMode(opts, driver, pool);
|
|
118
|
+
|
|
111
119
|
var summary = await spool(opts.source, {
|
|
112
120
|
runId: opts.movementId,
|
|
113
121
|
batchDir: opts.batchDir,
|
|
@@ -154,6 +162,7 @@ async function upload(opts) {
|
|
|
154
162
|
dbType: opts.dbType, // recorded when the caller supplies it
|
|
155
163
|
columns: columns,
|
|
156
164
|
primaryKeys: opts.primaryKeys,
|
|
165
|
+
mode: opts.mode || null,
|
|
157
166
|
mtId1: opts.mtId1, mtId2: opts.mtId2, mtId3: opts.mtId3, mtId4: opts.mtId4,
|
|
158
167
|
details: opts.details
|
|
159
168
|
});
|
|
@@ -265,7 +274,8 @@ async function upload(opts) {
|
|
|
265
274
|
columns: columns,
|
|
266
275
|
primaryKeys: opts.primaryKeys,
|
|
267
276
|
movementId: opts.movementId,
|
|
268
|
-
replaceFrom: opts.replaceFrom
|
|
277
|
+
replaceFrom: opts.replaceFrom,
|
|
278
|
+
replaceAll: opts.replaceAll
|
|
269
279
|
});
|
|
270
280
|
}
|
|
271
281
|
await opts.metaStore.recordEnd(opts.movementId, {
|
|
@@ -290,7 +300,9 @@ async function upload(opts) {
|
|
|
290
300
|
errorRows: errorRows,
|
|
291
301
|
aborted: !!stats.aborted,
|
|
292
302
|
// Staged only: what the move put in the target — { inserted, replaced }.
|
|
303
|
+
// For a Replace, `replaced` is how many rows the table held before.
|
|
293
304
|
merged: merged,
|
|
305
|
+
mode: opts.mode || null,
|
|
294
306
|
durationMs: Date.now() - startedAt
|
|
295
307
|
};
|
|
296
308
|
} finally {
|
|
@@ -396,6 +408,13 @@ async function rollback(opts) {
|
|
|
396
408
|
|
|
397
409
|
if (opts.metaStore && opts.metaStore.getById) {
|
|
398
410
|
var meta = await opts.metaStore.getById(opts.movementId);
|
|
411
|
+
// A REPLACE emptied the table before its rows went in. Deleting its rows
|
|
412
|
+
// now would leave the table empty, not as it was — the old rows are gone.
|
|
413
|
+
var metaOf = meta && meta.meta && typeof meta.meta === 'string' ? JSON.parse(meta.meta) : (meta && meta.meta);
|
|
414
|
+
if (metaOf && metaOf.mode === 'replace') {
|
|
415
|
+
throw new Error('rollback: this import replaced the table\'s rows — they cannot be brought back by deleting the new ones, ' +
|
|
416
|
+
'so it cannot be rolled back. Import the earlier data again to restore it.');
|
|
417
|
+
}
|
|
399
418
|
if (meta && meta.primary_keys && meta.primary_keys.length) {
|
|
400
419
|
throw new Error(
|
|
401
420
|
'rollback: this movement used upsert (primary keys: ' + meta.primary_keys.join(', ') + ') — ' +
|
|
@@ -458,6 +477,18 @@ function normalizeOptions(opts) {
|
|
|
458
477
|
if (opts.driver && opts.driver.stagesUpserts && (opts.primaryKeys || []).length) opts.staged = true;
|
|
459
478
|
opts.writeTable = null;
|
|
460
479
|
opts.replaceFrom = opts.replaceFrom || null;
|
|
480
|
+
if (opts.mode != null && MODES.indexOf(opts.mode) === -1) {
|
|
481
|
+
throw new Error('upload: mode must be one of ' + MODES.join(', ') + ' — got "' + opts.mode + '"');
|
|
482
|
+
}
|
|
483
|
+
// REPLACE IS STAGED: the rows go to a staging table while the import runs,
|
|
484
|
+
// and one transaction empties the target and moves them in at the end — so
|
|
485
|
+
// an import that fails or is stopped never leaves the table empty.
|
|
486
|
+
opts.replaceAll = opts.mode === 'replace';
|
|
487
|
+
if (opts.replaceAll) {
|
|
488
|
+
if ((opts.primaryKeys || []).length) throw new Error('upload: Replace empties the table first, so it takes no primaryKeys (that is an upsert)');
|
|
489
|
+
if (opts.replaceFrom) throw new Error('upload: Replace replaces the whole table; replaceFrom replaces a range — choose one');
|
|
490
|
+
opts.staged = true;
|
|
491
|
+
}
|
|
461
492
|
if (opts.replaceFrom) {
|
|
462
493
|
if (!opts.replaceFrom.column) throw new Error('upload: replaceFrom needs a column');
|
|
463
494
|
if ((opts.primaryKeys || []).length) throw new Error('upload: replaceFrom replaces a range, so it takes no primaryKeys');
|
|
@@ -466,9 +497,71 @@ function normalizeOptions(opts) {
|
|
|
466
497
|
if (opts.staged && typeof opts.driver.mergeStaged !== 'function') {
|
|
467
498
|
throw new Error('upload: staged loads are not supported for this database — its driver has no mergeStaged');
|
|
468
499
|
}
|
|
500
|
+
if (opts.staged && opts.driver.stagedReplaceOnly && !opts.replaceAll) {
|
|
501
|
+
throw new Error('upload: on this database a load is staged only to replace a whole table (mode: replace) — not for upserts or replaceFrom');
|
|
502
|
+
}
|
|
469
503
|
return opts;
|
|
470
504
|
}
|
|
471
505
|
|
|
506
|
+
// What an import may do to its target. 'new': make it — refused if it exists.
|
|
507
|
+
// 'append': add to it — it must exist. 'replace': empty it and load — it must
|
|
508
|
+
// exist; staged, so a failed import leaves it as it was.
|
|
509
|
+
var MODES = ['new', 'append', 'replace'];
|
|
510
|
+
|
|
511
|
+
async function checkMode(opts, driver, pool) {
|
|
512
|
+
var schema = await driver.getTableSchema(pool, opts.targetTable);
|
|
513
|
+
var exists = !!(schema && schema.length);
|
|
514
|
+
if (opts.mode === 'new' && exists) {
|
|
515
|
+
throw refusal('Table "' + opts.targetTable + '" already exists. Choose Append to add to it or Replace to replace its rows, or give a new name.');
|
|
516
|
+
}
|
|
517
|
+
if (opts.mode !== 'new' && !exists) {
|
|
518
|
+
throw refusal('Table "' + opts.targetTable + '" does not exist, so there is nothing to ' + (opts.mode === 'append' ? 'append to' : 'replace') +
|
|
519
|
+
'. Check the name, or choose New table.');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function refusal(message) {
|
|
524
|
+
var err = new Error(message);
|
|
525
|
+
err.code = 'TARGET_REFUSED';
|
|
526
|
+
return err;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The file's columns against an existing table — what an import would do,
|
|
531
|
+
* said before it does it. Matched by exact name; the framework's own columns
|
|
532
|
+
* are left out.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} opts - { driver, connection (config or pool), targetTable, columns: string[] }
|
|
535
|
+
* @returns {Promise<{ exists, matched: string[], extra: string[], missing: [{ name, required }] }>}
|
|
536
|
+
* extra: in the file, not the table — the import would be refused.
|
|
537
|
+
* missing: in the table, not the file — left empty; `required` when the
|
|
538
|
+
* column takes no empty value and has no default, so every row would be refused.
|
|
539
|
+
*/
|
|
540
|
+
async function compareToTable(opts) {
|
|
541
|
+
var driver = opts.driver;
|
|
542
|
+
var poolIsExternal = looksLikePool(opts.connection);
|
|
543
|
+
var pool = poolIsExternal ? opts.connection : await driver.connect(opts.connection);
|
|
544
|
+
try {
|
|
545
|
+
var schema = (await driver.getTableSchema(pool, opts.targetTable)) || [];
|
|
546
|
+
var table = schema.filter(function(c) { return c.name.indexOf('__xeplr_') !== 0; });
|
|
547
|
+
var fileCols = (opts.columns || []).filter(function(n) { return n != null && n !== ''; }).map(String);
|
|
548
|
+
var inTable = {};
|
|
549
|
+
table.forEach(function(c) { inTable[c.name] = c; });
|
|
550
|
+
var inFile = {};
|
|
551
|
+
fileCols.forEach(function(n) { inFile[n] = true; });
|
|
552
|
+
return {
|
|
553
|
+
exists: schema.length > 0,
|
|
554
|
+
matched: fileCols.filter(function(n) { return inTable[n]; }),
|
|
555
|
+
extra: schema.length ? fileCols.filter(function(n) { return !inTable[n]; }) : [],
|
|
556
|
+
missing: table.filter(function(c) { return !inFile[c.name]; }).map(function(c) {
|
|
557
|
+
return { name: c.name, required: c.nullable === false && !c.hasDefault };
|
|
558
|
+
})
|
|
559
|
+
};
|
|
560
|
+
} finally {
|
|
561
|
+
if (!poolIsExternal) await driver.close(pool);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
472
565
|
function safeBatchSize(columnCount, maxParams) {
|
|
473
566
|
var paramsPerRow = columnCount + 1; // +1 for __xeplr_movement_id__
|
|
474
567
|
return Math.max(1, Math.floor(maxParams / paramsPerRow));
|
|
@@ -601,5 +694,7 @@ module.exports = {
|
|
|
601
694
|
wasCommitted: wasCommitted,
|
|
602
695
|
inferColumns: inferColumns,
|
|
603
696
|
inferColumnType: inferColumnType,
|
|
697
|
+
compareToTable: compareToTable,
|
|
698
|
+
MODES: MODES,
|
|
604
699
|
makeMetaStore: require('./meta-store-knex')
|
|
605
700
|
};
|
|
@@ -88,6 +88,9 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
88
88
|
status: 'running',
|
|
89
89
|
primary_keys: plan.primaryKeys ? JSON.stringify(plan.primaryKeys) : null,
|
|
90
90
|
columns: plan.columns ? JSON.stringify(plan.columns) : null,
|
|
91
|
+
// What the import did to its target ('new' | 'append' | 'replace'):
|
|
92
|
+
// a Replace cannot be rolled back, and rollback reads it here.
|
|
93
|
+
meta: plan.mode ? JSON.stringify({ mode: plan.mode }) : null,
|
|
91
94
|
error: null,
|
|
92
95
|
started_at: knex.fn.now(),
|
|
93
96
|
ended_at: null,
|
package/package.json
CHANGED