@vibeorm/runtime 1.0.0

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.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Relation Loader
3
+ *
4
+ * Implements the hybrid loading strategy:
5
+ * - To-one relations: LEFT JOIN in the main query (future optimization)
6
+ * - To-many relations: Separate batched WHERE IN queries
7
+ *
8
+ * Currently uses batched queries for all relation types for simplicity.
9
+ * The JOIN strategy for to-one can be added later as an optimization.
10
+ */
11
+
12
+ import type {
13
+ ModelMeta,
14
+ ModelMetaMap,
15
+ RelationFieldMeta,
16
+ ProfilingContext,
17
+ } from "./types.ts";
18
+ import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
19
+ import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
20
+ import type { RelationSqlQuery } from "./query-builder.ts";
21
+
22
+ type SqlExecutor = (params: {
23
+ text: string;
24
+ values: unknown[];
25
+ }) => Promise<Record<string, unknown>[]>;
26
+
27
+ /**
28
+ * Load relations for a set of parent records.
29
+ *
30
+ * Examines the select/include args to determine which relations to load,
31
+ * executes batched queries in PARALLEL, and stitches the results onto
32
+ * parent records.
33
+ *
34
+ * Sibling relations are independent — they query different tables and write
35
+ * to different keys on the parent records — so they can safely run concurrently.
36
+ */
37
+ export async function loadRelations(params: {
38
+ parentRecords: Record<string, unknown>[];
39
+ parentModelMeta: ModelMeta;
40
+ allModelsMeta: ModelMetaMap;
41
+ args: Record<string, unknown>;
42
+ executor: SqlExecutor;
43
+ profilingCtx?: ProfilingContext;
44
+ }): Promise<Record<string, unknown>[]> {
45
+ const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx } =
46
+ params;
47
+
48
+ if (parentRecords.length === 0) return parentRecords;
49
+
50
+ const relationsToLoad = resolveRelationsToLoad({
51
+ parentModelMeta,
52
+ args,
53
+ });
54
+
55
+ if (relationsToLoad.length === 0) return parentRecords;
56
+
57
+ const modelMap = getModelByNameMap({ allModelsMeta });
58
+ const pkField = parentModelMeta.primaryKey[0]!;
59
+ const parentIds = [
60
+ ...new Set(
61
+ parentRecords
62
+ .map((r) => r[pkField])
63
+ .filter((id) => id !== undefined && id !== null)
64
+ ),
65
+ ];
66
+
67
+ if (parentIds.length === 0) return parentRecords;
68
+
69
+ // Phase 1: Load all sibling relations in parallel.
70
+ // Each relation query hits a different table and writes to a unique key
71
+ // on the parent records, so there are no data races.
72
+ await Promise.all(
73
+ relationsToLoad.map(async ({ relationMeta, nestedArgs }) => {
74
+ const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
75
+ if (!relatedModelMeta) return;
76
+
77
+ if (relationMeta.isList) {
78
+ await loadToManyRelation({
79
+ parentRecords,
80
+ parentModelMeta,
81
+ relationMeta,
82
+ relatedModelMeta,
83
+ parentIds,
84
+ nestedArgs,
85
+ allModelsMeta,
86
+ executor,
87
+ pkField,
88
+ profilingCtx,
89
+ });
90
+ } else if (relationMeta.isForeignKey) {
91
+ await loadToOneWithFk({
92
+ parentRecords,
93
+ parentModelMeta,
94
+ relationMeta,
95
+ relatedModelMeta,
96
+ nestedArgs,
97
+ allModelsMeta,
98
+ executor,
99
+ profilingCtx,
100
+ });
101
+ } else {
102
+ await loadToOneWithoutFk({
103
+ parentRecords,
104
+ parentModelMeta,
105
+ relationMeta,
106
+ relatedModelMeta,
107
+ parentIds,
108
+ nestedArgs,
109
+ allModelsMeta,
110
+ executor,
111
+ pkField,
112
+ profilingCtx,
113
+ });
114
+ }
115
+ })
116
+ );
117
+
118
+ // Phase 2: Recursively load nested relations in parallel.
119
+ // Each nested load operates on a different set of child records.
120
+ await Promise.all(
121
+ relationsToLoad
122
+ .filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
123
+ .map(async ({ relationMeta, nestedArgs }) => {
124
+ const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
125
+ if (!relatedModelMeta) return;
126
+
127
+ const loadedRecords = parentRecords
128
+ .flatMap((r) => {
129
+ const val = r[relationMeta.name];
130
+ if (Array.isArray(val)) return val;
131
+ if (val && typeof val === "object") return [val];
132
+ return [];
133
+ })
134
+ .filter((r): r is Record<string, unknown> => r !== null);
135
+
136
+ if (loadedRecords.length > 0) {
137
+ await loadRelations({
138
+ parentRecords: loadedRecords,
139
+ parentModelMeta: relatedModelMeta,
140
+ allModelsMeta,
141
+ args: nestedArgs,
142
+ executor,
143
+ });
144
+
145
+ // Strip auto-included PKs from child records if user's select didn't include them
146
+ const nestedSelect = nestedArgs.select as Record<string, boolean | object> | undefined;
147
+ if (nestedSelect) {
148
+ for (const pkName of relatedModelMeta.primaryKey) {
149
+ if (nestedSelect[pkName] === undefined) {
150
+ for (const rec of loadedRecords) {
151
+ delete rec[pkName];
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ })
158
+ );
159
+
160
+ return parentRecords;
161
+ }
162
+
163
+ // ─── To-Many Loader ───────────────────────────────────────────────
164
+
165
+ async function loadToManyRelation(params: {
166
+ parentRecords: Record<string, unknown>[];
167
+ parentModelMeta: ModelMeta;
168
+ relationMeta: RelationFieldMeta;
169
+ relatedModelMeta: ModelMeta;
170
+ parentIds: unknown[];
171
+ nestedArgs: Record<string, unknown>;
172
+ allModelsMeta: ModelMetaMap;
173
+ executor: SqlExecutor;
174
+ pkField: string;
175
+ profilingCtx?: ProfilingContext;
176
+ }): Promise<void> {
177
+ const {
178
+ parentRecords,
179
+ parentModelMeta,
180
+ relationMeta,
181
+ relatedModelMeta,
182
+ parentIds,
183
+ nestedArgs,
184
+ allModelsMeta,
185
+ executor,
186
+ pkField,
187
+ profilingCtx,
188
+ } = params;
189
+
190
+ // Choose builder based on relation type
191
+ const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
192
+
193
+ const query = isM2M
194
+ ? buildManyToManyQuery({
195
+ parentModelMeta,
196
+ relationMeta,
197
+ relatedModelMeta,
198
+ parentIds,
199
+ args: nestedArgs,
200
+ allModelsMeta,
201
+ })
202
+ : buildRelationQuery({
203
+ parentModelMeta,
204
+ relationMeta,
205
+ relatedModelMeta,
206
+ parentIds,
207
+ args: nestedArgs,
208
+ allModelsMeta,
209
+ });
210
+
211
+ const t0 = profilingCtx ? performance.now() : 0;
212
+ const rows = await executor(query);
213
+ if (profilingCtx) {
214
+ profilingCtx.relationProfiles.push({
215
+ relation: relationMeta.name,
216
+ sqlExecMs: performance.now() - t0,
217
+ rowCount: rows.length,
218
+ sql: query.text,
219
+ });
220
+ }
221
+
222
+ // Group by FK — use the already-selected field name when FK was deduped,
223
+ // otherwise use the __vibeorm_fk alias
224
+ const fkKey = query.fkFieldName ?? "__vibeorm_fk";
225
+ const grouped = new Map<unknown, Record<string, unknown>[]>();
226
+ for (const row of rows) {
227
+ const fk = row[fkKey];
228
+ if (!query.fkFieldName) delete row.__vibeorm_fk;
229
+ // Also clean up __vibeorm_rn from ROW_NUMBER windowed queries
230
+ if ("__vibeorm_rn" in row) delete row.__vibeorm_rn;
231
+ if (!grouped.has(fk)) {
232
+ grouped.set(fk, []);
233
+ }
234
+ grouped.get(fk)!.push(row);
235
+ }
236
+
237
+ // Stitch onto parent records
238
+ for (const parent of parentRecords) {
239
+ const parentId = parent[pkField];
240
+ parent[relationMeta.name] = grouped.get(parentId) ?? [];
241
+ }
242
+ }
243
+
244
+ // ─── To-One with FK (this side has foreignKey) ────────────────────
245
+
246
+ async function loadToOneWithFk(params: {
247
+ parentRecords: Record<string, unknown>[];
248
+ parentModelMeta: ModelMeta;
249
+ relationMeta: RelationFieldMeta;
250
+ relatedModelMeta: ModelMeta;
251
+ nestedArgs: Record<string, unknown>;
252
+ allModelsMeta: ModelMetaMap;
253
+ executor: SqlExecutor;
254
+ profilingCtx?: ProfilingContext;
255
+ }): Promise<void> {
256
+ const {
257
+ parentRecords,
258
+ parentModelMeta,
259
+ relationMeta,
260
+ relatedModelMeta,
261
+ nestedArgs,
262
+ allModelsMeta,
263
+ executor,
264
+ profilingCtx,
265
+ } = params;
266
+
267
+ // Get FK values from parent records (deduplicated for smaller IN clauses)
268
+ const fkFieldName = relationMeta.fields[0]!;
269
+ const fkValues = [
270
+ ...new Set(
271
+ parentRecords
272
+ .map((r) => r[fkFieldName])
273
+ .filter((v) => v !== undefined && v !== null)
274
+ ),
275
+ ];
276
+
277
+ if (fkValues.length === 0) {
278
+ for (const parent of parentRecords) {
279
+ parent[relationMeta.name] = null;
280
+ }
281
+ return;
282
+ }
283
+
284
+ // Query related model by its PK (which is what our FK references)
285
+ const refField = relationMeta.references[0]!;
286
+ const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
287
+ const refScalarField = sfMap.get(refField);
288
+ const refDbName = refScalarField?.dbName ?? refField;
289
+
290
+ const columns = resolveSelectColumnsForRelation({
291
+ modelMeta: relatedModelMeta,
292
+ args: nestedArgs,
293
+ });
294
+ const table = `"${relatedModelMeta.dbName}"`;
295
+ const columnsSql = columns
296
+ .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
297
+ .join(", ");
298
+
299
+ const text = `SELECT ${columnsSql}, ${table}."${refDbName}" AS "__vibeorm_pk" FROM ${table} WHERE ${table}."${refDbName}" = ANY($1)`;
300
+
301
+ const t0 = profilingCtx ? performance.now() : 0;
302
+ const rows = await executor({ text, values: [new PgArray(fkValues)] });
303
+ if (profilingCtx) {
304
+ profilingCtx.relationProfiles.push({
305
+ relation: relationMeta.name,
306
+ sqlExecMs: performance.now() - t0,
307
+ rowCount: rows.length,
308
+ sql: text,
309
+ });
310
+ }
311
+
312
+ // Index by PK
313
+ const byPk = new Map<unknown, Record<string, unknown>>();
314
+ for (const row of rows) {
315
+ const pk = row.__vibeorm_pk;
316
+ delete row.__vibeorm_pk;
317
+ byPk.set(pk, row);
318
+ }
319
+
320
+ // Stitch
321
+ for (const parent of parentRecords) {
322
+ const fkValue = parent[fkFieldName];
323
+ parent[relationMeta.name] = byPk.get(fkValue) ?? null;
324
+ }
325
+ }
326
+
327
+ // ─── To-One without FK (other side has FK) ────────────────────────
328
+
329
+ async function loadToOneWithoutFk(params: {
330
+ parentRecords: Record<string, unknown>[];
331
+ parentModelMeta: ModelMeta;
332
+ relationMeta: RelationFieldMeta;
333
+ relatedModelMeta: ModelMeta;
334
+ parentIds: unknown[];
335
+ nestedArgs: Record<string, unknown>;
336
+ allModelsMeta: ModelMetaMap;
337
+ executor: SqlExecutor;
338
+ pkField: string;
339
+ profilingCtx?: ProfilingContext;
340
+ }): Promise<void> {
341
+ const {
342
+ parentRecords,
343
+ parentModelMeta,
344
+ relationMeta,
345
+ relatedModelMeta,
346
+ parentIds,
347
+ nestedArgs,
348
+ allModelsMeta,
349
+ executor,
350
+ pkField,
351
+ profilingCtx,
352
+ } = params;
353
+
354
+ const query = buildRelationQuery({
355
+ parentModelMeta,
356
+ relationMeta,
357
+ relatedModelMeta,
358
+ parentIds,
359
+ args: nestedArgs,
360
+ allModelsMeta,
361
+ });
362
+
363
+ const t0 = profilingCtx ? performance.now() : 0;
364
+ const rows = await executor(query);
365
+ if (profilingCtx) {
366
+ profilingCtx.relationProfiles.push({
367
+ relation: relationMeta.name,
368
+ sqlExecMs: performance.now() - t0,
369
+ rowCount: rows.length,
370
+ sql: query.text,
371
+ });
372
+ }
373
+
374
+ // Index by FK (each FK should map to at most one record for to-one)
375
+ const fkKey = query.fkFieldName ?? "__vibeorm_fk";
376
+ const byFk = new Map<unknown, Record<string, unknown>>();
377
+ for (const row of rows) {
378
+ const fk = row[fkKey];
379
+ if (!query.fkFieldName) delete row.__vibeorm_fk;
380
+ byFk.set(fk, row);
381
+ }
382
+
383
+ // Stitch
384
+ for (const parent of parentRecords) {
385
+ const parentId = parent[pkField];
386
+ parent[relationMeta.name] = byFk.get(parentId) ?? null;
387
+ }
388
+ }
389
+
390
+ // ─── Helpers ──────────────────────────────────────────────────────
391
+
392
+ type RelationToLoad = {
393
+ relationMeta: RelationFieldMeta;
394
+ nestedArgs: Record<string, unknown>;
395
+ };
396
+
397
+ function resolveRelationsToLoad(params: {
398
+ parentModelMeta: ModelMeta;
399
+ args: Record<string, unknown>;
400
+ }): RelationToLoad[] {
401
+ const { parentModelMeta, args } = params;
402
+ const result: RelationToLoad[] = [];
403
+
404
+ const select = args.select as Record<string, unknown> | undefined;
405
+ const include = args.include as Record<string, unknown> | undefined;
406
+
407
+ for (const relationMeta of parentModelMeta.relationFields) {
408
+ let shouldLoad = false;
409
+ let nestedArgs: Record<string, unknown> = {};
410
+
411
+ if (select) {
412
+ const val = select[relationMeta.name];
413
+ if (val === true) {
414
+ shouldLoad = true;
415
+ } else if (typeof val === "object" && val !== null) {
416
+ shouldLoad = true;
417
+ nestedArgs = val as Record<string, unknown>;
418
+ }
419
+ }
420
+
421
+ if (include) {
422
+ const val = include[relationMeta.name];
423
+ if (val === true) {
424
+ shouldLoad = true;
425
+ } else if (typeof val === "object" && val !== null) {
426
+ shouldLoad = true;
427
+ nestedArgs = val as Record<string, unknown>;
428
+ }
429
+ }
430
+
431
+ if (shouldLoad) {
432
+ result.push({ relationMeta, nestedArgs });
433
+ }
434
+ }
435
+
436
+ return result;
437
+ }
438
+
439
+ function hasNestedRelations(params: {
440
+ nestedArgs: Record<string, unknown>;
441
+ }): boolean {
442
+ const { nestedArgs } = params;
443
+ return (
444
+ nestedArgs.include !== undefined ||
445
+ (typeof nestedArgs.select === "object" && nestedArgs.select !== null)
446
+ );
447
+ }
448
+
449
+ function resolveSelectColumnsForRelation(params: {
450
+ modelMeta: ModelMeta;
451
+ args: Record<string, unknown>;
452
+ }): { name: string; dbName: string; autoIncluded?: boolean }[] {
453
+ const { modelMeta, args } = params;
454
+ const select = args.select as Record<string, boolean | object> | undefined;
455
+
456
+ if (!select) {
457
+ return modelMeta.scalarFields.map((f) => ({
458
+ name: f.name,
459
+ dbName: f.dbName,
460
+ }));
461
+ }
462
+
463
+ const columns = modelMeta.scalarFields
464
+ .filter((f) => {
465
+ const val = select[f.name];
466
+ return val === true || (typeof val === "object" && val !== null);
467
+ })
468
+ .map((f) => ({ name: f.name, dbName: f.dbName }));
469
+
470
+ // Check if select has nested relations (object values referencing relation fields)
471
+ const hasNested = Object.entries(select).some(([key, val]) => {
472
+ if (typeof val !== "object" || val === null) return false;
473
+ return modelMeta.relationFields.some((r) => r.name === key);
474
+ });
475
+
476
+ if (hasNested) {
477
+ // Auto-include PK fields if not already selected
478
+ for (const pkName of modelMeta.primaryKey) {
479
+ if (!columns.some((c) => c.name === pkName)) {
480
+ const sf = modelMeta.scalarFields.find((f) => f.name === pkName);
481
+ if (sf) {
482
+ columns.push({ name: sf.name, dbName: sf.dbName, autoIncluded: true });
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ return columns;
489
+ }