@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +48 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +36 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +12 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +10635 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +10643 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +112 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +188 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +7 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +36 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +767 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +195 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +92 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +24 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +102 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +186 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +117 -0
- package/dist/types/src/controllers/client.d.ts +58 -0
- package/dist/types/src/controllers/collection_registry.d.ts +44 -0
- package/dist/types/src/controllers/customization_controller.d.ts +54 -0
- package/dist/types/src/controllers/data.d.ts +141 -0
- package/dist/types/src/controllers/data_driver.d.ts +168 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/index.d.ts +17 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +51 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +173 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +101 -0
- package/dist/types/src/types/backend.d.ts +533 -0
- package/dist/types/src/types/builders.d.ts +14 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +812 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +9 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +22 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +225 -0
- package/dist/types/src/types/properties.d.ts +1091 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +228 -0
- package/dist/types/src/types/translations.d.ts +826 -0
- package/dist/types/src/types/user_management_delegate.d.ts +120 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +93 -0
- package/src/PostgresBackendDriver.ts +1024 -0
- package/src/PostgresBootstrapper.ts +232 -0
- package/src/auth/ensure-tables.ts +309 -0
- package/src/auth/services.ts +740 -0
- package/src/cli.ts +347 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +62 -0
- package/src/data-transformer.ts +569 -0
- package/src/databasePoolManager.ts +84 -0
- package/src/history/HistoryService.ts +257 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +146 -0
- package/src/schema/generate-drizzle-schema-logic.ts +618 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1447 -0
- package/src/services/EntityPersistService.ts +351 -0
- package/src/services/RelationService.ts +1012 -0
- package/src/services/entity-helpers.ts +121 -0
- package/src/services/entityService.ts +209 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1005 -0
- package/src/utils/drizzle-conditions.ts +999 -0
- package/src/websocket.ts +487 -0
- package/test/auth-services.test.ts +569 -0
- package/test/branchService.test.ts +357 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +352 -0
- package/test/entityService.relations.test.ts +912 -0
- package/test/entityService.subcollection-search.test.ts +516 -0
- package/test/entityService.test.ts +977 -0
- package/test/generate-drizzle-schema.test.ts +795 -0
- package/test/historyService.test.ts +126 -0
- package/test/postgresDataDriver.test.ts +556 -0
- package/test/realtimeService.test.ts +276 -0
- package/test/relations.test.ts +662 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +30 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,1447 @@
|
|
|
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 { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
|
|
4
|
+
import { resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
6
|
+
import {
|
|
7
|
+
getCollectionByPath,
|
|
8
|
+
getTableForCollection,
|
|
9
|
+
getPrimaryKeys,
|
|
10
|
+
parseIdValues,
|
|
11
|
+
buildCompositeId
|
|
12
|
+
} from "./entity-helpers";
|
|
13
|
+
import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
|
|
14
|
+
import { RelationService } from "./RelationService";
|
|
15
|
+
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
16
|
+
import { DrizzleClient } from "../interfaces";
|
|
17
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
18
|
+
|
|
19
|
+
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
20
|
+
type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Service for handling all entity read operations.
|
|
24
|
+
* Handles fetching, searching, counting, and filtering entities.
|
|
25
|
+
*/
|
|
26
|
+
export class EntityFetchService {
|
|
27
|
+
private relationService: RelationService;
|
|
28
|
+
|
|
29
|
+
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
|
|
30
|
+
this.relationService = new RelationService(db, registry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the relational query builder for a given table name.
|
|
35
|
+
* Safely narrows the DrizzleClient union type to access db.query[tableName].
|
|
36
|
+
*/
|
|
37
|
+
private getQueryBuilder(tableName: string): RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined {
|
|
38
|
+
const query = (this.db as { query?: DbQueryAccessor }).query;
|
|
39
|
+
return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build filter conditions from FilterValues
|
|
44
|
+
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
45
|
+
*/
|
|
46
|
+
buildFilterConditions<M extends Record<string, any>>(
|
|
47
|
+
filter: FilterValues<Extract<keyof M, string>>,
|
|
48
|
+
table: PgTable<any>,
|
|
49
|
+
collectionPath: string
|
|
50
|
+
): SQL[] {
|
|
51
|
+
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================
|
|
55
|
+
// DRIZZLE QUERY HELPERS
|
|
56
|
+
// =============================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the `with` config for Drizzle's relational query API.
|
|
60
|
+
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
61
|
+
*
|
|
62
|
+
* When `include` is provided, only those relations are loaded.
|
|
63
|
+
* When `include` is absent, ALL relations are loaded (CMS path).
|
|
64
|
+
*
|
|
65
|
+
* Automatically detects many-to-many junction tables and nests
|
|
66
|
+
* the target relation so actual entity data is returned.
|
|
67
|
+
*/
|
|
68
|
+
private buildWithConfig(
|
|
69
|
+
collection: EntityCollection,
|
|
70
|
+
include?: string[]
|
|
71
|
+
): Record<string, boolean | { with: Record<string, boolean> }> {
|
|
72
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
73
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
74
|
+
const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
|
|
75
|
+
|
|
76
|
+
const shouldInclude = (key: string) =>
|
|
77
|
+
!include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
78
|
+
|
|
79
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
80
|
+
if (!shouldInclude(key)) continue;
|
|
81
|
+
// Only include relations that map to actual properties (or include all for REST)
|
|
82
|
+
if (!include && !propertyKeys.has(key)) continue;
|
|
83
|
+
|
|
84
|
+
const drizzleRelName = relation.relationName || key;
|
|
85
|
+
|
|
86
|
+
// Skip relations that use joinPath as they are not mapped in Drizzle schemas
|
|
87
|
+
if (relation.joinPath && relation.joinPath.length > 0) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Detect many-to-many junction tables:
|
|
92
|
+
// If the relation goes through a junction table (relation.through exists or
|
|
93
|
+
// the Drizzle schema maps to a junction table), we need two-level with.
|
|
94
|
+
if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
|
|
95
|
+
// The Drizzle relation points to the junction table.
|
|
96
|
+
// We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
|
|
97
|
+
// The target FK name is the relation on the junction table that points to the actual target.
|
|
98
|
+
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
99
|
+
if (targetFkName) {
|
|
100
|
+
withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
101
|
+
} else {
|
|
102
|
+
withConfig[drizzleRelName] = true;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
withConfig[drizzleRelName] = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return withConfig;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Detect if a many-to-many relation uses a junction table in the Drizzle schema.
|
|
114
|
+
*/
|
|
115
|
+
private isJunctionRelation(relation: Relation, _collection: EntityCollection): boolean {
|
|
116
|
+
// If `through` is defined, it's explicitly a junction relation
|
|
117
|
+
if (relation.through) return true;
|
|
118
|
+
// If joinPath has an intermediate table, it's likely junction-based
|
|
119
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the Drizzle relation name on the junction table that points to the actual target entity.
|
|
125
|
+
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
126
|
+
*/
|
|
127
|
+
private getJunctionTargetRelationName(relation: Relation, _collection: EntityCollection): string | null {
|
|
128
|
+
if (relation.through) {
|
|
129
|
+
// The junction relation on the junction table pointing to the target
|
|
130
|
+
// uses the targetColumn name as the Drizzle relation name
|
|
131
|
+
return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Convert a db.query result row (with nested relation objects) to an Entity<M>.
|
|
138
|
+
* Handles:
|
|
139
|
+
* - The { id, path, values } wrapping
|
|
140
|
+
* - Type normalization (dates, numbers, NaN) via normalizeDbValues
|
|
141
|
+
* - Converting nested relation objects to { id, path, __type: "relation" } for CMS
|
|
142
|
+
* - Flattening junction-table many-to-many results
|
|
143
|
+
*/
|
|
144
|
+
private drizzleResultToEntity<M extends Record<string, any>>(
|
|
145
|
+
row: Record<string, unknown>,
|
|
146
|
+
collection: EntityCollection,
|
|
147
|
+
collectionPath: string,
|
|
148
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
149
|
+
databaseId?: string,
|
|
150
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
151
|
+
): Entity<M> {
|
|
152
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
153
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
154
|
+
|
|
155
|
+
// Normalize non-relation values (dates, numbers, etc.)
|
|
156
|
+
const normalizedValues = normalizeDbValues(row as M, collection);
|
|
157
|
+
|
|
158
|
+
// Convert nested relation objects to CMS-style { id, path, __type: "relation" }
|
|
159
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
160
|
+
if (!propertyKeys.has(key)) continue;
|
|
161
|
+
const drizzleRelName = relation.relationName || key;
|
|
162
|
+
const relData = row[drizzleRelName];
|
|
163
|
+
|
|
164
|
+
if (relData === undefined || relData === null) continue;
|
|
165
|
+
|
|
166
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
167
|
+
const targetCollection = relation.target();
|
|
168
|
+
const targetPath = targetCollection.slug;
|
|
169
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
170
|
+
const targetIdField = targetPks[0].fieldName;
|
|
171
|
+
|
|
172
|
+
(normalizedValues as Record<string, unknown>)[key] = relData.map((item: Record<string, unknown>) => {
|
|
173
|
+
// Handle junction table flattening:
|
|
174
|
+
// Junction rows look like { post_id: 1, tag_id: { id: 5, name: "ts" } }
|
|
175
|
+
let targetEntity = item;
|
|
176
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
177
|
+
// Find the nested target object in the junction row
|
|
178
|
+
const nestedKey = Object.keys(item).find(
|
|
179
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
180
|
+
);
|
|
181
|
+
if (nestedKey) {
|
|
182
|
+
targetEntity = item[nestedKey] as Record<string, unknown>;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const relId = String(targetEntity[targetIdField] ?? targetEntity.id ?? targetEntity[Object.keys(targetEntity)[0]]);
|
|
187
|
+
const targetValues = normalizeDbValues(targetEntity, targetCollection);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
id: relId,
|
|
191
|
+
path: targetPath,
|
|
192
|
+
__type: "relation" as const,
|
|
193
|
+
data: {
|
|
194
|
+
id: relId,
|
|
195
|
+
path: targetPath,
|
|
196
|
+
values: targetValues,
|
|
197
|
+
databaseId
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
202
|
+
const targetCollection = relation.target();
|
|
203
|
+
const targetPath = targetCollection.slug;
|
|
204
|
+
const targetPks = getPrimaryKeys(targetCollection, this.registry);
|
|
205
|
+
const targetIdField = targetPks[0].fieldName;
|
|
206
|
+
const relObj = relData as Record<string, unknown>;
|
|
207
|
+
|
|
208
|
+
const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
|
|
209
|
+
const targetValues = normalizeDbValues(relObj, targetCollection);
|
|
210
|
+
|
|
211
|
+
(normalizedValues as Record<string, unknown>)[key] = {
|
|
212
|
+
id: relId,
|
|
213
|
+
path: targetPath,
|
|
214
|
+
__type: "relation" as const,
|
|
215
|
+
data: {
|
|
216
|
+
id: relId,
|
|
217
|
+
path: targetPath,
|
|
218
|
+
values: targetValues,
|
|
219
|
+
databaseId
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]),
|
|
227
|
+
path: collectionPath,
|
|
228
|
+
values: normalizedValues as M,
|
|
229
|
+
databaseId
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Post-fetch joinPath relations for a single entity.
|
|
235
|
+
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
236
|
+
* so they must be loaded separately after the primary query.
|
|
237
|
+
*/
|
|
238
|
+
private async resolveJoinPathRelations<M extends Record<string, any>>(
|
|
239
|
+
entity: Entity<M>,
|
|
240
|
+
collection: EntityCollection,
|
|
241
|
+
collectionPath: string,
|
|
242
|
+
parsedId: string | number,
|
|
243
|
+
databaseId?: string
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
246
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
247
|
+
|
|
248
|
+
const promises = Object.entries(resolvedRelations)
|
|
249
|
+
.filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0)
|
|
250
|
+
.map(async ([key, relation]) => {
|
|
251
|
+
try {
|
|
252
|
+
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
253
|
+
collectionPath,
|
|
254
|
+
parsedId,
|
|
255
|
+
key,
|
|
256
|
+
{ limit: relation.cardinality === "one" ? 1 : undefined }
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (relation.cardinality === "one" && relatedEntities.length > 0) {
|
|
260
|
+
const e = relatedEntities[0];
|
|
261
|
+
(entity.values as Record<string, unknown>)[key] = {
|
|
262
|
+
id: e.id,
|
|
263
|
+
path: e.path,
|
|
264
|
+
__type: "relation" as const,
|
|
265
|
+
data: e
|
|
266
|
+
};
|
|
267
|
+
} else if (relation.cardinality === "many") {
|
|
268
|
+
(entity.values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
|
|
269
|
+
id: e.id,
|
|
270
|
+
path: e.path,
|
|
271
|
+
__type: "relation" as const,
|
|
272
|
+
data: e
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.warn(`Could not resolve joinPath relation '${key}':`, e);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await Promise.all(promises);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Post-fetch joinPath relations for a batch of entities.
|
|
285
|
+
* Uses batch fetching to avoid N+1 queries for list views.
|
|
286
|
+
*/
|
|
287
|
+
private async resolveJoinPathRelationsBatch<M extends Record<string, any>>(
|
|
288
|
+
entities: Entity<M>[],
|
|
289
|
+
collection: EntityCollection,
|
|
290
|
+
collectionPath: string,
|
|
291
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
292
|
+
databaseId?: string
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
if (entities.length === 0) return;
|
|
295
|
+
|
|
296
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
297
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
298
|
+
|
|
299
|
+
const joinPathRelations = Object.entries(resolvedRelations)
|
|
300
|
+
.filter(([key, relation]) => propertyKeys.has(key) && relation.joinPath && relation.joinPath.length > 0);
|
|
301
|
+
|
|
302
|
+
if (joinPathRelations.length === 0) return;
|
|
303
|
+
|
|
304
|
+
for (const [key, relation] of joinPathRelations) {
|
|
305
|
+
try {
|
|
306
|
+
const entityIds = entities.map(e => {
|
|
307
|
+
const parsed = parseIdValues(e.id, [idInfo]);
|
|
308
|
+
return parsed[idInfo.fieldName] as string | number;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const resultMap = await this.relationService.batchFetchRelatedEntities(
|
|
312
|
+
collectionPath,
|
|
313
|
+
entityIds,
|
|
314
|
+
key,
|
|
315
|
+
relation
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
for (const entity of entities) {
|
|
319
|
+
const parsed = parseIdValues(entity.id, [idInfo]);
|
|
320
|
+
const entityId = parsed[idInfo.fieldName] as string | number;
|
|
321
|
+
const relatedEntity = resultMap.get(entityId);
|
|
322
|
+
|
|
323
|
+
if (relatedEntity) {
|
|
324
|
+
if (relation.cardinality === "one") {
|
|
325
|
+
(entity.values as Record<string, unknown>)[key] = {
|
|
326
|
+
id: relatedEntity.id,
|
|
327
|
+
path: relatedEntity.path,
|
|
328
|
+
__type: "relation" as const,
|
|
329
|
+
data: relatedEntity
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
console.warn(`Could not batch resolve joinPath relation '${key}':`, e);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Convert a db.query result row to a flat REST-style object with populated relations.
|
|
342
|
+
*/
|
|
343
|
+
private drizzleResultToRestRow(
|
|
344
|
+
row: Record<string, unknown>,
|
|
345
|
+
collection: EntityCollection,
|
|
346
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
347
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
348
|
+
): Record<string, unknown> {
|
|
349
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
350
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
351
|
+
|
|
352
|
+
for (const [k, v] of Object.entries(row)) {
|
|
353
|
+
if (k === idInfo.fieldName) continue;
|
|
354
|
+
|
|
355
|
+
const relation = resolvedRelations[k];
|
|
356
|
+
if (Array.isArray(v) && relation) {
|
|
357
|
+
// Many relation — flatten each nested entity, handling junction tables
|
|
358
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
359
|
+
if (this.isJunctionRelation(relation, collection)) {
|
|
360
|
+
const nestedKey = Object.keys(item).find(
|
|
361
|
+
nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk])
|
|
362
|
+
);
|
|
363
|
+
if (nestedKey) {
|
|
364
|
+
const nested = item[nestedKey] as Record<string, unknown>;
|
|
365
|
+
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
|
|
369
|
+
});
|
|
370
|
+
} else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) {
|
|
371
|
+
// One-to-one relation — inline the object
|
|
372
|
+
const relObj = v as Record<string, unknown>;
|
|
373
|
+
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
|
|
374
|
+
} else {
|
|
375
|
+
flat[k] = v;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return flat;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build db.query-compatible options from standard fetch options.
|
|
383
|
+
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
384
|
+
*/
|
|
385
|
+
private buildDrizzleQueryOptions<M extends Record<string, any>>(
|
|
386
|
+
table: PgTable<any>,
|
|
387
|
+
idField: AnyPgColumn,
|
|
388
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
389
|
+
options: {
|
|
390
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
391
|
+
orderBy?: string;
|
|
392
|
+
order?: "desc" | "asc";
|
|
393
|
+
limit?: number;
|
|
394
|
+
startAfter?: Record<string, unknown>;
|
|
395
|
+
searchString?: string;
|
|
396
|
+
},
|
|
397
|
+
collectionPath: string,
|
|
398
|
+
withConfig?: Record<string, any>
|
|
399
|
+
): Record<string, unknown> {
|
|
400
|
+
const queryOpts: Record<string, unknown> = {};
|
|
401
|
+
|
|
402
|
+
if (withConfig) queryOpts.with = withConfig;
|
|
403
|
+
|
|
404
|
+
// Build where conditions
|
|
405
|
+
const allConditions: SQL[] = [];
|
|
406
|
+
|
|
407
|
+
if (options.searchString) {
|
|
408
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
409
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
410
|
+
options.searchString, collection.properties, table
|
|
411
|
+
);
|
|
412
|
+
if (searchConditions.length === 0) {
|
|
413
|
+
// Return options that will produce empty results
|
|
414
|
+
queryOpts.where = and(eq(idField, -99999999)); // impossible condition
|
|
415
|
+
return queryOpts;
|
|
416
|
+
}
|
|
417
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (options.filter) {
|
|
421
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
422
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Cursor-based pagination (startAfter)
|
|
426
|
+
if (options.startAfter) {
|
|
427
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
|
|
428
|
+
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (allConditions.length > 0) {
|
|
432
|
+
queryOpts.where = and(...allConditions);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// OrderBy
|
|
436
|
+
const orderExpressions: unknown[] = [];
|
|
437
|
+
if (options.orderBy) {
|
|
438
|
+
const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
|
|
439
|
+
if (orderByField) {
|
|
440
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
orderExpressions.push(desc(idField));
|
|
444
|
+
if (orderExpressions.length > 0) {
|
|
445
|
+
queryOpts.orderBy = orderExpressions;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Limit
|
|
449
|
+
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
450
|
+
if (limitValue) queryOpts.limit = limitValue;
|
|
451
|
+
|
|
452
|
+
return queryOpts;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Extract cursor pagination conditions from startAfter options.
|
|
457
|
+
*/
|
|
458
|
+
private buildCursorConditions(
|
|
459
|
+
table: PgTable<any>,
|
|
460
|
+
idField: AnyPgColumn,
|
|
461
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
462
|
+
options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> }
|
|
463
|
+
): SQL[] {
|
|
464
|
+
if (!options.startAfter) return [];
|
|
465
|
+
const cursor = options.startAfter;
|
|
466
|
+
|
|
467
|
+
if (options.orderBy) {
|
|
468
|
+
const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
|
|
469
|
+
if (orderByField) {
|
|
470
|
+
const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
|
|
471
|
+
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
472
|
+
|
|
473
|
+
if (startAfterOrderValue !== undefined && startAfterId !== undefined) {
|
|
474
|
+
if (options.order === "asc") {
|
|
475
|
+
return [or(
|
|
476
|
+
gt(orderByField, startAfterOrderValue),
|
|
477
|
+
and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId))
|
|
478
|
+
)!];
|
|
479
|
+
} else {
|
|
480
|
+
return [or(
|
|
481
|
+
lt(orderByField, startAfterOrderValue),
|
|
482
|
+
and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId))
|
|
483
|
+
)!];
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
489
|
+
if (startAfterId !== undefined && startAfterId !== null) {
|
|
490
|
+
const idInfoArray = [idInfo] as Array<{ fieldName: string; type: "string" | "number" }>;
|
|
491
|
+
const parsedStartAfterIdObj = parseIdValues(startAfterId as string | number, idInfoArray);
|
|
492
|
+
return [lt(idField, parsedStartAfterIdObj[idInfo.fieldName])];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Fetch a single entity by ID
|
|
501
|
+
*/
|
|
502
|
+
async fetchEntity<M extends Record<string, any>>(
|
|
503
|
+
collectionPath: string,
|
|
504
|
+
entityId: string | number,
|
|
505
|
+
databaseId?: string
|
|
506
|
+
): Promise<Entity<M> | undefined> {
|
|
507
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
508
|
+
const table = getTableForCollection(collection, this.registry);
|
|
509
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
510
|
+
const idInfo = idInfoArray[0];
|
|
511
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
512
|
+
|
|
513
|
+
if (!idField) {
|
|
514
|
+
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const parsedIdObj = parseIdValues(entityId, idInfoArray);
|
|
518
|
+
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
519
|
+
|
|
520
|
+
// Primary path: use db.query.findFirst with relation loading
|
|
521
|
+
|
|
522
|
+
const tableName = getTableName(table);
|
|
523
|
+
|
|
524
|
+
const qb = this.getQueryBuilder(tableName);
|
|
525
|
+
if (qb) {
|
|
526
|
+
try {
|
|
527
|
+
const withConfig = this.buildWithConfig(collection);
|
|
528
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
|
|
529
|
+
const row = await qb.findFirst({
|
|
530
|
+
where: eq(idField, parsedId),
|
|
531
|
+
with: withConfig
|
|
532
|
+
} as unknown as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
533
|
+
|
|
534
|
+
if (!row) return undefined;
|
|
535
|
+
|
|
536
|
+
const entity = this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, databaseId, idInfoArray);
|
|
537
|
+
|
|
538
|
+
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
539
|
+
await this.resolveJoinPathRelations(entity, collection, collectionPath, parsedId, databaseId);
|
|
540
|
+
|
|
541
|
+
return entity;
|
|
542
|
+
} catch (e) {
|
|
543
|
+
console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Fallback: db.select + N+1 relation loading
|
|
548
|
+
const result = await this.db
|
|
549
|
+
.select()
|
|
550
|
+
.from(table)
|
|
551
|
+
.where(eq(idField, parsedId))
|
|
552
|
+
.limit(1);
|
|
553
|
+
|
|
554
|
+
if (result.length === 0) return undefined;
|
|
555
|
+
|
|
556
|
+
const raw = result[0] as M;
|
|
557
|
+
const values = await parseDataFromServer(raw, collection, this.db, this.registry);
|
|
558
|
+
|
|
559
|
+
// Load relations based on cardinality (N+1 — only used in fallback)
|
|
560
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
561
|
+
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
562
|
+
|
|
563
|
+
const relationPromises = Object.entries(resolvedRelations)
|
|
564
|
+
.filter(([key]) => propertyKeys.has(key))
|
|
565
|
+
.map(async ([key, relation]) => {
|
|
566
|
+
if (relation.cardinality === "many") {
|
|
567
|
+
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
568
|
+
collectionPath,
|
|
569
|
+
parsedId,
|
|
570
|
+
key,
|
|
571
|
+
{}
|
|
572
|
+
);
|
|
573
|
+
(values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
|
|
574
|
+
id: e.id,
|
|
575
|
+
path: e.path,
|
|
576
|
+
__type: "relation"
|
|
577
|
+
}));
|
|
578
|
+
} else if (relation.cardinality === "one") {
|
|
579
|
+
if ((values as Record<string, unknown>)[key] == null) {
|
|
580
|
+
try {
|
|
581
|
+
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
582
|
+
collectionPath,
|
|
583
|
+
parsedId,
|
|
584
|
+
key,
|
|
585
|
+
{ limit: 1 }
|
|
586
|
+
);
|
|
587
|
+
if (relatedEntities.length > 0) {
|
|
588
|
+
const e = relatedEntities[0];
|
|
589
|
+
(values as Record<string, unknown>)[key] = {
|
|
590
|
+
id: e.id,
|
|
591
|
+
path: e.path,
|
|
592
|
+
__type: "relation"
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
} catch (e) {
|
|
596
|
+
console.warn(`Could not resolve one-to-one relation property: ${key}`, e);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await Promise.all(relationPromises);
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
id: entityId.toString(),
|
|
606
|
+
path: collectionPath,
|
|
607
|
+
values: values as M,
|
|
608
|
+
databaseId
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Unified method to fetch entities with optional search functionality
|
|
614
|
+
*/
|
|
615
|
+
async fetchEntitiesWithConditions<M extends Record<string, any>>(
|
|
616
|
+
collectionPath: string,
|
|
617
|
+
options: {
|
|
618
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
619
|
+
orderBy?: string;
|
|
620
|
+
order?: "desc" | "asc";
|
|
621
|
+
limit?: number;
|
|
622
|
+
startAfter?: Record<string, unknown>;
|
|
623
|
+
searchString?: string;
|
|
624
|
+
databaseId?: string;
|
|
625
|
+
} = {}
|
|
626
|
+
): Promise<Entity<M>[]> {
|
|
627
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
628
|
+
const table = getTableForCollection(collection, this.registry);
|
|
629
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
630
|
+
const idInfo = idInfoArray[0];
|
|
631
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
632
|
+
|
|
633
|
+
if (!idField) {
|
|
634
|
+
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Primary path: use db.query.findMany with relation loading
|
|
638
|
+
|
|
639
|
+
const tableName = getTableName(table);
|
|
640
|
+
|
|
641
|
+
const qb = this.getQueryBuilder(tableName);
|
|
642
|
+
if (qb) {
|
|
643
|
+
try {
|
|
644
|
+
const withConfig = this.buildWithConfig(collection);
|
|
645
|
+
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
646
|
+
table, idField, idInfo, options, collectionPath, withConfig
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
|
|
650
|
+
const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
651
|
+
|
|
652
|
+
const entities = (results as Record<string, unknown>[]).map(row =>
|
|
653
|
+
this.drizzleResultToEntity<M>(row, collection, collectionPath, idInfo, options.databaseId, idInfoArray)
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
657
|
+
await this.resolveJoinPathRelationsBatch(entities, collection, collectionPath, idInfo, options.databaseId);
|
|
658
|
+
|
|
659
|
+
return entities;
|
|
660
|
+
} catch (e) {
|
|
661
|
+
console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Fallback: db.select + processEntityResults (N+1 for relations)
|
|
666
|
+
let query = this.db.select().from(table).$dynamic();
|
|
667
|
+
const allConditions: SQL[] = [];
|
|
668
|
+
|
|
669
|
+
if (options.searchString) {
|
|
670
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
671
|
+
options.searchString, collection.properties, table
|
|
672
|
+
);
|
|
673
|
+
if (searchConditions.length === 0) return [];
|
|
674
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (options.filter) {
|
|
678
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
679
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (allConditions.length > 0) {
|
|
683
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
684
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const orderExpressions = [];
|
|
688
|
+
if (options.orderBy) {
|
|
689
|
+
const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
|
|
690
|
+
if (orderByField) {
|
|
691
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
orderExpressions.push(desc(idField));
|
|
695
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
696
|
+
|
|
697
|
+
if (options.startAfter) {
|
|
698
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options);
|
|
699
|
+
if (cursorConditions.length > 0) {
|
|
700
|
+
allConditions.push(...cursorConditions);
|
|
701
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
702
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
707
|
+
if (limitValue) query = query.limit(limitValue);
|
|
708
|
+
|
|
709
|
+
const results = await query;
|
|
710
|
+
|
|
711
|
+
return this.processEntityResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Fallback path used when db.query is unavailable.
|
|
716
|
+
* The primary path uses drizzleResultToEntity which handles relation
|
|
717
|
+
* mapping without N+1 queries.
|
|
718
|
+
*
|
|
719
|
+
* Process raw database results into Entity objects with relations.
|
|
720
|
+
*/
|
|
721
|
+
private async processEntityResults<M extends Record<string, any>>(
|
|
722
|
+
results: Record<string, unknown>[],
|
|
723
|
+
collection: EntityCollection,
|
|
724
|
+
collectionPath: string,
|
|
725
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
726
|
+
databaseId?: string,
|
|
727
|
+
skipRelations: boolean = false,
|
|
728
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
729
|
+
): Promise<Entity<M>[]> {
|
|
730
|
+
if (results.length === 0) return [];
|
|
731
|
+
|
|
732
|
+
// First pass: parse all entities
|
|
733
|
+
const entitiesWithValues = await Promise.all(results.map(async (entity: Record<string, unknown>) => {
|
|
734
|
+
const values = await parseDataFromServer(entity as M, collection, this.db, this.registry);
|
|
735
|
+
return {
|
|
736
|
+
entity,
|
|
737
|
+
values,
|
|
738
|
+
id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray!) : String(entity[idInfo.fieldName]),
|
|
739
|
+
path: collectionPath
|
|
740
|
+
};
|
|
741
|
+
}));
|
|
742
|
+
|
|
743
|
+
if (!skipRelations) {
|
|
744
|
+
// Second pass: batch load missing one-to-one relations
|
|
745
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
746
|
+
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
747
|
+
|
|
748
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
749
|
+
if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
|
|
750
|
+
|
|
751
|
+
const entitiesMissingRelation = entitiesWithValues.filter(item => {
|
|
752
|
+
const val = (item.values as Record<string, unknown>)[key];
|
|
753
|
+
if (val == null) return true;
|
|
754
|
+
if (typeof val === "object" && !Array.isArray(val) && (val as Record<string, unknown>).__type === "relation" && (val as Record<string, unknown>).data == null) return true;
|
|
755
|
+
return false;
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
if (entitiesMissingRelation.length === 0) continue;
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
const entityIds = entitiesMissingRelation.map(item => item.entity[idInfo.fieldName] as string | number);
|
|
762
|
+
const relationResults = await this.relationService.batchFetchRelatedEntities(
|
|
763
|
+
collectionPath,
|
|
764
|
+
entityIds,
|
|
765
|
+
key,
|
|
766
|
+
relation
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
entitiesMissingRelation.forEach(item => {
|
|
770
|
+
const entityId = item.entity[idInfo.fieldName] as string | number;
|
|
771
|
+
const relatedEntity = relationResults.get(entityId);
|
|
772
|
+
if (relatedEntity) {
|
|
773
|
+
(item.values as Record<string, unknown>)[key] = {
|
|
774
|
+
id: relatedEntity.id,
|
|
775
|
+
path: relatedEntity.path,
|
|
776
|
+
__type: "relation",
|
|
777
|
+
data: relatedEntity
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
} catch (e) {
|
|
782
|
+
console.warn(`Could not batch load one-to-one relation property: ${key}`, e);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Handle many relations
|
|
787
|
+
const manyRelationPromises = entitiesWithValues.map(async (item) => {
|
|
788
|
+
const manyRelationQueries = Object.entries(resolvedRelations)
|
|
789
|
+
.filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many")
|
|
790
|
+
.map(async ([key]) => {
|
|
791
|
+
try {
|
|
792
|
+
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
793
|
+
collectionPath,
|
|
794
|
+
item.entity[idInfo.fieldName] as string | number,
|
|
795
|
+
key,
|
|
796
|
+
{}
|
|
797
|
+
);
|
|
798
|
+
(item.values as Record<string, unknown>)[key] = relatedEntities.map(e => ({
|
|
799
|
+
id: e.id,
|
|
800
|
+
path: e.path,
|
|
801
|
+
__type: "relation",
|
|
802
|
+
data: e
|
|
803
|
+
}));
|
|
804
|
+
} catch (e) {
|
|
805
|
+
console.warn(`Could not resolve many relation property: ${key}`, e);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
await Promise.all(manyRelationQueries);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
await Promise.all(manyRelationPromises);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return entitiesWithValues.map(item => ({
|
|
815
|
+
id: item.id,
|
|
816
|
+
path: item.path,
|
|
817
|
+
values: item.values as M,
|
|
818
|
+
databaseId
|
|
819
|
+
}));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Fetch a collection of entities
|
|
824
|
+
*/
|
|
825
|
+
async fetchCollection<M extends Record<string, any>>(
|
|
826
|
+
collectionPath: string,
|
|
827
|
+
options: {
|
|
828
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
829
|
+
orderBy?: string;
|
|
830
|
+
order?: "desc" | "asc";
|
|
831
|
+
limit?: number;
|
|
832
|
+
startAfter?: Record<string, unknown>;
|
|
833
|
+
searchString?: string;
|
|
834
|
+
databaseId?: string;
|
|
835
|
+
} = {}
|
|
836
|
+
): Promise<Entity<M>[]> {
|
|
837
|
+
// Handle multi-segment paths by resolving through relations
|
|
838
|
+
if (collectionPath.includes("/")) {
|
|
839
|
+
return this.fetchCollectionFromPath<M>(collectionPath, options);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return this.fetchEntitiesWithConditions<M>(collectionPath, options);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Search entities by text
|
|
847
|
+
*/
|
|
848
|
+
async searchEntities<M extends Record<string, any>>(
|
|
849
|
+
collectionPath: string,
|
|
850
|
+
searchString: string,
|
|
851
|
+
options: {
|
|
852
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
853
|
+
orderBy?: string;
|
|
854
|
+
order?: "desc" | "asc";
|
|
855
|
+
limit?: number;
|
|
856
|
+
databaseId?: string;
|
|
857
|
+
} = {}
|
|
858
|
+
): Promise<Entity<M>[]> {
|
|
859
|
+
return this.fetchEntitiesWithConditions<M>(collectionPath, {
|
|
860
|
+
...options,
|
|
861
|
+
searchString
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Fetch collection from multi-segment path
|
|
867
|
+
*/
|
|
868
|
+
private async fetchCollectionFromPath<M extends Record<string, any>>(
|
|
869
|
+
path: string,
|
|
870
|
+
options: {
|
|
871
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
872
|
+
orderBy?: string;
|
|
873
|
+
order?: "desc" | "asc";
|
|
874
|
+
limit?: number;
|
|
875
|
+
startAfter?: Record<string, unknown>;
|
|
876
|
+
searchString?: string;
|
|
877
|
+
databaseId?: string;
|
|
878
|
+
} = {}
|
|
879
|
+
): Promise<Entity<M>[]> {
|
|
880
|
+
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
881
|
+
|
|
882
|
+
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
883
|
+
throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const rootCollectionPath = pathSegments[0];
|
|
887
|
+
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
888
|
+
let currentEntityId: string | number = pathSegments[1];
|
|
889
|
+
|
|
890
|
+
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
891
|
+
const relationKey = pathSegments[i];
|
|
892
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
893
|
+
const relation = resolvedRelations[relationKey];
|
|
894
|
+
|
|
895
|
+
if (!relation) {
|
|
896
|
+
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (i === pathSegments.length - 1) {
|
|
900
|
+
return this.relationService.fetchRelatedEntities<M>(
|
|
901
|
+
currentCollection.slug,
|
|
902
|
+
currentEntityId,
|
|
903
|
+
relationKey,
|
|
904
|
+
options
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (i + 1 < pathSegments.length) {
|
|
909
|
+
const nextEntityId = pathSegments[i + 1];
|
|
910
|
+
currentCollection = relation.target();
|
|
911
|
+
currentEntityId = nextEntityId;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Count entities in a collection
|
|
920
|
+
*/
|
|
921
|
+
async countEntities<M extends Record<string, any>>(
|
|
922
|
+
collectionPath: string,
|
|
923
|
+
options: {
|
|
924
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
925
|
+
databaseId?: string;
|
|
926
|
+
} = {}
|
|
927
|
+
): Promise<number> {
|
|
928
|
+
if (collectionPath.includes("/")) {
|
|
929
|
+
return this.countEntitiesFromPath<M>(collectionPath, options);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
933
|
+
const table = getTableForCollection(collection, this.registry);
|
|
934
|
+
|
|
935
|
+
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
936
|
+
|
|
937
|
+
if (options.filter) {
|
|
938
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
939
|
+
if (filterConditions.length > 0) {
|
|
940
|
+
query = query.where(and(...filterConditions));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const result = await query;
|
|
945
|
+
return Number(result[0]?.count || 0);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Count entities from multi-segment path
|
|
950
|
+
*/
|
|
951
|
+
private async countEntitiesFromPath<M extends Record<string, any>>(
|
|
952
|
+
path: string,
|
|
953
|
+
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
954
|
+
): Promise<number> {
|
|
955
|
+
const pathSegments = path.split("/").filter(p => p && p !== "undefined");
|
|
956
|
+
|
|
957
|
+
if (pathSegments.length < 3 || pathSegments.length % 2 === 0) {
|
|
958
|
+
throw new Error(`Invalid relation path: ${path}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const rootCollectionPath = pathSegments[0];
|
|
962
|
+
let currentCollection = getCollectionByPath(rootCollectionPath, this.registry);
|
|
963
|
+
let currentEntityId: string | number = pathSegments[1];
|
|
964
|
+
|
|
965
|
+
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
966
|
+
const relationKey = pathSegments[i];
|
|
967
|
+
const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
968
|
+
const relation = resolvedRelations[relationKey];
|
|
969
|
+
|
|
970
|
+
if (!relation) {
|
|
971
|
+
throw new Error(`Relation '${relationKey}' not found`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (i === pathSegments.length - 1) {
|
|
975
|
+
return this.relationService.countRelatedEntities(
|
|
976
|
+
currentCollection.slug,
|
|
977
|
+
currentEntityId,
|
|
978
|
+
relationKey,
|
|
979
|
+
options
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (i + 1 < pathSegments.length) {
|
|
984
|
+
currentCollection = relation.target();
|
|
985
|
+
currentEntityId = pathSegments[i + 1];
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
throw new Error(`Unable to count for path: ${path}`);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Check if a field value is unique
|
|
994
|
+
*/
|
|
995
|
+
async checkUniqueField(
|
|
996
|
+
collectionPath: string,
|
|
997
|
+
fieldName: string,
|
|
998
|
+
value: unknown,
|
|
999
|
+
excludeEntityId?: string,
|
|
1000
|
+
_databaseId?: string
|
|
1001
|
+
): Promise<boolean> {
|
|
1002
|
+
if (value === undefined || value === null) return true;
|
|
1003
|
+
|
|
1004
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1005
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1006
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1007
|
+
const idInfo = idInfoArray[0];
|
|
1008
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1009
|
+
const field = table[fieldName as keyof typeof table] as AnyPgColumn;
|
|
1010
|
+
|
|
1011
|
+
if (!field) return true;
|
|
1012
|
+
|
|
1013
|
+
const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : undefined;
|
|
1014
|
+
const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(
|
|
1015
|
+
field,
|
|
1016
|
+
value,
|
|
1017
|
+
idField,
|
|
1018
|
+
parsedExcludeId
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
const result = await this.db
|
|
1022
|
+
.select({ count: count() })
|
|
1023
|
+
.from(table)
|
|
1024
|
+
.where(and(...conditions));
|
|
1025
|
+
|
|
1026
|
+
const countResult = Number(result[0]?.count || 0);
|
|
1027
|
+
return countResult === 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Get the RelationService instance for external use
|
|
1032
|
+
*/
|
|
1033
|
+
getRelationService(): RelationService {
|
|
1034
|
+
return this.relationService;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// =============================================================
|
|
1038
|
+
// REST API INCLUDE-AWARE METHODS
|
|
1039
|
+
// =============================================================
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Fetch a collection of entities with optional relation includes.
|
|
1043
|
+
* When `include` is provided, only the specified relations are populated
|
|
1044
|
+
* with full entity data (not just { id, path, __type }).
|
|
1045
|
+
* When `include` is absent, no relation queries are made (fast path).
|
|
1046
|
+
*
|
|
1047
|
+
* @param include - Array of relation keys to populate, or ["*"] for all
|
|
1048
|
+
*/
|
|
1049
|
+
async fetchCollectionForRest<M extends Record<string, any>>(
|
|
1050
|
+
collectionPath: string,
|
|
1051
|
+
options: {
|
|
1052
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1053
|
+
orderBy?: string;
|
|
1054
|
+
order?: "desc" | "asc";
|
|
1055
|
+
limit?: number;
|
|
1056
|
+
startAfter?: Record<string, unknown>;
|
|
1057
|
+
searchString?: string;
|
|
1058
|
+
databaseId?: string;
|
|
1059
|
+
} = {},
|
|
1060
|
+
include?: string[]
|
|
1061
|
+
): Promise<Record<string, unknown>[]> {
|
|
1062
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1063
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1064
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1065
|
+
const idInfo = idInfoArray[0];
|
|
1066
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1067
|
+
|
|
1068
|
+
// Primary path: use db.query.findMany
|
|
1069
|
+
|
|
1070
|
+
const tableName = getTableName(table);
|
|
1071
|
+
|
|
1072
|
+
const qb = this.getQueryBuilder(tableName);
|
|
1073
|
+
if (qb) {
|
|
1074
|
+
try {
|
|
1075
|
+
const withConfig = (include && include.length > 0)
|
|
1076
|
+
? this.buildWithConfig(collection, include)
|
|
1077
|
+
: undefined;
|
|
1078
|
+
|
|
1079
|
+
const queryOpts = this.buildDrizzleQueryOptions<M>(
|
|
1080
|
+
table, idField, idInfo, options, collectionPath, withConfig
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
|
|
1084
|
+
const results = await qb.findMany(queryOpts as unknown as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
1085
|
+
|
|
1086
|
+
return (results as Record<string, unknown>[]).map(row =>
|
|
1087
|
+
this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray)
|
|
1088
|
+
);
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Fallback: fetch base entities without relations
|
|
1095
|
+
const entities = await this.fetchEntitiesWithConditionsRaw<M>(collectionPath, options);
|
|
1096
|
+
|
|
1097
|
+
if (!include || include.length === 0) {
|
|
1098
|
+
return entities.map(entity => ({
|
|
1099
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
|
|
1100
|
+
...entity
|
|
1101
|
+
}));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Fallback relation loading via batch
|
|
1105
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
1106
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1107
|
+
const shouldInclude = (key: string) =>
|
|
1108
|
+
include[0] === "*" || include.includes(key);
|
|
1109
|
+
|
|
1110
|
+
const entityIds = entities.map(e => e[idInfo.fieldName] as string | number);
|
|
1111
|
+
|
|
1112
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1113
|
+
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
|
|
1114
|
+
try {
|
|
1115
|
+
const batchResults = await this.relationService.batchFetchRelatedEntities(
|
|
1116
|
+
collectionPath, entityIds, key, relation
|
|
1117
|
+
);
|
|
1118
|
+
for (const entity of entities) {
|
|
1119
|
+
const eid = entity[idInfo.fieldName] as string | number;
|
|
1120
|
+
const related = batchResults.get(eid);
|
|
1121
|
+
if (related) {
|
|
1122
|
+
(entity as Record<string, unknown>)[key] = { id: related.id, ...related.values };
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
console.warn(`[include] Failed to batch load one-to-one '${key}':`, e);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1131
|
+
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
|
|
1132
|
+
try {
|
|
1133
|
+
const batchResults = await this.batchFetchManyRelatedEntities(
|
|
1134
|
+
collectionPath, entityIds, key
|
|
1135
|
+
);
|
|
1136
|
+
for (const entity of entities) {
|
|
1137
|
+
const eid = entity[idInfo.fieldName] as string | number;
|
|
1138
|
+
const relatedList = batchResults.get(String(eid)) || [];
|
|
1139
|
+
(entity as Record<string, unknown>)[key] = relatedList.map(e => ({
|
|
1140
|
+
id: e.id, ...e.values
|
|
1141
|
+
}));
|
|
1142
|
+
}
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
console.warn(`[include] Failed to batch load many '${key}':`, e);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return entities.map(entity => ({
|
|
1149
|
+
id: (idInfoArray.length > 1) ? buildCompositeId(entity as Record<string, any>, idInfoArray) : String(entity[idInfo.fieldName]),
|
|
1150
|
+
...entity
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Fetch a single entity with optional relation includes for REST API.
|
|
1156
|
+
*/
|
|
1157
|
+
async fetchEntityForRest<M extends Record<string, any>>(
|
|
1158
|
+
collectionPath: string,
|
|
1159
|
+
entityId: string | number,
|
|
1160
|
+
include?: string[],
|
|
1161
|
+
databaseId?: string
|
|
1162
|
+
): Promise<Record<string, unknown> | null> {
|
|
1163
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1164
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1165
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1166
|
+
const idInfo = idInfoArray[0];
|
|
1167
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1168
|
+
|
|
1169
|
+
const parsedIdObj = parseIdValues(entityId, idInfoArray);
|
|
1170
|
+
const parsedId = parsedIdObj[idInfo.fieldName];
|
|
1171
|
+
|
|
1172
|
+
// Primary path: use db.query.findFirst
|
|
1173
|
+
|
|
1174
|
+
const tableName = getTableName(table);
|
|
1175
|
+
|
|
1176
|
+
const qb = this.getQueryBuilder(tableName);
|
|
1177
|
+
if (qb) {
|
|
1178
|
+
try {
|
|
1179
|
+
const withConfig = (include && include.length > 0)
|
|
1180
|
+
? this.buildWithConfig(collection, include)
|
|
1181
|
+
: undefined;
|
|
1182
|
+
|
|
1183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic query config
|
|
1184
|
+
const row = await qb.findFirst({
|
|
1185
|
+
where: eq(idField, parsedId),
|
|
1186
|
+
...(withConfig ? { with: withConfig } : {})
|
|
1187
|
+
} as unknown as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
1188
|
+
|
|
1189
|
+
if (!row) return null;
|
|
1190
|
+
|
|
1191
|
+
return this.drizzleResultToRestRow(row, collection, idInfo, idInfoArray);
|
|
1192
|
+
} catch (e) {
|
|
1193
|
+
console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Fallback: db.select + N+1 relation loading
|
|
1198
|
+
const result = await this.db
|
|
1199
|
+
.select()
|
|
1200
|
+
.from(table)
|
|
1201
|
+
.where(eq(idField, parsedId))
|
|
1202
|
+
.limit(1);
|
|
1203
|
+
|
|
1204
|
+
if (result.length === 0) return null;
|
|
1205
|
+
|
|
1206
|
+
const raw = result[0] as Record<string, unknown>;
|
|
1207
|
+
const flatEntity: Record<string, unknown> = { id: (idInfoArray.length > 1) ? buildCompositeId(raw as Record<string, any>, idInfoArray) : String(raw[idInfo.fieldName]), ...raw };
|
|
1208
|
+
|
|
1209
|
+
if (!include || include.length === 0) {
|
|
1210
|
+
return flatEntity;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Fallback relation population
|
|
1214
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
1215
|
+
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
1216
|
+
const shouldInclude = (key: string) =>
|
|
1217
|
+
include[0] === "*" || include.includes(key);
|
|
1218
|
+
|
|
1219
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1220
|
+
if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
|
|
1221
|
+
|
|
1222
|
+
try {
|
|
1223
|
+
const relatedEntities = await this.relationService.fetchRelatedEntities(
|
|
1224
|
+
collectionPath, parsedId, key, {}
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1227
|
+
if (relation.cardinality === "one") {
|
|
1228
|
+
if (relatedEntities.length > 0) {
|
|
1229
|
+
const e = relatedEntities[0];
|
|
1230
|
+
flatEntity[key] = { id: e.id, ...e.values };
|
|
1231
|
+
}
|
|
1232
|
+
} else {
|
|
1233
|
+
flatEntity[key] = relatedEntities.map(e => ({
|
|
1234
|
+
id: e.id, ...e.values
|
|
1235
|
+
}));
|
|
1236
|
+
}
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
console.warn(`[include] Failed to load relation '${key}':`, e);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return flatEntity;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Fetch raw rows without any relation processing (for REST fast path)
|
|
1247
|
+
*/
|
|
1248
|
+
private async fetchEntitiesWithConditionsRaw<M extends Record<string, any>>(
|
|
1249
|
+
collectionPath: string,
|
|
1250
|
+
options: {
|
|
1251
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1252
|
+
orderBy?: string;
|
|
1253
|
+
order?: "desc" | "asc";
|
|
1254
|
+
limit?: number;
|
|
1255
|
+
startAfter?: Record<string, unknown>;
|
|
1256
|
+
searchString?: string;
|
|
1257
|
+
} = {}
|
|
1258
|
+
): Promise<Record<string, unknown>[]> {
|
|
1259
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1260
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1261
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
1262
|
+
const idInfo = idInfoArray[0];
|
|
1263
|
+
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
1264
|
+
|
|
1265
|
+
let query = this.db.select().from(table).$dynamic();
|
|
1266
|
+
const allConditions: SQL[] = [];
|
|
1267
|
+
|
|
1268
|
+
if (options.searchString) {
|
|
1269
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1270
|
+
options.searchString, collection.properties, table
|
|
1271
|
+
);
|
|
1272
|
+
if (searchConditions.length === 0) return [];
|
|
1273
|
+
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (options.filter) {
|
|
1277
|
+
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
1278
|
+
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (allConditions.length > 0) {
|
|
1282
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
1283
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const orderExpressions = [];
|
|
1287
|
+
if (options.orderBy) {
|
|
1288
|
+
const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
|
|
1289
|
+
if (orderByField) {
|
|
1290
|
+
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
orderExpressions.push(desc(idField));
|
|
1294
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
1295
|
+
|
|
1296
|
+
const limitValue = options.searchString ? (options.limit || 50) : options.limit;
|
|
1297
|
+
if (limitValue) query = query.limit(limitValue);
|
|
1298
|
+
|
|
1299
|
+
return await query as Record<string, unknown>[];
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Check if the Drizzle instance has the relational query API available
|
|
1304
|
+
* for a given collection path.
|
|
1305
|
+
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
1306
|
+
*/
|
|
1307
|
+
private hasDrizzleQueryAPI(collectionPath: string): boolean {
|
|
1308
|
+
|
|
1309
|
+
const qb = this.getQueryBuilder("__probe__");
|
|
1310
|
+
if (!qb) {
|
|
1311
|
+
// If getQueryBuilder returns undefined even for a probe, query API is not available
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
1315
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1316
|
+
const tableName = getTableName(table);
|
|
1317
|
+
return !!this.getQueryBuilder(tableName);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
1322
|
+
* for efficient JOIN-based relation loading.
|
|
1323
|
+
* Returns null if the API is not available or the query fails.
|
|
1324
|
+
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1325
|
+
*/
|
|
1326
|
+
private async fetchWithDrizzleQuery<M extends Record<string, any>>(
|
|
1327
|
+
collectionPath: string,
|
|
1328
|
+
collection: EntityCollection,
|
|
1329
|
+
options: {
|
|
1330
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1331
|
+
orderBy?: string;
|
|
1332
|
+
order?: "desc" | "asc";
|
|
1333
|
+
limit?: number;
|
|
1334
|
+
},
|
|
1335
|
+
include: string[],
|
|
1336
|
+
idInfo: { fieldName: string; type: "string" | "number" },
|
|
1337
|
+
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1338
|
+
): Promise<Record<string, unknown>[] | null> {
|
|
1339
|
+
try {
|
|
1340
|
+
|
|
1341
|
+
const table = getTableForCollection(collection, this.registry);
|
|
1342
|
+
const tableName = getTableName(table);
|
|
1343
|
+
const queryTarget = this.getQueryBuilder(tableName);
|
|
1344
|
+
|
|
1345
|
+
if (!queryTarget?.findMany) return null;
|
|
1346
|
+
|
|
1347
|
+
// Build the `with` config from include array
|
|
1348
|
+
const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
|
|
1349
|
+
const withConfig: Record<string, boolean> = {};
|
|
1350
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1351
|
+
if (include[0] === "*" || include.includes(key)) {
|
|
1352
|
+
// Use the Drizzle relation name (from the schema)
|
|
1353
|
+
const drizzleRelName = relation.relationName || key;
|
|
1354
|
+
withConfig[drizzleRelName] = true;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Build query options
|
|
1359
|
+
const queryOpts: Record<string, unknown> = { with: withConfig };
|
|
1360
|
+
if (options.limit) queryOpts.limit = options.limit;
|
|
1361
|
+
|
|
1362
|
+
// Build where clause
|
|
1363
|
+
if (options.filter) {
|
|
1364
|
+
const filterConditions = this.buildFilterConditions(
|
|
1365
|
+
options.filter, table, collectionPath
|
|
1366
|
+
);
|
|
1367
|
+
if (filterConditions.length > 0) {
|
|
1368
|
+
queryOpts.where = and(...filterConditions);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Build orderBy
|
|
1373
|
+
if (options.orderBy) {
|
|
1374
|
+
const orderByField = table[options.orderBy as keyof typeof table] as AnyPgColumn;
|
|
1375
|
+
if (orderByField) {
|
|
1376
|
+
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deprecated method, will be removed
|
|
1381
|
+
const results = await queryTarget.findMany(queryOpts as unknown as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1382
|
+
|
|
1383
|
+
// Flatten the nested Drizzle results into REST format
|
|
1384
|
+
return results.map((row: Record<string, unknown>) => {
|
|
1385
|
+
const flat: Record<string, unknown> = { id: (idInfoArray && idInfoArray.length > 1) ? buildCompositeId(row as Record<string, any>, idInfoArray) : String(row[idInfo.fieldName]) };
|
|
1386
|
+
for (const [k, v] of Object.entries(row)) {
|
|
1387
|
+
if (k === idInfo.fieldName) continue;
|
|
1388
|
+
if (Array.isArray(v)) {
|
|
1389
|
+
// Many relation — flatten each nested entity
|
|
1390
|
+
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1391
|
+
// Junction table rows may have the target nested, flatten those
|
|
1392
|
+
const keys = Object.keys(item);
|
|
1393
|
+
// If it looks like a junction row (only FKs + nested objects), extract nested
|
|
1394
|
+
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1395
|
+
if (nestedObj && keys.length <= 3) {
|
|
1396
|
+
const nested = item[nestedObj] as Record<string, unknown>;
|
|
1397
|
+
return { id: String(nested.id ?? nested[Object.keys(nested)[0]]), ...nested };
|
|
1398
|
+
}
|
|
1399
|
+
return { id: String(item.id ?? item[Object.keys(item)[0]]), ...item };
|
|
1400
|
+
});
|
|
1401
|
+
} else if (typeof v === "object" && v !== null) {
|
|
1402
|
+
// One-to-one relation — inline the object
|
|
1403
|
+
const relObj = v as Record<string, unknown>;
|
|
1404
|
+
flat[k] = { id: String(relObj.id ?? relObj[Object.keys(relObj)[0]]), ...relObj };
|
|
1405
|
+
} else {
|
|
1406
|
+
flat[k] = v;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return flat;
|
|
1410
|
+
});
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
console.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back:`, e);
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Fallback path used when db.query is unavailable.
|
|
1419
|
+
* The primary path uses db.query.findMany with `with` config, which
|
|
1420
|
+
* loads all relations in a single query.
|
|
1421
|
+
*
|
|
1422
|
+
* Batch fetch many-to-many related entities for multiple parent IDs.
|
|
1423
|
+
* Groups results by parent ID to avoid N+1.
|
|
1424
|
+
*/
|
|
1425
|
+
private async batchFetchManyRelatedEntities(
|
|
1426
|
+
parentCollectionPath: string,
|
|
1427
|
+
parentIds: (string | number)[],
|
|
1428
|
+
relationKey: string
|
|
1429
|
+
): Promise<Map<string, Entity[]>> {
|
|
1430
|
+
const resultMap = new Map<string, Entity[]>();
|
|
1431
|
+
|
|
1432
|
+
// Fetch for all parents using Promise.all (limited batch)
|
|
1433
|
+
const batchPromises = parentIds.map(async (parentId) => {
|
|
1434
|
+
try {
|
|
1435
|
+
const related = await this.relationService.fetchRelatedEntities(
|
|
1436
|
+
parentCollectionPath, parentId, relationKey, {}
|
|
1437
|
+
);
|
|
1438
|
+
resultMap.set(String(parentId), related);
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
resultMap.set(String(parentId), []);
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
await Promise.all(batchPromises);
|
|
1445
|
+
return resultMap;
|
|
1446
|
+
}
|
|
1447
|
+
}
|