typeorm-extensions 2.0.0 → 2.2.0-rc.0

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -8
  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 +158 -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-period-filter.extension.js +1 -1
  40. package/dist/extensions/typed/typed-where/typed-where-array.extension.d.ts +10 -9
  41. package/dist/extensions/typed/typed-where/typed-where-array.extension.js +32 -0
  42. package/dist/extensions/typed/typed-where/typed-where-ilike.extension.d.ts +7 -6
  43. package/dist/extensions/typed/typed-where/typed-where-is-any.extension.d.ts +39 -129
  44. package/dist/extensions/typed/typed-where/typed-where-is-any.extension.js +2 -1
  45. package/dist/extensions/typed/typed-where/typed-where-is-distinct-from.extension.d.ts +7 -6
  46. package/dist/extensions/typed/typed-where/typed-where-is-distinct-from.extension.js +1 -1
  47. package/dist/extensions/typed/typed-where/typed-where-is-in.extension.d.ts +36 -117
  48. package/dist/extensions/typed/typed-where/typed-where-is-in.extension.js +11 -19
  49. package/dist/extensions/typed/typed-where/typed-where-json.extension.d.ts +7 -6
  50. package/dist/extensions/typed/typed-where/typed-where-json.extension.js +1 -1
  51. package/dist/extensions/typed/typed-where/typed-where-lower.extension.d.ts +7 -6
  52. package/dist/extensions/typed/typed-where/typed-where-similar.extension.d.ts +5 -4
  53. package/dist/extensions/typed/typed-where/typed-where.extension.d.ts +30 -73
  54. package/dist/extensions/typed/typed-where/typed-where.extension.js +10 -10
  55. package/dist/extensions/union.extension.d.ts +22 -0
  56. package/dist/extensions/union.extension.js +52 -0
  57. package/dist/extensions/where-exists.extension.d.ts +50 -0
  58. package/dist/extensions/where-exists.extension.js +64 -0
  59. package/dist/extensions/where-relation-exists.extension.d.ts +38 -0
  60. package/dist/extensions/where-relation-exists.extension.js +95 -0
  61. package/dist/index.d.ts +3 -0
  62. package/dist/index.js +1 -0
  63. package/dist/types/extensions/cursor-pagination.types.d.ts +5 -0
  64. package/dist/types/extensions/get-many-with-totals.types.d.ts +19 -0
  65. package/dist/types/extensions/index.d.ts +5 -0
  66. package/dist/types/extensions/index.js +5 -0
  67. package/dist/types/extensions/join-lateral.types.d.ts +6 -0
  68. package/dist/types/extensions/join-lateral.types.js +2 -0
  69. package/dist/types/extensions/period.types.d.ts +8 -0
  70. package/dist/types/extensions/query-returning.types.d.ts +11 -0
  71. package/dist/types/extensions/query-returning.types.js +2 -0
  72. package/dist/types/extensions/typed-group-by.types.d.ts +8 -0
  73. package/dist/types/extensions/typed-group-by.types.js +2 -0
  74. package/dist/types/extensions/typed-having.types.d.ts +2 -0
  75. package/dist/types/extensions/typed-having.types.js +2 -0
  76. package/dist/types/extensions/union.types.d.ts +12 -0
  77. package/dist/types/extensions/union.types.js +2 -0
  78. package/dist/types/extensions/where.types.d.ts +1 -0
  79. package/dist/types/interfaces/config.interface.d.ts +17 -0
  80. package/dist/types/interfaces/config.interface.js +2 -0
  81. package/dist/types/interfaces/index.d.ts +1 -0
  82. package/dist/types/interfaces/index.js +1 -0
  83. package/dist/types/modules/proxy-callback.types.d.ts +28 -4
  84. package/dist/types/modules/typed-params.types.d.ts +45 -0
  85. package/dist/types/modules/typed-params.types.js +2 -0
  86. package/dist/types/modules/typed-root.types.d.ts +13 -0
  87. package/dist/types/modules/typed-root.types.js +2 -0
  88. package/dist/utils/calculated-column.utils.d.ts +21 -0
  89. package/dist/utils/calculated-column.utils.js +63 -0
  90. package/dist/utils/json-path.utils.d.ts +22 -0
  91. package/dist/utils/json-path.utils.js +57 -0
  92. package/dist/utils/parameter.utils.d.ts +7 -0
  93. package/dist/utils/parameter.utils.js +21 -0
  94. package/dist/utils/period.utils.js +7 -2
  95. package/dist/utils/prototype.utils.d.ts +5 -1
  96. package/dist/utils/prototype.utils.js +4 -1
  97. package/dist/utils/proxy-key.utils.d.ts +9 -4
  98. package/dist/utils/proxy-key.utils.js +21 -14
  99. package/dist/utils/query-builder.utils.d.ts +13 -3
  100. package/dist/utils/query-builder.utils.js +95 -18
  101. package/dist/utils/sql.utils.d.ts +2 -0
  102. package/dist/utils/sql.utils.js +5 -1
  103. package/dist/utils/table.utils.d.ts +112 -2
  104. package/dist/utils/table.utils.js +206 -22
  105. package/dist/utils/time-zone.utils.d.ts +8 -0
  106. package/dist/utils/time-zone.utils.js +102 -0
  107. package/dist/utils/where.utils.d.ts +2 -2
  108. package/dist/utils/where.utils.js +23 -2
  109. package/package.json +4 -3
