@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- 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 +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -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/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- 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/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- 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 +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for batchFetchRelatedEntitiesMany
|
|
3
|
+
*
|
|
4
|
+
* Root cause of the original bug:
|
|
5
|
+
* `batchFetchRelatedEntitiesMany` only had two code paths:
|
|
6
|
+
* 1. `relation.joinPath` — custom multi-hop join path
|
|
7
|
+
* 2. FK-based fallback — delegated to `buildRelationQuery`, then extracted
|
|
8
|
+
* parentId from `foreignKeyOnTarget` / `inverseRelationName`
|
|
9
|
+
*
|
|
10
|
+
* Many-to-many owning relations (e.g. posts→tags via posts_tags junction)
|
|
11
|
+
* use `relation.through` — NOT `joinPath`, and NOT an inverse FK.
|
|
12
|
+
* The FK fallback would:
|
|
13
|
+
* - Correctly JOIN through the junction table (via buildRelationQuery)
|
|
14
|
+
* - But FAIL to extract parentId from the result rows because
|
|
15
|
+
* `relation.direction === "owning"`, so lines checking
|
|
16
|
+
* `direction === "inverse"` were skipped
|
|
17
|
+
* - parentId stayed `undefined`, all results were silently dropped
|
|
18
|
+
*
|
|
19
|
+
* The same gap existed for inverse M2M with `through`.
|
|
20
|
+
*
|
|
21
|
+
* Fix: Added a dedicated `through` handler before the FK fallback that
|
|
22
|
+
* queries junction→target directly and extracts parentId from the junction
|
|
23
|
+
* table's sourceColumn/targetColumn.
|
|
24
|
+
*/
|
|
25
|
+
import { RelationService } from "../src/services/RelationService";
|
|
26
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
27
|
+
import { EntityCollection, Relation } from "@rebasepro/types";
|
|
28
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
29
|
+
|
|
30
|
+
// ─── Mock Tables ──────────────────────────────────────────────────────
|
|
31
|
+
const mockPostsTable = {
|
|
32
|
+
id: { name: "id", dataType: "number" },
|
|
33
|
+
title: { name: "title" },
|
|
34
|
+
_def: { tableName: "posts" }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mockTagsTable = {
|
|
38
|
+
id: { name: "id", dataType: "number" },
|
|
39
|
+
name: { name: "name" },
|
|
40
|
+
_def: { tableName: "tags" }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mockPostsTagsTable = {
|
|
44
|
+
post_id: { name: "post_id", dataType: "number" },
|
|
45
|
+
tag_id: { name: "tag_id", dataType: "number" },
|
|
46
|
+
_def: { tableName: "posts_tags" }
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockAuthorsTable = {
|
|
50
|
+
id: { name: "id", dataType: "number" },
|
|
51
|
+
name: { name: "name" },
|
|
52
|
+
_def: { tableName: "authors" }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mockAuthorPostsTable = {
|
|
56
|
+
author_id: { name: "author_id", dataType: "number" },
|
|
57
|
+
post_id: { name: "post_id", dataType: "number" },
|
|
58
|
+
_def: { tableName: "author_posts" }
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─── Mock Collections ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const tagsCollection: EntityCollection = {
|
|
64
|
+
slug: "tags",
|
|
65
|
+
name: "Tags",
|
|
66
|
+
table: "tags",
|
|
67
|
+
properties: {
|
|
68
|
+
id: { type: "number" },
|
|
69
|
+
name: { type: "string" }
|
|
70
|
+
},
|
|
71
|
+
idField: "id"
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const postsCollection: EntityCollection = {
|
|
75
|
+
slug: "posts",
|
|
76
|
+
name: "Posts",
|
|
77
|
+
table: "posts",
|
|
78
|
+
properties: {
|
|
79
|
+
id: { type: "number" },
|
|
80
|
+
title: { type: "string" },
|
|
81
|
+
tags: { type: "relation", relationName: "tags" }
|
|
82
|
+
},
|
|
83
|
+
relations: [
|
|
84
|
+
{
|
|
85
|
+
relationName: "tags",
|
|
86
|
+
target: () => tagsCollection,
|
|
87
|
+
cardinality: "many",
|
|
88
|
+
direction: "owning",
|
|
89
|
+
through: {
|
|
90
|
+
table: "posts_tags",
|
|
91
|
+
sourceColumn: "post_id",
|
|
92
|
+
targetColumn: "tag_id"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
idField: "id"
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const authorsCollection: EntityCollection = {
|
|
100
|
+
slug: "authors",
|
|
101
|
+
name: "Authors",
|
|
102
|
+
table: "authors",
|
|
103
|
+
properties: {
|
|
104
|
+
id: { type: "number" },
|
|
105
|
+
name: { type: "string" }
|
|
106
|
+
},
|
|
107
|
+
idField: "id"
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Inverse M2M: tags → posts (from tag's perspective, "which posts use this tag?")
|
|
111
|
+
const tagsWithInversePosts: EntityCollection = {
|
|
112
|
+
slug: "tags_inv",
|
|
113
|
+
name: "Tags (inverse)",
|
|
114
|
+
table: "tags",
|
|
115
|
+
properties: {
|
|
116
|
+
id: { type: "number" },
|
|
117
|
+
name: { type: "string" },
|
|
118
|
+
posts: { type: "relation", relationName: "posts" }
|
|
119
|
+
},
|
|
120
|
+
relations: [
|
|
121
|
+
{
|
|
122
|
+
relationName: "posts",
|
|
123
|
+
target: () => postsCollection,
|
|
124
|
+
cardinality: "many",
|
|
125
|
+
direction: "inverse",
|
|
126
|
+
through: {
|
|
127
|
+
table: "posts_tags",
|
|
128
|
+
sourceColumn: "post_id",
|
|
129
|
+
targetColumn: "tag_id"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
idField: "id"
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// JoinPath-based M2M: authors → posts via author_posts
|
|
137
|
+
const authorsWithJoinPath: EntityCollection = {
|
|
138
|
+
slug: "authors_jp",
|
|
139
|
+
name: "Authors (joinPath)",
|
|
140
|
+
table: "authors",
|
|
141
|
+
properties: {
|
|
142
|
+
id: { type: "number" },
|
|
143
|
+
name: { type: "string" },
|
|
144
|
+
posts: { type: "relation", relationName: "posts" }
|
|
145
|
+
},
|
|
146
|
+
relations: [
|
|
147
|
+
{
|
|
148
|
+
relationName: "posts",
|
|
149
|
+
target: () => postsCollection,
|
|
150
|
+
cardinality: "many",
|
|
151
|
+
direction: "owning",
|
|
152
|
+
joinPath: [
|
|
153
|
+
{ table: "author_posts", on: { from: "id", to: "author_id" } },
|
|
154
|
+
{ table: "posts", on: { from: "post_id", to: "id" } }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
],
|
|
158
|
+
idField: "id"
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ─── Test Data Generators ─────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function generateJunctionRows(
|
|
164
|
+
postIds: number[],
|
|
165
|
+
tagIds: number[],
|
|
166
|
+
junctionTableName: string,
|
|
167
|
+
targetTableName: string,
|
|
168
|
+
sourceCol: string,
|
|
169
|
+
targetCol: string,
|
|
170
|
+
targetData: Record<string, unknown>[]
|
|
171
|
+
) {
|
|
172
|
+
// Create junction results that look like what Drizzle returns from
|
|
173
|
+
// FROM junction INNER JOIN target
|
|
174
|
+
const rows: Record<string, unknown>[] = [];
|
|
175
|
+
for (const postId of postIds) {
|
|
176
|
+
for (const tagId of tagIds) {
|
|
177
|
+
const targetRow = targetData.find(t => t.id === tagId);
|
|
178
|
+
if (targetRow) {
|
|
179
|
+
rows.push({
|
|
180
|
+
[junctionTableName]: {
|
|
181
|
+
[sourceCol]: postId,
|
|
182
|
+
[targetCol]: tagId,
|
|
183
|
+
},
|
|
184
|
+
[targetTableName]: { ...targetRow }
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Tests ────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe("batchFetchRelatedEntitiesMany: M2M through junction table regression", () => {
|
|
195
|
+
let registry: PostgresCollectionRegistry;
|
|
196
|
+
|
|
197
|
+
function createMockDb(resolveResults: () => unknown[]) {
|
|
198
|
+
let queryRecorder = {
|
|
199
|
+
selectCount: 0,
|
|
200
|
+
innerJoinCount: 0,
|
|
201
|
+
fromTable: undefined as string | undefined,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function makeChainable(): Record<string, unknown> {
|
|
205
|
+
const chain: Record<string, unknown> = {
|
|
206
|
+
select: jest.fn(() => {
|
|
207
|
+
queryRecorder.selectCount++;
|
|
208
|
+
return chain;
|
|
209
|
+
}),
|
|
210
|
+
from: jest.fn((table: Record<string, unknown>) => {
|
|
211
|
+
const tableDef = table._def as { tableName: string } | undefined;
|
|
212
|
+
queryRecorder.fromTable = tableDef?.tableName ?? "unknown";
|
|
213
|
+
return chain;
|
|
214
|
+
}),
|
|
215
|
+
where: jest.fn(() => chain),
|
|
216
|
+
$dynamic: jest.fn(() => chain),
|
|
217
|
+
limit: jest.fn(() => chain),
|
|
218
|
+
offset: jest.fn(() => chain),
|
|
219
|
+
orderBy: jest.fn(() => chain),
|
|
220
|
+
innerJoin: jest.fn(() => {
|
|
221
|
+
queryRecorder.innerJoinCount++;
|
|
222
|
+
return chain;
|
|
223
|
+
}),
|
|
224
|
+
then: (resolve: (val: unknown[]) => void) => {
|
|
225
|
+
resolve(resolveResults());
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
return chain;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
db: makeChainable() as unknown as jest.Mocked<NodePgDatabase>,
|
|
233
|
+
recorder: queryRecorder,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
beforeEach(() => {
|
|
238
|
+
registry = new PostgresCollectionRegistry();
|
|
239
|
+
|
|
240
|
+
jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
|
|
241
|
+
if (path?.startsWith("posts")) return postsCollection;
|
|
242
|
+
if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
|
|
243
|
+
if (path?.startsWith("tags")) return tagsCollection;
|
|
244
|
+
if (path?.startsWith("authors_jp")) return authorsWithJoinPath;
|
|
245
|
+
if (path?.startsWith("authors")) return authorsCollection;
|
|
246
|
+
return undefined;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
jest.spyOn(registry, "getTable").mockImplementation(tableName => {
|
|
250
|
+
if (tableName === "posts") return mockPostsTable as any;
|
|
251
|
+
if (tableName === "tags") return mockTagsTable as any;
|
|
252
|
+
if (tableName === "posts_tags") return mockPostsTagsTable as any;
|
|
253
|
+
if (tableName === "authors") return mockAuthorsTable as any;
|
|
254
|
+
if (tableName === "author_posts") return mockAuthorPostsTable as any;
|
|
255
|
+
return undefined;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
jest.spyOn(registry, "getCollections").mockReturnValue([
|
|
259
|
+
postsCollection, tagsCollection, authorsCollection
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
jest.restoreAllMocks();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ━━━ Owning M2M with `through` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
268
|
+
|
|
269
|
+
describe("Owning M2M with `through` (posts → tags)", () => {
|
|
270
|
+
const mockTags = [
|
|
271
|
+
{ id: 1, name: "TypeScript" },
|
|
272
|
+
{ id: 2, name: "React" },
|
|
273
|
+
{ id: 3, name: "Node.js" },
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
it("should return tags for each post via junction table", async () => {
|
|
277
|
+
// Post 1 → tags 1,2; Post 2 → tags 2,3; Post 3 → tag 1
|
|
278
|
+
const junctionRows = [
|
|
279
|
+
{ posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
|
|
280
|
+
{ posts_tags: { post_id: 1, tag_id: 2 }, tags: { id: 2, name: "React" } },
|
|
281
|
+
{ posts_tags: { post_id: 2, tag_id: 2 }, tags: { id: 2, name: "React" } },
|
|
282
|
+
{ posts_tags: { post_id: 2, tag_id: 3 }, tags: { id: 3, name: "Node.js" } },
|
|
283
|
+
{ posts_tags: { post_id: 3, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const { db } = createMockDb(() => junctionRows);
|
|
287
|
+
const service = new RelationService(db, registry);
|
|
288
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
289
|
+
|
|
290
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
291
|
+
"posts", [1, 2, 3], "tags", relation
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Post 1 should have 2 tags
|
|
295
|
+
expect(results.get("1")).toHaveLength(2);
|
|
296
|
+
expect(results.get("1")!.map(e => e.values.name)).toEqual(
|
|
297
|
+
expect.arrayContaining(["TypeScript", "React"])
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Post 2 should have 2 tags
|
|
301
|
+
expect(results.get("2")).toHaveLength(2);
|
|
302
|
+
expect(results.get("2")!.map(e => e.values.name)).toEqual(
|
|
303
|
+
expect.arrayContaining(["React", "Node.js"])
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Post 3 should have 1 tag
|
|
307
|
+
expect(results.get("3")).toHaveLength(1);
|
|
308
|
+
expect(results.get("3")![0].values.name).toBe("TypeScript");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("should set correct entity id and path on each relation result", async () => {
|
|
312
|
+
const junctionRows = [
|
|
313
|
+
{ posts_tags: { post_id: 1, tag_id: 42 }, tags: { id: 42, name: "GraphQL" } },
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
const { db } = createMockDb(() => junctionRows);
|
|
317
|
+
const service = new RelationService(db, registry);
|
|
318
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
319
|
+
|
|
320
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
321
|
+
"posts", [1], "tags", relation
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const tags = results.get("1")!;
|
|
325
|
+
expect(tags).toHaveLength(1);
|
|
326
|
+
expect(tags[0].id).toBe("42");
|
|
327
|
+
expect(tags[0].path).toBe("tags");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should use exactly 1 SQL query (junction JOIN target)", async () => {
|
|
331
|
+
const junctionRows = [
|
|
332
|
+
{ posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
const { db, recorder } = createMockDb(() => junctionRows);
|
|
336
|
+
const service = new RelationService(db, registry);
|
|
337
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
338
|
+
|
|
339
|
+
await service.batchFetchRelatedEntitiesMany(
|
|
340
|
+
"posts", [1, 2, 3], "tags", relation
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Single SELECT from junction with innerJoin to target
|
|
344
|
+
expect(recorder.selectCount).toBe(1);
|
|
345
|
+
expect(recorder.innerJoinCount).toBe(1);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("should return empty map when no junction rows exist", async () => {
|
|
349
|
+
const { db } = createMockDb(() => []);
|
|
350
|
+
const service = new RelationService(db, registry);
|
|
351
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
352
|
+
|
|
353
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
354
|
+
"posts", [1, 2, 3], "tags", relation
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(results.size).toBe(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should return empty map for empty parent IDs", async () => {
|
|
361
|
+
const { db, recorder } = createMockDb(() => []);
|
|
362
|
+
const service = new RelationService(db, registry);
|
|
363
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
364
|
+
|
|
365
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
366
|
+
"posts", [], "tags", relation
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
expect(results.size).toBe(0);
|
|
370
|
+
// No queries should fire
|
|
371
|
+
expect(recorder.selectCount).toBe(0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("should handle posts where some have tags and some don't", async () => {
|
|
375
|
+
const junctionRows = [
|
|
376
|
+
{ posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
|
|
377
|
+
// Post 2 has no junction rows
|
|
378
|
+
{ posts_tags: { post_id: 3, tag_id: 2 }, tags: { id: 2, name: "React" } },
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
const { db } = createMockDb(() => junctionRows);
|
|
382
|
+
const service = new RelationService(db, registry);
|
|
383
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
384
|
+
|
|
385
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
386
|
+
"posts", [1, 2, 3], "tags", relation
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(results.get("1")).toHaveLength(1);
|
|
390
|
+
expect(results.has("2")).toBe(false); // No tags for post 2
|
|
391
|
+
expect(results.get("3")).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ━━━ Inverse M2M with `through` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
396
|
+
|
|
397
|
+
describe("Inverse M2M with `through` (tags → posts, inverse perspective)", () => {
|
|
398
|
+
it("should return posts for each tag via junction table", async () => {
|
|
399
|
+
// For the inverse direction, buildRelationQuery JOINs through the junction.
|
|
400
|
+
// Result rows contain junction + target data.
|
|
401
|
+
// The parentId (tag) is in the junction's targetColumn.
|
|
402
|
+
const joinedRows = [
|
|
403
|
+
{ posts_tags: { post_id: 10, tag_id: 1 }, posts: { id: 10, title: "Post A" } },
|
|
404
|
+
{ posts_tags: { post_id: 20, tag_id: 1 }, posts: { id: 20, title: "Post B" } },
|
|
405
|
+
{ posts_tags: { post_id: 30, tag_id: 2 }, posts: { id: 30, title: "Post C" } },
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
const { db } = createMockDb(() => joinedRows);
|
|
409
|
+
const service = new RelationService(db, registry);
|
|
410
|
+
const relation = tagsWithInversePosts.relations![0] as Relation;
|
|
411
|
+
|
|
412
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
413
|
+
"tags_inv", [1, 2], "posts", relation
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Tag 1 should have 2 posts
|
|
417
|
+
expect(results.get("1")).toHaveLength(2);
|
|
418
|
+
expect(results.get("1")!.map(e => e.values.title)).toEqual(
|
|
419
|
+
expect.arrayContaining(["Post A", "Post B"])
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Tag 2 should have 1 post
|
|
423
|
+
expect(results.get("2")).toHaveLength(1);
|
|
424
|
+
expect(results.get("2")![0].values.title).toBe("Post C");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("should return empty map when no junction rows exist for inverse", async () => {
|
|
428
|
+
const { db } = createMockDb(() => []);
|
|
429
|
+
const service = new RelationService(db, registry);
|
|
430
|
+
const relation = tagsWithInversePosts.relations![0] as Relation;
|
|
431
|
+
|
|
432
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
433
|
+
"tags_inv", [1, 2], "posts", relation
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
expect(results.size).toBe(0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ━━━ JoinPath (existing path, ensure no regression) ━━━━━━━━━━━━━━
|
|
441
|
+
|
|
442
|
+
describe("JoinPath M2M (authors → posts via author_posts)", () => {
|
|
443
|
+
it("should return posts for each author via joinPath", async () => {
|
|
444
|
+
// joinPath results are namespaced differently — parent data under parent table name
|
|
445
|
+
const joinedRows = [
|
|
446
|
+
{ authors: { id: 1, name: "Alice" }, posts: { id: 10, title: "Post X" } },
|
|
447
|
+
{ authors: { id: 1, name: "Alice" }, posts: { id: 20, title: "Post Y" } },
|
|
448
|
+
{ authors: { id: 2, name: "Bob" }, posts: { id: 30, title: "Post Z" } },
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
const { db } = createMockDb(() => joinedRows);
|
|
452
|
+
const service = new RelationService(db, registry);
|
|
453
|
+
const relation = authorsWithJoinPath.relations![0] as Relation;
|
|
454
|
+
|
|
455
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
456
|
+
"authors_jp", [1, 2], "posts", relation
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// Author 1 should have 2 posts
|
|
460
|
+
expect(results.get("1")).toHaveLength(2);
|
|
461
|
+
expect(results.get("1")!.map(e => e.values.title)).toEqual(
|
|
462
|
+
expect.arrayContaining(["Post X", "Post Y"])
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
// Author 2 should have 1 post
|
|
466
|
+
expect(results.get("2")).toHaveLength(1);
|
|
467
|
+
expect(results.get("2")![0].values.title).toBe("Post Z");
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ━━━ Error handling ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
472
|
+
|
|
473
|
+
describe("Error handling", () => {
|
|
474
|
+
it("should return empty map when junction table is not found", async () => {
|
|
475
|
+
// Override getTable to return undefined for junction
|
|
476
|
+
jest.spyOn(registry, "getTable").mockImplementation(tableName => {
|
|
477
|
+
if (tableName === "posts_tags") return undefined;
|
|
478
|
+
if (tableName === "posts") return mockPostsTable as any;
|
|
479
|
+
if (tableName === "tags") return mockTagsTable as any;
|
|
480
|
+
return undefined;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
|
|
484
|
+
const { db } = createMockDb(() => []);
|
|
485
|
+
const service = new RelationService(db, registry);
|
|
486
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
487
|
+
|
|
488
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
489
|
+
"posts", [1], "tags", relation
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
expect(results.size).toBe(0);
|
|
493
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
494
|
+
expect.stringContaining("Junction table 'posts_tags' not found")
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
consoleSpy.mockRestore();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// ━━━ ID type coercion (number vs string) ━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
502
|
+
// Drizzle may return junction column values as strings even when the
|
|
503
|
+
// parent IDs are numeric. The batch handler must not silently drop
|
|
504
|
+
// results due to `===` mismatches (the original parsedParentIds.includes
|
|
505
|
+
// bug).
|
|
506
|
+
|
|
507
|
+
describe("ID type coercion", () => {
|
|
508
|
+
it("owning M2M: should map results when junction returns string IDs but parent IDs are numbers", async () => {
|
|
509
|
+
// Junction data returns post_id as STRING, but we passed numeric parent IDs
|
|
510
|
+
const junctionRows = [
|
|
511
|
+
{ posts_tags: { post_id: "1", tag_id: "5" }, tags: { id: 5, name: "Rust" } },
|
|
512
|
+
{ posts_tags: { post_id: "2", tag_id: "5" }, tags: { id: 5, name: "Rust" } },
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const { db } = createMockDb(() => junctionRows);
|
|
516
|
+
const service = new RelationService(db, registry);
|
|
517
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
518
|
+
|
|
519
|
+
// Pass numeric parent IDs (parseIdValues will return numbers for numeric PKs)
|
|
520
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
521
|
+
"posts", [1, 2], "tags", relation
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Both posts should have their tag despite the string/number mismatch
|
|
525
|
+
expect(results.get("1")).toHaveLength(1);
|
|
526
|
+
expect(results.get("1")![0].values.name).toBe("Rust");
|
|
527
|
+
expect(results.get("2")).toHaveLength(1);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("inverse M2M: should map results when junction returns string IDs but parent IDs are numbers", async () => {
|
|
531
|
+
// Junction data returns tag_id (the parentId for inverse) as STRING
|
|
532
|
+
const joinedRows = [
|
|
533
|
+
{ posts_tags: { post_id: 10, tag_id: "1" }, posts: { id: 10, title: "Post A" } },
|
|
534
|
+
{ posts_tags: { post_id: 20, tag_id: "2" }, posts: { id: 20, title: "Post B" } },
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
const { db } = createMockDb(() => joinedRows);
|
|
538
|
+
const service = new RelationService(db, registry);
|
|
539
|
+
const relation = tagsWithInversePosts.relations![0] as Relation;
|
|
540
|
+
|
|
541
|
+
// Pass numeric parent IDs
|
|
542
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
543
|
+
"tags_inv", [1, 2], "posts", relation
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Both tags should have their post despite string/number mismatch
|
|
547
|
+
expect(results.get("1")).toHaveLength(1);
|
|
548
|
+
expect(results.get("1")![0].values.title).toBe("Post A");
|
|
549
|
+
expect(results.get("2")).toHaveLength(1);
|
|
550
|
+
expect(results.get("2")![0].values.title).toBe("Post B");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("owning M2M: should handle mixed string and number IDs in same batch", async () => {
|
|
554
|
+
// Some junction rows return numbers, others return strings
|
|
555
|
+
const junctionRows = [
|
|
556
|
+
{ posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
|
|
557
|
+
{ posts_tags: { post_id: "2", tag_id: "2" }, tags: { id: 2, name: "React" } },
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
const { db } = createMockDb(() => junctionRows);
|
|
561
|
+
const service = new RelationService(db, registry);
|
|
562
|
+
const relation = postsCollection.relations![0] as Relation;
|
|
563
|
+
|
|
564
|
+
const results = await service.batchFetchRelatedEntitiesMany(
|
|
565
|
+
"posts", [1, 2], "tags", relation
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
expect(results.get("1")).toHaveLength(1);
|
|
569
|
+
expect(results.get("2")).toHaveLength(1);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|