@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.
Files changed (168) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/dist/commands/main.d.ts +19 -0
  5. package/dist/commands/main.d.ts.map +1 -0
  6. package/dist/commands/main.js +19 -0
  7. package/dist/commands/main.js.map +1 -0
  8. package/dist/commands/make_filter.d.ts +18 -0
  9. package/dist/commands/make_filter.d.ts.map +1 -0
  10. package/dist/commands/make_filter.js +32 -0
  11. package/dist/commands/make_filter.js.map +1 -0
  12. package/dist/commands/make_filter_client.d.ts +35 -0
  13. package/dist/commands/make_filter_client.d.ts.map +1 -0
  14. package/dist/commands/make_filter_client.js +85 -0
  15. package/dist/commands/make_filter_client.js.map +1 -0
  16. package/dist/configure.d.ts +15 -0
  17. package/dist/configure.d.ts.map +1 -0
  18. package/dist/configure.js +20 -0
  19. package/dist/configure.js.map +1 -0
  20. package/dist/providers/filter_provider.d.ts +28 -0
  21. package/dist/providers/filter_provider.d.ts.map +1 -0
  22. package/dist/providers/filter_provider.js +33 -0
  23. package/dist/providers/filter_provider.js.map +1 -0
  24. package/dist/src/aggregate.d.ts +70 -0
  25. package/dist/src/aggregate.d.ts.map +1 -0
  26. package/dist/src/aggregate.js +195 -0
  27. package/dist/src/aggregate.js.map +1 -0
  28. package/dist/src/aggregate_path.d.ts +25 -0
  29. package/dist/src/aggregate_path.d.ts.map +1 -0
  30. package/dist/src/aggregate_path.js +35 -0
  31. package/dist/src/aggregate_path.js.map +1 -0
  32. package/dist/src/apply_from_request.d.ts +85 -0
  33. package/dist/src/apply_from_request.d.ts.map +1 -0
  34. package/dist/src/apply_from_request.js +164 -0
  35. package/dist/src/apply_from_request.js.map +1 -0
  36. package/dist/src/base_filter.d.ts +49 -0
  37. package/dist/src/base_filter.d.ts.map +1 -0
  38. package/dist/src/base_filter.js +33 -0
  39. package/dist/src/base_filter.js.map +1 -0
  40. package/dist/src/base_model_filter.d.ts +90 -0
  41. package/dist/src/base_model_filter.d.ts.map +1 -0
  42. package/dist/src/base_model_filter.js +85 -0
  43. package/dist/src/base_model_filter.js.map +1 -0
  44. package/dist/src/coerce_value.d.ts +26 -0
  45. package/dist/src/coerce_value.d.ts.map +1 -0
  46. package/dist/src/coerce_value.js +82 -0
  47. package/dist/src/coerce_value.js.map +1 -0
  48. package/dist/src/cursor.d.ts +92 -0
  49. package/dist/src/cursor.d.ts.map +1 -0
  50. package/dist/src/cursor.js +107 -0
  51. package/dist/src/cursor.js.map +1 -0
  52. package/dist/src/custom_filter.d.ts +36 -0
  53. package/dist/src/custom_filter.d.ts.map +1 -0
  54. package/dist/src/custom_filter.js +127 -0
  55. package/dist/src/custom_filter.js.map +1 -0
  56. package/dist/src/decorator_metadata.d.ts +41 -0
  57. package/dist/src/decorator_metadata.d.ts.map +1 -0
  58. package/dist/src/decorator_metadata.js +83 -0
  59. package/dist/src/decorator_metadata.js.map +1 -0
  60. package/dist/src/decorators.d.ts +73 -0
  61. package/dist/src/decorators.d.ts.map +1 -0
  62. package/dist/src/decorators.js +118 -0
  63. package/dist/src/decorators.js.map +1 -0
  64. package/dist/src/escape-like.d.ts +10 -0
  65. package/dist/src/escape-like.d.ts.map +1 -0
  66. package/dist/src/escape-like.js +15 -0
  67. package/dist/src/escape-like.js.map +1 -0
  68. package/dist/src/field_aliases.d.ts +30 -0
  69. package/dist/src/field_aliases.d.ts.map +1 -0
  70. package/dist/src/field_aliases.js +65 -0
  71. package/dist/src/field_aliases.js.map +1 -0
  72. package/dist/src/filter_class.d.ts +46 -0
  73. package/dist/src/filter_class.d.ts.map +1 -0
  74. package/dist/src/filter_class.js +244 -0
  75. package/dist/src/filter_class.js.map +1 -0
  76. package/dist/src/filter_spec.d.ts +231 -0
  77. package/dist/src/filter_spec.d.ts.map +1 -0
  78. package/dist/src/filter_spec.js +182 -0
  79. package/dist/src/filter_spec.js.map +1 -0
  80. package/dist/src/filterable_mixin.d.ts +49 -0
  81. package/dist/src/filterable_mixin.d.ts.map +1 -0
  82. package/dist/src/filterable_mixin.js +47 -0
  83. package/dist/src/filterable_mixin.js.map +1 -0
  84. package/dist/src/generate_client.d.ts +107 -0
  85. package/dist/src/generate_client.d.ts.map +1 -0
  86. package/dist/src/generate_client.js +164 -0
  87. package/dist/src/generate_client.js.map +1 -0
  88. package/dist/src/group_by_count.d.ts +67 -0
  89. package/dist/src/group_by_count.d.ts.map +1 -0
  90. package/dist/src/group_by_count.js +71 -0
  91. package/dist/src/group_by_count.js.map +1 -0
  92. package/dist/src/in_memory.d.ts +279 -0
  93. package/dist/src/in_memory.d.ts.map +1 -0
  94. package/dist/src/in_memory.js +810 -0
  95. package/dist/src/in_memory.js.map +1 -0
  96. package/dist/src/index.d.ts +51 -0
  97. package/dist/src/index.d.ts.map +1 -0
  98. package/dist/src/index.js +33 -0
  99. package/dist/src/index.js.map +1 -0
  100. package/dist/src/lucid_adapter.d.ts +311 -0
  101. package/dist/src/lucid_adapter.d.ts.map +1 -0
  102. package/dist/src/lucid_adapter.js +469 -0
  103. package/dist/src/lucid_adapter.js.map +1 -0
  104. package/dist/src/lucid_macros.d.ts +87 -0
  105. package/dist/src/lucid_macros.d.ts.map +1 -0
  106. package/dist/src/lucid_macros.js +67 -0
  107. package/dist/src/lucid_macros.js.map +1 -0
  108. package/dist/src/normalizer.d.ts +21 -0
  109. package/dist/src/normalizer.d.ts.map +1 -0
  110. package/dist/src/normalizer.js +61 -0
  111. package/dist/src/normalizer.js.map +1 -0
  112. package/dist/src/operators.d.ts +37 -0
  113. package/dist/src/operators.d.ts.map +1 -0
  114. package/dist/src/operators.js +43 -0
  115. package/dist/src/operators.js.map +1 -0
  116. package/dist/src/paginate.d.ts +12 -0
  117. package/dist/src/paginate.d.ts.map +1 -0
  118. package/dist/src/paginate.js +88 -0
  119. package/dist/src/paginate.js.map +1 -0
  120. package/dist/src/pagination_stash.d.ts +4 -0
  121. package/dist/src/pagination_stash.d.ts.map +1 -0
  122. package/dist/src/pagination_stash.js +19 -0
  123. package/dist/src/pagination_stash.js.map +1 -0
  124. package/dist/src/parse_request.d.ts +50 -0
  125. package/dist/src/parse_request.d.ts.map +1 -0
  126. package/dist/src/parse_request.js +220 -0
  127. package/dist/src/parse_request.js.map +1 -0
  128. package/dist/src/runner.d.ts +65 -0
  129. package/dist/src/runner.d.ts.map +1 -0
  130. package/dist/src/runner.js +371 -0
  131. package/dist/src/runner.js.map +1 -0
  132. package/dist/src/source_resolver.d.ts +18 -0
  133. package/dist/src/source_resolver.d.ts.map +1 -0
  134. package/dist/src/source_resolver.js +64 -0
  135. package/dist/src/source_resolver.js.map +1 -0
  136. package/dist/src/spatie_parser.d.ts +39 -0
  137. package/dist/src/spatie_parser.d.ts.map +1 -0
  138. package/dist/src/spatie_parser.js +134 -0
  139. package/dist/src/spatie_parser.js.map +1 -0
  140. package/dist/src/testing.d.ts +73 -0
  141. package/dist/src/testing.d.ts.map +1 -0
  142. package/dist/src/testing.js +136 -0
  143. package/dist/src/testing.js.map +1 -0
  144. package/dist/src/types.d.ts +226 -0
  145. package/dist/src/types.d.ts.map +1 -0
  146. package/dist/src/types.js +2 -0
  147. package/dist/src/types.js.map +1 -0
  148. package/dist/src/validate-column-filter.d.ts +30 -0
  149. package/dist/src/validate-column-filter.d.ts.map +1 -0
  150. package/dist/src/validate-column-filter.js +148 -0
  151. package/dist/src/validate-column-filter.js.map +1 -0
  152. package/dist/src/value-shape.d.ts +18 -0
  153. package/dist/src/value-shape.d.ts.map +1 -0
  154. package/dist/src/value-shape.js +36 -0
  155. package/dist/src/value-shape.js.map +1 -0
  156. package/dist/stubs/main.d.ts +3 -0
  157. package/dist/stubs/main.d.ts.map +1 -0
  158. package/dist/stubs/main.js +4 -0
  159. package/dist/stubs/main.js.map +1 -0
  160. package/dist/stubs/make/filter/main.stub +45 -0
  161. package/package.json +102 -0
  162. package/skills/filter-basics/SKILL.md +285 -0
  163. package/skills/filter-codegen/SKILL.md +246 -0
  164. package/skills/filter-definitions/SKILL.md +340 -0
  165. package/skills/filter-querying/SKILL.md +319 -0
  166. package/skills/filter-safety/SKILL.md +304 -0
  167. package/stubs/main.ts +4 -0
  168. 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,3 @@
1
+ /** Absolute path to this package's stubs, handed to `codemods.makeUsingStub`. */
2
+ export declare const stubsRoot: string;
3
+ //# sourceMappingURL=main.d.ts.map
@@ -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,4 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /** Absolute path to this package's stubs, handed to `codemods.makeUsingStub`. */
3
+ export const stubsRoot = fileURLToPath(new URL('./', import.meta.url));
4
+ //# sourceMappingURL=main.js.map
@@ -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).