metal-orm 1.1.8 → 1.1.10
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 +769 -764
- package/dist/index.cjs +2352 -226
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +605 -40
- package/dist/index.d.ts +605 -40
- package/dist/index.js +2324 -226
- package/dist/index.js.map +1 -1
- package/package.json +22 -17
- package/src/bulk/bulk-context.ts +83 -0
- package/src/bulk/bulk-delete-executor.ts +89 -0
- package/src/bulk/bulk-executor.base.ts +73 -0
- package/src/bulk/bulk-insert-executor.ts +74 -0
- package/src/bulk/bulk-types.ts +70 -0
- package/src/bulk/bulk-update-executor.ts +192 -0
- package/src/bulk/bulk-upsert-executor.ts +95 -0
- package/src/bulk/bulk-utils.ts +91 -0
- package/src/bulk/index.ts +18 -0
- package/src/codegen/typescript.ts +30 -21
- package/src/core/ast/expression-builders.ts +107 -10
- package/src/core/ast/expression-nodes.ts +52 -22
- package/src/core/ast/expression-visitor.ts +23 -13
- package/src/core/dialect/abstract.ts +30 -17
- package/src/core/dialect/mysql/index.ts +20 -5
- package/src/core/execution/db-executor.ts +96 -64
- package/src/core/execution/executors/better-sqlite3-executor.ts +94 -0
- package/src/core/execution/executors/mssql-executor.ts +66 -34
- package/src/core/execution/executors/mysql-executor.ts +98 -66
- package/src/core/execution/executors/postgres-executor.ts +33 -11
- package/src/core/execution/executors/sqlite-executor.ts +86 -30
- package/src/decorators/bootstrap.ts +482 -398
- package/src/decorators/column-decorator.ts +87 -96
- package/src/decorators/decorator-metadata.ts +100 -24
- package/src/decorators/entity.ts +27 -24
- package/src/decorators/relations.ts +231 -149
- package/src/decorators/transformers/transformer-decorators.ts +26 -29
- package/src/decorators/validators/country-validators-decorators.ts +9 -15
- package/src/dto/apply-filter.ts +568 -551
- package/src/index.ts +16 -9
- package/src/orm/entity-hydration.ts +116 -72
- package/src/orm/entity-metadata.ts +347 -301
- package/src/orm/entity-relations.ts +264 -207
- package/src/orm/entity.ts +199 -199
- package/src/orm/execute.ts +13 -13
- package/src/orm/lazy-batch/morph-many.ts +70 -0
- package/src/orm/lazy-batch/morph-one.ts +69 -0
- package/src/orm/lazy-batch/morph-to.ts +59 -0
- package/src/orm/lazy-batch.ts +4 -1
- package/src/orm/orm-session.ts +170 -104
- package/src/orm/pooled-executor-factory.ts +99 -58
- package/src/orm/query-logger.ts +49 -40
- package/src/orm/relation-change-processor.ts +198 -96
- package/src/orm/relations/belongs-to.ts +143 -143
- package/src/orm/relations/has-many.ts +204 -204
- package/src/orm/relations/has-one.ts +174 -174
- package/src/orm/relations/many-to-many.ts +288 -288
- package/src/orm/relations/morph-many.ts +156 -0
- package/src/orm/relations/morph-one.ts +151 -0
- package/src/orm/relations/morph-to.ts +162 -0
- package/src/orm/save-graph.ts +116 -1
- package/src/query-builder/expression-table-mapper.ts +5 -0
- package/src/query-builder/hydration-manager.ts +345 -345
- package/src/query-builder/hydration-planner.ts +178 -148
- package/src/query-builder/relation-conditions.ts +171 -151
- package/src/query-builder/relation-cte-builder.ts +5 -1
- package/src/query-builder/relation-filter-utils.ts +9 -6
- package/src/query-builder/relation-include-strategies.ts +44 -2
- package/src/query-builder/relation-join-strategies.ts +8 -1
- package/src/query-builder/relation-service.ts +250 -241
- package/src/query-builder/select/cursor-pagination.ts +323 -0
- package/src/query-builder/select/select-operations.ts +110 -105
- package/src/query-builder/select.ts +42 -1
- package/src/query-builder/update-include.ts +4 -0
- package/src/schema/relation.ts +296 -188
- package/src/schema/types.ts +138 -123
- package/src/tree/tree-decorator.ts +127 -137
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { TableDef } from '../../schema/table.js';
|
|
2
|
+
import { SelectQueryNode, OrderByNode } from '../../core/ast/query.js';
|
|
3
|
+
import {
|
|
4
|
+
ColumnNode,
|
|
5
|
+
LiteralNode,
|
|
6
|
+
ExpressionNode,
|
|
7
|
+
and,
|
|
8
|
+
or
|
|
9
|
+
} from '../../core/ast/expression.js';
|
|
10
|
+
import { OrmSession } from '../../orm/orm-session.js';
|
|
11
|
+
import { SelectQueryState } from '../select-query-state.js';
|
|
12
|
+
import type { SelectQueryBuilder } from '../select.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Public types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export type CursorPageOptions = {
|
|
19
|
+
first?: number;
|
|
20
|
+
after?: string;
|
|
21
|
+
last?: number;
|
|
22
|
+
before?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CursorPageInfo = {
|
|
26
|
+
hasNextPage: boolean;
|
|
27
|
+
hasPreviousPage: boolean;
|
|
28
|
+
startCursor: string | null;
|
|
29
|
+
endCursor: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CursorPageResult<T> = {
|
|
33
|
+
items: T[];
|
|
34
|
+
pageInfo: CursorPageInfo;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Internal types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
interface CursorOrderSpec {
|
|
42
|
+
table: string;
|
|
43
|
+
column: string;
|
|
44
|
+
valueKey: string;
|
|
45
|
+
direction: 'ASC' | 'DESC';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EncodedCursor {
|
|
49
|
+
v: 2;
|
|
50
|
+
values: unknown[];
|
|
51
|
+
orderSig: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export function encodeCursor(payload: EncodedCursor): string {
|
|
59
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function decodeCursor(cursor: string): EncodedCursor {
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error('executeCursor: invalid cursor format');
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
typeof parsed !== 'object' || parsed === null ||
|
|
71
|
+
(parsed as EncodedCursor).v !== 2 ||
|
|
72
|
+
!Array.isArray((parsed as EncodedCursor).values) ||
|
|
73
|
+
typeof (parsed as EncodedCursor).orderSig !== 'string'
|
|
74
|
+
) {
|
|
75
|
+
throw new Error('executeCursor: invalid cursor payload');
|
|
76
|
+
}
|
|
77
|
+
return parsed as EncodedCursor;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildOrderSignature(specs: CursorOrderSpec[]): string {
|
|
81
|
+
return specs.map(s => `${s.table}.${s.column}:${s.direction}`).join(',');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractOrderSpecs(ast: SelectQueryNode): CursorOrderSpec[] {
|
|
85
|
+
if (!ast.orderBy || ast.orderBy.length === 0) {
|
|
86
|
+
throw new Error('executeCursor: ORDER BY is required for cursor pagination');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return ast.orderBy.map((ob: OrderByNode) => {
|
|
90
|
+
if (ob.nulls) {
|
|
91
|
+
throw new Error('executeCursor: NULLS FIRST/LAST is not supported for cursor pagination');
|
|
92
|
+
}
|
|
93
|
+
const term = ob.term;
|
|
94
|
+
if (!term || (term as ColumnNode).type !== 'Column') {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'executeCursor: only column references are supported in ORDER BY for cursor pagination'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const col = term as ColumnNode;
|
|
100
|
+
return {
|
|
101
|
+
table: col.table,
|
|
102
|
+
column: col.name,
|
|
103
|
+
valueKey: resolveOrderValueKey(ast, col),
|
|
104
|
+
direction: ob.direction
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveOrderValueKey(ast: SelectQueryNode, col: ColumnNode): string {
|
|
110
|
+
const projectedColumn = ast.columns.find((candidate): candidate is ColumnNode =>
|
|
111
|
+
candidate.type === 'Column' &&
|
|
112
|
+
candidate.table === col.table &&
|
|
113
|
+
candidate.name === col.name
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return projectedColumn?.alias ?? projectedColumn?.name ?? col.alias ?? col.name;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildKeysetPredicate(
|
|
120
|
+
specs: CursorOrderSpec[],
|
|
121
|
+
values: unknown[],
|
|
122
|
+
mode: 'after' | 'before'
|
|
123
|
+
): ExpressionNode {
|
|
124
|
+
if (values.length !== specs.length) {
|
|
125
|
+
throw new Error('executeCursor: invalid cursor payload');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// For a multi-column keyset (c1 DESC, c2 DESC) with mode='after':
|
|
129
|
+
// (c1 < v1) OR (c1 = v1 AND c2 < v2)
|
|
130
|
+
// 'after' on DESC → use '<'; 'after' on ASC → use '>'
|
|
131
|
+
// 'before' inverts the operators.
|
|
132
|
+
|
|
133
|
+
const branches: ExpressionNode[] = [];
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < specs.length; i++) {
|
|
136
|
+
const spec = specs[i];
|
|
137
|
+
const colNode: ColumnNode = { type: 'Column', table: spec.table, name: spec.column };
|
|
138
|
+
const value = values[i];
|
|
139
|
+
if (value === null || value === undefined) {
|
|
140
|
+
throw new Error('executeCursor: invalid cursor payload');
|
|
141
|
+
}
|
|
142
|
+
const literal: LiteralNode = { type: 'Literal', value: value as LiteralNode['value'] };
|
|
143
|
+
|
|
144
|
+
// Determine the comparison operator for the "breaking" column
|
|
145
|
+
let operator: '>' | '<';
|
|
146
|
+
if (mode === 'after') {
|
|
147
|
+
operator = spec.direction === 'ASC' ? '>' : '<';
|
|
148
|
+
} else {
|
|
149
|
+
operator = spec.direction === 'ASC' ? '<' : '>';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build equality prefix: c0 = v0 AND c1 = v1 AND ... AND c(i-1) = v(i-1)
|
|
153
|
+
const eqParts: ExpressionNode[] = [];
|
|
154
|
+
for (let j = 0; j < i; j++) {
|
|
155
|
+
const prevSpec = specs[j];
|
|
156
|
+
const prevCol: ColumnNode = { type: 'Column', table: prevSpec.table, name: prevSpec.column };
|
|
157
|
+
const prevValue = values[j];
|
|
158
|
+
if (prevValue === null || prevValue === undefined) {
|
|
159
|
+
throw new Error('executeCursor: invalid cursor payload');
|
|
160
|
+
}
|
|
161
|
+
const prevVal: LiteralNode = { type: 'Literal', value: prevValue as LiteralNode['value'] };
|
|
162
|
+
eqParts.push({
|
|
163
|
+
type: 'BinaryExpression',
|
|
164
|
+
left: prevCol,
|
|
165
|
+
operator: '=',
|
|
166
|
+
right: prevVal
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The "breaking" comparison: ci <op> vi
|
|
171
|
+
const breakExpr: ExpressionNode = {
|
|
172
|
+
type: 'BinaryExpression',
|
|
173
|
+
left: colNode,
|
|
174
|
+
operator,
|
|
175
|
+
right: literal
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (eqParts.length === 0) {
|
|
179
|
+
branches.push(breakExpr);
|
|
180
|
+
} else {
|
|
181
|
+
branches.push(and(...eqParts, breakExpr));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return branches.length === 1 ? branches[0] : or(...branches);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildCursorFromRow(row: Record<string, unknown>, specs: CursorOrderSpec[]): string {
|
|
189
|
+
const values = specs.map(spec => {
|
|
190
|
+
const value = row[spec.valueKey];
|
|
191
|
+
if (value === null || value === undefined) {
|
|
192
|
+
throw new Error('executeCursor: cursor pagination requires non-null ORDER BY values');
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
});
|
|
196
|
+
return encodeCursor({ v: 2, values, orderSig: buildOrderSignature(specs) });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function reverseDirection(direction: 'ASC' | 'DESC'): 'ASC' | 'DESC' {
|
|
200
|
+
return direction === 'ASC' ? 'DESC' : 'ASC';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createExecutionBuilder<T, TTable extends TableDef>(
|
|
204
|
+
builder: SelectQueryBuilder<T, TTable>,
|
|
205
|
+
options: {
|
|
206
|
+
predicate?: ExpressionNode;
|
|
207
|
+
limit: number;
|
|
208
|
+
reverseOrder: boolean;
|
|
209
|
+
}
|
|
210
|
+
): SelectQueryBuilder<T, TTable> {
|
|
211
|
+
const internals = builder.getInternals();
|
|
212
|
+
const baseAst = internals.context.state.ast;
|
|
213
|
+
|
|
214
|
+
const orderBy = options.reverseOrder && baseAst.orderBy
|
|
215
|
+
? baseAst.orderBy.map(order => ({
|
|
216
|
+
...order,
|
|
217
|
+
direction: reverseDirection(order.direction)
|
|
218
|
+
}))
|
|
219
|
+
: baseAst.orderBy;
|
|
220
|
+
|
|
221
|
+
const nextAst: SelectQueryNode = {
|
|
222
|
+
...baseAst,
|
|
223
|
+
where: options.predicate
|
|
224
|
+
? (baseAst.where ? and(baseAst.where, options.predicate) : options.predicate)
|
|
225
|
+
: baseAst.where,
|
|
226
|
+
orderBy,
|
|
227
|
+
limit: options.limit
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const nextContext = {
|
|
231
|
+
...internals.context,
|
|
232
|
+
state: new SelectQueryState(builder.getTable(), nextAst)
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return internals.clone(nextContext, internals.includeTree);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Main executor
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export async function executeCursorQuery<T, TTable extends TableDef>(
|
|
243
|
+
builder: SelectQueryBuilder<T, TTable>,
|
|
244
|
+
session: OrmSession,
|
|
245
|
+
options: CursorPageOptions
|
|
246
|
+
): Promise<CursorPageResult<T>> {
|
|
247
|
+
const { first, after, last, before } = options;
|
|
248
|
+
|
|
249
|
+
// --- Validation ---
|
|
250
|
+
if (first != null && last != null) {
|
|
251
|
+
throw new Error('executeCursor: "first" and "last" cannot be used together');
|
|
252
|
+
}
|
|
253
|
+
if (after != null && before != null) {
|
|
254
|
+
throw new Error('executeCursor: "after" and "before" cannot be used together');
|
|
255
|
+
}
|
|
256
|
+
if (first == null && last == null) {
|
|
257
|
+
throw new Error('executeCursor: either "first" or "last" must be provided');
|
|
258
|
+
}
|
|
259
|
+
const limit = first ?? last!;
|
|
260
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
261
|
+
throw new Error(`executeCursor: "${first != null ? 'first' : 'last'}" must be an integer >= 1`);
|
|
262
|
+
}
|
|
263
|
+
const isBackward = last != null;
|
|
264
|
+
const cursor = after ?? before;
|
|
265
|
+
|
|
266
|
+
// --- Extract order specs from builder AST ---
|
|
267
|
+
const ast = builder.getInternals().context.state.ast;
|
|
268
|
+
const specs = extractOrderSpecs(ast);
|
|
269
|
+
|
|
270
|
+
// --- Apply cursor predicate if present ---
|
|
271
|
+
let predicate: ExpressionNode | undefined;
|
|
272
|
+
if (cursor) {
|
|
273
|
+
const decoded = decodeCursor(cursor);
|
|
274
|
+
const expectedSig = buildOrderSignature(specs);
|
|
275
|
+
if (decoded.orderSig !== expectedSig) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
'executeCursor: cursor ORDER BY signature does not match the current query. ' +
|
|
278
|
+
'The ORDER BY clause must remain the same between paginated requests.'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
predicate = buildKeysetPredicate(specs, decoded.values, isBackward ? 'before' : 'after');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Fetch limit + 1 to detect hasNextPage ---
|
|
285
|
+
const executionBuilder = createExecutionBuilder(builder, {
|
|
286
|
+
predicate,
|
|
287
|
+
limit: limit + 1,
|
|
288
|
+
reverseOrder: isBackward
|
|
289
|
+
});
|
|
290
|
+
const rows = await executionBuilder.execute(session);
|
|
291
|
+
|
|
292
|
+
const hasExtraItem = rows.length > limit;
|
|
293
|
+
if (hasExtraItem) {
|
|
294
|
+
rows.pop();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const orderedRows = isBackward ? rows.reverse() : rows;
|
|
298
|
+
const items = orderedRows as (T & Record<string, unknown>)[];
|
|
299
|
+
const hasItems = items.length > 0;
|
|
300
|
+
const hasNextPage = hasItems
|
|
301
|
+
? (isBackward ? before != null : hasExtraItem)
|
|
302
|
+
: false;
|
|
303
|
+
const hasPreviousPage = hasItems
|
|
304
|
+
? (isBackward ? hasExtraItem : after != null)
|
|
305
|
+
: false;
|
|
306
|
+
|
|
307
|
+
const startCursor = hasItems
|
|
308
|
+
? buildCursorFromRow(items[0] as Record<string, unknown>, specs)
|
|
309
|
+
: null;
|
|
310
|
+
const endCursor = hasItems
|
|
311
|
+
? buildCursorFromRow(items[items.length - 1] as Record<string, unknown>, specs)
|
|
312
|
+
: null;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
items,
|
|
316
|
+
pageInfo: {
|
|
317
|
+
hasNextPage,
|
|
318
|
+
hasPreviousPage,
|
|
319
|
+
startCursor,
|
|
320
|
+
endCursor
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TableDef } from '../../schema/table.js';
|
|
2
|
+
import { isSingleTargetRelation } from '../../schema/relation.js';
|
|
2
3
|
import { ColumnDef } from '../../schema/column-types.js';
|
|
3
4
|
import { OrderingTerm, SelectQueryNode } from '../../core/ast/query.js';
|
|
4
5
|
import { ColumnNode, FunctionNode, ExpressionNode, exists, notExists } from '../../core/ast/expression.js';
|
|
@@ -12,35 +13,35 @@ import { OrmSession } from '../../orm/orm-session.js';
|
|
|
12
13
|
import type { SelectQueryBuilder } from '../select.js';
|
|
13
14
|
import { findPrimaryKey } from '../hydration-planner.js';
|
|
14
15
|
import { payloadResultSets } from '../../core/execution/db-executor.js';
|
|
15
|
-
|
|
16
|
-
export type WhereHasOptions = {
|
|
17
|
-
correlate?: ExpressionNode;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type RelationCallback = <TChildTable extends TableDef>(
|
|
21
|
-
qb: SelectQueryBuilder<unknown, TChildTable>
|
|
22
|
-
) => SelectQueryBuilder<unknown, TChildTable>;
|
|
23
|
-
|
|
24
|
-
type ChildBuilderFactory = <R, TChild extends TableDef>(table: TChild) => SelectQueryBuilder<R, TChild>;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Builds a new query context with an ORDER BY clause applied.
|
|
28
|
-
*/
|
|
29
|
-
export function applyOrderBy(
|
|
30
|
-
context: SelectQueryBuilderContext,
|
|
31
|
-
predicateFacet: SelectPredicateFacet,
|
|
32
|
-
term: ColumnDef | OrderingTerm,
|
|
33
|
-
directionOrOptions: OrderDirection | { direction?: OrderDirection; nulls?: 'FIRST' | 'LAST'; collation?: string }
|
|
34
|
-
): SelectQueryBuilderContext {
|
|
35
|
-
const options =
|
|
36
|
-
typeof directionOrOptions === 'string' ? { direction: directionOrOptions } : directionOrOptions;
|
|
37
|
-
const dir = options.direction ?? ORDER_DIRECTIONS.ASC;
|
|
38
|
-
return predicateFacet.orderBy(context, term, dir, options.nulls, options.collation);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Runs the count query for the provided context and session.
|
|
43
|
-
*/
|
|
16
|
+
|
|
17
|
+
export type WhereHasOptions = {
|
|
18
|
+
correlate?: ExpressionNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RelationCallback = <TChildTable extends TableDef>(
|
|
22
|
+
qb: SelectQueryBuilder<unknown, TChildTable>
|
|
23
|
+
) => SelectQueryBuilder<unknown, TChildTable>;
|
|
24
|
+
|
|
25
|
+
type ChildBuilderFactory = <R, TChild extends TableDef>(table: TChild) => SelectQueryBuilder<R, TChild>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds a new query context with an ORDER BY clause applied.
|
|
29
|
+
*/
|
|
30
|
+
export function applyOrderBy(
|
|
31
|
+
context: SelectQueryBuilderContext,
|
|
32
|
+
predicateFacet: SelectPredicateFacet,
|
|
33
|
+
term: ColumnDef | OrderingTerm,
|
|
34
|
+
directionOrOptions: OrderDirection | { direction?: OrderDirection; nulls?: 'FIRST' | 'LAST'; collation?: string }
|
|
35
|
+
): SelectQueryBuilderContext {
|
|
36
|
+
const options =
|
|
37
|
+
typeof directionOrOptions === 'string' ? { direction: directionOrOptions } : directionOrOptions;
|
|
38
|
+
const dir = options.direction ?? ORDER_DIRECTIONS.ASC;
|
|
39
|
+
return predicateFacet.orderBy(context, term, dir, options.nulls, options.collation);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs the count query for the provided context and session.
|
|
44
|
+
*/
|
|
44
45
|
export async function executeCount(
|
|
45
46
|
context: SelectQueryBuilderContext,
|
|
46
47
|
env: SelectQueryBuilderEnvironment,
|
|
@@ -103,10 +104,10 @@ export async function executeCount(
|
|
|
103
104
|
const payload = await execCtx.interceptors.run({ sql: compiled.sql, params: compiled.params }, execCtx.executor);
|
|
104
105
|
const results = payloadResultSets(payload);
|
|
105
106
|
const value = results[0]?.values?.[0]?.[0];
|
|
106
|
-
|
|
107
|
-
if (typeof value === 'number') return value;
|
|
108
|
-
if (typeof value === 'bigint') return Number(value);
|
|
109
|
-
if (typeof value === 'string') return Number(value);
|
|
107
|
+
|
|
108
|
+
if (typeof value === 'number') return value;
|
|
109
|
+
if (typeof value === 'bigint') return Number(value);
|
|
110
|
+
if (typeof value === 'string') return Number(value);
|
|
110
111
|
return value === null || value === undefined ? 0 : Number(value);
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -156,75 +157,79 @@ export async function executeCountRows(
|
|
|
156
157
|
if (typeof value === 'string') return Number(value);
|
|
157
158
|
return value === null || value === undefined ? 0 : Number(value);
|
|
158
159
|
}
|
|
159
|
-
|
|
160
|
-
export interface PaginatedResult<T> {
|
|
161
|
-
items: T[];
|
|
162
|
-
totalItems: number;
|
|
163
|
-
page: number;
|
|
164
|
-
pageSize: number;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Executes paged queries using the provided builder helpers.
|
|
169
|
-
*/
|
|
170
|
-
export async function executePagedQuery<T, TTable extends TableDef>(
|
|
171
|
-
builder: SelectQueryBuilder<T, TTable>,
|
|
172
|
-
session: OrmSession,
|
|
173
|
-
options: { page: number; pageSize: number },
|
|
174
|
-
countCallback: (session: OrmSession) => Promise<number>
|
|
175
|
-
): Promise<PaginatedResult<T>> {
|
|
176
|
-
const { page, pageSize } = options;
|
|
177
|
-
|
|
178
|
-
if (!Number.isInteger(page) || page < 1) {
|
|
179
|
-
throw new Error('executePaged: page must be an integer >= 1');
|
|
180
|
-
}
|
|
181
|
-
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
182
|
-
throw new Error('executePaged: pageSize must be an integer >= 1');
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const offset = (page - 1) * pageSize;
|
|
186
|
-
|
|
187
|
-
const totalItems = await countCallback(session);
|
|
188
|
-
const items = await builder.limit(pageSize).offset(offset).execute(session);
|
|
189
|
-
|
|
190
|
-
return { items, totalItems, page, pageSize };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Builds an EXISTS or NOT EXISTS predicate for a related table.
|
|
195
|
-
*/
|
|
196
|
-
export function buildWhereHasPredicate<TTable extends TableDef>(
|
|
197
|
-
env: SelectQueryBuilderEnvironment,
|
|
198
|
-
context: SelectQueryBuilderContext,
|
|
199
|
-
relationFacet: SelectRelationFacet,
|
|
200
|
-
createChildBuilder: ChildBuilderFactory,
|
|
201
|
-
relationName: keyof TTable['relations'] & string,
|
|
202
|
-
callbackOrOptions?: RelationCallback | WhereHasOptions,
|
|
203
|
-
maybeOptions?: WhereHasOptions,
|
|
204
|
-
negate = false
|
|
205
|
-
): ExpressionNode {
|
|
206
|
-
const relation = env.table.relations[relationName as string];
|
|
207
|
-
if (!relation) {
|
|
208
|
-
throw new Error(`Relation '${relationName}' not found on table '${env.table.name}'`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const callback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined;
|
|
212
|
-
const options = (typeof callbackOrOptions === 'function' ? maybeOptions : callbackOrOptions) as
|
|
213
|
-
| WhereHasOptions
|
|
214
|
-
| undefined;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
160
|
+
|
|
161
|
+
export interface PaginatedResult<T> {
|
|
162
|
+
items: T[];
|
|
163
|
+
totalItems: number;
|
|
164
|
+
page: number;
|
|
165
|
+
pageSize: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Executes paged queries using the provided builder helpers.
|
|
170
|
+
*/
|
|
171
|
+
export async function executePagedQuery<T, TTable extends TableDef>(
|
|
172
|
+
builder: SelectQueryBuilder<T, TTable>,
|
|
173
|
+
session: OrmSession,
|
|
174
|
+
options: { page: number; pageSize: number },
|
|
175
|
+
countCallback: (session: OrmSession) => Promise<number>
|
|
176
|
+
): Promise<PaginatedResult<T>> {
|
|
177
|
+
const { page, pageSize } = options;
|
|
178
|
+
|
|
179
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
180
|
+
throw new Error('executePaged: page must be an integer >= 1');
|
|
181
|
+
}
|
|
182
|
+
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
183
|
+
throw new Error('executePaged: pageSize must be an integer >= 1');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const offset = (page - 1) * pageSize;
|
|
187
|
+
|
|
188
|
+
const totalItems = await countCallback(session);
|
|
189
|
+
const items = await builder.limit(pageSize).offset(offset).execute(session);
|
|
190
|
+
|
|
191
|
+
return { items, totalItems, page, pageSize };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Builds an EXISTS or NOT EXISTS predicate for a related table.
|
|
196
|
+
*/
|
|
197
|
+
export function buildWhereHasPredicate<TTable extends TableDef>(
|
|
198
|
+
env: SelectQueryBuilderEnvironment,
|
|
199
|
+
context: SelectQueryBuilderContext,
|
|
200
|
+
relationFacet: SelectRelationFacet,
|
|
201
|
+
createChildBuilder: ChildBuilderFactory,
|
|
202
|
+
relationName: keyof TTable['relations'] & string,
|
|
203
|
+
callbackOrOptions?: RelationCallback | WhereHasOptions,
|
|
204
|
+
maybeOptions?: WhereHasOptions,
|
|
205
|
+
negate = false
|
|
206
|
+
): ExpressionNode {
|
|
207
|
+
const relation = env.table.relations[relationName as string];
|
|
208
|
+
if (!relation) {
|
|
209
|
+
throw new Error(`Relation '${relationName}' not found on table '${env.table.name}'`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const callback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined;
|
|
213
|
+
const options = (typeof callbackOrOptions === 'function' ? maybeOptions : callbackOrOptions) as
|
|
214
|
+
| WhereHasOptions
|
|
215
|
+
| undefined;
|
|
216
|
+
|
|
217
|
+
if (!isSingleTargetRelation(relation)) {
|
|
218
|
+
throw new Error(`Polymorphic relation '${relationName}' does not support whereHas/whereHasNot`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let subQb = createChildBuilder<unknown, TableDef>(relation.target);
|
|
222
|
+
if (callback) {
|
|
223
|
+
subQb = callback(subQb);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const subAst = subQb.getAST();
|
|
227
|
+
const finalSubAst = relationFacet.applyRelationCorrelation(
|
|
228
|
+
context,
|
|
229
|
+
relationName,
|
|
230
|
+
subAst,
|
|
231
|
+
options?.correlate
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return negate ? notExists(finalSubAst) : exists(finalSubAst);
|
|
235
|
+
}
|
|
@@ -61,6 +61,12 @@ import {
|
|
|
61
61
|
WhereHasOptions
|
|
62
62
|
} from './select/select-operations.js';
|
|
63
63
|
export type { PaginatedResult };
|
|
64
|
+
import {
|
|
65
|
+
executeCursorQuery,
|
|
66
|
+
CursorPageOptions,
|
|
67
|
+
CursorPageResult
|
|
68
|
+
} from './select/cursor-pagination.js';
|
|
69
|
+
export type { CursorPageOptions, CursorPageResult, CursorPageInfo } from './select/cursor-pagination.js';
|
|
64
70
|
import { SelectFromFacet } from './select/from-facet.js';
|
|
65
71
|
import { SelectJoinFacet } from './select/join-facet.js';
|
|
66
72
|
import { SelectProjectionFacet } from './select/projection-facet.js';
|
|
@@ -946,7 +952,42 @@ export class SelectQueryBuilder<T = EntityInstance<TableDef>, TTable extends Tab
|
|
|
946
952
|
}
|
|
947
953
|
|
|
948
954
|
/**
|
|
949
|
-
* Executes the query
|
|
955
|
+
* Executes the query using cursor-based (keyset) pagination.
|
|
956
|
+
* Requires a stable ORDER BY on selected, non-null columns.
|
|
957
|
+
* Cursor pagination currently supports simple column references only and
|
|
958
|
+
* the cursor token is opaque: it must be reused with the same ORDER BY signature.
|
|
959
|
+
*
|
|
960
|
+
* @param session - ORM session context
|
|
961
|
+
* @param options - Cursor pagination options (`first`/`after` or `last`/`before`)
|
|
962
|
+
* @returns Promise of cursor-paginated result with items and pageInfo
|
|
963
|
+
* @example
|
|
964
|
+
* const page1 = await selectFrom(users)
|
|
965
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
966
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
967
|
+
* .executeCursor(session, { first: 20 });
|
|
968
|
+
*
|
|
969
|
+
* // Next page
|
|
970
|
+
* const page2 = await selectFrom(users)
|
|
971
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
972
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
973
|
+
* .executeCursor(session, { first: 20, after: page1.pageInfo.endCursor });
|
|
974
|
+
*
|
|
975
|
+
* // Previous page from a known cursor
|
|
976
|
+
* const prevPage = await selectFrom(users)
|
|
977
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
978
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
979
|
+
* .executeCursor(session, { last: 20, before: page2.pageInfo.startCursor });
|
|
980
|
+
*/
|
|
981
|
+
async executeCursor(
|
|
982
|
+
session: OrmSession,
|
|
983
|
+
options: CursorPageOptions
|
|
984
|
+
): Promise<CursorPageResult<T>> {
|
|
985
|
+
const builder = this.ensureDefaultSelection();
|
|
986
|
+
return executeCursorQuery(builder, session, options);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Executes the query and returns an array of values for a single column.
|
|
950
991
|
* This is a convenience method to avoid manual `.map(r => r.column)`.
|
|
951
992
|
*
|
|
952
993
|
* @param column - The column name to extract
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { JOIN_KINDS } from '../core/sql/sql.js';
|
|
2
2
|
import type { TableDef } from '../schema/table.js';
|
|
3
|
+
import { isSingleTargetRelation } from '../schema/relation.js';
|
|
3
4
|
import { findJoinByRelationKey, findJoinIndexByRelationKey } from './join-utils.js';
|
|
4
5
|
import { addRelationJoin, updateRelationJoin } from './relation-join-strategies.js';
|
|
5
6
|
import { getExposedName } from './table-alias-utils.js';
|
|
@@ -92,6 +93,9 @@ export const updateInclude = <T, TTable extends TableDef>(
|
|
|
92
93
|
});
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
if (!isSingleTargetRelation(relation)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
95
99
|
const joinForSegment = findJoinByRelationKey(state.ast.joins, relationKey);
|
|
96
100
|
currentAlias = joinForSegment ? (getExposedName(joinForSegment.table) ?? relation.target.name) : relation.target.name;
|
|
97
101
|
currentTable = relation.target;
|