@proteinjs/db 1.28.1 → 1.29.1
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/LICENSE +21 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +3 -3
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/Db.d.ts +9 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +99 -30
- package/dist/src/Db.js.map +1 -1
- package/dist/src/Table.d.ts +6 -0
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/Table.js +5 -0
- package/dist/src/Table.js.map +1 -1
- package/dist/src/schema/TableManager.js +1 -1
- package/dist/src/schema/TableManager.js.map +1 -1
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/reusable/PreloadReferencesTests.d.ts +10 -0
- package/dist/test/reusable/PreloadReferencesTests.d.ts.map +1 -0
- package/dist/test/reusable/PreloadReferencesTests.js +241 -0
- package/dist/test/reusable/PreloadReferencesTests.js.map +1 -0
- package/generated/index.ts +15 -18
- package/generated/test/index.ts +40 -43
- package/package.json +3 -2
- package/src/Db.ts +66 -4
- package/src/Table.ts +6 -1
- package/src/schema/TableManager.ts +1 -1
- package/test/index.ts +1 -0
- package/test/reusable/PreloadReferencesTests.ts +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.1",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -65,5 +65,6 @@
|
|
|
65
65
|
"jest": "29.7.0",
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
|
-
}
|
|
68
|
+
},
|
|
69
|
+
"gitHead": "c945fb49ea7db32eb5bf51630eff23f5df06560c"
|
|
69
70
|
}
|
package/src/Db.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { isInstanceOf } from '@proteinjs/util';
|
|
30
30
|
import { Reference } from './reference/Reference';
|
|
31
31
|
import { ReferenceArray } from './reference/ReferenceArray';
|
|
32
|
+
import { ReferenceCache } from './reference/ReferenceCache';
|
|
32
33
|
import { ArrayMembershipUpdate, applyArrayMembershipOps } from './reference/ArrayMembershipOps';
|
|
33
34
|
import { PreservedPath, overlayPreservedPaths } from './UpdatePreserving';
|
|
34
35
|
|
|
@@ -541,15 +542,25 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
541
542
|
return records;
|
|
542
543
|
}
|
|
543
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Batch-load the result set's references: one IN query per referenced table, grouped
|
|
547
|
+
* across rows and reference-array members, instead of a `get()` per reference (a 30-row
|
|
548
|
+
* window over references was 30 serialized point reads — each through its own default-
|
|
549
|
+
* driver Db). The batch queries ride THIS instance — driver, table resolution, ambient
|
|
550
|
+
* transaction, and authority — so a preload behaves like part of the query that carried
|
|
551
|
+
* it. References already loaded, or serveable from `ReferenceCache` (`Reference.get`
|
|
552
|
+
* consults it per read, deliberately never stamping `_object`), are left untouched.
|
|
553
|
+
*/
|
|
544
554
|
private async preloadReferences(records: any[], queryOptions?: QueryOptions<any>) {
|
|
545
555
|
const { preloadReferences } = queryOptions || {};
|
|
546
556
|
if (!preloadReferences?.enabled) {
|
|
547
557
|
return;
|
|
548
558
|
}
|
|
549
559
|
|
|
560
|
+
const pendingReferences: Reference<any>[] = [];
|
|
561
|
+
const pendingReferenceArrays: ReferenceArray<any>[] = [];
|
|
550
562
|
for (const record of records) {
|
|
551
|
-
const
|
|
552
|
-
for (const [fieldPropertyName, fieldValue] of fields) {
|
|
563
|
+
for (const [fieldPropertyName, fieldValue] of Object.entries(record)) {
|
|
553
564
|
if (preloadReferences.excludeColumns?.includes(fieldPropertyName)) {
|
|
554
565
|
continue;
|
|
555
566
|
}
|
|
@@ -558,11 +569,62 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
558
569
|
continue;
|
|
559
570
|
}
|
|
560
571
|
|
|
561
|
-
if (isInstanceOf(fieldValue, Reference)
|
|
562
|
-
|
|
572
|
+
if (isInstanceOf(fieldValue, Reference)) {
|
|
573
|
+
const reference = fieldValue as Reference<any>;
|
|
574
|
+
if (!reference._object && reference._id && !ReferenceCache.get().get(reference._table, reference._id)) {
|
|
575
|
+
pendingReferences.push(reference);
|
|
576
|
+
}
|
|
577
|
+
} else if (isInstanceOf(fieldValue, ReferenceArray)) {
|
|
578
|
+
const referenceArray = fieldValue as ReferenceArray<any>;
|
|
579
|
+
if (!referenceArray._objects) {
|
|
580
|
+
pendingReferenceArrays.push(referenceArray);
|
|
581
|
+
}
|
|
563
582
|
}
|
|
564
583
|
}
|
|
565
584
|
}
|
|
585
|
+
|
|
586
|
+
const idsByTable = new Map<string, Set<string>>();
|
|
587
|
+
const addIds = (tableName: string, ids: string[]) => {
|
|
588
|
+
if (ids.length === 0) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
let tableIds = idsByTable.get(tableName);
|
|
592
|
+
if (!tableIds) {
|
|
593
|
+
tableIds = new Set();
|
|
594
|
+
idsByTable.set(tableName, tableIds);
|
|
595
|
+
}
|
|
596
|
+
for (const id of ids) {
|
|
597
|
+
tableIds.add(id);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
for (const reference of pendingReferences) {
|
|
601
|
+
addIds(reference._table, [reference._id as string]);
|
|
602
|
+
}
|
|
603
|
+
for (const referenceArray of pendingReferenceArrays) {
|
|
604
|
+
addIds(referenceArray._table, referenceArray._ids);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const rowsByTable = new Map<string, Map<string, any>>();
|
|
608
|
+
await Promise.all(
|
|
609
|
+
Array.from(idsByTable.entries()).map(async ([tableName, tableIds]) => {
|
|
610
|
+
const table = this.getTable(tableName);
|
|
611
|
+
const qb = new QueryBuilderFactory()
|
|
612
|
+
.getQueryBuilder(table)
|
|
613
|
+
.condition({ field: 'id', operator: 'IN', value: Array.from(tableIds) });
|
|
614
|
+
const rows = await this.query(table, qb);
|
|
615
|
+
rowsByTable.set(tableName, new Map(rows.map((row) => [row.id, row])));
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
for (const reference of pendingReferences) {
|
|
620
|
+
reference._object = rowsByTable.get(reference._table)?.get(reference._id as string);
|
|
621
|
+
}
|
|
622
|
+
for (const referenceArray of pendingReferenceArrays) {
|
|
623
|
+
const rowsById = rowsByTable.get(referenceArray._table);
|
|
624
|
+
referenceArray._objects = referenceArray._ids
|
|
625
|
+
.map((id) => rowsById?.get(id))
|
|
626
|
+
.filter((row): row is any => row !== undefined);
|
|
627
|
+
}
|
|
566
628
|
}
|
|
567
629
|
|
|
568
630
|
async getRowCount<T extends R>(table: Table<T>, query?: Query<T>): Promise<number> {
|
package/src/Table.ts
CHANGED
|
@@ -79,7 +79,12 @@ export abstract class Table<T extends Record> implements Loadable, CustomSeriali
|
|
|
79
79
|
public __serializerId = TableSerializerId;
|
|
80
80
|
abstract name: string;
|
|
81
81
|
abstract columns: Columns<T>;
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* `unique: true` creates a UNIQUE index (composite uniqueness lives here; single-column
|
|
84
|
+
* uniqueness can also use `ColumnOptions.unique`). Name unique indexes with a `_unique`
|
|
85
|
+
* suffix — schema metadata classifies unique indexes by that suffix.
|
|
86
|
+
*/
|
|
87
|
+
public indexes: { columns: (keyof T)[]; name?: string; unique?: boolean }[] = [];
|
|
83
88
|
/** When records are deleted, delete records having references pointing to deleted records */
|
|
84
89
|
public cascadeDeleteReferences: () => { table: string; referenceColumn: string }[] = () => [];
|
|
85
90
|
/**
|
|
@@ -246,7 +246,7 @@ export class TableManager {
|
|
|
246
246
|
const serializedColumns = JSON.stringify(columns);
|
|
247
247
|
currentIndexMap[serializedColumns] = true;
|
|
248
248
|
if (!existingIndexMap[serializedColumns]) {
|
|
249
|
-
indexesToCreate.push({ name: index.name, columns });
|
|
249
|
+
indexesToCreate.push({ name: index.name, columns, unique: index.unique });
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
}
|
package/test/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './reusable/ColumnTypesTests';
|
|
|
4
4
|
export * from './reusable/DynamicReferenceColumnTests';
|
|
5
5
|
export * from './reusable/TransactionTests';
|
|
6
6
|
export * from './reusable/CascadeDeleteTests';
|
|
7
|
+
export * from './reusable/PreloadReferencesTests';
|
|
7
8
|
|
|
8
9
|
export * from './util/testUser';
|
|
9
10
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Db, DbDriver, QueryBuilderFactory, Reference, ReferenceArray, Table } from '@proteinjs/db';
|
|
2
|
+
import { DbTestEnvironment } from '../util/DbTestEnvironment';
|
|
3
|
+
import { dynamicReferenceTestTables } from '../util/tables/dynamicReferenceColumnTestTables';
|
|
4
|
+
import { cascadeDeleteTestTables } from '../util/tables/cascadeDeleteTestTables';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Preload behavior tests: `preloadReferences` must hydrate every reference in the result set
|
|
8
|
+
* with ONE query per referenced table — batching is the point of the option (a 30-row window
|
|
9
|
+
* preloaded per-row is 30 serialized point reads). The assertions count driver round trips,
|
|
10
|
+
* the outcome that keeps preload cheap, and then verify hydration is complete (no further
|
|
11
|
+
* round trips to read every referenced object).
|
|
12
|
+
*/
|
|
13
|
+
export const preloadReferencesTests = (driver: DbDriver, dropTable: (table: Table<any>) => Promise<void>) => {
|
|
14
|
+
return () => {
|
|
15
|
+
// Counting proxy: every runQuery through the driver increments `queryCount`. Db reads
|
|
16
|
+
// nothing else off the driver during query execution that matters here; everything not
|
|
17
|
+
// overridden delegates to the real driver via the prototype chain.
|
|
18
|
+
let queryCount = 0;
|
|
19
|
+
const countingDriver: DbDriver = Object.create(driver, {
|
|
20
|
+
runQuery: {
|
|
21
|
+
value: (...args: unknown[]) => {
|
|
22
|
+
queryCount++;
|
|
23
|
+
return (driver.runQuery as (...a: unknown[]) => Promise<unknown>).apply(driver, args);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const db = new Db(countingDriver);
|
|
28
|
+
const testEnv = new DbTestEnvironment(driver, dropTable);
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => await testEnv.beforeAll(), 30000);
|
|
31
|
+
afterAll(async () => await testEnv.afterAll(), 30000);
|
|
32
|
+
|
|
33
|
+
test('preload hydrates dynamic references with one query per referenced table', async () => {
|
|
34
|
+
const engineerA = await db.insert(dynamicReferenceTestTables.Engineer, {
|
|
35
|
+
name: 'preload-batch engineer A',
|
|
36
|
+
yearsOfExperience: 3,
|
|
37
|
+
});
|
|
38
|
+
const engineerB = await db.insert(dynamicReferenceTestTables.Engineer, {
|
|
39
|
+
name: 'preload-batch engineer B',
|
|
40
|
+
yearsOfExperience: 7,
|
|
41
|
+
});
|
|
42
|
+
const designer = await db.insert(dynamicReferenceTestTables.Designer, {
|
|
43
|
+
name: 'preload-batch designer',
|
|
44
|
+
specialization: 'Motion',
|
|
45
|
+
});
|
|
46
|
+
const employees = [engineerA, engineerB, designer];
|
|
47
|
+
const employeeTables = [
|
|
48
|
+
dynamicReferenceTestTables.Engineer.name,
|
|
49
|
+
dynamicReferenceTestTables.Engineer.name,
|
|
50
|
+
dynamicReferenceTestTables.Designer.name,
|
|
51
|
+
];
|
|
52
|
+
for (let i = 0; i < employees.length; i++) {
|
|
53
|
+
await db.insert(dynamicReferenceTestTables.ProjectAssignment, {
|
|
54
|
+
projectName: `preload-batch project ${i}`,
|
|
55
|
+
employeeRef: new Reference(employeeTables[i], employees[i].id),
|
|
56
|
+
startDate: '2026-01-01',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const qb = new QueryBuilderFactory()
|
|
61
|
+
.getQueryBuilder(dynamicReferenceTestTables.ProjectAssignment)
|
|
62
|
+
.condition({ field: 'projectName', operator: 'LIKE', value: 'preload-batch project %' });
|
|
63
|
+
queryCount = 0;
|
|
64
|
+
const assignments = await db.query(dynamicReferenceTestTables.ProjectAssignment, qb, {
|
|
65
|
+
preloadReferences: { enabled: true, includeColumns: ['employeeRef'] },
|
|
66
|
+
});
|
|
67
|
+
const queriesDuringPreload = queryCount;
|
|
68
|
+
|
|
69
|
+
expect(assignments.length).toBe(3);
|
|
70
|
+
// Hydration must be complete: every referenced object readable with ZERO further round trips.
|
|
71
|
+
queryCount = 0;
|
|
72
|
+
const names = (await Promise.all(assignments.map((assignment) => assignment.employeeRef!.get())))
|
|
73
|
+
.map((employee) => employee!.name)
|
|
74
|
+
.sort();
|
|
75
|
+
expect(names).toEqual(['preload-batch designer', 'preload-batch engineer A', 'preload-batch engineer B']);
|
|
76
|
+
expect(queryCount).toBe(0);
|
|
77
|
+
// One round trip for the assignments window, then one per referenced table
|
|
78
|
+
// (engineers, designers) — never one per row.
|
|
79
|
+
expect(queriesDuringPreload).toBe(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('preload hydrates reference arrays with one query per referenced table', async () => {
|
|
83
|
+
const memberIds: string[][] = [];
|
|
84
|
+
for (let g = 0; g < 2; g++) {
|
|
85
|
+
const ids: string[] = [];
|
|
86
|
+
for (let m = 0; m < 2; m++) {
|
|
87
|
+
const member = await db.insert(cascadeDeleteTestTables.MemberArr, {
|
|
88
|
+
name: `preload-arr member ${g}.${m}`,
|
|
89
|
+
});
|
|
90
|
+
ids.push(member.id);
|
|
91
|
+
}
|
|
92
|
+
memberIds.push(ids);
|
|
93
|
+
}
|
|
94
|
+
for (let g = 0; g < 2; g++) {
|
|
95
|
+
await db.insert(cascadeDeleteTestTables.GroupArr, {
|
|
96
|
+
groupName: `preload-arr group ${g}`,
|
|
97
|
+
memberRefs: new ReferenceArray(cascadeDeleteTestTables.MemberArr.name, memberIds[g]),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const qb = new QueryBuilderFactory()
|
|
102
|
+
.getQueryBuilder(cascadeDeleteTestTables.GroupArr)
|
|
103
|
+
.condition({ field: 'groupName', operator: 'LIKE', value: 'preload-arr group %' })
|
|
104
|
+
.sort([{ field: 'groupName', desc: false }]);
|
|
105
|
+
queryCount = 0;
|
|
106
|
+
const groups = await db.query(cascadeDeleteTestTables.GroupArr, qb, {
|
|
107
|
+
preloadReferences: { enabled: true },
|
|
108
|
+
});
|
|
109
|
+
const queriesDuringPreload = queryCount;
|
|
110
|
+
|
|
111
|
+
expect(groups.length).toBe(2);
|
|
112
|
+
// Hydration must be complete AND ordered per each array's _ids, with zero further round trips.
|
|
113
|
+
queryCount = 0;
|
|
114
|
+
for (let g = 0; g < 2; g++) {
|
|
115
|
+
const members = await groups[g].memberRefs!.get();
|
|
116
|
+
expect(members.map((member) => member.name)).toEqual([
|
|
117
|
+
`preload-arr member ${g}.0`,
|
|
118
|
+
`preload-arr member ${g}.1`,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
expect(queryCount).toBe(0);
|
|
122
|
+
// One round trip for the groups, one for ALL members across both arrays.
|
|
123
|
+
expect(queriesDuringPreload).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
};
|