feathers-utils 10.1.0 → 10.3.0

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.
Files changed (48) hide show
  1. package/dist/{hooks-Bg5XWcV7.mjs → hooks-DpFQfcFa.mjs} +122 -59
  2. package/dist/hooks-DpFQfcFa.mjs.map +1 -0
  3. package/dist/hooks.d.mts +110 -36
  4. package/dist/hooks.mjs +5 -5
  5. package/dist/{index-DKA0E_ad.d.mts → index-C6MN6wag.d.mts} +181 -64
  6. package/dist/index.d.mts +3 -3
  7. package/dist/index.mjs +7 -7
  8. package/dist/{internal.utils-BMzV_-xp.mjs → internal.utils-BBB-b6Ud.mjs} +16 -2
  9. package/dist/internal.utils-BBB-b6Ud.mjs.map +1 -0
  10. package/dist/{mutate-result.util-Dqzepn1M.mjs → mutate-result.util-C0nY6L7i.mjs} +2 -2
  11. package/dist/{mutate-result.util-Dqzepn1M.mjs.map → mutate-result.util-C0nY6L7i.mjs.map} +1 -1
  12. package/dist/{predicates-puYa4nkf.mjs → predicates-NOnUyMic.mjs} +53 -53
  13. package/dist/{predicates-puYa4nkf.mjs.map → predicates-NOnUyMic.mjs.map} +1 -1
  14. package/dist/predicates.d.mts +18 -18
  15. package/dist/predicates.mjs +1 -1
  16. package/dist/{resolve-B9hRleHY.mjs → resolve-BflgIVD8.mjs} +2 -2
  17. package/dist/{resolve-B9hRleHY.mjs.map → resolve-BflgIVD8.mjs.map} +1 -1
  18. package/dist/resolvers.mjs +1 -1
  19. package/dist/{transform-result.hook-B65pTRJO.mjs → transform-result.hook-V2QYN2K0.mjs} +2 -2
  20. package/dist/{transform-result.hook-B65pTRJO.mjs.map → transform-result.hook-V2QYN2K0.mjs.map} +1 -1
  21. package/dist/transformers.mjs +3 -3
  22. package/dist/{utils-Br6DNQ1B.mjs → utils-DByCpAsf.mjs} +407 -146
  23. package/dist/utils-DByCpAsf.mjs.map +1 -0
  24. package/dist/utils.d.mts +2 -2
  25. package/dist/utils.mjs +4 -4
  26. package/package.json +1 -1
  27. package/src/common/index.ts +1 -0
  28. package/src/common/is-empty-object.ts +38 -0
  29. package/src/hooks/index.ts +3 -1
  30. package/src/hooks/mute-event/mute-event.hook.ts +56 -0
  31. package/src/hooks/set-query-defaults/set-query-defaults.hook.ts +37 -0
  32. package/src/hooks/soft-delete/soft-delete.hook.ts +17 -3
  33. package/src/predicates/index.ts +3 -3
  34. package/src/utils/add-to-query/add-to-query.util.ts +19 -1
  35. package/src/utils/index.ts +5 -2
  36. package/src/utils/merge-query/dedupe-branches.ts +42 -0
  37. package/src/utils/merge-query/extract-query-filters.ts +80 -0
  38. package/src/utils/merge-query/has-conflict.ts +39 -0
  39. package/src/utils/merge-query/logical-branches.ts +39 -0
  40. package/src/utils/merge-query/merge-query-bodies.ts +136 -0
  41. package/src/utils/merge-query/merge-query.util.ts +102 -0
  42. package/src/utils/merge-query/merge-select.ts +64 -0
  43. package/src/utils/query-defaults/query-defaults.util.ts +39 -0
  44. package/src/utils/query-has-property/query-has-property.util.ts +37 -0
  45. package/src/utils/walk-query/walk-query.util.ts +43 -3
  46. package/dist/hooks-Bg5XWcV7.mjs.map +0 -1
  47. package/dist/internal.utils-BMzV_-xp.mjs.map +0 -1
  48. package/dist/utils-Br6DNQ1B.mjs.map +0 -1
