@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.28.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 fields = Object.entries(record);
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) || isInstanceOf(fieldValue, ReferenceArray)) {
562
- await (fieldValue as Reference<any> | ReferenceArray<any>).get();
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
- public indexes: { columns: (keyof T)[]; name?: string }[] = [];
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
+ };