Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/utils/merge-query/flatten-and-branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type QueryRecord = Record<string, any>

/**
* Recursively hoists nested `$and` conjuncts so that an `$and` never contains
* another `$and` branch. `$and` is associative, so a branch like
* `{ $or: [...], $and: [n] }` is split into the two conjuncts `{ $or: [...] }`
* and `n`. A pure `{ $and: [...] }` branch contributes only its inner branches.
* Internal helper for {@link mergeQuery} (intersect).
*/
export function flattenAndBranches(branches: QueryRecord[]): QueryRecord[] {
const result: QueryRecord[] = []
for (const branch of branches) {
if (Array.isArray(branch.$and)) {
const { $and, ...rest } = branch
if (Object.keys(rest).length > 0) {
result.push(rest)
}
result.push(...flattenAndBranches($and))
} else {
result.push(branch)
}
}
return result
}

if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest

describe('flattenAndBranches', () => {
it('leaves plain branches untouched', () => {
expect(flattenAndBranches([{ id: 1 }, { id: 2 }])).toEqual([
{ id: 1 },
{ id: 2 },
])
})

it('hoists a nested $and and keeps the remaining keys as a branch', () => {
expect(
flattenAndBranches([{ $or: ['c'], $and: [{ $nor: ['n'] }] }]),
).toEqual([{ $or: ['c'] }, { $nor: ['n'] }])
})

it('drops the remainder when a branch is a pure $and', () => {
expect(flattenAndBranches([{ $and: [{ id: 1 }, { id: 2 }] }])).toEqual([
{ id: 1 },
{ id: 2 },
])
})

it('flattens recursively', () => {
expect(
flattenAndBranches([{ $and: [{ $and: [{ id: 1 }] }, { id: 2 }] }]),
).toEqual([{ id: 1 }, { id: 2 }])
})
})
}
20 changes: 18 additions & 2 deletions src/utils/merge-query/merge-query-bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MergeQueryMode } from './merge-query.util.js'
import { isEmptyObject } from '../../common/is-empty-object.js'
import { logicalBranches } from './logical-branches.js'
import { dedupeBranches } from './dedupe-branches.js'
import { flattenAndBranches } from './flatten-and-branches.js'
import { hasConflict } from './has-conflict.js'

type QueryRecord = Record<string, any>
Expand Down Expand Up @@ -54,10 +55,15 @@ export function mergeQueryBodies(
return { ...target, ...source }
}

const branches = dedupeBranches([
const collected = [
...(targetBranches ?? [target]),
...(sourceBranches ?? [source]),
])
]

// under `$and`, hoist any nested `$and` so the result never nests `$and` in `$and`
const branches = dedupeBranches(
op === '$and' ? flattenAndBranches(collected) : collected,
)

if (branches.length === 0) {
return {}
Expand Down Expand Up @@ -132,5 +138,15 @@ if (import.meta.vitest) {
),
).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] })
})

it('intersect hoists a nested $and instead of nesting it', () => {
expect(
mergeQueryBodies(
{ $or: ['u'] },
{ $or: ['c'], $and: [{ $nor: ['n'] }] },
'intersect',
),
).toEqual({ $and: [{ $or: ['u'] }, { $or: ['c'] }, { $nor: ['n'] }] })
})
})
}
13 changes: 13 additions & 0 deletions src/utils/merge-query/merge-query.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,19 @@ describe('mergeQuery', () => {
options: { mode: 'intersect' },
expected: { $and: [{ price: { $gt: 5 } }, { price: { $gt: 8 } }] },
},
// e.g. a user $or merged with a CASL rule that carries its own $and ($nor)
'hoists the source $and instead of nesting it': {
target: { $or: [{ a: 1 }] },
source: { $or: [{ b: 2 }], $and: [{ $nor: [{ c: 3 }] }] },
options: { mode: 'intersect' },
expected: {
$and: [
{ $or: [{ a: 1 }] },
{ $or: [{ b: 2 }] },
{ $nor: [{ c: 3 }] },
],
},
},
})
})

Expand Down
Loading