]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: add matcherpatternquery
authorEduardo San Martin Morote <posva13@gmail.com>
Wed, 20 Aug 2025 11:17:06 +0000 (13:17 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 20 Aug 2025 11:17:06 +0000 (13:17 +0200)
packages/router/src/experimental/index.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts [new file with mode: 0644]
packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts [new file with mode: 0644]
packages/router/src/experimental/route-resolver/matchers/param-parsers/index.ts

index 23fb09811d316a75a8f40ef46925ad59573342ab..3bec12dfc8695aee8544fde72e45eaa791cba21f 100644 (file)
@@ -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 (file)
index 0000000..b5e31f1
--- /dev/null
@@ -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' })
+    })
+  })
+})
index 9078b15548cbc7db4a7597b6432095bad01a4fb3..7d1b4cd1128abc32b742aa845a9ac58b3625d594 100644 (file)
@@ -59,7 +59,7 @@ export class MatcherPatternQueryParam<T, ParamName extends string>
             )
           } 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<T, ParamName extends string>
       }
 
       // 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<ParamName, T>
   }
@@ -90,7 +98,7 @@ export class MatcherPatternQueryParam<T, ParamName extends string>
   build(params: Record<ParamName, T>): 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 (file)
index 0000000..f5cd559
--- /dev/null
@@ -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<boolean, string | null>
+
+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<boolean | null, string | null>
+
+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<boolean[], (string | null)[]>
+
+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<boolean[] | null, string[] | null>
+
+/**
+ * Native Param parser for booleans.
+ *
+ * @internal
+ */
+export const PARAM_PARSER_BOOL: ParamParser<boolean | boolean[] | null> = {
+  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,
+}
index d7eadc3b480b725d056f8d2095ec3f7f65c60789..bf621b28a5160aab5f2bd06519a6372c3089c4ca 100644 (file)
@@ -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<string | string[] | null, string | string[] | null>
 
 export type { ParamParser }
 
 export { PARAM_PARSER_INT } from './numbers'
+export { PARAM_PARSER_BOOL } from './booleans'