@@ -2,18 +2,106 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.tableHelper = void 0;
4
4
  const sql_utils_1 = require("./sql.utils");
5
- /** Wraps a JSON key in single quotes, doubling any quote inside it (standard SQL string literal). */
6
- function toJsonKeyLiteral(fieldName) {
7
- return `'${String(fieldName).replace(/'/g, "''")}'`;
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}` : ''}` };
8
31
  }
9
- function buildJsonPath(fieldNames, jsonOperator) {
10
- let result = '';
11
- for (let i = 0; i < fieldNames.length; i++) {
12
- const op = i === fieldNames.length - 1 ? jsonOperator : '->';
13
- result += `${result ? ' ' : ''}${op} ${toJsonKeyLiteral(fieldNames[i])}`;
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}"`);
14
50
  }
15
- return result;
16
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, "''")}'`;
17
105
  /**
18
106
  * Creates a type-safe SQL helper for building column references, WHERE expressions,
19
107
  * JOIN clauses, and JSONB accessors for a given entity type.
@@ -31,13 +119,13 @@ const tableHelper = (alias, options = {}) => ({
31
119
  * identifier, so names with other characters work too: `"user"."first-name"`.
32
120
  */
33
121
  column(columnName, alias) {
34
- const { useQuotes = true } = options;
122
+ const { useQuotes = true, driver } = options;
35
123
  const tableAlias = alias !== null && alias !== void 0 ? alias : this.alias;
36
124
  const column = String(columnName);
37
125
  if (!useQuotes) {
38
126
  return [tableAlias, column].filter(Boolean).join('.');
39
127
  }
40
- 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)]
41
129
  .filter(Boolean)
42
130
  .join('.');
43
131
  },
