Skip to content

Commit a94fea6

Browse files
committed
feat: handle default and missing values in queries
1 parent d9c63f1 commit a94fea6

File tree

6 files changed

+142
-34
lines changed

6 files changed

+142
-34
lines changed

packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,30 @@ describe('MatcherPatternQueryParam', () => {
182182
)
183183
expect(matcher.match({ p: '5' })).toEqual({ page: '5' })
184184
})
185+
186+
it('lets the parser handle null values', () => {
187+
expect(
188+
new MatcherPatternQueryParam(
189+
'active',
190+
'a',
191+
'value',
192+
// transforms null to true
193+
PARAM_PARSER_BOOL,
194+
false
195+
).match({ a: null })
196+
).toEqual({ active: true })
197+
198+
expect(
199+
new MatcherPatternQueryParam(
200+
'active',
201+
'a',
202+
'value',
203+
// this leavs the value as null
204+
PARAM_PARSER_DEFAULTS,
205+
'ko'
206+
).match({ a: null })
207+
).toEqual({ active: null })
208+
})
185209
})
186210

187211
describe('parser integration', () => {
@@ -301,15 +325,57 @@ describe('MatcherPatternQueryParam', () => {
301325
expect(matcher.match({ item: [] })).toEqual({ items: [] })
302326
})
303327

304-
it('filters out null values in arrays', () => {
328+
it('integer parser filters out null values in arrays', () => {
329+
const matcher = new MatcherPatternQueryParam(
330+
'ids',
331+
'id',
332+
'array',
333+
PARAM_PARSER_INT
334+
)
335+
// Integer parser filters out null values from arrays
336+
expect(matcher.match({ id: ['1', null, '3'] })).toEqual({
337+
ids: [1, 3],
338+
})
339+
})
340+
341+
it('integer parser with default also filters null values', () => {
305342
const matcher = new MatcherPatternQueryParam(
306343
'ids',
307344
'id',
308345
'array',
309346
PARAM_PARSER_INT,
310347
() => []
311348
)
312-
expect(matcher.match({ id: ['1', null, '3'] })).toEqual({ ids: [1, 3] })
349+
// Integer parser filters null values even with default
350+
expect(matcher.match({ id: ['1', null, '3'] })).toEqual({
351+
ids: [1, 3],
352+
})
353+
})
354+
355+
it('passes null values to boolean parser in arrays', () => {
356+
const matcher = new MatcherPatternQueryParam(
357+
'flags',
358+
'flag',
359+
'array',
360+
PARAM_PARSER_BOOL
361+
)
362+
// Now that null filtering is removed, null values get passed to parser
363+
expect(matcher.match({ flag: ['true', null, 'false'] })).toEqual({
364+
flags: [true, true, false],
365+
})
366+
})
367+
368+
it('handles null values with default parser in arrays', () => {
369+
const matcher = new MatcherPatternQueryParam(
370+
'values',
371+
'value',
372+
'array',
373+
PARAM_PARSER_DEFAULTS
374+
)
375+
// Now that null filtering is removed, null values get passed to parser
376+
expect(matcher.match({ value: ['a', null, 'b'] })).toEqual({
377+
values: ['a', null, 'b'],
378+
})
313379
})
314380

315381
it('handles undefined query param with default', () => {

packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
MatcherParamsFormatted,
55
MatcherPattern,
66
MatcherQueryParams,
7+
MatcherQueryParamsValue,
78
} from './matcher-pattern'
89
import { ParamParser, PARAM_PARSER_DEFAULTS } from './param-parsers'
910
import { miss } from './errors'
@@ -29,48 +30,41 @@ export class MatcherPatternQueryParam<T, ParamName extends string>
2930
) {}
3031

3132
match(query: MatcherQueryParams): Record<ParamName, T> {
32-
const queryValue = query[this.queryKey]
33+
const queryValue: MatcherQueryParamsValue | undefined = query[this.queryKey]
34+
35+
// Check if query param is missing for default value handling
3336

3437
let valueBeforeParse =
3538
this.format === 'value'
3639
? Array.isArray(queryValue)
3740
? queryValue[0]
3841
: queryValue
39-
: this.format === 'array'
40-
? Array.isArray(queryValue)
41-
? queryValue
42+
: // format === 'array'
43+
Array.isArray(queryValue)
44+
? queryValue
45+
: queryValue == null
46+
? []
4247
: [queryValue]
43-
: queryValue
4448

4549
let value: T | undefined
4650

47-
// if we have an array, we need to try catch each value
51+
// if we have an array, pass the whole array to the parser
4852
if (Array.isArray(valueBeforeParse)) {
49-
// @ts-expect-error: T is not connected to valueBeforeParse
50-
value = []
51-
for (const v of valueBeforeParse) {
52-
if (v != null) {
53-
try {
54-
;(value as unknown[]).push(
55-
// for ts errors
56-
(this.parser.get ?? PARAM_PARSER_DEFAULTS.get)(v) as T
57-
)
58-
} catch (error) {
59-
// we skip the invalid value unless there is no defaultValue
60-
if (this.defaultValue === undefined) {
61-
throw error
62-
}
53+
// for arrays, if original query param was missing and we have a default, use it
54+
if (queryValue === undefined && this.defaultValue !== undefined) {
55+
value = toValue(this.defaultValue)
56+
} else {
57+
try {
58+
value = (this.parser.get ?? PARAM_PARSER_DEFAULTS.get)(
59+
valueBeforeParse
60+
) as T
61+
} catch (error) {
62+
if (this.defaultValue === undefined) {
63+
throw error
6364
}
65+
value = undefined
6466
}
6567
}
66-
67-
// if we have no values, we want to fall back to the default value
68-
if (
69-
this.defaultValue !== undefined &&
70-
(value as unknown[]).length === 0
71-
) {
72-
value = undefined
73-
}
7468
} else {
7569
try {
7670
value =

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,16 @@ describe('MatcherPatternPathDynamic', () => {
430430
set: (v: number | null) => (v == null ? null : String(v / 2)),
431431
})
432432

433+
const nullAwareParser = definePathParamParser({
434+
get: (v: string | null) => {
435+
if (v === null) return 'was-null'
436+
if (v === undefined) return 'was-undefined'
437+
return `processed-${v}`
438+
},
439+
set: (v: string | null) =>
440+
v === 'was-null' ? null : String(v).replace('processed-', ''),
441+
})
442+
433443
it('single regular param', () => {
434444
const pattern = new MatcherPatternPathDynamic(
435445
/^\/teams\/([^/]+?)$/i,
@@ -460,5 +470,22 @@ describe('MatcherPatternPathDynamic', () => {
460470
expect(pattern.build({ teamId: 0 })).toBe('/teams/0')
461471
expect(pattern.build({ teamId: null })).toBe('/teams')
462472
})
473+
474+
it('handles null values in optional params with custom parser', () => {
475+
const pattern = new MatcherPatternPathDynamic(
476+
/^\/teams(?:\/([^/]+?))?$/i,
477+
{
478+
teamId: [nullAwareParser, false, true],
479+
},
480+
['teams', 1]
481+
)
482+
483+
expect(pattern.match('/teams')).toEqual({ teamId: 'was-null' })
484+
expect(pattern.match('/teams/hello')).toEqual({
485+
teamId: 'processed-hello',
486+
})
487+
expect(pattern.build({ teamId: 'was-null' })).toBe('/teams')
488+
expect(pattern.build({ teamId: 'processed-world' })).toBe('/teams/world')
489+
})
463490
})
464491
})

packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ describe('PARAM_PARSER_BOOL', () => {
6666
expect(PARAM_PARSER_BOOL.get([null, null])).toEqual([true, true])
6767
})
6868

69+
it('handles mixed arrays with null values correctly', () => {
70+
expect(PARAM_PARSER_BOOL.get([null, 'true', null])).toEqual([
71+
true,
72+
true,
73+
true,
74+
])
75+
expect(PARAM_PARSER_BOOL.get(['false', null, 'TRUE'])).toEqual([
76+
false,
77+
true,
78+
true,
79+
])
80+
expect(PARAM_PARSER_BOOL.get([null, 'false', null, 'true'])).toEqual([
81+
true,
82+
false,
83+
true,
84+
true,
85+
])
86+
})
87+
6988
it('throws for arrays with invalid values', () => {
7089
expect(() => PARAM_PARSER_BOOL.get(['true', 'invalid'])).toThrow()
7190
expect(() => PARAM_PARSER_BOOL.get(['invalid'])).toThrow()

packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,10 @@ describe('PARAM_PARSER_INT', () => {
9292
expect(() => PARAM_PARSER_INT.get(['', '2'])).toThrow()
9393
})
9494

95-
it('throws for arrays with null values', () => {
96-
expect(() => PARAM_PARSER_INT.get(['1', null, '3'])).toThrow()
97-
expect(() => PARAM_PARSER_INT.get([null])).toThrow()
95+
it('filters out null values from arrays', () => {
96+
expect(PARAM_PARSER_INT.get(['1', null, '3'])).toEqual([1, 3])
97+
expect(PARAM_PARSER_INT.get([null])).toEqual([])
98+
expect(PARAM_PARSER_INT.get(['42', null, null, '7'])).toEqual([42, 7])
9899
})
99100
})
100101

packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const PARAM_INTEGER_SINGLE = {
1313
} satisfies ParamParser<number, string | null>
1414

1515
const PARAM_INTEGER_REPEATABLE = {
16-
get: (value: (string | null)[]) => value.map(PARAM_INTEGER_SINGLE.get),
16+
get: (value: (string | null)[]) =>
17+
value.filter((v): v is string => v != null).map(PARAM_INTEGER_SINGLE.get),
1718
set: (value: number[]) => value.map(PARAM_INTEGER_SINGLE.set),
1819
} satisfies ParamParser<number[], (string | null)[]>
1920

0 commit comments

Comments
 (0)