@dudousxd/nestjs-filter 1.0.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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/adapter/adapter.d.ts +158 -0
- package/dist/adapter/adapter.d.ts.map +1 -0
- package/dist/adapter/adapter.js +2 -0
- package/dist/adapter/adapter.js.map +1 -0
- package/dist/als-store.d.ts +15 -0
- package/dist/als-store.d.ts.map +1 -0
- package/dist/als-store.js +6 -0
- package/dist/als-store.js.map +1 -0
- package/dist/base-filter.d.ts +46 -0
- package/dist/base-filter.d.ts.map +1 -0
- package/dist/base-filter.js +95 -0
- package/dist/base-filter.js.map +1 -0
- package/dist/cli/generate.d.ts +3 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +43 -0
- package/dist/cli/generate.js.map +1 -0
- package/dist/decorator/apply-filter.decorator.d.ts +11 -0
- package/dist/decorator/apply-filter.decorator.d.ts.map +1 -0
- package/dist/decorator/apply-filter.decorator.js +22 -0
- package/dist/decorator/apply-filter.decorator.js.map +1 -0
- package/dist/decorator/filter-for.decorator.d.ts +4 -0
- package/dist/decorator/filter-for.decorator.d.ts.map +1 -0
- package/dist/decorator/filter-for.decorator.js +29 -0
- package/dist/decorator/filter-for.decorator.js.map +1 -0
- package/dist/decorator/filterable.decorator.d.ts +5 -0
- package/dist/decorator/filterable.decorator.d.ts.map +1 -0
- package/dist/decorator/filterable.decorator.js +24 -0
- package/dist/decorator/filterable.decorator.js.map +1 -0
- package/dist/decorator/relations.decorator.d.ts +29 -0
- package/dist/decorator/relations.decorator.d.ts.map +1 -0
- package/dist/decorator/relations.decorator.js +40 -0
- package/dist/decorator/relations.decorator.js.map +1 -0
- package/dist/errors/exceptions.d.ts +32 -0
- package/dist/errors/exceptions.d.ts.map +1 -0
- package/dist/errors/exceptions.js +56 -0
- package/dist/errors/exceptions.js.map +1 -0
- package/dist/filter/filter-exception.filter.d.ts +6 -0
- package/dist/filter/filter-exception.filter.d.ts.map +1 -0
- package/dist/filter/filter-exception.filter.js +32 -0
- package/dist/filter/filter-exception.filter.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/input/dispatcher.d.ts +2 -0
- package/dist/input/dispatcher.d.ts.map +1 -0
- package/dist/input/dispatcher.js +25 -0
- package/dist/input/dispatcher.js.map +1 -0
- package/dist/input/normalizer.d.ts +9 -0
- package/dist/input/normalizer.d.ts.map +1 -0
- package/dist/input/normalizer.js +51 -0
- package/dist/input/normalizer.js.map +1 -0
- package/dist/input/source-resolver.d.ts +3 -0
- package/dist/input/source-resolver.d.ts.map +1 -0
- package/dist/input/source-resolver.js +49 -0
- package/dist/input/source-resolver.js.map +1 -0
- package/dist/input/validator.d.ts +3 -0
- package/dist/input/validator.d.ts.map +1 -0
- package/dist/input/validator.js +33 -0
- package/dist/input/validator.js.map +1 -0
- package/dist/interceptor/apply-filter.interceptor.d.ts +10 -0
- package/dist/interceptor/apply-filter.interceptor.d.ts.map +1 -0
- package/dist/interceptor/apply-filter.interceptor.js +81 -0
- package/dist/interceptor/apply-filter.interceptor.js.map +1 -0
- package/dist/module.d.ts +15 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +111 -0
- package/dist/module.js.map +1 -0
- package/dist/operators/column-filter.dto.d.ts +22 -0
- package/dist/operators/column-filter.dto.d.ts.map +1 -0
- package/dist/operators/column-filter.dto.js +61 -0
- package/dist/operators/column-filter.dto.js.map +1 -0
- package/dist/operators/index.d.ts +5 -0
- package/dist/operators/index.d.ts.map +1 -0
- package/dist/operators/index.js +4 -0
- package/dist/operators/index.js.map +1 -0
- package/dist/operators/types.d.ts +21 -0
- package/dist/operators/types.d.ts.map +1 -0
- package/dist/operators/types.js +28 -0
- package/dist/operators/types.js.map +1 -0
- package/dist/operators/validate-column-filter.d.ts +24 -0
- package/dist/operators/validate-column-filter.d.ts.map +1 -0
- package/dist/operators/validate-column-filter.js +118 -0
- package/dist/operators/validate-column-filter.js.map +1 -0
- package/dist/runner.d.ts +105 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +554 -0
- package/dist/runner.js.map +1 -0
- package/dist/testing/filter-testing.module.d.ts +7 -0
- package/dist/testing/filter-testing.module.d.ts.map +1 -0
- package/dist/testing/filter-testing.module.js +41 -0
- package/dist/testing/filter-testing.module.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +3 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-query-builder.d.ts +6 -0
- package/dist/testing/mock-query-builder.d.ts.map +1 -0
- package/dist/testing/mock-query-builder.js +22 -0
- package/dist/testing/mock-query-builder.js.map +1 -0
- package/dist/tokens.d.ts +8 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +8 -0
- package/dist/tokens.js.map +1 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/escape-like.d.ts +10 -0
- package/dist/utils/escape-like.d.ts.map +1 -0
- package/dist/utils/escape-like.js +15 -0
- package/dist/utils/escape-like.js.map +1 -0
- package/package.json +82 -0
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type Type } from '@nestjs/common';
|
|
2
|
+
import { ModuleRef } from '@nestjs/core';
|
|
3
|
+
import type { FilterAdapter } from './adapter/adapter.js';
|
|
4
|
+
import type { FilterContext, FilterModuleOptions } from './types.js';
|
|
5
|
+
export declare class FilterRunner {
|
|
6
|
+
private readonly moduleRef;
|
|
7
|
+
private readonly options;
|
|
8
|
+
private readonly logger;
|
|
9
|
+
private adapter;
|
|
10
|
+
constructor(moduleRef: ModuleRef, options: FilterModuleOptions, injectedAdapter: FilterAdapter | null);
|
|
11
|
+
/**
|
|
12
|
+
* Lazily resolves the adapter from the DI container if the injected
|
|
13
|
+
* value is null. This handles the case where the adapter module
|
|
14
|
+
* (e.g. MikroOrmFilterModule) is imported after FilterModule.forRoot()
|
|
15
|
+
* and the FilterRunner's local injection gets null.
|
|
16
|
+
*/
|
|
17
|
+
private resolveAdapter;
|
|
18
|
+
apply<F extends object, Q>(FilterClass: Type<F>, input: unknown, qb: Q, context?: FilterContext): Promise<Q>;
|
|
19
|
+
private resolveFilter;
|
|
20
|
+
private runSetup;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a method for a whitelisted key, bypassing static allowed/blocked checks.
|
|
23
|
+
* Only checks the @FilterFor map directly.
|
|
24
|
+
*/
|
|
25
|
+
private resolveWhitelistedMethod;
|
|
26
|
+
/**
|
|
27
|
+
* Applies relation-bound input keys by delegating to the related filter
|
|
28
|
+
* via the adapter's applyRelationConstraint.
|
|
29
|
+
*/
|
|
30
|
+
private applyRelation;
|
|
31
|
+
/**
|
|
32
|
+
* Extracts the structured input shape from raw input.
|
|
33
|
+
*
|
|
34
|
+
* Supports:
|
|
35
|
+
* - `{ filter: {...}, include: [...], search: '...' }` (new structured format)
|
|
36
|
+
* - Any other shape is treated as the filter portion directly (backward compat for internal calls)
|
|
37
|
+
*/
|
|
38
|
+
private extractStructuredInput;
|
|
39
|
+
/**
|
|
40
|
+
* Extracts `where` (ColumnFilter[]) from input and returns the remaining
|
|
41
|
+
* input keys for @FilterFor dispatch.
|
|
42
|
+
*
|
|
43
|
+
* Detects three input modes:
|
|
44
|
+
* 1. Plain Record<string, unknown> → no column filters, input passes through
|
|
45
|
+
* 2. Object with `where: ColumnFilter[]` → column filters extracted, remaining keys pass through
|
|
46
|
+
* 3. null/undefined/non-object → no column filters, empty remaining input
|
|
47
|
+
*/
|
|
48
|
+
private extractColumnFilters;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the set of auto-field names from @Filterable metadata.
|
|
51
|
+
*
|
|
52
|
+
* Returns:
|
|
53
|
+
* - null if autoFields is `false` (opt-out)
|
|
54
|
+
* - Set of all possible keys when autoFields is `true` (represented as a "match-all" set)
|
|
55
|
+
* - Set of explicit field names when autoFields is a string array
|
|
56
|
+
*
|
|
57
|
+
* When autoFields is `true` (the default), the set contains all possible
|
|
58
|
+
* keys from the `allowed` list if present; otherwise it introspects entity
|
|
59
|
+
* metadata via the adapter's `getEntityFields()` to restrict to real columns.
|
|
60
|
+
*
|
|
61
|
+
* When metadata introspection is unavailable (adapter doesn't implement
|
|
62
|
+
* `getEntityFields` or returns null), falls back to accept-all with a
|
|
63
|
+
* logged warning.
|
|
64
|
+
*/
|
|
65
|
+
private resolveAutoFields;
|
|
66
|
+
/**
|
|
67
|
+
* Parses raw include input into an array of string paths.
|
|
68
|
+
*
|
|
69
|
+
* Supports:
|
|
70
|
+
* - comma-separated string: `'role,posts'` → `['role', 'posts']`
|
|
71
|
+
* - string array: `['role', 'posts']` → `['role', 'posts']`
|
|
72
|
+
* - falsy values: `undefined`, `null`, `''` → `[]`
|
|
73
|
+
*/
|
|
74
|
+
parseIncludes(raw: unknown): string[];
|
|
75
|
+
/**
|
|
76
|
+
* Validates include paths against the allowlist (if defined) or entity relations.
|
|
77
|
+
* Silently skips invalid paths.
|
|
78
|
+
*/
|
|
79
|
+
private validateIncludes;
|
|
80
|
+
/**
|
|
81
|
+
* Applies global search across string columns or a tsvector column.
|
|
82
|
+
*/
|
|
83
|
+
private applyGlobalSearch;
|
|
84
|
+
/**
|
|
85
|
+
* Applies filters dynamically against an entity without requiring a filter class.
|
|
86
|
+
*
|
|
87
|
+
* Uses entity metadata (via adapter) for auto-fields, operators from structured
|
|
88
|
+
* input, includes, and search. No @FilterFor methods, no setup() hook, no
|
|
89
|
+
* whitelist/blacklist — intended for admin endpoints that query any table.
|
|
90
|
+
*
|
|
91
|
+
* @param entity - The entity class to query against.
|
|
92
|
+
* @param input - Raw input (structured or flat).
|
|
93
|
+
* @param qb - The query builder instance.
|
|
94
|
+
* @param context - Optional filter context.
|
|
95
|
+
* @returns The query builder with filters applied.
|
|
96
|
+
*/
|
|
97
|
+
applyDynamic<Q>(entity: Type<unknown>, input: unknown, qb: Q, context?: FilterContext): Promise<Q>;
|
|
98
|
+
/**
|
|
99
|
+
* Applies global search for dynamic mode: auto-detects all string columns
|
|
100
|
+
* from entity metadata (no filter class with static search config).
|
|
101
|
+
*/
|
|
102
|
+
private applyGlobalSearchDynamic;
|
|
103
|
+
private handleUnknownKey;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,IAAI,EAAE,MAAM,gBAAgB,CAAC;AACvE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAgB1D,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAWrE,qBACa,YAAY;IAMrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IACK,OAAO,CAAC,QAAQ,CAAC,OAAO;IANzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,OAAO,CAAC,OAAO,CAAuB;gBAGnB,SAAS,EAAE,SAAS,EACW,OAAO,EAAE,mBAAmB,EACpD,eAAe,EAAE,aAAa,GAAG,IAAI;IAK/D;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IAahB,KAAK,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EAC7B,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,EACpB,KAAK,EAAE,OAAO,EACd,EAAE,EAAE,CAAC,EACL,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC;YA8LC,aAAa;YAmBb,QAAQ;IAUtB;;;OAGG;IACH,OAAO,CAAC,wBAAwB;IAKhC;;;OAGG;YACW,aAAa;IAsB3B;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;IAqB9B;;;;;;;;OAQG;IACH,OAAO,CAAC,oBAAoB;IAwB5B;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,iBAAiB;IA2CzB;;;;;;;OAOG;IACH,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE;IAWrC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAyBxB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA0CzB;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,CAAC,EAClB,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,EACrB,KAAK,EAAE,OAAO,EACd,EAAE,EAAE,CAAC,EACL,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC;IA4Eb;;;OAGG;IACH,OAAO,CAAC,wBAAwB;IAsBhC,OAAO,CAAC,gBAAgB;CAOzB"}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
var FilterRunner_1;
|
|
14
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
15
|
+
import { ModuleRef } from '@nestjs/core';
|
|
16
|
+
import { runWithFilterState } from './als-store.js';
|
|
17
|
+
import { getFilterForMap } from './decorator/filter-for.decorator.js';
|
|
18
|
+
import { getFilterableMetadata } from './decorator/filterable.decorator.js';
|
|
19
|
+
import { resolveRelation } from './decorator/relations.decorator.js';
|
|
20
|
+
import { FilterMethodException, FilterNotRegisteredException, UnknownFilterKeyException, } from './errors/exceptions.js';
|
|
21
|
+
import { resolveDispatchTarget } from './input/dispatcher.js';
|
|
22
|
+
import { normalizeInput } from './input/normalizer.js';
|
|
23
|
+
import { validateInput } from './input/validator.js';
|
|
24
|
+
import { validateColumnFilters } from './operators/validate-column-filter.js';
|
|
25
|
+
import { FILTER_ADAPTER, FILTER_MODULE_OPTIONS } from './tokens.js';
|
|
26
|
+
const MATCH_ALL_SET = { has: () => true };
|
|
27
|
+
let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
28
|
+
moduleRef;
|
|
29
|
+
options;
|
|
30
|
+
logger = new Logger(FilterRunner_1.name);
|
|
31
|
+
adapter;
|
|
32
|
+
constructor(moduleRef, options, injectedAdapter) {
|
|
33
|
+
this.moduleRef = moduleRef;
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.adapter = injectedAdapter;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Lazily resolves the adapter from the DI container if the injected
|
|
39
|
+
* value is null. This handles the case where the adapter module
|
|
40
|
+
* (e.g. MikroOrmFilterModule) is imported after FilterModule.forRoot()
|
|
41
|
+
* and the FilterRunner's local injection gets null.
|
|
42
|
+
*/
|
|
43
|
+
resolveAdapter() {
|
|
44
|
+
if (this.adapter)
|
|
45
|
+
return this.adapter;
|
|
46
|
+
try {
|
|
47
|
+
const resolved = this.moduleRef.get(FILTER_ADAPTER, { strict: false });
|
|
48
|
+
if (resolved) {
|
|
49
|
+
this.adapter = resolved;
|
|
50
|
+
}
|
|
51
|
+
return this.adapter;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async apply(FilterClass, input, qb, context = {}) {
|
|
58
|
+
const filter = await this.resolveFilter(FilterClass);
|
|
59
|
+
const adapter = this.resolveAdapter();
|
|
60
|
+
// Extract structured input: { filter, include, search }
|
|
61
|
+
const rawInput = this.extractStructuredInput(input);
|
|
62
|
+
const filterInput = rawInput.filter;
|
|
63
|
+
const rawInclude = rawInput.include;
|
|
64
|
+
const rawSearch = rawInput.search;
|
|
65
|
+
// Extract column filters from the filter portion before normalization
|
|
66
|
+
const { columnFilters, remainingInput } = this.extractColumnFilters(filterInput);
|
|
67
|
+
const normalized = normalizeInput(remainingInput, {
|
|
68
|
+
normalizer: this.options.inputNormalizer ?? 'camelCase',
|
|
69
|
+
dropId: this.options.dropId ?? true,
|
|
70
|
+
...(this.options.stripEmpty !== undefined && { stripEmpty: this.options.stripEmpty }),
|
|
71
|
+
});
|
|
72
|
+
const finalInput = this.options.validation === 'off' ? normalized : await validateInput(FilterClass, normalized);
|
|
73
|
+
const $whitelisted = new Set();
|
|
74
|
+
const $blacklisted = new Set();
|
|
75
|
+
const $pushed = [];
|
|
76
|
+
return runWithFilterState({
|
|
77
|
+
$query: qb,
|
|
78
|
+
$input: Object.freeze({ ...finalInput }),
|
|
79
|
+
$context: context,
|
|
80
|
+
$adapter: adapter,
|
|
81
|
+
$whitelisted,
|
|
82
|
+
$blacklisted,
|
|
83
|
+
$pushed,
|
|
84
|
+
}, async () => {
|
|
85
|
+
await this.runSetup(filter);
|
|
86
|
+
// Apply column filters via adapter before @FilterFor dispatch
|
|
87
|
+
if (columnFilters.length > 0 && adapter?.applyColumnFilters) {
|
|
88
|
+
validateColumnFilters(columnFilters);
|
|
89
|
+
adapter.applyColumnFilters(qb, columnFilters);
|
|
90
|
+
}
|
|
91
|
+
else if (columnFilters.length > 0 && !adapter?.applyColumnFilters) {
|
|
92
|
+
this.logger.warn('Column filters (where) provided but adapter does not support applyColumnFilters. Skipping.');
|
|
93
|
+
}
|
|
94
|
+
// Resolve auto-fields configuration
|
|
95
|
+
const autoFieldSet = this.resolveAutoFields(FilterClass);
|
|
96
|
+
const filterableMeta = getFilterableMetadata(FilterClass);
|
|
97
|
+
// Collect relation-bound keys for batched processing
|
|
98
|
+
const relationBatches = new Map();
|
|
99
|
+
for (const [key, value] of Object.entries(finalInput)) {
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
if ($blacklisted.has(key))
|
|
103
|
+
continue;
|
|
104
|
+
const methodName = $whitelisted.has(key)
|
|
105
|
+
? this.resolveWhitelistedMethod(FilterClass, key)
|
|
106
|
+
: resolveDispatchTarget(FilterClass, key);
|
|
107
|
+
if (methodName) {
|
|
108
|
+
try {
|
|
109
|
+
const method = filter[methodName];
|
|
110
|
+
await method.call(filter, value, key);
|
|
111
|
+
}
|
|
112
|
+
catch (cause) {
|
|
113
|
+
throw new FilterMethodException(key, value, cause);
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Check if this key is mapped to a relation
|
|
118
|
+
const relation = resolveRelation(FilterClass, key);
|
|
119
|
+
if (relation) {
|
|
120
|
+
const [relationName, config] = relation;
|
|
121
|
+
if (!relationBatches.has(relationName)) {
|
|
122
|
+
relationBatches.set(relationName, { config, entries: [] });
|
|
123
|
+
}
|
|
124
|
+
relationBatches.get(relationName).entries.push([key, value]);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Check if this key is an auto-field
|
|
128
|
+
if (autoFieldSet?.has(key)) {
|
|
129
|
+
if (adapter?.applyAutoField) {
|
|
130
|
+
adapter.applyAutoField(qb, key, value);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.logger.warn(`Auto-field "${key}" provided but adapter does not support applyAutoField. Skipping.`);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Check if this is a dot-notation relation field (e.g. 'posts.title')
|
|
138
|
+
if (key.includes('.') &&
|
|
139
|
+
autoFieldSet &&
|
|
140
|
+
filterableMeta &&
|
|
141
|
+
adapter?.getEntityRelations &&
|
|
142
|
+
adapter?.applyAutoRelationField) {
|
|
143
|
+
const dotIndex = key.indexOf('.');
|
|
144
|
+
const relName = key.substring(0, dotIndex);
|
|
145
|
+
const fieldName = key.substring(dotIndex + 1);
|
|
146
|
+
if (fieldName.length > 0) {
|
|
147
|
+
const relations = adapter.getEntityRelations(filterableMeta.entity);
|
|
148
|
+
if (relations) {
|
|
149
|
+
const isRelation = relations.some((r) => r.name === relName);
|
|
150
|
+
if (isRelation) {
|
|
151
|
+
adapter.applyAutoRelationField(qb, relName, fieldName, value);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.handleUnknownKey(key);
|
|
158
|
+
}
|
|
159
|
+
// Apply relation constraints in batch per relation
|
|
160
|
+
for (const [relationName, { config, entries }] of relationBatches) {
|
|
161
|
+
await this.applyRelation(config.filter, qb, relationName, entries, context);
|
|
162
|
+
}
|
|
163
|
+
// Process pushed entries (BFS: pushed handlers may push more entries)
|
|
164
|
+
const MAX_PUSH_ITERATIONS = 100;
|
|
165
|
+
let pushIterations = 0;
|
|
166
|
+
while ($pushed.length > 0) {
|
|
167
|
+
if (++pushIterations > MAX_PUSH_ITERATIONS) {
|
|
168
|
+
throw new FilterMethodException('$push', undefined, new Error(`Push loop exceeded ${MAX_PUSH_ITERATIONS} iterations — possible infinite cycle.`));
|
|
169
|
+
}
|
|
170
|
+
const [key, value] = $pushed.shift();
|
|
171
|
+
if (value === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
if ($blacklisted.has(key))
|
|
174
|
+
continue;
|
|
175
|
+
const methodName = $whitelisted.has(key)
|
|
176
|
+
? this.resolveWhitelistedMethod(FilterClass, key)
|
|
177
|
+
: resolveDispatchTarget(FilterClass, key);
|
|
178
|
+
if (!methodName) {
|
|
179
|
+
this.handleUnknownKey(key);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const method = filter[methodName];
|
|
184
|
+
await method.call(filter, value, key);
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
throw new FilterMethodException(key, value, cause);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Apply includes (eager loading)
|
|
191
|
+
const includes = this.parseIncludes(rawInclude);
|
|
192
|
+
if (includes.length > 0 && adapter?.applyIncludes && filterableMeta) {
|
|
193
|
+
const allowedIncludes = FilterClass
|
|
194
|
+
.includes;
|
|
195
|
+
const validIncludes = this.validateIncludes(includes, allowedIncludes, adapter, filterableMeta.entity);
|
|
196
|
+
if (validIncludes.length > 0) {
|
|
197
|
+
adapter.applyIncludes(qb, validIncludes, filterableMeta.entity);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Apply global search
|
|
201
|
+
if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) {
|
|
202
|
+
this.applyGlobalSearch(qb, rawSearch.trim(), FilterClass, adapter, filterableMeta);
|
|
203
|
+
}
|
|
204
|
+
return qb;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async resolveFilter(FilterClass) {
|
|
208
|
+
try {
|
|
209
|
+
return await this.moduleRef.resolve(FilterClass, undefined, { strict: false });
|
|
210
|
+
}
|
|
211
|
+
catch (resolveErr) {
|
|
212
|
+
try {
|
|
213
|
+
return this.moduleRef.get(FilterClass, { strict: false });
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
if (resolveErr instanceof Error &&
|
|
217
|
+
!resolveErr.message.includes('could not find') &&
|
|
218
|
+
!resolveErr.message.includes('Could not find')) {
|
|
219
|
+
throw resolveErr;
|
|
220
|
+
}
|
|
221
|
+
throw new FilterNotRegisteredException(FilterClass.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async runSetup(filter) {
|
|
226
|
+
const maybe = filter;
|
|
227
|
+
if (typeof maybe.setup !== 'function')
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
await maybe.setup();
|
|
231
|
+
}
|
|
232
|
+
catch (cause) {
|
|
233
|
+
throw new FilterMethodException('setup', undefined, cause);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Resolves a method for a whitelisted key, bypassing static allowed/blocked checks.
|
|
238
|
+
* Only checks the @FilterFor map directly.
|
|
239
|
+
*/
|
|
240
|
+
resolveWhitelistedMethod(FilterClass, key) {
|
|
241
|
+
const map = getFilterForMap(FilterClass);
|
|
242
|
+
return map.get(key) ?? null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Applies relation-bound input keys by delegating to the related filter
|
|
246
|
+
* via the adapter's applyRelationConstraint.
|
|
247
|
+
*/
|
|
248
|
+
async applyRelation(RelatedFilterClass, qb, relationName, entries, context) {
|
|
249
|
+
if (!this.adapter?.applyRelationConstraint) {
|
|
250
|
+
this.logger.warn(`Relation "${relationName}" skipped: adapter does not support applyRelationConstraint.`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const inputObj = {};
|
|
254
|
+
for (const [key, value] of entries) {
|
|
255
|
+
inputObj[key] = value;
|
|
256
|
+
}
|
|
257
|
+
await this.adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
|
|
258
|
+
await this.apply(RelatedFilterClass, { filter: inputObj }, relationQb, context);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Extracts the structured input shape from raw input.
|
|
263
|
+
*
|
|
264
|
+
* Supports:
|
|
265
|
+
* - `{ filter: {...}, include: [...], search: '...' }` (new structured format)
|
|
266
|
+
* - Any other shape is treated as the filter portion directly (backward compat for internal calls)
|
|
267
|
+
*/
|
|
268
|
+
extractStructuredInput(input) {
|
|
269
|
+
if (input == null || typeof input !== 'object') {
|
|
270
|
+
return { filter: input, include: undefined, search: undefined };
|
|
271
|
+
}
|
|
272
|
+
const inputObj = input;
|
|
273
|
+
// Detect structured format: must have a 'filter' key (even if undefined/null)
|
|
274
|
+
if ('filter' in inputObj) {
|
|
275
|
+
return {
|
|
276
|
+
filter: inputObj.filter ?? undefined,
|
|
277
|
+
include: inputObj.include ?? undefined,
|
|
278
|
+
search: inputObj.search ?? undefined,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// Not structured — treat entire input as the filter portion
|
|
282
|
+
return { filter: input, include: undefined, search: undefined };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Extracts `where` (ColumnFilter[]) from input and returns the remaining
|
|
286
|
+
* input keys for @FilterFor dispatch.
|
|
287
|
+
*
|
|
288
|
+
* Detects three input modes:
|
|
289
|
+
* 1. Plain Record<string, unknown> → no column filters, input passes through
|
|
290
|
+
* 2. Object with `where: ColumnFilter[]` → column filters extracted, remaining keys pass through
|
|
291
|
+
* 3. null/undefined/non-object → no column filters, empty remaining input
|
|
292
|
+
*/
|
|
293
|
+
extractColumnFilters(input) {
|
|
294
|
+
if (input == null || typeof input !== 'object') {
|
|
295
|
+
return { columnFilters: [], remainingInput: input };
|
|
296
|
+
}
|
|
297
|
+
const inputObj = input;
|
|
298
|
+
if (!('where' in inputObj) || !Array.isArray(inputObj.where)) {
|
|
299
|
+
return { columnFilters: [], remainingInput: input };
|
|
300
|
+
}
|
|
301
|
+
const columnFilters = inputObj.where;
|
|
302
|
+
// Build remaining input without the 'where' key
|
|
303
|
+
const remaining = {};
|
|
304
|
+
for (const [key, value] of Object.entries(inputObj)) {
|
|
305
|
+
if (key !== 'where') {
|
|
306
|
+
remaining[key] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { columnFilters, remainingInput: remaining };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Resolves the set of auto-field names from @Filterable metadata.
|
|
313
|
+
*
|
|
314
|
+
* Returns:
|
|
315
|
+
* - null if autoFields is `false` (opt-out)
|
|
316
|
+
* - Set of all possible keys when autoFields is `true` (represented as a "match-all" set)
|
|
317
|
+
* - Set of explicit field names when autoFields is a string array
|
|
318
|
+
*
|
|
319
|
+
* When autoFields is `true` (the default), the set contains all possible
|
|
320
|
+
* keys from the `allowed` list if present; otherwise it introspects entity
|
|
321
|
+
* metadata via the adapter's `getEntityFields()` to restrict to real columns.
|
|
322
|
+
*
|
|
323
|
+
* When metadata introspection is unavailable (adapter doesn't implement
|
|
324
|
+
* `getEntityFields` or returns null), falls back to accept-all with a
|
|
325
|
+
* logged warning.
|
|
326
|
+
*/
|
|
327
|
+
resolveAutoFields(FilterClass) {
|
|
328
|
+
const meta = getFilterableMetadata(FilterClass);
|
|
329
|
+
if (!meta)
|
|
330
|
+
return null;
|
|
331
|
+
const autoFieldsConfig = meta.autoFields ?? true;
|
|
332
|
+
if (autoFieldsConfig === false)
|
|
333
|
+
return null;
|
|
334
|
+
if (autoFieldsConfig === true) {
|
|
335
|
+
// When autoFields is true with an allowed list, only allowed keys are auto-applicable
|
|
336
|
+
if (meta.allowed) {
|
|
337
|
+
// Remove keys that already have @FilterFor mappings
|
|
338
|
+
const filterForMap = getFilterForMap(FilterClass);
|
|
339
|
+
const set = new Set();
|
|
340
|
+
for (const key of meta.allowed) {
|
|
341
|
+
if (!filterForMap.has(key))
|
|
342
|
+
set.add(key);
|
|
343
|
+
}
|
|
344
|
+
return set;
|
|
345
|
+
}
|
|
346
|
+
// Introspect entity metadata to restrict auto-fields to real columns
|
|
347
|
+
const adapter = this.resolveAdapter();
|
|
348
|
+
if (adapter?.getEntityFields) {
|
|
349
|
+
const entityFields = adapter.getEntityFields(meta.entity);
|
|
350
|
+
if (entityFields) {
|
|
351
|
+
const filterForMap = getFilterForMap(FilterClass);
|
|
352
|
+
const set = new Set();
|
|
353
|
+
for (const field of entityFields) {
|
|
354
|
+
if (!filterForMap.has(field.name))
|
|
355
|
+
set.add(field.name);
|
|
356
|
+
}
|
|
357
|
+
return set;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Fallback: adapter doesn't support metadata introspection — accept all with warning
|
|
361
|
+
this.logger.warn(`autoFields: true on ${FilterClass.name} cannot validate fields against entity metadata. The adapter does not implement getEntityFields() or returned null. All input keys will be accepted (legacy behavior). Consider upgrading your adapter or using an explicit autoFields list.`);
|
|
362
|
+
return MATCH_ALL_SET;
|
|
363
|
+
}
|
|
364
|
+
// Explicit list of auto-field names
|
|
365
|
+
return new Set(autoFieldsConfig);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Parses raw include input into an array of string paths.
|
|
369
|
+
*
|
|
370
|
+
* Supports:
|
|
371
|
+
* - comma-separated string: `'role,posts'` → `['role', 'posts']`
|
|
372
|
+
* - string array: `['role', 'posts']` → `['role', 'posts']`
|
|
373
|
+
* - falsy values: `undefined`, `null`, `''` → `[]`
|
|
374
|
+
*/
|
|
375
|
+
parseIncludes(raw) {
|
|
376
|
+
if (!raw)
|
|
377
|
+
return [];
|
|
378
|
+
if (typeof raw === 'string')
|
|
379
|
+
return raw
|
|
380
|
+
.split(',')
|
|
381
|
+
.map((s) => s.trim())
|
|
382
|
+
.filter(Boolean);
|
|
383
|
+
if (Array.isArray(raw))
|
|
384
|
+
return raw.filter((s) => typeof s === 'string');
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Validates include paths against the allowlist (if defined) or entity relations.
|
|
389
|
+
* Silently skips invalid paths.
|
|
390
|
+
*/
|
|
391
|
+
validateIncludes(includes, allowlist, adapter, entity) {
|
|
392
|
+
const maxDepth = this.options.maxIncludeDepth ?? 3;
|
|
393
|
+
return includes.filter((path) => {
|
|
394
|
+
const segments = path.split('.');
|
|
395
|
+
if (segments.length > maxDepth)
|
|
396
|
+
return false;
|
|
397
|
+
if (segments.some((s) => !s))
|
|
398
|
+
return false;
|
|
399
|
+
if (allowlist) {
|
|
400
|
+
return allowlist.includes(path);
|
|
401
|
+
}
|
|
402
|
+
// Validate first segment against entity relations
|
|
403
|
+
if (adapter.getEntityRelations) {
|
|
404
|
+
const relations = adapter.getEntityRelations(entity);
|
|
405
|
+
if (relations) {
|
|
406
|
+
return relations.some((r) => r.name === segments[0]);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Applies global search across string columns or a tsvector column.
|
|
414
|
+
*/
|
|
415
|
+
applyGlobalSearch(qb, searchTerm, FilterClass, adapter, filterableMeta) {
|
|
416
|
+
if (!adapter || !filterableMeta)
|
|
417
|
+
return;
|
|
418
|
+
const searchConfig = FilterClass.search;
|
|
419
|
+
if (searchConfig && typeof searchConfig === 'object' && 'vector' in searchConfig) {
|
|
420
|
+
// tsvector search
|
|
421
|
+
if (adapter.applyVectorSearch) {
|
|
422
|
+
adapter.applyVectorSearch(qb, searchTerm, searchConfig.vector);
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// ILIKE search
|
|
427
|
+
let columns;
|
|
428
|
+
if (Array.isArray(searchConfig)) {
|
|
429
|
+
columns = searchConfig;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Auto-detect: get all string columns from entity metadata
|
|
433
|
+
const fields = adapter.getEntityFields?.(filterableMeta.entity);
|
|
434
|
+
columns = fields?.filter((f) => f.type === 'string').map((f) => f.name) ?? [];
|
|
435
|
+
}
|
|
436
|
+
if (columns.length > 10) {
|
|
437
|
+
this.logger.warn(`Global search on ${columns.length} columns may be slow. Consider declaring static search = [...] on ${FilterClass.name}`);
|
|
438
|
+
}
|
|
439
|
+
if (columns.length > 0 && adapter.applySearch) {
|
|
440
|
+
adapter.applySearch(qb, searchTerm, columns, filterableMeta.entity);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Applies filters dynamically against an entity without requiring a filter class.
|
|
445
|
+
*
|
|
446
|
+
* Uses entity metadata (via adapter) for auto-fields, operators from structured
|
|
447
|
+
* input, includes, and search. No @FilterFor methods, no setup() hook, no
|
|
448
|
+
* whitelist/blacklist — intended for admin endpoints that query any table.
|
|
449
|
+
*
|
|
450
|
+
* @param entity - The entity class to query against.
|
|
451
|
+
* @param input - Raw input (structured or flat).
|
|
452
|
+
* @param qb - The query builder instance.
|
|
453
|
+
* @param context - Optional filter context.
|
|
454
|
+
* @returns The query builder with filters applied.
|
|
455
|
+
*/
|
|
456
|
+
async applyDynamic(entity, input, qb, context = {}) {
|
|
457
|
+
const adapter = this.resolveAdapter();
|
|
458
|
+
// Extract structured input: { filter, include, search }
|
|
459
|
+
const rawInput = this.extractStructuredInput(input);
|
|
460
|
+
const filterInput = rawInput.filter;
|
|
461
|
+
const rawInclude = rawInput.include;
|
|
462
|
+
const rawSearch = rawInput.search;
|
|
463
|
+
// Extract column filters before normalization
|
|
464
|
+
const { columnFilters, remainingInput } = this.extractColumnFilters(filterInput);
|
|
465
|
+
const normalized = normalizeInput(remainingInput, {
|
|
466
|
+
normalizer: this.options.inputNormalizer ?? 'camelCase',
|
|
467
|
+
dropId: this.options.dropId ?? true,
|
|
468
|
+
...(this.options.stripEmpty !== undefined && { stripEmpty: this.options.stripEmpty }),
|
|
469
|
+
});
|
|
470
|
+
// Apply column filters via adapter
|
|
471
|
+
if (columnFilters.length > 0 && adapter?.applyColumnFilters) {
|
|
472
|
+
validateColumnFilters(columnFilters);
|
|
473
|
+
adapter.applyColumnFilters(qb, columnFilters);
|
|
474
|
+
}
|
|
475
|
+
else if (columnFilters.length > 0 && !adapter?.applyColumnFilters) {
|
|
476
|
+
this.logger.warn('Column filters (where) provided but adapter does not support applyColumnFilters. Skipping.');
|
|
477
|
+
}
|
|
478
|
+
// Auto-fields: all entity columns (no filter class = no @FilterFor to check)
|
|
479
|
+
if (adapter?.getEntityFields && adapter?.applyAutoField) {
|
|
480
|
+
const fields = adapter.getEntityFields(entity);
|
|
481
|
+
if (fields) {
|
|
482
|
+
const fieldNames = new Set(fields.map((f) => f.name));
|
|
483
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
484
|
+
if (value === undefined)
|
|
485
|
+
continue;
|
|
486
|
+
if (key.includes('.')) {
|
|
487
|
+
// Dot-notation relation
|
|
488
|
+
const dotIndex = key.indexOf('.');
|
|
489
|
+
const relName = key.substring(0, dotIndex);
|
|
490
|
+
const fieldName = key.substring(dotIndex + 1);
|
|
491
|
+
if (fieldName.length > 0 &&
|
|
492
|
+
adapter.getEntityRelations &&
|
|
493
|
+
adapter.applyAutoRelationField) {
|
|
494
|
+
const rels = adapter.getEntityRelations(entity);
|
|
495
|
+
if (rels?.some((r) => r.name === relName)) {
|
|
496
|
+
adapter.applyAutoRelationField(qb, relName, fieldName, value);
|
|
497
|
+
}
|
|
498
|
+
// Unknown relation: silently skipped
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else if (fieldNames.has(key)) {
|
|
502
|
+
adapter.applyAutoField(qb, key, value);
|
|
503
|
+
}
|
|
504
|
+
// Unknown keys silently skipped
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Includes — no allowlist (no filter class), validate against entity metadata only
|
|
509
|
+
const includes = this.parseIncludes(rawInclude);
|
|
510
|
+
if (includes.length > 0 && adapter?.applyIncludes) {
|
|
511
|
+
const validIncludes = this.validateIncludes(includes, undefined, adapter, entity);
|
|
512
|
+
if (validIncludes.length > 0) {
|
|
513
|
+
adapter.applyIncludes(qb, validIncludes, entity);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Search — auto-detect all string columns from entity metadata
|
|
517
|
+
if (rawSearch && typeof rawSearch === 'string' && rawSearch.trim()) {
|
|
518
|
+
this.applyGlobalSearchDynamic(qb, rawSearch.trim(), entity, adapter);
|
|
519
|
+
}
|
|
520
|
+
return qb;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Applies global search for dynamic mode: auto-detects all string columns
|
|
524
|
+
* from entity metadata (no filter class with static search config).
|
|
525
|
+
*/
|
|
526
|
+
applyGlobalSearchDynamic(qb, searchTerm, entity, adapter) {
|
|
527
|
+
if (!adapter)
|
|
528
|
+
return;
|
|
529
|
+
const fields = adapter.getEntityFields?.(entity);
|
|
530
|
+
const columns = fields?.filter((f) => f.type === 'string').map((f) => f.name) ?? [];
|
|
531
|
+
if (columns.length > 10) {
|
|
532
|
+
this.logger.warn(`Global search on ${columns.length} columns may be slow. Consider using a filter class with static search = [...]`);
|
|
533
|
+
}
|
|
534
|
+
if (columns.length > 0 && adapter.applySearch) {
|
|
535
|
+
adapter.applySearch(qb, searchTerm, columns, entity);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
handleUnknownKey(key) {
|
|
539
|
+
const policy = this.options.onUnknownKey ?? 'ignore';
|
|
540
|
+
if (policy === 'throw')
|
|
541
|
+
throw new UnknownFilterKeyException(key);
|
|
542
|
+
if (policy === 'warn') {
|
|
543
|
+
this.logger.warn(`Unknown filter key: "${key}"`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
FilterRunner = FilterRunner_1 = __decorate([
|
|
548
|
+
Injectable(),
|
|
549
|
+
__param(1, Inject(FILTER_MODULE_OPTIONS)),
|
|
550
|
+
__param(2, Inject(FILTER_ADAPTER)),
|
|
551
|
+
__metadata("design:paramtypes", [ModuleRef, Object, Object])
|
|
552
|
+
], FilterRunner);
|
|
553
|
+
export { FilterRunner };
|
|
554
|
+
//# sourceMappingURL=runner.js.map
|