@vibeorm/runtime 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/package.json +47 -0
- package/src/adapter.ts +114 -0
- package/src/client.ts +2055 -0
- package/src/errors.ts +39 -0
- package/src/id-generators.ts +151 -0
- package/src/index.ts +36 -0
- package/src/lateral-join-builder.ts +759 -0
- package/src/query-builder.ts +1417 -0
- package/src/relation-loader.ts +489 -0
- package/src/types.ts +290 -0
- package/src/where-builder.ts +737 -0
|
@@ -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
|
+
}
|