diff --git a/src/utils/merge-query/flatten-and-branches.ts b/src/utils/merge-query/flatten-and-branches.ts new file mode 100644 index 0000000..634dc9f --- /dev/null +++ b/src/utils/merge-query/flatten-and-branches.ts @@ -0,0 +1,56 @@ +type QueryRecord = Record + +/** + * 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 }]) + }) + }) +} diff --git a/src/utils/merge-query/merge-query-bodies.ts b/src/utils/merge-query/merge-query-bodies.ts index 016a5ec..f0f4a4c 100644 --- a/src/utils/merge-query/merge-query-bodies.ts +++ b/src/utils/merge-query/merge-query-bodies.ts @@ -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 @@ -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 {} @@ -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'] }] }) + }) }) } diff --git a/src/utils/merge-query/merge-query.util.test.ts b/src/utils/merge-query/merge-query.util.test.ts index fbb3129..ec2aada 100644 --- a/src/utils/merge-query/merge-query.util.test.ts +++ b/src/utils/merge-query/merge-query.util.test.ts @@ -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 }] }, + ], + }, + }, }) })