@rebasepro/server-postgres 0.10.1-canary.b1e3dbf → 0.10.1-canary.ed8caed
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/chunk-DSJWtz9O.js +40 -0
- package/dist/ensure-collection-tables-BVvtkkRm.js +304 -0
- package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
- package/dist/index.es.js +499 -4644
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/src-CBgtrPhJ.js +336 -0
- package/dist/src-CBgtrPhJ.js.map +1 -0
- package/dist/src-lcfUP4xg.js +4106 -0
- package/dist/src-lcfUP4xg.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +41 -0
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +40 -0
- package/src/schema/doctor.ts +6 -6
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +13 -9
- package/src/schema/generate-postgres-ddl-logic.ts +22 -15
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +6 -6
- package/src/services/FetchService.ts +104 -114
- package/src/services/PersistService.ts +127 -85
- package/src/services/RelationService.ts +95 -6
- package/src/services/nested-path.ts +130 -0
- package/src/utils/drizzle-conditions.ts +143 -0
|
@@ -2,7 +2,7 @@ import { eq, and, sql, SQL } from "drizzle-orm";
|
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { CollectionConfig, Properties, Relation } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations
|
|
5
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
@@ -16,6 +16,13 @@ import { RelationService } from "./RelationService";
|
|
|
16
16
|
import { FetchService } from "./FetchService";
|
|
17
17
|
import { DrizzleClient } from "../interfaces";
|
|
18
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
19
|
+
import {
|
|
20
|
+
assertWritableThrough,
|
|
21
|
+
isJunctionBackedRelation,
|
|
22
|
+
isNestedPath,
|
|
23
|
+
resolveNestedPath,
|
|
24
|
+
type NestedPathHop
|
|
25
|
+
} from "./nested-path";
|
|
19
26
|
import { ApiError, logger } from "@rebasepro/server";
|
|
20
27
|
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
21
28
|
|
|
@@ -80,6 +87,33 @@ export class PersistService {
|
|
|
80
87
|
* Delete an row by ID
|
|
81
88
|
*/
|
|
82
89
|
async delete(collectionPath: string, id: string | number, _databaseId?: string): Promise<void> {
|
|
90
|
+
// A nested address deletes *through* a relation, and what that removes
|
|
91
|
+
// depends on who owns the target row.
|
|
92
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
93
|
+
if (hop) {
|
|
94
|
+
assertWritableThrough(hop, collectionPath);
|
|
95
|
+
|
|
96
|
+
if (!await this.relationService.isRelated(hop, id)) {
|
|
97
|
+
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isJunctionBackedRelation(hop.relation)) {
|
|
101
|
+
// Shared target: drop the link, not the row.
|
|
102
|
+
if (!hop.relation.through) {
|
|
103
|
+
throw ApiError.badRequest(
|
|
104
|
+
`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, ` +
|
|
105
|
+
`so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" ` +
|
|
106
|
+
"directly if that is what you meant.",
|
|
107
|
+
"RELATION_NOT_UNLINKABLE"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
await this.relationService.unlinkRelatedEntity(this.db, hop, id);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Owned child (inverse FK): deleting the row is the right meaning,
|
|
114
|
+
// and membership above has established it is this parent's child.
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
84
118
|
const table = getTableForCollection(collection, this.registry);
|
|
85
119
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
@@ -113,6 +147,44 @@ export class PersistService {
|
|
|
113
147
|
await this.db.delete(table);
|
|
114
148
|
}
|
|
115
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The column on the *target* table that records the parent, for a create
|
|
152
|
+
* under a nested one-to-many path.
|
|
153
|
+
*
|
|
154
|
+
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
155
|
+
* `joinPath`), so the caller writes the row without stamping anything.
|
|
156
|
+
*
|
|
157
|
+
* `relation.localKey` is deliberately not consulted: it names a column on
|
|
158
|
+
* the *source* table. Falling back to it here — which is what this used to
|
|
159
|
+
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
160
|
+
*/
|
|
161
|
+
private resolveParentForeignKeyColumn(hop: NestedPathHop): string | undefined {
|
|
162
|
+
const { relation, relationKey, targetCollection } = hop;
|
|
163
|
+
|
|
164
|
+
if (relation.foreignKeyOnTarget) return relation.foreignKeyOnTarget;
|
|
165
|
+
|
|
166
|
+
if (relation.joinPath && relation.joinPath.length === 1) {
|
|
167
|
+
const joinStep = relation.joinPath[0];
|
|
168
|
+
const targetTableName = getTableName(targetCollection);
|
|
169
|
+
if (joinStep.table !== targetTableName) {
|
|
170
|
+
logger.warn(`Join step for relation '${relationKey}' targets '${joinStep.table}', not the target table '${targetTableName}'.`);
|
|
171
|
+
}
|
|
172
|
+
return DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to)[0];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (relation.joinPath && relation.joinPath.length > 1) {
|
|
176
|
+
// Multi-hop: the link lives in an intermediate table, not in a
|
|
177
|
+
// column on the target. Nothing to stamp.
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw ApiError.badRequest(
|
|
182
|
+
`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no ` +
|
|
183
|
+
"`foreignKeyOnTarget` (the column on " + `'${targetCollection.slug}'` + " that records the parent) and no `joinPath`.",
|
|
184
|
+
"RELATION_NOT_WRITABLE"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
116
188
|
/**
|
|
117
189
|
* Save an row (create or update)
|
|
118
190
|
*
|
|
@@ -129,93 +201,59 @@ export class PersistService {
|
|
|
129
201
|
databaseId?: string,
|
|
130
202
|
options?: { upsert?: boolean }
|
|
131
203
|
): Promise<Record<string, unknown>> {
|
|
132
|
-
// If saving under a nested relation path, resolve the
|
|
204
|
+
// If saving under a nested relation path, resolve the relation it ends in.
|
|
133
205
|
let effectiveCollectionPath = collectionPath;
|
|
134
206
|
const effectiveValues: Partial<M> = { ...values };
|
|
135
207
|
let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
|
|
136
208
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
|
|
184
|
-
|
|
185
|
-
if (relevantJoinStep) {
|
|
186
|
-
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to);
|
|
187
|
-
targetColumnName = targetColumnNames[0];
|
|
188
|
-
} else {
|
|
189
|
-
logger.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
|
|
190
|
-
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
|
|
191
|
-
targetColumnName = targetColumnNames[0];
|
|
192
|
-
}
|
|
193
|
-
} else if (relation.joinPath && relation.joinPath.length > 1) {
|
|
194
|
-
// For multi-hop relations (like many-to-many through a junction table),
|
|
195
|
-
// there is no direct foreign key on the target table pointing to the parent.
|
|
196
|
-
// The relationship is managed via the junction table.
|
|
197
|
-
// We shouldn't inject the parent ID directly into the target row payload.
|
|
198
|
-
break;
|
|
199
|
-
} else {
|
|
200
|
-
throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
|
|
204
|
-
const parentIdInfo = parentIdInfoArray[0];
|
|
205
|
-
const parsedParentIdObj = parseIdValues(currentId, parentIdInfoArray);
|
|
206
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
207
|
-
|
|
208
|
-
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
|
|
209
|
-
if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
|
|
210
|
-
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
211
|
-
}
|
|
212
|
-
(effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
|
|
213
|
-
break;
|
|
214
|
-
} else {
|
|
215
|
-
const nextEntityId = segments[i + 1];
|
|
216
|
-
currentCollection = relation.target();
|
|
217
|
-
currentId = nextEntityId;
|
|
209
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
210
|
+
|
|
211
|
+
if (hop) {
|
|
212
|
+
assertWritableThrough(hop, collectionPath);
|
|
213
|
+
effectiveCollectionPath = hop.targetCollection.slug;
|
|
214
|
+
|
|
215
|
+
const parentIdForWrite = () => {
|
|
216
|
+
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
217
|
+
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (hop.relation.through) {
|
|
221
|
+
// A junction path addresses set membership, so a write through it
|
|
222
|
+
// asserts "this row is in this parent's set" — on create *and* on
|
|
223
|
+
// update. The junction row is written after the main write below,
|
|
224
|
+
// idempotently, which is what makes `PUT parent/id/child/childId`
|
|
225
|
+
// able to attach a row that already exists.
|
|
226
|
+
//
|
|
227
|
+
// Unlike an owning foreign key, this takes the row from nobody:
|
|
228
|
+
// its other parents keep it. That is why linking is safe here
|
|
229
|
+
// where reparenting (below) is not.
|
|
230
|
+
junctionTableInfo = {
|
|
231
|
+
parentCollection: hop.parentCollection,
|
|
232
|
+
parentId: parentIdForWrite(),
|
|
233
|
+
relation: hop.relation,
|
|
234
|
+
relationKey: hop.relationKey
|
|
235
|
+
};
|
|
236
|
+
} else if (id !== undefined) {
|
|
237
|
+
// Updating an existing row *through* an owning parent. The parent
|
|
238
|
+
// segment is an assertion about where the row already lives, not
|
|
239
|
+
// an instruction to move it there: injecting the FK here silently
|
|
240
|
+
// reparented whatever id was named, so `PUT authors/1/posts/43`
|
|
241
|
+
// stole post 43 from its real author. Check membership instead,
|
|
242
|
+
// and leave the FK to an explicit value in the body.
|
|
243
|
+
if (!await this.relationService.isRelated(hop, id)) {
|
|
244
|
+
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// One-to-many create: stamp the parent's id onto the child's FK.
|
|
248
|
+
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
249
|
+
|
|
250
|
+
if (targetColumnName) {
|
|
251
|
+
const parsedParentId = parentIdForWrite();
|
|
252
|
+
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
|
|
253
|
+
if (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
|
|
254
|
+
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
|
|
218
255
|
}
|
|
256
|
+
(effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
|
|
219
257
|
}
|
|
220
258
|
}
|
|
221
259
|
}
|
|
@@ -372,8 +410,12 @@ export class PersistService {
|
|
|
372
410
|
await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
373
411
|
}
|
|
374
412
|
|
|
375
|
-
//
|
|
376
|
-
|
|
413
|
+
// Attach the row to the parent's set. Runs for updates as well as
|
|
414
|
+
// creates — it used to be gated on `!id`, which is why a row that
|
|
415
|
+
// already existed could never be added to a parent through its
|
|
416
|
+
// path. The insert is idempotent, so re-asserting a link is a
|
|
417
|
+
// no-op rather than a duplicate-key error.
|
|
418
|
+
if (junctionTableInfo) {
|
|
377
419
|
await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
378
420
|
}
|
|
379
421
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { parseDataFromServer } from "../data-transformer";
|
|
16
16
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
17
17
|
import { logger } from "@rebasepro/server";
|
|
18
|
+
import type { NestedPathHop } from "./nested-path";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
@@ -333,6 +334,24 @@ export class RelationService {
|
|
|
333
334
|
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
342
|
+
* `additionalFilters` (conditions on the target table).
|
|
343
|
+
*
|
|
344
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
345
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
346
|
+
* same join — a membership test that reconstructed the join separately would
|
|
347
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
348
|
+
*/
|
|
349
|
+
private async countRelatedRows(
|
|
350
|
+
parentCollection: CollectionConfig,
|
|
351
|
+
parentId: string | number,
|
|
352
|
+
relation: Relation,
|
|
353
|
+
additionalFilters: SQL[]
|
|
354
|
+
): Promise<number> {
|
|
336
355
|
const targetCollection = relation.target();
|
|
337
356
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
338
357
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -350,9 +369,6 @@ export class RelationService {
|
|
|
350
369
|
// Start count with distinct to avoid duplicates from junction tables
|
|
351
370
|
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
352
371
|
|
|
353
|
-
// Build additional filter conditions
|
|
354
|
-
const additionalFilters: SQL[] = [];
|
|
355
|
-
|
|
356
372
|
// Use unified count query builder from DrizzleConditionBuilder
|
|
357
373
|
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
358
374
|
query,
|
|
@@ -370,6 +386,75 @@ export class RelationService {
|
|
|
370
386
|
return Number(result[0]?.count || 0);
|
|
371
387
|
}
|
|
372
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
391
|
+
*
|
|
392
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
393
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
394
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
395
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
396
|
+
*/
|
|
397
|
+
async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
|
|
398
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
399
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
400
|
+
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
401
|
+
|
|
402
|
+
const identity: SQL[] = targetPks.map(pk => {
|
|
403
|
+
const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
404
|
+
if (!column) {
|
|
405
|
+
throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
406
|
+
}
|
|
407
|
+
return eq(column, parsedTargetId[pk.fieldName]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
415
|
+
* row itself alone.
|
|
416
|
+
*
|
|
417
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
418
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
419
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
420
|
+
* `tags` table and delete by primary key.
|
|
421
|
+
*/
|
|
422
|
+
async unlinkRelatedEntity(
|
|
423
|
+
tx: DrizzleClient,
|
|
424
|
+
hop: NestedPathHop,
|
|
425
|
+
targetId: string | number
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
const through = hop.relation.through;
|
|
428
|
+
if (!through) {
|
|
429
|
+
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const junctionTable = this.registry.getTable(through.table);
|
|
433
|
+
if (!junctionTable) {
|
|
434
|
+
throw new Error(`Junction table not found: ${through.table}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
438
|
+
const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
439
|
+
|
|
440
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
441
|
+
throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
445
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
446
|
+
|
|
447
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
448
|
+
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
449
|
+
|
|
450
|
+
await tx.delete(junctionTable).where(and(
|
|
451
|
+
eq(sourceJunctionColumn, parsedParentId),
|
|
452
|
+
eq(targetJunctionColumn, parsedTargetId)
|
|
453
|
+
));
|
|
454
|
+
|
|
455
|
+
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
373
458
|
/**
|
|
374
459
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
375
460
|
*/
|
|
@@ -1348,15 +1433,19 @@ parentSourceColName };
|
|
|
1348
1433
|
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1349
1434
|
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
1350
1435
|
|
|
1351
|
-
// Create the junction table entry linking parent to the
|
|
1436
|
+
// Create the junction table entry linking parent to the target row.
|
|
1352
1437
|
const junctionData = {
|
|
1353
1438
|
[sourceJunctionColumn.name]: parentId,
|
|
1354
1439
|
[targetJunctionColumn.name]: parsedNewEntityId
|
|
1355
1440
|
};
|
|
1356
1441
|
|
|
1357
|
-
|
|
1442
|
+
// Idempotent: a link either exists or it does not, so asking for one
|
|
1443
|
+
// twice is not an error. This is what lets `PUT parent/id/child/childId`
|
|
1444
|
+
// mean "this row belongs to this parent's set" — the only way to
|
|
1445
|
+
// attach an *existing* row, which previously had none.
|
|
1446
|
+
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
1358
1447
|
|
|
1359
|
-
logger.info(`
|
|
1448
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
1360
1449
|
} catch (error) {
|
|
1361
1450
|
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
|
|
1362
1451
|
throw error;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { CollectionConfig, Relation } from "@rebasepro/types";
|
|
2
|
+
import { findRelation, resolveCollectionRelations } from "@rebasepro/common";
|
|
3
|
+
import { ApiError } from "@rebasepro/server";
|
|
4
|
+
|
|
5
|
+
import { getCollectionByPath } from "./collection-helpers";
|
|
6
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The last hop of a nested collection path, e.g. `authors/1/posts`.
|
|
10
|
+
*
|
|
11
|
+
* The walk that produces this was written out four separate times — in
|
|
12
|
+
* `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
|
|
13
|
+
* `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
|
|
14
|
+
* drifted, so the read path and the write path did not agree on which relation
|
|
15
|
+
* a path named. It lives here once now.
|
|
16
|
+
*/
|
|
17
|
+
export interface NestedPathHop {
|
|
18
|
+
/** The collection the final relation is declared on (e.g. `authors`). */
|
|
19
|
+
parentCollection: CollectionConfig;
|
|
20
|
+
/** The parent's id as it appeared in the path, unparsed. */
|
|
21
|
+
parentId: string;
|
|
22
|
+
/** The path segment that named the relation (e.g. `posts`). */
|
|
23
|
+
relationKey: string;
|
|
24
|
+
relation: Relation;
|
|
25
|
+
/** `relation.target()`, resolved once. */
|
|
26
|
+
targetCollection: CollectionConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
31
|
+
* collection.
|
|
32
|
+
*
|
|
33
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
34
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
35
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
36
|
+
* a root collection whose slug happens to contain a slash.
|
|
37
|
+
*/
|
|
38
|
+
export function isNestedPath(path: string): boolean {
|
|
39
|
+
return path.includes("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function splitPathSegments(path: string): string[] {
|
|
43
|
+
return path.split("/").filter(s => s && s !== "undefined");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
50
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
51
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
52
|
+
* individual walks used to raise, with the available names attached.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveNestedPath(
|
|
55
|
+
path: string,
|
|
56
|
+
registry: PostgresCollectionRegistry
|
|
57
|
+
): NestedPathHop | undefined {
|
|
58
|
+
if (!isNestedPath(path)) return undefined;
|
|
59
|
+
|
|
60
|
+
const segments = splitPathSegments(path);
|
|
61
|
+
if (segments.length < 3 || segments.length % 2 === 0) {
|
|
62
|
+
throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let parentCollection = getCollectionByPath(segments[0], registry);
|
|
66
|
+
let parentId = segments[1];
|
|
67
|
+
|
|
68
|
+
for (let i = 2; i < segments.length; i += 2) {
|
|
69
|
+
const relationKey = segments[i];
|
|
70
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
71
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
72
|
+
|
|
73
|
+
if (!relation) {
|
|
74
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const targetCollection = relation.target();
|
|
81
|
+
|
|
82
|
+
if (i === segments.length - 1) {
|
|
83
|
+
return {
|
|
84
|
+
parentCollection,
|
|
85
|
+
parentId,
|
|
86
|
+
relationKey,
|
|
87
|
+
relation,
|
|
88
|
+
targetCollection
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parentCollection = targetCollection;
|
|
93
|
+
parentId = segments[i + 1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unreachable: the loop returns on the final segment, and the odd-length
|
|
97
|
+
// check above guarantees there is one.
|
|
98
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
103
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
104
|
+
* such a path addresses the *link*, not the row.
|
|
105
|
+
*/
|
|
106
|
+
export function isJunctionBackedRelation(relation: Relation): boolean {
|
|
107
|
+
return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
112
|
+
*
|
|
113
|
+
* There is no column on the target row that records a to-one parent — the
|
|
114
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
115
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
116
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
117
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
118
|
+
* one.
|
|
119
|
+
*/
|
|
120
|
+
export function assertWritableThrough(hop: NestedPathHop, path: string): void {
|
|
121
|
+
if (hop.relation.cardinality !== "many") {
|
|
122
|
+
throw ApiError.badRequest(
|
|
123
|
+
`"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: ` +
|
|
124
|
+
`the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on ` +
|
|
125
|
+
`'${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set ` +
|
|
126
|
+
`'${hop.relationKey}' on the parent instead.`,
|
|
127
|
+
"RELATION_NOT_WRITABLE"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|