@@ -0,0 +1,39 @@
1
+ import { dequal as deepEqual } from 'dequal'
2
+
3
+ type QueryRecord = Record<string, any>
4
+
5
+ /**
6
+ * Two query bodies conflict when they share at least one key whose values are not
7
+ * deep-equal. Internal helper for {@link mergeQuery}.
8
+ */
9
+ export function hasConflict(target: QueryRecord, source: QueryRecord): boolean {
10
+ for (const key of Object.keys(target)) {
11
+ if (key in source && !deepEqual(target[key], source[key])) {
12
+ return true
13
+ }
14
+ }
15
+ return false
16
+ }
17
+
18
+ if (import.meta.vitest) {
19
+ const { describe, it, expect } = import.meta.vitest
20
+
21
+ describe('hasConflict', () => {
22
+ it('is false for disjoint keys', () => {
23
+ expect(hasConflict({ a: 1 }, { b: 2 })).toBe(false)
24
+ })
25
+
26
+ it('is false for shared equal values', () => {
27
+ expect(hasConflict({ a: 1 }, { a: 1, b: 2 })).toBe(false)
28
+ })
29
+
30
+ it('is true for shared differing values', () => {
31
+ expect(hasConflict({ a: 1 }, { a: 2 })).toBe(true)
32
+ })
33
+
34
+ it('compares values deeply', () => {
35
+ expect(hasConflict({ a: { x: 1 } }, { a: { x: 1 } })).toBe(false)
36
+ expect(hasConflict({ a: { x: 1 } }, { a: { x: 2 } })).toBe(true)
37
+ })
38
+ })
39
+ }
@@ -0,0 +1,39 @@
1
+ type QueryRecord = Record<string, any>
2
+
3
+ /**
4
+ * Returns the branches of a logical-only query (a query whose single key is `op`),
5
+ * or `null` when the query is not purely `{ [op]: [...] }`. Internal helper for
6
+ * {@link mergeQuery}.
7
+ */
8
+ export function logicalBranches(
9
+ query: QueryRecord,
10
+ op: '$or' | '$and',
11
+ ): QueryRecord[] | null {
12
+ const keys = Object.keys(query)
13
+ if (keys.length === 1 && keys[0] === op && Array.isArray(query[op])) {
14
+ return query[op] as QueryRecord[]
15
+ }
16
+ return null
17
+ }
18
+
19
+ if (import.meta.vitest) {
20
+ const { describe, it, expect } = import.meta.vitest
21
+
22
+ describe('logicalBranches', () => {
23
+ it('returns branches for a logical-only query', () => {
24
+ expect(logicalBranches({ $or: [{ id: 1 }] }, '$or')).toEqual([{ id: 1 }])
25
+ })
26
+
27
+ it('returns null when the operator is mixed with other keys', () => {
28
+ expect(logicalBranches({ $or: [{ id: 1 }], a: 1 }, '$or')).toBeNull()
29
+ })
30
+
31
+ it('returns null for the wrong operator', () => {
32
+ expect(logicalBranches({ $and: [{ id: 1 }] }, '$or')).toBeNull()
33
+ })
34
+
35
+ it('returns null when the operator value is not an array', () => {
36
+ expect(logicalBranches({ $or: { id: 1 } }, '$or')).toBeNull()
37
+ })
38
+ })
39
+ }
@@ -0,0 +1,136 @@
1
+ import type { MergeQueryMode } from './merge-query.util.js'
2
+ import { isEmptyObject } from '../../common/is-empty-object.js'
3
+ import { logicalBranches } from './logical-branches.js'
4
+ import { dedupeBranches } from './dedupe-branches.js'
5
+ import { hasConflict } from './has-conflict.js'
6
+
7
+ type QueryRecord = Record<string, any>
8
+
9
+ /**
10
+ * Merges two query bodies (filters already removed) according to the mode.
11
+ * Internal helper for {@link mergeQuery}.
12
+ *
13
+ * - `target` / `source`: precedence merge (that side wins on conflict).
14
+ * - `combine`: the two bodies always become branches of a single `$or`.
15
+ * - `intersect`: non-conflicting bodies merge flat; on conflict they become
16
+ * branches of a single `$and`.
17
+ *
18
+ * Logical-only bodies (`{ $or: [...] }` for combine, `{ $and: [...] }` for
19
+ * intersect) are flattened into the result and their branches de-duplicated.
20
+ */
21
+ export function mergeQueryBodies(
22
+ target: QueryRecord,
23
+ source: QueryRecord,
24
+ mode: MergeQueryMode,
25
+ ): QueryRecord {
26
+ if (mode === 'target') {
27
+ return { ...source, ...target }
28
+ }
29
+ if (mode === 'source') {
30
+ return { ...target, ...source }
31
+ }
32
+
33
+ if (isEmptyObject(target)) {
34
+ return { ...source }
35
+ }
36
+ if (isEmptyObject(source)) {
37
+ return { ...target }
38
+ }
39
+
40
+ const op = mode === 'combine' ? '$or' : '$and'
41
+
42
+ const targetBranches = logicalBranches(target, op)
43
+ const sourceBranches = logicalBranches(source, op)
44
+
45
+ // For intersect (AND) the top level is itself an implicit AND, so two
46
+ // conflict-free bodies can be merged flat. For combine (OR) there is no flat
47
+ // representation — combine always produces an `$or`.
48
+ if (
49
+ op === '$and' &&
50
+ !targetBranches &&
51
+ !sourceBranches &&
52
+ !hasConflict(target, source)
53
+ ) {
54
+ return { ...target, ...source }
55
+ }
56
+
57
+ const branches = dedupeBranches([
58
+ ...(targetBranches ?? [target]),
59
+ ...(sourceBranches ?? [source]),
60
+ ])
61
+
62
+ if (branches.length === 0) {
63
+ return {}
64
+ }
65
+ if (branches.length === 1) {
66
+ return { ...branches[0] }
67
+ }
68
+ return { [op]: branches }
69
+ }
70
+
71
+ if (import.meta.vitest) {
72
+ const { describe, it, expect } = import.meta.vitest
73
+
74
+ describe('mergeQueryBodies', () => {
75
+ it('target / source precedence', () => {
76
+ expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'target')).toEqual({
77
+ id: 1,
78
+ a: 3,
79
+ })
80
+ expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'source')).toEqual({
81
+ id: 2,
82
+ a: 3,
83
+ })
84
+ })
85
+
86
+ it('returns the other side when one is empty', () => {
87
+ expect(mergeQueryBodies({}, { id: 1 }, 'combine')).toEqual({ id: 1 })
88
+ expect(mergeQueryBodies({ id: 1 }, {}, 'intersect')).toEqual({ id: 1 })
89
+ })
90
+
91
+ it('combine always produces an $or, even for disjoint keys', () => {
92
+ expect(mergeQueryBodies({ a: 1 }, { b: 2 }, 'combine')).toEqual({
93
+ $or: [{ a: 1 }, { b: 2 }],
94
+ })
95
+ })
96
+
97
+ it('combine flattens and dedupes $or branches', () => {
98
+ expect(
99
+ mergeQueryBodies(
100
+ { $or: [{ id: 1 }, { id: 2 }] },
101
+ { $or: [{ id: 2 }, { id: 3 }] },
102
+ 'combine',
103
+ ),
104
+ ).toEqual({ $or: [{ id: 1 }, { id: 2 }, { id: 3 }] })
105
+ })
106
+
107
+ it('combine collapses to a single body', () => {
108
+ expect(mergeQueryBodies({ id: 1 }, { id: 1 }, 'combine')).toEqual({
109
+ id: 1,
110
+ })
111
+ })
112
+
113
+ it('intersect merges disjoint keys flat', () => {
114
+ expect(mergeQueryBodies({ id: 1 }, { userId: 2 }, 'intersect')).toEqual({
115
+ id: 1,
116
+ userId: 2,
117
+ })
118
+ })
119
+
120
+ it('intersect wraps conflicts in $and', () => {
121
+ expect(mergeQueryBodies({ id: 1 }, { id: 2 }, 'intersect')).toEqual({
122
+ $and: [{ id: 1 }, { id: 2 }],
123
+ })
124
+ })
125
+
126
+ it('intersect flattens $and branches', () => {
127
+ expect(
128
+ mergeQueryBodies(
129
+ { $and: [{ id: 1 }, { id: 2 }] },
130
+ { $and: [{ id: 3 }] },
131
+ 'intersect',
132
+ ),
133
+ ).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] })
134
+ })
135
+ })
136
+ }
@@ -0,0 +1,102 @@
1
+ import type { Query } from '@feathersjs/feathers'
2
+ import { extractQueryFilters } from './extract-query-filters.js'
3
+ import { mergeQueryBodies } from './merge-query-bodies.js'
4
+ import { mergeSelect } from './merge-select.js'
5
+
6
+ export type MergeQueryMode = 'target' | 'source' | 'combine' | 'intersect'
7
+
8
+ export interface MergeQueryOptions {
9
+ /**
10
+ * How to merge query properties that both queries constrain.
11
+ *
12
+ * - `combine` (default): broaden — the two queries always become branches of an `$or`.
13
+ * - `intersect`: narrow — non-conflicting properties merge flat, conflicts become an `$and`.
14
+ * - `target`: keep the target's value on conflict.
15
+ * - `source`: keep the source's value on conflict.
16
+ */
17
+ mode?: MergeQueryMode
18
+ }
19
+
20
+ /**
21
+ * Properties are combined with a logical operator rather than merged at the value
22
+ * level, so the result is always a valid query: `combine` always wraps the two
23
+ * queries in `$or` (broaden — OR has no flat form), while `intersect` merges
24
+ * non-conflicting properties flat and wraps conflicts in `$and` (narrow). The
25
+ * special filters `$select`, `$limit`, `$skip` and `$sort` are merged separately.
26
+ * Inputs are never mutated.
27
+ *
28
+ * This is well suited to merging a client-provided query with a server-side
29
+ * restriction inside a hook.
30
+ *
31
+ * @param target Query to be merged into
32
+ * @param source Query to be merged from
33
+ * @param options
34
+ * @returns the merged query
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { mergeQuery } from 'feathers-utils/utils'
39
+ *
40
+ * // combine (default): the two queries always become an $or
41
+ * mergeQuery({ id: 1 }, { id: 2 })
42
+ * // => { $or: [{ id: 1 }, { id: 2 }] }
43
+ *
44
+ * mergeQuery({ status: 'active' }, { authorId: 5 })
45
+ * // => { $or: [{ status: 'active' }, { authorId: 5 }] }
46
+ * ```
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * // intersect: non-conflicting properties merge flat, conflicts become an $and
51
+ * mergeQuery({ status: 'active' }, { authorId: 5 }, { mode: 'intersect' })
52
+ * // => { status: 'active', authorId: 5 }
53
+ *
54
+ * mergeQuery({ id: 1 }, { id: 2 }, { mode: 'intersect' })
55
+ * // => { $and: [{ id: 1 }, { id: 2 }] }
56
+ * ```
57
+ *
58
+ * @see https://utils.feathersjs.com/utils/merge-query.html
59
+ */
60
+ export function mergeQuery(
61
+ target: Query,
62
+ source: Query,
63
+ options?: MergeQueryOptions,
64
+ ): Query {
65
+ const mode = options?.mode ?? 'combine'
66
+
67
+ const targetFilters = extractQueryFilters(target)
68
+ const sourceFilters = extractQueryFilters(source)
69
+
70
+ const result: Query = mergeQueryBodies(
71
+ targetFilters.query,
72
+ sourceFilters.query,
73
+ mode,
74
+ )
75
+
76
+ const $select = mergeSelect(
77
+ targetFilters.$select,
78
+ sourceFilters.$select,
79
+ mode,
80
+ )
81
+ if ($select !== undefined) {
82
+ result.$select = $select
83
+ }
84
+
85
+ if ('$limit' in sourceFilters) {
86
+ result.$limit = sourceFilters.$limit
87
+ } else if ('$limit' in targetFilters) {
88
+ result.$limit = targetFilters.$limit
89
+ }
90
+
91
+ if ('$skip' in sourceFilters) {
92
+ result.$skip = sourceFilters.$skip
93
+ } else if ('$skip' in targetFilters) {
94
+ result.$skip = targetFilters.$skip
95
+ }
96
+
97
+ if ('$sort' in targetFilters || '$sort' in sourceFilters) {
98
+ result.$sort = { ...targetFilters.$sort, ...sourceFilters.$sort }
99
+ }
100
+
101
+ return result
102
+ }
@@ -0,0 +1,64 @@
1
+ import type { MergeQueryMode } from './merge-query.util.js'
2
+
3
+ /**
4
+ * Merges two `$select` filters according to the mode: `combine` → union,
5
+ * `intersect` → intersection, `target`/`source` → that side. When only one side
6
+ * provides a `$select`, that one is used. Internal helper for {@link mergeQuery}.
7
+ */
8
+ export function mergeSelect(
9
+ target: any,
10
+ source: any,
11
+ mode: MergeQueryMode,
12
+ ): any {
13
+ if (target === undefined) {
14
+ return source
15
+ }
16
+ if (source === undefined) {
17
+ return target
18
+ }
19
+ if (mode === 'target') {
20
+ return target
21
+ }
22
+ if (mode === 'source') {
23
+ return source
24
+ }
25
+ const targetArr = Array.isArray(target) ? target : [target]
26
+ const sourceArr = Array.isArray(source) ? source : [source]
27
+ if (mode === 'combine') {
28
+ return [...new Set([...targetArr, ...sourceArr])]
29
+ }
30
+ // intersect
31
+ return targetArr.filter((value) => sourceArr.includes(value))
32
+ }
33
+
34
+ if (import.meta.vitest) {
35
+ const { describe, it, expect } = import.meta.vitest
36
+
37
+ describe('mergeSelect', () => {
38
+ it('returns the defined side when one is missing', () => {
39
+ expect(mergeSelect(undefined, ['a'], 'combine')).toEqual(['a'])
40
+ expect(mergeSelect(['a'], undefined, 'combine')).toEqual(['a'])
41
+ })
42
+
43
+ it('unions on combine', () => {
44
+ expect(mergeSelect(['a', 'b'], ['b', 'c'], 'combine')).toEqual([
45
+ 'a',
46
+ 'b',
47
+ 'c',
48
+ ])
49
+ })
50
+
51
+ it('intersects on intersect', () => {
52
+ expect(mergeSelect(['a', 'b'], ['b', 'c'], 'intersect')).toEqual(['b'])
53
+ })
54
+
55
+ it('can produce an empty intersection', () => {
56
+ expect(mergeSelect(['a'], ['b'], 'intersect')).toEqual([])
57
+ })
58
+
59
+ it('picks the requested side on target / source', () => {
60
+ expect(mergeSelect(['a'], ['b'], 'target')).toEqual(['a'])
61
+ expect(mergeSelect(['a'], ['b'], 'source')).toEqual(['b'])
62
+ })
63
+ })
64
+ }
@@ -0,0 +1,39 @@
1
+ import type { Query } from '@feathersjs/feathers'
2
+ import { addToQuery } from '../add-to-query/add-to-query.util.js'
3
+ import { queryHasProperty } from '../query-has-property/query-has-property.util.js'
4
+
5
+ /**
6
+ * Adds default properties to a Feathers query — but only for fields the query does
7
+ * not already constrain. Presence is checked with {@link queryHasProperty}, so a field
8
+ * referenced anywhere (including nested in `$and`/`$or`/`$nor`) is left untouched and
9
+ * the caller keeps control over it. The query is treated as the `data` equivalent of
10
+ * the `defaults` transformer. Each default is applied independently (per-field).
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { queryDefaults } from 'feathers-utils/utils'
15
+ *
16
+ * queryDefaults({ status: 'active' }, { isTemplate: false })
17
+ * // => { status: 'active', isTemplate: false }
18
+ *
19
+ * queryDefaults({ $or: [{ isTemplate: true }] }, { isTemplate: false })
20
+ * // => { $or: [{ isTemplate: true }] } (untouched — already referenced)
21
+ * ```
22
+ *
23
+ * @see https://utils.feathersjs.com/utils/query-defaults.html
24
+ */
25
+ export const queryDefaults = (
26
+ query: Query | undefined,
27
+ defaults: Query,
28
+ ): Query => {
29
+ const source: Query = query ?? {}
30
+
31
+ const toAdd: Query = {}
32
+ for (const key in defaults) {
33
+ if (!queryHasProperty(source, key)) {
34
+ toAdd[key] = defaults[key]
35
+ }
36
+ }
37
+
38
+ return addToQuery(source, toAdd)
39
+ }
@@ -0,0 +1,37 @@
1
+ import type { Query } from '@feathersjs/feathers'
2
+ import { toArray, type MaybeArray } from '../../internal.utils.js'
3
+ import { walkQuery } from '../walk-query/walk-query.util.js'
4
+
5
+ /**
6
+ * Checks whether a Feathers query contains one or more properties — including
7
+ * properties nested inside `$and`/`$or`/`$nor` arrays. Returns `true` as soon as
8
+ * any of the given property names is found. The query is not mutated.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { queryHasProperty } from 'feathers-utils/utils'
13
+ *
14
+ * queryHasProperty({ isTemplate: true }, 'isTemplate') // true
15
+ * queryHasProperty({ $or: [{ isTemplate: true }] }, 'isTemplate') // true
16
+ * queryHasProperty({ age: { $gt: 18 } }, ['isTemplate', 'status']) // false
17
+ * ```
18
+ *
19
+ * @see https://utils.feathersjs.com/utils/query-has-property.html
20
+ */
21
+ export const queryHasProperty = (
22
+ query: Query,
23
+ property: MaybeArray<string>,
24
+ ): boolean => {
25
+ const properties = new Set(toArray(property))
26
+
27
+ let found = false
28
+ walkQuery(query, ({ property: key, stop }) => {
29
+ if (properties.has(key)) {
30
+ found = true
31
+ stop()
32
+ }
33
+ // returning undefined leaves the value untouched → no mutation
34
+ })
35
+
36
+ return found
37
+ }
@@ -12,15 +12,27 @@ export type WalkQueryOptions = {
12
12
  operator: string | undefined
13
13
  value: any
14
14
  path: (string | number)[]
15
+ /**
16
+ * Stops the traversal. Any replacement value returned from the current walker
17
+ * call is still applied, but no further properties are visited.
18
+ */
19
+ stop: () => void
15
20
  }
