typeorm-extensions 2.1.0 → 2.2.0-rc.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -3
  3. package/dist/decorators/calculated-column.decorator.d.ts +30 -0
  4. package/dist/decorators/calculated-column.decorator.js +82 -0
  5. package/dist/decorators/index.d.ts +1 -0
  6. package/dist/decorators/index.js +17 -0
  7. package/dist/extensions/calculated-column.extension.d.ts +33 -0
  8. package/dist/extensions/calculated-column.extension.js +33 -0
  9. package/dist/extensions/cursor-pagination.extension.d.ts +2 -1
  10. package/dist/extensions/cursor-pagination.extension.js +4 -3
  11. package/dist/extensions/get-many-with-totals.extension.d.ts +9 -1
  12. package/dist/extensions/get-many-with-totals.extension.js +159 -10
  13. package/dist/extensions/index.d.ts +7 -0
  14. package/dist/extensions/index.js +7 -0
  15. package/dist/extensions/join-lateral.extension.d.ts +19 -0
  16. package/dist/extensions/join-lateral.extension.js +50 -0
  17. package/dist/extensions/join-unnest.extension.d.ts +19 -0
  18. package/dist/extensions/join-unnest.extension.js +40 -0
  19. package/dist/extensions/order.extension.js +18 -7
  20. package/dist/extensions/query-returning.extension.d.ts +29 -0
  21. package/dist/extensions/query-returning.extension.js +50 -0
  22. package/dist/extensions/typed/index.d.ts +3 -0
  23. package/dist/extensions/typed/index.js +3 -2
  24. package/dist/extensions/typed/typed-distinct/typed-distinct.extension.d.ts +3 -2
  25. package/dist/extensions/typed/typed-group-by/typed-group-by.extension.d.ts +24 -0
  26. package/dist/extensions/typed/typed-group-by/typed-group-by.extension.js +31 -0
  27. package/dist/extensions/typed/typed-having/typed-having.extension.d.ts +36 -0
  28. package/dist/extensions/typed/typed-having/typed-having.extension.js +42 -0
  29. package/dist/extensions/typed/typed-joins/typed-joins.extension.d.ts +5 -4
  30. package/dist/extensions/typed/typed-order-by/typed-order-by-array-position.extension.d.ts +3 -2
  31. package/dist/extensions/typed/typed-order-by/typed-order-by-array-position.extension.js +10 -2
  32. package/dist/extensions/typed/typed-order-by/typed-order-by.extension.d.ts +3 -2
  33. package/dist/extensions/typed/typed-order-by/typed-order-by.extension.js +3 -2
  34. package/dist/extensions/typed/typed-root.extension.d.ts +25 -0
  35. package/dist/extensions/typed/typed-root.extension.js +13 -0
  36. package/dist/extensions/typed/typed-select/typed-select.extension.d.ts +5 -4
  37. package/dist/extensions/typed/typed-select/typed-select.extension.js +3 -2
  38. package/dist/extensions/typed/typed-where/typed-period-filter.extension.d.ts +2 -1
  39. package/dist/extensions/typed/typed-where/typed-where-array.extension.d.ts +10 -9
  40. package/dist/extensions/typed/typed-where/typed-where-array.extension.js +32 -0
  41. package/dist/extensions/typed/typed-where/typed-where-ilike.extension.d.ts +7 -6
  42. package/dist/extensions/typed/typed-where/typed-where-is-any.extension.d.ts +7 -6
  43. package/dist/extensions/typed/typed-where/typed-where-is-distinct-from.extension.d.ts +7 -6
  44. package/dist/extensions/typed/typed-where/typed-where-is-in.extension.d.ts +7 -6
  45. package/dist/extensions/typed/typed-where/typed-where-json.extension.d.ts +7 -6
  46. package/dist/extensions/typed/typed-where/typed-where-lower.extension.d.ts +7 -6
  47. package/dist/extensions/typed/typed-where/typed-where-similar.extension.d.ts +5 -4
  48. package/dist/extensions/typed/typed-where/typed-where.extension.d.ts +4 -3
  49. package/dist/extensions/union.extension.d.ts +22 -0
  50. package/dist/extensions/union.extension.js +52 -0
  51. package/dist/extensions/where-exists.extension.d.ts +50 -0
  52. package/dist/extensions/where-exists.extension.js +64 -0
  53. package/dist/extensions/where-relation-exists.extension.d.ts +38 -0
  54. package/dist/extensions/where-relation-exists.extension.js +95 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +1 -0
  57. package/dist/types/extensions/cursor-pagination.types.d.ts +5 -0
  58. package/dist/types/extensions/get-many-with-totals.types.d.ts +19 -0
  59. package/dist/types/extensions/index.d.ts +5 -0
  60. package/dist/types/extensions/index.js +5 -0
  61. package/dist/types/extensions/join-lateral.types.d.ts +6 -0
  62. package/dist/types/extensions/join-lateral.types.js +2 -0
  63. package/dist/types/extensions/period.types.d.ts +8 -0
  64. package/dist/types/extensions/query-returning.types.d.ts +11 -0
  65. package/dist/types/extensions/query-returning.types.js +2 -0
  66. package/dist/types/extensions/typed-group-by.types.d.ts +8 -0
  67. package/dist/types/extensions/typed-group-by.types.js +2 -0
  68. package/dist/types/extensions/typed-having.types.d.ts +2 -0
  69. package/dist/types/extensions/typed-having.types.js +2 -0
  70. package/dist/types/extensions/union.types.d.ts +12 -0
  71. package/dist/types/extensions/union.types.js +2 -0
  72. package/dist/types/extensions/where.types.d.ts +1 -0
  73. package/dist/types/modules/typed-root.types.d.ts +17 -0
  74. package/dist/types/modules/typed-root.types.js +2 -0
  75. package/dist/utils/calculated-column.utils.d.ts +21 -0
  76. package/dist/utils/calculated-column.utils.js +63 -0
  77. package/dist/utils/parameter.utils.d.ts +7 -0
  78. package/dist/utils/parameter.utils.js +21 -0
  79. package/dist/utils/period.utils.js +7 -2
  80. package/dist/utils/prototype.utils.d.ts +5 -1
  81. package/dist/utils/prototype.utils.js +4 -1
  82. package/dist/utils/proxy-key.utils.d.ts +3 -3
  83. package/dist/utils/proxy-key.utils.js +7 -7
  84. package/dist/utils/query-builder.utils.d.ts +8 -0
  85. package/dist/utils/query-builder.utils.js +30 -2
  86. package/dist/utils/table.utils.d.ts +112 -2
  87. package/dist/utils/table.utils.js +206 -11
  88. package/dist/utils/time-zone.utils.d.ts +8 -0
  89. package/dist/utils/time-zone.utils.js +102 -0
  90. package/dist/utils/where.utils.d.ts +2 -2
  91. package/dist/utils/where.utils.js +23 -2
  92. package/package.json +3 -3
