@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e
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/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index.es.js +1254 -591
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1254 -591
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
- package/dist/server-postgresql/src/auth/services.d.ts +7 -3
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/server-postgresql/src/connection.d.ts +34 -1
- package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
- package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
- package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
- package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
- package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/package.json +88 -89
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +63 -79
- package/src/PostgresBootstrapper.ts +7 -8
- package/src/auth/ensure-tables.ts +158 -86
- package/src/auth/services.ts +109 -50
- package/src/cli.ts +259 -16
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/connection.ts +70 -48
- package/src/data-transformer.ts +155 -116
- package/src/databasePoolManager.ts +6 -5
- package/src/history/HistoryService.ts +3 -12
- package/src/interfaces.ts +3 -3
- package/src/schema/auth-schema.ts +26 -3
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +204 -57
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +5 -5
- package/src/services/EntityFetchService.ts +317 -188
- package/src/services/EntityPersistService.ts +15 -17
- package/src/services/RelationService.ts +299 -37
- package/src/services/entity-helpers.ts +39 -13
- package/src/services/entityService.ts +11 -9
- package/src/services/realtimeService.ts +58 -29
- package/src/utils/drizzle-conditions.ts +25 -24
- package/src/websocket.ts +52 -21
- package/test/auth-services.test.ts +131 -39
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +22 -12
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/entityService.errors.test.ts +31 -16
- package/test/entityService.relations.test.ts +155 -59
- package/test/entityService.subcollection-search.test.ts +107 -57
- package/test/entityService.test.ts +105 -47
- package/test/generate-drizzle-schema.test.ts +262 -69
- package/test/historyService.test.ts +31 -16
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +260 -168
- package/test/realtimeService.test.ts +70 -39
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +492 -39
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +2 -2
- package/test_find_changed.mjs +3 -1
- package/test_hash.js +14 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +5 -5
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { HistoryService, findChangedFields } from "../src/history/HistoryService";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { DrizzleClient } from "../src/interfaces";
|
|
4
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
4
5
|
|
|
5
6
|
describe("HistoryService - changedFields and history insertion logic", () => {
|
|
6
7
|
describe("findChangedFields", () => {
|
|
7
8
|
it("should return null when identical flat objects are compared", () => {
|
|
8
|
-
const oldValues = { title: "Hello",
|
|
9
|
-
|
|
9
|
+
const oldValues = { title: "Hello",
|
|
10
|
+
description: "World" };
|
|
11
|
+
const newValues = { title: "Hello",
|
|
12
|
+
description: "World" };
|
|
10
13
|
const result = findChangedFields(oldValues, newValues);
|
|
11
14
|
expect(result).toBeNull();
|
|
12
15
|
});
|
|
@@ -19,19 +22,25 @@ describe("HistoryService - changedFields and history insertion logic", () => {
|
|
|
19
22
|
});
|
|
20
23
|
|
|
21
24
|
it("should skip properties starting with double underscore", () => {
|
|
22
|
-
const oldValues = { title: "Hello",
|
|
23
|
-
|
|
25
|
+
const oldValues = { title: "Hello",
|
|
26
|
+
__internal: 123 };
|
|
27
|
+
const newValues = { title: "Hello",
|
|
28
|
+
__internal: 456 };
|
|
24
29
|
const result = findChangedFields(oldValues, newValues);
|
|
25
30
|
expect(result).toBeNull();
|
|
26
31
|
});
|
|
27
32
|
|
|
28
33
|
it("should return null for deeply identical relations", () => {
|
|
29
34
|
const oldValues = {
|
|
30
|
-
author: { id: "1",
|
|
35
|
+
author: { id: "1",
|
|
36
|
+
path: "authors",
|
|
37
|
+
__type: "relation" },
|
|
31
38
|
tags: [{ id: "1" }, { id: "2" }]
|
|
32
39
|
};
|
|
33
40
|
const newValues = {
|
|
34
|
-
author: { id: "1",
|
|
41
|
+
author: { id: "1",
|
|
42
|
+
path: "authors",
|
|
43
|
+
__type: "relation" },
|
|
35
44
|
tags: [{ id: "1" }, { id: "2" }]
|
|
36
45
|
};
|
|
37
46
|
const result = findChangedFields(oldValues as Record<string, unknown>, newValues as Record<string, unknown>);
|
|
@@ -40,15 +49,19 @@ describe("HistoryService - changedFields and history insertion logic", () => {
|
|
|
40
49
|
|
|
41
50
|
it("should detect changes in relation properties when IDs differ", () => {
|
|
42
51
|
const oldValues = {
|
|
43
|
-
author: { id: "1",
|
|
52
|
+
author: { id: "1",
|
|
53
|
+
path: "authors",
|
|
54
|
+
__type: "relation" }
|
|
44
55
|
};
|
|
45
56
|
const newValues = {
|
|
46
|
-
author: { id: "2",
|
|
57
|
+
author: { id: "2",
|
|
58
|
+
path: "authors",
|
|
59
|
+
__type: "relation" }
|
|
47
60
|
};
|
|
48
61
|
const result = findChangedFields(oldValues as Record<string, unknown>, newValues as Record<string, unknown>);
|
|
49
62
|
expect(result).toEqual(["author"]);
|
|
50
63
|
});
|
|
51
|
-
|
|
64
|
+
|
|
52
65
|
it("should detect differences in relation arrays", () => {
|
|
53
66
|
const oldValues = {
|
|
54
67
|
tags: [{ id: "1" }]
|
|
@@ -69,8 +82,8 @@ describe("HistoryService - changedFields and history insertion logic", () => {
|
|
|
69
82
|
db = {
|
|
70
83
|
execute: jest.fn().mockResolvedValue({})
|
|
71
84
|
} as unknown as jest.Mocked<NodePgDatabase>;
|
|
72
|
-
historyService = new HistoryService(db as unknown as DrizzleClient, {} as
|
|
73
|
-
jest.spyOn(console,
|
|
85
|
+
historyService = new HistoryService(db as unknown as DrizzleClient, {} as unknown as PostgresCollectionRegistry);
|
|
86
|
+
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
74
87
|
});
|
|
75
88
|
|
|
76
89
|
afterEach(() => {
|
|
@@ -83,7 +96,7 @@ describe("HistoryService - changedFields and history insertion logic", () => {
|
|
|
83
96
|
entityId: "1",
|
|
84
97
|
action: "update",
|
|
85
98
|
previousValues: { title: "same" },
|
|
86
|
-
values: { title: "same" }
|
|
99
|
+
values: { title: "same" }
|
|
87
100
|
});
|
|
88
101
|
|
|
89
102
|
// db.execute should not be called since there is no data to log
|
|
@@ -95,15 +108,17 @@ describe("HistoryService - changedFields and history insertion logic", () => {
|
|
|
95
108
|
tableName: "posts",
|
|
96
109
|
entityId: "1",
|
|
97
110
|
action: "update",
|
|
98
|
-
previousValues: { title: "old",
|
|
99
|
-
|
|
111
|
+
previousValues: { title: "old",
|
|
112
|
+
tags: [{ id: 1 }] },
|
|
113
|
+
values: { title: "new",
|
|
114
|
+
tags: [{ id: 2 }] }
|
|
100
115
|
});
|
|
101
116
|
|
|
102
117
|
// Since it's a difference, db.execute should be called. (plus 2 prune calls)
|
|
103
118
|
expect(db.execute.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
104
|
-
|
|
119
|
+
|
|
105
120
|
const executedSql = db.execute.mock.calls[0][0] as unknown as { query: string; sql?: string; strings?: string[]; values?: unknown[] };
|
|
106
|
-
|
|
121
|
+
|
|
107
122
|
// Drizzle wraps SQL in its own SQL type which contains sql strings and params.
|
|
108
123
|
const serializedSql = JSON.stringify(executedSql);
|
|
109
124
|
// The syntax we added is ARRAY[?]::text[] or similar
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* N+1 Query Regression Test
|
|
3
|
+
*
|
|
4
|
+
* Validates that batch-loading of owning relations (e.g. posts → author)
|
|
5
|
+
* uses O(1) SQL queries instead of one query per entity.
|
|
6
|
+
*
|
|
7
|
+
* Root cause of the original bug:
|
|
8
|
+
* `batchFetchRelatedEntities` for owning relations was passing parent
|
|
9
|
+
* entity IDs (post IDs) to the query instead of the FK values (author IDs).
|
|
10
|
+
* This meant `WHERE authors.id IN (1,2,3,...50)` used post IDs — completely
|
|
11
|
+
* wrong. The fix reads FK values from the parent table first, then queries
|
|
12
|
+
* the target table with correct FK values.
|
|
13
|
+
*/
|
|
14
|
+
import { RelationService } from "../src/services/RelationService";
|
|
15
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
16
|
+
import type { EntityCollection, Relation } from "@rebasepro/types";
|
|
17
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
18
|
+
|
|
19
|
+
// ─── Mock Tables ──────────────────────────────────────────────────
|
|
20
|
+
const mockAuthorsTable = {
|
|
21
|
+
id: { name: "id",
|
|
22
|
+
dataType: "number" },
|
|
23
|
+
name: { name: "name" },
|
|
24
|
+
_def: { tableName: "authors" }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const mockPostsTable = {
|
|
28
|
+
id: { name: "id",
|
|
29
|
+
dataType: "number" },
|
|
30
|
+
title: { name: "title" },
|
|
31
|
+
author_id: { name: "author_id",
|
|
32
|
+
dataType: "number" },
|
|
33
|
+
_def: { tableName: "posts" }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ─── Mock Collections ─────────────────────────────────────────────
|
|
37
|
+
let authorsCollection: EntityCollection;
|
|
38
|
+
|
|
39
|
+
const postsCollection: EntityCollection = {
|
|
40
|
+
slug: "posts",
|
|
41
|
+
name: "Posts",
|
|
42
|
+
table: "posts",
|
|
43
|
+
properties: {
|
|
44
|
+
id: { type: "number" },
|
|
45
|
+
title: { type: "string" },
|
|
46
|
+
author: {
|
|
47
|
+
type: "relation",
|
|
48
|
+
relationName: "author"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
relations: [
|
|
52
|
+
{
|
|
53
|
+
relationName: "author",
|
|
54
|
+
target: () => authorsCollection,
|
|
55
|
+
cardinality: "one",
|
|
56
|
+
direction: "owning",
|
|
57
|
+
localKey: "author_id"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
idField: "id"
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
authorsCollection = {
|
|
64
|
+
slug: "authors",
|
|
65
|
+
name: "Authors",
|
|
66
|
+
table: "authors",
|
|
67
|
+
properties: {
|
|
68
|
+
id: { type: "number" },
|
|
69
|
+
name: { type: "string" }
|
|
70
|
+
},
|
|
71
|
+
idField: "id"
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ─── Data generators ─────────────────────────────────────────────
|
|
75
|
+
const NUM_POSTS = 50;
|
|
76
|
+
const NUM_AUTHORS = 5;
|
|
77
|
+
|
|
78
|
+
function generateMockPosts(count: number): Array<{ id: number; title: string; author_id: number }> {
|
|
79
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
80
|
+
id: i + 1,
|
|
81
|
+
title: `Post ${i + 1}`,
|
|
82
|
+
author_id: (i % NUM_AUTHORS) + 1
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function generateMockAuthors(count: number): Array<{ id: number; name: string }> {
|
|
87
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
88
|
+
id: i + 1,
|
|
89
|
+
name: `Author ${i + 1}`
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Tests ────────────────────────────────────────────────────────
|
|
94
|
+
describe("N+1 Query Regression: batchFetchRelatedEntities (owning relations)", () => {
|
|
95
|
+
let registry: PostgresCollectionRegistry;
|
|
96
|
+
let selectCallCount: number;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a mock Drizzle database that counts `select()` calls.
|
|
100
|
+
* Routes responses based on the table being queried.
|
|
101
|
+
*/
|
|
102
|
+
function createSpiedDb(
|
|
103
|
+
mockPosts: ReturnType<typeof generateMockPosts>,
|
|
104
|
+
mockAuthors: ReturnType<typeof generateMockAuthors>
|
|
105
|
+
) {
|
|
106
|
+
selectCallCount = 0;
|
|
107
|
+
let currentFromTable: string | undefined;
|
|
108
|
+
let currentSelectColumns: Record<string, unknown> | undefined;
|
|
109
|
+
|
|
110
|
+
function resolveResults(): unknown[] {
|
|
111
|
+
if (currentFromTable === "posts") {
|
|
112
|
+
// If selecting specific columns (parentId, fkValue shape from the FK lookup)
|
|
113
|
+
if (currentSelectColumns && Object.keys(currentSelectColumns).some(k => k === "parentId" || k === "fkValue")) {
|
|
114
|
+
return mockPosts.map(p => ({
|
|
115
|
+
parentId: p.id,
|
|
116
|
+
fkValue: p.author_id
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
return mockPosts;
|
|
120
|
+
}
|
|
121
|
+
if (currentFromTable === "authors") {
|
|
122
|
+
return mockAuthors;
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeChainable(): Record<string, unknown> {
|
|
128
|
+
const chain: Record<string, unknown> = {
|
|
129
|
+
select: jest.fn((columns?: Record<string, unknown>) => {
|
|
130
|
+
selectCallCount++;
|
|
131
|
+
currentSelectColumns = columns;
|
|
132
|
+
return chain;
|
|
133
|
+
}),
|
|
134
|
+
from: jest.fn((table: Record<string, unknown>) => {
|
|
135
|
+
const tableDef = table._def as { tableName: string } | undefined;
|
|
136
|
+
currentFromTable = tableDef?.tableName ?? "unknown";
|
|
137
|
+
return chain;
|
|
138
|
+
}),
|
|
139
|
+
where: jest.fn(() => chain),
|
|
140
|
+
$dynamic: jest.fn(() => chain),
|
|
141
|
+
limit: jest.fn(() => chain),
|
|
142
|
+
offset: jest.fn(() => chain),
|
|
143
|
+
orderBy: jest.fn(() => chain),
|
|
144
|
+
innerJoin: jest.fn(() => chain),
|
|
145
|
+
// Make it thenable so `await query` resolves to results
|
|
146
|
+
then: (resolve: (val: unknown[]) => void) => {
|
|
147
|
+
resolve(resolveResults());
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return chain;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return makeChainable() as unknown as jest.Mocked<NodePgDatabase>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
registry = new PostgresCollectionRegistry();
|
|
158
|
+
|
|
159
|
+
jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
|
|
160
|
+
if (path?.startsWith("posts")) return postsCollection;
|
|
161
|
+
if (path?.startsWith("authors")) return authorsCollection;
|
|
162
|
+
return undefined;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
jest.spyOn(registry, "getTable").mockImplementation(tableName => {
|
|
166
|
+
if (tableName === "posts") return mockPostsTable as unknown as ReturnType<typeof registry.getTable>;
|
|
167
|
+
if (tableName === "authors") return mockAuthorsTable as unknown as ReturnType<typeof registry.getTable>;
|
|
168
|
+
return undefined;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
jest.spyOn(registry, "getCollections").mockReturnValue([postsCollection, authorsCollection]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
jest.restoreAllMocks();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should use exactly 2 SQL queries for owning relation batch fetch (not N+1)", async () => {
|
|
179
|
+
const mockPosts = generateMockPosts(NUM_POSTS);
|
|
180
|
+
const mockAuthors = generateMockAuthors(NUM_AUTHORS);
|
|
181
|
+
const db = createSpiedDb(mockPosts, mockAuthors);
|
|
182
|
+
|
|
183
|
+
const relationService = new RelationService(db, registry);
|
|
184
|
+
selectCallCount = 0;
|
|
185
|
+
|
|
186
|
+
const postIds = mockPosts.map(p => p.id);
|
|
187
|
+
const authorRelation = postsCollection.relations![0] as Relation;
|
|
188
|
+
|
|
189
|
+
const results = await relationService.batchFetchRelatedEntities(
|
|
190
|
+
"posts",
|
|
191
|
+
postIds,
|
|
192
|
+
"author",
|
|
193
|
+
authorRelation
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Exactly 2 queries:
|
|
197
|
+
// 1. SELECT id, author_id FROM posts WHERE id IN (...) — FK lookup
|
|
198
|
+
// 2. SELECT * FROM authors WHERE id IN (...) — target fetch
|
|
199
|
+
//
|
|
200
|
+
// The old broken code would have done either:
|
|
201
|
+
// - 1 wrong query (WHERE authors.id IN (post_ids) — wrong IDs)
|
|
202
|
+
// - Or N queries (one per entity)
|
|
203
|
+
expect(selectCallCount).toBe(2);
|
|
204
|
+
|
|
205
|
+
console.log(`[N+1 Regression] ${NUM_POSTS} posts: batchFetchRelatedEntities used ${selectCallCount} queries`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should correctly map each post to its author via FK values", async () => {
|
|
209
|
+
const mockPosts = generateMockPosts(NUM_POSTS);
|
|
210
|
+
const mockAuthors = generateMockAuthors(NUM_AUTHORS);
|
|
211
|
+
const db = createSpiedDb(mockPosts, mockAuthors);
|
|
212
|
+
|
|
213
|
+
const relationService = new RelationService(db, registry);
|
|
214
|
+
|
|
215
|
+
const postIds = mockPosts.map(p => p.id);
|
|
216
|
+
const authorRelation = postsCollection.relations![0] as Relation;
|
|
217
|
+
|
|
218
|
+
const results = await relationService.batchFetchRelatedEntities(
|
|
219
|
+
"posts",
|
|
220
|
+
postIds,
|
|
221
|
+
"author",
|
|
222
|
+
authorRelation
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Every post should have a resolved author
|
|
226
|
+
for (const post of mockPosts) {
|
|
227
|
+
const authorEntity = results.get(String(post.id));
|
|
228
|
+
expect(authorEntity).toBeDefined();
|
|
229
|
+
expect(authorEntity!.path).toBe("authors");
|
|
230
|
+
// The author ID should match the FK value, not the post ID
|
|
231
|
+
expect(authorEntity!.id).toBe(String(post.author_id));
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should handle null FK values gracefully (only 1 query for FK lookup)", async () => {
|
|
236
|
+
const nullFkPosts = Array.from({ length: 5 }, (_, i) => ({
|
|
237
|
+
id: i + 1,
|
|
238
|
+
title: `Orphan Post ${i + 1}`,
|
|
239
|
+
author_id: null as unknown as number
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
const db = createSpiedDb(nullFkPosts as ReturnType<typeof generateMockPosts>, []);
|
|
243
|
+
const relationService = new RelationService(db, registry);
|
|
244
|
+
selectCallCount = 0;
|
|
245
|
+
|
|
246
|
+
const postIds = nullFkPosts.map(p => p.id);
|
|
247
|
+
const authorRelation = postsCollection.relations![0] as Relation;
|
|
248
|
+
|
|
249
|
+
const results = await relationService.batchFetchRelatedEntities(
|
|
250
|
+
"posts",
|
|
251
|
+
postIds,
|
|
252
|
+
"author",
|
|
253
|
+
authorRelation
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Only 1 query (FK lookup) then early-return since all FKs are null
|
|
257
|
+
expect(selectCallCount).toBe(1);
|
|
258
|
+
expect(results.size).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should deduplicate FK values when multiple entities share the same relation target", async () => {
|
|
262
|
+
// All 50 posts point to author 1
|
|
263
|
+
const sameAuthorPosts = Array.from({ length: NUM_POSTS }, (_, i) => ({
|
|
264
|
+
id: i + 1,
|
|
265
|
+
title: `Post ${i + 1}`,
|
|
266
|
+
author_id: 1
|
|
267
|
+
}));
|
|
268
|
+
const singleAuthor = [{ id: 1,
|
|
269
|
+
name: "Shared Author" }];
|
|
270
|
+
|
|
271
|
+
const db = createSpiedDb(sameAuthorPosts, singleAuthor);
|
|
272
|
+
const relationService = new RelationService(db, registry);
|
|
273
|
+
selectCallCount = 0;
|
|
274
|
+
|
|
275
|
+
const postIds = sameAuthorPosts.map(p => p.id);
|
|
276
|
+
const authorRelation = postsCollection.relations![0] as Relation;
|
|
277
|
+
|
|
278
|
+
const results = await relationService.batchFetchRelatedEntities(
|
|
279
|
+
"posts",
|
|
280
|
+
postIds,
|
|
281
|
+
"author",
|
|
282
|
+
authorRelation
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Still exactly 2 queries
|
|
286
|
+
expect(selectCallCount).toBe(2);
|
|
287
|
+
|
|
288
|
+
// All 50 posts should resolve to the same author
|
|
289
|
+
for (const post of sameAuthorPosts) {
|
|
290
|
+
const authorEntity = results.get(String(post.id));
|
|
291
|
+
expect(authorEntity).toBeDefined();
|
|
292
|
+
expect(authorEntity!.id).toBe("1");
|
|
293
|
+
expect(authorEntity!.path).toBe("authors");
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("should handle empty parent IDs array without any queries", async () => {
|
|
298
|
+
const db = createSpiedDb([], []);
|
|
299
|
+
const relationService = new RelationService(db, registry);
|
|
300
|
+
selectCallCount = 0;
|
|
301
|
+
|
|
302
|
+
const authorRelation = postsCollection.relations![0] as Relation;
|
|
303
|
+
|
|
304
|
+
const results = await relationService.batchFetchRelatedEntities(
|
|
305
|
+
"posts",
|
|
306
|
+
[],
|
|
307
|
+
"author",
|
|
308
|
+
authorRelation
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(selectCallCount).toBe(0);
|
|
312
|
+
expect(results.size).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
});
|