@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
|
@@ -2,7 +2,7 @@ import { eq, and } from "drizzle-orm";
|
|
|
2
2
|
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { Entity, EntityCollection, Properties, Relation } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
@@ -56,7 +56,7 @@ export class EntityPersistService {
|
|
|
56
56
|
/**
|
|
57
57
|
* Save an entity (create or update)
|
|
58
58
|
*/
|
|
59
|
-
async saveEntity<M extends Record<string,
|
|
59
|
+
async saveEntity<M extends Record<string, unknown>>(
|
|
60
60
|
collectionPath: string,
|
|
61
61
|
values: Partial<M>,
|
|
62
62
|
entityId?: string | number,
|
|
@@ -65,6 +65,7 @@ export class EntityPersistService {
|
|
|
65
65
|
// If saving under a nested relation path, resolve the parent and inject FK
|
|
66
66
|
let effectiveCollectionPath = collectionPath;
|
|
67
67
|
const effectiveValues: Partial<M> = { ...values };
|
|
68
|
+
let junctionTableInfo: { parentCollection: EntityCollection; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
|
|
68
69
|
|
|
69
70
|
if (collectionPath.includes("/")) {
|
|
70
71
|
const segments = collectionPath.split("/").filter(Boolean);
|
|
@@ -75,11 +76,12 @@ export class EntityPersistService {
|
|
|
75
76
|
|
|
76
77
|
for (let i = 2; i < segments.length; i += 2) {
|
|
77
78
|
const relationKey = segments[i];
|
|
78
|
-
const resolvedRelations = resolveCollectionRelations(currentCollection
|
|
79
|
-
const relation = resolvedRelations
|
|
79
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
80
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
80
81
|
|
|
81
82
|
if (!relation) {
|
|
82
|
-
|
|
83
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
84
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'. Available relations: [${available}]`);
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
if (i === segments.length - 1) {
|
|
@@ -93,7 +95,7 @@ export class EntityPersistService {
|
|
|
93
95
|
const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
|
|
94
96
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
junctionTableInfo = {
|
|
97
99
|
parentCollection: currentCollection,
|
|
98
100
|
parentId: parsedParentId,
|
|
99
101
|
relation: relation,
|
|
@@ -161,7 +163,7 @@ export class EntityPersistService {
|
|
|
161
163
|
// Separate relations that require special handling
|
|
162
164
|
const relationValues: Record<string, unknown> = {};
|
|
163
165
|
const otherValues: Partial<M> = { ...effectiveValues };
|
|
164
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
166
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
165
167
|
|
|
166
168
|
for (const key in resolvedRelations) {
|
|
167
169
|
const relation = resolvedRelations[key];
|
|
@@ -174,17 +176,13 @@ export class EntityPersistService {
|
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
// Transform relations to IDs, then sanitize
|
|
177
|
-
const
|
|
179
|
+
const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
|
|
178
180
|
|
|
179
|
-
// Extract relation updates
|
|
180
|
-
const inverseRelationUpdates =
|
|
181
|
-
const joinPathRelationUpdates =
|
|
182
|
-
const junctionTableInfo = (processedData as Record<string, unknown>).__junction_table_info as { parentCollection: EntityCollection<any, any>; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
|
|
183
|
-
delete (processedData as Record<string, unknown>).__inverseRelationUpdates;
|
|
184
|
-
delete (processedData as Record<string, unknown>).__joinPathRelationUpdates;
|
|
185
|
-
delete (processedData as Record<string, unknown>).__junction_table_info;
|
|
181
|
+
// Extract relation updates from the typed result
|
|
182
|
+
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
183
|
+
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
186
184
|
|
|
187
|
-
const entityData = sanitizeAndConvertDates(
|
|
185
|
+
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
188
186
|
|
|
189
187
|
let savedId: string | number;
|
|
190
188
|
try {
|
|
@@ -211,7 +209,7 @@ export class EntityPersistService {
|
|
|
211
209
|
// empty after relation stripping and Drizzle throws "No values to set".
|
|
212
210
|
const scalarKeys = Object.keys(entityData as Record<string, unknown>);
|
|
213
211
|
if (scalarKeys.length > 0) {
|
|
214
|
-
|
|
212
|
+
const updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
|
|
215
213
|
const conditions = [];
|
|
216
214
|
for (const info of idInfoArray) {
|
|
217
215
|
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
|
|
@@ -2,7 +2,7 @@ import { and, eq, inArray, sql, SQL } from "drizzle-orm";
|
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { DrizzleClient } from "../interfaces";
|
|
4
4
|
import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
@@ -24,7 +24,7 @@ export class RelationService {
|
|
|
24
24
|
/**
|
|
25
25
|
* Fetch entities related to a parent entity through a specific relation
|
|
26
26
|
*/
|
|
27
|
-
async fetchRelatedEntities<M extends Record<string,
|
|
27
|
+
async fetchRelatedEntities<M extends Record<string, unknown>>(
|
|
28
28
|
parentCollectionPath: string,
|
|
29
29
|
parentEntityId: string | number,
|
|
30
30
|
relationKey: string,
|
|
@@ -39,11 +39,12 @@ export class RelationService {
|
|
|
39
39
|
} = {}
|
|
40
40
|
): Promise<Entity<M>[]> {
|
|
41
41
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
42
|
-
const resolvedRelations = resolveCollectionRelations(parentCollection
|
|
43
|
-
const relation = resolvedRelations
|
|
42
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
43
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
44
44
|
|
|
45
45
|
if (!relation) {
|
|
46
|
-
|
|
46
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
47
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
return this.fetchEntitiesUsingJoins<M>(parentCollection, parentEntityId, relation, options);
|
|
@@ -52,7 +53,7 @@ export class RelationService {
|
|
|
52
53
|
/**
|
|
53
54
|
* Fetch entities using join paths for complex relations
|
|
54
55
|
*/
|
|
55
|
-
async fetchEntitiesUsingJoins<M extends Record<string,
|
|
56
|
+
async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
|
|
56
57
|
parentCollection: EntityCollection,
|
|
57
58
|
parentEntityId: string | number,
|
|
58
59
|
relation: Relation,
|
|
@@ -209,16 +210,19 @@ export class RelationService {
|
|
|
209
210
|
/**
|
|
210
211
|
* Count related entities for a parent entity
|
|
211
212
|
*/
|
|
212
|
-
async countRelatedEntities<M extends Record<string,
|
|
213
|
+
async countRelatedEntities<M extends Record<string, unknown>>(
|
|
213
214
|
parentCollectionPath: string,
|
|
214
215
|
parentEntityId: string | number,
|
|
215
216
|
relationKey: string,
|
|
216
217
|
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
217
218
|
): Promise<number> {
|
|
218
219
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
219
|
-
const resolvedRelations = resolveCollectionRelations(parentCollection
|
|
220
|
-
const relation = resolvedRelations
|
|
221
|
-
if (!relation)
|
|
220
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
221
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
222
|
+
if (!relation) {
|
|
223
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
224
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
225
|
+
}
|
|
222
226
|
|
|
223
227
|
const targetCollection = relation.target();
|
|
224
228
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
@@ -265,7 +269,7 @@ export class RelationService {
|
|
|
265
269
|
parentEntityIds: (string | number)[],
|
|
266
270
|
_relationKey: string,
|
|
267
271
|
relation: Relation
|
|
268
|
-
): Promise<Map<string
|
|
272
|
+
): Promise<Map<string, Entity<Record<string, unknown>>>> {
|
|
269
273
|
if (parentEntityIds.length === 0) return new Map();
|
|
270
274
|
|
|
271
275
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
@@ -323,25 +327,95 @@ export class RelationService {
|
|
|
323
327
|
|
|
324
328
|
const results = await query;
|
|
325
329
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
326
|
-
const resultMap = new Map<string
|
|
330
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
327
331
|
|
|
328
332
|
// Group results by parent ID
|
|
329
|
-
|
|
333
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
330
334
|
const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
331
335
|
const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
|
|
332
336
|
const parentId = parentEntity[parentIdInfo.fieldName] as string | number;
|
|
333
337
|
|
|
334
|
-
|
|
338
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
339
|
+
|
|
340
|
+
resultMap.set(String(parentId), {
|
|
335
341
|
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
336
342
|
path: targetCollection.slug,
|
|
337
|
-
values:
|
|
343
|
+
values: parsedValues as Record<string, unknown>
|
|
338
344
|
});
|
|
339
|
-
}
|
|
345
|
+
}
|
|
340
346
|
|
|
341
347
|
return resultMap;
|
|
342
348
|
}
|
|
343
349
|
|
|
344
|
-
// Handle
|
|
350
|
+
// Handle owning relations with proper FK-based batching.
|
|
351
|
+
// For owning relations, parent entities hold the FK (e.g. posts.author_id).
|
|
352
|
+
// We need to:
|
|
353
|
+
// 1. Fetch FK values from the parent table in a single query
|
|
354
|
+
// 2. Query the target table with unique FK values
|
|
355
|
+
// 3. Map results back to parent entities via their FK values
|
|
356
|
+
if (relation.direction === "owning" && relation.localKey) {
|
|
357
|
+
const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
|
|
358
|
+
if (!localKeyCol) {
|
|
359
|
+
throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Step 1: Fetch all FK values from parent table in ONE query
|
|
363
|
+
const fkRows = await this.db
|
|
364
|
+
.select({
|
|
365
|
+
parentId: parentIdCol,
|
|
366
|
+
fkValue: localKeyCol
|
|
367
|
+
})
|
|
368
|
+
.from(parentTable)
|
|
369
|
+
.where(inArray(parentIdCol, parsedParentIds));
|
|
370
|
+
|
|
371
|
+
// Build parentId → fkValue mapping and collect unique FK values
|
|
372
|
+
const parentToFk = new Map<string, string | number>();
|
|
373
|
+
const uniqueFkValues: (string | number)[] = [];
|
|
374
|
+
const seenFks = new Set<string>();
|
|
375
|
+
|
|
376
|
+
for (const row of fkRows as Array<{ parentId: string | number; fkValue: string | number | null }>) {
|
|
377
|
+
if (row.fkValue == null) continue;
|
|
378
|
+
parentToFk.set(String(row.parentId), row.fkValue);
|
|
379
|
+
const fkStr = String(row.fkValue);
|
|
380
|
+
if (!seenFks.has(fkStr)) {
|
|
381
|
+
seenFks.add(fkStr);
|
|
382
|
+
uniqueFkValues.push(row.fkValue);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (uniqueFkValues.length === 0) return new Map();
|
|
387
|
+
|
|
388
|
+
// Step 2: Fetch all target entities in ONE query
|
|
389
|
+
const targetResults = await this.db
|
|
390
|
+
.select()
|
|
391
|
+
.from(targetTable)
|
|
392
|
+
.where(inArray(targetIdField, uniqueFkValues));
|
|
393
|
+
|
|
394
|
+
// Index target entities by their ID
|
|
395
|
+
const targetById = new Map<string, Record<string, unknown>>();
|
|
396
|
+
for (const row of targetResults as Array<Record<string, unknown>>) {
|
|
397
|
+
const tid = String(row[targetIdInfo.fieldName]);
|
|
398
|
+
targetById.set(tid, row);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Step 3: Map back to parent entities
|
|
402
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
403
|
+
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
404
|
+
const targetEntity = targetById.get(String(fkValue));
|
|
405
|
+
if (targetEntity) {
|
|
406
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
407
|
+
resultMap.set(parentIdStr, {
|
|
408
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
409
|
+
path: targetCollection.slug,
|
|
410
|
+
values: parsedValues as Record<string, unknown>
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return resultMap;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Handle inverse relation types with batching
|
|
345
419
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
346
420
|
|
|
347
421
|
// Build the relation query with ALL parent IDs
|
|
@@ -359,10 +433,15 @@ export class RelationService {
|
|
|
359
433
|
);
|
|
360
434
|
|
|
361
435
|
const results = await query;
|
|
362
|
-
const resultMap = new Map<string
|
|
436
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
437
|
+
|
|
438
|
+
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
439
|
+
// number-vs-string type mismatches (Drizzle may return either depending
|
|
440
|
+
// on the column type and driver).
|
|
441
|
+
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
363
442
|
|
|
364
443
|
// Map results back to parent entities
|
|
365
|
-
|
|
444
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
366
445
|
const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
367
446
|
|
|
368
447
|
// Determine the parent ID this result belongs to based on the relation type
|
|
@@ -373,23 +452,201 @@ export class RelationService {
|
|
|
373
452
|
} else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
374
453
|
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
375
454
|
parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
|
|
376
|
-
} else if (relation.direction === "owning" && relation.localKey) {
|
|
377
|
-
for (const parsedParentId of parsedParentIds) {
|
|
378
|
-
if (!resultMap.has(parsedParentId)) {
|
|
379
|
-
parentId = parsedParentId;
|
|
380
|
-
break;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
455
|
}
|
|
384
456
|
|
|
385
|
-
if (parentId !== undefined &&
|
|
386
|
-
|
|
457
|
+
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
458
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
459
|
+
resultMap.set(String(parentId), {
|
|
460
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
461
|
+
path: targetCollection.slug,
|
|
462
|
+
values: parsedValues as Record<string, unknown>
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return resultMap;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Batch fetch many-cardinality related entities for multiple parent entities.
|
|
472
|
+
* Returns a Map<parentId, Entity[]> instead of Map<parentId, Entity>.
|
|
473
|
+
* Uses a single SQL query with IN clause to avoid N+1.
|
|
474
|
+
*/
|
|
475
|
+
async batchFetchRelatedEntitiesMany(
|
|
476
|
+
parentCollectionPath: string,
|
|
477
|
+
parentEntityIds: (string | number)[],
|
|
478
|
+
_relationKey: string,
|
|
479
|
+
relation: Relation
|
|
480
|
+
): Promise<Map<string, Entity<Record<string, unknown>>[]>> {
|
|
481
|
+
if (parentEntityIds.length === 0) return new Map();
|
|
482
|
+
|
|
483
|
+
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
484
|
+
const targetCollection = relation.target();
|
|
485
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
486
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
487
|
+
const targetIdInfo = targetPks[0];
|
|
488
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
489
|
+
|
|
490
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
491
|
+
const parentIdInfo = parentPks[0];
|
|
492
|
+
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
493
|
+
if (!parentTable) throw new Error("Parent table not found");
|
|
494
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
495
|
+
|
|
496
|
+
const parsedParentIds = parentEntityIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
497
|
+
|
|
498
|
+
// Handle join path relations (many-to-many through junction tables)
|
|
499
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
500
|
+
let query = this.db.select().from(parentTable).$dynamic();
|
|
501
|
+
let currentTable = parentTable;
|
|
502
|
+
|
|
503
|
+
for (const join of relation.joinPath) {
|
|
504
|
+
const joinTable = this.registry.getTable(join.table);
|
|
505
|
+
if (!joinTable) throw new Error(`Join table not found: ${join.table}`);
|
|
506
|
+
|
|
507
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
508
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
509
|
+
const fromColName = fromColumn.split(".").pop()!;
|
|
510
|
+
const toColName = toColumn.split(".").pop()!;
|
|
511
|
+
|
|
512
|
+
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
513
|
+
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
514
|
+
if (!fromCol || !toCol) throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
515
|
+
|
|
516
|
+
// @ts-expect-error Drizzle mutates base query generic on innerJoin
|
|
517
|
+
query = query.innerJoin(joinTable, eq(fromCol, toCol));
|
|
518
|
+
currentTable = joinTable;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
522
|
+
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
523
|
+
|
|
524
|
+
const results = await query;
|
|
525
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
526
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
527
|
+
|
|
528
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
529
|
+
const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
530
|
+
const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
|
|
531
|
+
const parentId = String(parentEntity[parentIdInfo.fieldName]);
|
|
532
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
533
|
+
|
|
534
|
+
const arr = resultMap.get(parentId) || [];
|
|
535
|
+
arr.push({
|
|
536
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
537
|
+
path: targetCollection.slug,
|
|
538
|
+
values: parsedValues as Record<string, unknown>
|
|
539
|
+
});
|
|
540
|
+
resultMap.set(parentId, arr);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return resultMap;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Handle many-to-many owning relations with junction table (relation.through)
|
|
547
|
+
// This is the standard path for posts→tags style relations where
|
|
548
|
+
// sanitizeRelation populated the `through` config.
|
|
549
|
+
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
550
|
+
const junctionTable = this.registry.getTable(relation.through.table);
|
|
551
|
+
if (!junctionTable) {
|
|
552
|
+
console.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
553
|
+
return new Map();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const sourceJunctionCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
557
|
+
const targetJunctionCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
558
|
+
|
|
559
|
+
if (!sourceJunctionCol || !targetJunctionCol) {
|
|
560
|
+
console.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
|
|
561
|
+
return new Map();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// SELECT target.*, junction.sourceColumn FROM junction
|
|
565
|
+
// INNER JOIN target ON junction.targetColumn = target.id
|
|
566
|
+
// WHERE junction.sourceColumn IN (parentIds)
|
|
567
|
+
const query = this.db
|
|
568
|
+
.select()
|
|
569
|
+
.from(junctionTable)
|
|
570
|
+
.innerJoin(targetTable, eq(targetJunctionCol, targetIdField))
|
|
571
|
+
.where(inArray(sourceJunctionCol, parsedParentIds));
|
|
572
|
+
|
|
573
|
+
const results = await query;
|
|
574
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
575
|
+
const targetTableName = getTableName(targetCollection);
|
|
576
|
+
|
|
577
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
578
|
+
// The junction table data is namespaced under its table name
|
|
579
|
+
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
580
|
+
const targetData = (row[targetTableName] || row) as Record<string, unknown>;
|
|
581
|
+
|
|
582
|
+
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
583
|
+
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
584
|
+
|
|
585
|
+
const arr = resultMap.get(parentId) || [];
|
|
586
|
+
arr.push({
|
|
587
|
+
id: String(targetData[targetIdInfo.fieldName]),
|
|
588
|
+
path: targetCollection.slug,
|
|
589
|
+
values: parsedValues as Record<string, unknown>
|
|
590
|
+
});
|
|
591
|
+
resultMap.set(parentId, arr);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return resultMap;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Handle FK-based relations (one-to-many inverse)
|
|
598
|
+
let query = this.db.select().from(targetTable).$dynamic();
|
|
599
|
+
|
|
600
|
+
// @ts-expect-error buildRelationQuery uses dynamic queries
|
|
601
|
+
query = DrizzleConditionBuilder.buildRelationQuery(
|
|
602
|
+
query,
|
|
603
|
+
relation,
|
|
604
|
+
parsedParentIds,
|
|
605
|
+
targetTable,
|
|
606
|
+
parentTable,
|
|
607
|
+
parentIdCol,
|
|
608
|
+
targetIdField,
|
|
609
|
+
this.registry,
|
|
610
|
+
[]
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const results = await query;
|
|
614
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
615
|
+
|
|
616
|
+
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
617
|
+
// number-vs-string type mismatches (Drizzle may return either depending
|
|
618
|
+
// on the column type and driver).
|
|
619
|
+
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
620
|
+
|
|
621
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
622
|
+
const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
623
|
+
|
|
624
|
+
let parentId: string | number | undefined;
|
|
625
|
+
|
|
626
|
+
if (relation.through && relation.direction === "inverse") {
|
|
627
|
+
// Inverse many-to-many via junction table: the junction's targetColumn
|
|
628
|
+
// references the parent (since from the inverse perspective, source/target are swapped).
|
|
629
|
+
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
630
|
+
parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
|
|
631
|
+
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
632
|
+
parentId = targetEntity[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
633
|
+
} else if (relation.direction === "inverse" && relation.inverseRelationName) {
|
|
634
|
+
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
635
|
+
parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
639
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
640
|
+
const key = String(parentId);
|
|
641
|
+
const arr = resultMap.get(key) || [];
|
|
642
|
+
arr.push({
|
|
387
643
|
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
388
644
|
path: targetCollection.slug,
|
|
389
|
-
values:
|
|
645
|
+
values: parsedValues as Record<string, unknown>
|
|
390
646
|
});
|
|
647
|
+
resultMap.set(key, arr);
|
|
391
648
|
}
|
|
392
|
-
}
|
|
649
|
+
}
|
|
393
650
|
|
|
394
651
|
return resultMap;
|
|
395
652
|
}
|
|
@@ -397,16 +654,16 @@ export class RelationService {
|
|
|
397
654
|
/**
|
|
398
655
|
* Update many-to-many and junction relations
|
|
399
656
|
*/
|
|
400
|
-
async updateRelationsUsingJoins<M extends Record<string,
|
|
657
|
+
async updateRelationsUsingJoins<M extends Record<string, unknown>>(
|
|
401
658
|
tx: DrizzleClient,
|
|
402
659
|
collection: EntityCollection,
|
|
403
660
|
entityId: string | number,
|
|
404
661
|
relationValues: Partial<M>
|
|
405
662
|
) {
|
|
406
|
-
const resolvedRelations = resolveCollectionRelations(collection
|
|
663
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
407
664
|
|
|
408
665
|
for (const [key, value] of Object.entries(relationValues)) {
|
|
409
|
-
const relation = resolvedRelations
|
|
666
|
+
const relation = findRelation(resolvedRelations, key);
|
|
410
667
|
if (!relation || relation.cardinality !== "many") continue;
|
|
411
668
|
|
|
412
669
|
const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
|
|
@@ -517,6 +774,10 @@ export class RelationService {
|
|
|
517
774
|
await tx.insert(junctionTable).values(newLinks);
|
|
518
775
|
}
|
|
519
776
|
}
|
|
777
|
+
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
|
|
778
|
+
// Inverse M2M relations should be saved from the owning side.
|
|
779
|
+
// The owning collection manages the junction table rows.
|
|
780
|
+
console.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
520
781
|
} else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
521
782
|
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
522
783
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
@@ -601,7 +862,7 @@ export class RelationService {
|
|
|
601
862
|
|
|
602
863
|
// Check if this is a many-to-many inverse relation
|
|
603
864
|
if (relation.cardinality === "many" && relation.direction === "inverse") {
|
|
604
|
-
const targetCollectionRelations = resolveCollectionRelations(targetCollection
|
|
865
|
+
const targetCollectionRelations = resolveCollectionRelations(targetCollection);
|
|
605
866
|
let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
|
|
606
867
|
|
|
607
868
|
for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
|
|
@@ -752,7 +1013,7 @@ export class RelationService {
|
|
|
752
1013
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
753
1014
|
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
754
1015
|
const targetIdInfo = targetPks[0];
|
|
755
|
-
const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel ===
|
|
1016
|
+
const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
|
|
756
1017
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
757
1018
|
|
|
758
1019
|
const newLinks = parsedTargetIds.map(targetId => ({
|
|
@@ -767,7 +1028,7 @@ export class RelationService {
|
|
|
767
1028
|
// Single value for one-to-one
|
|
768
1029
|
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
769
1030
|
const targetIdInfo = targetPks[0];
|
|
770
|
-
const targetId = typeof newValue ===
|
|
1031
|
+
const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
|
|
771
1032
|
const parsedTargetIdObj = parseIdValues(targetId, targetPks);
|
|
772
1033
|
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
773
1034
|
|
|
@@ -955,7 +1216,8 @@ export class RelationService {
|
|
|
955
1216
|
currentFrom = prevStep.on.from;
|
|
956
1217
|
}
|
|
957
1218
|
const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
|
|
958
|
-
return { targetFKColName,
|
|
1219
|
+
return { targetFKColName,
|
|
1220
|
+
parentSourceColName };
|
|
959
1221
|
}
|
|
960
1222
|
|
|
961
1223
|
/**
|