@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0
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/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-CU6WZGYV.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
|
|
1
|
+
import { and, eq, inArray, notInArray, or, sql, SQL, getTableName as drizzleTableName } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { DrizzleClient } from "../interfaces";
|
|
4
4
|
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
|
|
6
6
|
import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
|
|
7
7
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
8
8
|
import {
|
|
9
9
|
getCollectionByPath,
|
|
10
10
|
getTableForCollection,
|
|
11
|
+
relationMisconfigured,
|
|
11
12
|
requirePrimaryKeys,
|
|
12
13
|
parseIdValues,
|
|
13
14
|
buildCompositeId,
|
|
@@ -17,8 +18,14 @@ import {
|
|
|
17
18
|
} from "./collection-helpers";
|
|
18
19
|
import { parseDataFromServer } from "../data-transformer";
|
|
19
20
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
20
|
-
import { logger } from "@rebasepro/server";
|
|
21
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
21
22
|
import type { NestedPathHop } from "./nested-path";
|
|
23
|
+
import {
|
|
24
|
+
applyJunctionMembership,
|
|
25
|
+
bindJoinPathJunction,
|
|
26
|
+
bindThroughJunction,
|
|
27
|
+
removeJunctionLink
|
|
28
|
+
} from "./junction-writes";
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
31
|
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
@@ -221,7 +228,11 @@ export class RelationService {
|
|
|
221
228
|
return keyByParentId.get(String(parentId));
|
|
222
229
|
}
|
|
223
230
|
|
|
224
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Shared with {@link RelationWriteService}: a write needs the same source
|
|
233
|
+
* key a read does, and resolving it twice is how the two would disagree.
|
|
234
|
+
*/
|
|
235
|
+
async resolveSourceKeys(
|
|
225
236
|
parentCollection: CollectionConfig,
|
|
226
237
|
relation: ResolvedHasOne | ResolvedHasMany,
|
|
227
238
|
parentIds: (string | number)[],
|
|
@@ -423,7 +434,8 @@ export class RelationService {
|
|
|
423
434
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
424
435
|
options.searchString,
|
|
425
436
|
targetCollection.properties,
|
|
426
|
-
targetTable
|
|
437
|
+
targetTable,
|
|
438
|
+
targetCollection
|
|
427
439
|
);
|
|
428
440
|
|
|
429
441
|
if (searchConditions.length === 0) {
|
|
@@ -574,51 +586,6 @@ export class RelationService {
|
|
|
574
586
|
return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
|
|
575
587
|
}
|
|
576
588
|
|
|
577
|
-
/**
|
|
578
|
-
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
579
|
-
* row itself alone.
|
|
580
|
-
*
|
|
581
|
-
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
582
|
-
* target is shared, so deleting the row would remove the tag from every other
|
|
583
|
-
* post that uses it. It used to do exactly that — resolve the path to the
|
|
584
|
-
* `tags` table and delete by primary key.
|
|
585
|
-
*/
|
|
586
|
-
async unlinkRelatedEntity(
|
|
587
|
-
tx: DrizzleClient,
|
|
588
|
-
hop: NestedPathHop,
|
|
589
|
-
targetId: string | number
|
|
590
|
-
): Promise<void> {
|
|
591
|
-
if (!isManyToMany(hop.relation)) {
|
|
592
|
-
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
593
|
-
}
|
|
594
|
-
const through = hop.relation.through;
|
|
595
|
-
|
|
596
|
-
const junctionTable = this.registry.getTable(through.table);
|
|
597
|
-
if (!junctionTable) {
|
|
598
|
-
throw new Error(`Junction table not found: ${through.table}`);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
602
|
-
const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
603
|
-
|
|
604
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
605
|
-
throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
609
|
-
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
610
|
-
|
|
611
|
-
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
612
|
-
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
613
|
-
|
|
614
|
-
await tx.delete(junctionTable).where(and(
|
|
615
|
-
eq(sourceJunctionColumn, parsedParentId),
|
|
616
|
-
eq(targetJunctionColumn, parsedTargetId)
|
|
617
|
-
));
|
|
618
|
-
|
|
619
|
-
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
589
|
/**
|
|
623
590
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
624
591
|
*/
|
|
@@ -708,7 +675,10 @@ export class RelationService {
|
|
|
708
675
|
// 3. Map results back to parent rows via their FK values
|
|
709
676
|
if (relation.kind === "belongsTo") {
|
|
710
677
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
711
|
-
|
|
678
|
+
// `localKey` is the column; the generated table is keyed by the wire
|
|
679
|
+
// name, so `author_id` finds nothing where the key is `authorId`.
|
|
680
|
+
const localKeyField = fieldKeyForColumn(parentCollection, relation.localKey);
|
|
681
|
+
const localKeyCol = parentTable[localKeyField as keyof typeof parentTable] as AnyPgColumn;
|
|
712
682
|
if (!localKeyCol) {
|
|
713
683
|
throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
714
684
|
}
|
|
@@ -818,7 +788,9 @@ export class RelationService {
|
|
|
818
788
|
|
|
819
789
|
// Keyed by string throughout: Drizzle returns a numeric column as a
|
|
820
790
|
// number or a string depending on the column type and the driver.
|
|
821
|
-
const foreignKeyValue = targetRow[
|
|
791
|
+
const foreignKeyValue = targetRow[
|
|
792
|
+
fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
|
|
793
|
+
] as string | number | undefined;
|
|
822
794
|
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
823
795
|
|
|
824
796
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
@@ -905,19 +877,17 @@ export class RelationService {
|
|
|
905
877
|
// The junction names its parent with one column, so the same
|
|
906
878
|
// single-key limit applies as for a direct foreign key.
|
|
907
879
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
908
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
909
|
-
if (!junctionTable) {
|
|
910
|
-
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
911
|
-
return new Map();
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
const sourceJunctionCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
915
|
-
const targetJunctionCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
916
880
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
881
|
+
// An empty map here is a lie a caller cannot see through: `posts/1/tags`
|
|
882
|
+
// answering `[]` is exactly what a post with no tags looks like. That
|
|
883
|
+
// is the failure `assertRelationsResolve` was written to make
|
|
884
|
+
// impossible at boot; this says the same thing if one gets past it.
|
|
885
|
+
const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } =
|
|
886
|
+
bindThroughJunction(
|
|
887
|
+
this.registry,
|
|
888
|
+
relation.through,
|
|
889
|
+
`${parentCollection.slug}.${relation.relationName}`
|
|
890
|
+
);
|
|
921
891
|
|
|
922
892
|
// SELECT target.*, junction.sourceColumn FROM junction
|
|
923
893
|
// INNER JOIN target ON junction.targetColumn = target.id
|
|
@@ -989,7 +959,9 @@ export class RelationService {
|
|
|
989
959
|
// what reaches here names the parent with a column on the target.
|
|
990
960
|
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
991
961
|
|
|
992
|
-
const foreignKeyValue = targetRow[
|
|
962
|
+
const foreignKeyValue = targetRow[
|
|
963
|
+
fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
|
|
964
|
+
] as string | number | undefined;
|
|
993
965
|
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
994
966
|
|
|
995
967
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
@@ -1003,635 +975,4 @@ export class RelationService {
|
|
|
1003
975
|
|
|
1004
976
|
return resultMap;
|
|
1005
977
|
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Update many-to-many and junction relations
|
|
1009
|
-
*/
|
|
1010
|
-
async updateRelationsUsingJoins<M extends Record<string, unknown>>(
|
|
1011
|
-
tx: DrizzleClient,
|
|
1012
|
-
collection: CollectionConfig,
|
|
1013
|
-
id: string | number,
|
|
1014
|
-
relationValues: Partial<M>
|
|
1015
|
-
) {
|
|
1016
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1017
|
-
|
|
1018
|
-
for (const [key, value] of Object.entries(relationValues)) {
|
|
1019
|
-
const relation = findRelation(resolvedRelations, key);
|
|
1020
|
-
if (!relation || relation.cardinality !== "many") continue;
|
|
1021
|
-
|
|
1022
|
-
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
1023
|
-
const targetCollection = relation.target();
|
|
1024
|
-
|
|
1025
|
-
// Use joinPath if available
|
|
1026
|
-
if (relation.kind === "via") {
|
|
1027
|
-
const parentTableName = getTableName(collection);
|
|
1028
|
-
const targetTableName = getTableName(targetCollection);
|
|
1029
|
-
|
|
1030
|
-
let junctionTable: PgTable | undefined = undefined;
|
|
1031
|
-
let sourceJunctionColumn: AnyPgColumn | null = null;
|
|
1032
|
-
let targetJunctionColumn: AnyPgColumn | null = null;
|
|
1033
|
-
|
|
1034
|
-
const junctionTableName = relation.joinPath.find(step =>
|
|
1035
|
-
step.table !== parentTableName && step.table !== targetTableName
|
|
1036
|
-
)?.table;
|
|
1037
|
-
|
|
1038
|
-
if (junctionTableName) {
|
|
1039
|
-
junctionTable = this.registry.getTable(junctionTableName);
|
|
1040
|
-
|
|
1041
|
-
if (junctionTable) {
|
|
1042
|
-
for (const joinStep of relation.joinPath) {
|
|
1043
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
|
|
1044
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
|
|
1045
|
-
|
|
1046
|
-
if (fromTable === parentTableName && toTable === junctionTableName) {
|
|
1047
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
1048
|
-
sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1049
|
-
} else if (fromTable === junctionTableName && toTable === parentTableName) {
|
|
1050
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
1051
|
-
sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
if (fromTable === junctionTableName && toTable === targetTableName) {
|
|
1055
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
1056
|
-
targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1057
|
-
} else if (fromTable === targetTableName && toTable === junctionTableName) {
|
|
1058
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
1059
|
-
targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
|
|
1066
|
-
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
1067
|
-
continue;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
1071
|
-
const parentIdInfo = parentPks[0];
|
|
1072
|
-
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
1073
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1074
|
-
|
|
1075
|
-
// Delete existing relations for this row
|
|
1076
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
1077
|
-
|
|
1078
|
-
if (targetEntityIds.length > 0) {
|
|
1079
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1080
|
-
const targetIdInfo = targetPks[0];
|
|
1081
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1082
|
-
|
|
1083
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1084
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
1085
|
-
[targetJunctionColumn.name]: targetId
|
|
1086
|
-
}));
|
|
1087
|
-
|
|
1088
|
-
if (newLinks.length > 0) {
|
|
1089
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
} else if (relation.kind === "manyToMany") {
|
|
1093
|
-
// Handle many-to-many relations with junction table using 'through' property
|
|
1094
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1095
|
-
if (!junctionTable) {
|
|
1096
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1101
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1102
|
-
|
|
1103
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1104
|
-
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
1109
|
-
const parentIdInfo = parentPks[0];
|
|
1110
|
-
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
1111
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1112
|
-
|
|
1113
|
-
// Delete existing relations for this row
|
|
1114
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
1115
|
-
|
|
1116
|
-
if (targetEntityIds.length > 0) {
|
|
1117
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1118
|
-
const targetIdInfo = targetPks[0];
|
|
1119
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1120
|
-
|
|
1121
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1122
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
1123
|
-
[targetJunctionColumn.name]: targetId
|
|
1124
|
-
}));
|
|
1125
|
-
|
|
1126
|
-
if (newLinks.length > 0) {
|
|
1127
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
1131
|
-
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
1132
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1133
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1134
|
-
const targetIdInfo = targetPks[0];
|
|
1135
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
1136
|
-
const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
1137
|
-
|
|
1138
|
-
if (!fkCol || !targetIdCol) {
|
|
1139
|
-
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// What the children's foreign key must hold — the parent's id
|
|
1144
|
-
// unless the link declares a `sourceKey`, and then the value of
|
|
1145
|
-
// that column on this parent row.
|
|
1146
|
-
const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx))
|
|
1147
|
-
.keyByParentId.get(String(id));
|
|
1148
|
-
|
|
1149
|
-
if (parentKeyValue === undefined) {
|
|
1150
|
-
throw new Error(
|
|
1151
|
-
`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in ` +
|
|
1152
|
-
`\`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is ` +
|
|
1153
|
-
"nothing for the related rows to point at."
|
|
1154
|
-
);
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// Clear existing links not in the new set
|
|
1158
|
-
if (targetEntityIds.length > 0) {
|
|
1159
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1160
|
-
await tx
|
|
1161
|
-
.update(targetTable)
|
|
1162
|
-
.set({ [relation.foreignKeyOnTarget]: null })
|
|
1163
|
-
.where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
1164
|
-
|
|
1165
|
-
// Set FK for the provided targets
|
|
1166
|
-
await tx
|
|
1167
|
-
.update(targetTable)
|
|
1168
|
-
.set({ [relation.foreignKeyOnTarget]: parentKeyValue })
|
|
1169
|
-
.where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
|
|
1170
|
-
} else {
|
|
1171
|
-
// If empty array provided, clear all existing links for this parent
|
|
1172
|
-
await tx
|
|
1173
|
-
.update(targetTable)
|
|
1174
|
-
.set({ [relation.foreignKeyOnTarget]: null })
|
|
1175
|
-
.where(eq(fkCol, parentKeyValue));
|
|
1176
|
-
}
|
|
1177
|
-
} else {
|
|
1178
|
-
logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
/**
|
|
1184
|
-
* Update inverse relations (where FK is on the target table)
|
|
1185
|
-
*/
|
|
1186
|
-
async updateInverseRelations(
|
|
1187
|
-
tx: DrizzleClient,
|
|
1188
|
-
sourceCollection: CollectionConfig,
|
|
1189
|
-
sourceEntityId: string | number,
|
|
1190
|
-
inverseRelationUpdates: Array<{
|
|
1191
|
-
relationKey: string;
|
|
1192
|
-
relation: ResolvedRelation;
|
|
1193
|
-
newValue: unknown;
|
|
1194
|
-
}>
|
|
1195
|
-
) {
|
|
1196
|
-
for (const update of inverseRelationUpdates) {
|
|
1197
|
-
const { relation, newValue } = update;
|
|
1198
|
-
|
|
1199
|
-
try {
|
|
1200
|
-
const targetCollection = relation.target();
|
|
1201
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1202
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1203
|
-
const targetIdInfo = targetPks[0];
|
|
1204
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1205
|
-
const sourceIdInfo = sourcePks[0];
|
|
1206
|
-
|
|
1207
|
-
if (relation.kind === "via") {
|
|
1208
|
-
await this.updateInverseJoinPathRelation(
|
|
1209
|
-
tx,
|
|
1210
|
-
sourceCollection,
|
|
1211
|
-
sourceEntityId,
|
|
1212
|
-
targetCollection,
|
|
1213
|
-
relation,
|
|
1214
|
-
newValue
|
|
1215
|
-
);
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// A many-to-many names its own junction. This used to walk the
|
|
1220
|
-
// *target's* relations looking for an owning side whose
|
|
1221
|
-
// `relationName` matched `inverseRelationName`, and swap its
|
|
1222
|
-
// source/target columns — a search that silently did nothing
|
|
1223
|
-
// when the far side was declared differently than expected.
|
|
1224
|
-
if (isManyToMany(relation)) {
|
|
1225
|
-
await this.updateManyToManyInverseRelation(
|
|
1226
|
-
tx,
|
|
1227
|
-
sourceCollection,
|
|
1228
|
-
sourceEntityId,
|
|
1229
|
-
targetCollection,
|
|
1230
|
-
relation,
|
|
1231
|
-
newValue,
|
|
1232
|
-
{
|
|
1233
|
-
table: relation.through.table,
|
|
1234
|
-
sourceColumn: relation.through.sourceColumn,
|
|
1235
|
-
targetColumn: relation.through.targetColumn
|
|
1236
|
-
}
|
|
1237
|
-
);
|
|
1238
|
-
continue;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
// What is left names the parent with a column on the target.
|
|
1242
|
-
if (!hasForeignKeyOnTarget(relation)) {
|
|
1243
|
-
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
1244
|
-
continue;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget! as keyof typeof targetTable] as AnyPgColumn;
|
|
1248
|
-
if (!foreignKeyColumn) {
|
|
1249
|
-
logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
|
|
1250
|
-
continue;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// The value the target's foreign key holds: this row's id, or
|
|
1254
|
-
// the column named by `sourceKey` when the link joins on one.
|
|
1255
|
-
const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx))
|
|
1256
|
-
.keyByParentId.get(String(sourceEntityId));
|
|
1257
|
-
|
|
1258
|
-
if (sourceKeyValue === undefined) {
|
|
1259
|
-
throw new Error(
|
|
1260
|
-
`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row ` +
|
|
1261
|
-
`'${sourceEntityId}' has no value in \`sourceKey: ` +
|
|
1262
|
-
`"${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing ` +
|
|
1263
|
-
"for the related row to point at."
|
|
1264
|
-
);
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
if (newValue === null || newValue === undefined) {
|
|
1268
|
-
await tx
|
|
1269
|
-
.update(targetTable)
|
|
1270
|
-
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
1271
|
-
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
1272
|
-
} else {
|
|
1273
|
-
const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
|
|
1274
|
-
const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
|
|
1275
|
-
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
1276
|
-
|
|
1277
|
-
// First, clear any existing FK that points to this source row
|
|
1278
|
-
await tx
|
|
1279
|
-
.update(targetTable)
|
|
1280
|
-
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
1281
|
-
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
1282
|
-
|
|
1283
|
-
// Then, update the new target row to point to this source row
|
|
1284
|
-
await tx
|
|
1285
|
-
.update(targetTable)
|
|
1286
|
-
.set({ [relation.foreignKeyOnTarget!]: sourceKeyValue })
|
|
1287
|
-
.where(eq(targetIdField, parsedNewTargetId));
|
|
1288
|
-
}
|
|
1289
|
-
} catch (e) {
|
|
1290
|
-
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
/**
|
|
1296
|
-
* Handle inverse relations with joinPath
|
|
1297
|
-
*/
|
|
1298
|
-
private async updateInverseJoinPathRelation(
|
|
1299
|
-
tx: DrizzleClient,
|
|
1300
|
-
sourceCollection: CollectionConfig,
|
|
1301
|
-
sourceEntityId: string | number,
|
|
1302
|
-
targetCollection: CollectionConfig,
|
|
1303
|
-
relation: ResolvedVia,
|
|
1304
|
-
newValue: unknown
|
|
1305
|
-
) {
|
|
1306
|
-
try {
|
|
1307
|
-
|
|
1308
|
-
const sourceTableName = getTableName(sourceCollection);
|
|
1309
|
-
const targetTableName = getTableName(targetCollection);
|
|
1310
|
-
|
|
1311
|
-
// Find intermediate tables that are neither source nor target
|
|
1312
|
-
const intermediateTables = relation.joinPath
|
|
1313
|
-
.map(step => step.table)
|
|
1314
|
-
.filter(table => table !== sourceTableName && table !== targetTableName);
|
|
1315
|
-
|
|
1316
|
-
// If there's exactly one intermediate table, it's likely a junction table for many-to-many
|
|
1317
|
-
if (intermediateTables.length === 1 && relation.cardinality === "many") {
|
|
1318
|
-
const junctionTableName = intermediateTables[0];
|
|
1319
|
-
const junctionTable = this.registry.getTable(junctionTableName);
|
|
1320
|
-
|
|
1321
|
-
if (!junctionTable) {
|
|
1322
|
-
logger.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
let sourceJunctionColumn: AnyPgColumn | null = null;
|
|
1327
|
-
let targetJunctionColumn: AnyPgColumn | null = null;
|
|
1328
|
-
|
|
1329
|
-
for (const step of relation.joinPath) {
|
|
1330
|
-
if (step.table === junctionTableName) {
|
|
1331
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
|
|
1332
|
-
const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
|
|
1333
|
-
const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
|
|
1334
|
-
|
|
1335
|
-
if (fromTable === sourceTableName) {
|
|
1336
|
-
sourceJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1337
|
-
} else if (fromTable === targetTableName) {
|
|
1338
|
-
targetJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1339
|
-
} else {
|
|
1340
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
|
|
1341
|
-
if (toTable === sourceTableName) {
|
|
1342
|
-
sourceJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1343
|
-
} else if (toTable === targetTableName) {
|
|
1344
|
-
targetJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1351
|
-
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// Perform the junction table update
|
|
1356
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1357
|
-
const sourceIdInfo = sourcePks[0];
|
|
1358
|
-
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1359
|
-
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1360
|
-
|
|
1361
|
-
// Clear existing entries for this source row
|
|
1362
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
1363
|
-
|
|
1364
|
-
// Add new entries if newValue is provided
|
|
1365
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1366
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1367
|
-
const targetIdInfo = targetPks[0];
|
|
1368
|
-
// This path already read both shapes; the other two did not.
|
|
1369
|
-
// Same helper now, so the three cannot drift again.
|
|
1370
|
-
const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
|
|
1371
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1372
|
-
|
|
1373
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1374
|
-
[sourceJunctionColumn!.name]: parsedSourceId,
|
|
1375
|
-
[targetJunctionColumn!.name]: targetId
|
|
1376
|
-
}));
|
|
1377
|
-
|
|
1378
|
-
if (newLinks.length > 0) {
|
|
1379
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1380
|
-
}
|
|
1381
|
-
} else if (newValue && !Array.isArray(newValue)) {
|
|
1382
|
-
// Single value for one-to-one
|
|
1383
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1384
|
-
const targetIdInfo = targetPks[0];
|
|
1385
|
-
const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
|
|
1386
|
-
const parsedTargetIdObj = parseIdValues(targetId, targetPks);
|
|
1387
|
-
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
1388
|
-
|
|
1389
|
-
const newLink = {
|
|
1390
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1391
|
-
[targetJunctionColumn.name]: parsedTargetId
|
|
1392
|
-
};
|
|
1393
|
-
|
|
1394
|
-
await tx.insert(junctionTable).values(newLink);
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error: error });
|
|
1399
|
-
throw error;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
/**
|
|
1404
|
-
* Handle many-to-many inverse relation updates using junction tables
|
|
1405
|
-
*/
|
|
1406
|
-
private async updateManyToManyInverseRelation(
|
|
1407
|
-
tx: DrizzleClient,
|
|
1408
|
-
sourceCollection: CollectionConfig,
|
|
1409
|
-
sourceEntityId: string | number,
|
|
1410
|
-
targetCollection: CollectionConfig,
|
|
1411
|
-
relation: ResolvedRelation,
|
|
1412
|
-
newValue: unknown,
|
|
1413
|
-
junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
|
|
1414
|
-
) {
|
|
1415
|
-
try {
|
|
1416
|
-
const junctionTable = this.registry.getTable(junctionInfo.table);
|
|
1417
|
-
if (!junctionTable) {
|
|
1418
|
-
logger.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
|
|
1419
|
-
return;
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1423
|
-
const targetJunctionColumn = junctionTable[junctionInfo.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1424
|
-
|
|
1425
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1426
|
-
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
1427
|
-
return;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1431
|
-
const sourceIdInfo = sourcePks[0];
|
|
1432
|
-
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1433
|
-
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1434
|
-
|
|
1435
|
-
// Clear existing entries for this source row
|
|
1436
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
1437
|
-
|
|
1438
|
-
// Add new entries if newValue is provided
|
|
1439
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1440
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1441
|
-
const targetIdInfo = targetPks[0];
|
|
1442
|
-
const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
|
|
1443
|
-
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1444
|
-
|
|
1445
|
-
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1446
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1447
|
-
[targetJunctionColumn.name]: targetId
|
|
1448
|
-
}));
|
|
1449
|
-
|
|
1450
|
-
if (newLinks.length > 0) {
|
|
1451
|
-
await tx.insert(junctionTable).values(newLinks);
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
} catch (error) {
|
|
1455
|
-
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error: error });
|
|
1456
|
-
throw error;
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* Update one-to-one relations that use joinPath
|
|
1462
|
-
*/
|
|
1463
|
-
async updateJoinPathOneToOneRelations(
|
|
1464
|
-
tx: DrizzleClient,
|
|
1465
|
-
parentCollection: CollectionConfig,
|
|
1466
|
-
parentId: string | number,
|
|
1467
|
-
updates: Array<{
|
|
1468
|
-
relationKey: string;
|
|
1469
|
-
// Only a `via` relation has a join path to write through, and the
|
|
1470
|
-
// caller only collects those.
|
|
1471
|
-
relation: ResolvedVia;
|
|
1472
|
-
newTargetId: string | number | null;
|
|
1473
|
-
}>
|
|
1474
|
-
) {
|
|
1475
|
-
for (const upd of updates) {
|
|
1476
|
-
const { relation, newTargetId } = upd;
|
|
1477
|
-
const targetCollection = relation.target();
|
|
1478
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1479
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1480
|
-
const targetIdInfo = targetPks[0];
|
|
1481
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
1482
|
-
|
|
1483
|
-
// Determine mapping of columns
|
|
1484
|
-
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
1485
|
-
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
1486
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
1487
|
-
const parentIdInfo = parentPks[0];
|
|
1488
|
-
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
1489
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1490
|
-
|
|
1491
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
1492
|
-
const parentSourceCol = parentTable[parentSourceColName as keyof typeof parentTable] as AnyPgColumn;
|
|
1493
|
-
const targetFKCol = targetTable[targetFKColName as keyof typeof targetTable] as AnyPgColumn;
|
|
1494
|
-
|
|
1495
|
-
if (!parentSourceCol) {
|
|
1496
|
-
logger.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
1497
|
-
continue;
|
|
1498
|
-
}
|
|
1499
|
-
if (!targetFKCol) {
|
|
1500
|
-
logger.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
1501
|
-
continue;
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
// Fetch the parent row to obtain the value for parentSourceCol
|
|
1505
|
-
const parentRows = await tx
|
|
1506
|
-
.select({ val: parentSourceCol })
|
|
1507
|
-
.from(parentTable)
|
|
1508
|
-
.where(eq(parentIdCol, parsedParentId))
|
|
1509
|
-
.limit(1);
|
|
1510
|
-
if (parentRows.length === 0) continue;
|
|
1511
|
-
const parentFKValue = parentRows[0].val as string | number | null;
|
|
1512
|
-
|
|
1513
|
-
if (newTargetId === null || newTargetId === undefined) {
|
|
1514
|
-
// Clear any target rows currently linked to this parent via the FK
|
|
1515
|
-
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
1516
|
-
await tx.update(targetTable)
|
|
1517
|
-
.set({ [targetFKColName]: null })
|
|
1518
|
-
.where(eq(targetFKCol, String(parentFKValue)));
|
|
1519
|
-
}
|
|
1520
|
-
continue;
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
// Parse the new target id
|
|
1524
|
-
const parsedTargetIdObj = parseIdValues(newTargetId, targetPks);
|
|
1525
|
-
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
1526
|
-
|
|
1527
|
-
// Ensure one-to-one by clearing existing link from any target rows with this parent FK
|
|
1528
|
-
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
1529
|
-
await tx.update(targetTable)
|
|
1530
|
-
.set({ [targetFKColName]: null })
|
|
1531
|
-
.where(eq(targetFKCol, String(parentFKValue)));
|
|
1532
|
-
} else {
|
|
1533
|
-
logger.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
|
|
1534
|
-
continue;
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// Now set the FK on the target row
|
|
1538
|
-
await tx.update(targetTable)
|
|
1539
|
-
.set({ [targetFKColName]: parentFKValue })
|
|
1540
|
-
.where(eq(targetIdCol, parsedTargetId));
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
/**
|
|
1545
|
-
* Resolve joinPath write mapping for one-to-one relations
|
|
1546
|
-
*/
|
|
1547
|
-
resolveJoinPathWriteMapping(
|
|
1548
|
-
parentCollection: CollectionConfig,
|
|
1549
|
-
relation: ResolvedVia
|
|
1550
|
-
): { targetFKColName: string; parentSourceColName: string } {
|
|
1551
|
-
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1552
|
-
throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
1553
|
-
}
|
|
1554
|
-
const parentTableName = getTableName(parentCollection);
|
|
1555
|
-
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
1556
|
-
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
1557
|
-
let currentFrom = lastStep.on.from;
|
|
1558
|
-
|
|
1559
|
-
let safety = 0;
|
|
1560
|
-
while (safety++ < 10) {
|
|
1561
|
-
const currentFromTable = DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0];
|
|
1562
|
-
if (currentFromTable === parentTableName) {
|
|
1563
|
-
break;
|
|
1564
|
-
}
|
|
1565
|
-
const prevStep = relation.joinPath.find((s) => {
|
|
1566
|
-
const to = Array.isArray(s.on.to) ? s.on.to[0] : s.on.to;
|
|
1567
|
-
return to === currentFrom;
|
|
1568
|
-
});
|
|
1569
|
-
if (!prevStep) {
|
|
1570
|
-
throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
1571
|
-
}
|
|
1572
|
-
currentFrom = prevStep.on.from;
|
|
1573
|
-
}
|
|
1574
|
-
const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
|
|
1575
|
-
return { targetFKColName,
|
|
1576
|
-
parentSourceColName };
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/**
|
|
1580
|
-
* Handle junction table creation for many-to-many path-based saves
|
|
1581
|
-
*/
|
|
1582
|
-
async handleJunctionTableCreation(
|
|
1583
|
-
tx: DrizzleClient,
|
|
1584
|
-
newEntityId: string | number,
|
|
1585
|
-
junctionTableInfo: {
|
|
1586
|
-
parentCollection: CollectionConfig;
|
|
1587
|
-
parentId: string | number;
|
|
1588
|
-
// Only a junction-backed relation has a link to write, and the
|
|
1589
|
-
// caller only builds this for one — stated here so the body needs
|
|
1590
|
-
// no narrowing.
|
|
1591
|
-
relation: ResolvedManyToMany;
|
|
1592
|
-
relationKey: string;
|
|
1593
|
-
}
|
|
1594
|
-
) {
|
|
1595
|
-
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
1596
|
-
const targetCollection = relation.target();
|
|
1597
|
-
|
|
1598
|
-
try {
|
|
1599
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1600
|
-
if (!junctionTable) {
|
|
1601
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
|
|
1602
|
-
return;
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1606
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1607
|
-
|
|
1608
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1609
|
-
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
1610
|
-
return;
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
// Parse the new row ID to the correct type
|
|
1614
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1615
|
-
const targetIdInfo = targetPks[0];
|
|
1616
|
-
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1617
|
-
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
1618
|
-
|
|
1619
|
-
// Create the junction table entry linking parent to the target row.
|
|
1620
|
-
const junctionData = {
|
|
1621
|
-
[sourceJunctionColumn.name]: parentId,
|
|
1622
|
-
[targetJunctionColumn.name]: parsedNewEntityId
|
|
1623
|
-
};
|
|
1624
|
-
|
|
1625
|
-
// Idempotent: a link either exists or it does not, so asking for one
|
|
1626
|
-
// twice is not an error. This is what lets `PUT parent/id/child/childId`
|
|
1627
|
-
// mean "this row belongs to this parent's set" — the only way to
|
|
1628
|
-
// attach an *existing* row, which previously had none.
|
|
1629
|
-
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
1630
|
-
|
|
1631
|
-
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
1632
|
-
} catch (error) {
|
|
1633
|
-
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
|
|
1634
|
-
throw error;
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
978
|
}
|