@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
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation pipeline gap-analysis tests
|
|
3
|
+
*
|
|
4
|
+
* Covers the remaining risk areas identified by gap analysis:
|
|
5
|
+
*
|
|
6
|
+
* 1. 🔴 ID type coercion in batchFetchRelatedEntities (single-cardinality)
|
|
7
|
+
* — parsedParentIds.includes(parentId) used strict ===, silently dropping
|
|
8
|
+
* all inverse results when Drizzle returned string IDs for numeric PKs.
|
|
9
|
+
* Fixed by using Set<string> + String() normalization.
|
|
10
|
+
*
|
|
11
|
+
* 2. 🟡 Inverse M2M `through` write-path warning
|
|
12
|
+
* — updateRelationsUsingJoins had no explicit handler for inverse M2M
|
|
13
|
+
* through relations; they fell to a generic warning. Now emits a
|
|
14
|
+
* specific, actionable message.
|
|
15
|
+
*
|
|
16
|
+
* 3. 🟢 sanitizeRelation junction table naming convention
|
|
17
|
+
* — Verifies that auto-inferred junction table names from sorted slugs
|
|
18
|
+
* match expectations for various collection name patterns.
|
|
19
|
+
*/
|
|
20
|
+
import { RelationService } from "../src/services/RelationService";
|
|
21
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
22
|
+
import { EntityCollection, Relation } from "@rebasepro/types";
|
|
23
|
+
import { sanitizeRelation } from "@rebasepro/common";
|
|
24
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
25
|
+
|
|
26
|
+
// ─── Mock Tables ──────────────────────────────────────────────────────
|
|
27
|
+
const mockPostsTable = {
|
|
28
|
+
id: { name: "id", dataType: "number" },
|
|
29
|
+
title: { name: "title" },
|
|
30
|
+
author_id: { name: "author_id", dataType: "number" },
|
|
31
|
+
_def: { tableName: "posts" }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const mockTagsTable = {
|
|
35
|
+
id: { name: "id", dataType: "number" },
|
|
36
|
+
name: { name: "name" },
|
|
37
|
+
_def: { tableName: "tags" }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mockPostsTagsTable = {
|
|
41
|
+
post_id: { name: "post_id", dataType: "number" },
|
|
42
|
+
tag_id: { name: "tag_id", dataType: "number" },
|
|
43
|
+
_def: { tableName: "posts_tags" }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mockAuthorsTable = {
|
|
47
|
+
id: { name: "id", dataType: "number" },
|
|
48
|
+
name: { name: "name" },
|
|
49
|
+
_def: { tableName: "authors" }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ─── Mock Collections ─────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const tagsCollection: EntityCollection = {
|
|
55
|
+
slug: "tags",
|
|
56
|
+
name: "Tags",
|
|
57
|
+
table: "tags",
|
|
58
|
+
properties: {
|
|
59
|
+
id: { type: "number" },
|
|
60
|
+
name: { type: "string" }
|
|
61
|
+
},
|
|
62
|
+
idField: "id"
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const postsCollection: EntityCollection = {
|
|
66
|
+
slug: "posts",
|
|
67
|
+
name: "Posts",
|
|
68
|
+
table: "posts",
|
|
69
|
+
properties: {
|
|
70
|
+
id: { type: "number" },
|
|
71
|
+
title: { type: "string" },
|
|
72
|
+
tags: { type: "relation", relationName: "tags" }
|
|
73
|
+
},
|
|
74
|
+
relations: [
|
|
75
|
+
{
|
|
76
|
+
relationName: "tags",
|
|
77
|
+
target: () => tagsCollection,
|
|
78
|
+
cardinality: "many",
|
|
79
|
+
direction: "owning",
|
|
80
|
+
through: {
|
|
81
|
+
table: "posts_tags",
|
|
82
|
+
sourceColumn: "post_id",
|
|
83
|
+
targetColumn: "tag_id"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
idField: "id"
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const authorsCollection: EntityCollection = {
|
|
91
|
+
slug: "authors",
|
|
92
|
+
name: "Authors",
|
|
93
|
+
table: "authors",
|
|
94
|
+
properties: {
|
|
95
|
+
id: { type: "number" },
|
|
96
|
+
name: { type: "string" },
|
|
97
|
+
posts: { type: "relation", relationName: "posts" }
|
|
98
|
+
},
|
|
99
|
+
relations: [
|
|
100
|
+
{
|
|
101
|
+
relationName: "posts",
|
|
102
|
+
target: () => postsCollection,
|
|
103
|
+
cardinality: "many",
|
|
104
|
+
direction: "inverse",
|
|
105
|
+
foreignKeyOnTarget: "author_id"
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
idField: "id"
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Inverse M2M: tags → posts (from tag's perspective)
|
|
112
|
+
const tagsWithInversePosts: EntityCollection = {
|
|
113
|
+
slug: "tags_inv",
|
|
114
|
+
name: "Tags (inverse)",
|
|
115
|
+
table: "tags",
|
|
116
|
+
properties: {
|
|
117
|
+
id: { type: "number" },
|
|
118
|
+
name: { type: "string" },
|
|
119
|
+
posts: { type: "relation", relationName: "posts" }
|
|
120
|
+
},
|
|
121
|
+
relations: [
|
|
122
|
+
{
|
|
123
|
+
relationName: "posts",
|
|
124
|
+
target: () => postsCollection,
|
|
125
|
+
cardinality: "many",
|
|
126
|
+
direction: "inverse",
|
|
127
|
+
through: {
|
|
128
|
+
table: "posts_tags",
|
|
129
|
+
sourceColumn: "post_id",
|
|
130
|
+
targetColumn: "tag_id"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
idField: "id"
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ─── Mock DB Factory ──────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function createMockDb(resolveResults: () => unknown[]) {
|
|
140
|
+
const recorder = {
|
|
141
|
+
selectCount: 0,
|
|
142
|
+
innerJoinCount: 0,
|
|
143
|
+
fromTable: undefined as string | undefined,
|
|
144
|
+
deleteCalls: [] as unknown[],
|
|
145
|
+
insertCalls: [] as unknown[],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function makeChainable(): Record<string, unknown> {
|
|
149
|
+
const chain: Record<string, unknown> = {
|
|
150
|
+
select: jest.fn(() => {
|
|
151
|
+
recorder.selectCount++;
|
|
152
|
+
return chain;
|
|
153
|
+
}),
|
|
154
|
+
from: jest.fn((table: Record<string, unknown>) => {
|
|
155
|
+
const tableDef = table._def as { tableName: string } | undefined;
|
|
156
|
+
recorder.fromTable = tableDef?.tableName ?? "unknown";
|
|
157
|
+
return chain;
|
|
158
|
+
}),
|
|
159
|
+
where: jest.fn(() => chain),
|
|
160
|
+
$dynamic: jest.fn(() => chain),
|
|
161
|
+
limit: jest.fn(() => chain),
|
|
162
|
+
offset: jest.fn(() => chain),
|
|
163
|
+
orderBy: jest.fn(() => chain),
|
|
164
|
+
innerJoin: jest.fn(() => {
|
|
165
|
+
recorder.innerJoinCount++;
|
|
166
|
+
return chain;
|
|
167
|
+
}),
|
|
168
|
+
delete: jest.fn((table: unknown) => {
|
|
169
|
+
recorder.deleteCalls.push(table);
|
|
170
|
+
return chain;
|
|
171
|
+
}),
|
|
172
|
+
insert: jest.fn((table: unknown) => {
|
|
173
|
+
recorder.insertCalls.push(table);
|
|
174
|
+
return { values: jest.fn(() => chain) };
|
|
175
|
+
}),
|
|
176
|
+
set: jest.fn(() => chain),
|
|
177
|
+
values: jest.fn(() => chain),
|
|
178
|
+
then: (resolve: (val: unknown[]) => void) => {
|
|
179
|
+
resolve(resolveResults());
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
return chain;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
db: makeChainable() as unknown as jest.Mocked<NodePgDatabase>,
|
|
187
|
+
recorder,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
192
|
+
// 1. ID type coercion in batchFetchRelatedEntities (single cardinality)
|
|
193
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
194
|
+
|
|
195
|
+
describe("batchFetchRelatedEntities: ID type coercion (single cardinality)", () => {
|
|
196
|
+
let registry: PostgresCollectionRegistry;
|
|
197
|
+
|
|
198
|
+
beforeEach(() => {
|
|
199
|
+
registry = new PostgresCollectionRegistry();
|
|
200
|
+
|
|
201
|
+
jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
|
|
202
|
+
if (path?.startsWith("posts")) return postsCollection;
|
|
203
|
+
if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
|
|
204
|
+
if (path?.startsWith("tags")) return tagsCollection;
|
|
205
|
+
if (path?.startsWith("authors")) return authorsCollection;
|
|
206
|
+
return undefined;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
jest.spyOn(registry, "getTable").mockImplementation(tableName => {
|
|
210
|
+
if (tableName === "posts") return mockPostsTable as any;
|
|
211
|
+
if (tableName === "tags") return mockTagsTable as any;
|
|
212
|
+
if (tableName === "posts_tags") return mockPostsTagsTable as any;
|
|
213
|
+
if (tableName === "authors") return mockAuthorsTable as any;
|
|
214
|
+
return undefined;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
jest.spyOn(registry, "getCollections").mockReturnValue([
|
|
218
|
+
postsCollection, tagsCollection, authorsCollection
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
afterEach(() => {
|
|
223
|
+
jest.restoreAllMocks();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("should match results when Drizzle returns string IDs but parsedParentIds are numbers (FK inverse)", async () => {
|
|
227
|
+
// Simulate Drizzle returning author_id as a string even though it's a numeric column
|
|
228
|
+
const resultRows = [
|
|
229
|
+
{ id: 10, title: "Post A", author_id: "1" },
|
|
230
|
+
{ id: 20, title: "Post B", author_id: "2" },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const { db } = createMockDb(() => resultRows);
|
|
234
|
+
const service = new RelationService(db, registry);
|
|
235
|
+
const relation = authorsCollection.relations![0] as Relation;
|
|
236
|
+
|
|
237
|
+
// Pass numeric parent IDs — parseIdValues will return numbers
|
|
238
|
+
const results = await service.batchFetchRelatedEntities(
|
|
239
|
+
"authors", [1, 2], "posts", relation
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Before fix: both results would be silently dropped because
|
|
243
|
+
// parsedParentIds.includes("1") returns false when parsedParentIds contains 1
|
|
244
|
+
expect(results.get("1")).toBeDefined();
|
|
245
|
+
expect(results.get("1")!.values.title).toBe("Post A");
|
|
246
|
+
expect(results.get("2")).toBeDefined();
|
|
247
|
+
expect(results.get("2")!.values.title).toBe("Post B");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("should match results when Drizzle returns number IDs but parsedParentIds contain strings", async () => {
|
|
251
|
+
// Simulate the reverse: Drizzle returns numbers, but parsed IDs might be strings
|
|
252
|
+
const resultRows = [
|
|
253
|
+
{ id: 10, title: "Post A", author_id: 1 },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const { db } = createMockDb(() => resultRows);
|
|
257
|
+
const service = new RelationService(db, registry);
|
|
258
|
+
const relation = authorsCollection.relations![0] as Relation;
|
|
259
|
+
|
|
260
|
+
const results = await service.batchFetchRelatedEntities(
|
|
261
|
+
"authors", [1], "posts", relation
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(results.get("1")).toBeDefined();
|
|
265
|
+
expect(results.get("1")!.values.title).toBe("Post A");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should handle mixed string and number IDs across result rows", async () => {
|
|
269
|
+
const resultRows = [
|
|
270
|
+
{ id: 10, title: "Post A", author_id: 1 }, // number
|
|
271
|
+
{ id: 20, title: "Post B", author_id: "2" }, // string
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
const { db } = createMockDb(() => resultRows);
|
|
275
|
+
const service = new RelationService(db, registry);
|
|
276
|
+
const relation = authorsCollection.relations![0] as Relation;
|
|
277
|
+
|
|
278
|
+
const results = await service.batchFetchRelatedEntities(
|
|
279
|
+
"authors", [1, 2], "posts", relation
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(results.get("1")).toBeDefined();
|
|
283
|
+
expect(results.get("2")).toBeDefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("should not match results for IDs not in the parent set", async () => {
|
|
287
|
+
const resultRows = [
|
|
288
|
+
{ id: 10, title: "Post A", author_id: "999" },
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const { db } = createMockDb(() => resultRows);
|
|
292
|
+
const service = new RelationService(db, registry);
|
|
293
|
+
const relation = authorsCollection.relations![0] as Relation;
|
|
294
|
+
|
|
295
|
+
const results = await service.batchFetchRelatedEntities(
|
|
296
|
+
"authors", [1, 2], "posts", relation
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// 999 is not in the parent set
|
|
300
|
+
expect(results.size).toBe(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("should handle inferredForeignKeyName path with string IDs", async () => {
|
|
304
|
+
// Test the `inverseRelationName`-based FK inference path
|
|
305
|
+
const authorsWithInverseNameOnly: EntityCollection = {
|
|
306
|
+
slug: "authors_inr",
|
|
307
|
+
name: "Authors (inverseRelationName)",
|
|
308
|
+
table: "authors",
|
|
309
|
+
properties: {
|
|
310
|
+
id: { type: "number" },
|
|
311
|
+
name: { type: "string" }
|
|
312
|
+
},
|
|
313
|
+
idField: "id"
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const postsWithFK: EntityCollection = {
|
|
317
|
+
slug: "posts_fk",
|
|
318
|
+
name: "Posts (FK)",
|
|
319
|
+
table: "posts",
|
|
320
|
+
properties: {
|
|
321
|
+
id: { type: "number" },
|
|
322
|
+
title: { type: "string" }
|
|
323
|
+
},
|
|
324
|
+
idField: "id"
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
|
|
328
|
+
if (path?.startsWith("authors_inr")) return authorsWithInverseNameOnly;
|
|
329
|
+
if (path?.startsWith("posts_fk")) return postsWithFK;
|
|
330
|
+
return undefined;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const relation: Relation = {
|
|
334
|
+
relationName: "posts",
|
|
335
|
+
target: () => postsWithFK,
|
|
336
|
+
cardinality: "one",
|
|
337
|
+
direction: "inverse",
|
|
338
|
+
inverseRelationName: "author"
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Drizzle returns author_id as string
|
|
342
|
+
const resultRows = [
|
|
343
|
+
{ id: 10, title: "Post A", author_id: "1" },
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
const { db } = createMockDb(() => resultRows);
|
|
347
|
+
const service = new RelationService(db, registry);
|
|
348
|
+
|
|
349
|
+
const results = await service.batchFetchRelatedEntities(
|
|
350
|
+
"authors_inr", [1], "posts", relation
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
expect(results.get("1")).toBeDefined();
|
|
354
|
+
expect(results.get("1")!.values.title).toBe("Post A");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
359
|
+
// 2. Inverse M2M write-path warning
|
|
360
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
361
|
+
|
|
362
|
+
describe("updateRelationsUsingJoins: inverse M2M through warning", () => {
|
|
363
|
+
let registry: PostgresCollectionRegistry;
|
|
364
|
+
|
|
365
|
+
beforeEach(() => {
|
|
366
|
+
registry = new PostgresCollectionRegistry();
|
|
367
|
+
|
|
368
|
+
jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
|
|
369
|
+
if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
|
|
370
|
+
if (path?.startsWith("tags")) return tagsCollection;
|
|
371
|
+
if (path?.startsWith("posts")) return postsCollection;
|
|
372
|
+
return undefined;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
jest.spyOn(registry, "getTable").mockImplementation(tableName => {
|
|
376
|
+
if (tableName === "posts") return mockPostsTable as any;
|
|
377
|
+
if (tableName === "tags") return mockTagsTable as any;
|
|
378
|
+
if (tableName === "posts_tags") return mockPostsTagsTable as any;
|
|
379
|
+
return undefined;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
jest.spyOn(registry, "getCollections").mockReturnValue([
|
|
383
|
+
postsCollection, tagsCollection
|
|
384
|
+
]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
jest.restoreAllMocks();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("should emit a specific warning when attempting to save an inverse M2M through relation", async () => {
|
|
392
|
+
const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
|
|
393
|
+
|
|
394
|
+
const { db } = createMockDb(() => []);
|
|
395
|
+
const service = new RelationService(db, registry);
|
|
396
|
+
|
|
397
|
+
// Try to save posts from the tags (inverse) side
|
|
398
|
+
await service.updateRelationsUsingJoins(
|
|
399
|
+
db as any,
|
|
400
|
+
tagsWithInversePosts,
|
|
401
|
+
1,
|
|
402
|
+
{ posts: [{ id: 10 }, { id: 20 }] }
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Should warn about inverse M2M
|
|
406
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
407
|
+
expect.stringContaining("Inverse M2M relation")
|
|
408
|
+
);
|
|
409
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
410
|
+
expect.stringContaining("should be saved from the owning side")
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
consoleSpy.mockRestore();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("should NOT warn for owning M2M through relations (normal save path)", async () => {
|
|
417
|
+
const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
|
|
418
|
+
|
|
419
|
+
const { db } = createMockDb(() => []);
|
|
420
|
+
const service = new RelationService(db, registry);
|
|
421
|
+
|
|
422
|
+
// Owning side save should work normally (or at least not trigger the inverse warning)
|
|
423
|
+
await service.updateRelationsUsingJoins(
|
|
424
|
+
db as any,
|
|
425
|
+
postsCollection,
|
|
426
|
+
1,
|
|
427
|
+
{ tags: [{ id: 1 }, { id: 2 }] }
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
// Should NOT have the inverse M2M warning
|
|
431
|
+
const inverseCalls = consoleSpy.mock.calls.filter(
|
|
432
|
+
call => typeof call[0] === "string" && call[0].includes("Inverse M2M")
|
|
433
|
+
);
|
|
434
|
+
expect(inverseCalls).toHaveLength(0);
|
|
435
|
+
|
|
436
|
+
consoleSpy.mockRestore();
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
441
|
+
// 3. sanitizeRelation junction table naming convention
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
443
|
+
|
|
444
|
+
describe("sanitizeRelation: auto-inferred junction table naming", () => {
|
|
445
|
+
it("should produce sorted junction table name: posts + tags → posts_tags", () => {
|
|
446
|
+
const source: EntityCollection = {
|
|
447
|
+
slug: "posts",
|
|
448
|
+
name: "Posts",
|
|
449
|
+
table: "posts",
|
|
450
|
+
properties: {}
|
|
451
|
+
};
|
|
452
|
+
const target: EntityCollection = {
|
|
453
|
+
slug: "tags",
|
|
454
|
+
name: "Tags",
|
|
455
|
+
table: "tags",
|
|
456
|
+
properties: {}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const relation: Partial<Relation> = {
|
|
460
|
+
relationName: "tags",
|
|
461
|
+
target: () => target,
|
|
462
|
+
cardinality: "many",
|
|
463
|
+
direction: "owning"
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
467
|
+
|
|
468
|
+
expect(normalized.through).toBeDefined();
|
|
469
|
+
expect(normalized.through!.table).toBe("posts_tags"); // ["posts", "tags"].sort().join("_")
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("should sort alphabetically: articles + tags → articles_tags (not tags_articles)", () => {
|
|
473
|
+
const source: EntityCollection = {
|
|
474
|
+
slug: "tags",
|
|
475
|
+
name: "Tags",
|
|
476
|
+
table: "tags",
|
|
477
|
+
properties: {}
|
|
478
|
+
};
|
|
479
|
+
const target: EntityCollection = {
|
|
480
|
+
slug: "articles",
|
|
481
|
+
name: "Articles",
|
|
482
|
+
table: "articles",
|
|
483
|
+
properties: {}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const relation: Partial<Relation> = {
|
|
487
|
+
relationName: "articles",
|
|
488
|
+
target: () => target,
|
|
489
|
+
cardinality: "many",
|
|
490
|
+
direction: "owning"
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
494
|
+
|
|
495
|
+
expect(normalized.through!.table).toBe("articles_tags"); // sorted
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("should handle underscore-containing slugs: blog_posts + labels → blog_posts_labels", () => {
|
|
499
|
+
const source: EntityCollection = {
|
|
500
|
+
slug: "blog-posts",
|
|
501
|
+
name: "Blog Posts",
|
|
502
|
+
table: "blog_posts",
|
|
503
|
+
properties: {}
|
|
504
|
+
};
|
|
505
|
+
const target: EntityCollection = {
|
|
506
|
+
slug: "labels",
|
|
507
|
+
name: "Labels",
|
|
508
|
+
table: "labels",
|
|
509
|
+
properties: {}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const relation: Partial<Relation> = {
|
|
513
|
+
relationName: "labels",
|
|
514
|
+
target: () => target,
|
|
515
|
+
cardinality: "many",
|
|
516
|
+
direction: "owning"
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
520
|
+
|
|
521
|
+
expect(normalized.through!.table).toBe("blog_posts_labels"); // sorted: blog_posts < labels
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("should generate correct source and target columns", () => {
|
|
525
|
+
const source: EntityCollection = {
|
|
526
|
+
slug: "posts",
|
|
527
|
+
name: "Posts",
|
|
528
|
+
table: "posts",
|
|
529
|
+
properties: {}
|
|
530
|
+
};
|
|
531
|
+
const target: EntityCollection = {
|
|
532
|
+
slug: "tags",
|
|
533
|
+
name: "Tags",
|
|
534
|
+
table: "tags",
|
|
535
|
+
properties: {}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const relation: Partial<Relation> = {
|
|
539
|
+
relationName: "tags",
|
|
540
|
+
target: () => target,
|
|
541
|
+
cardinality: "many",
|
|
542
|
+
direction: "owning"
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
546
|
+
|
|
547
|
+
// sourceColumn derives from source slug (singularized), targetColumn from relationName (singularized)
|
|
548
|
+
// generateForeignKeyName("posts") → "post_id", generateForeignKeyName("tags") → "tag_id"
|
|
549
|
+
expect(normalized.through!.sourceColumn).toBe("post_id");
|
|
550
|
+
expect(normalized.through!.targetColumn).toBe("tag_id");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("should preserve explicit through config and not overwrite it", () => {
|
|
554
|
+
const source: EntityCollection = {
|
|
555
|
+
slug: "posts",
|
|
556
|
+
name: "Posts",
|
|
557
|
+
table: "posts",
|
|
558
|
+
properties: {}
|
|
559
|
+
};
|
|
560
|
+
const target: EntityCollection = {
|
|
561
|
+
slug: "tags",
|
|
562
|
+
name: "Tags",
|
|
563
|
+
table: "tags",
|
|
564
|
+
properties: {}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const relation: Partial<Relation> = {
|
|
568
|
+
relationName: "tags",
|
|
569
|
+
target: () => target,
|
|
570
|
+
cardinality: "many",
|
|
571
|
+
direction: "owning",
|
|
572
|
+
through: {
|
|
573
|
+
table: "custom_junction",
|
|
574
|
+
sourceColumn: "src_id",
|
|
575
|
+
targetColumn: "tgt_id"
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
580
|
+
|
|
581
|
+
expect(normalized.through!.table).toBe("custom_junction");
|
|
582
|
+
expect(normalized.through!.sourceColumn).toBe("src_id");
|
|
583
|
+
expect(normalized.through!.targetColumn).toBe("tgt_id");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("should handle self-referencing M2M: users + users → users_users", () => {
|
|
587
|
+
const usersCollection: EntityCollection = {
|
|
588
|
+
slug: "users",
|
|
589
|
+
name: "Users",
|
|
590
|
+
table: "users",
|
|
591
|
+
properties: {}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const relation: Partial<Relation> = {
|
|
595
|
+
relationName: "friends",
|
|
596
|
+
target: () => usersCollection,
|
|
597
|
+
cardinality: "many",
|
|
598
|
+
direction: "owning"
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const normalized = sanitizeRelation(relation, usersCollection as any);
|
|
602
|
+
|
|
603
|
+
// Both tables are "users", sorted = ["users", "users"], joined = "users_users"
|
|
604
|
+
expect(normalized.through!.table).toBe("users_users");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should NOT add through config for joinPath-based relations", () => {
|
|
608
|
+
const source: EntityCollection = {
|
|
609
|
+
slug: "users",
|
|
610
|
+
name: "Users",
|
|
611
|
+
table: "users",
|
|
612
|
+
properties: {}
|
|
613
|
+
};
|
|
614
|
+
const target: EntityCollection = {
|
|
615
|
+
slug: "permissions",
|
|
616
|
+
name: "Permissions",
|
|
617
|
+
table: "permissions",
|
|
618
|
+
properties: {}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const relation: Partial<Relation> = {
|
|
622
|
+
relationName: "permissions",
|
|
623
|
+
target: () => target,
|
|
624
|
+
cardinality: "many",
|
|
625
|
+
direction: "owning",
|
|
626
|
+
joinPath: [
|
|
627
|
+
{ table: "user_roles", on: { from: "id", to: "user_id" } },
|
|
628
|
+
{ table: "permissions", on: { from: "permission_id", to: "id" } }
|
|
629
|
+
]
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const normalized = sanitizeRelation(relation, source as any);
|
|
633
|
+
|
|
634
|
+
// joinPath takes precedence — no through should be auto-generated
|
|
635
|
+
expect(normalized.through).toBeUndefined();
|
|
636
|
+
});
|
|
637
|
+
});
|