@vibeorm/runtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1417 @@
1
+ /**
2
+ * SQL Query Builder
3
+ *
4
+ * Takes an operation descriptor (model, operation, args) and produces
5
+ * parameterized SQL queries for PostgreSQL.
6
+ */
7
+
8
+ import type {
9
+ ModelMeta,
10
+ ModelMetaMap,
11
+ SqlQuery,
12
+ Operation,
13
+ ScalarFieldMeta,
14
+ } from "./types.ts";
15
+ import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
16
+ import { buildWhereClause } from "./where-builder.ts";
17
+
18
+ // ─── SELECT Builder ───────────────────────────────────────────────
19
+
20
+ export function buildSelectQuery(params: {
21
+ modelMeta: ModelMeta;
22
+ allModelsMeta: ModelMetaMap;
23
+ args: Record<string, unknown>;
24
+ }): SqlQuery {
25
+ const { modelMeta, allModelsMeta, args } = params;
26
+ const table = `"${modelMeta.dbName}"`;
27
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
28
+
29
+ // Determine which columns to select
30
+ const columns = resolveSelectColumns({ modelMeta, args });
31
+ const columnsSql = columns
32
+ .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
33
+ .join(", ");
34
+
35
+ // DISTINCT ON support (PostgreSQL)
36
+ let distinctSql = "";
37
+ if (args.distinct) {
38
+ const distinctFields = args.distinct as string[];
39
+ if (distinctFields.length > 0) {
40
+ const distinctCols = distinctFields.map((field) => {
41
+ const sf = sfMap.get(field);
42
+ return `${table}."${sf ? sf.dbName : field}"`;
43
+ });
44
+ distinctSql = `DISTINCT ON (${distinctCols.join(", ")}) `;
45
+ }
46
+ }
47
+
48
+ // Build WHERE — start with explicit where, then add cursor condition
49
+ let where = args.where as Record<string, unknown> | undefined;
50
+
51
+ // Cursor-based pagination: inject cursor condition into WHERE
52
+ // For composite cursors we need lexicographic ordering:
53
+ // (a > $1) OR (a = $1 AND b > $2) OR (a = $1 AND b = $2 AND c > $3) ...
54
+ if (args.cursor) {
55
+ const cursorInput = args.cursor as Record<string, unknown>;
56
+ const cursorEntries = Object.entries(cursorInput).filter(
57
+ ([, v]) => v !== undefined
58
+ );
59
+
60
+ // Determine direction: positive take = forward (>), negative take = backward (<)
61
+ const take = args.take as number | undefined;
62
+ const isBackward = take !== undefined && take < 0;
63
+ const cmpOp = isBackward ? "lt" : "gt";
64
+
65
+ let cursorWhere: Record<string, unknown> | undefined;
66
+
67
+ if (cursorEntries.length === 1) {
68
+ // Single-field cursor: simple gt/lt (most common case)
69
+ const [field, value] = cursorEntries[0]!;
70
+ cursorWhere = { [field]: { [cmpOp]: value } };
71
+ } else if (cursorEntries.length > 1) {
72
+ // Composite cursor: build lexicographic OR conditions
73
+ // For fields [a, b, c] with forward:
74
+ // (a > v1) OR (a = v1 AND b > v2) OR (a = v1 AND b = v2 AND c > v3)
75
+ const orBranches: Record<string, unknown>[] = [];
76
+ for (let i = 0; i < cursorEntries.length; i++) {
77
+ const branch: Record<string, unknown> = {};
78
+ // All preceding fields must equal their cursor values
79
+ for (let j = 0; j < i; j++) {
80
+ const [eqField, eqValue] = cursorEntries[j]!;
81
+ branch[eqField] = { equals: eqValue };
82
+ }
83
+ // The i-th field uses the comparison operator
84
+ const [cmpField, cmpValue] = cursorEntries[i]!;
85
+ branch[cmpField] = { [cmpOp]: cmpValue };
86
+ orBranches.push(branch);
87
+ }
88
+ cursorWhere = { OR: orBranches };
89
+ }
90
+
91
+ // Merge cursor conditions with existing where via AND
92
+ if (cursorWhere) {
93
+ where = where
94
+ ? { AND: [where, cursorWhere] }
95
+ : cursorWhere;
96
+ }
97
+ }
98
+
99
+ const whereResult = buildWhereClause({
100
+ where,
101
+ modelMeta,
102
+ allModelsMeta,
103
+ paramOffset: 0,
104
+ });
105
+
106
+ let paramIdx = whereResult.values.length;
107
+ const allValues = [...whereResult.values];
108
+
109
+ // Build ORDER BY
110
+ let orderBySql = "";
111
+ const orderByJoins: string[] = [];
112
+ if (args.orderBy) {
113
+ const orderByItems = Array.isArray(args.orderBy)
114
+ ? (args.orderBy as Record<string, unknown>[])
115
+ : [args.orderBy as Record<string, unknown>];
116
+
117
+ const orderClauses = orderByItems.flatMap((item) =>
118
+ Object.entries(item).map(([field, direction]) => {
119
+ // Null ordering: { sort: "desc", nulls: "last" }
120
+ if (typeof direction === "object" && direction !== null) {
121
+ const dirObj = direction as Record<string, unknown>;
122
+
123
+ // SortOrderWithNulls: { sort: "asc", nulls: "last" }
124
+ if (dirObj.sort && typeof dirObj.sort === "string") {
125
+ const scalarField = sfMap.get(field);
126
+ const col = scalarField ? scalarField.dbName : field;
127
+ let clause = `${table}."${col}" ${(dirObj.sort as string).toUpperCase()}`;
128
+ if (dirObj.nulls === "first") clause += " NULLS FIRST";
129
+ else if (dirObj.nulls === "last") clause += " NULLS LAST";
130
+ return clause;
131
+ }
132
+
133
+ // Relation count ordering: { posts: { _count: "desc" } }
134
+ if (dirObj._count && typeof dirObj._count === "string") {
135
+ const relField = modelMeta.relationFields.find((r) => r.name === field);
136
+ if (relField) {
137
+ const modelMap = getModelByNameMap({ allModelsMeta });
138
+ const relatedMeta = modelMap.get(relField.relatedModel);
139
+ if (relatedMeta) {
140
+ const reverseRel = relatedMeta.relationFields.find(
141
+ (r: { relatedModel: string; isForeignKey: boolean; fields: readonly string[] }) =>
142
+ r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
143
+ );
144
+ if (reverseRel) {
145
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
146
+ const fkScalar = relatedSfMap.get(reverseRel.fields[0]!);
147
+ const fkDbName = fkScalar?.dbName ?? reverseRel.fields[0]!;
148
+ const parentPk = modelMeta.primaryKey[0]!;
149
+ const parentSf = sfMap.get(parentPk);
150
+ const parentPkDb = parentSf?.dbName ?? parentPk;
151
+ return `(SELECT COUNT(*) FROM "${relatedMeta.dbName}" WHERE "${relatedMeta.dbName}"."${fkDbName}" = ${table}."${parentPkDb}") ${(dirObj._count as string).toUpperCase()}`;
152
+ }
153
+ }
154
+ }
155
+ return `1 ${(dirObj._count as string).toUpperCase()}`;
156
+ }
157
+
158
+ // Relation field ordering: { author: { name: "asc" } }
159
+ const relField = modelMeta.relationFields.find((r) => r.name === field);
160
+ if (relField && !relField.isList) {
161
+ const modelMap = getModelByNameMap({ allModelsMeta });
162
+ const relatedMeta = modelMap.get(relField.relatedModel);
163
+ if (relatedMeta) {
164
+ const relatedAlias = `"__orderby_${field}"`;
165
+ // Determine join condition
166
+ let joinCond: string;
167
+ if (relField.isForeignKey && relField.fields.length > 0) {
168
+ const fkScalar = sfMap.get(relField.fields[0]!);
169
+ const fkDbName = fkScalar?.dbName ?? relField.fields[0]!;
170
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
171
+ const refScalar = relatedSfMap.get(relField.references[0]!);
172
+ const refDbName = refScalar?.dbName ?? relField.references[0]!;
173
+ joinCond = `${relatedAlias}."${refDbName}" = ${table}."${fkDbName}"`;
174
+ } else {
175
+ const reverseRel = relatedMeta.relationFields.find(
176
+ (r: { relatedModel: string; isForeignKey: boolean; fields: readonly string[] }) =>
177
+ r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
178
+ );
179
+ if (reverseRel) {
180
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
181
+ const fkScalar = relatedSfMap.get(reverseRel.fields[0]!);
182
+ const fkDbName = fkScalar?.dbName ?? reverseRel.fields[0]!;
183
+ const refScalar = sfMap.get(reverseRel.references[0]!);
184
+ const refDbName = refScalar?.dbName ?? reverseRel.references[0]!;
185
+ joinCond = `${relatedAlias}."${fkDbName}" = ${table}."${refDbName}"`;
186
+ } else {
187
+ joinCond = "TRUE";
188
+ }
189
+ }
190
+ orderByJoins.push(`LEFT JOIN "${relatedMeta.dbName}" ${relatedAlias} ON ${joinCond}`);
191
+
192
+ // Get the ordering field from the relation object
193
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
194
+ return Object.entries(dirObj).map(([relField2, relDir]) => {
195
+ const relSf = relatedSfMap.get(relField2);
196
+ const relCol = relSf?.dbName ?? relField2;
197
+ return `${relatedAlias}."${relCol}" ${(relDir as string).toUpperCase()}`;
198
+ }).join(", ");
199
+ }
200
+ }
201
+ }
202
+
203
+ // Simple scalar ordering
204
+ const scalarField = sfMap.get(field);
205
+ const col = scalarField ? scalarField.dbName : field;
206
+ return `${table}."${col}" ${(direction as string).toUpperCase()}`;
207
+ })
208
+ );
209
+
210
+ if (orderClauses.length > 0) {
211
+ orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
212
+ }
213
+ } else if (args.distinct) {
214
+ // DISTINCT ON requires ORDER BY on the same columns first
215
+ const distinctFields = args.distinct as string[];
216
+ if (distinctFields.length > 0) {
217
+ const distinctOrderClauses = distinctFields.map((field) => {
218
+ const sf = sfMap.get(field);
219
+ return `${table}."${sf ? sf.dbName : field}" ASC`;
220
+ });
221
+ orderBySql = ` ORDER BY ${distinctOrderClauses.join(", ")}`;
222
+ }
223
+ } else if (args.cursor) {
224
+ // Cursor pagination without explicit orderBy: default to ordering by cursor
225
+ // fields to ensure deterministic results and proper index utilization.
226
+ const cursorInput = args.cursor as Record<string, unknown>;
227
+ const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
228
+ const take = args.take as number | undefined;
229
+ const isBackward = take !== undefined && take < 0;
230
+ const defaultDir = isBackward ? "DESC" : "ASC";
231
+ const cursorOrderClauses = cursorEntries.map(([field]) => {
232
+ const sf = sfMap.get(field);
233
+ return `${table}."${sf ? sf.dbName : field}" ${defaultDir}`;
234
+ });
235
+ if (cursorOrderClauses.length > 0) {
236
+ orderBySql = ` ORDER BY ${cursorOrderClauses.join(", ")}`;
237
+ }
238
+ }
239
+
240
+ // Build LIMIT / OFFSET
241
+ let limitSql = "";
242
+ if (args.take !== undefined) {
243
+ paramIdx++;
244
+ // take can be negative for backward cursor pagination; use absolute value
245
+ const takeValue = Math.abs(args.take as number);
246
+ limitSql = ` LIMIT $${paramIdx}`;
247
+ allValues.push(takeValue);
248
+ }
249
+
250
+ let offsetSql = "";
251
+ if (args.skip !== undefined) {
252
+ paramIdx++;
253
+ offsetSql = ` OFFSET $${paramIdx}`;
254
+ allValues.push(args.skip);
255
+ }
256
+
257
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
258
+ const joinsPart = orderByJoins.length > 0 ? ` ${orderByJoins.join(" ")}` : "";
259
+ const text = `SELECT ${distinctSql}${columnsSql} FROM ${table}${joinsPart}${wherePart}${orderBySql}${limitSql}${offsetSql}`;
260
+
261
+ return { text, values: allValues };
262
+ }
263
+
264
+ // ─── INSERT Builder ───────────────────────────────────────────────
265
+
266
+ export function buildInsertQuery(params: {
267
+ modelMeta: ModelMeta;
268
+ data: Record<string, unknown>;
269
+ }): SqlQuery {
270
+ const { modelMeta, data } = params;
271
+ const table = `"${modelMeta.dbName}"`;
272
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
273
+
274
+ // Filter to only scalar fields that are present in data
275
+ const entries: Array<{ dbName: string; value: unknown }> = [];
276
+
277
+ for (const [key, value] of Object.entries(data)) {
278
+ if (value === undefined) continue;
279
+ const scalarField = sfMap.get(key);
280
+ if (scalarField) {
281
+ entries.push({ dbName: scalarField.dbName, value });
282
+ }
283
+ }
284
+
285
+ if (entries.length === 0) {
286
+ // Insert with default values only
287
+ const returningCols = modelMeta.scalarFields
288
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
289
+ .join(", ");
290
+ return {
291
+ text: `INSERT INTO ${table} DEFAULT VALUES RETURNING ${returningCols}`,
292
+ values: [],
293
+ };
294
+ }
295
+
296
+ const columns = entries.map((e) => `"${e.dbName}"`).join(", ");
297
+ const placeholders = entries.map((_, i) => `$${i + 1}`).join(", ");
298
+ const values = entries.map((e) => e.value);
299
+
300
+ const returningCols = modelMeta.scalarFields
301
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
302
+ .join(", ");
303
+
304
+ const text = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING ${returningCols}`;
305
+
306
+ return { text, values };
307
+ }
308
+
309
+ // ─── UPDATE Builder ───────────────────────────────────────────────
310
+
311
+ export function buildUpdateQuery(params: {
312
+ modelMeta: ModelMeta;
313
+ allModelsMeta: ModelMetaMap;
314
+ where: Record<string, unknown>;
315
+ data: Record<string, unknown>;
316
+ }): SqlQuery {
317
+ const { modelMeta, allModelsMeta, where, data } = params;
318
+ const table = `"${modelMeta.dbName}"`;
319
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
320
+
321
+ let paramIdx = 0;
322
+ const allValues: unknown[] = [];
323
+ const setClauses: string[] = [];
324
+
325
+ for (const [key, value] of Object.entries(data)) {
326
+ if (value === undefined) continue;
327
+ const scalarField = sfMap.get(key);
328
+ if (!scalarField) continue;
329
+
330
+ const setResult = buildSetClause({
331
+ dbName: scalarField.dbName,
332
+ value,
333
+ paramIdx,
334
+ });
335
+ setClauses.push(setResult.sql);
336
+ allValues.push(...setResult.values);
337
+ paramIdx += setResult.values.length;
338
+ }
339
+
340
+ // Build WHERE
341
+ const whereResult = buildWhereClause({
342
+ where,
343
+ modelMeta,
344
+ allModelsMeta,
345
+ paramOffset: paramIdx,
346
+ });
347
+ allValues.push(...whereResult.values);
348
+
349
+ const returningCols = modelMeta.scalarFields
350
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
351
+ .join(", ");
352
+
353
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
354
+ const text = `UPDATE ${table} SET ${setClauses.join(", ")}${wherePart} RETURNING ${returningCols}`;
355
+
356
+ return { text, values: allValues };
357
+ }
358
+
359
+ // ─── DELETE Builder ───────────────────────────────────────────────
360
+
361
+ export function buildDeleteQuery(params: {
362
+ modelMeta: ModelMeta;
363
+ allModelsMeta: ModelMetaMap;
364
+ where: Record<string, unknown>;
365
+ }): SqlQuery {
366
+ const { modelMeta, allModelsMeta, where } = params;
367
+ const table = `"${modelMeta.dbName}"`;
368
+
369
+ const whereResult = buildWhereClause({
370
+ where,
371
+ modelMeta,
372
+ allModelsMeta,
373
+ paramOffset: 0,
374
+ });
375
+
376
+ const returningCols = modelMeta.scalarFields
377
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
378
+ .join(", ");
379
+
380
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
381
+ const text = `DELETE FROM ${table}${wherePart} RETURNING ${returningCols}`;
382
+
383
+ return { text, values: whereResult.values };
384
+ }
385
+
386
+ // ─── COUNT Builder ────────────────────────────────────────────────
387
+
388
+ export function buildCountQuery(params: {
389
+ modelMeta: ModelMeta;
390
+ allModelsMeta: ModelMetaMap;
391
+ args: Record<string, unknown>;
392
+ countStrategy?: "direct" | "subquery";
393
+ }): SqlQuery {
394
+ const { modelMeta, allModelsMeta, args, countStrategy = "direct" } = params;
395
+ const table = `"${modelMeta.dbName}"`;
396
+
397
+ const whereResult = buildWhereClause({
398
+ where: args.where as Record<string, unknown> | undefined,
399
+ modelMeta,
400
+ allModelsMeta,
401
+ paramOffset: 0,
402
+ });
403
+
404
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
405
+
406
+ if (countStrategy === "subquery") {
407
+ // Wrap in a subquery with OFFSET 0. This can produce better query plans
408
+ // on serverless/proxy PostgreSQL providers where the standard COUNT(*)
409
+ // path chooses a suboptimal plan. The OFFSET 0 is a no-op but prevents
410
+ // PostgreSQL from flattening the subquery, which can change plan choice.
411
+ const pkCol = `${table}."${modelMeta.primaryKey[0]}"`;
412
+ const offsetIdx = whereResult.values.length + 1;
413
+ const text = `SELECT COUNT(*) AS "count" FROM (SELECT ${pkCol} FROM ${table}${wherePart} OFFSET $${offsetIdx}) AS "sub"`;
414
+ return { text, values: [...whereResult.values, 0] };
415
+ }
416
+
417
+ const text = `SELECT COUNT(*) AS "count" FROM ${table}${wherePart}`;
418
+ return { text, values: whereResult.values };
419
+ }
420
+
421
+ // ─── Relation Query Builder ───────────────────────────────────────
422
+
423
+ /**
424
+ * Build a query to load related records for a batch of parent IDs.
425
+ * Used for to-many relations (hybrid strategy: separate batched query).
426
+ */
427
+ export type RelationSqlQuery = SqlQuery & {
428
+ /** When the FK column is already in the selected columns, this holds
429
+ * its application-level field name so callers can use it for stitching
430
+ * instead of the __vibeorm_fk alias. undefined means __vibeorm_fk is present. */
431
+ fkFieldName?: string;
432
+ };
433
+
434
+ export function buildRelationQuery(params: {
435
+ parentModelMeta: ModelMeta;
436
+ relationMeta: {
437
+ readonly relatedModel: string;
438
+ readonly type: string;
439
+ readonly fields: readonly string[];
440
+ readonly references: readonly string[];
441
+ readonly isList: boolean;
442
+ };
443
+ relatedModelMeta: ModelMeta;
444
+ parentIds: unknown[];
445
+ args?: Record<string, unknown>;
446
+ allModelsMeta: ModelMetaMap;
447
+ }): RelationSqlQuery {
448
+ const {
449
+ parentModelMeta,
450
+ relationMeta,
451
+ relatedModelMeta,
452
+ parentIds,
453
+ args = {},
454
+ allModelsMeta,
455
+ } = params;
456
+
457
+ const table = `"${relatedModelMeta.dbName}"`;
458
+ const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
459
+
460
+ // Determine which columns to select
461
+ const columns = resolveSelectColumns({
462
+ modelMeta: relatedModelMeta,
463
+ args,
464
+ });
465
+ const columnsSql = columns
466
+ .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
467
+ .join(", ");
468
+
469
+ const allValues: unknown[] = [];
470
+ let paramIdx = 0;
471
+
472
+ // The FK column on the related model that references the parent
473
+ let fkDbName: string;
474
+ let fkFieldName: string | undefined;
475
+
476
+ // Access relationName for disambiguation (multi-FK scenarios like Follow)
477
+ const parentRelationName = (relationMeta as { relationName?: string }).relationName;
478
+
479
+ if (relationMeta.fields.length > 0) {
480
+ const reverseRelation = relatedModelMeta.relationFields.find(
481
+ (r) =>
482
+ r.relatedModel === parentModelMeta.name &&
483
+ r.isForeignKey &&
484
+ r.references.length > 0 &&
485
+ (!parentRelationName || r.relationName === parentRelationName)
486
+ );
487
+ if (reverseRelation) {
488
+ fkFieldName = reverseRelation.fields[0]!;
489
+ const fkField = sfMap.get(fkFieldName);
490
+ fkDbName = fkField?.dbName ?? fkFieldName;
491
+ } else {
492
+ fkDbName = relationMeta.fields[0]!;
493
+ }
494
+ } else {
495
+ const reverseRelation = relatedModelMeta.relationFields.find(
496
+ (r) =>
497
+ r.relatedModel === parentModelMeta.name &&
498
+ r.isForeignKey &&
499
+ (!parentRelationName || r.relationName === parentRelationName)
500
+ );
501
+ if (reverseRelation) {
502
+ fkFieldName = reverseRelation.fields[0]!;
503
+ const fkField = sfMap.get(fkFieldName);
504
+ fkDbName = fkField?.dbName ?? fkFieldName;
505
+ } else {
506
+ fkDbName = `${parentModelMeta.name.toLowerCase()}Id`;
507
+ }
508
+ }
509
+
510
+ // Only add __vibeorm_fk alias if the FK column isn't already in the selected columns.
511
+ // This avoids transferring the same column value twice per row.
512
+ const fkAlreadySelected = columns.some((c) => c.dbName === fkDbName);
513
+ const fkSelectSql = fkAlreadySelected
514
+ ? ""
515
+ : `, ${table}."${fkDbName}" AS "__vibeorm_fk"`;
516
+
517
+ paramIdx++;
518
+ allValues.push(new PgArray(parentIds));
519
+
520
+ let whereSql = `${table}."${fkDbName}" = ANY($${paramIdx})`;
521
+
522
+ // Add any additional where from nested args
523
+ if (args.where) {
524
+ const subWhere = buildWhereClause({
525
+ where: args.where as Record<string, unknown>,
526
+ modelMeta: relatedModelMeta,
527
+ allModelsMeta,
528
+ paramOffset: paramIdx,
529
+ });
530
+ if (subWhere.sql) {
531
+ whereSql += ` AND (${subWhere.sql})`;
532
+ allValues.push(...subWhere.values);
533
+ paramIdx += subWhere.values.length;
534
+ }
535
+ }
536
+
537
+ // ORDER BY
538
+ let orderBySql = "";
539
+ if (args.orderBy) {
540
+ const orderByItems = Array.isArray(args.orderBy)
541
+ ? (args.orderBy as Record<string, string>[])
542
+ : [args.orderBy as Record<string, string>];
543
+
544
+ const orderClauses = orderByItems.flatMap((item) =>
545
+ Object.entries(item).map(([field, direction]) => {
546
+ const scalarField = sfMap.get(field);
547
+ const col = scalarField ? scalarField.dbName : field;
548
+ return `${table}."${col}" ${direction.toUpperCase()}`;
549
+ })
550
+ );
551
+
552
+ if (orderClauses.length > 0) {
553
+ orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
554
+ }
555
+ }
556
+
557
+ // Per-parent LIMIT/OFFSET using ROW_NUMBER() window function
558
+ if (args.take !== undefined || args.skip !== undefined) {
559
+ let windowOrderBy = "";
560
+ if (args.orderBy) {
561
+ const orderByItems = Array.isArray(args.orderBy)
562
+ ? (args.orderBy as Record<string, string>[])
563
+ : [args.orderBy as Record<string, string>];
564
+ const clauses = orderByItems.flatMap((item) =>
565
+ Object.entries(item).map(([field, direction]) => {
566
+ const sf = sfMap.get(field);
567
+ const col = sf ? sf.dbName : field;
568
+ return `${table}."${col}" ${(direction as string).toUpperCase()}`;
569
+ })
570
+ );
571
+ if (clauses.length > 0) windowOrderBy = clauses.join(", ");
572
+ }
573
+ if (!windowOrderBy) {
574
+ const pk = relatedModelMeta.primaryKey[0];
575
+ if (pk) {
576
+ const pkScalar = sfMap.get(pk);
577
+ windowOrderBy = `${table}."${pkScalar?.dbName ?? pk}" ASC`;
578
+ } else {
579
+ windowOrderBy = "1";
580
+ }
581
+ }
582
+
583
+ const fkSelectInner = fkAlreadySelected
584
+ ? ""
585
+ : `, ${table}."${fkDbName}" AS "__vibeorm_fk"`;
586
+ // ORDER BY is already captured in the ROW_NUMBER() OVER clause; omit the
587
+ // redundant trailing ORDER BY to avoid an extra sort node in the plan.
588
+ const innerSql = `SELECT ${columnsSql}${fkSelectInner}, ROW_NUMBER() OVER (PARTITION BY ${table}."${fkDbName}" ORDER BY ${windowOrderBy}) AS "__vibeorm_rn" FROM ${table} WHERE ${whereSql}`;
589
+
590
+ // Build compound WHERE condition on __vibeorm_rn
591
+ const rnConditions: string[] = [];
592
+ if (args.skip !== undefined) {
593
+ paramIdx++;
594
+ allValues.push(args.skip);
595
+ rnConditions.push(`__ranked."__vibeorm_rn" > $${paramIdx}`);
596
+ }
597
+ if (args.take !== undefined) {
598
+ paramIdx++;
599
+ if (args.skip !== undefined) {
600
+ // upper bound = skip + take
601
+ allValues.push((args.skip as number) + (args.take as number));
602
+ } else {
603
+ allValues.push(args.take);
604
+ }
605
+ rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
606
+ }
607
+
608
+ const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")}`;
609
+
610
+ return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
611
+ }
612
+
613
+ const text = `SELECT ${columnsSql}${fkSelectSql} FROM ${table} WHERE ${whereSql}${orderBySql}`;
614
+
615
+ return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
616
+ }
617
+
618
+ // ─── Many-to-Many Relation Query Builder ──────────────────────────
619
+
620
+ /**
621
+ * Build a query to load M:N related records via an implicit join table.
622
+ * E.g., Post.tags via "_PostToTag" join table.
623
+ */
624
+ export function buildManyToManyQuery(params: {
625
+ parentModelMeta: ModelMeta;
626
+ relationMeta: {
627
+ readonly relatedModel: string;
628
+ readonly type: string;
629
+ readonly isList: boolean;
630
+ readonly joinTable?: string;
631
+ readonly relationName?: string;
632
+ };
633
+ relatedModelMeta: ModelMeta;
634
+ parentIds: unknown[];
635
+ args?: Record<string, unknown>;
636
+ allModelsMeta: ModelMetaMap;
637
+ }): RelationSqlQuery {
638
+ const {
639
+ parentModelMeta,
640
+ relationMeta,
641
+ relatedModelMeta,
642
+ parentIds,
643
+ args = {},
644
+ allModelsMeta,
645
+ } = params;
646
+
647
+ const joinTable = `"${(relationMeta as { joinTable?: string }).joinTable}"`;
648
+ const relatedTable = `"${relatedModelMeta.dbName}"`;
649
+ const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
650
+
651
+ // Determine A vs B: alphabetical model name order
652
+ const sorted = [parentModelMeta.name, relatedModelMeta.name].sort();
653
+ const parentIsA = parentModelMeta.name === sorted[0];
654
+ const parentCol = parentIsA ? "A" : "B";
655
+ const relatedCol = parentIsA ? "B" : "A";
656
+
657
+ // Determine which columns to select from the related model
658
+ const columns = resolveSelectColumns({
659
+ modelMeta: relatedModelMeta,
660
+ args,
661
+ });
662
+ const columnsSql = columns
663
+ .map((c) => `${relatedTable}."${c.dbName}" AS "${c.name}"`)
664
+ .join(", ");
665
+
666
+ const allValues: unknown[] = [];
667
+ let paramIdx = 0;
668
+
669
+ // Build = ANY clause for parent IDs (stable SQL shape for prepared statement caching)
670
+ paramIdx++;
671
+ allValues.push(new PgArray(parentIds));
672
+
673
+ // Related model PK (typically "id")
674
+ const relatedPk = relatedModelMeta.primaryKey[0];
675
+ const relatedPkSf = relatedPk ? sfMap.get(relatedPk) : undefined;
676
+ const relatedPkDb = relatedPkSf?.dbName ?? relatedPk ?? "id";
677
+
678
+ let whereSql = `${joinTable}."${parentCol}" = ANY($${paramIdx})`;
679
+
680
+ // Add any additional where from nested args
681
+ if (args.where) {
682
+ const subWhere = buildWhereClause({
683
+ where: args.where as Record<string, unknown>,
684
+ modelMeta: relatedModelMeta,
685
+ allModelsMeta,
686
+ paramOffset: paramIdx,
687
+ });
688
+ if (subWhere.sql) {
689
+ whereSql += ` AND (${subWhere.sql})`;
690
+ allValues.push(...subWhere.values);
691
+ paramIdx += subWhere.values.length;
692
+ }
693
+ }
694
+
695
+ // ORDER BY
696
+ let orderBySql = "";
697
+ if (args.orderBy) {
698
+ const orderByItems = Array.isArray(args.orderBy)
699
+ ? (args.orderBy as Record<string, string>[])
700
+ : [args.orderBy as Record<string, string>];
701
+
702
+ const orderClauses = orderByItems.flatMap((item) =>
703
+ Object.entries(item).map(([field, direction]) => {
704
+ const scalarField = sfMap.get(field);
705
+ const col = scalarField ? scalarField.dbName : field;
706
+ return `${relatedTable}."${col}" ${direction.toUpperCase()}`;
707
+ })
708
+ );
709
+
710
+ if (orderClauses.length > 0) {
711
+ orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
712
+ }
713
+ }
714
+
715
+ // LIMIT / take + skip support via ROW_NUMBER
716
+ if (args.take !== undefined || args.skip !== undefined) {
717
+ let windowOrderBy = "";
718
+ if (args.orderBy) {
719
+ const orderByItems = Array.isArray(args.orderBy)
720
+ ? (args.orderBy as Record<string, string>[])
721
+ : [args.orderBy as Record<string, string>];
722
+ const clauses = orderByItems.flatMap((item) =>
723
+ Object.entries(item).map(([field, direction]) => {
724
+ const sf = sfMap.get(field);
725
+ const col = sf ? sf.dbName : field;
726
+ return `${relatedTable}."${col}" ${(direction as string).toUpperCase()}`;
727
+ })
728
+ );
729
+ if (clauses.length > 0) windowOrderBy = clauses.join(", ");
730
+ }
731
+ if (!windowOrderBy) {
732
+ windowOrderBy = `${relatedTable}."${relatedPkDb}" ASC`;
733
+ }
734
+
735
+ // ORDER BY is already captured in the ROW_NUMBER() OVER clause; omit the
736
+ // redundant trailing ORDER BY to avoid an extra sort node in the plan.
737
+ const innerSql = `SELECT ${columnsSql}, ${joinTable}."${parentCol}" AS "__vibeorm_fk", ROW_NUMBER() OVER (PARTITION BY ${joinTable}."${parentCol}" ORDER BY ${windowOrderBy}) AS "__vibeorm_rn" FROM ${relatedTable} INNER JOIN ${joinTable} ON ${joinTable}."${relatedCol}" = ${relatedTable}."${relatedPkDb}" WHERE ${whereSql}`;
738
+
739
+ const rnConditions: string[] = [];
740
+ if (args.skip !== undefined) {
741
+ paramIdx++;
742
+ allValues.push(args.skip);
743
+ rnConditions.push(`__ranked."__vibeorm_rn" > $${paramIdx}`);
744
+ }
745
+ if (args.take !== undefined) {
746
+ paramIdx++;
747
+ if (args.skip !== undefined) {
748
+ allValues.push((args.skip as number) + (args.take as number));
749
+ } else {
750
+ allValues.push(args.take);
751
+ }
752
+ rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
753
+ }
754
+
755
+ const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")}`;
756
+ return { text, values: allValues };
757
+ }
758
+
759
+ const text = `SELECT ${columnsSql}, ${joinTable}."${parentCol}" AS "__vibeorm_fk" FROM ${relatedTable} INNER JOIN ${joinTable} ON ${joinTable}."${relatedCol}" = ${relatedTable}."${relatedPkDb}" WHERE ${whereSql}${orderBySql}`;
760
+
761
+ return { text, values: allValues };
762
+ }
763
+
764
+ // ─── INSERT MANY Builder ──────────────────────────────────────────
765
+
766
+ /**
767
+ * Build a multi-row INSERT query.
768
+ * Produces: INSERT INTO "Table" ("col1", "col2") VALUES ($1, $2), ($3, $4), ...
769
+ *
770
+ * Handles varying keys across records by computing the union of all keys
771
+ * and using DEFAULT for missing ones.
772
+ */
773
+ export function buildInsertManyQuery(params: {
774
+ modelMeta: ModelMeta;
775
+ data: Record<string, unknown>[];
776
+ skipDuplicates?: boolean;
777
+ returning?: boolean;
778
+ selectFields?: string[];
779
+ }): SqlQuery {
780
+ const { modelMeta, data, skipDuplicates = false, returning = false, selectFields } = params;
781
+ const table = `"${modelMeta.dbName}"`;
782
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
783
+
784
+ if (data.length === 0) {
785
+ // Empty data — return a no-op that returns count 0
786
+ return { text: `SELECT 0 AS "count"`, values: [] };
787
+ }
788
+
789
+ // Compute the union of all keys across all records, filtered to scalar fields
790
+ const allKeys = new Set<string>();
791
+ for (const record of data) {
792
+ for (const key of Object.keys(record)) {
793
+ if (record[key] === undefined) continue;
794
+ if (sfMap.has(key)) {
795
+ allKeys.add(key);
796
+ }
797
+ }
798
+ }
799
+
800
+ const orderedKeys = [...allKeys];
801
+
802
+ if (orderedKeys.length === 0) {
803
+ // All records have empty data — insert defaults for each row
804
+ // PostgreSQL doesn't support multi-row DEFAULT VALUES, so use a union
805
+ const unionParts = data.map(() => `SELECT`);
806
+ // Actually just use single inserts... or use a CTE
807
+ // Simpler: use VALUES with explicit DEFAULT keywords
808
+ // PostgreSQL supports: INSERT INTO t DEFAULT VALUES (but only one row)
809
+ // For multiple: INSERT INTO t (col) VALUES (DEFAULT), (DEFAULT), ...
810
+ // We need at least one column. Use the PK column.
811
+ const pkField = modelMeta.scalarFields[0]!;
812
+ const col = `"${pkField.dbName}"`;
813
+ const valueRows = data.map(() => `(DEFAULT)`).join(", ");
814
+ const returningClause = returning
815
+ ? ` RETURNING ${resolveReturningColumns({ modelMeta, selectFields })}`
816
+ : "";
817
+ const conflictClause = skipDuplicates ? ` ON CONFLICT DO NOTHING` : "";
818
+ return {
819
+ text: `INSERT INTO ${table} (${col}) VALUES ${valueRows}${conflictClause}${returningClause}`,
820
+ values: [],
821
+ };
822
+ }
823
+
824
+ // Map keys to DB column names
825
+ const columnMap = orderedKeys.map((key) => {
826
+ const scalarField = sfMap.get(key)!;
827
+ return { key, dbName: scalarField.dbName };
828
+ });
829
+
830
+ const columns = columnMap.map((c) => `"${c.dbName}"`).join(", ");
831
+
832
+ // Build VALUES rows
833
+ const allValues: unknown[] = [];
834
+ let paramIdx = 0;
835
+ const valueRows: string[] = [];
836
+
837
+ for (const record of data) {
838
+ const placeholders: string[] = [];
839
+ for (const { key } of columnMap) {
840
+ const value = record[key];
841
+ if (value === undefined) {
842
+ placeholders.push("DEFAULT");
843
+ } else {
844
+ paramIdx++;
845
+ placeholders.push(`$${paramIdx}`);
846
+ allValues.push(value);
847
+ }
848
+ }
849
+ valueRows.push(`(${placeholders.join(", ")})`);
850
+ }
851
+
852
+ const conflictClause = skipDuplicates ? ` ON CONFLICT DO NOTHING` : "";
853
+ const returningClause = returning
854
+ ? ` RETURNING ${resolveReturningColumns({ modelMeta, selectFields })}`
855
+ : "";
856
+
857
+ const text = `INSERT INTO ${table} (${columns}) VALUES ${valueRows.join(", ")}${conflictClause}${returningClause}`;
858
+
859
+ return { text, values: allValues };
860
+ }
861
+
862
+ /**
863
+ * Resolve RETURNING columns for insert many queries.
864
+ */
865
+ function resolveReturningColumns(params: {
866
+ modelMeta: ModelMeta;
867
+ selectFields?: string[];
868
+ }): string {
869
+ const { modelMeta, selectFields } = params;
870
+
871
+ if (selectFields && selectFields.length > 0) {
872
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
873
+ return selectFields
874
+ .map((name) => {
875
+ const sf = sfMap.get(name);
876
+ if (sf) return `"${sf.dbName}" AS "${sf.name}"`;
877
+ return `"${name}" AS "${name}"`;
878
+ })
879
+ .join(", ");
880
+ }
881
+
882
+ return modelMeta.scalarFields
883
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
884
+ .join(", ");
885
+ }
886
+
887
+ // ─── UPDATE MANY Builder ──────────────────────────────────────────
888
+
889
+ /**
890
+ * Build an UPDATE query without RETURNING (for updateMany).
891
+ * Uses a subquery COUNT trick to return the affected row count.
892
+ */
893
+ export function buildUpdateManyQuery(params: {
894
+ modelMeta: ModelMeta;
895
+ allModelsMeta: ModelMetaMap;
896
+ where: Record<string, unknown>;
897
+ data: Record<string, unknown>;
898
+ }): SqlQuery {
899
+ const { modelMeta, allModelsMeta, where, data } = params;
900
+ const table = `"${modelMeta.dbName}"`;
901
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
902
+
903
+ let paramIdx = 0;
904
+ const allValues: unknown[] = [];
905
+ const setClauses: string[] = [];
906
+
907
+ for (const [key, value] of Object.entries(data)) {
908
+ if (value === undefined) continue;
909
+ const scalarField = sfMap.get(key);
910
+ if (!scalarField) continue;
911
+
912
+ const setResult = buildSetClause({
913
+ dbName: scalarField.dbName,
914
+ value,
915
+ paramIdx,
916
+ });
917
+ setClauses.push(setResult.sql);
918
+ allValues.push(...setResult.values);
919
+ paramIdx += setResult.values.length;
920
+ }
921
+
922
+ if (setClauses.length === 0) {
923
+ return { text: `SELECT 0 AS "count"`, values: [] };
924
+ }
925
+
926
+ // Build WHERE
927
+ const whereResult = buildWhereClause({
928
+ where,
929
+ modelMeta,
930
+ allModelsMeta,
931
+ paramOffset: paramIdx,
932
+ });
933
+ allValues.push(...whereResult.values);
934
+
935
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
936
+ const text = `UPDATE ${table} SET ${setClauses.join(", ")}${wherePart}`;
937
+
938
+ return { text, values: allValues };
939
+ }
940
+
941
+ // ─── AGGREGATE Builder ────────────────────────────────────────────
942
+
943
+ /**
944
+ * Build an aggregate query with _count, _avg, _sum, _min, _max.
945
+ */
946
+ export function buildAggregateQuery(params: {
947
+ modelMeta: ModelMeta;
948
+ allModelsMeta: ModelMetaMap;
949
+ args: Record<string, unknown>;
950
+ }): SqlQuery {
951
+ const { modelMeta, allModelsMeta, args } = params;
952
+ const table = `"${modelMeta.dbName}"`;
953
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
954
+
955
+ const selectParts: string[] = [];
956
+
957
+ // _count
958
+ const countArg = args._count;
959
+ if (countArg === true) {
960
+ selectParts.push(`COUNT(*) AS "_count__all"`);
961
+ } else if (typeof countArg === "object" && countArg !== null) {
962
+ const countFields = countArg as Record<string, boolean>;
963
+ if (countFields._all) {
964
+ selectParts.push(`COUNT(*) AS "_count__all"`);
965
+ }
966
+ for (const [field, enabled] of Object.entries(countFields)) {
967
+ if (field === "_all" || !enabled) continue;
968
+ const sf = sfMap.get(field);
969
+ const col = sf ? sf.dbName : field;
970
+ selectParts.push(`COUNT("${col}") AS "_count__${field}"`);
971
+ }
972
+ }
973
+
974
+ // _avg, _sum, _min, _max
975
+ for (const aggFn of ["_avg", "_sum", "_min", "_max"] as const) {
976
+ const aggArg = args[aggFn];
977
+ if (typeof aggArg === "object" && aggArg !== null) {
978
+ const sqlFn = aggFn.slice(1).toUpperCase(); // "avg" → "AVG"
979
+ const fields = aggArg as Record<string, boolean>;
980
+ for (const [field, enabled] of Object.entries(fields)) {
981
+ if (!enabled) continue;
982
+ const sf = sfMap.get(field);
983
+ const col = sf ? sf.dbName : field;
984
+ selectParts.push(`${sqlFn}("${col}") AS "${aggFn}__${field}"`);
985
+ }
986
+ }
987
+ }
988
+
989
+ if (selectParts.length === 0) {
990
+ // No aggregation requested — just count all
991
+ selectParts.push(`COUNT(*) AS "_count__all"`);
992
+ }
993
+
994
+ // Build WHERE
995
+ const whereResult = buildWhereClause({
996
+ where: args.where as Record<string, unknown> | undefined,
997
+ modelMeta,
998
+ allModelsMeta,
999
+ paramOffset: 0,
1000
+ });
1001
+
1002
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
1003
+ const text = `SELECT ${selectParts.join(", ")} FROM ${table}${wherePart}`;
1004
+
1005
+ return { text, values: whereResult.values };
1006
+ }
1007
+
1008
+ // ─── GROUP BY Builder ─────────────────────────────────────────────
1009
+
1010
+ /**
1011
+ * Build a GROUP BY query with aggregate functions and HAVING clause.
1012
+ */
1013
+ export function buildGroupByQuery(params: {
1014
+ modelMeta: ModelMeta;
1015
+ allModelsMeta: ModelMetaMap;
1016
+ args: Record<string, unknown>;
1017
+ }): SqlQuery {
1018
+ const { modelMeta, allModelsMeta, args } = params;
1019
+ const table = `"${modelMeta.dbName}"`;
1020
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1021
+
1022
+ const by = args.by as string[];
1023
+ if (!by || by.length === 0) {
1024
+ throw new Error("groupBy requires a non-empty 'by' array");
1025
+ }
1026
+
1027
+ // Group-by columns
1028
+ const groupColumns = by.map((field) => {
1029
+ const sf = sfMap.get(field);
1030
+ const col = sf ? sf.dbName : field;
1031
+ return { field, col, dbName: col };
1032
+ });
1033
+
1034
+ const selectParts: string[] = groupColumns.map(
1035
+ (g) => `${table}."${g.col}" AS "${g.field}"`
1036
+ );
1037
+
1038
+ // Aggregate functions
1039
+ const countArg = args._count;
1040
+ if (countArg === true) {
1041
+ selectParts.push(`COUNT(*) AS "_count__all"`);
1042
+ } else if (typeof countArg === "object" && countArg !== null) {
1043
+ const countFields = countArg as Record<string, boolean>;
1044
+ if (countFields._all) {
1045
+ selectParts.push(`COUNT(*) AS "_count__all"`);
1046
+ }
1047
+ for (const [field, enabled] of Object.entries(countFields)) {
1048
+ if (field === "_all" || !enabled) continue;
1049
+ const sf = sfMap.get(field);
1050
+ const col = sf ? sf.dbName : field;
1051
+ selectParts.push(`COUNT("${col}") AS "_count__${field}"`);
1052
+ }
1053
+ }
1054
+
1055
+ for (const aggFn of ["_avg", "_sum", "_min", "_max"] as const) {
1056
+ const aggArg = args[aggFn];
1057
+ if (typeof aggArg === "object" && aggArg !== null) {
1058
+ const sqlFn = aggFn.slice(1).toUpperCase();
1059
+ const fields = aggArg as Record<string, boolean>;
1060
+ for (const [field, enabled] of Object.entries(fields)) {
1061
+ if (!enabled) continue;
1062
+ const sf = sfMap.get(field);
1063
+ const col = sf ? sf.dbName : field;
1064
+ selectParts.push(`${sqlFn}("${col}") AS "${aggFn}__${field}"`);
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ // Build WHERE
1070
+ const whereResult = buildWhereClause({
1071
+ where: args.where as Record<string, unknown> | undefined,
1072
+ modelMeta,
1073
+ allModelsMeta,
1074
+ paramOffset: 0,
1075
+ });
1076
+ let paramIdx = whereResult.values.length;
1077
+ const allValues = [...whereResult.values];
1078
+
1079
+ const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
1080
+
1081
+ // GROUP BY
1082
+ const groupBySql = groupColumns
1083
+ .map((g) => `${table}."${g.col}"`)
1084
+ .join(", ");
1085
+
1086
+ // HAVING
1087
+ let havingSql = "";
1088
+ if (args.having) {
1089
+ const havingResult = buildHavingClause({
1090
+ having: args.having as Record<string, unknown>,
1091
+ modelMeta,
1092
+ paramOffset: paramIdx,
1093
+ });
1094
+ if (havingResult.sql) {
1095
+ havingSql = ` HAVING ${havingResult.sql}`;
1096
+ allValues.push(...havingResult.values);
1097
+ paramIdx += havingResult.values.length;
1098
+ }
1099
+ }
1100
+
1101
+ // ORDER BY
1102
+ let orderBySql = "";
1103
+ if (args.orderBy) {
1104
+ const orderByItems = Array.isArray(args.orderBy)
1105
+ ? (args.orderBy as Record<string, string>[])
1106
+ : [args.orderBy as Record<string, string>];
1107
+
1108
+ const orderClauses = orderByItems.flatMap((item) =>
1109
+ Object.entries(item).map(([field, direction]) => {
1110
+ // Support ordering by aggregate: { _count: { id: "asc" } }
1111
+ if (field.startsWith("_") && typeof direction === "object" && direction !== null) {
1112
+ const aggEntries = Object.entries(direction as Record<string, string>);
1113
+ return aggEntries.map(([aggField, aggDir]) => {
1114
+ const sqlFn = field.slice(1).toUpperCase();
1115
+ if (field === "_count" && aggField === "_all") {
1116
+ return `COUNT(*) ${(aggDir as string).toUpperCase()}`;
1117
+ }
1118
+ const sf = sfMap.get(aggField);
1119
+ const col = sf ? sf.dbName : aggField;
1120
+ return `${sqlFn}("${col}") ${(aggDir as string).toUpperCase()}`;
1121
+ }).join(", ");
1122
+ }
1123
+ const sf = sfMap.get(field);
1124
+ const col = sf ? sf.dbName : field;
1125
+ return `${table}."${col}" ${(direction as string).toUpperCase()}`;
1126
+ })
1127
+ );
1128
+
1129
+ if (orderClauses.length > 0) {
1130
+ orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
1131
+ }
1132
+ }
1133
+
1134
+ // LIMIT / OFFSET
1135
+ let limitSql = "";
1136
+ if (args.take !== undefined) {
1137
+ paramIdx++;
1138
+ limitSql = ` LIMIT $${paramIdx}`;
1139
+ allValues.push(args.take);
1140
+ }
1141
+
1142
+ let offsetSql = "";
1143
+ if (args.skip !== undefined) {
1144
+ paramIdx++;
1145
+ offsetSql = ` OFFSET $${paramIdx}`;
1146
+ allValues.push(args.skip);
1147
+ }
1148
+
1149
+ const text = `SELECT ${selectParts.join(", ")} FROM ${table}${wherePart} GROUP BY ${groupBySql}${havingSql}${orderBySql}${limitSql}${offsetSql}`;
1150
+
1151
+ return { text, values: allValues };
1152
+ }
1153
+
1154
+ // ─── HAVING Clause Builder ────────────────────────────────────────
1155
+
1156
+ /**
1157
+ * Build a HAVING clause from Prisma-style having input.
1158
+ * Supports: { field: { _avg: { gt: 10 }, _count: { gte: 5 } } }
1159
+ */
1160
+ function buildHavingClause(params: {
1161
+ having: Record<string, unknown>;
1162
+ modelMeta: ModelMeta;
1163
+ paramOffset: number;
1164
+ }): { sql: string; values: unknown[] } {
1165
+ const { having, modelMeta, paramOffset } = params;
1166
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1167
+ const conditions: string[] = [];
1168
+ const values: unknown[] = [];
1169
+ let paramIdx = paramOffset;
1170
+
1171
+ for (const [field, filter] of Object.entries(having)) {
1172
+ if (filter === undefined) continue;
1173
+ if (typeof filter !== "object" || filter === null) continue;
1174
+
1175
+ const filterObj = filter as Record<string, unknown>;
1176
+
1177
+ for (const [aggFn, comparison] of Object.entries(filterObj)) {
1178
+ if (!aggFn.startsWith("_") || typeof comparison !== "object" || comparison === null) continue;
1179
+
1180
+ const sqlFn = aggFn.slice(1).toUpperCase(); // "_avg" → "AVG"
1181
+ const sf = sfMap.get(field);
1182
+ const col = sf ? sf.dbName : field;
1183
+
1184
+ const compObj = comparison as Record<string, unknown>;
1185
+ for (const [op, val] of Object.entries(compObj)) {
1186
+ if (val === undefined) continue;
1187
+ paramIdx++;
1188
+ values.push(val);
1189
+
1190
+ let sqlOp: string;
1191
+ switch (op) {
1192
+ case "equals": sqlOp = "="; break;
1193
+ case "not": sqlOp = "!="; break;
1194
+ case "lt": sqlOp = "<"; break;
1195
+ case "lte": sqlOp = "<="; break;
1196
+ case "gt": sqlOp = ">"; break;
1197
+ case "gte": sqlOp = ">="; break;
1198
+ default: continue;
1199
+ }
1200
+
1201
+ conditions.push(`${sqlFn}("${col}") ${sqlOp} $${paramIdx}`);
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ return {
1207
+ sql: conditions.join(" AND "),
1208
+ values,
1209
+ };
1210
+ }
1211
+
1212
+ // ─── SET Clause Builder (atomic ops + list ops) ──────────────────
1213
+
1214
+ /**
1215
+ * Build a single SET clause for an UPDATE statement.
1216
+ * Handles:
1217
+ * - Plain values: "col" = $N
1218
+ * - Atomic number ops: { increment: N } → "col" = "col" + $N
1219
+ * - Scalar list ops: { set: [...] } → "col" = $N, { push: v } → "col" = array_append("col", $N)
1220
+ */
1221
+ function buildSetClause(params: {
1222
+ dbName: string;
1223
+ value: unknown;
1224
+ paramIdx: number;
1225
+ }): { sql: string; values: unknown[] } {
1226
+ const { dbName, value, paramIdx } = params;
1227
+ let idx = paramIdx;
1228
+
1229
+ // Check for atomic number operations: { increment, decrement, multiply, divide, set }
1230
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
1231
+ const ops = value as Record<string, unknown>;
1232
+
1233
+ if (ops.increment !== undefined) {
1234
+ idx++;
1235
+ return { sql: `"${dbName}" = "${dbName}" + $${idx}`, values: [ops.increment] };
1236
+ }
1237
+ if (ops.decrement !== undefined) {
1238
+ idx++;
1239
+ return { sql: `"${dbName}" = "${dbName}" - $${idx}`, values: [ops.decrement] };
1240
+ }
1241
+ if (ops.multiply !== undefined) {
1242
+ idx++;
1243
+ return { sql: `"${dbName}" = "${dbName}" * $${idx}`, values: [ops.multiply] };
1244
+ }
1245
+ if (ops.divide !== undefined) {
1246
+ idx++;
1247
+ return { sql: `"${dbName}" = "${dbName}" / $${idx}`, values: [ops.divide] };
1248
+ }
1249
+
1250
+ // Scalar list operations: { set: [...] } or { push: value }
1251
+ if ("push" in ops) {
1252
+ const pushVal = ops.push;
1253
+ if (Array.isArray(pushVal)) {
1254
+ // Push multiple: "col" = "col" || $N
1255
+ idx++;
1256
+ return { sql: `"${dbName}" = "${dbName}" || $${idx}`, values: [pushVal] };
1257
+ } else {
1258
+ // Push single: "col" = array_append("col", $N)
1259
+ idx++;
1260
+ return { sql: `"${dbName}" = array_append("${dbName}", $${idx})`, values: [pushVal] };
1261
+ }
1262
+ }
1263
+
1264
+ if ("set" in ops) {
1265
+ idx++;
1266
+ return { sql: `"${dbName}" = $${idx}`, values: [ops.set] };
1267
+ }
1268
+ }
1269
+
1270
+ // Plain value
1271
+ idx++;
1272
+ return { sql: `"${dbName}" = $${idx}`, values: [value] };
1273
+ }
1274
+
1275
+ // ─── UPSERT Builder (INSERT ... ON CONFLICT) ─────────────────────
1276
+
1277
+ /**
1278
+ * Build an atomic upsert query using INSERT ... ON CONFLICT DO UPDATE SET.
1279
+ * This is race-condition-free unlike the findUnique + create/update approach.
1280
+ */
1281
+ export function buildUpsertQuery(params: {
1282
+ modelMeta: ModelMeta;
1283
+ where: Record<string, unknown>;
1284
+ create: Record<string, unknown>;
1285
+ update: Record<string, unknown>;
1286
+ }): SqlQuery {
1287
+ const { modelMeta, where, create, update } = params;
1288
+ const table = `"${modelMeta.dbName}"`;
1289
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1290
+
1291
+ // Determine conflict columns from where clause (unique fields)
1292
+ const conflictCols: string[] = [];
1293
+ for (const key of Object.keys(where)) {
1294
+ const sf = sfMap.get(key);
1295
+ if (sf) {
1296
+ conflictCols.push(`"${sf.dbName}"`);
1297
+ }
1298
+ }
1299
+
1300
+ // If no conflict columns, fallback to primary key
1301
+ if (conflictCols.length === 0) {
1302
+ for (const pk of modelMeta.primaryKey) {
1303
+ const sf = sfMap.get(pk);
1304
+ conflictCols.push(`"${sf?.dbName ?? pk}"`);
1305
+ }
1306
+ }
1307
+
1308
+ // Build INSERT part from create data
1309
+ const insertEntries: Array<{ dbName: string; value: unknown }> = [];
1310
+ for (const [key, value] of Object.entries(create)) {
1311
+ if (value === undefined) continue;
1312
+ const sf = sfMap.get(key);
1313
+ if (sf) {
1314
+ insertEntries.push({ dbName: sf.dbName, value });
1315
+ }
1316
+ }
1317
+
1318
+ let paramIdx = 0;
1319
+ const allValues: unknown[] = [];
1320
+
1321
+ const insertColumns = insertEntries.map((e) => `"${e.dbName}"`).join(", ");
1322
+ const insertPlaceholders = insertEntries.map((e) => {
1323
+ paramIdx++;
1324
+ allValues.push(e.value);
1325
+ return `$${paramIdx}`;
1326
+ }).join(", ");
1327
+
1328
+ // Build UPDATE SET part from update data (using atomic operations)
1329
+ const updateClauses: string[] = [];
1330
+ for (const [key, value] of Object.entries(update)) {
1331
+ if (value === undefined) continue;
1332
+ const sf = sfMap.get(key);
1333
+ if (!sf) continue;
1334
+
1335
+ const setResult = buildSetClause({
1336
+ dbName: sf.dbName,
1337
+ value,
1338
+ paramIdx,
1339
+ });
1340
+ updateClauses.push(setResult.sql);
1341
+ allValues.push(...setResult.values);
1342
+ paramIdx += setResult.values.length;
1343
+ }
1344
+
1345
+ const returningCols = modelMeta.scalarFields
1346
+ .map((f) => `"${f.dbName}" AS "${f.name}"`)
1347
+ .join(", ");
1348
+
1349
+ let text: string;
1350
+ if (updateClauses.length > 0) {
1351
+ text = `INSERT INTO ${table} (${insertColumns}) VALUES (${insertPlaceholders}) ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${updateClauses.join(", ")} RETURNING ${returningCols}`;
1352
+ } else {
1353
+ text = `INSERT INTO ${table} (${insertColumns}) VALUES (${insertPlaceholders}) ON CONFLICT (${conflictCols.join(", ")}) DO NOTHING RETURNING ${returningCols}`;
1354
+ }
1355
+
1356
+ return { text, values: allValues };
1357
+ }
1358
+
1359
+ // ─── Column Resolution ────────────────────────────────────────────
1360
+
1361
+ function resolveSelectColumns(params: {
1362
+ modelMeta: ModelMeta;
1363
+ args: Record<string, unknown>;
1364
+ }): readonly ScalarFieldMeta[] {
1365
+ const { modelMeta, args } = params;
1366
+ const select = args.select as Record<string, boolean | object> | undefined;
1367
+ const omit = args.omit as Record<string, boolean> | undefined;
1368
+
1369
+ if (select) {
1370
+ // With select → return only selected scalar fields
1371
+ const columns = modelMeta.scalarFields.filter((f) => {
1372
+ const val = select[f.name];
1373
+ return val === true || (typeof val === "object" && val !== null);
1374
+ });
1375
+
1376
+ // Auto-include PK if select has nested relations (needed for Phase 2 relation loading)
1377
+ const hasNested = Object.entries(select).some(([key, val]) => {
1378
+ if (typeof val !== "object" || val === null) return false;
1379
+ return modelMeta.relationFields.some((r) => r.name === key);
1380
+ });
1381
+
1382
+ if (hasNested) {
1383
+ // Auto-include PK columns (needed for all relation loading strategies)
1384
+ for (const pkName of modelMeta.primaryKey) {
1385
+ if (!columns.some((c) => c.name === pkName)) {
1386
+ const sf = modelMeta.scalarFields.find((f) => f.name === pkName);
1387
+ if (sf) columns.push(sf);
1388
+ }
1389
+ }
1390
+
1391
+ // Auto-include FK fields for selected to-one relations where parent holds the FK
1392
+ // (needed for Phase 2 relation loading to query related records by FK value)
1393
+ for (const [key, val] of Object.entries(select)) {
1394
+ if (typeof val !== "object" || val === null) continue;
1395
+ const rel = modelMeta.relationFields.find((r) => r.name === key);
1396
+ if (rel && rel.isForeignKey && !rel.isList) {
1397
+ for (const fkFieldName of rel.fields) {
1398
+ if (!columns.some((c) => c.name === fkFieldName)) {
1399
+ const sf = modelMeta.scalarFields.find((f) => f.name === fkFieldName);
1400
+ if (sf) columns.push(sf);
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ return columns;
1408
+ }
1409
+
1410
+ if (omit) {
1411
+ // With omit → return all scalar fields except omitted ones
1412
+ return modelMeta.scalarFields.filter((f) => !omit[f.name]);
1413
+ }
1414
+
1415
+ // No select/omit → return all scalar fields (default selection)
1416
+ return modelMeta.scalarFields;
1417
+ }