typeorm-extensions 2.2.0-rc.10 → 2.2.0-rc.11

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 CHANGED
@@ -57,8 +57,8 @@ npm install typeorm-extensions
57
57
 
58
58
  **Pagination** ([docs](https://koenigstag.github.io/typeorm-extensions/docs/api/pagination/))
59
59
  - `getManyWithTotals` and `getRawManyWithTotals`: the rows of a page, the total, `page` / `pageSize`, `hasMore` and the order it was read in
60
- - `getManyWithCursor`: keyset pagination with an opaque cursor
61
- - `applyPaginationFilter` for a page of any query
60
+ - `getManyWithCursor` and `getRawManyWithCursor`: keyset pagination with an opaque cursor, by columns or by SQL expressions
61
+ - `applyPagination` for a page of any query
62
62
 
63
63
  **Filters and ordering** ([docs](https://koenigstag.github.io/typeorm-extensions/docs/api/where/))
64
64
  - `whereIsIn` / `whereIsNotIn` that handle empty arrays, `whereIsAny`, null-safe `whereIsDistinctFrom`
@@ -1,6 +1,6 @@
1
1
  import { ObjectLiteral } from 'typeorm';
2
2
  import type { CursorPaginationFilter } from '../types/interfaces/cursor-pagination.interface';
3
- import type { GetManyWithCursorOptions, ListWithCursor } from '../types/extensions/cursor-pagination.types';
3
+ import type { GetManyWithCursorOptions, GetRawManyWithCursorOptions, ListWithCursor } from '../types/extensions/cursor-pagination.types';
4
4
  export { InvalidCursorError } from '../utils/cursor.utils';
5
5
  declare module 'typeorm/query-builder/SelectQueryBuilder' {
6
6
  interface SelectQueryBuilder<Entity> {
@@ -16,5 +16,17 @@ declare module 'typeorm/query-builder/SelectQueryBuilder' {
16
16
  * be decoded or was made for another order.
17
17
  */
18
18
  getManyWithCursor<CursorEntity extends ObjectLiteral = Entity>(this: SelectQueryBuilder<CursorEntity>, cursorFilter: CursorPaginationFilter | null | undefined, options: GetManyWithCursorOptions<CursorEntity>): Promise<ListWithCursor<CursorEntity>>;
19
+ /**
20
+ * `getManyWithCursor` for raw rows (`getRawMany()`), ordered by SQL expressions, so a computed
21
+ * value can be a key too: `CASE "member"."role" WHEN 'owner' THEN 0 ELSE 1 END`.
22
+ *
23
+ * Orders the query by `options.orderBy`, replacing its ORDER BY, and appends the primary key of
24
+ * the main alias when the query is on an entity and has no GROUP BY or DISTINCT. Loads
25
+ * `pageSize + 1` rows with LIMIT and returns `nextCursor` together with `hasMore`. The cursor
26
+ * names the keys by a hash of their SQL, not the SQL itself.
27
+ * The query builder itself is not changed. Throws `InvalidCursorError` for a cursor that can't
28
+ * be decoded or was made for another order.
29
+ */
30
+ getRawManyWithCursor<Row = ObjectLiteral>(cursorFilter: CursorPaginationFilter | null | undefined, options: GetRawManyWithCursorOptions): Promise<ListWithCursor<Row>>;
19
31
  }
20
32
  }
@@ -8,6 +8,7 @@ const order_utils_1 = require("../utils/order.utils");
8
8
  const where_utils_1 = require("../utils/where.utils");
9
9
  const sql_utils_1 = require("../utils/sql.utils");
10
10
  const query_builder_utils_1 = require("../utils/query-builder.utils");
11
+ const common_utils_1 = require("../utils/common.utils");
11
12
  const cursor_utils_1 = require("../utils/cursor.utils");
12
13
  var cursor_utils_2 = require("../utils/cursor.utils");
13
14
  Object.defineProperty(exports, "InvalidCursorError", { enumerable: true, get: function () { return cursor_utils_2.InvalidCursorError; } });
@@ -17,6 +18,9 @@ const extension = {
17
18
  getManyWithCursor(cursorFilter, options) {
18
19
  return getManyWithCursor(this, cursorFilter, options);
19
20
  },
21
+ getRawManyWithCursor(cursorFilter, options) {
22
+ return getRawManyWithCursor(this, cursorFilter, options);
23
+ },
20
24
  },
21
25
  };
22
26
  (0, prototype_utils_1.patchPrototype)(SelectQueryBuilder_1.SelectQueryBuilder, extension);
@@ -101,3 +105,82 @@ async function getManyWithCursor(builder, cursorFilter, options) {
101
105
  });
102
106
  return { list, nextCursor: (0, cursor_utils_1.encodeCursor)(fields, values), hasMore: true, pageSize, orderBy: appliedOrderBy };
103
107
  }
108
+ /** The expressions of `orderBy`, followed by the primary key of the main alias when it identifies a row. */
109
+ function getExpressionKeys(builder, orderBy) {
110
+ var _a;
111
+ const keys = (orderBy !== null && orderBy !== void 0 ? orderBy : []).map(({ selector, direction }) => {
112
+ if (typeof selector !== 'string' || !selector.trim()) {
113
+ throw new TypeError('getRawManyWithCursor: every orderBy key needs a selector');
114
+ }
115
+ return { selector, direction: (0, order_utils_1.toSqlOrderDirection)(direction) };
116
+ });
117
+ const { expressionMap } = builder;
118
+ const mainAlias = expressionMap.mainAlias;
119
+ // grouped or distinct rows are not one per entity, and their primary key may not even be selectable
120
+ const isRowPerEntity = expressionMap.groupBys.length === 0 &&
121
+ !expressionMap.selectDistinct &&
122
+ !((_a = expressionMap.selectDistinctOn) === null || _a === void 0 ? void 0 : _a.length);
123
+ if ((mainAlias === null || mainAlias === void 0 ? void 0 : mainAlias.hasMetadata) && isRowPerEntity) {
124
+ const lastDirection = keys.length ? keys[keys.length - 1].direction : 'ASC';
125
+ for (const column of mainAlias.metadata.primaryColumns) {
126
+ const quoted = `${builder.escape(mainAlias.name)}.${builder.escape(column.databaseName)}`;
127
+ const path = `${mainAlias.name}.${column.propertyPath}`;
128
+ if (!keys.some((key) => key.selector === quoted || key.selector === path)) {
129
+ keys.push({ selector: quoted, direction: lastDirection });
130
+ }
131
+ }
132
+ }
133
+ if (!keys.length) {
134
+ throw new TypeError('getRawManyWithCursor: orderBy is empty and the query has no primary key to order by');
135
+ }
136
+ return keys;
137
+ }
138
+ async function getRawManyWithCursor(builder, cursorFilter, options) {
139
+ const keys = getExpressionKeys(builder, options === null || options === void 0 ? void 0 : options.orderBy);
140
+ const fields = keys.map((key) => (0, cursor_utils_1.getExpressionKeyName)(key.selector));
141
+ const pageSize = (0, pagination_utils_1.normalizePageSize)(cursorFilter === null || cursorFilter === void 0 ? void 0 : cursorFilter.pageSize);
142
+ const query = builder.clone();
143
+ const driver = query.connection.driver.options.type;
144
+ const textCastType = (0, cursor_utils_1.getTextCastType)(driver);
145
+ if (cursorFilter === null || cursorFilter === void 0 ? void 0 : cursorFilter.cursor) {
146
+ const values = (0, cursor_utils_1.decodeCursor)(cursorFilter.cursor, fields);
147
+ const keysetColumns = keys.map((key, i) => {
148
+ const paramName = (0, sql_utils_1.createUniqueParameterName)(`cursor${i}`);
149
+ query.setParameter(paramName, values[i]);
150
+ // in parentheses: an expression may bind looser than the comparison
151
+ return { column: `(${key.selector})`, direction: key.direction, paramName };
152
+ });
153
+ (0, where_utils_1.andWhereNarrowing)(query, (0, cursor_utils_1.buildKeysetCondition)(keysetColumns, (0, cursor_utils_1.supportsRowValueComparison)(driver)));
154
+ }
155
+ query.orderBy();
156
+ keys.forEach((key) => query.addOrderBy(key.selector, key.direction));
157
+ const appliedOrderBy = (0, query_builder_utils_1.getAppliedOrderBy)(query);
158
+ // the type of an expression is unknown here: each key is also read as text, which the cursor
159
+ // takes instead when the driver returned a Date, so timestamps keep the database's precision
160
+ const keyAliases = keys.map((key, i) => {
161
+ const valueAlias = `cursor_key_${i}`;
162
+ const textAlias = textCastType ? `cursor_text_${i}` : undefined;
163
+ query.addSelect(key.selector, valueAlias);
164
+ if (textAlias) {
165
+ query.addSelect(`CAST((${key.selector}) AS ${textCastType})`, textAlias);
166
+ }
167
+ return { valueAlias, textAlias };
168
+ });
169
+ const addedAliases = new Set(keyAliases.flatMap(({ valueAlias, textAlias }) => (textAlias ? [valueAlias, textAlias] : [valueAlias])));
170
+ query.limit(pageSize + 1);
171
+ const rows = await query.getRawMany();
172
+ const page = rows.slice(0, pageSize);
173
+ const list = page.map((row) => Object.fromEntries(Object.entries(row).filter(([column]) => !addedAliases.has(column))));
174
+ if (rows.length <= pageSize) {
175
+ return { list, nextCursor: null, hasMore: false, pageSize, orderBy: appliedOrderBy };
176
+ }
177
+ const last = page[page.length - 1];
178
+ const values = keyAliases.map(({ valueAlias, textAlias }, i) => {
179
+ const value = last[valueAlias];
180
+ if (value === null || value === undefined) {
181
+ throw new TypeError(`getRawManyWithCursor: "${keys[i].selector}" is NULL in the last row; cursor keys must not be NULL`);
182
+ }
183
+ return (0, common_utils_1.isDate)(value) && textAlias ? last[textAlias] : value;
184
+ });
185
+ return { list, nextCursor: (0, cursor_utils_1.encodeCursor)(fields, values), hasMore: true, pageSize, orderBy: appliedOrderBy };
186
+ }
@@ -67,10 +67,10 @@ async function readWithTotals(builder, paginationFilter, options, read) {
67
67
  if (loadAll) {
68
68
  return read.rows(builder);
69
69
  }
70
- const paged = builder.applyPaginationFilter(paginationFilter, paginationOptions);
70
+ const paged = (0, pagination_utils_1.paginateQuery)(builder, paginationFilter, paginationOptions);
71
71
  if (probeForMore) {
72
72
  const { useTakeAndSkip = constants_1.defaultUseTakeAndSkip } = paginationOptions;
73
- // replaces the limit applyPaginationFilter just set, keeping its offset
73
+ // replaces the limit paginateQuery just set, keeping its offset
74
74
  (0, pagination_utils_1.setPageLimit)(paged, limit + 1, useTakeAndSkip);
75
75
  }
76
76
  return read.rows(paged);
@@ -3,6 +3,12 @@ import { ObjectLiteral } from 'typeorm';
3
3
  import { ApplyPaginationOptions } from '../types/extensions/pagination.types';
4
4
  declare module 'typeorm/query-builder/SelectQueryBuilder' {
5
5
  interface SelectQueryBuilder<Entity extends ObjectLiteral> {
6
+ /**
7
+ * Pages the query by `page` and `pageSize` (page 1 and 10 rows by default): LIMIT / OFFSET, or
8
+ * `take` / `skip` with `useTakeAndSkip`, which a query joining a collection needs.
9
+ */
10
+ applyPagination(paginationFilter: PaginationFilter, options?: ApplyPaginationOptions): this;
11
+ /** @deprecated Use `applyPagination`, which takes the same arguments. Will be removed in 3.0. */
6
12
  applyPaginationFilter(paginationFilter: PaginationFilter, options?: ApplyPaginationOptions): this;
7
13
  }
8
14
  }
@@ -1,25 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- /* eslint-disable @typescript-eslint/no-explicit-any */
4
3
  const SelectQueryBuilder_1 = require("typeorm/query-builder/SelectQueryBuilder");
5
- const constants_1 = require("../constants");
6
4
  const pagination_utils_1 = require("../utils/pagination.utils");
7
5
  const prototype_utils_1 = require("../utils/prototype.utils");
8
6
  // patching
9
7
  const extension = {
10
8
  prototype: {
9
+ applyPagination(paginationFilter, options) {
10
+ return (0, pagination_utils_1.paginateQuery)(this, paginationFilter, options);
11
+ },
12
+ // calls the implementation, not this.applyPagination: an application may patch a method of that name itself
11
13
  applyPaginationFilter(paginationFilter, options) {
12
- return applyPaginationFilter(this, paginationFilter, options);
14
+ return (0, pagination_utils_1.paginateQuery)(this, paginationFilter, options);
13
15
  },
14
16
  },
15
17
  };
16
18
  (0, prototype_utils_1.patchPrototype)(SelectQueryBuilder_1.SelectQueryBuilder, extension);
17
- // implementation
18
- function applyPaginationFilter(builder, paginationFilter, options) {
19
- const { useTakeAndSkip = constants_1.defaultUseTakeAndSkip } = options || {};
20
- const { limit, offset } = (0, pagination_utils_1.getLimitAndOffset)(paginationFilter);
21
- if (useTakeAndSkip) {
22
- return builder.take(limit).skip(offset);
23
- }
24
- return builder.limit(limit).offset(offset);
25
- }
@@ -1,4 +1,4 @@
1
- import type { CursorOrderParam } from '../interfaces/cursor-pagination.interface';
1
+ import type { CursorOrderParam, RawCursorOrderParam } from '../interfaces/cursor-pagination.interface';
2
2
  import type { OrderType } from '../interfaces/ordering.interface';
3
3
  export type GetManyWithCursorOptions<Entity> = {
4
4
  /**
@@ -10,7 +10,17 @@ export type GetManyWithCursorOptions<Entity> = {
10
10
  /** Use `take` instead of `limit`, as needed for queries with joined collections. Defaults to `false`. */
11
11
  useTakeAndSkip?: boolean;
12
12
  };
13
+ export type GetRawManyWithCursorOptions = {
14
+ /**
15
+ * SQL expressions that order the rows. On a query of an entity without GROUP BY or DISTINCT the
16
+ * primary key of the main alias is appended when it isn't listed; otherwise the last key has to be
17
+ * unique per row, or rows with equal keys may be skipped or repeated between pages.
18
+ * It replaces any ORDER BY of the query.
19
+ */
20
+ orderBy: RawCursorOrderParam[];
21
+ };
13
22
  export interface ListWithCursor<Entity> {
23
+ /** The entities of the page, or with `getRawManyWithCursor` its raw rows. */
14
24
  list: Entity[];
15
25
  /** Cursor of the next page, `null` on the last page. */
16
26
  nextCursor: string | null;
@@ -21,6 +31,6 @@ export interface ListWithCursor<Entity> {
21
31
  hasMore: boolean;
22
32
  /** The page size used: the `pageSize` given, rounded down, or `10` when it was missing or below 1. */
23
33
  pageSize: number;
24
- /** The ORDER BY the page was read in: the `orderBy` columns, then the primary key columns not among them. */
34
+ /** The ORDER BY the page was read in: the `orderBy` keys, then the primary key columns not among them. */
25
35
  orderBy: OrderType[];
26
36
  }
@@ -14,3 +14,14 @@ export type CursorOrderParam<Entity> = {
14
14
  /** `'ASC'` by default. */
15
15
  direction?: OrderDirection;
16
16
  };
17
+ /** An SQL expression that orders the rows of `getRawManyWithCursor`. */
18
+ export type RawCursorOrderParam = {
19
+ /**
20
+ * A column (`user.createdAt`, `"user"."created_at"`) or a computed value
21
+ * (`CASE "member"."role" WHEN 'owner' THEN 0 ELSE 1 END`). It must be valid in WHERE, so not an
22
+ * aggregate, and must not be `NULL`.
23
+ */
24
+ selector: string;
25
+ /** `'ASC'` by default. */
26
+ direction?: OrderDirection;
27
+ };
@@ -5,6 +5,11 @@ export declare class InvalidCursorError extends TypeError {
5
5
  }
6
6
  /** A key value in a cursor: as read from the database, or a JS Date tagged as `{ $date }`. */
7
7
  export type CursorValue = string | number | boolean;
8
+ /**
9
+ * Short stable name of an SQL expression key, for the cursor to recognise its order by: a cursor
10
+ * carries no SQL. 32-bit FNV-1a; telling orders apart doesn't need more.
11
+ */
12
+ export declare function getExpressionKeyName(selector: string): string;
8
13
  /** base64url-encoded JSON of the key fields and the key values of the last row. */
9
14
  export declare function encodeCursor(fields: string[], values: unknown[]): string;
10
15
  /** Key values of a cursor made for `fields`; throws `InvalidCursorError` otherwise. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildKeysetCondition = exports.getTextCastType = exports.supportsRowValueComparison = exports.decodeCursor = exports.encodeCursor = exports.InvalidCursorError = void 0;
3
+ exports.buildKeysetCondition = exports.getTextCastType = exports.supportsRowValueComparison = exports.decodeCursor = exports.encodeCursor = exports.getExpressionKeyName = exports.InvalidCursorError = void 0;
4
4
  const sql_utils_1 = require("./sql.utils");
5
5
  const common_utils_1 = require("./common.utils");
6
6
  /** A cursor that can't be decoded or doesn't belong to the query's order (e.g. tampered with). */
@@ -34,6 +34,19 @@ const fromBase64Url = (value) => {
34
34
  const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
35
35
  return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
36
36
  };
37
+ /**
38
+ * Short stable name of an SQL expression key, for the cursor to recognise its order by: a cursor
39
+ * carries no SQL. 32-bit FNV-1a; telling orders apart doesn't need more.
40
+ */
41
+ function getExpressionKeyName(selector) {
42
+ let hash = 0x811c9dc5;
43
+ for (let i = 0; i < selector.length; i++) {
44
+ hash ^= selector.charCodeAt(i);
45
+ hash = Math.imul(hash, 0x01000193);
46
+ }
47
+ return `sql:${(hash >>> 0).toString(16).padStart(8, '0')}`;
48
+ }
49
+ exports.getExpressionKeyName = getExpressionKeyName;
37
50
  /** base64url-encoded JSON of the key fields and the key values of the last row. */
38
51
  function encodeCursor(fields, values) {
39
52
  const payload = { k: fields, v: values.map(toCursorValue) };
@@ -1,4 +1,5 @@
1
1
  import type { ObjectLiteral, SelectQueryBuilder } from "typeorm";
2
+ import type { ApplyPaginationOptions } from "../types/extensions/pagination.types";
2
3
  import { PaginationFilter } from "../types/interfaces/pagination.interface";
3
4
  /** The page size to read: a number or a numeric string of at least 1, rounded down; `defaultPageSize` otherwise. */
4
5
  export declare const normalizePageSize: (pageSize: unknown) => number;
@@ -10,3 +11,5 @@ export declare const getLimitAndOffset: ({ page, pageSize }: PaginationFilter) =
10
11
  page: number;
11
12
  pageSize: number;
12
13
  };
14
+ /** What `applyPagination` does, for code that must not depend on what is patched under that name. */
15
+ export declare const paginateQuery: <Entity extends ObjectLiteral>(builder: SelectQueryBuilder<Entity>, paginationFilter: PaginationFilter, options?: ApplyPaginationOptions) => SelectQueryBuilder<Entity>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getLimitAndOffset = exports.setPageLimit = exports.normalizePageSize = void 0;
3
+ exports.paginateQuery = exports.getLimitAndOffset = exports.setPageLimit = exports.normalizePageSize = void 0;
4
4
  const constants_1 = require("../constants");
5
5
  // an unparsed query parameter is a string: "20" + 1 would be "201"
6
6
  const toPositiveInteger = (value, fallback) => {
@@ -24,3 +24,10 @@ const getLimitAndOffset = ({ page, pageSize }) => {
24
24
  };
25
25
  };
26
26
  exports.getLimitAndOffset = getLimitAndOffset;
27
+ /** What `applyPagination` does, for code that must not depend on what is patched under that name. */
28
+ const paginateQuery = (builder, paginationFilter, options) => {
29
+ const { useTakeAndSkip = constants_1.defaultUseTakeAndSkip } = options || {};
30
+ const { limit, offset } = (0, exports.getLimitAndOffset)(paginationFilter);
31
+ return useTakeAndSkip ? builder.take(limit).skip(offset) : builder.limit(limit).offset(offset);
32
+ };
33
+ exports.paginateQuery = paginateQuery;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeorm-extensions",
3
- "version": "2.2.0-rc.10",
3
+ "version": "2.2.0-rc.11",
4
4
  "description": "typeorm commonly used extensions",
5
5
  "main": "./dist/index.js",
6
6
  "types": "dist/index.d.ts",