@devstroupe/devkit-nest 1.1.15 → 1.2.0-beta.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/README.md +35 -55
  2. package/dist/application/crud-use-cases.d.ts +35 -0
  3. package/dist/application/crud-use-cases.js +44 -0
  4. package/dist/application/crud-use-cases.test.d.ts +1 -0
  5. package/dist/application/crud-use-cases.test.js +19 -0
  6. package/dist/application/index.d.ts +2 -0
  7. package/dist/application/index.js +18 -0
  8. package/dist/application/read-model.d.ts +5 -0
  9. package/dist/application/read-model.js +2 -0
  10. package/dist/context/execution-context.d.ts +19 -0
  11. package/dist/context/execution-context.interceptor.d.ts +5 -0
  12. package/dist/context/execution-context.interceptor.js +35 -0
  13. package/dist/context/execution-context.interceptor.test.d.ts +1 -0
  14. package/dist/context/execution-context.interceptor.test.js +26 -0
  15. package/dist/context/execution-context.js +38 -0
  16. package/dist/context/execution-context.test.d.ts +1 -0
  17. package/dist/context/execution-context.test.js +18 -0
  18. package/dist/context/index.d.ts +2 -0
  19. package/dist/context/index.js +18 -0
  20. package/dist/database/base.repository.d.ts +25 -13
  21. package/dist/database/base.repository.js +63 -29
  22. package/dist/database/base.repository.test.d.ts +1 -0
  23. package/dist/database/base.repository.test.js +46 -0
  24. package/dist/database/typeorm-explain.d.ts +3 -0
  25. package/dist/database/typeorm-explain.js +8 -0
  26. package/dist/database/typeorm-explain.test.d.ts +1 -0
  27. package/dist/database/typeorm-explain.test.js +15 -0
  28. package/dist/database/typeorm-query.builder.d.ts +36 -8
  29. package/dist/database/typeorm-query.builder.js +150 -165
  30. package/dist/database/typeorm-query.builder.test.js +32 -1
  31. package/dist/database/typeorm-reader.d.ts +9 -0
  32. package/dist/database/typeorm-reader.js +49 -0
  33. package/dist/database/typeorm-reader.test.d.ts +1 -0
  34. package/dist/database/typeorm-reader.test.js +55 -0
  35. package/dist/database/typeorm-sql.d.ts +3 -0
  36. package/dist/database/typeorm-sql.js +7 -0
  37. package/dist/database/typeorm-sql.test.d.ts +1 -0
  38. package/dist/database/typeorm-sql.test.js +21 -0
  39. package/dist/database/typeorm-unit-of-work.d.ts +13 -0
  40. package/dist/database/typeorm-unit-of-work.js +16 -0
  41. package/dist/database/typeorm-unit-of-work.test.d.ts +1 -0
  42. package/dist/database/typeorm-unit-of-work.test.js +34 -0
  43. package/dist/http/base-crud.controller.d.ts +26 -10
  44. package/dist/http/base-crud.controller.js +46 -36
  45. package/dist/http/base-crud.controller.test.d.ts +1 -0
  46. package/dist/http/base-crud.controller.test.js +18 -0
  47. package/dist/http/module-http-proxy.d.ts +16 -0
  48. package/dist/http/module-http-proxy.js +160 -0
  49. package/dist/http/module-http-proxy.test.d.ts +1 -0
  50. package/dist/http/module-http-proxy.test.js +37 -0
  51. package/dist/index.d.ts +8 -0
  52. package/dist/index.js +8 -0
  53. package/dist/platform/index.d.ts +3 -0
  54. package/dist/platform/index.js +19 -0
  55. package/dist/platform/integrations/company-registry.d.ts +16 -0
  56. package/dist/platform/integrations/company-registry.js +2 -0
  57. package/dist/platform/integrations/external-http.d.ts +38 -0
  58. package/dist/platform/integrations/external-http.js +78 -0
  59. package/dist/platform/integrations/external-http.test.d.ts +1 -0
  60. package/dist/platform/integrations/external-http.test.js +42 -0
  61. package/dist/platform/integrations/index.d.ts +3 -0
  62. package/dist/platform/integrations/index.js +19 -0
  63. package/dist/platform/integrations/webhook.d.ts +45 -0
  64. package/dist/platform/integrations/webhook.js +74 -0
  65. package/dist/platform/integrations/webhook.test.d.ts +1 -0
  66. package/dist/platform/integrations/webhook.test.js +66 -0
  67. package/dist/platform/messaging/index.d.ts +1 -0
  68. package/dist/platform/messaging/index.js +17 -0
  69. package/dist/platform/messaging/outbox.d.ts +56 -0
  70. package/dist/platform/messaging/outbox.js +90 -0
  71. package/dist/platform/messaging/outbox.test.d.ts +1 -0
  72. package/dist/platform/messaging/outbox.test.js +65 -0
  73. package/dist/platform/security/index.d.ts +2 -0
  74. package/dist/platform/security/index.js +18 -0
  75. package/dist/platform/security/password-hasher.d.ts +14 -0
  76. package/dist/platform/security/password-hasher.js +20 -0
  77. package/dist/platform/security/security.test.d.ts +1 -0
  78. package/dist/platform/security/security.test.js +24 -0
  79. package/dist/platform/security/token.d.ts +22 -0
  80. package/dist/platform/security/token.js +44 -0
  81. package/dist/realtime/index.d.ts +1 -0
  82. package/dist/realtime/index.js +1 -0
  83. package/dist/realtime/realtime.gateway.d.ts +4 -1
  84. package/dist/realtime/realtime.gateway.js +10 -38
  85. package/dist/realtime/realtime.gateway.test.d.ts +1 -0
  86. package/dist/realtime/realtime.gateway.test.js +34 -0
  87. package/dist/realtime/realtime.module.d.ts +7 -0
  88. package/dist/realtime/realtime.module.js +17 -6
  89. package/dist/realtime/realtime.tokens.d.ts +1 -0
  90. package/dist/realtime/realtime.tokens.js +4 -0
  91. package/dist/storage/drivers/local.storage.js +8 -1
  92. package/package.json +18 -14
