@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,759 @@
1
+ /**
2
+ * Lateral JOIN Relation Loading Strategy
3
+ *
4
+ * Builds a SINGLE query that fetches parent records + all first-level
5
+ * relations using PostgreSQL LATERAL JOINs. This replaces both the
6
+ * initial SELECT and the relation loading in one round-trip.
7
+ *
8
+ * Produces SQL like:
9
+ *
10
+ * SELECT "User"."id" AS "id", "User"."email" AS "email",
11
+ * __lat_0."__vibeorm_rel_posts",
12
+ * __lat_1."__vibeorm_rel_profile"
13
+ * FROM "User"
14
+ * LEFT JOIN LATERAL (
15
+ * SELECT COALESCE(json_agg(json_build_object(
16
+ * 'id', __rel."id", 'title', __rel."title", ...
17
+ * )), '[]'::json) AS "__vibeorm_rel_posts"
18
+ * FROM "Post" __rel WHERE __rel."authorId" = "User"."id"
19
+ * ) __lat_0 ON true
20
+ * LEFT JOIN LATERAL (
21
+ * SELECT json_build_object(
22
+ * 'id', __rel."id", 'bio', __rel."bio", ...
23
+ * ) AS "__vibeorm_rel_profile"
24
+ * FROM "Profile" __rel WHERE __rel."userId" = "User"."id" LIMIT 1
25
+ * ) __lat_1 ON true
26
+ * WHERE "User"."role" = $1
27
+ * ORDER BY "User"."id" ASC
28
+ * LIMIT 20
29
+ */
30
+
31
+ import type {
32
+ ModelMeta,
33
+ ModelMetaMap,
34
+ RelationFieldMeta,
35
+ SqlQuery,
36
+ ProfilingContext,
37
+ } from "./types.ts";
38
+ import { getScalarFieldMap, getModelByNameMap } from "./types.ts";
39
+ import { buildWhereClause } from "./where-builder.ts";
40
+ import { loadRelations } from "./relation-loader.ts";
41
+
42
+ type SqlExecutor = (params: {
43
+ text: string;
44
+ values: unknown[];
45
+ }) => Promise<Record<string, unknown>[]>;
46
+
47
+ // ─── Query Builder ────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Build a complete SELECT query with lateral joins for relations.
51
+ * This replaces both buildSelectQuery + loadRelations in a single SQL statement.
52
+ */
53
+ export function buildLateralJoinQuery(params: {
54
+ modelMeta: ModelMeta;
55
+ allModelsMeta: ModelMetaMap;
56
+ args: Record<string, unknown>;
57
+ relationsToLoad: RelationToLoad[];
58
+ }): { query: SqlQuery; relationAliases: RelationAlias[] } {
59
+ const { modelMeta, allModelsMeta, args, relationsToLoad } = params;
60
+ const table = `"${modelMeta.dbName}"`;
61
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
62
+ const modelMap = getModelByNameMap({ allModelsMeta });
63
+
64
+ const pkField = modelMeta.primaryKey[0]!;
65
+ const pkScalar = sfMap.get(pkField);
66
+ const pkDbName = pkScalar?.dbName ?? pkField;
67
+
68
+ // Resolve which scalar columns to select on parent
69
+ const selectArg = args.select as Record<string, boolean | object> | undefined;
70
+ const parentScalars = selectArg
71
+ ? modelMeta.scalarFields.filter((f) => {
72
+ const val = selectArg[f.name];
73
+ return val === true || (typeof val === "object" && val !== null);
74
+ })
75
+ : modelMeta.scalarFields;
76
+
77
+ // Always include the PK so we can stitch results
78
+ const hasPk = parentScalars.some((f) => f.name === pkField);
79
+ const columnsToSelect = hasPk
80
+ ? parentScalars
81
+ : [pkScalar!, ...parentScalars];
82
+
83
+ const parentColumns = columnsToSelect
84
+ .map((f) => `${table}."${f.dbName}" AS "${f.name}"`)
85
+ .join(", ");
86
+
87
+ const allValues: unknown[] = [];
88
+ let paramIdx = 0;
89
+
90
+ // Build LATERAL JOINs for each relation
91
+ const lateralJoins: string[] = [];
92
+ const lateralSelects: string[] = [];
93
+ const relationAliases: RelationAlias[] = [];
94
+
95
+ for (let i = 0; i < relationsToLoad.length; i++) {
96
+ const { relationMeta, nestedArgs } = relationsToLoad[i]!;
97
+ const alias = `__lat_${i}`;
98
+ const colAlias = `__vibeorm_rel_${relationMeta.name}`;
99
+
100
+ // Find the related model meta
101
+ const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
102
+ if (!relatedModelMeta) continue;
103
+
104
+ // Determine FK column and join condition
105
+ const { fkDbName, fkTable } = resolveFkColumn({
106
+ parentModelMeta: modelMeta,
107
+ relationMeta,
108
+ relatedModelMeta,
109
+ });
110
+
111
+ // Build json_build_object fields for the related model.
112
+ // Respect nested select/omit to avoid serialising columns the caller doesn't need.
113
+ const relatedScalars = resolveRelatedScalars({
114
+ relatedModelMeta,
115
+ nestedArgs,
116
+ });
117
+ const jsonFields = relatedScalars
118
+ .map((f) => `'${f.name}', __rel."${f.dbName}"`)
119
+ .join(", ");
120
+
121
+ let lateralSubquery: string;
122
+
123
+ if (relationMeta.isList) {
124
+ // To-many: use json_agg with COALESCE for empty arrays
125
+ const subWhere = `__rel."${fkDbName}" = ${table}."${pkDbName}"`;
126
+
127
+ // Nested where filter from include/select args
128
+ let nestedWhereSql = "";
129
+ if (nestedArgs.where) {
130
+ // Build nested where using __rel alias for the related model
131
+ const relMetaWithAlias = {
132
+ ...relatedModelMeta,
133
+ dbName: "__rel" as string, // temporarily override for SQL generation
134
+ } as unknown as typeof relatedModelMeta;
135
+ // We can't easily re-alias, so build with original and replace table ref
136
+ const nestedWhereResult = buildWhereClause({
137
+ where: nestedArgs.where as Record<string, unknown>,
138
+ modelMeta: relatedModelMeta,
139
+ allModelsMeta,
140
+ paramOffset: paramIdx,
141
+ });
142
+ if (nestedWhereResult.sql) {
143
+ // Replace "TableName". with __rel. for the subquery alias
144
+ const aliasedSql = nestedWhereResult.sql.replace(
145
+ new RegExp(`"${relatedModelMeta.dbName}"\\."`, "g"),
146
+ `__rel."`
147
+ );
148
+ nestedWhereSql = ` AND ${aliasedSql}`;
149
+ paramIdx += nestedWhereResult.values.length;
150
+ allValues.push(...nestedWhereResult.values);
151
+ }
152
+ }
153
+
154
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
155
+
156
+ let orderBySql = "";
157
+ if (nestedArgs.orderBy) {
158
+ const orderByItems = Array.isArray(nestedArgs.orderBy)
159
+ ? (nestedArgs.orderBy as Record<string, string>[])
160
+ : [nestedArgs.orderBy as Record<string, string>];
161
+ const clauses = orderByItems.flatMap((item) =>
162
+ Object.entries(item).map(([field, dir]) => {
163
+ const sf = relatedSfMap.get(field);
164
+ const col = sf ? sf.dbName : field;
165
+ return `__rel."${col}" ${(dir as string).toUpperCase()}`;
166
+ })
167
+ );
168
+ if (clauses.length > 0) {
169
+ orderBySql = ` ORDER BY ${clauses.join(", ")}`;
170
+ }
171
+ }
172
+
173
+ let limitSql = "";
174
+ if (nestedArgs.take !== undefined) {
175
+ paramIdx++;
176
+ limitSql = ` LIMIT $${paramIdx}`;
177
+ allValues.push(nestedArgs.take);
178
+ }
179
+
180
+ let offsetSql = "";
181
+ if (nestedArgs.skip !== undefined) {
182
+ paramIdx++;
183
+ offsetSql = ` OFFSET $${paramIdx}`;
184
+ allValues.push(nestedArgs.skip);
185
+ }
186
+
187
+ // When take or skip is specified, use a subquery to apply per-parent LIMIT/OFFSET properly
188
+ if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
189
+ lateralSubquery = `LEFT JOIN LATERAL (
190
+ SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})), '[]'::jsonb) AS "${colAlias}"
191
+ FROM (
192
+ SELECT * FROM "${relatedModelMeta.dbName}" __rel
193
+ WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
194
+ ) __rel
195
+ ) ${alias} ON true`;
196
+ } else {
197
+ lateralSubquery = `LEFT JOIN LATERAL (
198
+ SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
199
+ FROM "${relatedModelMeta.dbName}" __rel
200
+ WHERE ${subWhere}${nestedWhereSql}
201
+ ) ${alias} ON true`;
202
+ }
203
+ } else {
204
+ // To-one: use json_build_object with LIMIT 1
205
+ let joinCondition: string;
206
+
207
+ if (fkTable === "parent") {
208
+ // Parent holds FK (e.g., Post.authorId -> User.id)
209
+ const parentFkField = relationMeta.fields[0]!;
210
+ const parentFkScalar = sfMap.get(parentFkField);
211
+ const parentFkDbName = parentFkScalar?.dbName ?? parentFkField;
212
+ const refField = relationMeta.references[0]!;
213
+ const relatedSfMapForOne = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
214
+ const refScalar = relatedSfMapForOne.get(refField);
215
+ const refDbName = refScalar?.dbName ?? refField;
216
+ joinCondition = `__rel."${refDbName}" = ${table}."${parentFkDbName}"`;
217
+ } else {
218
+ // Related model holds FK (e.g., User.profile -> Profile.userId)
219
+ joinCondition = `__rel."${fkDbName}" = ${table}."${pkDbName}"`;
220
+ }
221
+
222
+ lateralSubquery = `LEFT JOIN LATERAL (
223
+ SELECT json_build_object(${jsonFields}) AS "${colAlias}"
224
+ FROM "${relatedModelMeta.dbName}" __rel
225
+ WHERE ${joinCondition}
226
+ LIMIT 1
227
+ ) ${alias} ON true`;
228
+ }
229
+
230
+ lateralJoins.push(lateralSubquery);
231
+ lateralSelects.push(`${alias}."${colAlias}"`);
232
+ relationAliases.push({ alias: colAlias, relationMeta, nestedArgs });
233
+ }
234
+
235
+ // Build WHERE from args
236
+ const whereResult = buildWhereClause({
237
+ where: args.where as Record<string, unknown> | undefined,
238
+ modelMeta,
239
+ allModelsMeta,
240
+ paramOffset: paramIdx,
241
+ });
242
+ paramIdx += whereResult.values.length;
243
+ allValues.push(...whereResult.values);
244
+
245
+ // Build ORDER BY
246
+ let orderBySql = "";
247
+ if (args.orderBy) {
248
+ const orderByItems = Array.isArray(args.orderBy)
249
+ ? (args.orderBy as Record<string, string>[])
250
+ : [args.orderBy as Record<string, string>];
251
+
252
+ const orderClauses = orderByItems.flatMap((item) =>
253
+ Object.entries(item).map(([field, direction]) => {
254
+ const scalarField = sfMap.get(field);
255
+ const col = scalarField ? scalarField.dbName : field;
256
+ return `${table}."${col}" ${direction.toUpperCase()}`;
257
+ })
258
+ );
259
+
260
+ if (orderClauses.length > 0) {
261
+ orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
262
+ }
263
+ }
264
+
265
+ // Build LIMIT / OFFSET
266
+ let limitSql = "";
267
+ if (args.take !== undefined) {
268
+ paramIdx++;
269
+ limitSql = ` LIMIT $${paramIdx}`;
270
+ allValues.push(args.take);
271
+ }
272
+
273
+ let offsetSql = "";
274
+ if (args.skip !== undefined) {
275
+ paramIdx++;
276
+ offsetSql = ` OFFSET $${paramIdx}`;
277
+ allValues.push(args.skip);
278
+ }
279
+
280
+ const selectSql = [parentColumns, ...lateralSelects].join(", ");
281
+ const joinsPart = lateralJoins.length > 0
282
+ ? `\n${lateralJoins.join("\n")}`
283
+ : "";
284
+
285
+ // ── CTE optimisation ────────────────────────────────────────────
286
+ // When the WHERE clause contains relation-based filters (some/every/
287
+ // none/is/isNot), the where-builder emits EXISTS / NOT EXISTS
288
+ // sub-queries. If we put that WHERE after the LATERAL JOINs the
289
+ // planner may execute every lateral join for every parent row before
290
+ // filtering. Wrapping the base set in a CTE with LIMIT/OFFSET
291
+ // guarantees PostgreSQL resolves the matching PKs first, then runs
292
+ // the lateral joins only against the matched rows.
293
+ //
294
+ // We also enable the CTE when LIMIT is present and there are 3+
295
+ // lateral joins. Without it Postgres may evaluate every lateral for
296
+ // every candidate row before the LIMIT prunes the result set.
297
+ const hasRelFilter = whereHasRelationFilter({
298
+ where: args.where as Record<string, unknown> | undefined,
299
+ modelMeta,
300
+ });
301
+ const hasHeavyIncludes =
302
+ args.take !== undefined && relationsToLoad.length >= 3;
303
+ const useCte = hasRelFilter || hasHeavyIncludes;
304
+
305
+ let text: string;
306
+
307
+ if (useCte) {
308
+ // CTE selects only the PK, applying WHERE + ORDER + LIMIT + OFFSET
309
+ const cteWherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
310
+ const cteSql =
311
+ `WITH __vibeorm_base AS (` +
312
+ `SELECT ${table}."${pkDbName}" FROM ${table}` +
313
+ `${cteWherePart}${orderBySql}${limitSql}${offsetSql}` +
314
+ `)`;
315
+
316
+ // Outer query joins the CTE to the parent table, then lateral joins
317
+ text =
318
+ `${cteSql} SELECT ${selectSql} FROM ${table}` +
319
+ ` INNER JOIN __vibeorm_base ON __vibeorm_base."${pkDbName}" = ${table}."${pkDbName}"` +
320
+ `${joinsPart}${orderBySql}`;
321
+ } else {
322
+ // No relation filter — standard flat query (existing behaviour)
323
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
324
+ text = `SELECT ${selectSql} FROM ${table}${joinsPart}${wherePart}${orderBySql}${limitSql}${offsetSql}`;
325
+ }
326
+
327
+ return {
328
+ query: { text, values: allValues },
329
+ relationAliases,
330
+ };
331
+ }
332
+
333
+ // ─── Executor (single-query path) ─────────────────────────────────
334
+
335
+ /**
336
+ * Execute a findMany/findFirst/findUnique with lateral joins.
337
+ * Returns fully-hydrated records in a single DB round-trip.
338
+ */
339
+ export async function executeLateralJoinQuery(params: {
340
+ modelMeta: ModelMeta;
341
+ allModelsMeta: ModelMetaMap;
342
+ args: Record<string, unknown>;
343
+ executor: SqlExecutor;
344
+ profilingCtx?: ProfilingContext;
345
+ }): Promise<Record<string, unknown>[]> {
346
+ const { modelMeta, allModelsMeta, args, executor, profilingCtx } = params;
347
+ const profiling = !!profilingCtx;
348
+
349
+ const t0 = profiling ? performance.now() : 0;
350
+
351
+ const relationsToLoad = resolveRelationsToLoad({ parentModelMeta: modelMeta, args });
352
+
353
+ // If no relations to load, this shouldn't have been called, but handle gracefully
354
+ if (relationsToLoad.length === 0) {
355
+ // Fall through — the caller should use buildSelectQuery instead
356
+ // But just in case, import the builder dynamically wouldn't work, so just build a simple query
357
+ const { buildSelectQuery } = await import("./query-builder.ts");
358
+ const query = buildSelectQuery({ modelMeta, allModelsMeta, args });
359
+ return executor(query);
360
+ }
361
+
362
+ const { query, relationAliases } = buildLateralJoinQuery({
363
+ modelMeta,
364
+ allModelsMeta,
365
+ args,
366
+ relationsToLoad,
367
+ });
368
+
369
+ const t1 = profiling ? performance.now() : 0;
370
+
371
+ const rows = await executor(query);
372
+
373
+ const t2 = profiling ? performance.now() : 0;
374
+
375
+ // Parse JSON relation columns
376
+ const pkField = modelMeta.primaryKey[0]!;
377
+ const results: Record<string, unknown>[] = [];
378
+
379
+ for (const row of rows) {
380
+ const record: Record<string, unknown> = {};
381
+
382
+ // Copy scalar fields
383
+ for (const sf of modelMeta.scalarFields) {
384
+ if (row[sf.name] !== undefined) {
385
+ record[sf.name] = row[sf.name];
386
+ }
387
+ }
388
+
389
+ // Parse relation columns from JSON
390
+ for (const { alias, relationMeta } of relationAliases) {
391
+ const jsonValue = row[alias];
392
+ if (jsonValue === null || jsonValue === undefined) {
393
+ record[relationMeta.name] = relationMeta.isList ? [] : null;
394
+ } else if (typeof jsonValue === "string") {
395
+ try {
396
+ record[relationMeta.name] = JSON.parse(jsonValue);
397
+ } catch {
398
+ record[relationMeta.name] = relationMeta.isList ? [] : null;
399
+ }
400
+ } else {
401
+ // Already parsed by bun:sql (json/jsonb columns auto-parse)
402
+ record[relationMeta.name] = jsonValue;
403
+ }
404
+ }
405
+
406
+ results.push(record);
407
+ }
408
+
409
+ const t3 = profiling ? performance.now() : 0;
410
+
411
+ // Recursively load nested relations (deeper levels fall back to batched)
412
+ const nestedModelMap = getModelByNameMap({ allModelsMeta });
413
+ await Promise.all(
414
+ relationAliases
415
+ .filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
416
+ .map(async ({ relationMeta, nestedArgs }) => {
417
+ const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
418
+ if (!relatedModelMeta) return;
419
+
420
+ const allRelated = results
421
+ .flatMap((r) => {
422
+ const val = r[relationMeta.name];
423
+ if (Array.isArray(val)) return val;
424
+ if (val && typeof val === "object") return [val];
425
+ return [];
426
+ })
427
+ .filter((r): r is Record<string, unknown> => r !== null);
428
+
429
+ if (allRelated.length > 0) {
430
+ await loadRelations({
431
+ parentRecords: allRelated,
432
+ parentModelMeta: relatedModelMeta,
433
+ allModelsMeta,
434
+ args: nestedArgs,
435
+ executor,
436
+ });
437
+ }
438
+ })
439
+ );
440
+
441
+ // Populate profiling context with per-phase timings
442
+ if (profilingCtx) {
443
+ profilingCtx.queryBuildMs = t1 - t0;
444
+ profilingCtx.sqlExecMs = t2 - t1;
445
+ profilingCtx.resultMapMs = t3 - t2;
446
+ profilingCtx.sql = query.text;
447
+ profilingCtx.sqlValues = query.values;
448
+ profilingCtx.rowCount = rows.length;
449
+ }
450
+
451
+ return results;
452
+ }
453
+
454
+ // ─── Legacy entry point (kept for backward compat) ────────────────
455
+
456
+ /**
457
+ * @deprecated Use executeLateralJoinQuery instead. This function exists
458
+ * for backward compatibility and performs 2 queries instead of 1.
459
+ */
460
+ export async function loadRelationsWithLateralJoin(params: {
461
+ parentRecords: Record<string, unknown>[];
462
+ parentModelMeta: ModelMeta;
463
+ allModelsMeta: ModelMetaMap;
464
+ args: Record<string, unknown>;
465
+ executor: SqlExecutor;
466
+ profilingCtx?: ProfilingContext;
467
+ }): Promise<Record<string, unknown>[]> {
468
+ // For the post-processing path (create, update, etc.), we still need
469
+ // the 2-query approach since the parent records are already fetched.
470
+ // But we can use the lateral join to load relations in 1 query instead of N.
471
+ const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx } = params;
472
+
473
+ if (parentRecords.length === 0) return parentRecords;
474
+
475
+ const relationsToLoad = resolveRelationsToLoad({ parentModelMeta, args });
476
+ if (relationsToLoad.length === 0) return parentRecords;
477
+
478
+ const pkField = parentModelMeta.primaryKey[0]!;
479
+ const parentIds = [
480
+ ...new Set(
481
+ parentRecords
482
+ .map((r) => r[pkField])
483
+ .filter((id) => id !== undefined && id !== null)
484
+ ),
485
+ ];
486
+
487
+ if (parentIds.length === 0) return parentRecords;
488
+
489
+ // Build a lateral join query scoped to these parent PKs
490
+ const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
491
+ const pkScalar = parentSfMap.get(pkField);
492
+ const pkDbName = pkScalar?.dbName ?? pkField;
493
+ const table = `"${parentModelMeta.dbName}"`;
494
+
495
+ // Use the lateral join builder with a synthetic WHERE IN for the PKs
496
+ const syntheticArgs = {
497
+ ...args,
498
+ where: {
499
+ [pkField]: { in: parentIds },
500
+ },
501
+ };
502
+
503
+ const { query, relationAliases } = buildLateralJoinQuery({
504
+ modelMeta: parentModelMeta,
505
+ allModelsMeta,
506
+ args: syntheticArgs,
507
+ relationsToLoad,
508
+ });
509
+
510
+ const rows = await executor(query);
511
+
512
+ // Index by PK
513
+ const relMap = new Map<unknown, Record<string, unknown>>();
514
+ for (const row of rows) {
515
+ const pk = row[pkField];
516
+ const parsed: Record<string, unknown> = {};
517
+ for (const { alias, relationMeta } of relationAliases) {
518
+ const jsonValue = row[alias];
519
+ if (jsonValue === null || jsonValue === undefined) {
520
+ parsed[relationMeta.name] = relationMeta.isList ? [] : null;
521
+ } else if (typeof jsonValue === "string") {
522
+ try {
523
+ parsed[relationMeta.name] = JSON.parse(jsonValue);
524
+ } catch {
525
+ parsed[relationMeta.name] = relationMeta.isList ? [] : null;
526
+ }
527
+ } else {
528
+ parsed[relationMeta.name] = jsonValue;
529
+ }
530
+ }
531
+ relMap.set(pk, parsed);
532
+ }
533
+
534
+ // Stitch relations onto the original parent records
535
+ for (const parent of parentRecords) {
536
+ const pk = parent[pkField];
537
+ const rels = relMap.get(pk);
538
+ if (rels) {
539
+ for (const [key, value] of Object.entries(rels)) {
540
+ parent[key] = value;
541
+ }
542
+ } else {
543
+ for (const { relationMeta } of relationAliases) {
544
+ parent[relationMeta.name] = relationMeta.isList ? [] : null;
545
+ }
546
+ }
547
+ }
548
+
549
+ // Recursively load nested relations in parallel
550
+ const legacyModelMap = getModelByNameMap({ allModelsMeta });
551
+ await Promise.all(
552
+ relationAliases
553
+ .filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
554
+ .map(async ({ relationMeta, nestedArgs }) => {
555
+ const relatedModelMeta = legacyModelMap.get(relationMeta.relatedModel);
556
+ if (!relatedModelMeta) return;
557
+
558
+ const allRelated = parentRecords
559
+ .flatMap((r) => {
560
+ const val = r[relationMeta.name];
561
+ if (Array.isArray(val)) return val;
562
+ if (val && typeof val === "object") return [val];
563
+ return [];
564
+ })
565
+ .filter((r): r is Record<string, unknown> => r !== null);
566
+
567
+ if (allRelated.length > 0) {
568
+ await loadRelations({
569
+ parentRecords: allRelated,
570
+ parentModelMeta: relatedModelMeta,
571
+ allModelsMeta,
572
+ args: nestedArgs,
573
+ executor,
574
+ });
575
+ }
576
+ })
577
+ );
578
+
579
+ return parentRecords;
580
+ }
581
+
582
+ // ─── Helpers ──────────────────────────────────────────────────────
583
+
584
+ type RelationToLoad = {
585
+ relationMeta: RelationFieldMeta;
586
+ nestedArgs: Record<string, unknown>;
587
+ };
588
+
589
+ type RelationAlias = {
590
+ alias: string;
591
+ relationMeta: RelationFieldMeta;
592
+ nestedArgs: Record<string, unknown>;
593
+ };
594
+
595
+ export function resolveRelationsToLoad(params: {
596
+ parentModelMeta: ModelMeta;
597
+ args: Record<string, unknown>;
598
+ }): RelationToLoad[] {
599
+ const { parentModelMeta, args } = params;
600
+ const result: RelationToLoad[] = [];
601
+
602
+ const select = args.select as Record<string, unknown> | undefined;
603
+ const include = args.include as Record<string, unknown> | undefined;
604
+
605
+ for (const relationMeta of parentModelMeta.relationFields) {
606
+ let shouldLoad = false;
607
+ let nestedArgs: Record<string, unknown> = {};
608
+
609
+ if (select) {
610
+ const val = select[relationMeta.name];
611
+ if (val === true) {
612
+ shouldLoad = true;
613
+ } else if (typeof val === "object" && val !== null) {
614
+ shouldLoad = true;
615
+ nestedArgs = val as Record<string, unknown>;
616
+ }
617
+ }
618
+
619
+ if (include) {
620
+ const val = include[relationMeta.name];
621
+ if (val === true) {
622
+ shouldLoad = true;
623
+ } else if (typeof val === "object" && val !== null) {
624
+ shouldLoad = true;
625
+ nestedArgs = val as Record<string, unknown>;
626
+ }
627
+ }
628
+
629
+ if (shouldLoad) {
630
+ result.push({ relationMeta, nestedArgs });
631
+ }
632
+ }
633
+
634
+ return result;
635
+ }
636
+
637
+ function hasNestedRelations(params: { nestedArgs: Record<string, unknown> }): boolean {
638
+ const { nestedArgs } = params;
639
+ return (
640
+ nestedArgs.include !== undefined ||
641
+ (typeof nestedArgs.select === "object" && nestedArgs.select !== null)
642
+ );
643
+ }
644
+
645
+ /**
646
+ * Check whether a Prisma-style where object references any relation field
647
+ * on the given model. Walks AND / OR / NOT combinators recursively.
648
+ * Returns true if any key in the where tree corresponds to a relation field,
649
+ * which means the where-builder will emit EXISTS / NOT EXISTS sub-queries.
650
+ */
651
+ export function whereHasRelationFilter(params: {
652
+ where: Record<string, unknown> | undefined;
653
+ modelMeta: ModelMeta;
654
+ }): boolean {
655
+ const { where, modelMeta } = params;
656
+ if (!where || typeof where !== "object") return false;
657
+
658
+ const relationNames = new Set(
659
+ (modelMeta.relationFields ?? []).map((r) => r.name)
660
+ );
661
+
662
+ for (const [key, value] of Object.entries(where)) {
663
+ if (value === undefined) continue;
664
+
665
+ // Logical combinators — recurse
666
+ if (key === "AND" || key === "OR" || key === "NOT") {
667
+ const items = Array.isArray(value) ? value : [value];
668
+ for (const item of items) {
669
+ if (
670
+ whereHasRelationFilter({
671
+ where: item as Record<string, unknown>,
672
+ modelMeta,
673
+ })
674
+ ) {
675
+ return true;
676
+ }
677
+ }
678
+ continue;
679
+ }
680
+
681
+ // Direct relation filter key (some/every/none/is/isNot or shorthand)
682
+ if (relationNames.has(key)) {
683
+ return true;
684
+ }
685
+ }
686
+
687
+ return false;
688
+ }
689
+
690
+ /**
691
+ * Resolve which scalar columns to include in a lateral join's json_build_object.
692
+ * Respects nested select/omit so we don't serialise unnecessary columns.
693
+ * Always includes the PK so nested relation loading can stitch correctly.
694
+ */
695
+ function resolveRelatedScalars(params: {
696
+ relatedModelMeta: ModelMeta;
697
+ nestedArgs: Record<string, unknown>;
698
+ }): readonly { readonly name: string; readonly dbName: string }[] {
699
+ const { relatedModelMeta, nestedArgs } = params;
700
+ const selectArg = nestedArgs.select as Record<string, boolean | object> | undefined;
701
+ const omitArg = nestedArgs.omit as Record<string, boolean> | undefined;
702
+
703
+ if (selectArg) {
704
+ const selected = relatedModelMeta.scalarFields.filter((f) => {
705
+ const val = selectArg[f.name];
706
+ return val === true || (typeof val === "object" && val !== null);
707
+ });
708
+
709
+ // Always include PK for relation stitching
710
+ for (const pkName of relatedModelMeta.primaryKey) {
711
+ if (!selected.some((f) => f.name === pkName)) {
712
+ const sf = relatedModelMeta.scalarFields.find((f) => f.name === pkName);
713
+ if (sf) selected.push(sf);
714
+ }
715
+ }
716
+
717
+ return selected;
718
+ }
719
+
720
+ if (omitArg) {
721
+ return relatedModelMeta.scalarFields.filter((f) => !omitArg[f.name]);
722
+ }
723
+
724
+ // Default: all scalars
725
+ return relatedModelMeta.scalarFields;
726
+ }
727
+
728
+ function resolveFkColumn(params: {
729
+ parentModelMeta: ModelMeta;
730
+ relationMeta: RelationFieldMeta;
731
+ relatedModelMeta: ModelMeta;
732
+ }): { fkDbName: string; fkTable: "parent" | "related" } {
733
+ const { parentModelMeta, relationMeta, relatedModelMeta } = params;
734
+
735
+ if (relationMeta.isForeignKey) {
736
+ // This side (parent) holds the FK
737
+ return { fkDbName: "", fkTable: "parent" };
738
+ }
739
+
740
+ // The related model holds the FK -- find it (with relationName disambiguation)
741
+ const parentRelationName = (relationMeta as { relationName?: string }).relationName;
742
+ const reverseRelation = relatedModelMeta.relationFields.find(
743
+ (r) => r.relatedModel === parentModelMeta.name && r.isForeignKey &&
744
+ (!parentRelationName || r.relationName === parentRelationName)
745
+ );
746
+
747
+ if (reverseRelation) {
748
+ const fkFieldName = reverseRelation.fields[0]!;
749
+ const fkSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
750
+ const fkField = fkSfMap.get(fkFieldName);
751
+ return { fkDbName: fkField?.dbName ?? fkFieldName, fkTable: "related" };
752
+ }
753
+
754
+ // Fallback convention
755
+ return {
756
+ fkDbName: `${parentModelMeta.name.toLowerCase()}Id`,
757
+ fkTable: "related",
758
+ };
759
+ }