@plumile/filter-query 0.1.32 → 0.1.34
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 +7 -0
- package/README.md +10 -10
- package/lib/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +29 -7
- package/src/__tests__/additional-coverage.test.ts +0 -82
- package/src/__tests__/list-edge.test.ts +0 -26
- package/src/__tests__/mutate.test.ts +0 -85
- package/src/__tests__/parse-stringify.test.ts +0 -104
- package/src/__tests__/parse.test.ts +0 -130
- package/src/__tests__/remove-filter.test.ts +0 -46
- package/src/__tests__/schema-edge.test.ts +0 -51
- package/src/__tests__/schema-infer.test-d.ts +0 -24
- package/src/__tests__/schema.test.ts +0 -30
- package/src/__tests__/stability-and-diagnostics.test.ts +0 -40
- package/src/__tests__/stringify.test.ts +0 -64
- package/src/errors.ts +0 -75
- package/src/index.ts +0 -6
- package/src/mutate.ts +0 -151
- package/src/parse.ts +0 -244
- package/src/schema.ts +0 -81
- package/src/stringify.ts +0 -83
- package/src/types.ts +0 -104
- package/tools/build-package.sh +0 -5
- package/tools/test-build-package.sh +0 -4
- package/tsconfig.build.json +0 -8
- package/tsconfig.esm.json +0 -7
- package/tsconfig.json +0 -8
- package/tsconfig.types.json +0 -9
- package/vitest.config.ts +0 -7
package/src/index.ts
DELETED
package/src/mutate.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import type { InferFilters, Schema } from './types.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Checks shallow equality between two plain objects (own enumerable string keys only).
|
|
5
|
-
*/
|
|
6
|
-
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
7
|
-
if (a === b) {
|
|
8
|
-
return true;
|
|
9
|
-
}
|
|
10
|
-
if (
|
|
11
|
-
typeof a !== 'object' ||
|
|
12
|
-
a === null ||
|
|
13
|
-
typeof b !== 'object' ||
|
|
14
|
-
b === null
|
|
15
|
-
) {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
const aObj = a as Record<string, unknown>;
|
|
19
|
-
const bObj = b as Record<string, unknown>;
|
|
20
|
-
const aKeys = Object.keys(aObj);
|
|
21
|
-
const bKeys = Object.keys(bObj);
|
|
22
|
-
if (aKeys.length !== bKeys.length) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
for (const k of aKeys) {
|
|
26
|
-
if (aObj[k] !== bObj[k]) {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Sets or unsets a specific operator value on a field while preserving reference
|
|
35
|
-
* equality when nothing changes.
|
|
36
|
-
*
|
|
37
|
-
* @param filters - Current filters object that will be treated as immutable input.
|
|
38
|
-
* @param _schema - Schema reference (reserved for future validation hooks).
|
|
39
|
-
* @param field - Field key to update.
|
|
40
|
-
* @param operator - Operator name to set or remove.
|
|
41
|
-
* @param value - New operator value; pass `undefined` to remove it.
|
|
42
|
-
*/
|
|
43
|
-
export function setFilter<
|
|
44
|
-
S extends Schema,
|
|
45
|
-
F extends keyof S,
|
|
46
|
-
O extends keyof NonNullable<InferFilters<S>[F]>,
|
|
47
|
-
>(
|
|
48
|
-
filters: Readonly<InferFilters<S>>,
|
|
49
|
-
_schema: S, // reserved for future validation hooks
|
|
50
|
-
field: F,
|
|
51
|
-
operator: O,
|
|
52
|
-
value: NonNullable<InferFilters<S>[F]>[O] | undefined,
|
|
53
|
-
): InferFilters<S> {
|
|
54
|
-
const currentField = filters[field];
|
|
55
|
-
if (value === undefined) {
|
|
56
|
-
if (currentField === undefined) {
|
|
57
|
-
return filters as InferFilters<S>;
|
|
58
|
-
}
|
|
59
|
-
const { [operator]: _removed, ...rest } = currentField as Record<
|
|
60
|
-
string,
|
|
61
|
-
unknown
|
|
62
|
-
>;
|
|
63
|
-
if (Object.keys(rest).length === 0) {
|
|
64
|
-
const { [field]: _f, ...clone } = filters as Record<string, unknown>;
|
|
65
|
-
return clone as InferFilters<S>;
|
|
66
|
-
}
|
|
67
|
-
return { ...filters, [field]: rest } as InferFilters<S>;
|
|
68
|
-
}
|
|
69
|
-
const newField = {
|
|
70
|
-
...(currentField as Record<string, unknown> | undefined),
|
|
71
|
-
[operator]: value,
|
|
72
|
-
};
|
|
73
|
-
if (currentField !== undefined && shallowEqual(currentField, newField)) {
|
|
74
|
-
return filters as InferFilters<S>;
|
|
75
|
-
}
|
|
76
|
-
return { ...filters, [field]: newField } as InferFilters<S>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Removes either an entire field or a specific operator from the filters object.
|
|
81
|
-
*
|
|
82
|
-
* @param filters - Current filters object (treated immutably).
|
|
83
|
-
* @param field - Field name to alter.
|
|
84
|
-
* @param operator - Optional operator to remove; omit to drop the whole field.
|
|
85
|
-
*/
|
|
86
|
-
export function removeFilter<S extends Schema, F extends keyof S>(
|
|
87
|
-
filters: Readonly<InferFilters<S>>,
|
|
88
|
-
field: F,
|
|
89
|
-
operator?: keyof NonNullable<InferFilters<S>[F]>,
|
|
90
|
-
): InferFilters<S> {
|
|
91
|
-
const currentField = filters[field];
|
|
92
|
-
if (currentField === undefined) {
|
|
93
|
-
return filters as InferFilters<S>;
|
|
94
|
-
}
|
|
95
|
-
if (operator === undefined) {
|
|
96
|
-
const { [field]: _removed, ...clone } = filters as Record<string, unknown>;
|
|
97
|
-
return clone as InferFilters<S>;
|
|
98
|
-
}
|
|
99
|
-
const { [operator]: _opRemoved, ...rest } = currentField as Record<
|
|
100
|
-
string,
|
|
101
|
-
unknown
|
|
102
|
-
>;
|
|
103
|
-
if (Object.keys(rest).length === 0) {
|
|
104
|
-
const { [field]: _f, ...clone } = filters as Record<string, unknown>;
|
|
105
|
-
return clone as InferFilters<S>;
|
|
106
|
-
}
|
|
107
|
-
return { ...filters, [field]: rest } as InferFilters<S>;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Merges a patch of filters into an existing filters object while preserving reference
|
|
112
|
-
* identity for unchanged fields.
|
|
113
|
-
*
|
|
114
|
-
* @param base - Original filters.
|
|
115
|
-
* @param patch - Filters to overlay.
|
|
116
|
-
*/
|
|
117
|
-
export function mergeFilters<S extends Schema>(
|
|
118
|
-
base: Readonly<InferFilters<S>>,
|
|
119
|
-
patch: Readonly<InferFilters<S>>,
|
|
120
|
-
): InferFilters<S> {
|
|
121
|
-
let changed = false;
|
|
122
|
-
const result: Partial<InferFilters<S>> = { ...base };
|
|
123
|
-
for (const key of Object.keys(patch) as (keyof S)[]) {
|
|
124
|
-
const existing = base[key];
|
|
125
|
-
const incoming = patch[key];
|
|
126
|
-
if (incoming === undefined) {
|
|
127
|
-
// skip undefined incoming
|
|
128
|
-
} else if (existing === undefined) {
|
|
129
|
-
result[key] = incoming as InferFilters<S>[typeof key];
|
|
130
|
-
changed = true;
|
|
131
|
-
} else if (!shallowEqual(existing, incoming)) {
|
|
132
|
-
result[key] = incoming as InferFilters<S>[typeof key];
|
|
133
|
-
changed = true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (changed) {
|
|
137
|
-
return result as InferFilters<S>;
|
|
138
|
-
}
|
|
139
|
-
return base as InferFilters<S>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Determines if the filters object is empty (no fields present).
|
|
144
|
-
*
|
|
145
|
-
* @param filters - Filters to inspect.
|
|
146
|
-
*/
|
|
147
|
-
export function isEmpty<S extends Schema>(
|
|
148
|
-
filters: Readonly<InferFilters<S>>,
|
|
149
|
-
): boolean {
|
|
150
|
-
return Object.keys(filters).length === 0;
|
|
151
|
-
}
|
package/src/parse.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Schema,
|
|
3
|
-
Operator,
|
|
4
|
-
ListOperator,
|
|
5
|
-
BetweenOperator,
|
|
6
|
-
InferFilters,
|
|
7
|
-
FieldDescriptor,
|
|
8
|
-
ParseResult,
|
|
9
|
-
} from './types.js';
|
|
10
|
-
import type { Diagnostic } from './errors.js';
|
|
11
|
-
|
|
12
|
-
const BETWEEN: BetweenOperator = 'between';
|
|
13
|
-
const LIST_OPS: readonly ListOperator[] = ['in', 'nin'];
|
|
14
|
-
|
|
15
|
-
interface InternalAccumulator<S extends Schema> {
|
|
16
|
-
filters: InferFilters<S>;
|
|
17
|
-
diagnostics: Diagnostic[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Shallow clone plus ensure field object helper (immutable update). */
|
|
21
|
-
function ensureFieldObject<S extends Schema>(
|
|
22
|
-
filters: InferFilters<S>,
|
|
23
|
-
field: string,
|
|
24
|
-
): { container: InferFilters<S>; target: Record<string, unknown> } {
|
|
25
|
-
const existing = filters[field as keyof S] as
|
|
26
|
-
| Record<string, unknown>
|
|
27
|
-
| undefined;
|
|
28
|
-
if (existing !== undefined) {
|
|
29
|
-
return { container: filters, target: existing };
|
|
30
|
-
}
|
|
31
|
-
const clone: InferFilters<S> = { ...filters };
|
|
32
|
-
const created: Record<string, unknown> = {};
|
|
33
|
-
(clone as Record<string, unknown>)[field] = created;
|
|
34
|
-
return { container: clone, target: created };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Decode component, recording a diagnostic on failure. */
|
|
38
|
-
function decodeValue(
|
|
39
|
-
value: string,
|
|
40
|
-
field: string,
|
|
41
|
-
operator: string,
|
|
42
|
-
diagnostics: Diagnostic[],
|
|
43
|
-
): string | undefined {
|
|
44
|
-
try {
|
|
45
|
-
return decodeURIComponent(value);
|
|
46
|
-
} catch {
|
|
47
|
-
diagnostics.push({
|
|
48
|
-
kind: 'DecodeError',
|
|
49
|
-
field,
|
|
50
|
-
operator,
|
|
51
|
-
detail: 'Failed to decode',
|
|
52
|
-
});
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Parse a single raw value according to field kind. */
|
|
58
|
-
function parseSingleValue(
|
|
59
|
-
descriptor: FieldDescriptor,
|
|
60
|
-
raw: string,
|
|
61
|
-
): string | number | undefined {
|
|
62
|
-
if (descriptor.kind === 'number') {
|
|
63
|
-
if (raw === '') {
|
|
64
|
-
// Treat empty string as invalid for numeric fields (reject instead of coercing to 0)
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
const n = Number(raw);
|
|
68
|
-
if (!Number.isFinite(n)) {
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
return n;
|
|
72
|
-
}
|
|
73
|
-
return raw;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Handle a between operator value. */
|
|
77
|
-
function handleBetween<S extends Schema>(
|
|
78
|
-
descriptor: FieldDescriptor,
|
|
79
|
-
decoded: string,
|
|
80
|
-
field: string,
|
|
81
|
-
operator: string,
|
|
82
|
-
acc: InternalAccumulator<S>,
|
|
83
|
-
): void {
|
|
84
|
-
const parts: string[] = decoded.split(',');
|
|
85
|
-
if (parts.length !== 2) {
|
|
86
|
-
acc.diagnostics.push({
|
|
87
|
-
kind: 'InvalidArity',
|
|
88
|
-
field,
|
|
89
|
-
operator,
|
|
90
|
-
detail: 'Expected exactly 2 values',
|
|
91
|
-
});
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
const [aRaw, bRaw] = parts as [string, string];
|
|
95
|
-
const a = parseSingleValue(descriptor, aRaw);
|
|
96
|
-
const b = parseSingleValue(descriptor, bRaw);
|
|
97
|
-
if (a === undefined || b === undefined) {
|
|
98
|
-
acc.diagnostics.push({
|
|
99
|
-
kind: 'InvalidValue',
|
|
100
|
-
field,
|
|
101
|
-
operator,
|
|
102
|
-
detail: 'One of the between values invalid',
|
|
103
|
-
});
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const ensured = ensureFieldObject(acc.filters, field);
|
|
107
|
-
acc.filters = ensured.container;
|
|
108
|
-
if (Object.prototype.hasOwnProperty.call(ensured.target, 'between')) {
|
|
109
|
-
acc.diagnostics.push({ kind: 'DuplicateBetween', field });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
ensured.target.between = [a, b] as unknown;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Handle list operators (in, nin). */
|
|
116
|
-
function handleList<S extends Schema>(
|
|
117
|
-
descriptor: FieldDescriptor,
|
|
118
|
-
decoded: string,
|
|
119
|
-
field: string,
|
|
120
|
-
operator: string,
|
|
121
|
-
acc: InternalAccumulator<S>,
|
|
122
|
-
): void {
|
|
123
|
-
const listRaw = decoded.split(',');
|
|
124
|
-
const parsedList: (string | number)[] = [];
|
|
125
|
-
for (const item of listRaw) {
|
|
126
|
-
const parsed = parseSingleValue(descriptor, item);
|
|
127
|
-
if (parsed === undefined) {
|
|
128
|
-
acc.diagnostics.push({
|
|
129
|
-
kind: 'InvalidValue',
|
|
130
|
-
field,
|
|
131
|
-
operator,
|
|
132
|
-
detail: 'List item invalid',
|
|
133
|
-
});
|
|
134
|
-
} else {
|
|
135
|
-
parsedList.push(parsed);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (parsedList.length === 0) {
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
const ensured = ensureFieldObject(acc.filters, field);
|
|
142
|
-
acc.filters = ensured.container;
|
|
143
|
-
const targetObj: Record<string, unknown> = ensured.target;
|
|
144
|
-
const existing = targetObj[operator];
|
|
145
|
-
if (Array.isArray(existing)) {
|
|
146
|
-
targetObj[operator] = [...existing, ...parsedList];
|
|
147
|
-
} else {
|
|
148
|
-
targetObj[operator] = parsedList;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Handle scalar operator values. */
|
|
153
|
-
function handleScalar<S extends Schema>(
|
|
154
|
-
descriptor: FieldDescriptor,
|
|
155
|
-
decoded: string,
|
|
156
|
-
field: string,
|
|
157
|
-
operator: string,
|
|
158
|
-
acc: InternalAccumulator<S>,
|
|
159
|
-
): void {
|
|
160
|
-
const scalar = parseSingleValue(descriptor, decoded);
|
|
161
|
-
if (scalar === undefined) {
|
|
162
|
-
acc.diagnostics.push({ kind: 'InvalidValue', field, operator });
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const ensured = ensureFieldObject(acc.filters, field);
|
|
166
|
-
acc.filters = ensured.container;
|
|
167
|
-
const scalarTarget: Record<string, unknown> = ensured.target;
|
|
168
|
-
scalarTarget[operator] = scalar;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Parses a raw search string (leading `?` optional) according to the provided schema
|
|
173
|
-
* and returns the filters plus diagnostics.
|
|
174
|
-
*
|
|
175
|
-
* @param rawSearch - Query string to parse (with or without the leading question mark).
|
|
176
|
-
* @param schema - Schema describing allowed fields and operators.
|
|
177
|
-
* @returns Parsed filters alongside non-blocking diagnostics.
|
|
178
|
-
*/
|
|
179
|
-
export function parse<S extends Schema>(
|
|
180
|
-
rawSearch: string,
|
|
181
|
-
schema: S,
|
|
182
|
-
): ParseResult<S> {
|
|
183
|
-
const acc: InternalAccumulator<S> = { filters: {}, diagnostics: [] };
|
|
184
|
-
let search = rawSearch;
|
|
185
|
-
if (search.startsWith('?')) {
|
|
186
|
-
search = search.slice(1);
|
|
187
|
-
}
|
|
188
|
-
if (search.trim() === '') {
|
|
189
|
-
return acc;
|
|
190
|
-
}
|
|
191
|
-
const segments = search.split('&');
|
|
192
|
-
for (const seg of segments) {
|
|
193
|
-
const eqIdx = seg.indexOf('=');
|
|
194
|
-
const validBasic = seg !== '' && eqIdx !== -1;
|
|
195
|
-
if (validBasic) {
|
|
196
|
-
const rawKey = seg.slice(0, eqIdx);
|
|
197
|
-
const rawVal = seg.slice(eqIdx + 1);
|
|
198
|
-
const lastDot = rawKey.lastIndexOf('.');
|
|
199
|
-
// Case 1: key with explicit operator (field.op)
|
|
200
|
-
const explicitShape = !(lastDot <= 0 || lastDot === rawKey.length - 1);
|
|
201
|
-
if (explicitShape) {
|
|
202
|
-
const field = rawKey.slice(0, lastDot);
|
|
203
|
-
const operator = rawKey.slice(lastDot + 1) as Operator;
|
|
204
|
-
const descriptor = schema[field];
|
|
205
|
-
const descOk = descriptor?.operators.includes(operator) === true;
|
|
206
|
-
if (!descOk) {
|
|
207
|
-
if (descriptor == null) {
|
|
208
|
-
acc.diagnostics.push({ kind: 'UnknownField', field });
|
|
209
|
-
} else {
|
|
210
|
-
acc.diagnostics.push({ kind: 'UnknownOperator', field, operator });
|
|
211
|
-
}
|
|
212
|
-
} else {
|
|
213
|
-
const decoded = decodeValue(rawVal, field, operator, acc.diagnostics);
|
|
214
|
-
if (decoded !== undefined) {
|
|
215
|
-
const isList = (LIST_OPS as readonly string[]).includes(operator);
|
|
216
|
-
if (operator === BETWEEN) {
|
|
217
|
-
handleBetween(descriptor, decoded, field, operator, acc);
|
|
218
|
-
} else if (isList) {
|
|
219
|
-
handleList(descriptor, decoded, field, operator, acc);
|
|
220
|
-
} else {
|
|
221
|
-
handleScalar(descriptor, decoded, field, operator, acc);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} else if (lastDot === -1) {
|
|
226
|
-
// implicit equality (no dot) => operator 'eq'
|
|
227
|
-
const field = rawKey;
|
|
228
|
-
const operator: Operator = 'eq';
|
|
229
|
-
const descriptor = schema[field];
|
|
230
|
-
if (descriptor == null) {
|
|
231
|
-
acc.diagnostics.push({ kind: 'UnknownField', field });
|
|
232
|
-
} else if (!descriptor.operators.includes(operator)) {
|
|
233
|
-
acc.diagnostics.push({ kind: 'UnknownOperator', field, operator });
|
|
234
|
-
} else {
|
|
235
|
-
const decoded = decodeValue(rawVal, field, operator, acc.diagnostics);
|
|
236
|
-
if (decoded !== undefined) {
|
|
237
|
-
handleScalar(descriptor, decoded, field, operator, acc);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return acc;
|
|
244
|
-
}
|
package/src/schema.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
FieldDescriptor,
|
|
3
|
-
NumberFieldDescriptor,
|
|
4
|
-
Schema,
|
|
5
|
-
StringFieldDescriptor,
|
|
6
|
-
Operator,
|
|
7
|
-
} from './types.js';
|
|
8
|
-
|
|
9
|
-
const DEFAULT_NUMBER_OPERATORS: readonly Operator[] = [
|
|
10
|
-
'gt',
|
|
11
|
-
'gte',
|
|
12
|
-
'lt',
|
|
13
|
-
'lte',
|
|
14
|
-
'eq',
|
|
15
|
-
'neq',
|
|
16
|
-
'between',
|
|
17
|
-
'in',
|
|
18
|
-
'nin',
|
|
19
|
-
];
|
|
20
|
-
const DEFAULT_STRING_OPERATORS: readonly Operator[] = [
|
|
21
|
-
'contains',
|
|
22
|
-
'sw',
|
|
23
|
-
'ew',
|
|
24
|
-
'eq',
|
|
25
|
-
'neq',
|
|
26
|
-
'in',
|
|
27
|
-
'nin',
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Create a numeric field descriptor with a given operator whitelist.
|
|
32
|
-
*/
|
|
33
|
-
export function numberField(
|
|
34
|
-
ops: readonly Operator[] = DEFAULT_NUMBER_OPERATORS,
|
|
35
|
-
): NumberFieldDescriptor {
|
|
36
|
-
return {
|
|
37
|
-
kind: 'number',
|
|
38
|
-
operators: ops,
|
|
39
|
-
parse(raw: string): number | undefined {
|
|
40
|
-
if (raw === '') {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
const n = Number(raw);
|
|
44
|
-
if (Number.isFinite(n)) {
|
|
45
|
-
return n;
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
},
|
|
49
|
-
// Accept unknown to satisfy base interface intersection; caller ensures type correctness
|
|
50
|
-
serialize(value: unknown): string {
|
|
51
|
-
return String(value as number);
|
|
52
|
-
},
|
|
53
|
-
} as NumberFieldDescriptor;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Create a string field descriptor with a given operator whitelist.
|
|
58
|
-
*/
|
|
59
|
-
export function stringField(
|
|
60
|
-
ops: readonly Operator[] = DEFAULT_STRING_OPERATORS,
|
|
61
|
-
): StringFieldDescriptor {
|
|
62
|
-
return {
|
|
63
|
-
kind: 'string',
|
|
64
|
-
operators: ops,
|
|
65
|
-
parse(raw: string): string | undefined {
|
|
66
|
-
return raw;
|
|
67
|
-
},
|
|
68
|
-
serialize(value: unknown): string {
|
|
69
|
-
return String(value as string);
|
|
70
|
-
},
|
|
71
|
-
} as StringFieldDescriptor;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Freeze and return the schema (immutability guard for consumers).
|
|
76
|
-
*/
|
|
77
|
-
export function defineSchema<S extends Record<string, FieldDescriptor>>(
|
|
78
|
-
schema: S,
|
|
79
|
-
): Schema & S {
|
|
80
|
-
return Object.freeze({ ...schema });
|
|
81
|
-
}
|
package/src/stringify.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import type { InferFilters, Schema, Operator } from './types.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Builds the encoded key portion for a field/operator pair (implicit `eq` has no dot suffix).
|
|
5
|
-
*/
|
|
6
|
-
function encodeKey(field: string, op: Operator): string {
|
|
7
|
-
if (op === 'eq') return field; // implicit equality form
|
|
8
|
-
return `${field}.${op}`;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Serializes a filters object into a canonical query string respecting the schema operator ordering.
|
|
13
|
-
*
|
|
14
|
-
* @param filters - Parsed filters to encode.
|
|
15
|
-
* @param schema - Schema used to determine operator ordering and formatting.
|
|
16
|
-
* @returns Query string without leading question mark.
|
|
17
|
-
*/
|
|
18
|
-
export function stringify<S extends Schema>(
|
|
19
|
-
filters: InferFilters<S>,
|
|
20
|
-
schema: S,
|
|
21
|
-
): string {
|
|
22
|
-
const parts: string[] = [];
|
|
23
|
-
for (const field of Object.keys(schema)) {
|
|
24
|
-
const value = filters[field as keyof S];
|
|
25
|
-
const descriptor = schema[field];
|
|
26
|
-
if (value === undefined || descriptor === undefined) {
|
|
27
|
-
// skip absent
|
|
28
|
-
} else {
|
|
29
|
-
for (const op of descriptor.operators) {
|
|
30
|
-
if (op === 'eq') {
|
|
31
|
-
const scalar = (value as Record<string, unknown>)[op];
|
|
32
|
-
if (scalar !== undefined) {
|
|
33
|
-
let printable: unknown = scalar;
|
|
34
|
-
if (
|
|
35
|
-
typeof printable !== 'string' &&
|
|
36
|
-
typeof printable !== 'number'
|
|
37
|
-
) {
|
|
38
|
-
printable = JSON.stringify(printable);
|
|
39
|
-
}
|
|
40
|
-
parts.push(
|
|
41
|
-
`${encodeKey(field, op)}=${encodeURIComponent(String(printable))}`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
} else if (op === 'between') {
|
|
45
|
-
const tuple = (value as Record<string, unknown>).between as
|
|
46
|
-
| unknown[]
|
|
47
|
-
| undefined;
|
|
48
|
-
if (Array.isArray(tuple) && tuple.length === 2) {
|
|
49
|
-
const first = encodeURIComponent(String(tuple[0]));
|
|
50
|
-
const second = encodeURIComponent(String(tuple[1]));
|
|
51
|
-
parts.push(`${encodeKey(field, op)}=${first},${second}`);
|
|
52
|
-
}
|
|
53
|
-
} else if (op === 'in' || op === 'nin') {
|
|
54
|
-
const list = (value as Record<string, unknown>)[op] as
|
|
55
|
-
| unknown[]
|
|
56
|
-
| undefined;
|
|
57
|
-
if (Array.isArray(list) && list.length > 0) {
|
|
58
|
-
const encodedItems = list
|
|
59
|
-
.map((v) => {
|
|
60
|
-
return encodeURIComponent(String(v));
|
|
61
|
-
})
|
|
62
|
-
.join(',');
|
|
63
|
-
parts.push(`${encodeKey(field, op)}=${encodedItems}`);
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
const scalar = (value as Record<string, unknown>)[op];
|
|
67
|
-
if (scalar !== undefined) {
|
|
68
|
-
let printable: unknown;
|
|
69
|
-
if (typeof scalar === 'string' || typeof scalar === 'number') {
|
|
70
|
-
printable = scalar;
|
|
71
|
-
} else {
|
|
72
|
-
printable = JSON.stringify(scalar);
|
|
73
|
-
}
|
|
74
|
-
parts.push(
|
|
75
|
-
`${encodeKey(field, op)}=${encodeURIComponent(String(printable))}`,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return parts.join('&');
|
|
83
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import type { Diagnostic } from './errors.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Operators that take a single scalar value (numbers or strings) in the filter schema.
|
|
5
|
-
*/
|
|
6
|
-
export type ScalarOperator =
|
|
7
|
-
| 'gt'
|
|
8
|
-
| 'gte'
|
|
9
|
-
| 'lt'
|
|
10
|
-
| 'lte'
|
|
11
|
-
| 'eq'
|
|
12
|
-
| 'neq'
|
|
13
|
-
| 'contains'
|
|
14
|
-
| 'sw'
|
|
15
|
-
| 'ew';
|
|
16
|
-
/** Operator representing a range of two values. */
|
|
17
|
-
export type BetweenOperator = 'between';
|
|
18
|
-
/** Operators whose values are lists. */
|
|
19
|
-
export type ListOperator = 'in' | 'nin';
|
|
20
|
-
/** All supported operator kinds. */
|
|
21
|
-
export type Operator = ScalarOperator | BetweenOperator | ListOperator;
|
|
22
|
-
|
|
23
|
-
/** Primitive field kinds supported by the schema. */
|
|
24
|
-
export type PrimitiveKind = 'number' | 'string';
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Base descriptor shared by number and string fields.
|
|
28
|
-
*/
|
|
29
|
-
export interface FieldDescriptorBase<TKind extends PrimitiveKind> {
|
|
30
|
-
readonly kind: TKind;
|
|
31
|
-
readonly operators: readonly Operator[];
|
|
32
|
-
readonly parse?: (raw: string) => unknown;
|
|
33
|
-
readonly serialize?: (value: unknown) => string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Descriptor for numeric fields. Provides numeric parsing/serialisation hooks. */
|
|
37
|
-
export type NumberFieldDescriptor = FieldDescriptorBase<'number'> & {
|
|
38
|
-
readonly parse?: (raw: string) => number | undefined;
|
|
39
|
-
readonly serialize?: (value: number) => string;
|
|
40
|
-
};
|
|
41
|
-
/** Descriptor for string fields. Provides string parsing/serialisation hooks. */
|
|
42
|
-
export type StringFieldDescriptor = FieldDescriptorBase<'string'> & {
|
|
43
|
-
readonly parse?: (raw: string) => string | undefined;
|
|
44
|
-
readonly serialize?: (value: string) => string;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** Descriptor accepted by the schema (number or string). */
|
|
48
|
-
export type FieldDescriptor = NumberFieldDescriptor | StringFieldDescriptor;
|
|
49
|
-
|
|
50
|
-
/** Immutable mapping between field keys and their descriptors. */
|
|
51
|
-
export type Schema = Readonly<Record<string, FieldDescriptor>>;
|
|
52
|
-
|
|
53
|
-
// Infer operator mapping for a single field descriptor
|
|
54
|
-
type OperatorKeys<D extends FieldDescriptor> = Extract<
|
|
55
|
-
D['operators'][number],
|
|
56
|
-
ScalarOperator
|
|
57
|
-
>;
|
|
58
|
-
type ScalarMap<D extends FieldDescriptor> =
|
|
59
|
-
D extends FieldDescriptorBase<'number'>
|
|
60
|
-
? Record<OperatorKeys<D>, number | undefined>
|
|
61
|
-
: Record<OperatorKeys<D>, string | undefined>;
|
|
62
|
-
|
|
63
|
-
type BetweenMap<D extends FieldDescriptor> =
|
|
64
|
-
'between' extends D['operators'][number]
|
|
65
|
-
? {
|
|
66
|
-
between?: D extends FieldDescriptorBase<'number'>
|
|
67
|
-
? readonly [number, number]
|
|
68
|
-
: readonly [string, string];
|
|
69
|
-
}
|
|
70
|
-
: Record<never, never>;
|
|
71
|
-
|
|
72
|
-
type ListOps<D extends FieldDescriptor> = Extract<
|
|
73
|
-
D['operators'][number],
|
|
74
|
-
ListOperator
|
|
75
|
-
>;
|
|
76
|
-
type ListMap<D extends FieldDescriptor> = ('in' extends ListOps<D>
|
|
77
|
-
? {
|
|
78
|
-
in?: D extends FieldDescriptorBase<'number'>
|
|
79
|
-
? readonly number[]
|
|
80
|
-
: readonly string[];
|
|
81
|
-
}
|
|
82
|
-
: Record<never, never>) &
|
|
83
|
-
('nin' extends ListOps<D>
|
|
84
|
-
? {
|
|
85
|
-
nin?: D extends FieldDescriptorBase<'number'>
|
|
86
|
-
? readonly number[]
|
|
87
|
-
: readonly string[];
|
|
88
|
-
}
|
|
89
|
-
: Record<never, never>);
|
|
90
|
-
|
|
91
|
-
export type InferField<D extends FieldDescriptor> = Partial<
|
|
92
|
-
ScalarMap<D> & BetweenMap<D> & ListMap<D>
|
|
93
|
-
>;
|
|
94
|
-
|
|
95
|
-
/** Filters map inferred from a schema definition. */
|
|
96
|
-
export type InferFilters<S extends Schema> = {
|
|
97
|
-
[K in keyof S]?: InferField<S[K]>;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
/** Result returned by the `parse` function: parsed filters plus diagnostics. */
|
|
101
|
-
export interface ParseResult<S extends Schema> {
|
|
102
|
-
readonly filters: InferFilters<S>;
|
|
103
|
-
readonly diagnostics: Diagnostic[];
|
|
104
|
-
}
|
package/tools/build-package.sh
DELETED
package/tsconfig.build.json
DELETED