@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,7 +1,7 @@
|
|
|
1
1
|
import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
|
|
4
|
-
import { resolveCollectionRelations } from "@rebasepro/common";
|
|
4
|
+
import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
5
5
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
6
6
|
import {
|
|
7
7
|
getCollectionByPath,
|
|
@@ -43,7 +43,7 @@ export class EntityFetchService {
|
|
|
43
43
|
* Build filter conditions from FilterValues
|
|
44
44
|
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
45
45
|
*/
|
|
46
|
-
buildFilterConditions<M extends Record<string,
|
|
46
|
+
buildFilterConditions<M extends Record<string, unknown>>(
|
|
47
47
|
filter: FilterValues<Extract<keyof M, string>>,
|
|
48
48
|
table: PgTable<any>,
|
|
49
49
|
collectionPath: string
|
|
@@ -55,6 +55,25 @@ export class EntityFetchService {
|
|
|
55
55
|
// DRIZZLE QUERY HELPERS
|
|
56
56
|
// =============================================================
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the correct Drizzle column for sorting.
|
|
60
|
+
* Automatically maps owning relation property keys to their underlying foreign key column.
|
|
61
|
+
*/
|
|
62
|
+
private resolveOrderByField(
|
|
63
|
+
table: PgTable<any>,
|
|
64
|
+
orderBy: string,
|
|
65
|
+
collection?: EntityCollection
|
|
66
|
+
): AnyPgColumn | undefined {
|
|
67
|
+
let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
|
|
68
|
+
if (!orderByField && collection) {
|
|
69
|
+
const property = collection.properties[orderBy];
|
|
70
|
+
if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") {
|
|
71
|
+
orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return orderByField;
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* Build the `with` config for Drizzle's relational query API.
|
|
60
79
|
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
@@ -69,8 +88,7 @@ export class EntityFetchService {
|
|
|
69
88
|
collection: EntityCollection,
|
|
70
89
|
include?: string[]
|
|
71
90
|
): Record<string, boolean | { with: Record<string, boolean> }> {
|
|
72
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
73
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
91
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
74
92
|
const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
|
|
75
93
|
|
|
76
94
|
const shouldInclude = (key: string) =>
|
|
@@ -78,8 +96,6 @@ export class EntityFetchService {
|
|
|
78
96
|
|
|
79
97
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
80
98
|
if (!shouldInclude(key)) continue;
|
|
81
|
-
// Only include relations that map to actual properties (or include all for REST)
|
|
82
|
-
if (!include && !propertyKeys.has(key)) continue;
|
|
83
99
|
|
|
84
100
|
const drizzleRelName = relation.relationName || key;
|
|
85
101
|
|
|
@@ -141,7 +157,7 @@ export class EntityFetchService {
|
|
|
141
157
|
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
142
158
|
* - Flattening junction-table many-to-many results
|
|
143
159
|
*/
|
|
144
|
-
private drizzleResultToEntity<M extends Record<string,
|
|
160
|
+
private drizzleResultToEntity<M extends Record<string, unknown>>(
|
|
145
161
|
row: Record<string, unknown>,
|
|
146
162
|
collection: EntityCollection,
|
|
147
163
|
collectionPath: string,
|
|
@@ -149,15 +165,13 @@ export class EntityFetchService {
|
|
|
149
165
|
databaseId?: string,
|
|
150
166
|
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
151
167
|
): Entity<M> {
|
|
152
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
153
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
168
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
154
169
|
|
|
155
170
|
// Normalize non-relation values (dates, numbers, etc.)
|
|
156
171
|
const normalizedValues = normalizeDbValues(row as M, collection);
|
|
157
172
|
|
|
158
173
|
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
159
174
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
160
|
-
if (!propertyKeys.has(key)) continue;
|
|
161
175
|
const drizzleRelName = relation.relationName || key;
|
|
162
176
|
const relData = row[drizzleRelName];
|
|
163
177
|
|
|
@@ -186,17 +200,12 @@ export class EntityFetchService {
|
|
|
186
200
|
const relId = String(targetEntity[targetIdField] ?? targetEntity.id ?? targetEntity[Object.keys(targetEntity)[0]]);
|
|
187
201
|
const targetValues = normalizeDbValues(targetEntity, targetCollection);
|
|
188
202
|
|
|
189
|
-
return {
|
|
203
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
190
204
|
id: relId,
|
|
191
205
|
path: targetPath,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
path: targetPath,
|
|
196
|
-
values: targetValues,
|
|
197
|
-
databaseId
|
|
198
|
-
}
|
|
199
|
-
};
|
|
206
|
+
values: targetValues,
|
|
207
|
+
databaseId
|
|
208
|
+
});
|
|
200
209
|
});
|
|
201
210
|
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
202
211
|
const targetCollection = relation.target();
|
|
@@ -208,22 +217,17 @@ export class EntityFetchService {
|
|
|
208
217
|
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
209
218
|
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
210
219
|
|
|
211
|
-
(normalizedValues as Record<string, unknown>)[key] = {
|
|
220
|
+
(normalizedValues as Record<string, unknown>)[key] = createRelationRefWithData(relId, targetPath, {
|
|
212
221
|
id: relId,
|
|
213
222
|
path: targetPath,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
path: targetPath,
|
|
218
|
-
values: targetValues,
|
|
219
|
-
databaseId
|
|
220
|
-
}
|
|
221
|
-
};
|
|
223
|
+
values: targetValues,
|
|
224
|
+
databaseId
|
|
225
|
+
});
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
return {
|
|
226
|
-
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string,
|
|
230
|
+
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]),
|
|
227
231
|
path: collectionPath,
|
|
228
232
|
values: normalizedValues as M,
|
|
229
233
|
databaseId
|
|
@@ -235,18 +239,17 @@ export class EntityFetchService {
|
|
|
235
239
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
236
240
|
* so they must be loaded separately after the primary query.
|
|
237
241
|
*/
|
|
238
|
-
private async resolveJoinPathRelations<M extends Record<string,
|
|
242
|
+
private async resolveJoinPathRelations<M extends Record<string, unknown>>(
|
|
239
243
|
entity: Entity<M>,
|
|
240
244
|
collection: EntityCollection,
|
|
241
245
|
collectionPath: string,
|
|
242
246
|
parsedId: string | number,
|
|
243
247
|
databaseId?: string
|
|
244
248
|
): Promise<void> {
|
|
245
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
246
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
249
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
247
250
|
|
|
248
251
|
const promises = Object.entries(resolvedRelations)
|
|
249
|
-
.filter(([key, relation]) =>
|
|
252
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
|
|
250
253
|
.map(async ([key, relation]) => {
|
|
251
254
|
try {
|
|
252
255
|
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
@@ -258,19 +261,11 @@ export class EntityFetchService {
|
|
|
258
261
|
|
|
259
262
|
if (relation.cardinality === "one" && relatedEntities.length > 0) {
|
|
260
263
|
const e = relatedEntities[0];
|
|
261
|
-
(entity.values as Record<string, unknown>)[key] =
|
|
262
|
-
id: e.id,
|
|
263
|
-
path: e.path,
|
|
264
|
-
__type: "relation" as const,
|
|
265
|
-
data: e
|
|
266
|
-
};
|
|
264
|
+
(entity.values as Record<string, unknown>)[key] = createRelationRefWithData(e.id, e.path, e);
|
|
267
265
|
} else if (relation.cardinality === "many") {
|
|
268
|
-
(entity.values as Record<string, unknown>)[key] = relatedEntities.map(e =>
|
|
269
|
-
id
|
|
270
|
-
|
|
271
|
-
__type: "relation" as const,
|
|
272
|
-
data: e
|
|
273
|
-
}));
|
|
266
|
+
(entity.values as Record<string, unknown>)[key] = relatedEntities.map(e =>
|
|
267
|
+
createRelationRefWithData(e.id, e.path, e)
|
|
268
|
+
);
|
|
274
269
|
}
|
|
275
270
|
} catch (e) {
|
|
276
271
|
console.warn(`Could not resolve joinPath relation '${key}':`, e);
|
|
@@ -284,7 +279,7 @@ export class EntityFetchService {
|
|
|
284
279
|
* Post-fetch joinPath relations for a batch of entities.
|
|
285
280
|
* Uses batch fetching to avoid N+1 queries for list views.
|
|
286
281
|
*/
|
|
287
|
-
private async resolveJoinPathRelationsBatch<M extends Record<string,
|
|
282
|
+
private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
|
|
288
283
|
entities: Entity<M>[],
|
|
289
284
|
collection: EntityCollection,
|
|
290
285
|
collectionPath: string,
|
|
@@ -293,11 +288,10 @@ export class EntityFetchService {
|
|
|
293
288
|
): Promise<void> {
|
|
294
289
|
if (entities.length === 0) return;
|
|
295
290
|
|
|
296
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
297
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
291
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
298
292
|
|
|
299
293
|
const joinPathRelations = Object.entries(resolvedRelations)
|
|
300
|
-
.filter(([key, relation]) =>
|
|
294
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
301
295
|
|
|
302
296
|
if (joinPathRelations.length === 0) return;
|
|
303
297
|
|
|
@@ -318,21 +312,94 @@ export class EntityFetchService {
|
|
|
318
312
|
for (const entity of entities) {
|
|
319
313
|
const parsed = parseIdValues(entity.id, [idInfo]);
|
|
320
314
|
const entityId = parsed[idInfo.fieldName] as string | number;
|
|
321
|
-
const relatedEntity = resultMap.get(entityId);
|
|
315
|
+
const relatedEntity = resultMap.get(String(entityId));
|
|
322
316
|
|
|
323
317
|
if (relatedEntity) {
|
|
324
318
|
if (relation.cardinality === "one") {
|
|
325
|
-
(entity.values as Record<string, unknown>)[key] =
|
|
319
|
+
(entity.values as Record<string, unknown>)[key] = createRelationRefWithData(relatedEntity.id, relatedEntity.path, relatedEntity);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.warn(`Could not batch resolve joinPath relation '${key}':`, e);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
331
|
+
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
332
|
+
*/
|
|
333
|
+
private async resolveJoinPathRelationsBatchRest(
|
|
334
|
+
rows: Record<string, unknown>[],
|
|
335
|
+
collection: EntityCollection,
|
|
336
|
+
collectionPath: string,
|
|
337
|
+
idInfoArray: { fieldName: string; type: "string" | "number" }[],
|
|
338
|
+
include?: string[]
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
if (rows.length === 0) return;
|
|
341
|
+
|
|
342
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
343
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
344
|
+
const shouldInclude = (key: string) =>
|
|
345
|
+
!include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
346
|
+
|
|
347
|
+
const joinPathRelations = Object.entries(resolvedRelations)
|
|
348
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
349
|
+
|
|
350
|
+
if (joinPathRelations.length === 0) return;
|
|
351
|
+
|
|
352
|
+
const idInfo = idInfoArray[0];
|
|
353
|
+
|
|
354
|
+
for (const [key, relation] of joinPathRelations) {
|
|
355
|
+
try {
|
|
356
|
+
// Determine the parent IDs based on the parsed string ID from the REST row
|
|
357
|
+
const entityIds = rows.map(r => {
|
|
358
|
+
const parsed = parseIdValues(String(r.id), idInfoArray);
|
|
359
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (relation.cardinality === "one") {
|
|
363
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
364
|
+
collectionPath,
|
|
365
|
+
entityIds,
|
|
366
|
+
key,
|
|
367
|
+
relation
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (const row of rows) {
|
|
371
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
372
|
+
const entityId = parsed[idInfo.fieldName] as string | number;
|
|
373
|
+
const relatedEntity = resultMap.get(String(entityId));
|
|
374
|
+
|
|
375
|
+
if (relatedEntity) {
|
|
376
|
+
row[key] = {
|
|
326
377
|
id: relatedEntity.id,
|
|
327
|
-
|
|
328
|
-
__type: "relation" as const,
|
|
329
|
-
data: relatedEntity
|
|
378
|
+
...relatedEntity.values
|
|
330
379
|
};
|
|
380
|
+
} else {
|
|
381
|
+
row[key] = null;
|
|
331
382
|
}
|
|
332
383
|
}
|
|
384
|
+
} else if (relation.cardinality === "many") {
|
|
385
|
+
const resultMap = await this.batchFetchManyRelatedEntities(
|
|
386
|
+
collectionPath,
|
|
387
|
+
entityIds,
|
|
388
|
+
key
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
for (const row of rows) {
|
|
392
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
393
|
+
const entityId = parsed[idInfo.fieldName] as string | number;
|
|
394
|
+
const relatedList = resultMap.get(String(entityId)) || [];
|
|
395
|
+
row[key] = relatedList.map(e => ({
|
|
396
|
+
id: e.id,
|
|
397
|
+
...e.values
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
333
400
|
}
|
|
334
401
|
} catch (e) {
|
|
335
|
-
console.warn(`Could not batch resolve joinPath relation '${key}':`, e);
|
|
402
|
+
console.warn(`Could not batch resolve joinPath relation '${key}' for REST:`, e);
|
|
336
403
|
}
|
|
337
404
|
}
|
|
338
405
|
}
|
|
@@ -346,13 +413,13 @@ export class EntityFetchService {
|
|
|
346
413
|
idInfo: { fieldName: string; type: "string" | "number" },
|
|
347
414
|
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
348
415
|
): Record<string, unknown> {
|
|
349
|
-
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string,
|
|
350
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
416
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
417
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
351
418
|
|
|
352
419
|
for (const [k, v] of Object.entries(row)) {
|
|
353
420
|
if (k === idInfo.fieldName) continue;
|
|
354
421
|
|
|
355
|
-
const relation = resolvedRelations
|
|
422
|
+
const relation = findRelation(resolvedRelations, k);
|
|
356
423
|
if (Array.isArray(v) && relation) {
|
|
357
424
|
// Many relation — flatten each nested entity, handling junction tables
|
|
358
425
|
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
@@ -362,15 +429,18 @@ export class EntityFetchService {
|
|
|
362
429
|
);
|
|
363
430
|
if (nestedKey) {
|
|
364
431
|
const nested = item[nestedKey] as Record<string, unknown>;
|
|
365
|
-
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
|
|
432
|
+
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
|
|
433
|
+
...nested };
|
|
366
434
|
}
|
|
367
435
|
}
|
|
368
|
-
return { id: String(item.id ?? item[Object.keys(item)[0]]),
|
|
436
|
+
return { id: String(item.id ?? item[Object.keys(item)[0]]),
|
|
437
|
+
...item };
|
|
369
438
|
});
|
|
370
439
|
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
371
440
|
// One-to-one relation — inline the object
|
|
372
441
|
const relObj = v as Record<string, unknown>;
|
|
373
|
-
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
|
|
442
|
+
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
|
|
443
|
+
...relObj };
|
|
374
444
|
} else {
|
|
375
445
|
flat[k] = v;
|
|
376
446
|
}
|
|
@@ -382,7 +452,7 @@ export class EntityFetchService {
|
|
|
382
452
|
* Build db.query-compatible options from standard fetch options.
|
|
383
453
|
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
384
454
|
*/
|
|
385
|
-
private buildDrizzleQueryOptions<M extends Record<string,
|
|
455
|
+
private buildDrizzleQueryOptions<M extends Record<string, unknown>>(
|
|
386
456
|
table: PgTable<any>,
|
|
387
457
|
idField: AnyPgColumn,
|
|
388
458
|
idInfo: { fieldName: string; type: "string" | "number" },
|
|
@@ -391,11 +461,12 @@ export class EntityFetchService {
|
|
|
391
461
|
orderBy?: string;
|
|
392
462
|
order?: "desc" | "asc";
|
|
393
463
|
limit?: number;
|
|
464
|
+
offset?: number;
|
|
394
465
|
startAfter?: Record<string, unknown>;
|
|
395
466
|
searchString?: string;
|
|
396
467
|
},
|
|
397
468
|
collectionPath: string,
|
|
398
|
-
withConfig?: Record<string,
|
|
469
|
+
withConfig?: Record<string, unknown>
|
|
399
470
|
): Record<string, unknown> {
|
|
400
471
|
const queryOpts: Record<string, unknown> = {};
|
|
401
472
|
|
|
@@ -424,7 +495,7 @@ export class EntityFetchService {
|
|
|
424
495
|
|
|
425
496
|
// Cursor-based pagination (startAfter)
|
|
426
497
|
if (options.startAfter) {
|
|
427
|
-
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
|
|
498
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
428
499
|
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
429
500
|
}
|
|
430
501
|
|
|
@@ -435,7 +506,8 @@ export class EntityFetchService {
|
|
|
435
506
|
// OrderBy
|
|
436
507
|
const orderExpressions: unknown[] = [];
|
|
437
508
|
if (options.orderBy) {
|
|
438
|
-
const
|
|
509
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
510
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
439
511
|
if (orderByField) {
|
|
440
512
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
441
513
|
}
|
|
@@ -449,6 +521,9 @@ export class EntityFetchService {
|
|
|
449
521
|
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
450
522
|
if (limitValue) queryOpts.limit = limitValue;
|
|
451
523
|
|
|
524
|
+
// Offset (numeric pagination)
|
|
525
|
+
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
526
|
+
|
|
452
527
|
return queryOpts;
|
|
453
528
|
}
|
|
454
529
|
|
|
@@ -459,13 +534,15 @@ export class EntityFetchService {
|
|
|
459
534
|
table: PgTable<any>,
|
|
460
535
|
idField: AnyPgColumn,
|
|
461
536
|
idInfo: { fieldName: string; type: "string" | "number" },
|
|
462
|
-
options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> }
|
|
537
|
+
options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
|
|
538
|
+
collectionPath?: string
|
|
463
539
|
): SQL[] {
|
|
464
540
|
if (!options.startAfter) return [];
|
|
465
541
|
const cursor = options.startAfter;
|
|
466
542
|
|
|
467
543
|
if (options.orderBy) {
|
|
468
|
-
const
|
|
544
|
+
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
545
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
469
546
|
if (orderByField) {
|
|
470
547
|
const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
|
|
471
548
|
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
@@ -499,7 +576,7 @@ export class EntityFetchService {
|
|
|
499
576
|
/**
|
|
500
577
|
* Fetch a single entity by ID
|
|
501
578
|
*/
|
|
502
|
-
async fetchEntity<M extends Record<string,
|
|
579
|
+
async fetchEntity<M extends Record<string, unknown>>(
|
|
503
580
|
collectionPath: string,
|
|
504
581
|
entityId: string | number,
|
|
505
582
|
databaseId?: string
|
|
@@ -518,14 +595,14 @@ export class EntityFetchService {
|
|
|
518
595
|
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
519
596
|
|
|
520
597
|
// Primary path: use db.query.findFirst with relation loading
|
|
521
|
-
|
|
598
|
+
|
|
522
599
|
const tableName = getTableName(table);
|
|
523
600
|
|
|
524
601
|
const qb = this.getQueryBuilder(tableName);
|
|
525
602
|
if (qb) {
|
|
526
603
|
try {
|
|
527
604
|
const withConfig = this.buildWithConfig(collection);
|
|
528
|
-
|
|
605
|
+
|
|
529
606
|
const row = await qb.findFirst({
|
|
530
607
|
where: eq(idField, parsedId),
|
|
531
608
|
with: withConfig
|
|
@@ -557,7 +634,7 @@ export class EntityFetchService {
|
|
|
557
634
|
const values = await parseDataFromServer(raw, collection, this.db, this.registry);
|
|
558
635
|
|
|
559
636
|
// Load relations based on cardinality (N+1 — only used in fallback)
|
|
560
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
637
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
561
638
|
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
562
639
|
|
|
563
640
|
const relationPromises = Object.entries(resolvedRelations)
|
|
@@ -570,11 +647,9 @@ export class EntityFetchService {
|
|
|
570
647
|
key,
|
|
571
648
|
{}
|
|
572
649
|
);
|
|
573
|
-
(values as Record<string, unknown>)[key] = relatedEntities.map(e =>
|
|
574
|
-
id
|
|
575
|
-
|
|
576
|
-
__type: "relation"
|
|
577
|
-
}));
|
|
650
|
+
(values as Record<string, unknown>)[key] = relatedEntities.map(e =>
|
|
651
|
+
createRelationRef(e.id, e.path)
|
|
652
|
+
);
|
|
578
653
|
} else if (relation.cardinality === "one") {
|
|
579
654
|
if ((values as Record<string, unknown>)[key] == null) {
|
|
580
655
|
try {
|
|
@@ -586,11 +661,7 @@ export class EntityFetchService {
|
|
|
586
661
|
);
|
|
587
662
|
if (relatedEntities.length > 0) {
|
|
588
663
|
const e = relatedEntities[0];
|
|
589
|
-
(values as Record<string, unknown>)[key] =
|
|
590
|
-
id: e.id,
|
|
591
|
-
path: e.path,
|
|
592
|
-
__type: "relation"
|
|
593
|
-
};
|
|
664
|
+
(values as Record<string, unknown>)[key] = createRelationRef(e.id, e.path);
|
|
594
665
|
}
|
|
595
666
|
} catch (e) {
|
|
596
667
|
console.warn(`Could not resolve one-to-one relation property: ${key}`, e);
|
|
@@ -612,13 +683,14 @@ export class EntityFetchService {
|
|
|
612
683
|
/**
|
|
613
684
|
* Unified method to fetch entities with optional search functionality
|
|
614
685
|
*/
|
|
615
|
-
async fetchEntitiesWithConditions<M extends Record<string,
|
|
686
|
+
async fetchEntitiesWithConditions<M extends Record<string, unknown>>(
|
|
616
687
|
collectionPath: string,
|
|
617
688
|
options: {
|
|
618
689
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
619
690
|
orderBy?: string;
|
|
620
691
|
order?: "desc" | "asc";
|
|
621
692
|
limit?: number;
|
|
693
|
+
offset?: number;
|
|
622
694
|
startAfter?: Record<string, unknown>;
|
|
623
695
|
searchString?: string;
|
|
624
696
|
databaseId?: string;
|
|
@@ -635,27 +707,30 @@ export class EntityFetchService {
|
|
|
635
707
|
}
|
|
636
708
|
|
|
637
709
|
// Primary path: use db.query.findMany with relation loading
|
|
638
|
-
|
|
710
|
+
// Skip when searchString is present (same reason as fetchCollectionForRest)
|
|
711
|
+
// Skip when collection has relations — lateral JOINs are catastrophically
|
|
712
|
+
// slow for large collections (7s+ for 350 rows). The db.select fallback
|
|
713
|
+
// path uses batch relation resolution which is 50x faster.
|
|
714
|
+
|
|
639
715
|
const tableName = getTableName(table);
|
|
640
716
|
|
|
641
717
|
const qb = this.getQueryBuilder(tableName);
|
|
642
|
-
|
|
718
|
+
const withConfig = this.buildWithConfig(collection);
|
|
719
|
+
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
720
|
+
|
|
721
|
+
if (qb && !options.searchString && !hasRelations) {
|
|
643
722
|
try {
|
|
644
|
-
const withConfig = this.buildWithConfig(collection);
|
|
645
723
|
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
646
|
-
table, idField, idInfo, options, collectionPath,
|
|
724
|
+
table, idField, idInfo, options, collectionPath, undefined
|
|
647
725
|
);
|
|
648
726
|
|
|
649
|
-
|
|
727
|
+
|
|
650
728
|
const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
651
729
|
|
|
652
730
|
const entities = (results as Record<string, unknown>[]).map(row =>
|
|
653
731
|
this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
|
|
654
732
|
);
|
|
655
733
|
|
|
656
|
-
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
657
|
-
await this.resolveJoinPathRelationsBatch(entities, collection, collectionPath, idInfo, options.databaseId);
|
|
658
|
-
|
|
659
734
|
return entities;
|
|
660
735
|
} catch (e) {
|
|
661
736
|
console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
|
|
@@ -686,7 +761,7 @@ export class EntityFetchService {
|
|
|
686
761
|
|
|
687
762
|
const orderExpressions = [];
|
|
688
763
|
if (options.orderBy) {
|
|
689
|
-
const orderByField = table
|
|
764
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
690
765
|
if (orderByField) {
|
|
691
766
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
692
767
|
}
|
|
@@ -695,7 +770,7 @@ export class EntityFetchService {
|
|
|
695
770
|
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
696
771
|
|
|
697
772
|
if (options.startAfter) {
|
|
698
|
-
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
|
|
773
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
699
774
|
if (cursorConditions.length > 0) {
|
|
700
775
|
allConditions.push(...cursorConditions);
|
|
701
776
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -706,6 +781,9 @@ export class EntityFetchService {
|
|
|
706
781
|
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
707
782
|
if (limitValue) query = query.limit(limitValue);
|
|
708
783
|
|
|
784
|
+
// Offset (numeric pagination)
|
|
785
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
786
|
+
|
|
709
787
|
const results = await query;
|
|
710
788
|
|
|
711
789
|
return this.processEntityResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
@@ -718,31 +796,36 @@ export class EntityFetchService {
|
|
|
718
796
|
*
|
|
719
797
|
* Process raw database results into Entity objects with relations.
|
|
720
798
|
*/
|
|
721
|
-
private async processEntityResults<M extends Record<string,
|
|
799
|
+
private async processEntityResults<M extends Record<string, unknown>>(
|
|
722
800
|
results: Record<string, unknown>[],
|
|
723
801
|
collection: EntityCollection,
|
|
724
802
|
collectionPath: string,
|
|
725
803
|
idInfo: { fieldName: string; type: "string" | "number" },
|
|
726
804
|
databaseId?: string,
|
|
727
|
-
skipRelations
|
|
805
|
+
skipRelations = false,
|
|
728
806
|
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
729
807
|
): Promise<Entity<M>[]> {
|
|
730
808
|
if (results.length === 0) return [];
|
|
731
809
|
|
|
732
|
-
// First pass: parse all entities
|
|
810
|
+
// First pass: parse all entities WITHOUT per-entity relation queries.
|
|
811
|
+
// We deliberately omit db/registry so parseDataFromServer only does type
|
|
812
|
+
// coercion (dates, numbers, FK→relation stubs for owning relations) and
|
|
813
|
+
// does NOT issue individual SQL queries for inverse relations. The second
|
|
814
|
+
// pass below batch-loads all inverse/many relations in O(1) queries per
|
|
815
|
+
// relation type, avoiding the N+1 that plagued the old path.
|
|
733
816
|
const entitiesWithValues = await Promise.all(results.map(async (entity: Record<string, unknown>) => {
|
|
734
|
-
const values = await parseDataFromServer(entity as M, collection
|
|
817
|
+
const values = await parseDataFromServer(entity as M, collection);
|
|
735
818
|
return {
|
|
736
819
|
entity,
|
|
737
820
|
values,
|
|
738
|
-
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string,
|
|
821
|
+
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray!) : String(entity[idInfo.fieldName]),
|
|
739
822
|
path: collectionPath
|
|
740
823
|
};
|
|
741
824
|
}));
|
|
742
825
|
|
|
743
826
|
if (!skipRelations) {
|
|
744
827
|
// Second pass: batch load missing one-to-one relations
|
|
745
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
828
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
746
829
|
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
747
830
|
|
|
748
831
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
@@ -768,14 +851,9 @@ export class EntityFetchService {
|
|
|
768
851
|
|
|
769
852
|
entitiesMissingRelation.forEach(item => {
|
|
770
853
|
const entityId = item.entity[idInfo.fieldName] as string | number;
|
|
771
|
-
const relatedEntity = relationResults.get(entityId);
|
|
854
|
+
const relatedEntity = relationResults.get(String(entityId));
|
|
772
855
|
if (relatedEntity) {
|
|
773
|
-
(item.values as Record<string, unknown>)[key] =
|
|
774
|
-
id: relatedEntity.id,
|
|
775
|
-
path: relatedEntity.path,
|
|
776
|
-
__type: "relation",
|
|
777
|
-
data: relatedEntity
|
|
778
|
-
};
|
|
856
|
+
(item.values as Record<string, unknown>)[key] = createRelationRefWithData(relatedEntity.id, relatedEntity.path, relatedEntity);
|
|
779
857
|
}
|
|
780
858
|
});
|
|
781
859
|
} catch (e) {
|
|
@@ -783,32 +861,32 @@ export class EntityFetchService {
|
|
|
783
861
|
}
|
|
784
862
|
}
|
|
785
863
|
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
path: e.path,
|
|
801
|
-
__type: "relation",
|
|
802
|
-
data: e
|
|
803
|
-
}));
|
|
804
|
-
} catch (e) {
|
|
805
|
-
console.warn(`Could not resolve many relation property: ${key}`, e);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
await Promise.all(manyRelationQueries);
|
|
809
|
-
});
|
|
864
|
+
// Batch load many-cardinality relations (1 query per relation type
|
|
865
|
+
// instead of N queries per entity)
|
|
866
|
+
const manyRelations = Object.entries(resolvedRelations)
|
|
867
|
+
.filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
|
|
868
|
+
|
|
869
|
+
for (const [key, relation] of manyRelations) {
|
|
870
|
+
try {
|
|
871
|
+
const entityIds = entitiesWithValues.map(item => item.entity[idInfo.fieldName] as string | number);
|
|
872
|
+
const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
873
|
+
collectionPath,
|
|
874
|
+
entityIds,
|
|
875
|
+
key,
|
|
876
|
+
relation
|
|
877
|
+
);
|
|
810
878
|
|
|
811
|
-
|
|
879
|
+
entitiesWithValues.forEach(item => {
|
|
880
|
+
const entityId = String(item.entity[idInfo.fieldName]);
|
|
881
|
+
const relatedEntities = relationResults.get(entityId) || [];
|
|
882
|
+
(item.values as Record<string, unknown>)[key] = relatedEntities.map(e =>
|
|
883
|
+
createRelationRefWithData(e.id, e.path, e)
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
} catch (e) {
|
|
887
|
+
console.warn(`Could not batch load many relation property: ${key}`, e);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
812
890
|
}
|
|
813
891
|
|
|
814
892
|
return entitiesWithValues.map(item => ({
|
|
@@ -822,13 +900,14 @@ export class EntityFetchService {
|
|
|
822
900
|
/**
|
|
823
901
|
* Fetch a collection of entities
|
|
824
902
|
*/
|
|
825
|
-
async fetchCollection<M extends Record<string,
|
|
903
|
+
async fetchCollection<M extends Record<string, unknown>>(
|
|
826
904
|
collectionPath: string,
|
|
827
905
|
options: {
|
|
828
906
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
829
907
|
orderBy?: string;
|
|
830
908
|
order?: "desc" | "asc";
|
|
831
909
|
limit?: number;
|
|
910
|
+
offset?: number;
|
|
832
911
|
startAfter?: Record<string, unknown>;
|
|
833
912
|
searchString?: string;
|
|
834
913
|
databaseId?: string;
|
|
@@ -845,7 +924,7 @@ export class EntityFetchService {
|
|
|
845
924
|
/**
|
|
846
925
|
* Search entities by text
|
|
847
926
|
*/
|
|
848
|
-
async searchEntities<M extends Record<string,
|
|
927
|
+
async searchEntities<M extends Record<string, unknown>>(
|
|
849
928
|
collectionPath: string,
|
|
850
929
|
searchString: string,
|
|
851
930
|
options: {
|
|
@@ -865,7 +944,7 @@ export class EntityFetchService {
|
|
|
865
944
|
/**
|
|
866
945
|
* Fetch collection from multi-segment path
|
|
867
946
|
*/
|
|
868
|
-
private async fetchCollectionFromPath<M extends Record<string,
|
|
947
|
+
private async fetchCollectionFromPath<M extends Record<string, unknown>>(
|
|
869
948
|
path: string,
|
|
870
949
|
options: {
|
|
871
950
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
@@ -889,20 +968,27 @@ export class EntityFetchService {
|
|
|
889
968
|
|
|
890
969
|
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
891
970
|
const relationKey = pathSegments[i];
|
|
892
|
-
const resolvedRelations = resolveCollectionRelations(currentCollection
|
|
893
|
-
const relation = resolvedRelations
|
|
971
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
972
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
894
973
|
|
|
895
974
|
if (!relation) {
|
|
896
975
|
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
897
976
|
}
|
|
898
977
|
|
|
899
978
|
if (i === pathSegments.length - 1) {
|
|
900
|
-
|
|
979
|
+
const entities = await this.relationService.fetchRelatedEntities<M>(
|
|
901
980
|
currentCollection.slug,
|
|
902
981
|
currentEntityId,
|
|
903
982
|
relationKey,
|
|
904
983
|
options
|
|
905
984
|
);
|
|
985
|
+
// Remap entity paths to use the full subcollection path (e.g., "authors/19/posts")
|
|
986
|
+
// instead of just the target collection slug ("posts"). This ensures
|
|
987
|
+
// delete/update operations use the correct path for WebSocket notification matching.
|
|
988
|
+
for (const entity of entities) {
|
|
989
|
+
entity.path = path;
|
|
990
|
+
}
|
|
991
|
+
return entities;
|
|
906
992
|
}
|
|
907
993
|
|
|
908
994
|
if (i + 1 < pathSegments.length) {
|
|
@@ -918,10 +1004,11 @@ export class EntityFetchService {
|
|
|
918
1004
|
/**
|
|
919
1005
|
* Count entities in a collection
|
|
920
1006
|
*/
|
|
921
|
-
async countEntities<M extends Record<string,
|
|
1007
|
+
async countEntities<M extends Record<string, unknown>>(
|
|
922
1008
|
collectionPath: string,
|
|
923
1009
|
options: {
|
|
924
1010
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1011
|
+
searchString?: string;
|
|
925
1012
|
databaseId?: string;
|
|
926
1013
|
} = {}
|
|
927
1014
|
): Promise<number> {
|
|
@@ -933,12 +1020,24 @@ export class EntityFetchService {
|
|
|
933
1020
|
const table = getTableForCollection(collection, this.registry);
|
|
934
1021
|
|
|
935
1022
|
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
1023
|
+
const allConditions: SQL[] = [];
|
|
1024
|
+
|
|
1025
|
+
if (options.searchString) {
|
|
1026
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1027
|
+
options.searchString, collection.properties, table
|
|
1028
|
+
);
|
|
1029
|
+
if (searchConditions.length === 0) return 0;
|
|
1030
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
1031
|
+
}
|
|
936
1032
|
|
|
937
1033
|
if (options.filter) {
|
|
938
1034
|
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
939
|
-
if (filterConditions.length > 0)
|
|
940
|
-
|
|
941
|
-
|
|
1035
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (allConditions.length > 0) {
|
|
1039
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
1040
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
942
1041
|
}
|
|
943
1042
|
|
|
944
1043
|
const result = await query;
|
|
@@ -948,7 +1047,7 @@ export class EntityFetchService {
|
|
|
948
1047
|
/**
|
|
949
1048
|
* Count entities from multi-segment path
|
|
950
1049
|
*/
|
|
951
|
-
private async countEntitiesFromPath<M extends Record<string,
|
|
1050
|
+
private async countEntitiesFromPath<M extends Record<string, unknown>>(
|
|
952
1051
|
path: string,
|
|
953
1052
|
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
954
1053
|
): Promise<number> {
|
|
@@ -964,8 +1063,8 @@ export class EntityFetchService {
|
|
|
964
1063
|
|
|
965
1064
|
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
966
1065
|
const relationKey = pathSegments[i];
|
|
967
|
-
const resolvedRelations = resolveCollectionRelations(currentCollection
|
|
968
|
-
const relation = resolvedRelations
|
|
1066
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
1067
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
969
1068
|
|
|
970
1069
|
if (!relation) {
|
|
971
1070
|
throw new Error(`Relation '${relationKey}' not found`);
|
|
@@ -1046,13 +1145,14 @@ export class EntityFetchService {
|
|
|
1046
1145
|
*
|
|
1047
1146
|
* @param include - Array of relation keys to populate, or ["*"] for all
|
|
1048
1147
|
*/
|
|
1049
|
-
async fetchCollectionForRest<M extends Record<string,
|
|
1148
|
+
async fetchCollectionForRest<M extends Record<string, unknown>>(
|
|
1050
1149
|
collectionPath: string,
|
|
1051
1150
|
options: {
|
|
1052
1151
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1053
1152
|
orderBy?: string;
|
|
1054
1153
|
order?: "desc" | "asc";
|
|
1055
1154
|
limit?: number;
|
|
1155
|
+
offset?: number;
|
|
1056
1156
|
startAfter?: Record<string, unknown>;
|
|
1057
1157
|
searchString?: string;
|
|
1058
1158
|
databaseId?: string;
|
|
@@ -1066,11 +1166,14 @@ export class EntityFetchService {
|
|
|
1066
1166
|
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1067
1167
|
|
|
1068
1168
|
// Primary path: use db.query.findMany
|
|
1069
|
-
|
|
1169
|
+
// NOTE: Skip db.query path when searchString is present because
|
|
1170
|
+
// Drizzle's relational query API doesn't properly apply raw SQL
|
|
1171
|
+
// ILIKE conditions — the fallback db.select path handles them correctly.
|
|
1172
|
+
|
|
1070
1173
|
const tableName = getTableName(table);
|
|
1071
1174
|
|
|
1072
1175
|
const qb = this.getQueryBuilder(tableName);
|
|
1073
|
-
if (qb) {
|
|
1176
|
+
if (qb && !options.searchString) {
|
|
1074
1177
|
try {
|
|
1075
1178
|
const withConfig = (include && include.length > 0)
|
|
1076
1179
|
? this.buildWithConfig(collection, include)
|
|
@@ -1080,12 +1183,17 @@ export class EntityFetchService {
|
|
|
1080
1183
|
table, idField, idInfo, options, collectionPath, withConfig
|
|
1081
1184
|
);
|
|
1082
1185
|
|
|
1083
|
-
|
|
1186
|
+
|
|
1084
1187
|
const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1085
1188
|
|
|
1086
|
-
|
|
1189
|
+
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1087
1190
|
this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
|
|
1088
1191
|
);
|
|
1192
|
+
|
|
1193
|
+
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1194
|
+
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
1195
|
+
|
|
1196
|
+
return restRows;
|
|
1089
1197
|
} catch (e) {
|
|
1090
1198
|
console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
|
|
1091
1199
|
}
|
|
@@ -1096,13 +1204,13 @@ export class EntityFetchService {
|
|
|
1096
1204
|
|
|
1097
1205
|
if (!include || include.length === 0) {
|
|
1098
1206
|
return entities.map(entity => ({
|
|
1099
|
-
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string,
|
|
1207
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray) : String(entity[idInfo.fieldName]),
|
|
1100
1208
|
...entity
|
|
1101
1209
|
}));
|
|
1102
1210
|
}
|
|
1103
1211
|
|
|
1104
1212
|
// Fallback relation loading via batch
|
|
1105
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
1213
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1106
1214
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1107
1215
|
const shouldInclude = (key: string) =>
|
|
1108
1216
|
include[0] === "*" || include.includes(key);
|
|
@@ -1117,9 +1225,10 @@ export class EntityFetchService {
|
|
|
1117
1225
|
);
|
|
1118
1226
|
for (const entity of entities) {
|
|
1119
1227
|
const eid = entity[idInfo.fieldName] as string | number;
|
|
1120
|
-
const related = batchResults.get(eid);
|
|
1228
|
+
const related = batchResults.get(String(eid));
|
|
1121
1229
|
if (related) {
|
|
1122
|
-
(entity as Record<string, unknown>)[key] = { id: related.id,
|
|
1230
|
+
(entity as Record<string, unknown>)[key] = { id: related.id,
|
|
1231
|
+
...related.values };
|
|
1123
1232
|
}
|
|
1124
1233
|
}
|
|
1125
1234
|
} catch (e) {
|
|
@@ -1137,7 +1246,8 @@ export class EntityFetchService {
|
|
|
1137
1246
|
const eid = entity[idInfo.fieldName] as string | number;
|
|
1138
1247
|
const relatedList = batchResults.get(String(eid)) || [];
|
|
1139
1248
|
(entity as Record<string, unknown>)[key] = relatedList.map(e => ({
|
|
1140
|
-
id: e.id,
|
|
1249
|
+
id: e.id,
|
|
1250
|
+
...e.values
|
|
1141
1251
|
}));
|
|
1142
1252
|
}
|
|
1143
1253
|
} catch (e) {
|
|
@@ -1146,7 +1256,7 @@ export class EntityFetchService {
|
|
|
1146
1256
|
}
|
|
1147
1257
|
|
|
1148
1258
|
return entities.map(entity => ({
|
|
1149
|
-
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string,
|
|
1259
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, unknown>, idInfoArray) : String(entity[idInfo.fieldName]),
|
|
1150
1260
|
...entity
|
|
1151
1261
|
}));
|
|
1152
1262
|
}
|
|
@@ -1154,7 +1264,7 @@ export class EntityFetchService {
|
|
|
1154
1264
|
/**
|
|
1155
1265
|
* Fetch a single entity with optional relation includes for REST API.
|
|
1156
1266
|
*/
|
|
1157
|
-
async fetchEntityForRest<M extends Record<string,
|
|
1267
|
+
async fetchEntityForRest<M extends Record<string, unknown>>(
|
|
1158
1268
|
collectionPath: string,
|
|
1159
1269
|
entityId: string | number,
|
|
1160
1270
|
include?: string[],
|
|
@@ -1170,7 +1280,7 @@ export class EntityFetchService {
|
|
|
1170
1280
|
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
1171
1281
|
|
|
1172
1282
|
// Primary path: use db.query.findFirst
|
|
1173
|
-
|
|
1283
|
+
|
|
1174
1284
|
const tableName = getTableName(table);
|
|
1175
1285
|
|
|
1176
1286
|
const qb = this.getQueryBuilder(tableName);
|
|
@@ -1180,7 +1290,7 @@ export class EntityFetchService {
|
|
|
1180
1290
|
? this.buildWithConfig(collection, include)
|
|
1181
1291
|
: undefined;
|
|
1182
1292
|
|
|
1183
|
-
|
|
1293
|
+
|
|
1184
1294
|
const row = await qb.findFirst({
|
|
1185
1295
|
where: eq(idField, parsedId),
|
|
1186
1296
|
...(withConfig ? { with: withConfig } : {})
|
|
@@ -1188,7 +1298,12 @@ export class EntityFetchService {
|
|
|
1188
1298
|
|
|
1189
1299
|
if (!row) return null;
|
|
1190
1300
|
|
|
1191
|
-
|
|
1301
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
1302
|
+
|
|
1303
|
+
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1304
|
+
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
1305
|
+
|
|
1306
|
+
return restRow;
|
|
1192
1307
|
} catch (e) {
|
|
1193
1308
|
console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
|
|
1194
1309
|
}
|
|
@@ -1204,14 +1319,15 @@ export class EntityFetchService {
|
|
|
1204
1319
|
if (result.length === 0) return null;
|
|
1205
1320
|
|
|
1206
1321
|
const raw = result[0] as Record<string, unknown>;
|
|
1207
|
-
const flatEntity: Record<string, unknown> = { id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string,
|
|
1322
|
+
const flatEntity: Record<string, unknown> = { id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>, idInfoArray) : String(raw[idInfo.fieldName]),
|
|
1323
|
+
...raw };
|
|
1208
1324
|
|
|
1209
1325
|
if (!include || include.length === 0) {
|
|
1210
1326
|
return flatEntity;
|
|
1211
1327
|
}
|
|
1212
1328
|
|
|
1213
1329
|
// Fallback relation population
|
|
1214
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
1330
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1215
1331
|
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1216
1332
|
const shouldInclude = (key: string) =>
|
|
1217
1333
|
include[0] === "*" || include.includes(key);
|
|
@@ -1227,11 +1343,13 @@ export class EntityFetchService {
|
|
|
1227
1343
|
if (relation.cardinality === "one") {
|
|
1228
1344
|
if (relatedEntities.length > 0) {
|
|
1229
1345
|
const e = relatedEntities[0];
|
|
1230
|
-
flatEntity[key] = { id: e.id,
|
|
1346
|
+
flatEntity[key] = { id: e.id,
|
|
1347
|
+
...e.values };
|
|
1231
1348
|
}
|
|
1232
1349
|
} else {
|
|
1233
1350
|
flatEntity[key] = relatedEntities.map(e => ({
|
|
1234
|
-
id: e.id,
|
|
1351
|
+
id: e.id,
|
|
1352
|
+
...e.values
|
|
1235
1353
|
}));
|
|
1236
1354
|
}
|
|
1237
1355
|
} catch (e) {
|
|
@@ -1245,13 +1363,14 @@ export class EntityFetchService {
|
|
|
1245
1363
|
/**
|
|
1246
1364
|
* Fetch raw rows without any relation processing (for REST fast path)
|
|
1247
1365
|
*/
|
|
1248
|
-
private async fetchEntitiesWithConditionsRaw<M extends Record<string,
|
|
1366
|
+
private async fetchEntitiesWithConditionsRaw<M extends Record<string, unknown>>(
|
|
1249
1367
|
collectionPath: string,
|
|
1250
1368
|
options: {
|
|
1251
1369
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1252
1370
|
orderBy?: string;
|
|
1253
1371
|
order?: "desc" | "asc";
|
|
1254
1372
|
limit?: number;
|
|
1373
|
+
offset?: number;
|
|
1255
1374
|
startAfter?: Record<string, unknown>;
|
|
1256
1375
|
searchString?: string;
|
|
1257
1376
|
} = {}
|
|
@@ -1285,7 +1404,7 @@ export class EntityFetchService {
|
|
|
1285
1404
|
|
|
1286
1405
|
const orderExpressions = [];
|
|
1287
1406
|
if (options.orderBy) {
|
|
1288
|
-
const orderByField = table
|
|
1407
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1289
1408
|
if (orderByField) {
|
|
1290
1409
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1291
1410
|
}
|
|
@@ -1296,6 +1415,9 @@ export class EntityFetchService {
|
|
|
1296
1415
|
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
1297
1416
|
if (limitValue) query = query.limit(limitValue);
|
|
1298
1417
|
|
|
1418
|
+
// Offset (numeric pagination)
|
|
1419
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
1420
|
+
|
|
1299
1421
|
return await query as Record<string, unknown>[];
|
|
1300
1422
|
}
|
|
1301
1423
|
|
|
@@ -1305,7 +1427,7 @@ export class EntityFetchService {
|
|
|
1305
1427
|
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
1306
1428
|
*/
|
|
1307
1429
|
private hasDrizzleQueryAPI(collectionPath: string): boolean {
|
|
1308
|
-
|
|
1430
|
+
|
|
1309
1431
|
const qb = this.getQueryBuilder("__probe__");
|
|
1310
1432
|
if (!qb) {
|
|
1311
1433
|
// If getQueryBuilder returns undefined even for a probe, query API is not available
|
|
@@ -1323,7 +1445,7 @@ export class EntityFetchService {
|
|
|
1323
1445
|
* Returns null if the API is not available or the query fails.
|
|
1324
1446
|
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1325
1447
|
*/
|
|
1326
|
-
private async fetchWithDrizzleQuery<M extends Record<string,
|
|
1448
|
+
private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
|
|
1327
1449
|
collectionPath: string,
|
|
1328
1450
|
collection: EntityCollection,
|
|
1329
1451
|
options: {
|
|
@@ -1337,7 +1459,7 @@ export class EntityFetchService {
|
|
|
1337
1459
|
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1338
1460
|
): Promise<Record<string, unknown>[] | null> {
|
|
1339
1461
|
try {
|
|
1340
|
-
|
|
1462
|
+
|
|
1341
1463
|
const table = getTableForCollection(collection, this.registry);
|
|
1342
1464
|
const tableName = getTableName(table);
|
|
1343
1465
|
const queryTarget = this.getQueryBuilder(tableName);
|
|
@@ -1345,7 +1467,7 @@ export class EntityFetchService {
|
|
|
1345
1467
|
if (!queryTarget?.findMany) return null;
|
|
1346
1468
|
|
|
1347
1469
|
// Build the `with` config from include array
|
|
1348
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
1470
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1349
1471
|
const withConfig: Record<string, boolean> = {};
|
|
1350
1472
|
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1351
1473
|
if (include[0] === "*" || include.includes(key)) {
|
|
@@ -1371,18 +1493,18 @@ export class EntityFetchService {
|
|
|
1371
1493
|
|
|
1372
1494
|
// Build orderBy
|
|
1373
1495
|
if (options.orderBy) {
|
|
1374
|
-
const orderByField = table
|
|
1496
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1375
1497
|
if (orderByField) {
|
|
1376
1498
|
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1377
1499
|
}
|
|
1378
1500
|
}
|
|
1379
1501
|
|
|
1380
|
-
|
|
1502
|
+
|
|
1381
1503
|
const results = await queryTarget.findMany(queryOpts as unknown as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1382
1504
|
|
|
1383
1505
|
// Flatten the nested Drizzle results into REST format
|
|
1384
1506
|
return results.map((row: Record<string, unknown>) => {
|
|
1385
|
-
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string,
|
|
1507
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
1386
1508
|
for (const [k, v] of Object.entries(row)) {
|
|
1387
1509
|
if (k === idInfo.fieldName) continue;
|
|
1388
1510
|
if (Array.isArray(v)) {
|
|
@@ -1394,14 +1516,17 @@ export class EntityFetchService {
|
|
|
1394
1516
|
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1395
1517
|
if (nestedObj && keys.length <= 3) {
|
|
1396
1518
|
const nested = item[nestedObj] as Record<string, unknown>;
|
|
1397
|
-
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
|
|
1519
|
+
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]),
|
|
1520
|
+
...nested };
|
|
1398
1521
|
}
|
|
1399
|
-
return { id: String(item.id ?? item[Object.keys(item)[0]]),
|
|
1522
|
+
return { id: String(item.id ?? item[Object.keys(item)[0]]),
|
|
1523
|
+
...item };
|
|
1400
1524
|
});
|
|
1401
1525
|
} else if (typeof v === "object" && v !== null) {
|
|
1402
1526
|
// One-to-one relation — inline the object
|
|
1403
1527
|
const relObj = v as Record<string, unknown>;
|
|
1404
|
-
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
|
|
1528
|
+
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]),
|
|
1529
|
+
...relObj };
|
|
1405
1530
|
} else {
|
|
1406
1531
|
flat[k] = v;
|
|
1407
1532
|
}
|
|
@@ -1427,21 +1552,25 @@ export class EntityFetchService {
|
|
|
1427
1552
|
parentIds: (string | number)[],
|
|
1428
1553
|
relationKey: string
|
|
1429
1554
|
): Promise<Map<string, Entity[]>> {
|
|
1430
|
-
|
|
1555
|
+
if (parentIds.length === 0) return new Map();
|
|
1431
1556
|
|
|
1432
|
-
//
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1557
|
+
// Resolve the relation definition so we can use the true batch method
|
|
1558
|
+
const collection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
1559
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1560
|
+
const relation = resolvedRelations[relationKey];
|
|
1561
|
+
|
|
1562
|
+
if (!relation) {
|
|
1563
|
+
console.warn(`[batchFetchManyRelatedEntities] Relation '${relationKey}' not found, skipping`);
|
|
1564
|
+
return new Map();
|
|
1565
|
+
}
|
|
1443
1566
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1567
|
+
// Delegate to RelationService.batchFetchRelatedEntitiesMany which
|
|
1568
|
+
// uses a single SQL query with IN(...) — O(1) instead of O(N).
|
|
1569
|
+
return this.relationService.batchFetchRelatedEntitiesMany(
|
|
1570
|
+
parentCollectionPath,
|
|
1571
|
+
parentIds,
|
|
1572
|
+
relationKey,
|
|
1573
|
+
relation
|
|
1574
|
+
);
|
|
1446
1575
|
}
|
|
1447
1576
|
}
|