@vibeorm/runtime 1.3.0 → 2.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -107
- package/dist/adapter.d.ts +124 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/codecs.d.ts +170 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/computed.d.ts +43 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/extensions.d.ts +102 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6625 -0
- package/dist/index.js.map +21 -0
- package/dist/model-meta.d.ts +156 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/nested-writes.d.ts +100 -0
- package/dist/nested-writes.d.ts.map +1 -0
- package/dist/query-builder.d.ts +250 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/relation-loader.d.ts +75 -0
- package/dist/relation-loader.d.ts.map +1 -0
- package/dist/relation-plan.d.ts +103 -0
- package/dist/relation-plan.d.ts.map +1 -0
- package/dist/render-cache.d.ts +48 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/views.d.ts +97 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +31 -26
- package/src/adapter.ts +0 -146
- package/src/client.ts +0 -2172
- package/src/coerce.ts +0 -184
- package/src/count-loader.ts +0 -152
- package/src/errors.ts +0 -492
- package/src/id-generators.ts +0 -151
- package/src/index.ts +0 -55
- package/src/lateral-join-builder.ts +0 -1053
- package/src/query-builder.ts +0 -1832
- package/src/relation-loader.ts +0 -534
- package/src/retry.ts +0 -183
- package/src/types.ts +0 -317
- package/src/view.ts +0 -629
- package/src/where-builder.ts +0 -772
package/src/query-builder.ts
DELETED
|
@@ -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
|
-
}
|