@plumile/filter-query 0.1.17
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/LICENSE +21 -0
- package/README.md +202 -0
- package/lib/errors.d.ts +39 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +2 -0
- package/lib/esm/errors.d.ts +39 -0
- package/lib/esm/errors.d.ts.map +1 -0
- package/lib/esm/errors.js +2 -0
- package/lib/esm/index.d.ts +7 -0
- package/lib/esm/index.d.ts.map +1 -0
- package/lib/esm/index.js +5 -0
- package/lib/esm/mutate.d.ts +6 -0
- package/lib/esm/mutate.d.ts.map +1 -0
- package/lib/esm/mutate.js +88 -0
- package/lib/esm/parse.d.ts +3 -0
- package/lib/esm/parse.d.ts.map +1 -0
- package/lib/esm/parse.js +164 -0
- package/lib/esm/schema.d.ts +5 -0
- package/lib/esm/schema.d.ts.map +1 -0
- package/lib/esm/schema.js +55 -0
- package/lib/esm/stringify.d.ts +3 -0
- package/lib/esm/stringify.d.ts.map +1 -0
- package/lib/esm/stringify.js +50 -0
- package/lib/esm/types.d.ts +43 -0
- package/lib/esm/types.d.ts.map +1 -0
- package/lib/esm/types.js +2 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/mutate.d.ts +6 -0
- package/lib/mutate.d.ts.map +1 -0
- package/lib/mutate.js +88 -0
- package/lib/parse.d.ts +3 -0
- package/lib/parse.d.ts.map +1 -0
- package/lib/parse.js +164 -0
- package/lib/schema.d.ts +5 -0
- package/lib/schema.d.ts.map +1 -0
- package/lib/schema.js +55 -0
- package/lib/stringify.d.ts +3 -0
- package/lib/stringify.d.ts.map +1 -0
- package/lib/stringify.js +50 -0
- package/lib/tsconfig.esm.tsbuildinfo +1 -0
- package/lib/types/errors.d.ts +39 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mutate.d.ts +6 -0
- package/lib/types/mutate.d.ts.map +1 -0
- package/lib/types/parse.d.ts +3 -0
- package/lib/types/parse.d.ts.map +1 -0
- package/lib/types/schema.d.ts +5 -0
- package/lib/types/schema.d.ts.map +1 -0
- package/lib/types/stringify.d.ts +3 -0
- package/lib/types/stringify.d.ts.map +1 -0
- package/lib/types/types.d.ts +43 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types.d.ts +43 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/package.json +43 -0
- package/src/__tests__/additional-coverage.test.ts +82 -0
- package/src/__tests__/list-edge.test.ts +26 -0
- package/src/__tests__/mutate.test.ts +33 -0
- package/src/__tests__/parse-stringify.test.ts +104 -0
- package/src/__tests__/remove-filter.test.ts +46 -0
- package/src/__tests__/schema-edge.test.ts +46 -0
- package/src/__tests__/schema-infer.test-d.ts +24 -0
- package/src/__tests__/stability-and-diagnostics.test.ts +40 -0
- package/src/errors.ts +46 -0
- package/src/index.ts +6 -0
- package/src/mutate.ts +132 -0
- package/src/parse.ts +221 -0
- package/src/schema.ts +81 -0
- package/src/stringify.ts +60 -0
- package/src/types.ts +88 -0
- package/tools/build-package.sh +5 -0
- package/tools/test-build-package.sh +4 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.esm.json +7 -0
- package/tsconfig.json +8 -0
- package/tsconfig.types.json +9 -0
- package/vitest.config.ts +7 -0
package/src/mutate.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { InferFilters, Schema } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shallow object equality (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
|
+
* Set or unset a specific operator value for a field.
|
|
35
|
+
*/
|
|
36
|
+
export function setFilter<
|
|
37
|
+
S extends Schema,
|
|
38
|
+
F extends keyof S,
|
|
39
|
+
O extends keyof NonNullable<InferFilters<S>[F]>,
|
|
40
|
+
>(
|
|
41
|
+
filters: Readonly<InferFilters<S>>,
|
|
42
|
+
_schema: S, // reserved for future validation hooks
|
|
43
|
+
field: F,
|
|
44
|
+
operator: O,
|
|
45
|
+
value: NonNullable<InferFilters<S>[F]>[O] | undefined,
|
|
46
|
+
): InferFilters<S> {
|
|
47
|
+
const currentField = filters[field];
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
if (currentField === undefined) {
|
|
50
|
+
return filters as InferFilters<S>;
|
|
51
|
+
}
|
|
52
|
+
const { [operator]: _removed, ...rest } = currentField as Record<
|
|
53
|
+
string,
|
|
54
|
+
unknown
|
|
55
|
+
>;
|
|
56
|
+
if (Object.keys(rest).length === 0) {
|
|
57
|
+
const { [field]: _f, ...clone } = filters as Record<string, unknown>;
|
|
58
|
+
return clone as InferFilters<S>;
|
|
59
|
+
}
|
|
60
|
+
return { ...filters, [field]: rest } as InferFilters<S>;
|
|
61
|
+
}
|
|
62
|
+
const newField = {
|
|
63
|
+
...(currentField as Record<string, unknown> | undefined),
|
|
64
|
+
[operator]: value,
|
|
65
|
+
};
|
|
66
|
+
if (currentField !== undefined && shallowEqual(currentField, newField)) {
|
|
67
|
+
return filters as InferFilters<S>;
|
|
68
|
+
}
|
|
69
|
+
return { ...filters, [field]: newField } as InferFilters<S>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove either an entire field or a specific operator inside a field.
|
|
74
|
+
*/
|
|
75
|
+
export function removeFilter<S extends Schema, F extends keyof S>(
|
|
76
|
+
filters: Readonly<InferFilters<S>>,
|
|
77
|
+
field: F,
|
|
78
|
+
operator?: keyof NonNullable<InferFilters<S>[F]>,
|
|
79
|
+
): InferFilters<S> {
|
|
80
|
+
const currentField = filters[field];
|
|
81
|
+
if (currentField === undefined) {
|
|
82
|
+
return filters as InferFilters<S>;
|
|
83
|
+
}
|
|
84
|
+
if (operator === undefined) {
|
|
85
|
+
const { [field]: _removed, ...clone } = filters as Record<string, unknown>;
|
|
86
|
+
return clone as InferFilters<S>;
|
|
87
|
+
}
|
|
88
|
+
const { [operator]: _opRemoved, ...rest } = currentField as Record<
|
|
89
|
+
string,
|
|
90
|
+
unknown
|
|
91
|
+
>;
|
|
92
|
+
if (Object.keys(rest).length === 0) {
|
|
93
|
+
const { [field]: _f, ...clone } = filters as Record<string, unknown>;
|
|
94
|
+
return clone as InferFilters<S>;
|
|
95
|
+
}
|
|
96
|
+
return { ...filters, [field]: rest } as InferFilters<S>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Merge two filters objects preserving reference identity for unchanged fields.
|
|
101
|
+
*/
|
|
102
|
+
export function mergeFilters<S extends Schema>(
|
|
103
|
+
base: Readonly<InferFilters<S>>,
|
|
104
|
+
patch: Readonly<InferFilters<S>>,
|
|
105
|
+
): InferFilters<S> {
|
|
106
|
+
let changed = false;
|
|
107
|
+
const result: Partial<InferFilters<S>> = { ...base };
|
|
108
|
+
for (const key of Object.keys(patch) as (keyof S)[]) {
|
|
109
|
+
const existing = base[key];
|
|
110
|
+
const incoming = patch[key];
|
|
111
|
+
if (incoming === undefined) {
|
|
112
|
+
// skip undefined incoming
|
|
113
|
+
} else if (existing === undefined) {
|
|
114
|
+
result[key] = incoming as InferFilters<S>[typeof key];
|
|
115
|
+
changed = true;
|
|
116
|
+
} else if (!shallowEqual(existing, incoming)) {
|
|
117
|
+
result[key] = incoming as InferFilters<S>[typeof key];
|
|
118
|
+
changed = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (changed) {
|
|
122
|
+
return result as InferFilters<S>;
|
|
123
|
+
}
|
|
124
|
+
return base as InferFilters<S>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Determine if the filters object is empty (no fields). */
|
|
128
|
+
export function isEmpty<S extends Schema>(
|
|
129
|
+
filters: Readonly<InferFilters<S>>,
|
|
130
|
+
): boolean {
|
|
131
|
+
return Object.keys(filters).length === 0;
|
|
132
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
/** Parse a raw search string (leading ? optional) according to the provided schema. */
|
|
172
|
+
export function parse<S extends Schema>(
|
|
173
|
+
rawSearch: string,
|
|
174
|
+
schema: S,
|
|
175
|
+
): ParseResult<S> {
|
|
176
|
+
const acc: InternalAccumulator<S> = { filters: {}, diagnostics: [] };
|
|
177
|
+
let search = rawSearch;
|
|
178
|
+
if (search.startsWith('?')) {
|
|
179
|
+
search = search.slice(1);
|
|
180
|
+
}
|
|
181
|
+
if (search.trim() === '') {
|
|
182
|
+
return acc;
|
|
183
|
+
}
|
|
184
|
+
const segments = search.split('&');
|
|
185
|
+
for (const seg of segments) {
|
|
186
|
+
const eqIdx = seg.indexOf('=');
|
|
187
|
+
const validBasic = seg !== '' && eqIdx !== -1;
|
|
188
|
+
if (validBasic) {
|
|
189
|
+
const rawKey = seg.slice(0, eqIdx);
|
|
190
|
+
const rawVal = seg.slice(eqIdx + 1);
|
|
191
|
+
const lastDot = rawKey.lastIndexOf('.');
|
|
192
|
+
const keyShapeOk = !(lastDot <= 0 || lastDot === rawKey.length - 1);
|
|
193
|
+
if (keyShapeOk) {
|
|
194
|
+
const field = rawKey.slice(0, lastDot);
|
|
195
|
+
const operator = rawKey.slice(lastDot + 1) as Operator;
|
|
196
|
+
const descriptor = schema[field];
|
|
197
|
+
const descOk = descriptor?.operators.includes(operator) === true;
|
|
198
|
+
if (!descOk) {
|
|
199
|
+
if (descriptor === undefined) {
|
|
200
|
+
acc.diagnostics.push({ kind: 'UnknownField', field });
|
|
201
|
+
} else {
|
|
202
|
+
acc.diagnostics.push({ kind: 'UnknownOperator', field, operator });
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const decoded = decodeValue(rawVal, field, operator, acc.diagnostics);
|
|
206
|
+
if (decoded !== undefined) {
|
|
207
|
+
const isList = (LIST_OPS as readonly string[]).includes(operator);
|
|
208
|
+
if (operator === BETWEEN) {
|
|
209
|
+
handleBetween(descriptor, decoded, field, operator, acc);
|
|
210
|
+
} else if (isList) {
|
|
211
|
+
handleList(descriptor, decoded, field, operator, acc);
|
|
212
|
+
} else {
|
|
213
|
+
handleScalar(descriptor, decoded, field, operator, acc);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return acc;
|
|
221
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { InferFilters, Schema, Operator } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** Build encoded key portion field.op */
|
|
4
|
+
function encodeKey(field: string, op: Operator): string {
|
|
5
|
+
return `${field}.${op}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Serialize filters object into a query string. */
|
|
9
|
+
export function stringify<S extends Schema>(
|
|
10
|
+
filters: InferFilters<S>,
|
|
11
|
+
schema: S,
|
|
12
|
+
): string {
|
|
13
|
+
const parts: string[] = [];
|
|
14
|
+
for (const field of Object.keys(schema)) {
|
|
15
|
+
const value = filters[field as keyof S];
|
|
16
|
+
const descriptor = schema[field];
|
|
17
|
+
if (value === undefined || descriptor === undefined) {
|
|
18
|
+
// skip absent
|
|
19
|
+
} else {
|
|
20
|
+
for (const op of descriptor.operators) {
|
|
21
|
+
if (op === 'between') {
|
|
22
|
+
const tuple = (value as Record<string, unknown>).between as
|
|
23
|
+
| unknown[]
|
|
24
|
+
| undefined;
|
|
25
|
+
if (Array.isArray(tuple) && tuple.length === 2) {
|
|
26
|
+
const first = encodeURIComponent(String(tuple[0]));
|
|
27
|
+
const second = encodeURIComponent(String(tuple[1]));
|
|
28
|
+
parts.push(`${encodeKey(field, op)}=${first},${second}`);
|
|
29
|
+
}
|
|
30
|
+
} else if (op === 'in' || op === 'nin') {
|
|
31
|
+
const list = (value as Record<string, unknown>)[op] as
|
|
32
|
+
| unknown[]
|
|
33
|
+
| undefined;
|
|
34
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
35
|
+
const encodedItems = list
|
|
36
|
+
.map((v) => {
|
|
37
|
+
return encodeURIComponent(String(v));
|
|
38
|
+
})
|
|
39
|
+
.join(',');
|
|
40
|
+
parts.push(`${encodeKey(field, op)}=${encodedItems}`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
const scalar = (value as Record<string, unknown>)[op];
|
|
44
|
+
if (scalar !== undefined) {
|
|
45
|
+
let printable: unknown;
|
|
46
|
+
if (typeof scalar === 'string' || typeof scalar === 'number') {
|
|
47
|
+
printable = scalar;
|
|
48
|
+
} else {
|
|
49
|
+
printable = JSON.stringify(scalar);
|
|
50
|
+
}
|
|
51
|
+
parts.push(
|
|
52
|
+
`${encodeKey(field, op)}=${encodeURIComponent(String(printable))}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return parts.join('&');
|
|
60
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Diagnostic } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export type ScalarOperator =
|
|
4
|
+
| 'gt'
|
|
5
|
+
| 'gte'
|
|
6
|
+
| 'lt'
|
|
7
|
+
| 'lte'
|
|
8
|
+
| 'eq'
|
|
9
|
+
| 'neq'
|
|
10
|
+
| 'contains'
|
|
11
|
+
| 'sw'
|
|
12
|
+
| 'ew';
|
|
13
|
+
export type BetweenOperator = 'between';
|
|
14
|
+
export type ListOperator = 'in' | 'nin';
|
|
15
|
+
export type Operator = ScalarOperator | BetweenOperator | ListOperator;
|
|
16
|
+
|
|
17
|
+
export type PrimitiveKind = 'number' | 'string';
|
|
18
|
+
|
|
19
|
+
export interface FieldDescriptorBase<TKind extends PrimitiveKind> {
|
|
20
|
+
readonly kind: TKind;
|
|
21
|
+
readonly operators: readonly Operator[];
|
|
22
|
+
readonly parse?: (raw: string) => unknown;
|
|
23
|
+
readonly serialize?: (value: unknown) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type NumberFieldDescriptor = FieldDescriptorBase<'number'> & {
|
|
27
|
+
readonly parse?: (raw: string) => number | undefined;
|
|
28
|
+
readonly serialize?: (value: number) => string;
|
|
29
|
+
};
|
|
30
|
+
export type StringFieldDescriptor = FieldDescriptorBase<'string'> & {
|
|
31
|
+
readonly parse?: (raw: string) => string | undefined;
|
|
32
|
+
readonly serialize?: (value: string) => string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type FieldDescriptor = NumberFieldDescriptor | StringFieldDescriptor;
|
|
36
|
+
|
|
37
|
+
export type Schema = Readonly<Record<string, FieldDescriptor>>;
|
|
38
|
+
|
|
39
|
+
// Infer operator mapping for a single field descriptor
|
|
40
|
+
type OperatorKeys<D extends FieldDescriptor> = Extract<
|
|
41
|
+
D['operators'][number],
|
|
42
|
+
ScalarOperator
|
|
43
|
+
>;
|
|
44
|
+
type ScalarMap<D extends FieldDescriptor> =
|
|
45
|
+
D extends FieldDescriptorBase<'number'>
|
|
46
|
+
? Record<OperatorKeys<D>, number | undefined>
|
|
47
|
+
: Record<OperatorKeys<D>, string | undefined>;
|
|
48
|
+
|
|
49
|
+
type BetweenMap<D extends FieldDescriptor> =
|
|
50
|
+
'between' extends D['operators'][number]
|
|
51
|
+
? {
|
|
52
|
+
between?: D extends FieldDescriptorBase<'number'>
|
|
53
|
+
? readonly [number, number]
|
|
54
|
+
: readonly [string, string];
|
|
55
|
+
}
|
|
56
|
+
: Record<never, never>;
|
|
57
|
+
|
|
58
|
+
type ListOps<D extends FieldDescriptor> = Extract<
|
|
59
|
+
D['operators'][number],
|
|
60
|
+
ListOperator
|
|
61
|
+
>;
|
|
62
|
+
type ListMap<D extends FieldDescriptor> = ('in' extends ListOps<D>
|
|
63
|
+
? {
|
|
64
|
+
in?: D extends FieldDescriptorBase<'number'>
|
|
65
|
+
? readonly number[]
|
|
66
|
+
: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
: Record<never, never>) &
|
|
69
|
+
('nin' extends ListOps<D>
|
|
70
|
+
? {
|
|
71
|
+
nin?: D extends FieldDescriptorBase<'number'>
|
|
72
|
+
? readonly number[]
|
|
73
|
+
: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
: Record<never, never>);
|
|
76
|
+
|
|
77
|
+
export type InferField<D extends FieldDescriptor> = Partial<
|
|
78
|
+
ScalarMap<D> & BetweenMap<D> & ListMap<D>
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
export type InferFilters<S extends Schema> = {
|
|
82
|
+
[K in keyof S]?: InferField<S[K]>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface ParseResult<S extends Schema> {
|
|
86
|
+
readonly filters: InferFilters<S>;
|
|
87
|
+
readonly diagnostics: Diagnostic[];
|
|
88
|
+
}
|
package/tsconfig.json
ADDED