@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
import { and, eq, inArray, sql, SQL } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { DrizzleClient } from "../interfaces";
|
|
4
|
+
import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
6
|
+
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
|
+
import {
|
|
8
|
+
getCollectionByPath,
|
|
9
|
+
getTableForCollection,
|
|
10
|
+
getPrimaryKeys,
|
|
11
|
+
parseIdValues,
|
|
12
|
+
buildCompositeId
|
|
13
|
+
} from "./entity-helpers";
|
|
14
|
+
import { parseDataFromServer } from "../data-transformer";
|
|
15
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Service for handling all relation-related operations.
|
|
19
|
+
* Handles fetching, updating, and managing entity relations.
|
|
20
|
+
*/
|
|
21
|
+
export class RelationService {
|
|
22
|
+
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch entities related to a parent entity through a specific relation
|
|
26
|
+
*/
|
|
27
|
+
async fetchRelatedEntities<M extends Record<string, unknown>>(
|
|
28
|
+
parentCollectionPath: string,
|
|
29
|
+
parentEntityId: string | number,
|
|
30
|
+
relationKey: string,
|
|
31
|
+
options: {
|
|
32
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
33
|
+
orderBy?: string;
|
|
34
|
+
order?: "desc" | "asc";
|
|
35
|
+
limit?: number;
|
|
36
|
+
startAfter?: Record<string, unknown>;
|
|
37
|
+
searchString?: string;
|
|
38
|
+
databaseId?: string;
|
|
39
|
+
} = {}
|
|
40
|
+
): Promise<Entity<M>[]> {
|
|
41
|
+
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
42
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
43
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
44
|
+
|
|
45
|
+
if (!relation) {
|
|
46
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
47
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return this.fetchEntitiesUsingJoins<M>(parentCollection, parentEntityId, relation, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch entities using join paths for complex relations
|
|
55
|
+
*/
|
|
56
|
+
async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
|
|
57
|
+
parentCollection: EntityCollection,
|
|
58
|
+
parentEntityId: string | number,
|
|
59
|
+
relation: Relation,
|
|
60
|
+
options: {
|
|
61
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
62
|
+
orderBy?: string;
|
|
63
|
+
order?: "desc" | "asc";
|
|
64
|
+
limit?: number;
|
|
65
|
+
startAfter?: Record<string, unknown>;
|
|
66
|
+
searchString?: string;
|
|
67
|
+
databaseId?: string;
|
|
68
|
+
} = {}
|
|
69
|
+
): Promise<Entity<M>[]> {
|
|
70
|
+
const targetCollection = relation.target();
|
|
71
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
72
|
+
const idInfo = getPrimaryKeys(targetCollection, this.registry);
|
|
73
|
+
const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
74
|
+
|
|
75
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
76
|
+
const parentIdInfo = parentPks[0];
|
|
77
|
+
const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
|
|
78
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
79
|
+
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
80
|
+
if (!parentTable) throw new Error("Parent table not found");
|
|
81
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
82
|
+
|
|
83
|
+
// Handle join path relations
|
|
84
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
85
|
+
let query = this.db.select().from(parentTable).$dynamic();
|
|
86
|
+
let currentTable = parentTable;
|
|
87
|
+
|
|
88
|
+
// Apply each join in the path
|
|
89
|
+
for (const join of relation.joinPath) {
|
|
90
|
+
const joinTable = this.registry.getTable(join.table);
|
|
91
|
+
if (!joinTable) {
|
|
92
|
+
throw new Error(`Join table not found: ${join.table}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
96
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
97
|
+
|
|
98
|
+
const fromParts = fromColumn.split(".");
|
|
99
|
+
const toParts = toColumn.split(".");
|
|
100
|
+
|
|
101
|
+
const fromColName = fromParts[fromParts.length - 1];
|
|
102
|
+
const toColName = toParts[toParts.length - 1];
|
|
103
|
+
|
|
104
|
+
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
105
|
+
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
106
|
+
|
|
107
|
+
if (!fromCol || !toCol) {
|
|
108
|
+
throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// @ts-expect-error Drizzle mutates base query generic on innerJoin
|
|
112
|
+
query = query.innerJoin(joinTable, eq(fromCol, toCol));
|
|
113
|
+
currentTable = joinTable;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Add where condition for the parent entity
|
|
117
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
118
|
+
query = query.where(eq(parentIdField, parsedParentId));
|
|
119
|
+
|
|
120
|
+
if (options.limit) {
|
|
121
|
+
query = query.limit(options.limit);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const results = await query;
|
|
125
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
126
|
+
|
|
127
|
+
// Process results
|
|
128
|
+
const entities: Entity<M>[] = [];
|
|
129
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
130
|
+
const targetEntity = (row[targetTableName] as Record<string, unknown>) || row;
|
|
131
|
+
const entityId = targetEntity[idInfo[0].fieldName as string];
|
|
132
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection, this.db, this.registry);
|
|
133
|
+
|
|
134
|
+
entities.push({
|
|
135
|
+
id: entityId?.toString() || "",
|
|
136
|
+
path: targetCollection.slug,
|
|
137
|
+
values: parsedValues as M
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return entities;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Handle other relation types
|
|
145
|
+
let query = this.db.select().from(targetTable).$dynamic();
|
|
146
|
+
|
|
147
|
+
// Build additional filter conditions
|
|
148
|
+
const additionalFilters: SQL[] = [];
|
|
149
|
+
|
|
150
|
+
// Handle search conditions if searchString is provided
|
|
151
|
+
if (options.searchString) {
|
|
152
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
153
|
+
options.searchString,
|
|
154
|
+
targetCollection.properties,
|
|
155
|
+
targetTable
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (searchConditions.length === 0) {
|
|
159
|
+
// No searchable fields found, return empty results
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
|
|
164
|
+
if (searchCombined) {
|
|
165
|
+
additionalFilters.push(searchCombined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Use unified relation query builder
|
|
170
|
+
// @ts-expect-error buildRelationQuery uses dynamic queries
|
|
171
|
+
query = DrizzleConditionBuilder.buildRelationQuery(
|
|
172
|
+
query,
|
|
173
|
+
relation,
|
|
174
|
+
parsedParentId,
|
|
175
|
+
targetTable,
|
|
176
|
+
parentTable,
|
|
177
|
+
parentIdCol,
|
|
178
|
+
idField,
|
|
179
|
+
this.registry,
|
|
180
|
+
additionalFilters
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (options.limit) {
|
|
184
|
+
query = query.limit(options.limit);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const results = await query;
|
|
188
|
+
|
|
189
|
+
// Process results - ensure results is iterable
|
|
190
|
+
if (!results || !Array.isArray(results)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const entities: Entity<M>[] = [];
|
|
195
|
+
for (const row of results) {
|
|
196
|
+
const targetEntity = row[getTableName(targetCollection)] || row;
|
|
197
|
+
const entityId = targetEntity[idInfo[0].fieldName];
|
|
198
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection, this.db, this.registry);
|
|
199
|
+
|
|
200
|
+
entities.push({
|
|
201
|
+
id: entityId?.toString() || "",
|
|
202
|
+
path: targetCollection.slug,
|
|
203
|
+
values: parsedValues as M
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return entities;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Count related entities for a parent entity
|
|
212
|
+
*/
|
|
213
|
+
async countRelatedEntities<M extends Record<string, unknown>>(
|
|
214
|
+
parentCollectionPath: string,
|
|
215
|
+
parentEntityId: string | number,
|
|
216
|
+
relationKey: string,
|
|
217
|
+
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
218
|
+
): Promise<number> {
|
|
219
|
+
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
220
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
221
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
222
|
+
if (!relation) {
|
|
223
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
224
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const targetCollection = relation.target();
|
|
228
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
229
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
230
|
+
const targetIdInfo = targetPks[0];
|
|
231
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
232
|
+
|
|
233
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
234
|
+
const parentIdInfo = parentPks[0];
|
|
235
|
+
const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
|
|
236
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
237
|
+
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
238
|
+
if (!parentTable) throw new Error("Parent table not found");
|
|
239
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
240
|
+
|
|
241
|
+
// Start count with distinct to avoid duplicates from junction tables
|
|
242
|
+
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
243
|
+
|
|
244
|
+
// Build additional filter conditions
|
|
245
|
+
const additionalFilters: SQL[] = [];
|
|
246
|
+
|
|
247
|
+
// Use unified count query builder from DrizzleConditionBuilder
|
|
248
|
+
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
249
|
+
query,
|
|
250
|
+
relation,
|
|
251
|
+
parsedParentId,
|
|
252
|
+
targetTable,
|
|
253
|
+
parentTable,
|
|
254
|
+
parentIdCol,
|
|
255
|
+
targetIdField,
|
|
256
|
+
this.registry,
|
|
257
|
+
additionalFilters
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const result = await query;
|
|
261
|
+
return Number(result[0]?.count || 0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Batch fetch related entities for multiple parent entities to avoid N+1 queries
|
|
266
|
+
*/
|
|
267
|
+
async batchFetchRelatedEntities(
|
|
268
|
+
parentCollectionPath: string,
|
|
269
|
+
parentEntityIds: (string | number)[],
|
|
270
|
+
_relationKey: string,
|
|
271
|
+
relation: Relation
|
|
272
|
+
): Promise<Map<string, Entity<Record<string, unknown>>>> {
|
|
273
|
+
if (parentEntityIds.length === 0) return new Map();
|
|
274
|
+
|
|
275
|
+
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
276
|
+
const targetCollection = relation.target();
|
|
277
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
278
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
279
|
+
const targetIdInfo = targetPks[0];
|
|
280
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
281
|
+
|
|
282
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
283
|
+
const parentIdInfo = parentPks[0];
|
|
284
|
+
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
285
|
+
if (!parentTable) throw new Error("Parent table not found");
|
|
286
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
287
|
+
|
|
288
|
+
// Parse all parent IDs once
|
|
289
|
+
const parsedParentIds = parentEntityIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
290
|
+
|
|
291
|
+
// Handle join path relations with batching
|
|
292
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
293
|
+
let query = this.db.select().from(parentTable).$dynamic();
|
|
294
|
+
let currentTable = parentTable;
|
|
295
|
+
|
|
296
|
+
// Apply each join in the path
|
|
297
|
+
for (const join of relation.joinPath) {
|
|
298
|
+
const joinTable = this.registry.getTable(join.table);
|
|
299
|
+
if (!joinTable) {
|
|
300
|
+
throw new Error(`Join table not found: ${join.table}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
304
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
305
|
+
|
|
306
|
+
const fromParts = fromColumn.split(".");
|
|
307
|
+
const toParts = toColumn.split(".");
|
|
308
|
+
|
|
309
|
+
const fromColName = fromParts[fromParts.length - 1];
|
|
310
|
+
const toColName = toParts[toParts.length - 1];
|
|
311
|
+
|
|
312
|
+
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
313
|
+
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
314
|
+
|
|
315
|
+
if (!fromCol || !toCol) {
|
|
316
|
+
throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// @ts-expect-error Drizzle mutates base query generic on innerJoin
|
|
320
|
+
query = query.innerJoin(joinTable, eq(fromCol, toCol));
|
|
321
|
+
currentTable = joinTable;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Add where condition for ALL parent entities at once
|
|
325
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
326
|
+
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
327
|
+
|
|
328
|
+
const results = await query;
|
|
329
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
330
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
331
|
+
|
|
332
|
+
// Group results by parent ID
|
|
333
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
334
|
+
const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
335
|
+
const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
|
|
336
|
+
const parentId = parentEntity[parentIdInfo.fieldName] as string | number;
|
|
337
|
+
|
|
338
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
339
|
+
|
|
340
|
+
resultMap.set(String(parentId), {
|
|
341
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
342
|
+
path: targetCollection.slug,
|
|
343
|
+
values: parsedValues as Record<string, unknown>
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return resultMap;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Handle owning relations with proper FK-based batching.
|
|
351
|
+
// For owning relations, parent entities hold the FK (e.g. posts.author_id).
|
|
352
|
+
// We need to:
|
|
353
|
+
// 1. Fetch FK values from the parent table in a single query
|
|
354
|
+
// 2. Query the target table with unique FK values
|
|
355
|
+
// 3. Map results back to parent entities via their FK values
|
|
356
|
+
if (relation.direction === "owning" && relation.localKey) {
|
|
357
|
+
const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
|
|
358
|
+
if (!localKeyCol) {
|
|
359
|
+
throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Step 1: Fetch all FK values from parent table in ONE query
|
|
363
|
+
const fkRows = await this.db
|
|
364
|
+
.select({
|
|
365
|
+
parentId: parentIdCol,
|
|
366
|
+
fkValue: localKeyCol
|
|
367
|
+
})
|
|
368
|
+
.from(parentTable)
|
|
369
|
+
.where(inArray(parentIdCol, parsedParentIds));
|
|
370
|
+
|
|
371
|
+
// Build parentId → fkValue mapping and collect unique FK values
|
|
372
|
+
const parentToFk = new Map<string, string | number>();
|
|
373
|
+
const uniqueFkValues: (string | number)[] = [];
|
|
374
|
+
const seenFks = new Set<string>();
|
|
375
|
+
|
|
376
|
+
for (const row of fkRows as Array<{ parentId: string | number; fkValue: string | number | null }>) {
|
|
377
|
+
if (row.fkValue == null) continue;
|
|
378
|
+
parentToFk.set(String(row.parentId), row.fkValue);
|
|
379
|
+
const fkStr = String(row.fkValue);
|
|
380
|
+
if (!seenFks.has(fkStr)) {
|
|
381
|
+
seenFks.add(fkStr);
|
|
382
|
+
uniqueFkValues.push(row.fkValue);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (uniqueFkValues.length === 0) return new Map();
|
|
387
|
+
|
|
388
|
+
// Step 2: Fetch all target entities in ONE query
|
|
389
|
+
const targetResults = await this.db
|
|
390
|
+
.select()
|
|
391
|
+
.from(targetTable)
|
|
392
|
+
.where(inArray(targetIdField, uniqueFkValues));
|
|
393
|
+
|
|
394
|
+
// Index target entities by their ID
|
|
395
|
+
const targetById = new Map<string, Record<string, unknown>>();
|
|
396
|
+
for (const row of targetResults as Array<Record<string, unknown>>) {
|
|
397
|
+
const tid = String(row[targetIdInfo.fieldName]);
|
|
398
|
+
targetById.set(tid, row);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Step 3: Map back to parent entities
|
|
402
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
403
|
+
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
404
|
+
const targetEntity = targetById.get(String(fkValue));
|
|
405
|
+
if (targetEntity) {
|
|
406
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
407
|
+
resultMap.set(parentIdStr, {
|
|
408
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
409
|
+
path: targetCollection.slug,
|
|
410
|
+
values: parsedValues as Record<string, unknown>
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return resultMap;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Handle inverse relation types with batching
|
|
419
|
+
let query = this.db.select().from(targetTable).$dynamic();
|
|
420
|
+
|
|
421
|
+
// Build the relation query with ALL parent IDs
|
|
422
|
+
// @ts-expect-error buildRelationQuery uses dynamic queries
|
|
423
|
+
query = DrizzleConditionBuilder.buildRelationQuery(
|
|
424
|
+
query,
|
|
425
|
+
relation,
|
|
426
|
+
parsedParentIds, // Pass array instead of single ID
|
|
427
|
+
targetTable,
|
|
428
|
+
parentTable,
|
|
429
|
+
parentIdCol,
|
|
430
|
+
targetIdField,
|
|
431
|
+
this.registry,
|
|
432
|
+
[]
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const results = await query;
|
|
436
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>>();
|
|
437
|
+
|
|
438
|
+
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
439
|
+
// number-vs-string type mismatches (Drizzle may return either depending
|
|
440
|
+
// on the column type and driver).
|
|
441
|
+
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
442
|
+
|
|
443
|
+
// Map results back to parent entities
|
|
444
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
445
|
+
const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
446
|
+
|
|
447
|
+
// Determine the parent ID this result belongs to based on the relation type
|
|
448
|
+
let parentId: string | number | undefined;
|
|
449
|
+
|
|
450
|
+
if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
451
|
+
parentId = targetEntity[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
452
|
+
} else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
453
|
+
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
454
|
+
parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
458
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
459
|
+
resultMap.set(String(parentId), {
|
|
460
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
461
|
+
path: targetCollection.slug,
|
|
462
|
+
values: parsedValues as Record<string, unknown>
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return resultMap;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Batch fetch many-cardinality related entities for multiple parent entities.
|
|
472
|
+
* Returns a Map<parentId, Entity[]> instead of Map<parentId, Entity>.
|
|
473
|
+
* Uses a single SQL query with IN clause to avoid N+1.
|
|
474
|
+
*/
|
|
475
|
+
async batchFetchRelatedEntitiesMany(
|
|
476
|
+
parentCollectionPath: string,
|
|
477
|
+
parentEntityIds: (string | number)[],
|
|
478
|
+
_relationKey: string,
|
|
479
|
+
relation: Relation
|
|
480
|
+
): Promise<Map<string, Entity<Record<string, unknown>>[]>> {
|
|
481
|
+
if (parentEntityIds.length === 0) return new Map();
|
|
482
|
+
|
|
483
|
+
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
484
|
+
const targetCollection = relation.target();
|
|
485
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
486
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
487
|
+
const targetIdInfo = targetPks[0];
|
|
488
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
489
|
+
|
|
490
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
491
|
+
const parentIdInfo = parentPks[0];
|
|
492
|
+
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
493
|
+
if (!parentTable) throw new Error("Parent table not found");
|
|
494
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
495
|
+
|
|
496
|
+
const parsedParentIds = parentEntityIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
497
|
+
|
|
498
|
+
// Handle join path relations (many-to-many through junction tables)
|
|
499
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
500
|
+
let query = this.db.select().from(parentTable).$dynamic();
|
|
501
|
+
let currentTable = parentTable;
|
|
502
|
+
|
|
503
|
+
for (const join of relation.joinPath) {
|
|
504
|
+
const joinTable = this.registry.getTable(join.table);
|
|
505
|
+
if (!joinTable) throw new Error(`Join table not found: ${join.table}`);
|
|
506
|
+
|
|
507
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
508
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
509
|
+
const fromColName = fromColumn.split(".").pop()!;
|
|
510
|
+
const toColName = toColumn.split(".").pop()!;
|
|
511
|
+
|
|
512
|
+
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
513
|
+
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
514
|
+
if (!fromCol || !toCol) throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
515
|
+
|
|
516
|
+
// @ts-expect-error Drizzle mutates base query generic on innerJoin
|
|
517
|
+
query = query.innerJoin(joinTable, eq(fromCol, toCol));
|
|
518
|
+
currentTable = joinTable;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
522
|
+
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
523
|
+
|
|
524
|
+
const results = await query;
|
|
525
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
526
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
527
|
+
|
|
528
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
529
|
+
const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
530
|
+
const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
|
|
531
|
+
const parentId = String(parentEntity[parentIdInfo.fieldName]);
|
|
532
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
533
|
+
|
|
534
|
+
const arr = resultMap.get(parentId) || [];
|
|
535
|
+
arr.push({
|
|
536
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
537
|
+
path: targetCollection.slug,
|
|
538
|
+
values: parsedValues as Record<string, unknown>
|
|
539
|
+
});
|
|
540
|
+
resultMap.set(parentId, arr);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return resultMap;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Handle many-to-many owning relations with junction table (relation.through)
|
|
547
|
+
// This is the standard path for posts→tags style relations where
|
|
548
|
+
// sanitizeRelation populated the `through` config.
|
|
549
|
+
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
550
|
+
const junctionTable = this.registry.getTable(relation.through.table);
|
|
551
|
+
if (!junctionTable) {
|
|
552
|
+
console.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
553
|
+
return new Map();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const sourceJunctionCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
557
|
+
const targetJunctionCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
558
|
+
|
|
559
|
+
if (!sourceJunctionCol || !targetJunctionCol) {
|
|
560
|
+
console.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
|
|
561
|
+
return new Map();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// SELECT target.*, junction.sourceColumn FROM junction
|
|
565
|
+
// INNER JOIN target ON junction.targetColumn = target.id
|
|
566
|
+
// WHERE junction.sourceColumn IN (parentIds)
|
|
567
|
+
const query = this.db
|
|
568
|
+
.select()
|
|
569
|
+
.from(junctionTable)
|
|
570
|
+
.innerJoin(targetTable, eq(targetJunctionCol, targetIdField))
|
|
571
|
+
.where(inArray(sourceJunctionCol, parsedParentIds));
|
|
572
|
+
|
|
573
|
+
const results = await query;
|
|
574
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
575
|
+
const targetTableName = getTableName(targetCollection);
|
|
576
|
+
|
|
577
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
578
|
+
// The junction table data is namespaced under its table name
|
|
579
|
+
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
580
|
+
const targetData = (row[targetTableName] || row) as Record<string, unknown>;
|
|
581
|
+
|
|
582
|
+
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
583
|
+
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
584
|
+
|
|
585
|
+
const arr = resultMap.get(parentId) || [];
|
|
586
|
+
arr.push({
|
|
587
|
+
id: String(targetData[targetIdInfo.fieldName]),
|
|
588
|
+
path: targetCollection.slug,
|
|
589
|
+
values: parsedValues as Record<string, unknown>
|
|
590
|
+
});
|
|
591
|
+
resultMap.set(parentId, arr);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return resultMap;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Handle FK-based relations (one-to-many inverse)
|
|
598
|
+
let query = this.db.select().from(targetTable).$dynamic();
|
|
599
|
+
|
|
600
|
+
// @ts-expect-error buildRelationQuery uses dynamic queries
|
|
601
|
+
query = DrizzleConditionBuilder.buildRelationQuery(
|
|
602
|
+
query,
|
|
603
|
+
relation,
|
|
604
|
+
parsedParentIds,
|
|
605
|
+
targetTable,
|
|
606
|
+
parentTable,
|
|
607
|
+
parentIdCol,
|
|
608
|
+
targetIdField,
|
|
609
|
+
this.registry,
|
|
610
|
+
[]
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const results = await query;
|
|
614
|
+
const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
|
|
615
|
+
|
|
616
|
+
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
617
|
+
// number-vs-string type mismatches (Drizzle may return either depending
|
|
618
|
+
// on the column type and driver).
|
|
619
|
+
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
620
|
+
|
|
621
|
+
for (const row of results as Array<Record<string, unknown>>) {
|
|
622
|
+
const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
623
|
+
|
|
624
|
+
let parentId: string | number | undefined;
|
|
625
|
+
|
|
626
|
+
if (relation.through && relation.direction === "inverse") {
|
|
627
|
+
// Inverse many-to-many via junction table: the junction's targetColumn
|
|
628
|
+
// references the parent (since from the inverse perspective, source/target are swapped).
|
|
629
|
+
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
630
|
+
parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
|
|
631
|
+
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
632
|
+
parentId = targetEntity[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
633
|
+
} else if (relation.direction === "inverse" && relation.inverseRelationName) {
|
|
634
|
+
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
635
|
+
parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
639
|
+
const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
|
|
640
|
+
const key = String(parentId);
|
|
641
|
+
const arr = resultMap.get(key) || [];
|
|
642
|
+
arr.push({
|
|
643
|
+
id: String(targetEntity[targetIdInfo.fieldName]),
|
|
644
|
+
path: targetCollection.slug,
|
|
645
|
+
values: parsedValues as Record<string, unknown>
|
|
646
|
+
});
|
|
647
|
+
resultMap.set(key, arr);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return resultMap;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Update many-to-many and junction relations
|
|
656
|
+
*/
|
|
657
|
+
async updateRelationsUsingJoins<M extends Record<string, unknown>>(
|
|
658
|
+
tx: DrizzleClient,
|
|
659
|
+
collection: EntityCollection,
|
|
660
|
+
entityId: string | number,
|
|
661
|
+
relationValues: Partial<M>
|
|
662
|
+
) {
|
|
663
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
664
|
+
|
|
665
|
+
for (const [key, value] of Object.entries(relationValues)) {
|
|
666
|
+
const relation = findRelation(resolvedRelations, key);
|
|
667
|
+
if (!relation || relation.cardinality !== "many") continue;
|
|
668
|
+
|
|
669
|
+
const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
|
|
670
|
+
const targetCollection = relation.target();
|
|
671
|
+
|
|
672
|
+
// Use joinPath if available
|
|
673
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
674
|
+
const parentTableName = getTableName(collection);
|
|
675
|
+
const targetTableName = getTableName(targetCollection);
|
|
676
|
+
|
|
677
|
+
let junctionTable: PgTable | undefined = undefined;
|
|
678
|
+
let sourceJunctionColumn: AnyPgColumn | null = null;
|
|
679
|
+
let targetJunctionColumn: AnyPgColumn | null = null;
|
|
680
|
+
|
|
681
|
+
const junctionTableName = relation.joinPath.find(step =>
|
|
682
|
+
step.table !== parentTableName && step.table !== targetTableName
|
|
683
|
+
)?.table;
|
|
684
|
+
|
|
685
|
+
if (junctionTableName) {
|
|
686
|
+
junctionTable = this.registry.getTable(junctionTableName);
|
|
687
|
+
|
|
688
|
+
if (junctionTable) {
|
|
689
|
+
for (const joinStep of relation.joinPath) {
|
|
690
|
+
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
|
|
691
|
+
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
|
|
692
|
+
|
|
693
|
+
if (fromTable === parentTableName && toTable === junctionTableName) {
|
|
694
|
+
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
695
|
+
sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
696
|
+
} else if (fromTable === junctionTableName && toTable === parentTableName) {
|
|
697
|
+
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
698
|
+
sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (fromTable === junctionTableName && toTable === targetTableName) {
|
|
702
|
+
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
703
|
+
targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
704
|
+
} else if (fromTable === targetTableName && toTable === junctionTableName) {
|
|
705
|
+
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
706
|
+
targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
|
|
713
|
+
console.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
718
|
+
const parentIdInfo = parentPks[0];
|
|
719
|
+
const parsedParentIdObj = parseIdValues(entityId, parentPks);
|
|
720
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
721
|
+
|
|
722
|
+
// Delete existing relations for this entity
|
|
723
|
+
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
724
|
+
|
|
725
|
+
if (targetEntityIds.length > 0) {
|
|
726
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
727
|
+
const targetIdInfo = targetPks[0];
|
|
728
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
729
|
+
|
|
730
|
+
const newLinks = parsedTargetIds.map(targetId => ({
|
|
731
|
+
[sourceJunctionColumn.name]: parsedParentId,
|
|
732
|
+
[targetJunctionColumn.name]: targetId
|
|
733
|
+
}));
|
|
734
|
+
|
|
735
|
+
if (newLinks.length > 0) {
|
|
736
|
+
await tx.insert(junctionTable).values(newLinks);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
} else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
740
|
+
// Handle many-to-many relations with junction table using 'through' property
|
|
741
|
+
const junctionTable = this.registry.getTable(relation.through.table);
|
|
742
|
+
if (!junctionTable) {
|
|
743
|
+
console.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
748
|
+
const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
749
|
+
|
|
750
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
751
|
+
console.warn(`Junction columns not found for relation '${key}'`);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
756
|
+
const parentIdInfo = parentPks[0];
|
|
757
|
+
const parsedParentIdObj = parseIdValues(entityId, parentPks);
|
|
758
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
759
|
+
|
|
760
|
+
// Delete existing relations for this entity
|
|
761
|
+
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
762
|
+
|
|
763
|
+
if (targetEntityIds.length > 0) {
|
|
764
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
765
|
+
const targetIdInfo = targetPks[0];
|
|
766
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
767
|
+
|
|
768
|
+
const newLinks = parsedTargetIds.map(targetId => ({
|
|
769
|
+
[sourceJunctionColumn.name]: parsedParentId,
|
|
770
|
+
[targetJunctionColumn.name]: targetId
|
|
771
|
+
}));
|
|
772
|
+
|
|
773
|
+
if (newLinks.length > 0) {
|
|
774
|
+
await tx.insert(junctionTable).values(newLinks);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
|
|
778
|
+
// Inverse M2M relations should be saved from the owning side.
|
|
779
|
+
// The owning collection manages the junction table rows.
|
|
780
|
+
console.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
781
|
+
} else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
782
|
+
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
783
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
784
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
785
|
+
const targetIdInfo = targetPks[0];
|
|
786
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
787
|
+
const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
788
|
+
|
|
789
|
+
if (!fkCol || !targetIdCol) {
|
|
790
|
+
console.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const parentPks = getPrimaryKeys(collection, this.registry);
|
|
795
|
+
const parentIdInfo = parentPks[0];
|
|
796
|
+
const parsedParentIdObj = parseIdValues(entityId, parentPks);
|
|
797
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
798
|
+
|
|
799
|
+
// Clear existing links not in the new set
|
|
800
|
+
if (targetEntityIds.length > 0) {
|
|
801
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
802
|
+
await tx
|
|
803
|
+
.update(targetTable)
|
|
804
|
+
.set({ [relation.foreignKeyOnTarget]: null })
|
|
805
|
+
.where(and(eq(fkCol, parsedParentId), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
806
|
+
|
|
807
|
+
// Set FK for the provided targets
|
|
808
|
+
await tx
|
|
809
|
+
.update(targetTable)
|
|
810
|
+
.set({ [relation.foreignKeyOnTarget]: parsedParentId })
|
|
811
|
+
.where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
|
|
812
|
+
} else {
|
|
813
|
+
// If empty array provided, clear all existing links for this parent
|
|
814
|
+
await tx
|
|
815
|
+
.update(targetTable)
|
|
816
|
+
.set({ [relation.foreignKeyOnTarget]: null })
|
|
817
|
+
.where(eq(fkCol, parsedParentId));
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
console.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Update inverse relations (where FK is on the target table)
|
|
827
|
+
*/
|
|
828
|
+
async updateInverseRelations(
|
|
829
|
+
tx: DrizzleClient,
|
|
830
|
+
sourceCollection: EntityCollection,
|
|
831
|
+
sourceEntityId: string | number,
|
|
832
|
+
inverseRelationUpdates: Array<{
|
|
833
|
+
relationKey: string;
|
|
834
|
+
relation: Relation;
|
|
835
|
+
newValue: unknown;
|
|
836
|
+
currentEntityId?: string | number;
|
|
837
|
+
}>
|
|
838
|
+
) {
|
|
839
|
+
for (const update of inverseRelationUpdates) {
|
|
840
|
+
const { relation, newValue } = update;
|
|
841
|
+
|
|
842
|
+
try {
|
|
843
|
+
const targetCollection = relation.target();
|
|
844
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
845
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
846
|
+
const targetIdInfo = targetPks[0];
|
|
847
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
848
|
+
const sourceIdInfo = sourcePks[0];
|
|
849
|
+
|
|
850
|
+
// Handle inverse relations with joinPath
|
|
851
|
+
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
852
|
+
await this.updateInverseJoinPathRelation(
|
|
853
|
+
tx,
|
|
854
|
+
sourceCollection,
|
|
855
|
+
sourceEntityId,
|
|
856
|
+
targetCollection,
|
|
857
|
+
relation,
|
|
858
|
+
newValue
|
|
859
|
+
);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Check if this is a many-to-many inverse relation
|
|
864
|
+
if (relation.cardinality === "many" && relation.direction === "inverse") {
|
|
865
|
+
const targetCollectionRelations = resolveCollectionRelations(targetCollection);
|
|
866
|
+
let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
|
|
867
|
+
|
|
868
|
+
for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
|
|
869
|
+
if (targetRelation.cardinality === "many" &&
|
|
870
|
+
targetRelation.direction === "owning" &&
|
|
871
|
+
targetRelation.through &&
|
|
872
|
+
(targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
|
|
873
|
+
junctionInfo = {
|
|
874
|
+
table: targetRelation.through.table,
|
|
875
|
+
sourceColumn: targetRelation.through.targetColumn,
|
|
876
|
+
targetColumn: targetRelation.through.sourceColumn
|
|
877
|
+
};
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (junctionInfo) {
|
|
883
|
+
await this.updateManyToManyInverseRelation(
|
|
884
|
+
tx,
|
|
885
|
+
sourceCollection,
|
|
886
|
+
sourceEntityId,
|
|
887
|
+
targetCollection,
|
|
888
|
+
relation,
|
|
889
|
+
newValue,
|
|
890
|
+
junctionInfo
|
|
891
|
+
);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Handle simple inverse relations
|
|
897
|
+
if (!relation.foreignKeyOnTarget) {
|
|
898
|
+
console.warn(`Inverse relation '${relation.relationName}' is missing foreignKeyOnTarget property. Skipping.`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget! as keyof typeof targetTable] as AnyPgColumn;
|
|
903
|
+
if (!foreignKeyColumn) {
|
|
904
|
+
console.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
909
|
+
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
910
|
+
|
|
911
|
+
if (newValue === null || newValue === undefined) {
|
|
912
|
+
await tx
|
|
913
|
+
.update(targetTable)
|
|
914
|
+
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
915
|
+
.where(eq(foreignKeyColumn, parsedSourceId));
|
|
916
|
+
} else {
|
|
917
|
+
const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
|
|
918
|
+
const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
|
|
919
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
920
|
+
|
|
921
|
+
// First, clear any existing FK that points to this source entity
|
|
922
|
+
await tx
|
|
923
|
+
.update(targetTable)
|
|
924
|
+
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
925
|
+
.where(eq(foreignKeyColumn, parsedSourceId));
|
|
926
|
+
|
|
927
|
+
// Then, update the new target entity to point to this source entity
|
|
928
|
+
await tx
|
|
929
|
+
.update(targetTable)
|
|
930
|
+
.set({ [relation.foreignKeyOnTarget!]: parsedSourceId })
|
|
931
|
+
.where(eq(targetIdField, parsedNewTargetId));
|
|
932
|
+
}
|
|
933
|
+
} catch (e) {
|
|
934
|
+
console.warn(`Failed to update inverse relation '${relation.relationName}':`, e);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Handle inverse relations with joinPath
|
|
941
|
+
*/
|
|
942
|
+
private async updateInverseJoinPathRelation(
|
|
943
|
+
tx: DrizzleClient,
|
|
944
|
+
sourceCollection: EntityCollection,
|
|
945
|
+
sourceEntityId: string | number,
|
|
946
|
+
targetCollection: EntityCollection,
|
|
947
|
+
relation: Relation,
|
|
948
|
+
newValue: unknown
|
|
949
|
+
) {
|
|
950
|
+
try {
|
|
951
|
+
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
952
|
+
console.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const sourceTableName = getTableName(sourceCollection);
|
|
957
|
+
const targetTableName = getTableName(targetCollection);
|
|
958
|
+
|
|
959
|
+
// Find intermediate tables that are neither source nor target
|
|
960
|
+
const intermediateTables = relation.joinPath
|
|
961
|
+
.map(step => step.table)
|
|
962
|
+
.filter(table => table !== sourceTableName && table !== targetTableName);
|
|
963
|
+
|
|
964
|
+
// If there's exactly one intermediate table, it's likely a junction table for many-to-many
|
|
965
|
+
if (intermediateTables.length === 1 && relation.cardinality === "many") {
|
|
966
|
+
const junctionTableName = intermediateTables[0];
|
|
967
|
+
const junctionTable = this.registry.getTable(junctionTableName);
|
|
968
|
+
|
|
969
|
+
if (!junctionTable) {
|
|
970
|
+
console.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
let sourceJunctionColumn: AnyPgColumn | null = null;
|
|
975
|
+
let targetJunctionColumn: AnyPgColumn | null = null;
|
|
976
|
+
|
|
977
|
+
for (const step of relation.joinPath) {
|
|
978
|
+
if (step.table === junctionTableName) {
|
|
979
|
+
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
|
|
980
|
+
const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
|
|
981
|
+
const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
|
|
982
|
+
|
|
983
|
+
if (fromTable === sourceTableName) {
|
|
984
|
+
sourceJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
985
|
+
} else if (fromTable === targetTableName) {
|
|
986
|
+
targetJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
987
|
+
} else {
|
|
988
|
+
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
|
|
989
|
+
if (toTable === sourceTableName) {
|
|
990
|
+
sourceJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
991
|
+
} else if (toTable === targetTableName) {
|
|
992
|
+
targetJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
999
|
+
console.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Perform the junction table update
|
|
1004
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
1005
|
+
const sourceIdInfo = sourcePks[0];
|
|
1006
|
+
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1007
|
+
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1008
|
+
|
|
1009
|
+
// Clear existing entries for this source entity
|
|
1010
|
+
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
1011
|
+
|
|
1012
|
+
// Add new entries if newValue is provided
|
|
1013
|
+
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1014
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
1015
|
+
const targetIdInfo = targetPks[0];
|
|
1016
|
+
const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
|
|
1017
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1018
|
+
|
|
1019
|
+
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1020
|
+
[sourceJunctionColumn!.name]: parsedSourceId,
|
|
1021
|
+
[targetJunctionColumn!.name]: targetId
|
|
1022
|
+
}));
|
|
1023
|
+
|
|
1024
|
+
if (newLinks.length > 0) {
|
|
1025
|
+
await tx.insert(junctionTable).values(newLinks);
|
|
1026
|
+
}
|
|
1027
|
+
} else if (newValue && !Array.isArray(newValue)) {
|
|
1028
|
+
// Single value for one-to-one
|
|
1029
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
1030
|
+
const targetIdInfo = targetPks[0];
|
|
1031
|
+
const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
|
|
1032
|
+
const parsedTargetIdObj = parseIdValues(targetId, targetPks);
|
|
1033
|
+
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
1034
|
+
|
|
1035
|
+
const newLink = {
|
|
1036
|
+
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1037
|
+
[targetJunctionColumn.name]: parsedTargetId
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
await tx.insert(junctionTable).values(newLink);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
console.error(`Failed to update inverse joinPath relation '${relation.relationName}':`, error);
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Handle many-to-many inverse relation updates using junction tables
|
|
1051
|
+
*/
|
|
1052
|
+
private async updateManyToManyInverseRelation(
|
|
1053
|
+
tx: DrizzleClient,
|
|
1054
|
+
sourceCollection: EntityCollection,
|
|
1055
|
+
sourceEntityId: string | number,
|
|
1056
|
+
targetCollection: EntityCollection,
|
|
1057
|
+
relation: Relation,
|
|
1058
|
+
newValue: unknown,
|
|
1059
|
+
junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
|
|
1060
|
+
) {
|
|
1061
|
+
try {
|
|
1062
|
+
const junctionTable = this.registry.getTable(junctionInfo.table);
|
|
1063
|
+
if (!junctionTable) {
|
|
1064
|
+
console.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1069
|
+
const targetJunctionColumn = junctionTable[junctionInfo.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1070
|
+
|
|
1071
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1072
|
+
console.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
|
|
1077
|
+
const sourceIdInfo = sourcePks[0];
|
|
1078
|
+
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1079
|
+
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
1080
|
+
|
|
1081
|
+
// Clear existing entries for this source entity
|
|
1082
|
+
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
1083
|
+
|
|
1084
|
+
// Add new entries if newValue is provided
|
|
1085
|
+
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1086
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
1087
|
+
const targetIdInfo = targetPks[0];
|
|
1088
|
+
const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
|
|
1089
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1090
|
+
|
|
1091
|
+
const newLinks = parsedTargetIds.map(targetId => ({
|
|
1092
|
+
[sourceJunctionColumn.name]: parsedSourceId,
|
|
1093
|
+
[targetJunctionColumn.name]: targetId
|
|
1094
|
+
}));
|
|
1095
|
+
|
|
1096
|
+
if (newLinks.length > 0) {
|
|
1097
|
+
await tx.insert(junctionTable).values(newLinks);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
console.error(`Failed to update many-to-many inverse relation '${relation.relationName}':`, error);
|
|
1102
|
+
throw error;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Update one-to-one relations that use joinPath
|
|
1108
|
+
*/
|
|
1109
|
+
async updateJoinPathOneToOneRelations(
|
|
1110
|
+
tx: DrizzleClient,
|
|
1111
|
+
parentCollection: EntityCollection,
|
|
1112
|
+
parentEntityId: string | number,
|
|
1113
|
+
updates: Array<{
|
|
1114
|
+
relationKey: string;
|
|
1115
|
+
relation: Relation;
|
|
1116
|
+
newTargetId: string | number | null;
|
|
1117
|
+
}>
|
|
1118
|
+
) {
|
|
1119
|
+
for (const upd of updates) {
|
|
1120
|
+
const { relation, newTargetId } = upd;
|
|
1121
|
+
const targetCollection = relation.target();
|
|
1122
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1123
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
1124
|
+
const targetIdInfo = targetPks[0];
|
|
1125
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
1126
|
+
|
|
1127
|
+
// Determine mapping of columns
|
|
1128
|
+
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
1129
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
1130
|
+
const parentPks = getPrimaryKeys(parentCollection, this.registry);
|
|
1131
|
+
const parentIdInfo = parentPks[0];
|
|
1132
|
+
const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
|
|
1133
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
1134
|
+
|
|
1135
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
1136
|
+
const parentSourceCol = parentTable[parentSourceColName as keyof typeof parentTable] as AnyPgColumn;
|
|
1137
|
+
const targetFKCol = targetTable[targetFKColName as keyof typeof targetTable] as AnyPgColumn;
|
|
1138
|
+
|
|
1139
|
+
if (!parentSourceCol) {
|
|
1140
|
+
console.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
if (!targetFKCol) {
|
|
1144
|
+
console.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Fetch the parent row to obtain the value for parentSourceCol
|
|
1149
|
+
const parentRows = await tx
|
|
1150
|
+
.select({ val: parentSourceCol })
|
|
1151
|
+
.from(parentTable)
|
|
1152
|
+
.where(eq(parentIdCol, parsedParentId))
|
|
1153
|
+
.limit(1);
|
|
1154
|
+
if (parentRows.length === 0) continue;
|
|
1155
|
+
const parentFKValue = parentRows[0].val as string | number | null;
|
|
1156
|
+
|
|
1157
|
+
if (newTargetId === null || newTargetId === undefined) {
|
|
1158
|
+
// Clear any target rows currently linked to this parent via the FK
|
|
1159
|
+
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
1160
|
+
await tx.update(targetTable)
|
|
1161
|
+
.set({ [targetFKColName]: null })
|
|
1162
|
+
.where(eq(targetFKCol, parentFKValue as unknown as string));
|
|
1163
|
+
}
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Parse the new target id
|
|
1168
|
+
const parsedTargetIdObj = parseIdValues(newTargetId, targetPks);
|
|
1169
|
+
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
1170
|
+
|
|
1171
|
+
// Ensure one-to-one by clearing existing link from any target rows with this parent FK
|
|
1172
|
+
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
1173
|
+
await tx.update(targetTable)
|
|
1174
|
+
.set({ [targetFKColName]: null })
|
|
1175
|
+
.where(eq(targetFKCol, parentFKValue as unknown as string));
|
|
1176
|
+
} else {
|
|
1177
|
+
console.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Now set the FK on the target entity
|
|
1182
|
+
await tx.update(targetTable)
|
|
1183
|
+
.set({ [targetFKColName]: parentFKValue })
|
|
1184
|
+
.where(eq(targetIdCol, parsedTargetId));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Resolve joinPath write mapping for one-to-one relations
|
|
1190
|
+
*/
|
|
1191
|
+
resolveJoinPathWriteMapping(
|
|
1192
|
+
parentCollection: EntityCollection,
|
|
1193
|
+
relation: Relation
|
|
1194
|
+
): { targetFKColName: string; parentSourceColName: string } {
|
|
1195
|
+
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1196
|
+
throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
1197
|
+
}
|
|
1198
|
+
const parentTableName = getTableName(parentCollection);
|
|
1199
|
+
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
1200
|
+
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
1201
|
+
let currentFrom = lastStep.on.from;
|
|
1202
|
+
|
|
1203
|
+
let safety = 0;
|
|
1204
|
+
while (safety++ < 10) {
|
|
1205
|
+
const currentFromTable = DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0];
|
|
1206
|
+
if (currentFromTable === parentTableName) {
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
const prevStep = relation.joinPath.find((s) => {
|
|
1210
|
+
const to = Array.isArray(s.on.to) ? s.on.to[0] : s.on.to;
|
|
1211
|
+
return to === currentFrom;
|
|
1212
|
+
});
|
|
1213
|
+
if (!prevStep) {
|
|
1214
|
+
throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
1215
|
+
}
|
|
1216
|
+
currentFrom = prevStep.on.from;
|
|
1217
|
+
}
|
|
1218
|
+
const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
|
|
1219
|
+
return { targetFKColName,
|
|
1220
|
+
parentSourceColName };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Handle junction table creation for many-to-many path-based saves
|
|
1225
|
+
*/
|
|
1226
|
+
async handleJunctionTableCreation(
|
|
1227
|
+
tx: DrizzleClient,
|
|
1228
|
+
newEntityId: string | number,
|
|
1229
|
+
junctionTableInfo: {
|
|
1230
|
+
parentCollection: EntityCollection;
|
|
1231
|
+
parentId: string | number;
|
|
1232
|
+
relation: Relation;
|
|
1233
|
+
relationKey: string;
|
|
1234
|
+
}
|
|
1235
|
+
) {
|
|
1236
|
+
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
1237
|
+
const targetCollection = relation.target();
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
const junctionTable = this.registry.getTable(relation.through!.table);
|
|
1241
|
+
if (!junctionTable) {
|
|
1242
|
+
console.warn(`Junction table '${relation.through!.table}' not found for relation '${relationKey}'`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const sourceJunctionColumn = junctionTable[relation.through!.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1247
|
+
const targetJunctionColumn = junctionTable[relation.through!.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1248
|
+
|
|
1249
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1250
|
+
console.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Parse the new entity ID to the correct type
|
|
1255
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
1256
|
+
const targetIdInfo = targetPks[0];
|
|
1257
|
+
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1258
|
+
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
1259
|
+
|
|
1260
|
+
// Create the junction table entry linking parent to the new entity
|
|
1261
|
+
const junctionData = {
|
|
1262
|
+
[sourceJunctionColumn.name]: parentId,
|
|
1263
|
+
[targetJunctionColumn.name]: parsedNewEntityId
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
await tx.insert(junctionTable).values(junctionData);
|
|
1267
|
+
|
|
1268
|
+
console.log(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
console.error(`Failed to create junction table entry for relation '${relationKey}':`, error);
|
|
1271
|
+
throw error;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|