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.
Files changed (50) hide show
  1. package/dist/{hooks-tfw03iVM.mjs → hooks-D_u2QFhM.mjs} +65 -65
  2. package/dist/{hooks-tfw03iVM.mjs.map → hooks-D_u2QFhM.mjs.map} +1 -1
  3. package/dist/hooks.d.mts +48 -48
  4. package/dist/hooks.mjs +4 -4
  5. package/dist/{index-CKAzIogj.d.mts → index-H1zXVhff.d.mts} +209 -105
  6. package/dist/index.d.mts +2 -2
  7. package/dist/index.mjs +7 -7
  8. package/dist/internal.utils-CWuBYzpQ.mjs +120 -0
  9. package/dist/internal.utils-CWuBYzpQ.mjs.map +1 -0
  10. package/dist/{mutate-result.util-Dqzepn1M.mjs → mutate-result.util-mxvMl6bw.mjs} +2 -2
  11. package/dist/mutate-result.util-mxvMl6bw.mjs.map +1 -0
  12. package/dist/{predicates-puYa4nkf.mjs → predicates-CR4O2nSr.mjs} +53 -53
  13. package/dist/{predicates-puYa4nkf.mjs.map → predicates-CR4O2nSr.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-BbbdWdlO.mjs} +2 -2
  17. package/dist/{resolve-B9hRleHY.mjs.map → resolve-BbbdWdlO.mjs.map} +1 -1
  18. package/dist/resolvers.mjs +1 -1
  19. package/dist/{transform-result.hook-B65pTRJO.mjs → transform-result.hook-0D676rcY.mjs} +2 -2
  20. package/dist/{transform-result.hook-B65pTRJO.mjs.map → transform-result.hook-0D676rcY.mjs.map} +1 -1
  21. package/dist/transformers.mjs +3 -3
  22. package/dist/{utils-BAIcSl7u.mjs → utils-sTvj8-Jy.mjs} +467 -209
  23. package/dist/utils-sTvj8-Jy.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/dedupe-branches.ts +42 -0
  28. package/src/common/flatten-and-branches.ts +56 -0
  29. package/src/common/flatten-or-branches.ts +52 -0
  30. package/src/common/index.ts +4 -0
  31. package/src/common/is-empty-object.ts +38 -0
  32. package/src/hooks/index.ts +1 -1
  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 +6 -4
  36. package/src/utils/merge-query/extract-query-filters.ts +80 -0
  37. package/src/utils/merge-query/has-conflict.ts +39 -0
  38. package/src/utils/merge-query/logical-branches.ts +39 -0
  39. package/src/utils/merge-query/merge-query-bodies.ts +152 -0
  40. package/src/utils/merge-query/merge-query.util.ts +105 -0
  41. package/src/utils/merge-query/merge-select.ts +64 -0
  42. package/src/utils/replace-data/replace-data.util.ts +4 -4
  43. package/src/utils/replace-result/replace-result.util.ts +18 -18
  44. package/src/utils/simplify-query/merge-and-branches-up.ts +86 -0
  45. package/src/utils/simplify-query/merge-or-branch-up.ts +74 -0
  46. package/src/utils/simplify-query/simplify-query.util.ts +98 -0
  47. package/dist/internal.utils-BMzV_-xp.mjs +0 -55
  48. package/dist/internal.utils-BMzV_-xp.mjs.map +0 -1
  49. package/dist/mutate-result.util-Dqzepn1M.mjs.map +0 -1
  50. 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 "@feathersjs/feathers";
2
- import type { DataSingleHookContext } from "../../utility-types/hook-context.js";
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 "@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";
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 === "both") {
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
+ }