@vibeorm/runtime 1.3.0 → 2.0.0-alpha.10

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.
Files changed (96) hide show
  1. package/README.md +50 -107
  2. package/dist/adapter.d.ts +250 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/bulk-upsert.d.ts +282 -0
  5. package/dist/bulk-upsert.d.ts.map +1 -0
  6. package/dist/client.d.ts +200 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/codecs.d.ts +170 -0
  9. package/dist/codecs.d.ts.map +1 -0
  10. package/dist/computed.d.ts +43 -0
  11. package/dist/computed.d.ts.map +1 -0
  12. package/dist/db-now.d.ts +41 -0
  13. package/dist/db-now.d.ts.map +1 -0
  14. package/dist/diagnostics/index.d.ts +12 -0
  15. package/dist/diagnostics/index.d.ts.map +1 -0
  16. package/dist/diagnostics/insight.d.ts +63 -0
  17. package/dist/diagnostics/insight.d.ts.map +1 -0
  18. package/dist/diagnostics/plan.d.ts +88 -0
  19. package/dist/diagnostics/plan.d.ts.map +1 -0
  20. package/dist/diagnostics/preview.d.ts +43 -0
  21. package/dist/diagnostics/preview.d.ts.map +1 -0
  22. package/dist/diagnostics/types.d.ts +223 -0
  23. package/dist/diagnostics/types.d.ts.map +1 -0
  24. package/dist/diagnostics/workload.d.ts +32 -0
  25. package/dist/diagnostics/workload.d.ts.map +1 -0
  26. package/dist/extensions.d.ts +102 -0
  27. package/dist/extensions.d.ts.map +1 -0
  28. package/dist/index.d.ts +59 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +13070 -0
  31. package/dist/index.js.map +43 -0
  32. package/dist/keyset-iterator.d.ts +73 -0
  33. package/dist/keyset-iterator.d.ts.map +1 -0
  34. package/dist/keyset.d.ts +121 -0
  35. package/dist/keyset.d.ts.map +1 -0
  36. package/dist/model-meta.d.ts +200 -0
  37. package/dist/model-meta.d.ts.map +1 -0
  38. package/dist/nested-writes.d.ts +67 -0
  39. package/dist/nested-writes.d.ts.map +1 -0
  40. package/dist/policy-operation.d.ts +14 -0
  41. package/dist/policy-operation.d.ts.map +1 -0
  42. package/dist/policy.d.ts +17 -0
  43. package/dist/policy.d.ts.map +1 -0
  44. package/dist/query-builder.d.ts +271 -0
  45. package/dist/query-builder.d.ts.map +1 -0
  46. package/dist/relation-key.d.ts +23 -0
  47. package/dist/relation-key.d.ts.map +1 -0
  48. package/dist/relation-loader.d.ts +46 -0
  49. package/dist/relation-loader.d.ts.map +1 -0
  50. package/dist/relation-plan.d.ts +141 -0
  51. package/dist/relation-plan.d.ts.map +1 -0
  52. package/dist/render-cache.d.ts +48 -0
  53. package/dist/render-cache.d.ts.map +1 -0
  54. package/dist/rls-context.d.ts +14 -0
  55. package/dist/rls-context.d.ts.map +1 -0
  56. package/dist/rls-readiness.d.ts +114 -0
  57. package/dist/rls-readiness.d.ts.map +1 -0
  58. package/dist/scoped.d.ts +104 -0
  59. package/dist/scoped.d.ts.map +1 -0
  60. package/dist/strict-args.d.ts +47 -0
  61. package/dist/strict-args.d.ts.map +1 -0
  62. package/dist/telemetry/collector.d.ts +53 -0
  63. package/dist/telemetry/collector.d.ts.map +1 -0
  64. package/dist/telemetry/config.d.ts +53 -0
  65. package/dist/telemetry/config.d.ts.map +1 -0
  66. package/dist/telemetry/fingerprint.d.ts +38 -0
  67. package/dist/telemetry/fingerprint.d.ts.map +1 -0
  68. package/dist/telemetry/index.d.ts +18 -0
  69. package/dist/telemetry/index.d.ts.map +1 -0
  70. package/dist/telemetry/recorder.d.ts +93 -0
  71. package/dist/telemetry/recorder.d.ts.map +1 -0
  72. package/dist/telemetry/statement.d.ts +53 -0
  73. package/dist/telemetry/statement.d.ts.map +1 -0
  74. package/dist/telemetry/types.d.ts +265 -0
  75. package/dist/telemetry/types.d.ts.map +1 -0
  76. package/dist/validators.d.ts +61 -0
  77. package/dist/validators.d.ts.map +1 -0
  78. package/dist/views.d.ts +97 -0
  79. package/dist/views.d.ts.map +1 -0
  80. package/dist/write-scope.d.ts +14 -0
  81. package/dist/write-scope.d.ts.map +1 -0
  82. package/package.json +33 -26
  83. package/src/adapter.ts +0 -146
  84. package/src/client.ts +0 -2172
  85. package/src/coerce.ts +0 -184
  86. package/src/count-loader.ts +0 -152
  87. package/src/errors.ts +0 -492
  88. package/src/id-generators.ts +0 -151
  89. package/src/index.ts +0 -55
  90. package/src/lateral-join-builder.ts +0 -1053
  91. package/src/query-builder.ts +0 -1832
  92. package/src/relation-loader.ts +0 -534
  93. package/src/retry.ts +0 -183
  94. package/src/types.ts +0 -317
  95. package/src/view.ts +0 -629
  96. package/src/where-builder.ts +0 -772
