@vibeorm/runtime 1.1.1 → 1.1.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/package.json +1 -1
- package/src/client.ts +41 -0
- package/src/lateral-join-builder.ts +165 -41
- package/src/query-builder.ts +55 -19
- package/src/types.ts +2 -0
- package/src/where-builder.ts +2 -2
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -615,6 +615,18 @@ export function createClient(params: {
|
|
|
615
615
|
allModelsMeta,
|
|
616
616
|
executor,
|
|
617
617
|
});
|
|
618
|
+
|
|
619
|
+
// Re-read the parent record after nested ops to reflect FK changes
|
|
620
|
+
// (e.g., connect/disconnect may have updated FK columns)
|
|
621
|
+
const refreshQuery = buildSelectQuery({
|
|
622
|
+
modelMeta,
|
|
623
|
+
allModelsMeta,
|
|
624
|
+
args: { where: whereInput, take: 1 },
|
|
625
|
+
});
|
|
626
|
+
const refreshed = await executor(refreshQuery);
|
|
627
|
+
if (refreshed.length > 0) {
|
|
628
|
+
records = refreshed;
|
|
629
|
+
}
|
|
618
630
|
}
|
|
619
631
|
|
|
620
632
|
records = await loadRelationsForStrategy({
|
|
@@ -2105,6 +2117,35 @@ async function processNestedCreates(params: {
|
|
|
2105
2117
|
}
|
|
2106
2118
|
}
|
|
2107
2119
|
}
|
|
2120
|
+
|
|
2121
|
+
if (ops.connectOrCreate) {
|
|
2122
|
+
// ConnectOrCreate: try to find existing, else create, then set FK on parent
|
|
2123
|
+
const relatedMeta = modelMap.get(relationField.relatedModel);
|
|
2124
|
+
if (relatedMeta) {
|
|
2125
|
+
const { where: corWhere, create: corCreate } = ops.connectOrCreate as {
|
|
2126
|
+
where: Record<string, unknown>;
|
|
2127
|
+
create: Record<string, unknown>;
|
|
2128
|
+
};
|
|
2129
|
+
// Try to find existing record
|
|
2130
|
+
const selectQuery = buildSelectQuery({
|
|
2131
|
+
modelMeta: relatedMeta,
|
|
2132
|
+
allModelsMeta,
|
|
2133
|
+
args: { where: corWhere, take: 1 },
|
|
2134
|
+
});
|
|
2135
|
+
const existingRows = await executor(selectQuery);
|
|
2136
|
+
if (existingRows.length > 0) {
|
|
2137
|
+
processedData[fkField] = existingRows[0]![refField];
|
|
2138
|
+
} else {
|
|
2139
|
+
// Create new record
|
|
2140
|
+
const insertQuery = buildInsertQuery({ modelMeta: relatedMeta, data: corCreate });
|
|
2141
|
+
const insertedRows = await executor(insertQuery);
|
|
2142
|
+
const inserted = insertedRows[0];
|
|
2143
|
+
if (inserted) {
|
|
2144
|
+
processedData[fkField] = inserted[refField];
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2108
2149
|
}
|
|
2109
2150
|
// Case 2: Related model holds the FK (e.g., User.posts where Post has userId)
|
|
2110
2151
|
else if (!relationField.isForeignKey) {
|
|
@@ -96,6 +96,14 @@ export function buildLateralJoinQuery(params: {
|
|
|
96
96
|
let paramIdx = 0;
|
|
97
97
|
|
|
98
98
|
const take = typeof args.take === "number" ? (args.take as number) : undefined;
|
|
99
|
+
|
|
100
|
+
// Negative take is only valid with cursor-based pagination (backward page)
|
|
101
|
+
if (take !== undefined && take < 0 && args.cursor === undefined) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Negative \`take\` (${take}) is only supported with cursor-based pagination on model "${modelMeta.name}". Provide a \`cursor\` argument or use a positive \`take\` value.`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
99
107
|
const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
|
|
100
108
|
|
|
101
109
|
// Build WHERE — start with explicit where, then merge cursor conditions.
|
|
@@ -169,18 +177,12 @@ export function buildLateralJoinQuery(params: {
|
|
|
169
177
|
const { relationMeta, nestedArgs } = relationsToLoad[i]!;
|
|
170
178
|
const alias = `__lat_${i}`;
|
|
171
179
|
const colAlias = `__vibeorm_rel_${relationMeta.name}`;
|
|
180
|
+
const relatedAlias = `"__rel"`;
|
|
172
181
|
|
|
173
182
|
// Find the related model meta
|
|
174
183
|
const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
|
|
175
184
|
if (!relatedModelMeta) continue;
|
|
176
185
|
|
|
177
|
-
// Determine FK column and join condition
|
|
178
|
-
const { fkDbName, fkTable } = resolveFkColumn({
|
|
179
|
-
parentModelMeta: modelMeta,
|
|
180
|
-
relationMeta,
|
|
181
|
-
relatedModelMeta,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
186
|
// Build json_build_object fields for the related model.
|
|
185
187
|
// Respect nested select/omit to avoid serialising columns the caller doesn't need.
|
|
186
188
|
const relatedScalars = resolveRelatedScalars({
|
|
@@ -188,20 +190,36 @@ export function buildLateralJoinQuery(params: {
|
|
|
188
190
|
nestedArgs,
|
|
189
191
|
});
|
|
190
192
|
const jsonFields = relatedScalars
|
|
191
|
-
.map((f) => `'${f.name}',
|
|
193
|
+
.map((f) => `'${f.name}', ${relatedAlias}."${f.dbName}"`)
|
|
192
194
|
.join(", ");
|
|
193
195
|
|
|
194
196
|
let lateralSubquery: string;
|
|
195
197
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
// ─── M:N relations via junction table ─────────────────────────
|
|
199
|
+
const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
|
|
200
|
+
|
|
201
|
+
if (isM2M) {
|
|
202
|
+
const jtName = (relationMeta as { joinTable: string }).joinTable;
|
|
203
|
+
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
204
|
+
|
|
205
|
+
// Determine A vs B column: Prisma implicit junction tables use alphabetical model name order
|
|
206
|
+
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
207
|
+
const parentIsA = modelMeta.name === sorted[0];
|
|
208
|
+
const parentCol = parentIsA ? "A" : "B";
|
|
209
|
+
const relatedCol = parentIsA ? "B" : "A";
|
|
210
|
+
|
|
211
|
+
// Related model PK for junction join condition
|
|
212
|
+
const relatedPk = relatedModelMeta.primaryKey[0]!;
|
|
213
|
+
const relatedPkSf = relatedSfMap.get(relatedPk);
|
|
214
|
+
const relatedPkDb = relatedPkSf?.dbName ?? relatedPk;
|
|
215
|
+
|
|
216
|
+
// Junction join: __jt."B" = __rel."id" WHERE __jt."A" = parent."id"
|
|
217
|
+
const junctionJoin = `INNER JOIN "${jtName}" __jt ON __jt."${relatedCol}" = ${relatedAlias}."${relatedPkDb}"`;
|
|
218
|
+
const subWhere = `__jt."${parentCol}" = ${table}."${pkDbName}"`;
|
|
199
219
|
|
|
200
220
|
// Nested where filter from include/select args
|
|
201
221
|
let nestedWhereSql = "";
|
|
202
222
|
if (nestedArgs.where) {
|
|
203
|
-
// Build nested where using __rel alias for the related model.
|
|
204
|
-
// This avoids brittle regex rewrites of generated SQL table refs.
|
|
205
223
|
const relMetaWithAlias = {
|
|
206
224
|
...relatedModelMeta,
|
|
207
225
|
dbName: "__rel",
|
|
@@ -219,8 +237,6 @@ export function buildLateralJoinQuery(params: {
|
|
|
219
237
|
}
|
|
220
238
|
}
|
|
221
239
|
|
|
222
|
-
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
223
|
-
|
|
224
240
|
let orderBySql = "";
|
|
225
241
|
if (nestedArgs.orderBy) {
|
|
226
242
|
const orderByItems = Array.isArray(nestedArgs.orderBy)
|
|
@@ -239,19 +255,18 @@ export function buildLateralJoinQuery(params: {
|
|
|
239
255
|
`Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
|
|
240
256
|
);
|
|
241
257
|
}
|
|
242
|
-
return
|
|
258
|
+
return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
|
|
243
259
|
})
|
|
244
260
|
);
|
|
245
261
|
if (clauses.length > 0) {
|
|
246
262
|
orderBySql = ` ORDER BY ${clauses.join(", ")}`;
|
|
247
263
|
}
|
|
248
|
-
} else if (defaultOrderByPk) {
|
|
249
|
-
// Default ORDER BY PK for deterministic array ordering in jsonb_agg
|
|
264
|
+
} else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
250
265
|
const pkOrderClauses: string[] = [];
|
|
251
266
|
for (const rpkName of relatedModelMeta.primaryKey) {
|
|
252
267
|
const rpkSf = relatedSfMap.get(rpkName);
|
|
253
268
|
if (rpkSf) {
|
|
254
|
-
pkOrderClauses.push(
|
|
269
|
+
pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
|
|
255
270
|
}
|
|
256
271
|
}
|
|
257
272
|
if (pkOrderClauses.length > 0) {
|
|
@@ -276,44 +291,153 @@ export function buildLateralJoinQuery(params: {
|
|
|
276
291
|
// When take or skip is specified, use a subquery to apply per-parent LIMIT/OFFSET properly
|
|
277
292
|
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
278
293
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
279
|
-
SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})), '[]'::jsonb) AS "${colAlias}"
|
|
294
|
+
SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
|
|
280
295
|
FROM (
|
|
281
|
-
SELECT
|
|
296
|
+
SELECT ${relatedAlias}.* FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
297
|
+
${junctionJoin}
|
|
282
298
|
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
283
|
-
)
|
|
299
|
+
) ${relatedAlias}
|
|
284
300
|
) ${alias} ON true`;
|
|
285
301
|
} else {
|
|
286
302
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
287
303
|
SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
|
|
288
|
-
FROM "${relatedModelMeta.dbName}"
|
|
304
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
305
|
+
${junctionJoin}
|
|
289
306
|
WHERE ${subWhere}${nestedWhereSql}
|
|
290
307
|
) ${alias} ON true`;
|
|
291
308
|
}
|
|
309
|
+
|
|
310
|
+
// ─── Direct FK relations (1:N, 1:1) ──────────────────────────
|
|
292
311
|
} else {
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
312
|
+
// Determine FK column and join condition
|
|
313
|
+
const { fkDbName, fkTable } = resolveFkColumn({
|
|
314
|
+
parentModelMeta: modelMeta,
|
|
315
|
+
relationMeta,
|
|
316
|
+
relatedModelMeta,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (relationMeta.isList) {
|
|
320
|
+
// To-many: use json_agg with COALESCE for empty arrays
|
|
321
|
+
const subWhere = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
|
|
322
|
+
|
|
323
|
+
// Nested where filter from include/select args
|
|
324
|
+
let nestedWhereSql = "";
|
|
325
|
+
if (nestedArgs.where) {
|
|
326
|
+
// Build nested where using __rel alias for the related model.
|
|
327
|
+
// This avoids brittle regex rewrites of generated SQL table refs.
|
|
328
|
+
const relMetaWithAlias = {
|
|
329
|
+
...relatedModelMeta,
|
|
330
|
+
dbName: "__rel",
|
|
331
|
+
} as typeof relatedModelMeta;
|
|
332
|
+
const nestedWhereResult = buildWhereClause({
|
|
333
|
+
where: nestedArgs.where as Record<string, unknown>,
|
|
334
|
+
modelMeta: relMetaWithAlias,
|
|
335
|
+
allModelsMeta,
|
|
336
|
+
paramOffset: paramIdx,
|
|
337
|
+
});
|
|
338
|
+
if (nestedWhereResult.sql) {
|
|
339
|
+
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
340
|
+
paramIdx += nestedWhereResult.values.length;
|
|
341
|
+
allValues.push(...nestedWhereResult.values);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
346
|
+
|
|
347
|
+
let orderBySql = "";
|
|
348
|
+
if (nestedArgs.orderBy) {
|
|
349
|
+
const orderByItems = Array.isArray(nestedArgs.orderBy)
|
|
350
|
+
? (nestedArgs.orderBy as Record<string, string>[])
|
|
351
|
+
: [nestedArgs.orderBy as Record<string, string>];
|
|
352
|
+
const clauses = orderByItems.flatMap((item) =>
|
|
353
|
+
Object.entries(item).map(([field, dir]) => {
|
|
354
|
+
if (typeof dir !== "string") {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const sf = relatedSfMap.get(field);
|
|
360
|
+
if (!sf) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
if (clauses.length > 0) {
|
|
369
|
+
orderBySql = ` ORDER BY ${clauses.join(", ")}`;
|
|
370
|
+
}
|
|
371
|
+
} else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
372
|
+
// Default ORDER BY PK for deterministic array ordering in jsonb_agg
|
|
373
|
+
const pkOrderClauses: string[] = [];
|
|
374
|
+
for (const rpkName of relatedModelMeta.primaryKey) {
|
|
375
|
+
const rpkSf = relatedSfMap.get(rpkName);
|
|
376
|
+
if (rpkSf) {
|
|
377
|
+
pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (pkOrderClauses.length > 0) {
|
|
381
|
+
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let limitSql = "";
|
|
386
|
+
if (nestedArgs.take !== undefined) {
|
|
387
|
+
paramIdx++;
|
|
388
|
+
limitSql = ` LIMIT $${paramIdx}`;
|
|
389
|
+
allValues.push(Math.abs(nestedArgs.take as number));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let offsetSql = "";
|
|
393
|
+
if (nestedArgs.skip !== undefined) {
|
|
394
|
+
paramIdx++;
|
|
395
|
+
offsetSql = ` OFFSET $${paramIdx}`;
|
|
396
|
+
allValues.push(nestedArgs.skip);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// When take or skip is specified, use a subquery to apply per-parent LIMIT/OFFSET properly
|
|
400
|
+
if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
|
|
401
|
+
lateralSubquery = `LEFT JOIN LATERAL (
|
|
402
|
+
SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
|
|
403
|
+
FROM (
|
|
404
|
+
SELECT * FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
405
|
+
WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
|
|
406
|
+
) ${relatedAlias}
|
|
407
|
+
) ${alias} ON true`;
|
|
408
|
+
} else {
|
|
409
|
+
lateralSubquery = `LEFT JOIN LATERAL (
|
|
410
|
+
SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
|
|
411
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
412
|
+
WHERE ${subWhere}${nestedWhereSql}
|
|
413
|
+
) ${alias} ON true`;
|
|
414
|
+
}
|
|
306
415
|
} else {
|
|
307
|
-
//
|
|
308
|
-
joinCondition
|
|
309
|
-
|
|
416
|
+
// To-one: use json_build_object with LIMIT 1
|
|
417
|
+
let joinCondition: string;
|
|
418
|
+
|
|
419
|
+
if (fkTable === "parent") {
|
|
420
|
+
// Parent holds FK (e.g., Post.authorId -> User.id)
|
|
421
|
+
const parentFkField = relationMeta.fields[0]!;
|
|
422
|
+
const parentFkScalar = sfMap.get(parentFkField);
|
|
423
|
+
const parentFkDbName = parentFkScalar?.dbName ?? parentFkField;
|
|
424
|
+
const refField = relationMeta.references[0]!;
|
|
425
|
+
const relatedSfMapForOne = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
426
|
+
const refScalar = relatedSfMapForOne.get(refField);
|
|
427
|
+
const refDbName = refScalar?.dbName ?? refField;
|
|
428
|
+
joinCondition = `${relatedAlias}."${refDbName}" = ${table}."${parentFkDbName}"`;
|
|
429
|
+
} else {
|
|
430
|
+
// Related model holds FK (e.g., User.profile -> Profile.userId)
|
|
431
|
+
joinCondition = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
|
|
432
|
+
}
|
|
310
433
|
|
|
311
|
-
|
|
434
|
+
lateralSubquery = `LEFT JOIN LATERAL (
|
|
312
435
|
SELECT json_build_object(${jsonFields}) AS "${colAlias}"
|
|
313
|
-
FROM "${relatedModelMeta.dbName}"
|
|
436
|
+
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
314
437
|
WHERE ${joinCondition}
|
|
315
438
|
LIMIT 1
|
|
316
439
|
) ${alias} ON true`;
|
|
440
|
+
}
|
|
317
441
|
}
|
|
318
442
|
|
|
319
443
|
lateralJoins.push(lateralSubquery);
|
package/src/query-builder.ts
CHANGED
|
@@ -15,6 +15,20 @@ import type {
|
|
|
15
15
|
import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
|
|
16
16
|
import { buildWhereClause } from "./where-builder.ts";
|
|
17
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
|
+
|
|
18
32
|
// ─── SQL Direction Sanitizer ──────────────────────────────────────
|
|
19
33
|
|
|
20
34
|
const VALID_DIRECTIONS = new Set(["ASC", "DESC"]);
|
|
@@ -79,6 +93,14 @@ export function buildSelectQuery(params: {
|
|
|
79
93
|
const table = `"${modelMeta.dbName}"`;
|
|
80
94
|
const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
|
|
81
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
|
+
|
|
82
104
|
const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
|
|
83
105
|
|
|
84
106
|
// Determine which columns to select
|
|
@@ -510,7 +532,7 @@ export function buildInsertQuery(params: {
|
|
|
510
532
|
if (value === undefined) continue;
|
|
511
533
|
const scalarField = sfMap.get(key);
|
|
512
534
|
if (scalarField) {
|
|
513
|
-
entries.push({ dbName: scalarField.dbName, value });
|
|
535
|
+
entries.push({ dbName: scalarField.dbName, value: wrapListValue({ value, field: scalarField }) });
|
|
514
536
|
}
|
|
515
537
|
}
|
|
516
538
|
|
|
@@ -560,6 +582,7 @@ export function buildUpdateQuery(params: {
|
|
|
560
582
|
dbName: scalarField.dbName,
|
|
561
583
|
value,
|
|
562
584
|
paramIdx,
|
|
585
|
+
field: scalarField,
|
|
563
586
|
});
|
|
564
587
|
setClauses.push(setResult.sql);
|
|
565
588
|
allValues.push(...setResult.values);
|
|
@@ -839,7 +862,10 @@ export function buildRelationQuery(params: {
|
|
|
839
862
|
rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
|
|
840
863
|
}
|
|
841
864
|
|
|
842
|
-
const
|
|
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`;
|
|
843
869
|
|
|
844
870
|
return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
|
|
845
871
|
}
|
|
@@ -980,7 +1006,7 @@ export function buildManyToManyQuery(params: {
|
|
|
980
1006
|
rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
|
|
981
1007
|
}
|
|
982
1008
|
|
|
983
|
-
const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")}`;
|
|
1009
|
+
const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")} ORDER BY __ranked."__vibeorm_fk" ASC, __ranked."__vibeorm_rn" ASC`;
|
|
984
1010
|
return { text, values: allValues };
|
|
985
1011
|
}
|
|
986
1012
|
|
|
@@ -1094,10 +1120,10 @@ export function buildInsertManyQuery(params: {
|
|
|
1094
1120
|
};
|
|
1095
1121
|
}
|
|
1096
1122
|
|
|
1097
|
-
// Map keys to DB column names
|
|
1123
|
+
// Map keys to DB column names (keep field ref for isList wrapping)
|
|
1098
1124
|
const columnMap = orderedKeys.map((key) => {
|
|
1099
1125
|
const scalarField = sfMap.get(key)!;
|
|
1100
|
-
return { key, dbName: scalarField.dbName };
|
|
1126
|
+
return { key, dbName: scalarField.dbName, field: scalarField };
|
|
1101
1127
|
});
|
|
1102
1128
|
|
|
1103
1129
|
const columns = columnMap.map((c) => `"${c.dbName}"`).join(", ");
|
|
@@ -1109,14 +1135,14 @@ export function buildInsertManyQuery(params: {
|
|
|
1109
1135
|
|
|
1110
1136
|
for (const record of data) {
|
|
1111
1137
|
const placeholders: string[] = [];
|
|
1112
|
-
for (const { key } of columnMap) {
|
|
1138
|
+
for (const { key, field } of columnMap) {
|
|
1113
1139
|
const value = record[key];
|
|
1114
1140
|
if (value === undefined) {
|
|
1115
1141
|
placeholders.push("DEFAULT");
|
|
1116
1142
|
} else {
|
|
1117
1143
|
paramIdx++;
|
|
1118
1144
|
placeholders.push(`$${paramIdx}`);
|
|
1119
|
-
allValues.push(value);
|
|
1145
|
+
allValues.push(wrapListValue({ value, field }));
|
|
1120
1146
|
}
|
|
1121
1147
|
}
|
|
1122
1148
|
valueRows.push(`(${placeholders.join(", ")})`);
|
|
@@ -1190,6 +1216,7 @@ export function buildUpdateManyQuery(params: {
|
|
|
1190
1216
|
dbName: scalarField.dbName,
|
|
1191
1217
|
value,
|
|
1192
1218
|
paramIdx,
|
|
1219
|
+
field: scalarField,
|
|
1193
1220
|
});
|
|
1194
1221
|
setClauses.push(setResult.sql);
|
|
1195
1222
|
allValues.push(...setResult.values);
|
|
@@ -1549,9 +1576,14 @@ function buildSetClause(params: {
|
|
|
1549
1576
|
dbName: string;
|
|
1550
1577
|
value: unknown;
|
|
1551
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;
|
|
1552
1582
|
}): { sql: string; values: unknown[] } {
|
|
1553
|
-
const { dbName, value, paramIdx } = params;
|
|
1583
|
+
const { dbName, value, paramIdx, field, tablePrefix } = params;
|
|
1554
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}"`;
|
|
1555
1587
|
|
|
1556
1588
|
// Check for atomic number operations: { increment, decrement, multiply, divide, set }
|
|
1557
1589
|
if (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
@@ -1559,19 +1591,19 @@ function buildSetClause(params: {
|
|
|
1559
1591
|
|
|
1560
1592
|
if (ops.increment !== undefined) {
|
|
1561
1593
|
idx++;
|
|
1562
|
-
return { sql: `"${dbName}" =
|
|
1594
|
+
return { sql: `"${dbName}" = ${colRef} + $${idx}`, values: [ops.increment] };
|
|
1563
1595
|
}
|
|
1564
1596
|
if (ops.decrement !== undefined) {
|
|
1565
1597
|
idx++;
|
|
1566
|
-
return { sql: `"${dbName}" =
|
|
1598
|
+
return { sql: `"${dbName}" = ${colRef} - $${idx}`, values: [ops.decrement] };
|
|
1567
1599
|
}
|
|
1568
1600
|
if (ops.multiply !== undefined) {
|
|
1569
1601
|
idx++;
|
|
1570
|
-
return { sql: `"${dbName}" =
|
|
1602
|
+
return { sql: `"${dbName}" = ${colRef} * $${idx}`, values: [ops.multiply] };
|
|
1571
1603
|
}
|
|
1572
1604
|
if (ops.divide !== undefined) {
|
|
1573
1605
|
idx++;
|
|
1574
|
-
return { sql: `"${dbName}" =
|
|
1606
|
+
return { sql: `"${dbName}" = ${colRef} / $${idx}`, values: [ops.divide] };
|
|
1575
1607
|
}
|
|
1576
1608
|
|
|
1577
1609
|
// Scalar list operations: { set: [...] } or { push: value }
|
|
@@ -1580,23 +1612,23 @@ function buildSetClause(params: {
|
|
|
1580
1612
|
if (Array.isArray(pushVal)) {
|
|
1581
1613
|
// Push multiple: "col" = "col" || $N
|
|
1582
1614
|
idx++;
|
|
1583
|
-
return { sql: `"${dbName}" =
|
|
1615
|
+
return { sql: `"${dbName}" = ${colRef} || $${idx}`, values: [wrapListValue({ value: pushVal, field })] };
|
|
1584
1616
|
} else {
|
|
1585
1617
|
// Push single: "col" = array_append("col", $N)
|
|
1586
1618
|
idx++;
|
|
1587
|
-
return { sql: `"${dbName}" = array_append(
|
|
1619
|
+
return { sql: `"${dbName}" = array_append(${colRef}, $${idx})`, values: [pushVal] };
|
|
1588
1620
|
}
|
|
1589
1621
|
}
|
|
1590
1622
|
|
|
1591
1623
|
if ("set" in ops) {
|
|
1592
1624
|
idx++;
|
|
1593
|
-
return { sql: `"${dbName}" = $${idx}`, values: [ops.set] };
|
|
1625
|
+
return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value: ops.set, field })] };
|
|
1594
1626
|
}
|
|
1595
1627
|
}
|
|
1596
1628
|
|
|
1597
|
-
// Plain value
|
|
1629
|
+
// Plain value (wrap if scalar list)
|
|
1598
1630
|
idx++;
|
|
1599
|
-
return { sql: `"${dbName}" = $${idx}`, values: [value] };
|
|
1631
|
+
return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value, field })] };
|
|
1600
1632
|
}
|
|
1601
1633
|
|
|
1602
1634
|
// ─── UPSERT Builder (INSERT ... ON CONFLICT) ─────────────────────
|
|
@@ -1651,7 +1683,7 @@ export function buildUpsertQuery(params: {
|
|
|
1651
1683
|
if (value === undefined) continue;
|
|
1652
1684
|
const sf = sfMap.get(key);
|
|
1653
1685
|
if (sf) {
|
|
1654
|
-
insertEntries.push({ dbName: sf.dbName, value });
|
|
1686
|
+
insertEntries.push({ dbName: sf.dbName, value: wrapListValue({ value, field: sf }) });
|
|
1655
1687
|
}
|
|
1656
1688
|
}
|
|
1657
1689
|
|
|
@@ -1666,6 +1698,8 @@ export function buildUpsertQuery(params: {
|
|
|
1666
1698
|
}).join(", ");
|
|
1667
1699
|
|
|
1668
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").
|
|
1669
1703
|
const updateClauses: string[] = [];
|
|
1670
1704
|
for (const [key, value] of Object.entries(update)) {
|
|
1671
1705
|
if (value === undefined) continue;
|
|
@@ -1676,6 +1710,8 @@ export function buildUpsertQuery(params: {
|
|
|
1676
1710
|
dbName: sf.dbName,
|
|
1677
1711
|
value,
|
|
1678
1712
|
paramIdx,
|
|
1713
|
+
field: sf,
|
|
1714
|
+
tablePrefix: table,
|
|
1679
1715
|
});
|
|
1680
1716
|
updateClauses.push(setResult.sql);
|
|
1681
1717
|
allValues.push(...setResult.values);
|
|
@@ -1778,7 +1814,7 @@ function resolveMutationReturningColumns(params: {
|
|
|
1778
1814
|
const { modelMeta, args } = params;
|
|
1779
1815
|
const shouldForceFullReturning = args?.returning === true;
|
|
1780
1816
|
|
|
1781
|
-
if (!shouldForceFullReturning && args?.select) {
|
|
1817
|
+
if (!shouldForceFullReturning && (args?.select || args?.omit)) {
|
|
1782
1818
|
const columns = resolveSelectColumns({
|
|
1783
1819
|
modelMeta,
|
|
1784
1820
|
args,
|
package/src/types.ts
CHANGED
|
@@ -191,6 +191,8 @@ export type ScalarFieldMeta = {
|
|
|
191
191
|
readonly isUpdatedAt?: boolean;
|
|
192
192
|
/** Prisma type for runtime coercion (e.g., BigInt values from the DB driver) */
|
|
193
193
|
readonly type?: string;
|
|
194
|
+
/** Whether this field is a scalar list (e.g., String[], Int[]) */
|
|
195
|
+
readonly isList?: boolean;
|
|
194
196
|
/**
|
|
195
197
|
* Default value kind for application-level generation.
|
|
196
198
|
* - "uuid" | "cuid" | "nanoid" | "ulid": runtime generates the value if not provided
|
package/src/where-builder.ts
CHANGED
|
@@ -739,13 +739,13 @@ function buildScalarFilter(params: {
|
|
|
739
739
|
case "hasEvery": {
|
|
740
740
|
idx++;
|
|
741
741
|
conditions.push(`${col} @> $${idx}`);
|
|
742
|
-
values.push(operand);
|
|
742
|
+
values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
|
|
743
743
|
break;
|
|
744
744
|
}
|
|
745
745
|
case "hasSome": {
|
|
746
746
|
idx++;
|
|
747
747
|
conditions.push(`${col} && $${idx}`);
|
|
748
|
-
values.push(operand);
|
|
748
|
+
values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
|
|
749
749
|
break;
|
|
750
750
|
}
|
|
751
751
|
case "isEmpty": {
|