@vibeorm/runtime 1.3.0 → 2.0.0-alpha.2
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/README.md +50 -107
- package/dist/adapter.d.ts +124 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/codecs.d.ts +170 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/computed.d.ts +43 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/extensions.d.ts +102 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6625 -0
- package/dist/index.js.map +21 -0
- package/dist/model-meta.d.ts +156 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/nested-writes.d.ts +100 -0
- package/dist/nested-writes.d.ts.map +1 -0
- package/dist/query-builder.d.ts +250 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/relation-loader.d.ts +75 -0
- package/dist/relation-loader.d.ts.map +1 -0
- package/dist/relation-plan.d.ts +103 -0
- package/dist/relation-plan.d.ts.map +1 -0
- package/dist/render-cache.d.ts +48 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/views.d.ts +97 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +31 -26
- package/src/adapter.ts +0 -146
- package/src/client.ts +0 -2172
- package/src/coerce.ts +0 -184
- package/src/count-loader.ts +0 -152
- package/src/errors.ts +0 -492
- package/src/id-generators.ts +0 -151
- package/src/index.ts +0 -55
- package/src/lateral-join-builder.ts +0 -1053
- package/src/query-builder.ts +0 -1832
- package/src/relation-loader.ts +0 -534
- package/src/retry.ts +0 -183
- package/src/types.ts +0 -317
- package/src/view.ts +0 -629
- package/src/where-builder.ts +0 -772
|
@@ -1,1053 +0,0 @@
|
|
|
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(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb)
|
|
16
|
-
* AS "__vibeorm_rel_posts"
|
|
17
|
-
* FROM (
|
|
18
|
-
* SELECT __rel."id" AS "id", __rel."title" AS "title", ...
|
|
19
|
-
* FROM "Post" __rel WHERE __rel."authorId" = "User"."id"
|
|
20
|
-
* ) "__sub"
|
|
21
|
-
* ) __lat_0 ON true
|
|
22
|
-
* LEFT JOIN LATERAL (
|
|
23
|
-
* SELECT to_jsonb("__sub".*) AS "__vibeorm_rel_profile"
|
|
24
|
-
* FROM (
|
|
25
|
-
* SELECT __rel."id" AS "id", __rel."bio" AS "bio", ...
|
|
26
|
-
* FROM "Profile" __rel WHERE __rel."userId" = "User"."id"
|
|
27
|
-
* LIMIT 1
|
|
28
|
-
* ) "__sub"
|
|
29
|
-
* ) __lat_1 ON true
|
|
30
|
-
* WHERE "User"."role" = $1
|
|
31
|
-
* ORDER BY "User"."id" ASC
|
|
32
|
-
* LIMIT 20
|
|
33
|
-
*
|
|
34
|
-
* Uses to_jsonb() on a subselect instead of json_build_object() to avoid
|
|
35
|
-
* PostgreSQL's 100-argument function limit (models with >50 scalar fields
|
|
36
|
-
* would exceed this). Column aliases in the inner SELECT handle the
|
|
37
|
-
* dbName→tsName remapping at zero cost.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import type {
|
|
41
|
-
ModelMeta,
|
|
42
|
-
ModelMetaMap,
|
|
43
|
-
RelationFieldMeta,
|
|
44
|
-
SqlQuery,
|
|
45
|
-
ProfilingContext,
|
|
46
|
-
} from "./types.ts";
|
|
47
|
-
import { getScalarFieldMap, getModelByNameMap } from "./types.ts";
|
|
48
|
-
import { buildWhereClause } from "./where-builder.ts";
|
|
49
|
-
import { sanitizeDirection } from "./query-builder.ts";
|
|
50
|
-
import { loadRelations, resolveRelationsToLoad, hasNestedRelations } from "./relation-loader.ts";
|
|
51
|
-
import type { RelationToLoad } from "./relation-loader.ts";
|
|
52
|
-
import { coerceFieldTypes, modelNeedsCoercion } from "./coerce.ts";
|
|
53
|
-
import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
|
|
54
|
-
|
|
55
|
-
type SqlExecutor = (params: {
|
|
56
|
-
text: string;
|
|
57
|
-
values: unknown[];
|
|
58
|
-
}) => Promise<Record<string, unknown>[]>;
|
|
59
|
-
|
|
60
|
-
function reverseDirection(params: { direction: string }): string {
|
|
61
|
-
const normalized = sanitizeDirection({ direction: params.direction });
|
|
62
|
-
return normalized === "ASC" ? "DESC" : "ASC";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ─── Query Builder ────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build a complete SELECT query with lateral joins for relations.
|
|
69
|
-
* This replaces both buildSelectQuery + loadRelations in a single SQL statement.
|
|
70
|
-
*/
|
|
71
|
-
export function buildLateralJoinQuery(params: {
|
|
72
|
-
modelMeta: ModelMeta;
|
|
73
|
-
allModelsMeta: ModelMetaMap;
|
|
74
|
-
args: Record<string, unknown>;
|
|
75
|
-
relationsToLoad: RelationToLoad[];
|
|
76
|
-
defaultOrderByPk?: boolean;
|
|
77
|
-
}): { query: SqlQuery; relationAliases: RelationAlias[] } {
|
|
78
|
-
const { modelMeta, allModelsMeta, args, relationsToLoad, defaultOrderByPk = false } = params;
|
|
79
|
-
const table = `"${modelMeta.dbName}"`;
|
|
80
|
-
const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
|
|
81
|
-
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
82
|
-
|
|
83
|
-
const pkField = modelMeta.primaryKey[0]!;
|
|
84
|
-
const pkScalar = sfMap.get(pkField);
|
|
85
|
-
const pkDbName = pkScalar?.dbName ?? pkField;
|
|
86
|
-
|
|
87
|
-
// Resolve which scalar columns to select on parent
|
|
88
|
-
const selectArg = args.select as Record<string, boolean | object> | undefined;
|
|
89
|
-
const parentScalars = selectArg
|
|
90
|
-
? modelMeta.scalarFields.filter((f) => {
|
|
91
|
-
const val = selectArg[f.name];
|
|
92
|
-
return val === true || (typeof val === "object" && val !== null);
|
|
93
|
-
})
|
|
94
|
-
: modelMeta.scalarFields;
|
|
95
|
-
|
|
96
|
-
// Always include the PK so we can stitch results
|
|
97
|
-
const hasPk = parentScalars.some((f) => f.name === pkField);
|
|
98
|
-
const columnsToSelect = hasPk
|
|
99
|
-
? parentScalars
|
|
100
|
-
: [pkScalar!, ...parentScalars];
|
|
101
|
-
|
|
102
|
-
const parentColumns = columnsToSelect
|
|
103
|
-
.map((f) => `${table}."${f.dbName}" AS "${f.name}"`)
|
|
104
|
-
.join(", ");
|
|
105
|
-
|
|
106
|
-
const allValues: unknown[] = [];
|
|
107
|
-
let paramIdx = 0;
|
|
108
|
-
|
|
109
|
-
const take = typeof args.take === "number" ? (args.take as number) : undefined;
|
|
110
|
-
|
|
111
|
-
// Negative take is only valid with cursor-based pagination (backward page)
|
|
112
|
-
if (take !== undefined && take < 0 && args.cursor === undefined) {
|
|
113
|
-
throw new Error(
|
|
114
|
-
`Negative \`take\` (${take}) is only supported with cursor-based pagination on model "${modelMeta.name}". Provide a \`cursor\` argument or use a positive \`take\` value.`
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
|
|
119
|
-
|
|
120
|
-
// Build WHERE — start with explicit where, then merge cursor conditions.
|
|
121
|
-
let where = args.where as Record<string, unknown> | undefined;
|
|
122
|
-
if (args.cursor) {
|
|
123
|
-
const cursorInput = args.cursor as Record<string, unknown>;
|
|
124
|
-
const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
|
|
125
|
-
|
|
126
|
-
const orderByDirMap = new Map<string, string>();
|
|
127
|
-
if (args.orderBy) {
|
|
128
|
-
const orderByItems = Array.isArray(args.orderBy)
|
|
129
|
-
? (args.orderBy as Record<string, unknown>[])
|
|
130
|
-
: [args.orderBy as Record<string, unknown>];
|
|
131
|
-
for (const item of orderByItems) {
|
|
132
|
-
for (const [field, dir] of Object.entries(item)) {
|
|
133
|
-
if (!sfMap.has(field)) continue;
|
|
134
|
-
if (typeof dir === "string") {
|
|
135
|
-
orderByDirMap.set(field, dir.toUpperCase());
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const cmpOpForField = (field: string): string => {
|
|
142
|
-
const dir = orderByDirMap.get(field) ?? "ASC";
|
|
143
|
-
const isDesc = dir === "DESC";
|
|
144
|
-
return (isDesc !== isBackwardCursor) ? "lt" : "gt";
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
let cursorWhere: Record<string, unknown> | undefined;
|
|
148
|
-
if (cursorEntries.length === 1) {
|
|
149
|
-
const [field, value] = cursorEntries[0]!;
|
|
150
|
-
if (!sfMap.has(field)) {
|
|
151
|
-
throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
|
|
152
|
-
}
|
|
153
|
-
cursorWhere = { [field]: { [cmpOpForField(field)]: value } };
|
|
154
|
-
} else if (cursorEntries.length > 1) {
|
|
155
|
-
const orBranches: Record<string, unknown>[] = [];
|
|
156
|
-
for (let i = 0; i < cursorEntries.length; i++) {
|
|
157
|
-
const branch: Record<string, unknown> = {};
|
|
158
|
-
for (let j = 0; j < i; j++) {
|
|
159
|
-
const [eqField, eqValue] = cursorEntries[j]!;
|
|
160
|
-
if (!sfMap.has(eqField)) {
|
|
161
|
-
throw new Error(`Unknown field "${eqField}" in cursor for model "${modelMeta.name}"`);
|
|
162
|
-
}
|
|
163
|
-
branch[eqField] = { equals: eqValue };
|
|
164
|
-
}
|
|
165
|
-
const [cmpField, cmpValue] = cursorEntries[i]!;
|
|
166
|
-
if (!sfMap.has(cmpField)) {
|
|
167
|
-
throw new Error(`Unknown field "${cmpField}" in cursor for model "${modelMeta.name}"`);
|
|
168
|
-
}
|
|
169
|
-
branch[cmpField] = { [cmpOpForField(cmpField)]: cmpValue };
|
|
170
|
-
orBranches.push(branch);
|
|
171
|
-
}
|
|
172
|
-
cursorWhere = { OR: orBranches };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (cursorWhere) {
|
|
176
|
-
where = where
|
|
177
|
-
? { AND: [where, cursorWhere] }
|
|
178
|
-
: cursorWhere;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Build LATERAL JOINs for each relation
|
|
183
|
-
const lateralJoins: string[] = [];
|
|
184
|
-
const lateralSelects: string[] = [];
|
|
185
|
-
const relationAliases: RelationAlias[] = [];
|
|
186
|
-
|
|
187
|
-
for (let i = 0; i < relationsToLoad.length; i++) {
|
|
188
|
-
const { relationMeta, nestedArgs } = relationsToLoad[i]!;
|
|
189
|
-
const alias = `__lat_${i}`;
|
|
190
|
-
const colAlias = `__vibeorm_rel_${relationMeta.name}`;
|
|
191
|
-
const relatedAlias = `"__rel"`;
|
|
192
|
-
|
|
193
|
-
// Find the related model meta
|
|
194
|
-
const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
|
|
195
|
-
if (!relatedModelMeta) continue;
|
|
196
|
-
|
|
197
|
-
// Build aliased column list for the inner subquery.
|
|
198
|
-
// Uses to_jsonb("__sub".*) instead of json_build_object() to avoid
|
|
199
|
-
// PostgreSQL's 100-argument function limit on wide models.
|
|
200
|
-
// Respect nested select/omit to avoid serialising columns the caller doesn't need.
|
|
201
|
-
const relatedScalars = resolveRelatedScalars({
|
|
202
|
-
relatedModelMeta,
|
|
203
|
-
nestedArgs,
|
|
204
|
-
});
|
|
205
|
-
const columnAliases = relatedScalars
|
|
206
|
-
.map((f) => `${relatedAlias}."${f.dbName}" AS "${f.name}"`)
|
|
207
|
-
.join(", ");
|
|
208
|
-
|
|
209
|
-
let lateralSubquery: string;
|
|
210
|
-
|
|
211
|
-
// ─── M:N relations via junction table ─────────────────────────
|
|
212
|
-
const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
|
|
213
|
-
|
|
214
|
-
if (isM2M) {
|
|
215
|
-
const jtName = (relationMeta as { joinTable: string }).joinTable;
|
|
216
|
-
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
217
|
-
|
|
218
|
-
// Determine A vs B column: Prisma implicit junction tables use alphabetical model name order
|
|
219
|
-
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
220
|
-
const parentIsA = modelMeta.name === sorted[0];
|
|
221
|
-
const parentCol = parentIsA ? "A" : "B";
|
|
222
|
-
const relatedCol = parentIsA ? "B" : "A";
|
|
223
|
-
|
|
224
|
-
// Related model PK for junction join condition
|
|
225
|
-
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
226
|
-
const relatedPkSf = relatedSfMap.get(relatedPk);
|
|
227
|
-
const relatedPkDb = relatedPkSf?.dbName ?? relatedPk;
|
|
228
|
-
|
|
229
|
-
// Junction join: __jt."B" = __rel."id" WHERE __jt."A" = parent."id"
|
|
230
|
-
const junctionJoin = `INNER JOIN "${jtName}" __jt ON __jt."${relatedCol}" = ${relatedAlias}."${relatedPkDb}"`;
|
|
231
|
-
const subWhere = `__jt."${parentCol}" = ${table}."${pkDbName}"`;
|
|
232
|
-
|
|
233
|
-
// Nested where filter from include/select args
|
|
234
|
-
let nestedWhereSql = "";
|
|
235
|
-
if (nestedArgs.where) {
|
|
236
|
-
const relMetaWithAlias = {
|
|
237
|
-
...relatedModelMeta,
|
|
238
|
-
dbName: "__rel",
|
|
239
|
-
} as typeof relatedModelMeta;
|
|
240
|
-
const nestedWhereResult = buildWhereClause({
|
|
241
|
-
where: nestedArgs.where as Record<string, unknown>,
|
|
242
|
-
modelMeta: relMetaWithAlias,
|
|
243
|
-
allModelsMeta,
|
|
244
|
-
paramOffset: paramIdx,
|
|
245
|
-
});
|
|
246
|
-
if (nestedWhereResult.sql) {
|
|
247
|
-
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
248
|
-
paramIdx += nestedWhereResult.values.length;
|
|
249
|
-
allValues.push(...nestedWhereResult.values);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
let orderBySql = "";
|
|
254
|
-
if (nestedArgs.orderBy) {
|
|
255
|
-
const orderByItems = Array.isArray(nestedArgs.orderBy)
|
|
256
|
-
? (nestedArgs.orderBy as Record<string, string>[])
|
|
257
|
-
: [nestedArgs.orderBy as Record<string, string>];
|
|
258
|
-
const clauses = orderByItems.flatMap((item) =>
|
|
259
|
-
Object.entries(item).map(([field, dir]) => {
|
|
260
|
-
if (typeof dir !== "string") {
|
|
261
|
-
throw new Error(
|
|
262
|
-
`Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
const sf = relatedSfMap.get(field);
|
|
266
|
-
if (!sf) {
|
|
267
|
-
throw new Error(
|
|
268
|
-
`Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
|
|
272
|
-
})
|
|
273
|
-
);
|
|
274
|
-
if (clauses.length > 0) {
|
|
275
|
-
orderBySql = ` ORDER BY ${clauses.join(", ")}`;
|
|
276
|
-
}
|
|
277
|
-
} else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
278
|
-
const pkOrderClauses: string[] = [];
|
|
279
|
-
for (const rpkName of relatedModelMeta.primaryKey) {
|
|
280
|
-
const rpkSf = relatedSfMap.get(rpkName);
|
|
281
|
-
if (rpkSf) {
|
|
282
|
-
pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (pkOrderClauses.length > 0) {
|
|
286
|
-
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
let limitSql = "";
|
|
291
|
-
if (nestedArgs.take !== undefined) {
|
|
292
|
-
paramIdx++;
|
|
293
|
-
limitSql = ` LIMIT $${paramIdx}`;
|
|
294
|
-
allValues.push(Math.abs(nestedArgs.take as number));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let offsetSql = "";
|
|
298
|
-
if (nestedArgs.skip !== undefined) {
|
|
299
|
-
paramIdx++;
|
|
300
|
-
offsetSql = ` OFFSET $${paramIdx}`;
|
|
301
|
-
allValues.push(nestedArgs.skip);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Wrap in inner subquery with column aliases + to_jsonb to avoid
|
|
305
|
-
// PostgreSQL's 100-argument limit on json_build_object.
|
|
306
|
-
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
307
|
-
lateralSubquery = `LEFT JOIN LATERAL (
|
|
308
|
-
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
309
|
-
FROM (
|
|
310
|
-
SELECT ${columnAliases}
|
|
311
|
-
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
312
|
-
${junctionJoin}
|
|
313
|
-
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
314
|
-
) "__sub"
|
|
315
|
-
) ${alias} ON true`;
|
|
316
|
-
} else {
|
|
317
|
-
lateralSubquery = `LEFT JOIN LATERAL (
|
|
318
|
-
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
319
|
-
FROM (
|
|
320
|
-
SELECT ${columnAliases}
|
|
321
|
-
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
322
|
-
${junctionJoin}
|
|
323
|
-
WHERE ${subWhere}${nestedWhereSql}${orderBySql}
|
|
324
|
-
) "__sub"
|
|
325
|
-
) ${alias} ON true`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ─── Direct FK relations (1:N, 1:1) ──────────────────────────
|
|
329
|
-
} else {
|
|
330
|
-
// Determine FK column and join condition
|
|
331
|
-
const { fkDbName, fkTable } = resolveFkColumn({
|
|
332
|
-
parentModelMeta: modelMeta,
|
|
333
|
-
relationMeta,
|
|
334
|
-
relatedModelMeta,
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
if (relationMeta.isList) {
|
|
338
|
-
// To-many: use json_agg with COALESCE for empty arrays
|
|
339
|
-
const subWhere = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
|
|
340
|
-
|
|
341
|
-
// Nested where filter from include/select args
|
|
342
|
-
let nestedWhereSql = "";
|
|
343
|
-
if (nestedArgs.where) {
|
|
344
|
-
// Build nested where using __rel alias for the related model.
|
|
345
|
-
// This avoids brittle regex rewrites of generated SQL table refs.
|
|
346
|
-
const relMetaWithAlias = {
|
|
347
|
-
...relatedModelMeta,
|
|
348
|
-
dbName: "__rel",
|
|
349
|
-
} as typeof relatedModelMeta;
|
|
350
|
-
const nestedWhereResult = buildWhereClause({
|
|
351
|
-
where: nestedArgs.where as Record<string, unknown>,
|
|
352
|
-
modelMeta: relMetaWithAlias,
|
|
353
|
-
allModelsMeta,
|
|
354
|
-
paramOffset: paramIdx,
|
|
355
|
-
});
|
|
356
|
-
if (nestedWhereResult.sql) {
|
|
357
|
-
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
358
|
-
paramIdx += nestedWhereResult.values.length;
|
|
359
|
-
allValues.push(...nestedWhereResult.values);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
364
|
-
|
|
365
|
-
let orderBySql = "";
|
|
366
|
-
if (nestedArgs.orderBy) {
|
|
367
|
-
const orderByItems = Array.isArray(nestedArgs.orderBy)
|
|
368
|
-
? (nestedArgs.orderBy as Record<string, string>[])
|
|
369
|
-
: [nestedArgs.orderBy as Record<string, string>];
|
|
370
|
-
const clauses = orderByItems.flatMap((item) =>
|
|
371
|
-
Object.entries(item).map(([field, dir]) => {
|
|
372
|
-
if (typeof dir !== "string") {
|
|
373
|
-
throw new Error(
|
|
374
|
-
`Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
const sf = relatedSfMap.get(field);
|
|
378
|
-
if (!sf) {
|
|
379
|
-
throw new Error(
|
|
380
|
-
`Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
|
|
384
|
-
})
|
|
385
|
-
);
|
|
386
|
-
if (clauses.length > 0) {
|
|
387
|
-
orderBySql = ` ORDER BY ${clauses.join(", ")}`;
|
|
388
|
-
}
|
|
389
|
-
} else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
390
|
-
// Default ORDER BY PK for deterministic array ordering in jsonb_agg
|
|
391
|
-
const pkOrderClauses: string[] = [];
|
|
392
|
-
for (const rpkName of relatedModelMeta.primaryKey) {
|
|
393
|
-
const rpkSf = relatedSfMap.get(rpkName);
|
|
394
|
-
if (rpkSf) {
|
|
395
|
-
pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if (pkOrderClauses.length > 0) {
|
|
399
|
-
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
let limitSql = "";
|
|
404
|
-
if (nestedArgs.take !== undefined) {
|
|
405
|
-
paramIdx++;
|
|
406
|
-
limitSql = ` LIMIT $${paramIdx}`;
|
|
407
|
-
allValues.push(Math.abs(nestedArgs.take as number));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
let offsetSql = "";
|
|
411
|
-
if (nestedArgs.skip !== undefined) {
|
|
412
|
-
paramIdx++;
|
|
413
|
-
offsetSql = ` OFFSET $${paramIdx}`;
|
|
414
|
-
allValues.push(nestedArgs.skip);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Wrap in inner subquery with column aliases + to_jsonb to avoid
|
|
418
|
-
// PostgreSQL's 100-argument limit on json_build_object.
|
|
419
|
-
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
420
|
-
lateralSubquery = `LEFT JOIN LATERAL (
|
|
421
|
-
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
422
|
-
FROM (
|
|
423
|
-
SELECT ${columnAliases}
|
|
424
|
-
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
425
|
-
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
426
|
-
) "__sub"
|
|
427
|
-
) ${alias} ON true`;
|
|
428
|
-
} else {
|
|
429
|
-
lateralSubquery = `LEFT JOIN LATERAL (
|
|
430
|
-
SELECT COALESCE(jsonb_agg(to_jsonb("__sub".*)), '[]'::jsonb) AS "${colAlias}"
|
|
431
|
-
FROM (
|
|
432
|
-
SELECT ${columnAliases}
|
|
433
|
-
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
434
|
-
WHERE ${subWhere}${nestedWhereSql}${orderBySql}
|
|
435
|
-
) "__sub"
|
|
436
|
-
) ${alias} ON true`;
|
|
437
|
-
}
|
|
438
|
-
} else {
|
|
439
|
-
// To-one: use to_jsonb on subselect with LIMIT 1
|
|
440
|
-
let joinCondition: string;
|
|
441
|
-
|
|
442
|
-
if (fkTable === "parent") {
|
|
443
|
-
// Parent holds FK (e.g., Post.authorId -> User.id)
|
|
444
|
-
const parentFkField = relationMeta.fields[0]!;
|
|
445
|
-
const parentFkScalar = sfMap.get(parentFkField);
|
|
446
|
-
const parentFkDbName = parentFkScalar?.dbName ?? parentFkField;
|
|
447
|
-
const refField = relationMeta.references[0]!;
|
|
448
|
-
const relatedSfMapForOne = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
449
|
-
const refScalar = relatedSfMapForOne.get(refField);
|
|
450
|
-
const refDbName = refScalar?.dbName ?? refField;
|
|
451
|
-
joinCondition = `${relatedAlias}."${refDbName}" = ${table}."${parentFkDbName}"`;
|
|
452
|
-
} else {
|
|
453
|
-
// Related model holds FK (e.g., User.profile -> Profile.userId)
|
|
454
|
-
joinCondition = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Nested where filter from include/select args — mirrors the to-many branch.
|
|
458
|
-
// Without this the join silently ignored `include: { author: { where: {...} } }`.
|
|
459
|
-
let nestedWhereSql = "";
|
|
460
|
-
if (nestedArgs.where) {
|
|
461
|
-
const relMetaWithAlias = {
|
|
462
|
-
...relatedModelMeta,
|
|
463
|
-
dbName: "__rel",
|
|
464
|
-
} as typeof relatedModelMeta;
|
|
465
|
-
const nestedWhereResult = buildWhereClause({
|
|
466
|
-
where: nestedArgs.where as Record<string, unknown>,
|
|
467
|
-
modelMeta: relMetaWithAlias,
|
|
468
|
-
allModelsMeta,
|
|
469
|
-
paramOffset: paramIdx,
|
|
470
|
-
});
|
|
471
|
-
if (nestedWhereResult.sql) {
|
|
472
|
-
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
473
|
-
paramIdx += nestedWhereResult.values.length;
|
|
474
|
-
allValues.push(...nestedWhereResult.values);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
lateralSubquery = `LEFT JOIN LATERAL (
|
|
479
|
-
SELECT to_jsonb("__sub".*) AS "${colAlias}"
|
|
480
|
-
FROM (
|
|
481
|
-
SELECT ${columnAliases}
|
|
482
|
-
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
483
|
-
WHERE ${joinCondition}${nestedWhereSql}
|
|
484
|
-
LIMIT 1
|
|
485
|
-
) "__sub"
|
|
486
|
-
) ${alias} ON true`;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
lateralJoins.push(lateralSubquery);
|
|
491
|
-
lateralSelects.push(`${alias}."${colAlias}"`);
|
|
492
|
-
relationAliases.push({ alias: colAlias, relationMeta, nestedArgs });
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Build WHERE from args
|
|
496
|
-
const whereResult = buildWhereClause({
|
|
497
|
-
where,
|
|
498
|
-
modelMeta,
|
|
499
|
-
allModelsMeta,
|
|
500
|
-
paramOffset: paramIdx,
|
|
501
|
-
});
|
|
502
|
-
paramIdx += whereResult.values.length;
|
|
503
|
-
allValues.push(...whereResult.values);
|
|
504
|
-
|
|
505
|
-
// Build ORDER BY
|
|
506
|
-
let orderBySql = "";
|
|
507
|
-
if (args.orderBy) {
|
|
508
|
-
const orderByItems = Array.isArray(args.orderBy)
|
|
509
|
-
? (args.orderBy as Record<string, string>[])
|
|
510
|
-
: [args.orderBy as Record<string, string>];
|
|
511
|
-
|
|
512
|
-
const orderClauses = orderByItems.flatMap((item) =>
|
|
513
|
-
Object.entries(item).map(([field, direction]) => {
|
|
514
|
-
if (typeof direction !== "string") {
|
|
515
|
-
throw new Error(
|
|
516
|
-
`Unsupported orderBy direction for field "${field}" on model "${modelMeta.name}"`
|
|
517
|
-
);
|
|
518
|
-
}
|
|
519
|
-
const scalarField = sfMap.get(field);
|
|
520
|
-
if (!scalarField) {
|
|
521
|
-
throw new Error(`Unknown orderBy field "${field}" on model "${modelMeta.name}"`);
|
|
522
|
-
}
|
|
523
|
-
const resolvedDirection = isBackwardCursor
|
|
524
|
-
? reverseDirection({ direction })
|
|
525
|
-
: direction;
|
|
526
|
-
return `${table}."${scalarField.dbName}" ${sanitizeDirection({ direction: resolvedDirection })}`;
|
|
527
|
-
})
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
if (orderClauses.length > 0) {
|
|
531
|
-
orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
|
|
532
|
-
}
|
|
533
|
-
} else if (args.cursor) {
|
|
534
|
-
const cursorInput = args.cursor as Record<string, unknown>;
|
|
535
|
-
const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
|
|
536
|
-
const defaultDir = isBackwardCursor ? "DESC" : "ASC";
|
|
537
|
-
const cursorOrderClauses = cursorEntries.map(([field]) => {
|
|
538
|
-
const sf = sfMap.get(field);
|
|
539
|
-
if (!sf) {
|
|
540
|
-
throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
|
|
541
|
-
}
|
|
542
|
-
return `${table}."${sf.dbName}" ${defaultDir}`;
|
|
543
|
-
});
|
|
544
|
-
if (cursorOrderClauses.length > 0) {
|
|
545
|
-
orderBySql = ` ORDER BY ${cursorOrderClauses.join(", ")}`;
|
|
546
|
-
}
|
|
547
|
-
} else if (defaultOrderByPk) {
|
|
548
|
-
// Default ORDER BY primary key for deterministic results
|
|
549
|
-
const pkOrderClauses: string[] = [];
|
|
550
|
-
for (const pn of modelMeta.primaryKey) {
|
|
551
|
-
const pSf = sfMap.get(pn);
|
|
552
|
-
if (pSf) {
|
|
553
|
-
pkOrderClauses.push(`${table}."${pSf.dbName}" ASC`);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
if (pkOrderClauses.length > 0) {
|
|
557
|
-
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Build LIMIT / OFFSET
|
|
562
|
-
let limitSql = "";
|
|
563
|
-
if (take !== undefined) {
|
|
564
|
-
paramIdx++;
|
|
565
|
-
limitSql = ` LIMIT $${paramIdx}`;
|
|
566
|
-
allValues.push(Math.abs(take));
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
let offsetSql = "";
|
|
570
|
-
if (args.skip !== undefined) {
|
|
571
|
-
paramIdx++;
|
|
572
|
-
offsetSql = ` OFFSET $${paramIdx}`;
|
|
573
|
-
allValues.push(args.skip);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const selectSql = [parentColumns, ...lateralSelects].join(", ");
|
|
577
|
-
const joinsPart = lateralJoins.length > 0
|
|
578
|
-
? `\n${lateralJoins.join("\n")}`
|
|
579
|
-
: "";
|
|
580
|
-
|
|
581
|
-
// ── CTE optimisation ────────────────────────────────────────────
|
|
582
|
-
// When the WHERE clause contains relation-based filters (some/every/
|
|
583
|
-
// none/is/isNot), the where-builder emits EXISTS / NOT EXISTS
|
|
584
|
-
// sub-queries. If we put that WHERE after the LATERAL JOINs the
|
|
585
|
-
// planner may execute every lateral join for every parent row before
|
|
586
|
-
// filtering. Wrapping the base set in a CTE with LIMIT/OFFSET
|
|
587
|
-
// guarantees PostgreSQL resolves the matching PKs first, then runs
|
|
588
|
-
// the lateral joins only against the matched rows.
|
|
589
|
-
//
|
|
590
|
-
// We also enable the CTE when LIMIT is present and there are 3+
|
|
591
|
-
// lateral joins. Without it Postgres may evaluate every lateral for
|
|
592
|
-
// every candidate row before the LIMIT prunes the result set.
|
|
593
|
-
const hasRelFilter = whereHasRelationFilter({
|
|
594
|
-
where,
|
|
595
|
-
modelMeta,
|
|
596
|
-
});
|
|
597
|
-
const hasHeavyIncludes =
|
|
598
|
-
args.take !== undefined && relationsToLoad.length >= 3;
|
|
599
|
-
const useCte = hasRelFilter || hasHeavyIncludes;
|
|
600
|
-
|
|
601
|
-
let text: string;
|
|
602
|
-
|
|
603
|
-
if (useCte) {
|
|
604
|
-
// CTE selects only the PK, applying WHERE + ORDER + LIMIT + OFFSET
|
|
605
|
-
const cteWherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
|
|
606
|
-
const cteSql =
|
|
607
|
-
`WITH __vibeorm_base AS (` +
|
|
608
|
-
`SELECT ${table}."${pkDbName}" FROM ${table}` +
|
|
609
|
-
`${cteWherePart}${orderBySql}${limitSql}${offsetSql}` +
|
|
610
|
-
`)`;
|
|
611
|
-
|
|
612
|
-
// Outer query joins the CTE to the parent table, then lateral joins
|
|
613
|
-
text =
|
|
614
|
-
`${cteSql} SELECT ${selectSql} FROM ${table}` +
|
|
615
|
-
` INNER JOIN __vibeorm_base ON __vibeorm_base."${pkDbName}" = ${table}."${pkDbName}"` +
|
|
616
|
-
`${joinsPart}${orderBySql}`;
|
|
617
|
-
} else {
|
|
618
|
-
// No relation filter — standard flat query (existing behaviour)
|
|
619
|
-
const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
|
|
620
|
-
text = `SELECT ${selectSql} FROM ${table}${joinsPart}${wherePart}${orderBySql}${limitSql}${offsetSql}`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return {
|
|
624
|
-
query: { text, values: allValues },
|
|
625
|
-
relationAliases,
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ─── Executor (single-query path) ─────────────────────────────────
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Execute a findMany/findFirst/findUnique with lateral joins.
|
|
633
|
-
* Returns fully-hydrated records in a single DB round-trip.
|
|
634
|
-
*/
|
|
635
|
-
export async function executeLateralJoinQuery(params: {
|
|
636
|
-
modelMeta: ModelMeta;
|
|
637
|
-
allModelsMeta: ModelMetaMap;
|
|
638
|
-
args: Record<string, unknown>;
|
|
639
|
-
executor: SqlExecutor;
|
|
640
|
-
profilingCtx?: ProfilingContext;
|
|
641
|
-
defaultOrderByPk?: boolean;
|
|
642
|
-
}): Promise<Record<string, unknown>[]> {
|
|
643
|
-
const { modelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
|
|
644
|
-
const profiling = !!profilingCtx;
|
|
645
|
-
|
|
646
|
-
const t0 = profiling ? performance.now() : 0;
|
|
647
|
-
|
|
648
|
-
const relationsToLoad = resolveRelationsToLoad({ parentModelMeta: modelMeta, args });
|
|
649
|
-
|
|
650
|
-
// If no relations to load, this shouldn't have been called, but handle gracefully
|
|
651
|
-
if (relationsToLoad.length === 0) {
|
|
652
|
-
// Fall through — the caller should use buildSelectQuery instead
|
|
653
|
-
// But just in case, import the builder dynamically wouldn't work, so just build a simple query
|
|
654
|
-
const { buildSelectQuery } = await import("./query-builder.ts");
|
|
655
|
-
const query = buildSelectQuery({ modelMeta, allModelsMeta, args });
|
|
656
|
-
return executor(query);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const { query, relationAliases } = buildLateralJoinQuery({
|
|
660
|
-
modelMeta,
|
|
661
|
-
allModelsMeta,
|
|
662
|
-
args,
|
|
663
|
-
relationsToLoad,
|
|
664
|
-
defaultOrderByPk,
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
const t1 = profiling ? performance.now() : 0;
|
|
668
|
-
|
|
669
|
-
const rows = await executor(query);
|
|
670
|
-
|
|
671
|
-
const t2 = profiling ? performance.now() : 0;
|
|
672
|
-
|
|
673
|
-
// Parse JSON relation columns + apply BigInt coercion in a single pass.
|
|
674
|
-
//
|
|
675
|
-
// The "query" strategy coerces both parents (via `loadRelationsForStrategy`)
|
|
676
|
-
// and children (via the per-loader calls in `relation-loader.ts`). The
|
|
677
|
-
// lateral-join strategy bypasses both, so we do the equivalent work here
|
|
678
|
-
// (Bug #6).
|
|
679
|
-
//
|
|
680
|
-
// Performance: pre-compute which relation aliases actually need coercion
|
|
681
|
-
// (BigInt or enum-array — see `modelNeedsCoercion`). Wide schemas with
|
|
682
|
-
// many includes thus pay nothing extra for relations whose child model
|
|
683
|
-
// has neither.
|
|
684
|
-
const nestedModelMap = getModelByNameMap({ allModelsMeta });
|
|
685
|
-
const relationCoerceMeta = relationAliases.map(({ relationMeta }) => {
|
|
686
|
-
const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
|
|
687
|
-
if (!relatedModelMeta) return null;
|
|
688
|
-
return modelNeedsCoercion(relatedModelMeta) ? relatedModelMeta : null;
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
const results: Record<string, unknown>[] = [];
|
|
692
|
-
|
|
693
|
-
for (const row of rows) {
|
|
694
|
-
const record: Record<string, unknown> = {};
|
|
695
|
-
|
|
696
|
-
// Copy scalar fields
|
|
697
|
-
for (const sf of modelMeta.scalarFields) {
|
|
698
|
-
if (row[sf.name] !== undefined) {
|
|
699
|
-
record[sf.name] = row[sf.name];
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// Parse relation columns from JSON (and coerce inline if needed)
|
|
704
|
-
for (let i = 0; i < relationAliases.length; i++) {
|
|
705
|
-
const { alias, relationMeta } = relationAliases[i]!;
|
|
706
|
-
const jsonValue = row[alias];
|
|
707
|
-
let parsed: unknown;
|
|
708
|
-
if (jsonValue === null || jsonValue === undefined) {
|
|
709
|
-
parsed = relationMeta.isList ? [] : null;
|
|
710
|
-
} else if (typeof jsonValue === "string") {
|
|
711
|
-
try {
|
|
712
|
-
parsed = JSON.parse(jsonValue);
|
|
713
|
-
} catch {
|
|
714
|
-
parsed = relationMeta.isList ? [] : null;
|
|
715
|
-
}
|
|
716
|
-
} else {
|
|
717
|
-
// Already parsed by bun:sql (json/jsonb columns auto-parse)
|
|
718
|
-
parsed = jsonValue;
|
|
719
|
-
}
|
|
720
|
-
record[relationMeta.name] = parsed;
|
|
721
|
-
|
|
722
|
-
const coerceMeta = relationCoerceMeta[i];
|
|
723
|
-
if (coerceMeta) {
|
|
724
|
-
if (Array.isArray(parsed)) {
|
|
725
|
-
coerceFieldTypes({ records: parsed as Record<string, unknown>[], modelMeta: coerceMeta });
|
|
726
|
-
} else if (parsed && typeof parsed === "object") {
|
|
727
|
-
coerceFieldTypes({ records: [parsed as Record<string, unknown>], modelMeta: coerceMeta });
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
results.push(record);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Coerce parent records once (whole batch) after the parse loop.
|
|
736
|
-
coerceFieldTypes({ records: results, modelMeta });
|
|
737
|
-
|
|
738
|
-
const t3 = profiling ? performance.now() : 0;
|
|
739
|
-
|
|
740
|
-
// Recursively load nested relations (deeper levels fall back to batched)
|
|
741
|
-
await Promise.all(
|
|
742
|
-
relationAliases
|
|
743
|
-
.filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
|
|
744
|
-
.map(async ({ relationMeta, nestedArgs }) => {
|
|
745
|
-
const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
|
|
746
|
-
if (!relatedModelMeta) return;
|
|
747
|
-
|
|
748
|
-
const allRelated = results
|
|
749
|
-
.flatMap((r) => {
|
|
750
|
-
const val = r[relationMeta.name];
|
|
751
|
-
if (Array.isArray(val)) return val;
|
|
752
|
-
if (val && typeof val === "object") return [val];
|
|
753
|
-
return [];
|
|
754
|
-
})
|
|
755
|
-
.filter((r): r is Record<string, unknown> => r !== null);
|
|
756
|
-
|
|
757
|
-
if (allRelated.length > 0) {
|
|
758
|
-
await loadRelations({
|
|
759
|
-
parentRecords: allRelated,
|
|
760
|
-
parentModelMeta: relatedModelMeta,
|
|
761
|
-
allModelsMeta,
|
|
762
|
-
args: nestedArgs,
|
|
763
|
-
executor,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
})
|
|
767
|
-
);
|
|
768
|
-
|
|
769
|
-
// ─── _count support (Bug #5) ─────────────────────────────────────
|
|
770
|
-
// The "query" strategy resolves `_count` after `loadRelations` (see
|
|
771
|
-
// `loadRelationsForStrategy` in client.ts). The lateral-join strategy
|
|
772
|
-
// historically returned BEFORE `_count` ran, silently dropping it whenever
|
|
773
|
-
// the user combined `include: { posts: true, _count: { … } }` with the
|
|
774
|
-
// join strategy. We mirror the query-strategy behaviour here.
|
|
775
|
-
const countSpec = resolveCountSpec({ args });
|
|
776
|
-
if (countSpec && results.length > 0) {
|
|
777
|
-
await loadRelationCounts({
|
|
778
|
-
records: results,
|
|
779
|
-
modelMeta,
|
|
780
|
-
allModelsMeta,
|
|
781
|
-
countSpec,
|
|
782
|
-
executor,
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Populate profiling context with per-phase timings
|
|
787
|
-
if (profilingCtx) {
|
|
788
|
-
profilingCtx.queryBuildMs = t1 - t0;
|
|
789
|
-
profilingCtx.sqlExecMs = t2 - t1;
|
|
790
|
-
profilingCtx.resultMapMs = t3 - t2;
|
|
791
|
-
profilingCtx.sql = query.text;
|
|
792
|
-
profilingCtx.sqlValues = query.values;
|
|
793
|
-
profilingCtx.rowCount = rows.length;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
return results;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ─── Legacy entry point (kept for backward compat) ────────────────
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* @deprecated Use executeLateralJoinQuery instead. This function exists
|
|
803
|
-
* for backward compatibility and performs 2 queries instead of 1.
|
|
804
|
-
*/
|
|
805
|
-
export async function loadRelationsWithLateralJoin(params: {
|
|
806
|
-
parentRecords: Record<string, unknown>[];
|
|
807
|
-
parentModelMeta: ModelMeta;
|
|
808
|
-
allModelsMeta: ModelMetaMap;
|
|
809
|
-
args: Record<string, unknown>;
|
|
810
|
-
executor: SqlExecutor;
|
|
811
|
-
profilingCtx?: ProfilingContext;
|
|
812
|
-
defaultOrderByPk?: boolean;
|
|
813
|
-
}): Promise<Record<string, unknown>[]> {
|
|
814
|
-
// For the post-processing path (create, update, etc.), we still need
|
|
815
|
-
// the 2-query approach since the parent records are already fetched.
|
|
816
|
-
// But we can use the lateral join to load relations in 1 query instead of N.
|
|
817
|
-
const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
|
|
818
|
-
|
|
819
|
-
if (parentRecords.length === 0) return parentRecords;
|
|
820
|
-
|
|
821
|
-
const relationsToLoad = resolveRelationsToLoad({ parentModelMeta, args });
|
|
822
|
-
if (relationsToLoad.length === 0) return parentRecords;
|
|
823
|
-
|
|
824
|
-
const pkField = parentModelMeta.primaryKey[0]!;
|
|
825
|
-
const parentIds = [
|
|
826
|
-
...new Set(
|
|
827
|
-
parentRecords
|
|
828
|
-
.map((r) => r[pkField])
|
|
829
|
-
.filter((id) => id !== undefined && id !== null)
|
|
830
|
-
),
|
|
831
|
-
];
|
|
832
|
-
|
|
833
|
-
if (parentIds.length === 0) return parentRecords;
|
|
834
|
-
|
|
835
|
-
// Build a lateral join query scoped to these parent PKs
|
|
836
|
-
const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
|
|
837
|
-
const pkScalar = parentSfMap.get(pkField);
|
|
838
|
-
const pkDbName = pkScalar?.dbName ?? pkField;
|
|
839
|
-
const table = `"${parentModelMeta.dbName}"`;
|
|
840
|
-
|
|
841
|
-
// Use the lateral join builder with a synthetic WHERE IN for the PKs
|
|
842
|
-
const syntheticArgs = {
|
|
843
|
-
...args,
|
|
844
|
-
where: {
|
|
845
|
-
[pkField]: { in: parentIds },
|
|
846
|
-
},
|
|
847
|
-
};
|
|
848
|
-
|
|
849
|
-
const { query, relationAliases } = buildLateralJoinQuery({
|
|
850
|
-
modelMeta: parentModelMeta,
|
|
851
|
-
allModelsMeta,
|
|
852
|
-
args: syntheticArgs,
|
|
853
|
-
relationsToLoad,
|
|
854
|
-
defaultOrderByPk,
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
const rows = await executor(query);
|
|
858
|
-
|
|
859
|
-
// Index by PK
|
|
860
|
-
const relMap = new Map<unknown, Record<string, unknown>>();
|
|
861
|
-
for (const row of rows) {
|
|
862
|
-
const pk = row[pkField];
|
|
863
|
-
const parsed: Record<string, unknown> = {};
|
|
864
|
-
for (const { alias, relationMeta } of relationAliases) {
|
|
865
|
-
const jsonValue = row[alias];
|
|
866
|
-
if (jsonValue === null || jsonValue === undefined) {
|
|
867
|
-
parsed[relationMeta.name] = relationMeta.isList ? [] : null;
|
|
868
|
-
} else if (typeof jsonValue === "string") {
|
|
869
|
-
try {
|
|
870
|
-
parsed[relationMeta.name] = JSON.parse(jsonValue);
|
|
871
|
-
} catch {
|
|
872
|
-
parsed[relationMeta.name] = relationMeta.isList ? [] : null;
|
|
873
|
-
}
|
|
874
|
-
} else {
|
|
875
|
-
parsed[relationMeta.name] = jsonValue;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
relMap.set(pk, parsed);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// Stitch relations onto the original parent records
|
|
882
|
-
for (const parent of parentRecords) {
|
|
883
|
-
const pk = parent[pkField];
|
|
884
|
-
const rels = relMap.get(pk);
|
|
885
|
-
if (rels) {
|
|
886
|
-
for (const [key, value] of Object.entries(rels)) {
|
|
887
|
-
parent[key] = value;
|
|
888
|
-
}
|
|
889
|
-
} else {
|
|
890
|
-
for (const { relationMeta } of relationAliases) {
|
|
891
|
-
parent[relationMeta.name] = relationMeta.isList ? [] : null;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Recursively load nested relations in parallel
|
|
897
|
-
const legacyModelMap = getModelByNameMap({ allModelsMeta });
|
|
898
|
-
await Promise.all(
|
|
899
|
-
relationAliases
|
|
900
|
-
.filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
|
|
901
|
-
.map(async ({ relationMeta, nestedArgs }) => {
|
|
902
|
-
const relatedModelMeta = legacyModelMap.get(relationMeta.relatedModel);
|
|
903
|
-
if (!relatedModelMeta) return;
|
|
904
|
-
|
|
905
|
-
const allRelated = parentRecords
|
|
906
|
-
.flatMap((r) => {
|
|
907
|
-
const val = r[relationMeta.name];
|
|
908
|
-
if (Array.isArray(val)) return val;
|
|
909
|
-
if (val && typeof val === "object") return [val];
|
|
910
|
-
return [];
|
|
911
|
-
})
|
|
912
|
-
.filter((r): r is Record<string, unknown> => r !== null);
|
|
913
|
-
|
|
914
|
-
if (allRelated.length > 0) {
|
|
915
|
-
await loadRelations({
|
|
916
|
-
parentRecords: allRelated,
|
|
917
|
-
parentModelMeta: relatedModelMeta,
|
|
918
|
-
allModelsMeta,
|
|
919
|
-
args: nestedArgs,
|
|
920
|
-
executor,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
})
|
|
924
|
-
);
|
|
925
|
-
|
|
926
|
-
return parentRecords;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// ─── Helpers ──────────────────────────────────────────────────────
|
|
930
|
-
|
|
931
|
-
type RelationAlias = {
|
|
932
|
-
alias: string;
|
|
933
|
-
relationMeta: RelationFieldMeta;
|
|
934
|
-
nestedArgs: Record<string, unknown>;
|
|
935
|
-
};
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Check whether a Prisma-style where object references any relation field
|
|
939
|
-
* on the given model. Walks AND / OR / NOT combinators recursively.
|
|
940
|
-
* Returns true if any key in the where tree corresponds to a relation field,
|
|
941
|
-
* which means the where-builder will emit EXISTS / NOT EXISTS sub-queries.
|
|
942
|
-
*/
|
|
943
|
-
export function whereHasRelationFilter(params: {
|
|
944
|
-
where: Record<string, unknown> | undefined;
|
|
945
|
-
modelMeta: ModelMeta;
|
|
946
|
-
}): boolean {
|
|
947
|
-
const { where, modelMeta } = params;
|
|
948
|
-
if (!where || typeof where !== "object") return false;
|
|
949
|
-
|
|
950
|
-
const relationNames = new Set(
|
|
951
|
-
(modelMeta.relationFields ?? []).map((r) => r.name)
|
|
952
|
-
);
|
|
953
|
-
|
|
954
|
-
for (const [key, value] of Object.entries(where)) {
|
|
955
|
-
if (value === undefined) continue;
|
|
956
|
-
|
|
957
|
-
// Logical combinators — recurse
|
|
958
|
-
if (key === "AND" || key === "OR" || key === "NOT") {
|
|
959
|
-
const items = Array.isArray(value) ? value : [value];
|
|
960
|
-
for (const item of items) {
|
|
961
|
-
if (
|
|
962
|
-
whereHasRelationFilter({
|
|
963
|
-
where: item as Record<string, unknown>,
|
|
964
|
-
modelMeta,
|
|
965
|
-
})
|
|
966
|
-
) {
|
|
967
|
-
return true;
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// Direct relation filter key (some/every/none/is/isNot or shorthand)
|
|
974
|
-
if (relationNames.has(key)) {
|
|
975
|
-
return true;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
return false;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Resolve which scalar columns to include in a lateral join's inner subquery.
|
|
984
|
-
* Respects nested select/omit so we don't serialise unnecessary columns.
|
|
985
|
-
* Always includes the PK so nested relation loading can stitch correctly.
|
|
986
|
-
*/
|
|
987
|
-
function resolveRelatedScalars(params: {
|
|
988
|
-
relatedModelMeta: ModelMeta;
|
|
989
|
-
nestedArgs: Record<string, unknown>;
|
|
990
|
-
}): readonly { readonly name: string; readonly dbName: string }[] {
|
|
991
|
-
const { relatedModelMeta, nestedArgs } = params;
|
|
992
|
-
const selectArg = nestedArgs.select as Record<string, boolean | object> | undefined;
|
|
993
|
-
const omitArg = nestedArgs.omit as Record<string, boolean> | undefined;
|
|
994
|
-
|
|
995
|
-
if (selectArg) {
|
|
996
|
-
const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
997
|
-
const selected = relatedModelMeta.scalarFields.filter((f) => {
|
|
998
|
-
const val = selectArg[f.name];
|
|
999
|
-
return val === true || (typeof val === "object" && val !== null);
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
// Always include PK for relation stitching
|
|
1003
|
-
const selectedNames = new Set(selected.map((f) => f.name));
|
|
1004
|
-
for (const pkName of relatedModelMeta.primaryKey) {
|
|
1005
|
-
if (!selectedNames.has(pkName)) {
|
|
1006
|
-
const sf = sfMap.get(pkName);
|
|
1007
|
-
if (sf) selected.push(sf);
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
return selected;
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
if (omitArg) {
|
|
1015
|
-
return relatedModelMeta.scalarFields.filter((f) => !omitArg[f.name]);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// Default: all scalars
|
|
1019
|
-
return relatedModelMeta.scalarFields;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function resolveFkColumn(params: {
|
|
1023
|
-
parentModelMeta: ModelMeta;
|
|
1024
|
-
relationMeta: RelationFieldMeta;
|
|
1025
|
-
relatedModelMeta: ModelMeta;
|
|
1026
|
-
}): { fkDbName: string; fkTable: "parent" | "related" } {
|
|
1027
|
-
const { parentModelMeta, relationMeta, relatedModelMeta } = params;
|
|
1028
|
-
|
|
1029
|
-
if (relationMeta.isForeignKey) {
|
|
1030
|
-
// This side (parent) holds the FK
|
|
1031
|
-
return { fkDbName: "", fkTable: "parent" };
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// The related model holds the FK -- find it (with relationName disambiguation)
|
|
1035
|
-
const parentRelationName = (relationMeta as { relationName?: string }).relationName;
|
|
1036
|
-
const reverseRelation = relatedModelMeta.relationFields.find(
|
|
1037
|
-
(r) => r.relatedModel === parentModelMeta.name && r.isForeignKey &&
|
|
1038
|
-
(!parentRelationName || r.relationName === parentRelationName)
|
|
1039
|
-
);
|
|
1040
|
-
|
|
1041
|
-
if (reverseRelation) {
|
|
1042
|
-
const fkFieldName = reverseRelation.fields[0]!;
|
|
1043
|
-
const fkSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
1044
|
-
const fkField = fkSfMap.get(fkFieldName);
|
|
1045
|
-
return { fkDbName: fkField?.dbName ?? fkFieldName, fkTable: "related" };
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// Fallback convention
|
|
1049
|
-
return {
|
|
1050
|
-
fkDbName: `${parentModelMeta.name.toLowerCase()}Id`,
|
|
1051
|
-
fkTable: "related",
|
|
1052
|
-
};
|
|
1053
|
-
}
|