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