@plumile/filter-query 0.1.32 → 0.1.33
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
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
|
|
5
|
-
const schema = defineSchema({ price: numberField(['in']) });
|
|
6
|
-
|
|
7
|
-
describe('list operator edge cases', () => {
|
|
8
|
-
it('empty list value produces no filter entry and an InvalidValue diagnostic', () => {
|
|
9
|
-
const { filters, diagnostics } = parse('price.in=', schema);
|
|
10
|
-
expect(filters.price).toBeUndefined();
|
|
11
|
-
const invalids = diagnostics.filter((d) => {
|
|
12
|
-
return d.kind === 'InvalidValue';
|
|
13
|
-
});
|
|
14
|
-
expect(invalids.length).toBe(1);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('decode error in list value yields DecodeError diagnostic and no filter change', () => {
|
|
18
|
-
const { filters, diagnostics } = parse('price.in=1%ZZ', schema);
|
|
19
|
-
expect(filters).toEqual({});
|
|
20
|
-
expect(
|
|
21
|
-
diagnostics.some((d) => {
|
|
22
|
-
return d.kind === 'DecodeError' && d.operator === 'in';
|
|
23
|
-
}),
|
|
24
|
-
).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { setFilter, removeFilter, mergeFilters, isEmpty } from '../mutate.js';
|
|
4
|
-
import { parse } from '../parse.js';
|
|
5
|
-
|
|
6
|
-
const schema = defineSchema({
|
|
7
|
-
price: numberField(),
|
|
8
|
-
title: stringField(),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('mutate helpers', () => {
|
|
12
|
-
it('setFilter adds and removes operators while cleaning up empty fields', () => {
|
|
13
|
-
const base = parse('price.gt=10', schema).filters;
|
|
14
|
-
const withBetween = setFilter(base, schema, 'price', 'between', [1, 2]);
|
|
15
|
-
expect(withBetween).not.toBe(base);
|
|
16
|
-
expect(withBetween.price?.between).toEqual([1, 2]);
|
|
17
|
-
expect(isEmpty(withBetween)).toBe(false);
|
|
18
|
-
|
|
19
|
-
const removedBetween = setFilter(
|
|
20
|
-
withBetween,
|
|
21
|
-
schema,
|
|
22
|
-
'price',
|
|
23
|
-
'between',
|
|
24
|
-
undefined,
|
|
25
|
-
);
|
|
26
|
-
expect(removedBetween.price?.between).toBeUndefined();
|
|
27
|
-
expect(removedBetween.price?.gt).toBe(10);
|
|
28
|
-
|
|
29
|
-
const withoutField = setFilter(
|
|
30
|
-
removedBetween,
|
|
31
|
-
schema,
|
|
32
|
-
'price',
|
|
33
|
-
'gt',
|
|
34
|
-
undefined,
|
|
35
|
-
);
|
|
36
|
-
expect(withoutField.price).toBeUndefined();
|
|
37
|
-
expect(isEmpty(withoutField)).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('setFilter keeps reference when writing same scalar value', () => {
|
|
41
|
-
const base = parse('title.eq=hello', schema).filters;
|
|
42
|
-
const same = setFilter(base, schema, 'title', 'eq', 'hello');
|
|
43
|
-
expect(same).toBe(base);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('removeFilter drops only the targeted operator and preserves others', () => {
|
|
47
|
-
const base = parse('price.gt=10&price.lt=20', schema).filters;
|
|
48
|
-
const withoutGt = removeFilter(base, 'price', 'gt');
|
|
49
|
-
expect(withoutGt).not.toBe(base);
|
|
50
|
-
expect(withoutGt.price?.lt).toBe(20);
|
|
51
|
-
expect(withoutGt.price?.gt).toBeUndefined();
|
|
52
|
-
|
|
53
|
-
const withoutAny = removeFilter(withoutGt, 'price', 'lt');
|
|
54
|
-
expect(withoutAny.price).toBeUndefined();
|
|
55
|
-
expect(isEmpty(withoutAny)).toBe(true);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('removeFilter returns original reference when nothing changes', () => {
|
|
59
|
-
const base = parse('price.gt=10', schema).filters;
|
|
60
|
-
const untouched = removeFilter(base, 'title');
|
|
61
|
-
expect(untouched).toBe(base);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('mergeFilters merges incoming values and preserves reference when unchanged', () => {
|
|
65
|
-
const a = parse('price.gt=10&title.contains=foo', schema).filters;
|
|
66
|
-
const identical = mergeFilters(a, parse('price.gt=10', schema).filters);
|
|
67
|
-
expect(identical).toBe(a);
|
|
68
|
-
|
|
69
|
-
const patch = {
|
|
70
|
-
price: { lt: 30 },
|
|
71
|
-
title: { contains: 'bar' },
|
|
72
|
-
};
|
|
73
|
-
const merged = mergeFilters(a, patch);
|
|
74
|
-
expect(merged).not.toBe(a);
|
|
75
|
-
expect(merged.price).toEqual({ lt: 30 });
|
|
76
|
-
expect(merged.title?.contains).toBe('bar');
|
|
77
|
-
expect(a.price?.gt).toBe(10); // original untouched
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('isEmpty reflects whether any filters remain', () => {
|
|
81
|
-
expect(isEmpty({})).toBe(true);
|
|
82
|
-
const parsed = parse('title.eq=hello', schema).filters;
|
|
83
|
-
expect(isEmpty(parsed)).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
import { stringify } from '../stringify.js';
|
|
5
|
-
|
|
6
|
-
const schema = defineSchema({
|
|
7
|
-
price: numberField(),
|
|
8
|
-
title: stringField(),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('parse & stringify', () => {
|
|
12
|
-
it('parses scalar and list operators and round-trips', () => {
|
|
13
|
-
const q =
|
|
14
|
-
'price.gt=10&price.lte=100&price.in=10,20&title.contains=foo%20bar&title.in=a&title.in=b';
|
|
15
|
-
const { filters, diagnostics } = parse(q, schema);
|
|
16
|
-
expect(diagnostics.length).toBe(0);
|
|
17
|
-
expect(filters.price?.gt).toBe(10);
|
|
18
|
-
expect(filters.price?.lte).toBe(100);
|
|
19
|
-
expect(filters.price?.in).toEqual([10, 20]);
|
|
20
|
-
expect(filters.title?.contains).toBe('foo bar');
|
|
21
|
-
expect(filters.title?.in).toEqual(['a', 'b']);
|
|
22
|
-
const out = stringify(filters, schema);
|
|
23
|
-
// canonical order: price first (decl), then title
|
|
24
|
-
expect(out).toContain('price.gt=10');
|
|
25
|
-
expect(out).toContain('price.lte=100');
|
|
26
|
-
expect(out).toContain('price.in=10,20');
|
|
27
|
-
expect(out).toContain('title.contains=foo%20bar');
|
|
28
|
-
expect(out).toContain('title.in=a,b');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('handles between and duplicate between diagnostic', () => {
|
|
32
|
-
const q = 'price.between=1,5&price.between=2,6';
|
|
33
|
-
const { filters, diagnostics } = parse(q, schema);
|
|
34
|
-
expect(filters.price?.between).toEqual([1, 5]);
|
|
35
|
-
expect(
|
|
36
|
-
diagnostics.some((d) => {
|
|
37
|
-
return d.kind === 'DuplicateBetween';
|
|
38
|
-
}),
|
|
39
|
-
).toBe(true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('round-trips canonical ordering', () => {
|
|
43
|
-
const q = 'title.ew=bar&price.in=2,1&price.gt=3&title.sw=foo';
|
|
44
|
-
const { filters } = parse(q, schema);
|
|
45
|
-
const out = stringify(filters, schema);
|
|
46
|
-
// price declared before title so should start with price.
|
|
47
|
-
expect(out.startsWith('price.')).toBe(true);
|
|
48
|
-
// parsing the output again should yield equivalent filters
|
|
49
|
-
const again = parse(out, schema).filters;
|
|
50
|
-
expect(again).toEqual(filters);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('produces diagnostics for invalid number and invalid between arity', () => {
|
|
54
|
-
const q = 'price.gt=xx&price.between=1,2,3&title.contains=x%ZZ';
|
|
55
|
-
const { diagnostics } = parse(q, schema);
|
|
56
|
-
expect(
|
|
57
|
-
diagnostics.find((d) => {
|
|
58
|
-
return d.kind === 'InvalidValue';
|
|
59
|
-
}),
|
|
60
|
-
).toBeTruthy();
|
|
61
|
-
expect(
|
|
62
|
-
diagnostics.find((d) => {
|
|
63
|
-
return d.kind === 'InvalidArity';
|
|
64
|
-
}),
|
|
65
|
-
).toBeTruthy();
|
|
66
|
-
expect(
|
|
67
|
-
diagnostics.find((d) => {
|
|
68
|
-
return d.kind === 'DecodeError';
|
|
69
|
-
}),
|
|
70
|
-
).toBeTruthy();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('merges multiple nin occurrences preserving order', () => {
|
|
74
|
-
const q = 'price.nin=1,2&price.nin=3&price.nin=4,5';
|
|
75
|
-
const { filters } = parse(q, schema);
|
|
76
|
-
expect(filters.price?.nin).toEqual([1, 2, 3, 4, 5]);
|
|
77
|
-
const s = stringify(filters, schema);
|
|
78
|
-
expect(s).toContain('price.nin=1,2,3,4,5');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('supports implicit eq and explicit neq operators with last-write', () => {
|
|
82
|
-
const q = 'price=10&price=11&title=foo&title.neq=bar';
|
|
83
|
-
const { filters } = parse(q, schema);
|
|
84
|
-
expect(filters.price?.eq).toBe(11); // last write wins
|
|
85
|
-
expect(filters.title?.eq).toBe('foo');
|
|
86
|
-
expect(filters.title?.neq).toBe('bar');
|
|
87
|
-
const s = stringify(filters, schema);
|
|
88
|
-
expect(s).toContain('price=11');
|
|
89
|
-
expect(s).toMatch(/title=foo/);
|
|
90
|
-
expect(s).toMatch(/title\.neq=bar/);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('supports sw / ew / contains textual operators', () => {
|
|
94
|
-
const q = 'title.sw=Hello&title.ew=World&title.contains=lo%20Wo';
|
|
95
|
-
const { filters } = parse(q, schema);
|
|
96
|
-
expect(filters.title?.sw).toBe('Hello');
|
|
97
|
-
expect(filters.title?.ew).toBe('World');
|
|
98
|
-
expect(filters.title?.contains).toBe('lo Wo');
|
|
99
|
-
const s = stringify(filters, schema);
|
|
100
|
-
expect(s).toContain('title.sw=Hello');
|
|
101
|
-
expect(s).toContain('title.ew=World');
|
|
102
|
-
expect(s).toContain('title.contains=lo%20Wo');
|
|
103
|
-
});
|
|
104
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
|
|
5
|
-
const schema = defineSchema({
|
|
6
|
-
title: stringField(),
|
|
7
|
-
price: numberField(),
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
describe('parse helpers', () => {
|
|
11
|
-
it('parses implicit equality and explicit operators into the same field object', () => {
|
|
12
|
-
const result = parse('?title=foo&title.contains=bar', schema);
|
|
13
|
-
expect(result.filters.title).toEqual({
|
|
14
|
-
eq: 'foo',
|
|
15
|
-
contains: 'bar',
|
|
16
|
-
});
|
|
17
|
-
expect(result.diagnostics).toEqual([]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('handles between operator arity and numeric validation', () => {
|
|
21
|
-
const ok = parse('price.between=1,3', schema);
|
|
22
|
-
expect(ok.filters.price?.between).toEqual([1, 3]);
|
|
23
|
-
expect(ok.diagnostics).toEqual([]);
|
|
24
|
-
|
|
25
|
-
const wrongArity = parse('price.between=1', schema);
|
|
26
|
-
expect(wrongArity.filters.price).toBeUndefined();
|
|
27
|
-
expect(wrongArity.diagnostics).toContainEqual(
|
|
28
|
-
expect.objectContaining({
|
|
29
|
-
kind: 'InvalidArity',
|
|
30
|
-
field: 'price',
|
|
31
|
-
operator: 'between',
|
|
32
|
-
}),
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
const invalidValue = parse('price.between=1,not-a-number', schema);
|
|
36
|
-
expect(invalidValue.filters.price).toBeUndefined();
|
|
37
|
-
expect(invalidValue.diagnostics).toContainEqual(
|
|
38
|
-
expect.objectContaining({
|
|
39
|
-
kind: 'InvalidValue',
|
|
40
|
-
field: 'price',
|
|
41
|
-
operator: 'between',
|
|
42
|
-
}),
|
|
43
|
-
);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('records duplicate between declarations as diagnostics', () => {
|
|
47
|
-
const result = parse('price.between=1,3&price.between=2,4', schema);
|
|
48
|
-
expect(result.filters.price?.between).toEqual([1, 3]);
|
|
49
|
-
expect(result.diagnostics).toContainEqual(
|
|
50
|
-
expect.objectContaining({
|
|
51
|
-
kind: 'DuplicateBetween',
|
|
52
|
-
field: 'price',
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('aggregates list operators and appends across segments', () => {
|
|
58
|
-
const list = parse('title.in=foo,bar%20baz&title.in=qux', schema);
|
|
59
|
-
expect(list.filters.title?.in).toEqual(['foo', 'bar baz', 'qux']);
|
|
60
|
-
expect(list.diagnostics).toEqual([]);
|
|
61
|
-
|
|
62
|
-
const numericList = parse('price.in=1,NaN,3', schema);
|
|
63
|
-
expect(numericList.filters.price?.in).toEqual([1, 3]);
|
|
64
|
-
expect(numericList.diagnostics).toContainEqual(
|
|
65
|
-
expect.objectContaining({
|
|
66
|
-
kind: 'InvalidValue',
|
|
67
|
-
field: 'price',
|
|
68
|
-
operator: 'in',
|
|
69
|
-
}),
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('decodes values and reports decode failures', () => {
|
|
74
|
-
const decoded = parse('title.contains=hello%20world', schema);
|
|
75
|
-
expect(decoded.filters.title?.contains).toBe('hello world');
|
|
76
|
-
expect(decoded.diagnostics).toEqual([]);
|
|
77
|
-
|
|
78
|
-
const failure = parse('title.eq=%', schema);
|
|
79
|
-
expect(failure.filters.title).toBeUndefined();
|
|
80
|
-
expect(failure.diagnostics).toContainEqual(
|
|
81
|
-
expect.objectContaining({
|
|
82
|
-
kind: 'DecodeError',
|
|
83
|
-
field: 'title',
|
|
84
|
-
operator: 'eq',
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('validates scalar numeric operators and surfaces invalid values', () => {
|
|
90
|
-
const ok = parse('price.eq=42', schema);
|
|
91
|
-
expect(ok.filters.price?.eq).toBe(42);
|
|
92
|
-
|
|
93
|
-
const bad = parse('price.eq=', schema);
|
|
94
|
-
expect(bad.filters.price).toBeUndefined();
|
|
95
|
-
expect(bad.diagnostics).toContainEqual(
|
|
96
|
-
expect.objectContaining({
|
|
97
|
-
kind: 'InvalidValue',
|
|
98
|
-
field: 'price',
|
|
99
|
-
operator: 'eq',
|
|
100
|
-
}),
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('reports unknown fields and operators', () => {
|
|
105
|
-
const field = parse('unknown.eq=value', schema);
|
|
106
|
-
expect(field.filters.unknown as unknown).toBeUndefined();
|
|
107
|
-
expect(field.diagnostics).toContainEqual(
|
|
108
|
-
expect.objectContaining({
|
|
109
|
-
kind: 'UnknownField',
|
|
110
|
-
field: 'unknown',
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const operator = parse('title.between=foo,bar', schema);
|
|
115
|
-
expect(operator.filters.title).toBeUndefined();
|
|
116
|
-
expect(operator.diagnostics).toContainEqual(
|
|
117
|
-
expect.objectContaining({
|
|
118
|
-
kind: 'UnknownOperator',
|
|
119
|
-
field: 'title',
|
|
120
|
-
operator: 'between',
|
|
121
|
-
}),
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('returns empty result for blank input', () => {
|
|
126
|
-
expect(parse('', schema)).toEqual({ filters: {}, diagnostics: [] });
|
|
127
|
-
expect(parse(' ', schema)).toEqual({ filters: {}, diagnostics: [] });
|
|
128
|
-
expect(parse('?', schema)).toEqual({ filters: {}, diagnostics: [] });
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
import { setFilter, removeFilter, isEmpty, mergeFilters } from '../mutate.js';
|
|
5
|
-
|
|
6
|
-
const schema = defineSchema({
|
|
7
|
-
price: numberField(['gt', 'lte', 'in']),
|
|
8
|
-
title: stringField(['contains', 'in']),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('removeFilter & isEmpty', () => {
|
|
12
|
-
it('removeFilter(operator) removes only that operator keeping field if others remain', () => {
|
|
13
|
-
const base = parse('price.gt=10&price.lte=20', schema).filters;
|
|
14
|
-
const removed = removeFilter(base, 'price', 'gt');
|
|
15
|
-
expect(removed.price?.gt).toBeUndefined();
|
|
16
|
-
expect(removed.price?.lte).toBe(20);
|
|
17
|
-
// field object replaced (immutability) but root cloned
|
|
18
|
-
expect(removed).not.toBe(base);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('removeFilter(field) removes entire field object when no operator specified', () => {
|
|
22
|
-
const base = parse('price.gt=10&title.contains=foo', schema).filters;
|
|
23
|
-
const withoutPrice = removeFilter(base, 'price');
|
|
24
|
-
expect(withoutPrice.price).toBeUndefined();
|
|
25
|
-
expect(withoutPrice.title?.contains).toBe('foo');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('setFilter(undefined) removes operator and prunes empty field', () => {
|
|
29
|
-
let parsed = parse('price.gt=10', schema).filters;
|
|
30
|
-
parsed = setFilter(parsed, schema, 'price', 'gt', undefined);
|
|
31
|
-
expect(parsed.price).toBeUndefined();
|
|
32
|
-
expect(isEmpty(parsed)).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('mergeFilters applies changes and preserves identity when patch is noop after removals', () => {
|
|
36
|
-
const a = parse('price.gt=10&price.lte=20', schema).filters;
|
|
37
|
-
const b = removeFilter(a, 'price', 'lte');
|
|
38
|
-
// patching with remaining price field should keep reference
|
|
39
|
-
const merged = mergeFilters(b, { price: b.price } as any);
|
|
40
|
-
expect(merged).toBe(b);
|
|
41
|
-
// adding new info changes reference
|
|
42
|
-
const merged2 = mergeFilters(b, { title: { contains: 'x' } } as any);
|
|
43
|
-
expect(merged2).not.toBe(b);
|
|
44
|
-
expect(merged2.title?.contains).toBe('x');
|
|
45
|
-
});
|
|
46
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
|
|
5
|
-
describe('schema & parse edge cases', () => {
|
|
6
|
-
it('defineSchema returns a frozen object', () => {
|
|
7
|
-
const schema = defineSchema({ price: numberField(['gt']) });
|
|
8
|
-
expect(Object.isFrozen(schema)).toBe(true);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('restricted operator whitelist rejects others (UnknownOperator)', () => {
|
|
12
|
-
const schema = defineSchema({ price: numberField(['gt']) });
|
|
13
|
-
const { filters, diagnostics } = parse('price.gt=5&price.lte=10', schema);
|
|
14
|
-
expect(filters.price?.gt).toBe(5);
|
|
15
|
-
expect(
|
|
16
|
-
diagnostics.some((d) => {
|
|
17
|
-
return d.kind === 'UnknownOperator' && d.operator === 'lte';
|
|
18
|
-
}),
|
|
19
|
-
).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('invalid key shapes or unknown implicit fields produce UnknownField only for unknown names', () => {
|
|
23
|
-
const schema = defineSchema({ price: numberField(['gt']) });
|
|
24
|
-
const { filters, diagnostics } = parse('pricegt=5&price.=6&.gt=7', schema);
|
|
25
|
-
expect(filters).toEqual({});
|
|
26
|
-
// pricegt becomes implicit eq -> UnknownField; the malformed keys with trailing/leading dot are ignored
|
|
27
|
-
expect(
|
|
28
|
-
diagnostics.filter((d) => {
|
|
29
|
-
return d.kind === 'UnknownField';
|
|
30
|
-
}).length,
|
|
31
|
-
).toBe(1);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('list operator keeps only valid numeric items and reports diagnostics for invalid + empty ones', () => {
|
|
35
|
-
const schema = defineSchema({ price: numberField(['in']) });
|
|
36
|
-
const { filters, diagnostics } = parse('price.in=1,abc,2,', schema);
|
|
37
|
-
expect(filters.price?.in).toEqual([1, 2]);
|
|
38
|
-
expect(
|
|
39
|
-
diagnostics.filter((d) => {
|
|
40
|
-
return d.kind === 'InvalidValue' && d.operator === 'in';
|
|
41
|
-
}).length,
|
|
42
|
-
).toBeGreaterThanOrEqual(2); // 'abc' and '' both invalid
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('empty search yields empty filters & no diagnostics', () => {
|
|
46
|
-
const schema = defineSchema({ title: stringField(['contains']) });
|
|
47
|
-
const { filters, diagnostics } = parse('', schema);
|
|
48
|
-
expect(filters).toEqual({});
|
|
49
|
-
expect(diagnostics).toEqual([]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
2
|
-
import type { InferFilters } from '../types.js';
|
|
3
|
-
|
|
4
|
-
export const schema = defineSchema({
|
|
5
|
-
price: numberField(['gt', 'between']),
|
|
6
|
-
title: stringField(['contains', 'in']),
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
type F = InferFilters<typeof schema>;
|
|
10
|
-
|
|
11
|
-
// valid usages
|
|
12
|
-
const a: F = { price: { gt: 1 }, title: { contains: 'x' } };
|
|
13
|
-
const b: F = { price: { between: [1, 2] as const } }; // ok
|
|
14
|
-
|
|
15
|
-
const c: F = { price: { in: [1, 2] } };
|
|
16
|
-
// @ts-expect-error wrong tuple arity
|
|
17
|
-
const d: F = { price: { between: [1, 2, 3] } };
|
|
18
|
-
// @ts-expect-error wrong type
|
|
19
|
-
const e: F = { price: { gt: 'nope' } };
|
|
20
|
-
// @ts-expect-error missing array for list operator
|
|
21
|
-
const f: F = { title: { in: 'x' } };
|
|
22
|
-
|
|
23
|
-
// force usage to avoid unused variable errors
|
|
24
|
-
export const _examples = { a, b, c, d, e, f };
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { numberField, stringField, defineSchema } from '../schema.js';
|
|
3
|
-
|
|
4
|
-
describe('schema helpers', () => {
|
|
5
|
-
it('numberField parses finite numbers and rejects invalid input', () => {
|
|
6
|
-
const descriptor = numberField();
|
|
7
|
-
expect(descriptor.kind).toBe('number');
|
|
8
|
-
expect(descriptor.operators).toContain('between');
|
|
9
|
-
expect(descriptor.parse('42')).toBe(42);
|
|
10
|
-
expect(descriptor.parse('')).toBeUndefined();
|
|
11
|
-
expect(descriptor.parse('NaN')).toBeUndefined();
|
|
12
|
-
expect(descriptor.serialize(13)).toBe('13');
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('stringField returns raw strings and serializes values', () => {
|
|
16
|
-
const descriptor = stringField(['eq', 'contains']);
|
|
17
|
-
expect(descriptor.kind).toBe('string');
|
|
18
|
-
expect(descriptor.operators).toEqual(['eq', 'contains']);
|
|
19
|
-
expect(descriptor.parse('hello')).toBe('hello');
|
|
20
|
-
expect(descriptor.serialize(100)).toBe('100');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('defineSchema freezes the returned schema copy', () => {
|
|
24
|
-
const source = { title: stringField() };
|
|
25
|
-
const schema = defineSchema(source);
|
|
26
|
-
expect(schema).not.toBe(source);
|
|
27
|
-
expect(Object.isFrozen(schema)).toBe(true);
|
|
28
|
-
expect(schema.title).toBeDefined();
|
|
29
|
-
});
|
|
30
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
import { setFilter, mergeFilters } from '../mutate.js';
|
|
5
|
-
|
|
6
|
-
const schema = defineSchema({
|
|
7
|
-
price: numberField(),
|
|
8
|
-
title: stringField(),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('reference stability & combined diagnostics', () => {
|
|
12
|
-
it('setFilter preserves reference when setting identical value again', () => {
|
|
13
|
-
const empty = parse('', schema).filters;
|
|
14
|
-
const f1 = setFilter(empty, schema, 'price', 'gt', 10);
|
|
15
|
-
expect(f1).not.toBe(empty);
|
|
16
|
-
const f2 = setFilter(f1, schema, 'price', 'gt', 10);
|
|
17
|
-
expect(f2).toBe(f1); // identical value no change
|
|
18
|
-
const f3 = setFilter(f2, schema, 'price', 'lte', 20);
|
|
19
|
-
expect(f3).not.toBe(f2);
|
|
20
|
-
// mergeFilters with empty patch returns same reference
|
|
21
|
-
const m1 = mergeFilters(f3, {} as any); // patch brings nothing
|
|
22
|
-
expect(m1).toBe(f3);
|
|
23
|
-
// mergeFilters with identical field object also preserves root
|
|
24
|
-
const patchSame = { price: f3.price } as any;
|
|
25
|
-
const m2 = mergeFilters(f3, patchSame);
|
|
26
|
-
expect(m2).toBe(f3);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('reports UnknownField and UnknownOperator in same parse plus other diagnostics', () => {
|
|
30
|
-
const q = 'unknown.gt=5&price.xyz=10&price.gte=abc';
|
|
31
|
-
const { diagnostics } = parse(q, schema);
|
|
32
|
-
// collect kinds
|
|
33
|
-
const kinds = diagnostics.map((d) => {
|
|
34
|
-
return d.kind;
|
|
35
|
-
});
|
|
36
|
-
expect(kinds).toContain('UnknownField');
|
|
37
|
-
expect(kinds).toContain('UnknownOperator');
|
|
38
|
-
expect(kinds).toContain('InvalidValue'); // abc invalid number
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { defineSchema, numberField, stringField } from '../schema.js';
|
|
3
|
-
import { parse } from '../parse.js';
|
|
4
|
-
import { stringify } from '../stringify.js';
|
|
5
|
-
import type { InferFilters } from '../types.js';
|
|
6
|
-
|
|
7
|
-
const schema = defineSchema({
|
|
8
|
-
title: stringField(['eq', 'contains', 'in']),
|
|
9
|
-
price: numberField(['eq', 'between', 'in', 'nin']),
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
describe('stringify helpers', () => {
|
|
13
|
-
it('omits dot for implicit equality and uses dotted keys for other operators', () => {
|
|
14
|
-
const { filters } = parse('title=hello&title.contains=world', schema);
|
|
15
|
-
const output = stringify(filters, schema);
|
|
16
|
-
const segments = output.split('&');
|
|
17
|
-
expect(segments).toContain('title=hello');
|
|
18
|
-
expect(segments).toContain('title.contains=world');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('serializes operators in schema order including between and list variants', () => {
|
|
22
|
-
const { filters } = parse(
|
|
23
|
-
'title=hello&title.contains=world&price=1&price.between=1,4&price.in=2,3&price.nin=5,6',
|
|
24
|
-
schema,
|
|
25
|
-
);
|
|
26
|
-
const output = stringify(filters, schema);
|
|
27
|
-
expect(output.split('&')).toEqual([
|
|
28
|
-
'title=hello',
|
|
29
|
-
'title.contains=world',
|
|
30
|
-
'price=1',
|
|
31
|
-
'price.between=1,4',
|
|
32
|
-
'price.in=2,3',
|
|
33
|
-
'price.nin=5,6',
|
|
34
|
-
]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('stringifies non primitive scalars before encoding', () => {
|
|
38
|
-
const customSchema = defineSchema({
|
|
39
|
-
meta: stringField(['eq']),
|
|
40
|
-
});
|
|
41
|
-
type CustomFilters = InferFilters<typeof customSchema>;
|
|
42
|
-
const filters: CustomFilters = {
|
|
43
|
-
meta: {
|
|
44
|
-
eq: { nested: true } as unknown,
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
const output = stringify(filters, customSchema);
|
|
48
|
-
expect(output).toBe(
|
|
49
|
-
`meta=${encodeURIComponent(JSON.stringify({ nested: true }))}`,
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('skips operators with undefined or empty values', () => {
|
|
54
|
-
type Filters = InferFilters<typeof schema>;
|
|
55
|
-
const filters: Filters = {
|
|
56
|
-
title: {
|
|
57
|
-
contains: 'value',
|
|
58
|
-
},
|
|
59
|
-
price: {} as Filters['price'],
|
|
60
|
-
};
|
|
61
|
-
const output = stringify(filters, schema);
|
|
62
|
-
expect(output).toBe('title.contains=value');
|
|
63
|
-
});
|
|
64
|
-
});
|
package/src/errors.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Union of all diagnostic variants that can be produced while parsing a filter query string.
|
|
3
|
-
*/
|
|
4
|
-
export type Diagnostic =
|
|
5
|
-
| UnknownFieldDiagnostic
|
|
6
|
-
| UnknownOperatorDiagnostic
|
|
7
|
-
| InvalidValueDiagnostic
|
|
8
|
-
| InvalidArityDiagnostic
|
|
9
|
-
| DuplicateBetweenDiagnostic
|
|
10
|
-
| DecodeErrorDiagnostic;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Common diagnostic shape.
|
|
14
|
-
* Consumers should refine via the `kind` discriminant before reading additional fields.
|
|
15
|
-
*/
|
|
16
|
-
export interface BaseDiagnostic {
|
|
17
|
-
readonly kind: string;
|
|
18
|
-
readonly field?: string;
|
|
19
|
-
readonly operator?: string;
|
|
20
|
-
readonly detail?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Raised when the query references a field that is not present in the schema definition.
|
|
25
|
-
*/
|
|
26
|
-
export interface UnknownFieldDiagnostic extends BaseDiagnostic {
|
|
27
|
-
kind: 'UnknownField';
|
|
28
|
-
field: string;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Raised when a field is known but an operator that is not whitelisted on the schema
|
|
32
|
-
* is encountered.
|
|
33
|
-
*/
|
|
34
|
-
export interface UnknownOperatorDiagnostic extends BaseDiagnostic {
|
|
35
|
-
kind: 'UnknownOperator';
|
|
36
|
-
field: string;
|
|
37
|
-
operator: string;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Signals that a value could not be parsed according to the field descriptor
|
|
41
|
-
* (e.g. malformed number).
|
|
42
|
-
*/
|
|
43
|
-
export interface InvalidValueDiagnostic extends BaseDiagnostic {
|
|
44
|
-
kind: 'InvalidValue';
|
|
45
|
-
field: string;
|
|
46
|
-
operator: string;
|
|
47
|
-
detail?: string;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Emitted when an operator receives an unexpected number of values
|
|
51
|
-
* (e.g. `between` without exactly two items).
|
|
52
|
-
*/
|
|
53
|
-
export interface InvalidArityDiagnostic extends BaseDiagnostic {
|
|
54
|
-
kind: 'InvalidArity';
|
|
55
|
-
field: string;
|
|
56
|
-
operator: string;
|
|
57
|
-
detail: string; // expected arity info
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Indicates that multiple `between` declarations were encountered for the same field;
|
|
61
|
-
* only the first is kept.
|
|
62
|
-
*/
|
|
63
|
-
export interface DuplicateBetweenDiagnostic extends BaseDiagnostic {
|
|
64
|
-
kind: 'DuplicateBetween';
|
|
65
|
-
field: string;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Raised when a value cannot be decoded from the URL query string (invalid percent-encoding).
|
|
69
|
-
*/
|
|
70
|
-
export interface DecodeErrorDiagnostic extends BaseDiagnostic {
|
|
71
|
-
kind: 'DecodeError';
|
|
72
|
-
field: string;
|
|
73
|
-
operator: string;
|
|
74
|
-
detail: string;
|
|
75
|
-
}
|