@proteinjs/db 1.51.0 → 1.53.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/CHANGELOG.md +22 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +48 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +206 -31
- package/dist/src/Db.js.map +1 -1
- package/dist/src/DuplicateKeyError.d.ts +20 -0
- package/dist/src/DuplicateKeyError.d.ts.map +1 -0
- package/dist/src/DuplicateKeyError.js +47 -0
- package/dist/src/DuplicateKeyError.js.map +1 -0
- package/dist/test/DuplicateKeyError.test.d.ts +2 -0
- package/dist/test/DuplicateKeyError.test.d.ts.map +1 -0
- package/dist/test/DuplicateKeyError.test.js +287 -0
- package/dist/test/DuplicateKeyError.test.js.map +1 -0
- package/dist/test/InsertMany.test.d.ts +2 -0
- package/dist/test/InsertMany.test.d.ts.map +1 -0
- package/dist/test/InsertMany.test.js +475 -0
- package/dist/test/InsertMany.test.js.map +1 -0
- package/dist/test/TableServiceAuth.test.js +12 -0
- package/dist/test/TableServiceAuth.test.js.map +1 -1
- package/dist/test/reusable/CrudTests.d.ts.map +1 -1
- package/dist/test/reusable/CrudTests.js +44 -0
- package/dist/test/reusable/CrudTests.js.map +1 -1
- package/dist/test/reusable/TransactionTests.d.ts.map +1 -1
- package/dist/test/reusable/TransactionTests.js +90 -0
- package/dist/test/reusable/TransactionTests.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +3 -3
- package/src/Db.ts +128 -1
- package/src/DuplicateKeyError.ts +26 -0
- package/test/DuplicateKeyError.test.ts +179 -0
- package/test/InsertMany.test.ts +298 -0
- package/test/TableServiceAuth.test.ts +12 -0
- package/test/reusable/CrudTests.ts +30 -0
- package/test/reusable/TransactionTests.ts +46 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { Statement } from '@proteinjs/db-query';
|
|
3
|
+
import { Db, DbDriver, DbDriverDmlStatementConfig } from '../src/Db';
|
|
4
|
+
import { Table } from '../src/Table';
|
|
5
|
+
import { Record, withRecordColumns } from '../src/Record';
|
|
6
|
+
import { BooleanColumn, StringColumn } from '../src/Columns';
|
|
7
|
+
import { TableWatcher } from '../src/TableWatcher';
|
|
8
|
+
import { TableWatcherRunner } from '../src/TableWatcherRunner';
|
|
9
|
+
import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `Db.insertMany`'s contract against a recording driver (no database): N rows issue ONE statement
|
|
13
|
+
* carrying every row with its defaults; each per-row hook — the before-insert table watcher, the
|
|
14
|
+
* columns' before/after-insert hooks, the after-insert table watcher — fires once per row, in
|
|
15
|
+
* the order given, and sees the row exactly as the single `insert` road shows it; a batch wider
|
|
16
|
+
* than the driver's statement parameter bound splits into the fewest statements that fit; the
|
|
17
|
+
* batch is one transaction (the ambient one, else its own); a refused statement runs no
|
|
18
|
+
* after-phase. What the database does with the statement — every row landing, the rollback of a
|
|
19
|
+
* refused batch — is the drivers' emulator suites' (the reusable CRUD and transaction tests).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Doc extends Record {
|
|
23
|
+
title: string;
|
|
24
|
+
note?: string | null;
|
|
25
|
+
flagged?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type HookEvent = [phase: string, title: string, idPresent: boolean];
|
|
29
|
+
const log: HookEvent[] = [];
|
|
30
|
+
|
|
31
|
+
class DocTable extends Table<Doc> {
|
|
32
|
+
public name = 'insert_many_doc';
|
|
33
|
+
public columns = withRecordColumns<Doc>({
|
|
34
|
+
title: new StringColumn('title', {
|
|
35
|
+
onBeforeInsert: async (_table, record) => {
|
|
36
|
+
log.push(['column.before', record.title, typeof record.id === 'string']);
|
|
37
|
+
record.title = `${record.title}!`;
|
|
38
|
+
},
|
|
39
|
+
onAfterInsert: async (_table, record) => {
|
|
40
|
+
log.push(['column.after', record.title, typeof record.id === 'string']);
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
note: new StringColumn('note'),
|
|
44
|
+
flagged: new BooleanColumn('flagged', { defaultValue: async () => false }),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const docTable = new DocTable() as Table<Doc>;
|
|
49
|
+
|
|
50
|
+
class RecordingWatcher implements TableWatcher<Doc> {
|
|
51
|
+
name(): string {
|
|
52
|
+
return 'RecordingWatcher';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
table(): Table<Doc> {
|
|
56
|
+
return docTable;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async beforeInsert<T extends Doc>(record: Omit<T, keyof Doc>): Promise<Omit<T, keyof Doc>> {
|
|
60
|
+
const doc = record as unknown as Doc;
|
|
61
|
+
log.push(['watcher.before', doc.title, typeof doc.id === 'string']);
|
|
62
|
+
return { ...record, note: `seen ${doc.title}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async afterInsert<T extends Doc>(record: T): Promise<void> {
|
|
66
|
+
log.push(['watcher.after', record.title, typeof record.id === 'string']);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class TestTransactionContextFactory implements DefaultTransactionContextFactory {
|
|
71
|
+
private context: TransactionContextData = {};
|
|
72
|
+
|
|
73
|
+
getTransactionContext(): TransactionContextData {
|
|
74
|
+
return this.context;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
|
|
78
|
+
const previous = this.context;
|
|
79
|
+
this.context = context;
|
|
80
|
+
try {
|
|
81
|
+
return await fn();
|
|
82
|
+
} finally {
|
|
83
|
+
this.context = previous;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Records every DML statement the Db hands it, as the Spanner driver would generate it. */
|
|
89
|
+
class RecordingDriver implements DbDriver {
|
|
90
|
+
dml: { sql: string; params: { [name: string]: unknown }; transaction: unknown }[] = [];
|
|
91
|
+
transactions = 0;
|
|
92
|
+
refuse?: (sql: string) => boolean;
|
|
93
|
+
|
|
94
|
+
getDbName(): string {
|
|
95
|
+
return 'test';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async createDbIfNotExists(): Promise<void> {}
|
|
99
|
+
|
|
100
|
+
async createDb(): Promise<void> {}
|
|
101
|
+
|
|
102
|
+
async dropDb(): Promise<void> {}
|
|
103
|
+
|
|
104
|
+
getTableManager(): never {
|
|
105
|
+
throw new Error('not used by these tests');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async runQuery(): Promise<never[]> {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: unknown) {
|
|
113
|
+
const statement = generateStatement({
|
|
114
|
+
useParams: true,
|
|
115
|
+
useNamedParams: true,
|
|
116
|
+
prefixTablesWithDb: false,
|
|
117
|
+
getDriverColumnType: () => 'STRING(MAX)',
|
|
118
|
+
});
|
|
119
|
+
this.dml.push({ sql: statement.sql, params: statement.namedParams?.params ?? {}, transaction });
|
|
120
|
+
if (this.refuse?.(statement.sql)) {
|
|
121
|
+
throw new Error(`refused: ${statement.sql}`);
|
|
122
|
+
}
|
|
123
|
+
return tuplesOf(statement.sql);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
|
|
127
|
+
this.transactions += 1;
|
|
128
|
+
return await fn({ id: `transaction-${this.transactions}` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getOperationDeadlineMs(): number {
|
|
132
|
+
return 1000;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class BoundedDriver extends RecordingDriver {
|
|
137
|
+
constructor(private readonly limit: number) {
|
|
138
|
+
super();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getStatementParameterLimit(): number {
|
|
142
|
+
return this.limit;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const tuplesOf = (sql: string): number => (sql.match(/\(@param\d+/g) ?? []).length;
|
|
147
|
+
|
|
148
|
+
type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
|
|
149
|
+
type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
|
|
150
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
151
|
+
|
|
152
|
+
/** A system Db over `driver`, its watcher map rebuilt from the recording watcher alone. */
|
|
153
|
+
const dbOver = (driver: DbDriver): Db<Doc> => {
|
|
154
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
155
|
+
return new Db<Doc>(driver, () => docTable, new TestTransactionContextFactory(), true);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const rowsOf = (titles: string[]): Omit<Doc, keyof Record>[] => titles.map((title) => ({ title }));
|
|
159
|
+
const eventsFor = (title: string) => log.filter(([, t]) => t === title || t === `${title}!`);
|
|
160
|
+
const shapeOf = (doc: Doc) => ({ title: doc.title, note: doc.note, flagged: doc.flagged });
|
|
161
|
+
|
|
162
|
+
describe('Db.insertMany', () => {
|
|
163
|
+
let originalWatchers: unknown[] | undefined;
|
|
164
|
+
|
|
165
|
+
beforeAll(() => {
|
|
166
|
+
originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
|
|
167
|
+
objectCache()['@proteinjs/db/TableWatcher'] = [new RecordingWatcher()];
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterAll(() => {
|
|
171
|
+
if (originalWatchers) {
|
|
172
|
+
objectCache()['@proteinjs/db/TableWatcher'] = originalWatchers;
|
|
173
|
+
} else {
|
|
174
|
+
delete objectCache()['@proteinjs/db/TableWatcher'];
|
|
175
|
+
}
|
|
176
|
+
(TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
log.length = 0;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('N rows issue exactly ONE statement, every row in it with its defaults, returned in order', async () => {
|
|
184
|
+
const driver = new RecordingDriver();
|
|
185
|
+
const db = dbOver(driver);
|
|
186
|
+
|
|
187
|
+
const inserted = await db.insertMany(docTable, rowsOf(['a', 'b', 'c']));
|
|
188
|
+
|
|
189
|
+
expect(driver.dml).toHaveLength(1);
|
|
190
|
+
expect(tuplesOf(driver.dml[0].sql)).toBe(3);
|
|
191
|
+
expect(driver.dml[0].sql).toMatch(/^INSERT INTO `insert_many_doc` \(.+\) VALUES \(.+\), \(.+\), \(.+\);$/);
|
|
192
|
+
expect(inserted.map(shapeOf)).toEqual([
|
|
193
|
+
{ title: 'a!', note: 'seen a', flagged: false },
|
|
194
|
+
{ title: 'b!', note: 'seen b', flagged: false },
|
|
195
|
+
{ title: 'c!', note: 'seen c', flagged: false },
|
|
196
|
+
]);
|
|
197
|
+
for (const doc of inserted) {
|
|
198
|
+
expect(typeof doc.id).toBe('string');
|
|
199
|
+
expect(doc.created).toBeDefined();
|
|
200
|
+
expect(doc.updated).toBeDefined();
|
|
201
|
+
}
|
|
202
|
+
// The statement carries what the rows carry: the ids the defaults set and the watcher's note.
|
|
203
|
+
const bound = Object.values(driver.dml[0].params);
|
|
204
|
+
for (const doc of inserted) {
|
|
205
|
+
expect(bound).toContain(doc.id);
|
|
206
|
+
expect(bound).toContain(doc.note);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('every per-row hook fires once per row, in row order, and sees the row as the single insert shows it', async () => {
|
|
211
|
+
const serial = dbOver(new RecordingDriver());
|
|
212
|
+
for (const row of rowsOf(['a', 'b'])) {
|
|
213
|
+
await serial.insert(docTable, row);
|
|
214
|
+
}
|
|
215
|
+
const serialLog = [...log];
|
|
216
|
+
log.length = 0;
|
|
217
|
+
|
|
218
|
+
const batched = dbOver(new RecordingDriver());
|
|
219
|
+
await batched.insertMany(docTable, rowsOf(['a', 'b']));
|
|
220
|
+
|
|
221
|
+
// Each row's own sequence — what fired, in what order, with the id already set — is the
|
|
222
|
+
// serial road's exactly.
|
|
223
|
+
for (const title of ['a', 'b']) {
|
|
224
|
+
const serialEvents = serialLog.filter(([, t]) => t === title || t === `${title}!`);
|
|
225
|
+
expect(eventsFor(title)).toEqual(serialEvents);
|
|
226
|
+
expect(eventsFor(title).map(([phase]) => phase)).toEqual([
|
|
227
|
+
'watcher.before',
|
|
228
|
+
'column.before',
|
|
229
|
+
'column.after',
|
|
230
|
+
'watcher.after',
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
// Across rows, each phase runs in row order; the before-phases of every row precede the
|
|
234
|
+
// statement, the after-phases follow it.
|
|
235
|
+
expect(log.map(([phase, title]) => `${phase}:${title}`)).toEqual([
|
|
236
|
+
'watcher.before:a',
|
|
237
|
+
'column.before:a',
|
|
238
|
+
'watcher.before:b',
|
|
239
|
+
'column.before:b',
|
|
240
|
+
'column.after:a!',
|
|
241
|
+
'watcher.after:a!',
|
|
242
|
+
'column.after:b!',
|
|
243
|
+
'watcher.after:b!',
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a batch wider than the driver's parameter bound goes as the fewest statements that fit, in order", async () => {
|
|
248
|
+
// Six columns per row (id, created, updated, title, note, flagged); a bound of 13 admits two rows a statement.
|
|
249
|
+
const driver = new BoundedDriver(13);
|
|
250
|
+
const db = dbOver(driver);
|
|
251
|
+
|
|
252
|
+
const inserted = await db.insertMany(docTable, rowsOf(['a', 'b', 'c', 'd', 'e']));
|
|
253
|
+
|
|
254
|
+
expect(driver.dml.map((statement) => tuplesOf(statement.sql))).toEqual([2, 2, 1]);
|
|
255
|
+
for (const statement of driver.dml) {
|
|
256
|
+
expect(Object.keys(statement.params).length).toBeLessThanOrEqual(13);
|
|
257
|
+
}
|
|
258
|
+
expect(inserted.map((doc) => doc.title)).toEqual(['a!', 'b!', 'c!', 'd!', 'e!']);
|
|
259
|
+
// One transaction around the whole batch: every statement rides the same one.
|
|
260
|
+
expect(driver.transactions).toBe(1);
|
|
261
|
+
expect(new Set(driver.dml.map((statement) => statement.transaction)).size).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('the batch rides the ambient transaction when there is one, and opens its own when there is none', async () => {
|
|
265
|
+
const ambient = new RecordingDriver();
|
|
266
|
+
const db = dbOver(ambient);
|
|
267
|
+
await db.runTransaction(async () => {
|
|
268
|
+
await db.insertMany(docTable, rowsOf(['a', 'b']));
|
|
269
|
+
});
|
|
270
|
+
expect(ambient.transactions).toBe(1);
|
|
271
|
+
expect(ambient.dml[0].transaction).toEqual({ id: 'transaction-1' });
|
|
272
|
+
|
|
273
|
+
const own = new RecordingDriver();
|
|
274
|
+
await dbOver(own).insertMany(docTable, rowsOf(['a', 'b']));
|
|
275
|
+
expect(own.transactions).toBe(1);
|
|
276
|
+
expect(own.dml[0].transaction).toEqual({ id: 'transaction-1' });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('no rows: nothing issued, nothing opened, an empty result', async () => {
|
|
280
|
+
const driver = new RecordingDriver();
|
|
281
|
+
const inserted = await dbOver(driver).insertMany(docTable, []);
|
|
282
|
+
|
|
283
|
+
expect(inserted).toEqual([]);
|
|
284
|
+
expect(driver.dml).toHaveLength(0);
|
|
285
|
+
expect(driver.transactions).toBe(0);
|
|
286
|
+
expect(log).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('a refused statement fails the batch: the error surfaces and no after-phase runs for any row', async () => {
|
|
290
|
+
const driver = new RecordingDriver();
|
|
291
|
+
driver.refuse = () => true;
|
|
292
|
+
|
|
293
|
+
await expect(dbOver(driver).insertMany(docTable, rowsOf(['a', 'b']))).rejects.toThrow('refused');
|
|
294
|
+
|
|
295
|
+
expect(driver.dml).toHaveLength(1);
|
|
296
|
+
expect(log.map(([phase]) => phase).filter((phase) => phase.endsWith('.after'))).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UserAuth } from '@proteinjs/user-auth';
|
|
2
|
+
import { Logger } from '@proteinjs/logger';
|
|
2
3
|
import { Table } from '../src/Table';
|
|
3
4
|
import { withRecordColumns, Record } from '../src/Record';
|
|
4
5
|
import { StringColumn } from '../src/Columns';
|
|
@@ -267,6 +268,17 @@ describe('TableServiceAuth — default deny (no auth block)', () => {
|
|
|
267
268
|
expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
|
|
268
269
|
expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(true);
|
|
269
270
|
});
|
|
271
|
+
|
|
272
|
+
it('the multi-row insert is a server-side door only: the service refuses it, admin included', () => {
|
|
273
|
+
setUser(['admin']);
|
|
274
|
+
const table = new NoAuthTable();
|
|
275
|
+
const errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
276
|
+
try {
|
|
277
|
+
expect(auth().canAccess('insertMany', [table, [{ title: 't' }]])).toBe(false);
|
|
278
|
+
} finally {
|
|
279
|
+
errorSpy.mockRestore();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
270
282
|
});
|
|
271
283
|
|
|
272
284
|
describe('TableServiceAuth — updateArrayMembership / updatePreserving (update parity)', () => {
|
|
@@ -25,6 +25,36 @@ export const crudTests = (
|
|
|
25
25
|
await db.delete(emplyeeTable, { id: fetchedEmployee.id });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test('Insert many: every row lands with its defaults, as its own single insert would', async () => {
|
|
29
|
+
const emplyeeTable: Table<Employee> = crudTestTables.Employee;
|
|
30
|
+
const rows: Omit<Employee, keyof Record>[] = [
|
|
31
|
+
{ name: 'Veronica', department: 'Cake Factory' },
|
|
32
|
+
{ name: 'Ada', department: 'Cake Factory', jobTitle: null, isRemote: true },
|
|
33
|
+
{ name: 'Sean' },
|
|
34
|
+
];
|
|
35
|
+
const inserted = await db.insertMany(emplyeeTable, rows);
|
|
36
|
+
expect(inserted.map((employee) => employee.name)).toEqual(['Veronica', 'Ada', 'Sean']);
|
|
37
|
+
const qb = new QueryBuilder<Employee>(emplyeeTable.name).condition({
|
|
38
|
+
field: 'id',
|
|
39
|
+
operator: 'IN',
|
|
40
|
+
value: inserted.map((employee) => employee.id),
|
|
41
|
+
});
|
|
42
|
+
const fetched = await db.query(emplyeeTable, qb);
|
|
43
|
+
expect(fetched.length).toBe(3);
|
|
44
|
+
const byName = new Map(fetched.map((employee) => [employee.name, employee]));
|
|
45
|
+
expect(byName.get('Veronica')?.department).toBe('Cake Factory');
|
|
46
|
+
// A column one row carries and another leaves out stores NULL for the row without it —
|
|
47
|
+
// the stored value of a single-row insert that left it out.
|
|
48
|
+
expect(byName.get('Veronica')?.jobTitle).toBeNull();
|
|
49
|
+
expect(byName.get('Ada')?.isRemote).toBe(true);
|
|
50
|
+
expect(byName.get('Sean')?.department).toBeNull();
|
|
51
|
+
for (const employee of fetched) {
|
|
52
|
+
expect(employee.created).toBeTruthy();
|
|
53
|
+
expect(employee.updated).toBeTruthy();
|
|
54
|
+
}
|
|
55
|
+
await db.delete(emplyeeTable, qb);
|
|
56
|
+
});
|
|
57
|
+
|
|
28
58
|
test('Update', async () => {
|
|
29
59
|
const testEmployee: Omit<Employee, keyof Record> = { name: 'Veronica' };
|
|
30
60
|
const emplyeeTable: Table<Employee> = crudTestTables.Employee;
|
|
@@ -88,6 +88,52 @@ export const transactionTests = (
|
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
test('Insert many inside a transaction: a refused row rolls the whole batch back, and the writes before it', async () => {
|
|
92
|
+
const emplyeeTable: Table<TransactionEmployee> = transactionTestTables.TransactionEmployee;
|
|
93
|
+
let singleId: string | undefined;
|
|
94
|
+
let batchIds: string[] = [];
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
db.runTransaction(async () => {
|
|
98
|
+
const single = await db.insert(emplyeeTable, { name: 'Veronica', department: 'Engineering' });
|
|
99
|
+
singleId = single.id;
|
|
100
|
+
// The third row reuses the first row's id: the statement is refused as a whole.
|
|
101
|
+
const rows: Omit<TransactionEmployee, keyof Record>[] = [
|
|
102
|
+
{ name: 'Ada', department: 'Engineering' },
|
|
103
|
+
{ name: 'Sean', department: 'Engineering' },
|
|
104
|
+
{ name: 'Cassidy', department: 'Engineering' },
|
|
105
|
+
];
|
|
106
|
+
const first = await db.insertMany(emplyeeTable, [rows[0]]);
|
|
107
|
+
batchIds = [first[0].id];
|
|
108
|
+
await db.insertMany(emplyeeTable, [
|
|
109
|
+
rows[1],
|
|
110
|
+
{ ...rows[2], id: first[0].id } as Omit<TransactionEmployee, keyof Record>,
|
|
111
|
+
]);
|
|
112
|
+
})
|
|
113
|
+
).rejects.toThrow();
|
|
114
|
+
|
|
115
|
+
expect(singleId).toBeDefined();
|
|
116
|
+
expect(await db.get(emplyeeTable, { id: singleId })).toBeFalsy();
|
|
117
|
+
expect(await db.get(emplyeeTable, { id: batchIds[0] })).toBeFalsy();
|
|
118
|
+
expect(await db.query(emplyeeTable, { department: 'Engineering' })).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('Insert many outside a transaction is one unit: a refused row lands none of the batch', async () => {
|
|
122
|
+
const emplyeeTable: Table<TransactionEmployee> = transactionTestTables.TransactionEmployee;
|
|
123
|
+
const anchor = await db.insert(emplyeeTable, { name: 'Veronica', department: 'Sales' });
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
db.insertMany(emplyeeTable, [
|
|
127
|
+
{ name: 'Ada', department: 'Sales' },
|
|
128
|
+
{ name: 'Sean', department: 'Sales', id: anchor.id } as Omit<TransactionEmployee, keyof Record>,
|
|
129
|
+
])
|
|
130
|
+
).rejects.toThrow();
|
|
131
|
+
|
|
132
|
+
const sales = await db.query(emplyeeTable, { department: 'Sales' });
|
|
133
|
+
expect(sales.map((employee) => employee.name)).toEqual(['Veronica']);
|
|
134
|
+
await db.delete(emplyeeTable, { id: anchor.id });
|
|
135
|
+
});
|
|
136
|
+
|
|
91
137
|
test('Nested transactions are not allowed', async () => {
|
|
92
138
|
const testEmployee: Omit<TransactionEmployee, keyof Record> = {
|
|
93
139
|
name: 'Veronica',
|