@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
|
@@ -97,12 +97,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
97
97
|
columnDefinition = `uuid("${colName}")`;
|
|
98
98
|
} else if (stringProp.columnType === "uuid") {
|
|
99
99
|
columnDefinition = `uuid("${colName}")`;
|
|
100
|
-
} else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
101
|
-
columnDefinition = `text("${colName}")`;
|
|
102
100
|
} else if (stringProp.columnType === "char") {
|
|
103
101
|
columnDefinition = `char("${colName}")`;
|
|
104
|
-
} else {
|
|
102
|
+
} else if (stringProp.columnType === "varchar") {
|
|
105
103
|
columnDefinition = `varchar("${colName}")`;
|
|
104
|
+
} else {
|
|
105
|
+
// `text` is the default, and the only length-unbounded choice.
|
|
106
|
+
// Ask for `varchar` explicitly if you want the length constraint.
|
|
107
|
+
columnDefinition = `text("${colName}")`;
|
|
106
108
|
}
|
|
107
109
|
if (isIdProperty(propName, prop, collection)) {
|
|
108
110
|
columnDefinition += ".primaryKey()";
|
|
@@ -252,7 +254,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
252
254
|
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
253
255
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
254
256
|
const targetIdField = pkProp.name;
|
|
255
|
-
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `
|
|
257
|
+
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`);
|
|
256
258
|
|
|
257
259
|
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
258
260
|
const required = prop.validation?.required;
|
|
@@ -274,14 +276,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
274
276
|
const refProp = prop as ReferenceProperty;
|
|
275
277
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
276
278
|
if (!targetCollection) {
|
|
277
|
-
columnDefinition = `
|
|
279
|
+
columnDefinition = `text("${colName}")`;
|
|
278
280
|
break;
|
|
279
281
|
}
|
|
280
282
|
|
|
281
283
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
282
284
|
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
283
285
|
const targetIdField = pkProp.name;
|
|
284
|
-
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `
|
|
286
|
+
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`);
|
|
285
287
|
|
|
286
288
|
const required = prop.validation?.required;
|
|
287
289
|
const onDelete = required ? "cascade" : "set null";
|
|
@@ -597,8 +599,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
597
599
|
const onDelete = relation.onDelete ?? "cascade";
|
|
598
600
|
const refOptions = `{ onDelete: \"${onDelete}\" }`;
|
|
599
601
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
+
// `text`, matching the string default: a junction column must have the
|
|
603
|
+
// same type as the primary key it references.
|
|
604
|
+
const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text");
|
|
605
|
+
const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text");
|
|
602
606
|
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
603
607
|
const targetId = getPrimaryKeyName(targetCollection);
|
|
604
608
|
|
|
@@ -637,7 +641,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
637
641
|
// We should generate a basic id column if one was completely omitted.
|
|
638
642
|
const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
|
|
639
643
|
if (!hasIdColumn) {
|
|
640
|
-
columns.add(" id:
|
|
644
|
+
columns.add(" id: text(\"id\").primaryKey()");
|
|
641
645
|
}
|
|
642
646
|
|
|
643
647
|
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
@@ -4,7 +4,7 @@ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
6
6
|
|
|
7
|
-
const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
7
|
+
export const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
8
8
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") {
|
|
9
9
|
return prop.columnName;
|
|
10
10
|
}
|
|
@@ -36,7 +36,7 @@ const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
|
36
36
|
return getPrimaryKeyProp(collection).name;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
39
|
+
export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
40
40
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
41
41
|
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
42
42
|
return !hasExplicitId && propName === "id";
|
|
@@ -90,7 +90,7 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
|
|
|
90
90
|
return `${ddl};\n`;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
-
const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
93
|
+
export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
94
94
|
switch (prop.type) {
|
|
95
95
|
case "string": {
|
|
96
96
|
const stringProp = prop as StringProperty;
|
|
@@ -103,13 +103,18 @@ const getSqlColumnType = (propName: string, prop: Property, collection: Collecti
|
|
|
103
103
|
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
104
104
|
return "UUID";
|
|
105
105
|
}
|
|
106
|
-
if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
107
|
-
return "TEXT";
|
|
108
|
-
}
|
|
109
106
|
if (stringProp.columnType === "char") {
|
|
110
107
|
return "CHAR(255)";
|
|
111
108
|
}
|
|
112
|
-
|
|
109
|
+
if (stringProp.columnType === "varchar") {
|
|
110
|
+
return "VARCHAR(255)";
|
|
111
|
+
}
|
|
112
|
+
// `text` is the default. The two generators disagreed here before:
|
|
113
|
+
// this one emitted VARCHAR(255) while the drizzle path emitted a bare
|
|
114
|
+
// `varchar()`, which Postgres treats as unbounded — so the same
|
|
115
|
+
// property produced a capped column down one path and an uncapped one
|
|
116
|
+
// down the other.
|
|
117
|
+
return "TEXT";
|
|
113
118
|
}
|
|
114
119
|
case "number": {
|
|
115
120
|
const numProp = prop as NumberProperty;
|
|
@@ -173,20 +178,20 @@ const getSqlColumnType = (propName: string, prop: Property, collection: Collecti
|
|
|
173
178
|
try {
|
|
174
179
|
targetCollection = relation.target();
|
|
175
180
|
} catch {
|
|
176
|
-
return "
|
|
181
|
+
return "TEXT";
|
|
177
182
|
}
|
|
178
183
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
179
|
-
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "
|
|
184
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
|
|
180
185
|
}
|
|
181
186
|
case "reference": {
|
|
182
187
|
const refProp = prop as ReferenceProperty;
|
|
183
188
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
184
|
-
if (!targetCollection) return "
|
|
189
|
+
if (!targetCollection) return "TEXT";
|
|
185
190
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
186
|
-
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "
|
|
191
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
|
|
187
192
|
}
|
|
188
193
|
default:
|
|
189
|
-
return "
|
|
194
|
+
return "TEXT";
|
|
190
195
|
}
|
|
191
196
|
};
|
|
192
197
|
|
|
@@ -287,8 +292,10 @@ export const generatePostgresDdl = async (
|
|
|
287
292
|
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
288
293
|
const { sourceColumn, targetColumn } = relation.through;
|
|
289
294
|
|
|
290
|
-
|
|
291
|
-
|
|
295
|
+
// TEXT, matching the string default: a junction column has to have the
|
|
296
|
+
// same type as the primary key it references.
|
|
297
|
+
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "TEXT");
|
|
298
|
+
const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "TEXT");
|
|
292
299
|
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
293
300
|
const targetId = getPrimaryKeyName(targetCollection);
|
|
294
301
|
|
|
@@ -424,7 +431,7 @@ export const generatePostgresDdl = async (
|
|
|
424
431
|
// Backwards compatibility: add default id primary key if missing
|
|
425
432
|
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
426
433
|
if (!hasPk) {
|
|
427
|
-
columns.unshift(' "id"
|
|
434
|
+
columns.unshift(' "id" TEXT PRIMARY KEY');
|
|
428
435
|
}
|
|
429
436
|
|
|
430
437
|
ddl += columns.join(",\n");
|
|
@@ -51,10 +51,11 @@ export function inferPropertyFromData(
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// No currency heuristic. `admin.currency` was never a declared option and
|
|
55
|
+
// nothing reads it, so a column called `price` got a block of config that did
|
|
56
|
+
// nothing — invisible because this generator emits *source text*, which no
|
|
57
|
+
// typechecker ever saw. Reinstate it by adding `currency` to
|
|
58
|
+
// AdminNumberOptions first, and something that renders it.
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// ── JSON / JSONB Analysis ────────────────────────────────────────────
|
|
@@ -169,11 +170,10 @@ export function inferPropertyFromData(
|
|
|
169
170
|
if (isPk) extraLines.push(" isId: \"cuid\"");
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
173
|
+
// No colour heuristic. `admin.color` was never a declared option and nothing
|
|
174
|
+
// reads it — the same dead branch `currency` was. This generator emits *source
|
|
175
|
+
// text*, so no typechecker ever saw either. Reinstate by adding the option and
|
|
176
|
+
// something that renders it first.
|
|
177
177
|
|
|
178
178
|
// Text Lengths, Multiline & Markdown
|
|
179
179
|
let maxLength = 0;
|
|
@@ -212,9 +212,9 @@ export function inferPropertyFromData(
|
|
|
212
212
|
if (allAbsoluteUrls) {
|
|
213
213
|
const isImage = validValues.some(v => typeof v === "string" && v.match(/\.(jpeg|jpg|gif|png|webp|svg)/i));
|
|
214
214
|
if (isImage || isMedia) {
|
|
215
|
-
extraLines.push("
|
|
215
|
+
extraLines.push(" url: true,\n admin: {\n urlPreview: \"image\"\n }");
|
|
216
216
|
} else {
|
|
217
|
-
extraLines.push("
|
|
217
|
+
extraLines.push(" url: true");
|
|
218
218
|
}
|
|
219
219
|
} else {
|
|
220
220
|
const hasFileExtension = validValues.some(v => typeof v === "string" && v.match(/\.[a-zA-Z0-9]+$/));
|
|
@@ -225,9 +225,9 @@ export function inferPropertyFromData(
|
|
|
225
225
|
extraLines.push(` storage: {\n storagePath: "${inferredStoragePath}"\n }`);
|
|
226
226
|
} else if (isUrl) {
|
|
227
227
|
if (isMedia) {
|
|
228
|
-
extraLines.push("
|
|
228
|
+
extraLines.push(" url: true,\n admin: {\n urlPreview: \"image\"\n }");
|
|
229
229
|
} else {
|
|
230
|
-
extraLines.push("
|
|
230
|
+
extraLines.push(" url: true");
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
}
|
|
@@ -569,11 +569,11 @@ export function generateCollectionFile(
|
|
|
569
569
|
// Date auto-value heuristics
|
|
570
570
|
if (finalPropType === "date") {
|
|
571
571
|
if (colNameLower === "created_at" || colNameLower === "createdat") {
|
|
572
|
-
extra += "\n autoValue: \"on_create\",\n
|
|
572
|
+
extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
|
|
573
573
|
} else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
|
|
574
|
-
extra += "\n autoValue: \"on_update\",\n
|
|
574
|
+
extra += "\n autoValue: \"on_update\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
|
|
575
575
|
} else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
|
|
576
|
-
extra += "\n autoValue: \"on_create\",\n
|
|
576
|
+
extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true\n },";
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
|
|
@@ -604,13 +604,13 @@ export function generateCollectionFile(
|
|
|
604
604
|
if (isMedia) {
|
|
605
605
|
extra += `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
|
|
606
606
|
} else if (isUrl) {
|
|
607
|
-
extra += "\n
|
|
607
|
+
extra += "\n url: true,";
|
|
608
608
|
} else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
|
|
609
|
-
extra += "\n multiline: true,";
|
|
609
|
+
extra += "\n admin: {\n multiline: true\n },";
|
|
610
610
|
} else if (colNameLower === "content" || colNameLower === "body") {
|
|
611
611
|
extra += "\n multiline: true,\n markdown: true,";
|
|
612
612
|
} else if (col.data_type === "text") {
|
|
613
|
-
extra += "\n multiline: true,";
|
|
613
|
+
extra += "\n admin: {\n multiline: true\n },";
|
|
614
614
|
}
|
|
615
615
|
}
|
|
616
616
|
|
|
@@ -19,6 +19,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
|
|
|
19
19
|
import { DrizzleClient } from "../interfaces";
|
|
20
20
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
21
|
import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
22
|
+
import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
|
|
22
23
|
import { logger } from "@rebasepro/server";
|
|
23
24
|
|
|
24
25
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
@@ -279,7 +280,8 @@ export class FetchService {
|
|
|
279
280
|
logical?: LogicalCondition;
|
|
280
281
|
},
|
|
281
282
|
collectionPath: string,
|
|
282
|
-
withConfig?: Record<string, unknown
|
|
283
|
+
withConfig?: Record<string, unknown>,
|
|
284
|
+
scopeCondition?: SQL
|
|
283
285
|
): Record<string, unknown> {
|
|
284
286
|
const queryOpts: Record<string, unknown> = {};
|
|
285
287
|
|
|
@@ -288,6 +290,8 @@ export class FetchService {
|
|
|
288
290
|
// Build where conditions
|
|
289
291
|
const allConditions: SQL[] = [];
|
|
290
292
|
|
|
293
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
294
|
+
|
|
291
295
|
if (options.searchString) {
|
|
292
296
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
293
297
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
@@ -391,6 +395,59 @@ export class FetchService {
|
|
|
391
395
|
return [];
|
|
392
396
|
}
|
|
393
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
400
|
+
* target table, so a nested listing can run as an ordinary collection query.
|
|
401
|
+
*/
|
|
402
|
+
private buildRelationScope(hop: NestedPathHop): SQL {
|
|
403
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
404
|
+
const parentIdInfo = parentPks[0];
|
|
405
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
406
|
+
|
|
407
|
+
const parent = () => {
|
|
408
|
+
const table = getTableForCollection(hop.parentCollection, this.registry);
|
|
409
|
+
const idColumn = table[parentIdInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
410
|
+
if (!idColumn) {
|
|
411
|
+
throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
|
|
412
|
+
}
|
|
413
|
+
return { table,
|
|
414
|
+
idColumn };
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
418
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
419
|
+
const targetIdColumn = targetTable[targetPks[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
420
|
+
if (!targetIdColumn) {
|
|
421
|
+
throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return DrizzleConditionBuilder.buildRelationScopeCondition(
|
|
425
|
+
hop.relation,
|
|
426
|
+
parent,
|
|
427
|
+
parsedParentId as string | number,
|
|
428
|
+
targetTable,
|
|
429
|
+
targetIdColumn,
|
|
430
|
+
this.registry
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Whether `id` is actually reachable at `collectionPath`.
|
|
436
|
+
*
|
|
437
|
+
* Trivially true for a root path. For a nested one it is a real question:
|
|
438
|
+
* the path resolves to the target collection, and matching on the primary
|
|
439
|
+
* key alone made the parent segment decorative — `authors/1/posts/43`
|
|
440
|
+
* returned post 43 whoever wrote it, and the REST layer's delete then
|
|
441
|
+
* deleted it. A row that is not under this parent is reported as absent,
|
|
442
|
+
* which is what a caller addressing it through the parent should see.
|
|
443
|
+
*/
|
|
444
|
+
private async isAddressableUnder(collectionPath: string, id: string | number): Promise<boolean> {
|
|
445
|
+
if (!isNestedPath(collectionPath)) return true;
|
|
446
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
447
|
+
if (!hop) return true;
|
|
448
|
+
return this.relationService.isRelated(hop, id);
|
|
449
|
+
}
|
|
450
|
+
|
|
394
451
|
/**
|
|
395
452
|
* Fetch a single row by ID
|
|
396
453
|
*/
|
|
@@ -399,6 +456,8 @@ export class FetchService {
|
|
|
399
456
|
id: string | number,
|
|
400
457
|
databaseId?: string
|
|
401
458
|
): Promise<Record<string, unknown> | undefined> {
|
|
459
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return undefined;
|
|
460
|
+
|
|
402
461
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
403
462
|
const table = getTableForCollection(collection, this.registry);
|
|
404
463
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -516,8 +575,11 @@ export class FetchService {
|
|
|
516
575
|
databaseId?: string;
|
|
517
576
|
vectorSearch?: VectorSearchParams;
|
|
518
577
|
logical?: LogicalCondition;
|
|
578
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
579
|
+
relatedTo?: NestedPathHop;
|
|
519
580
|
} = {}
|
|
520
581
|
): Promise<Record<string, unknown>[]> {
|
|
582
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
|
|
521
583
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
522
584
|
const table = getTableForCollection(collection, this.registry);
|
|
523
585
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -545,7 +607,7 @@ export class FetchService {
|
|
|
545
607
|
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
|
|
546
608
|
try {
|
|
547
609
|
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
548
|
-
table, idField, idInfo, options, collectionPath, undefined
|
|
610
|
+
table, idField, idInfo, options, collectionPath, undefined, scopeCondition
|
|
549
611
|
);
|
|
550
612
|
|
|
551
613
|
|
|
@@ -578,6 +640,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
578
640
|
: this.db.select().from(table).$dynamic();
|
|
579
641
|
const allConditions: SQL[] = [];
|
|
580
642
|
|
|
643
|
+
if (scopeCondition) allConditions.push(scopeCondition);
|
|
644
|
+
|
|
581
645
|
if (options.searchString) {
|
|
582
646
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
583
647
|
options.searchString, collection.properties, table
|
|
@@ -771,9 +835,13 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
771
835
|
vectorSearch?: VectorSearchParams;
|
|
772
836
|
} = {}
|
|
773
837
|
): Promise<Record<string, unknown>[]> {
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
838
|
+
// A nested path is the target collection narrowed by a relation — the
|
|
839
|
+
// same query, one condition heavier. It used to be a separate builder
|
|
840
|
+
// that honoured `limit` and nothing else.
|
|
841
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
842
|
+
if (hop) {
|
|
843
|
+
return this.fetchRowsWithConditions<M>(hop.targetCollection.slug, { ...options,
|
|
844
|
+
relatedTo: hop });
|
|
777
845
|
}
|
|
778
846
|
|
|
779
847
|
return this.fetchRowsWithConditions<M>(collectionPath, options);
|
|
@@ -799,65 +867,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
799
867
|
});
|
|
800
868
|
}
|
|
801
869
|
|
|
802
|
-
/**
|
|
803
|
-
* Fetch collection from multi-segment path
|
|
804
|
-
*/
|
|
805
|
-
private async fetchCollectionFromPath<M extends Record<string, unknown>>(
|
|
806
|
-
path: string,
|
|
807
|
-
options: {
|
|
808
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
809
|
-
orderBy?: string;
|
|
810
|
-
order?: "desc" | "asc";
|
|
811
|
-
limit?: number;
|
|
812
|
-
startAfter?: Record<string, unknown>;
|
|
813
|
-
searchString?: string;
|
|
814
|
-
databaseId?: string;
|
|
815
|
-
} = {}
|
|
816
|
-
): Promise<Record<string, unknown>[]> {
|
|
817
|
-
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
818
|
-
|
|
819
|
-
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
820
|
-
throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
const rootCollectionPath = pathSegments[0];
|
|
824
|
-
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
825
|
-
let currentId: string | number = pathSegments[1];
|
|
826
|
-
|
|
827
|
-
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
828
|
-
const relationKey = pathSegments[i];
|
|
829
|
-
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
830
|
-
const relation = findRelation(resolvedRelations, relationKey);
|
|
831
|
-
|
|
832
|
-
if (!relation) {
|
|
833
|
-
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
if (i === pathSegments.length - 1) {
|
|
837
|
-
const rows = await this.relationService.fetchRelatedEntities<M>(
|
|
838
|
-
currentCollection.slug,
|
|
839
|
-
currentId,
|
|
840
|
-
relationKey,
|
|
841
|
-
options
|
|
842
|
-
);
|
|
843
|
-
// Flatten RelatedRow[] to rows: the target's columns, and only
|
|
844
|
-
// those. Merging `row.id` in last overwrote a real `id` column,
|
|
845
|
-
// and the address is derivable without it — a consumer resolves
|
|
846
|
-
// this path ("posts/1/comments") to the target collection and
|
|
847
|
-
// reads the key columns, which `values` carries.
|
|
848
|
-
return rows.map(row => ({ ...row.values as Record<string, unknown> }));
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (i + 1 < pathSegments.length) {
|
|
852
|
-
const nextEntityId = pathSegments[i + 1];
|
|
853
|
-
currentCollection = relation.target();
|
|
854
|
-
currentId = nextEntityId;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
throw new Error(`Unable to resolve path: ${path}`);
|
|
859
|
-
}
|
|
860
|
-
|
|
861
870
|
/**
|
|
862
871
|
* Count rows in a collection
|
|
863
872
|
*/
|
|
@@ -869,16 +878,20 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
869
878
|
databaseId?: string;
|
|
870
879
|
} = {}
|
|
871
880
|
): Promise<number> {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
881
|
+
// Same narrowing as the listing — and, unlike the count it replaces,
|
|
882
|
+
// the same `filter` and `searchString` too, so `total` describes the
|
|
883
|
+
// rows that were actually served.
|
|
884
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
885
|
+
const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
|
|
875
886
|
|
|
876
|
-
const collection = getCollectionByPath(
|
|
887
|
+
const collection = getCollectionByPath(effectivePath, this.registry);
|
|
877
888
|
const table = getTableForCollection(collection, this.registry);
|
|
878
889
|
|
|
879
890
|
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
880
891
|
const allConditions: SQL[] = [];
|
|
881
892
|
|
|
893
|
+
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
894
|
+
|
|
882
895
|
if (options.searchString) {
|
|
883
896
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
884
897
|
options.searchString, collection.properties, table
|
|
@@ -888,7 +901,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
888
901
|
}
|
|
889
902
|
|
|
890
903
|
if (options.filter) {
|
|
891
|
-
const filterConditions = this.buildFilterConditions(options.filter, table,
|
|
904
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
|
|
892
905
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
893
906
|
}
|
|
894
907
|
|
|
@@ -901,50 +914,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
901
914
|
return Number(result[0]?.count || 0);
|
|
902
915
|
}
|
|
903
916
|
|
|
904
|
-
/**
|
|
905
|
-
* Count rows from multi-segment path
|
|
906
|
-
*/
|
|
907
|
-
private async countEntitiesFromPath<M extends Record<string, unknown>>(
|
|
908
|
-
path: string,
|
|
909
|
-
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
910
|
-
): Promise<number> {
|
|
911
|
-
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
912
|
-
|
|
913
|
-
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
914
|
-
throw new Error(`Invalid relation path: ${path}`);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const rootCollectionPath = pathSegments[0];
|
|
918
|
-
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
919
|
-
let currentId: string | number = pathSegments[1];
|
|
920
|
-
|
|
921
|
-
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
922
|
-
const relationKey = pathSegments[i];
|
|
923
|
-
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
924
|
-
const relation = findRelation(resolvedRelations, relationKey);
|
|
925
|
-
|
|
926
|
-
if (!relation) {
|
|
927
|
-
throw new Error(`Relation '${relationKey}' not found`);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (i === pathSegments.length - 1) {
|
|
931
|
-
return this.relationService.countRelatedEntities(
|
|
932
|
-
currentCollection.slug,
|
|
933
|
-
currentId,
|
|
934
|
-
relationKey,
|
|
935
|
-
options
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (i + 1 < pathSegments.length) {
|
|
940
|
-
currentCollection = relation.target();
|
|
941
|
-
currentId = pathSegments[i + 1];
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
throw new Error(`Unable to count for path: ${path}`);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
917
|
/**
|
|
949
918
|
* Check if a field value is unique
|
|
950
919
|
*/
|
|
@@ -1014,9 +983,25 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1014
983
|
searchString?: string;
|
|
1015
984
|
databaseId?: string;
|
|
1016
985
|
vectorSearch?: VectorSearchParams;
|
|
986
|
+
/** Narrow to the rows reachable from a parent through a relation. */
|
|
987
|
+
relatedTo?: NestedPathHop;
|
|
1017
988
|
} = {},
|
|
1018
989
|
include?: string[]
|
|
1019
990
|
): Promise<Record<string, unknown>[]> {
|
|
991
|
+
// Resolve a nested path here rather than at the route, so `include`,
|
|
992
|
+
// `offset` and the rest reach a child listing by the same route they
|
|
993
|
+
// reach a root one.
|
|
994
|
+
if (isNestedPath(collectionPath)) {
|
|
995
|
+
const hop = resolveNestedPath(collectionPath, this.registry);
|
|
996
|
+
if (hop) {
|
|
997
|
+
return this.fetchCollectionForRest<M>(
|
|
998
|
+
hop.targetCollection.slug, { ...options,
|
|
999
|
+
relatedTo: hop }, include
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
|
|
1020
1005
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1021
1006
|
const table = getTableForCollection(collection, this.registry);
|
|
1022
1007
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -1039,7 +1024,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1039
1024
|
: undefined;
|
|
1040
1025
|
|
|
1041
1026
|
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
1042
|
-
table, idField, idInfo, options, collectionPath, withConfig
|
|
1027
|
+
table, idField, idInfo, options, collectionPath, withConfig, scopeCondition
|
|
1043
1028
|
);
|
|
1044
1029
|
|
|
1045
1030
|
|
|
@@ -1123,6 +1108,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1123
1108
|
include?: string[],
|
|
1124
1109
|
databaseId?: string
|
|
1125
1110
|
): Promise<Record<string, unknown> | null> {
|
|
1111
|
+
if (!await this.isAddressableUnder(collectionPath, id)) return null;
|
|
1112
|
+
|
|
1126
1113
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1127
1114
|
const table = getTableForCollection(collection, this.registry);
|
|
1128
1115
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
@@ -1229,6 +1216,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1229
1216
|
startAfter?: Record<string, unknown>;
|
|
1230
1217
|
searchString?: string;
|
|
1231
1218
|
vectorSearch?: VectorSearchParams;
|
|
1219
|
+
relatedTo?: NestedPathHop;
|
|
1232
1220
|
} = {}
|
|
1233
1221
|
): Promise<Record<string, unknown>[]> {
|
|
1234
1222
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
@@ -1248,6 +1236,8 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1248
1236
|
: this.db.select().from(table).$dynamic();
|
|
1249
1237
|
const allConditions: SQL[] = [];
|
|
1250
1238
|
|
|
1239
|
+
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
1240
|
+
|
|
1251
1241
|
if (options.searchString) {
|
|
1252
1242
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1253
1243
|
options.searchString, collection.properties, table
|