@@ -7,9 +7,9 @@ class KeyHolder {
7
7
  this._keys = _keys;
8
8
  this._keys = [..._keys];
9
9
  }
10
- toSql(length = 2, useQuotes) {
10
+ toSql(length = 2, useQuotes, driver) {
11
11
  const string = this.toString(length);
12
- const sql = useQuotes ? (0, sql_utils_1.stringToSQLIdentifier)(string) : string;
12
+ const sql = useQuotes ? (0, sql_utils_1.stringToSQLIdentifier)(string, driver) : string;
13
13
  return sql;
14
14
  }
15
15
  toString(length = 2) {
@@ -109,26 +109,26 @@ exports.getKey = getKey;
109
109
  * SQL key of a typed callback: `(u) => u.id` → `prefix.id`, `(u) => u.images.url` → `images.url`
110
110
  * (an `alias.column` path keeps its own alias). Strings are returned as is.
111
111
  */
112
- const getSqlKeyFromProxyCallback = (callback, addPrefix, useQuotes) => {
112
+ const getSqlKeyFromProxyCallback = (callback, addPrefix, useQuotes, driver) => {
113
113
  if (typeof callback === 'string')
114
114
  return callback;
115
115
  const keyHolder = (0, exports.getKeyHolder)(callback);
116
116
  if (Array.isArray(keyHolder)) {
117
- return keyHolder.map((holder) => (0, exports.getSqlKeyFromProxyCallback)(holder, addPrefix, useQuotes));
117
+ return keyHolder.map((holder) => (0, exports.getSqlKeyFromProxyCallback)(holder, addPrefix, useQuotes, driver));
118
118
  }
119
119
  // if select is like this: (proxy) => proxy.relation.column
120
120
  if (keyHolder.getKeys().length > 1) {
121
121
  // return unchanged
122
- return keyHolder.toSql(undefined, useQuotes);
122
+ return keyHolder.toSql(undefined, useQuotes, driver);
123
123
  }
124
124
  // else if select is one layer depth like this: (proxy) => proxy.column
125
125
  // then add alias before the key; resolve the alias only now, a path above doesn't need it
126
126
  const prefix = typeof addPrefix === 'function' ? addPrefix() : addPrefix;
127
127
  if (typeof prefix === 'string' && prefix.length >= 1) {
128
128
  // a new holder: a KeyHolder passed in by the caller must stay as it was
129
- return new KeyHolder([prefix, ...keyHolder.getKeys()]).toSql(undefined, useQuotes);
129
+ return new KeyHolder([prefix, ...keyHolder.getKeys()]).toSql(undefined, useQuotes, driver);
130
130
  }
131
- return keyHolder.toSql(undefined, useQuotes);
131
+ return keyHolder.toSql(undefined, useQuotes, driver);
132
132
  };
133
133
  exports.getSqlKeyFromProxyCallback = getSqlKeyFromProxyCallback;
134
134
  // example of typed select
@@ -1,4 +1,5 @@
1
1
  import type { ObjectLiteral, WhereExpressionBuilder } from 'typeorm';
2
+ import type { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
2
3
  import type { KeyProxyCallback } from '../types/modules/proxy-callback.types';
3
4
  import { JsonPathSqlOptions } from './json-path.utils';
4
5
  /**
@@ -7,6 +8,11 @@ import { JsonPathSqlOptions } from './json-path.utils';
7
8
  * entity or the property isn't a column.
8
9
  */
9
10
  export declare function getColumnDatabaseName(builder: WhereExpressionBuilder, aliasName: string | undefined, propertyPath: string): string | undefined;
11
+ /**
12
+ * The entity column a field names, the way `toFieldSql` resolves it: `alias.property` or
13
+ * `alias.column_name` on an alias with an entity, or a database column name of the main alias.
14
+ */
15
+ export declare function findFieldColumn(builder: WhereExpressionBuilder, field: string): ColumnMetadata | undefined;
10
16
  /**
11
17
  * SQL for a field passed as a string to the untyped methods (`whereIsIn('user.first-name', …)`).
12
18
  *
@@ -32,6 +38,8 @@ export declare function toFieldSql(builder: WhereExpressionBuilder, field: strin
32
38
  * gives its JSON accessor, cast on PostgreSQL to the type of `jsonOptions.value`.
33
39
  */
34
40
  export declare function getTypedColumnKey<Type extends ObjectLiteral>(builder: WhereExpressionBuilder, callback: KeyProxyCallback<Type>, jsonOptions?: JsonPathSqlOptions): string | string[];
41
+ /** Whether a typed callback selects a path inside a JSON column, `u => u.metadata.tags`, rather than a column. */
42
+ export declare function isTypedJsonPath<Type extends ObjectLiteral>(builder: WhereExpressionBuilder, callback: KeyProxyCallback<Type>): boolean;
35
43
  /**
36
44
  * Quoted column of a typed callback that must select exactly one column
37
45
  * (`whereIsAnyTyped`, `whereArrayContainsTyped`, …). Throws a `TypeError` for several columns:
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getTypedSelectionKey = exports.getTypedSingleColumnKey = exports.getTypedColumnKey = exports.toFieldSql = exports.getColumnDatabaseName = void 0;
3
+ exports.getTypedSelectionKey = exports.getTypedSingleColumnKey = exports.isTypedJsonPath = exports.getTypedColumnKey = exports.toFieldSql = exports.findFieldColumn = exports.getColumnDatabaseName = void 0;
4
4
  const proxy_key_utils_1 = require("./proxy-key.utils");
5
5
  const json_path_utils_1 = require("./json-path.utils");
6
6
  const sql_utils_1 = require("./sql.utils");
@@ -54,6 +54,21 @@ function findColumn(metadata, name) {
54
54
  var _a;
55
55
  return ((_a = metadata.columns.find((column) => column.databaseName === name)) !== null && _a !== void 0 ? _a : metadata.findColumnWithPropertyPath(name));
56
56
  }
57
+ /**
58
+ * The entity column a field names, the way `toFieldSql` resolves it: `alias.property` or
59
+ * `alias.column_name` on an alias with an entity, or a database column name of the main alias.
60
+ */
61
+ function findFieldColumn(builder, field) {
62
+ var _a;
63
+ const queryBuilder = builder;
64
+ const dot = field.indexOf('.');
65
+ if (dot > 0) {
66
+ const metadata = getAliasMetadata(queryBuilder, field.slice(0, dot));
67
+ return metadata && findColumn(metadata, field.slice(dot + 1));
68
+ }
69
+ return (_a = getAliasMetadata(queryBuilder, undefined)) === null || _a === void 0 ? void 0 : _a.columns.find((column) => column.databaseName === field);
70
+ }
71
+ exports.findFieldColumn = findFieldColumn;
57
72
  /**
58
73
  * SQL for a field passed as a string to the untyped methods (`whereIsIn('user.first-name', …)`).
59
74
  *
@@ -131,11 +146,17 @@ function findColumnPath(metadata, keys) {
131
146
  * before `from()` the property name is used as the column name.
132
147
  */
133
148
  function resolveTypedKey(queryBuilder, keys) {
149
+ var _a, _b;
134
150
  const resolved = resolveTypedKeyPath(queryBuilder, keys);
135
151
  // deeper than `alias.column` only inside a JSON column: a nested relation can't be mapped to an alias
136
152
  if (!resolved.jsonPath && keys.length > proxy_key_utils_1.MAX_KEY_DEPTH) {
137
153
  throw (0, proxy_key_utils_1.createKeyTooDeepError)(keys);
138
154
  }
155
+ const column = (_a = resolved.column) !== null && _a !== void 0 ? _a : (_b = resolved.metadata) === null || _b === void 0 ? void 0 : _b.findColumnWithPropertyPath(resolved.propertyPath);
156
+ // a @CalculatedColumn: TypeORM would write "alias"."property", a column the table doesn't have
157
+ if ((column === null || column === void 0 ? void 0 : column.isVirtualProperty) && !column.query) {
158
+ throw new TypeError(`${keys.join('.')} is a @CalculatedColumn, not a column of the table: use the SQL expression that fills it`);
159
+ }
139
160
  return resolved;
140
161
  }
141
162
  function resolveTypedKeyPath(queryBuilder, keys) {
@@ -213,6 +234,13 @@ function getTypedColumnKey(builder, callback, jsonOptions) {
213
234
  return Array.isArray(keyHolder) ? keyHolder.map(toKey) : toKey(keyHolder);
214
235
  }
215
236
  exports.getTypedColumnKey = getTypedColumnKey;
237
+ /** Whether a typed callback selects a path inside a JSON column, `u => u.metadata.tags`, rather than a column. */
238
+ function isTypedJsonPath(builder, callback) {
239
+ const keyHolder = (0, proxy_key_utils_1.getKeyHolder)(callback, true);
240
+ return (!Array.isArray(keyHolder) &&
241
+ Boolean(resolveTypedKey(builder, keyHolder.getKeys()).jsonPath));
242
+ }
243
+ exports.isTypedJsonPath = isTypedJsonPath;
216
244
  /**
217
245
  * Quoted column of a typed callback that must select exactly one column
218
246
  * (`whereIsAnyTyped`, `whereArrayContainsTyped`, …). Throws a `TypeError` for several columns:
@@ -242,7 +270,7 @@ function getTypedSelectionKey(builder, selection, useQuotes) {
242
270
  const resolved = resolveTypedKey(queryBuilder, keyHolder.getKeys());
243
271
  if (resolved.jsonPath) {
244
272
  throw new TypeError(`Typed key "${keyHolder.getKeys().join('.')}" points into the JSON column "${resolved.propertyPath}": ` +
245
- `select, order and distinct take columns only. For an expression use tableHelper().jsonColumn()`);
273
+ `select, order, distinct and group by take columns only. For an expression use tableHelper().jsonColumn()`);
246
274
  }
247
275
  if (useQuotes) {
248
276
  return toQuotedColumnKey(queryBuilder, resolved);
@@ -1,6 +1,8 @@
1
- import { ObjectLiteral } from "typeorm";
1
+ import { DatabaseType, ObjectLiteral } from "typeorm";
2
2
  export type TableHelperOptions = {
3
3
  useQuotes?: boolean;
4
+ /** The database the SQL is for: MySQL and MariaDB quote identifiers with backticks. Double quotes without it. */
5
+ driver?: DatabaseType;
4
6
  };
5
7
  type JsonFieldTuple<T, Depth extends 0[] = []> = Depth['length'] extends 4 ? [string, ...string[]] : {
6
8
  [K in keyof T & string]: [NonNullable<T[K]>] extends [Record<string, unknown>] ? [K, ...JsonFieldTuple<NonNullable<T[K]>, [...Depth, 0]>] | [K] : [K];
@@ -17,6 +19,60 @@ export type TableHelperCountOptions = {
17
19
  */
18
20
  syntax?: 'case' | 'filter';
19
21
  };
22
+ export type TableHelperJsonOptions = {
23
+ /** `jsonb_build_object` / `jsonb_agg` instead of the `json` functions. PostgreSQL only. */
24
+ jsonb?: boolean;
25
+ };
26
+ type ColumnName<T> = keyof T & string;
27
+ export type TableHelperJsonAggOptions<T> = TableHelperJsonOptions & {
28
+ /** Orders the array by a column of the helper's table, ascending or in the given direction. Not on MySQL. */
29
+ orderBy?: AggregateOrder<T>;
30
+ /**
31
+ * Aggregates only the rows that match, e.g. `images.isNotNull('id')`: after a LEFT JOIN without a match
32
+ * the row has `NULL` columns, which would otherwise come out as `[{"id": null}]`. Not on MySQL.
33
+ */
34
+ filter?: string;
35
+ };
36
+ /** Keys of the entity whose values are numbers. */
37
+ type NumericColumnName<T> = {
38
+ [K in keyof T & string]: [NonNullable<T[K]>] extends [number | bigint] ? K : never;
39
+ }[keyof T & string];
40
+ type AggregateOrder<T> = ColumnName<T> | readonly [ColumnName<T>, 'ASC' | 'DESC'];
41
+ export type TableHelperSumOptions = {
42
+ /** Sums only the rows that match this condition, e.g. `t.where('status', "= 'paid'")`. */
43
+ filter?: string;
44
+ /** How `filter` is written, as for `count`: `'case'` (default) or `'filter'` (PostgreSQL and SQLite 3.30+). */
45
+ syntax?: 'case' | 'filter';
46
+ };
47
+ export type TableHelperArrayAggOptions<T> = {
48
+ /** `array_agg(DISTINCT column)`: each value once. Then `orderBy` can only be the aggregated column. */
49
+ distinct?: boolean;
50
+ /** Orders the array by a column of the helper's table, ascending or in the given direction. */
51
+ orderBy?: AggregateOrder<T>;
52
+ /** Aggregates only the rows that match, e.g. `images.isNotNull('id')` after a LEFT JOIN without a match. */
53
+ filter?: string;
54
+ };
55
+ /** A column of the helper's table whose property is a `Date`. */
56
+ type DateColumnName<T> = {
57
+ [K in keyof T & string]: [NonNullable<T[K]>] extends [Date] ? K : never;
58
+ }[keyof T & string];
59
+ /** A date column of the helper's table, or an SQL expression: `{ sql: 'MAX("order"."paidAt")' }`. */
60
+ export type TableHelperExpression<T> = DateColumnName<T> | {
61
+ sql: string;
62
+ };
63
+ /**
64
+ * A time zone: a name or an ISO 8601 offset (`'Europe/Kyiv'`, `'UTC'`, `'+02:00'` = UTC+2), written into the SQL as an escaped string
65
+ * literal; a column of the helper's table, `{ column: 'timezone' }`, with an optional `fallback` zone for `NULL`;
66
+ * or an SQL expression, `{ sql: services.column('timezone') }`.
67
+ */
68
+ export type TableHelperTimeZone<T> = string | {
69
+ column: ColumnName<T>;
70
+ fallback?: string;
71
+ } | {
72
+ sql: string;
73
+ };
74
+ /** The units of PostgreSQL's `date_trunc`. */
75
+ export type DateTruncUnit = 'microseconds' | 'milliseconds' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | 'decade' | 'century' | 'millennium';
20
76
  export type JsonFieldRest<V> = [NonNullable<V>] extends [Record<string, unknown>] ? JsonFieldTuple<NonNullable<V>> : string[];
21
77
  /**
22
78
  * Creates a type-safe SQL helper for building column references, WHERE expressions,
@@ -51,7 +107,8 @@ export declare const tableHelper: <T extends ObjectLiteral>(alias?: string, opti
51
107
  /** Returns `"alias"."column" IS NOT NULL`. */
52
108
  isNotNull(columnName: keyof T): string;
53
109
  /**
54
- * Returns `CAST(COUNT("alias"."column") AS integer)`: the rows where the column isn't `NULL`.
110
+ * Returns `CAST(COUNT("alias"."column") AS integer)`: the rows where the column isn't `NULL`. With the `driver` option
111
+ * MySQL casts `AS SIGNED`, a `BIGINT`, which its TypeORM driver returns as a string by default.
55
112
  * Without a column it is `COUNT(*)`, all the rows of the result, in every database. To count the rows
56
113
  * of a table joined with LEFT JOIN, count one of its non-null columns: `images.count('id')`.
57
114
  * @example
@@ -62,6 +119,41 @@ export declare const tableHelper: <T extends ObjectLiteral>(alias?: string, opti
62
119
  * // CAST(COUNT(*) FILTER (WHERE "user"."status" = 'active') AS integer)
63
120
  */
64
121
  count(columnName?: keyof T | undefined, countOptions?: TableHelperCountOptions): string;
122
+ /**
123
+ * Returns `COALESCE(SUM("alias"."column"), 0)`: 0, not `NULL`, for a group without rows or a LEFT JOIN
124
+ * without a match. Only a column whose property is a number. PostgreSQL sums integers into a `bigint`
125
+ * and MySQL into a `DECIMAL`, which the drivers return as strings.
126
+ * @example
127
+ * t.sum('amountCents', { filter: t.where('status', "= 'paid'") })
128
+ * // COALESCE(SUM(CASE WHEN "order"."status" = 'paid' THEN "order"."amountCents" END), 0)
129
+ */
130
+ sum(columnName: NumericColumnName<T>, sumOptions?: TableHelperSumOptions): string;
131
+ /**
132
+ * Returns the PostgreSQL array of a column over the rows of a group, `{}` for a group without rows.
133
+ * Other databases have no arrays and throw a `TypeError`: use `jsonAgg` there.
134
+ * @example
135
+ * images.arrayAgg('id', { distinct: true, orderBy: ['id', 'DESC'], filter: images.isNotNull('id') })
136
+ * // COALESCE(array_agg(DISTINCT "image"."id" ORDER BY "image"."id" DESC) FILTER (WHERE "image"."id" IS NOT NULL), '{}')
137
+ */
138
+ arrayAgg(columnName: ColumnName<T>, aggOptions?: TableHelperArrayAggOptions<T>): string;
139
+ /**
140
+ * Returns a JSON object of columns, keyed by the column names or by the keys of a `{ key: column }` map.
141
+ * Every column is checked against the entity type.
142
+ * @example
143
+ * t.jsonBuildObject(['id', 'url']) // json_build_object('id', "image"."id", 'url', "image"."url")
144
+ * t.jsonBuildObject({ imageId: 'id', src: 'url' }) // json_build_object('imageId', "image"."id", 'src', "image"."url")
145
+ * // MySQL: JSON_OBJECT(…), SQLite: json_object(…); { jsonb: true }: jsonb_build_object(…)
146
+ */
147
+ jsonBuildObject(columns: readonly ColumnName<T>[] | Readonly<Record<string, ColumnName<T>>>, jsonOptions?: TableHelperJsonOptions): string;
148
+ /**
149
+ * Returns the JSON array of an expression over the rows of a group, `[]` for a group without rows
150
+ * (the aggregate alone gives `NULL` there). Use it with GROUP BY, or in a subquery.
151
+ * @example
152
+ * images.jsonAgg(images.jsonBuildObject(['id', 'url']), { orderBy: ['id', 'DESC'], filter: images.isNotNull('id') })
153
+ * // COALESCE(json_agg(json_build_object(…) ORDER BY "image"."id" DESC) FILTER (WHERE "image"."id" IS NOT NULL), CAST('[]' AS json))
154
+ * // MySQL: COALESCE(JSON_ARRAYAGG(…), JSON_ARRAY()), without ORDER BY and FILTER; SQLite: json_group_array(…)
155
+ */
156
+ jsonAgg(expression: string, aggOptions?: TableHelperJsonAggOptions<T>): string;
65
157
  /** Returns `CAST(expression AS type)`. */
66
158
  cast(expression: string, type: string): string;
67
159
  /**
@@ -82,5 +174,23 @@ export declare const tableHelper: <T extends ObjectLiteral>(alias?: string, opti
82
174
  * qb.whereTyped(u => u.metadata, `${t.jsonPath('metadata', '->>', 'role')} =`, 'admin')
83
175
  */
84
176
  jsonPath<K_1 extends keyof T>(_columnName: K_1, jsonOperator: '->>' | '->', ...fieldNames: JsonFieldRest<T[K_1]>): string;
177
+ /**
178
+ * Returns `(expression AT TIME ZONE zone)` (PostgreSQL). A `timestamptz` gives the wall-clock `timestamp`
179
+ * in that zone; a `timestamp` is read as wall-clock time there and gives a `timestamptz`.
180
+ * A zone name is written as an escaped literal, so it is safe to take from a request.
181
+ * @example
182
+ * t.atTimeZone('paidAt', 'Europe/Kyiv') // ("order"."paidAt" AT TIME ZONE 'Europe/Kyiv')
183
+ * t.atTimeZone('startsAt', { column: 'timezone', fallback: 'UTC' }) // ("order"."startsAt" AT TIME ZONE COALESCE("order"."timezone", 'UTC'))
184
+ */
185
+ atTimeZone(value: TableHelperExpression<T>, timeZone: TableHelperTimeZone<T>): string;
186
+ /**
187
+ * Returns `date_trunc('unit', expression)` (PostgreSQL). With a time zone it is the three-argument form
188
+ * (PostgreSQL 12+): a `timestamptz` is truncated at the boundaries of that zone, e.g. its local midnight
189
+ * for `'day'`, and the result is a `timestamptz` again.
190
+ * @example
191
+ * t.dateTrunc('day', 'paidAt') // date_trunc('day', "order"."paidAt")
192
+ * t.dateTrunc('day', 'paidAt', 'Europe/Kyiv') // date_trunc('day', "order"."paidAt", 'Europe/Kyiv')
193
+ */
194
+ dateTrunc(unit: DateTruncUnit, value: TableHelperExpression<T>, timeZone?: TableHelperTimeZone<T> | undefined): string;
85
195
  };
86
196
  export {};
@@ -3,6 +3,105 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.tableHelper = void 0;
4
4
  const sql_utils_1 = require("./sql.utils");
5
5
  const json_path_utils_1 = require("./json-path.utils");
6
+ /**
7
+ * The argument of an aggregate and the clause after it for a `filter` written with `syntax`:
8
+ * `CASE WHEN filter THEN argument END`, or `argument` and ` FILTER (WHERE filter)`.
9
+ */
10
+ function filtered(method, argument, filter, syntax = 'case', driver) {
11
+ if (syntax !== 'case' && syntax !== 'filter') {
12
+ throw new TypeError(`tableHelper.${method}(): syntax is 'case' or 'filter', got "${String(syntax)}"`);
13
+ }
14
+ if (filter && syntax === 'filter' && (0, sql_utils_1.isMysqlLikeDriver)(driver)) {
15
+ throw new TypeError(`tableHelper.${method}(): MySQL / MariaDB have no FILTER (WHERE …), use syntax 'case'`);
16
+ }
17
+ if (!filter) {
18
+ return { argument, suffix: '' };
19
+ }
20
+ return syntax === 'case'
21
+ ? { argument: `CASE WHEN ${filter} THEN ${argument} END`, suffix: '' }
22
+ : { argument, suffix: ` FILTER (WHERE ${filter})` };
23
+ }
24
+ /** `orderBy` of an aggregate: its column, and the SQL after the argument, ` ORDER BY column [direction]`. */
25
+ function aggregateOrder(method, orderBy, toSql) {
26
+ const [column, direction] = (typeof orderBy === 'string' ? [orderBy] : orderBy);
27
+ if (direction !== undefined && direction !== 'ASC' && direction !== 'DESC') {
28
+ throw new TypeError(`tableHelper.${method}(): the order is 'ASC' or 'DESC', got "${String(direction)}"`);
29
+ }
30
+ return { column, sql: ` ORDER BY ${toSql(column)}${direction ? ` ${direction}` : ''}` };
31
+ }
32
+ const DATE_TRUNC_UNITS = [
33
+ 'microseconds',
34
+ 'milliseconds',
35
+ 'second',
36
+ 'minute',
37
+ 'hour',
38
+ 'day',
39
+ 'week',
40
+ 'month',
41
+ 'quarter',
42
+ 'year',
43
+ 'decade',
44
+ 'century',
45
+ 'millennium',
46
+ ];
47
+ function assertPostgres(method, driver) {
48
+ if (driver !== undefined && !(0, sql_utils_1.isPostgresLikeDriver)(driver)) {
49
+ throw new TypeError(`tableHelper.${method}(): PostgreSQL only, the driver is "${driver}"`);
50
+ }
51
+ }
52
+ function toExpression(value, toColumn) {
53
+ return typeof value === 'string' ? toColumn(value) : value.sql;
54
+ }
55
+ function toTimeZone(method, timeZone, toColumn) {
56
+ if (typeof timeZone === 'string') {
57
+ if (!timeZone.trim()) {
58
+ throw new TypeError(`tableHelper.${method}(): the time zone is empty`);
59
+ }
60
+ return sqlString(toPostgresZone(timeZone));
61
+ }
62
+ if ('sql' in timeZone) {
63
+ return timeZone.sql;
64
+ }
65
+ const column = toColumn(timeZone.column);
66
+ return timeZone.fallback === undefined
67
+ ? column
68
+ : `COALESCE(${column}, ${sqlString(toPostgresZone(timeZone.fallback))})`;
69
+ }
70
+ const ISO_OFFSET = /^([+-])(\d{2}):?(\d{2})?$/;
71
+ /**
72
+ * PostgreSQL reads `'+02:00'` as a POSIX zone, whose offsets count west: UTC−2. An ISO 8601 offset is
73
+ * written as the POSIX zone of the same offset, `'<+0200>-02:00'`, so `'+02:00'` is UTC+2 as everywhere else.
74
+ */
75
+ function toPostgresZone(timeZone) {
76
+ const offset = ISO_OFFSET.exec(timeZone.trim());
77
+ if (offset === null) {
78
+ return timeZone;
79
+ }
80
+ const [, sign, hours, minutes = '00'] = offset;
81
+ return `<${sign}${hours}${minutes}>${sign === '+' ? '-' : '+'}${hours}:${minutes}`;
82
+ }
83
+ /** The JSON functions of a database: PostgreSQL's unless the driver is MySQL / MariaDB or SQLite. */
84
+ function jsonFunctions(driver, jsonb) {
85
+ const mysql = (0, sql_utils_1.isMysqlLikeDriver)(driver);
86
+ if (jsonb && (mysql || (0, sql_utils_1.isSqliteLikeDriver)(driver))) {
87
+ throw new TypeError(`tableHelper: jsonb is PostgreSQL's, the driver is "${driver}"`);
88
+ }
89
+ if (mysql) {
90
+ return { mysql, object: 'JSON_OBJECT', agg: 'JSON_ARRAYAGG', emptyArray: 'JSON_ARRAY()' };
91
+ }
92
+ if ((0, sql_utils_1.isSqliteLikeDriver)(driver)) {
93
+ return { mysql, object: 'json_object', agg: 'json_group_array', emptyArray: 'json_array()' };
94
+ }
95
+ // PostgreSQL's when no driver is given; SQL Server, Oracle and the rest have functions of their own
96
+ if (driver !== undefined && !(0, sql_utils_1.isPostgresLikeDriver)(driver)) {
97
+ throw new TypeError(`tableHelper: the JSON functions are PostgreSQL's, MySQL / MariaDB's and SQLite's, the driver is "${driver}"`);
98
+ }
99
+ const type = jsonb ? 'jsonb' : 'json';
100
+ // CAST, not `::json`: TypeORM would read `:json` as a parameter
101
+ return { mysql, object: `${type}_build_object`, agg: `${type}_agg`, emptyArray: `CAST('[]' AS ${type})` };
102
+ }
103
+ // MySQL reads a backslash in a string as an escape (unless NO_BACKSLASH_ESCAPES): `\'` would end the literal
104
+ const sqlString = (value, driver) => `'${((0, sql_utils_1.isMysqlLikeDriver)(driver) ? value.replace(/\\/g, '\\\\') : value).replace(/'/g, "''")}'`;
6
105
  /**
7
106
  * Creates a type-safe SQL helper for building column references, WHERE expressions,
8
107
  * JOIN clauses, and JSONB accessors for a given entity type.
@@ -20,13 +119,13 @@ const tableHelper = (alias, options = {}) => ({
20
119
  * identifier, so names with other characters work too: `"user"."first-name"`.
21
120
  */
22
121
  column(columnName, alias) {
23
- const { useQuotes = true } = options;
122
+ const { useQuotes = true, driver } = options;
24
123
  const tableAlias = alias !== null && alias !== void 0 ? alias : this.alias;
25
124
  const column = String(columnName);
26
125
  if (!useQuotes) {
27
126
  return [tableAlias, column].filter(Boolean).join('.');
28
127
  }
29
- return [tableAlias ? (0, sql_utils_1.quoteIdentifierPath)(tableAlias) : undefined, (0, sql_utils_1.quoteIdentifier)(column)]
128
+ return [tableAlias ? (0, sql_utils_1.quoteIdentifierPath)(tableAlias, driver) : undefined, (0, sql_utils_1.quoteIdentifier)(column, driver)]
30
129
  .filter(Boolean)
31
130
  .join('.');
32
131
  },
@@ -61,7 +160,8 @@ const tableHelper = (alias, options = {}) => ({
61
160
  return this.where(columnName, 'IS NOT NULL');
62
161
  },
63
162
  /**
64
- * Returns `CAST(COUNT("alias"."column") AS integer)`: the rows where the column isn't `NULL`.
163
+ * Returns `CAST(COUNT("alias"."column") AS integer)`: the rows where the column isn't `NULL`. With the `driver` option
164
+ * MySQL casts `AS SIGNED`, a `BIGINT`, which its TypeORM driver returns as a string by default.
65
165
  * Without a column it is `COUNT(*)`, all the rows of the result, in every database. To count the rows
66
166
  * of a table joined with LEFT JOIN, count one of its non-null columns: `images.count('id')`.
67
167
  * @example
@@ -76,21 +176,85 @@ const tableHelper = (alias, options = {}) => ({
76
176
  throw new TypeError('tableHelper.count(): the second argument is an options object, e.g. { distinct: true } or { filter: "…" }');
77
177
  }
78
178
  const { distinct = false, filter, syntax = 'case' } = countOptions;
79
- if (syntax !== 'case' && syntax !== 'filter') {
80
- throw new TypeError(`tableHelper.count(): syntax is 'case' or 'filter', got "${String(syntax)}"`);
81
- }
82
179
  const column = columnName ? this.column(columnName) : undefined;
83
180
  if (distinct && !column) {
84
181
  throw new TypeError("tableHelper.count({ distinct: true }) needs a column, e.g. count('email', { distinct: true })");
85
182
  }
86
183
  // `COUNT(*)`, not `COUNT("alias".*)`, which only PostgreSQL accepts;
87
184
  // a CASE can't return `*`: without a column, it counts 1 per matching row
88
- const counted = filter && syntax === 'case' ? `CASE WHEN ${filter} THEN ${column !== null && column !== void 0 ? column : '1'} END` : column !== null && column !== void 0 ? column : '*';
89
- let expression = `COUNT(${distinct ? 'DISTINCT ' : ''}${counted})`;
90
- if (filter && syntax === 'filter') {
91
- expression += ` FILTER (WHERE ${filter})`;
185
+ const { argument, suffix } = filtered('count', column !== null && column !== void 0 ? column : (filter && syntax === 'case' ? '1' : '*'), filter, syntax, options.driver);
186
+ // `CAST(… AS integer)` is a syntax error on MySQL
187
+ return this.cast(`COUNT(${distinct ? 'DISTINCT ' : ''}${argument})${suffix}`, (0, sql_utils_1.isMysqlLikeDriver)(options.driver) ? 'SIGNED' : 'integer');
188
+ },
189
+ /**
190
+ * Returns `COALESCE(SUM("alias"."column"), 0)`: 0, not `NULL`, for a group without rows or a LEFT JOIN
191
+ * without a match. Only a column whose property is a number. PostgreSQL sums integers into a `bigint`
192
+ * and MySQL into a `DECIMAL`, which the drivers return as strings.
193
+ * @example
194
+ * t.sum('amountCents', { filter: t.where('status', "= 'paid'") })
195
+ * // COALESCE(SUM(CASE WHEN "order"."status" = 'paid' THEN "order"."amountCents" END), 0)
196
+ */
197
+ sum(columnName, sumOptions = {}) {
198
+ const { argument, suffix } = filtered('sum', this.column(columnName), sumOptions.filter, sumOptions.syntax, options.driver);
199
+ return `COALESCE(SUM(${argument})${suffix}, 0)`;
200
+ },
201
+ /**
202
+ * Returns the PostgreSQL array of a column over the rows of a group, `{}` for a group without rows.
203
+ * Other databases have no arrays and throw a `TypeError`: use `jsonAgg` there.
204
+ * @example
205
+ * images.arrayAgg('id', { distinct: true, orderBy: ['id', 'DESC'], filter: images.isNotNull('id') })
206
+ * // COALESCE(array_agg(DISTINCT "image"."id" ORDER BY "image"."id" DESC) FILTER (WHERE "image"."id" IS NOT NULL), '{}')
207
+ */
208
+ arrayAgg(columnName, aggOptions = {}) {
209
+ var _a;
210
+ const { distinct = false, orderBy, filter } = aggOptions;
211
+ if (options.driver !== undefined && !(0, sql_utils_1.isPostgresLikeDriver)(options.driver)) {
212
+ throw new TypeError(`tableHelper.arrayAgg(): arrays are PostgreSQL's, the driver is "${options.driver}": use jsonAgg`);
213
+ }
214
+ const order = orderBy ? aggregateOrder('arrayAgg', orderBy, (column) => this.column(column)) : undefined;
215
+ // PostgreSQL: "in an aggregate with DISTINCT, ORDER BY expressions must appear in argument list"
216
+ if (distinct && order && order.column !== columnName) {
217
+ throw new TypeError(`tableHelper.arrayAgg(): with distinct, the array can only be ordered by "${columnName}", got "${order.column}"`);
218
+ }
219
+ const aggregate = `array_agg(${distinct ? 'DISTINCT ' : ''}${this.column(columnName)}${(_a = order === null || order === void 0 ? void 0 : order.sql) !== null && _a !== void 0 ? _a : ''})`;
220
+ // the untyped '{}' takes the type of the array
221
+ return `COALESCE(${aggregate}${filter ? ` FILTER (WHERE ${filter})` : ''}, '{}')`;
222
+ },
223
+ /**
224
+ * Returns a JSON object of columns, keyed by the column names or by the keys of a `{ key: column }` map.
225
+ * Every column is checked against the entity type.
226
+ * @example
227
+ * t.jsonBuildObject(['id', 'url']) // json_build_object('id', "image"."id", 'url', "image"."url")
228
+ * t.jsonBuildObject({ imageId: 'id', src: 'url' }) // json_build_object('imageId', "image"."id", 'src', "image"."url")
229
+ * // MySQL: JSON_OBJECT(…), SQLite: json_object(…); { jsonb: true }: jsonb_build_object(…)
230
+ */
231
+ jsonBuildObject(columns, jsonOptions = {}) {
232
+ const entries = Array.isArray(columns)
233
+ ? columns.map((column) => [column, column])
234
+ : Object.entries(columns);
235
+ if (!entries.length) {
236
+ throw new TypeError('tableHelper.jsonBuildObject() needs at least one column');
92
237
  }
93
- return this.cast(expression, 'integer');
238
+ const pairs = entries.map(([key, column]) => `${sqlString(key, options.driver)}, ${this.column(column)}`);
239
+ return `${jsonFunctions(options.driver, jsonOptions.jsonb).object}(${pairs.join(', ')})`;
240
+ },
241
+ /**
242
+ * Returns the JSON array of an expression over the rows of a group, `[]` for a group without rows
243
+ * (the aggregate alone gives `NULL` there). Use it with GROUP BY, or in a subquery.
244
+ * @example
245
+ * images.jsonAgg(images.jsonBuildObject(['id', 'url']), { orderBy: ['id', 'DESC'], filter: images.isNotNull('id') })
246
+ * // COALESCE(json_agg(json_build_object(…) ORDER BY "image"."id" DESC) FILTER (WHERE "image"."id" IS NOT NULL), CAST('[]' AS json))
247
+ * // MySQL: COALESCE(JSON_ARRAYAGG(…), JSON_ARRAY()), without ORDER BY and FILTER; SQLite: json_group_array(…)
248
+ */
249
+ jsonAgg(expression, aggOptions = {}) {
250
+ const { orderBy, filter, jsonb } = aggOptions;
251
+ const json = jsonFunctions(options.driver, jsonb);
252
+ if (json.mysql && (orderBy || filter)) {
253
+ throw new TypeError("tableHelper.jsonAgg(): MySQL's JSON_ARRAYAGG takes no ORDER BY and no FILTER");
254
+ }
255
+ const order = orderBy ? aggregateOrder('jsonAgg', orderBy, (column) => this.column(column)).sql : '';
256
+ const aggregate = `${json.agg}(${expression}${order})${filter ? ` FILTER (WHERE ${filter})` : ''}`;
257
+ return `COALESCE(${aggregate}, ${json.emptyArray})`;
94
258
  },
95
259
  /** Returns `CAST(expression AS type)`. */
96
260
  cast(expression, type) {
@@ -119,6 +283,37 @@ const tableHelper = (alias, options = {}) => ({
119
283
  */
120
284
  jsonPath(_columnName, jsonOperator, ...fieldNames) {
121
285
  return (0, json_path_utils_1.buildJsonPath)(fieldNames, jsonOperator);
286
+ },
287
+ /**
288
+ * Returns `(expression AT TIME ZONE zone)` (PostgreSQL). A `timestamptz` gives the wall-clock `timestamp`
289
+ * in that zone; a `timestamp` is read as wall-clock time there and gives a `timestamptz`.
290
+ * A zone name is written as an escaped literal, so it is safe to take from a request.
291
+ * @example
292
+ * t.atTimeZone('paidAt', 'Europe/Kyiv') // ("order"."paidAt" AT TIME ZONE 'Europe/Kyiv')
293
+ * t.atTimeZone('startsAt', { column: 'timezone', fallback: 'UTC' }) // ("order"."startsAt" AT TIME ZONE COALESCE("order"."timezone", 'UTC'))
294
+ */
295
+ atTimeZone(value, timeZone) {
296
+ assertPostgres('atTimeZone', options.driver);
297
+ const toColumn = (column) => this.column(column);
298
+ return `(${toExpression(value, toColumn)} AT TIME ZONE ${toTimeZone('atTimeZone', timeZone, toColumn)})`;
299
+ },
300
+ /**
301
+ * Returns `date_trunc('unit', expression)` (PostgreSQL). With a time zone it is the three-argument form
302
+ * (PostgreSQL 12+): a `timestamptz` is truncated at the boundaries of that zone, e.g. its local midnight
303
+ * for `'day'`, and the result is a `timestamptz` again.
304
+ * @example
305
+ * t.dateTrunc('day', 'paidAt') // date_trunc('day', "order"."paidAt")
306
+ * t.dateTrunc('day', 'paidAt', 'Europe/Kyiv') // date_trunc('day', "order"."paidAt", 'Europe/Kyiv')
307
+ */
308
+ dateTrunc(unit, value, timeZone) {
309
+ assertPostgres('dateTrunc', options.driver);
310
+ // the unit goes into the SQL as is
311
+ if (!DATE_TRUNC_UNITS.includes(unit)) {
312
+ throw new TypeError(`tableHelper.dateTrunc(): the unit is one of ${DATE_TRUNC_UNITS.join(', ')}, got "${String(unit)}"`);
313
+ }
314
+ const toColumn = (column) => this.column(column);
315
+ const zone = timeZone === undefined ? '' : `, ${toTimeZone('dateTrunc', timeZone, toColumn)}`;
316
+ return `date_trunc('${unit}', ${toExpression(value, toColumn)}${zone})`;
122
317
  }
123
318
  });
124
319
  exports.tableHelper = tableHelper;
@@ -0,0 +1,8 @@
1
+ /** Throws a `TypeError` for a time zone the runtime doesn't know. */
2
+ export declare function assertTimeZone(timeZone: string): void;
3
+ /**
4
+ * Reads a date (`'2024-01-31'`, its midnight) or a date-time without an offset (`'2024-01-31 10:00'`) as
5
+ * the wall clock of `timeZone`, and returns that instant. A date-time with an offset (`Z`, `+02:00`) is an
6
+ * instant already: it is returned as that `Date`. Any other string throws a `TypeError` naming `name`.
7
+ */
8
+ export declare function readInTimeZone(value: string, timeZone: string, name: string): Date;