@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.
- package/README.md +74 -0
- package/package.json +47 -0
- package/src/adapter.ts +114 -0
- package/src/client.ts +2055 -0
- package/src/errors.ts +39 -0
- package/src/id-generators.ts +151 -0
- package/src/index.ts +36 -0
- package/src/lateral-join-builder.ts +759 -0
- package/src/query-builder.ts +1417 -0
- package/src/relation-loader.ts +489 -0
- package/src/types.ts +290 -0
- package/src/where-builder.ts +737 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,2055 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VibeORM Client Runtime
|
|
3
|
+
*
|
|
4
|
+
* Creates the actual client instance that the generated code uses.
|
|
5
|
+
* This connects to PostgreSQL via a DatabaseAdapter and executes
|
|
6
|
+
* queries built by the query builder.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
VibeClientOptions,
|
|
11
|
+
ModelMeta,
|
|
12
|
+
ModelMetaMap,
|
|
13
|
+
ModelSchemas,
|
|
14
|
+
Operation,
|
|
15
|
+
QueryProfile,
|
|
16
|
+
ProfilingContext,
|
|
17
|
+
} from "./types.ts";
|
|
18
|
+
import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
|
|
19
|
+
import type { DatabaseAdapter } from "./adapter.ts";
|
|
20
|
+
import { VibeValidationError } from "./errors.ts";
|
|
21
|
+
import {
|
|
22
|
+
buildSelectQuery,
|
|
23
|
+
buildInsertQuery,
|
|
24
|
+
buildInsertManyQuery,
|
|
25
|
+
buildUpdateQuery,
|
|
26
|
+
buildUpdateManyQuery,
|
|
27
|
+
buildDeleteQuery,
|
|
28
|
+
buildCountQuery,
|
|
29
|
+
buildAggregateQuery,
|
|
30
|
+
buildGroupByQuery,
|
|
31
|
+
buildUpsertQuery,
|
|
32
|
+
} from "./query-builder.ts";
|
|
33
|
+
import { loadRelations } from "./relation-loader.ts";
|
|
34
|
+
import {
|
|
35
|
+
loadRelationsWithLateralJoin,
|
|
36
|
+
executeLateralJoinQuery,
|
|
37
|
+
resolveRelationsToLoad,
|
|
38
|
+
} from "./lateral-join-builder.ts";
|
|
39
|
+
import { generateDefault } from "./id-generators.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a VibeORM client instance.
|
|
43
|
+
* Called by the generated index.ts file.
|
|
44
|
+
*/
|
|
45
|
+
export function createClient(params: {
|
|
46
|
+
options: VibeClientOptions;
|
|
47
|
+
modelMeta: Record<string, ModelMeta>;
|
|
48
|
+
schemas?: Record<string, ModelSchemas>;
|
|
49
|
+
}): Record<string, unknown> {
|
|
50
|
+
const { options, modelMeta } = params;
|
|
51
|
+
const adapter = options.adapter;
|
|
52
|
+
const shouldLog = options?.log === true || options?.log === "query";
|
|
53
|
+
const shouldDebug = !!options?.debug;
|
|
54
|
+
|
|
55
|
+
/** Emit a query profile to the user's debug handler or console. */
|
|
56
|
+
function debugEmit(profile: QueryProfile): void {
|
|
57
|
+
if (!shouldDebug) return;
|
|
58
|
+
if (typeof options?.debug === "function") {
|
|
59
|
+
options.debug({ profile });
|
|
60
|
+
} else {
|
|
61
|
+
// Pretty-print to console
|
|
62
|
+
const pad = (s: string, n: number) => s.padEnd(n);
|
|
63
|
+
console.log(
|
|
64
|
+
`[vibeorm:debug] ${profile.model}.${profile.operation} — ${profile.totalMs.toFixed(2)}ms total`
|
|
65
|
+
);
|
|
66
|
+
console.log(
|
|
67
|
+
` query build: ${profile.queryBuildMs.toFixed(2)}ms`
|
|
68
|
+
);
|
|
69
|
+
console.log(
|
|
70
|
+
` sql exec: ${profile.sqlExecMs.toFixed(2)}ms (${profile.rowCount} rows)`
|
|
71
|
+
);
|
|
72
|
+
if (profile.resultMapMs > 0) {
|
|
73
|
+
console.log(
|
|
74
|
+
` result map: ${profile.resultMapMs.toFixed(2)}ms`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (profile.relationLoadMs > 0) {
|
|
78
|
+
console.log(
|
|
79
|
+
` relation load: ${profile.relationLoadMs.toFixed(2)}ms`
|
|
80
|
+
);
|
|
81
|
+
for (const rp of profile.relationProfiles) {
|
|
82
|
+
console.log(
|
|
83
|
+
` ${pad(rp.relation + ":", 20)} ${rp.sqlExecMs.toFixed(2)}ms (${rp.rowCount} rows)`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (profile.resultSizeBytes > 0) {
|
|
88
|
+
const kb = (profile.resultSizeBytes / 1024).toFixed(1);
|
|
89
|
+
console.log(` result size: ~${kb} KB`);
|
|
90
|
+
}
|
|
91
|
+
console.log(` SQL: ${profile.sql.slice(0, 200)}${profile.sql.length > 200 ? "..." : ""}`);
|
|
92
|
+
console.log("");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fast approximate result size in bytes (avoids full JSON.stringify). */
|
|
97
|
+
function estimateResultSize(params: { records: Record<string, unknown>[] }): number {
|
|
98
|
+
let size = 0;
|
|
99
|
+
for (const record of params.records) {
|
|
100
|
+
for (const key in record) {
|
|
101
|
+
const val = record[key];
|
|
102
|
+
size += key.length;
|
|
103
|
+
if (val === null || val === undefined) {
|
|
104
|
+
size += 4;
|
|
105
|
+
} else if (typeof val === "string") {
|
|
106
|
+
size += val.length;
|
|
107
|
+
} else if (typeof val === "number" || typeof val === "boolean") {
|
|
108
|
+
size += 8;
|
|
109
|
+
} else if (val instanceof Date) {
|
|
110
|
+
size += 24;
|
|
111
|
+
} else if (Array.isArray(val)) {
|
|
112
|
+
// Rough estimate for relation arrays: count items * avg row size
|
|
113
|
+
size += val.length * 200;
|
|
114
|
+
} else if (typeof val === "object") {
|
|
115
|
+
size += 200; // rough estimate for nested objects
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return size;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Create a profiling-aware executor that records per-call SQL timing. */
|
|
123
|
+
function createProfiledExecutor(profilingCtx: ProfilingContext): {
|
|
124
|
+
executor: typeof executeSql;
|
|
125
|
+
getTimings: () => { totalExecMs: number; calls: number };
|
|
126
|
+
} {
|
|
127
|
+
let totalExecMs = 0;
|
|
128
|
+
let calls = 0;
|
|
129
|
+
|
|
130
|
+
const executor = async (execParams: {
|
|
131
|
+
text: string;
|
|
132
|
+
values: unknown[];
|
|
133
|
+
}): Promise<Record<string, unknown>[]> => {
|
|
134
|
+
const t0 = performance.now();
|
|
135
|
+
const result = await executeSql(execParams);
|
|
136
|
+
const elapsed = performance.now() - t0;
|
|
137
|
+
totalExecMs += elapsed;
|
|
138
|
+
calls++;
|
|
139
|
+
|
|
140
|
+
// Record as a relation profile if this is a sub-query (not the main query)
|
|
141
|
+
// The main query is handled separately; relation queries are tracked via
|
|
142
|
+
// the profilingCtx being passed through loadRelations.
|
|
143
|
+
return result;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
executor,
|
|
148
|
+
getTimings: () => ({ totalExecMs, calls }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Normalize model meta into a lookup by camelCase name
|
|
153
|
+
const allModelsMeta: ModelMetaMap = {};
|
|
154
|
+
for (const [key, meta] of Object.entries(modelMeta)) {
|
|
155
|
+
allModelsMeta[key] = meta;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// SQL executor function — delegates to the adapter.
|
|
159
|
+
// Array values are converted via adapter.formatArrayParam so that
|
|
160
|
+
// = ANY($1) works correctly.
|
|
161
|
+
async function executeSql(params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
162
|
+
const { text, values: rawValues } = params;
|
|
163
|
+
// Convert PgArray instances - let the adapter handle the format
|
|
164
|
+
const values = rawValues.map((v) => (v instanceof PgArray ? adapter.formatArrayParam(v.values) : v));
|
|
165
|
+
|
|
166
|
+
if (shouldLog) {
|
|
167
|
+
console.log(`[vibeorm] ${text}`);
|
|
168
|
+
if (values.length > 0) {
|
|
169
|
+
console.log(`[vibeorm] params:`, values);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return adapter.execute({ text, values });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create delegate for a model
|
|
177
|
+
function createDelegate(params: {
|
|
178
|
+
modelKey: string;
|
|
179
|
+
modelMeta: ModelMeta;
|
|
180
|
+
executor: typeof executeSql;
|
|
181
|
+
schemas?: ModelSchemas;
|
|
182
|
+
}): Record<string, Function> {
|
|
183
|
+
const { modelKey, modelMeta, executor, schemas } = params;
|
|
184
|
+
|
|
185
|
+
// Resolve validation mode from options
|
|
186
|
+
const validateOpt = options?.validate;
|
|
187
|
+
const shouldValidateInput = !!schemas && (
|
|
188
|
+
validateOpt === true || validateOpt === "all" || validateOpt === "input"
|
|
189
|
+
);
|
|
190
|
+
const shouldValidateOutput = !!schemas && (
|
|
191
|
+
validateOpt === true || validateOpt === "all" || validateOpt === "output"
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
function validateInput(innerParams: {
|
|
195
|
+
data: unknown;
|
|
196
|
+
schemaKey: keyof ModelSchemas;
|
|
197
|
+
operation: string;
|
|
198
|
+
}): void {
|
|
199
|
+
if (!shouldValidateInput) return;
|
|
200
|
+
const schema = schemas?.[innerParams.schemaKey];
|
|
201
|
+
if (!schema) return;
|
|
202
|
+
const result = schema.safeParse(innerParams.data);
|
|
203
|
+
if (!result.success) {
|
|
204
|
+
throw new VibeValidationError({
|
|
205
|
+
model: modelMeta.name,
|
|
206
|
+
operation: innerParams.operation,
|
|
207
|
+
direction: "input",
|
|
208
|
+
zodError: result.error,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateOutput(innerParams: {
|
|
214
|
+
records: Record<string, unknown>[];
|
|
215
|
+
operation: string;
|
|
216
|
+
}): void {
|
|
217
|
+
if (!shouldValidateOutput) return;
|
|
218
|
+
const schema = schemas?.model;
|
|
219
|
+
if (!schema) return;
|
|
220
|
+
for (const record of innerParams.records) {
|
|
221
|
+
const result = schema.safeParse(record);
|
|
222
|
+
if (!result.success) {
|
|
223
|
+
throw new VibeValidationError({
|
|
224
|
+
model: modelMeta.name,
|
|
225
|
+
operation: innerParams.operation,
|
|
226
|
+
direction: "output",
|
|
227
|
+
zodError: result.error,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function shouldUseLateralJoin(args: Record<string, unknown>): boolean {
|
|
234
|
+
const strategy =
|
|
235
|
+
(args.relationStrategy as string) ??
|
|
236
|
+
options?.relationStrategy ??
|
|
237
|
+
"query";
|
|
238
|
+
if (strategy !== "join") return false;
|
|
239
|
+
// Only use lateral join if there are relations to load
|
|
240
|
+
const rels = resolveRelationsToLoad({ parentModelMeta: modelMeta, args });
|
|
241
|
+
return rels.length > 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function findMany(args: Record<string, unknown> = {}) {
|
|
245
|
+
if (shouldUseLateralJoin(args)) {
|
|
246
|
+
if (shouldDebug) {
|
|
247
|
+
const profilingCtx: ProfilingContext = { relationProfiles: [] };
|
|
248
|
+
const t0 = performance.now();
|
|
249
|
+
const result = await executeLateralJoinQuery({
|
|
250
|
+
modelMeta,
|
|
251
|
+
allModelsMeta,
|
|
252
|
+
args,
|
|
253
|
+
executor: executeSql,
|
|
254
|
+
profilingCtx,
|
|
255
|
+
});
|
|
256
|
+
const t1 = performance.now();
|
|
257
|
+
const sizeBytes = estimateResultSize({ records: result });
|
|
258
|
+
debugEmit({
|
|
259
|
+
model: modelMeta.name,
|
|
260
|
+
operation: "findMany",
|
|
261
|
+
totalMs: t1 - t0,
|
|
262
|
+
queryBuildMs: profilingCtx.queryBuildMs ?? 0,
|
|
263
|
+
sqlExecMs: profilingCtx.sqlExecMs ?? 0,
|
|
264
|
+
rowCount: result.length,
|
|
265
|
+
relationLoadMs: 0,
|
|
266
|
+
relationProfiles: profilingCtx.relationProfiles,
|
|
267
|
+
sql: profilingCtx.sql ?? "(lateral join)",
|
|
268
|
+
resultMapMs: profilingCtx.resultMapMs ?? 0,
|
|
269
|
+
resultSizeBytes: sizeBytes,
|
|
270
|
+
sqlValues: profilingCtx.sqlValues,
|
|
271
|
+
});
|
|
272
|
+
validateOutput({ records: result, operation: "findMany" });
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result = await executeLateralJoinQuery({
|
|
277
|
+
modelMeta,
|
|
278
|
+
allModelsMeta,
|
|
279
|
+
args,
|
|
280
|
+
executor,
|
|
281
|
+
});
|
|
282
|
+
validateOutput({ records: result, operation: "findMany" });
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (shouldDebug) {
|
|
287
|
+
const profilingCtx: ProfilingContext = { relationProfiles: [] };
|
|
288
|
+
const { executor: profExec } = createProfiledExecutor(profilingCtx);
|
|
289
|
+
const t0 = performance.now();
|
|
290
|
+
const query = buildSelectQuery({
|
|
291
|
+
modelMeta,
|
|
292
|
+
allModelsMeta,
|
|
293
|
+
args,
|
|
294
|
+
});
|
|
295
|
+
const t1 = performance.now();
|
|
296
|
+
let records = await profExec(query);
|
|
297
|
+
const t2 = performance.now();
|
|
298
|
+
records = await loadRelationsForStrategy({
|
|
299
|
+
records,
|
|
300
|
+
modelMeta,
|
|
301
|
+
allModelsMeta,
|
|
302
|
+
args,
|
|
303
|
+
executor: profExec,
|
|
304
|
+
profilingCtx,
|
|
305
|
+
});
|
|
306
|
+
const t3 = performance.now();
|
|
307
|
+
const sizeBytes = estimateResultSize({ records });
|
|
308
|
+
debugEmit({
|
|
309
|
+
model: modelMeta.name,
|
|
310
|
+
operation: "findMany",
|
|
311
|
+
totalMs: t3 - t0,
|
|
312
|
+
queryBuildMs: t1 - t0,
|
|
313
|
+
sqlExecMs: t2 - t1,
|
|
314
|
+
rowCount: records.length,
|
|
315
|
+
relationLoadMs: t3 - t2,
|
|
316
|
+
relationProfiles: profilingCtx.relationProfiles,
|
|
317
|
+
sql: query.text,
|
|
318
|
+
resultMapMs: 0,
|
|
319
|
+
resultSizeBytes: sizeBytes,
|
|
320
|
+
});
|
|
321
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
322
|
+
validateOutput({ records, operation: "findMany" });
|
|
323
|
+
return records;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const query = buildSelectQuery({
|
|
327
|
+
modelMeta,
|
|
328
|
+
allModelsMeta,
|
|
329
|
+
args,
|
|
330
|
+
});
|
|
331
|
+
let records = await executor(query);
|
|
332
|
+
|
|
333
|
+
records = await loadRelationsForStrategy({
|
|
334
|
+
records,
|
|
335
|
+
modelMeta,
|
|
336
|
+
allModelsMeta,
|
|
337
|
+
args,
|
|
338
|
+
executor,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
342
|
+
validateOutput({ records, operation: "findMany" });
|
|
343
|
+
return records;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function findFirst(args: Record<string, unknown> = {}) {
|
|
347
|
+
const argsWithLimit = { ...args, take: 1 };
|
|
348
|
+
|
|
349
|
+
if (shouldUseLateralJoin(args)) {
|
|
350
|
+
const records = await executeLateralJoinQuery({
|
|
351
|
+
modelMeta,
|
|
352
|
+
allModelsMeta,
|
|
353
|
+
args: argsWithLimit,
|
|
354
|
+
executor,
|
|
355
|
+
});
|
|
356
|
+
validateOutput({ records, operation: "findFirst" });
|
|
357
|
+
return records[0] ?? null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (shouldDebug) {
|
|
361
|
+
const profilingCtx: ProfilingContext = { relationProfiles: [] };
|
|
362
|
+
const { executor: profExec } = createProfiledExecutor(profilingCtx);
|
|
363
|
+
const t0 = performance.now();
|
|
364
|
+
const query = buildSelectQuery({ modelMeta, allModelsMeta, args: argsWithLimit });
|
|
365
|
+
const t1 = performance.now();
|
|
366
|
+
let records = await profExec(query);
|
|
367
|
+
const t2 = performance.now();
|
|
368
|
+
records = await loadRelationsForStrategy({
|
|
369
|
+
records, modelMeta, allModelsMeta, args, executor: profExec, profilingCtx,
|
|
370
|
+
});
|
|
371
|
+
const t3 = performance.now();
|
|
372
|
+
debugEmit({
|
|
373
|
+
model: modelMeta.name, operation: "findFirst",
|
|
374
|
+
totalMs: t3 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
|
|
375
|
+
rowCount: records.length, relationLoadMs: t3 - t2,
|
|
376
|
+
relationProfiles: profilingCtx.relationProfiles, sql: query.text,
|
|
377
|
+
resultMapMs: 0, resultSizeBytes: estimateResultSize({ records }),
|
|
378
|
+
});
|
|
379
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
380
|
+
validateOutput({ records, operation: "findFirst" });
|
|
381
|
+
return records[0] ?? null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const query = buildSelectQuery({
|
|
385
|
+
modelMeta,
|
|
386
|
+
allModelsMeta,
|
|
387
|
+
args: argsWithLimit,
|
|
388
|
+
});
|
|
389
|
+
let records = await executor(query);
|
|
390
|
+
|
|
391
|
+
records = await loadRelationsForStrategy({
|
|
392
|
+
records,
|
|
393
|
+
modelMeta,
|
|
394
|
+
allModelsMeta,
|
|
395
|
+
args,
|
|
396
|
+
executor,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
400
|
+
validateOutput({ records, operation: "findFirst" });
|
|
401
|
+
return records[0] ?? null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function findUnique(args: Record<string, unknown>) {
|
|
405
|
+
// Expand compound unique keys: { userId_planId: { userId: 1, planId: 2 } } → { userId: 1, planId: 2 }
|
|
406
|
+
if (args.where) {
|
|
407
|
+
args = { ...args, where: convertUniqueToWhere({ uniqueWhere: args.where as Record<string, unknown>, modelMeta }) };
|
|
408
|
+
}
|
|
409
|
+
const argsWithLimit = { ...args, take: 1 };
|
|
410
|
+
|
|
411
|
+
if (shouldUseLateralJoin(args)) {
|
|
412
|
+
const records = await executeLateralJoinQuery({
|
|
413
|
+
modelMeta,
|
|
414
|
+
allModelsMeta,
|
|
415
|
+
args: argsWithLimit,
|
|
416
|
+
executor,
|
|
417
|
+
});
|
|
418
|
+
validateOutput({ records, operation: "findUnique" });
|
|
419
|
+
return records[0] ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (shouldDebug) {
|
|
423
|
+
const profilingCtx: ProfilingContext = { relationProfiles: [] };
|
|
424
|
+
const { executor: profExec } = createProfiledExecutor(profilingCtx);
|
|
425
|
+
const t0 = performance.now();
|
|
426
|
+
const query = buildSelectQuery({ modelMeta, allModelsMeta, args: argsWithLimit });
|
|
427
|
+
const t1 = performance.now();
|
|
428
|
+
let records = await profExec(query);
|
|
429
|
+
const t2 = performance.now();
|
|
430
|
+
records = await loadRelationsForStrategy({
|
|
431
|
+
records, modelMeta, allModelsMeta, args, executor: profExec, profilingCtx,
|
|
432
|
+
});
|
|
433
|
+
const t3 = performance.now();
|
|
434
|
+
debugEmit({
|
|
435
|
+
model: modelMeta.name, operation: "findUnique",
|
|
436
|
+
totalMs: t3 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
|
|
437
|
+
rowCount: records.length, relationLoadMs: t3 - t2,
|
|
438
|
+
relationProfiles: profilingCtx.relationProfiles, sql: query.text,
|
|
439
|
+
resultMapMs: 0, resultSizeBytes: estimateResultSize({ records }),
|
|
440
|
+
});
|
|
441
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
442
|
+
validateOutput({ records, operation: "findUnique" });
|
|
443
|
+
return records[0] ?? null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const query = buildSelectQuery({
|
|
447
|
+
modelMeta,
|
|
448
|
+
allModelsMeta,
|
|
449
|
+
args: argsWithLimit,
|
|
450
|
+
});
|
|
451
|
+
let records = await executor(query);
|
|
452
|
+
|
|
453
|
+
records = await loadRelationsForStrategy({
|
|
454
|
+
records,
|
|
455
|
+
modelMeta,
|
|
456
|
+
allModelsMeta,
|
|
457
|
+
args,
|
|
458
|
+
executor,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
462
|
+
validateOutput({ records, operation: "findUnique" });
|
|
463
|
+
return records[0] ?? null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function findUniqueOrThrow(args: Record<string, unknown>) {
|
|
467
|
+
const result = await findUnique(args);
|
|
468
|
+
if (!result) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`No ${modelMeta.name} found for the given where clause`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function findFirstOrThrow(args: Record<string, unknown> = {}) {
|
|
477
|
+
const result = await findFirst(args);
|
|
478
|
+
if (!result) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`No ${modelMeta.name} found for the given where clause`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function create(args: Record<string, unknown>) {
|
|
487
|
+
const data = args.data as Record<string, unknown>;
|
|
488
|
+
|
|
489
|
+
// Validate input
|
|
490
|
+
validateInput({ data, schemaKey: "createInput", operation: "create" });
|
|
491
|
+
|
|
492
|
+
// Auto-inject @updatedAt fields and auto-generated defaults (uuid, cuid, etc.)
|
|
493
|
+
injectUpdatedAt({ data, modelMeta });
|
|
494
|
+
injectAutoDefaults({ data, modelMeta, idGenerator: options?.idGenerator });
|
|
495
|
+
|
|
496
|
+
const { processedData, deferredCreates } = await processNestedCreates({
|
|
497
|
+
data,
|
|
498
|
+
modelMeta,
|
|
499
|
+
allModelsMeta,
|
|
500
|
+
executor,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const query = buildInsertQuery({
|
|
504
|
+
modelMeta,
|
|
505
|
+
data: processedData,
|
|
506
|
+
});
|
|
507
|
+
let records = await executor(query);
|
|
508
|
+
|
|
509
|
+
// Execute deferred creates (related records that hold the FK to this parent)
|
|
510
|
+
// Group by related model to batch multiple creates into single INSERT statements.
|
|
511
|
+
const parentRecord = records[0];
|
|
512
|
+
if (parentRecord && deferredCreates.length > 0) {
|
|
513
|
+
for (const deferred of deferredCreates) {
|
|
514
|
+
const parentRefValue = parentRecord[deferred.parentRefField];
|
|
515
|
+
if (parentRefValue != null) {
|
|
516
|
+
const createItems = Array.isArray(deferred.createData) ? deferred.createData : [deferred.createData];
|
|
517
|
+
const dataArray = createItems.map((createData) => {
|
|
518
|
+
(createData as Record<string, unknown>)[deferred.fkField] = parentRefValue;
|
|
519
|
+
return createData as Record<string, unknown>;
|
|
520
|
+
});
|
|
521
|
+
if (dataArray.length === 1) {
|
|
522
|
+
const insertQuery = buildInsertQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray[0]! });
|
|
523
|
+
await executor(insertQuery);
|
|
524
|
+
} else if (dataArray.length > 1) {
|
|
525
|
+
const insertQuery = buildInsertManyQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray, returning: false });
|
|
526
|
+
await executor(insertQuery);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
records = await loadRelationsForStrategy({
|
|
533
|
+
records,
|
|
534
|
+
modelMeta,
|
|
535
|
+
allModelsMeta,
|
|
536
|
+
args,
|
|
537
|
+
executor,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
541
|
+
|
|
542
|
+
validateOutput({ records, operation: "create" });
|
|
543
|
+
return records[0]!;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function update(args: Record<string, unknown>) {
|
|
547
|
+
const where = args.where as Record<string, unknown>;
|
|
548
|
+
const data = args.data as Record<string, unknown>;
|
|
549
|
+
|
|
550
|
+
// Validate input
|
|
551
|
+
validateInput({ data, schemaKey: "updateInput", operation: "update" });
|
|
552
|
+
|
|
553
|
+
// Auto-inject @updatedAt fields
|
|
554
|
+
injectUpdatedAt({ data, modelMeta });
|
|
555
|
+
|
|
556
|
+
const whereInput = convertUniqueToWhere({
|
|
557
|
+
uniqueWhere: where,
|
|
558
|
+
modelMeta,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Separate scalar data from nested relation operations
|
|
562
|
+
const { scalarData, nestedOps } = separateNestedOps({
|
|
563
|
+
data,
|
|
564
|
+
modelMeta,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const query = buildUpdateQuery({
|
|
568
|
+
modelMeta,
|
|
569
|
+
allModelsMeta,
|
|
570
|
+
where: whereInput,
|
|
571
|
+
data: scalarData,
|
|
572
|
+
});
|
|
573
|
+
let records = await executor(query);
|
|
574
|
+
|
|
575
|
+
if (records.length === 0) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const updatedRecord = records[0]!;
|
|
582
|
+
|
|
583
|
+
// Process nested relation operations
|
|
584
|
+
if (nestedOps.length > 0) {
|
|
585
|
+
await processNestedUpdateOps({
|
|
586
|
+
parentRecord: updatedRecord,
|
|
587
|
+
nestedOps,
|
|
588
|
+
modelMeta,
|
|
589
|
+
allModelsMeta,
|
|
590
|
+
executor,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
records = await loadRelationsForStrategy({
|
|
595
|
+
records,
|
|
596
|
+
modelMeta,
|
|
597
|
+
allModelsMeta,
|
|
598
|
+
args,
|
|
599
|
+
executor,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
603
|
+
|
|
604
|
+
validateOutput({ records, operation: "update" });
|
|
605
|
+
return records[0]!;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function upsert(args: Record<string, unknown>) {
|
|
609
|
+
const where = args.where as Record<string, unknown>;
|
|
610
|
+
const createData = args.create as Record<string, unknown>;
|
|
611
|
+
const updateData = args.update as Record<string, unknown>;
|
|
612
|
+
|
|
613
|
+
// Validate inputs
|
|
614
|
+
validateInput({ data: createData, schemaKey: "createInput", operation: "upsert" });
|
|
615
|
+
validateInput({ data: updateData, schemaKey: "updateInput", operation: "upsert" });
|
|
616
|
+
|
|
617
|
+
// Auto-inject @updatedAt fields and auto-generated defaults for create data
|
|
618
|
+
injectUpdatedAt({ data: createData, modelMeta });
|
|
619
|
+
injectUpdatedAt({ data: updateData, modelMeta });
|
|
620
|
+
injectAutoDefaults({ data: createData, modelMeta, idGenerator: options?.idGenerator });
|
|
621
|
+
|
|
622
|
+
const whereInput = convertUniqueToWhere({
|
|
623
|
+
uniqueWhere: where,
|
|
624
|
+
modelMeta,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const query = buildUpsertQuery({
|
|
628
|
+
modelMeta,
|
|
629
|
+
where: whereInput,
|
|
630
|
+
create: createData,
|
|
631
|
+
update: updateData,
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
let records = await executor(query);
|
|
635
|
+
|
|
636
|
+
// If ON CONFLICT DO NOTHING returned no rows (rare edge case),
|
|
637
|
+
// fall back to reading the existing record
|
|
638
|
+
if (records.length === 0) {
|
|
639
|
+
const existing = await findUnique({ where, select: args.select, include: args.include });
|
|
640
|
+
if (existing) return existing;
|
|
641
|
+
throw new Error(`Upsert failed: could not insert or find ${modelMeta.name}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
records = await loadRelationsForStrategy({
|
|
645
|
+
records,
|
|
646
|
+
modelMeta,
|
|
647
|
+
allModelsMeta,
|
|
648
|
+
args,
|
|
649
|
+
executor,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
653
|
+
|
|
654
|
+
validateOutput({ records, operation: "upsert" });
|
|
655
|
+
return records[0]!;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function del(args: Record<string, unknown>) {
|
|
659
|
+
const where = args.where as Record<string, unknown>;
|
|
660
|
+
const whereInput = convertUniqueToWhere({
|
|
661
|
+
uniqueWhere: where,
|
|
662
|
+
modelMeta,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const query = buildDeleteQuery({
|
|
666
|
+
modelMeta,
|
|
667
|
+
allModelsMeta,
|
|
668
|
+
where: whereInput,
|
|
669
|
+
});
|
|
670
|
+
let records = await executor(query);
|
|
671
|
+
|
|
672
|
+
if (records.length === 0) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
coerceFieldTypes({ records, modelMeta });
|
|
679
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
680
|
+
|
|
681
|
+
validateOutput({ records, operation: "delete" });
|
|
682
|
+
return records[0]!;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function deleteMany(args: Record<string, unknown> = {}) {
|
|
686
|
+
const where = (args.where ?? {}) as Record<string, unknown>;
|
|
687
|
+
// Use a CTE with RETURNING 1 to count deleted rows server-side
|
|
688
|
+
// instead of transferring full row payloads over the wire.
|
|
689
|
+
const baseQuery = buildDeleteQuery({
|
|
690
|
+
modelMeta,
|
|
691
|
+
allModelsMeta,
|
|
692
|
+
where,
|
|
693
|
+
});
|
|
694
|
+
// Replace RETURNING <all cols> with RETURNING 1 inside a CTE
|
|
695
|
+
const returningIdx = baseQuery.text.indexOf(" RETURNING ");
|
|
696
|
+
const deleteText = returningIdx >= 0 ? baseQuery.text.slice(0, returningIdx) : baseQuery.text;
|
|
697
|
+
const countQuery = {
|
|
698
|
+
text: `WITH deleted AS (${deleteText} RETURNING 1) SELECT COUNT(*) AS "count" FROM deleted`,
|
|
699
|
+
values: baseQuery.values,
|
|
700
|
+
};
|
|
701
|
+
const result = await executor(countQuery);
|
|
702
|
+
const row = result[0] as { count: string | number } | undefined;
|
|
703
|
+
return { count: Number(row?.count ?? 0) };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function count(args: Record<string, unknown> = {}) {
|
|
707
|
+
const countStrategy = options?.countStrategy ?? "direct";
|
|
708
|
+
|
|
709
|
+
if (shouldDebug) {
|
|
710
|
+
const t0 = performance.now();
|
|
711
|
+
const query = buildCountQuery({ modelMeta, allModelsMeta, args, countStrategy });
|
|
712
|
+
const t1 = performance.now();
|
|
713
|
+
const records = await executor(query);
|
|
714
|
+
const t2 = performance.now();
|
|
715
|
+
const row = records[0] as { count: string | number } | undefined;
|
|
716
|
+
debugEmit({
|
|
717
|
+
model: modelMeta.name, operation: "count",
|
|
718
|
+
totalMs: t2 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
|
|
719
|
+
rowCount: 1, relationLoadMs: 0, relationProfiles: [], sql: query.text,
|
|
720
|
+
resultMapMs: 0, resultSizeBytes: 0,
|
|
721
|
+
});
|
|
722
|
+
return Number(row?.count ?? 0);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const query = buildCountQuery({
|
|
726
|
+
modelMeta,
|
|
727
|
+
allModelsMeta,
|
|
728
|
+
args,
|
|
729
|
+
countStrategy,
|
|
730
|
+
});
|
|
731
|
+
const records = await executor(query);
|
|
732
|
+
const row = records[0] as { count: string | number } | undefined;
|
|
733
|
+
return Number(row?.count ?? 0);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function createMany(args: Record<string, unknown>) {
|
|
737
|
+
const rawData = args.data as Record<string, unknown> | Record<string, unknown>[];
|
|
738
|
+
const dataArray = Array.isArray(rawData) ? rawData : [rawData];
|
|
739
|
+
|
|
740
|
+
// Short-circuit: empty data array → no rows inserted
|
|
741
|
+
if (dataArray.length === 0) {
|
|
742
|
+
return { count: 0 };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Validate inputs
|
|
746
|
+
for (const record of dataArray) {
|
|
747
|
+
validateInput({ data: record, schemaKey: "createInput", operation: "createMany" });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Auto-inject @updatedAt fields and auto-generated defaults on each record
|
|
751
|
+
for (const record of dataArray) {
|
|
752
|
+
injectUpdatedAt({ data: record, modelMeta });
|
|
753
|
+
injectAutoDefaults({ data: record, modelMeta, idGenerator: options?.idGenerator });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Count inserted rows server-side using CTE with RETURNING 1.
|
|
757
|
+
// This avoids transferring PK values over the wire just to count them.
|
|
758
|
+
// Handles skipDuplicates correctly since ON CONFLICT DO NOTHING rows
|
|
759
|
+
// won't appear in the RETURNING set.
|
|
760
|
+
const query = buildInsertManyQuery({
|
|
761
|
+
modelMeta,
|
|
762
|
+
data: dataArray,
|
|
763
|
+
skipDuplicates: args.skipDuplicates as boolean | undefined,
|
|
764
|
+
returning: true,
|
|
765
|
+
selectFields: [modelMeta.primaryKey[0]!],
|
|
766
|
+
});
|
|
767
|
+
// Wrap in CTE to count server-side
|
|
768
|
+
const returningIdx = query.text.indexOf(" RETURNING ");
|
|
769
|
+
if (returningIdx >= 0) {
|
|
770
|
+
const insertText = query.text.slice(0, returningIdx);
|
|
771
|
+
const countQuery = {
|
|
772
|
+
text: `WITH inserted AS (${insertText} RETURNING 1) SELECT COUNT(*) AS "count" FROM inserted`,
|
|
773
|
+
values: query.values,
|
|
774
|
+
};
|
|
775
|
+
const result = await executor(countQuery);
|
|
776
|
+
const row = result[0] as { count: string | number } | undefined;
|
|
777
|
+
return { count: Number(row?.count ?? 0) };
|
|
778
|
+
}
|
|
779
|
+
// Fallback: if no RETURNING clause (shouldn't happen), execute as-is
|
|
780
|
+
const result = await executor(query);
|
|
781
|
+
return { count: result.length };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function createManyAndReturn(args: Record<string, unknown>) {
|
|
785
|
+
const rawData = args.data as Record<string, unknown> | Record<string, unknown>[];
|
|
786
|
+
const dataArray = Array.isArray(rawData) ? rawData : [rawData];
|
|
787
|
+
|
|
788
|
+
// Short-circuit: empty data array → no rows inserted
|
|
789
|
+
if (dataArray.length === 0) {
|
|
790
|
+
return [];
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Validate inputs
|
|
794
|
+
for (const record of dataArray) {
|
|
795
|
+
validateInput({ data: record, schemaKey: "createInput", operation: "createManyAndReturn" });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Auto-inject @updatedAt fields and auto-generated defaults on each record
|
|
799
|
+
for (const record of dataArray) {
|
|
800
|
+
injectUpdatedAt({ data: record, modelMeta });
|
|
801
|
+
injectAutoDefaults({ data: record, modelMeta, idGenerator: options?.idGenerator });
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const query = buildInsertManyQuery({
|
|
805
|
+
modelMeta,
|
|
806
|
+
data: dataArray,
|
|
807
|
+
skipDuplicates: args.skipDuplicates as boolean | undefined,
|
|
808
|
+
returning: true,
|
|
809
|
+
});
|
|
810
|
+
let records = await executor(query);
|
|
811
|
+
|
|
812
|
+
records = await loadRelationsForStrategy({
|
|
813
|
+
records,
|
|
814
|
+
modelMeta,
|
|
815
|
+
allModelsMeta,
|
|
816
|
+
args,
|
|
817
|
+
executor,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
records = applySelectFiltering({ records, args, modelMeta });
|
|
821
|
+
|
|
822
|
+
validateOutput({ records, operation: "createManyAndReturn" });
|
|
823
|
+
return records;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function updateMany(args: Record<string, unknown>) {
|
|
827
|
+
const where = (args.where ?? {}) as Record<string, unknown>;
|
|
828
|
+
const data = args.data as Record<string, unknown>;
|
|
829
|
+
|
|
830
|
+
// Validate input
|
|
831
|
+
validateInput({ data, schemaKey: "updateInput", operation: "updateMany" });
|
|
832
|
+
|
|
833
|
+
// Short-circuit: if user provided no data fields, return 0 immediately
|
|
834
|
+
// (check BEFORE injectUpdatedAt so auto-managed fields don't cause a real UPDATE)
|
|
835
|
+
const userDataKeys = Object.keys(data).filter((k) => data[k] !== undefined);
|
|
836
|
+
if (userDataKeys.length === 0) {
|
|
837
|
+
return { count: 0 };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Auto-inject @updatedAt fields
|
|
841
|
+
injectUpdatedAt({ data, modelMeta });
|
|
842
|
+
|
|
843
|
+
const query = buildUpdateManyQuery({
|
|
844
|
+
modelMeta,
|
|
845
|
+
allModelsMeta,
|
|
846
|
+
where,
|
|
847
|
+
data,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// Short-circuit: if builder returned a no-op (empty data), return 0 directly
|
|
851
|
+
// This avoids wrapping `SELECT 0 AS "count"` in an invalid CTE with RETURNING
|
|
852
|
+
if (query.text.startsWith("SELECT 0")) {
|
|
853
|
+
return { count: 0 };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// updateMany without RETURNING — we need row count
|
|
857
|
+
// Use a CTE with RETURNING trick to get count
|
|
858
|
+
const countQuery = {
|
|
859
|
+
text: `WITH updated AS (${query.text} RETURNING 1) SELECT COUNT(*) AS "count" FROM updated`,
|
|
860
|
+
values: query.values,
|
|
861
|
+
};
|
|
862
|
+
const result = await executor(countQuery);
|
|
863
|
+
const row = result[0] as { count: string | number } | undefined;
|
|
864
|
+
return { count: Number(row?.count ?? 0) };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function aggregate(args: Record<string, unknown>) {
|
|
868
|
+
const query = buildAggregateQuery({
|
|
869
|
+
modelMeta,
|
|
870
|
+
allModelsMeta,
|
|
871
|
+
args,
|
|
872
|
+
});
|
|
873
|
+
const records = await executor(query);
|
|
874
|
+
const row = records[0] as Record<string, unknown> | undefined;
|
|
875
|
+
|
|
876
|
+
if (!row) {
|
|
877
|
+
return {};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Transform flat row with keys like "_count__all", "_avg__viewCount"
|
|
881
|
+
// into nested structure: { _count: 5, _avg: { viewCount: 45.2 } }
|
|
882
|
+
return parseAggregateResult({ row, args });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function groupBy(args: Record<string, unknown>) {
|
|
886
|
+
const query = buildGroupByQuery({
|
|
887
|
+
modelMeta,
|
|
888
|
+
allModelsMeta,
|
|
889
|
+
args,
|
|
890
|
+
});
|
|
891
|
+
const records = await executor(query);
|
|
892
|
+
|
|
893
|
+
// Transform each row's flat aggregate columns into nested structure
|
|
894
|
+
return records.map((row) => parseAggregateResult({ row, args }));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
findMany,
|
|
899
|
+
findFirst,
|
|
900
|
+
findUnique,
|
|
901
|
+
findUniqueOrThrow,
|
|
902
|
+
findFirstOrThrow,
|
|
903
|
+
create,
|
|
904
|
+
createMany,
|
|
905
|
+
createManyAndReturn,
|
|
906
|
+
update,
|
|
907
|
+
upsert,
|
|
908
|
+
delete: del,
|
|
909
|
+
deleteMany,
|
|
910
|
+
updateMany,
|
|
911
|
+
count,
|
|
912
|
+
aggregate,
|
|
913
|
+
groupBy,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Helper: load relations using the configured strategy
|
|
918
|
+
async function loadRelationsForStrategy(params: {
|
|
919
|
+
records: Record<string, unknown>[];
|
|
920
|
+
modelMeta: ModelMeta;
|
|
921
|
+
allModelsMeta: ModelMetaMap;
|
|
922
|
+
args: Record<string, unknown>;
|
|
923
|
+
executor: typeof executeSql;
|
|
924
|
+
profilingCtx?: ProfilingContext;
|
|
925
|
+
}): Promise<Record<string, unknown>[]> {
|
|
926
|
+
// Coerce field types (e.g., BigInt from string) before any processing
|
|
927
|
+
coerceFieldTypes({ records: params.records, modelMeta: params.modelMeta });
|
|
928
|
+
|
|
929
|
+
const strategy =
|
|
930
|
+
(params.args.relationStrategy as string) ??
|
|
931
|
+
options?.relationStrategy ??
|
|
932
|
+
"query";
|
|
933
|
+
|
|
934
|
+
let records: Record<string, unknown>[];
|
|
935
|
+
if (strategy === "join") {
|
|
936
|
+
records = await loadRelationsWithLateralJoin({
|
|
937
|
+
parentRecords: params.records,
|
|
938
|
+
parentModelMeta: params.modelMeta,
|
|
939
|
+
allModelsMeta: params.allModelsMeta,
|
|
940
|
+
args: params.args,
|
|
941
|
+
executor: params.executor,
|
|
942
|
+
profilingCtx: params.profilingCtx,
|
|
943
|
+
});
|
|
944
|
+
} else {
|
|
945
|
+
records = await loadRelations({
|
|
946
|
+
parentRecords: params.records,
|
|
947
|
+
parentModelMeta: params.modelMeta,
|
|
948
|
+
allModelsMeta: params.allModelsMeta,
|
|
949
|
+
args: params.args,
|
|
950
|
+
executor: params.executor,
|
|
951
|
+
profilingCtx: params.profilingCtx,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Load _count if requested in include or select
|
|
956
|
+
const countSpec = resolveCountSpec({ args: params.args });
|
|
957
|
+
if (countSpec && records.length > 0) {
|
|
958
|
+
await loadRelationCounts({
|
|
959
|
+
records,
|
|
960
|
+
modelMeta: params.modelMeta,
|
|
961
|
+
allModelsMeta: params.allModelsMeta,
|
|
962
|
+
countSpec,
|
|
963
|
+
executor: params.executor,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return records;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Build the client object with delegates for each model
|
|
971
|
+
const client: Record<string, unknown> = {};
|
|
972
|
+
|
|
973
|
+
for (const [key, meta] of Object.entries(allModelsMeta)) {
|
|
974
|
+
client[key] = createDelegate({
|
|
975
|
+
modelKey: key,
|
|
976
|
+
modelMeta: meta,
|
|
977
|
+
executor: executeSql,
|
|
978
|
+
schemas: params.schemas?.[key],
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// $transaction — supports both callback style and array-of-promises style
|
|
983
|
+
client.$transaction = async function <T>(
|
|
984
|
+
fnOrPromises: ((tx: Record<string, unknown>) => Promise<T>) | Promise<unknown>[]
|
|
985
|
+
): Promise<T | unknown[]> {
|
|
986
|
+
// Array-of-promises style
|
|
987
|
+
if (Array.isArray(fnOrPromises)) {
|
|
988
|
+
return adapter.transaction(async () => {
|
|
989
|
+
return Promise.all(fnOrPromises);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Callback style
|
|
994
|
+
const fn = fnOrPromises as (tx: Record<string, unknown>) => Promise<T>;
|
|
995
|
+
return adapter.transaction(async (txAdapter) => {
|
|
996
|
+
// Create a transactional executor
|
|
997
|
+
async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
998
|
+
const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
|
|
999
|
+
if (shouldLog) {
|
|
1000
|
+
console.log(`[vibeorm:tx] ${txParams.text}`);
|
|
1001
|
+
if (values.length > 0) {
|
|
1002
|
+
console.log(`[vibeorm:tx] params:`, values);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return txAdapter.execute({ text: txParams.text, values });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Build transactional delegates
|
|
1009
|
+
const txClient: Record<string, unknown> = {};
|
|
1010
|
+
for (const [key, meta] of Object.entries(allModelsMeta)) {
|
|
1011
|
+
txClient[key] = createDelegate({
|
|
1012
|
+
modelKey: key,
|
|
1013
|
+
modelMeta: meta,
|
|
1014
|
+
executor: txExecutor,
|
|
1015
|
+
schemas: params.schemas?.[key],
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Add $queryRaw and $executeRaw to transactional client
|
|
1020
|
+
txClient.$queryRaw = async function <T = unknown>(
|
|
1021
|
+
strings: TemplateStringsArray,
|
|
1022
|
+
...values: unknown[]
|
|
1023
|
+
): Promise<T[]> {
|
|
1024
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1025
|
+
if (shouldLog) {
|
|
1026
|
+
console.log(`[vibeorm:tx] ${text}`);
|
|
1027
|
+
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1028
|
+
}
|
|
1029
|
+
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1030
|
+
return result.rows as T[];
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
txClient.$executeRaw = async function (
|
|
1034
|
+
strings: TemplateStringsArray,
|
|
1035
|
+
...values: unknown[]
|
|
1036
|
+
): Promise<number> {
|
|
1037
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1038
|
+
if (shouldLog) {
|
|
1039
|
+
console.log(`[vibeorm:tx] ${text}`);
|
|
1040
|
+
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1041
|
+
}
|
|
1042
|
+
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1043
|
+
return result.affectedRows;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
return fn(txClient);
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// $queryRaw — tagged template literal for safe parameterized queries
|
|
1051
|
+
client.$queryRaw = async function <T = unknown>(
|
|
1052
|
+
strings: TemplateStringsArray,
|
|
1053
|
+
...values: unknown[]
|
|
1054
|
+
): Promise<T[]> {
|
|
1055
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1056
|
+
if (shouldLog) {
|
|
1057
|
+
console.log(`[vibeorm:raw] ${text}`);
|
|
1058
|
+
if (sqlParams.length > 0) {
|
|
1059
|
+
console.log(`[vibeorm:raw] params:`, sqlParams);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const result = await adapter.executeUnsafe({ text, values: sqlParams });
|
|
1063
|
+
return result.rows as T[];
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// $executeRaw — tagged template literal for INSERT/UPDATE/DELETE returning affected count
|
|
1067
|
+
client.$executeRaw = async function (
|
|
1068
|
+
strings: TemplateStringsArray,
|
|
1069
|
+
...values: unknown[]
|
|
1070
|
+
): Promise<number> {
|
|
1071
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1072
|
+
if (shouldLog) {
|
|
1073
|
+
console.log(`[vibeorm:raw] ${text}`);
|
|
1074
|
+
if (sqlParams.length > 0) {
|
|
1075
|
+
console.log(`[vibeorm:raw] params:`, sqlParams);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const result = await adapter.executeUnsafe({ text, values: sqlParams });
|
|
1079
|
+
return result.affectedRows;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
// $queryRawUnsafe — accepts a plain SQL string + params array
|
|
1083
|
+
client.$queryRawUnsafe = async function <T = unknown>(
|
|
1084
|
+
query: string,
|
|
1085
|
+
...values: unknown[]
|
|
1086
|
+
): Promise<T[]> {
|
|
1087
|
+
if (shouldLog) {
|
|
1088
|
+
console.log(`[vibeorm:raw] ${query}`);
|
|
1089
|
+
if (values.length > 0) {
|
|
1090
|
+
console.log(`[vibeorm:raw] params:`, values);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (values.length === 0) {
|
|
1094
|
+
const result = await adapter.executeUnsafe({ text: query });
|
|
1095
|
+
return result.rows as T[];
|
|
1096
|
+
}
|
|
1097
|
+
const rows = await adapter.execute({ text: query, values });
|
|
1098
|
+
return rows as T[];
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
// $executeRawUnsafe — accepts a plain SQL string + params array, returns affected count
|
|
1102
|
+
client.$executeRawUnsafe = async function (
|
|
1103
|
+
query: string,
|
|
1104
|
+
...values: unknown[]
|
|
1105
|
+
): Promise<number> {
|
|
1106
|
+
if (shouldLog) {
|
|
1107
|
+
console.log(`[vibeorm:raw] ${query}`);
|
|
1108
|
+
if (values.length > 0) {
|
|
1109
|
+
console.log(`[vibeorm:raw] params:`, values);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const result = await adapter.executeUnsafe({ text: query, values: values.length > 0 ? values : undefined });
|
|
1113
|
+
return result.affectedRows;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
// $connect
|
|
1117
|
+
client.$connect = async function (): Promise<void> {
|
|
1118
|
+
await adapter.connect();
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// $disconnect
|
|
1122
|
+
client.$disconnect = async function (): Promise<void> {
|
|
1123
|
+
await adapter.disconnect();
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
// Eager connection: warm up the pool immediately on client creation.
|
|
1127
|
+
// This runs in the background — it won't block client construction,
|
|
1128
|
+
// but will ensure connections are ready before the first real query.
|
|
1129
|
+
if (options.eager) {
|
|
1130
|
+
adapter.connect().catch(() => {
|
|
1131
|
+
// Swallow connection errors during eager warmup.
|
|
1132
|
+
// They'll surface on the first actual query instead.
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return client;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Coerce scalar field values to their correct JS types.
|
|
1141
|
+
* Currently handles BigInt fields: bun:sql returns PostgreSQL bigint as string,
|
|
1142
|
+
* but the application expects native BigInt values.
|
|
1143
|
+
*/
|
|
1144
|
+
function coerceFieldTypes(params: {
|
|
1145
|
+
records: Record<string, unknown>[];
|
|
1146
|
+
modelMeta: ModelMeta;
|
|
1147
|
+
}): Record<string, unknown>[] {
|
|
1148
|
+
const { records, modelMeta } = params;
|
|
1149
|
+
if (records.length === 0) return records;
|
|
1150
|
+
|
|
1151
|
+
// Find fields that need coercion
|
|
1152
|
+
const bigintFields = modelMeta.scalarFields.filter(
|
|
1153
|
+
(f) => (f as { type?: string }).type === "BigInt"
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
if (bigintFields.length === 0) return records;
|
|
1157
|
+
|
|
1158
|
+
for (const record of records) {
|
|
1159
|
+
for (const field of bigintFields) {
|
|
1160
|
+
const val = record[field.name];
|
|
1161
|
+
if (typeof val === "string") {
|
|
1162
|
+
record[field.name] = BigInt(val);
|
|
1163
|
+
} else if (typeof val === "number") {
|
|
1164
|
+
record[field.name] = BigInt(val);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return records;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Apply select filtering to returned records.
|
|
1174
|
+
* When args.select is specified, strips fields not in the select object
|
|
1175
|
+
* from each record. This ensures mutation results (create, update, delete, upsert)
|
|
1176
|
+
* only contain the selected scalar fields (plus any relation fields loaded separately).
|
|
1177
|
+
*/
|
|
1178
|
+
function applySelectFiltering(params: {
|
|
1179
|
+
records: Record<string, unknown>[];
|
|
1180
|
+
args: Record<string, unknown>;
|
|
1181
|
+
modelMeta: ModelMeta;
|
|
1182
|
+
}): Record<string, unknown>[] {
|
|
1183
|
+
const { records, args, modelMeta } = params;
|
|
1184
|
+
const select = args.select as Record<string, boolean | object> | undefined;
|
|
1185
|
+
if (!select) return records;
|
|
1186
|
+
|
|
1187
|
+
// Build set of allowed scalar field names
|
|
1188
|
+
const allowedFields = new Set<string>();
|
|
1189
|
+
for (const [key, val] of Object.entries(select)) {
|
|
1190
|
+
if (val === true || (typeof val === "object" && val !== null)) {
|
|
1191
|
+
allowedFields.add(key);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Also allow relation fields that were loaded (they won't be in scalarFields)
|
|
1196
|
+
const scalarFieldNames = new Set(modelMeta.scalarFields.map((f) => f.name));
|
|
1197
|
+
|
|
1198
|
+
return records.map((record) => {
|
|
1199
|
+
const filtered: Record<string, unknown> = {};
|
|
1200
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1201
|
+
if (allowedFields.has(key)) {
|
|
1202
|
+
filtered[key] = value;
|
|
1203
|
+
} else if (!scalarFieldNames.has(key)) {
|
|
1204
|
+
// Keep non-scalar fields that aren't in the model (e.g., _count, relation data)
|
|
1205
|
+
// Only strip scalar fields that weren't selected
|
|
1206
|
+
filtered[key] = value;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return filtered;
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Convert a WhereUniqueInput (e.g. { id: 1 } or { email: "test@test.com" })
|
|
1217
|
+
* to a WhereInput format suitable for the where builder.
|
|
1218
|
+
*
|
|
1219
|
+
* Handles compound unique keys: { userId_planId: { userId: 1, planId: 2 } }
|
|
1220
|
+
* is expanded to { userId: 1, planId: 2 }.
|
|
1221
|
+
*/
|
|
1222
|
+
function convertUniqueToWhere(params: {
|
|
1223
|
+
uniqueWhere: Record<string, unknown>;
|
|
1224
|
+
modelMeta: ModelMeta;
|
|
1225
|
+
}): Record<string, unknown> {
|
|
1226
|
+
const { uniqueWhere, modelMeta } = params;
|
|
1227
|
+
const result: Record<string, unknown> = {};
|
|
1228
|
+
|
|
1229
|
+
for (const [key, value] of Object.entries(uniqueWhere)) {
|
|
1230
|
+
if (value === undefined) continue;
|
|
1231
|
+
|
|
1232
|
+
// Check if this is a scalar field — pass through directly
|
|
1233
|
+
const isScalar = modelMeta.scalarFields.some((f) => f.name === key);
|
|
1234
|
+
if (isScalar) {
|
|
1235
|
+
result[key] = value;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Check if this is a compound unique key (e.g. "userId_planId")
|
|
1240
|
+
// These contain an object with the individual field values
|
|
1241
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1242
|
+
const compound = value as Record<string, unknown>;
|
|
1243
|
+
// Verify all sub-keys are scalar fields on this model
|
|
1244
|
+
const allScalar = Object.keys(compound).every((k) =>
|
|
1245
|
+
modelMeta.scalarFields.some((f) => f.name === k)
|
|
1246
|
+
);
|
|
1247
|
+
if (allScalar) {
|
|
1248
|
+
// Expand compound key into individual field conditions
|
|
1249
|
+
for (const [subKey, subValue] of Object.entries(compound)) {
|
|
1250
|
+
result[subKey] = subValue;
|
|
1251
|
+
}
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Pass through anything else (e.g. relation fields in select/include context)
|
|
1257
|
+
result[key] = value;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Auto-inject current timestamp for fields with @updatedAt.
|
|
1265
|
+
*/
|
|
1266
|
+
function injectUpdatedAt(params: {
|
|
1267
|
+
data: Record<string, unknown>;
|
|
1268
|
+
modelMeta: ModelMeta;
|
|
1269
|
+
}): void {
|
|
1270
|
+
for (const sf of params.modelMeta.scalarFields) {
|
|
1271
|
+
if (sf.isUpdatedAt && params.data[sf.name] === undefined) {
|
|
1272
|
+
params.data[sf.name] = new Date();
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Auto-inject generated values for fields with @default(uuid/cuid/nanoid/ulid)
|
|
1279
|
+
* when the user hasn't provided a value.
|
|
1280
|
+
*
|
|
1281
|
+
* If a custom idGenerator is provided in options, it takes precedence.
|
|
1282
|
+
* If the field has an idPrefix (from /// @vibeorm.idPrefix("...")), it is prepended.
|
|
1283
|
+
*/
|
|
1284
|
+
function injectAutoDefaults(params: {
|
|
1285
|
+
data: Record<string, unknown>;
|
|
1286
|
+
modelMeta: ModelMeta;
|
|
1287
|
+
idGenerator?: (params: { model: string; field: string; defaultKind: string }) => string;
|
|
1288
|
+
}): void {
|
|
1289
|
+
const { data, modelMeta, idGenerator } = params;
|
|
1290
|
+
for (const sf of modelMeta.scalarFields) {
|
|
1291
|
+
// Only inject for app-level defaults when the field is not provided
|
|
1292
|
+
if (!sf.hasDefault || sf.hasDefault === true) continue;
|
|
1293
|
+
if (data[sf.name] !== undefined) continue;
|
|
1294
|
+
|
|
1295
|
+
let value: string | undefined;
|
|
1296
|
+
|
|
1297
|
+
if (idGenerator) {
|
|
1298
|
+
value = idGenerator({ model: modelMeta.name, field: sf.name, defaultKind: sf.hasDefault });
|
|
1299
|
+
} else {
|
|
1300
|
+
value = generateDefault({ kind: sf.hasDefault });
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (value !== undefined) {
|
|
1304
|
+
// Prepend idPrefix if configured
|
|
1305
|
+
if (sf.idPrefix) {
|
|
1306
|
+
value = sf.idPrefix + value;
|
|
1307
|
+
}
|
|
1308
|
+
data[sf.name] = value;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Build parameterized SQL from a tagged template literal.
|
|
1315
|
+
* Converts: sql`SELECT * FROM "User" WHERE id = ${1}`
|
|
1316
|
+
* Into: { text: 'SELECT * FROM "User" WHERE id = $1', params: [1] }
|
|
1317
|
+
*/
|
|
1318
|
+
function buildTaggedTemplateSql(params: {
|
|
1319
|
+
strings: TemplateStringsArray;
|
|
1320
|
+
values: unknown[];
|
|
1321
|
+
}): { text: string; params: unknown[] } {
|
|
1322
|
+
const { strings, values } = params;
|
|
1323
|
+
let text = strings[0]!;
|
|
1324
|
+
const sqlParams: unknown[] = [];
|
|
1325
|
+
|
|
1326
|
+
for (let i = 0; i < values.length; i++) {
|
|
1327
|
+
sqlParams.push(values[i]);
|
|
1328
|
+
text += `$${i + 1}${strings[i + 1] ?? ""}`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return { text, params: sqlParams };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Parse a flat aggregate result row into the nested Prisma-style structure.
|
|
1336
|
+
* Converts: { "_count__all": 5, "_avg__viewCount": 45.2, "authorId": 1 }
|
|
1337
|
+
* Into: { _count: 5, _avg: { viewCount: 45.2 }, authorId: 1 }
|
|
1338
|
+
*/
|
|
1339
|
+
function parseAggregateResult(params: {
|
|
1340
|
+
row: Record<string, unknown>;
|
|
1341
|
+
args: Record<string, unknown>;
|
|
1342
|
+
}): Record<string, unknown> {
|
|
1343
|
+
const { row, args } = params;
|
|
1344
|
+
const result: Record<string, unknown> = {};
|
|
1345
|
+
|
|
1346
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1347
|
+
if (key.startsWith("_") && key.includes("__")) {
|
|
1348
|
+
// Aggregate column: "_count__all", "_avg__viewCount"
|
|
1349
|
+
const [aggFn, field] = key.split("__") as [string, string];
|
|
1350
|
+
|
|
1351
|
+
if (aggFn === "_count") {
|
|
1352
|
+
// _count: true returns a number, _count: { field: true } returns an object
|
|
1353
|
+
const countArg = args._count;
|
|
1354
|
+
if (countArg === true) {
|
|
1355
|
+
result._count = Number(value ?? 0);
|
|
1356
|
+
} else {
|
|
1357
|
+
if (!result._count || typeof result._count !== "object") {
|
|
1358
|
+
result._count = {};
|
|
1359
|
+
}
|
|
1360
|
+
if (field === "all") {
|
|
1361
|
+
(result._count as Record<string, unknown>)._all = Number(value ?? 0);
|
|
1362
|
+
} else {
|
|
1363
|
+
(result._count as Record<string, unknown>)[field!] = Number(value ?? 0);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
} else {
|
|
1367
|
+
// _avg, _sum, _min, _max — always nested objects
|
|
1368
|
+
if (!result[aggFn!]) {
|
|
1369
|
+
result[aggFn!] = {};
|
|
1370
|
+
}
|
|
1371
|
+
let parsed: unknown = value;
|
|
1372
|
+
if (value !== null && value !== undefined) {
|
|
1373
|
+
const asNum = Number(value);
|
|
1374
|
+
parsed = Number.isNaN(asNum) ? value : asNum;
|
|
1375
|
+
}
|
|
1376
|
+
(result[aggFn!] as Record<string, unknown>)[field!] = parsed;
|
|
1377
|
+
}
|
|
1378
|
+
} else {
|
|
1379
|
+
// Regular field (for groupBy results)
|
|
1380
|
+
result[key] = value;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return result;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Resolve _count specification from include or select args.
|
|
1389
|
+
* Returns the list of relation names to count, or null if _count not requested.
|
|
1390
|
+
*/
|
|
1391
|
+
function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
|
|
1392
|
+
const { args } = params;
|
|
1393
|
+
const include = args.include as Record<string, unknown> | undefined;
|
|
1394
|
+
const select = args.select as Record<string, unknown> | undefined;
|
|
1395
|
+
|
|
1396
|
+
const countArg = include?._count ?? select?._count;
|
|
1397
|
+
if (!countArg) return null;
|
|
1398
|
+
|
|
1399
|
+
if (countArg === true) {
|
|
1400
|
+
// Count all list relations — will be resolved by loadRelationCounts
|
|
1401
|
+
return ["__all__"];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (typeof countArg === "object" && countArg !== null) {
|
|
1405
|
+
const countObj = countArg as Record<string, unknown>;
|
|
1406
|
+
const selectObj = countObj.select as Record<string, boolean> | undefined;
|
|
1407
|
+
if (selectObj) {
|
|
1408
|
+
return Object.entries(selectObj)
|
|
1409
|
+
.filter(([_, enabled]) => enabled)
|
|
1410
|
+
.map(([name]) => name);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Load relation counts and attach _count object to each record.
|
|
1419
|
+
* Uses COUNT subqueries grouped by parent FK.
|
|
1420
|
+
*/
|
|
1421
|
+
async function loadRelationCounts(params: {
|
|
1422
|
+
records: Record<string, unknown>[];
|
|
1423
|
+
modelMeta: ModelMeta;
|
|
1424
|
+
allModelsMeta: ModelMetaMap;
|
|
1425
|
+
countSpec: string[];
|
|
1426
|
+
executor: (params: { text: string; values: unknown[] }) => Promise<Record<string, unknown>[]>;
|
|
1427
|
+
}): Promise<void> {
|
|
1428
|
+
const { records, modelMeta, allModelsMeta, countSpec, executor } = params;
|
|
1429
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
1430
|
+
const parentPk = modelMeta.primaryKey[0];
|
|
1431
|
+
if (!parentPk) return;
|
|
1432
|
+
|
|
1433
|
+
const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
|
|
1434
|
+
if (parentIds.length === 0) return;
|
|
1435
|
+
|
|
1436
|
+
// Resolve which relations to count
|
|
1437
|
+
const listRelations = modelMeta.relationFields.filter((r) => r.isList);
|
|
1438
|
+
const relationsToCount = countSpec.includes("__all__")
|
|
1439
|
+
? listRelations
|
|
1440
|
+
: listRelations.filter((r) => countSpec.includes(r.name));
|
|
1441
|
+
|
|
1442
|
+
// Initialize _count on all records
|
|
1443
|
+
for (const record of records) {
|
|
1444
|
+
const countObj: Record<string, number> = {};
|
|
1445
|
+
for (const rel of relationsToCount) {
|
|
1446
|
+
countObj[rel.name] = 0;
|
|
1447
|
+
}
|
|
1448
|
+
record._count = countObj;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Run all relation COUNT queries in parallel — each hits a different table
|
|
1452
|
+
// so there are no data races on the parent records.
|
|
1453
|
+
await Promise.all(
|
|
1454
|
+
relationsToCount.map(async (rel) => {
|
|
1455
|
+
const relatedModelMeta = modelMap.get(rel.relatedModel);
|
|
1456
|
+
if (!relatedModelMeta) return;
|
|
1457
|
+
|
|
1458
|
+
// M:N relation: count via join table
|
|
1459
|
+
if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
|
|
1460
|
+
const joinTableName = (rel as { joinTable?: string }).joinTable!;
|
|
1461
|
+
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
1462
|
+
const parentIsA = modelMeta.name === sorted[0];
|
|
1463
|
+
const parentCol = parentIsA ? "A" : "B";
|
|
1464
|
+
|
|
1465
|
+
const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
|
|
1466
|
+
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
1467
|
+
|
|
1468
|
+
const countMap = new Map<unknown, number>();
|
|
1469
|
+
for (const row of result) {
|
|
1470
|
+
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
for (const record of records) {
|
|
1474
|
+
const pkValue = record[parentPk];
|
|
1475
|
+
const cnt = countMap.get(pkValue) ?? 0;
|
|
1476
|
+
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
1477
|
+
}
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Find the FK column on the related model (with relationName disambiguation)
|
|
1482
|
+
const reverseRel = relatedModelMeta.relationFields.find(
|
|
1483
|
+
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
|
|
1484
|
+
(!rel.relationName || r.relationName === rel.relationName)
|
|
1485
|
+
);
|
|
1486
|
+
if (!reverseRel) return;
|
|
1487
|
+
|
|
1488
|
+
const fkField = reverseRel.fields[0]!;
|
|
1489
|
+
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
1490
|
+
const fkScalar = relatedSfMap.get(fkField);
|
|
1491
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
1492
|
+
const relatedTable = `"${relatedModelMeta.dbName}"`;
|
|
1493
|
+
|
|
1494
|
+
// Build: SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
|
|
1495
|
+
const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
|
|
1496
|
+
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
1497
|
+
|
|
1498
|
+
// Map counts back to parent records
|
|
1499
|
+
const countMap = new Map<unknown, number>();
|
|
1500
|
+
for (const row of result) {
|
|
1501
|
+
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
for (const record of records) {
|
|
1505
|
+
const pkValue = record[parentPk];
|
|
1506
|
+
const cnt = countMap.get(pkValue) ?? 0;
|
|
1507
|
+
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
1508
|
+
}
|
|
1509
|
+
})
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
type NestedOp = {
|
|
1514
|
+
relationField: ModelMeta["relationFields"][number];
|
|
1515
|
+
ops: Record<string, unknown>;
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Separate scalar data from nested relation operations in update data.
|
|
1520
|
+
*/
|
|
1521
|
+
function separateNestedOps(params: {
|
|
1522
|
+
data: Record<string, unknown>;
|
|
1523
|
+
modelMeta: ModelMeta;
|
|
1524
|
+
}): { scalarData: Record<string, unknown>; nestedOps: NestedOp[] } {
|
|
1525
|
+
const { data, modelMeta } = params;
|
|
1526
|
+
const scalarData: Record<string, unknown> = {};
|
|
1527
|
+
const nestedOps: NestedOp[] = [];
|
|
1528
|
+
|
|
1529
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1530
|
+
if (value === undefined) continue;
|
|
1531
|
+
|
|
1532
|
+
const relationField = modelMeta.relationFields.find((f) => f.name === key);
|
|
1533
|
+
if (relationField && typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
1534
|
+
nestedOps.push({ relationField, ops: value as Record<string, unknown> });
|
|
1535
|
+
} else {
|
|
1536
|
+
scalarData[key] = value;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
return { scalarData, nestedOps };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Process nested relation operations for an update.
|
|
1545
|
+
* Handles: connect, disconnect, create, delete, set, connectOrCreate, update, upsert, updateMany, deleteMany
|
|
1546
|
+
*/
|
|
1547
|
+
async function processNestedUpdateOps(params: {
|
|
1548
|
+
parentRecord: Record<string, unknown>;
|
|
1549
|
+
nestedOps: NestedOp[];
|
|
1550
|
+
modelMeta: ModelMeta;
|
|
1551
|
+
allModelsMeta: ModelMetaMap;
|
|
1552
|
+
executor: (params: { text: string; values: unknown[] }) => Promise<Record<string, unknown>[]>;
|
|
1553
|
+
}): Promise<void> {
|
|
1554
|
+
const { parentRecord, nestedOps, modelMeta, allModelsMeta, executor } = params;
|
|
1555
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
1556
|
+
|
|
1557
|
+
for (const { relationField, ops } of nestedOps) {
|
|
1558
|
+
const relatedModelMeta = modelMap.get(relationField.relatedModel);
|
|
1559
|
+
if (!relatedModelMeta) continue;
|
|
1560
|
+
|
|
1561
|
+
const parentPk = modelMeta.primaryKey[0];
|
|
1562
|
+
if (!parentPk) continue;
|
|
1563
|
+
const parentId = parentRecord[parentPk];
|
|
1564
|
+
|
|
1565
|
+
// To-one relation where parent holds FK (e.g., Post.author / Post.authorId)
|
|
1566
|
+
if (!relationField.isList && relationField.isForeignKey && relationField.fields.length > 0) {
|
|
1567
|
+
const fkField = relationField.fields[0]!;
|
|
1568
|
+
const refField = relationField.references[0]!;
|
|
1569
|
+
|
|
1570
|
+
if (ops.connect) {
|
|
1571
|
+
const connectData = ops.connect as Record<string, unknown>;
|
|
1572
|
+
const fkValue = connectData[refField];
|
|
1573
|
+
if (fkValue !== undefined) {
|
|
1574
|
+
const query = buildUpdateQuery({
|
|
1575
|
+
modelMeta,
|
|
1576
|
+
allModelsMeta,
|
|
1577
|
+
where: { [parentPk]: parentId },
|
|
1578
|
+
data: { [fkField]: fkValue },
|
|
1579
|
+
});
|
|
1580
|
+
await executor(query);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
if (ops.disconnect === true) {
|
|
1585
|
+
const query = buildUpdateQuery({
|
|
1586
|
+
modelMeta,
|
|
1587
|
+
allModelsMeta,
|
|
1588
|
+
where: { [parentPk]: parentId },
|
|
1589
|
+
data: { [fkField]: null },
|
|
1590
|
+
});
|
|
1591
|
+
await executor(query);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (ops.delete === true) {
|
|
1595
|
+
// First read FK, then delete the related record, then null the FK
|
|
1596
|
+
const fkValue = parentRecord[fkField];
|
|
1597
|
+
if (fkValue != null) {
|
|
1598
|
+
// Null out FK first
|
|
1599
|
+
const nullQuery = buildUpdateQuery({
|
|
1600
|
+
modelMeta,
|
|
1601
|
+
allModelsMeta,
|
|
1602
|
+
where: { [parentPk]: parentId },
|
|
1603
|
+
data: { [fkField]: null },
|
|
1604
|
+
});
|
|
1605
|
+
await executor(nullQuery);
|
|
1606
|
+
// Then delete the related record
|
|
1607
|
+
const delQuery = buildDeleteQuery({
|
|
1608
|
+
modelMeta: relatedModelMeta,
|
|
1609
|
+
allModelsMeta,
|
|
1610
|
+
where: { [refField]: fkValue },
|
|
1611
|
+
});
|
|
1612
|
+
await executor(delQuery);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
if (ops.create) {
|
|
1617
|
+
const createData = ops.create as Record<string, unknown>;
|
|
1618
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: createData });
|
|
1619
|
+
const insertedRows = await executor(insertQuery);
|
|
1620
|
+
const inserted = insertedRows[0];
|
|
1621
|
+
if (inserted) {
|
|
1622
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1623
|
+
const query = buildUpdateQuery({
|
|
1624
|
+
modelMeta,
|
|
1625
|
+
allModelsMeta,
|
|
1626
|
+
where: { [parentPk]: parentId },
|
|
1627
|
+
data: { [fkField]: inserted[relatedPk] },
|
|
1628
|
+
});
|
|
1629
|
+
await executor(query);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// connectOrCreate: find existing or create, then set FK on parent
|
|
1634
|
+
if (ops.connectOrCreate) {
|
|
1635
|
+
const { where: corWhere, create: corCreate } = ops.connectOrCreate as { where: Record<string, unknown>; create: Record<string, unknown> };
|
|
1636
|
+
const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: corWhere, take: 1 } });
|
|
1637
|
+
const existing = await executor(findQuery);
|
|
1638
|
+
if (existing.length > 0) {
|
|
1639
|
+
const fkValue = existing[0]![refField];
|
|
1640
|
+
if (fkValue !== undefined) {
|
|
1641
|
+
const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: fkValue } });
|
|
1642
|
+
await executor(query);
|
|
1643
|
+
}
|
|
1644
|
+
} else {
|
|
1645
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: corCreate });
|
|
1646
|
+
const insertedRows = await executor(insertQuery);
|
|
1647
|
+
const inserted = insertedRows[0];
|
|
1648
|
+
if (inserted) {
|
|
1649
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1650
|
+
const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: inserted[relatedPk] } });
|
|
1651
|
+
await executor(query);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// update (nested): update the currently connected related record
|
|
1657
|
+
if (ops.update) {
|
|
1658
|
+
const updateData = ops.update as Record<string, unknown>;
|
|
1659
|
+
const fkValue = parentRecord[fkField];
|
|
1660
|
+
if (fkValue != null) {
|
|
1661
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [refField]: fkValue }, data: updateData });
|
|
1662
|
+
await executor(query);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// upsert: update related if connected, create if not
|
|
1667
|
+
if (ops.upsert) {
|
|
1668
|
+
const { create: upsCreate, update: upsUpdate } = ops.upsert as { create: Record<string, unknown>; update: Record<string, unknown> };
|
|
1669
|
+
const fkValue = parentRecord[fkField];
|
|
1670
|
+
if (fkValue != null) {
|
|
1671
|
+
// Related record exists — update it
|
|
1672
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [refField]: fkValue }, data: upsUpdate });
|
|
1673
|
+
await executor(query);
|
|
1674
|
+
} else {
|
|
1675
|
+
// No related record — create one and set FK on parent
|
|
1676
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: upsCreate });
|
|
1677
|
+
const insertedRows = await executor(insertQuery);
|
|
1678
|
+
const inserted = insertedRows[0];
|
|
1679
|
+
if (inserted) {
|
|
1680
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1681
|
+
const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: inserted[relatedPk] } });
|
|
1682
|
+
await executor(query);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// To-one relation where related holds FK (e.g., User.profile where Profile.userId → User.id)
|
|
1691
|
+
if (!relationField.isList && !relationField.isForeignKey) {
|
|
1692
|
+
const reverseRel = relatedModelMeta.relationFields.find(
|
|
1693
|
+
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
|
|
1694
|
+
);
|
|
1695
|
+
if (!reverseRel) continue;
|
|
1696
|
+
const reverseFk = reverseRel.fields[0]!;
|
|
1697
|
+
const reverseRef = reverseRel.references[0]!;
|
|
1698
|
+
const parentRefValue = parentRecord[reverseRef];
|
|
1699
|
+
|
|
1700
|
+
if (ops.connect) {
|
|
1701
|
+
const connectData = ops.connect as Record<string, unknown>;
|
|
1702
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1703
|
+
const relatedId = connectData[relatedPk];
|
|
1704
|
+
if (relatedId !== undefined) {
|
|
1705
|
+
const query = buildUpdateQuery({
|
|
1706
|
+
modelMeta: relatedModelMeta,
|
|
1707
|
+
allModelsMeta,
|
|
1708
|
+
where: { [relatedPk]: relatedId },
|
|
1709
|
+
data: { [reverseFk]: parentRefValue },
|
|
1710
|
+
});
|
|
1711
|
+
await executor(query);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (ops.disconnect === true) {
|
|
1716
|
+
const query = buildUpdateManyQuery({
|
|
1717
|
+
modelMeta: relatedModelMeta,
|
|
1718
|
+
allModelsMeta,
|
|
1719
|
+
where: { [reverseFk]: parentRefValue },
|
|
1720
|
+
data: { [reverseFk]: null },
|
|
1721
|
+
});
|
|
1722
|
+
await executor(query);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
if (ops.delete === true) {
|
|
1726
|
+
const query = buildDeleteQuery({
|
|
1727
|
+
modelMeta: relatedModelMeta,
|
|
1728
|
+
allModelsMeta,
|
|
1729
|
+
where: { [reverseFk]: parentRefValue },
|
|
1730
|
+
});
|
|
1731
|
+
await executor(query);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (ops.create) {
|
|
1735
|
+
const createData = ops.create as Record<string, unknown>;
|
|
1736
|
+
(createData as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1737
|
+
const query = buildInsertQuery({ modelMeta: relatedModelMeta, data: createData });
|
|
1738
|
+
await executor(query);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// connectOrCreate: find existing or create, then set FK on related record
|
|
1742
|
+
if (ops.connectOrCreate) {
|
|
1743
|
+
const { where: corWhere, create: corCreate } = ops.connectOrCreate as { where: Record<string, unknown>; create: Record<string, unknown> };
|
|
1744
|
+
const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: corWhere, take: 1 } });
|
|
1745
|
+
const existing = await executor(findQuery);
|
|
1746
|
+
if (existing.length > 0) {
|
|
1747
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1748
|
+
const relatedId = existing[0]![relatedPk];
|
|
1749
|
+
if (relatedId !== undefined) {
|
|
1750
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [relatedPk]: relatedId }, data: { [reverseFk]: parentRefValue } });
|
|
1751
|
+
await executor(query);
|
|
1752
|
+
}
|
|
1753
|
+
} else {
|
|
1754
|
+
(corCreate as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1755
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: corCreate });
|
|
1756
|
+
await executor(insertQuery);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// update (nested): update the related record that points to this parent
|
|
1761
|
+
if (ops.update) {
|
|
1762
|
+
const updateData = ops.update as Record<string, unknown>;
|
|
1763
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [reverseFk]: parentRefValue }, data: updateData });
|
|
1764
|
+
await executor(query);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// upsert: update related if exists, create if not
|
|
1768
|
+
if (ops.upsert) {
|
|
1769
|
+
const { create: upsCreate, update: upsUpdate } = ops.upsert as { create: Record<string, unknown>; update: Record<string, unknown> };
|
|
1770
|
+
// Check if related record exists
|
|
1771
|
+
const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: { [reverseFk]: parentRefValue }, take: 1 } });
|
|
1772
|
+
const existing = await executor(findQuery);
|
|
1773
|
+
if (existing.length > 0) {
|
|
1774
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [reverseFk]: parentRefValue }, data: upsUpdate });
|
|
1775
|
+
await executor(query);
|
|
1776
|
+
} else {
|
|
1777
|
+
(upsCreate as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1778
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: upsCreate });
|
|
1779
|
+
await executor(insertQuery);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// To-many relation (e.g., User.posts)
|
|
1787
|
+
if (relationField.isList) {
|
|
1788
|
+
const reverseRel = relatedModelMeta.relationFields.find(
|
|
1789
|
+
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
|
|
1790
|
+
);
|
|
1791
|
+
if (!reverseRel) continue;
|
|
1792
|
+
const reverseFk = reverseRel.fields[0]!;
|
|
1793
|
+
const reverseRef = reverseRel.references[0]!;
|
|
1794
|
+
const parentRefValue = parentRecord[reverseRef];
|
|
1795
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
1796
|
+
|
|
1797
|
+
// set: replace all — disconnect all, then connect the specified ones
|
|
1798
|
+
if (ops.set) {
|
|
1799
|
+
const setItems = ops.set as Record<string, unknown>[];
|
|
1800
|
+
// Disconnect all existing
|
|
1801
|
+
const disconnectAllQuery = buildUpdateManyQuery({
|
|
1802
|
+
modelMeta: relatedModelMeta,
|
|
1803
|
+
allModelsMeta,
|
|
1804
|
+
where: { [reverseFk]: parentRefValue },
|
|
1805
|
+
data: { [reverseFk]: null },
|
|
1806
|
+
});
|
|
1807
|
+
await executor(disconnectAllQuery);
|
|
1808
|
+
// Batch connect: single UPDATE ... SET fk = $1 WHERE pk = ANY($2)
|
|
1809
|
+
const setIds = setItems.map((item) => item[relatedPk]).filter((id) => id !== undefined);
|
|
1810
|
+
if (setIds.length > 0) {
|
|
1811
|
+
const query = buildUpdateManyQuery({
|
|
1812
|
+
modelMeta: relatedModelMeta,
|
|
1813
|
+
allModelsMeta,
|
|
1814
|
+
where: { [relatedPk]: { in: setIds } },
|
|
1815
|
+
data: { [reverseFk]: parentRefValue },
|
|
1816
|
+
});
|
|
1817
|
+
await executor(query);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Batch connect: single UPDATE ... SET fk = $1 WHERE pk = ANY($2)
|
|
1822
|
+
if (ops.connect) {
|
|
1823
|
+
const connectItems = Array.isArray(ops.connect) ? ops.connect : [ops.connect];
|
|
1824
|
+
const ids = (connectItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
|
|
1825
|
+
if (ids.length > 0) {
|
|
1826
|
+
const query = buildUpdateManyQuery({
|
|
1827
|
+
modelMeta: relatedModelMeta,
|
|
1828
|
+
allModelsMeta,
|
|
1829
|
+
where: { [relatedPk]: { in: ids } },
|
|
1830
|
+
data: { [reverseFk]: parentRefValue },
|
|
1831
|
+
});
|
|
1832
|
+
await executor(query);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// Batch disconnect: single UPDATE ... SET fk = NULL WHERE pk = ANY($1)
|
|
1837
|
+
if (ops.disconnect) {
|
|
1838
|
+
const disconnectItems = Array.isArray(ops.disconnect) ? ops.disconnect : [ops.disconnect];
|
|
1839
|
+
const ids = (disconnectItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
|
|
1840
|
+
if (ids.length > 0) {
|
|
1841
|
+
const query = buildUpdateManyQuery({
|
|
1842
|
+
modelMeta: relatedModelMeta,
|
|
1843
|
+
allModelsMeta,
|
|
1844
|
+
where: { [relatedPk]: { in: ids } },
|
|
1845
|
+
data: { [reverseFk]: null },
|
|
1846
|
+
});
|
|
1847
|
+
await executor(query);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Batch delete: single DELETE ... WHERE pk = ANY($1)
|
|
1852
|
+
if (ops.delete) {
|
|
1853
|
+
const deleteItems = Array.isArray(ops.delete) ? ops.delete : [ops.delete];
|
|
1854
|
+
const ids = (deleteItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
|
|
1855
|
+
if (ids.length > 0) {
|
|
1856
|
+
const query = buildDeleteQuery({
|
|
1857
|
+
modelMeta: relatedModelMeta,
|
|
1858
|
+
allModelsMeta,
|
|
1859
|
+
where: { [relatedPk]: { in: ids } },
|
|
1860
|
+
});
|
|
1861
|
+
await executor(query);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Batch create: single multi-row INSERT
|
|
1866
|
+
if (ops.create) {
|
|
1867
|
+
const createItems = Array.isArray(ops.create) ? ops.create : [ops.create];
|
|
1868
|
+
const dataArray = (createItems as Record<string, unknown>[]).map((createData) => {
|
|
1869
|
+
(createData as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1870
|
+
return createData;
|
|
1871
|
+
});
|
|
1872
|
+
if (dataArray.length === 1) {
|
|
1873
|
+
const query = buildInsertQuery({ modelMeta: relatedModelMeta, data: dataArray[0]! });
|
|
1874
|
+
await executor(query);
|
|
1875
|
+
} else if (dataArray.length > 1) {
|
|
1876
|
+
const query = buildInsertManyQuery({ modelMeta: relatedModelMeta, data: dataArray, returning: false });
|
|
1877
|
+
await executor(query);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// connectOrCreate: find existing or create, then set FK on related record
|
|
1882
|
+
if (ops.connectOrCreate) {
|
|
1883
|
+
const items = Array.isArray(ops.connectOrCreate) ? ops.connectOrCreate : [ops.connectOrCreate];
|
|
1884
|
+
for (const item of items as { where: Record<string, unknown>; create: Record<string, unknown> }[]) {
|
|
1885
|
+
const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: item.where, take: 1 } });
|
|
1886
|
+
const existing = await executor(findQuery);
|
|
1887
|
+
if (existing.length > 0) {
|
|
1888
|
+
const relatedId = existing[0]![relatedPk];
|
|
1889
|
+
if (relatedId !== undefined) {
|
|
1890
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [relatedPk]: relatedId }, data: { [reverseFk]: parentRefValue } });
|
|
1891
|
+
await executor(query);
|
|
1892
|
+
}
|
|
1893
|
+
} else {
|
|
1894
|
+
(item.create as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1895
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: item.create });
|
|
1896
|
+
await executor(insertQuery);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// update (nested): update related records by where + data
|
|
1902
|
+
if (ops.update) {
|
|
1903
|
+
const items = Array.isArray(ops.update) ? ops.update : [ops.update];
|
|
1904
|
+
for (const item of items as { where: Record<string, unknown>; data: Record<string, unknown> }[]) {
|
|
1905
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.data });
|
|
1906
|
+
await executor(query);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// upsert: upsert related records by where + create/update
|
|
1911
|
+
if (ops.upsert) {
|
|
1912
|
+
const items = Array.isArray(ops.upsert) ? ops.upsert : [ops.upsert];
|
|
1913
|
+
for (const item of items as { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> }[]) {
|
|
1914
|
+
const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: { ...item.where, [reverseFk]: parentRefValue }, take: 1 } });
|
|
1915
|
+
const existing = await executor(findQuery);
|
|
1916
|
+
if (existing.length > 0) {
|
|
1917
|
+
const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.update });
|
|
1918
|
+
await executor(query);
|
|
1919
|
+
} else {
|
|
1920
|
+
(item.create as Record<string, unknown>)[reverseFk] = parentRefValue;
|
|
1921
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: item.create });
|
|
1922
|
+
await executor(insertQuery);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// updateMany: update multiple related records by filter
|
|
1928
|
+
if (ops.updateMany) {
|
|
1929
|
+
const items = Array.isArray(ops.updateMany) ? ops.updateMany : [ops.updateMany];
|
|
1930
|
+
for (const item of items as { where: Record<string, unknown>; data: Record<string, unknown> }[]) {
|
|
1931
|
+
const query = buildUpdateManyQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.data });
|
|
1932
|
+
await executor(query);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// deleteMany: delete multiple related records by filter
|
|
1937
|
+
if (ops.deleteMany) {
|
|
1938
|
+
const items = Array.isArray(ops.deleteMany) ? ops.deleteMany : [ops.deleteMany];
|
|
1939
|
+
for (const item of items as Record<string, unknown>[]) {
|
|
1940
|
+
const query = buildDeleteQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item, [reverseFk]: parentRefValue } });
|
|
1941
|
+
await executor(query);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
type DeferredCreate = {
|
|
1949
|
+
relatedModelMeta: ModelMeta;
|
|
1950
|
+
fkField: string;
|
|
1951
|
+
parentRefField: string;
|
|
1952
|
+
createData: unknown;
|
|
1953
|
+
};
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Process nested create/connect operations in the data object.
|
|
1957
|
+
* Extracts relation fields and handles them separately.
|
|
1958
|
+
*
|
|
1959
|
+
* Returns:
|
|
1960
|
+
* - processedData: scalar data ready for INSERT (with FK values resolved from connect/create)
|
|
1961
|
+
* - deferredCreates: nested creates where the related model holds the FK (must run after parent INSERT)
|
|
1962
|
+
*/
|
|
1963
|
+
async function processNestedCreates(params: {
|
|
1964
|
+
data: Record<string, unknown>;
|
|
1965
|
+
modelMeta: ModelMeta;
|
|
1966
|
+
allModelsMeta: ModelMetaMap;
|
|
1967
|
+
executor: (params: {
|
|
1968
|
+
text: string;
|
|
1969
|
+
values: unknown[];
|
|
1970
|
+
}) => Promise<Record<string, unknown>[]>;
|
|
1971
|
+
}): Promise<{ processedData: Record<string, unknown>; deferredCreates: DeferredCreate[] }> {
|
|
1972
|
+
const { data, modelMeta, allModelsMeta, executor } = params;
|
|
1973
|
+
const processedData: Record<string, unknown> = {};
|
|
1974
|
+
const deferredCreates: DeferredCreate[] = [];
|
|
1975
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
1976
|
+
|
|
1977
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1978
|
+
if (value === undefined) continue;
|
|
1979
|
+
|
|
1980
|
+
// Check if this is a relation field
|
|
1981
|
+
const relationField = modelMeta.relationFields.find(
|
|
1982
|
+
(f) => f.name === key
|
|
1983
|
+
);
|
|
1984
|
+
|
|
1985
|
+
if (!relationField) {
|
|
1986
|
+
// Scalar field — keep as is
|
|
1987
|
+
processedData[key] = value;
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// Handle nested relation operations
|
|
1992
|
+
if (typeof value === "object" && value !== null) {
|
|
1993
|
+
const ops = value as Record<string, unknown>;
|
|
1994
|
+
|
|
1995
|
+
// Case 1: Parent holds the FK (e.g., Post.author where Post has authorId)
|
|
1996
|
+
if (relationField.isForeignKey && relationField.fields.length > 0) {
|
|
1997
|
+
const fkField = relationField.fields[0]!;
|
|
1998
|
+
const refField = relationField.references[0]!;
|
|
1999
|
+
|
|
2000
|
+
if (ops.connect) {
|
|
2001
|
+
// Connect: set the FK value from the connected record's unique fields
|
|
2002
|
+
const connectData = ops.connect as Record<string, unknown>;
|
|
2003
|
+
if (connectData[refField] !== undefined) {
|
|
2004
|
+
processedData[fkField] = connectData[refField];
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
if (ops.create) {
|
|
2009
|
+
// Create: insert the related record first, then use its PK as FK value
|
|
2010
|
+
const relatedMeta = modelMap.get(relationField.relatedModel);
|
|
2011
|
+
if (relatedMeta) {
|
|
2012
|
+
const createData = ops.create as Record<string, unknown>;
|
|
2013
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedMeta, data: createData });
|
|
2014
|
+
const insertedRows = await executor(insertQuery);
|
|
2015
|
+
const inserted = insertedRows[0];
|
|
2016
|
+
if (inserted) {
|
|
2017
|
+
processedData[fkField] = inserted[refField];
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
// Case 2: Related model holds the FK (e.g., User.posts where Post has userId)
|
|
2023
|
+
else if (!relationField.isForeignKey) {
|
|
2024
|
+
const relatedMeta = modelMap.get(relationField.relatedModel);
|
|
2025
|
+
if (!relatedMeta) continue;
|
|
2026
|
+
|
|
2027
|
+
const reverseRel = relatedMeta.relationFields.find(
|
|
2028
|
+
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
|
|
2029
|
+
);
|
|
2030
|
+
if (!reverseRel) continue;
|
|
2031
|
+
|
|
2032
|
+
const reverseFk = reverseRel.fields[0]!;
|
|
2033
|
+
const reverseRef = reverseRel.references[0]!;
|
|
2034
|
+
|
|
2035
|
+
if (ops.connect) {
|
|
2036
|
+
// For connect on the non-FK side, we need deferred processing
|
|
2037
|
+
// (need parent PK first to set on the related record)
|
|
2038
|
+
// This is uncommon for CREATE — usually you'd connect from the FK side
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (ops.create) {
|
|
2042
|
+
// Defer: create after parent insert so we have parent's PK
|
|
2043
|
+
deferredCreates.push({
|
|
2044
|
+
relatedModelMeta: relatedMeta,
|
|
2045
|
+
fkField: reverseFk,
|
|
2046
|
+
parentRefField: reverseRef,
|
|
2047
|
+
createData: ops.create,
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
return { processedData, deferredCreates };
|
|
2055
|
+
}
|