@devstroupe/devkit-nest 1.1.16 → 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.
- package/README.md +35 -55
- package/dist/application/crud-use-cases.d.ts +35 -0
- package/dist/application/crud-use-cases.js +44 -0
- package/dist/application/crud-use-cases.test.d.ts +1 -0
- package/dist/application/crud-use-cases.test.js +19 -0
- package/dist/application/index.d.ts +2 -0
- package/dist/application/index.js +18 -0
- package/dist/application/read-model.d.ts +5 -0
- package/dist/application/read-model.js +2 -0
- package/dist/context/execution-context.d.ts +19 -0
- package/dist/context/execution-context.interceptor.d.ts +5 -0
- package/dist/context/execution-context.interceptor.js +35 -0
- package/dist/context/execution-context.interceptor.test.d.ts +1 -0
- package/dist/context/execution-context.interceptor.test.js +26 -0
- package/dist/context/execution-context.js +38 -0
- package/dist/context/execution-context.test.d.ts +1 -0
- package/dist/context/execution-context.test.js +18 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +18 -0
- package/dist/database/base.repository.d.ts +25 -13
- package/dist/database/base.repository.js +63 -29
- package/dist/database/base.repository.test.d.ts +1 -0
- package/dist/database/base.repository.test.js +46 -0
- package/dist/database/typeorm-explain.d.ts +3 -0
- package/dist/database/typeorm-explain.js +8 -0
- package/dist/database/typeorm-explain.test.d.ts +1 -0
- package/dist/database/typeorm-explain.test.js +15 -0
- package/dist/database/typeorm-query.builder.d.ts +36 -8
- package/dist/database/typeorm-query.builder.js +150 -165
- package/dist/database/typeorm-query.builder.test.js +32 -1
- package/dist/database/typeorm-reader.d.ts +9 -0
- package/dist/database/typeorm-reader.js +49 -0
- package/dist/database/typeorm-reader.test.d.ts +1 -0
- package/dist/database/typeorm-reader.test.js +55 -0
- package/dist/database/typeorm-sql.d.ts +3 -0
- package/dist/database/typeorm-sql.js +7 -0
- package/dist/database/typeorm-sql.test.d.ts +1 -0
- package/dist/database/typeorm-sql.test.js +21 -0
- package/dist/database/typeorm-unit-of-work.d.ts +13 -0
- package/dist/database/typeorm-unit-of-work.js +16 -0
- package/dist/database/typeorm-unit-of-work.test.d.ts +1 -0
- package/dist/database/typeorm-unit-of-work.test.js +34 -0
- package/dist/http/base-crud.controller.d.ts +26 -10
- package/dist/http/base-crud.controller.js +46 -36
- package/dist/http/base-crud.controller.test.d.ts +1 -0
- package/dist/http/base-crud.controller.test.js +18 -0
- package/dist/http/module-http-proxy.d.ts +16 -0
- package/dist/http/module-http-proxy.js +160 -0
- package/dist/http/module-http-proxy.test.d.ts +1 -0
- package/dist/http/module-http-proxy.test.js +37 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/platform/index.d.ts +3 -0
- package/dist/platform/index.js +19 -0
- package/dist/platform/integrations/company-registry.d.ts +16 -0
- package/dist/platform/integrations/company-registry.js +2 -0
- package/dist/platform/integrations/external-http.d.ts +38 -0
- package/dist/platform/integrations/external-http.js +78 -0
- package/dist/platform/integrations/external-http.test.d.ts +1 -0
- package/dist/platform/integrations/external-http.test.js +42 -0
- package/dist/platform/integrations/index.d.ts +3 -0
- package/dist/platform/integrations/index.js +19 -0
- package/dist/platform/integrations/webhook.d.ts +45 -0
- package/dist/platform/integrations/webhook.js +74 -0
- package/dist/platform/integrations/webhook.test.d.ts +1 -0
- package/dist/platform/integrations/webhook.test.js +66 -0
- package/dist/platform/messaging/index.d.ts +1 -0
- package/dist/platform/messaging/index.js +17 -0
- package/dist/platform/messaging/outbox.d.ts +56 -0
- package/dist/platform/messaging/outbox.js +90 -0
- package/dist/platform/messaging/outbox.test.d.ts +1 -0
- package/dist/platform/messaging/outbox.test.js +65 -0
- package/dist/platform/security/index.d.ts +2 -0
- package/dist/platform/security/index.js +18 -0
- package/dist/platform/security/password-hasher.d.ts +14 -0
- package/dist/platform/security/password-hasher.js +20 -0
- package/dist/platform/security/security.test.d.ts +1 -0
- package/dist/platform/security/security.test.js +24 -0
- package/dist/platform/security/token.d.ts +22 -0
- package/dist/platform/security/token.js +44 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +1 -0
- package/dist/realtime/realtime.gateway.d.ts +4 -1
- package/dist/realtime/realtime.gateway.js +10 -38
- package/dist/realtime/realtime.gateway.test.d.ts +1 -0
- package/dist/realtime/realtime.gateway.test.js +34 -0
- package/dist/realtime/realtime.module.d.ts +7 -0
- package/dist/realtime/realtime.module.js +17 -6
- package/dist/realtime/realtime.tokens.d.ts +1 -0
- package/dist/realtime/realtime.tokens.js +4 -0
- package/dist/storage/drivers/local.storage.js +8 -1
- 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 {
|
|
2
|
-
import { IFilterPagination } from '@devstroupe/devkit-core';
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 = :
|
|
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
|
+
});
|