@@ -1,1832 +0,0 @@
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
- // ─── Scalar List Helpers ──────────────────────────────────────────
19
-
20
- /**
21
- * Wrap a value in PgArray if the corresponding field is a scalar list (e.g. String[], Int[]).
22
- * This ensures adapters serialize the JS array as a PostgreSQL array literal
23
- * rather than passing it as a raw array or JSON string.
24
- */
25
- function wrapListValue(params: { value: unknown; field: ScalarFieldMeta }): unknown {
26
- if (params.field.isList && Array.isArray(params.value)) {
27
- return new PgArray(params.value);
28
- }
29
- return params.value;
30
- }
31
-
32
- // ─── SQL Direction Sanitizer ──────────────────────────────────────
33
-
34
- const VALID_DIRECTIONS = new Set(["ASC", "DESC"]);
35
-
36
- /** Sanitize an ORDER BY direction to prevent SQL injection via untrusted input. */
37
- export function sanitizeDirection(params: { direction: string }): string {
38
- const upper = params.direction.toUpperCase();
39
- return VALID_DIRECTIONS.has(upper) ? upper : "ASC";
40
- }
41
-
42
- function reverseDirection(params: { direction: string }): string {
43
- const normalized = sanitizeDirection({ direction: params.direction });
44
- return normalized === "ASC" ? "DESC" : "ASC";
45
- }
46
-
47
- function getScalarFieldOrThrow(params: {
48
- sfMap: ReadonlyMap<string, ScalarFieldMeta>;
49
- field: string;
50
- modelName: string;
51
- context: string;
52
- }): ScalarFieldMeta {
53
- const scalarField = params.sfMap.get(params.field);
54
- if (!scalarField) {
55
- throw new Error(
56
- `Unknown field "${params.field}" in ${params.context} for model "${params.modelName}"`
57
- );
58
- }
59
- return scalarField;
60
- }
61
-
62
- function getTakeValue(params: { args: Record<string, unknown> }): number | undefined {
63
- const take = params.args.take;
64
- return typeof take === "number" ? take : undefined;
65
- }
66
-
67
- const AGGREGATE_SQL_FN_BY_KEY = new Map<string, string>([
68
- ["_count", "COUNT"],
69
- ["_avg", "AVG"],
70
- ["_sum", "SUM"],
71
- ["_min", "MIN"],
72
- ["_max", "MAX"],
73
- ]);
74
-
75
- function getAggregateSqlFnOrThrow(params: { aggregateKey: string }): string {
76
- const sqlFn = AGGREGATE_SQL_FN_BY_KEY.get(params.aggregateKey);
77
- if (!sqlFn) {
78
- throw new Error(`Unsupported aggregate function "${params.aggregateKey}"`);
79
- }
80
- return sqlFn;
81
- }
82
-
83
- // ─── SELECT Builder ───────────────────────────────────────────────
84
-
85
- export function buildSelectQuery(params: {
86
- modelMeta: ModelMeta;
87
- allModelsMeta: ModelMetaMap;
88
- args: Record<string, unknown>;
89
- defaultOrderByPk?: boolean;
90
- distinctOrderByPk?: boolean;
91
- }): SqlQuery {
92
- const { modelMeta, allModelsMeta, args, defaultOrderByPk = false, distinctOrderByPk = true } = params;
93
- const table = `"${modelMeta.dbName}"`;
94
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
95
- const take = getTakeValue({ args });
96
-
97
- // Negative take is only valid with cursor-based pagination (backward page)
98
- if (take !== undefined && take < 0 && args.cursor === undefined) {
99
- throw new Error(
100
- `Negative \`take\` (${take}) is only supported with cursor-based pagination on model "${modelMeta.name}". Provide a \`cursor\` argument or use a positive \`take\` value.`
101
- );
102
- }
103
-
104
- const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
105
-
106
- // Determine which columns to select
107
- const columns = resolveSelectColumns({ modelMeta, args });
108
- const columnsSql = columns
109
- .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
110
- .join(", ");
111
-
112
- // DISTINCT ON support (PostgreSQL)
113
- let distinctSql = "";
114
- if (args.distinct) {
115
- const distinctFields = args.distinct as string[];
116
- if (distinctFields.length > 0) {
117
- const distinctCols = distinctFields.map((field) => {
118
- const sf = getScalarFieldOrThrow({
119
- sfMap,
120
- field,
121
- modelName: modelMeta.name,
122
- context: "distinct",
123
- });
124
- return `${table}."${sf.dbName}"`;
125
- });
126
- distinctSql = `DISTINCT ON (${distinctCols.join(", ")}) `;
127
- }
128
- }
129
-
130
- // Build WHERE — start with explicit where, then add cursor condition
131
- let where = args.where as Record<string, unknown> | undefined;
132
-
133
- // Cursor-based pagination: inject cursor condition into WHERE
134
- // For composite cursors we need lexicographic ordering:
135
- // (a > $1) OR (a = $1 AND b > $2) OR (a = $1 AND b = $2 AND c > $3) ...
136
- if (args.cursor) {
137
- const cursorInput = args.cursor as Record<string, unknown>;
138
- const cursorEntries = Object.entries(cursorInput).filter(
139
- ([, v]) => v !== undefined
140
- );
141
-
142
- // Determine the default comparison direction from take sign
143
- const isBackward = isBackwardCursor;
144
-
145
- // Resolve per-field ORDER BY directions so the cursor comparator aligns
146
- // with the sort direction. For DESC ordering, forward pagination uses `lt`
147
- // instead of `gt` so that the cursor skips to the next descending page.
148
- const orderByDirMap = new Map<string, string>();
149
- if (args.orderBy) {
150
- const orderByItems = Array.isArray(args.orderBy)
151
- ? (args.orderBy as Record<string, unknown>[])
152
- : [args.orderBy as Record<string, unknown>];
153
- for (const item of orderByItems) {
154
- for (const [field, dir] of Object.entries(item)) {
155
- if (typeof dir === "string") {
156
- orderByDirMap.set(field, dir.toUpperCase());
157
- } else if (typeof dir === "object" && dir !== null) {
158
- const dirObj = dir as Record<string, unknown>;
159
- if (typeof dirObj.sort === "string") {
160
- orderByDirMap.set(field, (dirObj.sort as string).toUpperCase());
161
- }
162
- }
163
- }
164
- }
165
- }
166
-
167
- /**
168
- * Resolve the cursor comparison operator for a given field.
169
- * - Forward (take >= 0) + ASC → gt (skip rows below cursor)
170
- * - Forward (take >= 0) + DESC → lt (skip rows above cursor)
171
- * - Backward (take < 0) + ASC → lt (reverse direction)
172
- * - Backward (take < 0) + DESC → gt (reverse direction)
173
- */
174
- const cmpOpForField = (field: string): string => {
175
- const dir = orderByDirMap.get(field) ?? "ASC";
176
- const isDesc = dir === "DESC";
177
- // Forward + ASC = gt, Forward + DESC = lt, Backward flips
178
- return (isDesc !== isBackward) ? "lt" : "gt";
179
- };
180
-
181
- let cursorWhere: Record<string, unknown> | undefined;
182
-
183
- if (cursorEntries.length === 1) {
184
- // Single-field cursor: simple gt/lt (most common case)
185
- const [field, value] = cursorEntries[0]!;
186
- cursorWhere = { [field]: { [cmpOpForField(field)]: value } };
187
- } else if (cursorEntries.length > 1) {
188
- // Composite cursor: build lexicographic OR conditions
189
- // For fields [a, b, c] with forward:
190
- // (a > v1) OR (a = v1 AND b > v2) OR (a = v1 AND b = v2 AND c > v3)
191
- const orBranches: Record<string, unknown>[] = [];
192
- for (let i = 0; i < cursorEntries.length; i++) {
193
- const branch: Record<string, unknown> = {};
194
- // All preceding fields must equal their cursor values
195
- for (let j = 0; j < i; j++) {
196
- const [eqField, eqValue] = cursorEntries[j]!;
197
- branch[eqField] = { equals: eqValue };
198
- }
199
- // The i-th field uses the field-specific comparison operator
200
- const [cmpField, cmpValue] = cursorEntries[i]!;
201
- branch[cmpField] = { [cmpOpForField(cmpField)]: cmpValue };
202
- orBranches.push(branch);
203
- }
204
- cursorWhere = { OR: orBranches };
205
- }
206
-
207
- // Merge cursor conditions with existing where via AND
208
- if (cursorWhere) {
209
- where = where
210
- ? { AND: [where, cursorWhere] }
211
- : cursorWhere;
212
- }
213
- }
214
-
215
- const whereResult = buildWhereClause({
216
- where,
217
- modelMeta,
218
- allModelsMeta,
219
- paramOffset: 0,
220
- });
221
-
222
- let paramIdx = whereResult.values.length;
223
- const allValues = [...whereResult.values];
224
-
225
- // Build ORDER BY
226
- let orderBySql = "";
227
- const orderByJoins: string[] = [];
228
- // Track joined relation aliases to avoid duplicate LEFT JOINs when the
229
- // same relation appears multiple times in orderBy (e.g., { author: { name: "asc" } }, { author: { email: "desc" } })
230
- const joinedRelationAliases = new Set<string>();
231
-
232
- if (args.orderBy) {
233
- const orderByItems = Array.isArray(args.orderBy)
234
- ? (args.orderBy as Record<string, unknown>[])
235
- : [args.orderBy as Record<string, unknown>];
236
-
237
- const orderClauses = orderByItems.flatMap((item) =>
238
- Object.entries(item).map(([field, direction]) => {
239
- // Null ordering: { sort: "desc", nulls: "last" }
240
- if (typeof direction === "object" && direction !== null) {
241
- const dirObj = direction as Record<string, unknown>;
242
-
243
- // SortOrderWithNulls: { sort: "asc", nulls: "last" }
244
- if (dirObj.sort && typeof dirObj.sort === "string") {
245
- const scalarField = getScalarFieldOrThrow({
246
- sfMap,
247
- field,
248
- modelName: modelMeta.name,
249
- context: "orderBy",
250
- });
251
- const direction = isBackwardCursor
252
- ? reverseDirection({ direction: dirObj.sort as string })
253
- : (dirObj.sort as string);
254
- let clause = `${table}."${scalarField.dbName}" ${sanitizeDirection({ direction })}`;
255
- if (dirObj.nulls === "first") clause += " NULLS FIRST";
256
- else if (dirObj.nulls === "last") clause += " NULLS LAST";
257
- return clause;
258
- }
259
-
260
- // Relation count ordering: { posts: { _count: "desc" } }
261
- // NOTE: Uses a correlated subquery per row. PostgreSQL's planner typically
262
- // converts this to a hash/merge join internally. An explicit LEFT JOIN on a
263
- // pre-aggregated subquery would be an alternative, but adds complexity and
264
- // risk for marginal gain on typical result sets (<10k rows).
265
- if (dirObj._count && typeof dirObj._count === "string") {
266
- const relField = modelMeta.relationFields.find((r) => r.name === field);
267
- if (relField) {
268
- const modelMap = getModelByNameMap({ allModelsMeta });
269
- const relatedMeta = modelMap.get(relField.relatedModel);
270
- if (relatedMeta) {
271
- const reverseRel = relatedMeta.relationFields.find(
272
- (r: { relatedModel: string; isForeignKey: boolean; fields: readonly string[] }) =>
273
- r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
274
- );
275
- if (reverseRel) {
276
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
277
- const fkScalar = relatedSfMap.get(reverseRel.fields[0]!);
278
- const fkDbName = fkScalar?.dbName ?? reverseRel.fields[0]!;
279
- const parentPk = modelMeta.primaryKey[0]!;
280
- const parentSf = sfMap.get(parentPk);
281
- const parentPkDb = parentSf?.dbName ?? parentPk;
282
- const direction = isBackwardCursor
283
- ? reverseDirection({ direction: dirObj._count as string })
284
- : (dirObj._count as string);
285
- return `(SELECT COUNT(*) FROM "${relatedMeta.dbName}" WHERE "${relatedMeta.dbName}"."${fkDbName}" = ${table}."${parentPkDb}") ${sanitizeDirection({ direction })}`;
286
- }
287
- }
288
- }
289
- throw new Error(
290
- `Unknown relation field "${field}" in orderBy._count for model "${modelMeta.name}"`
291
- );
292
- }
293
-
294
- // Relation field ordering: { author: { name: "asc" } }
295
- const relField = modelMeta.relationFields.find((r) => r.name === field);
296
- if (relField && !relField.isList) {
297
- const modelMap = getModelByNameMap({ allModelsMeta });
298
- const relatedMeta = modelMap.get(relField.relatedModel);
299
- if (relatedMeta) {
300
- const relatedAlias = `"__orderby_${field}"`;
301
-
302
- // Only emit the LEFT JOIN once per relation alias
303
- if (!joinedRelationAliases.has(field)) {
304
- joinedRelationAliases.add(field);
305
- // Determine join condition
306
- let joinCond: string;
307
- if (relField.isForeignKey && relField.fields.length > 0) {
308
- const fkScalar = sfMap.get(relField.fields[0]!);
309
- const fkDbName = fkScalar?.dbName ?? relField.fields[0]!;
310
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
311
- const refScalar = relatedSfMap.get(relField.references[0]!);
312
- const refDbName = refScalar?.dbName ?? relField.references[0]!;
313
- joinCond = `${relatedAlias}."${refDbName}" = ${table}."${fkDbName}"`;
314
- } else {
315
- const reverseRel = relatedMeta.relationFields.find(
316
- (r: { relatedModel: string; isForeignKey: boolean; fields: readonly string[] }) =>
317
- r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
318
- );
319
- if (reverseRel) {
320
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
321
- const fkScalar = relatedSfMap.get(reverseRel.fields[0]!);
322
- const fkDbName = fkScalar?.dbName ?? reverseRel.fields[0]!;
323
- const refScalar = sfMap.get(reverseRel.references[0]!);
324
- const refDbName = refScalar?.dbName ?? reverseRel.references[0]!;
325
- joinCond = `${relatedAlias}."${fkDbName}" = ${table}."${refDbName}"`;
326
- } else {
327
- joinCond = "TRUE";
328
- }
329
- }
330
- orderByJoins.push(`LEFT JOIN "${relatedMeta.dbName}" ${relatedAlias} ON ${joinCond}`);
331
- }
332
-
333
- // Get the ordering field from the relation object
334
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedMeta.scalarFields });
335
- return Object.entries(dirObj).map(([relField2, relDir]) => {
336
- if (typeof relDir !== "string") {
337
- throw new Error(
338
- `Unsupported orderBy direction for relation field "${field}.${relField2}" on model "${modelMeta.name}"`
339
- );
340
- }
341
- const relSf = getScalarFieldOrThrow({
342
- sfMap: relatedSfMap,
343
- field: relField2,
344
- modelName: relatedMeta.name,
345
- context: `orderBy.${field}`,
346
- });
347
- const direction = isBackwardCursor
348
- ? reverseDirection({ direction: relDir })
349
- : relDir;
350
- return `${relatedAlias}."${relSf.dbName}" ${sanitizeDirection({ direction })}`;
351
- }).join(", ");
352
- }
353
- }
354
- }
355
-
356
- // Simple scalar ordering — only allow known scalar fields
357
- if (typeof direction !== "string") {
358
- throw new Error(
359
- `Unsupported orderBy direction for field "${field}" on model "${modelMeta.name}"`
360
- );
361
- }
362
- const scalarField = getScalarFieldOrThrow({
363
- sfMap,
364
- field,
365
- modelName: modelMeta.name,
366
- context: "orderBy",
367
- });
368
- const resolvedDirection = isBackwardCursor
369
- ? reverseDirection({ direction })
370
- : direction;
371
- return `${table}."${scalarField.dbName}" ${sanitizeDirection({ direction: resolvedDirection })}`;
372
- })
373
- );
374
-
375
- const validOrderClauses = orderClauses;
376
-
377
- if (validOrderClauses.length > 0) {
378
- // When DISTINCT ON is used with explicit orderBy, PostgreSQL requires
379
- // the DISTINCT ON expressions to match the leftmost ORDER BY terms.
380
- // Prepend distinct columns that aren't already leading the ORDER BY.
381
- if (args.distinct) {
382
- const distinctFields = args.distinct as string[];
383
- const distinctPrefixClauses = distinctFields.map((field) => {
384
- const sf = getScalarFieldOrThrow({
385
- sfMap,
386
- field,
387
- modelName: modelMeta.name,
388
- context: "distinct",
389
- });
390
- return `${table}."${sf.dbName}"`;
391
- });
392
- // Build a set of column refs that are already leading ORDER BY terms
393
- const existingLeadCols = new Set(
394
- validOrderClauses.slice(0, distinctFields.length).map((clause) => {
395
- // Extract the column ref (everything before the direction keyword)
396
- const match = clause.match(/^(.+?)\s+(ASC|DESC)/i);
397
- return match ? match[1]!.trim() : clause.trim();
398
- })
399
- );
400
- // Prepend any distinct columns not already at the front
401
- const missingPrefixes = distinctPrefixClauses.filter((col) => !existingLeadCols.has(col));
402
- let combinedClauses: string[];
403
- if (missingPrefixes.length > 0) {
404
- const prefixOrderClauses = missingPrefixes.map((col) => `${col} ASC`);
405
- combinedClauses = [...prefixOrderClauses, ...validOrderClauses];
406
- } else {
407
- combinedClauses = [...validOrderClauses];
408
- }
409
- // Add PK tie-breaker for deterministic row selection per distinct group
410
- if (distinctOrderByPk) {
411
- const allOrderColRefs = new Set(
412
- combinedClauses.map((clause) => {
413
- const match = clause.match(/^(.+?)\s+(ASC|DESC)/i);
414
- return match ? match[1]!.trim() : clause.trim();
415
- })
416
- );
417
- for (const pkName of modelMeta.primaryKey) {
418
- const pkSf = sfMap.get(pkName);
419
- if (pkSf) {
420
- const pkRef = `${table}."${pkSf.dbName}"`;
421
- if (!allOrderColRefs.has(pkRef)) {
422
- combinedClauses.push(`${pkRef} ASC`);
423
- }
424
- }
425
- }
426
- }
427
- orderBySql = ` ORDER BY ${combinedClauses.join(", ")}`;
428
- } else {
429
- orderBySql = ` ORDER BY ${validOrderClauses.join(", ")}`;
430
- }
431
- }
432
- } else if (args.distinct) {
433
- // DISTINCT ON requires ORDER BY on the same columns first
434
- const distinctFields = args.distinct as string[];
435
- if (distinctFields.length > 0) {
436
- const distinctOrderClauses = distinctFields.map((field) => {
437
- const sf = getScalarFieldOrThrow({
438
- sfMap,
439
- field,
440
- modelName: modelMeta.name,
441
- context: "distinct",
442
- });
443
- return `${table}."${sf.dbName}" ASC`;
444
- });
445
- // Add PK tie-breaker for deterministic row selection per distinct group
446
- if (distinctOrderByPk) {
447
- const distinctFieldSet = new Set(distinctFields);
448
- for (const pkName of modelMeta.primaryKey) {
449
- if (!distinctFieldSet.has(pkName)) {
450
- const pkSf = sfMap.get(pkName);
451
- if (pkSf) {
452
- distinctOrderClauses.push(`${table}."${pkSf.dbName}" ASC`);
453
- }
454
- }
455
- }
456
- }
457
- orderBySql = ` ORDER BY ${distinctOrderClauses.join(", ")}`;
458
- }
459
- } else if (args.cursor) {
460
- // Cursor pagination without explicit orderBy: default to ordering by cursor
461
- // fields to ensure deterministic results and proper index utilization.
462
- const cursorInput = args.cursor as Record<string, unknown>;
463
- const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
464
- const take = args.take as number | undefined;
465
- const isBackward = take !== undefined && take < 0;
466
- const defaultDir = isBackward ? "DESC" : "ASC";
467
- const cursorOrderClauses = cursorEntries.map(([field]) => {
468
- const sf = getScalarFieldOrThrow({
469
- sfMap,
470
- field,
471
- modelName: modelMeta.name,
472
- context: "cursor",
473
- });
474
- return `${table}."${sf.dbName}" ${defaultDir}`;
475
- });
476
- if (cursorOrderClauses.length > 0) {
477
- orderBySql = ` ORDER BY ${cursorOrderClauses.join(", ")}`;
478
- }
479
- } else if (defaultOrderByPk) {
480
- // Default ORDER BY primary key for deterministic results
481
- const pkOrderClauses: string[] = [];
482
- for (const pkName of modelMeta.primaryKey) {
483
- const pkSf = sfMap.get(pkName);
484
- if (pkSf) {
485
- pkOrderClauses.push(`${table}."${pkSf.dbName}" ASC`);
486
- }
487
- }
488
- if (pkOrderClauses.length > 0) {
489
- orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
490
- }
491
- }
492
-
493
- // Build LIMIT / OFFSET
494
- let limitSql = "";
495
- if (take !== undefined) {
496
- paramIdx++;
497
- // take can be negative for backward cursor pagination; use absolute value
498
- const takeValue = Math.abs(take);
499
- limitSql = ` LIMIT $${paramIdx}`;
500
- allValues.push(takeValue);
501
- }
502
-
503
- let offsetSql = "";
504
- if (args.skip !== undefined) {
505
- paramIdx++;
506
- offsetSql = ` OFFSET $${paramIdx}`;
507
- allValues.push(args.skip);
508
- }
509
-
510
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
511
- const joinsPart = orderByJoins.length > 0 ? ` ${orderByJoins.join(" ")}` : "";
512
- const text = `SELECT ${distinctSql}${columnsSql} FROM ${table}${joinsPart}${wherePart}${orderBySql}${limitSql}${offsetSql}`;
513
-
514
- return { text, values: allValues };
515
- }
516
-
517
- // ─── INSERT Builder ───────────────────────────────────────────────
518
-
519
- export function buildInsertQuery(params: {
520
- modelMeta: ModelMeta;
521
- data: Record<string, unknown>;
522
- args?: Record<string, unknown>;
523
- }): SqlQuery {
524
- const { modelMeta, data, args } = params;
525
- const table = `"${modelMeta.dbName}"`;
526
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
527
-
528
- // Filter to only scalar fields that are present in data
529
- const entries: Array<{ dbName: string; value: unknown }> = [];
530
-
531
- for (const [key, value] of Object.entries(data)) {
532
- if (value === undefined) continue;
533
- const scalarField = sfMap.get(key);
534
- if (scalarField) {
535
- entries.push({ dbName: scalarField.dbName, value: wrapListValue({ value, field: scalarField }) });
536
- }
537
- }
538
-
539
- if (entries.length === 0) {
540
- // Insert with default values only
541
- const returningCols = resolveMutationReturningColumns({ modelMeta, args });
542
- return {
543
- text: `INSERT INTO ${table} DEFAULT VALUES RETURNING ${returningCols}`,
544
- values: [],
545
- };
546
- }
547
-
548
- const columns = entries.map((e) => `"${e.dbName}"`).join(", ");
549
- const placeholders = entries.map((_, i) => `$${i + 1}`).join(", ");
550
- const values = entries.map((e) => e.value);
551
-
552
- const returningCols = resolveMutationReturningColumns({ modelMeta, args });
553
-
554
- const text = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING ${returningCols}`;
555
-
556
- return { text, values };
557
- }
558
-
559
- // ─── UPDATE Builder ───────────────────────────────────────────────
560
-
561
- export function buildUpdateQuery(params: {
562
- modelMeta: ModelMeta;
563
- allModelsMeta: ModelMetaMap;
564
- where: Record<string, unknown>;
565
- data: Record<string, unknown>;
566
- args?: Record<string, unknown>;
567
- }): SqlQuery {
568
- const { modelMeta, allModelsMeta, where, data, args } = params;
569
- const table = `"${modelMeta.dbName}"`;
570
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
571
-
572
- let paramIdx = 0;
573
- const allValues: unknown[] = [];
574
- const setClauses: string[] = [];
575
-
576
- for (const [key, value] of Object.entries(data)) {
577
- if (value === undefined) continue;
578
- const scalarField = sfMap.get(key);
579
- if (!scalarField) continue;
580
-
581
- const setResult = buildSetClause({
582
- dbName: scalarField.dbName,
583
- value,
584
- paramIdx,
585
- field: scalarField,
586
- });
587
- setClauses.push(setResult.sql);
588
- allValues.push(...setResult.values);
589
- paramIdx += setResult.values.length;
590
- }
591
-
592
- // Build WHERE
593
- const whereResult = buildWhereClause({
594
- where,
595
- modelMeta,
596
- allModelsMeta,
597
- paramOffset: paramIdx,
598
- });
599
- allValues.push(...whereResult.values);
600
-
601
- const returningCols = resolveMutationReturningColumns({ modelMeta, args });
602
-
603
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
604
-
605
- // No scalar updates to apply. Avoid a no-op write and return the matched row
606
- // using a read query so callers still get update-style payload semantics.
607
- if (setClauses.length === 0) {
608
- const text = `SELECT ${returningCols} FROM ${table}${wherePart} LIMIT 1`;
609
- return { text, values: allValues };
610
- }
611
-
612
- const text = `UPDATE ${table} SET ${setClauses.join(", ")}${wherePart} RETURNING ${returningCols}`;
613
-
614
- return { text, values: allValues };
615
- }
616
-
617
- // ─── DELETE Builder ───────────────────────────────────────────────
618
-
619
- export function buildDeleteQuery(params: {
620
- modelMeta: ModelMeta;
621
- allModelsMeta: ModelMetaMap;
622
- where: Record<string, unknown>;
623
- args?: Record<string, unknown>;
624
- }): SqlQuery {
625
- const { modelMeta, allModelsMeta, where, args } = params;
626
- const table = `"${modelMeta.dbName}"`;
627
-
628
- const whereResult = buildWhereClause({
629
- where,
630
- modelMeta,
631
- allModelsMeta,
632
- paramOffset: 0,
633
- });
634
-
635
- const returningCols = resolveMutationReturningColumns({ modelMeta, args });
636
-
637
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
638
- const text = `DELETE FROM ${table}${wherePart} RETURNING ${returningCols}`;
639
-
640
- return { text, values: whereResult.values };
641
- }
642
-
643
- // ─── COUNT Builder ────────────────────────────────────────────────
644
-
645
- export function buildCountQuery(params: {
646
- modelMeta: ModelMeta;
647
- allModelsMeta: ModelMetaMap;
648
- args: Record<string, unknown>;
649
- countStrategy?: "direct" | "subquery";
650
- }): SqlQuery {
651
- const { modelMeta, allModelsMeta, args, countStrategy = "direct" } = params;
652
- const table = `"${modelMeta.dbName}"`;
653
-
654
- const operationCountStrategy = args.countStrategy;
655
- const resolvedCountStrategy =
656
- operationCountStrategy === "direct" || operationCountStrategy === "subquery"
657
- ? operationCountStrategy
658
- : countStrategy;
659
-
660
- const whereResult = buildWhereClause({
661
- where: args.where as Record<string, unknown> | undefined,
662
- modelMeta,
663
- allModelsMeta,
664
- paramOffset: 0,
665
- });
666
-
667
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
668
-
669
- if (resolvedCountStrategy === "subquery") {
670
- // Wrap in a subquery with OFFSET 0. This can produce better query plans
671
- // on serverless/proxy PostgreSQL providers where the standard COUNT(*)
672
- // path chooses a suboptimal plan. The OFFSET 0 is a no-op but prevents
673
- // PostgreSQL from flattening the subquery, which can change plan choice.
674
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
675
- const pkFieldName = modelMeta.primaryKey[0]!;
676
- const pkScalar = sfMap.get(pkFieldName);
677
- const pkDbName = pkScalar?.dbName ?? pkFieldName;
678
- const pkCol = `${table}."${pkDbName}"`;
679
- const text = `SELECT COUNT(*) AS "count" FROM (SELECT ${pkCol} FROM ${table}${wherePart} OFFSET 0) AS "sub"`;
680
- return { text, values: whereResult.values };
681
- }
682
-
683
- const text = `SELECT COUNT(*) AS "count" FROM ${table}${wherePart}`;
684
- return { text, values: whereResult.values };
685
- }
686
-
687
- // ─── Relation Query Builder ───────────────────────────────────────
688
-
689
- /**
690
- * Build a query to load related records for a batch of parent IDs.
691
- * Used for to-many relations (hybrid strategy: separate batched query).
692
- */
693
- export type RelationSqlQuery = SqlQuery & {
694
- /** When the FK column is already in the selected columns, this holds
695
- * its application-level field name so callers can use it for stitching
696
- * instead of the __vibeorm_fk alias. undefined means __vibeorm_fk is present. */
697
- fkFieldName?: string;
698
- };
699
-
700
- export function buildRelationQuery(params: {
701
- parentModelMeta: ModelMeta;
702
- relationMeta: {
703
- readonly relatedModel: string;
704
- readonly type: string;
705
- readonly fields: readonly string[];
706
- readonly references: readonly string[];
707
- readonly isList: boolean;
708
- readonly isForeignKey?: boolean;
709
- readonly relationName?: string;
710
- };
711
- relatedModelMeta: ModelMeta;
712
- parentIds: unknown[];
713
- args?: Record<string, unknown>;
714
- allModelsMeta: ModelMetaMap;
715
- defaultOrderByPk?: boolean;
716
- }): RelationSqlQuery {
717
- const {
718
- parentModelMeta,
719
- relationMeta,
720
- relatedModelMeta,
721
- parentIds,
722
- args = {},
723
- allModelsMeta,
724
- defaultOrderByPk = false,
725
- } = params;
726
-
727
- const table = `"${relatedModelMeta.dbName}"`;
728
- const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
729
-
730
- // Determine which columns to select
731
- const columns = resolveSelectColumns({
732
- modelMeta: relatedModelMeta,
733
- args,
734
- });
735
- const columnsSql = columns
736
- .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
737
- .join(", ");
738
-
739
- const allValues: unknown[] = [];
740
- let paramIdx = 0;
741
-
742
- // The FK column on the related model that references the parent
743
- let fkDbName: string;
744
- let fkFieldName: string | undefined;
745
-
746
- // Access relationName for disambiguation (multi-FK scenarios like Follow)
747
- const parentRelationName = (relationMeta as { relationName?: string }).relationName;
748
-
749
- if (relationMeta.fields.length > 0) {
750
- const reverseRelation = relatedModelMeta.relationFields.find(
751
- (r) =>
752
- r.relatedModel === parentModelMeta.name &&
753
- r.isForeignKey &&
754
- r.references.length > 0 &&
755
- (!parentRelationName || r.relationName === parentRelationName)
756
- );
757
- if (reverseRelation) {
758
- fkFieldName = reverseRelation.fields[0]!;
759
- const fkField = sfMap.get(fkFieldName);
760
- fkDbName = fkField?.dbName ?? fkFieldName;
761
- } else if (relationMeta.isForeignKey && !relationMeta.isList && relationMeta.references.length > 0) {
762
- // Parent holds the FK (many-to-one/to-one), so we query the related table
763
- // by the referenced column (typically PK) using parent FK values.
764
- fkFieldName = relationMeta.references[0]!;
765
- const refField = sfMap.get(fkFieldName);
766
- fkDbName = refField?.dbName ?? fkFieldName;
767
- } else {
768
- fkFieldName = relationMeta.fields[0]!;
769
- const localField = sfMap.get(fkFieldName);
770
- fkDbName = localField?.dbName ?? fkFieldName;
771
- }
772
- } else {
773
- const reverseRelation = relatedModelMeta.relationFields.find(
774
- (r) =>
775
- r.relatedModel === parentModelMeta.name &&
776
- r.isForeignKey &&
777
- (!parentRelationName || r.relationName === parentRelationName)
778
- );
779
- if (reverseRelation) {
780
- fkFieldName = reverseRelation.fields[0]!;
781
- const fkField = sfMap.get(fkFieldName);
782
- fkDbName = fkField?.dbName ?? fkFieldName;
783
- } else {
784
- fkDbName = `${parentModelMeta.name.toLowerCase()}Id`;
785
- }
786
- }
787
-
788
- // Only add __vibeorm_fk alias if the FK column isn't already in the selected columns.
789
- // This avoids transferring the same column value twice per row.
790
- const fkAlreadySelected = columns.some((c) => c.dbName === fkDbName);
791
- const fkSelectSql = fkAlreadySelected
792
- ? ""
793
- : `, ${table}."${fkDbName}" AS "__vibeorm_fk"`;
794
-
795
- paramIdx++;
796
- allValues.push(new PgArray(parentIds));
797
-
798
- let whereSql = `${table}."${fkDbName}" = ANY($${paramIdx})`;
799
-
800
- // Add any additional where from nested args
801
- if (args.where) {
802
- const subWhere = buildWhereClause({
803
- where: args.where as Record<string, unknown>,
804
- modelMeta: relatedModelMeta,
805
- allModelsMeta,
806
- paramOffset: paramIdx,
807
- });
808
- if (subWhere.sql) {
809
- whereSql += ` AND (${subWhere.sql})`;
810
- allValues.push(...subWhere.values);
811
- paramIdx += subWhere.values.length;
812
- }
813
- }
814
-
815
- // Build order clauses once (shared between ORDER BY and ROW_NUMBER window)
816
- const orderClauses = buildSimpleOrderClauses({
817
- orderBy: args.orderBy,
818
- sfMap,
819
- tableRef: table,
820
- modelName: relatedModelMeta.name,
821
- });
822
-
823
- // Per-parent LIMIT/OFFSET using ROW_NUMBER() window function
824
- if (args.take !== undefined || args.skip !== undefined) {
825
- let windowOrderBy = orderClauses.length > 0 ? orderClauses.join(", ") : "";
826
- if (!windowOrderBy) {
827
- const pk = relatedModelMeta.primaryKey[0];
828
- if (pk) {
829
- const pkScalar = sfMap.get(pk);
830
- windowOrderBy = `${table}."${pkScalar?.dbName ?? pk}" ASC`;
831
- } else {
832
- windowOrderBy = "1";
833
- }
834
- }
835
-
836
- const fkSelectInner = fkAlreadySelected
837
- ? ""
838
- : `, ${table}."${fkDbName}" AS "__vibeorm_fk"`;
839
- // ORDER BY is already captured in the ROW_NUMBER() OVER clause; omit the
840
- // redundant trailing ORDER BY to avoid an extra sort node in the plan.
841
- const innerSql = `SELECT ${columnsSql}${fkSelectInner}, ROW_NUMBER() OVER (PARTITION BY ${table}."${fkDbName}" ORDER BY ${windowOrderBy}) AS "__vibeorm_rn" FROM ${table} WHERE ${whereSql}`;
842
-
843
- // Normalize negative take (Prisma allows negative for backward pagination)
844
- const normalizedTake = args.take !== undefined ? Math.abs(args.take as number) : undefined;
845
- const normalizedSkip = args.skip !== undefined ? Math.max(0, args.skip as number) : undefined;
846
-
847
- // Build compound WHERE condition on __vibeorm_rn
848
- const rnConditions: string[] = [];
849
- if (normalizedSkip !== undefined) {
850
- paramIdx++;
851
- allValues.push(normalizedSkip);
852
- rnConditions.push(`__ranked."__vibeorm_rn" > $${paramIdx}`);
853
- }
854
- if (normalizedTake !== undefined) {
855
- paramIdx++;
856
- if (normalizedSkip !== undefined) {
857
- // upper bound = skip + take
858
- allValues.push(normalizedSkip + normalizedTake);
859
- } else {
860
- allValues.push(normalizedTake);
861
- }
862
- rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
863
- }
864
-
865
- const fkOrderExpr = fkAlreadySelected && fkFieldName
866
- ? `__ranked."${fkFieldName}"`
867
- : `__ranked."__vibeorm_fk"`;
868
- const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")} ORDER BY ${fkOrderExpr} ASC, __ranked."__vibeorm_rn" ASC`;
869
-
870
- return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
871
- }
872
-
873
- // Add PK fallback ORDER BY when defaultOrderByPk is enabled and no explicit orderBy
874
- let finalOrderClauses = orderClauses;
875
- if (finalOrderClauses.length === 0 && defaultOrderByPk) {
876
- const pkOrderClauses: string[] = [];
877
- for (const pkName of relatedModelMeta.primaryKey) {
878
- const pkSf = sfMap.get(pkName);
879
- if (pkSf) {
880
- pkOrderClauses.push(`${table}."${pkSf.dbName}" ASC`);
881
- }
882
- }
883
- finalOrderClauses = pkOrderClauses;
884
- }
885
- const orderBySql = finalOrderClauses.length > 0 ? ` ORDER BY ${finalOrderClauses.join(", ")}` : "";
886
- const text = `SELECT ${columnsSql}${fkSelectSql} FROM ${table} WHERE ${whereSql}${orderBySql}`;
887
-
888
- return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
889
- }
890
-
891
- // ─── Many-to-Many Relation Query Builder ──────────────────────────
892
-
893
- /**
894
- * Build a query to load M:N related records via an implicit join table.
895
- * E.g., Post.tags via "_PostToTag" join table.
896
- */
897
- export function buildManyToManyQuery(params: {
898
- parentModelMeta: ModelMeta;
899
- relationMeta: {
900
- readonly relatedModel: string;
901
- readonly type: string;
902
- readonly isList: boolean;
903
- readonly joinTable?: string;
904
- readonly relationName?: string;
905
- };
906
- relatedModelMeta: ModelMeta;
907
- parentIds: unknown[];
908
- args?: Record<string, unknown>;
909
- allModelsMeta: ModelMetaMap;
910
- defaultOrderByPk?: boolean;
911
- }): RelationSqlQuery {
912
- const {
913
- parentModelMeta,
914
- relationMeta,
915
- relatedModelMeta,
916
- parentIds,
917
- args = {},
918
- allModelsMeta,
919
- defaultOrderByPk = false,
920
- } = params;
921
-
922
- const joinTable = `"${(relationMeta as { joinTable?: string }).joinTable}"`;
923
- const relatedTable = `"${relatedModelMeta.dbName}"`;
924
- const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
925
-
926
- // Determine A vs B: alphabetical model name order
927
- const sorted = [parentModelMeta.name, relatedModelMeta.name].sort();
928
- const parentIsA = parentModelMeta.name === sorted[0];
929
- const parentCol = parentIsA ? "A" : "B";
930
- const relatedCol = parentIsA ? "B" : "A";
931
-
932
- // Determine which columns to select from the related model
933
- const columns = resolveSelectColumns({
934
- modelMeta: relatedModelMeta,
935
- args,
936
- });
937
- const columnsSql = columns
938
- .map((c) => `${relatedTable}."${c.dbName}" AS "${c.name}"`)
939
- .join(", ");
940
-
941
- const allValues: unknown[] = [];
942
- let paramIdx = 0;
943
-
944
- // Build = ANY clause for parent IDs (stable SQL shape for prepared statement caching)
945
- paramIdx++;
946
- allValues.push(new PgArray(parentIds));
947
-
948
- // Related model PK (typically "id")
949
- const relatedPk = relatedModelMeta.primaryKey[0];
950
- const relatedPkSf = relatedPk ? sfMap.get(relatedPk) : undefined;
951
- const relatedPkDb = relatedPkSf?.dbName ?? relatedPk ?? "id";
952
-
953
- let whereSql = `${joinTable}."${parentCol}" = ANY($${paramIdx})`;
954
-
955
- // Add any additional where from nested args
956
- if (args.where) {
957
- const subWhere = buildWhereClause({
958
- where: args.where as Record<string, unknown>,
959
- modelMeta: relatedModelMeta,
960
- allModelsMeta,
961
- paramOffset: paramIdx,
962
- });
963
- if (subWhere.sql) {
964
- whereSql += ` AND (${subWhere.sql})`;
965
- allValues.push(...subWhere.values);
966
- paramIdx += subWhere.values.length;
967
- }
968
- }
969
-
970
- // Build order clauses once (shared between ORDER BY and ROW_NUMBER window)
971
- const m2mOrderClauses = buildSimpleOrderClauses({
972
- orderBy: args.orderBy,
973
- sfMap,
974
- tableRef: relatedTable,
975
- modelName: relatedModelMeta.name,
976
- });
977
-
978
- // LIMIT / take + skip support via ROW_NUMBER
979
- if (args.take !== undefined || args.skip !== undefined) {
980
- let windowOrderBy = m2mOrderClauses.length > 0 ? m2mOrderClauses.join(", ") : "";
981
- if (!windowOrderBy) {
982
- windowOrderBy = `${relatedTable}."${relatedPkDb}" ASC`;
983
- }
984
-
985
- // ORDER BY is already captured in the ROW_NUMBER() OVER clause; omit the
986
- // redundant trailing ORDER BY to avoid an extra sort node in the plan.
987
- 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}`;
988
-
989
- // Normalize negative take (Prisma allows negative for backward pagination)
990
- const normalizedTake = args.take !== undefined ? Math.abs(args.take as number) : undefined;
991
- const normalizedSkip = args.skip !== undefined ? Math.max(0, args.skip as number) : undefined;
992
-
993
- const rnConditions: string[] = [];
994
- if (normalizedSkip !== undefined) {
995
- paramIdx++;
996
- allValues.push(normalizedSkip);
997
- rnConditions.push(`__ranked."__vibeorm_rn" > $${paramIdx}`);
998
- }
999
- if (normalizedTake !== undefined) {
1000
- paramIdx++;
1001
- if (normalizedSkip !== undefined) {
1002
- allValues.push(normalizedSkip + normalizedTake);
1003
- } else {
1004
- allValues.push(normalizedTake);
1005
- }
1006
- rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
1007
- }
1008
-
1009
- const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")} ORDER BY __ranked."__vibeorm_fk" ASC, __ranked."__vibeorm_rn" ASC`;
1010
- return { text, values: allValues };
1011
- }
1012
-
1013
- // Add PK fallback ORDER BY when defaultOrderByPk is enabled and no explicit orderBy
1014
- let finalM2mOrderClauses = m2mOrderClauses;
1015
- if (finalM2mOrderClauses.length === 0 && defaultOrderByPk) {
1016
- finalM2mOrderClauses = [`${relatedTable}."${relatedPkDb}" ASC`];
1017
- }
1018
- const m2mOrderBySql = finalM2mOrderClauses.length > 0 ? ` ORDER BY ${finalM2mOrderClauses.join(", ")}` : "";
1019
- const text = `SELECT ${columnsSql}, ${joinTable}."${parentCol}" AS "__vibeorm_fk" FROM ${relatedTable} INNER JOIN ${joinTable} ON ${joinTable}."${relatedCol}" = ${relatedTable}."${relatedPkDb}" WHERE ${whereSql}${m2mOrderBySql}`;
1020
-
1021
- return { text, values: allValues };
1022
- }
1023
-
1024
- // ─── Shared ORDER BY Helper ───────────────────────────────────────
1025
-
1026
- /**
1027
- * Build ORDER BY clauses from a Prisma-style orderBy arg for a single table.
1028
- * Returns an array of SQL clause strings like `"Table"."col" ASC`.
1029
- */
1030
- function buildSimpleOrderClauses(params: {
1031
- orderBy: unknown;
1032
- sfMap: ReadonlyMap<string, ScalarFieldMeta>;
1033
- tableRef: string;
1034
- modelName: string;
1035
- }): string[] {
1036
- const { orderBy, sfMap, tableRef, modelName } = params;
1037
- if (!orderBy) return [];
1038
-
1039
- const orderByItems = Array.isArray(orderBy)
1040
- ? (orderBy as Record<string, string>[])
1041
- : [orderBy as Record<string, string>];
1042
-
1043
- return orderByItems.flatMap((item) =>
1044
- Object.entries(item).map(([field, direction]) => {
1045
- if (typeof direction !== "string") {
1046
- throw new Error(
1047
- `Unsupported orderBy direction for field "${field}" on model "${modelName}"`
1048
- );
1049
- }
1050
- const scalarField = getScalarFieldOrThrow({
1051
- sfMap,
1052
- field,
1053
- modelName,
1054
- context: "orderBy",
1055
- });
1056
- return `${tableRef}."${scalarField.dbName}" ${sanitizeDirection({ direction })}`;
1057
- })
1058
- );
1059
- }
1060
-
1061
- // ─── INSERT MANY Builder ──────────────────────────────────────────
1062
-
1063
- /**
1064
- * Build a multi-row INSERT query.
1065
- * Produces: INSERT INTO "Table" ("col1", "col2") VALUES ($1, $2), ($3, $4), ...
1066
- *
1067
- * Handles varying keys across records by computing the union of all keys
1068
- * and using DEFAULT for missing ones.
1069
- */
1070
- export function buildInsertManyQuery(params: {
1071
- modelMeta: ModelMeta;
1072
- data: Record<string, unknown>[];
1073
- skipDuplicates?: boolean;
1074
- returning?: boolean;
1075
- selectFields?: string[];
1076
- }): SqlQuery {
1077
- const { modelMeta, data, skipDuplicates = false, returning = false, selectFields } = params;
1078
- const table = `"${modelMeta.dbName}"`;
1079
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1080
-
1081
- if (data.length === 0) {
1082
- // Empty data — return a no-op that returns count 0
1083
- return { text: `SELECT 0 AS "count"`, values: [] };
1084
- }
1085
-
1086
- // Compute the union of all keys across all records, filtered to scalar fields
1087
- const allKeys = new Set<string>();
1088
- for (const record of data) {
1089
- for (const key of Object.keys(record)) {
1090
- if (record[key] === undefined) continue;
1091
- if (sfMap.has(key)) {
1092
- allKeys.add(key);
1093
- }
1094
- }
1095
- }
1096
-
1097
- // Sort keys alphabetically to produce stable SQL text regardless of object
1098
- // key insertion order. This improves prepared statement cache hit rates.
1099
- const orderedKeys = [...allKeys].sort();
1100
-
1101
- if (orderedKeys.length === 0) {
1102
- // All records have empty data — insert defaults for each row
1103
- // PostgreSQL doesn't support multi-row DEFAULT VALUES, so use a union
1104
- const unionParts = data.map(() => `SELECT`);
1105
- // Actually just use single inserts... or use a CTE
1106
- // Simpler: use VALUES with explicit DEFAULT keywords
1107
- // PostgreSQL supports: INSERT INTO t DEFAULT VALUES (but only one row)
1108
- // For multiple: INSERT INTO t (col) VALUES (DEFAULT), (DEFAULT), ...
1109
- // We need at least one column. Use the PK column.
1110
- const pkField = modelMeta.scalarFields[0]!;
1111
- const col = `"${pkField.dbName}"`;
1112
- const valueRows = data.map(() => `(DEFAULT)`).join(", ");
1113
- const returningClause = returning
1114
- ? ` RETURNING ${resolveReturningColumns({ modelMeta, selectFields })}`
1115
- : "";
1116
- const conflictClause = skipDuplicates ? ` ON CONFLICT DO NOTHING` : "";
1117
- return {
1118
- text: `INSERT INTO ${table} (${col}) VALUES ${valueRows}${conflictClause}${returningClause}`,
1119
- values: [],
1120
- };
1121
- }
1122
-
1123
- // Map keys to DB column names (keep field ref for isList wrapping)
1124
- const columnMap = orderedKeys.map((key) => {
1125
- const scalarField = sfMap.get(key)!;
1126
- return { key, dbName: scalarField.dbName, field: scalarField };
1127
- });
1128
-
1129
- const columns = columnMap.map((c) => `"${c.dbName}"`).join(", ");
1130
-
1131
- // Build VALUES rows
1132
- const allValues: unknown[] = [];
1133
- let paramIdx = 0;
1134
- const valueRows: string[] = [];
1135
-
1136
- for (const record of data) {
1137
- const placeholders: string[] = [];
1138
- for (const { key, field } of columnMap) {
1139
- const value = record[key];
1140
- if (value === undefined) {
1141
- placeholders.push("DEFAULT");
1142
- } else {
1143
- paramIdx++;
1144
- placeholders.push(`$${paramIdx}`);
1145
- allValues.push(wrapListValue({ value, field }));
1146
- }
1147
- }
1148
- valueRows.push(`(${placeholders.join(", ")})`);
1149
- }
1150
-
1151
- const conflictClause = skipDuplicates ? ` ON CONFLICT DO NOTHING` : "";
1152
- const returningClause = returning
1153
- ? ` RETURNING ${resolveReturningColumns({ modelMeta, selectFields })}`
1154
- : "";
1155
-
1156
- const text = `INSERT INTO ${table} (${columns}) VALUES ${valueRows.join(", ")}${conflictClause}${returningClause}`;
1157
-
1158
- return { text, values: allValues };
1159
- }
1160
-
1161
- /**
1162
- * Resolve RETURNING columns for insert many queries.
1163
- */
1164
- function resolveReturningColumns(params: {
1165
- modelMeta: ModelMeta;
1166
- selectFields?: string[];
1167
- }): string {
1168
- const { modelMeta, selectFields } = params;
1169
-
1170
- if (selectFields && selectFields.length > 0) {
1171
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1172
- return selectFields
1173
- .map((name) => {
1174
- const sf = getScalarFieldOrThrow({
1175
- sfMap,
1176
- field: name,
1177
- modelName: modelMeta.name,
1178
- context: "createMany.select",
1179
- });
1180
- return `"${sf.dbName}" AS "${sf.name}"`;
1181
- })
1182
- .join(", ");
1183
- }
1184
-
1185
- return modelMeta.scalarFields
1186
- .map((f) => `"${f.dbName}" AS "${f.name}"`)
1187
- .join(", ");
1188
- }
1189
-
1190
- // ─── UPDATE MANY Builder ──────────────────────────────────────────
1191
-
1192
- /**
1193
- * Build an UPDATE query without RETURNING (for updateMany).
1194
- * Uses a subquery COUNT trick to return the affected row count.
1195
- */
1196
- export function buildUpdateManyQuery(params: {
1197
- modelMeta: ModelMeta;
1198
- allModelsMeta: ModelMetaMap;
1199
- where: Record<string, unknown>;
1200
- data: Record<string, unknown>;
1201
- }): SqlQuery {
1202
- const { modelMeta, allModelsMeta, where, data } = params;
1203
- const table = `"${modelMeta.dbName}"`;
1204
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1205
-
1206
- let paramIdx = 0;
1207
- const allValues: unknown[] = [];
1208
- const setClauses: string[] = [];
1209
-
1210
- for (const [key, value] of Object.entries(data)) {
1211
- if (value === undefined) continue;
1212
- const scalarField = sfMap.get(key);
1213
- if (!scalarField) continue;
1214
-
1215
- const setResult = buildSetClause({
1216
- dbName: scalarField.dbName,
1217
- value,
1218
- paramIdx,
1219
- field: scalarField,
1220
- });
1221
- setClauses.push(setResult.sql);
1222
- allValues.push(...setResult.values);
1223
- paramIdx += setResult.values.length;
1224
- }
1225
-
1226
- if (setClauses.length === 0) {
1227
- return { text: `SELECT 0 AS "count"`, values: [] };
1228
- }
1229
-
1230
- // Build WHERE
1231
- const whereResult = buildWhereClause({
1232
- where,
1233
- modelMeta,
1234
- allModelsMeta,
1235
- paramOffset: paramIdx,
1236
- });
1237
- allValues.push(...whereResult.values);
1238
-
1239
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
1240
- const text = `UPDATE ${table} SET ${setClauses.join(", ")}${wherePart}`;
1241
-
1242
- return { text, values: allValues };
1243
- }
1244
-
1245
- // ─── AGGREGATE Builder ────────────────────────────────────────────
1246
-
1247
- /**
1248
- * Build an aggregate query with _count, _avg, _sum, _min, _max.
1249
- */
1250
- export function buildAggregateQuery(params: {
1251
- modelMeta: ModelMeta;
1252
- allModelsMeta: ModelMetaMap;
1253
- args: Record<string, unknown>;
1254
- }): SqlQuery {
1255
- const { modelMeta, allModelsMeta, args } = params;
1256
- const table = `"${modelMeta.dbName}"`;
1257
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1258
-
1259
- const selectParts: string[] = [];
1260
-
1261
- // _count
1262
- const countArg = args._count;
1263
- if (countArg === true) {
1264
- selectParts.push(`COUNT(*) AS "_count__all"`);
1265
- } else if (typeof countArg === "object" && countArg !== null) {
1266
- const countFields = countArg as Record<string, boolean>;
1267
- if (countFields._all) {
1268
- selectParts.push(`COUNT(*) AS "_count__all"`);
1269
- }
1270
- for (const [field, enabled] of Object.entries(countFields)) {
1271
- if (field === "_all" || !enabled) continue;
1272
- const sf = getScalarFieldOrThrow({
1273
- sfMap,
1274
- field,
1275
- modelName: modelMeta.name,
1276
- context: "aggregate._count",
1277
- });
1278
- const col = sf.dbName;
1279
- selectParts.push(`COUNT("${col}") AS "_count__${field}"`);
1280
- }
1281
- }
1282
-
1283
- // _avg, _sum, _min, _max
1284
- for (const aggFn of ["_avg", "_sum", "_min", "_max"] as const) {
1285
- const aggArg = args[aggFn];
1286
- if (typeof aggArg === "object" && aggArg !== null) {
1287
- const sqlFn = getAggregateSqlFnOrThrow({ aggregateKey: aggFn });
1288
- const fields = aggArg as Record<string, boolean>;
1289
- for (const [field, enabled] of Object.entries(fields)) {
1290
- if (!enabled) continue;
1291
- const sf = getScalarFieldOrThrow({
1292
- sfMap,
1293
- field,
1294
- modelName: modelMeta.name,
1295
- context: aggFn,
1296
- });
1297
- const col = sf.dbName;
1298
- selectParts.push(`${sqlFn}("${col}") AS "${aggFn}__${field}"`);
1299
- }
1300
- }
1301
- }
1302
-
1303
- if (selectParts.length === 0) {
1304
- // No aggregation requested — just count all
1305
- selectParts.push(`COUNT(*) AS "_count__all"`);
1306
- }
1307
-
1308
- // Build WHERE
1309
- const whereResult = buildWhereClause({
1310
- where: args.where as Record<string, unknown> | undefined,
1311
- modelMeta,
1312
- allModelsMeta,
1313
- paramOffset: 0,
1314
- });
1315
-
1316
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
1317
- const text = `SELECT ${selectParts.join(", ")} FROM ${table}${wherePart}`;
1318
-
1319
- return { text, values: whereResult.values };
1320
- }
1321
-
1322
- // ─── GROUP BY Builder ─────────────────────────────────────────────
1323
-
1324
- /**
1325
- * Build a GROUP BY query with aggregate functions and HAVING clause.
1326
- */
1327
- export function buildGroupByQuery(params: {
1328
- modelMeta: ModelMeta;
1329
- allModelsMeta: ModelMetaMap;
1330
- args: Record<string, unknown>;
1331
- }): SqlQuery {
1332
- const { modelMeta, allModelsMeta, args } = params;
1333
- const table = `"${modelMeta.dbName}"`;
1334
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1335
-
1336
- const by = args.by as string[];
1337
- if (!by || by.length === 0) {
1338
- throw new Error("groupBy requires a non-empty 'by' array");
1339
- }
1340
-
1341
- // Group-by columns
1342
- const groupColumns = by.map((field) => {
1343
- const sf = getScalarFieldOrThrow({
1344
- sfMap,
1345
- field,
1346
- modelName: modelMeta.name,
1347
- context: "groupBy.by",
1348
- });
1349
- const col = sf.dbName;
1350
- return { field, col, dbName: col };
1351
- });
1352
-
1353
- const selectParts: string[] = groupColumns.map(
1354
- (g) => `${table}."${g.col}" AS "${g.field}"`
1355
- );
1356
-
1357
- // Aggregate functions
1358
- const countArg = args._count;
1359
- if (countArg === true) {
1360
- selectParts.push(`COUNT(*) AS "_count__all"`);
1361
- } else if (typeof countArg === "object" && countArg !== null) {
1362
- const countFields = countArg as Record<string, boolean>;
1363
- if (countFields._all) {
1364
- selectParts.push(`COUNT(*) AS "_count__all"`);
1365
- }
1366
- for (const [field, enabled] of Object.entries(countFields)) {
1367
- if (field === "_all" || !enabled) continue;
1368
- const sf = getScalarFieldOrThrow({
1369
- sfMap,
1370
- field,
1371
- modelName: modelMeta.name,
1372
- context: "groupBy._count",
1373
- });
1374
- const col = sf.dbName;
1375
- selectParts.push(`COUNT("${col}") AS "_count__${field}"`);
1376
- }
1377
- }
1378
-
1379
- for (const aggFn of ["_avg", "_sum", "_min", "_max"] as const) {
1380
- const aggArg = args[aggFn];
1381
- if (typeof aggArg === "object" && aggArg !== null) {
1382
- const sqlFn = getAggregateSqlFnOrThrow({ aggregateKey: aggFn });
1383
- const fields = aggArg as Record<string, boolean>;
1384
- for (const [field, enabled] of Object.entries(fields)) {
1385
- if (!enabled) continue;
1386
- const sf = getScalarFieldOrThrow({
1387
- sfMap,
1388
- field,
1389
- modelName: modelMeta.name,
1390
- context: aggFn,
1391
- });
1392
- const col = sf.dbName;
1393
- selectParts.push(`${sqlFn}("${col}") AS "${aggFn}__${field}"`);
1394
- }
1395
- }
1396
- }
1397
-
1398
- // Build WHERE
1399
- const whereResult = buildWhereClause({
1400
- where: args.where as Record<string, unknown> | undefined,
1401
- modelMeta,
1402
- allModelsMeta,
1403
- paramOffset: 0,
1404
- });
1405
- let paramIdx = whereResult.values.length;
1406
- const allValues = [...whereResult.values];
1407
-
1408
- const wherePart = whereResult.sql ? ` WHERE ${whereResult.sql}` : "";
1409
-
1410
- // GROUP BY
1411
- const groupBySql = groupColumns
1412
- .map((g) => `${table}."${g.col}"`)
1413
- .join(", ");
1414
-
1415
- // HAVING
1416
- let havingSql = "";
1417
- if (args.having) {
1418
- const havingResult = buildHavingClause({
1419
- having: args.having as Record<string, unknown>,
1420
- modelMeta,
1421
- paramOffset: paramIdx,
1422
- });
1423
- if (havingResult.sql) {
1424
- havingSql = ` HAVING ${havingResult.sql}`;
1425
- allValues.push(...havingResult.values);
1426
- paramIdx += havingResult.values.length;
1427
- }
1428
- }
1429
-
1430
- // ORDER BY
1431
- let orderBySql = "";
1432
- if (args.orderBy) {
1433
- const orderByItems = Array.isArray(args.orderBy)
1434
- ? (args.orderBy as Record<string, string>[])
1435
- : [args.orderBy as Record<string, string>];
1436
-
1437
- const orderClauses = orderByItems.flatMap((item) =>
1438
- Object.entries(item).map(([field, direction]) => {
1439
- // Support ordering by aggregate: { _count: { id: "asc" } }
1440
- if (field.startsWith("_") && typeof direction === "object" && direction !== null) {
1441
- const sqlFn = getAggregateSqlFnOrThrow({ aggregateKey: field });
1442
- const aggEntries = Object.entries(direction as Record<string, string>);
1443
- return aggEntries.map(([aggField, aggDir]) => {
1444
- if (typeof aggDir !== "string") {
1445
- throw new Error(
1446
- `Unsupported orderBy direction for aggregate field "${field}.${aggField}" on model "${modelMeta.name}"`
1447
- );
1448
- }
1449
- if (field === "_count" && aggField === "_all") {
1450
- return `COUNT(*) ${sanitizeDirection({ direction: aggDir })}`;
1451
- }
1452
- const sf = getScalarFieldOrThrow({
1453
- sfMap,
1454
- field: aggField,
1455
- modelName: modelMeta.name,
1456
- context: `${field}.orderBy`,
1457
- });
1458
- const col = sf.dbName;
1459
- return `${sqlFn}("${col}") ${sanitizeDirection({ direction: aggDir })}`;
1460
- }).join(", ");
1461
- }
1462
- if (typeof direction !== "string") {
1463
- throw new Error(
1464
- `Unsupported orderBy direction for field "${field}" on model "${modelMeta.name}"`
1465
- );
1466
- }
1467
- const sf = getScalarFieldOrThrow({
1468
- sfMap,
1469
- field,
1470
- modelName: modelMeta.name,
1471
- context: "groupBy.orderBy",
1472
- });
1473
- const col = sf.dbName;
1474
- return `${table}."${col}" ${sanitizeDirection({ direction })}`;
1475
- })
1476
- );
1477
-
1478
- if (orderClauses.length > 0) {
1479
- orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
1480
- }
1481
- }
1482
-
1483
- // LIMIT / OFFSET
1484
- let limitSql = "";
1485
- if (args.take !== undefined) {
1486
- paramIdx++;
1487
- limitSql = ` LIMIT $${paramIdx}`;
1488
- allValues.push(args.take);
1489
- }
1490
-
1491
- let offsetSql = "";
1492
- if (args.skip !== undefined) {
1493
- paramIdx++;
1494
- offsetSql = ` OFFSET $${paramIdx}`;
1495
- allValues.push(args.skip);
1496
- }
1497
-
1498
- const text = `SELECT ${selectParts.join(", ")} FROM ${table}${wherePart} GROUP BY ${groupBySql}${havingSql}${orderBySql}${limitSql}${offsetSql}`;
1499
-
1500
- return { text, values: allValues };
1501
- }
1502
-
1503
- // ─── HAVING Clause Builder ────────────────────────────────────────
1504
-
1505
- /**
1506
- * Build a HAVING clause from Prisma-style having input.
1507
- * Supports: { field: { _avg: { gt: 10 }, _count: { gte: 5 } } }
1508
- */
1509
- function buildHavingClause(params: {
1510
- having: Record<string, unknown>;
1511
- modelMeta: ModelMeta;
1512
- paramOffset: number;
1513
- }): { sql: string; values: unknown[] } {
1514
- const { having, modelMeta, paramOffset } = params;
1515
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1516
- const conditions: string[] = [];
1517
- const values: unknown[] = [];
1518
- let paramIdx = paramOffset;
1519
-
1520
- for (const [field, filter] of Object.entries(having)) {
1521
- if (filter === undefined) continue;
1522
- if (typeof filter !== "object" || filter === null) continue;
1523
-
1524
- const filterObj = filter as Record<string, unknown>;
1525
-
1526
- for (const [aggFn, comparison] of Object.entries(filterObj)) {
1527
- if (!aggFn.startsWith("_") || typeof comparison !== "object" || comparison === null) continue;
1528
-
1529
- const sqlFn = getAggregateSqlFnOrThrow({ aggregateKey: aggFn });
1530
- const sf = getScalarFieldOrThrow({
1531
- sfMap,
1532
- field,
1533
- modelName: modelMeta.name,
1534
- context: "having",
1535
- });
1536
- const col = sf.dbName;
1537
-
1538
- const compObj = comparison as Record<string, unknown>;
1539
- for (const [op, val] of Object.entries(compObj)) {
1540
- if (val === undefined) continue;
1541
- paramIdx++;
1542
- values.push(val);
1543
-
1544
- let sqlOp: string;
1545
- switch (op) {
1546
- case "equals": sqlOp = "="; break;
1547
- case "not": sqlOp = "!="; break;
1548
- case "lt": sqlOp = "<"; break;
1549
- case "lte": sqlOp = "<="; break;
1550
- case "gt": sqlOp = ">"; break;
1551
- case "gte": sqlOp = ">="; break;
1552
- default: continue;
1553
- }
1554
-
1555
- conditions.push(`${sqlFn}("${col}") ${sqlOp} $${paramIdx}`);
1556
- }
1557
- }
1558
- }
1559
-
1560
- return {
1561
- sql: conditions.join(" AND "),
1562
- values,
1563
- };
1564
- }
1565
-
1566
- // ─── SET Clause Builder (atomic ops + list ops) ──────────────────
1567
-
1568
- /**
1569
- * Build a single SET clause for an UPDATE statement.
1570
- * Handles:
1571
- * - Plain values: "col" = $N
1572
- * - Atomic number ops: { increment: N } → "col" = "col" + $N
1573
- * - Scalar list ops: { set: [...] } → "col" = $N, { push: v } → "col" = array_append("col", $N)
1574
- */
1575
- function buildSetClause(params: {
1576
- dbName: string;
1577
- value: unknown;
1578
- paramIdx: number;
1579
- field: ScalarFieldMeta;
1580
- /** Optional table prefix for qualifying column self-references (e.g. in ON CONFLICT DO UPDATE SET) */
1581
- tablePrefix?: string;
1582
- }): { sql: string; values: unknown[] } {
1583
- const { dbName, value, paramIdx, field, tablePrefix } = params;
1584
- let idx = paramIdx;
1585
- // Qualified column reference for the RHS (e.g. "Product"."price" in upsert context)
1586
- const colRef = tablePrefix ? `${tablePrefix}."${dbName}"` : `"${dbName}"`;
1587
-
1588
- // Check for atomic number operations: { increment, decrement, multiply, divide, set }
1589
- if (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
1590
- const ops = value as Record<string, unknown>;
1591
-
1592
- if (ops.increment !== undefined) {
1593
- idx++;
1594
- return { sql: `"${dbName}" = ${colRef} + $${idx}`, values: [ops.increment] };
1595
- }
1596
- if (ops.decrement !== undefined) {
1597
- idx++;
1598
- return { sql: `"${dbName}" = ${colRef} - $${idx}`, values: [ops.decrement] };
1599
- }
1600
- if (ops.multiply !== undefined) {
1601
- idx++;
1602
- return { sql: `"${dbName}" = ${colRef} * $${idx}`, values: [ops.multiply] };
1603
- }
1604
- if (ops.divide !== undefined) {
1605
- idx++;
1606
- return { sql: `"${dbName}" = ${colRef} / $${idx}`, values: [ops.divide] };
1607
- }
1608
-
1609
- // Scalar list operations: { set: [...] } or { push: value }
1610
- if ("push" in ops) {
1611
- const pushVal = ops.push;
1612
- if (Array.isArray(pushVal)) {
1613
- // Push multiple: "col" = "col" || $N
1614
- idx++;
1615
- return { sql: `"${dbName}" = ${colRef} || $${idx}`, values: [wrapListValue({ value: pushVal, field })] };
1616
- } else {
1617
- // Push single: "col" = array_append("col", $N)
1618
- idx++;
1619
- return { sql: `"${dbName}" = array_append(${colRef}, $${idx})`, values: [pushVal] };
1620
- }
1621
- }
1622
-
1623
- if ("set" in ops) {
1624
- idx++;
1625
- return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value: ops.set, field })] };
1626
- }
1627
- }
1628
-
1629
- // Plain value (wrap if scalar list)
1630
- idx++;
1631
- return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value, field })] };
1632
- }
1633
-
1634
- // ─── UPSERT Builder (INSERT ... ON CONFLICT) ─────────────────────
1635
-
1636
- /**
1637
- * Build an atomic upsert query using INSERT ... ON CONFLICT DO UPDATE SET.
1638
- * This is race-condition-free unlike the findUnique + create/update approach.
1639
- */
1640
- export function buildUpsertQuery(params: {
1641
- modelMeta: ModelMeta;
1642
- where: Record<string, unknown>;
1643
- create: Record<string, unknown>;
1644
- update: Record<string, unknown>;
1645
- args?: Record<string, unknown>;
1646
- }): SqlQuery {
1647
- const { modelMeta, where, create, update, args } = params;
1648
- const table = `"${modelMeta.dbName}"`;
1649
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1650
-
1651
- // Determine conflict columns from where clause (unique fields)
1652
- const conflictCols: string[] = [];
1653
- for (const key of Object.keys(where)) {
1654
- const sf = sfMap.get(key);
1655
- if (sf) {
1656
- conflictCols.push(`"${sf.dbName}"`);
1657
- }
1658
- }
1659
-
1660
- // If no conflict columns, fallback to primary key
1661
- if (conflictCols.length === 0) {
1662
- for (const pk of modelMeta.primaryKey) {
1663
- const sf = sfMap.get(pk);
1664
- conflictCols.push(`"${sf?.dbName ?? pk}"`);
1665
- }
1666
- }
1667
-
1668
- // Merge where unique values into create data so the conflict target column
1669
- // is always present in the INSERT. Without this, upsert({ where: { email: "x" },
1670
- // create: { name: "Y" }, ... }) would omit `email` from the INSERT row.
1671
- const mergedCreate = { ...create };
1672
- for (const [key, value] of Object.entries(where)) {
1673
- if (value === undefined || value === null) continue;
1674
- // Only merge scalar (non-object) values — skip nested where objects like { equals: ... }
1675
- if (typeof value !== "object" && mergedCreate[key] === undefined) {
1676
- mergedCreate[key] = value;
1677
- }
1678
- }
1679
-
1680
- // Build INSERT part from merged create data
1681
- const insertEntries: Array<{ dbName: string; value: unknown }> = [];
1682
- for (const [key, value] of Object.entries(mergedCreate)) {
1683
- if (value === undefined) continue;
1684
- const sf = sfMap.get(key);
1685
- if (sf) {
1686
- insertEntries.push({ dbName: sf.dbName, value: wrapListValue({ value, field: sf }) });
1687
- }
1688
- }
1689
-
1690
- let paramIdx = 0;
1691
- const allValues: unknown[] = [];
1692
-
1693
- const insertColumns = insertEntries.map((e) => `"${e.dbName}"`).join(", ");
1694
- const insertPlaceholders = insertEntries.map((e) => {
1695
- paramIdx++;
1696
- allValues.push(e.value);
1697
- return `$${paramIdx}`;
1698
- }).join(", ");
1699
-
1700
- // Build UPDATE SET part from update data (using atomic operations)
1701
- // In ON CONFLICT DO UPDATE SET context, column self-references must be table-qualified
1702
- // to avoid ambiguity (e.g. "Product"."price" instead of just "price").
1703
- const updateClauses: string[] = [];
1704
- for (const [key, value] of Object.entries(update)) {
1705
- if (value === undefined) continue;
1706
- const sf = sfMap.get(key);
1707
- if (!sf) continue;
1708
-
1709
- const setResult = buildSetClause({
1710
- dbName: sf.dbName,
1711
- value,
1712
- paramIdx,
1713
- field: sf,
1714
- tablePrefix: table,
1715
- });
1716
- updateClauses.push(setResult.sql);
1717
- allValues.push(...setResult.values);
1718
- paramIdx += setResult.values.length;
1719
- }
1720
-
1721
- const returningCols = resolveMutationReturningColumns({ modelMeta, args });
1722
-
1723
- let text: string;
1724
- if (updateClauses.length > 0) {
1725
- text = `INSERT INTO ${table} (${insertColumns}) VALUES (${insertPlaceholders}) ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${updateClauses.join(", ")} RETURNING ${returningCols}`;
1726
- } else {
1727
- text = `INSERT INTO ${table} (${insertColumns}) VALUES (${insertPlaceholders}) ON CONFLICT (${conflictCols.join(", ")}) DO NOTHING RETURNING ${returningCols}`;
1728
- }
1729
-
1730
- return { text, values: allValues };
1731
- }
1732
-
1733
- // ─── Column Resolution ────────────────────────────────────────────
1734
-
1735
- function resolveSelectColumns(params: {
1736
- modelMeta: ModelMeta;
1737
- args: Record<string, unknown>;
1738
- }): readonly ScalarFieldMeta[] {
1739
- const { modelMeta, args } = params;
1740
- const select = args.select as Record<string, boolean | object> | undefined;
1741
- const omit = args.omit as Record<string, boolean> | undefined;
1742
-
1743
- if (select) {
1744
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
1745
-
1746
- // With select → return only selected scalar fields
1747
- const columns = modelMeta.scalarFields.filter((f) => {
1748
- const val = select[f.name];
1749
- return val === true || (typeof val === "object" && val !== null);
1750
- });
1751
-
1752
- // Build a set of relation names for O(1) lookups
1753
- const relationNameSet = new Set(modelMeta.relationFields.map((r) => r.name));
1754
-
1755
- // Auto-include PK if select has nested relations (needed for Phase 2 relation loading)
1756
- const hasNested = Object.entries(select).some(([key, val]) => {
1757
- if (typeof val !== "object" || val === null) return false;
1758
- return relationNameSet.has(key);
1759
- });
1760
-
1761
- if (hasNested) {
1762
- // Track already-selected column names for O(1) dedup checks
1763
- const selectedNames = new Set(columns.map((c) => c.name));
1764
-
1765
- // Auto-include PK columns (needed for all relation loading strategies)
1766
- for (const pkName of modelMeta.primaryKey) {
1767
- if (!selectedNames.has(pkName)) {
1768
- const sf = sfMap.get(pkName);
1769
- if (sf) {
1770
- columns.push(sf);
1771
- selectedNames.add(pkName);
1772
- }
1773
- }
1774
- }
1775
-
1776
- // Build a map of relation name → RelationFieldMeta for O(1) lookups
1777
- const relationByName = new Map(modelMeta.relationFields.map((r) => [r.name, r]));
1778
-
1779
- // Auto-include FK fields for selected to-one relations where parent holds the FK
1780
- // (needed for Phase 2 relation loading to query related records by FK value)
1781
- for (const [key, val] of Object.entries(select)) {
1782
- if (typeof val !== "object" || val === null) continue;
1783
- const rel = relationByName.get(key);
1784
- if (rel && rel.isForeignKey && !rel.isList) {
1785
- for (const fkFieldName of rel.fields) {
1786
- if (!selectedNames.has(fkFieldName)) {
1787
- const sf = sfMap.get(fkFieldName);
1788
- if (sf) {
1789
- columns.push(sf);
1790
- selectedNames.add(fkFieldName);
1791
- }
1792
- }
1793
- }
1794
- }
1795
- }
1796
- }
1797
-
1798
- return columns;
1799
- }
1800
-
1801
- if (omit) {
1802
- // With omit → return all scalar fields except omitted ones
1803
- return modelMeta.scalarFields.filter((f) => !omit[f.name]);
1804
- }
1805
-
1806
- // No select/omit → return all scalar fields (default selection)
1807
- return modelMeta.scalarFields;
1808
- }
1809
-
1810
- function resolveMutationReturningColumns(params: {
1811
- modelMeta: ModelMeta;
1812
- args?: Record<string, unknown>;
1813
- }): string {
1814
- const { modelMeta, args } = params;
1815
- const shouldForceFullReturning = args?.returning === true;
1816
-
1817
- if (!shouldForceFullReturning && (args?.select || args?.omit)) {
1818
- const columns = resolveSelectColumns({
1819
- modelMeta,
1820
- args,
1821
- });
1822
- if (columns.length > 0) {
1823
- return columns
1824
- .map((f) => `"${f.dbName}" AS "${f.name}"`)
1825
- .join(", ");
1826
- }
1827
- }
1828
-
1829
- return modelMeta.scalarFields
1830
- .map((f) => `"${f.dbName}" AS "${f.name}"`)
1831
- .join(", ");
1832
- }