@@ -0,0 +1,3 @@
1
+ import type { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
2
+ /** Test/harness helper. Never expose database plans in a public endpoint. */
3
+ export declare function explainQuery<Row extends ObjectLiteral = ObjectLiteral>(dataSource: Pick<DataSource, 'query'>, queryBuilder: Pick<SelectQueryBuilder<Row>, 'getQueryAndParameters'>): Promise<unknown[]>;
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.explainQuery = explainQuery;
4
+ /** Test/harness helper. Never expose database plans in a public endpoint. */
5
+ async function explainQuery(dataSource, queryBuilder) {
6
+ const [sql, parameters] = queryBuilder.getQueryAndParameters();
7
+ return dataSource.query(`EXPLAIN ${sql}`, parameters);
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_test_1 = __importDefault(require("node:test"));
8
+ const typeorm_explain_1 = require("./typeorm-explain");
9
+ (0, node_test_1.default)('EXPLAIN harness preserves QueryBuilder parameters', async () => {
10
+ const calls = [];
11
+ const source = { query: async (...args) => { calls.push(args); return []; } };
12
+ const qb = { getQueryAndParameters: () => ['SELECT * FROM invoices WHERE tenant_id = ?', ['tenant-1']] };
13
+ await (0, typeorm_explain_1.explainQuery)(source, qb);
14
+ strict_1.default.deepEqual(calls, [['EXPLAIN SELECT * FROM invoices WHERE tenant_id = ?', ['tenant-1']]]);
15
+ });
@@ -1,11 +1,39 @@
1
- import { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
2
- import { IFilterPagination } from '@devstroupe/devkit-core';
3
- export interface IQueryBuilderOptions {
4
- allowedFilters?: string[];
5
- allowedSorts?: string[];
6
- searchableFields?: string[];
1
+ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
2
+ import { IFilterOperators, IFilterPagination, IPaginationPolicy } from '@devstroupe/devkit-core';
3
+ export type PublicFilterOperator = keyof IFilterOperators | 'eq';
4
+ export interface FilterDefinition<Entity extends ObjectLiteral> {
5
+ /** Trusted SQL expression declared by the application, never by the client. */
6
+ expression: string | ((alias: string) => string);
7
+ operators?: readonly PublicFilterOperator[];
8
+ transform?: (value: unknown) => unknown;
7
9
  }
10
+ export type FilterRegistry<Entity extends ObjectLiteral> = Record<string, FilterDefinition<Entity>>;
11
+ export interface MySqlFullTextSearchDefinition {
12
+ /** Trusted entity fields configured by the application, never by the client. */
13
+ fields: readonly string[];
14
+ mode?: 'natural' | 'boolean';
15
+ }
16
+ export interface IQueryBuilderOptions<Entity extends ObjectLiteral = ObjectLiteral> {
17
+ filterRegistry?: FilterRegistry<Entity>;
18
+ /** Transitional compatibility. Values are validated before becoming expressions. */
19
+ allowedFilters?: readonly string[];
20
+ allowedSorts?: readonly string[];
21
+ searchableFields?: readonly string[];
22
+ /** Uses MySQL MATCH ... AGAINST for indexes configured as FULLTEXT. */
23
+ fullTextSearch?: MySqlFullTextSearchDefinition;
24
+ /** Field used by readers that need a separate COUNT(DISTINCT ...) query. */
25
+ distinctCountField?: string;
26
+ pagination?: Partial<IPaginationPolicy>;
27
+ cursor?: {
28
+ field: string;
29
+ direction?: 'ASC' | 'DESC';
30
+ };
31
+ onUnknownFilter?: (field: string) => void;
32
+ /** Internal reader controls for an unbounded count query. */
33
+ applyPagination?: boolean;
34
+ applySorting?: boolean;
35
+ }
36
+ export declare function trustedQueryExpression(alias: string, field: string): string;
8
37
  export declare class TypeOrmQueryBuilder {
9
- static apply<Entity extends ObjectLiteral>(qb: SelectQueryBuilder<Entity>, filter: IFilterPagination, options?: IQueryBuilderOptions): SelectQueryBuilder<Entity>;
10
- private static parseStructuredValue;
38
+ static apply<Entity extends ObjectLiteral>(qb: SelectQueryBuilder<Entity>, filter: IFilterPagination, options?: IQueryBuilderOptions<Entity>): SelectQueryBuilder<Entity>;
11
39
  }
@@ -1,192 +1,177 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TypeOrmQueryBuilder = void 0;
4
+ exports.trustedQueryExpression = trustedQueryExpression;
4
5
  const typeorm_1 = require("typeorm");
6
+ const devkit_core_1 = require("@devstroupe/devkit-core");
7
+ const SAFE_FIELD = /^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
8
+ function trustedQueryExpression(alias, field) {
9
+ if (!SAFE_FIELD.test(field))
10
+ throw new Error(`Unsafe configured query field: ${field}`);
11
+ return field.includes('.') ? field : `${alias}.${field}`;
12
+ }
13
+ function structuredValue(value) {
14
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
15
+ return value;
16
+ }
17
+ if (typeof value !== 'string' || !value.trim().startsWith('{'))
18
+ return undefined;
19
+ try {
20
+ const parsed = JSON.parse(value);
21
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
22
+ ? parsed
23
+ : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function legacyField(raw) {
30
+ const suffixes = [
31
+ ['_$bool', 'eq'], ['_$ilike', 'contains'], ['_$like', 'contains'],
32
+ ['_$date_start', 'gte'], ['_$date_end', 'lte'], ['_$not_in', 'notIn'],
33
+ ['_$gte', 'gte'], ['_$lte', 'lte'], ['_$in', 'in'], ['_$null', 'isNull'],
34
+ ];
35
+ const matched = suffixes.find(([suffix]) => raw.endsWith(suffix));
36
+ return matched
37
+ ? { field: raw.slice(0, -matched[0].length), operator: matched[1] }
38
+ : { field: raw };
39
+ }
5
40
  class TypeOrmQueryBuilder {
6
41
  static apply(qb, filter, options = {}) {
7
42
  const alias = qb.alias;
8
- const { allowedFilters, allowedSorts, searchableFields } = options;
9
- const structuredFilter = this.parseStructuredValue(filter.filter);
10
- const structuredSort = this.parseStructuredValue(filter.sort);
11
- // 1. Paginação e Limite
12
- const page = Number(filter.page) || 1;
13
- let limit = 10;
14
- if (filter.limit === 'todos' || filter.limit === 'all') {
15
- limit = 0;
16
- }
17
- else if (filter.limit) {
18
- limit = Number(filter.limit);
19
- }
20
- if (limit > 0) {
43
+ const policy = {
44
+ defaultLimit: options.pagination?.defaultLimit ?? devkit_core_1.DEFAULT_PAGINATION_POLICY.defaultLimit,
45
+ maxLimit: options.pagination?.maxLimit ?? devkit_core_1.DEFAULT_PAGINATION_POLICY.maxLimit,
46
+ };
47
+ if (options.applyPagination !== false) {
48
+ const limit = (0, devkit_core_1.resolvePaginationLimit)(filter.limit, policy);
49
+ const page = Math.max(1, Number(filter.page) || 1);
21
50
  qb.take(limit).skip((page - 1) * limit);
22
51
  }
23
- // 2. Busca Global (search)
24
- if (filter.search && searchableFields?.length) {
25
- qb.andWhere(new typeorm_1.Brackets((subQb) => {
26
- searchableFields.forEach((field, index) => {
27
- const paramName = `search_param_${index}_${Math.random().toString(36).slice(2, 6)}`;
28
- const fullField = field.includes('.') ? field : `${alias}.${field}`;
29
- subQb.orWhere(`LOWER(${fullField}) LIKE :${paramName}`, {
30
- [paramName]: `%${String(filter.search).toLowerCase()}%`,
52
+ let parameterSequence = 0;
53
+ const parameter = (field, operator) => `devkit_${field.replace(/[^a-zA-Z0-9_]/g, '_')}_${operator}_${parameterSequence++}`;
54
+ const fullText = options.fullTextSearch;
55
+ const searchable = options.searchableFields ?? [];
56
+ if (filter.search && fullText?.fields.length) {
57
+ const fields = fullText.fields.map((field) => trustedQueryExpression(alias, field));
58
+ const mode = fullText.mode === 'boolean' ? ' IN BOOLEAN MODE' : '';
59
+ qb.andWhere(`MATCH(${fields.join(', ')}) AGAINST (:devkit_fulltext${mode})`, {
60
+ devkit_fulltext: filter.search,
61
+ });
62
+ }
63
+ else if (filter.search && searchable.length) {
64
+ qb.andWhere(new typeorm_1.Brackets((nested) => {
65
+ searchable.forEach((field, index) => {
66
+ const expression = trustedQueryExpression(alias, field);
67
+ nested.orWhere(`LOWER(${expression}) LIKE :devkit_search_${index}`, {
68
+ [`devkit_search_${index}`]: `%${filter.search.toLowerCase()}%`,
31
69
  });
32
70
  });
33
71
  }));
34
72
  }
35
- // 3. Filtros Estruturados (filter[field][operator]=value ou filter[field]=value)
36
- if (structuredFilter) {
37
- for (const [rawField, value] of Object.entries(structuredFilter)) {
38
- // Tratar sufixos legados para retrocompatibilidade
39
- let field = rawField;
40
- let inferredOperator = null;
41
- if (field.endsWith('_$bool')) {
42
- field = field.replace('_$bool', '');
43
- inferredOperator = 'eq';
44
- }
45
- else if (field.endsWith('_$like') || field.endsWith('_$ilike')) {
46
- field = field.replace(/_\$i?like/, '');
47
- inferredOperator = 'contains';
48
- }
49
- else if (field.endsWith('_$date_start') || field.endsWith('_$gte')) {
50
- field = field.replace(/_\$(date_start|gte)/, '');
51
- inferredOperator = 'gte';
52
- }
53
- else if (field.endsWith('_$date_end') || field.endsWith('_$lte')) {
54
- field = field.replace(/_\$(date_end|lte)/, '');
55
- inferredOperator = 'lte';
56
- }
57
- else if (field.endsWith('_$in')) {
58
- field = field.replace('_$in', '');
59
- inferredOperator = 'in';
60
- }
61
- else if (field.endsWith('_$not_in')) {
62
- field = field.replace('_$not_in', '');
63
- inferredOperator = 'notIn';
64
- }
65
- else if (field.endsWith('_$null')) {
66
- field = field.replace('_$null', '');
67
- inferredOperator = 'isNull';
68
- }
69
- // Validação de campos permitidos
70
- if (allowedFilters && !allowedFilters.includes(field)) {
71
- console.warn(`Filter on field "${field}" is not allowed.`);
73
+ const configuredRegistry = options.filterRegistry ?? {};
74
+ const compatibilityRegistry = Object.fromEntries((options.allowedFilters ?? []).map((field) => [field, { expression: trustedQueryExpression(alias, field) }]));
75
+ const registry = { ...compatibilityRegistry, ...configuredRegistry };
76
+ const parsedFilters = structuredValue(filter.filter);
77
+ for (const [rawField, rawValue] of Object.entries(parsedFilters ?? {})) {
78
+ const legacy = legacyField(rawField);
79
+ const definition = registry[legacy.field];
80
+ if (!definition) {
81
+ options.onUnknownFilter?.(legacy.field);
82
+ continue;
83
+ }
84
+ const expression = typeof definition.expression === 'function'
85
+ ? definition.expression(alias)
86
+ : definition.expression;
87
+ const allowedOperators = definition.operators ?? ['eq', 'neq', 'contains', 'startsWith', 'endsWith', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'between', 'isNull', 'isNotNull'];
88
+ const operations = legacy.operator
89
+ ? [[legacy.operator, rawValue]]
90
+ : rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)
91
+ ? Object.entries(rawValue)
92
+ : [['eq', rawValue]];
93
+ for (const [rawOperator, inputValue] of operations) {
94
+ const operator = rawOperator;
95
+ if (!allowedOperators.includes(operator))
72
96
  continue;
73
- }
74
- const prefix = field.includes('.') ? '' : `${alias}.`;
75
- const fullField = `${prefix}${field}`;
76
- const applyCondition = (op, val) => {
77
- if (val === undefined || val === null || val === '')
78
- return;
79
- const paramName = `${field.replace(/\./g, '_')}_${op}_${Math.random().toString(36).slice(2, 6)}`;
80
- switch (op) {
81
- case 'eq':
82
- qb.andWhere(`${fullField} = :${paramName}`, { [paramName]: val });
83
- break;
84
- case 'neq':
85
- qb.andWhere(`${fullField} != :${paramName}`, { [paramName]: val });
86
- break;
87
- case 'contains':
88
- qb.andWhere(`LOWER(${fullField}) LIKE :${paramName}`, { [paramName]: `%${String(val).toLowerCase()}%` });
89
- break;
90
- case 'startsWith':
91
- qb.andWhere(`LOWER(${fullField}) LIKE :${paramName}`, { [paramName]: `${String(val).toLowerCase()}%` });
92
- break;
93
- case 'endsWith':
94
- qb.andWhere(`LOWER(${fullField}) LIKE :${paramName}`, { [paramName]: `%${String(val).toLowerCase()}` });
95
- break;
96
- case 'gt':
97
- qb.andWhere(`${fullField} > :${paramName}`, { [paramName]: val });
98
- break;
99
- case 'gte':
100
- qb.andWhere(`${fullField} >= :${paramName}`, { [paramName]: val });
101
- break;
102
- case 'lt':
103
- qb.andWhere(`${fullField} < :${paramName}`, { [paramName]: val });
104
- break;
105
- case 'lte':
106
- qb.andWhere(`${fullField} <= :${paramName}`, { [paramName]: val });
107
- break;
108
- case 'in':
109
- const inArr = Array.isArray(val) ? val : String(val).split(',').map((s) => s.trim());
110
- if (inArr.length > 0) {
111
- qb.andWhere(`${fullField} IN (:...${paramName})`, { [paramName]: inArr });
112
- }
113
- break;
114
- case 'notIn':
115
- const notInArr = Array.isArray(val) ? val : String(val).split(',').map((s) => s.trim());
116
- if (notInArr.length > 0) {
117
- qb.andWhere(`${fullField} NOT IN (:...${paramName})`, { [paramName]: notInArr });
118
- }
119
- break;
120
- case 'between':
121
- const betweenArr = Array.isArray(val) ? val : String(val).split(',').map((s) => s.trim());
122
- if (betweenArr.length === 2) {
123
- const p1 = `${paramName}_1`;
124
- const p2 = `${paramName}_2`;
125
- qb.andWhere(`${fullField} BETWEEN :${p1} AND :${p2}`, { [p1]: betweenArr[0], [p2]: betweenArr[1] });
126
- }
127
- break;
128
- case 'isNull':
129
- const isNullVal = typeof val === 'boolean' ? val : val === 'true' || val === '1';
130
- qb.andWhere(`${fullField} IS ${isNullVal ? 'NULL' : 'NOT NULL'}`);
131
- break;
132
- case 'isNotNull':
133
- const isNotNullVal = typeof val === 'boolean' ? val : val === 'true' || val === '1';
134
- qb.andWhere(`${fullField} IS ${isNotNullVal ? 'NOT NULL' : 'NULL'}`);
135
- break;
97
+ const value = definition.transform ? definition.transform(inputValue) : inputValue;
98
+ if (value === undefined || value === null || value === '')
99
+ continue;
100
+ const param = parameter(legacy.field, operator);
101
+ switch (operator) {
102
+ case 'eq':
103
+ qb.andWhere(`${expression} = :${param}`, { [param]: value });
104
+ break;
105
+ case 'neq':
106
+ qb.andWhere(`${expression} != :${param}`, { [param]: value });
107
+ break;
108
+ case 'contains':
109
+ qb.andWhere(`LOWER(${expression}) LIKE :${param}`, { [param]: `%${String(value).toLowerCase()}%` });
110
+ break;
111
+ case 'startsWith':
112
+ qb.andWhere(`LOWER(${expression}) LIKE :${param}`, { [param]: `${String(value).toLowerCase()}%` });
113
+ break;
114
+ case 'endsWith':
115
+ qb.andWhere(`LOWER(${expression}) LIKE :${param}`, { [param]: `%${String(value).toLowerCase()}` });
116
+ break;
117
+ case 'gt':
118
+ qb.andWhere(`${expression} > :${param}`, { [param]: value });
119
+ break;
120
+ case 'gte':
121
+ qb.andWhere(`${expression} >= :${param}`, { [param]: value });
122
+ break;
123
+ case 'lt':
124
+ qb.andWhere(`${expression} < :${param}`, { [param]: value });
125
+ break;
126
+ case 'lte':
127
+ qb.andWhere(`${expression} <= :${param}`, { [param]: value });
128
+ break;
129
+ case 'in':
130
+ case 'notIn': {
131
+ const values = Array.isArray(value) ? value : String(value).split(',').map((item) => item.trim());
132
+ if (values.length)
133
+ qb.andWhere(`${expression} ${operator === 'in' ? 'IN' : 'NOT IN'} (:...${param})`, { [param]: values });
134
+ break;
136
135
  }
137
- };
138
- if (inferredOperator) {
139
- applyCondition(inferredOperator, value);
140
- }
141
- else if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
142
- for (const [op, val] of Object.entries(value)) {
143
- applyCondition(op, val);
136
+ case 'between': {
137
+ const values = Array.isArray(value) ? value : String(value).split(',').map((item) => item.trim());
138
+ if (values.length === 2)
139
+ qb.andWhere(`${expression} BETWEEN :${param}_from AND :${param}_to`, {
140
+ [`${param}_from`]: values[0], [`${param}_to`]: values[1],
141
+ });
142
+ break;
143
+ }
144
+ case 'isNull':
145
+ case 'isNotNull': {
146
+ const truthy = value === true || value === 'true' || value === '1';
147
+ const wantsNull = operator === 'isNull' ? truthy : !truthy;
148
+ qb.andWhere(`${expression} IS ${wantsNull ? 'NULL' : 'NOT NULL'}`);
149
+ break;
144
150
  }
145
- }
146
- else {
147
- // Valor direto, mapeia para 'eq'
148
- applyCondition('eq', value);
149
151
  }
150
152
  }
151
153
  }
152
- // 4. Ordenação Estruturada (sort[field]=asc)
153
- if (structuredSort) {
154
- for (const [field, direction] of Object.entries(structuredSort)) {
155
- if (allowedSorts && !allowedSorts.includes(field)) {
156
- console.warn(`Sorting on field "${field}" is not allowed.`);
157
- continue;
154
+ if (options.applySorting !== false) {
155
+ const parsedSort = structuredValue(filter.sort);
156
+ if (parsedSort) {
157
+ for (const [field, direction] of Object.entries(parsedSort)) {
158
+ if (!options.allowedSorts?.includes(field))
159
+ continue;
160
+ qb.addOrderBy(trustedQueryExpression(alias, field), String(direction).toUpperCase() === 'DESC' ? 'DESC' : 'ASC');
158
161
  }
159
- const orderDirection = String(direction).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
160
- const fullField = field.includes('.') ? field : `${alias}.${field}`;
161
- qb.addOrderBy(fullField, orderDirection);
162
162
  }
163
- }
164
- else if (filter.sort && typeof filter.sort === 'string' && filter.direction) {
165
- // Retrocompatibilidade com query params planos (sort=name&direction=desc)
166
- const field = filter.sort;
167
- if (!allowedSorts || allowedSorts.includes(field)) {
168
- const orderDirection = String(filter.direction).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
169
- const fullField = field.includes('.') ? field : `${alias}.${field}`;
170
- qb.addOrderBy(fullField, orderDirection);
163
+ else if (typeof filter.sort === 'string' && options.allowedSorts?.includes(filter.sort)) {
164
+ qb.addOrderBy(trustedQueryExpression(alias, filter.sort), String(filter.direction).toUpperCase() === 'DESC' ? 'DESC' : 'ASC');
171
165
  }
172
166
  }
173
- return qb;
174
- }
175
- static parseStructuredValue(value) {
176
- if (value && typeof value === 'object' && !Array.isArray(value)) {
177
- return value;
178
- }
179
- if (typeof value !== 'string' || !value.trim().startsWith('{'))
180
- return undefined;
181
- try {
182
- const parsed = JSON.parse(value);
183
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
184
- ? parsed
185
- : undefined;
186
- }
187
- catch {
188
- return undefined;
167
+ if (filter.cursor && options.cursor) {
168
+ const cursorExpression = trustedQueryExpression(alias, options.cursor.field);
169
+ const cursorParam = parameter(options.cursor.field, 'cursor');
170
+ const direction = options.cursor.direction ?? 'ASC';
171
+ qb.andWhere(`${cursorExpression} ${direction === 'ASC' ? '>' : '<'} :${cursorParam}`, { [cursorParam]: filter.cursor });
172
+ qb.addOrderBy(cursorExpression, direction);
189
173
  }
174
+ return qb;
190
175
  }
191
176
  }
192
177
  exports.TypeOrmQueryBuilder = TypeOrmQueryBuilder;
@@ -24,6 +24,37 @@ function createQueryBuilder() {
24
24
  const queryBuilder = createQueryBuilder();
25
25
  typeorm_query_builder_1.TypeOrmQueryBuilder.apply(queryBuilder, { filter: JSON.stringify({ company_id: 42 }) }, { allowedFilters: ['company_id'] });
26
26
  strict_1.default.equal(queryBuilder.conditions.length, 1);
27
- strict_1.default.match(queryBuilder.conditions[0].condition, /^cabin\.company_id = :company_id_eq_/);
27
+ strict_1.default.match(queryBuilder.conditions[0].condition, /^cabin\.company_id = :devkit_company_id_eq_/);
28
28
  strict_1.default.equal(Object.values(queryBuilder.conditions[0].params)[0], 42);
29
29
  });
30
+ (0, node_test_1.default)('TypeOrmQueryBuilder caps all and ignores unknown public fields', () => {
31
+ const queryBuilder = createQueryBuilder();
32
+ let taken = 0;
33
+ const unknown = [];
34
+ queryBuilder.take = (value) => { taken = value; return queryBuilder; };
35
+ typeorm_query_builder_1.TypeOrmQueryBuilder.apply(queryBuilder, { limit: 'all', filter: { injected: { eq: 'x' } } }, { allowedFilters: ['name'], pagination: { maxLimit: 50 }, onUnknownFilter: (field) => unknown.push(field) });
36
+ strict_1.default.equal(taken, 50);
37
+ strict_1.default.equal(queryBuilder.conditions.length, 0);
38
+ strict_1.default.deepEqual(unknown, ['injected']);
39
+ });
40
+ (0, node_test_1.default)('TypeOrmQueryBuilder applies keyset cursor with a trusted configured field', () => {
41
+ const queryBuilder = createQueryBuilder();
42
+ typeorm_query_builder_1.TypeOrmQueryBuilder.apply(queryBuilder, { cursor: '100' }, { cursor: { field: 'id', direction: 'ASC' } });
43
+ strict_1.default.match(queryBuilder.conditions[0].condition, /^cabin\.id > :devkit_id_cursor_/);
44
+ strict_1.default.equal(Object.values(queryBuilder.conditions[0].params)[0], '100');
45
+ });
46
+ (0, node_test_1.default)('TypeOrmQueryBuilder uses a typed registry expression and parameters', () => {
47
+ const queryBuilder = createQueryBuilder();
48
+ typeorm_query_builder_1.TypeOrmQueryBuilder.apply(queryBuilder, { filter: { status: { eq: 'active' } } }, { filterRegistry: { status: { expression: (alias) => `${alias}.internal_status`, operators: ['eq'] } } });
49
+ strict_1.default.match(queryBuilder.conditions[0].condition, /^cabin\.internal_status = :devkit_status_eq_/);
50
+ strict_1.default.equal(Object.values(queryBuilder.conditions[0].params)[0], 'active');
51
+ });
52
+ (0, node_test_1.default)('TypeOrmQueryBuilder applies trusted MySQL full-text search without exposing SQL fields', () => {
53
+ const queryBuilder = createQueryBuilder();
54
+ typeorm_query_builder_1.TypeOrmQueryBuilder.apply(queryBuilder, { search: 'blue widget' }, { fullTextSearch: { fields: ['name', 'description'], mode: 'boolean' } });
55
+ strict_1.default.equal(queryBuilder.conditions[0].condition, 'MATCH(cabin.name, cabin.description) AGAINST (:devkit_fulltext IN BOOLEAN MODE)');
56
+ strict_1.default.deepEqual(queryBuilder.conditions[0].params, { devkit_fulltext: 'blue widget' });
57
+ strict_1.default.throws(() => typeorm_query_builder_1.TypeOrmQueryBuilder.apply(createQueryBuilder(), { search: 'x' }, {
58
+ fullTextSearch: { fields: ['name) AGAINST (secret'] },
59
+ }), /Unsafe configured query field/);
60
+ });
@@ -0,0 +1,9 @@
1
+ import type { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
2
+ import type { IFilterPagination, IPaginator } from '@devstroupe/devkit-core';
3
+ import type { ReadModelReader } from '../application';
4
+ import { IQueryBuilderOptions } from './typeorm-query.builder';
5
+ export declare abstract class TypeOrmReader<Source extends ObjectLiteral, Row, Filter extends IFilterPagination = IFilterPagination> implements ReadModelReader<Row, Filter> {
6
+ protected abstract query(): SelectQueryBuilder<Source>;
7
+ protected abstract options(): IQueryBuilderOptions<Source>;
8
+ read(filter: Filter): Promise<IPaginator<Row>>;
9
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TypeOrmReader = void 0;
4
+ const devkit_core_1 = require("@devstroupe/devkit-core");
5
+ const typeorm_query_builder_1 = require("./typeorm-query.builder");
6
+ class TypeOrmReader {
7
+ async read(filter) {
8
+ const options = this.options();
9
+ const qb = typeorm_query_builder_1.TypeOrmQueryBuilder.apply(this.query(), filter, options);
10
+ const items = await qb.getRawMany();
11
+ const includeTotal = filter.includeTotal !== false;
12
+ let total;
13
+ if (includeTotal) {
14
+ const countQb = typeorm_query_builder_1.TypeOrmQueryBuilder.apply(this.query(), filter, {
15
+ ...options,
16
+ applyPagination: false,
17
+ applySorting: false,
18
+ });
19
+ if (options.distinctCountField) {
20
+ const expression = (0, typeorm_query_builder_1.trustedQueryExpression)(countQb.alias, options.distinctCountField);
21
+ const result = await countQb
22
+ .select(`COUNT(DISTINCT ${expression})`, 'devkit_total')
23
+ .getRawOne();
24
+ total = Number(result?.devkit_total ?? 0);
25
+ }
26
+ else {
27
+ total = await countQb.getCount();
28
+ }
29
+ }
30
+ const limit = (0, devkit_core_1.resolvePaginationLimit)(filter.limit, {
31
+ defaultLimit: options.pagination?.defaultLimit ?? 20,
32
+ maxLimit: options.pagination?.maxLimit ?? 100,
33
+ });
34
+ return {
35
+ items,
36
+ meta: {
37
+ currentPage: Math.max(1, Number(filter.page) || 1),
38
+ itemsPerPage: limit,
39
+ itemCount: items.length,
40
+ totalItems: total,
41
+ totalPage: total === undefined ? undefined : Math.ceil(total / limit),
42
+ hasNextPage: total === undefined
43
+ ? items.length === limit
44
+ : Math.max(1, Number(filter.page) || 1) * limit < total,
45
+ },
46
+ };
47
+ }
48
+ }
49
+ exports.TypeOrmReader = TypeOrmReader;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_test_1 = __importDefault(require("node:test"));
8
+ const typeorm_reader_1 = require("./typeorm-reader");
9
+ function queryBuilder(rows, total) {
10
+ const state = { joins: [], selection: '' };
11
+ return {
12
+ alias: 'order',
13
+ state,
14
+ leftJoin(table) { state.joins.push(table); return this; },
15
+ select(selection) { state.selection = selection; return this; },
16
+ take() { return this; },
17
+ skip() { return this; },
18
+ andWhere() { return this; },
19
+ addOrderBy() { return this; },
20
+ getRawMany: async () => rows,
21
+ getRawOne: async () => ({ devkit_total: String(total) }),
22
+ getCount: async () => total,
23
+ };
24
+ }
25
+ (0, node_test_1.default)('TypeOrmReader supports joins, typed raw projections and a separate COUNT DISTINCT', async () => {
26
+ const builders = [];
27
+ class OrderSummaryReader extends typeorm_reader_1.TypeOrmReader {
28
+ query() {
29
+ const qb = queryBuilder([{ customerId: 'customer-1', amount: 42 }], 7)
30
+ .leftJoin('order.items')
31
+ .select('order.customer_id AS customerId, SUM(item.amount) AS amount');
32
+ builders.push(qb);
33
+ return qb;
34
+ }
35
+ options() {
36
+ return { distinctCountField: 'customer_id', pagination: { maxLimit: 50 } };
37
+ }
38
+ }
39
+ const result = await new OrderSummaryReader().read({ page: 1, limit: 10, includeTotal: true });
40
+ strict_1.default.deepEqual(result.items, [{ customerId: 'customer-1', amount: 42 }]);
41
+ strict_1.default.equal(result.meta.totalItems, 7);
42
+ strict_1.default.equal(result.meta.totalPage, 1);
43
+ strict_1.default.deepEqual(builders[0].state.joins, ['order.items']);
44
+ strict_1.default.equal(builders[1].state.selection, 'COUNT(DISTINCT order.customer_id)');
45
+ });
46
+ (0, node_test_1.default)('TypeOrmReader skips the count query when the caller opts out of totals', async () => {
47
+ let queries = 0;
48
+ class FastReader extends typeorm_reader_1.TypeOrmReader {
49
+ query() { queries += 1; return queryBuilder([{ id: '1' }], 99); }
50
+ options() { return {}; }
51
+ }
52
+ const result = await new FastReader().read({ includeTotal: false });
53
+ strict_1.default.equal(queries, 1);
54
+ strict_1.default.equal(result.meta.totalItems, undefined);
55
+ });
@@ -0,0 +1,3 @@
1
+ import type { DataSource } from 'typeorm';
2
+ /** Preserves TypeORM's parameterized SQL tag while allowing a typed row shape. */
3
+ export declare function typeOrmSql<Row>(dataSource: Pick<DataSource, 'sql'>): (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Row[]>;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.typeOrmSql = typeOrmSql;
4
+ /** Preserves TypeORM's parameterized SQL tag while allowing a typed row shape. */
5
+ function typeOrmSql(dataSource) {
6
+ return (strings, ...values) => dataSource.sql(strings, ...values);
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_test_1 = __importDefault(require("node:test"));
8
+ const typeorm_sql_1 = require("./typeorm-sql");
9
+ (0, node_test_1.default)('typeOrmSql forwards template values separately from SQL text', async () => {
10
+ let capturedValues = [];
11
+ const source = {
12
+ async sql(_strings, ...values) {
13
+ capturedValues = values;
14
+ return [{ id: 'invoice-1' }];
15
+ },
16
+ };
17
+ const invoiceId = "invoice-1' OR 1=1";
18
+ const rows = await (0, typeorm_sql_1.typeOrmSql)(source) `SELECT id FROM invoices WHERE id = ${invoiceId}`;
19
+ strict_1.default.deepEqual(capturedValues, [invoiceId]);
20
+ strict_1.default.deepEqual(rows, [{ id: 'invoice-1' }]);
21
+ });