From 29295845e240c49a737522ff6447b44093e95e29 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 20 Aug 2025 13:17:06 +0200 Subject: [PATCH] feat: add matcherpatternquery --- packages/router/src/experimental/index.ts | 1 + .../matchers/matcher-pattern-query.spec.ts | 403 ++++++++++++++++++ .../matchers/matcher-pattern-query.ts | 20 +- .../matchers/param-parsers/booleans.ts | 50 +++ .../matchers/param-parsers/index.ts | 2 + 5 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts create mode 100644 packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts diff --git a/packages/router/src/experimental/index.ts b/packages/router/src/experimental/index.ts index 23fb0981..3bec12df 100644 --- a/packages/router/src/experimental/index.ts +++ b/packages/router/src/experimental/index.ts @@ -37,6 +37,7 @@ export { export { PARAM_PARSER_INT, + PARAM_PARSER_BOOL, type ParamParser, defineParamParser, } from './route-resolver/matchers/param-parsers' diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts new file mode 100644 index 00000000..b5e31f18 --- /dev/null +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from 'vitest' +import { MatcherPatternQueryParam } from './matcher-pattern-query' +import { + PARAM_PARSER_INT, + PARAM_PARSER_BOOL, + PARAM_PARSER_DEFAULTS, +} from './param-parsers' +import { MatchMiss } from './errors' + +describe('MatcherPatternQueryParam', () => { + describe('match() - format: value', () => { + it('extracts single string value', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ user_id: 'abc123' })).toEqual({ userId: 'abc123' }) + }) + + it('takes first value from array', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ user_id: ['first', 'second'] })).toEqual({ + userId: 'first', + }) + }) + + it('handles null value', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ user_id: null })).toEqual({ userId: null }) + }) + + it('handles missing query param', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({})).toEqual({ + userId: undefined, + }) + }) + }) + + describe('match() - format: array', () => { + it('extracts array value', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ tag: ['vue', 'router'] })).toEqual({ + tags: ['vue', 'router'], + }) + }) + + it('converts single value to array', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ tag: 'vue' })).toEqual({ tags: ['vue'] }) + }) + + it('handles null in array format', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ tag: null })).toEqual({ tags: [] }) + }) + + it('handles missing query', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({})).toEqual({ tags: [] }) + }) + }) + + describe('match() - format: both', () => { + it('preserves single string value', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'value', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ value: 'single' })).toEqual({ data: 'single' }) + }) + + it('preserves array value', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'values', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ values: ['a', 'b'] })).toEqual({ + data: ['a', 'b'], + }) + }) + + it('preserves null', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'value', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ value: null })).toEqual({ data: null }) + }) + + it('handles missing query param', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'value', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({})).toEqual({ data: undefined }) + }) + }) + + describe('build()', () => { + describe('format: value', () => { + it('builds query from single value', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ userId: 'abc123' })).toEqual({ + user_id: 'abc123', + }) + }) + + it('builds query from null value', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ userId: null })).toEqual({ user_id: null }) + }) + + it('strips off und efined values', () => { + const matcher = new MatcherPatternQueryParam( + 'userId', + 'user_id', + 'value', + PARAM_PARSER_DEFAULTS + ) + // @ts-expect-error: not sure if this should be allowed + expect(matcher.build({})).toEqual({}) + // @ts-expect-error: not sure if this should be allowed + expect(matcher.build({ userId: undefined })).toEqual({}) + }) + }) + + describe('format: array', () => { + it('builds query from array value', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ tags: ['vue', 'router'] })).toEqual({ + tag: ['vue', 'router'], + }) + }) + + it('builds query from single value as array', () => { + const matcher = new MatcherPatternQueryParam( + 'tags', + 'tag', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ tags: ['vue'] })).toEqual({ tag: ['vue'] }) + }) + }) + + describe('format: both', () => { + it('builds query from single value', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'value', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ data: 'single' })).toEqual({ value: 'single' }) + }) + + it('builds query from array value', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'values', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ data: ['a', 'b'] })).toEqual({ + values: ['a', 'b'], + }) + }) + + it('builds query from null value', () => { + const matcher = new MatcherPatternQueryParam( + 'data', + 'value', + 'both', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.build({ data: null })).toEqual({ value: null }) + }) + }) + }) + + describe('default values', () => { + it('uses function default value when query param missing', () => { + const matcher = new MatcherPatternQueryParam( + 'page', + 'p', + 'value', + PARAM_PARSER_DEFAULTS, + () => '1' + ) + expect(matcher.match({})).toEqual({ page: '1' }) + }) + + it('uses static default value', () => { + const matcher = new MatcherPatternQueryParam( + 'limit', + 'l', + 'value', + PARAM_PARSER_DEFAULTS, + '10' + ) + expect(matcher.match({})).toEqual({ limit: '10' }) + }) + + it('prefers actual value over default', () => { + const matcher = new MatcherPatternQueryParam( + 'page', + 'p', + 'value', + PARAM_PARSER_DEFAULTS, + '1' + ) + expect(matcher.match({ p: '5' })).toEqual({ page: '5' }) + }) + }) + + describe('parser integration', () => { + it('can use custom PARAM_PARSER_INT for numbers', () => { + const matcher = new MatcherPatternQueryParam( + 'count', + 'c', + 'value', + PARAM_PARSER_INT + ) + expect(matcher.match({ c: '42' })).toEqual({ count: 42 }) + expect(matcher.build({ count: 42 })).toEqual({ c: '42' }) + }) + + it('throws on error without default', () => { + const matcher = new MatcherPatternQueryParam( + 'count', + 'c', + 'value', + PARAM_PARSER_INT + ) + expect(() => matcher.match({ c: 'invalid' })).toThrow(MatchMiss) + }) + + it('falls back to default on parser error', () => { + const matcher = new MatcherPatternQueryParam( + 'count', + 'c', + 'value', + PARAM_PARSER_INT, + 0 + ) + expect(matcher.match({ c: 'invalid' })).toEqual({ count: 0 }) + }) + + it('can use PARAM_PARSER_BOOL for booleans', () => { + const matcher = new MatcherPatternQueryParam( + 'enabled', + 'e', + 'value', + PARAM_PARSER_BOOL + ) + + expect(matcher.match({ e: 'true' })).toEqual({ enabled: true }) + expect(matcher.match({ e: 'false' })).toEqual({ enabled: false }) + expect(matcher.build({ enabled: false })).toEqual({ e: 'false' }) + expect(matcher.build({ enabled: true })).toEqual({ e: 'true' }) + }) + }) + + describe('missing query parameters', () => { + it('returns undefined when query param missing with parser and no default', () => { + const matcher = new MatcherPatternQueryParam( + 'count', + 'c', + 'value', + PARAM_PARSER_INT + ) + expect(matcher.match({ other: 'value' })).toEqual({ count: undefined }) + }) + + it('uses default when query param missing', () => { + const matcher = new MatcherPatternQueryParam( + 'optional', + 'opt', + 'value', + PARAM_PARSER_DEFAULTS, + 'fallback' + ) + expect(matcher.match({})).toEqual({ optional: 'fallback' }) + }) + + it('uses function default when query param missing', () => { + const matcher = new MatcherPatternQueryParam( + 'timestamp', + 'ts', + 'value', + PARAM_PARSER_INT, + () => 0 + ) + expect(matcher.match({})).toEqual({ timestamp: 0 }) + }) + + it('uses default when query param missing in array format', () => { + const matcher = new MatcherPatternQueryParam( + 'items', + 'item', + 'array', + PARAM_PARSER_DEFAULTS, + ['a'] + ) + expect(matcher.match({})).toEqual({ items: ['a'] }) + }) + }) + + describe('edge cases', () => { + it('handles empty array', () => { + const matcher = new MatcherPatternQueryParam( + 'items', + 'item', + 'array', + PARAM_PARSER_DEFAULTS + ) + expect(matcher.match({ item: [] })).toEqual({ items: [] }) + }) + + it('filters out null values in arrays', () => { + const matcher = new MatcherPatternQueryParam( + 'ids', + 'id', + 'array', + PARAM_PARSER_INT, + () => [] + ) + expect(matcher.match({ id: ['1', null, '3'] })).toEqual({ ids: [1, 3] }) + }) + + it('handles undefined query param with default', () => { + const matcher = new MatcherPatternQueryParam( + 'missing', + 'miss', + 'value', + PARAM_PARSER_DEFAULTS, + 'default' + ) + expect(matcher.match({ other: 'value' })).toEqual({ missing: 'default' }) + }) + }) +}) diff --git a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts index 9078b155..7d1b4cd1 100644 --- a/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts +++ b/packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts @@ -59,7 +59,7 @@ export class MatcherPatternQueryParam ) } catch (error) { // we skip the invalid value unless there is no defaultValue - if (this.defaultValue == null) { + if (this.defaultValue === undefined) { throw error } } @@ -67,22 +67,30 @@ export class MatcherPatternQueryParam } // if we have no values, we want to fall back to the default value - if ((value as unknown[]).length === 0) { + if ( + (this.format === 'both' || this.defaultValue !== undefined) && + (value as unknown[]).length === 0 + ) { value = undefined } } else { try { // FIXME: fallback to default getter - value = this.parser.get!(valueBeforeParse) + value = + // non existing query param should falll back to defaultValue + valueBeforeParse === undefined + ? valueBeforeParse + : this.parser.get!(valueBeforeParse) } catch (error) { - if (this.defaultValue == null) { + if (this.defaultValue === undefined) { throw error } } } return { - [this.paramName]: value ?? toValue(this.defaultValue), + [this.paramName]: + value === undefined ? toValue(this.defaultValue) : value, // This is a TS limitation } as Record } @@ -90,7 +98,7 @@ export class MatcherPatternQueryParam build(params: Record): MatcherQueryParams { const paramValue = params[this.paramName] - if (paramValue == null) { + if (paramValue === undefined) { return {} as EmptyParams } diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts new file mode 100644 index 00000000..f5cd5598 --- /dev/null +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts @@ -0,0 +1,50 @@ +import { miss } from '../errors' +import { ParamParser } from './types' + +export const PARAM_BOOLEAN_SINGLE = { + get: (value: string | null) => { + if (value === 'true') return true + if (value === 'false') return false + throw miss() + }, + set: (value: boolean) => String(value), +} satisfies ParamParser + +export const PARAM_BOOLEAN_OPTIONAL = { + get: (value: string | null) => + value == null ? null : PARAM_BOOLEAN_SINGLE.get(value), + set: (value: boolean | null) => + value != null ? PARAM_BOOLEAN_SINGLE.set(value) : null, +} satisfies ParamParser + +export const PARAM_BOOLEAN_REPEATABLE = { + get: (value: (string | null)[]) => value.map(PARAM_BOOLEAN_SINGLE.get), + set: (value: boolean[]) => value.map(PARAM_BOOLEAN_SINGLE.set), +} satisfies ParamParser + +export const PARAM_BOOLEAN_REPEATABLE_OPTIONAL = { + get: (value: string[] | null) => + value == null ? null : PARAM_BOOLEAN_REPEATABLE.get(value), + set: (value: boolean[] | null) => + value != null ? PARAM_BOOLEAN_REPEATABLE.set(value) : null, +} satisfies ParamParser + +/** + * Native Param parser for booleans. + * + * @internal + */ +export const PARAM_PARSER_BOOL: ParamParser = { + get: value => + Array.isArray(value) + ? PARAM_BOOLEAN_REPEATABLE.get(value) + : value != null + ? PARAM_BOOLEAN_SINGLE.get(value) + : null, + set: value => + Array.isArray(value) + ? PARAM_BOOLEAN_REPEATABLE.set(value) + : value != null + ? PARAM_BOOLEAN_SINGLE.set(value) + : null, +} diff --git a/packages/router/src/experimental/route-resolver/matchers/param-parsers/index.ts b/packages/router/src/experimental/route-resolver/matchers/param-parsers/index.ts index d7eadc3b..bf621b28 100644 --- a/packages/router/src/experimental/route-resolver/matchers/param-parsers/index.ts +++ b/packages/router/src/experimental/route-resolver/matchers/param-parsers/index.ts @@ -36,8 +36,10 @@ export const PATH_PARAM_PARSER_DEFAULTS = { : Array.isArray(value) ? value.map(String) : String(value), + // differently from PARAM_PARSER_DEFAULTS, this doesn't allow null values in arrays } satisfies ParamParser export type { ParamParser } export { PARAM_PARSER_INT } from './numbers' +export { PARAM_PARSER_BOOL } from './booleans' -- 2.47.3