@vibeorm/runtime 1.0.2 → 1.1.1
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 +19 -0
- package/package.json +1 -1
- package/src/adapter.ts +33 -1
- package/src/client.ts +177 -90
- package/src/errors.ts +427 -6
- package/src/index.ts +15 -1
- package/src/lateral-join-builder.ts +157 -81
- package/src/query-builder.ts +573 -194
- package/src/relation-loader.ts +54 -20
- package/src/types.ts +25 -0
- package/src/where-builder.ts +56 -21
|
@@ -37,13 +37,20 @@ import type {
|
|
|
37
37
|
} from "./types.ts";
|
|
38
38
|
import { getScalarFieldMap, getModelByNameMap } from "./types.ts";
|
|
39
39
|
import { buildWhereClause } from "./where-builder.ts";
|
|
40
|
-
import {
|
|
40
|
+
import { sanitizeDirection } from "./query-builder.ts";
|
|
41
|
+
import { loadRelations, resolveRelationsToLoad, hasNestedRelations } from "./relation-loader.ts";
|
|
42
|
+
import type { RelationToLoad } from "./relation-loader.ts";
|
|
41
43
|
|
|
42
44
|
type SqlExecutor = (params: {
|
|
43
45
|
text: string;
|
|
44
46
|
values: unknown[];
|
|
45
47
|
}) => Promise<Record<string, unknown>[]>;
|
|
46
48
|
|
|
49
|
+
function reverseDirection(params: { direction: string }): string {
|
|
50
|
+
const normalized = sanitizeDirection({ direction: params.direction });
|
|
51
|
+
return normalized === "ASC" ? "DESC" : "ASC";
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
// ─── Query Builder ────────────────────────────────────────────────
|
|
48
55
|
|
|
49
56
|
/**
|
|
@@ -55,8 +62,9 @@ export function buildLateralJoinQuery(params: {
|
|
|
55
62
|
allModelsMeta: ModelMetaMap;
|
|
56
63
|
args: Record<string, unknown>;
|
|
57
64
|
relationsToLoad: RelationToLoad[];
|
|
65
|
+
defaultOrderByPk?: boolean;
|
|
58
66
|
}): { query: SqlQuery; relationAliases: RelationAlias[] } {
|
|
59
|
-
const { modelMeta, allModelsMeta, args, relationsToLoad } = params;
|
|
67
|
+
const { modelMeta, allModelsMeta, args, relationsToLoad, defaultOrderByPk = false } = params;
|
|
60
68
|
const table = `"${modelMeta.dbName}"`;
|
|
61
69
|
const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
|
|
62
70
|
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
@@ -87,6 +95,71 @@ export function buildLateralJoinQuery(params: {
|
|
|
87
95
|
const allValues: unknown[] = [];
|
|
88
96
|
let paramIdx = 0;
|
|
89
97
|
|
|
98
|
+
const take = typeof args.take === "number" ? (args.take as number) : undefined;
|
|
99
|
+
const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
|
|
100
|
+
|
|
101
|
+
// Build WHERE — start with explicit where, then merge cursor conditions.
|
|
102
|
+
let where = args.where as Record<string, unknown> | undefined;
|
|
103
|
+
if (args.cursor) {
|
|
104
|
+
const cursorInput = args.cursor as Record<string, unknown>;
|
|
105
|
+
const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
|
|
106
|
+
|
|
107
|
+
const orderByDirMap = new Map<string, string>();
|
|
108
|
+
if (args.orderBy) {
|
|
109
|
+
const orderByItems = Array.isArray(args.orderBy)
|
|
110
|
+
? (args.orderBy as Record<string, unknown>[])
|
|
111
|
+
: [args.orderBy as Record<string, unknown>];
|
|
112
|
+
for (const item of orderByItems) {
|
|
113
|
+
for (const [field, dir] of Object.entries(item)) {
|
|
114
|
+
if (!sfMap.has(field)) continue;
|
|
115
|
+
if (typeof dir === "string") {
|
|
116
|
+
orderByDirMap.set(field, dir.toUpperCase());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cmpOpForField = (field: string): string => {
|
|
123
|
+
const dir = orderByDirMap.get(field) ?? "ASC";
|
|
124
|
+
const isDesc = dir === "DESC";
|
|
125
|
+
return (isDesc !== isBackwardCursor) ? "lt" : "gt";
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let cursorWhere: Record<string, unknown> | undefined;
|
|
129
|
+
if (cursorEntries.length === 1) {
|
|
130
|
+
const [field, value] = cursorEntries[0]!;
|
|
131
|
+
if (!sfMap.has(field)) {
|
|
132
|
+
throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
|
|
133
|
+
}
|
|
134
|
+
cursorWhere = { [field]: { [cmpOpForField(field)]: value } };
|
|
135
|
+
} else if (cursorEntries.length > 1) {
|
|
136
|
+
const orBranches: Record<string, unknown>[] = [];
|
|
137
|
+
for (let i = 0; i < cursorEntries.length; i++) {
|
|
138
|
+
const branch: Record<string, unknown> = {};
|
|
139
|
+
for (let j = 0; j < i; j++) {
|
|
140
|
+
const [eqField, eqValue] = cursorEntries[j]!;
|
|
141
|
+
if (!sfMap.has(eqField)) {
|
|
142
|
+
throw new Error(`Unknown field "${eqField}" in cursor for model "${modelMeta.name}"`);
|
|
143
|
+
}
|
|
144
|
+
branch[eqField] = { equals: eqValue };
|
|
145
|
+
}
|
|
146
|
+
const [cmpField, cmpValue] = cursorEntries[i]!;
|
|
147
|
+
if (!sfMap.has(cmpField)) {
|
|
148
|
+
throw new Error(`Unknown field "${cmpField}" in cursor for model "${modelMeta.name}"`);
|
|
149
|
+
}
|
|
150
|
+
branch[cmpField] = { [cmpOpForField(cmpField)]: cmpValue };
|
|
151
|
+
orBranches.push(branch);
|
|
152
|
+
}
|
|
153
|
+
cursorWhere = { OR: orBranches };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (cursorWhere) {
|
|
157
|
+
where = where
|
|
158
|
+
? { AND: [where, cursorWhere] }
|
|
159
|
+
: cursorWhere;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
90
163
|
// Build LATERAL JOINs for each relation
|
|
91
164
|
const lateralJoins: string[] = [];
|
|
92
165
|
const lateralSelects: string[] = [];
|
|
@@ -127,25 +200,20 @@ export function buildLateralJoinQuery(params: {
|
|
|
127
200
|
// Nested where filter from include/select args
|
|
128
201
|
let nestedWhereSql = "";
|
|
129
202
|
if (nestedArgs.where) {
|
|
130
|
-
// Build nested where using __rel alias for the related model
|
|
203
|
+
// Build nested where using __rel alias for the related model.
|
|
204
|
+
// This avoids brittle regex rewrites of generated SQL table refs.
|
|
131
205
|
const relMetaWithAlias = {
|
|
132
206
|
...relatedModelMeta,
|
|
133
|
-
dbName: "__rel"
|
|
134
|
-
} as
|
|
135
|
-
// We can't easily re-alias, so build with original and replace table ref
|
|
207
|
+
dbName: "__rel",
|
|
208
|
+
} as typeof relatedModelMeta;
|
|
136
209
|
const nestedWhereResult = buildWhereClause({
|
|
137
210
|
where: nestedArgs.where as Record<string, unknown>,
|
|
138
|
-
modelMeta:
|
|
211
|
+
modelMeta: relMetaWithAlias,
|
|
139
212
|
allModelsMeta,
|
|
140
213
|
paramOffset: paramIdx,
|
|
141
214
|
});
|
|
142
215
|
if (nestedWhereResult.sql) {
|
|
143
|
-
|
|
144
|
-
const aliasedSql = nestedWhereResult.sql.replace(
|
|
145
|
-
new RegExp(`"${relatedModelMeta.dbName}"\\."`, "g"),
|
|
146
|
-
`__rel."`
|
|
147
|
-
);
|
|
148
|
-
nestedWhereSql = ` AND ${aliasedSql}`;
|
|
216
|
+
nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
|
|
149
217
|
paramIdx += nestedWhereResult.values.length;
|
|
150
218
|
allValues.push(...nestedWhereResult.values);
|
|
151
219
|
}
|
|
@@ -160,21 +228,42 @@ export function buildLateralJoinQuery(params: {
|
|
|
160
228
|
: [nestedArgs.orderBy as Record<string, string>];
|
|
161
229
|
const clauses = orderByItems.flatMap((item) =>
|
|
162
230
|
Object.entries(item).map(([field, dir]) => {
|
|
231
|
+
if (typeof dir !== "string") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
163
236
|
const sf = relatedSfMap.get(field);
|
|
164
|
-
|
|
165
|
-
|
|
237
|
+
if (!sf) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return `__rel."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
|
|
166
243
|
})
|
|
167
244
|
);
|
|
168
245
|
if (clauses.length > 0) {
|
|
169
246
|
orderBySql = ` ORDER BY ${clauses.join(", ")}`;
|
|
170
247
|
}
|
|
248
|
+
} else if (defaultOrderByPk) {
|
|
249
|
+
// Default ORDER BY PK for deterministic array ordering in jsonb_agg
|
|
250
|
+
const pkOrderClauses: string[] = [];
|
|
251
|
+
for (const rpkName of relatedModelMeta.primaryKey) {
|
|
252
|
+
const rpkSf = relatedSfMap.get(rpkName);
|
|
253
|
+
if (rpkSf) {
|
|
254
|
+
pkOrderClauses.push(`__rel."${rpkSf.dbName}" ASC`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (pkOrderClauses.length > 0) {
|
|
258
|
+
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
259
|
+
}
|
|
171
260
|
}
|
|
172
261
|
|
|
173
262
|
let limitSql = "";
|
|
174
263
|
if (nestedArgs.take !== undefined) {
|
|
175
264
|
paramIdx++;
|
|
176
265
|
limitSql = ` LIMIT $${paramIdx}`;
|
|
177
|
-
allValues.push(nestedArgs.take);
|
|
266
|
+
allValues.push(Math.abs(nestedArgs.take as number));
|
|
178
267
|
}
|
|
179
268
|
|
|
180
269
|
let offsetSql = "";
|
|
@@ -234,7 +323,7 @@ export function buildLateralJoinQuery(params: {
|
|
|
234
323
|
|
|
235
324
|
// Build WHERE from args
|
|
236
325
|
const whereResult = buildWhereClause({
|
|
237
|
-
where
|
|
326
|
+
where,
|
|
238
327
|
modelMeta,
|
|
239
328
|
allModelsMeta,
|
|
240
329
|
paramOffset: paramIdx,
|
|
@@ -251,23 +340,59 @@ export function buildLateralJoinQuery(params: {
|
|
|
251
340
|
|
|
252
341
|
const orderClauses = orderByItems.flatMap((item) =>
|
|
253
342
|
Object.entries(item).map(([field, direction]) => {
|
|
343
|
+
if (typeof direction !== "string") {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`Unsupported orderBy direction for field "${field}" on model "${modelMeta.name}"`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
254
348
|
const scalarField = sfMap.get(field);
|
|
255
|
-
|
|
256
|
-
|
|
349
|
+
if (!scalarField) {
|
|
350
|
+
throw new Error(`Unknown orderBy field "${field}" on model "${modelMeta.name}"`);
|
|
351
|
+
}
|
|
352
|
+
const resolvedDirection = isBackwardCursor
|
|
353
|
+
? reverseDirection({ direction })
|
|
354
|
+
: direction;
|
|
355
|
+
return `${table}."${scalarField.dbName}" ${sanitizeDirection({ direction: resolvedDirection })}`;
|
|
257
356
|
})
|
|
258
357
|
);
|
|
259
358
|
|
|
260
359
|
if (orderClauses.length > 0) {
|
|
261
360
|
orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
|
|
262
361
|
}
|
|
362
|
+
} else if (args.cursor) {
|
|
363
|
+
const cursorInput = args.cursor as Record<string, unknown>;
|
|
364
|
+
const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
|
|
365
|
+
const defaultDir = isBackwardCursor ? "DESC" : "ASC";
|
|
366
|
+
const cursorOrderClauses = cursorEntries.map(([field]) => {
|
|
367
|
+
const sf = sfMap.get(field);
|
|
368
|
+
if (!sf) {
|
|
369
|
+
throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
|
|
370
|
+
}
|
|
371
|
+
return `${table}."${sf.dbName}" ${defaultDir}`;
|
|
372
|
+
});
|
|
373
|
+
if (cursorOrderClauses.length > 0) {
|
|
374
|
+
orderBySql = ` ORDER BY ${cursorOrderClauses.join(", ")}`;
|
|
375
|
+
}
|
|
376
|
+
} else if (defaultOrderByPk) {
|
|
377
|
+
// Default ORDER BY primary key for deterministic results
|
|
378
|
+
const pkOrderClauses: string[] = [];
|
|
379
|
+
for (const pn of modelMeta.primaryKey) {
|
|
380
|
+
const pSf = sfMap.get(pn);
|
|
381
|
+
if (pSf) {
|
|
382
|
+
pkOrderClauses.push(`${table}."${pSf.dbName}" ASC`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (pkOrderClauses.length > 0) {
|
|
386
|
+
orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
|
|
387
|
+
}
|
|
263
388
|
}
|
|
264
389
|
|
|
265
390
|
// Build LIMIT / OFFSET
|
|
266
391
|
let limitSql = "";
|
|
267
|
-
if (
|
|
392
|
+
if (take !== undefined) {
|
|
268
393
|
paramIdx++;
|
|
269
394
|
limitSql = ` LIMIT $${paramIdx}`;
|
|
270
|
-
allValues.push(
|
|
395
|
+
allValues.push(Math.abs(take));
|
|
271
396
|
}
|
|
272
397
|
|
|
273
398
|
let offsetSql = "";
|
|
@@ -295,7 +420,7 @@ export function buildLateralJoinQuery(params: {
|
|
|
295
420
|
// lateral joins. Without it Postgres may evaluate every lateral for
|
|
296
421
|
// every candidate row before the LIMIT prunes the result set.
|
|
297
422
|
const hasRelFilter = whereHasRelationFilter({
|
|
298
|
-
where
|
|
423
|
+
where,
|
|
299
424
|
modelMeta,
|
|
300
425
|
});
|
|
301
426
|
const hasHeavyIncludes =
|
|
@@ -342,8 +467,9 @@ export async function executeLateralJoinQuery(params: {
|
|
|
342
467
|
args: Record<string, unknown>;
|
|
343
468
|
executor: SqlExecutor;
|
|
344
469
|
profilingCtx?: ProfilingContext;
|
|
470
|
+
defaultOrderByPk?: boolean;
|
|
345
471
|
}): Promise<Record<string, unknown>[]> {
|
|
346
|
-
const { modelMeta, allModelsMeta, args, executor, profilingCtx } = params;
|
|
472
|
+
const { modelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
|
|
347
473
|
const profiling = !!profilingCtx;
|
|
348
474
|
|
|
349
475
|
const t0 = profiling ? performance.now() : 0;
|
|
@@ -364,6 +490,7 @@ export async function executeLateralJoinQuery(params: {
|
|
|
364
490
|
allModelsMeta,
|
|
365
491
|
args,
|
|
366
492
|
relationsToLoad,
|
|
493
|
+
defaultOrderByPk,
|
|
367
494
|
});
|
|
368
495
|
|
|
369
496
|
const t1 = profiling ? performance.now() : 0;
|
|
@@ -464,11 +591,12 @@ export async function loadRelationsWithLateralJoin(params: {
|
|
|
464
591
|
args: Record<string, unknown>;
|
|
465
592
|
executor: SqlExecutor;
|
|
466
593
|
profilingCtx?: ProfilingContext;
|
|
594
|
+
defaultOrderByPk?: boolean;
|
|
467
595
|
}): Promise<Record<string, unknown>[]> {
|
|
468
596
|
// For the post-processing path (create, update, etc.), we still need
|
|
469
597
|
// the 2-query approach since the parent records are already fetched.
|
|
470
598
|
// But we can use the lateral join to load relations in 1 query instead of N.
|
|
471
|
-
const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx } = params;
|
|
599
|
+
const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
|
|
472
600
|
|
|
473
601
|
if (parentRecords.length === 0) return parentRecords;
|
|
474
602
|
|
|
@@ -505,6 +633,7 @@ export async function loadRelationsWithLateralJoin(params: {
|
|
|
505
633
|
allModelsMeta,
|
|
506
634
|
args: syntheticArgs,
|
|
507
635
|
relationsToLoad,
|
|
636
|
+
defaultOrderByPk,
|
|
508
637
|
});
|
|
509
638
|
|
|
510
639
|
const rows = await executor(query);
|
|
@@ -581,67 +710,12 @@ export async function loadRelationsWithLateralJoin(params: {
|
|
|
581
710
|
|
|
582
711
|
// ─── Helpers ──────────────────────────────────────────────────────
|
|
583
712
|
|
|
584
|
-
type RelationToLoad = {
|
|
585
|
-
relationMeta: RelationFieldMeta;
|
|
586
|
-
nestedArgs: Record<string, unknown>;
|
|
587
|
-
};
|
|
588
|
-
|
|
589
713
|
type RelationAlias = {
|
|
590
714
|
alias: string;
|
|
591
715
|
relationMeta: RelationFieldMeta;
|
|
592
716
|
nestedArgs: Record<string, unknown>;
|
|
593
717
|
};
|
|
594
718
|
|
|
595
|
-
export function resolveRelationsToLoad(params: {
|
|
596
|
-
parentModelMeta: ModelMeta;
|
|
597
|
-
args: Record<string, unknown>;
|
|
598
|
-
}): RelationToLoad[] {
|
|
599
|
-
const { parentModelMeta, args } = params;
|
|
600
|
-
const result: RelationToLoad[] = [];
|
|
601
|
-
|
|
602
|
-
const select = args.select as Record<string, unknown> | undefined;
|
|
603
|
-
const include = args.include as Record<string, unknown> | undefined;
|
|
604
|
-
|
|
605
|
-
for (const relationMeta of parentModelMeta.relationFields) {
|
|
606
|
-
let shouldLoad = false;
|
|
607
|
-
let nestedArgs: Record<string, unknown> = {};
|
|
608
|
-
|
|
609
|
-
if (select) {
|
|
610
|
-
const val = select[relationMeta.name];
|
|
611
|
-
if (val === true) {
|
|
612
|
-
shouldLoad = true;
|
|
613
|
-
} else if (typeof val === "object" && val !== null) {
|
|
614
|
-
shouldLoad = true;
|
|
615
|
-
nestedArgs = val as Record<string, unknown>;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (include) {
|
|
620
|
-
const val = include[relationMeta.name];
|
|
621
|
-
if (val === true) {
|
|
622
|
-
shouldLoad = true;
|
|
623
|
-
} else if (typeof val === "object" && val !== null) {
|
|
624
|
-
shouldLoad = true;
|
|
625
|
-
nestedArgs = val as Record<string, unknown>;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (shouldLoad) {
|
|
630
|
-
result.push({ relationMeta, nestedArgs });
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
return result;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function hasNestedRelations(params: { nestedArgs: Record<string, unknown> }): boolean {
|
|
638
|
-
const { nestedArgs } = params;
|
|
639
|
-
return (
|
|
640
|
-
nestedArgs.include !== undefined ||
|
|
641
|
-
(typeof nestedArgs.select === "object" && nestedArgs.select !== null)
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
719
|
/**
|
|
646
720
|
* Check whether a Prisma-style where object references any relation field
|
|
647
721
|
* on the given model. Walks AND / OR / NOT combinators recursively.
|
|
@@ -701,15 +775,17 @@ function resolveRelatedScalars(params: {
|
|
|
701
775
|
const omitArg = nestedArgs.omit as Record<string, boolean> | undefined;
|
|
702
776
|
|
|
703
777
|
if (selectArg) {
|
|
778
|
+
const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
704
779
|
const selected = relatedModelMeta.scalarFields.filter((f) => {
|
|
705
780
|
const val = selectArg[f.name];
|
|
706
781
|
return val === true || (typeof val === "object" && val !== null);
|
|
707
782
|
});
|
|
708
783
|
|
|
709
784
|
// Always include PK for relation stitching
|
|
785
|
+
const selectedNames = new Set(selected.map((f) => f.name));
|
|
710
786
|
for (const pkName of relatedModelMeta.primaryKey) {
|
|
711
|
-
if (!
|
|
712
|
-
const sf =
|
|
787
|
+
if (!selectedNames.has(pkName)) {
|
|
788
|
+
const sf = sfMap.get(pkName);
|
|
713
789
|
if (sf) selected.push(sf);
|
|
714
790
|
}
|
|
715
791
|
}
|