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
3 changes: 2 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"Bash(npm run *)",
"Bash(pnpm typecheck *)",
"Bash(pnpm vitest *)",
"Bash(pnpm lint *)"
"Bash(pnpm lint *)",
"Bash(echo \"build exit=$?\")"
]
}
}
1 change: 1 addition & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ 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'
38 changes: 38 additions & 0 deletions src/common/is-empty-object.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
}
2 changes: 1 addition & 1 deletion src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions src/predicates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
39 changes: 39 additions & 0 deletions src/utils/add-to-query/add-to-query.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] })
})
})
20 changes: 19 additions & 1 deletion src/utils/add-to-query/add-to-query.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,6 +48,22 @@ export function addToQuery<Q extends Query>(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,
Expand Down
9 changes: 5 additions & 4 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
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'
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'
42 changes: 42 additions & 0 deletions src/utils/merge-query/dedupe-branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { dequal as deepEqual } from 'dequal'
import { isEmptyObject } from '../../common/is-empty-object.js'

type QueryRecord = Record<string, any>

/**
* 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([])
})
})
}
80 changes: 80 additions & 0 deletions src/utils/merge-query/extract-query-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Query } from '@feathersjs/feathers'

export type FilterQueryResult<Q extends Query = Query> = {
$select?: Q['$select']
$limit?: Q['$limit']
$skip?: Q['$skip']
$sort?: Q['$sort']
query: Omit<Q, '$select' | '$limit' | '$skip' | '$sort'>
}

/**
* 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<Q extends Query>(
providedQuery?: Q,
): FilterQueryResult<Q> {
providedQuery ??= {} as Q
const { $select, $limit, $skip, $sort, ...query } = providedQuery

const result: FilterQueryResult<Q> = { query } as FilterQueryResult<Q>

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: {} })
})
})
}
39 changes: 39 additions & 0 deletions src/utils/merge-query/has-conflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { dequal as deepEqual } from 'dequal'

type QueryRecord = Record<string, any>

/**
* 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)
})
})
}
39 changes: 39 additions & 0 deletions src/utils/merge-query/logical-branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type QueryRecord = Record<string, any>

/**
* 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()
})
})
}
Loading
Loading