metal-orm 1.1.8 → 1.1.9
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 +1 -1
- package/dist/index.cjs +219 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -2
- package/dist/index.d.ts +47 -2
- package/dist/index.js +219 -1
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/query-builder/select/cursor-pagination.ts +323 -0
- package/src/query-builder/select.ts +42 -1
package/README.md
CHANGED
|
@@ -291,7 +291,7 @@ const listOpenTodos = selectFrom(todos).select(...defaultColumns);
|
|
|
291
291
|
|
|
292
292
|
That's it: schema, query, SQL, done.
|
|
293
293
|
|
|
294
|
-
If you are using the Level 2 runtime (`OrmSession`), `SelectQueryBuilder` also provides `count(session)
|
|
294
|
+
If you are using the Level 2 runtime (`OrmSession`), `SelectQueryBuilder` also provides `count(session)`, `executePaged(session, { page, pageSize })`, and `executeCursor(session, { first/after | last/before })` for common pagination patterns. See [docs/pagination.md](./docs/pagination.md) for offset pagination, eager-include pagination guards, and bidirectional cursor pagination.
|
|
295
295
|
|
|
296
296
|
#### Column pickers (preferred selection helpers)
|
|
297
297
|
|
package/dist/index.cjs
CHANGED
|
@@ -7714,6 +7714,193 @@ function buildWhereHasPredicate(env, context, relationFacet, createChildBuilder,
|
|
|
7714
7714
|
return negate ? notExists(finalSubAst) : exists(finalSubAst);
|
|
7715
7715
|
}
|
|
7716
7716
|
|
|
7717
|
+
// src/query-builder/select/cursor-pagination.ts
|
|
7718
|
+
function encodeCursor(payload) {
|
|
7719
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
7720
|
+
}
|
|
7721
|
+
function decodeCursor(cursor) {
|
|
7722
|
+
let parsed;
|
|
7723
|
+
try {
|
|
7724
|
+
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
7725
|
+
} catch {
|
|
7726
|
+
throw new Error("executeCursor: invalid cursor format");
|
|
7727
|
+
}
|
|
7728
|
+
if (typeof parsed !== "object" || parsed === null || parsed.v !== 2 || !Array.isArray(parsed.values) || typeof parsed.orderSig !== "string") {
|
|
7729
|
+
throw new Error("executeCursor: invalid cursor payload");
|
|
7730
|
+
}
|
|
7731
|
+
return parsed;
|
|
7732
|
+
}
|
|
7733
|
+
function buildOrderSignature(specs) {
|
|
7734
|
+
return specs.map((s) => `${s.table}.${s.column}:${s.direction}`).join(",");
|
|
7735
|
+
}
|
|
7736
|
+
function extractOrderSpecs(ast) {
|
|
7737
|
+
if (!ast.orderBy || ast.orderBy.length === 0) {
|
|
7738
|
+
throw new Error("executeCursor: ORDER BY is required for cursor pagination");
|
|
7739
|
+
}
|
|
7740
|
+
return ast.orderBy.map((ob) => {
|
|
7741
|
+
if (ob.nulls) {
|
|
7742
|
+
throw new Error("executeCursor: NULLS FIRST/LAST is not supported for cursor pagination");
|
|
7743
|
+
}
|
|
7744
|
+
const term = ob.term;
|
|
7745
|
+
if (!term || term.type !== "Column") {
|
|
7746
|
+
throw new Error(
|
|
7747
|
+
"executeCursor: only column references are supported in ORDER BY for cursor pagination"
|
|
7748
|
+
);
|
|
7749
|
+
}
|
|
7750
|
+
const col2 = term;
|
|
7751
|
+
return {
|
|
7752
|
+
table: col2.table,
|
|
7753
|
+
column: col2.name,
|
|
7754
|
+
valueKey: resolveOrderValueKey(ast, col2),
|
|
7755
|
+
direction: ob.direction
|
|
7756
|
+
};
|
|
7757
|
+
});
|
|
7758
|
+
}
|
|
7759
|
+
function resolveOrderValueKey(ast, col2) {
|
|
7760
|
+
const projectedColumn = ast.columns.find(
|
|
7761
|
+
(candidate) => candidate.type === "Column" && candidate.table === col2.table && candidate.name === col2.name
|
|
7762
|
+
);
|
|
7763
|
+
return projectedColumn?.alias ?? projectedColumn?.name ?? col2.alias ?? col2.name;
|
|
7764
|
+
}
|
|
7765
|
+
function buildKeysetPredicate(specs, values, mode) {
|
|
7766
|
+
if (values.length !== specs.length) {
|
|
7767
|
+
throw new Error("executeCursor: invalid cursor payload");
|
|
7768
|
+
}
|
|
7769
|
+
const branches = [];
|
|
7770
|
+
for (let i = 0; i < specs.length; i++) {
|
|
7771
|
+
const spec = specs[i];
|
|
7772
|
+
const colNode = { type: "Column", table: spec.table, name: spec.column };
|
|
7773
|
+
const value = values[i];
|
|
7774
|
+
if (value === null || value === void 0) {
|
|
7775
|
+
throw new Error("executeCursor: invalid cursor payload");
|
|
7776
|
+
}
|
|
7777
|
+
const literal = { type: "Literal", value };
|
|
7778
|
+
let operator;
|
|
7779
|
+
if (mode === "after") {
|
|
7780
|
+
operator = spec.direction === "ASC" ? ">" : "<";
|
|
7781
|
+
} else {
|
|
7782
|
+
operator = spec.direction === "ASC" ? "<" : ">";
|
|
7783
|
+
}
|
|
7784
|
+
const eqParts = [];
|
|
7785
|
+
for (let j = 0; j < i; j++) {
|
|
7786
|
+
const prevSpec = specs[j];
|
|
7787
|
+
const prevCol = { type: "Column", table: prevSpec.table, name: prevSpec.column };
|
|
7788
|
+
const prevValue = values[j];
|
|
7789
|
+
if (prevValue === null || prevValue === void 0) {
|
|
7790
|
+
throw new Error("executeCursor: invalid cursor payload");
|
|
7791
|
+
}
|
|
7792
|
+
const prevVal = { type: "Literal", value: prevValue };
|
|
7793
|
+
eqParts.push({
|
|
7794
|
+
type: "BinaryExpression",
|
|
7795
|
+
left: prevCol,
|
|
7796
|
+
operator: "=",
|
|
7797
|
+
right: prevVal
|
|
7798
|
+
});
|
|
7799
|
+
}
|
|
7800
|
+
const breakExpr = {
|
|
7801
|
+
type: "BinaryExpression",
|
|
7802
|
+
left: colNode,
|
|
7803
|
+
operator,
|
|
7804
|
+
right: literal
|
|
7805
|
+
};
|
|
7806
|
+
if (eqParts.length === 0) {
|
|
7807
|
+
branches.push(breakExpr);
|
|
7808
|
+
} else {
|
|
7809
|
+
branches.push(and(...eqParts, breakExpr));
|
|
7810
|
+
}
|
|
7811
|
+
}
|
|
7812
|
+
return branches.length === 1 ? branches[0] : or(...branches);
|
|
7813
|
+
}
|
|
7814
|
+
function buildCursorFromRow(row, specs) {
|
|
7815
|
+
const values = specs.map((spec) => {
|
|
7816
|
+
const value = row[spec.valueKey];
|
|
7817
|
+
if (value === null || value === void 0) {
|
|
7818
|
+
throw new Error("executeCursor: cursor pagination requires non-null ORDER BY values");
|
|
7819
|
+
}
|
|
7820
|
+
return value;
|
|
7821
|
+
});
|
|
7822
|
+
return encodeCursor({ v: 2, values, orderSig: buildOrderSignature(specs) });
|
|
7823
|
+
}
|
|
7824
|
+
function reverseDirection(direction) {
|
|
7825
|
+
return direction === "ASC" ? "DESC" : "ASC";
|
|
7826
|
+
}
|
|
7827
|
+
function createExecutionBuilder(builder, options) {
|
|
7828
|
+
const internals = builder.getInternals();
|
|
7829
|
+
const baseAst = internals.context.state.ast;
|
|
7830
|
+
const orderBy = options.reverseOrder && baseAst.orderBy ? baseAst.orderBy.map((order) => ({
|
|
7831
|
+
...order,
|
|
7832
|
+
direction: reverseDirection(order.direction)
|
|
7833
|
+
})) : baseAst.orderBy;
|
|
7834
|
+
const nextAst = {
|
|
7835
|
+
...baseAst,
|
|
7836
|
+
where: options.predicate ? baseAst.where ? and(baseAst.where, options.predicate) : options.predicate : baseAst.where,
|
|
7837
|
+
orderBy,
|
|
7838
|
+
limit: options.limit
|
|
7839
|
+
};
|
|
7840
|
+
const nextContext = {
|
|
7841
|
+
...internals.context,
|
|
7842
|
+
state: new SelectQueryState(builder.getTable(), nextAst)
|
|
7843
|
+
};
|
|
7844
|
+
return internals.clone(nextContext, internals.includeTree);
|
|
7845
|
+
}
|
|
7846
|
+
async function executeCursorQuery(builder, session, options) {
|
|
7847
|
+
const { first, after, last, before } = options;
|
|
7848
|
+
if (first != null && last != null) {
|
|
7849
|
+
throw new Error('executeCursor: "first" and "last" cannot be used together');
|
|
7850
|
+
}
|
|
7851
|
+
if (after != null && before != null) {
|
|
7852
|
+
throw new Error('executeCursor: "after" and "before" cannot be used together');
|
|
7853
|
+
}
|
|
7854
|
+
if (first == null && last == null) {
|
|
7855
|
+
throw new Error('executeCursor: either "first" or "last" must be provided');
|
|
7856
|
+
}
|
|
7857
|
+
const limit = first ?? last;
|
|
7858
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
7859
|
+
throw new Error(`executeCursor: "${first != null ? "first" : "last"}" must be an integer >= 1`);
|
|
7860
|
+
}
|
|
7861
|
+
const isBackward = last != null;
|
|
7862
|
+
const cursor = after ?? before;
|
|
7863
|
+
const ast = builder.getInternals().context.state.ast;
|
|
7864
|
+
const specs = extractOrderSpecs(ast);
|
|
7865
|
+
let predicate;
|
|
7866
|
+
if (cursor) {
|
|
7867
|
+
const decoded = decodeCursor(cursor);
|
|
7868
|
+
const expectedSig = buildOrderSignature(specs);
|
|
7869
|
+
if (decoded.orderSig !== expectedSig) {
|
|
7870
|
+
throw new Error(
|
|
7871
|
+
"executeCursor: cursor ORDER BY signature does not match the current query. The ORDER BY clause must remain the same between paginated requests."
|
|
7872
|
+
);
|
|
7873
|
+
}
|
|
7874
|
+
predicate = buildKeysetPredicate(specs, decoded.values, isBackward ? "before" : "after");
|
|
7875
|
+
}
|
|
7876
|
+
const executionBuilder = createExecutionBuilder(builder, {
|
|
7877
|
+
predicate,
|
|
7878
|
+
limit: limit + 1,
|
|
7879
|
+
reverseOrder: isBackward
|
|
7880
|
+
});
|
|
7881
|
+
const rows = await executionBuilder.execute(session);
|
|
7882
|
+
const hasExtraItem = rows.length > limit;
|
|
7883
|
+
if (hasExtraItem) {
|
|
7884
|
+
rows.pop();
|
|
7885
|
+
}
|
|
7886
|
+
const orderedRows = isBackward ? rows.reverse() : rows;
|
|
7887
|
+
const items = orderedRows;
|
|
7888
|
+
const hasItems = items.length > 0;
|
|
7889
|
+
const hasNextPage2 = hasItems ? isBackward ? before != null : hasExtraItem : false;
|
|
7890
|
+
const hasPreviousPage = hasItems ? isBackward ? hasExtraItem : after != null : false;
|
|
7891
|
+
const startCursor = hasItems ? buildCursorFromRow(items[0], specs) : null;
|
|
7892
|
+
const endCursor = hasItems ? buildCursorFromRow(items[items.length - 1], specs) : null;
|
|
7893
|
+
return {
|
|
7894
|
+
items,
|
|
7895
|
+
pageInfo: {
|
|
7896
|
+
hasNextPage: hasNextPage2,
|
|
7897
|
+
hasPreviousPage,
|
|
7898
|
+
startCursor,
|
|
7899
|
+
endCursor
|
|
7900
|
+
}
|
|
7901
|
+
};
|
|
7902
|
+
}
|
|
7903
|
+
|
|
7717
7904
|
// src/query-builder/select/from-facet.ts
|
|
7718
7905
|
var SelectFromFacet = class {
|
|
7719
7906
|
/**
|
|
@@ -8740,7 +8927,38 @@ var SelectQueryBuilder = class _SelectQueryBuilder {
|
|
|
8740
8927
|
return executePagedQuery(builder, session, options, (sess) => builder.count(sess));
|
|
8741
8928
|
}
|
|
8742
8929
|
/**
|
|
8743
|
-
* Executes the query
|
|
8930
|
+
* Executes the query using cursor-based (keyset) pagination.
|
|
8931
|
+
* Requires a stable ORDER BY on selected, non-null columns.
|
|
8932
|
+
* Cursor pagination currently supports simple column references only and
|
|
8933
|
+
* the cursor token is opaque: it must be reused with the same ORDER BY signature.
|
|
8934
|
+
*
|
|
8935
|
+
* @param session - ORM session context
|
|
8936
|
+
* @param options - Cursor pagination options (`first`/`after` or `last`/`before`)
|
|
8937
|
+
* @returns Promise of cursor-paginated result with items and pageInfo
|
|
8938
|
+
* @example
|
|
8939
|
+
* const page1 = await selectFrom(users)
|
|
8940
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
8941
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
8942
|
+
* .executeCursor(session, { first: 20 });
|
|
8943
|
+
*
|
|
8944
|
+
* // Next page
|
|
8945
|
+
* const page2 = await selectFrom(users)
|
|
8946
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
8947
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
8948
|
+
* .executeCursor(session, { first: 20, after: page1.pageInfo.endCursor });
|
|
8949
|
+
*
|
|
8950
|
+
* // Previous page from a known cursor
|
|
8951
|
+
* const prevPage = await selectFrom(users)
|
|
8952
|
+
* .orderBy(users.columns.createdAt, 'DESC')
|
|
8953
|
+
* .orderBy(users.columns.id, 'DESC')
|
|
8954
|
+
* .executeCursor(session, { last: 20, before: page2.pageInfo.startCursor });
|
|
8955
|
+
*/
|
|
8956
|
+
async executeCursor(session, options) {
|
|
8957
|
+
const builder = this.ensureDefaultSelection();
|
|
8958
|
+
return executeCursorQuery(builder, session, options);
|
|
8959
|
+
}
|
|
8960
|
+
/**
|
|
8961
|
+
* Executes the query and returns an array of values for a single column.
|
|
8744
8962
|
* This is a convenience method to avoid manual `.map(r => r.column)`.
|
|
8745
8963
|
*
|
|
8746
8964
|
* @param column - The column name to extract
|