From c53596c18ce58072ecb0e6df612a47baee4e1684 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:06:05 +0200 Subject: [PATCH 1/2] feat: new util "mergeQuery" (#39) --- .claude/settings.json | 3 +- src/common/index.ts | 1 + src/common/is-empty-object.ts | 38 +++ src/hooks/index.ts | 2 +- src/predicates/index.ts | 6 +- .../add-to-query/add-to-query.util.test.ts | 39 +++ src/utils/add-to-query/add-to-query.util.ts | 20 +- src/utils/index.ts | 9 +- src/utils/merge-query/dedupe-branches.ts | 42 +++ .../merge-query/extract-query-filters.ts | 80 +++++ src/utils/merge-query/has-conflict.ts | 39 +++ src/utils/merge-query/logical-branches.ts | 39 +++ src/utils/merge-query/merge-query-bodies.ts | 136 ++++++++ src/utils/merge-query/merge-query.util.md | 8 + .../merge-query/merge-query.util.test.ts | 298 ++++++++++++++++++ src/utils/merge-query/merge-query.util.ts | 102 ++++++ src/utils/merge-query/merge-select.ts | 64 ++++ test/index.test.ts | 1 + tsdown.config.ts | 6 +- 19 files changed, 919 insertions(+), 14 deletions(-) create mode 100644 src/common/is-empty-object.ts create mode 100644 src/utils/merge-query/dedupe-branches.ts create mode 100644 src/utils/merge-query/extract-query-filters.ts create mode 100644 src/utils/merge-query/has-conflict.ts create mode 100644 src/utils/merge-query/logical-branches.ts create mode 100644 src/utils/merge-query/merge-query-bodies.ts create mode 100644 src/utils/merge-query/merge-query.util.md create mode 100644 src/utils/merge-query/merge-query.util.test.ts create mode 100644 src/utils/merge-query/merge-query.util.ts create mode 100644 src/utils/merge-query/merge-select.ts diff --git a/.claude/settings.json b/.claude/settings.json index 8fb009c..c9d7097 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,7 +20,8 @@ "Bash(npm run *)", "Bash(pnpm typecheck *)", "Bash(pnpm vitest *)", - "Bash(pnpm lint *)" + "Bash(pnpm lint *)", + "Bash(echo \"build exit=$?\")" ] } } diff --git a/src/common/index.ts b/src/common/index.ts index 6c8f989..2d981d7 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -15,3 +15,4 @@ export function isPromise(p: any): p is Promise { export { traverse } from './traverse.js' export { clone } from './clone.js' +export { isEmptyObject } from './is-empty-object.js' diff --git a/src/common/is-empty-object.ts b/src/common/is-empty-object.ts new file mode 100644 index 0000000..b960c9d --- /dev/null +++ b/src/common/is-empty-object.ts @@ -0,0 +1,38 @@ +/** + * Returns `true` only for a plain empty object (`{}`). Arrays, `null`, primitives + * and non-empty objects are all `false`. + * + * @example + * ```ts + * isEmptyObject({}) // => true + * isEmptyObject({ a: 1 }) // => false + * isEmptyObject([]) // => false + * ``` + */ +export const isEmptyObject = (obj: unknown): boolean => + !!obj && + typeof obj === 'object' && + !Array.isArray(obj) && + Object.keys(obj).length === 0 + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('isEmptyObject', () => { + it('is true only for a plain empty object', () => { + expect(isEmptyObject({})).toBe(true) + }) + + it('is false for a non-empty object', () => { + expect(isEmptyObject({ a: 1 })).toBe(false) + }) + + it('is false for arrays, null, undefined and primitives', () => { + expect(isEmptyObject([])).toBe(false) + expect(isEmptyObject(null)).toBe(false) + expect(isEmptyObject(undefined)).toBe(false) + expect(isEmptyObject('')).toBe(false) + expect(isEmptyObject(0)).toBe(false) + }) + }) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3bda818..7d79d52 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,9 +4,9 @@ export * from './check-multi/check-multi.hook.js' export * from './check-required/check-required.hook.js' export * from './create-related/create-related.hook.js' export * from './debug/debug.hook.js' -export * from './find-or-create/find-or-create.hook.js' export * from './disable-pagination/disable-pagination.hook.js' export * from './disallow/disallow.hook.js' +export * from './find-or-create/find-or-create.hook.js' export * from './iff-else/iff-else.hook.js' export * from './iff/iff.hook.js' export * from './mute-event/mute-event.hook.js' diff --git a/src/predicates/index.ts b/src/predicates/index.ts index 4846a19..d5242e4 100644 --- a/src/predicates/index.ts +++ b/src/predicates/index.ts @@ -4,12 +4,12 @@ export * from './is-multi/is-multi.predicate.js' export * from './is-paginated/is-paginated.predicate.js' export * from './is-provider/is-provider.predicate.js' export * from './not/not.predicate.js' -export * from './should-skip/should-skip.predicate.js' export * from './or/or.predicate.js' +export * from './should-skip/should-skip.predicate.js' // re-export hooks -export * from '../hooks/iff/iff.hook.js' export * from '../hooks/iff-else/iff-else.hook.js' -export * from '../hooks/unless/unless.hook.js' +export * from '../hooks/iff/iff.hook.js' export * from '../hooks/skippable/skippable.hook.js' export * from '../hooks/throw-if/throw-if.hook.js' +export * from '../hooks/unless/unless.hook.js' diff --git a/src/utils/add-to-query/add-to-query.util.test.ts b/src/utils/add-to-query/add-to-query.util.test.ts index a6c6745..7544ced 100644 --- a/src/utils/add-to-query/add-to-query.util.test.ts +++ b/src/utils/add-to-query/add-to-query.util.test.ts @@ -69,4 +69,43 @@ describe('addToQuery', () => { expect(result).toEqual({ id: 1, $and: [{ id: 2, age: 30 }] }) }) + + it('flattens a pure $and query into the existing $and instead of nesting', () => { + const result = addToQuery( + { $and: [{ id: 1 }, { id: 2 }] }, + { $and: [{ id: 3 }] }, + ) + + expect(result).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] }) + }) + + it('flattens and dedupes $and branches', () => { + const result = addToQuery( + { $and: [{ id: 1 }, { id: 2 }] }, + { $and: [{ id: 2 }, { id: 3 }] }, + ) + + expect(result).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] }) + }) + + it('flattens a $and query alongside other target keys', () => { + const result = addToQuery( + { id: 1, $and: [{ id: 2 }] }, + { $and: [{ id: 3 }] }, + ) + + expect(result).toEqual({ id: 1, $and: [{ id: 2 }, { id: 3 }] }) + }) + + it('is a no-op when the added $and branches already exist', () => { + const result = addToQuery({ $and: [{ id: 1 }] }, { $and: [{ id: 1 }] }) + + expect(result).toEqual({ $and: [{ id: 1 }] }) + }) + + it('merges a pure $and into a target without $and directly', () => { + const result = addToQuery({ id: 1 }, { $and: [{ id: 2 }] }) + + expect(result).toEqual({ id: 1, $and: [{ id: 2 }] }) + }) }) diff --git a/src/utils/add-to-query/add-to-query.util.ts b/src/utils/add-to-query/add-to-query.util.ts index 3711df5..9a9379a 100644 --- a/src/utils/add-to-query/add-to-query.util.ts +++ b/src/utils/add-to-query/add-to-query.util.ts @@ -4,7 +4,9 @@ import { dequal as deepEqual } from 'dequal' /** * Safely merges properties into a Feathers query object. If a property already exists * with a different value, it wraps both in a `$and` array to preserve both conditions. - * If the exact same key-value pair already exists, no changes are made. + * If the exact same key-value pair already exists, no changes are made. When the added + * query is itself a pure `$and` (`{ $and: [...] }`), its branches are flattened into the + * target's `$and` rather than nested. * * @example * ```ts @@ -46,6 +48,22 @@ export function addToQuery(targetQuery: Q, query: Q): Q { return targetQuery } + // when the added query is itself a pure `$and`, flatten its branches into the + // target's `$and` instead of nesting another `$and` inside it + if (entries.length === 1 && Array.isArray(query.$and)) { + const existing = (targetQuery.$and as any[]) ?? [] + const newBranches = (query.$and as any[]).filter( + (branch) => !existing.some((q) => deepEqual(q, branch)), + ) + if (newBranches.length === 0) { + return targetQuery + } + return { + ...targetQuery, + $and: [...existing, ...newBranches], + } + } + if (!targetQuery.$and) { return { ...targetQuery, diff --git a/src/utils/index.ts b/src/utils/index.ts index f32f022..f42521a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ export * from './add-skip/add-skip.util.js' -export * from './chunk-find/chunk-find.util.js' export * from './add-to-query/add-to-query.util.js' export * from './check-context/check-context.util.js' +export * from './chunk-find/chunk-find.util.js' export * from './context-to-json/context-to-json.util.js' export * from './define-hooks/define-hooks.util.js' export * from './get-data-is-array/get-data-is-array.util.js' @@ -9,17 +9,18 @@ export * from './get-exposed-methods/get-exposed-methods.util.js' export * from './get-paginate/get-paginate.util.js' export * from './get-result-is-array/get-result-is-array.util.js' export * from './iterate-find/iterate-find.util.js' +export * from './merge-query/merge-query.util.js' export * from './mutate-data/mutate-data.util.js' export * from './mutate-result/mutate-result.util.js' export * from './patch-batch/patch-batch.util.js' +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 './skip-result/skip-result.util.js' +export * from './sort-query-properties/sort-query-properties.util.js' export * from './to-paginated/to-paginated.util.js' export * from './transform-params/transform-params.util.js' -export * from './sort-query-properties/sort-query-properties.util.js' export * from './wait-for-service-event/wait-for-service-event.util.js' export * from './walk-query/walk-query.util.js' -export * from './query-has-property/query-has-property.util.js' -export * from './query-defaults/query-defaults.util.js' export * from './zip-data-result/zip-data-result.util.js' diff --git a/src/utils/merge-query/dedupe-branches.ts b/src/utils/merge-query/dedupe-branches.ts new file mode 100644 index 0000000..c2f2933 --- /dev/null +++ b/src/utils/merge-query/dedupe-branches.ts @@ -0,0 +1,42 @@ +import { dequal as deepEqual } from 'dequal' +import { isEmptyObject } from '../../common/is-empty-object.js' + +type QueryRecord = Record + +/** + * Removes empty (`{}`) and deep-equal duplicate branches, preserving order. + * Internal helper for {@link mergeQuery}. + */ +export function dedupeBranches(branches: QueryRecord[]): QueryRecord[] { + const result: QueryRecord[] = [] + for (const branch of branches) { + if (isEmptyObject(branch)) { + continue + } + if (!result.some((existing) => deepEqual(existing, branch))) { + result.push(branch) + } + } + return result +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('dedupeBranches', () => { + it('removes empty objects and deep-equal duplicates', () => { + expect(dedupeBranches([{ id: 1 }, {}, { id: 1 }, { id: 2 }])).toEqual([ + { id: 1 }, + { id: 2 }, + ]) + }) + + it('preserves order', () => { + expect(dedupeBranches([{ b: 2 }, { a: 1 }])).toEqual([{ b: 2 }, { a: 1 }]) + }) + + it('returns an empty array when all branches are empty', () => { + expect(dedupeBranches([{}, {}])).toEqual([]) + }) + }) +} diff --git a/src/utils/merge-query/extract-query-filters.ts b/src/utils/merge-query/extract-query-filters.ts new file mode 100644 index 0000000..084595a --- /dev/null +++ b/src/utils/merge-query/extract-query-filters.ts @@ -0,0 +1,80 @@ +import type { Query } from '@feathersjs/feathers' + +export type FilterQueryResult = { + $select?: Q['$select'] + $limit?: Q['$limit'] + $skip?: Q['$skip'] + $sort?: Q['$sort'] + query: Omit +} + +/** + * Splits a query into its special filters ($select, $limit, $skip, $sort) and the + * remaining query body. Internal helper for {@link mergeQuery} — not part of the + * public API. + */ +export function extractQueryFilters( + providedQuery?: Q, +): FilterQueryResult { + providedQuery ??= {} as Q + const { $select, $limit, $skip, $sort, ...query } = providedQuery + + const result: FilterQueryResult = { query } as FilterQueryResult + + if ('$select' in providedQuery) { + result.$select = $select + } + + if ('$limit' in providedQuery) { + result.$limit = $limit + } + + if ('$skip' in providedQuery) { + result.$skip = $skip + } + + if ('$sort' in providedQuery) { + result.$sort = $sort + } + + return result +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('extractQueryFilters', () => { + it('splits filters from the query body', () => { + expect( + extractQueryFilters({ + $select: ['a'], + $limit: 10, + $skip: 10, + $sort: { a: 1 }, + a: 1, + b: 2, + }), + ).toEqual({ + $select: ['a'], + $limit: 10, + $skip: 10, + $sort: { a: 1 }, + query: { a: 1, b: 2 }, + }) + }) + + it('omits filters that are not provided', () => { + expect(extractQueryFilters({ a: 1, b: 2 })).toEqual({ + query: { a: 1, b: 2 }, + }) + }) + + it('returns an empty body for an empty query', () => { + expect(extractQueryFilters({})).toEqual({ query: {} }) + }) + + it('returns an empty body for undefined', () => { + expect(extractQueryFilters(undefined)).toEqual({ query: {} }) + }) + }) +} diff --git a/src/utils/merge-query/has-conflict.ts b/src/utils/merge-query/has-conflict.ts new file mode 100644 index 0000000..88c6c07 --- /dev/null +++ b/src/utils/merge-query/has-conflict.ts @@ -0,0 +1,39 @@ +import { dequal as deepEqual } from 'dequal' + +type QueryRecord = Record + +/** + * Two query bodies conflict when they share at least one key whose values are not + * deep-equal. Internal helper for {@link mergeQuery}. + */ +export function hasConflict(target: QueryRecord, source: QueryRecord): boolean { + for (const key of Object.keys(target)) { + if (key in source && !deepEqual(target[key], source[key])) { + return true + } + } + return false +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('hasConflict', () => { + it('is false for disjoint keys', () => { + expect(hasConflict({ a: 1 }, { b: 2 })).toBe(false) + }) + + it('is false for shared equal values', () => { + expect(hasConflict({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + + it('is true for shared differing values', () => { + expect(hasConflict({ a: 1 }, { a: 2 })).toBe(true) + }) + + it('compares values deeply', () => { + expect(hasConflict({ a: { x: 1 } }, { a: { x: 1 } })).toBe(false) + expect(hasConflict({ a: { x: 1 } }, { a: { x: 2 } })).toBe(true) + }) + }) +} diff --git a/src/utils/merge-query/logical-branches.ts b/src/utils/merge-query/logical-branches.ts new file mode 100644 index 0000000..13a503d --- /dev/null +++ b/src/utils/merge-query/logical-branches.ts @@ -0,0 +1,39 @@ +type QueryRecord = Record + +/** + * Returns the branches of a logical-only query (a query whose single key is `op`), + * or `null` when the query is not purely `{ [op]: [...] }`. Internal helper for + * {@link mergeQuery}. + */ +export function logicalBranches( + query: QueryRecord, + op: '$or' | '$and', +): QueryRecord[] | null { + const keys = Object.keys(query) + if (keys.length === 1 && keys[0] === op && Array.isArray(query[op])) { + return query[op] as QueryRecord[] + } + return null +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('logicalBranches', () => { + it('returns branches for a logical-only query', () => { + expect(logicalBranches({ $or: [{ id: 1 }] }, '$or')).toEqual([{ id: 1 }]) + }) + + it('returns null when the operator is mixed with other keys', () => { + expect(logicalBranches({ $or: [{ id: 1 }], a: 1 }, '$or')).toBeNull() + }) + + it('returns null for the wrong operator', () => { + expect(logicalBranches({ $and: [{ id: 1 }] }, '$or')).toBeNull() + }) + + it('returns null when the operator value is not an array', () => { + expect(logicalBranches({ $or: { id: 1 } }, '$or')).toBeNull() + }) + }) +} diff --git a/src/utils/merge-query/merge-query-bodies.ts b/src/utils/merge-query/merge-query-bodies.ts new file mode 100644 index 0000000..016a5ec --- /dev/null +++ b/src/utils/merge-query/merge-query-bodies.ts @@ -0,0 +1,136 @@ +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 { hasConflict } from './has-conflict.js' + +type QueryRecord = Record + +/** + * Merges two query bodies (filters already removed) according to the mode. + * Internal helper for {@link mergeQuery}. + * + * - `target` / `source`: precedence merge (that side wins on conflict). + * - `combine`: the two bodies always become branches of a single `$or`. + * - `intersect`: non-conflicting bodies merge flat; on conflict they become + * branches of a single `$and`. + * + * Logical-only bodies (`{ $or: [...] }` for combine, `{ $and: [...] }` for + * intersect) are flattened into the result and their branches de-duplicated. + */ +export function mergeQueryBodies( + target: QueryRecord, + source: QueryRecord, + mode: MergeQueryMode, +): QueryRecord { + if (mode === 'target') { + return { ...source, ...target } + } + if (mode === 'source') { + return { ...target, ...source } + } + + if (isEmptyObject(target)) { + return { ...source } + } + if (isEmptyObject(source)) { + return { ...target } + } + + const op = mode === 'combine' ? '$or' : '$and' + + const targetBranches = logicalBranches(target, op) + const sourceBranches = logicalBranches(source, op) + + // For intersect (AND) the top level is itself an implicit AND, so two + // conflict-free bodies can be merged flat. For combine (OR) there is no flat + // representation — combine always produces an `$or`. + if ( + op === '$and' && + !targetBranches && + !sourceBranches && + !hasConflict(target, source) + ) { + return { ...target, ...source } + } + + const branches = dedupeBranches([ + ...(targetBranches ?? [target]), + ...(sourceBranches ?? [source]), + ]) + + if (branches.length === 0) { + return {} + } + if (branches.length === 1) { + return { ...branches[0] } + } + return { [op]: branches } +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('mergeQueryBodies', () => { + it('target / source precedence', () => { + expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'target')).toEqual({ + id: 1, + a: 3, + }) + expect(mergeQueryBodies({ id: 1 }, { id: 2, a: 3 }, 'source')).toEqual({ + id: 2, + a: 3, + }) + }) + + it('returns the other side when one is empty', () => { + expect(mergeQueryBodies({}, { id: 1 }, 'combine')).toEqual({ id: 1 }) + expect(mergeQueryBodies({ id: 1 }, {}, 'intersect')).toEqual({ id: 1 }) + }) + + it('combine always produces an $or, even for disjoint keys', () => { + expect(mergeQueryBodies({ a: 1 }, { b: 2 }, 'combine')).toEqual({ + $or: [{ a: 1 }, { b: 2 }], + }) + }) + + it('combine flattens and dedupes $or branches', () => { + expect( + mergeQueryBodies( + { $or: [{ id: 1 }, { id: 2 }] }, + { $or: [{ id: 2 }, { id: 3 }] }, + 'combine', + ), + ).toEqual({ $or: [{ id: 1 }, { id: 2 }, { id: 3 }] }) + }) + + it('combine collapses to a single body', () => { + expect(mergeQueryBodies({ id: 1 }, { id: 1 }, 'combine')).toEqual({ + id: 1, + }) + }) + + it('intersect merges disjoint keys flat', () => { + expect(mergeQueryBodies({ id: 1 }, { userId: 2 }, 'intersect')).toEqual({ + id: 1, + userId: 2, + }) + }) + + it('intersect wraps conflicts in $and', () => { + expect(mergeQueryBodies({ id: 1 }, { id: 2 }, 'intersect')).toEqual({ + $and: [{ id: 1 }, { id: 2 }], + }) + }) + + it('intersect flattens $and branches', () => { + expect( + mergeQueryBodies( + { $and: [{ id: 1 }, { id: 2 }] }, + { $and: [{ id: 3 }] }, + 'intersect', + ), + ).toEqual({ $and: [{ id: 1 }, { id: 2 }, { id: 3 }] }) + }) + }) +} diff --git a/src/utils/merge-query/merge-query.util.md b/src/utils/merge-query/merge-query.util.md new file mode 100644 index 0000000..5023ed5 --- /dev/null +++ b/src/utils/merge-query/merge-query.util.md @@ -0,0 +1,8 @@ +--- +title: mergeQuery +category: utils +see: + - utils/add-to-query + - utils/query-defaults + - utils/walk-query +--- diff --git a/src/utils/merge-query/merge-query.util.test.ts b/src/utils/merge-query/merge-query.util.test.ts new file mode 100644 index 0000000..fbb3129 --- /dev/null +++ b/src/utils/merge-query/merge-query.util.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from 'vitest' +import type { Query } from '@feathersjs/feathers' +import { mergeQuery, type MergeQueryOptions } from './merge-query.util.js' + +type Pair = { + target: Query + source: Query + options?: MergeQueryOptions + expected: Query +} + +const run = (pairs: Record) => { + for (const [name, { target, source, options, expected }] of Object.entries( + pairs, + )) { + it(name, () => { + expect(mergeQuery(target, source, options)).toEqual(expected) + }) + } +} + +describe('mergeQuery', () => { + describe('general', () => { + it('defaults to combine', () => { + expect(mergeQuery({ id: 1 }, { id: 2 })).toEqual({ + $or: [{ id: 1 }, { id: 2 }], + }) + }) + + it('returns a new query and never mutates its inputs', () => { + const cases: Array<{ + target: Query + source: Query + mode: 'combine' | 'intersect' + }> = [ + { + target: { price: { $gt: 5 }, $sort: { id: 1 } }, + source: { price: { $lt: 10 }, $sort: { name: -1 } }, + mode: 'combine', + }, + { + target: { $or: [{ id: 1 }, { id: 2 }] }, + source: { $or: [{ id: 2 }, { id: 3 }] }, + mode: 'combine', + }, + { + target: { $and: [{ id: 1 }] }, + source: { $and: [{ id: 2 }] }, + mode: 'intersect', + }, + ] + + for (const { target, source, mode } of cases) { + const targetSnapshot = structuredClone(target) + const sourceSnapshot = structuredClone(source) + + const result = mergeQuery(target, source, { mode }) + + expect(target).toEqual(targetSnapshot) + expect(source).toEqual(sourceSnapshot) + expect(result).not.toBe(target) + expect(result).not.toBe(source) + } + }) + }) + + describe('handle target / source', () => { + run({ + 'empty + empty': { + target: {}, + source: {}, + options: { mode: 'target' }, + expected: {}, + }, + 'target wins on conflict, adds source-only keys': { + target: { id: 1, test1: true }, + source: { id: 2, test2: false }, + options: { mode: 'target' }, + expected: { id: 1, test1: true, test2: false }, + }, + 'source wins on conflict, keeps target-only keys': { + target: { id: 1, test1: true }, + source: { id: 2, test2: false }, + options: { mode: 'source' }, + expected: { id: 2, test1: true, test2: false }, + }, + }) + }) + + describe('combine (default)', () => { + run({ + 'disjoint keys still become $or': { + target: { a: 1 }, + source: { b: 2 }, + options: { mode: 'combine' }, + expected: { $or: [{ a: 1 }, { b: 2 }] }, + }, + 'conflicting key becomes $or': { + target: { id: 1 }, + source: { id: 2 }, + options: { mode: 'combine' }, + expected: { $or: [{ id: 1 }, { id: 2 }] }, + }, + 'a shared equal key still becomes $or': { + target: { id: 1, a: 2 }, + source: { id: 1, b: 3 }, + options: { mode: 'combine' }, + expected: { + $or: [ + { id: 1, a: 2 }, + { id: 1, b: 3 }, + ], + }, + }, + 'identical bodies collapse': { + target: { id: 1 }, + source: { id: 1 }, + options: { mode: 'combine' }, + expected: { id: 1 }, + }, + 'unions $or branches': { + target: { $or: [{ id: 1 }, { id: 2 }] }, + source: { $or: [{ id: 3 }] }, + options: { mode: 'combine' }, + expected: { $or: [{ id: 1 }, { id: 2 }, { id: 3 }] }, + }, + 'dedupes $or branches': { + target: { $or: [{ id: 1 }, { id: 1 }, { id: 2 }] }, + source: { $or: [{ id: 2 }] }, + options: { mode: 'combine' }, + expected: { $or: [{ id: 1 }, { id: 2 }] }, + }, + '$and bodies become branches of an $or': { + target: { $and: [{ id: 1 }, { id: 2 }] }, + source: { $and: [{ id: 3 }] }, + options: { mode: 'combine' }, + expected: { + $or: [{ $and: [{ id: 1 }, { id: 2 }] }, { $and: [{ id: 3 }] }], + }, + }, + 'empty $or branches collapse to {}': { + target: { $or: [{}] }, + source: { $or: [{}] }, + options: { mode: 'combine' }, + expected: {}, + }, + 'conflicting operator objects become $or': { + target: { price: { $gt: 5 } }, + source: { price: { $lt: 10 } }, + options: { mode: 'combine' }, + expected: { $or: [{ price: { $gt: 5 } }, { price: { $lt: 10 } }] }, + }, + 'conflicting null becomes $or': { + target: { a: null }, + source: { a: 1 }, + options: { mode: 'combine' }, + expected: { $or: [{ a: null }, { a: 1 }] }, + }, + 'conflicting array values become $or': { + target: { roles: ['a'] }, + source: { roles: ['b'] }, + options: { mode: 'combine' }, + expected: { $or: [{ roles: ['a'] }, { roles: ['b'] }] }, + }, + 'an existing $or is extended by a plain query': { + target: { $or: [{ id: 1 }] }, + source: { status: 'x' }, + options: { mode: 'combine' }, + expected: { $or: [{ id: 1 }, { status: 'x' }] }, + }, + 'empty source returns target': { + target: { id: 1 }, + source: {}, + options: { mode: 'combine' }, + expected: { id: 1 }, + }, + 'empty target returns source': { + target: {}, + source: { id: 1 }, + options: { mode: 'combine' }, + expected: { id: 1 }, + }, + }) + }) + + describe('intersect', () => { + run({ + 'disjoint keys merge flat': { + target: { id: 1 }, + source: { userId: 2 }, + options: { mode: 'intersect' }, + expected: { id: 1, userId: 2 }, + }, + 'conflicting key becomes $and': { + target: { id: 1 }, + source: { id: 2 }, + options: { mode: 'intersect' }, + expected: { $and: [{ id: 1 }, { id: 2 }] }, + }, + 'unions $and branches': { + target: { $and: [{ id: 1 }, { id: 2 }] }, + source: { $and: [{ id: 3 }] }, + options: { mode: 'intersect' }, + expected: { $and: [{ id: 1 }, { id: 2 }, { id: 3 }] }, + }, + 'dedupes $and branches': { + target: { $and: [{ id: 1 }, { id: 1 }, { id: 2 }] }, + source: { $and: [{ id: 2 }] }, + options: { mode: 'intersect' }, + expected: { $and: [{ id: 1 }, { id: 2 }] }, + }, + '$or bodies become branches of an $and': { + target: { $or: [{ id: 1 }, { id: 2 }] }, + source: { $or: [{ id: 3 }] }, + options: { mode: 'intersect' }, + expected: { + $and: [{ $or: [{ id: 1 }, { id: 2 }] }, { $or: [{ id: 3 }] }], + }, + }, + 'subset (source ⊆ target) merges flat': { + target: { id: 1, userId: 2 }, + source: { userId: 2 }, + options: { mode: 'intersect' }, + expected: { id: 1, userId: 2 }, + }, + 'overlapping ranges become $and (no longer throws)': { + target: { price: { $gt: 5 } }, + source: { price: { $gt: 8 } }, + options: { mode: 'intersect' }, + expected: { $and: [{ price: { $gt: 5 } }, { price: { $gt: 8 } }] }, + }, + }) + }) + + describe('filters', () => { + run({ + '$limit: -1 is preserved': { + target: { $limit: -1 }, + source: { id: 1 }, + expected: { id: 1, $limit: -1 }, + }, + '$select is unioned on combine': { + target: { $select: ['a', 'b'] }, + source: { $select: ['b', 'c'] }, + options: { mode: 'combine' }, + expected: { $select: ['a', 'b', 'c'] }, + }, + '$select is intersected on intersect': { + target: { $select: ['a', 'b'] }, + source: { $select: ['b', 'c'] }, + options: { mode: 'intersect' }, + expected: { $select: ['b'] }, + }, + '$select intersection can be empty': { + target: { $select: ['a'] }, + source: { $select: ['b'] }, + options: { mode: 'intersect' }, + expected: { $select: [] }, + }, + '$select from a single side is kept': { + target: {}, + source: { $select: ['x'] }, + options: { mode: 'combine' }, + expected: { $select: ['x'] }, + }, + '$sort is deep-merged': { + target: { $sort: { id: 1 } }, + source: { $sort: { name: -1 } }, + options: { mode: 'combine' }, + expected: { $sort: { id: 1, name: -1 } }, + }, + '$sort same key is overridden by source': { + target: { $sort: { id: 1 } }, + source: { $sort: { id: -1 } }, + options: { mode: 'combine' }, + expected: { $sort: { id: -1 } }, + }, + '$limit is overridden by source': { + target: { $limit: 10 }, + source: { $limit: 5 }, + options: { mode: 'combine' }, + expected: { $limit: 5 }, + }, + 'target-only filters are kept': { + target: { $limit: 50, $skip: 10, $sort: { id: 1 } }, + source: { id: 1 }, + options: { mode: 'intersect' }, + expected: { id: 1, $limit: 50, $skip: 10, $sort: { id: 1 } }, + }, + 'source $limit overrides while other filters stay': { + target: { $limit: 50, $skip: 10, $sort: { id: 1 } }, + source: { $limit: 10, id: 1 }, + options: { mode: 'intersect' }, + expected: { id: 1, $limit: 10, $skip: 10, $sort: { id: 1 } }, + }, + }) + }) +}) diff --git a/src/utils/merge-query/merge-query.util.ts b/src/utils/merge-query/merge-query.util.ts new file mode 100644 index 0000000..8cc9606 --- /dev/null +++ b/src/utils/merge-query/merge-query.util.ts @@ -0,0 +1,102 @@ +import type { Query } from '@feathersjs/feathers' +import { extractQueryFilters } from './extract-query-filters.js' +import { mergeQueryBodies } from './merge-query-bodies.js' +import { mergeSelect } from './merge-select.js' + +export type MergeQueryMode = 'target' | 'source' | 'combine' | 'intersect' + +export interface MergeQueryOptions { + /** + * How to merge query properties that both queries constrain. + * + * - `combine` (default): broaden — the two queries always become branches of an `$or`. + * - `intersect`: narrow — non-conflicting properties merge flat, conflicts become an `$and`. + * - `target`: keep the target's value on conflict. + * - `source`: keep the source's value on conflict. + */ + mode?: MergeQueryMode +} + +/** + * Properties are combined with a logical operator rather than merged at the value + * level, so the result is always a valid query: `combine` always wraps the two + * queries in `$or` (broaden — OR has no flat form), while `intersect` merges + * non-conflicting properties flat and wraps conflicts in `$and` (narrow). The + * special filters `$select`, `$limit`, `$skip` and `$sort` are merged separately. + * Inputs are never mutated. + * + * This is well suited to merging a client-provided query with a server-side + * restriction inside a hook. + * + * @param target Query to be merged into + * @param source Query to be merged from + * @param options + * @returns the merged query + * + * @example + * ```ts + * import { mergeQuery } from 'feathers-utils/utils' + * + * // combine (default): the two queries always become an $or + * mergeQuery({ id: 1 }, { id: 2 }) + * // => { $or: [{ id: 1 }, { id: 2 }] } + * + * mergeQuery({ status: 'active' }, { authorId: 5 }) + * // => { $or: [{ status: 'active' }, { authorId: 5 }] } + * ``` + * + * @example + * ```ts + * // intersect: non-conflicting properties merge flat, conflicts become an $and + * mergeQuery({ status: 'active' }, { authorId: 5 }, { mode: 'intersect' }) + * // => { status: 'active', authorId: 5 } + * + * mergeQuery({ id: 1 }, { id: 2 }, { mode: 'intersect' }) + * // => { $and: [{ id: 1 }, { id: 2 }] } + * ``` + * + * @see https://utils.feathersjs.com/utils/merge-query.html + */ +export function mergeQuery( + target: Query, + source: Query, + options?: MergeQueryOptions, +): Query { + const mode = options?.mode ?? 'combine' + + const targetFilters = extractQueryFilters(target) + const sourceFilters = extractQueryFilters(source) + + const result: Query = mergeQueryBodies( + targetFilters.query, + sourceFilters.query, + mode, + ) + + const $select = mergeSelect( + targetFilters.$select, + sourceFilters.$select, + mode, + ) + if ($select !== undefined) { + result.$select = $select + } + + if ('$limit' in sourceFilters) { + result.$limit = sourceFilters.$limit + } else if ('$limit' in targetFilters) { + result.$limit = targetFilters.$limit + } + + if ('$skip' in sourceFilters) { + result.$skip = sourceFilters.$skip + } else if ('$skip' in targetFilters) { + result.$skip = targetFilters.$skip + } + + if ('$sort' in targetFilters || '$sort' in sourceFilters) { + result.$sort = { ...targetFilters.$sort, ...sourceFilters.$sort } + } + + return result +} diff --git a/src/utils/merge-query/merge-select.ts b/src/utils/merge-query/merge-select.ts new file mode 100644 index 0000000..482904e --- /dev/null +++ b/src/utils/merge-query/merge-select.ts @@ -0,0 +1,64 @@ +import type { MergeQueryMode } from './merge-query.util.js' + +/** + * Merges two `$select` filters according to the mode: `combine` → union, + * `intersect` → intersection, `target`/`source` → that side. When only one side + * provides a `$select`, that one is used. Internal helper for {@link mergeQuery}. + */ +export function mergeSelect( + target: any, + source: any, + mode: MergeQueryMode, +): any { + if (target === undefined) { + return source + } + if (source === undefined) { + return target + } + if (mode === 'target') { + return target + } + if (mode === 'source') { + return source + } + const targetArr = Array.isArray(target) ? target : [target] + const sourceArr = Array.isArray(source) ? source : [source] + if (mode === 'combine') { + return [...new Set([...targetArr, ...sourceArr])] + } + // intersect + return targetArr.filter((value) => sourceArr.includes(value)) +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('mergeSelect', () => { + it('returns the defined side when one is missing', () => { + expect(mergeSelect(undefined, ['a'], 'combine')).toEqual(['a']) + expect(mergeSelect(['a'], undefined, 'combine')).toEqual(['a']) + }) + + it('unions on combine', () => { + expect(mergeSelect(['a', 'b'], ['b', 'c'], 'combine')).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + it('intersects on intersect', () => { + expect(mergeSelect(['a', 'b'], ['b', 'c'], 'intersect')).toEqual(['b']) + }) + + it('can produce an empty intersection', () => { + expect(mergeSelect(['a'], ['b'], 'intersect')).toEqual([]) + }) + + it('picks the requested side on target / source', () => { + expect(mergeSelect(['a'], ['b'], 'target')).toEqual(['a']) + expect(mergeSelect(['a'], ['b'], 'source')).toEqual(['b']) + }) + }) +} diff --git a/test/index.test.ts b/test/index.test.ts index c500687..afd77c6 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -57,6 +57,7 @@ const utils = [ 'getPaginate', 'getResultIsArray', 'iterateFind', + 'mergeQuery', 'mutateData', 'mutateResult', 'patchBatch', diff --git a/tsdown.config.ts b/tsdown.config.ts index 8e7194d..ca4e7a1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -22,9 +22,7 @@ export default defineConfig({ transformers: 'src/transformers/index.ts', guards: 'src/guards/index.ts', }, - transform: { - define: { - 'import.meta.vitest': 'undefined', - }, + define: { + 'import.meta.vitest': 'undefined', }, }) From a788e33965803bc0e7319410fc737c53e1a7455e Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:08:17 +0200 Subject: [PATCH 2/2] 10.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4689ff..5b2d60c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feathers-utils", - "version": "10.2.0", + "version": "10.3.0", "description": "Useful hooks and utils for use with feathers services.", "main": "./dist/index.mjs", "module": "./dist/index.mjs",