16
21
 
17
22
  export type WalkQueryCallback = (options: WalkQueryOptions) => any
18
23
 
24
+ type WalkQueryState = { stopped: boolean }
25
+
19
26
  const _walkQueryUtil = <Q extends Query>(
20
27
  query: Q,
21
28
  walker: WalkQueryCallback,
29
+ state: WalkQueryState,
22
30
  options?: WalkQueryOptionsInit | WalkQueryOptions,
23
31
  ): Q => {
32
+ const stop = () => {
33
+ state.stopped = true
34
+ }
35
+
24
36
  let cloned = false
25
37
  const clonedSecond: Record<string, boolean> = {}
26
38
  function set(key: string, value: any, secondKey?: string | number) {
@@ -42,6 +54,10 @@ const _walkQueryUtil = <Q extends Query>(
42
54
  }
43
55
 
44
56
  for (const key in query) {
57
+ if (state.stopped) {
58
+ break
59
+ }
60
+
45
61
  if (
46
62
  (key === '$or' || key === '$and' || key === '$nor') &&
47
63
  Array.isArray(query[key])
@@ -51,8 +67,12 @@ const _walkQueryUtil = <Q extends Query>(
51
67
  let copiedArray = false
52
68
 
53
69
  for (let i = 0, n = array.length; i < n; i++) {
70
+ if (state.stopped) {
71
+ break
72
+ }
73
+
54
74
  const nestedQuery = array[i]
55
- const transformed = _walkQueryUtil(nestedQuery, walker, {
75
+ const transformed = _walkQueryUtil(nestedQuery, walker, state, {
56
76
  ...options,
57
77
  path: [...(options?.path || []), key, i],
58
78
  })
@@ -77,6 +97,10 @@ const _walkQueryUtil = <Q extends Query>(
77
97
  ) {
78
98
  let hasOperator = false
79
99
  for (const operator in query[key]) {
100
+ if (state.stopped) {
101
+ break
102
+ }
103
+
80
104
  if (operator.startsWith('$')) {
81
105
  hasOperator = true
82
106
  const value = walker({
@@ -84,6 +108,7 @@ const _walkQueryUtil = <Q extends Query>(
84
108
  path: [...(options?.path ?? []), key],
85
109
  property: key,
86
110
  value: query[key][operator],
111
+ stop,
87
112
  })
88
113
 
89
114
  if (value !== undefined && value !== query[key][operator]) {
@@ -98,6 +123,7 @@ const _walkQueryUtil = <Q extends Query>(
98
123
  path: [...(options?.path ?? []), key],
99
124
  property: key,
100
125
  value: query[key],
126
+ stop,
101
127
  })
102
128
 
103
129
  if (value !== undefined && value !== query[key]) {
@@ -110,6 +136,7 @@ const _walkQueryUtil = <Q extends Query>(
110
136
  path: [...(options?.path ?? []), key],
111
137
  property: key,
112
138
  value: query[key],
139
+ stop,
113
140
  })
114
141
 
115
142
  if (value !== undefined && value !== query[key]) {
@@ -124,7 +151,8 @@ const _walkQueryUtil = <Q extends Query>(
124
151
  /**
125
152
  * Walks every property of a Feathers query (including nested `$and`/`$or`/`$nor` arrays)
126
153
  * and calls the `walker` function for each one. The walker receives the property name, operator,
127
- * value, and path, and can return a replacement value. Returns a new query only if changes were made.
154
+ * value, path, and a `stop` function, and can return a replacement value. Calling `stop()` halts
155
+ * the traversal early. Returns a new query only if changes were made.
128
156
  *
129
157
  * @example
130
158
  * ```ts
@@ -136,11 +164,23 @@ const _walkQueryUtil = <Q extends Query>(
136
164
  * // => { age: { $gt: 18 } }
137
165
  * ```
138
166
  *
167
+ * @example
168
+ * ```ts
169
+ * // stop early once a property is found
170
+ * let found = false
171
+ * walkQuery(query, ({ property, stop }) => {
172
+ * if (property === 'isTemplate') {
173
+ * found = true
174
+ * stop()
175
+ * }
176
+ * })
177
+ * ```
178
+ *
139
179
  * @see https://utils.feathersjs.com/utils/walk-query.html
140
180
  */
141
181
  export const walkQuery = <Q extends Query>(
142
182
  query: Q,
143
183
  walker: WalkQueryCallback,
144
184
  ): Q => {
145
- return _walkQueryUtil(query, walker)
185
+ return _walkQueryUtil(query, walker, { stopped: false })
146
186
  }