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.
- package/dist/{hooks-Bg5XWcV7.mjs → hooks-DpFQfcFa.mjs} +122 -59
- package/dist/hooks-DpFQfcFa.mjs.map +1 -0
- package/dist/hooks.d.mts +110 -36
- package/dist/hooks.mjs +5 -5
- package/dist/{index-DKA0E_ad.d.mts → index-C6MN6wag.d.mts} +181 -64
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +7 -7
- package/dist/{internal.utils-BMzV_-xp.mjs → internal.utils-BBB-b6Ud.mjs} +16 -2
- package/dist/internal.utils-BBB-b6Ud.mjs.map +1 -0
- package/dist/{mutate-result.util-Dqzepn1M.mjs → mutate-result.util-C0nY6L7i.mjs} +2 -2
- package/dist/{mutate-result.util-Dqzepn1M.mjs.map → mutate-result.util-C0nY6L7i.mjs.map} +1 -1
- package/dist/{predicates-puYa4nkf.mjs → predicates-NOnUyMic.mjs} +53 -53
- package/dist/{predicates-puYa4nkf.mjs.map → predicates-NOnUyMic.mjs.map} +1 -1
- package/dist/predicates.d.mts +18 -18
- package/dist/predicates.mjs +1 -1
- package/dist/{resolve-B9hRleHY.mjs → resolve-BflgIVD8.mjs} +2 -2
- package/dist/{resolve-B9hRleHY.mjs.map → resolve-BflgIVD8.mjs.map} +1 -1
- package/dist/resolvers.mjs +1 -1
- package/dist/{transform-result.hook-B65pTRJO.mjs → transform-result.hook-V2QYN2K0.mjs} +2 -2
- package/dist/{transform-result.hook-B65pTRJO.mjs.map → transform-result.hook-V2QYN2K0.mjs.map} +1 -1
- package/dist/transformers.mjs +3 -3
- package/dist/{utils-Br6DNQ1B.mjs → utils-DByCpAsf.mjs} +407 -146
- package/dist/utils-DByCpAsf.mjs.map +1 -0
- package/dist/utils.d.mts +2 -2
- package/dist/utils.mjs +4 -4
- package/package.json +1 -1
- package/src/common/index.ts +1 -0
- package/src/common/is-empty-object.ts +38 -0
- package/src/hooks/index.ts +3 -1
- package/src/hooks/mute-event/mute-event.hook.ts +56 -0
- package/src/hooks/set-query-defaults/set-query-defaults.hook.ts +37 -0
- package/src/hooks/soft-delete/soft-delete.hook.ts +17 -3
- package/src/predicates/index.ts +3 -3
- package/src/utils/add-to-query/add-to-query.util.ts +19 -1
- package/src/utils/index.ts +5 -2
- package/src/utils/merge-query/dedupe-branches.ts +42 -0
- package/src/utils/merge-query/extract-query-filters.ts +80 -0
- package/src/utils/merge-query/has-conflict.ts +39 -0
- package/src/utils/merge-query/logical-branches.ts +39 -0
- package/src/utils/merge-query/merge-query-bodies.ts +136 -0
- package/src/utils/merge-query/merge-query.util.ts +102 -0
- package/src/utils/merge-query/merge-select.ts +64 -0
- package/src/utils/query-defaults/query-defaults.util.ts +39 -0
- package/src/utils/query-has-property/query-has-property.util.ts +37 -0
- package/src/utils/walk-query/walk-query.util.ts +43 -3
- package/dist/hooks-Bg5XWcV7.mjs.map +0 -1
- package/dist/internal.utils-BMzV_-xp.mjs.map +0 -1
- 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
|
|
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
|
}
|