@@ -72,7 +160,8 @@ const tableHelper = (alias, options = {}) => ({
72
160
  return this.where(columnName, 'IS NOT NULL');
73
161
  },
74
162
  /**
75
- * 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.
76
165
  * Without a column it is `COUNT(*)`, all the rows of the result, in every database. To count the rows
77
166
  * of a table joined with LEFT JOIN, count one of its non-null columns: `images.count('id')`.
78
167
  * @example
@@ -87,21 +176,85 @@ const tableHelper = (alias, options = {}) => ({
87
176
  throw new TypeError('tableHelper.count(): the second argument is an options object, e.g. { distinct: true } or { filter: "…" }');
88
177
  }
89
178
  const { distinct = false, filter, syntax = 'case' } = countOptions;
90
- if (syntax !== 'case' && syntax !== 'filter') {
91
- throw new TypeError(`tableHelper.count(): syntax is 'case' or 'filter', got "${String(syntax)}"`);
92
- }
93
179
  const column = columnName ? this.column(columnName) : undefined;
94
180
  if (distinct && !column) {
95
181
  throw new TypeError("tableHelper.count({ distinct: true }) needs a column, e.g. count('email', { distinct: true })");
96
182
  }
97
183
  // `COUNT(*)`, not `COUNT("alias".*)`, which only PostgreSQL accepts;
98
184
  // a CASE can't return `*`: without a column, it counts 1 per matching row
99
- const counted = filter && syntax === 'case' ? `CASE WHEN ${filter} THEN ${column !== null && column !== void 0 ? column : '1'} END` : column !== null && column !== void 0 ? column : '*';
100
- let expression = `COUNT(${distinct ? 'DISTINCT ' : ''}${counted})`;
101
- if (filter && syntax === 'filter') {
102
- 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}"`);
103
218
  }
104
- return this.cast(expression, 'integer');
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');
237
+ }
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})`;
105
258
  },
106
259
  /** Returns `CAST(expression AS type)`. */
107
260
  cast(expression, type) {
@@ -115,7 +268,7 @@ const tableHelper = (alias, options = {}) => ({
115
268
  */
116
269
  jsonColumn(columnName, jsonOperator, ...fieldNames) {
117
270
  const column = this.column(columnName);
118
- const path = buildJsonPath(fieldNames, jsonOperator);
271
+ const path = (0, json_path_utils_1.buildJsonPath)(fieldNames, jsonOperator);
119
272
  return `${column} ${path}`;
120
273
  },
121
274
  /**
@@ -129,7 +282,38 @@ const tableHelper = (alias, options = {}) => ({
129
282
  * qb.whereTyped(u => u.metadata, `${t.jsonPath('metadata', '->>', 'role')} =`, 'admin')
130
283
  */
131
284
  jsonPath(_columnName, jsonOperator, ...fieldNames) {
132
- return buildJsonPath(fieldNames, jsonOperator);
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})`;
133
317
  }
134
318
  });
135
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;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readInTimeZone = exports.assertTimeZone = void 0;
4
+ const DAY_MS = 24 * 60 * 60 * 1000;
5
+ const MAX_CACHED_FORMATTERS = 500;
6
+ const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/i;
7
+ const OFFSET = /^([+-])(\d{2}):?(\d{2})?$/;
8
+ const formatters = new Map();
9
+ function getFormatter(timeZone) {
10
+ let formatter = formatters.get(timeZone);
11
+ if (formatter === undefined) {
12
+ try {
13
+ formatter = new Intl.DateTimeFormat('en-US', {
14
+ timeZone,
15
+ hour12: false,
16
+ era: 'short',
17
+ year: 'numeric',
18
+ month: 'numeric',
19
+ day: 'numeric',
20
+ hour: 'numeric',
21
+ minute: 'numeric',
22
+ second: 'numeric',
23
+ });
24
+ }
25
+ catch {
26
+ throw new TypeError(`Invalid timeZone "${timeZone}": not an IANA time zone`);
27
+ }
28
+ // the keys are caller input: Intl accepts any letter case of a zone name
29
+ if (formatters.size >= MAX_CACHED_FORMATTERS) {
30
+ formatters.clear();
31
+ }
32
+ formatters.set(timeZone, formatter);
33
+ }
34
+ return formatter;
35
+ }
36
+ /** The wall clock of an instant in a time zone, as milliseconds of the same wall clock in UTC. */
37
+ function getWallClock(instant, timeZone) {
38
+ const parts = {};
39
+ for (const { type, value } of getFormatter(timeZone).formatToParts(new Date(instant))) {
40
+ parts[type] = value;
41
+ }
42
+ const year = parts.era === 'BC' ? 1 - Number(parts.year) : Number(parts.year);
43
+ const wallClock = new Date(0);
44
+ // some ICU versions write midnight as 24 with hour12: false
45
+ wallClock.setUTCFullYear(year, Number(parts.month) - 1, Number(parts.day));
46
+ wallClock.setUTCHours(Number(parts.hour) % 24, Number(parts.minute), Number(parts.second), 0);
47
+ return wallClock.getTime() + (((instant % 1000) + 1000) % 1000);
48
+ }
49
+ /**
50
+ * The instant at which the wall clock of a time zone shows `wallClock` (milliseconds of that wall clock in
51
+ * UTC). Like PostgreSQL: a time skipped by a forward transition takes the offset before it, and a time that
52
+ * occurs twice is read with the offset after the transition, the later of the two instants.
53
+ */
54
+ function wallClockToInstant(wallClock, timeZone) {
55
+ const offsets = new Set([wallClock - DAY_MS, wallClock, wallClock + DAY_MS].map((instant) => getWallClock(instant, timeZone) - instant));
56
+ const candidates = [...offsets].map((offset) => wallClock - offset);
57
+ const matching = candidates.filter((instant) => getWallClock(instant, timeZone) === wallClock);
58
+ return Math.max(...(matching.length > 0 ? matching : candidates));
59
+ }
60
+ /** Throws a `TypeError` for a time zone the runtime doesn't know. */
61
+ function assertTimeZone(timeZone) {
62
+ getFormatter(timeZone);
63
+ }
64
+ exports.assertTimeZone = assertTimeZone;
65
+ /**
66
+ * Reads a date (`'2024-01-31'`, its midnight) or a date-time without an offset (`'2024-01-31 10:00'`) as
67
+ * the wall clock of `timeZone`, and returns that instant. A date-time with an offset (`Z`, `+02:00`) is an
68
+ * instant already: it is returned as that `Date`. Any other string throws a `TypeError` naming `name`.
69
+ */
70
+ function readInTimeZone(value, timeZone, name) {
71
+ const match = DATE_TIME.exec(value);
72
+ if (match === null) {
73
+ throw new TypeError(`Invalid ${name} "${value}": with timeZone, expected a Date, a date (YYYY-MM-DD) or a date-time ` +
74
+ '(YYYY-MM-DD HH:mm[:ss[.sss]]) with or without an offset');
75
+ }
76
+ const [, year, month, day, hour = '0', minute = '0', second = '0', fraction = '', offset] = match;
77
+ const wallClock = new Date(0);
78
+ wallClock.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
79
+ // a Date keeps milliseconds: finer fractions are dropped
80
+ wallClock.setUTCHours(Number(hour), Number(minute), Number(second), Number(fraction.padEnd(3, '0').slice(0, 3)));
81
+ const roundTrip = wallClock.toISOString();
82
+ const expected = `${year}-${month}-${day}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
83
+ if (isNaN(wallClock.getTime()) || roundTrip.slice(0, 19) !== expected) {
84
+ throw new TypeError(`Invalid ${name} "${value}": not a calendar date and time`);
85
+ }
86
+ if (offset === undefined) {
87
+ return new Date(wallClockToInstant(wallClock.getTime(), timeZone));
88
+ }
89
+ // an offset makes it an instant already; as text the databases would read it each their own way
90
+ return new Date(wallClock.getTime() - getOffsetMinutes(offset, value, name) * 60000);
91
+ }
92
+ exports.readInTimeZone = readInTimeZone;
93
+ function getOffsetMinutes(offset, value, name) {
94
+ if (offset.toUpperCase() === 'Z') {
95
+ return 0;
96
+ }
97
+ const [, sign, hours, minutes = '00'] = OFFSET.exec(offset);
98
+ if (Number(hours) > 23 || Number(minutes) > 59) {
99
+ throw new TypeError(`Invalid ${name} "${value}": not a UTC offset`);
100
+ }
101
+ return (sign === '-' ? -1 : 1) * (Number(hours) * 60 + Number(minutes));
102
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ObjectLiteral, WhereExpressionBuilder } from 'typeorm';
2
- import type { WhereMethods } from '../types/extensions/where.types';
2
+ import type { HavingMethods, WhereMethods } from '../types/extensions/where.types';
3
3
  export declare function getValuePlaceholder<T>(value: T, paramName?: string): string;
4
4
  /**
5
5
  * Whether `params` is a `{ name: value }` params object (a plain object or a class instance such
@@ -12,7 +12,7 @@ export declare function isParamsObject(params: unknown): params is Record<string
12
12
  * so `::type` casts and colons inside literals don't count unless they name a `params` key.
13
13
  */
14
14
  export declare function hasParamPlaceholder(conditions: string, params?: unknown): boolean;
15
- export declare function attachWhere<QB extends WhereExpressionBuilder>(builder: QB, method: WhereMethods, field: string, conditions: string, params?: unknown, options?: {
15
+ export declare function attachWhere<QB extends WhereExpressionBuilder>(builder: QB, method: WhereMethods | HavingMethods, field: string, conditions: string, params?: unknown, options?: {
16
16
  addValue?: boolean;
17
17
  }): QB;
18
18
  /**
@@ -49,14 +49,35 @@ function hasParamPlaceholder(conditions, params) {
49
49
  return false;
50
50
  }
51
51
  exports.hasParamPlaceholder = hasParamPlaceholder;
52
+ /**
53
+ * A params object whose value goes after the condition (`'='`, `{ id: 1 }`) holds that one value. With other
54
+ * keys only the first was used and the rest dropped; with none the column was compared with NULL.
55
+ */
56
+ function assertSingleValueParams(field, params) {
57
+ if (!isParamsObject(params)) {
58
+ return;
59
+ }
60
+ const keys = Object.keys(params);
61
+ if (keys.length !== 1) {
62
+ throw new TypeError(`Condition on "${field}": a params object without a placeholder in the condition holds the one value to compare with, ` +
63
+ `got ${keys.length} keys (${keys.join(', ') || 'none'}). Pass the value itself, or name the keys in the condition: '= :id'`);
64
+ }
65
+ }
66
+ /** The builder's `where` / `having` method, bound to it: only a SelectQueryBuilder has `having`. */
67
+ function addCondition(builder, method) {
68
+ const add = builder[method];
69
+ return (condition, parameters) => add.call(builder, condition, parameters);
70
+ }
52
71
  function attachWhere(builder, method, field, conditions, params, options) {
53
72
  let { addValue } = options || {};
54
73
  if (method && typeof field === 'string' && typeof conditions === 'string') {
74
+ const fieldName = field;
55
75
  field = (0, query_builder_utils_1.toFieldSql)(builder, field);
56
76
  const hasParams = typeof params !== 'undefined';
57
77
  // `addValue` defaults to `true` unless `conditions` already references one of the `params` keys
58
78
  addValue = addValue !== null && addValue !== void 0 ? addValue : !hasParamPlaceholder(conditions, params);
59
79
  if (addValue && hasParams) {
80
+ assertSingleValueParams(fieldName, params);
60
81
  // pick first value from params object or pass as is (a Date or a Buffer is a value too)
61
82
  const firstValue = isParamsObject(params) ? Object.values(params)[0] : params;
62
83
  // 0, false, '' are valid values like any other
@@ -69,10 +90,10 @@ function attachWhere(builder, method, field, conditions, params, options) {
69
90
  : undefined;
70
91
  const queryParams = paramName && valueIsValid ? { [paramName]: firstValue } : undefined;
71
92
  const valuePlaceholder = getValuePlaceholder(firstValue, paramName);
72
- return builder[method](`${field} ${conditions} ${valuePlaceholder}`, queryParams);
93
+ return addCondition(builder, method)(`${field} ${conditions} ${valuePlaceholder}`, queryParams);
73
94
  }
74
95
  else {
75
- return builder[method](`${field} ${conditions}`, params);
96
+ return addCondition(builder, method)(`${field} ${conditions}`, params);
76
97
  }
77
98
  }
78
99
  return builder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeorm-extensions",
3
- "version": "2.0.0",
3
+ "version": "2.2.0-rc.0",
4
4
  "description": "typeorm commonly used extensions",
5
5
  "main": "./dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,7 +13,8 @@
13
13
  },
14
14
  "scripts": {
15
15
  "build": "tsc -p .",
16
- "lint": "eslint . --ext .ts"
16
+ "lint": "eslint . --ext .ts",
17
+ "test:types": "tsc -p test/types"
17
18
  },
18
19
  "keywords": [
19
20
  "typeorm",
@@ -23,7 +24,7 @@
23
24
  "utils"
24
25
  ],
25
26
  "author": "koenigstag",
26
- "license": "ISC",
27
+ "license": "MIT",
27
28
  "peerDependencies": {
28
29
  "typeorm": "^0.3.12 || ^1.0.0"
29
30
  },