Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { dequal as deepEqual } from 'dequal'
import { isEmptyObject } from '../../common/is-empty-object.js'
import { isEmptyObject } from './is-empty-object.js'

type QueryRecord = Record<string, any>

/**
* Removes empty (`{}`) and deep-equal duplicate branches, preserving order.
* Internal helper for {@link mergeQuery}.
* Removes empty (`{}`) and deep-equal duplicate branches from a logical
* (`$and`/`$or`) branch array, preserving order. Internal query-AST helper.
*/
export function dedupeBranches(branches: QueryRecord[]): QueryRecord[] {
const result: QueryRecord[] = []
Expand Down
56 changes: 56 additions & 0 deletions src/common/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 query-AST helper.
*/
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 }])
})
})
}
52 changes: 52 additions & 0 deletions src/common/flatten-or-branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
type QueryRecord = Record<string, any>

/**
* Recursively hoists nested *pure* `$or` branches so that an `$or` never contains
* another `{ $or: [...] }`-only branch. `$or` is associative, so a branch that is
* exactly `{ $or: [a, b] }` is replaced by `a` and `b`. A branch that mixes `$or`
* with other keys is left intact (its other keys are AND-ed and cannot be hoisted).
* Internal query-AST helper.
*/
export function flattenOrBranches(branches: QueryRecord[]): QueryRecord[] {
const result: QueryRecord[] = []
for (const branch of branches) {
const keys = Object.keys(branch)
if (keys.length === 1 && keys[0] === '$or' && Array.isArray(branch.$or)) {
result.push(...flattenOrBranches(branch.$or))
} else {
result.push(branch)
}
}
return result
}

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

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

it('hoists a pure $or branch', () => {
expect(
flattenOrBranches([{ a: 1 }, { $or: [{ b: 2 }, { c: 3 }] }]),
).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }])
})

it('does not hoist a $or branch mixed with other keys', () => {
expect(flattenOrBranches([{ $or: [{ b: 2 }], x: 1 }])).toEqual([
{ $or: [{ b: 2 }], x: 1 },
])
})

