@rebasepro/server-postgres 0.0.1-canary.4829d6e
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 +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +640 -0
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { CollectionConfig, FilterValues, Relation, LogicalCondition } from "@rebasepro/types";
|
|
4
|
+
import type { VectorSearchParams } from "@rebasepro/types";
|
|
5
|
+
import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
6
|
+
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
|
+
import {
|
|
8
|
+
getCollectionByPath,
|
|
9
|
+
getTableForCollection,
|
|
10
|
+
getPrimaryKeys,
|
|
11
|
+
parseIdValues,
|
|
12
|
+
buildCompositeId
|
|
13
|
+
} from "./collection-helpers";
|
|
14
|
+
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
15
|
+
import { RelationService } from "./RelationService";
|
|
16
|
+
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
17
|
+
import { DrizzleClient } from "../interfaces";
|
|
18
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
19
|
+
import { logger } from "@rebasepro/server";
|
|
20
|
+
|
|
21
|
+
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
22
|
+
type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Service for handling all row read operations.
|
|
26
|
+
* Handles fetching, searching, counting, and filtering rows.
|
|
27
|
+
*/
|
|
28
|
+
export class FetchService {
|
|
29
|
+
private relationService: RelationService;
|
|
30
|
+
|
|
31
|
+
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
|
|
32
|
+
this.relationService = new RelationService(db, registry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the relational query builder for a given table name.
|
|
37
|
+
* Safely narrows the DrizzleClient union type to access db.query[tableName].
|
|
38
|
+
*/
|
|
39
|
+
private getQueryBuilder(tableName: string): RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined {
|
|
40
|
+
const query = (this.db as { query?: DbQueryAccessor }).query;
|
|
41
|
+
return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build filter conditions from FilterValues
|
|
46
|
+
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
47
|
+
*/
|
|
48
|
+
buildFilterConditions<M extends Record<string, unknown>>(
|
|
49
|
+
filter: FilterValues<Extract<keyof M, string>>,
|
|
50
|
+
table: PgTable<any>,
|
|
51
|
+
collectionPath: string
|
|
52
|
+
): SQL[] {
|
|
53
|
+
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================
|
|
57
|
+
// DRIZZLE QUERY HELPERS
|
|
58
|
+
// =============================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the correct Drizzle column for sorting.
|
|
62
|
+
* Automatically maps owning relation property keys to their underlying foreign key column.
|
|
63
|
+
*/
|
|
64
|
+
private resolveOrderByField(
|
|
65
|
+
table: PgTable<any>,
|
|
66
|
+
orderBy: string,
|
|
67
|
+
collection?: CollectionConfig
|
|
68
|
+
): AnyPgColumn | undefined {
|
|
69
|
+
let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
|
|
70
|
+
if (!orderByField && collection) {
|
|
71
|
+
const property = collection.properties[orderBy];
|
|
72
|
+
if (property && property.type === "relation" && "relation" in property && property.relation?.direction === "owning") {
|
|
73
|
+
orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return orderByField;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `with` config for Drizzle's relational query API.
|
|
81
|
+
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
82
|
+
*
|
|
83
|
+
* When `include` is provided, only those relations are loaded.
|
|
84
|
+
* When `include` is absent, ALL relations are loaded (CMS path).
|
|
85
|
+
*
|
|
86
|
+
* Automatically detects many-to-many junction tables and nests
|
|
87
|
+
* the target relation so actual row data is returned.
|
|
88
|
+
*/
|
|
89
|
+
private buildWithConfig(
|
|
90
|
+
collection: CollectionConfig,
|
|
91
|
+
include?: string[]
|
|
92
|
+
): Record<string, boolean | { with: Record<string, boolean> }> {
|
|
93
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
94
|
+
const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
|
|
95
|
+
|
|
96
|
+
const shouldInclude = (key: string) =>
|
|
97
|
+
!include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
98
|
+
|
|
99
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
100
|
+
if (!shouldInclude(key)) continue;
|
|
101
|
+
|
|
102
|
+
const drizzleRelName = relation.relationName || key;
|
|
103
|
+
|
|
104
|
+
// Skip relations that use joinPath as they are not mapped in Drizzle schemas
|
|
105
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Detect many-to-many junction tables:
|
|
110
|
+
// If the relation goes through a junction table (relation.through exists or
|
|
111
|
+
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
112
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
113
|
+
// The Drizzle relation points to the junction table.
|
|
114
|
+
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
115
|
+
// The target FK name is the relation on the junction table that points to the actual target.
|
|
116
|
+
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
117
|
+
if (targetFkName) {
|
|
118
|
+
withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
119
|
+
} else {
|
|
120
|
+
withConfig[drizzleRelName] = true;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
withConfig[drizzleRelName] = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return withConfig;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
132
|
+
*/
|
|
133
|
+
private isJunctionRelation(relation: Relation, _collection: CollectionConfig): boolean {
|
|
134
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
135
|
+
if (relation.through) return true;
|
|
136
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
137
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
143
|
+
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
144
|
+
*/
|
|
145
|
+
private getJunctionTargetRelationName(relation: Relation, _collection: CollectionConfig): string | null {
|
|
146
|
+
if (relation.through) {
|
|
147
|
+
// The junction relation on the junction table pointing to the target
|
|
148
|
+
// uses the targetColumn name as the Drizzle relation name
|
|
149
|
+
return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convert a db.query result row (with nested relation objects) to a flat row.
|
|
156
|
+
* Handles:
|
|
157
|
+
* - Placing `id` at the top level as a string
|
|
158
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
159
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
160
|
+
* - Flattening junction-table many-to-many results
|
|
161
|
+
*/
|
|
162
|
+
private drizzleResultToRow<M extends Record<string, unknown>>(
|
|
163
|
+
row: Record<string, unknown>,
|
|
164
|
+
collection: CollectionConfig,
|
|
165
|
+
_collectionPath: string,
|
|
166
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
167
|
+
_databaseId?: string,
|
|
168
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
169
|
+
): Record<string, unknown> {
|
|
170
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
171
|
+
|
|
172
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
173
|
+
const normalizedValues = normalizeDbValues(row as M, collection) as Record<string, unknown>;
|
|
174
|
+
|
|
175
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
176
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
177
|
+
const drizzleRelName = relation.relationName || key;
|
|
178
|
+
const relData = row[drizzleRelName];
|
|
179
|
+
|
|
180
|
+
if (relData === undefined || relData === null) continue;
|
|
181
|
+
|
|
182
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
183
|
+
const targetCollection = relation.target();
|
|
184
|
+
const targetPath = targetCollection.slug;
|
|
185
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
186
|
+
const targetIdField = targetPks[0].fieldName;
|
|
187
|
+
|
|
188
|
+
normalizedValues[key] = relData.map((item: Record<string, unknown>) => {
|
|
189
|
+
// Handle junction table flattening:
|
|
190
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
191
|
+
let targetRow = item;
|
|
192
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
193
|
+
// Find the nested target object in the junction row
|
|
194
|
+
const nestedKey = Object.keys(item).find(
|
|
195
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
196
|
+
);
|
|
197
|
+
if (nestedKey) {
|
|
198
|
+
targetRow = item[nestedKey] as Record<string, unknown>;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
|
|
203
|
+
const targetValues = normalizeDbValues(targetRow, targetCollection);
|
|
204
|
+
|
|
205
|
+
return createRelationRefWithData(relId, targetPath, {
|
|
206
|
+
id: relId,
|
|
207
|
+
path: targetPath,
|
|
208
|
+
values: targetValues
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
212
|
+
const targetCollection = relation.target();
|
|
213
|
+
const targetPath = targetCollection.slug;
|
|
214
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
215
|
+
const targetIdField = targetPks[0].fieldName;
|
|
216
|
+
const relObj = relData as Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
219
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
220
|
+
|
|
221
|
+
normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
|
|
222
|
+
id: relId,
|
|
223
|
+
path: targetPath,
|
|
224
|
+
values: targetValues
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
...normalizedValues,
|
|
231
|
+
// Spread the canonical id last so it wins over a raw `id` column
|
|
232
|
+
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Post-fetch joinPath relations for a single flat row.
|
|
238
|
+
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
239
|
+
* so they must be loaded separately after the primary query.
|
|
240
|
+
*/
|
|
241
|
+
private async resolveJoinPathRelations<M extends Record<string, unknown>>(
|
|
242
|
+
row: Record<string, unknown>,
|
|
243
|
+
collection: CollectionConfig,
|
|
244
|
+
collectionPath: string,
|
|
245
|
+
parsedId: string | number,
|
|
246
|
+
_databaseId?: string
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
249
|
+
|
|
250
|
+
const promises = Object.entries(resolvedRelations)
|
|
251
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0)
|
|
252
|
+
.map(async ([key, relation]) => {
|
|
253
|
+
try {
|
|
254
|
+
const relatedRows = await this.relationService.fetchRelatedEntities(
|
|
255
|
+
collectionPath,
|
|
256
|
+
parsedId,
|
|
257
|
+
key,
|
|
258
|
+
{ limit: relation.cardinality === "one" ? 1 : undefined }
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (relation.cardinality === "one" && relatedRows.length > 0) {
|
|
262
|
+
const e = relatedRows[0];
|
|
263
|
+
row[key] = createRelationRefWithData(e.id, e.path, e);
|
|
264
|
+
} else if (relation.cardinality === "many") {
|
|
265
|
+
row[key] = relatedRows.map(e =>
|
|
266
|
+
createRelationRefWithData(e.id, e.path, e)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await Promise.all(promises);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Post-fetch joinPath relations for a batch of flat rows.
|
|
279
|
+
* Uses batch fetching to avoid N+1 queries for list views.
|
|
280
|
+
*/
|
|
281
|
+
private async resolveJoinPathRelationsBatch<M extends Record<string, unknown>>(
|
|
282
|
+
rows: Record<string, unknown>[],
|
|
283
|
+
collection: CollectionConfig,
|
|
284
|
+
collectionPath: string,
|
|
285
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
286
|
+
_databaseId?: string
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
if (rows.length === 0) return;
|
|
289
|
+
|
|
290
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
291
|
+
|
|
292
|
+
const joinPathRelations = Object.entries(resolvedRelations)
|
|
293
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
|
|
294
|
+
|
|
295
|
+
if (joinPathRelations.length === 0) return;
|
|
296
|
+
|
|
297
|
+
for (const [key, relation] of joinPathRelations) {
|
|
298
|
+
try {
|
|
299
|
+
const rowIds = rows.map(r => {
|
|
300
|
+
const parsed = parseIdValues(String(r.id), [idInfo]);
|
|
301
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
305
|
+
collectionPath,
|
|
306
|
+
rowIds,
|
|
307
|
+
key,
|
|
308
|
+
relation
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
const parsed = parseIdValues(String(row.id), [idInfo]);
|
|
313
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
314
|
+
const relatedRow = resultMap.get(String(id));
|
|
315
|
+
|
|
316
|
+
if (relatedRow) {
|
|
317
|
+
if (relation.cardinality === "one") {
|
|
318
|
+
row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
330
|
+
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
331
|
+
*/
|
|
332
|
+
private async resolveJoinPathRelationsBatchRest(
|
|
333
|
+
rows: Record<string, unknown>[],
|
|
334
|
+
collection: CollectionConfig,
|
|
335
|
+
collectionPath: string,
|
|
336
|
+
idInfoArray: { fieldName: string; type: "string" | "number" }[],
|
|
337
|
+
include?: string[]
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
if (rows.length === 0) return;
|
|
340
|
+
|
|
341
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
342
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
343
|
+
const shouldInclude = (key: string) =>
|
|
344
|
+
!include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
345
|
+
|
|
346
|
+
const joinPathRelations = Object.entries(resolvedRelations)
|
|
347
|
+
.filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
|
|
348
|
+
|
|
349
|
+
if (joinPathRelations.length === 0) return;
|
|
350
|
+
|
|
351
|
+
const idInfo = idInfoArray[0];
|
|
352
|
+
|
|
353
|
+
for (const [key, relation] of joinPathRelations) {
|
|
354
|
+
try {
|
|
355
|
+
// Determine the parent IDs based on the parsed string ID from the REST row
|
|
356
|
+
const rowIds = rows.map(r => {
|
|
357
|
+
const parsed = parseIdValues(String(r.id), idInfoArray);
|
|
358
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (relation.cardinality === "one") {
|
|
362
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
363
|
+
collectionPath,
|
|
364
|
+
rowIds,
|
|
365
|
+
key,
|
|
366
|
+
relation
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
for (const row of rows) {
|
|
370
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
371
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
372
|
+
const relatedRow = resultMap.get(String(id));
|
|
373
|
+
|
|
374
|
+
if (relatedRow) {
|
|
375
|
+
row[key] = {
|
|
376
|
+
...relatedRow.values,
|
|
377
|
+
id: relatedRow.id
|
|
378
|
+
};
|
|
379
|
+
} else {
|
|
380
|
+
row[key] = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} else if (relation.cardinality === "many") {
|
|
384
|
+
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
385
|
+
collectionPath,
|
|
386
|
+
rowIds,
|
|
387
|
+
key,
|
|
388
|
+
relation
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
for (const row of rows) {
|
|
392
|
+
const parsed = parseIdValues(String(row.id), idInfoArray);
|
|
393
|
+
const id = parsed[idInfo.fieldName] as string | number;
|
|
394
|
+
const relatedList = resultMap.get(String(id)) || [];
|
|
395
|
+
row[key] = relatedList.map(e => ({
|
|
396
|
+
...e.values,
|
|
397
|
+
id: e.id
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch (e) {
|
|
402
|
+
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
409
|
+
*/
|
|
410
|
+
private drizzleResultToRestRow(
|
|
411
|
+
row: Record<string, unknown>,
|
|
412
|
+
collection: CollectionConfig,
|
|
413
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
414
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
415
|
+
): Record<string, unknown> {
|
|
416
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
417
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
418
|
+
|
|
419
|
+
for (const [k, v] of Object.entries(row)) {
|
|
420
|
+
if (k === idInfo.fieldName) continue;
|
|
421
|
+
|
|
422
|
+
const relation = findRelation(resolvedRelations, k);
|
|
423
|
+
if (Array.isArray(v) && relation) {
|
|
424
|
+
// Many relation — flatten each nested row, handling junction tables
|
|
425
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
426
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
427
|
+
const nestedKey = Object.keys(item).find(
|
|
428
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
429
|
+
);
|
|
430
|
+
if (nestedKey) {
|
|
431
|
+
const nested = item[nestedKey] as Record<string, unknown>;
|
|
432
|
+
return { ...nested,
|
|
433
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { ...item,
|
|
437
|
+
id: String(item.id ?? item[Object.keys(item)[0]]) };
|
|
438
|
+
});
|
|
439
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
440
|
+
// One-to-one relation — inline the object
|
|
441
|
+
const relObj = v as Record<string, unknown>;
|
|
442
|
+
flat[k] = { ...relObj,
|
|
443
|
+
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
444
|
+
} else {
|
|
445
|
+
flat[k] = v;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return flat;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Build db.query-compatible options from standard fetch options.
|
|
453
|
+
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
454
|
+
*/
|
|
455
|
+
private buildDrizzleQueryOptions<M extends Record<string, unknown>>(
|
|
456
|
+
table: PgTable<any>,
|
|
457
|
+
idField: AnyPgColumn,
|
|
458
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
459
|
+
options: {
|
|
460
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
461
|
+
orderBy?: string;
|
|
462
|
+
order?: "desc" | "asc";
|
|
463
|
+
limit?: number;
|
|
464
|
+
offset?: number;
|
|
465
|
+
startAfter?: Record<string, unknown>;
|
|
466
|
+
searchString?: string;
|
|
467
|
+
logical?: LogicalCondition;
|
|
468
|
+
},
|
|
469
|
+
collectionPath: string,
|
|
470
|
+
withConfig?: Record<string, unknown>
|
|
471
|
+
): Record<string, unknown> {
|
|
472
|
+
const queryOpts: Record<string, unknown> = {};
|
|
473
|
+
|
|
474
|
+
if (withConfig) queryOpts.with = withConfig;
|
|
475
|
+
|
|
476
|
+
// Build where conditions
|
|
477
|
+
const allConditions: SQL[] = [];
|
|
478
|
+
|
|
479
|
+
if (options.searchString) {
|
|
480
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
481
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
482
|
+
options.searchString, collection.properties, table
|
|
483
|
+
);
|
|
484
|
+
if (searchConditions.length === 0) {
|
|
485
|
+
// Return options that will produce empty results
|
|
486
|
+
queryOpts.where = and(eq(idField, -99999999)); // impossible condition
|
|
487
|
+
return queryOpts;
|
|
488
|
+
}
|
|
489
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (options.filter) {
|
|
493
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
494
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (options.logical) {
|
|
498
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
499
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Cursor-based pagination (startAfter)
|
|
503
|
+
if (options.startAfter) {
|
|
504
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
505
|
+
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (allConditions.length > 0) {
|
|
509
|
+
queryOpts.where = and(...allConditions);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// OrderBy
|
|
513
|
+
const orderExpressions: unknown[] = [];
|
|
514
|
+
if (options.orderBy) {
|
|
515
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
516
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
517
|
+
if (orderByField) {
|
|
518
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
orderExpressions.push(desc(idField));
|
|
522
|
+
if (orderExpressions.length > 0) {
|
|
523
|
+
queryOpts.orderBy = orderExpressions;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Limit
|
|
527
|
+
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
528
|
+
if (limitValue) queryOpts.limit = limitValue;
|
|
529
|
+
|
|
530
|
+
// Offset (numeric pagination)
|
|
531
|
+
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
532
|
+
|
|
533
|
+
return queryOpts;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Extract cursor pagination conditions from startAfter options.
|
|
538
|
+
*/
|
|
539
|
+
private buildCursorConditions(
|
|
540
|
+
table: PgTable<any>,
|
|
541
|
+
idField: AnyPgColumn,
|
|
542
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
543
|
+
options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
|
|
544
|
+
collectionPath?: string
|
|
545
|
+
): SQL[] {
|
|
546
|
+
if (!options.startAfter) return [];
|
|
547
|
+
const cursor = options.startAfter;
|
|
548
|
+
|
|
549
|
+
if (options.orderBy) {
|
|
550
|
+
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
551
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
552
|
+
if (orderByField) {
|
|
553
|
+
const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
|
|
554
|
+
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
555
|
+
|
|
556
|
+
if (startAfterOrderValue !== undefined && startAfterId !== undefined) {
|
|
557
|
+
if (options.order === "asc") {
|
|
558
|
+
return [or(
|
|
559
|
+
gt(orderByField, startAfterOrderValue),
|
|
560
|
+
and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId))
|
|
561
|
+
)!];
|
|
562
|
+
} else {
|
|
563
|
+
return [or(
|
|
564
|
+
lt(orderByField, startAfterOrderValue),
|
|
565
|
+
and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId))
|
|
566
|
+
)!];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
572
|
+
if (startAfterId !== undefined && startAfterId !== null) {
|
|
573
|
+
const idInfoArray = [idInfo] as Array<{ fieldName: string; type: "string" | "number" }>;
|
|
574
|
+
const parsedStartAfterIdObj = parseIdValues(startAfterId as string | number, idInfoArray);
|
|
575
|
+
return [lt(idField, parsedStartAfterIdObj[idInfo.fieldName])];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Fetch a single row by ID
|
|
584
|
+
*/
|
|
585
|
+
async fetchOne<M extends Record<string, unknown>>(
|
|
586
|
+
collectionPath: string,
|
|
587
|
+
id: string | number,
|
|
588
|
+
databaseId?: string
|
|
589
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
590
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
591
|
+
const table = getTableForCollection(collection, this.registry);
|
|
592
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
593
|
+
const idInfo = idInfoArray[0];
|
|
594
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
595
|
+
|
|
596
|
+
if (!idField) {
|
|
597
|
+
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const parsedIdObj = parseIdValues(id, idInfoArray);
|
|
601
|
+
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
602
|
+
|
|
603
|
+
// Primary path: use db.query.findFirst with relation loading
|
|
604
|
+
|
|
605
|
+
const tableName = getTableName(table);
|
|
606
|
+
|
|
607
|
+
const qb = this.getQueryBuilder(tableName);
|
|
608
|
+
if (qb) {
|
|
609
|
+
try {
|
|
610
|
+
const withConfig = this.buildWithConfig(collection);
|
|
611
|
+
|
|
612
|
+
const row = await qb.findFirst({
|
|
613
|
+
where: eq(idField, parsedId),
|
|
614
|
+
with: withConfig
|
|
615
|
+
} as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
616
|
+
|
|
617
|
+
if (!row) return undefined;
|
|
618
|
+
|
|
619
|
+
const flatRow = this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
620
|
+
|
|
621
|
+
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
622
|
+
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
623
|
+
|
|
624
|
+
return flatRow;
|
|
625
|
+
} catch (e) {
|
|
626
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
627
|
+
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
628
|
+
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
629
|
+
}
|
|
630
|
+
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Fallback: db.select + N+1 relation loading
|
|
635
|
+
const result = await this.db
|
|
636
|
+
.select()
|
|
637
|
+
.from(table)
|
|
638
|
+
.where(eq(idField, parsedId))
|
|
639
|
+
.limit(1);
|
|
640
|
+
|
|
641
|
+
if (result.length === 0) return undefined;
|
|
642
|
+
|
|
643
|
+
const raw = result[0] as M;
|
|
644
|
+
const values = await parseDataFromServer(raw, collection, this.db, this.registry) as Record<string, unknown>;
|
|
645
|
+
|
|
646
|
+
// Load relations based on cardinality (N+1 — only used in fallback)
|
|
647
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
648
|
+
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
649
|
+
|
|
650
|
+
const relationPromises = Object.entries(resolvedRelations)
|
|
651
|
+
.filter(([key]) => propertyKeys.has(key))
|
|
652
|
+
.map(async ([key, relation]) => {
|
|
653
|
+
if (relation.cardinality === "many") {
|
|
654
|
+
const relatedRows = await this.relationService.fetchRelatedEntities(
|
|
655
|
+
collectionPath,
|
|
656
|
+
parsedId,
|
|
657
|
+
key,
|
|
658
|
+
{}
|
|
659
|
+
);
|
|
660
|
+
values[key] = relatedRows.map(e =>
|
|
661
|
+
createRelationRef(e.id, e.path)
|
|
662
|
+
);
|
|
663
|
+
} else if (relation.cardinality === "one") {
|
|
664
|
+
if (values[key] == null) {
|
|
665
|
+
try {
|
|
666
|
+
const relatedRows = await this.relationService.fetchRelatedEntities(
|
|
667
|
+
collectionPath,
|
|
668
|
+
parsedId,
|
|
669
|
+
key,
|
|
670
|
+
{ limit: 1 }
|
|
671
|
+
);
|
|
672
|
+
if (relatedRows.length > 0) {
|
|
673
|
+
const e = relatedRows[0];
|
|
674
|
+
values[key] = createRelationRef(e.id, e.path);
|
|
675
|
+
}
|
|
676
|
+
} catch (e) {
|
|
677
|
+
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
await Promise.all(relationPromises);
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
...values,
|
|
687
|
+
id: id.toString()
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Unified method to fetch rows with optional search functionality
|
|
693
|
+
*/
|
|
694
|
+
async fetchRowsWithConditions<M extends Record<string, unknown>>(
|
|
695
|
+
collectionPath: string,
|
|
696
|
+
options: {
|
|
697
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
698
|
+
orderBy?: string;
|
|
699
|
+
order?: "desc" | "asc";
|
|
700
|
+
limit?: number;
|
|
701
|
+
offset?: number;
|
|
702
|
+
startAfter?: Record<string, unknown>;
|
|
703
|
+
searchString?: string;
|
|
704
|
+
databaseId?: string;
|
|
705
|
+
vectorSearch?: VectorSearchParams;
|
|
706
|
+
logical?: LogicalCondition;
|
|
707
|
+
} = {}
|
|
708
|
+
): Promise<Record<string, unknown>[]> {
|
|
709
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
710
|
+
const table = getTableForCollection(collection, this.registry);
|
|
711
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
712
|
+
const idInfo = idInfoArray[0];
|
|
713
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
714
|
+
|
|
715
|
+
if (!idField) {
|
|
716
|
+
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Primary path: use db.query.findMany with relation loading
|
|
720
|
+
// Skip when searchString is present (same reason as fetchCollectionForRest)
|
|
721
|
+
// Skip when collection has relations — lateral JOINs are catastrophically
|
|
722
|
+
// slow for large collections (7s+ for 350 rows). The db.select fallback
|
|
723
|
+
// path uses batch relation resolution which is 50x faster.
|
|
724
|
+
|
|
725
|
+
const tableName = getTableName(table);
|
|
726
|
+
|
|
727
|
+
const qb = this.getQueryBuilder(tableName);
|
|
728
|
+
const withConfig = this.buildWithConfig(collection);
|
|
729
|
+
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
730
|
+
|
|
731
|
+
// Skip db.query path when vectorSearch is present — it doesn't support
|
|
732
|
+
// custom SELECT expressions needed for the _distance column.
|
|
733
|
+
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
|
|
734
|
+
try {
|
|
735
|
+
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
736
|
+
table, idField, idInfo, options, collectionPath, undefined
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
741
|
+
|
|
742
|
+
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
743
|
+
this.drizzleResultToRow<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
return rows;
|
|
747
|
+
} catch (e) {
|
|
748
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
749
|
+
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
750
|
+
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
751
|
+
}
|
|
752
|
+
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Fallback: db.select + processRowResults (N+1 for relations)
|
|
757
|
+
// When vectorSearch is present, add _distance to the SELECT.
|
|
758
|
+
let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
|
|
759
|
+
if (options.vectorSearch) {
|
|
760
|
+
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let query = vectorMeta
|
|
764
|
+
? this.db.select({ table_row: table,
|
|
765
|
+
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
766
|
+
: this.db.select().from(table).$dynamic();
|
|
767
|
+
const allConditions: SQL[] = [];
|
|
768
|
+
|
|
769
|
+
if (options.searchString) {
|
|
770
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
771
|
+
options.searchString, collection.properties, table
|
|
772
|
+
);
|
|
773
|
+
if (searchConditions.length === 0) return [];
|
|
774
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (options.filter) {
|
|
778
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
779
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (options.logical) {
|
|
783
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
784
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Vector distance threshold filter
|
|
788
|
+
if (vectorMeta?.filter) {
|
|
789
|
+
allConditions.push(vectorMeta.filter);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (allConditions.length > 0) {
|
|
793
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
794
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const orderExpressions = [];
|
|
798
|
+
// Vector search overrides ORDER BY with distance (ascending = closest first)
|
|
799
|
+
if (vectorMeta) {
|
|
800
|
+
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
801
|
+
} else if (options.orderBy) {
|
|
802
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
803
|
+
if (orderByField) {
|
|
804
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
orderExpressions.push(desc(idField));
|
|
808
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
809
|
+
|
|
810
|
+
if (options.startAfter) {
|
|
811
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
812
|
+
if (cursorConditions.length > 0) {
|
|
813
|
+
allConditions.push(...cursorConditions);
|
|
814
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
815
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const limitValue = options.vectorSearch
|
|
820
|
+
? (options.limit || 10)
|
|
821
|
+
: options.searchString ? (options.limit || 50) : options.limit;
|
|
822
|
+
if (limitValue) query = query.limit(limitValue);
|
|
823
|
+
|
|
824
|
+
// Offset (numeric pagination)
|
|
825
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
826
|
+
|
|
827
|
+
const rawResults = await query;
|
|
828
|
+
|
|
829
|
+
// When vector search is active, unwrap the nested select shape and
|
|
830
|
+
// attach _distance to each row's values.
|
|
831
|
+
const results = vectorMeta
|
|
832
|
+
? (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
|
|
833
|
+
...r.table_row,
|
|
834
|
+
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
835
|
+
}))
|
|
836
|
+
: rawResults as Record<string, unknown>[];
|
|
837
|
+
|
|
838
|
+
return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Fallback path used when db.query is unavailable.
|
|
843
|
+
* The primary path uses drizzleResultToRow which handles relation
|
|
844
|
+
* mapping without N+1 queries.
|
|
845
|
+
*
|
|
846
|
+
* Process raw database results into flat rows with relations.
|
|
847
|
+
*/
|
|
848
|
+
private async processRowResults<M extends Record<string, unknown>>(
|
|
849
|
+
results: Record<string, unknown>[],
|
|
850
|
+
collection: CollectionConfig,
|
|
851
|
+
collectionPath: string,
|
|
852
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
853
|
+
_databaseId?: string,
|
|
854
|
+
skipRelations = false,
|
|
855
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
856
|
+
): Promise<Record<string, unknown>[]> {
|
|
857
|
+
if (results.length === 0) return [];
|
|
858
|
+
|
|
859
|
+
// First pass: parse all rows WITHOUT per-row relation queries.
|
|
860
|
+
// We deliberately omit db/registry so parseDataFromServer only does type
|
|
861
|
+
// coercion (dates, numbers, FK→relation stubs for owning relations) and
|
|
862
|
+
// does NOT issue individual SQL queries for inverse relations. The second
|
|
863
|
+
// pass below batch-loads all inverse/many relations in O(1) queries per
|
|
864
|
+
// relation type, avoiding the N+1 that plagued the old path.
|
|
865
|
+
const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
|
|
866
|
+
const values = await parseDataFromServer(rawRow as M, collection) as Record<string, unknown>;
|
|
867
|
+
const id = (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(rawRow as Record<string, unknown>, idInfoArray!) : String(rawRow[idInfo.fieldName]);
|
|
868
|
+
return {
|
|
869
|
+
rawRow,
|
|
870
|
+
values,
|
|
871
|
+
id
|
|
872
|
+
};
|
|
873
|
+
}));
|
|
874
|
+
|
|
875
|
+
if (!skipRelations) {
|
|
876
|
+
// Second pass: batch load missing one-to-one relations
|
|
877
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
878
|
+
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
879
|
+
|
|
880
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
881
|
+
if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
|
|
882
|
+
|
|
883
|
+
const rowsMissingRelation = parsedRows.filter(item => {
|
|
884
|
+
const val = item.values[key];
|
|
885
|
+
if (val == null) return true;
|
|
886
|
+
if (typeof val === "object" && !Array.isArray(val) && (val as Record<string, unknown>).__type === "relation" && (val as Record<string, unknown>).data == null) return true;
|
|
887
|
+
return false;
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
if (rowsMissingRelation.length === 0) continue;
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
const rowIds = rowsMissingRelation.map(item => item.rawRow[idInfo.fieldName] as string | number);
|
|
894
|
+
const relationResults = await this.relationService.batchFetchRelatedEntities(
|
|
895
|
+
collectionPath,
|
|
896
|
+
rowIds,
|
|
897
|
+
key,
|
|
898
|
+
relation
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
rowsMissingRelation.forEach(item => {
|
|
902
|
+
const id = item.rawRow[idInfo.fieldName] as string | number;
|
|
903
|
+
const relatedRow = relationResults.get(String(id));
|
|
904
|
+
if (relatedRow) {
|
|
905
|
+
item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
} catch (e) {
|
|
909
|
+
logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Batch load many-cardinality relations (1 query per relation type
|
|
914
|
+
// instead of N queries per row)
|
|
915
|
+
const manyRelations = Object.entries(resolvedRelations)
|
|
916
|
+
.filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
|
|
917
|
+
|
|
918
|
+
for (const [key, relation] of manyRelations) {
|
|
919
|
+
try {
|
|
920
|
+
const rowIds = parsedRows.map(item => item.rawRow[idInfo.fieldName] as string | number);
|
|
921
|
+
const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
922
|
+
collectionPath,
|
|
923
|
+
rowIds,
|
|
924
|
+
key,
|
|
925
|
+
relation
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
parsedRows.forEach(item => {
|
|
929
|
+
const id = String(item.rawRow[idInfo.fieldName]);
|
|
930
|
+
const relatedRows = relationResults.get(id) || [];
|
|
931
|
+
item.values[key] = relatedRows.map(e =>
|
|
932
|
+
createRelationRefWithData(e.id, e.path, e)
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
} catch (e) {
|
|
936
|
+
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return parsedRows.map(item => ({
|
|
942
|
+
...item.values,
|
|
943
|
+
id: item.id
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Fetch a collection of rows
|
|
949
|
+
*/
|
|
950
|
+
async fetchCollection<M extends Record<string, unknown>>(
|
|
951
|
+
collectionPath: string,
|
|
952
|
+
options: {
|
|
953
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
954
|
+
orderBy?: string;
|
|
955
|
+
order?: "desc" | "asc";
|
|
956
|
+
limit?: number;
|
|
957
|
+
offset?: number;
|
|
958
|
+
startAfter?: Record<string, unknown>;
|
|
959
|
+
searchString?: string;
|
|
960
|
+
databaseId?: string;
|
|
961
|
+
vectorSearch?: VectorSearchParams;
|
|
962
|
+
} = {}
|
|
963
|
+
): Promise<Record<string, unknown>[]> {
|
|
964
|
+
// Handle multi-segment paths by resolving through relations
|
|
965
|
+
if (collectionPath.includes("/")) {
|
|
966
|
+
return this.fetchCollectionFromPath<M>(collectionPath, options);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return this.fetchRowsWithConditions<M>(collectionPath, options);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Search rows by text
|
|
974
|
+
*/
|
|
975
|
+
async searchRows<M extends Record<string, unknown>>(
|
|
976
|
+
collectionPath: string,
|
|
977
|
+
searchString: string,
|
|
978
|
+
options: {
|
|
979
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
980
|
+
orderBy?: string;
|
|
981
|
+
order?: "desc" | "asc";
|
|
982
|
+
limit?: number;
|
|
983
|
+
databaseId?: string;
|
|
984
|
+
} = {}
|
|
985
|
+
): Promise<Record<string, unknown>[]> {
|
|
986
|
+
return this.fetchRowsWithConditions<M>(collectionPath, {
|
|
987
|
+
...options,
|
|
988
|
+
searchString
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Fetch collection from multi-segment path
|
|
994
|
+
*/
|
|
995
|
+
private async fetchCollectionFromPath<M extends Record<string, unknown>>(
|
|
996
|
+
path: string,
|
|
997
|
+
options: {
|
|
998
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
999
|
+
orderBy?: string;
|
|
1000
|
+
order?: "desc" | "asc";
|
|
1001
|
+
limit?: number;
|
|
1002
|
+
startAfter?: Record<string, unknown>;
|
|
1003
|
+
searchString?: string;
|
|
1004
|
+
databaseId?: string;
|
|
1005
|
+
} = {}
|
|
1006
|
+
): Promise<Record<string, unknown>[]> {
|
|
1007
|
+
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
1008
|
+
|
|
1009
|
+
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
1010
|
+
throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const rootCollectionPath = pathSegments[0];
|
|
1014
|
+
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
1015
|
+
let currentId: string | number = pathSegments[1];
|
|
1016
|
+
|
|
1017
|
+
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
1018
|
+
const relationKey = pathSegments[i];
|
|
1019
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
1020
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
1021
|
+
|
|
1022
|
+
if (!relation) {
|
|
1023
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (i === pathSegments.length - 1) {
|
|
1027
|
+
const rows = await this.relationService.fetchRelatedEntities<M>(
|
|
1028
|
+
currentCollection.slug,
|
|
1029
|
+
currentId,
|
|
1030
|
+
relationKey,
|
|
1031
|
+
options
|
|
1032
|
+
);
|
|
1033
|
+
// Convert RelatedRow[] from RelationService to flat rows
|
|
1034
|
+
return rows.map(row => ({
|
|
1035
|
+
...row.values as Record<string, unknown>,
|
|
1036
|
+
id: row.id
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (i + 1 < pathSegments.length) {
|
|
1041
|
+
const nextEntityId = pathSegments[i + 1];
|
|
1042
|
+
currentCollection = relation.target();
|
|
1043
|
+
currentId = nextEntityId;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Count rows in a collection
|
|
1052
|
+
*/
|
|
1053
|
+
async count<M extends Record<string, unknown>>(
|
|
1054
|
+
collectionPath: string,
|
|
1055
|
+
options: {
|
|
1056
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1057
|
+
searchString?: string;
|
|
1058
|
+
databaseId?: string;
|
|
1059
|
+
} = {}
|
|
1060
|
+
): Promise<number> {
|
|
1061
|
+
if (collectionPath.includes("/")) {
|
|
1062
|
+
return this.countEntitiesFromPath<M>(collectionPath, options);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1066
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1067
|
+
|
|
1068
|
+
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
1069
|
+
const allConditions: SQL[] = [];
|
|
1070
|
+
|
|
1071
|
+
if (options.searchString) {
|
|
1072
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1073
|
+
options.searchString, collection.properties, table
|
|
1074
|
+
);
|
|
1075
|
+
if (searchConditions.length === 0) return 0;
|
|
1076
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (options.filter) {
|
|
1080
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
1081
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (allConditions.length > 0) {
|
|
1085
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
1086
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const result = await query;
|
|
1090
|
+
return Number(result[0]?.count || 0);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Count rows from multi-segment path
|
|
1095
|
+
*/
|
|
1096
|
+
private async countEntitiesFromPath<M extends Record<string, unknown>>(
|
|
1097
|
+
path: string,
|
|
1098
|
+
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
1099
|
+
): Promise<number> {
|
|
1100
|
+
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
1101
|
+
|
|
1102
|
+
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
1103
|
+
throw new Error(`Invalid relation path: ${path}`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const rootCollectionPath = pathSegments[0];
|
|
1107
|
+
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
1108
|
+
let currentId: string | number = pathSegments[1];
|
|
1109
|
+
|
|
1110
|
+
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
1111
|
+
const relationKey = pathSegments[i];
|
|
1112
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection);
|
|
1113
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
1114
|
+
|
|
1115
|
+
if (!relation) {
|
|
1116
|
+
throw new Error(`Relation '${relationKey}' not found`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (i === pathSegments.length - 1) {
|
|
1120
|
+
return this.relationService.countRelatedEntities(
|
|
1121
|
+
currentCollection.slug,
|
|
1122
|
+
currentId,
|
|
1123
|
+
relationKey,
|
|
1124
|
+
options
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (i + 1 < pathSegments.length) {
|
|
1129
|
+
currentCollection = relation.target();
|
|
1130
|
+
currentId = pathSegments[i + 1];
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
throw new Error(`Unable to count for path: ${path}`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Check if a field value is unique
|
|
1139
|
+
*/
|
|
1140
|
+
async checkUniqueField(
|
|
1141
|
+
collectionPath: string,
|
|
1142
|
+
fieldName: string,
|
|
1143
|
+
value: unknown,
|
|
1144
|
+
excludeEntityId?: string,
|
|
1145
|
+
_databaseId?: string
|
|
1146
|
+
): Promise<boolean> {
|
|
1147
|
+
if (value === undefined || value === null) return true;
|
|
1148
|
+
|
|
1149
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1150
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1151
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1152
|
+
const idInfo = idInfoArray[0];
|
|
1153
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1154
|
+
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
1155
|
+
|
|
1156
|
+
if (!field) return true;
|
|
1157
|
+
|
|
1158
|
+
const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : undefined;
|
|
1159
|
+
const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(
|
|
1160
|
+
field,
|
|
1161
|
+
value,
|
|
1162
|
+
idField,
|
|
1163
|
+
parsedExcludeId
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
const result = await this.db
|
|
1167
|
+
.select({ count: count() })
|
|
1168
|
+
.from(table)
|
|
1169
|
+
.where(and(...conditions));
|
|
1170
|
+
|
|
1171
|
+
const countResult = Number(result[0]?.count || 0);
|
|
1172
|
+
return countResult === 0;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Get the RelationService instance for external use
|
|
1177
|
+
*/
|
|
1178
|
+
getRelationService(): RelationService {
|
|
1179
|
+
return this.relationService;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// =============================================================
|
|
1183
|
+
// REST API INCLUDE-AWARE METHODS
|
|
1184
|
+
// =============================================================
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Fetch a collection of rows with optional relation includes.
|
|
1188
|
+
* When `include` is provided, only the specified relations are populated
|
|
1189
|
+
* with full row data (not just { id, path, __type }).
|
|
1190
|
+
* When `include` is absent, no relation queries are made (fast path).
|
|
1191
|
+
*
|
|
1192
|
+
* @param include - Array of relation keys to populate, or ["*"] for all
|
|
1193
|
+
*/
|
|
1194
|
+
async fetchCollectionForRest<M extends Record<string, unknown>>(
|
|
1195
|
+
collectionPath: string,
|
|
1196
|
+
options: {
|
|
1197
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1198
|
+
orderBy?: string;
|
|
1199
|
+
order?: "desc" | "asc";
|
|
1200
|
+
limit?: number;
|
|
1201
|
+
offset?: number;
|
|
1202
|
+
startAfter?: Record<string, unknown>;
|
|
1203
|
+
searchString?: string;
|
|
1204
|
+
databaseId?: string;
|
|
1205
|
+
vectorSearch?: VectorSearchParams;
|
|
1206
|
+
} = {},
|
|
1207
|
+
include?: string[]
|
|
1208
|
+
): Promise<Record<string, unknown>[]> {
|
|
1209
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1210
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1211
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1212
|
+
const idInfo = idInfoArray[0];
|
|
1213
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1214
|
+
|
|
1215
|
+
// Primary path: use db.query.findMany
|
|
1216
|
+
// NOTE: Skip db.query path when searchString is present because
|
|
1217
|
+
// Drizzle's relational query API doesn't properly apply raw SQL
|
|
1218
|
+
// ILIKE conditions — the fallback db.select path handles them correctly.
|
|
1219
|
+
|
|
1220
|
+
const tableName = getTableName(table);
|
|
1221
|
+
|
|
1222
|
+
const qb = this.getQueryBuilder(tableName);
|
|
1223
|
+
// Skip db.query path when vectorSearch is present — needs custom SELECT
|
|
1224
|
+
if (qb && !options.searchString && !options.vectorSearch) {
|
|
1225
|
+
try {
|
|
1226
|
+
const withConfig = (include && include.length > 0)
|
|
1227
|
+
? this.buildWithConfig(collection, include)
|
|
1228
|
+
: undefined;
|
|
1229
|
+
|
|
1230
|
+
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
1231
|
+
table, idField, idInfo, options, collectionPath, withConfig
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1236
|
+
|
|
1237
|
+
const restRows = (results as Record<string, unknown>[]).map(row =>
|
|
1238
|
+
this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1242
|
+
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
1243
|
+
|
|
1244
|
+
return restRows;
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1247
|
+
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1248
|
+
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
1249
|
+
}
|
|
1250
|
+
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Fallback: fetch base rows without relations
|
|
1255
|
+
const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
|
|
1256
|
+
|
|
1257
|
+
if (!include || include.length === 0) {
|
|
1258
|
+
return rows.map(row => ({
|
|
1259
|
+
...row,
|
|
1260
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
|
|
1261
|
+
}));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Fallback relation loading via batch
|
|
1265
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1266
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1267
|
+
const shouldInclude = (key: string) =>
|
|
1268
|
+
include[0] === "*" || include.includes(key);
|
|
1269
|
+
|
|
1270
|
+
const rowIds = rows.map(e => e[idInfo.fieldName] as string | number);
|
|
1271
|
+
|
|
1272
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1273
|
+
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
|
|
1274
|
+
try {
|
|
1275
|
+
const batchResults = await this.relationService.batchFetchRelatedEntities(
|
|
1276
|
+
collectionPath, rowIds, key, relation
|
|
1277
|
+
);
|
|
1278
|
+
for (const row of rows) {
|
|
1279
|
+
const eid = row[idInfo.fieldName] as string | number;
|
|
1280
|
+
const related = batchResults.get(String(eid));
|
|
1281
|
+
if (related) {
|
|
1282
|
+
(row as Record<string, unknown>)[key] = { ...related.values,
|
|
1283
|
+
id: related.id };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
} catch (e) {
|
|
1287
|
+
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1292
|
+
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
|
|
1293
|
+
try {
|
|
1294
|
+
const batchResults = await this.batchFetchManyRelatedRows(
|
|
1295
|
+
collectionPath, rowIds, key
|
|
1296
|
+
);
|
|
1297
|
+
for (const row of rows) {
|
|
1298
|
+
const eid = row[idInfo.fieldName] as string | number;
|
|
1299
|
+
const relatedList = batchResults.get(String(eid)) || [];
|
|
1300
|
+
(row as Record<string, unknown>)[key] = relatedList;
|
|
1301
|
+
}
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return rows.map(row => ({
|
|
1308
|
+
...row,
|
|
1309
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName])
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Fetch a single row with optional relation includes for REST API.
|
|
1315
|
+
*/
|
|
1316
|
+
async fetchOneForRest<M extends Record<string, unknown>>(
|
|
1317
|
+
collectionPath: string,
|
|
1318
|
+
id: string | number,
|
|
1319
|
+
include?: string[],
|
|
1320
|
+
databaseId?: string
|
|
1321
|
+
): Promise<Record<string, unknown> | null> {
|
|
1322
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1323
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1324
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1325
|
+
const idInfo = idInfoArray[0];
|
|
1326
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1327
|
+
|
|
1328
|
+
const parsedIdObj = parseIdValues(id, idInfoArray);
|
|
1329
|
+
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
1330
|
+
|
|
1331
|
+
// Primary path: use db.query.findFirst
|
|
1332
|
+
|
|
1333
|
+
const tableName = getTableName(table);
|
|
1334
|
+
|
|
1335
|
+
const qb = this.getQueryBuilder(tableName);
|
|
1336
|
+
if (qb) {
|
|
1337
|
+
try {
|
|
1338
|
+
const withConfig = (include && include.length > 0)
|
|
1339
|
+
? this.buildWithConfig(collection, include)
|
|
1340
|
+
: undefined;
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
const row = await qb.findFirst({
|
|
1344
|
+
where: eq(idField, parsedId),
|
|
1345
|
+
...(withConfig ? { with: withConfig } : {})
|
|
1346
|
+
} as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
1347
|
+
|
|
1348
|
+
if (!row) return null;
|
|
1349
|
+
|
|
1350
|
+
const restRow = this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
1351
|
+
|
|
1352
|
+
// Drizzle relational query API doesn't resolve joinPath relations, fetch manually
|
|
1353
|
+
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
1354
|
+
|
|
1355
|
+
return restRow;
|
|
1356
|
+
} catch (e) {
|
|
1357
|
+
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1358
|
+
logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1359
|
+
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
1360
|
+
}
|
|
1361
|
+
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Fallback: db.select + N+1 relation loading
|
|
1366
|
+
const result = await this.db
|
|
1367
|
+
.select()
|
|
1368
|
+
.from(table)
|
|
1369
|
+
.where(eq(idField, parsedId))
|
|
1370
|
+
.limit(1);
|
|
1371
|
+
|
|
1372
|
+
if (result.length === 0) return null;
|
|
1373
|
+
|
|
1374
|
+
const raw = result[0] as Record<string, unknown>;
|
|
1375
|
+
const flatEntity: Record<string, unknown> = { ...raw,
|
|
1376
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, unknown>, idInfoArray) : String(raw[idInfo.fieldName]) };
|
|
1377
|
+
|
|
1378
|
+
if (!include || include.length === 0) {
|
|
1379
|
+
return flatEntity;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Fallback relation population
|
|
1383
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1384
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1385
|
+
const shouldInclude = (key: string) =>
|
|
1386
|
+
include[0] === "*" || include.includes(key);
|
|
1387
|
+
|
|
1388
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1389
|
+
if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
|
|
1390
|
+
|
|
1391
|
+
try {
|
|
1392
|
+
const relatedRows = await this.relationService.fetchRelatedEntities(
|
|
1393
|
+
collectionPath, parsedId, key, {}
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
if (relation.cardinality === "one") {
|
|
1397
|
+
if (relatedRows.length > 0) {
|
|
1398
|
+
const e = relatedRows[0];
|
|
1399
|
+
flatEntity[key] = { id: e.id,
|
|
1400
|
+
...e.values };
|
|
1401
|
+
}
|
|
1402
|
+
} else {
|
|
1403
|
+
flatEntity[key] = relatedRows.map(e => ({
|
|
1404
|
+
id: e.id,
|
|
1405
|
+
...e.values
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
1408
|
+
} catch (e) {
|
|
1409
|
+
logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
return flatEntity;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Fetch raw rows without any relation processing (for REST fast path)
|
|
1418
|
+
*/
|
|
1419
|
+
private async fetchRowsWithConditionsRaw<M extends Record<string, unknown>>(
|
|
1420
|
+
collectionPath: string,
|
|
1421
|
+
options: {
|
|
1422
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1423
|
+
orderBy?: string;
|
|
1424
|
+
order?: "desc" | "asc";
|
|
1425
|
+
limit?: number;
|
|
1426
|
+
offset?: number;
|
|
1427
|
+
startAfter?: Record<string, unknown>;
|
|
1428
|
+
searchString?: string;
|
|
1429
|
+
vectorSearch?: VectorSearchParams;
|
|
1430
|
+
} = {}
|
|
1431
|
+
): Promise<Record<string, unknown>[]> {
|
|
1432
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1433
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1434
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1435
|
+
const idInfo = idInfoArray[0];
|
|
1436
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1437
|
+
|
|
1438
|
+
let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
|
|
1439
|
+
if (options.vectorSearch) {
|
|
1440
|
+
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
let query = vectorMeta
|
|
1444
|
+
? this.db.select({ table_row: table,
|
|
1445
|
+
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
1446
|
+
: this.db.select().from(table).$dynamic();
|
|
1447
|
+
const allConditions: SQL[] = [];
|
|
1448
|
+
|
|
1449
|
+
if (options.searchString) {
|
|
1450
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1451
|
+
options.searchString, collection.properties, table
|
|
1452
|
+
);
|
|
1453
|
+
if (searchConditions.length === 0) return [];
|
|
1454
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (options.filter) {
|
|
1458
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
1459
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (vectorMeta?.filter) {
|
|
1463
|
+
allConditions.push(vectorMeta.filter);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (allConditions.length > 0) {
|
|
1467
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
1468
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const orderExpressions = [];
|
|
1472
|
+
if (vectorMeta) {
|
|
1473
|
+
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
1474
|
+
} else if (options.orderBy) {
|
|
1475
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1476
|
+
if (orderByField) {
|
|
1477
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
orderExpressions.push(desc(idField));
|
|
1481
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
1482
|
+
|
|
1483
|
+
const limitValue = options.vectorSearch
|
|
1484
|
+
? (options.limit || 10)
|
|
1485
|
+
: options.searchString ? (options.limit || 50) : options.limit;
|
|
1486
|
+
if (limitValue) query = query.limit(limitValue);
|
|
1487
|
+
|
|
1488
|
+
// Offset (numeric pagination)
|
|
1489
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
1490
|
+
|
|
1491
|
+
const rawResults = await query;
|
|
1492
|
+
|
|
1493
|
+
if (vectorMeta) {
|
|
1494
|
+
return (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
|
|
1495
|
+
...r.table_row,
|
|
1496
|
+
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
1497
|
+
}));
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
return rawResults as Record<string, unknown>[];
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Check if the Drizzle instance has the relational query API available
|
|
1505
|
+
* for a given collection path.
|
|
1506
|
+
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
1507
|
+
*/
|
|
1508
|
+
private hasDrizzleQueryAPI(collectionPath: string): boolean {
|
|
1509
|
+
|
|
1510
|
+
const qb = this.getQueryBuilder("__probe__");
|
|
1511
|
+
if (!qb) {
|
|
1512
|
+
// If getQueryBuilder returns undefined even for a probe, query API is not available
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1516
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1517
|
+
const tableName = getTableName(table);
|
|
1518
|
+
return !!this.getQueryBuilder(tableName);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
1523
|
+
* for efficient JOIN-based relation loading.
|
|
1524
|
+
* Returns null if the API is not available or the query fails.
|
|
1525
|
+
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1526
|
+
*/
|
|
1527
|
+
private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
|
|
1528
|
+
collectionPath: string,
|
|
1529
|
+
collection: CollectionConfig,
|
|
1530
|
+
options: {
|
|
1531
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1532
|
+
orderBy?: string;
|
|
1533
|
+
order?: "desc" | "asc";
|
|
1534
|
+
limit?: number;
|
|
1535
|
+
},
|
|
1536
|
+
include: string[],
|
|
1537
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
1538
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1539
|
+
): Promise<Record<string, unknown>[] | null> {
|
|
1540
|
+
try {
|
|
1541
|
+
|
|
1542
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1543
|
+
const tableName = getTableName(table);
|
|
1544
|
+
const queryTarget = this.getQueryBuilder(tableName);
|
|
1545
|
+
|
|
1546
|
+
if (!queryTarget?.findMany) return null;
|
|
1547
|
+
|
|
1548
|
+
// Build the `with` config from include array
|
|
1549
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1550
|
+
const withConfig: Record<string, boolean> = {};
|
|
1551
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1552
|
+
if (include[0] === "*" || include.includes(key)) {
|
|
1553
|
+
// Use the Drizzle relation name (from the schema)
|
|
1554
|
+
const drizzleRelName = relation.relationName || key;
|
|
1555
|
+
withConfig[drizzleRelName] = true;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Build query options
|
|
1560
|
+
const queryOpts: Record<string, unknown> = { with: withConfig };
|
|
1561
|
+
if (options.limit) queryOpts.limit = options.limit;
|
|
1562
|
+
|
|
1563
|
+
// Build where clause
|
|
1564
|
+
if (options.filter) {
|
|
1565
|
+
const filterConditions = this.buildFilterConditions(
|
|
1566
|
+
options.filter, table, collectionPath
|
|
1567
|
+
);
|
|
1568
|
+
if (filterConditions.length > 0) {
|
|
1569
|
+
queryOpts.where = and(...filterConditions);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Build orderBy
|
|
1574
|
+
if (options.orderBy) {
|
|
1575
|
+
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1576
|
+
if (orderByField) {
|
|
1577
|
+
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1583
|
+
|
|
1584
|
+
// Flatten the nested Drizzle results into REST format
|
|
1585
|
+
return results.map((row: Record<string, unknown>) => {
|
|
1586
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, unknown>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
1587
|
+
for (const [k, v] of Object.entries(row)) {
|
|
1588
|
+
if (k === idInfo.fieldName) continue;
|
|
1589
|
+
if (Array.isArray(v)) {
|
|
1590
|
+
// Many relation — flatten each nested row
|
|
1591
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1592
|
+
// Junction table rows may have the target nested, flatten those
|
|
1593
|
+
const keys = Object.keys(item);
|
|
1594
|
+
// If it looks like a junction row (only FKs + nested objects), extract nested
|
|
1595
|
+
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1596
|
+
if (nestedObj && keys.length <= 3) {
|
|
1597
|
+
const nested = item[nestedObj] as Record<string, unknown>;
|
|
1598
|
+
return { ...nested,
|
|
1599
|
+
id: String(nested.id ?? nested[Object.keys(nested)[0]]) };
|
|
1600
|
+
}
|
|
1601
|
+
return { ...item,
|
|
1602
|
+
id: String(item.id ?? item[Object.keys(item)[0]]) };
|
|
1603
|
+
});
|
|
1604
|
+
} else if (typeof v === "object" && v !== null) {
|
|
1605
|
+
// One-to-one relation — inline the object
|
|
1606
|
+
const relObj = v as Record<string, unknown>;
|
|
1607
|
+
flat[k] = { ...relObj,
|
|
1608
|
+
id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]) };
|
|
1609
|
+
} else {
|
|
1610
|
+
flat[k] = v;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return flat;
|
|
1614
|
+
});
|
|
1615
|
+
} catch (e) {
|
|
1616
|
+
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Fallback path used when db.query is unavailable.
|
|
1623
|
+
* The primary path uses db.query.findMany with `with` config, which
|
|
1624
|
+
* loads all relations in a single query.
|
|
1625
|
+
*
|
|
1626
|
+
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
1627
|
+
* Groups results by parent ID to avoid N+1.
|
|
1628
|
+
*/
|
|
1629
|
+
private async batchFetchManyRelatedRows(
|
|
1630
|
+
parentCollectionPath: string,
|
|
1631
|
+
parentIds: (string | number)[],
|
|
1632
|
+
relationKey: string
|
|
1633
|
+
): Promise<Map<string, Record<string, unknown>[]>> {
|
|
1634
|
+
if (parentIds.length === 0) return new Map();
|
|
1635
|
+
|
|
1636
|
+
// Resolve the relation definition so we can use the true batch method
|
|
1637
|
+
const collection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
1638
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1639
|
+
const relation = resolvedRelations[relationKey];
|
|
1640
|
+
|
|
1641
|
+
if (!relation) {
|
|
1642
|
+
logger.warn(`[batchFetchManyRelatedRows] Relation '${relationKey}' not found, skipping`);
|
|
1643
|
+
return new Map();
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Delegate to RelationService.batchFetchRelatedEntitiesMany which
|
|
1647
|
+
// uses a single SQL query with IN(...) — O(1) instead of O(N).
|
|
1648
|
+
// RelationService returns RelatedRow shapes (id + path + values) — flatten to plain rows here.
|
|
1649
|
+
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(
|
|
1650
|
+
parentCollectionPath,
|
|
1651
|
+
parentIds,
|
|
1652
|
+
relationKey,
|
|
1653
|
+
relation
|
|
1654
|
+
);
|
|
1655
|
+
const flatMap = new Map<string, Record<string, unknown>[]>();
|
|
1656
|
+
for (const [key, rows] of entityMap) {
|
|
1657
|
+
flatMap.set(key, rows.map(e => ({ ...e.values, id: e.id })));
|
|
1658
|
+
}
|
|
1659
|
+
return flatMap;
|
|
1660
|
+
}
|
|
1661
|
+
}
|