@vibeorm/runtime 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client.ts +266 -293
- package/src/coerce.ts +84 -0
- package/src/count-loader.ts +152 -0
- package/src/lateral-join-builder.ts +80 -10
- package/src/relation-loader.ts +11 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
import { generateDefault } from "./id-generators.ts";
|
|
39
39
|
import { createView } from "./view.ts";
|
|
40
40
|
import type { ViewDefinition } from "./view.ts";
|
|
41
|
+
import { coerceFieldTypes } from "./coerce.ts";
|
|
42
|
+
import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Creates a VibeORM client instance.
|
|
@@ -181,14 +183,54 @@ export function createClient(params: {
|
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
//
|
|
186
|
+
// Build a one-off implicit transaction for a single write operation that
|
|
187
|
+
// has nested relation ops (Bug #8). Mirrors the executor-wrapping pattern
|
|
188
|
+
// used in `client.$transaction`'s callback path — same error normalisation,
|
|
189
|
+
// same PgArray formatting, same logging.
|
|
190
|
+
//
|
|
191
|
+
// The caller passes a `runFn` that takes the transactional executor and
|
|
192
|
+
// performs all writes; we then commit and return the result.
|
|
193
|
+
async function runInImplicitTx<T>(implicitParams: {
|
|
194
|
+
runFn: (txExec: typeof executeSql) => Promise<T>;
|
|
195
|
+
}): Promise<T> {
|
|
196
|
+
const { runFn } = implicitParams;
|
|
197
|
+
try {
|
|
198
|
+
return await adapter.transaction(async (txAdapter) => {
|
|
199
|
+
async function txExecutor(execParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
200
|
+
const values = execParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
|
|
201
|
+
if (shouldLog) {
|
|
202
|
+
console.log(`[vibeorm:tx-implicit] ${execParams.text}`);
|
|
203
|
+
if (values.length > 0) console.log(`[vibeorm:tx-implicit] params:`, values);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return await txAdapter.execute({ text: execParams.text, values });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw normalizeError({ error: err });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return runFn(txExecutor);
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw normalizeError({ error: err });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Create delegate for a model.
|
|
219
|
+
//
|
|
220
|
+
// `inTransaction` (default false) tells the delegate it's running inside
|
|
221
|
+
// an outer `$transaction(...)` callback. When true, write operations that
|
|
222
|
+
// contain nested relation ops skip their implicit-transaction wrap (Bug #8 —
|
|
223
|
+
// mirror Prisma behaviour) because the caller's `tx` already provides
|
|
224
|
+
// atomicity. Without this flag, we'd open a SAVEPOINT on the in-tx
|
|
225
|
+
// connection for every nested-write, which is correct but wasteful.
|
|
185
226
|
function createDelegate(params: {
|
|
186
227
|
modelKey: string;
|
|
187
228
|
modelMeta: ModelMeta;
|
|
188
229
|
executor: typeof executeSql;
|
|
189
230
|
schemas?: ModelSchemas;
|
|
231
|
+
inTransaction?: boolean;
|
|
190
232
|
}): Record<string, Function> {
|
|
191
|
-
const { modelKey, modelMeta, executor, schemas } = params;
|
|
233
|
+
const { modelKey, modelMeta, executor, schemas, inTransaction = false } = params;
|
|
192
234
|
|
|
193
235
|
// Resolve validation mode from options
|
|
194
236
|
const validateOpt = options?.validate;
|
|
@@ -517,43 +559,62 @@ export function createClient(params: {
|
|
|
517
559
|
injectUpdatedAt({ data, modelMeta });
|
|
518
560
|
injectAutoDefaults({ data, modelMeta, idGenerator: options?.idGenerator });
|
|
519
561
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
562
|
+
// Bug #8: when the write has any nested relation ops, do the whole
|
|
563
|
+
// INSERT + nested-children sequence inside a single transaction so
|
|
564
|
+
// partial failures roll back atomically (Prisma parity). Skip the
|
|
565
|
+
// wrap when we're already inside a caller-supplied transaction.
|
|
566
|
+
const hasAnyNestedRelation = hasNestedRelationOps({ data, modelMeta });
|
|
567
|
+
const needsTxWrap = hasAnyNestedRelation && !inTransaction;
|
|
526
568
|
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
569
|
+
const runCreate = async (txExec: typeof executor): Promise<Record<string, unknown>[]> => {
|
|
570
|
+
const { processedData, deferredCreates } = await processNestedCreates({
|
|
571
|
+
data,
|
|
572
|
+
modelMeta,
|
|
573
|
+
allModelsMeta,
|
|
574
|
+
executor: txExec,
|
|
575
|
+
});
|
|
533
576
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
577
|
+
const query = buildInsertQuery({
|
|
578
|
+
modelMeta,
|
|
579
|
+
data: processedData,
|
|
580
|
+
args: deferredCreates.length === 0 ? args : undefined,
|
|
581
|
+
});
|
|
582
|
+
const parentRows = await txExec(query);
|
|
583
|
+
|
|
584
|
+
const parentRecord = parentRows[0];
|
|
585
|
+
if (parentRecord && deferredCreates.length > 0) {
|
|
586
|
+
for (const deferred of deferredCreates) {
|
|
587
|
+
const parentRefValue = parentRecord[deferred.parentRefField];
|
|
588
|
+
if (parentRefValue != null) {
|
|
589
|
+
const createItems = Array.isArray(deferred.createData) ? deferred.createData : [deferred.createData];
|
|
590
|
+
const dataArray = createItems.map((createData) => {
|
|
591
|
+
(createData as Record<string, unknown>)[deferred.fkField] = parentRefValue;
|
|
592
|
+
return createData as Record<string, unknown>;
|
|
593
|
+
});
|
|
594
|
+
if (dataArray.length === 1) {
|
|
595
|
+
const insertQuery = buildInsertQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray[0]! });
|
|
596
|
+
await txExec(insertQuery);
|
|
597
|
+
} else if (dataArray.length > 1) {
|
|
598
|
+
const insertQuery = buildInsertManyQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray, returning: false });
|
|
599
|
+
await txExec(insertQuery);
|
|
600
|
+
}
|
|
552
601
|
}
|
|
553
602
|
}
|
|
554
603
|
}
|
|
604
|
+
|
|
605
|
+
return parentRows;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
let records: Record<string, unknown>[];
|
|
609
|
+
if (needsTxWrap) {
|
|
610
|
+
records = await runInImplicitTx({ runFn: runCreate });
|
|
611
|
+
} else {
|
|
612
|
+
records = await runCreate(executor);
|
|
555
613
|
}
|
|
556
614
|
|
|
615
|
+
// Keep relation-loading + select-filtering + output validation OUTSIDE
|
|
616
|
+
// the transaction so we don't hold a connection longer than the
|
|
617
|
+
// actual write needs it.
|
|
557
618
|
records = await loadRelationsForStrategy({
|
|
558
619
|
records,
|
|
559
620
|
modelMeta,
|
|
@@ -589,46 +650,57 @@ export function createClient(params: {
|
|
|
589
650
|
modelMeta,
|
|
590
651
|
});
|
|
591
652
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
where: whereInput,
|
|
596
|
-
data: scalarData,
|
|
597
|
-
args: nestedOps.length === 0 ? args : undefined,
|
|
598
|
-
});
|
|
599
|
-
let records = await executor(query);
|
|
600
|
-
|
|
601
|
-
if (records.length === 0) {
|
|
602
|
-
throw new VibeRequestError({
|
|
603
|
-
code: "NOT_FOUND",
|
|
604
|
-
message: `An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`,
|
|
605
|
-
meta: { model: modelMeta.name, operation: "update" },
|
|
606
|
-
});
|
|
607
|
-
}
|
|
653
|
+
// Bug #8: wrap UPDATE + nested ops in a single transaction so partial
|
|
654
|
+
// failures roll back atomically. Skip when already inside a caller tx.
|
|
655
|
+
const needsTxWrap = nestedOps.length > 0 && !inTransaction;
|
|
608
656
|
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
// Process nested relation operations
|
|
612
|
-
if (nestedOps.length > 0) {
|
|
613
|
-
await processNestedUpdateOps({
|
|
614
|
-
parentRecord: updatedRecord,
|
|
615
|
-
nestedOps,
|
|
657
|
+
const runUpdate = async (txExec: typeof executor): Promise<Record<string, unknown>[]> => {
|
|
658
|
+
const query = buildUpdateQuery({
|
|
616
659
|
modelMeta,
|
|
617
660
|
allModelsMeta,
|
|
618
|
-
|
|
661
|
+
where: whereInput,
|
|
662
|
+
data: scalarData,
|
|
663
|
+
args: nestedOps.length === 0 ? args : undefined,
|
|
619
664
|
});
|
|
665
|
+
let updateRows = await txExec(query);
|
|
620
666
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
667
|
+
if (updateRows.length === 0) {
|
|
668
|
+
throw new VibeRequestError({
|
|
669
|
+
code: "NOT_FOUND",
|
|
670
|
+
message: `An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`,
|
|
671
|
+
meta: { model: modelMeta.name, operation: "update" },
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (nestedOps.length > 0) {
|
|
676
|
+
await processNestedUpdateOps({
|
|
677
|
+
parentRecord: updateRows[0]!,
|
|
678
|
+
nestedOps,
|
|
679
|
+
modelMeta,
|
|
680
|
+
allModelsMeta,
|
|
681
|
+
executor: txExec,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Re-read the parent record after nested ops to reflect FK changes
|
|
685
|
+
// (e.g., connect/disconnect may have updated FK columns).
|
|
686
|
+
const refreshQuery = buildSelectQuery({
|
|
687
|
+
modelMeta,
|
|
688
|
+
allModelsMeta,
|
|
689
|
+
args: { where: whereInput, take: 1 },
|
|
690
|
+
});
|
|
691
|
+
const refreshed = await txExec(refreshQuery);
|
|
692
|
+
if (refreshed.length > 0) {
|
|
693
|
+
updateRows = refreshed;
|
|
694
|
+
}
|
|
631
695
|
}
|
|
696
|
+
return updateRows;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
let records: Record<string, unknown>[];
|
|
700
|
+
if (needsTxWrap) {
|
|
701
|
+
records = await runInImplicitTx({ runFn: runUpdate });
|
|
702
|
+
} else {
|
|
703
|
+
records = await runUpdate(executor);
|
|
632
704
|
}
|
|
633
705
|
|
|
634
706
|
records = await loadRelationsForStrategy({
|
|
@@ -1059,75 +1131,107 @@ export function createClient(params: {
|
|
|
1059
1131
|
const fn = fnOrPromises as (tx: Record<string, unknown>) => Promise<T>;
|
|
1060
1132
|
try {
|
|
1061
1133
|
return await adapter.transaction(async (txAdapter) => {
|
|
1062
|
-
|
|
1063
|
-
async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
1064
|
-
const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
|
|
1065
|
-
if (shouldLog) {
|
|
1066
|
-
console.log(`[vibeorm:tx] ${txParams.text}`);
|
|
1067
|
-
if (values.length > 0) {
|
|
1068
|
-
console.log(`[vibeorm:tx] params:`, values);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
try {
|
|
1072
|
-
return await txAdapter.execute({ text: txParams.text, values });
|
|
1073
|
-
} catch (err) {
|
|
1074
|
-
throw normalizeError({ error: err });
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Build transactional delegates
|
|
1079
|
-
const txClient: Record<string, unknown> = {};
|
|
1080
|
-
for (const [key, meta] of Object.entries(allModelsMeta)) {
|
|
1081
|
-
txClient[key] = createDelegate({
|
|
1082
|
-
modelKey: key,
|
|
1083
|
-
modelMeta: meta,
|
|
1084
|
-
executor: txExecutor,
|
|
1085
|
-
schemas: params.schemas?.[key],
|
|
1086
|
-
});
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// Add $queryRaw and $executeRaw to transactional client
|
|
1090
|
-
txClient.$queryRaw = async function <T = unknown>(
|
|
1091
|
-
strings: TemplateStringsArray,
|
|
1092
|
-
...values: unknown[]
|
|
1093
|
-
): Promise<T[]> {
|
|
1094
|
-
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1095
|
-
if (shouldLog) {
|
|
1096
|
-
console.log(`[vibeorm:tx] ${text}`);
|
|
1097
|
-
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1098
|
-
}
|
|
1099
|
-
try {
|
|
1100
|
-
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1101
|
-
return result.rows as T[];
|
|
1102
|
-
} catch (err) {
|
|
1103
|
-
throw normalizeError({ error: err });
|
|
1104
|
-
}
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
txClient.$executeRaw = async function (
|
|
1108
|
-
strings: TemplateStringsArray,
|
|
1109
|
-
...values: unknown[]
|
|
1110
|
-
): Promise<number> {
|
|
1111
|
-
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1112
|
-
if (shouldLog) {
|
|
1113
|
-
console.log(`[vibeorm:tx] ${text}`);
|
|
1114
|
-
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1115
|
-
}
|
|
1116
|
-
try {
|
|
1117
|
-
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1118
|
-
return result.affectedRows;
|
|
1119
|
-
} catch (err) {
|
|
1120
|
-
throw normalizeError({ error: err });
|
|
1121
|
-
}
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
return fn(txClient);
|
|
1134
|
+
return fn(buildTxClient({ txAdapter }));
|
|
1125
1135
|
}, txOptions);
|
|
1126
1136
|
} catch (err) {
|
|
1127
1137
|
throw normalizeError({ error: err });
|
|
1128
1138
|
}
|
|
1129
1139
|
};
|
|
1130
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Build a transactional client backed by the given txAdapter. Recursive:
|
|
1143
|
+
* the returned `$transaction` method opens a nested transaction (which
|
|
1144
|
+
* the adapters realise as a SAVEPOINT) and rebuilds the same shape over
|
|
1145
|
+
* the resulting innerTxAdapter, so arbitrarily-deep nesting works.
|
|
1146
|
+
*
|
|
1147
|
+
* Used by both the top-level `db.$transaction` callback path and by the
|
|
1148
|
+
* `$transaction` exposed on tx clients (Bug #3 — nested transactions on
|
|
1149
|
+
* the bun adapter previously crashed because they tried to call
|
|
1150
|
+
* `sql.begin()` on a reserved connection).
|
|
1151
|
+
*/
|
|
1152
|
+
function buildTxClient(builderParams: { txAdapter: DatabaseAdapter }): Record<string, unknown> {
|
|
1153
|
+
const { txAdapter } = builderParams;
|
|
1154
|
+
|
|
1155
|
+
async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
1156
|
+
const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
|
|
1157
|
+
if (shouldLog) {
|
|
1158
|
+
console.log(`[vibeorm:tx] ${txParams.text}`);
|
|
1159
|
+
if (values.length > 0) {
|
|
1160
|
+
console.log(`[vibeorm:tx] params:`, values);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
return await txAdapter.execute({ text: txParams.text, values });
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
throw normalizeError({ error: err });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const txClient: Record<string, unknown> = {};
|
|
1171
|
+
for (const [key, meta] of Object.entries(allModelsMeta)) {
|
|
1172
|
+
txClient[key] = createDelegate({
|
|
1173
|
+
modelKey: key,
|
|
1174
|
+
modelMeta: meta,
|
|
1175
|
+
executor: txExecutor,
|
|
1176
|
+
schemas: params.schemas?.[key],
|
|
1177
|
+
inTransaction: true,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
txClient.$queryRaw = async function <T = unknown>(
|
|
1182
|
+
strings: TemplateStringsArray,
|
|
1183
|
+
...values: unknown[]
|
|
1184
|
+
): Promise<T[]> {
|
|
1185
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1186
|
+
if (shouldLog) {
|
|
1187
|
+
console.log(`[vibeorm:tx] ${text}`);
|
|
1188
|
+
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1192
|
+
return result.rows as T[];
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
throw normalizeError({ error: err });
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
txClient.$executeRaw = async function (
|
|
1199
|
+
strings: TemplateStringsArray,
|
|
1200
|
+
...values: unknown[]
|
|
1201
|
+
): Promise<number> {
|
|
1202
|
+
const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
|
|
1203
|
+
if (shouldLog) {
|
|
1204
|
+
console.log(`[vibeorm:tx] ${text}`);
|
|
1205
|
+
if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
|
|
1209
|
+
return result.affectedRows;
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
throw normalizeError({ error: err });
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
// Nested $transaction → SAVEPOINT through the adapter contract.
|
|
1216
|
+
// The adapter implementation (bun, pg) knows whether the current source
|
|
1217
|
+
// is a pool, a reserved connection, or an in-tx handle, and picks
|
|
1218
|
+
// BEGIN/COMMIT vs SAVEPOINT/RELEASE accordingly.
|
|
1219
|
+
txClient.$transaction = async function <U>(
|
|
1220
|
+
nestedFn: (tx: Record<string, unknown>) => Promise<U>,
|
|
1221
|
+
nestedOptions?: TransactionOptions
|
|
1222
|
+
): Promise<U> {
|
|
1223
|
+
try {
|
|
1224
|
+
return await txAdapter.transaction(async (innerTxAdapter) => {
|
|
1225
|
+
return nestedFn(buildTxClient({ txAdapter: innerTxAdapter }));
|
|
1226
|
+
}, nestedOptions);
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
throw normalizeError({ error: err });
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
return txClient;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1131
1235
|
// $queryRaw — tagged template literal for safe parameterized queries
|
|
1132
1236
|
client.$queryRaw = async function <T = unknown>(
|
|
1133
1237
|
strings: TemplateStringsArray,
|
|
@@ -1168,7 +1272,12 @@ export function createClient(params: {
|
|
|
1168
1272
|
}
|
|
1169
1273
|
};
|
|
1170
1274
|
|
|
1171
|
-
// $queryRawUnsafe — accepts a plain SQL string + params array
|
|
1275
|
+
// $queryRawUnsafe — accepts a plain SQL string + params array.
|
|
1276
|
+
// Routed through `executeUnsafe` (NOT `execute`) so that adapters know this
|
|
1277
|
+
// is dynamic raw SQL and can skip prepared-statement caching. This also lets
|
|
1278
|
+
// adapters apply raw-query-only param normalisation (e.g. bun:sql converting
|
|
1279
|
+
// plain JS array params to PG array literals for `= ANY($N)`) without
|
|
1280
|
+
// affecting the ORM hot path.
|
|
1172
1281
|
client.$queryRawUnsafe = async function <T = unknown>(
|
|
1173
1282
|
query: string,
|
|
1174
1283
|
...values: unknown[]
|
|
@@ -1180,12 +1289,11 @@ export function createClient(params: {
|
|
|
1180
1289
|
}
|
|
1181
1290
|
}
|
|
1182
1291
|
try {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
return rows as T[];
|
|
1292
|
+
const result = await adapter.executeUnsafe({
|
|
1293
|
+
text: query,
|
|
1294
|
+
values: values.length > 0 ? values : undefined,
|
|
1295
|
+
});
|
|
1296
|
+
return result.rows as T[];
|
|
1189
1297
|
} catch (err) {
|
|
1190
1298
|
throw normalizeError({ error: err });
|
|
1191
1299
|
}
|
|
@@ -1247,39 +1355,6 @@ export function createClient(params: {
|
|
|
1247
1355
|
return client;
|
|
1248
1356
|
}
|
|
1249
1357
|
|
|
1250
|
-
/**
|
|
1251
|
-
* Coerce scalar field values to their correct JS types.
|
|
1252
|
-
* Currently handles BigInt fields: bun:sql returns PostgreSQL bigint as string,
|
|
1253
|
-
* but the application expects native BigInt values.
|
|
1254
|
-
*/
|
|
1255
|
-
function coerceFieldTypes(params: {
|
|
1256
|
-
records: Record<string, unknown>[];
|
|
1257
|
-
modelMeta: ModelMeta;
|
|
1258
|
-
}): Record<string, unknown>[] {
|
|
1259
|
-
const { records, modelMeta } = params;
|
|
1260
|
-
if (records.length === 0) return records;
|
|
1261
|
-
|
|
1262
|
-
// Find fields that need coercion
|
|
1263
|
-
const bigintFields = modelMeta.scalarFields.filter(
|
|
1264
|
-
(f) => (f as { type?: string }).type === "BigInt"
|
|
1265
|
-
);
|
|
1266
|
-
|
|
1267
|
-
if (bigintFields.length === 0) return records;
|
|
1268
|
-
|
|
1269
|
-
for (const record of records) {
|
|
1270
|
-
for (const field of bigintFields) {
|
|
1271
|
-
const val = record[field.name];
|
|
1272
|
-
if (typeof val === "string") {
|
|
1273
|
-
record[field.name] = BigInt(val);
|
|
1274
|
-
} else if (typeof val === "number") {
|
|
1275
|
-
record[field.name] = BigInt(val);
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
return records;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
1358
|
/**
|
|
1284
1359
|
* Apply select filtering to returned records.
|
|
1285
1360
|
* When args.select is specified, strips fields not in the select object
|
|
@@ -1499,137 +1574,35 @@ function parseAggregateResult(params: {
|
|
|
1499
1574
|
return result;
|
|
1500
1575
|
}
|
|
1501
1576
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
|
|
1507
|
-
const { args } = params;
|
|
1508
|
-
const include = args.include as Record<string, unknown> | undefined;
|
|
1509
|
-
const select = args.select as Record<string, unknown> | undefined;
|
|
1510
|
-
|
|
1511
|
-
const countArg = include?._count ?? select?._count;
|
|
1512
|
-
if (!countArg) return null;
|
|
1513
|
-
|
|
1514
|
-
if (countArg === true) {
|
|
1515
|
-
// Count all list relations — will be resolved by loadRelationCounts
|
|
1516
|
-
return ["__all__"];
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
if (typeof countArg === "object" && countArg !== null) {
|
|
1520
|
-
const countObj = countArg as Record<string, unknown>;
|
|
1521
|
-
const selectObj = countObj.select as Record<string, boolean> | undefined;
|
|
1522
|
-
if (selectObj) {
|
|
1523
|
-
return Object.entries(selectObj)
|
|
1524
|
-
.filter(([_, enabled]) => enabled)
|
|
1525
|
-
.map(([name]) => name);
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
return null;
|
|
1530
|
-
}
|
|
1577
|
+
type NestedOp = {
|
|
1578
|
+
relationField: ModelMeta["relationFields"][number];
|
|
1579
|
+
ops: Record<string, unknown>;
|
|
1580
|
+
};
|
|
1531
1581
|
|
|
1532
1582
|
/**
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1583
|
+
* Cheap upfront detection: does this write `data` object contain any keys
|
|
1584
|
+
* that correspond to a relation field on the model? Used by `create` (and
|
|
1585
|
+
* by analogy, fits any nested-write op detection) to decide whether to wrap
|
|
1586
|
+
* the operation in an implicit transaction (Bug #8).
|
|
1587
|
+
*
|
|
1588
|
+
* Doesn't try to verify the nested value is a well-formed `{ create | connect
|
|
1589
|
+
* | … }` object — `processNestedCreates` does that validation while running.
|
|
1590
|
+
* The conservative over-wrap (e.g. user passes `{ relationName: undefined }`)
|
|
1591
|
+
* is cheap and still correct.
|
|
1535
1592
|
*/
|
|
1536
|
-
|
|
1537
|
-
|
|
1593
|
+
function hasNestedRelationOps(params: {
|
|
1594
|
+
data: Record<string, unknown>;
|
|
1538
1595
|
modelMeta: ModelMeta;
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
const parentPk = modelMeta.primaryKey[0];
|
|
1546
|
-
if (!parentPk) return;
|
|
1547
|
-
|
|
1548
|
-
const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
|
|
1549
|
-
if (parentIds.length === 0) return;
|
|
1550
|
-
|
|
1551
|
-
// Resolve which relations to count
|
|
1552
|
-
const listRelations = modelMeta.relationFields.filter((r) => r.isList);
|
|
1553
|
-
const relationsToCount = countSpec.includes("__all__")
|
|
1554
|
-
? listRelations
|
|
1555
|
-
: listRelations.filter((r) => countSpec.includes(r.name));
|
|
1556
|
-
|
|
1557
|
-
// Initialize _count on all records
|
|
1558
|
-
for (const record of records) {
|
|
1559
|
-
const countObj: Record<string, number> = {};
|
|
1560
|
-
for (const rel of relationsToCount) {
|
|
1561
|
-
countObj[rel.name] = 0;
|
|
1562
|
-
}
|
|
1563
|
-
record._count = countObj;
|
|
1596
|
+
}): boolean {
|
|
1597
|
+
const { data, modelMeta } = params;
|
|
1598
|
+
for (const rf of modelMeta.relationFields) {
|
|
1599
|
+
const v = data[rf.name];
|
|
1600
|
+
if (v === undefined) continue;
|
|
1601
|
+
if (v !== null && typeof v === "object") return true;
|
|
1564
1602
|
}
|
|
1565
|
-
|
|
1566
|
-
// Run all relation COUNT queries in parallel — each hits a different table
|
|
1567
|
-
// so there are no data races on the parent records.
|
|
1568
|
-
await Promise.all(
|
|
1569
|
-
relationsToCount.map(async (rel) => {
|
|
1570
|
-
const relatedModelMeta = modelMap.get(rel.relatedModel);
|
|
1571
|
-
if (!relatedModelMeta) return;
|
|
1572
|
-
|
|
1573
|
-
// M:N relation: count via join table
|
|
1574
|
-
if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
|
|
1575
|
-
const joinTableName = (rel as { joinTable?: string }).joinTable!;
|
|
1576
|
-
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
1577
|
-
const parentIsA = modelMeta.name === sorted[0];
|
|
1578
|
-
const parentCol = parentIsA ? "A" : "B";
|
|
1579
|
-
|
|
1580
|
-
const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
|
|
1581
|
-
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
1582
|
-
|
|
1583
|
-
const countMap = new Map<unknown, number>();
|
|
1584
|
-
for (const row of result) {
|
|
1585
|
-
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
for (const record of records) {
|
|
1589
|
-
const pkValue = record[parentPk];
|
|
1590
|
-
const cnt = countMap.get(pkValue) ?? 0;
|
|
1591
|
-
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
1592
|
-
}
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
// Find the FK column on the related model (with relationName disambiguation)
|
|
1597
|
-
const reverseRel = relatedModelMeta.relationFields.find(
|
|
1598
|
-
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
|
|
1599
|
-
(!rel.relationName || r.relationName === rel.relationName)
|
|
1600
|
-
);
|
|
1601
|
-
if (!reverseRel) return;
|
|
1602
|
-
|
|
1603
|
-
const fkField = reverseRel.fields[0]!;
|
|
1604
|
-
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
1605
|
-
const fkScalar = relatedSfMap.get(fkField);
|
|
1606
|
-
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
1607
|
-
const relatedTable = `"${relatedModelMeta.dbName}"`;
|
|
1608
|
-
|
|
1609
|
-
// Build: SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
|
|
1610
|
-
const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
|
|
1611
|
-
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
1612
|
-
|
|
1613
|
-
// Map counts back to parent records
|
|
1614
|
-
const countMap = new Map<unknown, number>();
|
|
1615
|
-
for (const row of result) {
|
|
1616
|
-
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
for (const record of records) {
|
|
1620
|
-
const pkValue = record[parentPk];
|
|
1621
|
-
const cnt = countMap.get(pkValue) ?? 0;
|
|
1622
|
-
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
1623
|
-
}
|
|
1624
|
-
})
|
|
1625
|
-
);
|
|
1603
|
+
return false;
|
|
1626
1604
|
}
|
|
1627
1605
|
|
|
1628
|
-
type NestedOp = {
|
|
1629
|
-
relationField: ModelMeta["relationFields"][number];
|
|
1630
|
-
ops: Record<string, unknown>;
|
|
1631
|
-
};
|
|
1632
|
-
|
|
1633
1606
|
/**
|
|
1634
1607
|
* Separate scalar data from nested relation operations in update data.
|
|
1635
1608
|
*/
|
package/src/coerce.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scalar value type coercion.
|
|
3
|
+
*
|
|
4
|
+
* Coerce raw DB driver output to the JS types declared in the Prisma schema.
|
|
5
|
+
* Currently handles `BigInt` (pg / bun:sql return PG `bigint` as a string,
|
|
6
|
+
* but the application expects native `BigInt`).
|
|
7
|
+
*
|
|
8
|
+
* Lives in its own module so all relation loaders (query strategy, lateral
|
|
9
|
+
* join strategy, post-write refresh, raw post-processing) can call it without
|
|
10
|
+
* creating circular imports between `client.ts` and the relation loaders.
|
|
11
|
+
*
|
|
12
|
+
* Mutates the records in place — callers rely on this for performance.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ModelMeta, ScalarFieldMeta } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-model cache of names of fields that need BigInt coercion.
|
|
19
|
+
* Empty arrays are cached too, so the `length === 0` fast path costs one
|
|
20
|
+
* `WeakMap.get` after the first call per model.
|
|
21
|
+
*
|
|
22
|
+
* Keyed by the `scalarFields` array reference (the same hot-path cache key
|
|
23
|
+
* used by `getScalarFieldMap`), which is stable for the process lifetime
|
|
24
|
+
* because model metadata is generated once at startup.
|
|
25
|
+
*/
|
|
26
|
+
const _bigintFieldsCache = new WeakMap<
|
|
27
|
+
readonly ScalarFieldMeta[],
|
|
28
|
+
readonly string[]
|
|
29
|
+
>();
|
|
30
|
+
|
|
31
|
+
function getBigIntFieldNames(modelMeta: ModelMeta): readonly string[] {
|
|
32
|
+
const sf = modelMeta.scalarFields;
|
|
33
|
+
let names = _bigintFieldsCache.get(sf);
|
|
34
|
+
if (names) return names;
|
|
35
|
+
const arr: string[] = [];
|
|
36
|
+
for (const f of sf) {
|
|
37
|
+
if ((f as { type?: string }).type === "BigInt") arr.push(f.name);
|
|
38
|
+
}
|
|
39
|
+
names = arr;
|
|
40
|
+
_bigintFieldsCache.set(sf, names);
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Coerce scalar field values on the given records to their JS-native types.
|
|
46
|
+
*
|
|
47
|
+
* Today only `BigInt` fields are affected. Driver behaviour:
|
|
48
|
+
* - `bun:sql` returns PG `bigint` as a JS `string`.
|
|
49
|
+
* - `node-postgres` returns PG `bigint` as a JS `string` by default too.
|
|
50
|
+
* - In both cases the Prisma type is `BigInt`, so we coerce.
|
|
51
|
+
*
|
|
52
|
+
* No-op when the model has no `BigInt` fields, or when `records` is empty.
|
|
53
|
+
*/
|
|
54
|
+
export function coerceFieldTypes(params: {
|
|
55
|
+
records: Record<string, unknown>[];
|
|
56
|
+
modelMeta: ModelMeta;
|
|
57
|
+
}): void {
|
|
58
|
+
const { records, modelMeta } = params;
|
|
59
|
+
if (records.length === 0) return;
|
|
60
|
+
|
|
61
|
+
const bigintNames = getBigIntFieldNames(modelMeta);
|
|
62
|
+
if (bigintNames.length === 0) return;
|
|
63
|
+
|
|
64
|
+
for (const record of records) {
|
|
65
|
+
for (const name of bigintNames) {
|
|
66
|
+
const val = record[name];
|
|
67
|
+
if (typeof val === "string") {
|
|
68
|
+
record[name] = BigInt(val);
|
|
69
|
+
} else if (typeof val === "number") {
|
|
70
|
+
record[name] = BigInt(val);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True iff the model has any `BigInt` fields that require coercion.
|
|
78
|
+
* Cheap O(1) lookup after first call per model. Hot-path callers (lateral-join
|
|
79
|
+
* builder, relation loaders) use this to skip the per-row coercion loop
|
|
80
|
+
* entirely for relations whose related model has no `BigInt` columns.
|
|
81
|
+
*/
|
|
82
|
+
export function modelHasBigInt(modelMeta: ModelMeta): boolean {
|
|
83
|
+
return getBigIntFieldNames(modelMeta).length > 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `_count` resolution + loader, used by both the "query" and "join"
|
|
3
|
+
* relation strategies.
|
|
4
|
+
*
|
|
5
|
+
* `resolveCountSpec` looks at `include._count` / `select._count` and returns
|
|
6
|
+
* the list of list-relation names to count (or `["__all__"]` for `_count: true`,
|
|
7
|
+
* or `null` when not requested).
|
|
8
|
+
*
|
|
9
|
+
* `loadRelationCounts` issues a single grouped `COUNT(*) GROUP BY <fk>` per
|
|
10
|
+
* relation (parallelised across relations) and attaches the result as a
|
|
11
|
+
* `_count` object on each parent record.
|
|
12
|
+
*
|
|
13
|
+
* Lives in its own module so the lateral-join path can call it without
|
|
14
|
+
* pulling in `client.ts` (which would create a circular import).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ModelMeta, ModelMetaMap } from "./types.ts";
|
|
18
|
+
import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
type SqlExecutor = (params: {
|
|
21
|
+
text: string;
|
|
22
|
+
values: unknown[];
|
|
23
|
+
}) => Promise<Record<string, unknown>[]>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve `_count` specification from `include` or `select` args.
|
|
27
|
+
* Returns the list of relation names to count, `["__all__"]` to count every
|
|
28
|
+
* list relation, or `null` if `_count` was not requested.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
|
|
31
|
+
const { args } = params;
|
|
32
|
+
const include = args.include as Record<string, unknown> | undefined;
|
|
33
|
+
const select = args.select as Record<string, unknown> | undefined;
|
|
34
|
+
|
|
35
|
+
const countArg = include?._count ?? select?._count;
|
|
36
|
+
if (!countArg) return null;
|
|
37
|
+
|
|
38
|
+
if (countArg === true) {
|
|
39
|
+
// Count all list relations — will be resolved by loadRelationCounts
|
|
40
|
+
return ["__all__"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof countArg === "object" && countArg !== null) {
|
|
44
|
+
const countObj = countArg as Record<string, unknown>;
|
|
45
|
+
const selectObj = countObj.select as Record<string, boolean> | undefined;
|
|
46
|
+
if (selectObj) {
|
|
47
|
+
return Object.entries(selectObj)
|
|
48
|
+
.filter(([_, enabled]) => enabled)
|
|
49
|
+
.map(([name]) => name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load relation counts and attach a `_count` object to each parent record.
|
|
58
|
+
* Uses one grouped `COUNT(*)` query per relation, executed in parallel.
|
|
59
|
+
*
|
|
60
|
+
* Mutates the parent records in place.
|
|
61
|
+
*/
|
|
62
|
+
export async function loadRelationCounts(params: {
|
|
63
|
+
records: Record<string, unknown>[];
|
|
64
|
+
modelMeta: ModelMeta;
|
|
65
|
+
allModelsMeta: ModelMetaMap;
|
|
66
|
+
countSpec: string[];
|
|
67
|
+
executor: SqlExecutor;
|
|
68
|
+
}): Promise<void> {
|
|
69
|
+
const { records, modelMeta, allModelsMeta, countSpec, executor } = params;
|
|
70
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
71
|
+
const parentPk = modelMeta.primaryKey[0];
|
|
72
|
+
if (!parentPk) return;
|
|
73
|
+
|
|
74
|
+
const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
|
|
75
|
+
if (parentIds.length === 0) return;
|
|
76
|
+
|
|
77
|
+
// Resolve which relations to count
|
|
78
|
+
const listRelations = modelMeta.relationFields.filter((r) => r.isList);
|
|
79
|
+
const relationsToCount = countSpec.includes("__all__")
|
|
80
|
+
? listRelations
|
|
81
|
+
: listRelations.filter((r) => countSpec.includes(r.name));
|
|
82
|
+
|
|
83
|
+
// Initialize _count on all records
|
|
84
|
+
for (const record of records) {
|
|
85
|
+
const countObj: Record<string, number> = {};
|
|
86
|
+
for (const rel of relationsToCount) {
|
|
87
|
+
countObj[rel.name] = 0;
|
|
88
|
+
}
|
|
89
|
+
record._count = countObj;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Run all relation COUNT queries in parallel — each hits a different table
|
|
93
|
+
// so there are no data races on the parent records.
|
|
94
|
+
await Promise.all(
|
|
95
|
+
relationsToCount.map(async (rel) => {
|
|
96
|
+
const relatedModelMeta = modelMap.get(rel.relatedModel);
|
|
97
|
+
if (!relatedModelMeta) return;
|
|
98
|
+
|
|
99
|
+
// M:N relation: count via join table
|
|
100
|
+
if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
|
|
101
|
+
const joinTableName = (rel as { joinTable?: string }).joinTable!;
|
|
102
|
+
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
103
|
+
const parentIsA = modelMeta.name === sorted[0];
|
|
104
|
+
const parentCol = parentIsA ? "A" : "B";
|
|
105
|
+
|
|
106
|
+
const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
|
|
107
|
+
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
108
|
+
|
|
109
|
+
const countMap = new Map<unknown, number>();
|
|
110
|
+
for (const row of result) {
|
|
111
|
+
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const record of records) {
|
|
115
|
+
const pkValue = record[parentPk];
|
|
116
|
+
const cnt = countMap.get(pkValue) ?? 0;
|
|
117
|
+
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Find the FK column on the related model (with relationName disambiguation)
|
|
123
|
+
const reverseRel = relatedModelMeta.relationFields.find(
|
|
124
|
+
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
|
|
125
|
+
(!rel.relationName || r.relationName === rel.relationName)
|
|
126
|
+
);
|
|
127
|
+
if (!reverseRel) return;
|
|
128
|
+
|
|
129
|
+
const fkField = reverseRel.fields[0]!;
|
|
130
|
+
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
131
|
+
const fkScalar = relatedSfMap.get(fkField);
|
|
132
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
133
|
+
const relatedTable = `"${relatedModelMeta.dbName}"`;
|
|
134
|
+
|
|
135
|
+
// SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
|
|
136
|
+
const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
|
|
137
|
+
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
138
|
+
|
|
139
|
+
// Map counts back to parent records
|
|
140
|
+
const countMap = new Map<unknown, number>();
|
|
141
|
+
for (const row of result) {
|
|
142
|
+
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const record of records) {
|
|
146
|
+
const pkValue = record[parentPk];
|
|
147
|
+
const cnt = countMap.get(pkValue) ?? 0;
|
|
148
|
+
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -49,6 +49,8 @@ import { buildWhereClause } from "./where-builder.ts";
|
|
|
49
49
|
import { sanitizeDirection } from "./query-builder.ts";
|
|
50
50
|
import { loadRelations, resolveRelationsToLoad, hasNestedRelations } from "./relation-loader.ts";
|
|
51
51
|
import type { RelationToLoad } from "./relation-loader.ts";
|
|
52
|
+
import { coerceFieldTypes, modelHasBigInt } from "./coerce.ts";
|
|
53
|
+
import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
|
|
52
54
|
|
|
53
55
|
type SqlExecutor = (params: {
|
|
54
56
|
text: string;
|
|
@@ -452,12 +454,33 @@ export function buildLateralJoinQuery(params: {
|
|
|
452
454
|
joinCondition = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
|
|
453
455
|
}
|
|
454
456
|
|
|
457
|
+
// Nested where filter from include/select args — mirrors the to-many branch.
|
|
458
|
+
// Without this the join silently ignored `include: { author: { where: {...} } }`.
|
|
459
|
+
let nestedWhereSql = "";
|
|
460
|
+
if (nestedArgs.where) {
|
|
461
|
+
const relMetaWithAlias = {
|
|
462
|
+
...relatedModelMeta,
|
|
463
|
+
dbName: "__rel",
|
|
464
|
+
} as typeof relatedModelMeta;
|
|
465
|
+
const nestedWhereResult = buildWhereClause({
|
|
466
|
+
where: nestedArgs.where as Record<string, unknown>,
|
|
467
|
+
modelMeta: relMetaWithAlias,
|
|
468
|
+
allModelsMeta,
|
|
469
|
+
paramOffset: paramIdx,
|
|
470
|
+
});
|
|
471
|
+
if (nestedWhereResult.sql) {
|
|
472
|
+
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
473
|
+
paramIdx += nestedWhereResult.values.length;
|
|
474
|
+
allValues.push(...nestedWhereResult.values);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
455
478
|
lateralSubquery = `LEFT JOIN LATERAL (
|
|
456
479
|
SELECT to_jsonb("__sub".*) AS "${colAlias}"
|
|
457
480
|
FROM (
|
|
458
481
|
SELECT ${columnAliases}
|
|
459
482
|
FROM "${relatedModelMeta.dbName}" ${relatedAlias}
|
|
460
|
-
WHERE ${joinCondition}
|
|
483
|
+
WHERE ${joinCondition}${nestedWhereSql}
|
|
461
484
|
LIMIT 1
|
|
462
485
|
) "__sub"
|
|
463
486
|
) ${alias} ON true`;
|
|
@@ -647,8 +670,24 @@ export async function executeLateralJoinQuery(params: {
|
|
|
647
670
|
|
|
648
671
|
const t2 = profiling ? performance.now() : 0;
|
|
649
672
|
|
|
650
|
-
// Parse JSON relation columns
|
|
651
|
-
|
|
673
|
+
// Parse JSON relation columns + apply BigInt coercion in a single pass.
|
|
674
|
+
//
|
|
675
|
+
// The "query" strategy coerces both parents (via `loadRelationsForStrategy`)
|
|
676
|
+
// and children (via the per-loader calls in `relation-loader.ts`). The
|
|
677
|
+
// lateral-join strategy bypasses both, so we do the equivalent work here
|
|
678
|
+
// (Bug #6).
|
|
679
|
+
//
|
|
680
|
+
// Performance: pre-compute which relation aliases actually need BigInt
|
|
681
|
+
// coercion (by looking at the related model's scalar fields). Wide schemas
|
|
682
|
+
// with many includes thus pay nothing extra for relations whose child model
|
|
683
|
+
// has no BigInt fields.
|
|
684
|
+
const nestedModelMap = getModelByNameMap({ allModelsMeta });
|
|
685
|
+
const relationCoerceMeta = relationAliases.map(({ relationMeta }) => {
|
|
686
|
+
const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
|
|
687
|
+
if (!relatedModelMeta) return null;
|
|
688
|
+
return modelHasBigInt(relatedModelMeta) ? relatedModelMeta : null;
|
|
689
|
+
});
|
|
690
|
+
|
|
652
691
|
const results: Record<string, unknown>[] = [];
|
|
653
692
|
|
|
654
693
|
for (const row of rows) {
|
|
@@ -661,30 +700,44 @@ export async function executeLateralJoinQuery(params: {
|
|
|
661
700
|
}
|
|
662
701
|
}
|
|
663
702
|
|
|
664
|
-
// Parse relation columns from JSON
|
|
665
|
-
for (
|
|
703
|
+
// Parse relation columns from JSON (and coerce inline if needed)
|
|
704
|
+
for (let i = 0; i < relationAliases.length; i++) {
|
|
705
|
+
const { alias, relationMeta } = relationAliases[i]!;
|
|
666
706
|
const jsonValue = row[alias];
|
|
707
|
+
let parsed: unknown;
|
|
667
708
|
if (jsonValue === null || jsonValue === undefined) {
|
|
668
|
-
|
|
709
|
+
parsed = relationMeta.isList ? [] : null;
|
|
669
710
|
} else if (typeof jsonValue === "string") {
|
|
670
711
|
try {
|
|
671
|
-
|
|
712
|
+
parsed = JSON.parse(jsonValue);
|
|
672
713
|
} catch {
|
|
673
|
-
|
|
714
|
+
parsed = relationMeta.isList ? [] : null;
|
|
674
715
|
}
|
|
675
716
|
} else {
|
|
676
717
|
// Already parsed by bun:sql (json/jsonb columns auto-parse)
|
|
677
|
-
|
|
718
|
+
parsed = jsonValue;
|
|
719
|
+
}
|
|
720
|
+
record[relationMeta.name] = parsed;
|
|
721
|
+
|
|
722
|
+
const coerceMeta = relationCoerceMeta[i];
|
|
723
|
+
if (coerceMeta) {
|
|
724
|
+
if (Array.isArray(parsed)) {
|
|
725
|
+
coerceFieldTypes({ records: parsed as Record<string, unknown>[], modelMeta: coerceMeta });
|
|
726
|
+
} else if (parsed && typeof parsed === "object") {
|
|
727
|
+
coerceFieldTypes({ records: [parsed as Record<string, unknown>], modelMeta: coerceMeta });
|
|
728
|
+
}
|
|
678
729
|
}
|
|
679
730
|
}
|
|
680
731
|
|
|
681
732
|
results.push(record);
|
|
682
733
|
}
|
|
683
734
|
|
|
735
|
+
// Coerce parent records once (whole batch) after the parse loop.
|
|
736
|
+
coerceFieldTypes({ records: results, modelMeta });
|
|
737
|
+
|
|
684
738
|
const t3 = profiling ? performance.now() : 0;
|
|
685
739
|
|
|
686
740
|
// Recursively load nested relations (deeper levels fall back to batched)
|
|
687
|
-
const nestedModelMap = getModelByNameMap({ allModelsMeta });
|
|
688
741
|
await Promise.all(
|
|
689
742
|
relationAliases
|
|
690
743
|
.filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
|
|
@@ -713,6 +766,23 @@ export async function executeLateralJoinQuery(params: {
|
|
|
713
766
|
})
|
|
714
767
|
);
|
|
715
768
|
|
|
769
|
+
// ─── _count support (Bug #5) ─────────────────────────────────────
|
|
770
|
+
// The "query" strategy resolves `_count` after `loadRelations` (see
|
|
771
|
+
// `loadRelationsForStrategy` in client.ts). The lateral-join strategy
|
|
772
|
+
// historically returned BEFORE `_count` ran, silently dropping it whenever
|
|
773
|
+
// the user combined `include: { posts: true, _count: { … } }` with the
|
|
774
|
+
// join strategy. We mirror the query-strategy behaviour here.
|
|
775
|
+
const countSpec = resolveCountSpec({ args });
|
|
776
|
+
if (countSpec && results.length > 0) {
|
|
777
|
+
await loadRelationCounts({
|
|
778
|
+
records: results,
|
|
779
|
+
modelMeta,
|
|
780
|
+
allModelsMeta,
|
|
781
|
+
countSpec,
|
|
782
|
+
executor,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
716
786
|
// Populate profiling context with per-phase timings
|
|
717
787
|
if (profilingCtx) {
|
|
718
788
|
profilingCtx.queryBuildMs = t1 - t0;
|
package/src/relation-loader.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
|
|
|
19
19
|
import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
|
|
20
20
|
import type { RelationSqlQuery } from "./query-builder.ts";
|
|
21
21
|
import { buildWhereClause } from "./where-builder.ts";
|
|
22
|
+
import { coerceFieldTypes } from "./coerce.ts";
|
|
22
23
|
|
|
23
24
|
type SqlExecutor = (params: {
|
|
24
25
|
text: string;
|
|
@@ -242,6 +243,10 @@ async function loadToManyRelation(params: {
|
|
|
242
243
|
grouped.get(fk)!.push(row);
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
// Coerce scalar types on the loaded child records (e.g., BigInt from string).
|
|
247
|
+
// Mirrors what `loadRelationsForStrategy` does on parent records.
|
|
248
|
+
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
249
|
+
|
|
245
250
|
// Stitch onto parent records
|
|
246
251
|
for (const parent of parentRecords) {
|
|
247
252
|
const parentId = parent[pkField];
|
|
@@ -345,6 +350,9 @@ async function loadToOneWithFk(params: {
|
|
|
345
350
|
byPk.set(pk, row);
|
|
346
351
|
}
|
|
347
352
|
|
|
353
|
+
// Coerce scalar types on the loaded child records before stitching.
|
|
354
|
+
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
355
|
+
|
|
348
356
|
// Stitch
|
|
349
357
|
for (const parent of parentRecords) {
|
|
350
358
|
const fkValue = parent[fkFieldName];
|
|
@@ -411,6 +419,9 @@ async function loadToOneWithoutFk(params: {
|
|
|
411
419
|
byFk.set(fk, row);
|
|
412
420
|
}
|
|
413
421
|
|
|
422
|
+
// Coerce scalar types on the loaded child records before stitching.
|
|
423
|
+
coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
|
|
424
|
+
|
|
414
425
|
// Stitch
|
|
415
426
|
for (const parent of parentRecords) {
|
|
416
427
|
const parentId = parent[pkField];
|