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