@adonis-agora/filter 0.0.0-beta-20261110131309
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 +417 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/commands/main.d.ts +19 -0
- package/dist/commands/main.d.ts.map +1 -0
- package/dist/commands/main.js +19 -0
- package/dist/commands/main.js.map +1 -0
- package/dist/commands/make_filter.d.ts +18 -0
- package/dist/commands/make_filter.d.ts.map +1 -0
- package/dist/commands/make_filter.js +32 -0
- package/dist/commands/make_filter.js.map +1 -0
- package/dist/commands/make_filter_client.d.ts +35 -0
- package/dist/commands/make_filter_client.d.ts.map +1 -0
- package/dist/commands/make_filter_client.js +85 -0
- package/dist/commands/make_filter_client.js.map +1 -0
- package/dist/configure.d.ts +15 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +20 -0
- package/dist/configure.js.map +1 -0
- package/dist/providers/filter_provider.d.ts +28 -0
- package/dist/providers/filter_provider.d.ts.map +1 -0
- package/dist/providers/filter_provider.js +33 -0
- package/dist/providers/filter_provider.js.map +1 -0
- package/dist/src/aggregate.d.ts +70 -0
- package/dist/src/aggregate.d.ts.map +1 -0
- package/dist/src/aggregate.js +195 -0
- package/dist/src/aggregate.js.map +1 -0
- package/dist/src/aggregate_path.d.ts +25 -0
- package/dist/src/aggregate_path.d.ts.map +1 -0
- package/dist/src/aggregate_path.js +35 -0
- package/dist/src/aggregate_path.js.map +1 -0
- package/dist/src/apply_from_request.d.ts +85 -0
- package/dist/src/apply_from_request.d.ts.map +1 -0
- package/dist/src/apply_from_request.js +164 -0
- package/dist/src/apply_from_request.js.map +1 -0
- package/dist/src/base_filter.d.ts +49 -0
- package/dist/src/base_filter.d.ts.map +1 -0
- package/dist/src/base_filter.js +33 -0
- package/dist/src/base_filter.js.map +1 -0
- package/dist/src/base_model_filter.d.ts +90 -0
- package/dist/src/base_model_filter.d.ts.map +1 -0
- package/dist/src/base_model_filter.js +85 -0
- package/dist/src/base_model_filter.js.map +1 -0
- package/dist/src/coerce_value.d.ts +26 -0
- package/dist/src/coerce_value.d.ts.map +1 -0
- package/dist/src/coerce_value.js +82 -0
- package/dist/src/coerce_value.js.map +1 -0
- package/dist/src/cursor.d.ts +92 -0
- package/dist/src/cursor.d.ts.map +1 -0
- package/dist/src/cursor.js +107 -0
- package/dist/src/cursor.js.map +1 -0
- package/dist/src/custom_filter.d.ts +36 -0
- package/dist/src/custom_filter.d.ts.map +1 -0
- package/dist/src/custom_filter.js +127 -0
- package/dist/src/custom_filter.js.map +1 -0
- package/dist/src/decorator_metadata.d.ts +41 -0
- package/dist/src/decorator_metadata.d.ts.map +1 -0
- package/dist/src/decorator_metadata.js +83 -0
- package/dist/src/decorator_metadata.js.map +1 -0
- package/dist/src/decorators.d.ts +73 -0
- package/dist/src/decorators.d.ts.map +1 -0
- package/dist/src/decorators.js +118 -0
- package/dist/src/decorators.js.map +1 -0
- package/dist/src/escape-like.d.ts +10 -0
- package/dist/src/escape-like.d.ts.map +1 -0
- package/dist/src/escape-like.js +15 -0
- package/dist/src/escape-like.js.map +1 -0
- package/dist/src/field_aliases.d.ts +30 -0
- package/dist/src/field_aliases.d.ts.map +1 -0
- package/dist/src/field_aliases.js +65 -0
- package/dist/src/field_aliases.js.map +1 -0
- package/dist/src/filter_class.d.ts +46 -0
- package/dist/src/filter_class.d.ts.map +1 -0
- package/dist/src/filter_class.js +244 -0
- package/dist/src/filter_class.js.map +1 -0
- package/dist/src/filter_spec.d.ts +231 -0
- package/dist/src/filter_spec.d.ts.map +1 -0
- package/dist/src/filter_spec.js +182 -0
- package/dist/src/filter_spec.js.map +1 -0
- package/dist/src/filterable_mixin.d.ts +49 -0
- package/dist/src/filterable_mixin.d.ts.map +1 -0
- package/dist/src/filterable_mixin.js +47 -0
- package/dist/src/filterable_mixin.js.map +1 -0
- package/dist/src/generate_client.d.ts +107 -0
- package/dist/src/generate_client.d.ts.map +1 -0
- package/dist/src/generate_client.js +164 -0
- package/dist/src/generate_client.js.map +1 -0
- package/dist/src/group_by_count.d.ts +67 -0
- package/dist/src/group_by_count.d.ts.map +1 -0
- package/dist/src/group_by_count.js +71 -0
- package/dist/src/group_by_count.js.map +1 -0
- package/dist/src/in_memory.d.ts +279 -0
- package/dist/src/in_memory.d.ts.map +1 -0
- package/dist/src/in_memory.js +810 -0
- package/dist/src/in_memory.js.map +1 -0
- package/dist/src/index.d.ts +51 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +33 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lucid_adapter.d.ts +311 -0
- package/dist/src/lucid_adapter.d.ts.map +1 -0
- package/dist/src/lucid_adapter.js +469 -0
- package/dist/src/lucid_adapter.js.map +1 -0
- package/dist/src/lucid_macros.d.ts +87 -0
- package/dist/src/lucid_macros.d.ts.map +1 -0
- package/dist/src/lucid_macros.js +67 -0
- package/dist/src/lucid_macros.js.map +1 -0
- package/dist/src/normalizer.d.ts +21 -0
- package/dist/src/normalizer.d.ts.map +1 -0
- package/dist/src/normalizer.js +61 -0
- package/dist/src/normalizer.js.map +1 -0
- package/dist/src/operators.d.ts +37 -0
- package/dist/src/operators.d.ts.map +1 -0
- package/dist/src/operators.js +43 -0
- package/dist/src/operators.js.map +1 -0
- package/dist/src/paginate.d.ts +12 -0
- package/dist/src/paginate.d.ts.map +1 -0
- package/dist/src/paginate.js +88 -0
- package/dist/src/paginate.js.map +1 -0
- package/dist/src/pagination_stash.d.ts +4 -0
- package/dist/src/pagination_stash.d.ts.map +1 -0
- package/dist/src/pagination_stash.js +19 -0
- package/dist/src/pagination_stash.js.map +1 -0
- package/dist/src/parse_request.d.ts +50 -0
- package/dist/src/parse_request.d.ts.map +1 -0
- package/dist/src/parse_request.js +220 -0
- package/dist/src/parse_request.js.map +1 -0
- package/dist/src/runner.d.ts +65 -0
- package/dist/src/runner.d.ts.map +1 -0
- package/dist/src/runner.js +371 -0
- package/dist/src/runner.js.map +1 -0
- package/dist/src/source_resolver.d.ts +18 -0
- package/dist/src/source_resolver.d.ts.map +1 -0
- package/dist/src/source_resolver.js +64 -0
- package/dist/src/source_resolver.js.map +1 -0
- package/dist/src/spatie_parser.d.ts +39 -0
- package/dist/src/spatie_parser.d.ts.map +1 -0
- package/dist/src/spatie_parser.js +134 -0
- package/dist/src/spatie_parser.js.map +1 -0
- package/dist/src/testing.d.ts +73 -0
- package/dist/src/testing.d.ts.map +1 -0
- package/dist/src/testing.js +136 -0
- package/dist/src/testing.js.map +1 -0
- package/dist/src/types.d.ts +226 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/validate-column-filter.d.ts +30 -0
- package/dist/src/validate-column-filter.d.ts.map +1 -0
- package/dist/src/validate-column-filter.js +148 -0
- package/dist/src/validate-column-filter.js.map +1 -0
- package/dist/src/value-shape.d.ts +18 -0
- package/dist/src/value-shape.d.ts.map +1 -0
- package/dist/src/value-shape.js +36 -0
- package/dist/src/value-shape.js.map +1 -0
- package/dist/stubs/main.d.ts +3 -0
- package/dist/stubs/main.d.ts.map +1 -0
- package/dist/stubs/main.js +4 -0
- package/dist/stubs/main.js.map +1 -0
- package/dist/stubs/make/filter/main.stub +45 -0
- package/package.json +102 -0
- package/skills/filter-basics/SKILL.md +285 -0
- package/skills/filter-codegen/SKILL.md +246 -0
- package/skills/filter-definitions/SKILL.md +340 -0
- package/skills/filter-querying/SKILL.md +319 -0
- package/skills/filter-safety/SKILL.md +304 -0
- package/stubs/main.ts +4 -0
- package/stubs/make/filter/main.stub +45 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { FILTER_OPERATORS, OPERATOR_ALIASES, } from './operators.js';
|
|
2
|
+
const operatorSet = new Set(FILTER_OPERATORS);
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes an operator string to its canonical {@link FilterOperator},
|
|
5
|
+
* resolving SQL-symbol aliases (`=` -> `equals`, `!=` -> `notEquals`, ...).
|
|
6
|
+
* Canonical operators (and unknown strings) pass through unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeOperator(operator) {
|
|
9
|
+
return (OPERATOR_ALIASES[operator] ?? operator);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Operators that require no value (unary operators).
|
|
13
|
+
*/
|
|
14
|
+
const UNARY_OPERATORS = new Set([
|
|
15
|
+
'isEmpty',
|
|
16
|
+
'isNotEmpty',
|
|
17
|
+
'isNull',
|
|
18
|
+
'isNotNull',
|
|
19
|
+
'exists',
|
|
20
|
+
'notExists',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Operators that require an array value.
|
|
24
|
+
*/
|
|
25
|
+
const ARRAY_OPERATORS = new Set([
|
|
26
|
+
'in',
|
|
27
|
+
'notIn',
|
|
28
|
+
'isAnyOf',
|
|
29
|
+
'between',
|
|
30
|
+
'notBetween',
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Maximum allowed nesting depth for AND/OR filter composition.
|
|
34
|
+
* Prevents stack overflow from maliciously deep payloads (DoS).
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_FILTER_DEPTH = 10;
|
|
37
|
+
export class InvalidColumnFilterError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'InvalidColumnFilterError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validates a ColumnFilter at runtime:
|
|
45
|
+
* - field must be a non-empty string with no SQL-unsafe characters
|
|
46
|
+
* - operator must be a known FilterOperator
|
|
47
|
+
* - value is required for non-unary operators
|
|
48
|
+
* - 'between' requires a 2-element array
|
|
49
|
+
* - 'in'/'isAnyOf' require arrays
|
|
50
|
+
* - AND/OR arrays are validated recursively
|
|
51
|
+
*/
|
|
52
|
+
export function validateColumnFilter(filter, depth = 0) {
|
|
53
|
+
if (depth > MAX_FILTER_DEPTH) {
|
|
54
|
+
throw new InvalidColumnFilterError(`Filter nesting exceeds maximum depth (${MAX_FILTER_DEPTH}).`);
|
|
55
|
+
}
|
|
56
|
+
if (!filter || typeof filter !== 'object') {
|
|
57
|
+
throw new InvalidColumnFilterError('Column filter must be a non-null object.');
|
|
58
|
+
}
|
|
59
|
+
// Pure group node: `{ AND: [...] }` / `{ OR: [...] }` with no column of its
|
|
60
|
+
// own composes child filters and has no field/operator/value to validate.
|
|
61
|
+
// The client builder emits these (sometimes with an empty `field`), so treat
|
|
62
|
+
// an empty field as absent. Validate only the nested arrays, then stop.
|
|
63
|
+
const isGroupNode = (filter.AND !== undefined || filter.OR !== undefined) &&
|
|
64
|
+
(filter.field === undefined || filter.field === '');
|
|
65
|
+
if (isGroupNode) {
|
|
66
|
+
for (const key of ['AND', 'OR']) {
|
|
67
|
+
const group = filter[key];
|
|
68
|
+
if (group !== undefined) {
|
|
69
|
+
if (!Array.isArray(group)) {
|
|
70
|
+
throw new InvalidColumnFilterError(`"${key}" must be an array of ColumnFilter.`);
|
|
71
|
+
}
|
|
72
|
+
for (const sub of group) {
|
|
73
|
+
validateColumnFilter(sub, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Validate field
|
|
80
|
+
if (typeof filter.field !== 'string' || filter.field.length === 0) {
|
|
81
|
+
throw new InvalidColumnFilterError('Column filter "field" must be a non-empty string.');
|
|
82
|
+
}
|
|
83
|
+
// Reject field names that contain SQL-unsafe characters
|
|
84
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(filter.field)) {
|
|
85
|
+
throw new InvalidColumnFilterError(`Column filter field "${filter.field}" contains invalid characters. Only letters, digits, underscores, and dots are allowed.`);
|
|
86
|
+
}
|
|
87
|
+
// Validate operator, accepting SQL-symbol aliases (`=`, `!=`, `<`, ...).
|
|
88
|
+
const op = normalizeOperator(filter.operator);
|
|
89
|
+
if (!operatorSet.has(op)) {
|
|
90
|
+
throw new InvalidColumnFilterError(`Unknown filter operator "${filter.operator}". ` +
|
|
91
|
+
`Valid operators: ${FILTER_OPERATORS.join(', ')}.`);
|
|
92
|
+
}
|
|
93
|
+
// Persist the canonical form so downstream query builders never see aliases.
|
|
94
|
+
filter.operator = op;
|
|
95
|
+
// Reject null for non-unary operators (point to isNull instead)
|
|
96
|
+
if (!UNARY_OPERATORS.has(op) && filter.value === null) {
|
|
97
|
+
throw new InvalidColumnFilterError(`Operator "${op}" received null. Use "isNull" for NULL checks.`);
|
|
98
|
+
}
|
|
99
|
+
// Validate value
|
|
100
|
+
if (UNARY_OPERATORS.has(op)) {
|
|
101
|
+
// Unary operators don't need a value — ignore any provided value
|
|
102
|
+
}
|
|
103
|
+
else if (op === 'between' || op === 'notBetween') {
|
|
104
|
+
if (!Array.isArray(filter.value) || filter.value.length !== 2) {
|
|
105
|
+
throw new InvalidColumnFilterError(`Operator "${op}" requires a value that is a 2-element array, got: ${JSON.stringify(filter.value)}.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (ARRAY_OPERATORS.has(op)) {
|
|
109
|
+
if (!Array.isArray(filter.value)) {
|
|
110
|
+
throw new InvalidColumnFilterError(`Operator "${op}" requires an array value, got: ${typeof filter.value}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (filter.value === undefined) {
|
|
115
|
+
throw new InvalidColumnFilterError(`Operator "${op}" requires a value.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Validate nested AND
|
|
119
|
+
if (filter.AND !== undefined) {
|
|
120
|
+
if (!Array.isArray(filter.AND)) {
|
|
121
|
+
throw new InvalidColumnFilterError('"AND" must be an array of ColumnFilter.');
|
|
122
|
+
}
|
|
123
|
+
for (const sub of filter.AND) {
|
|
124
|
+
validateColumnFilter(sub, depth + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Validate nested OR
|
|
128
|
+
if (filter.OR !== undefined) {
|
|
129
|
+
if (!Array.isArray(filter.OR)) {
|
|
130
|
+
throw new InvalidColumnFilterError('"OR" must be an array of ColumnFilter.');
|
|
131
|
+
}
|
|
132
|
+
for (const sub of filter.OR) {
|
|
133
|
+
validateColumnFilter(sub, depth + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validates an array of ColumnFilter objects.
|
|
139
|
+
*/
|
|
140
|
+
export function validateColumnFilters(filters) {
|
|
141
|
+
if (!Array.isArray(filters)) {
|
|
142
|
+
throw new InvalidColumnFilterError('Column filters must be an array.');
|
|
143
|
+
}
|
|
144
|
+
for (const filter of filters) {
|
|
145
|
+
validateColumnFilter(filter);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=validate-column-filter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-column-filter.js","sourceRoot":"","sources":["../../src/validate-column-filter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ColumnFilter } from './operators.js';
|
|
2
|
+
/**
|
|
3
|
+
* True when `value` is a non-empty plain object whose keys are ALL filter operators — i.e. an
|
|
4
|
+
* operator map like `{ gt: 5, lt: 10 }` rather than a scalar equality value. Arrays and `null` are
|
|
5
|
+
* not operator objects. Single source of the shape classifier every adapter's auto-field path uses.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isOperatorObject(value: unknown): value is Record<string, unknown>;
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a raw auto-field value to canonical {@link ColumnFilter}[]:
|
|
10
|
+
* - an array → a single `in` filter,
|
|
11
|
+
* - an {@link isOperatorObject operator object} → one filter per operator,
|
|
12
|
+
* - any other (scalar) value → a single `equals` filter.
|
|
13
|
+
*
|
|
14
|
+
* Lets an adapter's auto-field/relation/computed surfaces drive the same `ColumnFilter` path the
|
|
15
|
+
* structured-filter input already uses, instead of re-implementing the scalar/array/object ladder.
|
|
16
|
+
*/
|
|
17
|
+
export declare function valueToColumnFilters(field: string, value: unknown): ColumnFilter[];
|
|
18
|
+
//# sourceMappingURL=value-shape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"value-shape.d.ts","sourceRoot":"","sources":["../../src/value-shape.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAyC,MAAM,gBAAgB,CAAC;AAK1F;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIjF;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,YAAY,EAAE,CAUlF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { FILTER_OPERATORS } from './operators.js';
|
|
2
|
+
/** Every canonical operator name, for the operator-object shape check. */
|
|
3
|
+
const OPERATOR_SET = new Set(FILTER_OPERATORS);
|
|
4
|
+
/**
|
|
5
|
+
* True when `value` is a non-empty plain object whose keys are ALL filter operators — i.e. an
|
|
6
|
+
* operator map like `{ gt: 5, lt: 10 }` rather than a scalar equality value. Arrays and `null` are
|
|
7
|
+
* not operator objects. Single source of the shape classifier every adapter's auto-field path uses.
|
|
8
|
+
*/
|
|
9
|
+
export function isOperatorObject(value) {
|
|
10
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
return false;
|
|
12
|
+
const keys = Object.keys(value);
|
|
13
|
+
return keys.length > 0 && keys.every((k) => OPERATOR_SET.has(k));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Normalize a raw auto-field value to canonical {@link ColumnFilter}[]:
|
|
17
|
+
* - an array → a single `in` filter,
|
|
18
|
+
* - an {@link isOperatorObject operator object} → one filter per operator,
|
|
19
|
+
* - any other (scalar) value → a single `equals` filter.
|
|
20
|
+
*
|
|
21
|
+
* Lets an adapter's auto-field/relation/computed surfaces drive the same `ColumnFilter` path the
|
|
22
|
+
* structured-filter input already uses, instead of re-implementing the scalar/array/object ladder.
|
|
23
|
+
*/
|
|
24
|
+
export function valueToColumnFilters(field, value) {
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return [{ field, operator: 'in', value }];
|
|
27
|
+
if (isOperatorObject(value)) {
|
|
28
|
+
return Object.entries(value).map(([operator, opVal]) => ({
|
|
29
|
+
field,
|
|
30
|
+
operator: operator,
|
|
31
|
+
value: opVal,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
return [{ field, operator: 'equals', value }];
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=value-shape.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"value-shape.js","sourceRoot":"","sources":["../../src/value-shape.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,gBAAgB,EAAuB,MAAM,gBAAgB,CAAC;AAE1F,0EAA0E;AAC1E,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAS,gBAAgB,CAAC,CAAC;AAE5E;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,KAAK,IAAI,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACrF,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAChC,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAa,EAAE,KAAc;IAChE,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACpE,IAAI,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;YACvD,KAAK;YACL,QAAQ,EAAE,QAA0B;YACpC,KAAK,EAAE,KAAK;SACb,CAAC,CAAC,CAAC;IACN,CAAC;IACD,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../stubs/main.ts"],"names":[],"mappings":"AAEA,iFAAiF;AACjF,eAAO,MAAM,SAAS,QAAgD,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.js","sourceRoot":"","sources":["../../stubs/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,iFAAiF;AACjF,MAAM,CAAC,MAAM,SAAS,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{{#var filterName = string(entity.name).pascalCase().removeSuffix('Filter').toString() + 'Filter'}}
|
|
2
|
+
{{#var modelName = string(entity.name).pascalCase().removeSuffix('Filter').toString()}}
|
|
3
|
+
{{#var filterFileName = string(entity.name).snakeCase().removeSuffix('filter').toString() + '_filter'}}
|
|
4
|
+
{{#var modelFileName = string(entity.name).snakeCase().removeSuffix('filter').toString()}}
|
|
5
|
+
{{{
|
|
6
|
+
exports({
|
|
7
|
+
to: app.makePath('app/filters', entity.path, filterFileName + '.ts')
|
|
8
|
+
})
|
|
9
|
+
}}}
|
|
10
|
+
import { BaseModelFilter } from '@adonis-agora/filter'
|
|
11
|
+
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
|
|
12
|
+
import {{ modelName }} from '#models/{{ modelFileName }}'
|
|
13
|
+
|
|
14
|
+
export default class {{ filterName }} extends BaseModelFilter {
|
|
15
|
+
declare $query: ModelQueryBuilderContract<typeof {{ modelName }}>
|
|
16
|
+
|
|
17
|
+
static model = {{ modelName }}
|
|
18
|
+
|
|
19
|
+
/** Columns clients may filter on with the standard operators — these need no method. */
|
|
20
|
+
static filterable = ['name']
|
|
21
|
+
/** Columns clients may sort on. Defaults to the filterable list. */
|
|
22
|
+
static sortable = ['createdAt']
|
|
23
|
+
/** Columns the free-text search term scans. */
|
|
24
|
+
static searchable = ['name']
|
|
25
|
+
|
|
26
|
+
static defaultSort = [{ field: 'createdAt', direction: 'desc' as const }]
|
|
27
|
+
static defaultSize = 25
|
|
28
|
+
static maxSize = 100
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs on every call, before anything the request asked for — the place for a scope the client
|
|
32
|
+
* cannot relax (a tenant, a soft-delete guard, an ownership check).
|
|
33
|
+
*/
|
|
34
|
+
setup() {
|
|
35
|
+
// this.$query.whereNull('deletedAt')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One method per request key that needs SQL of its own. ?filter[fullName]=silva lands here;
|
|
40
|
+
* writing the method IS what exposes the key, so it needs no allow-list entry.
|
|
41
|
+
*/
|
|
42
|
+
// fullName(value: string) {
|
|
43
|
+
// this.$query.whereILike('full_name', '%' + value + '%')
|
|
44
|
+
// }
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adonis-agora/filter",
|
|
3
|
+
"version": "0.0.0-beta-20261110131309",
|
|
4
|
+
"description": "Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/DavideCarvalho/adonis-agora-filter.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://davidecarvalho.github.io/agora/docs/filter",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/DavideCarvalho/adonis-agora-filter/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"adonisjs",
|
|
17
|
+
"adonis",
|
|
18
|
+
"filter",
|
|
19
|
+
"lucid",
|
|
20
|
+
"query",
|
|
21
|
+
"spatie",
|
|
22
|
+
"pagination",
|
|
23
|
+
"agora",
|
|
24
|
+
"tanstack-intent"
|
|
25
|
+
],
|
|
26
|
+
"author": "Davi Carvalho <davi@goflip.ai>",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/src/index.js",
|
|
29
|
+
"types": "./dist/src/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/src/index.d.ts",
|
|
33
|
+
"import": "./dist/src/index.js",
|
|
34
|
+
"default": "./dist/src/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/src/testing.d.ts",
|
|
38
|
+
"import": "./dist/src/testing.js",
|
|
39
|
+
"default": "./dist/src/testing.js"
|
|
40
|
+
},
|
|
41
|
+
"./in-memory": {
|
|
42
|
+
"types": "./dist/src/in_memory.d.ts",
|
|
43
|
+
"import": "./dist/src/in_memory.js",
|
|
44
|
+
"default": "./dist/src/in_memory.js"
|
|
45
|
+
},
|
|
46
|
+
"./configure": {
|
|
47
|
+
"types": "./dist/configure.d.ts",
|
|
48
|
+
"import": "./dist/configure.js",
|
|
49
|
+
"default": "./dist/configure.js"
|
|
50
|
+
},
|
|
51
|
+
"./commands": {
|
|
52
|
+
"types": "./dist/commands/main.d.ts",
|
|
53
|
+
"import": "./dist/commands/main.js",
|
|
54
|
+
"default": "./dist/commands/main.js"
|
|
55
|
+
},
|
|
56
|
+
"./filter_provider": {
|
|
57
|
+
"types": "./dist/providers/filter_provider.d.ts",
|
|
58
|
+
"import": "./dist/providers/filter_provider.js",
|
|
59
|
+
"default": "./dist/providers/filter_provider.js"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist/",
|
|
64
|
+
"stubs/",
|
|
65
|
+
"README.md",
|
|
66
|
+
"CHANGELOG.md",
|
|
67
|
+
"skills"
|
|
68
|
+
],
|
|
69
|
+
"sideEffects": false,
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@adonisjs/core": "^7.3.0",
|
|
72
|
+
"@adonisjs/lucid": "^22.0.0"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"@adonisjs/lucid": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@adonisjs/assembler": "8.6.0",
|
|
81
|
+
"@adonisjs/core": "7.6.0",
|
|
82
|
+
"@adonisjs/lucid": "22.4.2",
|
|
83
|
+
"@tanstack/intent": "0.5.6",
|
|
84
|
+
"@types/luxon": "3.7.6",
|
|
85
|
+
"@types/node": "26.6.5",
|
|
86
|
+
"@types/pg": "8.23.1",
|
|
87
|
+
"luxon": "3.7.2",
|
|
88
|
+
"pg": "8.23.1",
|
|
89
|
+
"typescript": "7.0.2",
|
|
90
|
+
"vitest": "5.0.5"
|
|
91
|
+
},
|
|
92
|
+
"engines": {
|
|
93
|
+
"node": ">=24"
|
|
94
|
+
},
|
|
95
|
+
"scripts": {
|
|
96
|
+
"build": "tsc -p tsconfig.json && pnpm run copy:stubs",
|
|
97
|
+
"test": "vitest run --passWithNoTests",
|
|
98
|
+
"test:watch": "vitest",
|
|
99
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.typetest.json",
|
|
100
|
+
"copy:stubs": "mkdir -p dist/stubs/make/filter && cp stubs/make/filter/main.stub dist/stubs/make/filter/main.stub"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: filter-basics
|
|
3
|
+
description: >
|
|
4
|
+
Add Spatie/JSON:API-style query filtering to an AdonisJS endpoint with
|
|
5
|
+
@adonis-agora/filter. Covers parseFilterRequest(qs), applyFilter(query, input,
|
|
6
|
+
{ allowed, sortable, searchable }) with the returned/clamped { page, size },
|
|
7
|
+
the declarative defineFilter + applyFilterFromRequest flow (tenant scope,
|
|
8
|
+
defaultFilters, defaultSort, defaultSize/maxSize), the optional provider
|
|
9
|
+
macros .applyFilterFromRequest() / .filterPaginate(), wire format parsing
|
|
10
|
+
(filter[field][op]=value, sort=-createdAt, page/size), and input sourcing from
|
|
11
|
+
query string, POST body, or a nested key, and the in-memory adapter
|
|
12
|
+
(@adonis-agora/filter/in-memory: defineCollection, filterInMemory,
|
|
13
|
+
cursorInMemory) for lists that are not a table. Use when building a filtered
|
|
14
|
+
list endpoint, wiring a reusable filter policy, deciding free functions vs
|
|
15
|
+
macros, or filtering a plain array with the same wire format.
|
|
16
|
+
metadata:
|
|
17
|
+
type: core
|
|
18
|
+
library: "@adonis-agora/filter"
|
|
19
|
+
library_version: "0.7.0"
|
|
20
|
+
framework: adonisjs
|
|
21
|
+
sources:
|
|
22
|
+
- "DavideCarvalho/adonis-filter:docs/getting-started.mdx"
|
|
23
|
+
- "DavideCarvalho/adonis-filter:docs/guides/controllers.mdx"
|
|
24
|
+
- "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
|
|
25
|
+
- "DavideCarvalho/adonis-filter:docs/guides/provider.mdx"
|
|
26
|
+
- "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
|
|
27
|
+
- "DavideCarvalho/adonis-filter:docs/guides/in-memory.mdx"
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Filter basics: parse a request, apply it to a Lucid query
|
|
31
|
+
|
|
32
|
+
The whole server surface is two functions: `parseFilterRequest(request.qs())`
|
|
33
|
+
reshapes the request into a structured `FilterInput`, then
|
|
34
|
+
`applyFilter(query, input, config)` validates it, prunes it against an
|
|
35
|
+
allow-list, mutates your Lucid query in place, and returns the resolved
|
|
36
|
+
`{ page, size }`. No config file, no decorators; the optional service provider
|
|
37
|
+
only adds chainable macros.
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install @adonis-agora/filter
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// app/controllers/users_controller.ts
|
|
47
|
+
import type { HttpContext } from '@adonisjs/core/http'
|
|
48
|
+
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
|
|
49
|
+
import User from '#models/user'
|
|
50
|
+
|
|
51
|
+
export default class UsersController {
|
|
52
|
+
async index({ request }: HttpContext) {
|
|
53
|
+
const input = parseFilterRequest(request.qs())
|
|
54
|
+
const query = User.query()
|
|
55
|
+
|
|
56
|
+
const { page, size } = applyFilter(query, input, {
|
|
57
|
+
allowed: ['name', 'email', 'age', 'status'],
|
|
58
|
+
sortable: ['name', 'createdAt'],
|
|
59
|
+
searchable: ['name', 'email'],
|
|
60
|
+
defaultSize: 25,
|
|
61
|
+
maxSize: 100,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return query.paginate(page, size)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// start/routes.ts
|
|
71
|
+
import router from '@adonisjs/core/services/router'
|
|
72
|
+
const UsersController = () => import('#controllers/users_controller')
|
|
73
|
+
|
|
74
|
+
router.get('/users', [UsersController, 'index'])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A request like
|
|
78
|
+
`GET /users?filter[status]=active&filter[age][gte]=18&search=fleet&sort=-createdAt&page=1&size=25`
|
|
79
|
+
parses, validates, prunes against the allow-list, applies WHERE/ORDER BY/ILIKE,
|
|
80
|
+
and returns `{ page: 1, size: 25 }` for `query.paginate()`.
|
|
81
|
+
|
|
82
|
+
## Core patterns
|
|
83
|
+
|
|
84
|
+
### 1. Two-function primitive — parse once, apply once
|
|
85
|
+
|
|
86
|
+
`parseFilterRequest` is a pure reshape (no validation, no allow-listing);
|
|
87
|
+
`applyFilter` owns validation and pruning. It reads any decoded object, so the
|
|
88
|
+
same controller serves GET query strings and structured POST bodies:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
|
|
92
|
+
|
|
93
|
+
// GET — decoded query string
|
|
94
|
+
const input = parseFilterRequest(request.qs())
|
|
95
|
+
|
|
96
|
+
// POST /search — the body may be the flat Spatie shape or the structured
|
|
97
|
+
// object the client builder's .build() returns ({ filter: { where }, sort, paginate })
|
|
98
|
+
const bodyInput = parseFilterRequest(request.body())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Source: `docs/guides/controllers.mdx`, `docs/guides/operators.mdx`
|
|
102
|
+
|
|
103
|
+
### 2. Reusable policies with defineFilter + applyFilterFromRequest
|
|
104
|
+
|
|
105
|
+
When a policy spans endpoints, declare a `FilterSpec` once at module scope and
|
|
106
|
+
apply it in one call. Server scope (tenant + `defaultFilters`) is injected
|
|
107
|
+
before allow-listed request filters; `defaultSort` applies when the request has
|
|
108
|
+
none:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// app/filters/user_filter.ts
|
|
112
|
+
import { defineFilter } from '@adonis-agora/filter'
|
|
113
|
+
import User from '#models/user'
|
|
114
|
+
|
|
115
|
+
export const userFilter = defineFilter({
|
|
116
|
+
filterable: ['name', 'email', 'age', 'status'],
|
|
117
|
+
sortable: ['name', 'createdAt'],
|
|
118
|
+
searchable: ['name', 'email'],
|
|
119
|
+
relations: { posts: { filterable: ['title', 'status'] } },
|
|
120
|
+
tenant: { column: 'tenantId', resolve: (ctx) => (ctx as any).auth.user!.tenantId },
|
|
121
|
+
defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
|
|
122
|
+
defaultSort: [{ field: 'createdAt', direction: 'desc' }],
|
|
123
|
+
defaultSize: 25,
|
|
124
|
+
maxSize: 100,
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// app/controllers/users_controller.ts
|
|
130
|
+
import { applyFilterFromRequest } from '@adonis-agora/filter'
|
|
131
|
+
import { userFilter } from '#filters/user_filter'
|
|
132
|
+
import User from '#models/user'
|
|
133
|
+
|
|
134
|
+
async index(ctx: HttpContext) {
|
|
135
|
+
const query = User.query()
|
|
136
|
+
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
|
|
137
|
+
return query.paginate(page, size)
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Source: `docs/definitions/index.mdx`
|
|
142
|
+
|
|
143
|
+
### 3. Optional macros — inline chaining
|
|
144
|
+
|
|
145
|
+
Register the provider (`node ace add @adonis-agora/filter`, or add
|
|
146
|
+
`() => import('@adonis-agora/filter/filter_provider')` under `providers` in
|
|
147
|
+
`adonisrc.ts`) and every model query gains `.applyFilterFromRequest(spec)` /
|
|
148
|
+
`.filterPaginate(spec)`:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { userFilter } from '#filters/user_filter'
|
|
152
|
+
import User from '#models/user'
|
|
153
|
+
|
|
154
|
+
// ctx read from AsyncLocalStorage inside a request
|
|
155
|
+
return User.query().where('active', true).applyFilterFromRequest(userFilter)
|
|
156
|
+
|
|
157
|
+
// filter + paginate in one terminal call
|
|
158
|
+
return User.query().filterPaginate(userFilter)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`filterPaginate` returns Lucid's paginator but reads the page before the total:
|
|
162
|
+
a page shorter than `size` gives the total as `offset + rows`, so no `COUNT(*)`
|
|
163
|
+
runs. Full pages, and empty pages past the first, still count. Models with `before:paginate` / `before:fetch` hooks, and `distinct` /
|
|
164
|
+
aggregate / raw / `union` selects, keep Lucid's own `paginate`.
|
|
165
|
+
|
|
166
|
+
The free `applyFilterFromRequest(query, spec, ctx, options?)` always needs an
|
|
167
|
+
explicit `ctx` — the AsyncLocalStorage fallback exists only in the macro layer.
|
|
168
|
+
|
|
169
|
+
Source: `docs/guides/provider.mdx`
|
|
170
|
+
|
|
171
|
+
### 4. Pagination is returned, not applied
|
|
172
|
+
|
|
173
|
+
`applyFilter` resolves `size = clamp(input.size ?? defaultSize ?? 25, 1, maxSize ?? 100)`
|
|
174
|
+
and `page = max(1, input.page ?? 1)` but leaves execution to you — so you can
|
|
175
|
+
paginate, run plain `.exec()`, or layer a DISTINCT projection on the same
|
|
176
|
+
filtered query.
|
|
177
|
+
|
|
178
|
+
Source: `docs/getting-started.mdx`, `docs/guides/filter-classes.mdx`
|
|
179
|
+
|
|
180
|
+
### 5. Lists that are not a table — the in-memory adapter
|
|
181
|
+
|
|
182
|
+
Rows merged from several sources, static catalogs and remote API pages take the
|
|
183
|
+
same input through `@adonis-agora/filter/in-memory`. `collection.query(rows)` is a
|
|
184
|
+
`QueryBuilderLike`, so every runner entry point (and `defineFilter` specs) drives
|
|
185
|
+
it unchanged; the builder evaluates the calls with Postgres semantics (ILIKE,
|
|
186
|
+
three-valued NULLs, NULLS LAST asc / FIRST desc, EXISTS for to-many paths).
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { defineCollection, filterInMemory } from '@adonis-agora/filter/in-memory'
|
|
190
|
+
|
|
191
|
+
const users = defineCollection<UserRow>({
|
|
192
|
+
fields: { name: 'string', age: 'number', tags: 'string[]', fullName: { type: 'string', get: (u) => `${u.first} ${u.last}` } },
|
|
193
|
+
relations: { posts: { kind: 'one-to-many', target: () => posts, get: (u) => u.posts } },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const page = filterInMemory(users, rows, parseFilterRequest(ctx.request.qs()), {
|
|
197
|
+
allowed: ['name', 'age', 'fullName', 'posts.title'],
|
|
198
|
+
fieldTypes: users.fieldTypes,
|
|
199
|
+
})
|
|
200
|
+
// or: const q = users.query(rows); const { page, size } = applyFilterFromRequest(q, spec, ctx); q.paginate(page, size)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Policy `computed`, `fullText` and `vectorSimilarity` are SQL-only and throw
|
|
204
|
+
`InMemoryQueryError` there — declare a virtual field on the collection instead.
|
|
205
|
+
|
|
206
|
+
Source: `docs/guides/in-memory.mdx`
|
|
207
|
+
|
|
208
|
+
## Common mistakes
|
|
209
|
+
|
|
210
|
+
### [CRITICAL] Feeding raw request page/size to paginate()
|
|
211
|
+
|
|
212
|
+
Wrong:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
const input = parseFilterRequest(request.qs())
|
|
216
|
+
await User.query().paginate(input.page ?? 1, input.size ?? 25)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Correct:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
const query = User.query()
|
|
223
|
+
const { page, size } = applyFilter(query, input, { allowed: ['name'], maxSize: 100 })
|
|
224
|
+
return query.paginate(page, size)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`applyFilter` clamps size into `[1, maxSize]` and floors page at 1; values taken
|
|
228
|
+
straight off the query string skip the clamp entirely, so `?size=500000`
|
|
229
|
+
executes against the database.
|
|
230
|
+
|
|
231
|
+
Source: docs/getting-started.mdx ("Why pagination is returned, not applied"); docs/guides/filter-classes.mdx (Pagination bounds)
|
|
232
|
+
|
|
233
|
+
### [HIGH] Putting the tenant column in the allow-list
|
|
234
|
+
|
|
235
|
+
Wrong:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
export const userFilter = defineFilter({
|
|
239
|
+
filterable: ['name', 'tenantId'], // client can now constrain tenantId too
|
|
240
|
+
tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
|
|
241
|
+
})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Correct:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
export const userFilter = defineFilter({
|
|
248
|
+
filterable: ['name'],
|
|
249
|
+
tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The tenant scope always lands as trusted server policy; if the same column is
|
|
254
|
+
also client-filterable, a sent value AND-combines with it and silently returns
|
|
255
|
+
an empty result set. Same rule for pre-scoped queries: constrain before
|
|
256
|
+
`applyFilter` and leave the column out of `allowed`.
|
|
257
|
+
|
|
258
|
+
Source: docs/definitions/index.mdx ("Tenant scope is un-tamperable"); docs/guides/controllers.mdx
|
|
259
|
+
|
|
260
|
+
### [MEDIUM] Expecting `search` to work without `searchable`
|
|
261
|
+
|
|
262
|
+
Wrong:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
applyFilter(query, input, { allowed: ['name'] })
|
|
266
|
+
// ?search=fleet is silently skipped — no searchable columns declared
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Correct:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
applyFilter(query, input, {
|
|
273
|
+
allowed: ['name'],
|
|
274
|
+
searchable: ['name', 'email'],
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The free-text term only routes anywhere when `searchable` is non-empty;
|
|
279
|
+
otherwise it is dropped with no error and rows come back unfiltered by search.
|
|
280
|
+
|
|
281
|
+
Source: docs/guides/filter-classes.mdx ("searchable enables free-text search")
|
|
282
|
+
|
|
283
|
+
See also: `../filter-safety/SKILL.md` (tightening the boundary before exposing
|
|
284
|
+
the endpoint publicly), `../filter-definitions/SKILL.md` (growing the policy
|
|
285
|
+
into a reusable spec).
|