@proteinjs/db 1.50.2 → 1.52.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/src/Db.d.ts +35 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +169 -19
- package/dist/src/Db.js.map +1 -1
- package/dist/src/services/DbService.d.ts +7 -0
- package/dist/src/services/DbService.d.ts.map +1 -1
- package/dist/src/services/DbService.js +13 -1
- package/dist/src/services/DbService.js.map +1 -1
- package/dist/test/DbServiceRetryDeclaration.test.d.ts +2 -0
- package/dist/test/DbServiceRetryDeclaration.test.d.ts.map +1 -0
- package/dist/test/DbServiceRetryDeclaration.test.js +139 -0
- package/dist/test/DbServiceRetryDeclaration.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/package.json +4 -4
- package/src/Db.ts +102 -0
- package/src/services/DbService.ts +13 -1
- package/test/DbServiceRetryDeclaration.test.ts +105 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.52.0",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.
|
|
44
|
+
"@proteinjs/db-query": "^1.11.0",
|
|
45
45
|
"@proteinjs/logger": "^1.2.0",
|
|
46
46
|
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
48
48
|
"@proteinjs/server-api": "^3.0.11",
|
|
49
|
-
"@proteinjs/service": "^1.
|
|
49
|
+
"@proteinjs/service": "^1.8.0",
|
|
50
50
|
"@proteinjs/user-auth": "^1.3.0",
|
|
51
51
|
"@proteinjs/util": "^1.6.1",
|
|
52
52
|
"moment": "2.29.4",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "57c2ed1dca68a508bbd7b194ea7695ac4cf93344"
|
|
70
70
|
}
|
package/src/Db.ts
CHANGED
|
@@ -104,6 +104,14 @@ export interface DbDriver {
|
|
|
104
104
|
* configured its database to tolerate rather than a number of its own.
|
|
105
105
|
*/
|
|
106
106
|
getOperationDeadlineMs(): number;
|
|
107
|
+
/**
|
|
108
|
+
* The most bound parameters one statement may carry on this driver's backend, when the backend
|
|
109
|
+
* bounds it — `Db.insertMany` sizes its multi-row statements by it (`columns × rows` parameters
|
|
110
|
+
* each), so a batch wider than the bound goes as the fewest statements that fit instead of one
|
|
111
|
+
* the backend refuses. A driver whose backend declares no such bound leaves this undeclared,
|
|
112
|
+
* and a batch on it is always one statement.
|
|
113
|
+
*/
|
|
114
|
+
getStatementParameterLimit?(): number;
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export class Db<R extends Record = Record> implements DbService<R> {
|
|
@@ -228,6 +236,77 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
228
236
|
return recordCopy as T;
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Insert `records` as ONE statement (a multi-row INSERT) instead of one statement per row,
|
|
241
|
+
* each row keeping every contract {@link insert} gives a single one: the table's default field
|
|
242
|
+
* values, the before-insert table watchers, the columns' before-insert hooks, the encryption
|
|
243
|
+
* context, then — after the statement — the search-token upkeep, the columns' after-insert
|
|
244
|
+
* hooks and the after-insert table watchers. Each phase runs per row, in the order given, and
|
|
245
|
+
* sees the row exactly as `insert` would; what differs from N single inserts is only that every
|
|
246
|
+
* row's before-phases precede the statement and every row's after-phases follow it.
|
|
247
|
+
*
|
|
248
|
+
* Atomic: inside a transaction the statement rides it; outside one this opens a transaction
|
|
249
|
+
* of its own around the batch, so a row the database refuses fails the whole batch and no row
|
|
250
|
+
* of it lands. A batch wider than the driver's statement parameter bound
|
|
251
|
+
* ({@link DbDriver.getStatementParameterLimit}) goes as the fewest statements that fit, in
|
|
252
|
+
* order, inside that same transaction.
|
|
253
|
+
*
|
|
254
|
+
* Returns the rows as inserted (defaults applied, watchers' changes carried), in order. An
|
|
255
|
+
* empty list inserts nothing and returns [].
|
|
256
|
+
*/
|
|
257
|
+
async insertMany<T extends R>(table: Table<T>, records: Omit<T, keyof R>[]): Promise<T[]> {
|
|
258
|
+
if (!this.runAsSystem) {
|
|
259
|
+
this.auth.canInsert(table);
|
|
260
|
+
}
|
|
261
|
+
if (records.length === 0) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
|
|
265
|
+
const db = this.newSelfWrapDb();
|
|
266
|
+
return await db.runTransaction(async () => await db.insertMany(table, records));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const prepared: {
|
|
270
|
+
recordCopy: any;
|
|
271
|
+
encryptionContext?: { keyOwner: string };
|
|
272
|
+
serializedRecord: SerializedRecord;
|
|
273
|
+
}[] = [];
|
|
274
|
+
for (const record of records) {
|
|
275
|
+
let recordCopy = Object.assign({}, record);
|
|
276
|
+
await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
|
|
277
|
+
recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
|
|
278
|
+
await this.addColumnInsertHooks(table, recordCopy);
|
|
279
|
+
const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
|
|
280
|
+
const serializedRecord = await new RecordSerializer(table, encryptionContext).serialize(recordCopy);
|
|
281
|
+
prepared.push({ recordCopy, encryptionContext, serializedRecord });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const statementRows of this.chunkByParameterLimit(prepared.map((row) => row.serializedRecord))) {
|
|
285
|
+
const generateInsert = (config: DbDriverDmlStatementConfig) =>
|
|
286
|
+
new StatementFactory<T>().insertMany(
|
|
287
|
+
table.name,
|
|
288
|
+
statementRows as Partial<T>[],
|
|
289
|
+
this.statementConfigFactory.getStatementConfig(config)
|
|
290
|
+
);
|
|
291
|
+
await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const { recordCopy, encryptionContext } of prepared) {
|
|
295
|
+
if (encryptionContext) {
|
|
296
|
+
const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
|
|
297
|
+
await new EncryptionTokenMaintenance().afterInsert(
|
|
298
|
+
table,
|
|
299
|
+
recordCopy,
|
|
300
|
+
encryptionContext.keyOwner,
|
|
301
|
+
this.newSystemDb()
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
await this.runColumnAfterInsertHooks(table, recordCopy);
|
|
305
|
+
await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
|
|
306
|
+
}
|
|
307
|
+
return prepared.map((row) => row.recordCopy as T);
|
|
308
|
+
}
|
|
309
|
+
|
|
231
310
|
async update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number> {
|
|
232
311
|
if (!this.runAsSystem) {
|
|
233
312
|
this.auth.canUpdate(table);
|
|
@@ -1045,6 +1124,29 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
1045
1124
|
return context.currentTransaction;
|
|
1046
1125
|
}
|
|
1047
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* The rows of one multi-row insert split into the fewest statements the driver's statement
|
|
1129
|
+
* parameter bound admits (`insertMany`): each statement binds `columns × rows` parameters,
|
|
1130
|
+
* the columns being the union over the whole batch (what `StatementFactory.insertMany` lists),
|
|
1131
|
+
* so the split follows the batch's widest shape rather than the first statement's. One
|
|
1132
|
+
* statement when the driver declares no bound.
|
|
1133
|
+
*/
|
|
1134
|
+
private chunkByParameterLimit(rows: SerializedRecord[]): SerializedRecord[][] {
|
|
1135
|
+
const limit = this.dbDriver.getStatementParameterLimit?.();
|
|
1136
|
+
if (limit === undefined) {
|
|
1137
|
+
return [rows];
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const columns = new Set<string>();
|
|
1141
|
+
for (const row of rows) {
|
|
1142
|
+
for (const column of Object.keys(row)) {
|
|
1143
|
+
columns.add(column);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const rowsPerStatement = Math.max(1, Math.floor(limit / Math.max(1, columns.size)));
|
|
1147
|
+
return this.chunk(rows, rowsPerStatement);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1048
1150
|
// Utility: simple chunker
|
|
1049
1151
|
private chunk<T>(arr: T[], size: number): T[][] {
|
|
1050
1152
|
if (size <= 0) {
|
|
@@ -5,7 +5,19 @@ import { QueryBuilder } from '@proteinjs/db-query';
|
|
|
5
5
|
import { ArrayMembershipUpdate } from '../reference/ArrayMembershipOps';
|
|
6
6
|
import { PreservedPath } from '../UpdatePreserving';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The db service rides the service client's retry policy by declaration alone, no logic of its
|
|
10
|
+
* own: the reads (`query`, `get`, `getRowCount`, `tableExists`) are declared `'read'` — bounded at
|
|
11
|
+
* the first contact and redelivered under the client's series when a delivery produced no
|
|
12
|
+
* response — and every write stays undeclared: one delivery, never redelivered (a redelivered
|
|
13
|
+
* `insert` would insert twice). The policy's numbers and exceptions are @proteinjs/service's.
|
|
14
|
+
*/
|
|
15
|
+
export const getDbService = serviceFactory<DbService>('@proteinjs/db/DbService', undefined, {
|
|
16
|
+
query: 'read',
|
|
17
|
+
get: 'read',
|
|
18
|
+
getRowCount: 'read',
|
|
19
|
+
tableExists: 'read',
|
|
20
|
+
});
|
|
9
21
|
|
|
10
22
|
export type Query<T> = ObjectQuery<T> | QueryBuilder<T>;
|
|
11
23
|
export type ObjectQuery<T> = Partial<{ [P in keyof T]: any }>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Interface, Method, SourceRepository, TypeAliasDeclaration } from '@proteinjs/reflection';
|
|
2
|
+
import { READ_CONTACT_TIMEOUT_MS, REDELIVERY_BASE_MS } from '@proteinjs/service';
|
|
3
|
+
import { getDbService } from '../src/services/DbService';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The db service rides the service client's retry policy by declaration alone: its reads (`query`,
|
|
7
|
+
* `get`, `getRowCount`, `tableExists`) are declared `'read'` on the factory — bounded at the first
|
|
8
|
+
* contact and redelivered as fresh requests under the client's series when a delivery produced no
|
|
9
|
+
* response — and its writes (`insert`, `update`, `updateArrayMembership`, `updatePreserving`,
|
|
10
|
+
* `delete`) stay undeclared: one delivery, no bound, never redelivered. Nothing here is the db
|
|
11
|
+
* package's own logic — the policy, its numbers and its exceptions are @proteinjs/service's; this
|
|
12
|
+
* suite pins the declaration. Read as OUTCOMES off the transport: the requests that left and their
|
|
13
|
+
* watchdog signals, under fake timers.
|
|
14
|
+
*
|
|
15
|
+
* The service interface is seeded into the source repository directly (this package's own test pattern —
|
|
16
|
+
* DbDefaultDriverDuplicateModuleState seeds the object cache the same way), so the real factory
|
|
17
|
+
* builds the real clients without the generated source graph.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type RepositoryInternals = { flattenedSourceGraph: { interfaces: { [qualifiedName: string]: Interface } } };
|
|
21
|
+
|
|
22
|
+
const READS = ['query', 'get', 'getRowCount', 'tableExists'];
|
|
23
|
+
const WRITES = ['insert', 'update', 'updateArrayMembership', 'updatePreserving', 'delete'];
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
// Node's Request rejects the relative service paths a browser resolves against the page origin.
|
|
27
|
+
global.Request = class {
|
|
28
|
+
constructor(
|
|
29
|
+
public url: string,
|
|
30
|
+
public init: any
|
|
31
|
+
) {}
|
|
32
|
+
} as any;
|
|
33
|
+
const returnType = { name: 'Promise<any>' } as unknown as TypeAliasDeclaration;
|
|
34
|
+
const methods = [...READS, ...WRITES].map(
|
|
35
|
+
(name) => new Method(name, returnType, true, false, false, false, 'public', [])
|
|
36
|
+
);
|
|
37
|
+
(SourceRepository.get() as unknown as RepositoryInternals).flattenedSourceGraph.interfaces[
|
|
38
|
+
'@proteinjs/db/DbService'
|
|
39
|
+
] = new Interface('@proteinjs/db', 'DbService', [], methods);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** A transport that never answers: its promise settles only when the request is abandoned (its signal aborts). */
|
|
43
|
+
const neverAnswers = () =>
|
|
44
|
+
jest.fn(
|
|
45
|
+
(request: any) =>
|
|
46
|
+
new Promise((unused, reject) => {
|
|
47
|
+
request.init.signal?.addEventListener('abort', () =>
|
|
48
|
+
reject(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }))
|
|
49
|
+
);
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const sentRequests = (): any[] => (global.fetch as jest.Mock).mock.calls.map(([request]) => request);
|
|
54
|
+
|
|
55
|
+
/** The call's outcome, readable without awaiting it — a pending call must read as pending. */
|
|
56
|
+
const track = (promise: Promise<any>) => {
|
|
57
|
+
const outcome: { state: 'pending' | 'resolved' | 'rejected' } = { state: 'pending' };
|
|
58
|
+
promise.then(
|
|
59
|
+
() => {
|
|
60
|
+
outcome.state = 'resolved';
|
|
61
|
+
},
|
|
62
|
+
() => {
|
|
63
|
+
outcome.state = 'rejected';
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
return outcome;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const aTable = { name: 'a_table' } as any;
|
|
70
|
+
|
|
71
|
+
describe('the db service under the client retry policy', () => {
|
|
72
|
+
beforeEach(() => jest.useFakeTimers());
|
|
73
|
+
afterEach(() => jest.useRealTimers());
|
|
74
|
+
|
|
75
|
+
it.each(READS)(
|
|
76
|
+
'%s is a declared read: bounded at the first contact, redelivered as a fresh request',
|
|
77
|
+
async (name) => {
|
|
78
|
+
global.fetch = neverAnswers() as any;
|
|
79
|
+
const service = getDbService() as any;
|
|
80
|
+
const outcome = track(service[name](aTable, {}));
|
|
81
|
+
|
|
82
|
+
await jest.advanceTimersByTimeAsync(READ_CONTACT_TIMEOUT_MS);
|
|
83
|
+
expect(sentRequests()).toHaveLength(1);
|
|
84
|
+
expect(sentRequests()[0].url).toBe(`/service/@proteinjs/db/DbService/${name}`);
|
|
85
|
+
expect(sentRequests()[0].init.signal.aborted).toBe(true);
|
|
86
|
+
|
|
87
|
+
await jest.advanceTimersByTimeAsync(REDELIVERY_BASE_MS);
|
|
88
|
+
expect(sentRequests()).toHaveLength(2);
|
|
89
|
+
expect(sentRequests()[1].init.signal.aborted).toBe(false);
|
|
90
|
+
expect(outcome.state).toBe('pending');
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it.each(WRITES)('%s is undeclared: one delivery, no bound, never redelivered', async (name) => {
|
|
95
|
+
global.fetch = neverAnswers() as any;
|
|
96
|
+
const service = getDbService() as any;
|
|
97
|
+
const outcome = track(service[name](aTable, {}));
|
|
98
|
+
|
|
99
|
+
await jest.advanceTimersByTimeAsync(READ_CONTACT_TIMEOUT_MS * 4);
|
|
100
|
+
expect(sentRequests()).toHaveLength(1);
|
|
101
|
+
expect(sentRequests()[0].url).toBe(`/service/@proteinjs/db/DbService/${name}`);
|
|
102
|
+
expect(sentRequests()[0].init.signal).toBeUndefined();
|
|
103
|
+
expect(outcome.state).toBe('pending');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -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;
|