it('flattens recursively', () => {
expect(
flattenOrBranches([{ $or: [{ $or: [{ a: 1 }] }, { b: 2 }] }]),
).toEqual([{ a: 1 }, { b: 2 }])
})
})
}
3 changes: 3 additions & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ export function isPromise(p: any): p is Promise<any> {
export { traverse } from './traverse.js'
export { clone } from './clone.js'
export { isEmptyObject } from './is-empty-object.js'
export { dedupeBranches } from './dedupe-branches.js'
export { flattenAndBranches } from './flatten-and-branches.js'
export { flattenOrBranches } from './flatten-or-branches.js'
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './query-defaults/query-defaults.util.js'
export * from './query-has-property/query-has-property.util.js'
export * from './replace-data/replace-data.util.js'
export * from './replace-result/replace-result.util.js'
export * from './simplify-query/simplify-query.util.js'
export * from './skip-result/skip-result.util.js'
export * from './sort-query-properties/sort-query-properties.util.js'
export * from './to-paginated/to-paginated.util.js'
Expand Down
22 changes: 19 additions & 3 deletions src/utils/merge-query/merge-query-bodies.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { MergeQueryMode } from './merge-query.util.js'
import { isEmptyObject } from '../../common/is-empty-object.js'
import { dedupeBranches } from '../../common/dedupe-branches.js'
import { flattenAndBranches } from '../../common/flatten-and-branches.js'
import { logicalBranches } from './logical-branches.js'
import { dedupeBranches } from './dedupe-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'] }] })
})
})
}
1 change: 1 addition & 0 deletions src/utils/merge-query/merge-query.util.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ see:
- utils/add-to-query
- utils/query-defaults
- utils/walk-query
- utils/simplify-query
---
72 changes: 68 additions & 4 deletions src/utils/merge-query/merge-query.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ describe('mergeQuery', () => {
},
'$and bodies become branches of an $or': {
target: { $and: [{ id: 1 }, { id: 2 }] },
source: { $and: [{ id: 3 }] },
source: { $and: [{ id: 3 }, { id: 4 }] },
options: { mode: 'combine' },
expected: {
$or: [{ $and: [{ id: 1 }, { id: 2 }] }, { $and: [{ id: 3 }] }],
$or: [
{ $and: [{ id: 1 }, { id: 2 }] },
{ $and: [{ id: 3 }, { id: 4 }] },
],
},
},
'empty $or branches collapse to {}': {
Expand Down Expand Up @@ -211,10 +214,13 @@ describe('mergeQuery', () => {
},
'$or bodies become branches of an $and': {
target: { $or: [{ id: 1 }, { id: 2 }] },
source: { $or: [{ id: 3 }] },
source: { $or: [{ id: 3 }, { id: 4 }] },
options: { mode: 'intersect' },
expected: {
$and: [{ $or: [{ id: 1 }, { id: 2 }] }, { $or: [{ id: 3 }] }],
$and: [
{ $or: [{ id: 1 }, { id: 2 }] },
{ $or: [{ id: 3 }, { id: 4 }] },
],
},
},
'subset (source ⊆ target) merges flat': {
Expand All @@ -229,6 +235,64 @@ describe('mergeQuery', () => {
options: { mode: 'intersect' },
expected: { $and: [{ price: { $gt: 5 } }, { price: { $gt: 8 } }] },
},
// a user $or merged with a CASL rule that carries its own $and ($nor):
// single-branch wrappers collapse, the rest merges flat (no nesting)
'merges a user $or with a CASL rule, flat and without nesting': {
target: { $or: [{ a: 1 }] },
source: { $or: [{ b: 2 }], $and: [{ $nor: [{ c: 3 }] }] },
options: { mode: 'intersect' },
expected: { a: 1, b: 2, $nor: [{ c: 3 }] },
},
// when both sides keep multiple $or branches the $nor stays a separate
// (still flat) conjunct alongside them
'merges multi-branch $or rules without nesting the $and': {
target: { $or: [{ a: 1 }, { a: 2 }] },
source: { $or: [{ b: 1 }, { b: 2 }], $and: [{ $nor: [{ c: 3 }] }] },
options: { mode: 'intersect' },
expected: {
$and: [
{ $or: [{ a: 1 }, { a: 2 }] },
{ $or: [{ b: 1 }, { b: 2 }], $nor: [{ c: 3 }] },
],
},
},
})
})

// a source with a single-branch $or collapses to its child (an $or of one is
// that child) and then merges correctly — i.e. `replaceOr` is not needed here
describe('source with a single-branch $or', () => {
run({
'intersect, conflicting child': {
target: { id: 1 },
source: { $or: [{ id: 2 }] },
options: { mode: 'intersect' },
expected: { $and: [{ id: 1 }, { id: 2 }] },
},
'intersect, disjoint child': {
target: { status: 'active' },
source: { $or: [{ role: 'admin' }] },
options: { mode: 'intersect' },
expected: { status: 'active', role: 'admin' },
},
'intersect, child with multiple keys': {
target: { a: 1 },
source: { $or: [{ b: 2, c: 3 }] },
options: { mode: 'intersect' },
expected: { a: 1, b: 2, c: 3 },
},
'combine, disjoint child': {
target: { a: 1 },
source: { $or: [{ b: 2 }] },
options: { mode: 'combine' },
expected: { $or: [{ a: 1 }, { b: 2 }] },
},
'combine, into an existing target $or': {
target: { $or: [{ a: 1 }, { a: 2 }] },
source: { $or: [{ b: 1 }] },
options: { mode: 'combine' },
expected: { $or: [{ a: 1 }, { a: 2 }, { b: 1 }] },
},
})
})

Expand Down
7 changes: 5 additions & 2 deletions src/utils/merge-query/merge-query.util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Query } from '@feathersjs/feathers'
import { simplifyQuery } from '../simplify-query/simplify-query.util.js'
import { extractQueryFilters } from './extract-query-filters.js'
import { mergeQueryBodies } from './merge-query-bodies.js'
import { mergeSelect } from './merge-select.js'
Expand Down Expand Up @@ -64,8 +65,10 @@ export function mergeQuery(
): Query {
const mode = options?.mode ?? 'combine'

const targetFilters = extractQueryFilters(target)
const sourceFilters = extractQueryFilters(source)
// normalize inputs first: drop empty/duplicate/redundant logical wrappers and
// hoist nested operators, so the merge works on clean, canonical queries
const targetFilters = extractQueryFilters(simplifyQuery(target))
const sourceFilters = extractQueryFilters(simplifyQuery(source))

const result: Query = mergeQueryBodies(
targetFilters.query,
Expand Down
50 changes: 25 additions & 25 deletions src/utils/replace-data/replace-data.util.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
import { describe, it, expect } from "vitest";
import type { HookContext } from "@feathersjs/feathers";
import { replaceData } from "./replace-data.util.js";
import { describe, it, expect } from 'vitest'
import type { HookContext } from '@feathersjs/feathers'
import { replaceData } from './replace-data.util.js'

const ctx = (data: any): HookContext =>
({ type: "before", method: "create", data }) as any;
({ type: 'before', method: 'create', data }) as any

describe("replaceData", () => {
it("replaces a single data item, preserving the single shape", () => {
const context = ctx({ name: "a" });
replaceData(context, [{ name: "b" }]);
expect(context.data).toEqual({ name: "b" });
});
describe('replaceData', () => {
it('replaces a single data item, preserving the single shape', () => {
const context = ctx({ name: 'a' })
replaceData(context, [{ name: 'b' }])
expect(context.data).toEqual({ name: 'b' })
})

it("replaces array data, preserving the array shape", () => {
const context = ctx([{ n: 1 }, { n: 2 }]);
replaceData(context, [{ n: 10 }]);
expect(context.data).toEqual([{ n: 10 }]);
});
it('replaces array data, preserving the array shape', () => {
const context = ctx([{ n: 1 }, { n: 2 }])
replaceData(context, [{ n: 10 }])
expect(context.data).toEqual([{ n: 10 }])
})

it("returns the context", () => {
const context = ctx({ a: 1 });
expect(replaceData(context, [{ a: 2 }])).toBe(context);
});
it('returns the context', () => {
const context = ctx({ a: 1 })
expect(replaceData(context, [{ a: 2 }])).toBe(context)
})

it("round-trips with getDataIsArray shape", () => {
const context = ctx([{ n: 1 }, { n: 2 }]);
replaceData(context, [{ n: 1 }, { n: 2 }, { n: 3 }]);
expect(context.data).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
});
});
it('round-trips with getDataIsArray shape', () => {
const context = ctx([{ n: 1 }, { n: 2 }])
replaceData(context, [{ n: 1 }, { n: 2 }, { n: 3 }])
expect(context.data).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }])
})
})
8 changes: 4 additions & 4 deletions src/utils/replace-data/replace-data.util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { HookContext } from "@feathersjs/feathers";
import type { DataSingleHookContext } from "../../utility-types/hook-context.js";
import type { HookContext } from '@feathersjs/feathers'
import type { DataSingleHookContext } from '../../utility-types/hook-context.js'

/**
* Replaces `context.data` wholesale with the given items, preserving the original
Expand All @@ -21,6 +21,6 @@ export function replaceData<H extends HookContext = HookContext>(
context: H,
data: DataSingleHookContext<H>[],
): H {
context.data = Array.isArray(context.data) ? data : data[0];
return context;
context.data = Array.isArray(context.data) ? data : data[0]
return context
}
Loading
Loading