feathers-utils 10.2.0 → 10.4.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-tfw03iVM.mjs → hooks-D_u2QFhM.mjs} +65 -65
- package/dist/{hooks-tfw03iVM.mjs.map → hooks-D_u2QFhM.mjs.map} +1 -1
- package/dist/hooks.d.mts +48 -48
- package/dist/hooks.mjs +4 -4
- package/dist/{index-CKAzIogj.d.mts → index-H1zXVhff.d.mts} +209 -105
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +7 -7
- package/dist/internal.utils-CWuBYzpQ.mjs +120 -0
- package/dist/internal.utils-CWuBYzpQ.mjs.map +1 -0
- package/dist/{mutate-result.util-Dqzepn1M.mjs → mutate-result.util-mxvMl6bw.mjs} +2 -2
- package/dist/mutate-result.util-mxvMl6bw.mjs.map +1 -0
- package/dist/{predicates-puYa4nkf.mjs → predicates-CR4O2nSr.mjs} +53 -53
- package/dist/{predicates-puYa4nkf.mjs.map → predicates-CR4O2nSr.mjs.map} +1 -1
- package/dist/predicates.d.mts +18 -18
- package/dist/predicates.mjs +1 -1
- package/dist/{resolve-B9hRleHY.mjs → resolve-BbbdWdlO.mjs} +2 -2
- package/dist/{resolve-B9hRleHY.mjs.map → resolve-BbbdWdlO.mjs.map} +1 -1
- package/dist/resolvers.mjs +1 -1
- package/dist/{transform-result.hook-B65pTRJO.mjs → transform-result.hook-0D676rcY.mjs} +2 -2
- package/dist/{transform-result.hook-B65pTRJO.mjs.map → transform-result.hook-0D676rcY.mjs.map} +1 -1
- package/dist/transformers.mjs +3 -3
- package/dist/{utils-BAIcSl7u.mjs → utils-sTvj8-Jy.mjs} +467 -209
- package/dist/utils-sTvj8-Jy.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/dedupe-branches.ts +42 -0
- package/src/common/flatten-and-branches.ts +56 -0
- package/src/common/flatten-or-branches.ts +52 -0
- package/src/common/index.ts +4 -0
- package/src/common/is-empty-object.ts +38 -0
- package/src/hooks/index.ts +1 -1
- 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 +6 -4
- 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 +152 -0
- package/src/utils/merge-query/merge-query.util.ts +105 -0
- package/src/utils/merge-query/merge-select.ts +64 -0
- package/src/utils/replace-data/replace-data.util.ts +4 -4
- package/src/utils/replace-result/replace-result.util.ts +18 -18
- package/src/utils/simplify-query/merge-and-branches-up.ts +86 -0
- package/src/utils/simplify-query/merge-or-branch-up.ts +74 -0
- package/src/utils/simplify-query/simplify-query.util.ts +98 -0
- package/dist/internal.utils-BMzV_-xp.mjs +0 -55
- package/dist/internal.utils-BMzV_-xp.mjs.map +0 -1
- package/dist/mutate-result.util-Dqzepn1M.mjs.map +0 -1
- package/dist/utils-BAIcSl7u.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,152 @@
|
|
|
1
|
+
import type { MergeQueryMode } from './merge-query.util.js'
|
|
2
|
+
import { isEmptyObject } from '../../common/is-empty-object.js'
|
|
3
|
+
import { dedupeBranches } from '../../common/dedupe-branches.js'
|
|
4
|
+
import { flattenAndBranches } from '../../common/flatten-and-branches.js'
|
|
5
|
+
import { logicalBranches } from './logical-branches.js'
|
|
6
|
+
import { hasConflict } from './has-conflict.js'
|
|
7
|
+
|
|
8
|
+
type QueryRecord = Record<string, any>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Merges two query bodies (filters already removed) according to the mode.
|
|
12
|
+
* Internal helper for {@link mergeQuery}.
|
|
13
|
+
*
|
|
14
|
+
* - `target` / `source`: precedence merge (that side wins on conflict).
|
|
15
|
+
* - `combine`: the two bodies always become branches of a single `$or`.
|
|
16
|
+
* - `intersect`: non-conflicting bodies merge flat; on conflict they become
|
|
17
|
+
* branches of a single `$and`.
|
|
18
|
+
*
|
|
19
|
+
* Logical-only bodies (`{ $or: [...] }` for combine, `{ $and: [...] }` for
|
|
20
|
+
* intersect) are flattened into the result and their branches de-duplicated.
|
|
21
|
+
*/
|
|
22
|
+
export function mergeQueryBodies(
|
|
23
|
+
target: QueryRecord,
|
|
24
|
+
source: QueryRecord,
|
|
25
|
+
mode: MergeQueryMode,
|
|
26
|
+
): QueryRecord {
|
|
27
|
+
if (mode === 'target') {
|
|
28
|
+
return { ...source, ...target }
|
|
29
|
+
}
|
|
30
|
+
if (mode === 'source') {
|
|
31
|
+
return { ...target, ...source }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isEmptyObject(target)) {
|
|
35
|
+
return { ...source }
|
|
36
|
+
}
|
|
37
|
+
if (isEmptyObject(source)) {
|
|
38
|
+
return { ...target }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const op = mode === 'combine' ? '$or' : '$and'
|
|
42
|
+
|
|
43
|
+
const targetBranches = logicalBranches(target, op)
|
|
44
|
+
const sourceBranches = logicalBranches(source, op)
|
|
45
|
+
|
|
46
|
+
// For intersect (AND) the top level is itself an implicit AND, so two
|
|
47
|
+
// conflict-free bodies can be merged flat. For combine (OR) there is no flat
|
|
48
|
+
// representation — combine always produces an `$or`.
|
|
49
|
+
if (
|
|
50
|
+
op === '$and' &&
|
|
51
|
+
!targetBranches &&
|
|
52
|
+
!sourceBranches &&
|
|
53
|
+
!hasConflict(target, source)
|
|
54
|
+
) {
|
|
55
|
+
return { ...target, ...source }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const collected = [
|
|
59
|
+
...(targetBranches ?? [target]),
|
|
60
|
+
...(sourceBranches ?? [source]),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// under `$and`, hoist any nested `$and` so the result never nests `$and` in `$and`
|
|
64
|
+
const branches = dedupeBranches(
|
|
65
|
+
op === '$and' ? flattenAndBranches(collected) : collected,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if (branches.length === 0) {
|
|
69
|
+
return {}
|
|
70
|
+
}
|
|
71
|
+
if (branches.length === 1) {
|
|
72
|
+
return { ...branches[0] }
|
|
73
|
+
}
|
|
74
|
+
return { [op]: branches }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (import.meta.vitest) {
|
|
78
|
+
const { describe, it, expect } = import.meta.vitest
|
|
79
|
+
|
|
80
|
+
describe('mergeQueryBodies', () => {
|
|
81
|
+
it('target / source precedence', () => {
|
|
82
|
+
expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'target')).toEqual({
|
|
83
|
+
id: 1,
|
|
84
|
+
a: 3,
|
|
85
|
+
})
|
|
86
|
+
expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'source')).toEqual({
|
|
87
|
+
id: 2,
|
|
88
|
+
a: 3,
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns the other side when one is empty', () => {
|
|
93
|
+
expect(mergeQueryBodies({}, { id: 1 }, 'combine')).toEqual({ id: 1 })
|
|
94
|
+
expect(mergeQueryBodies({ id: 1 }, {}, 'intersect')).toEqual({ id: 1 })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('combine always produces an $or, even for disjoint keys', () => {
|
|
98
|
+
expect(mergeQueryBodies({ a: 1 }, { b: 2 }, 'combine')).toEqual({
|
|
99
|
+
$or: [{ a: 1 }, { b: 2 }],
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('combine flattens and dedupes $or branches', () => {
|
|
104
|
+
expect(
|
|
105
|
+
mergeQueryBodies(
|
|
106
|
+
{ $or: [{ id: 1 }, { id: 2 }] },
|
|
107
|
+
{ $or: [{ id: 2 }, { id: 3 }] },
|
|
108
|
+
'combine',
|
|
109
|
+
),
|
|
110
|
+
).toEqual({ $or: [{ id: 1 }, { id: 2 }, { id: 3 }] })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('combine collapses to a single body', () => {
|
|
114
|
+
expect(mergeQueryBodies({ id: 1 }, { id: 1 }, 'combine')).toEqual({
|
|
115
|
+
id: 1,
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('intersect merges disjoint keys flat', () => {
|
|
120
|
+
expect(mergeQueryBodies({ id: 1 }, { userId: 2 }, 'intersect')).toEqual({
|
|
121
|
+
id: 1,
|
|
122
|
+
userId: 2,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('intersect wraps conflicts in $and', () => {
|
|
127
|
+
expect(mergeQueryBodies({ id: 1 }, { id: 2 }, 'intersect')).toEqual({
|
|
128
|
+
$and: [{ id: 1 }, { id: 2 }],
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('intersect flattens $and branches', () => {
|
|
133
|
+
expect(
|
|
134
|
+
mergeQueryBodies(
|
|
135
|
+
{ $and: [{ id: 1 }, { id: 2 }] },
|
|
136
|
+
{ $and: [{ id: 3 }] },
|
|
137
|
+
'intersect',
|
|
138
|
+
),
|
|
139
|
+
).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('intersect hoists a nested $and instead of nesting it', () => {
|
|
143
|
+
expect(
|
|
144
|
+
mergeQueryBodies(
|
|
145
|
+
{ $or: ['u'] },
|
|
146
|
+
{ $or: ['c'], $and: [{ $nor: ['n'] }] },
|
|
147
|
+
'intersect',
|
|
148
|
+
),
|
|
149
|
+
).toEqual({ $and: [{ $or: ['u'] }, { $or: ['c'] }, { $nor: ['n'] }] })
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Query } from '@feathersjs/feathers'
|
|
2
|
+
import { simplifyQuery } from '../simplify-query/simplify-query.util.js'
|
|
3
|
+
import { extractQueryFilters } from './extract-query-filters.js'
|
|
4
|
+
import { mergeQueryBodies } from './merge-query-bodies.js'
|
|
5
|
+
import { mergeSelect } from './merge-select.js'
|
|
6
|
+
|
|
7
|
+
export type MergeQueryMode = 'target' | 'source' | 'combine' | 'intersect'
|
|
8
|
+
|
|
9
|
+
export interface MergeQueryOptions {
|
|
10
|
+
/**
|
|
11
|
+
* How to merge query properties that both queries constrain.
|
|
12
|
+
*
|
|
13
|
+
* - `combine` (default): broaden — the two queries always become branches of an `$or`.
|
|
14
|
+
* - `intersect`: narrow — non-conflicting properties merge flat, conflicts become an `$and`.
|
|
15
|
+
* - `target`: keep the target's value on conflict.
|
|
16
|
+
* - `source`: keep the source's value on conflict.
|
|
17
|
+
*/
|
|
18
|
+
mode?: MergeQueryMode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Properties are combined with a logical operator rather than merged at the value
|
|
23
|
+
* level, so the result is always a valid query: `combine` always wraps the two
|
|
24
|
+
* queries in `$or` (broaden — OR has no flat form), while `intersect` merges
|
|
25
|
+
* non-conflicting properties flat and wraps conflicts in `$and` (narrow). The
|
|
26
|
+
* special filters `$select`, `$limit`, `$skip` and `$sort` are merged separately.
|
|
27
|
+
* Inputs are never mutated.
|
|
28
|
+
*
|
|
29
|
+
* This is well suited to merging a client-provided query with a server-side
|
|
30
|
+
* restriction inside a hook.
|
|
31
|
+
*
|
|
32
|
+
* @param target Query to be merged into
|
|
33
|
+
* @param source Query to be merged from
|
|
34
|
+
* @param options
|
|
35
|
+
* @returns the merged query
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { mergeQuery } from 'feathers-utils/utils'
|
|
40
|
+
*
|
|
41
|
+
* // combine (default): the two queries always become an $or
|
|
42
|
+
* mergeQuery({ id: 1 }, { id: 2 })
|
|
43
|
+
* // => { $or: [{ id: 1 }, { id: 2 }] }
|
|
44
|
+
*
|
|
45
|
+
* mergeQuery({ status: 'active' }, { authorId: 5 })
|
|
46
|
+
* // => { $or: [{ status: 'active' }, { authorId: 5 }] }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* // intersect: non-conflicting properties merge flat, conflicts become an $and
|
|
52
|
+
* mergeQuery({ status: 'active' }, { authorId: 5 }, { mode: 'intersect' })
|
|
53
|
+
* // => { status: 'active', authorId: 5 }
|
|
54
|
+
*
|
|
55
|
+
* mergeQuery({ id: 1 }, { id: 2 }, { mode: 'intersect' })
|
|
56
|
+
* // => { $and: [{ id: 1 }, { id: 2 }] }
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @see https://utils.feathersjs.com/utils/merge-query.html
|
|
60
|
+
*/
|
|
61
|
+
export function mergeQuery(
|
|
62
|
+
target: Query,
|
|
63
|
+
source: Query,
|
|
64
|
+
options?: MergeQueryOptions,
|
|
65
|
+
): Query {
|
|
66
|
+
const mode = options?.mode ?? 'combine'
|
|
67
|
+
|
|
68
|
+
// normalize inputs first: drop empty/duplicate/redundant logical wrappers and
|
|
69
|
+
// hoist nested operators, so the merge works on clean, canonical queries
|
|
70
|
+
const targetFilters = extractQueryFilters(simplifyQuery(target))
|
|
71
|
+
const sourceFilters = extractQueryFilters(simplifyQuery(source))
|
|
72
|
+
|
|
73
|
+
const result: Query = mergeQueryBodies(
|
|
74
|
+
targetFilters.query,
|
|
75
|
+
sourceFilters.query,
|
|
76
|
+
mode,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const $select = mergeSelect(
|
|
80
|
+
targetFilters.$select,
|
|
81
|
+
sourceFilters.$select,
|
|
82
|
+
mode,
|
|
83
|
+
)
|
|
84
|
+
if ($select !== undefined) {
|
|
85
|
+
result.$select = $select
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ('$limit' in sourceFilters) {
|
|
89
|
+
result.$limit = sourceFilters.$limit
|
|
90
|
+
} else if ('$limit' in targetFilters) {
|
|
91
|
+
result.$limit = targetFilters.$limit
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if ('$skip' in sourceFilters) {
|
|
95
|
+
result.$skip = sourceFilters.$skip
|
|
96
|
+
} else if ('$skip' in targetFilters) {
|
|
97
|
+
result.$skip = targetFilters.$skip
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ('$sort' in targetFilters || '$sort' in sourceFilters) {
|
|
101
|
+
result.$sort = { ...targetFilters.$sort, ...sourceFilters.$sort }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { HookContext } from
|
|
2
|
-
import type { DataSingleHookContext } from
|
|
1
|
+
import type { HookContext } from '@feathersjs/feathers'
|
|
2
|
+
import type { DataSingleHookContext } from '../../utility-types/hook-context.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Replaces `context.data` wholesale with the given items, preserving the original
|
|
@@ -21,6 +21,6 @@ export function replaceData<H extends HookContext = HookContext>(
|
|
|
21
21
|
context: H,
|
|
22
22
|
data: DataSingleHookContext<H>[],
|
|
23
23
|
): H {
|
|
24
|
-
context.data = Array.isArray(context.data) ? data : data[0]
|
|
25
|
-
return context
|
|
24
|
+
context.data = Array.isArray(context.data) ? data : data[0]
|
|
25
|
+
return context
|
|
26
26
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { HookContext } from
|
|
2
|
-
import { copy } from
|
|
3
|
-
import { getResultIsArray } from
|
|
4
|
-
import type { DispatchOption } from
|
|
5
|
-
import type { ResultSingleHookContext } from
|
|
1
|
+
import type { HookContext } from '@feathersjs/feathers'
|
|
2
|
+
import { copy } from 'fast-copy'
|
|
3
|
+
import { getResultIsArray } from '../get-result-is-array/get-result-is-array.util.js'
|
|
4
|
+
import type { DispatchOption } from '../../types.js'
|
|
5
|
+
import type { ResultSingleHookContext } from '../../utility-types/hook-context.js'
|
|
6
6
|
|
|
7
7
|
export type ReplaceResultOptions = {
|
|
8
8
|
/**
|
|
@@ -10,8 +10,8 @@ export type ReplaceResultOptions = {
|
|
|
10
10
|
* writes both `result` and `dispatch`. When dispatch is requested and not yet
|
|
11
11
|
* present, it is seeded from a clone of `context.result`.
|
|
12
12
|
*/
|
|
13
|
-
dispatch?: DispatchOption
|
|
14
|
-
}
|
|
13
|
+
dispatch?: DispatchOption
|
|
14
|
+
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Replaces `context.result` (and/or `context.dispatch`) wholesale with the given
|
|
@@ -34,27 +34,27 @@ export function replaceResult<H extends HookContext = HookContext>(
|
|
|
34
34
|
options?: ReplaceResultOptions,
|
|
35
35
|
): H {
|
|
36
36
|
if (!!options?.dispatch && !context.dispatch) {
|
|
37
|
-
context.dispatch = copy(context.result)
|
|
37
|
+
context.dispatch = copy(context.result)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const write = (dispatch: boolean) => {
|
|
41
|
-
const { isArray, key } = getResultIsArray(context, { dispatch })
|
|
41
|
+
const { isArray, key } = getResultIsArray(context, { dispatch })
|
|
42
42
|
|
|
43
43
|
if (!isArray) {
|
|
44
|
-
context[key] = result[0]
|
|
44
|
+
context[key] = result[0]
|
|
45
45
|
} else if (!Array.isArray(context[key]) && context[key]?.data) {
|
|
46
|
-
context[key].data = result
|
|
46
|
+
context[key].data = result
|
|
47
47
|
} else {
|
|
48
|
-
context[key] = result
|
|
48
|
+
context[key] = result
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
|
51
51
|
|
|
52
|
-
if (options?.dispatch ===
|
|
53
|
-
write(true)
|
|
54
|
-
write(false)
|
|
52
|
+
if (options?.dispatch === 'both') {
|
|
53
|
+
write(true)
|
|
54
|
+
write(false)
|
|
55
55
|
} else {
|
|
56
|
-
write(!!options?.dispatch)
|
|
56
|
+
write(!!options?.dispatch)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
return context
|
|
59
|
+
return context
|
|
60
60
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { dequal as deepEqual } from 'dequal'
|
|
2
|
+
|
|
3
|
+
type QueryRecord = Record<string, any>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `$and` is an implicit AND with the rest of the query, so ALL of its branches can
|
|
7
|
+
* be merged up into the parent at once — as long as no key would be set to two
|
|
8
|
+
* different values (across the branches or the existing keys). On any such collision
|
|
9
|
+
* the `$and` is kept intact. Internal helper for {@link simplifyQuery}.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeAndBranchesUp(
|
|
12
|
+
result: QueryRecord,
|
|
13
|
+
enabled: boolean,
|
|
14
|
+
): QueryRecord {
|
|
15
|
+
if (!enabled || !Array.isArray(result.$and) || result.$and.length === 0) {
|
|
16
|
+
return result
|
|
17
|
+
}
|
|
18
|
+
const rest = { ...result }
|
|
19
|
+
delete rest.$and
|
|
20
|
+
const seen = new Map<string, any>(Object.entries(rest))
|
|
21
|
+
for (const branch of result.$and) {
|
|
22
|
+
for (const [key, value] of Object.entries(branch)) {
|
|
23
|
+
if (seen.has(key) && !deepEqual(seen.get(key), value)) {
|
|
24
|
+
return result
|
|
25
|
+
}
|
|
26
|
+
seen.set(key, value)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Object.assign(rest, ...result.$and)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (import.meta.vitest) {
|
|
33
|
+
const { describe, it, expect } = import.meta.vitest
|
|
34
|
+
|
|
35
|
+
describe('mergeAndBranchesUp', () => {
|
|
36
|
+
it('merges all branches up when keys do not collide', () => {
|
|
37
|
+
expect(mergeAndBranchesUp({ $and: [{ a: 1 }, { b: 2 }] }, true)).toEqual({
|
|
38
|
+
a: 1,
|
|
39
|
+
b: 2,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('merges branches up alongside existing root keys', () => {
|
|
44
|
+
expect(
|
|
45
|
+
mergeAndBranchesUp({ c: 3, $and: [{ a: 1 }, { b: 2 }] }, true),
|
|
46
|
+
).toEqual({ a: 1, b: 2, c: 3 })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps the $and on a value collision between branches', () => {
|
|
50
|
+
expect(mergeAndBranchesUp({ $and: [{ a: 1 }, { a: 2 }] }, true)).toEqual({
|
|
51
|
+
$and: [{ a: 1 }, { a: 2 }],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps the $and on a collision with a root key', () => {
|
|
56
|
+
expect(mergeAndBranchesUp({ a: 1, $and: [{ a: 2 }] }, true)).toEqual({
|
|
57
|
+
a: 1,
|
|
58
|
+
$and: [{ a: 2 }],
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('allows a branch key equal to an existing root value', () => {
|
|
63
|
+
expect(
|
|
64
|
+
mergeAndBranchesUp({ a: 1, $and: [{ a: 1 }, { b: 2 }] }, true),
|
|
65
|
+
).toEqual({ a: 1, b: 2 })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does nothing when disabled', () => {
|
|
69
|
+
expect(mergeAndBranchesUp({ $and: [{ a: 1 }] }, false)).toEqual({
|
|
70
|
+
$and: [{ a: 1 }],
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('ignores a missing or empty $and', () => {
|
|
75
|
+
expect(mergeAndBranchesUp({ a: 1 }, true)).toEqual({ a: 1 })
|
|
76
|
+
expect(mergeAndBranchesUp({ $and: [] }, true)).toEqual({ $and: [] })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not mutate the input', () => {
|
|
80
|
+
const query = { c: 3, $and: [{ a: 1 }, { b: 2 }] }
|
|
81
|
+
const snapshot = structuredClone(query)
|
|
82
|
+
mergeAndBranchesUp(query, true)
|
|
83
|
+
expect(query).toEqual(snapshot)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { dequal as deepEqual } from 'dequal'
|
|
2
|
+
|
|
3
|
+
type QueryRecord = Record<string, any>
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `$or` is a disjunction, so only a *single* branch can be merged up (an `$or` of
|
|
7
|
+
* one is just that branch) — and only when no key would collide. This is the key
|
|
8
|
+
* asymmetry with `$and` ({@link mergeAndBranchesUp}). Internal helper for
|
|
9
|
+
* {@link simplifyQuery}.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeOrBranchUp(
|
|
12
|
+
result: QueryRecord,
|
|
13
|
+
enabled: boolean,
|
|
14
|
+
): QueryRecord {
|
|
15
|
+
if (!enabled || !Array.isArray(result.$or) || result.$or.length !== 1) {
|
|
16
|
+
return result
|
|
17
|
+
}
|
|
18
|
+
const branch = result.$or[0]
|
|
19
|
+
const rest = { ...result }
|
|
20
|
+
delete rest.$or
|
|
21
|
+
for (const key of Object.keys(branch)) {
|
|
22
|
+
if (key in rest && !deepEqual(rest[key], branch[key])) {
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { ...rest, ...branch }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (import.meta.vitest) {
|
|
30
|
+
const { describe, it, expect } = import.meta.vitest
|
|
31
|
+
|
|
32
|
+
describe('mergeOrBranchUp', () => {
|
|
33
|
+
it('merges a single branch up when keys do not collide', () => {
|
|
34
|
+
expect(mergeOrBranchUp({ a: 1, $or: [{ b: 2 }] }, true)).toEqual({
|
|
35
|
+
a: 1,
|
|
36
|
+
b: 2,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('collapses a sole single-branch $or to that branch', () => {
|
|
41
|
+
expect(mergeOrBranchUp({ $or: [{ a: 1 }] }, true)).toEqual({ a: 1 })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps the $or on a collision with a root key', () => {
|
|
45
|
+
expect(mergeOrBranchUp({ a: 1, $or: [{ a: 2 }] }, true)).toEqual({
|
|
46
|
+
a: 1,
|
|
47
|
+
$or: [{ a: 2 }],
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does NOT merge a multi-branch $or', () => {
|
|
52
|
+
expect(mergeOrBranchUp({ $or: [{ a: 1 }, { b: 2 }] }, true)).toEqual({
|
|
53
|
+
$or: [{ a: 1 }, { b: 2 }],
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does nothing when disabled', () => {
|
|
58
|
+
expect(mergeOrBranchUp({ $or: [{ a: 1 }] }, false)).toEqual({
|
|
59
|
+
$or: [{ a: 1 }],
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('ignores a missing $or', () => {
|
|
64
|
+
expect(mergeOrBranchUp({ a: 1 }, true)).toEqual({ a: 1 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not mutate the input', () => {
|
|
68
|
+
const query = { a: 1, $or: [{ b: 2 }] }
|
|
69
|
+
const snapshot = structuredClone(query)
|
|
70
|
+
mergeOrBranchUp(query, true)
|
|
71
|
+
expect(query).toEqual(snapshot)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
}
|